refactor: reorder mobile navigation tabs and remove the homepage link.

This commit is contained in:
Haitao Pan 2026-01-30 12:33:55 +08:00
parent 6b61379b91
commit 4f3f54a579
2 changed files with 522 additions and 413 deletions

View File

@ -1,131 +1,157 @@
'use client'
"use client";
import { FormEvent, ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Link from 'next/link'
import { useRouter, useSearchParams } from 'next/navigation'
import { Github } from 'lucide-react'
import {
FormEvent,
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { Github } from "lucide-react";
import { AuthLayout, AuthLayoutSocialButton } from '@components/auth/AuthLayout'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import {
AuthLayout,
AuthLayoutSocialButton,
} from "@components/auth/AuthLayout";
import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
type LoginContentProps = {
accountServiceBaseUrl: string
children?: ReactNode
}
accountServiceBaseUrl: string;
children?: ReactNode;
};
export default function LoginContent({ accountServiceBaseUrl, children }: LoginContentProps) {
const { language } = useLanguage()
const t = translations[language].auth.login
const alerts = t.alerts
const searchParams = useSearchParams()
const router = useRouter()
export default function LoginContent({
accountServiceBaseUrl,
children,
}: LoginContentProps) {
const { language } = useLanguage();
const t = translations[language].auth.login;
const alerts = t.alerts;
const searchParams = useSearchParams();
const router = useRouter();
useEffect(() => {
const sensitiveKeys = ['username', 'password', 'email']
const hasSensitiveParams = sensitiveKeys.some((key) => searchParams.has(key))
const sensitiveKeys = ["username", "password", "email"];
const hasSensitiveParams = sensitiveKeys.some((key) =>
searchParams.has(key),
);
if (!hasSensitiveParams) {
return
return;
}
const sanitized = new URLSearchParams(searchParams.toString())
sensitiveKeys.forEach((key) => sanitized.delete(key))
const sanitized = new URLSearchParams(searchParams.toString());
sensitiveKeys.forEach((key) => sanitized.delete(key));
const queryString = sanitized.toString()
router.replace(queryString ? `/login?${queryString}` : '/login', { scroll: false })
}, [router, searchParams])
const queryString = sanitized.toString();
router.replace(queryString ? `/login?${queryString}` : "/login", {
scroll: false,
});
}, [router, searchParams]);
const errorParam = searchParams.get('error')
const registeredParam = searchParams.get('registered')
const setupMfaParam = searchParams.get('setupMfa')
const errorParam = searchParams.get("error");
const registeredParam = searchParams.get("registered");
const setupMfaParam = searchParams.get("setupMfa");
const normalize = useCallback(
(value: string) =>
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, ''),
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, ""),
[],
)
);
const loginUrl = process.env.NEXT_PUBLIC_LOGIN_URL || `${accountServiceBaseUrl}/api/auth/login`
const loginUrl =
process.env.NEXT_PUBLIC_LOGIN_URL ||
`${accountServiceBaseUrl}/api/auth/login`;
const socialButtonsDisabled = false
const githubAuthUrl = `${process.env.NEXT_PUBLIC_ACCOUNTS_SVC_URL}/api/auth/oauth/login/github`
const googleAuthUrl = `${process.env.NEXT_PUBLIC_ACCOUNTS_SVC_URL}/api/auth/oauth/login/google`
const socialButtonsDisabled = true;
const githubAuthUrl = `${process.env.NEXT_PUBLIC_ACCOUNTS_SVC_URL}/api/auth/oauth/login/github`;
const googleAuthUrl = `${process.env.NEXT_PUBLIC_ACCOUNTS_SVC_URL}/api/auth/oauth/login/google`;
const loginUrlRef = useRef(loginUrl)
const loginUrlRef = useRef(loginUrl);
const deriveSameOriginLoginFallback = useCallback((url: string): string | undefined => {
if (typeof window === 'undefined') {
return undefined
}
try {
const currentOrigin = window.location.origin
const parsed = new URL(url, currentOrigin)
if (parsed.origin === currentOrigin) {
const relative = `${parsed.pathname}${parsed.search}${parsed.hash}` || '/api/auth/login'
return relative
const deriveSameOriginLoginFallback = useCallback(
(url: string): string | undefined => {
if (typeof window === "undefined") {
return undefined;
}
const localHostnames = new Set(['localhost', '127.0.0.1', '[::1]'])
const parsedHostname = parsed.hostname.toLowerCase()
const browserHostname = window.location.hostname.toLowerCase()
try {
const currentOrigin = window.location.origin;
const parsed = new URL(url, currentOrigin);
const parsedIsLocal = localHostnames.has(parsedHostname)
const browserIsLocal = localHostnames.has(browserHostname)
if (parsed.origin === currentOrigin) {
const relative =
`${parsed.pathname}${parsed.search}${parsed.hash}` ||
"/api/auth/login";
return relative;
}
if (!browserIsLocal && parsedIsLocal) {
const relative = `${parsed.pathname}${parsed.search}${parsed.hash}` || '/api/auth/login'
return relative
const localHostnames = new Set(["localhost", "127.0.0.1", "[::1]"]);
const parsedHostname = parsed.hostname.toLowerCase();
const browserHostname = window.location.hostname.toLowerCase();
const parsedIsLocal = localHostnames.has(parsedHostname);
const browserIsLocal = localHostnames.has(browserHostname);
if (!browserIsLocal && parsedIsLocal) {
const relative =
`${parsed.pathname}${parsed.search}${parsed.hash}` ||
"/api/auth/login";
return relative;
}
if (
window.location.protocol === "https:" &&
parsed.protocol === "http:" &&
parsedHostname === browserHostname
) {
parsed.protocol = "https:";
return parsed.toString();
}
} catch (error) {
console.warn("Failed to derive same-origin login fallback", error);
}
if (
window.location.protocol === 'https:' &&
parsed.protocol === 'http:' &&
parsedHostname === browserHostname
) {
parsed.protocol = 'https:'
return parsed.toString()
}
} catch (error) {
console.warn('Failed to derive same-origin login fallback', error)
}
return undefined
}, [])
return undefined;
},
[],
);
useEffect(() => {
loginUrlRef.current = loginUrl
}, [loginUrl])
loginUrlRef.current = loginUrl;
}, [loginUrl]);
const initialAlert = useMemo(() => {
const successMessages: string[] = []
if (registeredParam === '1') {
successMessages.push(alerts.registered)
const successMessages: string[] = [];
if (registeredParam === "1") {
successMessages.push(alerts.registered);
}
if (setupMfaParam === '1') {
const setupRequiredMessage = alerts.mfa?.setupRequired ?? alerts.genericError
if (setupMfaParam === "1") {
const setupRequiredMessage =
alerts.mfa?.setupRequired ?? alerts.genericError;
if (setupRequiredMessage) {
successMessages.push(setupRequiredMessage)
successMessages.push(setupRequiredMessage);
}
}
if (successMessages.length > 0) {
return { type: 'success', message: successMessages.join(' ') } as const
return { type: "success", message: successMessages.join(" ") } as const;
}
if (!errorParam) {
return null
return null;
}
const normalizedError = normalize(errorParam)
const normalizedError = normalize(errorParam);
const errorMap: Record<string, string> = {
missing_credentials: alerts.missingCredentials,
email_and_password_are_required: alerts.missingCredentials,
@ -133,97 +159,102 @@ export default function LoginContent({ accountServiceBaseUrl, children }: LoginC
user_not_found: alerts.userNotFound ?? alerts.genericError,
credentials_in_query: alerts.genericError,
invalid_request: alerts.genericError,
};
const message = errorMap[normalizedError] ?? alerts.genericError;
return { type: "error", message } as const;
}, [alerts, errorParam, normalize, registeredParam, setupMfaParam]);
}
const message = errorMap[normalizedError] ?? alerts.genericError
return { type: 'error', message } as const
}, [alerts, errorParam, normalize, registeredParam, setupMfaParam])
const [alert, setAlert] = useState(initialAlert)
const [isSubmitting, setIsSubmitting] = useState(false)
const [alert, setAlert] = useState(initialAlert);
const [isSubmitting, setIsSubmitting] = useState(false);
useEffect(() => {
setAlert(initialAlert)
}, [initialAlert])
setAlert(initialAlert);
}, [initialAlert]);
const handleSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
event.preventDefault();
if (isSubmitting) {
return
return;
}
const formData = new FormData(event.currentTarget)
const username = String(formData.get('username') ?? '').trim()
const password = String(formData.get('password') ?? '')
const remember = formData.get('remember') === 'on'
const formData = new FormData(event.currentTarget);
const username = String(formData.get("username") ?? "").trim();
const password = String(formData.get("password") ?? "");
const remember = formData.get("remember") === "on";
if (!username || !password) {
setAlert({ type: 'error', message: alerts.missingCredentials })
return
setAlert({ type: "error", message: alerts.missingCredentials });
return;
}
setIsSubmitting(true)
setAlert(null)
setIsSubmitting(true);
setAlert(null);
try {
const requestPayload = {
method: 'POST',
method: "POST",
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify({
username,
password,
remember,
}),
} as const
} as const;
let response: Response
let usedUrl = loginUrlRef.current
let response: Response;
let usedUrl = loginUrlRef.current;
try {
response = await fetch(usedUrl, requestPayload)
response = await fetch(usedUrl, requestPayload);
} catch (primaryError) {
const sameOriginFallback = deriveSameOriginLoginFallback(usedUrl)
const sameOriginFallback = deriveSameOriginLoginFallback(usedUrl);
if (sameOriginFallback && sameOriginFallback !== usedUrl) {
try {
response = await fetch(sameOriginFallback, requestPayload)
loginUrlRef.current = sameOriginFallback
usedUrl = sameOriginFallback
response = await fetch(sameOriginFallback, requestPayload);
loginUrlRef.current = sameOriginFallback;
usedUrl = sameOriginFallback;
} catch (fallbackError) {
console.error('Primary login request failed, same-origin fallback also failed', fallbackError)
throw fallbackError
console.error(
"Primary login request failed, same-origin fallback also failed",
fallbackError,
);
throw fallbackError;
}
} else {
const httpsPattern = /^https:/i
const httpsPattern = /^https:/i;
if (httpsPattern.test(usedUrl)) {
const insecureUrl = usedUrl.replace(httpsPattern, 'http:')
const insecureUrl = usedUrl.replace(httpsPattern, "http:");
try {
response = await fetch(insecureUrl, requestPayload)
loginUrlRef.current = insecureUrl
usedUrl = insecureUrl
response = await fetch(insecureUrl, requestPayload);
loginUrlRef.current = insecureUrl;
usedUrl = insecureUrl;
} catch (fallbackError) {
console.error('Primary login request failed, insecure fallback also failed', fallbackError)
throw fallbackError
console.error(
"Primary login request failed, insecure fallback also failed",
fallbackError,
);
throw fallbackError;
}
} else {
throw primaryError
throw primaryError;
}
}
}
if (!response.ok) {
let errorCode = 'invalid_credentials'
let errorCode = "invalid_credentials";
try {
const data = await response.json()
if (typeof data?.error === 'string') {
errorCode = data.error
const data = await response.json();
if (typeof data?.error === "string") {
errorCode = data.error;
}
} catch (error) {
console.error('Failed to parse login response', error)
console.error("Failed to parse login response", error);
}
const errorMap: Record<string, string> = {
@ -232,26 +263,29 @@ export default function LoginContent({ accountServiceBaseUrl, children }: LoginC
user_not_found: alerts.userNotFound ?? alerts.genericError,
invalid_request: alerts.genericError,
credentials_in_query: alerts.genericError,
}
};
setAlert({ type: 'error', message: errorMap[normalize(errorCode)] ?? alerts.genericError })
return
setAlert({
type: "error",
message: errorMap[normalize(errorCode)] ?? alerts.genericError,
});
return;
}
const data: { redirectTo?: string } = await response
.json()
.catch(() => ({}))
router.push(data?.redirectTo || '/')
router.refresh()
.catch(() => ({}));
router.push(data?.redirectTo || "/");
router.refresh();
} catch (error) {
console.error('Failed to submit login request', error)
setAlert({ type: 'error', message: alerts.genericError })
console.error("Failed to submit login request", error);
setAlert({ type: "error", message: alerts.genericError });
} finally {
setIsSubmitting(false)
setIsSubmitting(false);
}
},
[alerts, deriveSameOriginLoginFallback, isSubmitting, normalize, router],
)
);
const socialButtons = useMemo<AuthLayoutSocialButton[]>(() => {
return [
@ -267,18 +301,26 @@ export default function LoginContent({ accountServiceBaseUrl, children }: LoginC
icon: <div className="h-5 w-5 flex items-center justify-center">G</div>, // Replace with proper icon later if available
disabled: socialButtonsDisabled,
},
]
}, [githubAuthUrl, googleAuthUrl, socialButtonsDisabled, t.social.github])
];
}, [githubAuthUrl, googleAuthUrl, socialButtonsDisabled, t.social.github]);
const formContent = useMemo(() => {
if (children) {
return children
return children;
}
return (
<form className="space-y-5" method="post" onSubmit={handleSubmit} noValidate>
<form
className="space-y-5"
method="post"
onSubmit={handleSubmit}
noValidate
>
<div className="space-y-2">
<label htmlFor="login-username" className="text-sm font-medium text-slate-600">
<label
htmlFor="login-username"
className="text-sm font-medium text-slate-600"
>
{t.form.email}
</label>
<input
@ -293,10 +335,16 @@ export default function LoginContent({ accountServiceBaseUrl, children }: LoginC
</div>
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<label htmlFor="login-password" className="font-medium text-slate-600">
<label
htmlFor="login-password"
className="font-medium text-slate-600"
>
{t.form.password}
</label>
<Link href="#" className="font-medium text-sky-600 hover:text-sky-500">
<Link
href="#"
className="font-medium text-sky-600 hover:text-sky-500"
>
{t.forgotPassword}
</Link>
</div>
@ -324,11 +372,11 @@ export default function LoginContent({ accountServiceBaseUrl, children }: LoginC
aria-busy={isSubmitting}
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70"
>
{isSubmitting ? t.form.submitting ?? t.form.submit : t.form.submit}
{isSubmitting ? (t.form.submitting ?? t.form.submit) : t.form.submit}
</button>
</form>
)
}, [children, handleSubmit, isSubmitting, t])
);
}, [children, handleSubmit, isSubmitting, t]);
return (
<AuthLayout
mode="login"
@ -338,10 +386,14 @@ export default function LoginContent({ accountServiceBaseUrl, children }: LoginC
alert={alert}
socialHeading={t.social.title}
socialButtons={socialButtons}
switchAction={{ text: t.registerPrompt.text, linkLabel: t.registerPrompt.link, href: '/register' }}
switchAction={{
text: t.registerPrompt.text,
linkLabel: t.registerPrompt.link,
href: "/register",
}}
bottomNote={t.bottomNote}
>
{formContent}
</AuthLayout>
)
);
}

