Files
Magent/frontend/app/signup/page.tsx
T

266 lines
8.2 KiB
TypeScript

"use client";
import { Suspense, useCallback, useEffect, useMemo, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import AuthLayout from "../ui/AuthLayout";
import { clearToken, getApiBase, setToken } from "../lib/auth";
type InviteInfo = {
code: string;
email_bound?: boolean;
label?: string | null;
description?: string | null;
enabled: boolean;
is_expired?: boolean;
is_usable?: boolean;
expires_at?: string | null;
max_uses?: number | null;
use_count?: number | null;
remaining_uses?: number | null;
profile?: {
id: number;
name: string;
description?: string | null;
} | null;
};
const formatDate = (value?: string | null) => {
if (!value) return "Never";
const date = new Date(value);
if (Number.isNaN(date.valueOf())) return value;
return date.toLocaleString();
};
function SignupPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [inviteCode, setInviteCode] = useState(searchParams.get("code") ?? "");
const [invite, setInvite] = useState<InviteInfo | null>(null);
const [inviteLoading, setInviteLoading] = useState(false);
const [loading, setLoading] = useState(false);
const [username, setUsername] = useState("");
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<string | null>(null);
const canSubmit = useMemo(() => {
return Boolean(
invite?.is_usable &&
(invite.email_bound || email.trim()) &&
username.trim() &&
password &&
!loading &&
!inviteLoading,
);
}, [invite, email, username, password, loading, inviteLoading]);
const lookupInvite = useCallback(async (code: string) => {
const trimmed = code.trim();
if (!trimmed) {
setInvite(null);
return;
}
setInviteLoading(true);
setError(null);
setStatus(null);
try {
const baseUrl = getApiBase();
const response = await fetch(`${baseUrl}/auth/invites/${encodeURIComponent(trimmed)}`);
if (!response.ok) {
const text = await response.text();
throw new Error(text || "Invite not found");
}
const data = await response.json();
setInvite(data?.invite ?? null);
setStatus("Invite loaded.");
} catch (err) {
console.error(err);
setInvite(null);
setError("Invite code not found or unavailable.");
} finally {
setInviteLoading(false);
}
}, []);
useEffect(() => {
const initialCode = searchParams.get("code") ?? "";
if (initialCode) {
setInviteCode(initialCode);
void lookupInvite(initialCode);
}
}, [lookupInvite, searchParams]);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
if (password !== confirmPassword) {
setError("Passwords do not match.");
return;
}
if (!inviteCode.trim()) {
setError("Invite code is required.");
return;
}
if (!invite?.is_usable) {
setError("Invite is not usable. Refresh invite details or ask an admin for a new code.");
return;
}
setLoading(true);
setError(null);
setStatus(null);
try {
clearToken();
const baseUrl = getApiBase();
const response = await fetch(`${baseUrl}/auth/signup`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
invite_code: inviteCode,
username: username.trim(),
...(!invite.email_bound ? { email: email.trim() } : {}),
password,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(text || "Sign-up failed");
}
const data = await response.json();
if (data?.authenticated) {
setToken("cookie");
window.location.href = "/welcome";
return;
}
throw new Error("Sign-up did not complete");
} catch (err) {
console.error(err);
setError(err instanceof Error ? err.message : "Unable to create account.");
} finally {
setLoading(false);
}
};
return (
<AuthLayout title="Create account" description="Your invite is the first step to your media library.">
<form onSubmit={submit} className="account-form login-form auth-flow-form">
<label>
Invite code
<div className="invite-lookup-row">
<input
value={inviteCode}
onChange={(e) => {
setInviteCode(e.target.value);
setInvite(null);
setEmail("");
}}
placeholder="Paste your invite code"
autoCapitalize="characters"
/>
<button
type="button"
className="ghost-button"
disabled={inviteLoading}
onClick={() => void lookupInvite(inviteCode)}
>
{inviteLoading ? "Checking…" : "Check invite"}
</button>
</div>
</label>
{invite && (
<div className={`invite-summary ${invite.is_usable ? "" : "is-disabled"}`}>
<div className="invite-summary-row">
<strong>{invite.label || invite.code}</strong>
<span className={`small-pill ${invite.is_usable ? "" : "is-muted"}`}>
{invite.is_usable ? "Ready" : "Unavailable"}
</span>
</div>
{invite.description && <p>{invite.description}</p>}
<details className="auth-invite-details">
<summary>Invite details</summary>
<div className="admin-meta-row">
<span>Code: {invite.code}</span>
<span>Expires: {formatDate(invite.expires_at)}</span>
<span>Remaining uses: {invite.remaining_uses ?? "Unlimited"}</span>
<span>Profile: {invite.profile?.name || "None"}</span>
</div>
</details>
</div>
)}
{invite?.email_bound ? (
<p className="account-hint">
Your account will use the email address this invitation was sent to. This invitation can be used once.
</p>
) : (
<label>
Email address
<input
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
autoComplete="email"
placeholder="you@example.com"
/>
</label>
)}
<label>
Username
<input value={username} onChange={(e) => setUsername(e.target.value)} autoComplete="username" />
</label>
<label>
Password
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
<label>
Confirm password
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</label>
{error && (
<div className="account-notice is-error" role="alert">
{error}
</div>
)}
{status && (
<div className="account-notice is-status" role="status">
{status}
</div>
)}
<div className="auth-actions">
<button type="submit" className="account-primary" disabled={!canSubmit}>
{loading ? "Creating account…" : "Create account"}
</button>
</div>
<button type="button" className="ghost-button" disabled={loading} onClick={() => router.push("/login")}>
Back to sign in
</button>
</form>
</AuthLayout>
);
}
export default function SignupPage() {
return (
<Suspense
fallback={
<AuthLayout title="Create account" description="Your invite is the first step to your media library.">
<p role="status">Loading sign-up</p>
</AuthLayout>
}
>
<SignupPageContent />
</Suspense>
);
}