feat: refine registration UX and migrate middleware CORS

This commit is contained in:
Haitao Pan 2026-01-25 12:02:06 +08:00
parent fee51ac323
commit caa658cabb
5 changed files with 374 additions and 648 deletions

BIN
frontend.log Normal file

Binary file not shown.

View File

@ -62,6 +62,20 @@ const nextConfig = {
return config; return config;
}, },
async headers() {
return [
{
source: "/api/:path*",
headers: [
{ key: "Access-Control-Allow-Credentials", value: "true" },
{ key: "Access-Control-Allow-Origin", value: process.env.CORS_ALLOWED_ORIGINS || "https://console.svc.plus,http://localhost:3000" },
{ key: "Access-Control-Allow-Methods", value: "GET,POST,PUT,PATCH,DELETE,OPTIONS" },
{ key: "Access-Control-Allow-Headers", value: "Content-Type, Authorization, X-Requested-With, X-Account-Session" },
],
},
];
},
reactStrictMode: true, reactStrictMode: true,
typedRoutes: false, typedRoutes: false,
turbopack: { turbopack: {

View File

@ -28,6 +28,7 @@ const VERIFICATION_CODE_LENGTH = 6
const RESEND_COOLDOWN_SECONDS = 60 const RESEND_COOLDOWN_SECONDS = 60
const EMAIL_PATTERN = /^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$/ 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 PASSWORD_STRENGTH_PATTERN = /^(?=.*[A-Za-z])(?=.*\d).{8,}$/
const USERNAME_PATTERN = /^[a-zA-Z][a-zA-Z0-9]{3,15}$/
export default function RegisterContent() { export default function RegisterContent() {
const { language } = useLanguage() const { language } = useLanguage()
@ -118,19 +119,22 @@ export default function RegisterContent() {
const [alert, setAlert] = useState<AlertState | null>(initialAlert) const [alert, setAlert] = useState<AlertState | null>(initialAlert)
const [isSubmitting, setIsSubmitting] = useState(false) 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 [codeDigits, setCodeDigits] = useState<string[]>(() => Array(VERIFICATION_CODE_LENGTH).fill('')) const [codeDigits, setCodeDigits] = useState<string[]>(() => Array(VERIFICATION_CODE_LENGTH).fill(''))
const [hasRequestedCode, setHasRequestedCode] = useState(false)
const [pendingEmail, setPendingEmail] = useState('')
const [pendingPassword, setPendingPassword] = useState('')
const [isResending, setIsResending] = useState(false)
const [resendCooldown, setResendCooldown] = useState(0) const [resendCooldown, setResendCooldown] = useState(0)
const [isVerified, setIsVerified] = useState(false) const [isResending, setIsResending] = useState(false)
const [formValues, setFormValues] = useState({ const [formValues, setFormValues] = useState({
username: '',
email: '', email: '',
password: '', password: '',
confirmPassword: '', confirmPassword: '',
agreement: false, agreement: false,
}) })
const [isFormReady, setIsFormReady] = useState(false) const [isFormReady, setIsFormReady] = useState(false)
const formRef = useRef<HTMLFormElement | null>(null) const formRef = useRef<HTMLFormElement | null>(null)
const codeInputRefs = useRef<(HTMLInputElement | null)[]>([]) const codeInputRefs = useRef<(HTMLInputElement | null)[]>([])
@ -168,7 +172,7 @@ export default function RegisterContent() {
}, []) }, [])
const handleInputChange = useCallback( const handleInputChange = useCallback(
(field: 'email' | 'password' | 'confirmPassword') => (field: 'username' | 'email' | 'password' | 'confirmPassword') =>
(event: ChangeEvent<HTMLInputElement>) => { (event: ChangeEvent<HTMLInputElement>) => {
const { value } = event.target const { value } = event.target
setFormValues((previous) => ({ ...previous, [field]: value })) setFormValues((previous) => ({ ...previous, [field]: value }))
@ -191,6 +195,13 @@ export default function RegisterContent() {
if (sanitized && index < VERIFICATION_CODE_LENGTH - 1) { 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)
} }
}, },
[focusCodeInput], [focusCodeInput],
@ -249,32 +260,6 @@ export default function RegisterContent() {
[focusCodeInput], [focusCodeInput],
) )
const handleSubmit = useCallback(
async (event: FormEvent<HTMLFormElement>) => {
event.preventDefault()
if (isSubmitting) {
return
}
formRef.current = event.currentTarget
const formData = new FormData(event.currentTarget)
const emailInput = String(formData.get('email') ?? '').trim()
const normalizedEmail = emailInput.toLowerCase()
const password = String(formData.get('password') ?? '')
const confirmPassword = String(formData.get('confirmPassword') ?? '')
const agreementAccepted = formData.get('agreement') === 'on'
const verificationCode = codeDigits.join('')
setFormValues((previous) => ({
...previous,
email: emailInput,
password,
confirmPassword,
agreement: agreementAccepted,
}))
const showError = (message: string) => { const showError = (message: string) => {
setAlert({ type: 'error', message }) setAlert({ type: 'error', message })
} }
@ -283,8 +268,16 @@ export default function RegisterContent() {
setAlert({ type: 'info', message }) setAlert({ type: 'info', message })
} }
if (!hasRequestedCode) { // Step 1: Request Verification Code
if (!emailInput || !EMAIL_PATTERN.test(emailInput)) { const handleRequestVerification = async () => {
const { username, email, password, confirmPassword, agreement } = formValues
if (!username.trim() || !USERNAME_PATTERN.test(username.trim())) {
showError(alerts.invalidName ?? alerts.missingFields)
return
}
if (!email || !EMAIL_PATTERN.test(email)) {
showError(alerts.invalidEmail) showError(alerts.invalidEmail)
return return
} }
@ -304,7 +297,7 @@ export default function RegisterContent() {
return return
} }
if (!agreementAccepted) { if (!agreement) {
showError(alerts.agreementRequired ?? alerts.missingFields) showError(alerts.agreementRequired ?? alerts.missingFields)
return return
} }
@ -322,10 +315,11 @@ export default function RegisterContent() {
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ email: emailInput }), body: JSON.stringify({ email: email.trim() }),
}) })
if (!response.ok) { if (!response.ok) {
// ... (error handling)
let errorCode = 'generic_error' let errorCode = 'generic_error'
try { try {
const data = await response.json() const data = await response.json()
@ -345,105 +339,36 @@ export default function RegisterContent() {
} }
showError(errorMap[normalize(errorCode)] ?? alerts.genericError) showError(errorMap[normalize(errorCode)] ?? alerts.genericError)
setIsSubmitting(false)
return return
} }
setPendingEmail(normalizedEmail) // Success: Move to Step 2
setPendingPassword(password) setCurrentStep(1)
setHasRequestedCode(true)
setIsVerified(false)
resetCodeDigits()
focusCodeInput(0)
setResendCooldown(RESEND_COOLDOWN_SECONDS) setResendCooldown(RESEND_COOLDOWN_SECONDS)
resetCodeDigits()
const successMessage = alerts.verificationSent ?? alerts.genericError const successMessage = alerts.verificationSent ?? alerts.genericError
setAlert({ type: 'success', message: successMessage }) setAlert({ type: 'success', message: successMessage })
// Focus code input after a short delay for state transition
setTimeout(() => focusCodeInput(0), 100)
} catch (error) { } catch (error) {
console.error('Failed to request verification code', error) console.error('Failed to request verification code', error)
showError(alerts.genericError) showError(alerts.genericError)
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
return
} }
const emailForVerification = pendingEmail || normalizedEmail // Step 2: Verify Code & Register
if (!emailForVerification) { const handleCompleteRegistration = async () => {
showError(alerts.invalidEmail) const verificationCode = codeDigits.join('')
return
}
if (!isVerified) {
if (verificationCode.length !== VERIFICATION_CODE_LENGTH) { if (verificationCode.length !== VERIFICATION_CODE_LENGTH) {
showError(alerts.codeRequired ?? alerts.invalidCode ?? alerts.missingFields) showError(alerts.codeRequired ?? alerts.invalidCode ?? alerts.missingFields)
return return
} }
setIsSubmitting(true)
showStatus(
t.form.validation?.verifying ??
t.form.verifying ??
t.form.verifySubmit ??
t.form.submit,
)
try {
const response = await fetch('/api/auth/register/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ email: emailForVerification, code: verificationCode }),
})
if (!response.ok) {
let errorCode = 'generic_error'
try {
const data = await response.json()
if (typeof data?.error === 'string') {
errorCode = data.error
}
} catch (error) {
console.error('Failed to parse verification response', error)
}
const errorMap: Record<string, string> = {
invalid_request: alerts.genericError,
missing_verification: alerts.codeRequired ?? alerts.missingFields,
invalid_code:
alerts.verificationFailed ?? alerts.invalidCode ?? alerts.genericError,
verification_failed: alerts.verificationFailed ?? alerts.genericError,
account_service_unreachable: alerts.genericError,
}
showError(errorMap[normalize(errorCode)] ?? alerts.genericError)
setIsSubmitting(false)
return
}
setIsVerified(true)
const successMessage = alerts.verificationReady ?? alerts.success
setAlert({ type: 'success', message: successMessage })
} catch (error) {
console.error('Failed to verify email', error)
showError(alerts.genericError)
} finally {
setIsSubmitting(false)
}
return
}
if (!pendingPassword) {
showError(alerts.genericError)
return
}
if (verificationCode.length !== VERIFICATION_CODE_LENGTH) {
showError(alerts.codeRequired ?? alerts.invalidCode ?? alerts.genericError)
return
}
setIsSubmitting(true) setIsSubmitting(true)
showStatus( showStatus(
t.form.validation?.completing ?? t.form.validation?.completing ??
@ -453,15 +378,17 @@ export default function RegisterContent() {
) )
try { try {
const { username, email, password } = formValues
const registerResponse = await fetch('/api/auth/register', { const registerResponse = await fetch('/api/auth/register', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
email: emailForVerification, name: username.trim(),
password: pendingPassword, email: email.trim(),
confirmPassword: pendingPassword, password,
code: verificationCode, code: verificationCode,
}), }),
}) })
@ -474,6 +401,7 @@ export default function RegisterContent() {
} }
if (!registerResponse.ok || registerData?.success === false) { if (!registerResponse.ok || registerData?.success === false) {
// ... (error handling)
const errorCode = const errorCode =
typeof registerData?.error === 'string' ? registerData.error : 'registration_failed' typeof registerData?.error === 'string' ? registerData.error : 'registration_failed'
const errorMap: Record<string, string> = { const errorMap: Record<string, string> = {
@ -495,18 +423,18 @@ export default function RegisterContent() {
} }
showError(errorMap[normalize(errorCode)] ?? alerts.genericError) showError(errorMap[normalize(errorCode)] ?? alerts.genericError)
setIsSubmitting(false)
return return
} }
// 2. Login
const loginResponse = await fetch('/api/auth/login', { const loginResponse = await fetch('/api/auth/login', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ body: JSON.stringify({
email: emailForVerification, email: email.trim(),
password: pendingPassword, password,
}), }),
}) })
@ -520,69 +448,51 @@ export default function RegisterContent() {
} }
if (!loginResponse.ok || !loginData?.success) { if (!loginResponse.ok || !loginData?.success) {
const errorCode = typeof loginData?.error === 'string' ? loginData.error : 'generic_error' // Login failed but registration succeeded
const errorMap: Record<string, string> = { const successMessage = alerts.registrationComplete ?? alerts.success
invalid_credentials: alerts.genericError, setAlert({ type: 'success', message: successMessage })
missing_credentials: alerts.missingFields, router.push('/login')
account_service_unreachable: alerts.genericError, return
authentication_failed: alerts.genericError,
} }
if (loginData?.needMfa) { if (loginData?.needMfa) {
router.push('/login?needMfa=1') router.push('/login?needMfa=1')
router.refresh() router.refresh()
setIsSubmitting(false)
return
}
showError(errorMap[normalize(errorCode)] ?? alerts.genericError)
setIsSubmitting(false)
return return
} }
// Success
setCurrentStep(2)
const successMessage = alerts.registrationComplete ?? alerts.success const successMessage = alerts.registrationComplete ?? alerts.success
setAlert({ type: 'success', message: successMessage }) setAlert({ type: 'success', message: successMessage })
router.push(loginData?.redirectTo || '/') router.push(loginData?.redirectTo || '/')
router.refresh() router.refresh()
} catch (error) { } catch (error) {
console.error('Failed to complete registration', error) console.error('Failed to complete registration', error)
showError(alerts.genericError) showError(alerts.genericError)
} finally { } finally {
setIsSubmitting(false) setIsSubmitting(false)
} }
}, }
[
alerts, const handleSubmit = (event: FormEvent<HTMLFormElement>) => {
codeDigits, event.preventDefault()
focusCodeInput, if (isSubmitting) return
hasRequestedCode,
isSubmitting, if (currentStep === 0) {
isVerified, handleRequestVerification()
normalize, } else if (currentStep === 1) {
pendingEmail, handleCompleteRegistration()
pendingPassword, }
resetCodeDigits, }
router,
t.form,
],
)
const handleResend = useCallback(async () => { const handleResend = useCallback(async () => {
if (isResending || resendCooldown > 0 || isVerified) { if (isResending || resendCooldown > 0) return
return
}
const emailFromFormRaw = const { email } = formValues
pendingEmail || if (!email) return
(formRef.current ? String(new FormData(formRef.current).get('email') ?? '').trim() : '')
if (!emailFromFormRaw) {
setAlert({ type: 'error', message: alerts.invalidEmail })
return
}
const emailFromForm = emailFromFormRaw.trim()
setIsResending(true) setIsResending(true)
const resendStatusMessage = const resendStatusMessage =
@ -593,206 +503,47 @@ export default function RegisterContent() {
try { try {
const response = await fetch('/api/auth/register/send', { const response = await fetch('/api/auth/register/send', {
method: 'POST', method: 'POST',
headers: { headers: { 'Content-Type': 'application/json' },
'Content-Type': 'application/json', body: JSON.stringify({ email: email.trim() }),
},
body: JSON.stringify({ email: emailFromForm }),
}) })
if (!response.ok) { if (!response.ok) {
let errorCode = 'generic_error' setAlert({ type: 'error', message: alerts.genericError })
try {
const data = await response.json()
if (typeof data?.error === 'string') {
errorCode = data.error
}
} catch (error) {
console.error('Failed to parse resend response', error)
}
const errorMap: Record<string, string> = {
invalid_request: alerts.genericError,
invalid_email: alerts.invalidEmail,
verification_failed: alerts.verificationFailed ?? alerts.genericError,
already_verified: alerts.verificationFailed ?? alerts.genericError,
account_service_unreachable: alerts.genericError,
email_already_exists: alerts.userExists,
}
setAlert({ type: 'error', message: errorMap[normalize(errorCode)] ?? alerts.genericError })
setIsResending(false)
return return
} }
setPendingEmail(emailFromForm.toLowerCase())
setHasRequestedCode(true)
setIsVerified(false)
resetCodeDigits()
focusCodeInput(0)
setResendCooldown(RESEND_COOLDOWN_SECONDS) setResendCooldown(RESEND_COOLDOWN_SECONDS)
const message = alerts.verificationResent ?? alerts.verificationSent ?? 'Verification code resent.' const message = alerts.verificationResent ?? alerts.verificationSent ?? 'Verification code resent.'
setAlert({ type: 'success', message }) setAlert({ type: 'success', message })
setIsResending(false)
} catch (error) { } catch (error) {
console.error('Failed to resend verification code', 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,
focusCodeInput,
isResending,
isVerified,
normalize,
pendingEmail,
resetCodeDigits,
resendCooldown,
t.form.verificationCodeResend,
t.form.verificationCodeResending,
])
// Render Helpers
const aboveForm = t.uuidNote ? ( 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"> <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} {t.uuidNote}
</div> </div>
) : null ) : null
const isVerificationStep = hasRequestedCode && !isVerified const submitLabel = useMemo(() => {
const submitLabel = isVerified if (isSubmitting) {
? isSubmitting if (currentStep === 0) return t.form.submitting ?? t.form.submit
? t.form.completing ?? t.form.completeSubmit ?? t.form.submit return t.form.completing ?? t.form.submit
: t.form.completeSubmit ?? t.form.submit }
: isVerificationStep if (currentStep === 0) return '下一步 (获取验证码)'
? isSubmitting return t.form.completeSubmit ?? '完成注册'
? t.form.verifying ?? t.form.verifySubmit ?? t.form.submit }, [isSubmitting, currentStep, t.form])
: t.form.verifySubmit ?? t.form.submit
: isSubmitting
? t.form.submitting ?? t.form.submit
: t.form.submit
const resendLabel = isResending const resendLabel = isResending
? t.form.verificationCodeResending ?? t.form.verificationCodeResend ? t.form.verificationCodeResending ?? t.form.verificationCodeResend
: resendCooldown > 0 : resendCooldown > 0
? `${t.form.verificationCodeResend} (${resendCooldown}s)` ? `${t.form.verificationCodeResend} (${resendCooldown}s)`
: t.form.verificationCodeResend : t.form.verificationCodeResend
const verificationDescriptionId = useId()
const validationHints = t.form.validation
const validationState = useMemo(() => {
const messages: string[] = []
if (!isFormReady && validationHints?.initializing) {
return { disabled: true, messages: [validationHints.initializing] }
}
if (isSubmitting) {
if (isVerified) {
messages.push(
validationHints?.completing ??
t.form.completing ??
t.form.completeSubmit ??
t.form.submit,
)
} else if (isVerificationStep) {
messages.push(
validationHints?.verifying ??
t.form.verifying ??
t.form.verifySubmit ??
t.form.submit,
)
} else {
messages.push(validationHints?.submitting ?? t.form.submitting ?? t.form.submit)
}
return { disabled: true, messages }
}
if (!hasRequestedCode) {
const emailValue = formValues.email.trim()
if (!emailValue) {
messages.push(validationHints?.emailMissing ?? alerts.invalidEmail)
} else if (!EMAIL_PATTERN.test(emailValue)) {
messages.push(validationHints?.emailInvalid ?? alerts.invalidEmail)
}
if (!formValues.password) {
messages.push(validationHints?.passwordMissing ?? alerts.missingFields)
}
if (!formValues.confirmPassword) {
messages.push(validationHints?.confirmPasswordMissing ?? alerts.missingFields)
}
if (formValues.password && !PASSWORD_STRENGTH_PATTERN.test(formValues.password)) {
messages.push(validationHints?.passwordWeak ?? alerts.weakPassword ?? alerts.genericError)
}
if (
formValues.password &&
formValues.confirmPassword &&
formValues.password !== formValues.confirmPassword
) {
messages.push(validationHints?.passwordMismatch ?? alerts.passwordMismatch)
}
if (!formValues.agreement) {
messages.push(
validationHints?.agreementRequired ?? alerts.agreementRequired ?? alerts.missingFields,
)
}
const uniqueMessages = Array.from(new Set(messages.filter(Boolean)))
return { disabled: uniqueMessages.length > 0, messages: uniqueMessages }
}
if (!isVerified) {
if (codeDigits.some((digit) => !digit)) {
messages.push(
validationHints?.codeIncomplete ??
alerts.codeRequired ??
alerts.invalidCode ??
alerts.missingFields,
)
}
const uniqueMessages = Array.from(new Set(messages.filter(Boolean)))
return { disabled: uniqueMessages.length > 0, messages: uniqueMessages }
}
if (codeDigits.some((digit) => !digit)) {
messages.push(
validationHints?.codeIncomplete ??
alerts.codeRequired ??
alerts.invalidCode ??
alerts.missingFields,
)
}
if (!pendingPassword) {
messages.push(validationHints?.passwordUnavailable ?? alerts.genericError)
}
const uniqueMessages = Array.from(new Set(messages.filter(Boolean)))
return { disabled: uniqueMessages.length > 0, messages: uniqueMessages }
}, [
alerts,
codeDigits,
formValues,
hasRequestedCode,
isFormReady,
isSubmitting,
isVerificationStep,
isVerified,
pendingPassword,
t.form.completeSubmit,
t.form.completing,
t.form.submit,
t.form.submitting,
t.form.verifySubmit,
t.form.verifying,
validationHints,
])
const isSubmitDisabled = validationState.disabled
const validationMessages = validationState.messages
return ( return (
<AuthLayout <AuthLayout
@ -814,6 +565,25 @@ export default function RegisterContent() {
onSubmit={handleSubmit} onSubmit={handleSubmit}
noValidate noValidate
> >
{currentStep === 0 && (
<>
<div className="space-y-2">
<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'}
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')}
/>
</div>
<div className="space-y-2"> <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} {t.form.email}
@ -824,13 +594,13 @@ export default function RegisterContent() {
type="email" type="email"
autoComplete="email" autoComplete="email"
placeholder={t.form.emailPlaceholder} placeholder={t.form.emailPlaceholder}
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 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" 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 required
disabled={isVerificationStep}
value={formValues.email} value={formValues.email}
onChange={handleInputChange('email')} onChange={handleInputChange('email')}
/> />
</div> </div>
<div className="grid gap-5 sm:grid-cols-2"> <div className="grid gap-5 sm:grid-cols-2">
<div className="space-y-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">
@ -842,9 +612,8 @@ export default function RegisterContent() {
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
placeholder={t.form.passwordPlaceholder} placeholder={t.form.passwordPlaceholder}
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 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" 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={!isVerificationStep} required
disabled={isVerificationStep}
value={formValues.password} value={formValues.password}
onChange={handleInputChange('password')} onChange={handleInputChange('password')}
/> />
@ -859,78 +628,20 @@ export default function RegisterContent() {
type="password" type="password"
autoComplete="new-password" autoComplete="new-password"
placeholder={t.form.confirmPasswordPlaceholder} placeholder={t.form.confirmPasswordPlaceholder}
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 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-400" 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={!isVerificationStep} required
disabled={isVerificationStep}
value={formValues.confirmPassword} value={formValues.confirmPassword}
onChange={handleInputChange('confirmPassword')} onChange={handleInputChange('confirmPassword')}
/> />
</div> </div>
</div> </div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-600" htmlFor="verification-code-0">
{t.form.verificationCodeLabel}
</label>
{t.form.verificationCodeDescription ? (
<p
id={verificationDescriptionId}
className="text-xs text-slate-500"
>
{t.form.verificationCodeDescription}
</p>
) : null}
{hasRequestedCode && !isVerified ? (
<div className="space-y-4">
<div className="rounded-2xl border border-dashed border-sky-200 bg-sky-50/80 px-4 py-3 text-sm text-sky-700">
<strong>10 </strong>
<br />
</div>
<div className="flex justify-between gap-2">
{codeDigits.map((digit, index) => (
<input
key={index}
ref={(el) => {
codeInputRefs.current[index] = el
}}
id={`verification-code-${index}`}
name={`verification-code-${index}`}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
pattern="\d{1}"
maxLength={1}
className="h-12 w-full rounded-xl border border-slate-200 bg-white/90 text-center text-lg font-semibold text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
value={digit}
onChange={(e) => handleCodeChange(index, e.target.value)}
onKeyDown={(e) => handleCodeKeyDown(index, e)}
onPaste={(e) => index === 0 && handleCodePaste(0, e)}
/>
))}
</div>
<div className="text-center">
<button
type="button"
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"
>
{resendLabel}
</button>
</div>
</div>
) : null}
</div>
<label className="flex items-start gap-3 text-sm text-slate-600"> <label className="flex items-start gap-3 text-sm text-slate-600">
<input <input
type="checkbox" type="checkbox"
name="agreement" name="agreement"
required={!isVerificationStep} required
disabled={isVerificationStep} className="mt-1 h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500"
className="mt-1 h-4 w-4 rounded border-slate-300 text-sky-600 focus:ring-sky-500 disabled:cursor-not-allowed disabled:opacity-60"
checked={formValues.agreement} checked={formValues.agreement}
onChange={handleAgreementChange} onChange={handleAgreementChange}
/> />
@ -941,22 +652,67 @@ export default function RegisterContent() {
</Link> </Link>
</span> </span>
</label> </label>
{validationMessages.length > 0 ? ( </>
<div )}
className="rounded-2xl border border-slate-200 bg-white/80 px-4 py-3 text-sm text-slate-600"
role="status" {currentStep === 1 && (
aria-live="polite" <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">
<ul className="list-disc space-y-1 pl-5"> <strong>{formValues.email}</strong>
{validationMessages.map((message) => ( <br />
<li key={message}>{message}</li> 10
))}
</ul>
</div> </div>
) : null}
<div className="space-y-2">
<label className="text-sm font-medium text-slate-600">
{t.form.verificationCodeLabel}
</label>
<div className="flex justify-between gap-2">
{codeDigits.map((digit, index) => (
<input
key={index}
ref={(el) => {
codeInputRefs.current[index] = el
}}
type="text"
inputMode="numeric"
autoComplete="one-time-code"
maxLength={1}
className="h-12 w-full rounded-xl border border-slate-200 bg-white/90 text-center text-lg font-semibold text-slate-900 shadow-sm transition focus:border-sky-500 focus:outline-none focus:ring-2 focus:ring-sky-200"
value={digit}
onChange={(e) => handleCodeChange(index, e.target.value)}
onKeyDown={(e) => handleCodeKeyDown(index, e)}
onPaste={(e) => index === 0 && handleCodePaste(0, e)}
/>
))}
</div>
</div>
<div className="flex items-center justify-between">
<button
type="button"
onClick={() => setCurrentStep(0)}
className="text-sm text-slate-500 hover:text-slate-700"
>
</button>
<button
type="button"
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' }}
>
{resendLabel}
</button>
</div>
</div>
)}
<button <button
type="submit" type="submit"
disabled={isSubmitDisabled} 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" 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} {submitLabel}

View File

@ -222,6 +222,8 @@ type AuthRegisterTranslation = {
form: { form: {
title: string title: string
subtitle: string subtitle: string
name: string
namePlaceholder: string
email: string email: string
emailPlaceholder: string emailPlaceholder: string
password: string password: string
@ -723,6 +725,8 @@ export const translations: Record<'en' | 'zh', Translation> = {
form: { form: {
title: 'Create your account', title: 'Create your account',
subtitle: 'Submit your email and password, request the code, and enter it to activate your account.', subtitle: 'Submit your email and password, request the code, and enter it to activate your account.',
name: 'Username',
namePlaceholder: '4-16 chars, starts with letter',
email: 'Work email', email: 'Work email',
emailPlaceholder: 'name@example.com', emailPlaceholder: 'name@example.com',
password: 'Password', password: 'Password',
@ -1384,6 +1388,8 @@ export const translations: Record<'en' | 'zh', Translation> = {
form: { form: {
title: '创建账号', title: '创建账号',
subtitle: '先提交邮箱和密码获取验证码,再输入邮箱收到的验证码完成注册。', subtitle: '先提交邮箱和密码获取验证码,再输入邮箱收到的验证码完成注册。',
name: '用户名',
namePlaceholder: '4-16位字母或数字字母开头',
email: '邮箱', email: '邮箱',
emailPlaceholder: 'name@example.com', emailPlaceholder: 'name@example.com',
password: '密码', password: '密码',

View File

@ -1,50 +0,0 @@
import { NextResponse } from 'next/server'
import type { NextRequest } from 'next/server'
const DEFAULT_ALLOWED_ORIGINS = 'https://console.svc.plus,http://localhost:3000'
function parseAllowedOrigins() {
const raw = process.env.CORS_ALLOWED_ORIGINS ?? DEFAULT_ALLOWED_ORIGINS
return raw
.split(',')
.map((value) => value.trim())
.filter(Boolean)
}
function resolveAllowedOrigin(origin: string | null, allowed: string[]) {
if (!origin) return null
if (allowed.includes('*')) return '*'
return allowed.includes(origin) ? origin : null
}
function applyCorsHeaders(response: NextResponse, origin: string | null) {
if (!origin) return
response.headers.set('Access-Control-Allow-Origin', origin)
response.headers.set('Access-Control-Allow-Credentials', 'true')
response.headers.set('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE,OPTIONS')
response.headers.set(
'Access-Control-Allow-Headers',
'Content-Type, Authorization, X-Requested-With, X-Account-Session',
)
response.headers.set('Vary', 'Origin')
}
export function middleware(request: NextRequest) {
const allowedOrigins = parseAllowedOrigins()
const originHeader = request.headers.get('origin')
const allowedOrigin = resolveAllowedOrigin(originHeader, allowedOrigins)
if (request.method === 'OPTIONS') {
const response = new NextResponse(null, { status: 204 })
applyCorsHeaders(response, allowedOrigin)
return response
}
const response = NextResponse.next()
applyCorsHeaders(response, allowedOrigin)
return response
}
export const config = {
matcher: ['/api/:path*'],
}