View File

@ -1,7 +1,7 @@
'use client'
"use client";
import Link from 'next/link'
import { Github } from 'lucide-react'
import Link from "next/link";
import { Github } from "lucide-react";
import {
ChangeEvent,
ClipboardEvent,
@ -13,36 +13,38 @@ import {
useRef,
useState,
useId,
} from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
} from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { AuthLayout, AuthLayoutSocialButton } from '@components/auth/AuthLayout'
import { useLanguage } from '@i18n/LanguageProvider'
import { translations } from '@i18n/translations'
import {
AuthLayout,
AuthLayoutSocialButton,
} from "@components/auth/AuthLayout";
import { useLanguage } from "@i18n/LanguageProvider";
import { translations } from "@i18n/translations";
type AlertState = { type: "error" | "success" | "info"; message: string };
type AlertState = { type: 'error' | 'success' | 'info'; message: string }
const VERIFICATION_CODE_LENGTH = 6
const RESEND_COOLDOWN_SECONDS = 60
const EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/
const PASSWORD_STRENGTH_PATTERN = /^(?=.*[A-Za-z])(?=.*\d).{8,}$/
const USERNAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]{3,15}$/
const VERIFICATION_CODE_LENGTH = 6;
const RESEND_COOLDOWN_SECONDS = 60;
const EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/;
const PASSWORD_STRENGTH_PATTERN = /^(?=.*[A-Za-z])(?=.*\d).{8,}$/;
const USERNAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]{3,15}$/;
export default function RegisterContent() {
const { language } = useLanguage()
const t = translations[language].auth.register
const alerts = t.alerts
const searchParams = useSearchParams()
const router = useRouter()
const { language } = useLanguage();
const t = translations[language].auth.register;
const alerts = t.alerts;
const searchParams = useSearchParams();
const router = useRouter();
const isSocialAuthVisible = true
const githubAuthUrl = `${process.env.NEXT_PUBLIC_ACCOUNTS_SVC_URL}/api/auth/oauth/login/github`
const googleAuthUrl = `${process.env.NEXT_PUBLIC_ACCOUNTS_SVC_URL}/api/auth/oauth/login/google`
const isSocialAuthVisible = false;
const githubAuthUrl = `${process.env.NEXT_PUBLIC_ACCOUNTS_SVC_URL}/api/auth/oauth/login/github`;
const googleAuthUrl = `${process.env.NEXT_PUBLIC_ACCOUNTS_SVC_URL}/api/auth/oauth/login/google`;
const socialButtons = useMemo<AuthLayoutSocialButton[]>(() => {
if (!isSocialAuthVisible) {
return []
return [];
}
return [
@ -56,47 +58,51 @@ export default function RegisterContent() {
href: googleAuthUrl,
icon: <div className="h-5 w-5 flex items-center justify-center">G</div>,
},
]
}, [githubAuthUrl, googleAuthUrl, isSocialAuthVisible, t.social.github])
];
}, [githubAuthUrl, googleAuthUrl, isSocialAuthVisible, t.social.github]);
useEffect(() => {
const sensitiveKeys = ['username', 'password', 'confirmPassword', 'email']
const hasSensitiveParams = sensitiveKeys.some((key) => searchParams.has(key))
const sensitiveKeys = ["username", "password", "confirmPassword", "email"];
const hasSensitiveParams = sensitiveKeys.some((key) =>
searchParams.has(key),
);
if (!hasSensitiveParams) {
return
return;
}
const sanitized = new URLSearchParams(searchParams.toString())
sensitiveKeys.forEach((key) => sanitized.delete(key))
const sanitized = new URLSearchParams(searchParams.toString());
sensitiveKeys.forEach((key) => sanitized.delete(key));
const queryString = sanitized.toString()
router.replace(queryString ? `/register?${queryString}` : '/register', { scroll: false })
}, [router, searchParams])
const queryString = sanitized.toString();
router.replace(queryString ? `/register?${queryString}` : "/register", {
scroll: false,
});
}, [router, searchParams]);
const normalize = useCallback(
(value: string) =>
value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '_')
.replace(/^_+|_+$/g, ''),
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, ""),
[],
)
);
const initialAlert = useMemo<AlertState | null>(() => {
const errorParam = searchParams.get('error')
const successParam = searchParams.get('success')
const errorParam = searchParams.get("error");
const successParam = searchParams.get("success");
if (successParam === '1') {
return { type: 'success', message: alerts.success }
if (successParam === "1") {
return { type: "success", message: alerts.success };
}
if (!errorParam) {
return null
return null;
}
const normalizedError = normalize(errorParam)
const normalizedError = normalize(errorParam);
const errorMap: Record<string, string> = {
missing_fields: alerts.missingFields,
email_and_password_are_required: alerts.missingFields,
@ -111,222 +117,238 @@ export default function RegisterContent() {
invalid_name: alerts.invalidName ?? alerts.genericError,
name_required: alerts.invalidName ?? alerts.genericError,
credentials_in_query: alerts.genericError,
}
const message = errorMap[normalizedError] ?? alerts.genericError
return { type: 'error', message }
}, [alerts, normalize, searchParams])
};
const message = errorMap[normalizedError] ?? alerts.genericError;
return { type: "error", message };
}, [alerts, normalize, searchParams]);
const [alert, setAlert] = useState<AlertState | null>(initialAlert)
const [isSubmitting, setIsSubmitting] = useState(false)
const [alert, setAlert] = useState<AlertState | null>(initialAlert);
const [isSubmitting, setIsSubmitting] = useState(false);
// Wizard Step State: 0 = Info, 1 = Verification, 2 = Success (Processing/Redirecting)
const [currentStep, setCurrentStep] = useState<0 | 1 | 2>(0)
const [currentStep, setCurrentStep] = useState<0 | 1 | 2>(0);
const [codeDigits, setCodeDigits] = useState<string[]>(() => Array(VERIFICATION_CODE_LENGTH).fill(''))
const [resendCooldown, setResendCooldown] = useState(0)
const [isResending, setIsResending] = useState(false)
const [codeDigits, setCodeDigits] = useState<string[]>(() =>
Array(VERIFICATION_CODE_LENGTH).fill(""),
);
const [resendCooldown, setResendCooldown] = useState(0);
const [isResending, setIsResending] = useState(false);
const [formValues, setFormValues] = useState({
username: '',
email: '',
password: '',
confirmPassword: '',
username: "",
email: "",
password: "",
confirmPassword: "",
agreement: false,
})
});
const [isFormReady, setIsFormReady] = useState(false)
const formRef = useRef<HTMLFormElement | null>(null)
const codeInputRefs = useRef<(HTMLInputElement | null)[]>([])
const [isFormReady, setIsFormReady] = useState(false);
const formRef = useRef<HTMLFormElement | null>(null);
const codeInputRefs = useRef<(HTMLInputElement | null)[]>([]);
useEffect(() => {
setAlert(initialAlert)
}, [initialAlert])
setAlert(initialAlert);
}, [initialAlert]);
useEffect(() => {
setIsFormReady(true)
}, [])
setIsFormReady(true);
}, []);
useEffect(() => {
if (resendCooldown <= 0) {
return
return;
}
const timer = window.setInterval(() => {
setResendCooldown((current) => (current > 0 ? current - 1 : 0))
}, 1000)
setResendCooldown((current) => (current > 0 ? current - 1 : 0));
}, 1000);
return () => window.clearInterval(timer)
}, [resendCooldown])
return () => window.clearInterval(timer);
}, [resendCooldown]);
const focusCodeInput = useCallback((index: number) => {
const input = codeInputRefs.current[index]
const input = codeInputRefs.current[index];
if (input) {
input.focus()
input.select()
input.focus();
input.select();
}
}, [])
}, []);
const resetCodeDigits = useCallback(() => {
setCodeDigits(Array(VERIFICATION_CODE_LENGTH).fill(''))
}, [])
setCodeDigits(Array(VERIFICATION_CODE_LENGTH).fill(""));
}, []);
const handleInputChange = useCallback(
(field: 'username' | 'email' | 'password' | 'confirmPassword') =>
(field: "username" | "email" | "password" | "confirmPassword") =>
(event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.target
setFormValues((previous) => ({ ...previous, [field]: value }))
const { value } = event.target;
setFormValues((previous) => ({ ...previous, [field]: value }));
},
[],
)
);
const handleAgreementChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setFormValues((previous) => ({ ...previous, agreement: event.target.checked }))
}, [])
const handleAgreementChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
setFormValues((previous) => ({
...previous,
agreement: event.target.checked,
}));
},
[],
);
const handleCodeChange = useCallback(
(index: number, value: string) => {
const sanitized = value.replace(/\D/g, '')
const sanitized = value.replace(/\D/g, "");
setCodeDigits((previous) => {
const next = [...previous]
next[index] = sanitized ? sanitized[sanitized.length - 1] ?? '' : ''
return next
})
const next = [...previous];
next[index] = sanitized ? (sanitized[sanitized.length - 1] ?? "") : "";
return next;
});
if (sanitized && index < VERIFICATION_CODE_LENGTH - 1) {
focusCodeInput(index + 1)
focusCodeInput(index + 1);
} else if (sanitized && index === VERIFICATION_CODE_LENGTH - 1) {
// Auto-submit when the last digit is entered
// We use a timeout to let the state update first
setTimeout(() => {
const form = formRef.current
if (form) form.requestSubmit()
}, 100)
const form = formRef.current;
if (form) form.requestSubmit();
}, 100);
}
},
[focusCodeInput],
)
);
const handleCodeKeyDown = useCallback(
(index: number, event: KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Backspace' && !codeDigits[index] && index > 0) {
event.preventDefault()
if (event.key === "Backspace" && !codeDigits[index] && index > 0) {
event.preventDefault();
setCodeDigits((previous) => {
const next = [...previous]
next[index - 1] = ''
return next
})
focusCodeInput(index - 1)
return
const next = [...previous];
next[index - 1] = "";
return next;
});
focusCodeInput(index - 1);
return;
}
if (event.key === 'ArrowLeft' && index > 0) {
event.preventDefault()
focusCodeInput(index - 1)
return
if (event.key === "ArrowLeft" && index > 0) {
event.preventDefault();
focusCodeInput(index - 1);
return;
}
if (event.key === 'ArrowRight' && index < VERIFICATION_CODE_LENGTH - 1) {
event.preventDefault()
focusCodeInput(index + 1)
if (event.key === "ArrowRight" && index < VERIFICATION_CODE_LENGTH - 1) {
event.preventDefault();
focusCodeInput(index + 1);
}
},
[codeDigits, focusCodeInput],
)
);
const handleCodePaste = useCallback(
(index: number, event: ClipboardEvent<HTMLInputElement>) => {
event.preventDefault()
const clipboardValue = event.clipboardData.getData('text').replace(/\D/g, '')
event.preventDefault();
const clipboardValue = event.clipboardData
.getData("text")
.replace(/\D/g, "");
if (!clipboardValue) {
return
return;
}
const digits = clipboardValue.slice(0, VERIFICATION_CODE_LENGTH - index).split('')
const digits = clipboardValue
.slice(0, VERIFICATION_CODE_LENGTH - index)
.split("");
setCodeDigits((previous) => {
const next = [...previous]
const next = [...previous];
digits.forEach((digit, offset) => {
const targetIndex = index + offset
const targetIndex = index + offset;
if (targetIndex < VERIFICATION_CODE_LENGTH) {
next[targetIndex] = digit
next[targetIndex] = digit;
}
})
return next
})
});
return next;
});
const lastFilledIndex = Math.min(index + digits.length - 1, VERIFICATION_CODE_LENGTH - 1)
focusCodeInput(lastFilledIndex)
const lastFilledIndex = Math.min(
index + digits.length - 1,
VERIFICATION_CODE_LENGTH - 1,
);
focusCodeInput(lastFilledIndex);
},
[focusCodeInput],
)
);
const showError = (message: string) => {
setAlert({ type: 'error', message })
}
setAlert({ type: "error", message });
};
const showStatus = (message: string) => {
setAlert({ type: 'info', message })
}
setAlert({ type: "info", message });
};
// Step 1: Request Verification Code
const handleRequestVerification = async () => {
const { username, email, password, confirmPassword, agreement } = formValues
const { username, email, password, confirmPassword, agreement } =
formValues;
if (!username.trim() || !USERNAME_PATTERN.test(username.trim())) {
showError(alerts.invalidName ?? alerts.missingFields)
return
showError(alerts.invalidName ?? alerts.missingFields);
return;
}
if (!email || !EMAIL_PATTERN.test(email)) {
showError(alerts.invalidEmail)
return
showError(alerts.invalidEmail);
return;
}
if (!password || !confirmPassword) {
showError(alerts.missingFields)
return
showError(alerts.missingFields);
return;
}
if (!PASSWORD_STRENGTH_PATTERN.test(password)) {
showError(alerts.weakPassword ?? alerts.genericError)
return
showError(alerts.weakPassword ?? alerts.genericError);
return;
}
if (password !== confirmPassword) {
showError(alerts.passwordMismatch)
return
showError(alerts.passwordMismatch);
return;
}
if (!agreement) {
showError(alerts.agreementRequired ?? alerts.missingFields)
return
showError(alerts.agreementRequired ?? alerts.missingFields);
return;
}
setIsSubmitting(true)
setIsSubmitting(true);
showStatus(
t.form.validation?.submitting ??
t.form.submitting ??
'Submitting registration request…',
)
t.form.submitting ??
"Submitting registration request…",
);
try {
const response = await fetch('/api/auth/register/send', {
method: 'POST',
const response = await fetch("/api/auth/register/send", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({ email: email.trim() }),
})
});
if (!response.ok) {
// ... (error handling)
let errorCode = 'generic_error'
let errorCode = "generic_error";
try {
const data = await response.json()
if (typeof data?.error === 'string') {
errorCode = data.error
const data = await response.json();
if (typeof data?.error === "string") {
errorCode = data.error;
}
} catch (error) {
console.error('Failed to parse verification send response', error)
console.error("Failed to parse verification send response", error);
}
const errorMap: Record<string, string> = {
@ -335,54 +357,55 @@ export default function RegisterContent() {
verification_failed: alerts.verificationFailed ?? alerts.genericError,
email_already_exists: alerts.userExists,
account_service_unreachable: alerts.genericError,
}
};
showError(errorMap[normalize(errorCode)] ?? alerts.genericError)
return
showError(errorMap[normalize(errorCode)] ?? alerts.genericError);
return;
}
// Success: Move to Step 2
setCurrentStep(1)
setResendCooldown(RESEND_COOLDOWN_SECONDS)
resetCodeDigits()
setCurrentStep(1);
setResendCooldown(RESEND_COOLDOWN_SECONDS);
resetCodeDigits();
const successMessage = alerts.verificationSent ?? alerts.genericError
setAlert({ type: 'success', message: successMessage })
const successMessage = alerts.verificationSent ?? alerts.genericError;
setAlert({ type: "success", message: successMessage });
// Focus code input after a short delay for state transition
setTimeout(() => focusCodeInput(0), 100)
setTimeout(() => focusCodeInput(0), 100);
} catch (error) {
console.error('Failed to request verification code', error)
showError(alerts.genericError)
console.error("Failed to request verification code", error);
showError(alerts.genericError);
} finally {
setIsSubmitting(false)
setIsSubmitting(false);
}
}
};
// Step 2: Verify Code & Register
const handleCompleteRegistration = async () => {
const verificationCode = codeDigits.join('')
const verificationCode = codeDigits.join("");
if (verificationCode.length !== VERIFICATION_CODE_LENGTH) {
showError(alerts.codeRequired ?? alerts.invalidCode ?? alerts.missingFields)
return
showError(
alerts.codeRequired ?? alerts.invalidCode ?? alerts.missingFields,
);
return;
}
setIsSubmitting(true)
setIsSubmitting(true);
showStatus(
t.form.validation?.completing ??
t.form.completing ??
t.form.completeSubmit ??
t.form.submit,
)
t.form.completing ??
t.form.completeSubmit ??
t.form.submit,
);
try {
const { username, email, password } = formValues
const { username, email, password } = formValues;
const registerResponse = await fetch('/api/auth/register', {
method: 'POST',
const registerResponse = await fetch("/api/auth/register", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({
name: username.trim(),
@ -390,19 +413,21 @@ export default function RegisterContent() {
password,
code: verificationCode,
}),
})
});
let registerData: { success?: boolean; error?: string } | null = null
let registerData: { success?: boolean; error?: string } | null = null;
try {
registerData = await registerResponse.json()
registerData = await registerResponse.json();
} catch (error) {
registerData = null
registerData = null;
}
if (!registerResponse.ok || registerData?.success === false) {
// ... (error handling)
const errorCode =
typeof registerData?.error === 'string' ? registerData.error : 'registration_failed'
typeof registerData?.error === "string"
? registerData.error
: "registration_failed";
const errorMap: Record<string, string> = {
invalid_request: alerts.genericError,
missing_credentials: alerts.missingFields,
@ -417,132 +442,140 @@ export default function RegisterContent() {
credentials_in_query: alerts.genericError,
verification_required: alerts.codeRequired ?? alerts.genericError,
invalid_code:
alerts.verificationFailed ?? alerts.invalidCode ?? alerts.genericError,
alerts.verificationFailed ??
alerts.invalidCode ??
alerts.genericError,
account_service_unreachable: alerts.genericError,
}
};
showError(errorMap[normalize(errorCode)] ?? alerts.genericError)
return
showError(errorMap[normalize(errorCode)] ?? alerts.genericError);
return;
}
// 2. Login
const loginResponse = await fetch('/api/auth/login', {
method: 'POST',
const loginResponse = await fetch("/api/auth/login", {
method: "POST",
headers: {
'Content-Type': 'application/json',
"Content-Type": "application/json",
},
body: JSON.stringify({
email: email.trim(),
password,
}),
})
});
let loginData:
| { success?: boolean; needMfa?: boolean; error?: string; redirectTo?: string }
| null = null
let loginData: {
success?: boolean;
needMfa?: boolean;
error?: string;
redirectTo?: string;
} | null = null;
try {
loginData = await loginResponse.json()
loginData = await loginResponse.json();
} catch (error) {
loginData = null
loginData = null;
}
if (!loginResponse.ok || !loginData?.success) {
// Login failed but registration succeeded
const successMessage = alerts.registrationComplete ?? alerts.success
setAlert({ type: 'success', message: successMessage })
router.push('/login')
return
const successMessage = alerts.registrationComplete ?? alerts.success;
setAlert({ type: "success", message: successMessage });
router.push("/login");
return;
}
if (loginData?.needMfa) {
router.push('/login?needMfa=1')
router.refresh()
return
router.push("/login?needMfa=1");
router.refresh();
return;
}
// Success
setCurrentStep(2)
const successMessage = alerts.registrationComplete ?? alerts.success
setAlert({ type: 'success', message: successMessage })
router.push(loginData?.redirectTo || '/')
router.refresh()
setCurrentStep(2);
const successMessage = alerts.registrationComplete ?? alerts.success;
setAlert({ type: "success", message: successMessage });
router.push(loginData?.redirectTo || "/");
router.refresh();
} catch (error) {
console.error('Failed to complete registration', error)
showError(alerts.genericError)
console.error("Failed to complete registration", error);
showError(alerts.genericError);
} finally {
setIsSubmitting(false)
setIsSubmitting(false);
}
}
};
const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (isSubmitting) return
event.preventDefault();
if (isSubmitting) return;
if (currentStep === 0) {
handleRequestVerification()
handleRequestVerification();
} else if (currentStep === 1) {
handleCompleteRegistration()
handleCompleteRegistration();
}
}
};
const handleResend = useCallback(async () => {
if (isResending || resendCooldown > 0) return
if (isResending || resendCooldown > 0) return;
const { email } = formValues
if (!email) return
const { email } = formValues;
if (!email) return;
setIsResending(true)
setIsResending(true);
const resendStatusMessage =
t.form.verificationCodeResending ??
(t.form.verificationCodeResend ? `${t.form.verificationCodeResend}` : 'Resending verification code…')
setAlert({ type: 'info', message: resendStatusMessage })
(t.form.verificationCodeResend
? `${t.form.verificationCodeResend}`
: "Resending verification code…");
setAlert({ type: "info", message: resendStatusMessage });
try {
const response = await fetch('/api/auth/register/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
const response = await fetch("/api/auth/register/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email: email.trim() }),
})
});
if (!response.ok) {
setAlert({ type: 'error', message: alerts.genericError })
return
setAlert({ type: "error", message: alerts.genericError });
return;
}
setResendCooldown(RESEND_COOLDOWN_SECONDS)
const message = alerts.verificationResent ?? alerts.verificationSent ?? 'Verification code resent.'
setAlert({ type: 'success', message })
setResendCooldown(RESEND_COOLDOWN_SECONDS);
const message =
alerts.verificationResent ??
alerts.verificationSent ??
"Verification code resent.";
setAlert({ type: "success", message });
} catch (error) {
setAlert({ type: 'error', message: alerts.genericError })
setAlert({ type: "error", message: alerts.genericError });
} finally {
setIsResending(false)
setIsResending(false);
}
}, [alerts, formValues, isResending, resendCooldown, t.form])
}, [alerts, formValues, isResending, resendCooldown, t.form]);
// Render Helpers
const aboveForm = t.uuidNote ? (
<div className="rounded-2xl border border-dashed border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-700">
{t.uuidNote}
</div>
) : null
) : null;
const submitLabel = useMemo(() => {
if (isSubmitting) {
if (currentStep === 0) return t.form.submitting ?? t.form.submit
return t.form.completing ?? t.form.submit
if (currentStep === 0) return t.form.submitting ?? t.form.submit;
return t.form.completing ?? t.form.submit;
}
if (currentStep === 0) return '下一步 (获取验证码)'
return t.form.completeSubmit ?? '完成注册'
}, [isSubmitting, currentStep, t.form])
if (currentStep === 0) return "下一步 (获取验证码)";
return t.form.completeSubmit ?? "完成注册";
}, [isSubmitting, currentStep, t.form]);
const resendLabel = isResending
? t.form.verificationCodeResending ?? t.form.verificationCodeResend
? (t.form.verificationCodeResending ?? t.form.verificationCodeResend)
: resendCooldown > 0
? `${t.form.verificationCodeResend} (${resendCooldown}s)`
: t.form.verificationCodeResend
: t.form.verificationCodeResend;
return (
<AuthLayout
@ -554,7 +587,11 @@ export default function RegisterContent() {
socialHeading={t.social.title}
socialButtons={socialButtons}
aboveForm={aboveForm}
switchAction={{ text: t.loginPrompt.text, linkLabel: t.loginPrompt.link, href: '/login' }}
switchAction={{
text: t.loginPrompt.text,
linkLabel: t.loginPrompt.link,
href: "/login",
}}
bottomNote={t.bottomNote}
>
<form
@ -567,24 +604,32 @@ export default function RegisterContent() {
{currentStep === 0 && (
<>
<div className="space-y-2">
<label htmlFor="username" className="text-sm font-medium text-slate-600">
{t.form.name || 'Username'}
<label
htmlFor="username"
className="text-sm font-medium text-slate-600"
>
{t.form.name || "Username"}
</label>
<input
id="username"
name="username"
type="text"
autoComplete="username"
placeholder={t.form.namePlaceholder || '4-16 chars, starts with letter'}
placeholder={
t.form.namePlaceholder || "4-16 chars, starts with letter"
}
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
required
value={formValues.username}
onChange={handleInputChange('username')}
onChange={handleInputChange("username")}
/>
</div>
<div className="space-y-2">
<label htmlFor="email" className="text-sm font-medium text-slate-600">
<label
htmlFor="email"
className="text-sm font-medium text-slate-600"
>
{t.form.email}
</label>
<input
@ -596,13 +641,16 @@ export default function RegisterContent() {
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
required
value={formValues.email}
onChange={handleInputChange('email')}
onChange={handleInputChange("email")}
/>
</div>
<div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-2">
<label htmlFor="password" className="text-sm font-medium text-slate-600">
<label
htmlFor="password"
className="text-sm font-medium text-slate-600"
>
{t.form.password}
</label>
<input
@ -614,11 +662,14 @@ export default function RegisterContent() {
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
required
value={formValues.password}
onChange={handleInputChange('password')}
onChange={handleInputChange("password")}
/>
</div>
<div className="space-y-2">
<label htmlFor="confirm-password" className="text-sm font-medium text-slate-600">
<label
htmlFor="confirm-password"
className="text-sm font-medium text-slate-600"
>
{t.form.confirmPassword}
</label>
<input
@ -630,7 +681,7 @@ export default function RegisterContent() {
className="w-full rounded-2xl border border-slate-200 bg-white/90 px-4 py-2.5 text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
required
value={formValues.confirmPassword}
onChange={handleInputChange('confirmPassword')}
onChange={handleInputChange("confirmPassword")}
/>
</div>
</div>
@ -645,8 +696,11 @@ export default function RegisterContent() {
onChange={handleAgreementChange}
/>
<span>
{t.form.agreement}{' '}
<Link href="/docs" className="font-semibold text-sky-600 hover:text-sky-500">
{t.form.agreement}{" "}
<Link
href="/docs"
className="font-semibold text-sky-600 hover:text-sky-500"
>
{t.form.terms}
</Link>
</span>
@ -657,7 +711,8 @@ export default function RegisterContent() {
{currentStep === 1 && (
<div className="space-y-6">
<div className="rounded-2xl border border-dashed border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-700">
<strong>{formValues.email}</strong>
<strong>{formValues.email}</strong>{" "}
<br />
10
</div>
@ -671,7 +726,7 @@ export default function RegisterContent() {
<input
key={index}
ref={(el) => {
codeInputRefs.current[index] = el
codeInputRefs.current[index] = el;
}}
type="text"
inputMode="numeric"
@ -701,7 +756,7 @@ export default function RegisterContent() {
onClick={handleResend}
disabled={isResending || resendCooldown > 0}
className="text-sm font-medium text-sky-600 transition hover:text-sky-500 disabled:cursor-not-allowed disabled:opacity-50"
style={{ zIndex: 10, position: 'relative' }}
style={{ zIndex: 10, position: "relative" }}
>
{resendLabel}
</button>
@ -711,12 +766,14 @@ export default function RegisterContent() {
<button
type="submit"
disabled={isSubmitting || (currentStep === 1 && codeDigits.some(d => !d))}
disabled={
isSubmitting || (currentStep === 1 && codeDigits.some((d) => !d))
}
className="w-full rounded-2xl bg-gradient-to-r from-sky-500 to-blue-500 px-4 py-2.5 text-sm font-semibold text-white shadow-lg shadow-sky-500/20 transition hover:from-sky-500 hover:to-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-sky-500 disabled:cursor-not-allowed disabled:opacity-70"
>
{submitLabel}
</button>
</form>
</AuthLayout>
)
);
}