eb8f9857ff
Backup / restore flow: - deviceBackup.ts: v2 format wrapping userId + deviceId + privateKey in an encrypted JSON payload so restore can re-seed localStorage, vault, and reattach to the existing server-side device row without provisioning a new one (conv-key bundles stay valid, no "awaiting key" state) - shared/auth: restoreDeviceFromServerRecord — verifies session.user.id matches the backup's userId, confirms the server device row still exists, then writes the private key into the local secret store - BackupExportDialog — passphrase + confirm, generates portable string, copy + download .txt - DeviceRestore — textarea + passphrase → seeds vault + writes deviceId cache, treats this install as the original device - DevicePage now has tabs "Neu einrichten" / "Backup wiederherstellen" - BackupPromptBanner — post-registration nudge, reads sessionStorage signal from fresh provisions and persists "never-ask-again" in localStorage so it stops nagging - SettingsPage backup section: uses the new dialog; removes the dangerous in-place key import (restore now lives in the device flow) Username casing: - Migration 20260420000002 drops lower() from the handle_new_user trigger and widens the regex to [A-Za-z0-9_]. profiles.username is citext so uniqueness + lookups stay case-insensitive regardless of stored casing - Shared auth: trim() only, no toLowerCase on signup/lookups/search. ilike handles CI anyway and citext makes client normalisation redundant - AuthPage regex + input preserve case, FriendsPage search preserves case - i18n (de+en): updated username_hint / username_invalid / ERR_USERNAME_INVALID to reflect the new rule Quick wins: - React Router v7 future flags (v7_startTransition + v7_relativeSplatPath) set on BrowserRouter — silences the upgrade warning - appUpdates.checkForUpdate: swallow benign network/fetch/"could not fetch valid release JSON" cases silently instead of console spam - osNotify: persist an "asked" marker in localStorage so the permission prompt only fires once per install (OS already persists the answer, but the plugin re-queries loudly otherwise)
626 lines
22 KiB
TypeScript
626 lines
22 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],
|
|
);
|
|
|
|
// Already signed in? Bounce to chats. Guards take it from here.
|
|
if (session) return <Navigate to="/chats" replace />;
|
|
|
|
return (
|
|
<Shell>
|
|
<BrandPanel />
|
|
<FormCard
|
|
mode={mode}
|
|
onModeChange={setMode}
|
|
email={email}
|
|
onEmailChange={setEmail}
|
|
username={username}
|
|
onUsernameChange={setUsername}
|
|
usernameValid={usernameValid}
|
|
inviteCode={inviteCode}
|
|
onInviteChange={setInviteCode}
|
|
ui={ui}
|
|
onSubmit={handleSubmit}
|
|
/>
|
|
</Shell>
|
|
);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Layout
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function Shell({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<main className="relative min-h-screen overflow-hidden bg-ink-950 text-neutral-100">
|
|
<BackgroundStage />
|
|
<div className="relative z-10 grid min-h-screen w-full grid-cols-1 gap-0 lg:grid-cols-[minmax(0,1fr)_minmax(440px,560px)]">
|
|
{children}
|
|
</div>
|
|
</main>
|
|
);
|
|
}
|
|
|
|
function BackgroundStage() {
|
|
return (
|
|
<div aria-hidden="true" className="pointer-events-none absolute inset-0">
|
|
<div className="bg-grid absolute inset-0 opacity-[0.28]" />
|
|
<div className="absolute -left-40 top-[22%] h-[560px] w-[560px] -translate-y-1/2 rounded-full bg-brand-500/30 blur-3xl animate-blob-a xl:h-[680px] xl:w-[680px] 2xl:h-[820px] 2xl:w-[820px]" />
|
|
<div className="absolute left-[38%] top-[60%] h-[520px] w-[520px] -translate-y-1/2 rounded-full bg-fuchsia-500/20 blur-3xl animate-blob-b xl:h-[640px] xl:w-[640px] 2xl:h-[780px] 2xl:w-[780px]" />
|
|
<div className="absolute -right-32 top-[12%] h-[420px] w-[420px] rounded-full bg-indigo-500/20 blur-3xl animate-blob-b xl:h-[520px] xl:w-[520px]" />
|
|
<div className="absolute -right-20 bottom-0 h-[460px] w-[460px] rounded-full bg-rose-500/10 blur-3xl animate-blob-a xl:h-[560px] xl:w-[560px]" />
|
|
<div className="absolute inset-0 bg-gradient-to-b from-transparent via-transparent to-ink-950/80" />
|
|
<div className="absolute inset-0 bg-[radial-gradient(ellipse_at_center,transparent_40%,rgba(5,5,7,0.65)_100%)]" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BrandPanel() {
|
|
const { t } = useTranslation(['auth', 'common']);
|
|
return (
|
|
<section className="relative hidden lg:block">
|
|
<div className="grid h-full grid-rows-[auto_1fr_auto] px-10 py-10 xl:px-14 xl:py-14 2xl:px-20 2xl:py-16">
|
|
<header className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-3">
|
|
<LogoMark className="h-9 w-9" />
|
|
<span className="font-display text-lg font-semibold tracking-tight">
|
|
{t('common:app_name')}
|
|
</span>
|
|
</div>
|
|
<LanguageSwitcher />
|
|
</header>
|
|
|
|
<div className="flex items-center">
|
|
<div className="w-full max-w-xl animate-fade-in xl:max-w-2xl 2xl:max-w-3xl">
|
|
<p className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1 text-xs font-medium text-neutral-300 backdrop-blur">
|
|
<ShieldIcon className="h-3.5 w-3.5 text-emerald-400" />
|
|
{t('auth:brand.badge')}
|
|
</p>
|
|
<h1 className="mt-6 font-display text-4xl font-semibold leading-[1.05] tracking-tight text-white xl:text-5xl 2xl:text-6xl">
|
|
{t('auth:brand.title_line_1')}
|
|
<br />
|
|
{t('auth:brand.title_line_2')}
|
|
</h1>
|
|
<p className="mt-5 max-w-lg text-base leading-relaxed text-neutral-400 xl:text-lg 2xl:max-w-xl">
|
|
{t('auth:brand.subtitle')}
|
|
</p>
|
|
|
|
<dl className="mt-10 grid grid-cols-1 gap-4 sm:grid-cols-2 xl:mt-12 xl:gap-5 2xl:grid-cols-3">
|
|
<Feature
|
|
icon={<LockIcon className="h-5 w-5 text-brand-300" />}
|
|
title={t('auth:brand.feature_zk_title')}
|
|
desc={t('auth:brand.feature_zk_desc')}
|
|
/>
|
|
<Feature
|
|
icon={<SparklesIcon className="h-5 w-5 text-brand-300" />}
|
|
title={t('auth:brand.feature_selfhost_title')}
|
|
desc={t('auth:brand.feature_selfhost_desc')}
|
|
/>
|
|
<Feature
|
|
icon={<ShieldIcon className="h-5 w-5 text-brand-300" />}
|
|
title={t('auth:brand.feature_invite_title')}
|
|
desc={t('auth:brand.feature_invite_desc')}
|
|
/>
|
|
</dl>
|
|
</div>
|
|
</div>
|
|
|
|
<footer className="flex items-center justify-between text-xs text-neutral-500">
|
|
<span>v0.1.0 · {t('common:dev_build')}</span>
|
|
<span className="inline-flex items-center gap-1.5 text-neutral-600">
|
|
<span className="h-1.5 w-1.5 rounded-full bg-emerald-400" />
|
|
{t('common:local_stack_online')}
|
|
</span>
|
|
</footer>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function Feature({ icon, title, desc }: { icon: React.ReactNode; title: string; desc: string }) {
|
|
return (
|
|
<div className="rounded-xl border border-white/5 bg-white/5 p-4 backdrop-blur">
|
|
<div className="flex items-center gap-2">
|
|
{icon}
|
|
<dt className="text-sm font-semibold text-white">{title}</dt>
|
|
</div>
|
|
<dd className="mt-1.5 text-sm text-neutral-400">{desc}</dd>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface FormCardProps {
|
|
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 FormCard({
|
|
mode,
|
|
onModeChange,
|
|
email,
|
|
onEmailChange,
|
|
username,
|
|
onUsernameChange,
|
|
usernameValid,
|
|
inviteCode,
|
|
onInviteChange,
|
|
ui,
|
|
onSubmit,
|
|
}: FormCardProps) {
|
|
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 items-center justify-center px-5 py-10 sm:px-8 lg:px-10 xl:px-16">
|
|
<div className="absolute left-6 right-6 top-6 flex items-center justify-between 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">ChatApp</span>
|
|
</div>
|
|
<LanguageSwitcher compact />
|
|
</div>
|
|
|
|
<div className="w-full max-w-md animate-slide-up">
|
|
<div className="rounded-2xl border border-white/10 bg-ink-900/70 p-6 shadow-glow backdrop-blur-xl sm:p-8">
|
|
<header className="mb-6">
|
|
<h2 className="font-display text-2xl font-semibold tracking-tight text-white">
|
|
{t(titleKey)}
|
|
</h2>
|
|
<p className="mt-1.5 text-sm text-neutral-400">{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="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
|
/>
|
|
}
|
|
/>
|
|
|
|
{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="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm text-white placeholder-neutral-500 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
|
/>
|
|
}
|
|
/>
|
|
|
|
<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="w-full rounded-lg border border-white/10 bg-ink-800 py-2.5 pl-10 pr-3 text-sm font-mono text-white transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
|
/>
|
|
}
|
|
/>
|
|
</>
|
|
)}
|
|
|
|
<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-gradient-to-br from-brand-400 to-brand-600 px-4 py-3 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 focus:ring-offset-2 focus:ring-offset-ink-900 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} />
|
|
</div>
|
|
|
|
<p className="mt-6 text-center text-xs text-neutral-500">{t('auth:legal_note')}</p>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
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-white/10 bg-ink-800 p-1 text-sm"
|
|
>
|
|
<div
|
|
aria-hidden="true"
|
|
className="absolute bottom-1 top-1 w-[calc(50%-4px)] rounded-md bg-brand-500/20 ring-1 ring-brand-400/40 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-white' : 'text-neutral-400 hover:text-neutral-200')
|
|
}
|
|
>
|
|
{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-xs font-medium uppercase tracking-wide text-neutral-400">
|
|
{label}
|
|
</label>
|
|
<div className="relative">
|
|
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-neutral-500">
|
|
{icon}
|
|
</span>
|
|
{input}
|
|
</div>
|
|
{hint && (
|
|
<p className={'text-xs ' + (invalid ? 'text-rose-400' : 'text-neutral-500')}>{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/20 bg-emerald-500/10 p-3.5 text-sm text-emerald-100"
|
|
>
|
|
<CheckCircleIcon className="mt-0.5 h-5 w-5 shrink-0 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-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/20 bg-rose-500/10 p-3.5 text-sm text-rose-100"
|
|
>
|
|
<AlertIcon className="mt-0.5 h-5 w-5 shrink-0 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-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);
|
|
// Session updates via Supabase subscription; AuthPage Navigate redirects.
|
|
} 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-white/10 bg-ink-800/50 p-4">
|
|
<label
|
|
htmlFor={inputId}
|
|
className="block text-xs font-medium uppercase tracking-wide text-neutral-400"
|
|
>
|
|
{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-white/10 bg-ink-900 px-3 py-3 text-center font-mono text-xl tracking-[0.4em] text-white placeholder-neutral-600 transition focus:border-brand-400 focus:outline-none focus:ring-2 focus:ring-brand-500/40"
|
|
/>
|
|
<p className="text-xs text-neutral-500">{t('auth:otp_hint')}</p>
|
|
|
|
{error && (
|
|
<p
|
|
role="alert"
|
|
className="break-words rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-2 text-xs 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-gradient-to-br from-brand-400 to-brand-600 px-4 py-2.5 text-sm font-semibold text-white shadow-glow transition hover:from-brand-300 hover:to-brand-500 focus:outline-none focus:ring-2 focus:ring-brand-400/60 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-white/5 pt-5 text-xs text-neutral-500">
|
|
<span>
|
|
{t(promptKey)}{' '}
|
|
<button
|
|
type="button"
|
|
onClick={() => onModeChange(mode === 'signup' ? 'login' : 'signup')}
|
|
className="cursor-pointer font-medium text-brand-300 underline-offset-2 hover:text-brand-200 hover:underline focus:outline-none focus:ring-2 focus:ring-brand-400/40 focus:ring-offset-2 focus:ring-offset-ink-900"
|
|
>
|
|
{t(switchKey)}
|
|
</button>
|
|
</span>
|
|
<a
|
|
href="http://127.0.0.1:54323"
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="hover:text-neutral-300"
|
|
>
|
|
{t('auth:footer_studio')} ↗
|
|
</a>
|
|
</div>
|
|
);
|
|
}
|