feat(auth): implement all 16 homepage/login/register review items
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m47s
CI / tests-ui (push) Successful in 2m30s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m56s
Gitea Actions Runner Test / test-job (push) Successful in 1s
CI / check (push) Successful in 33s
CI / tests-integration (push) Successful in 1m42s
CI / tests-unit (push) Successful in 1m47s
CI / tests-ui (push) Successful in 2m30s
CI / preflight (push) Skipped
CI / deploy (push) Failing after 2m56s
- 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
@@ -1,6 +1,14 @@
|
||||
"use client";
|
||||
|
||||
import { KeyRound, Lock, Mail, ShieldCheck, UserRound } from "lucide-react";
|
||||
import {
|
||||
Check,
|
||||
KeyRound,
|
||||
Lock,
|
||||
Mail,
|
||||
ShieldCheck,
|
||||
UserRound,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import Script from "next/script";
|
||||
import { signIn } from "next-auth/react";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -73,6 +81,7 @@ export function RegisterForm({
|
||||
const [termsError, setTermsError] = useState(false);
|
||||
const termsRef = useRef<HTMLDivElement>(null);
|
||||
const [password, setPassword] = useState("");
|
||||
const [confirmPassword, setConfirmPassword] = useState("");
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [showConfirm, setShowConfirm] = useState(false);
|
||||
const [signingIn, setSigningIn] = useState(false);
|
||||
@@ -83,6 +92,21 @@ export function RegisterForm({
|
||||
const [referralCode, setReferralCode] = useState("");
|
||||
const strength = passwordStrength(password, t);
|
||||
|
||||
/**
|
||||
* The password policy the server enforces, evaluated live so the visitor can
|
||||
* see which rule they are still missing instead of submitting and reading a
|
||||
* single error back.
|
||||
*/
|
||||
const requirements = [
|
||||
{ label: t("passwordMinLength"), met: password.length >= 12 },
|
||||
{ label: t("passwordUpper"), met: /[A-Z]/.test(password) },
|
||||
{ label: t("passwordLower"), met: /[a-z]/.test(password) },
|
||||
{ label: t("passwordDigit"), met: /\d/.test(password) },
|
||||
{ label: t("passwordSpecial"), met: /[^A-Za-z0-9]/.test(password) },
|
||||
];
|
||||
/** Shown as soon as the confirmation is filled in and disagrees. */
|
||||
const mismatch = confirmPassword.length > 0 && confirmPassword !== password;
|
||||
|
||||
// The invite link is `/register?ref=<username>`; read it client-side so the
|
||||
// attribution travels with the sign-up form without server state.
|
||||
useEffect(() => {
|
||||
@@ -146,9 +170,14 @@ export function RegisterForm({
|
||||
{/* Form */}
|
||||
<form
|
||||
action={(fd) => {
|
||||
const passwordValue = String(fd.get("password") ?? "");
|
||||
const confirmation = String(fd.get("password_confirmation") ?? "");
|
||||
// A mismatch is reported live under the field — no round-trip
|
||||
// needed just to hear it again from the server.
|
||||
if (passwordValue !== confirmation) return;
|
||||
credentialsRef.current = {
|
||||
username: String(fd.get("username") ?? "").trim(),
|
||||
password: String(fd.get("password") ?? ""),
|
||||
password: passwordValue,
|
||||
};
|
||||
formAction(fd);
|
||||
}}
|
||||
@@ -368,6 +397,26 @@ export function RegisterForm({
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<ul aria-label={t("password")} className="space-y-1 pt-1">
|
||||
{requirements.map((r) => (
|
||||
<li
|
||||
key={r.label}
|
||||
className="flex items-center gap-1.5 text-[11px] font-semibold transition-colors duration-200"
|
||||
style={{
|
||||
color: r.met
|
||||
? "var(--color-success)"
|
||||
: "var(--color-text-muted)",
|
||||
}}
|
||||
>
|
||||
{r.met ? (
|
||||
<Check className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
) : (
|
||||
<X className="h-3 w-3 shrink-0" aria-hidden="true" />
|
||||
)}
|
||||
{r.label}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="password_confirmation" className="auth-label">
|
||||
@@ -385,6 +434,8 @@ export function RegisterForm({
|
||||
type={showConfirm ? "text" : "password"}
|
||||
placeholder={t("confirmPasswordPlaceholder")}
|
||||
autoComplete="new-password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
className="auth-input has-icon has-action"
|
||||
required
|
||||
/>
|
||||
@@ -395,6 +446,14 @@ export function RegisterForm({
|
||||
labelHide={t("hidePassword")}
|
||||
/>
|
||||
</div>
|
||||
{mismatch && (
|
||||
<p
|
||||
className="animate-fade-in-up text-xs font-bold"
|
||||
style={{ color: "var(--color-danger)" }}
|
||||
>
|
||||
{t("passwordsDontMatch")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -521,8 +580,11 @@ export function RegisterForm({
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isPending || signingIn}
|
||||
onClick={() => {
|
||||
onClick={(e) => {
|
||||
if (!termsAccepted) {
|
||||
// Don't fire a doomed server round-trip: shake the
|
||||
// terms box and stay put until it is checked.
|
||||
e.preventDefault();
|
||||
setTermsError(true);
|
||||
termsRef.current?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
|
||||
Reference in new issue
Block a user