- add theme-aware helpers: btn-brand, btn-glass-dark, auth-input, auth-label, auth-alert, explore-pill, avatar-tile, aurora blobs and a hero scroll cue - reorder backdrop-filter declarations so the glass blur survives the production CSS optimizer in modern Chromium - hero: aurora glow, frosted recent-users chip, premium CTA buttons and cue - explore nav: icon pills with hover arrows; features: gradient icon tiles - stats: single glass panel with column dividers; join CTA: aurora + ring - auth forms: visible labels, icon inputs, eye/password toggle, gradient submit and pill footer links - auth pages: gradient card frame, aurora accents on the intro panel and hover-lift avatar tiles; unified pill-shaped top bar - drop the hard-coded register banner image in favor of the framed card
301 lines
8.2 KiB
TypeScript
301 lines
8.2 KiB
TypeScript
"use client";
|
|
|
|
import { KeyRound, Lock, UserRound } from "lucide-react";
|
|
import { signIn } from "next-auth/react";
|
|
import { useTranslations } from "next-intl";
|
|
import { type FormEvent, useId, useState } from "react";
|
|
import { precheckLogin } from "@/actions/auth-precheck";
|
|
import {
|
|
type CaptchaPublicConfig,
|
|
CaptchaWidget,
|
|
readCaptchaToken,
|
|
} from "@/components/auth/captcha-widget";
|
|
import { PasswordToggle } from "@/components/auth/password-toggle";
|
|
import Link from "@/components/link";
|
|
import { signInWithTransientRetry } from "@/lib/auth/sign-in-retry";
|
|
|
|
export type LoginFormVariant =
|
|
/** Full page: labelled fields, plus the register / forgot-password footer. */
|
|
| "page"
|
|
/** Homepage sidebar: visible labels, no footer, tighter spacing. */
|
|
| "compact";
|
|
|
|
export interface LoginFormProps {
|
|
captcha?: CaptchaPublicConfig;
|
|
nonce?: string;
|
|
variant?: LoginFormVariant;
|
|
/** Where to land after a successful sign-in. */
|
|
redirectTo?: string;
|
|
}
|
|
|
|
/**
|
|
* The single sign-in form for both `/login` and the homepage sidebar. It runs a
|
|
* cheap server-side precheck first so the visitor gets a specific reason
|
|
* (bad password, unverified email, 2FA required, captcha) instead of NextAuth's
|
|
* generic failure, then completes the credentials sign-in.
|
|
*/
|
|
export function LoginForm({
|
|
captcha = { provider: "none" },
|
|
nonce,
|
|
variant = "page",
|
|
redirectTo = "/me",
|
|
}: LoginFormProps = {}) {
|
|
const t = useTranslations("pages.login");
|
|
const fieldId = useId();
|
|
const [username, setUsername] = useState("");
|
|
const [password, setPassword] = useState("");
|
|
const [showPassword, setShowPassword] = useState(false);
|
|
const [code, setCode] = useState("");
|
|
const [needs2fa, setNeeds2fa] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [pending, setPending] = useState(false);
|
|
|
|
const showFooter = variant === "page";
|
|
const lockCredentials = needs2fa ? "opacity-50 pointer-events-none" : "";
|
|
|
|
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
|
e.preventDefault();
|
|
setError(null);
|
|
setPending(true);
|
|
try {
|
|
if (!needs2fa) {
|
|
const captchaToken = readCaptchaToken(e.currentTarget, captcha);
|
|
const pre = await precheckLogin(username, password, captchaToken);
|
|
if (pre === "invalid") {
|
|
setError(t("errorInvalidCredentials"));
|
|
return;
|
|
}
|
|
if (pre === "captcha") {
|
|
setError(t("errorCaptcha"));
|
|
return;
|
|
}
|
|
if (pre === "unverified") {
|
|
setError(t("errorUnverified"));
|
|
return;
|
|
}
|
|
if (pre === "twofactor") {
|
|
setNeeds2fa(true);
|
|
return;
|
|
}
|
|
}
|
|
const res = await signInWithTransientRetry(() =>
|
|
signIn("credentials", {
|
|
username,
|
|
password,
|
|
code,
|
|
redirect: false,
|
|
}),
|
|
);
|
|
if (!res || res.error) {
|
|
setError(
|
|
needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"),
|
|
);
|
|
return;
|
|
}
|
|
window.location.href = redirectTo;
|
|
} catch {
|
|
setError(needs2fa ? t("errorInvalid2fa") : t("errorInvalidCredentials"));
|
|
} finally {
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{needs2fa && (
|
|
<p
|
|
className="animate-fade-in-up mb-4 rounded-xl border px-3 py-2.5 text-sm font-semibold"
|
|
style={{
|
|
color: "var(--color-text-readable)",
|
|
background:
|
|
"color-mix(in srgb, var(--color-primary) 10%, transparent)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 26%, transparent)",
|
|
}}
|
|
>
|
|
{t("subtitle2fa")}
|
|
</p>
|
|
)}
|
|
|
|
<form
|
|
onSubmit={onSubmit}
|
|
aria-label={t("signIn")}
|
|
className="relative flex flex-col gap-4"
|
|
>
|
|
<div className={`space-y-1.5 ${lockCredentials}`}>
|
|
<label htmlFor={`${fieldId}-username`} className="auth-label">
|
|
{t("username")}
|
|
</label>
|
|
<div className="relative">
|
|
<UserRound
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
/>
|
|
<input
|
|
id={`${fieldId}-username`}
|
|
name="username"
|
|
type="text"
|
|
value={username}
|
|
onChange={(e) => setUsername(e.target.value)}
|
|
placeholder={t("usernamePlaceholder")}
|
|
autoComplete="username"
|
|
disabled={needs2fa}
|
|
required
|
|
className="auth-input has-icon"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={`space-y-1.5 ${lockCredentials}`}>
|
|
<label htmlFor={`${fieldId}-password`} className="auth-label">
|
|
{t("password")}
|
|
</label>
|
|
<div className="relative">
|
|
<Lock
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
/>
|
|
<input
|
|
id={`${fieldId}-password`}
|
|
name="password"
|
|
type={showPassword ? "text" : "password"}
|
|
value={password}
|
|
onChange={(e) => setPassword(e.target.value)}
|
|
placeholder={t("passwordPlaceholder")}
|
|
autoComplete="current-password"
|
|
disabled={needs2fa}
|
|
required
|
|
className="auth-input has-icon has-action"
|
|
/>
|
|
<PasswordToggle
|
|
show={showPassword}
|
|
onToggle={() => setShowPassword((v) => !v)}
|
|
labelShow={t("showPassword")}
|
|
labelHide={t("hidePassword")}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{needs2fa ? (
|
|
<div className="animate-scale-in space-y-1.5">
|
|
<label htmlFor={`${fieldId}-2fa`} className="auth-label">
|
|
{t("codePlaceholder")}
|
|
</label>
|
|
<div className="relative">
|
|
<KeyRound
|
|
aria-hidden="true"
|
|
className="pointer-events-none absolute left-3.5 top-1/2 h-4 w-4 -translate-y-1/2"
|
|
style={{ color: "var(--color-primary)" }}
|
|
/>
|
|
<input
|
|
id={`${fieldId}-2fa`}
|
|
name="code"
|
|
type="text"
|
|
value={code}
|
|
onChange={(e) => setCode(e.target.value)}
|
|
placeholder={t("codePlaceholder")}
|
|
inputMode="numeric"
|
|
autoComplete="one-time-code"
|
|
className="auth-input has-icon"
|
|
style={{
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 45%, transparent)",
|
|
}}
|
|
/>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
|
)}
|
|
|
|
{error && (
|
|
<p
|
|
role="alert"
|
|
aria-live="polite"
|
|
className="auth-alert animate-fade-in-up m-0"
|
|
>
|
|
{error}
|
|
</p>
|
|
)}
|
|
|
|
<button
|
|
type="submit"
|
|
disabled={pending}
|
|
className="btn-brand btn-shine w-full cursor-pointer py-3.5 text-sm font-extrabold uppercase tracking-wider disabled:opacity-60"
|
|
>
|
|
{pending ? (
|
|
<span className="inline-flex items-center justify-center gap-2">
|
|
<svg
|
|
className="h-4 w-4 animate-spin"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
role="img"
|
|
aria-label={t("pleaseWait")}
|
|
>
|
|
<circle
|
|
className="opacity-25"
|
|
cx="12"
|
|
cy="12"
|
|
r="10"
|
|
stroke="currentColor"
|
|
strokeWidth="4"
|
|
/>
|
|
<path
|
|
className="opacity-75"
|
|
fill="currentColor"
|
|
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
|
/>
|
|
</svg>
|
|
{t("pleaseWait")}
|
|
</span>
|
|
) : needs2fa ? (
|
|
t("verify")
|
|
) : (
|
|
t("signIn")
|
|
)}
|
|
</button>
|
|
</form>
|
|
|
|
{showFooter && (
|
|
<div
|
|
className="mt-6 space-y-3 border-t pt-5"
|
|
style={{
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-text-muted) 14%, transparent)",
|
|
}}
|
|
>
|
|
<p
|
|
className="text-center text-xs"
|
|
style={{ color: "var(--color-text-muted)" }}
|
|
>
|
|
{t("noAccount")}{" "}
|
|
<Link
|
|
href="/register"
|
|
className="font-extrabold transition-colors hover:underline"
|
|
style={{ color: "var(--color-primary)" }}
|
|
>
|
|
{t("createOne")}
|
|
</Link>
|
|
</p>
|
|
<Link
|
|
href="/forgot"
|
|
className="flex items-center justify-center gap-2 rounded-full border px-4 py-2.5 text-xs font-extrabold transition-all duration-200 hover:-translate-y-0.5"
|
|
style={{
|
|
color: "var(--color-primary)",
|
|
background:
|
|
"color-mix(in srgb, var(--color-primary) 8%, transparent)",
|
|
borderColor:
|
|
"color-mix(in srgb, var(--color-primary) 35%, transparent)",
|
|
}}
|
|
>
|
|
<KeyRound aria-hidden="true" className="h-3.5 w-3.5" />
|
|
{t("forgotPassword")}
|
|
</Link>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
}
|