Files
Metamorph/frontend/src/pages/RegisterPage.tsx

164 lines
4.8 KiB
TypeScript
Raw Normal View History

2026-05-11 06:05:27 +02:00
import { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { Alert } from '@/components/ui/Alert';
import { Button } from '@/components/ui/Button';
import { Card } from '@/components/ui/Card';
import { SectionHeader } from '@/components/ui/SectionHeader';
import { TextField } from '@/components/ui/TextField';
import { ApiError, apiGet, apiPost } from '@/lib/api';
interface InvitationPreview {
is_valid: boolean;
reason: string | null;
email_hint: string | null;
expires_at: string;
groups: string[];
}
export function RegisterPage() {
const [params] = useSearchParams();
const token = params.get('token') ?? '';
const navigate = useNavigate();
const [preview, setPreview] = useState<InvitationPreview | null>(null);
const [previewError, setPreviewError] = useState<string | null>(null);
const [email, setEmail] = useState('');
const [displayName, setDisplayName] = useState('');
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [done, setDone] = useState(false);
useEffect(() => {
if (!token) {
setPreviewError('Missing invitation token in the URL.');
return;
}
apiGet<InvitationPreview>(`/invitations/preview/${encodeURIComponent(token)}`, {
anonymous: true,
})
.then((p) => {
setPreview(p);
if (p.email_hint) setEmail(p.email_hint);
})
.catch((err: unknown) =>
setPreviewError(err instanceof Error ? err.message : 'Could not load invitation.'),
);
}, [token]);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
if (password !== confirm) {
setError('Passwords do not match.');
return;
}
setBusy(true);
try {
await apiPost(
`/invitations/accept/${encodeURIComponent(token)}`,
{ email, password, display_name: displayName || undefined },
{ anonymous: true, noRefresh: true },
);
setDone(true);
setTimeout(() => navigate('/login', { replace: true }), 1500);
} catch (err) {
if (err instanceof ApiError) {
const payload = err.payload as { error?: string; message?: string } | null;
setError(payload?.message ?? payload?.error ?? `HTTP ${err.status}`);
} else {
setError(err instanceof Error ? err.message : 'Registration failed.');
}
} finally {
setBusy(false);
}
}
if (done) {
return (
<div className="mx-auto max-w-md mt-12">
<Alert accent="green">Account created. Redirecting to login</Alert>
</div>
);
}
if (previewError) {
return (
<div className="mx-auto max-w-md mt-12">
<Alert accent="red">{previewError}</Alert>
</div>
);
}
if (!preview) {
return (
<div className="mx-auto max-w-md mt-12">
<Alert accent="cyan">Loading invitation</Alert>
</div>
);
}
if (!preview.is_valid) {
return (
<div className="mx-auto max-w-md mt-12">
<Alert accent="red">
This invitation is not usable: <code>{preview.reason}</code>
</Alert>
</div>
);
}
return (
<div className="mx-auto max-w-xl mt-12">
<SectionHeader
prefix="Account"
highlight="Registration"
accent="green"
description="Welcome — pick a password to join the platform."
/>
<Card accent="green">
<form onSubmit={handleSubmit} className="space-y-4">
<TextField
label="Email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
hint={preview.email_hint ? `Invited as ${preview.email_hint}` : undefined}
required
/>
<TextField
label="Display name (optional)"
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
/>
<TextField
label="Password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<TextField
label="Confirm password"
type="password"
value={confirm}
onChange={(e) => setConfirm(e.target.value)}
required
/>
{preview.groups.length > 0 && (
<Alert accent="cyan">
You will be added to: {preview.groups.map((g) => `[${g}]`).join(' ')}
</Alert>
)}
{error && <Alert accent="red">{error}</Alert>}
<Button type="submit" accent="green" disabled={busy}>
{busy ? 'Creating account…' : 'Create account'}
</Button>
</form>
</Card>
</div>
);
}