feat(auth): implement all 16 homepage/login/register review items
- add countArticles() (published-only, mirrors news-list) and warm total_articles - localize homepage metadata; bind articleCount to both stats; unique photo alts - drop duplicate news date and the mascot preload priorities - extract shared AuthPageFrame/AuthUsersCards used by /login and /register - login: localized noindex metadata, session redirect via safeRedirectPath, ?from passthrough from proxy, unified auth roster cache keys, registered notice - register: localized metadata, session redirect to /me, unified cache keys - add resend-verification flow on /verify with rate-limited non-enumerable action - add safeRedirectPath() with unit tests - register form: live requirements checklist + password mismatch guard - login form: unverified state with resend-link CTA - honour prefers-reduced-motion in TypewriterText - add 6 translations across all 25 locales
This commit is contained in:
1 parent
8561c3f85e
commit
3933214953
43 files changed
+1037
-490
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -48,6 +48,7 @@ export function LoginForm({
|
||||
const [code, setCode] = useState("");
|
||||
const [needs2fa, setNeeds2fa] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [unverified, setUnverified] = useState(false);
|
||||
const [pending, setPending] = useState(false);
|
||||
|
||||
const showFooter = variant === "page";
|
||||
@@ -56,6 +57,7 @@ export function LoginForm({
|
||||
async function onSubmit(e: FormEvent<HTMLFormElement>) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setUnverified(false);
|
||||
setPending(true);
|
||||
try {
|
||||
if (!needs2fa) {
|
||||
@@ -71,6 +73,7 @@ export function LoginForm({
|
||||
}
|
||||
if (pre === "unverified") {
|
||||
setError(t("errorUnverified"));
|
||||
setUnverified(true);
|
||||
return;
|
||||
}
|
||||
if (pre === "twofactor") {
|
||||
@@ -220,6 +223,21 @@ export function LoginForm({
|
||||
</p>
|
||||
)}
|
||||
|
||||
{unverified && (
|
||||
<p
|
||||
className="m-0 text-center text-xs"
|
||||
style={{ color: "var(--color-text-muted)" }}
|
||||
>
|
||||
<Link
|
||||
href="/verify"
|
||||
className="font-extrabold transition-colors hover:underline"
|
||||
style={{ color: "var(--color-primary)" }}
|
||||
>
|
||||
{t("verifyEmailCta")}
|
||||
</Link>
|
||||
</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import { Mail } from "lucide-react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { useActionState, useId } from "react";
|
||||
import {
|
||||
type ResendVerificationState,
|
||||
resendVerification,
|
||||
} from "@/actions/verify";
|
||||
|
||||
const INITIAL_STATE: ResendVerificationState = { ok: false, error: null };
|
||||
|
||||
/**
|
||||
* Lets a visitor whose verification token expired or was mangled request a
|
||||
* fresh mail straight from the invalid card of /verify. The server answers
|
||||
* identically for unknown addresses, so no registered address can be probed;
|
||||
* the live feedback is limited to "we tried" vs "throttled".
|
||||
*/
|
||||
export function ResendVerificationForm() {
|
||||
const t = useTranslations("pages.verify");
|
||||
const fieldId = useId();
|
||||
const [state, formAction, isPending] = useActionState(
|
||||
resendVerification,
|
||||
INITIAL_STATE,
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="mt-1 w-full space-y-2 text-left">
|
||||
{state.ok ? (
|
||||
<p
|
||||
role="status"
|
||||
className="auth-alert auth-alert--success m-0 animate-fade-in-up"
|
||||
>
|
||||
{t("resendSent")}
|
||||
</p>
|
||||
) : state.error ? (
|
||||
<p role="alert" className="auth-alert m-0 animate-fade-in-up">
|
||||
{t("resendFailed")}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<label htmlFor={fieldId} className="auth-label">
|
||||
{t("resendEmail")}
|
||||
</label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
<div className="relative flex-1">
|
||||
<Mail
|
||||
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}
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
placeholder={t("resendEmail")}
|
||||
className="auth-input has-icon"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending}
|
||||
className="btn-brand btn-shine mb-0.5 shrink-0 px-5 py-3 text-sm font-extrabold uppercase tracking-wider disabled:opacity-60"
|
||||
>
|
||||
{isPending ? (
|
||||
<span className="inline-flex items-center justify-center gap-2">
|
||||
<svg
|
||||
className="animate-spin h-4 w-4"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
role="img"
|
||||
aria-label="Loading"
|
||||
>
|
||||
<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("resendButton")}
|
||||
</span>
|
||||
) : (
|
||||
t("resendButton")
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user