184 lines
6.5 KiB
TypeScript
184 lines
6.5 KiB
TypeScript
import { useState } from "react";
|
|
import type { FormEvent } from "react";
|
|
import { Link, Navigate, useNavigate, useSearchParams } from "react-router";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { api } from "@/api/client";
|
|
import type { Profile, SignupStatusResponse } from "@/api/types";
|
|
import { getBootstrapProfile, useAuth } from "@/hooks/useAuth";
|
|
import { Button } from "@/components/ui/button";
|
|
import { PasswordInput } from "@/components/PasswordInput";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { useServerBranding } from "@/hooks/useServerBranding";
|
|
import { sanitizeAuthRedirect } from "@/lib/authRedirect";
|
|
import { toast } from "sonner";
|
|
|
|
export default function Signup() {
|
|
const [searchParams] = useSearchParams();
|
|
const [username, setUsername] = useState("");
|
|
const [email, setEmail] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [confirmPassword, setConfirmPassword] = useState("");
|
|
const [inviteCode, setInviteCode] = useState(searchParams.get("code") ?? "");
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const { signup, profile, selectProfile, user, loading } = useAuth();
|
|
const navigate = useNavigate();
|
|
const { serverName } = useServerBranding();
|
|
const redirectTarget = sanitizeAuthRedirect(searchParams.get("redirect"));
|
|
|
|
const statusQuery = useQuery({
|
|
queryKey: ["auth", "signup-status"],
|
|
queryFn: () => api<SignupStatusResponse>("/auth/signup"),
|
|
});
|
|
|
|
if (loading || statusQuery.isPending) {
|
|
return (
|
|
<div className="auth-shell">
|
|
<div className="border-primary h-8 w-8 animate-spin rounded-full border-b-2" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (user) {
|
|
return <Navigate to={redirectTarget || (profile ? "/" : "/profiles")} replace />;
|
|
}
|
|
|
|
if (statusQuery.data && !statusQuery.data.enabled) {
|
|
return (
|
|
<div className="auth-shell">
|
|
<Card className="auth-card glass panel-border w-full max-w-sm border-0">
|
|
<CardHeader>
|
|
<CardTitle className="text-3xl font-extrabold tracking-[-0.04em]">
|
|
{serverName}
|
|
</CardTitle>
|
|
<CardDescription className="mt-2 text-sm leading-6">
|
|
Public signups are currently closed.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
async function handleSubmit(e: FormEvent) {
|
|
e.preventDefault();
|
|
if (password !== confirmPassword) {
|
|
toast.error("Passwords do not match");
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
try {
|
|
await signup(username, email, password, inviteCode);
|
|
if (redirectTarget) {
|
|
navigate(redirectTarget, { replace: true });
|
|
return;
|
|
}
|
|
try {
|
|
const profileList = await api<{ profiles: Profile[] }>("/profiles");
|
|
const soleProfile = getBootstrapProfile(profileList.profiles ?? []);
|
|
if (soleProfile) {
|
|
selectProfile(soleProfile);
|
|
navigate("/");
|
|
return;
|
|
}
|
|
} catch {
|
|
navigate("/profiles");
|
|
return;
|
|
}
|
|
navigate("/profiles");
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : "Signup failed");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="auth-shell">
|
|
<Card className="auth-card glass panel-border w-full max-w-sm border-0">
|
|
<CardHeader>
|
|
<CardTitle className="text-3xl font-extrabold tracking-[-0.04em]">{serverName}</CardTitle>
|
|
<CardDescription className="mt-2 text-sm leading-6">
|
|
Create a new account to get started.
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<form onSubmit={handleSubmit} className="space-y-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="signup-username">Username</Label>
|
|
<Input
|
|
id="signup-username"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
autoComplete="username"
|
|
autoFocus
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="signup-email">Email</Label>
|
|
<Input
|
|
id="signup-email"
|
|
type="email"
|
|
value={email}
|
|
onChange={(e) => setEmail(e.target.value)}
|
|
autoComplete="email"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="signup-password">Password</Label>
|
|
<p className="text-muted-foreground text-xs">At least 8 characters</p>
|
|
<PasswordInput
|
|
id="signup-password"
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
autoComplete="new-password"
|
|
required
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="signup-confirm-password">Confirm password</Label>
|
|
<PasswordInput
|
|
id="signup-confirm-password"
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
autoComplete="new-password"
|
|
required
|
|
/>
|
|
{confirmPassword && password !== confirmPassword && (
|
|
<p className="text-destructive text-xs">Passwords do not match</p>
|
|
)}
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="signup-invite-code">Invite code</Label>
|
|
<Input
|
|
id="signup-invite-code"
|
|
value={inviteCode}
|
|
onChange={(e) => setInviteCode(e.target.value)}
|
|
placeholder="Enter your invite code"
|
|
required
|
|
/>
|
|
</div>
|
|
<Button type="submit" className="w-full" disabled={submitting}>
|
|
{submitting ? "Creating account..." : "Create account"}
|
|
</Button>
|
|
</form>
|
|
<p className="text-muted-foreground mt-4 text-center text-sm">
|
|
Already have an account?{" "}
|
|
<Link
|
|
to={
|
|
redirectTarget ? `/login?redirect=${encodeURIComponent(redirectTarget)}` : "/login"
|
|
}
|
|
className="text-foreground underline hover:no-underline"
|
|
>
|
|
Sign in
|
|
</Link>
|
|
</p>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
);
|
|
}
|