Files
ChatApp/apps/desktop/src/pages/AuthPage.tsx
T
byGalax 12bb585081 style(auth): migrate auth + language switcher to semantic color tokens
Swaps hardcoded brand-/white-/neutral- utilities for the accent / surface
/ fg / line / fg-muted tokens so light-mode and theme overrides behave
correctly. Also moves focus rings from `focus:` to `focus-visible:`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 23:03:24 +02:00

675 lines
23 KiB
TypeScript

import {
loginWithMagicLink,
signUpWithMagicLink,
verifyMagicLinkOtp,
} from '@chat-app/shared/auth';
import {
extractErrorCode,
isSupportedLocale,
type SupportedLocale,
} from '@chat-app/shared/i18n';
import { useCallback, useId, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Navigate } from 'react-router-dom';
import {
AlertIcon,
ArrowRightIcon,
AtIcon,
CheckCircleIcon,
LockIcon,
LogoMark,
MailIcon,
ShieldIcon,
SparklesIcon,
SpinnerIcon,
TicketIcon,
} from '../components/icons';
import { LanguageSwitcher } from '../components/LanguageSwitcher';
import { useAuth } from '../context/AuthContext';
import { env } from '../lib/env';
import { supabase } from '../lib/supabase';
type Mode = 'signup' | 'login';
type UiState =
| { kind: 'idle' }
| { kind: 'sending' }
| { kind: 'sent'; email: string }
| { kind: 'error'; message: string };
const USERNAME_PATTERN = /^[A-Za-z0-9_]{3,32}$/;
export function AuthPage() {
const { session } = useAuth();
const { t, i18n } = useTranslation(['auth', 'common', 'errors']);
const [ui, setUi] = useState<UiState>({ kind: 'idle' });
const [mode, setMode] = useState<Mode>('signup');
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [inviteCode, setInviteCode] = useState('DEV-INVITE-001');
const usernameValid = useMemo(() => USERNAME_PATTERN.test(username), [username]);
const handleSubmit = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
setUi({ kind: 'sending' });
try {
if (mode === 'signup') {
const activeLocale = i18n.resolvedLanguage ?? i18n.language;
await signUpWithMagicLink(supabase, {
email,
username,
inviteCode,
redirectTo: env.authRedirectUrl,
...(isSupportedLocale(activeLocale)
? { locale: activeLocale satisfies SupportedLocale }
: {}),
});
} else {
await loginWithMagicLink(supabase, email, env.authRedirectUrl);
}
setUi({ kind: 'sent', email });
} catch (err: unknown) {
const code = extractErrorCode(err);
const message = code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic');
setUi({ kind: 'error', message });
}
},
[mode, email, username, inviteCode, i18n, t],
);
if (session) return <Navigate to="/chats" replace />;
return (
<main className="relative min-h-screen overflow-hidden bg-surface text-fg">
<ShellBackground />
<div className="relative z-10 grid min-h-screen grid-cols-1 lg:grid-cols-[1fr_minmax(440px,520px)]">
<BrandSection />
<FormSection
mode={mode}
onModeChange={setMode}
email={email}
onEmailChange={setEmail}
username={username}
onUsernameChange={setUsername}
usernameValid={usernameValid}
inviteCode={inviteCode}
onInviteChange={setInviteCode}
ui={ui}
onSubmit={handleSubmit}
/>
</div>
</main>
);
}
// ---------------------------------------------------------------------------
// Shell background — ambient blobs spread across the full viewport. Anchored
// to percentages so they stay in the same relative position regardless of
// monitor width (works as well on 1440 as on 3440 ultrawide).
// ---------------------------------------------------------------------------
function ShellBackground() {
return (
<div aria-hidden="true" className="pointer-events-none absolute inset-0 hidden dark:block">
<div className="bg-grid absolute inset-0 opacity-[0.12]" />
<div className="absolute left-[15%] top-[18%] h-[520px] w-[520px] -translate-x-1/2 rounded-full bg-accent/25 blur-3xl" />
<div className="absolute left-[35%] top-[70%] h-[480px] w-[480px] -translate-x-1/2 rounded-full bg-fuchsia-500/15 blur-3xl" />
<div className="absolute left-[60%] top-[30%] h-[440px] w-[440px] -translate-x-1/2 rounded-full bg-indigo-500/15 blur-3xl" />
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_40%,rgba(5,5,7,0.45)_100%)]" />
</div>
);
}
// ---------------------------------------------------------------------------
// Brand section — edge-to-edge panel on the left. Inner content constrained
// so it stays readable on ultrawide screens; ambient background fills the rest.
// ---------------------------------------------------------------------------
function BrandSection() {
const { t } = useTranslation(['auth', 'common']);
return (
<section className="relative hidden lg:flex">
<DecorativeBubbles />
<div className="relative mx-auto grid h-full w-full max-w-2xl grid-rows-[auto_1fr_auto] gap-8 px-10 py-10 xl:max-w-3xl xl:px-14 xl:py-14">
<header className="flex items-center gap-3">
<LogoMark className="h-9 w-9" />
<span className="font-display text-lg font-semibold tracking-tight text-fg">
{t('common:app_name')}
</span>
</header>
<div className="flex flex-col justify-center">
<span className="inline-flex w-fit items-center gap-2 rounded-full border border-line bg-surface-2/70 px-3 py-1 text-xs font-medium text-fg-muted backdrop-blur">
<ShieldIcon className="h-3.5 w-3.5 text-emerald-500 dark:text-emerald-400" />
{t('auth:brand.badge')}
</span>
<h1 className="mt-6 font-display text-4xl font-semibold leading-[1.05] tracking-tight text-fg xl:text-5xl 2xl:text-6xl">
{t('auth:brand.title_line_1')}
<br />
<span className="text-accent">{t('auth:brand.title_line_2')}</span>
</h1>
<p className="mt-5 max-w-xl text-base leading-relaxed text-fg-muted xl:text-lg">
{t('auth:brand.subtitle')}
</p>
<ul className="mt-10 grid max-w-2xl gap-3 sm:grid-cols-1 xl:mt-12 xl:gap-4">
<FeatureRow
icon={<LockIcon className="h-4 w-4" />}
title={t('auth:brand.feature_zk_title')}
desc={t('auth:brand.feature_zk_desc')}
/>
<FeatureRow
icon={<SparklesIcon className="h-4 w-4" />}
title={t('auth:brand.feature_selfhost_title')}
desc={t('auth:brand.feature_selfhost_desc')}
/>
<FeatureRow
icon={<ShieldIcon className="h-4 w-4" />}
title={t('auth:brand.feature_invite_title')}
desc={t('auth:brand.feature_invite_desc')}
/>
</ul>
</div>
<footer className="flex items-center justify-between gap-3 text-xs text-fg-muted">
<span className="font-mono">v0.1.0 · {t('common:dev_build')}</span>
<span className="inline-flex items-center gap-1.5">
<span className="relative flex h-1.5 w-1.5">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-emerald-500 opacity-60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-emerald-500" />
</span>
{t('common:local_stack_online')}
</span>
</footer>
</div>
</section>
);
}
// Subtle chat-bubble silhouettes in the far background — gives the brand side
// product context without competing with the typographic content.
function DecorativeBubbles() {
return (
<div
aria-hidden="true"
className="pointer-events-none absolute inset-0 hidden overflow-hidden opacity-60 xl:block"
>
<div className="absolute right-[8%] top-[22%] h-16 w-44 rounded-2xl rounded-bl-sm border border-line bg-surface-2/40 backdrop-blur-sm" />
<div className="absolute right-[18%] top-[42%] h-12 w-32 rounded-2xl rounded-br-sm border border-accent/20 bg-accent/10 backdrop-blur-sm" />
<div className="absolute right-[6%] top-[58%] h-14 w-40 rounded-2xl rounded-bl-sm border border-line bg-surface-2/40 backdrop-blur-sm" />
</div>
);
}
function FeatureRow({
icon,
title,
desc,
}: {
icon: React.ReactNode;
title: string;
desc: string;
}) {
return (
<li className="flex items-start gap-3">
<span className="mt-0.5 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-accent/10 text-accent ring-1 ring-accent/20">
{icon}
</span>
<div className="min-w-0 flex-1 pt-1">
<p className="text-sm font-semibold text-fg xl:text-base">{title}</p>
<p className="mt-0.5 text-xs leading-relaxed text-fg-muted xl:text-sm">{desc}</p>
</div>
</li>
);
}
// ---------------------------------------------------------------------------
// Form section — fixed-width panel on the right. Full viewport height,
// bg-surface-2 for visual distinction from the brand side.
// ---------------------------------------------------------------------------
interface FormSectionProps {
mode: Mode;
onModeChange: (m: Mode) => void;
email: string;
onEmailChange: (v: string) => void;
username: string;
onUsernameChange: (v: string) => void;
usernameValid: boolean;
inviteCode: string;
onInviteChange: (v: string) => void;
ui: UiState;
onSubmit: (e: React.FormEvent) => void;
}
function FormSection({
mode,
onModeChange,
email,
onEmailChange,
username,
onUsernameChange,
usernameValid,
inviteCode,
onInviteChange,
ui,
onSubmit,
}: FormSectionProps) {
const { t } = useTranslation(['auth']);
const busy = ui.kind === 'sending';
const emailId = useId();
const usernameId = useId();
const inviteId = useId();
const titleKey = mode === 'signup' ? 'auth:signup.title' : 'auth:login.title';
const subtitleKey = mode === 'signup' ? 'auth:signup.subtitle' : 'auth:login.subtitle';
const ctaKey = mode === 'signup' ? 'auth:signup.cta' : 'auth:login.cta';
const ctaSendingKey = mode === 'signup' ? 'auth:signup.cta_sending' : 'auth:login.cta_sending';
return (
<section className="relative flex flex-col border-t border-line bg-surface-2/80 backdrop-blur-xl lg:border-l lg:border-t-0">
{/* Mobile-only header strip */}
<div className="flex items-center justify-between border-b border-line px-5 py-4 lg:hidden">
<div className="flex items-center gap-2.5">
<LogoMark className="h-7 w-7" />
<span className="font-display text-base font-semibold tracking-tight text-fg">
ChatApp
</span>
</div>
<LanguageSwitcher compact />
</div>
{/* Desktop-only top strip — LanguageSwitcher in the corner */}
<div className="hidden items-center justify-end px-8 pt-8 lg:flex">
<LanguageSwitcher />
</div>
<div className="flex flex-1 items-center">
<div className="mx-auto w-full max-w-md px-6 py-8 sm:px-8 lg:px-10 lg:py-10">
<header className="mb-6">
<h2 className="font-display text-2xl font-semibold tracking-tight text-fg">
{t(titleKey)}
</h2>
<p className="mt-1.5 text-sm text-fg-muted">{t(subtitleKey)}</p>
</header>
<Segmented mode={mode} onChange={onModeChange} />
<form onSubmit={onSubmit} className="mt-6 space-y-4" noValidate>
<Field
id={emailId}
label={t('auth:fields.email')}
icon={<MailIcon className="h-4 w-4" />}
input={
<input
id={emailId}
type="email"
name="email"
inputMode="email"
autoComplete="email"
required
placeholder={t('auth:fields.email_placeholder')}
value={email}
onChange={(e) => onEmailChange(e.target.value)}
className={INPUT_CLASS}
/>
}
/>
{mode === 'signup' && (
<>
<Field
id={usernameId}
label={t('auth:fields.username')}
hint={
username.length > 0 && !usernameValid
? t('auth:fields.username_invalid')
: t('auth:fields.username_hint')
}
invalid={username.length > 0 && !usernameValid}
icon={<AtIcon className="h-4 w-4" />}
input={
<input
id={usernameId}
type="text"
name="username"
autoComplete="username"
autoCapitalize="none"
autoCorrect="off"
spellCheck={false}
required
placeholder={t('auth:fields.username_placeholder')}
value={username}
onChange={(e) => onUsernameChange(e.target.value)}
pattern={USERNAME_PATTERN.source}
className={INPUT_CLASS}
/>
}
/>
<Field
id={inviteId}
label={t('auth:fields.invite_code')}
hint={t('auth:fields.invite_hint')}
icon={<TicketIcon className="h-4 w-4" />}
input={
<input
id={inviteId}
type="text"
name="invite"
autoCorrect="off"
spellCheck={false}
required
value={inviteCode}
onChange={(e) => onInviteChange(e.target.value)}
className={INPUT_CLASS + ' font-mono'}
/>
}
/>
</>
)}
<button
type="submit"
disabled={busy}
aria-busy={busy}
className="group inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-4 py-3 text-sm font-semibold text-accent-fg transition hover:bg-accent/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-2 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy ? (
<>
<SpinnerIcon className="h-4 w-4" />
<span>{t(ctaSendingKey)}</span>
</>
) : (
<>
<span>{t(ctaKey)}</span>
<ArrowRightIcon className="h-4 w-4 transition group-hover:translate-x-0.5" />
</>
)}
</button>
<StatusBanner ui={ui} />
{ui.kind === 'sent' && <OtpForm email={ui.email} />}
</form>
<Footer mode={mode} onModeChange={onModeChange} />
<p className="mt-6 text-center text-xs text-fg-muted">
{t('auth:legal_note')}
</p>
</div>
</div>
</section>
);
}
const INPUT_CLASS =
'w-full rounded-lg border border-line bg-surface-3 py-2.5 pl-10 pr-3 text-sm text-fg placeholder-fg-muted transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30';
function Segmented({ mode, onChange }: { mode: Mode; onChange: (m: Mode) => void }) {
const { t } = useTranslation(['auth']);
return (
<div
role="tablist"
aria-label="Authentication mode"
className="relative grid grid-cols-2 rounded-lg border border-line bg-surface-3 p-1 text-sm"
>
<div
aria-hidden="true"
className="absolute bottom-1 top-1 w-[calc(50%-4px)] rounded-md bg-accent/15 ring-1 ring-accent/30 transition-transform duration-200"
style={{ transform: 'translateX(' + (mode === 'signup' ? '0%' : 'calc(100% + 4px)') + ')' }}
/>
<SegmentButton active={mode === 'signup'} onClick={() => onChange('signup')}>
{t('auth:tab_signup')}
</SegmentButton>
<SegmentButton active={mode === 'login'} onClick={() => onChange('login')}>
{t('auth:tab_login')}
</SegmentButton>
</div>
);
}
function SegmentButton({
active,
onClick,
children,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
role="tab"
aria-selected={active}
onClick={onClick}
className={
'relative z-10 cursor-pointer rounded-md px-3 py-2 font-medium transition focus:outline-none ' +
(active ? 'text-fg' : 'text-fg-muted hover:text-fg')
}
>
{children}
</button>
);
}
function Field({
id,
label,
hint,
icon,
input,
invalid,
}: {
id: string;
label: string;
hint?: string;
icon: React.ReactNode;
input: React.ReactNode;
invalid?: boolean;
}) {
return (
<div className="space-y-1.5">
<label
htmlFor={id}
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted"
>
{label}
</label>
<div className="relative">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-fg-muted">
{icon}
</span>
{input}
</div>
{hint && (
<p
className={
'text-[11px] leading-snug ' +
(invalid ? 'text-rose-600 dark:text-rose-400' : 'text-fg-muted')
}
>
{hint}
</p>
)}
</div>
);
}
function StatusBanner({ ui }: { ui: UiState }) {
const { t } = useTranslation(['auth']);
if (ui.kind === 'sent') {
return (
<div
role="status"
className="flex items-start gap-3 rounded-lg border border-emerald-500/25 bg-emerald-500/10 p-3.5 text-sm text-emerald-800 dark:text-emerald-100"
>
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 text-emerald-600 dark:text-emerald-400" />
<div className="min-w-0 flex-1 space-y-1">
<p className="break-words font-medium">
{t('auth:sent_banner', { email: ui.email })}
</p>
<p className="break-words text-xs text-emerald-700/90 dark:text-emerald-200/80">
<InbucketHint />
</p>
</div>
</div>
);
}
if (ui.kind === 'error') {
return (
<div
role="alert"
className="flex items-start gap-3 rounded-lg border border-rose-500/25 bg-rose-500/10 p-3.5 text-sm text-rose-800 dark:text-rose-100"
>
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 text-rose-600 dark:text-rose-400" />
<p className="min-w-0 flex-1 break-words">{ui.message}</p>
</div>
);
}
return null;
}
function InbucketHint() {
const { t } = useTranslation(['auth']);
const parts = t('auth:sent_banner_hint').split('Inbucket');
if (parts.length === 1) return <span>{t('auth:sent_banner_hint')}</span>;
return (
<span>
{parts[0]}
<a
href="http://127.0.0.1:54324"
target="_blank"
rel="noreferrer"
className="underline underline-offset-2 hover:text-emerald-900 dark:hover:text-white"
>
Inbucket
</a>
{parts[1]}
</span>
);
}
function OtpForm({ email }: { email: string }) {
const { t } = useTranslation(['auth', 'errors']);
const [token, setToken] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputId = useId();
async function handleVerify(e: React.FormEvent) {
e.preventDefault();
e.stopPropagation();
if (busy || token.length !== 6) return;
setBusy(true);
setError(null);
try {
await verifyMagicLinkOtp(supabase, email, token);
} catch (err: unknown) {
const code = extractErrorCode(err);
setError(
code
? t('errors:' + code, { defaultValue: t('errors:generic') })
: err instanceof Error
? err.message
: t('errors:generic'),
);
} finally {
setBusy(false);
}
}
return (
<div className="mt-4 space-y-2 rounded-lg border border-line bg-surface-3 p-4">
<label
htmlFor={inputId}
className="block text-[11px] font-semibold uppercase tracking-wider text-fg-muted"
>
{t('auth:otp_label')}
</label>
<input
id={inputId}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="\d{6}"
maxLength={6}
value={token}
onChange={(e) => setToken(e.target.value.replace(/\D/g, ''))}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
void handleVerify(e);
}
}}
placeholder={t('auth:otp_placeholder')}
autoFocus
className="w-full rounded-lg border border-line bg-surface-2 px-3 py-3 text-center font-mono text-xl tracking-[0.4em] text-fg placeholder-fg-muted/50 transition focus:border-accent focus:outline-none focus:ring-2 focus:ring-accent/30"
/>
<p className="text-[11px] text-fg-muted">{t('auth:otp_hint')}</p>
{error && (
<p
role="alert"
className="break-words rounded-md border border-rose-500/25 bg-rose-500/10 px-3 py-2 text-xs text-rose-800 dark:text-rose-200"
>
{error}
</p>
)}
<button
type="button"
disabled={busy || token.length !== 6}
onClick={(e) => void handleVerify(e)}
aria-busy={busy}
className="mt-1 inline-flex w-full cursor-pointer items-center justify-center gap-2 rounded-lg bg-accent px-4 py-2.5 text-sm font-semibold text-accent-fg transition hover:bg-accent/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/50 disabled:cursor-not-allowed disabled:opacity-60"
>
{busy && <SpinnerIcon className="h-4 w-4" />}
<span>{t(busy ? 'auth:otp_cta_loading' : 'auth:otp_cta')}</span>
</button>
</div>
);
}
function Footer({ mode, onModeChange }: { mode: Mode; onModeChange: (m: Mode) => void }) {
const { t } = useTranslation(['auth']);
const promptKey = mode === 'signup' ? 'auth:footer_signup_prompt' : 'auth:footer_login_prompt';
const switchKey =
mode === 'signup' ? 'auth:footer_switch_to_login' : 'auth:footer_switch_to_signup';
return (
<div className="mt-6 flex items-center justify-between border-t border-line pt-5 text-xs text-fg-muted">
<span>
{t(promptKey)}{' '}
<button
type="button"
onClick={() => onModeChange(mode === 'signup' ? 'login' : 'signup')}
className="cursor-pointer font-semibold text-accent underline-offset-2 hover:underline focus:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 focus-visible:ring-offset-2 focus-visible:ring-offset-surface-2"
>
{t(switchKey)}
</button>
</span>
<a
href="http://127.0.0.1:54323"
target="_blank"
rel="noreferrer"
className="hover:text-fg"
>
{t('auth:footer_studio')}
</a>
</div>
);
}