268 lines
8.1 KiB
TypeScript
268 lines
8.1 KiB
TypeScript
"use client";
|
||
import { useState } from "react";
|
||
import { useRouter } from "next/navigation";
|
||
import { ArrowRight, Compass, Mail, ShieldCheck } from "lucide-react";
|
||
import { api } from "./StartGame";
|
||
import { authActionSchema, authDestination } from "@/shared/account";
|
||
|
||
type Mode = "login" | "signup" | "recover" | "magic";
|
||
export function SignIn({
|
||
next = "/dashboard",
|
||
initialError = "",
|
||
}: {
|
||
next?: string;
|
||
initialError?: string;
|
||
}) {
|
||
const [mode, setMode] = useState<Mode>("login"),
|
||
[email, setEmail] = useState(""),
|
||
[name, setName] = useState(""),
|
||
[password, setPassword] = useState(""),
|
||
[confirmation, setConfirmation] = useState(""),
|
||
[message, setMessage] = useState(""),
|
||
[error, setError] = useState(initialError),
|
||
[busy, setBusy] = useState(false);
|
||
const router = useRouter();
|
||
function changeMode(value: Mode) {
|
||
setMode(value);
|
||
setError("");
|
||
setMessage("");
|
||
setPassword("");
|
||
setConfirmation("");
|
||
}
|
||
async function submit(event: React.FormEvent) {
|
||
event.preventDefault();
|
||
setError("");
|
||
setMessage("");
|
||
const body =
|
||
mode === "signup"
|
||
? { action: mode, email, password, displayName: name, next }
|
||
: mode === "login"
|
||
? { action: mode, email, password, next }
|
||
: mode === "magic"
|
||
? { action: mode, email, next }
|
||
: { action: mode, email };
|
||
const valid = authActionSchema.safeParse(body);
|
||
if (!valid.success) {
|
||
setError(valid.error.issues[0].message);
|
||
return;
|
||
}
|
||
if (mode === "signup" && password !== confirmation) {
|
||
setError("Your passwords do not match.");
|
||
return;
|
||
}
|
||
setBusy(true);
|
||
try {
|
||
const result = await api<{ next?: string; message?: string }>(
|
||
"auth",
|
||
valid.data,
|
||
);
|
||
setPassword("");
|
||
setConfirmation("");
|
||
if (result.next) {
|
||
router.replace(authDestination(result.next));
|
||
router.refresh();
|
||
} else setMessage(result.message ?? "Check your email to continue.");
|
||
} catch (e) {
|
||
setError((e as Error).message);
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
}
|
||
const title = {
|
||
login: "Welcome back.",
|
||
signup: "Make yourself at home.",
|
||
recover: "Find your way back.",
|
||
magic: "An email, a way in.",
|
||
}[mode];
|
||
return (
|
||
<div className="auth-layout">
|
||
<aside className="auth-story">
|
||
<span className="eyebrow">YOUR NEXT EXPEDITION</span>
|
||
<h1>
|
||
A familiar world.
|
||
<br />
|
||
Your own perspective.
|
||
</h1>
|
||
<p>
|
||
Keep your journeys, share the places you know, and help build the next
|
||
map.
|
||
</p>
|
||
<div className="auth-story-map" aria-hidden="true">
|
||
<Compass size={52} />
|
||
<span>Every world has a story.</span>
|
||
</div>
|
||
<div className="auth-benefits">
|
||
<span>
|
||
<Compass size={18} />
|
||
Save your expeditions
|
||
</span>
|
||
<span>
|
||
<ShieldCheck size={18} />
|
||
Keep control of your maps
|
||
</span>
|
||
</div>
|
||
</aside>
|
||
<section className="auth-card">
|
||
<div className="auth-tabs" aria-label="Account options">
|
||
<button
|
||
type="button"
|
||
aria-pressed={mode === "login"}
|
||
disabled={busy}
|
||
onClick={() => changeMode("login")}
|
||
>
|
||
Log in
|
||
</button>
|
||
<button
|
||
type="button"
|
||
aria-pressed={mode === "signup"}
|
||
disabled={busy}
|
||
onClick={() => changeMode("signup")}
|
||
>
|
||
Create account
|
||
</button>
|
||
</div>
|
||
<span className="eyebrow">YOUR CARTOGRAPHER ACCOUNT</span>
|
||
<h2>{title}</h2>
|
||
<p className="muted">
|
||
{mode === "signup"
|
||
? "Choose the name people will see on your maps."
|
||
: mode === "recover"
|
||
? "We’ll email you a link to set a new password."
|
||
: mode === "magic"
|
||
? "For existing accounts. Open the link on this device to continue."
|
||
: "Log in with your email and password."}
|
||
</p>
|
||
<form onSubmit={submit} aria-busy={busy}>
|
||
{mode === "signup" && (
|
||
<label className="field">
|
||
Display name
|
||
<input
|
||
name="displayName"
|
||
autoComplete="nickname"
|
||
minLength={2}
|
||
maxLength={32}
|
||
value={name}
|
||
onChange={(e) => setName(e.target.value)}
|
||
placeholder="How should we call you?"
|
||
required
|
||
disabled={busy}
|
||
/>
|
||
<small>2–32 characters. You can change it in settings.</small>
|
||
</label>
|
||
)}
|
||
<label className="field">
|
||
Email address
|
||
<input
|
||
name="email"
|
||
type="email"
|
||
autoComplete="email"
|
||
value={email}
|
||
onChange={(e) => setEmail(e.target.value)}
|
||
placeholder="you@example.com"
|
||
required
|
||
disabled={busy}
|
||
/>
|
||
</label>
|
||
{(mode === "login" || mode === "signup") && (
|
||
<label className="field">
|
||
Password
|
||
<input
|
||
name="password"
|
||
type="password"
|
||
autoComplete={
|
||
mode === "signup" ? "new-password" : "current-password"
|
||
}
|
||
minLength={mode === "signup" ? 12 : 1}
|
||
value={password}
|
||
onChange={(e) => setPassword(e.target.value)}
|
||
required
|
||
disabled={busy}
|
||
/>
|
||
{mode === "signup" && (
|
||
<small>
|
||
Use at least 12 characters. A unique passphrase works well.
|
||
</small>
|
||
)}
|
||
</label>
|
||
)}
|
||
{mode === "signup" && (
|
||
<label className="field">
|
||
Confirm password
|
||
<input
|
||
name="confirmPassword"
|
||
type="password"
|
||
autoComplete="new-password"
|
||
value={confirmation}
|
||
onChange={(e) => setConfirmation(e.target.value)}
|
||
required
|
||
disabled={busy}
|
||
/>
|
||
</label>
|
||
)}
|
||
{mode === "login" && (
|
||
<button
|
||
type="button"
|
||
className="text-link auth-recovery"
|
||
disabled={busy}
|
||
onClick={() => changeMode("recover")}
|
||
>
|
||
Forgot password?
|
||
</button>
|
||
)}
|
||
{error && (
|
||
<p role="alert" className="error-message">
|
||
{error}
|
||
</p>
|
||
)}
|
||
{message && (
|
||
<p role="status" className="success-message">
|
||
{message}
|
||
</p>
|
||
)}
|
||
<button className="button primary full" disabled={busy}>
|
||
{mode === "recover" || mode === "magic" ? (
|
||
<Mail size={17} />
|
||
) : (
|
||
<ArrowRight size={17} />
|
||
)}{" "}
|
||
{busy
|
||
? "Please wait…"
|
||
: {
|
||
login: "Log in",
|
||
signup: "Create account",
|
||
recover: "Send reset link",
|
||
magic: "Send sign-in link",
|
||
}[mode]}
|
||
</button>
|
||
</form>
|
||
{mode === "login" && (
|
||
<div className="auth-alternative">
|
||
<span>Already use email links?</span>
|
||
<button
|
||
type="button"
|
||
className="text-link"
|
||
disabled={busy}
|
||
onClick={() => changeMode("magic")}
|
||
>
|
||
Send me a sign-in link
|
||
</button>
|
||
</div>
|
||
)}
|
||
{(mode === "recover" || mode === "magic") && (
|
||
<button
|
||
type="button"
|
||
className="text-link auth-back"
|
||
disabled={busy}
|
||
onClick={() => changeMode("login")}
|
||
>
|
||
← Back to log in
|
||
</button>
|
||
)}
|
||
<p className="auth-footnote">
|
||
Your email stays private. Your display name appears with your maps.
|
||
</p>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|