feat: refine registration UX and migrate middleware CORS
This commit is contained in:
parent
fee51ac323
commit
caa658cabb
BIN
frontend.log
Normal file
BIN
frontend.log
Normal file
Binary file not shown.
@ -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: {
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
@ -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: '密码',
|
||||||
|
|||||||
@ -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*'],
|
|
||||||
}
|
|
||||||
Loading…
Reference in New Issue
Block a user