573 lines
19 KiB
TypeScript
573 lines
19 KiB
TypeScript
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Group } from '@/components/ui/group';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Separator } from '@/components/ui/separator';
|
|
import { Switch } from '@/components/ui/switch';
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { joinServerByInvite, loadFederatedServers, switchServer } from '@/features/app/actions';
|
|
import { connect, getHandshakeHash } from '@/features/server/actions';
|
|
import { initE2EE } from '@/lib/e2ee';
|
|
import { useInfo } from '@/features/server/hooks';
|
|
import { getFileUrl, getUrlFromServer } from '@/helpers/get-file-url';
|
|
import {
|
|
getLocalStorageItem,
|
|
LocalStorageKey,
|
|
removeLocalStorageItem,
|
|
setLocalStorageItem
|
|
} from '@/helpers/storage';
|
|
import { useForm } from '@/hooks/use-form';
|
|
import { getAccessToken, initSupabase, supabase } from '@/lib/supabase';
|
|
import type { Provider } from '@supabase/supabase-js';
|
|
import { memo, useCallback, useMemo, useState } from 'react';
|
|
import { toast } from 'sonner';
|
|
|
|
const OAUTH_PROVIDER_LABELS: Record<string, string> = {
|
|
google: 'Google',
|
|
discord: 'Discord',
|
|
facebook: 'Facebook',
|
|
twitch: 'Twitch'
|
|
};
|
|
|
|
const Connect = memo(() => {
|
|
const [activeTab, setActiveTab] = useState<'login' | 'register'>('login');
|
|
const [loading, setLoading] = useState(false);
|
|
const info = useInfo();
|
|
|
|
const loginForm = useForm<{
|
|
email: string;
|
|
password: string;
|
|
rememberCredentials: boolean;
|
|
}>({
|
|
email: getLocalStorageItem(LocalStorageKey.EMAIL) || '',
|
|
password: '',
|
|
rememberCredentials: !!getLocalStorageItem(
|
|
LocalStorageKey.REMEMBER_CREDENTIALS
|
|
)
|
|
});
|
|
|
|
const registerForm = useForm<{
|
|
displayName: string;
|
|
email: string;
|
|
password: string;
|
|
}>({
|
|
displayName: '',
|
|
email: '',
|
|
password: ''
|
|
});
|
|
|
|
const inviteCode = useMemo(() => {
|
|
const urlParams = new URLSearchParams(window.location.search);
|
|
const invite = urlParams.get('invite');
|
|
return invite || undefined;
|
|
}, []);
|
|
|
|
const ensureSupabaseReady = useCallback(() => {
|
|
if (supabase) return true;
|
|
if (info?.supabaseUrl && info?.supabaseAnonKey) {
|
|
initSupabase(info.supabaseUrl, info.supabaseAnonKey);
|
|
return !!supabase;
|
|
}
|
|
return false;
|
|
}, [info?.supabaseUrl, info?.supabaseAnonKey]);
|
|
|
|
const establishSession = useCallback(
|
|
async (accessToken: string, refreshToken: string) => {
|
|
const { error } = await supabase.auth.setSession({
|
|
access_token: accessToken,
|
|
refresh_token: refreshToken
|
|
});
|
|
|
|
if (error) {
|
|
throw new Error(`Failed to store session: ${error.message}`);
|
|
}
|
|
|
|
// Ensure token is visible to the ws connectionParams path before connect().
|
|
for (let i = 0; i < 5; i += 1) {
|
|
const token = await getAccessToken();
|
|
if (token) return;
|
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
}
|
|
|
|
throw new Error('Failed to read authentication token after login');
|
|
},
|
|
[]
|
|
);
|
|
|
|
const onRememberCredentialsChange = useCallback(
|
|
(checked: boolean) => {
|
|
loginForm.onChange('rememberCredentials', checked);
|
|
|
|
if (checked) {
|
|
setLocalStorageItem(LocalStorageKey.REMEMBER_CREDENTIALS, 'true');
|
|
} else {
|
|
removeLocalStorageItem(LocalStorageKey.REMEMBER_CREDENTIALS);
|
|
}
|
|
},
|
|
// loginForm.onChange is a stable sub-property
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
[loginForm.onChange]
|
|
);
|
|
|
|
const onLoginClick = useCallback(async () => {
|
|
setLoading(true);
|
|
|
|
try {
|
|
if (!ensureSupabaseReady()) {
|
|
throw new Error('Authentication client is not initialized');
|
|
}
|
|
|
|
const url = getUrlFromServer();
|
|
const response = await fetch(`${url}/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
email: loginForm.values.email,
|
|
password: loginForm.values.password
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
loginForm.setErrors(data.errors || {});
|
|
return;
|
|
}
|
|
|
|
const data = (await response.json()) as {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
};
|
|
|
|
await establishSession(data.accessToken, data.refreshToken);
|
|
|
|
if (loginForm.values.rememberCredentials) {
|
|
setLocalStorageItem(LocalStorageKey.EMAIL, loginForm.values.email);
|
|
}
|
|
|
|
await connect();
|
|
|
|
// Initialize E2EE and load federated servers (mirrors loadApp() flow)
|
|
initE2EE().catch((err) => console.error('E2EE initialization failed:', err));
|
|
await loadFederatedServers();
|
|
|
|
// If there's an invite code, join that server and switch to it
|
|
if (inviteCode) {
|
|
try {
|
|
const server = await joinServerByInvite(inviteCode);
|
|
const hash = getHandshakeHash();
|
|
if (server && hash) {
|
|
await switchServer(server.id, hash);
|
|
}
|
|
} catch {
|
|
// Invite join failed — user is still connected to default server
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
toast.error(`Could not connect: ${errorMessage}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [loginForm.values, loginForm.setErrors, inviteCode, ensureSupabaseReady, establishSession]);
|
|
|
|
const onRegisterClick = useCallback(async () => {
|
|
setLoading(true);
|
|
|
|
try {
|
|
if (!ensureSupabaseReady()) {
|
|
throw new Error('Authentication client is not initialized');
|
|
}
|
|
|
|
const url = getUrlFromServer();
|
|
const response = await fetch(`${url}/register`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
email: registerForm.values.email,
|
|
password: registerForm.values.password,
|
|
displayName: registerForm.values.displayName,
|
|
invite: inviteCode
|
|
})
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
registerForm.setErrors(data.errors || {});
|
|
return;
|
|
}
|
|
|
|
const data = (await response.json()) as {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
};
|
|
|
|
await establishSession(data.accessToken, data.refreshToken);
|
|
|
|
await connect();
|
|
|
|
// Initialize E2EE and load federated servers (mirrors loadApp() flow)
|
|
initE2EE().catch((err) => console.error('E2EE initialization failed:', err));
|
|
await loadFederatedServers();
|
|
|
|
// If there's an invite code, join that server and switch to it
|
|
if (inviteCode) {
|
|
try {
|
|
const server = await joinServerByInvite(inviteCode);
|
|
const hash = getHandshakeHash();
|
|
if (server && hash) {
|
|
await switchServer(server.id, hash);
|
|
}
|
|
} catch {
|
|
// Invite join failed — user is still connected to default server
|
|
}
|
|
}
|
|
} catch (error) {
|
|
const errorMessage =
|
|
error instanceof Error ? error.message : String(error);
|
|
toast.error(`Could not create account: ${errorMessage}`);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [registerForm.values, registerForm.setErrors, inviteCode, ensureSupabaseReady, establishSession]);
|
|
|
|
const onOAuthClick = useCallback(
|
|
async (provider: string) => {
|
|
if (!ensureSupabaseReady()) {
|
|
toast.error('Authentication client is not initialized');
|
|
return;
|
|
}
|
|
|
|
const redirectTo = new URL(window.location.origin);
|
|
|
|
if (inviteCode) {
|
|
redirectTo.searchParams.set('invite', inviteCode);
|
|
}
|
|
|
|
await supabase.auth.signInWithOAuth({
|
|
provider: provider as Provider,
|
|
options: {
|
|
redirectTo: redirectTo.toString()
|
|
}
|
|
});
|
|
},
|
|
[inviteCode, ensureSupabaseReady]
|
|
);
|
|
|
|
const logoSrc = useMemo(() => {
|
|
if (info?.logo) {
|
|
return getFileUrl(info.logo);
|
|
}
|
|
|
|
return '/logo.png';
|
|
}, [info]);
|
|
|
|
const OAuthSection = useMemo(() => {
|
|
const providers = info?.enabledAuthProviders ?? [];
|
|
return providers.length > 0 ? (
|
|
<>
|
|
<div className="flex items-center gap-3 my-1">
|
|
<Separator className="flex-1" />
|
|
<span className="text-xs text-muted-foreground/60 uppercase tracking-wider">
|
|
or
|
|
</span>
|
|
<Separator className="flex-1" />
|
|
</div>
|
|
<div className="flex gap-2">
|
|
{providers.map((provider) => (
|
|
<Button
|
|
key={provider}
|
|
className="flex-1"
|
|
variant="outline"
|
|
disabled={loading}
|
|
onClick={() => onOAuthClick(provider)}
|
|
>
|
|
{OAUTH_PROVIDER_LABELS[provider] ?? provider}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
</>
|
|
) : null;
|
|
},
|
|
[info?.enabledAuthProviders, loading, onOAuthClick]
|
|
);
|
|
|
|
return (
|
|
<>
|
|
{/* Keyframe animation for gradient */}
|
|
<style>{`
|
|
@keyframes gradient-shift {
|
|
0%, 100% {
|
|
background-position: 0% 50%;
|
|
}
|
|
25% {
|
|
background-position: 100% 50%;
|
|
}
|
|
50% {
|
|
background-position: 100% 100%;
|
|
}
|
|
75% {
|
|
background-position: 0% 100%;
|
|
}
|
|
}
|
|
@keyframes float-orb {
|
|
0%, 100% {
|
|
transform: translateY(0px) scale(1);
|
|
opacity: 0.3;
|
|
}
|
|
50% {
|
|
transform: translateY(-20px) scale(1.1);
|
|
opacity: 0.6;
|
|
}
|
|
}
|
|
@keyframes float-orb-alt {
|
|
0%, 100% {
|
|
transform: translateY(0px) scale(1.05);
|
|
opacity: 0.2;
|
|
}
|
|
50% {
|
|
transform: translateY(15px) scale(0.95);
|
|
opacity: 0.5;
|
|
}
|
|
}
|
|
@keyframes pulse-glow {
|
|
0%, 100% {
|
|
opacity: 0.15;
|
|
transform: scale(1);
|
|
}
|
|
50% {
|
|
opacity: 0.3;
|
|
transform: scale(1.05);
|
|
}
|
|
}
|
|
`}</style>
|
|
|
|
<div className="flex min-h-screen w-full bg-background">
|
|
{/* Insecure connection banner */}
|
|
{!window.isSecureContext && (
|
|
<div className="fixed top-0 left-0 right-0 z-50 p-3">
|
|
<Alert variant="destructive" className="max-w-lg mx-auto">
|
|
<AlertTitle>Insecure Connection</AlertTitle>
|
|
<AlertDescription className="text-xs">
|
|
You are on an insecure connection (HTTP). Voice and video features
|
|
may not work. Set up HTTPS for full functionality.
|
|
</AlertDescription>
|
|
</Alert>
|
|
</div>
|
|
)}
|
|
|
|
{/* Left Panel — Branding Hero (hidden on mobile) */}
|
|
<div
|
|
className="hidden md:flex relative w-[45%] flex-col items-center justify-center overflow-hidden"
|
|
style={{
|
|
background:
|
|
'linear-gradient(135deg, #1a0533 0%, #2d1b69 25%, #1e3a5f 50%, #4c1d95 75%, #1a0533 100%)',
|
|
backgroundSize: '400% 400%',
|
|
animation: 'gradient-shift 15s ease infinite'
|
|
}}
|
|
>
|
|
{/* Floating decorative orbs */}
|
|
<div
|
|
className="absolute top-[15%] left-[20%] w-32 h-32 rounded-full bg-purple-500/20 blur-[60px]"
|
|
style={{ animation: 'float-orb 8s ease-in-out infinite' }}
|
|
/>
|
|
<div
|
|
className="absolute bottom-[20%] right-[15%] w-40 h-40 rounded-full bg-indigo-400/15 blur-[80px]"
|
|
style={{ animation: 'float-orb-alt 10s ease-in-out infinite' }}
|
|
/>
|
|
<div
|
|
className="absolute top-[60%] left-[10%] w-24 h-24 rounded-full bg-blue-500/20 blur-[50px]"
|
|
style={{ animation: 'float-orb 12s ease-in-out infinite 2s' }}
|
|
/>
|
|
<div
|
|
className="absolute top-[30%] right-[25%] w-20 h-20 rounded-full bg-violet-400/25 blur-[40px]"
|
|
style={{ animation: 'float-orb-alt 9s ease-in-out infinite 1s' }}
|
|
/>
|
|
<div
|
|
className="absolute bottom-[35%] left-[40%] w-48 h-48 rounded-full bg-purple-600/10 blur-[100px]"
|
|
style={{ animation: 'pulse-glow 6s ease-in-out infinite' }}
|
|
/>
|
|
|
|
{/* Branding content */}
|
|
<div className="relative z-10 flex flex-col items-center text-center px-10">
|
|
<img
|
|
src={logoSrc}
|
|
alt="Pulse Chat"
|
|
className="w-[120px] h-[120px] mb-6 drop-shadow-2xl"
|
|
/>
|
|
<h1 className="text-4xl font-bold text-white tracking-tight mb-3">
|
|
Pulse Chat
|
|
</h1>
|
|
<p className="text-lg text-white/70 max-w-[320px] leading-relaxed">
|
|
Pulse keeps things simple — fast voice, structured text, and a focus on stability.
|
|
</p>
|
|
<p className="text-sm text-white/50 max-w-[320px] leading-relaxed mt-2">
|
|
No distractions. No bloat. Just connection.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Right Panel — Auth Form */}
|
|
<div className="flex-1 flex flex-col items-center justify-center relative px-4 py-8 md:px-10">
|
|
{/* Mobile gradient accent bar */}
|
|
<div
|
|
className="md:hidden absolute top-0 left-0 right-0 h-1"
|
|
style={{
|
|
background:
|
|
'linear-gradient(90deg, #7c3aed, #6366f1, #3b82f6, #7c3aed)',
|
|
backgroundSize: '200% 100%',
|
|
animation: 'gradient-shift 8s ease infinite'
|
|
}}
|
|
/>
|
|
|
|
{/* Mobile logo (shown only on small screens) */}
|
|
<div className="md:hidden flex flex-col items-center mb-8">
|
|
<img
|
|
src={logoSrc}
|
|
alt="Pulse Chat"
|
|
className="w-16 h-16 mb-3 drop-shadow-lg"
|
|
/>
|
|
<h1 className="text-xl font-bold text-foreground tracking-tight">
|
|
Pulse Chat
|
|
</h1>
|
|
</div>
|
|
|
|
<div className="w-full max-w-[420px]">
|
|
{/* Auth card */}
|
|
<div className="bg-card/50 backdrop-blur-xl border border-border/40 shadow-2xl rounded-2xl p-8 md:p-10">
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={(v) => setActiveTab(v as 'login' | 'register')}
|
|
>
|
|
<TabsList className="w-full mb-8">
|
|
<TabsTrigger value="login" className="flex-1">
|
|
Sign In
|
|
</TabsTrigger>
|
|
<TabsTrigger value="register" className="flex-1">
|
|
Create Account
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Login Tab */}
|
|
<TabsContent value="login" className="mt-0">
|
|
<div className="flex flex-col gap-4">
|
|
<Group label="Email">
|
|
<Input
|
|
{...loginForm.r('email')}
|
|
type="email"
|
|
placeholder="you@example.com"
|
|
className="h-10"
|
|
/>
|
|
</Group>
|
|
<Group label="Password">
|
|
<Input
|
|
{...loginForm.r('password')}
|
|
type="password"
|
|
placeholder="Enter your password"
|
|
onEnter={onLoginClick}
|
|
className="h-10"
|
|
/>
|
|
</Group>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-sm text-muted-foreground">
|
|
Remember me
|
|
</span>
|
|
<Switch
|
|
checked={loginForm.values.rememberCredentials}
|
|
onCheckedChange={onRememberCredentialsChange}
|
|
/>
|
|
</div>
|
|
<Button
|
|
className="w-full mt-1 h-11 text-sm font-medium"
|
|
onClick={onLoginClick}
|
|
disabled={
|
|
loading ||
|
|
!loginForm.values.email ||
|
|
!loginForm.values.password
|
|
}
|
|
>
|
|
Sign In
|
|
</Button>
|
|
{OAuthSection}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Register Tab */}
|
|
<TabsContent value="register" className="mt-0">
|
|
<div className="flex flex-col gap-4">
|
|
<Group label="Display Name">
|
|
<Input
|
|
{...registerForm.r('displayName')}
|
|
type="text"
|
|
placeholder="How others will see you"
|
|
className="h-10"
|
|
/>
|
|
</Group>
|
|
<Group label="Email">
|
|
<Input
|
|
{...registerForm.r('email')}
|
|
type="email"
|
|
placeholder="you@example.com"
|
|
className="h-10"
|
|
/>
|
|
</Group>
|
|
<Group label="Password">
|
|
<Input
|
|
{...registerForm.r('password')}
|
|
type="password"
|
|
placeholder="At least 4 characters"
|
|
onEnter={onRegisterClick}
|
|
className="h-10"
|
|
/>
|
|
</Group>
|
|
<Button
|
|
className="w-full mt-1 h-11 text-sm font-medium"
|
|
onClick={onRegisterClick}
|
|
disabled={
|
|
loading ||
|
|
!registerForm.values.displayName ||
|
|
!registerForm.values.email ||
|
|
!registerForm.values.password
|
|
}
|
|
>
|
|
Create Account
|
|
</Button>
|
|
{OAuthSection}
|
|
|
|
{(info?.registrationDisabled || !info?.allowNewUsers) && !inviteCode && (
|
|
<p className="text-xs text-muted-foreground text-center mt-2">
|
|
Registration requires an invite link. Ask an existing member
|
|
to invite you.
|
|
</p>
|
|
)}
|
|
|
|
{inviteCode && (
|
|
<Alert variant="info" className="mt-2">
|
|
<AlertTitle>You were invited</AlertTitle>
|
|
<AlertDescription>
|
|
<span className="font-mono text-xs">
|
|
Invite code: {inviteCode}
|
|
</span>
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex justify-center mt-6 text-xs text-muted-foreground/40">
|
|
<span>v{VITE_APP_VERSION}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
});
|
|
|
|
export { Connect };
|