feat(auth): unify and polish login, register and homepage auth UI
CI / check (push) Successful in 35s
CI / tests-imaging-browsers (push) Successful in 1m34s
CI / tests-integration (push) Successful in 2m1s
CI / tests-unit (push) Successful in 2m11s
CI / tests-quality (push) Successful in 52s
CI / tests-ui (push) Successful in 5m7s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m11s
CI / check (push) Successful in 35s
CI / tests-imaging-browsers (push) Successful in 1m34s
CI / tests-integration (push) Successful in 2m1s
CI / tests-unit (push) Successful in 2m11s
CI / tests-quality (push) Successful in 52s
CI / tests-ui (push) Successful in 5m7s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 4m11s
Login and register each carried their own copy of the promo panel and the error banner, so the two pages drifted apart and every caller had to wire role="alert" by hand. Extract a shared AuthPromoCard and AuthAlert, use them in both forms and for the login notices, and link errors to their fields via aria-invalid / aria-describedby. Focus now lands on the 2FA code field the moment it appears, the credential fields lock while a request is in flight, and invalid inputs get a danger tint. The homepage top bar was a byte-for-byte duplicate of AuthTopBar; it now renders the shared component, and the compact homepage login uses the tighter spacing its comment already promised.
This commit is contained in:
1 parent
2249303dbd
commit
c6e5e0b1a9
8 files changed
+215
-296
No files matched your search
@@ -3,8 +3,9 @@
|
||||
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 { type FormEvent, useEffect, useId, useRef, useState } from "react";
|
||||
import { precheckLogin } from "@/actions/auth-precheck";
|
||||
import { AuthAlert } from "@/components/auth/auth-alert";
|
||||
import {
|
||||
type CaptchaPublicConfig,
|
||||
CaptchaWidget,
|
||||
@@ -51,8 +52,17 @@ export function LoginForm({
|
||||
const [unverified, setUnverified] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [locked, setLocked] = useState(false);
|
||||
const codeRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// The 2FA field only exists after the pre-check asks for a code; move focus
|
||||
// there automatically so the visitor can keep typing without reaching for
|
||||
// the mouse (the lint rule rules out a bare `autoFocus`).
|
||||
useEffect(() => {
|
||||
if (needs2fa) codeRef.current?.focus();
|
||||
}, [needs2fa]);
|
||||
|
||||
const showFooter = variant === "page";
|
||||
const compact = variant === "compact";
|
||||
const lockCredentials =
|
||||
needs2fa || locked ? "opacity-50 pointer-events-none" : "";
|
||||
|
||||
@@ -115,7 +125,9 @@ export function LoginForm({
|
||||
<>
|
||||
{needs2fa && (
|
||||
<p
|
||||
className="animate-fade-in-up mb-4 rounded-xl border px-3 py-2.5 text-sm font-semibold"
|
||||
className={`animate-fade-in-up rounded-xl border px-3 py-2.5 text-sm font-semibold ${
|
||||
compact ? "mb-3" : "mb-4"
|
||||
}`}
|
||||
style={{
|
||||
color: "var(--color-text-readable)",
|
||||
background:
|
||||
@@ -131,7 +143,7 @@ export function LoginForm({
|
||||
<form
|
||||
onSubmit={onSubmit}
|
||||
aria-label={t("signIn")}
|
||||
className="relative flex flex-col gap-4"
|
||||
className={`relative flex flex-col ${compact ? "gap-3" : "gap-4"}`}
|
||||
>
|
||||
<div className={`space-y-1.5 ${lockCredentials}`}>
|
||||
<label htmlFor={`${fieldId}-username`} className="auth-label">
|
||||
@@ -151,8 +163,10 @@ export function LoginForm({
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
placeholder={t("usernamePlaceholder")}
|
||||
autoComplete="username"
|
||||
disabled={needs2fa}
|
||||
disabled={needs2fa || pending}
|
||||
required
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${fieldId}-error` : undefined}
|
||||
className="auth-input has-icon"
|
||||
/>
|
||||
</div>
|
||||
@@ -176,8 +190,10 @@ export function LoginForm({
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder={t("passwordPlaceholder")}
|
||||
autoComplete="current-password"
|
||||
disabled={needs2fa}
|
||||
disabled={needs2fa || pending}
|
||||
required
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${fieldId}-error` : undefined}
|
||||
className="auth-input has-icon has-action"
|
||||
/>
|
||||
<PasswordToggle
|
||||
@@ -201,6 +217,7 @@ export function LoginForm({
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
/>
|
||||
<input
|
||||
ref={codeRef}
|
||||
id={`${fieldId}-2fa`}
|
||||
name="code"
|
||||
type="text"
|
||||
@@ -209,6 +226,9 @@ export function LoginForm({
|
||||
placeholder={t("codePlaceholder")}
|
||||
inputMode="numeric"
|
||||
autoComplete="one-time-code"
|
||||
disabled={pending}
|
||||
aria-invalid={error ? true : undefined}
|
||||
aria-describedby={error ? `${fieldId}-error` : undefined}
|
||||
className="auth-input has-icon"
|
||||
style={{
|
||||
borderColor:
|
||||
@@ -221,15 +241,7 @@ export function LoginForm({
|
||||
<CaptchaWidget captcha={captcha} nonce={nonce} />
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
className="auth-alert animate-fade-in-up m-0"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{error && <AuthAlert id={`${fieldId}-error`}>{error}</AuthAlert>}
|
||||
|
||||
{unverified && (
|
||||
<p
|
||||
@@ -249,7 +261,9 @@ export function LoginForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending || locked}
|
||||
className="btn-brand btn-shine w-full cursor-pointer py-3.5 text-sm font-extrabold uppercase tracking-wider disabled:opacity-60"
|
||||
className={`btn-brand btn-shine w-full cursor-pointer text-sm font-extrabold uppercase tracking-wider disabled:opacity-60 ${
|
||||
compact ? "py-3" : "py-3.5"
|
||||
}`}
|
||||
>
|
||||
{pending ? (
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
|
||||
Reference in new issue
Block a user