All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m6s
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { ThemeToggle } from "@/components/ui/ThemeToggle";
|
|
|
|
type LoginStage = "login" | "token" | "password" | "success";
|
|
|
|
async function postJson(path: string, body?: object) {
|
|
const response = await fetch(path, {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(body ?? {}),
|
|
});
|
|
const data: { error?: string } = await response.json();
|
|
if (!response.ok) throw new Error(data.error || "Request failed");
|
|
}
|
|
|
|
export default function LoginPage() {
|
|
const [stage, setStage] = useState<LoginStage>("login");
|
|
const [password, setPassword] = useState("");
|
|
const [confirmPassword, setConfirmPassword] = useState("");
|
|
const [token, setToken] = useState("");
|
|
const [error, setError] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
const [setupRequired, setSetupRequired] = useState<boolean | null>(null);
|
|
|
|
useEffect(() => {
|
|
fetch("/api/auth/setup-status")
|
|
.then((response) => response.json())
|
|
.then((data) => setSetupRequired(data.setupRequired))
|
|
.catch(() => setSetupRequired(false));
|
|
}, []);
|
|
|
|
async function run(action: () => Promise<void>) {
|
|
setError("");
|
|
setLoading(true);
|
|
try {
|
|
await action();
|
|
} catch (caught) {
|
|
setError(caught instanceof Error ? caught.message : "Network error. Please try again.");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
function handleLogin(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
void run(async () => {
|
|
await postJson("/api/auth/login", { password });
|
|
window.location.href = "/";
|
|
});
|
|
}
|
|
|
|
function beginReset() {
|
|
void run(async () => {
|
|
await postJson("/api/auth/password-reset/request");
|
|
setToken("");
|
|
setStage("token");
|
|
});
|
|
}
|
|
|
|
function handleToken(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
void run(async () => {
|
|
await postJson("/api/auth/password-reset/verify", { token: token.trim() });
|
|
setPassword("");
|
|
setConfirmPassword("");
|
|
setStage("password");
|
|
});
|
|
}
|
|
|
|
function handlePasswordReset(event: React.FormEvent) {
|
|
event.preventDefault();
|
|
void run(async () => {
|
|
await postJson("/api/auth/password-reset/complete", {
|
|
password,
|
|
confirmPassword,
|
|
});
|
|
setToken("");
|
|
setPassword("");
|
|
setConfirmPassword("");
|
|
setStage("success");
|
|
});
|
|
}
|
|
|
|
function returnToLogin() {
|
|
setStage("login");
|
|
setError("");
|
|
setToken("");
|
|
setPassword("");
|
|
setConfirmPassword("");
|
|
}
|
|
|
|
if (setupRequired === null) {
|
|
return (
|
|
<div className="flex min-h-screen items-center justify-center p-4">
|
|
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const title =
|
|
stage === "token"
|
|
? "Enter reset token"
|
|
: stage === "password"
|
|
? "Choose a new password"
|
|
: stage === "success"
|
|
? "Password updated"
|
|
: setupRequired
|
|
? "Welcome to Study Desk"
|
|
: "Welcome back";
|
|
|
|
const description =
|
|
stage === "token"
|
|
? "Find the token in the Study Desk Docker logs. It expires after 15 minutes."
|
|
: stage === "password"
|
|
? "Use at least eight characters and enter the password twice."
|
|
: stage === "success"
|
|
? "Your old password and existing sessions are no longer valid."
|
|
: setupRequired
|
|
? "Please set your admin password for the first time."
|
|
: "Open your notes, decks, and practice quizzes.";
|
|
|
|
return (
|
|
<div className="relative flex min-h-screen items-center justify-center overflow-hidden p-4 sm:p-8">
|
|
<div className="absolute inset-0 paper-grid opacity-70" />
|
|
<div className="absolute -left-24 top-1/4 h-80 w-80 rounded-full bg-primary/15 blur-3xl" />
|
|
<div className="absolute -right-24 bottom-0 h-72 w-72 rounded-full bg-accent/15 blur-3xl" />
|
|
<div className="absolute right-4 top-4 z-10"><ThemeToggle /></div>
|
|
|
|
<main className="relative w-full max-w-md animate-fade-in">
|
|
<header className="mb-7 text-center">
|
|
<div className="mb-4 inline-grid h-14 w-14 place-items-center rounded-2xl bg-primary text-xl font-black text-white shadow-[0_12px_30px_color-mix(in_srgb,var(--theme-primary)_30%,transparent)]">S</div>
|
|
<p className="text-xs font-bold uppercase tracking-[0.2em] text-primary">Personal workspace</p>
|
|
<h1 className="editorial-title mt-2 text-4xl text-text-heading">{title}</h1>
|
|
<p className="mt-1 text-sm text-text-muted">{description}</p>
|
|
</header>
|
|
|
|
<div className="rounded-[2rem] border border-border-light bg-bg-surface/90 p-6 shadow-[var(--shadow-modal)] backdrop-blur sm:p-8">
|
|
{stage === "login" && (
|
|
<form onSubmit={handleLogin} className="space-y-4">
|
|
<PasswordField
|
|
id="password"
|
|
label="Password"
|
|
value={password}
|
|
onChange={setPassword}
|
|
placeholder={setupRequired ? "Create a new password" : "Enter your password"}
|
|
autoFocus
|
|
/>
|
|
<ErrorMessage message={error} />
|
|
<PrimaryButton loading={loading} disabled={password.length < 8}>
|
|
{setupRequired ? "Save Password & Login" : "Sign In"}
|
|
</PrimaryButton>
|
|
{!setupRequired && (
|
|
<button
|
|
type="button"
|
|
onClick={beginReset}
|
|
disabled={loading}
|
|
className="w-full rounded-lg py-2 text-sm font-semibold text-primary hover:text-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:opacity-50"
|
|
>
|
|
Forgot password?
|
|
</button>
|
|
)}
|
|
</form>
|
|
)}
|
|
|
|
{stage === "token" && (
|
|
<form onSubmit={handleToken} className="space-y-4">
|
|
<div>
|
|
<label htmlFor="reset-token" className="mb-1.5 block text-sm font-medium text-text-secondary">Reset token</label>
|
|
<input
|
|
id="reset-token"
|
|
value={token}
|
|
onChange={(event) => setToken(event.target.value)}
|
|
autoComplete="one-time-code"
|
|
autoFocus
|
|
className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 font-mono text-text-heading placeholder:text-text-muted focus:border-primary"
|
|
placeholder="Paste the token from Docker logs"
|
|
/>
|
|
</div>
|
|
<ErrorMessage message={error} />
|
|
<PrimaryButton loading={loading} disabled={!token.trim()}>Validate Token</PrimaryButton>
|
|
<SecondaryButton onClick={returnToLogin}>Back to sign in</SecondaryButton>
|
|
</form>
|
|
)}
|
|
|
|
{stage === "password" && (
|
|
<form onSubmit={handlePasswordReset} className="space-y-4">
|
|
<div>
|
|
<PasswordField
|
|
id="new-password"
|
|
label="New password"
|
|
value={password}
|
|
onChange={setPassword}
|
|
describedBy="password-length-status"
|
|
invalid={password.length < 8}
|
|
autoFocus
|
|
/>
|
|
<ValidationMessage
|
|
id="password-length-status"
|
|
valid={password.length >= 8}
|
|
validText="Password meets the length requirement."
|
|
invalidText="Password must be at least 8 characters."
|
|
/>
|
|
</div>
|
|
<div>
|
|
<PasswordField
|
|
id="confirm-password"
|
|
label="Confirm new password"
|
|
value={confirmPassword}
|
|
onChange={setConfirmPassword}
|
|
describedBy={confirmPassword ? "password-match-status" : undefined}
|
|
invalid={confirmPassword.length > 0 && confirmPassword !== password}
|
|
/>
|
|
{confirmPassword && (
|
|
<ValidationMessage
|
|
id="password-match-status"
|
|
valid={confirmPassword === password}
|
|
validText="Passwords match."
|
|
invalidText="Passwords do not match."
|
|
/>
|
|
)}
|
|
</div>
|
|
<ErrorMessage message={error} />
|
|
<PrimaryButton
|
|
loading={loading}
|
|
disabled={password.length < 8 || confirmPassword !== password}
|
|
>
|
|
Set New Password
|
|
</PrimaryButton>
|
|
<SecondaryButton onClick={returnToLogin}>Cancel</SecondaryButton>
|
|
</form>
|
|
)}
|
|
|
|
{stage === "success" && (
|
|
<div className="space-y-4 text-center">
|
|
<div className="rounded-xl bg-success-bg px-4 py-3 text-sm text-success" role="status">
|
|
Your password has been reset successfully.
|
|
</div>
|
|
<PrimaryButton loading={false} onClick={returnToLogin}>Sign In</PrimaryButton>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function PasswordField({
|
|
id,
|
|
label,
|
|
value,
|
|
onChange,
|
|
placeholder,
|
|
describedBy,
|
|
invalid = false,
|
|
autoFocus = false,
|
|
}: {
|
|
id: string;
|
|
label: string;
|
|
value: string;
|
|
onChange: (value: string) => void;
|
|
placeholder?: string;
|
|
describedBy?: string;
|
|
invalid?: boolean;
|
|
autoFocus?: boolean;
|
|
}) {
|
|
return (
|
|
<div>
|
|
<label htmlFor={id} className="mb-1.5 block text-sm font-medium text-text-secondary">{label}</label>
|
|
<input
|
|
id={id}
|
|
type="password"
|
|
value={value}
|
|
onChange={(event) => onChange(event.target.value)}
|
|
placeholder={placeholder}
|
|
autoComplete={id === "password" ? "current-password" : "new-password"}
|
|
aria-describedby={describedBy}
|
|
aria-invalid={invalid}
|
|
autoFocus={autoFocus}
|
|
className="min-h-12 w-full rounded-xl border border-border bg-bg-surface-alt/60 px-4 text-text-heading placeholder:text-text-muted focus:border-primary"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ValidationMessage({ id, valid, validText, invalidText }: {
|
|
id: string;
|
|
valid: boolean;
|
|
validText: string;
|
|
invalidText: string;
|
|
}) {
|
|
return (
|
|
<p
|
|
id={id}
|
|
aria-live="polite"
|
|
className={`mt-1.5 text-sm ${valid ? "text-success" : "text-error"}`}
|
|
>
|
|
{valid ? validText : invalidText}
|
|
</p>
|
|
);
|
|
}
|
|
|
|
function ErrorMessage({ message }: { message: string }) {
|
|
if (!message) return null;
|
|
return <div role="alert" className="rounded-lg bg-error-bg px-3 py-2.5 text-sm text-error">{message}</div>;
|
|
}
|
|
|
|
function PrimaryButton({ loading, disabled = false, onClick, children }: {
|
|
loading: boolean;
|
|
disabled?: boolean;
|
|
onClick?: () => void;
|
|
children: React.ReactNode;
|
|
}) {
|
|
return (
|
|
<button
|
|
type={onClick ? "button" : "submit"}
|
|
onClick={onClick}
|
|
disabled={loading || disabled}
|
|
className="min-h-12 w-full rounded-xl bg-primary px-4 font-bold text-white transition-colors hover:bg-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary disabled:cursor-not-allowed disabled:opacity-50"
|
|
>
|
|
{loading ? "Working..." : children}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function SecondaryButton({ onClick, children }: { onClick: () => void; children: React.ReactNode }) {
|
|
return (
|
|
<button type="button" onClick={onClick} className="min-h-11 w-full rounded-xl border border-border px-4 font-semibold text-text-secondary hover:bg-bg-surface-alt focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary">
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|