194 lines
5.6 KiB
TypeScript
194 lines
5.6 KiB
TypeScript
'use client'
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
|
import type { FormEvent } from 'react'
|
|
|
|
type CaptchaResponse = {
|
|
expiresAt: number
|
|
prompt: string
|
|
token: string
|
|
}
|
|
|
|
function getSafeRedirect() {
|
|
if (typeof window === 'undefined') return '/admin'
|
|
|
|
const redirectTo = new URLSearchParams(window.location.search).get('redirect')
|
|
|
|
if (!redirectTo || !redirectTo.startsWith('/') || redirectTo.startsWith('//')) return '/admin'
|
|
|
|
return redirectTo
|
|
}
|
|
|
|
async function readErrorMessage(response: Response) {
|
|
try {
|
|
const data = await response.json()
|
|
|
|
if (typeof data?.message === 'string') return data.message
|
|
if (Array.isArray(data?.errors) && typeof data.errors[0]?.message === 'string') {
|
|
return data.errors[0].message
|
|
}
|
|
} catch {
|
|
return null
|
|
}
|
|
|
|
return null
|
|
}
|
|
|
|
export function AdminLoginForm() {
|
|
const [captcha, setCaptcha] = useState<CaptchaResponse | null>(null)
|
|
const [email, setEmail] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [captchaAnswer, setCaptchaAnswer] = useState('')
|
|
const [error, setError] = useState('')
|
|
const [loadingCaptcha, setLoadingCaptcha] = useState(true)
|
|
const [submitting, setSubmitting] = useState(false)
|
|
const redirectTo = useMemo(getSafeRedirect, [])
|
|
|
|
const refreshCaptcha = useCallback(async () => {
|
|
setLoadingCaptcha(true)
|
|
setCaptchaAnswer('')
|
|
|
|
try {
|
|
const response = await fetch('/api/admin/captcha', {
|
|
cache: 'no-store',
|
|
credentials: 'same-origin',
|
|
})
|
|
|
|
if (!response.ok) throw new Error('Failed to load captcha')
|
|
|
|
setCaptcha(await response.json())
|
|
} catch {
|
|
setError('验证码加载失败,请刷新页面后重试。')
|
|
} finally {
|
|
setLoadingCaptcha(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
refreshCaptcha()
|
|
}, [refreshCaptcha])
|
|
|
|
useEffect(() => {
|
|
fetch('/api/users/me', {
|
|
cache: 'no-store',
|
|
credentials: 'same-origin',
|
|
})
|
|
.then(async (response) => {
|
|
if (!response.ok) return
|
|
|
|
const data = await response.json()
|
|
|
|
if (data?.user) window.location.assign(redirectTo)
|
|
})
|
|
.catch(() => undefined)
|
|
}, [redirectTo])
|
|
|
|
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
event.preventDefault()
|
|
|
|
if (!captcha) {
|
|
setError('验证码还没有加载完成。')
|
|
return
|
|
}
|
|
|
|
setError('')
|
|
setSubmitting(true)
|
|
|
|
const response = await fetch('/api/users/login', {
|
|
body: JSON.stringify({
|
|
captchaAnswer,
|
|
captchaToken: captcha.token,
|
|
email,
|
|
password,
|
|
}),
|
|
credentials: 'same-origin',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
method: 'POST',
|
|
})
|
|
|
|
if (response.ok) {
|
|
window.location.assign(redirectTo)
|
|
return
|
|
}
|
|
|
|
const message = await readErrorMessage(response)
|
|
|
|
setError(message || '登录失败,请检查账号、密码和验证码。')
|
|
setSubmitting(false)
|
|
refreshCaptcha()
|
|
}
|
|
|
|
return (
|
|
<main className="template-minimal eversolo-login-shell">
|
|
<div className="template-minimal__wrap eversolo-login-card">
|
|
<div className="login__brand" aria-hidden="true">
|
|
<div className="eversolo-login-logo">Eversolo</div>
|
|
</div>
|
|
<form className="login__form eversolo-login-form" onSubmit={handleSubmit}>
|
|
<div className="login__form__inputWrap eversolo-login-form__fields">
|
|
<label className="field-type text">
|
|
<span className="field-label">Email</span>
|
|
<input
|
|
autoComplete="email"
|
|
autoFocus
|
|
className="text-input"
|
|
name="email"
|
|
onChange={(event) => setEmail(event.target.value)}
|
|
required
|
|
type="email"
|
|
value={email}
|
|
/>
|
|
</label>
|
|
<label className="field-type text">
|
|
<span className="field-label">Password</span>
|
|
<input
|
|
autoComplete="current-password"
|
|
className="text-input"
|
|
name="password"
|
|
onChange={(event) => setPassword(event.target.value)}
|
|
required
|
|
type="password"
|
|
value={password}
|
|
/>
|
|
</label>
|
|
<label className="field-type text eversolo-captcha-field">
|
|
<span className="field-label">Verification code</span>
|
|
<span className="eversolo-captcha-field__row">
|
|
<input
|
|
autoComplete="off"
|
|
className="text-input"
|
|
disabled={loadingCaptcha}
|
|
inputMode="text"
|
|
name="captchaAnswer"
|
|
onChange={(event) => setCaptchaAnswer(event.target.value.toUpperCase())}
|
|
required
|
|
type="text"
|
|
value={captchaAnswer}
|
|
/>
|
|
<button
|
|
className="eversolo-captcha-field__code"
|
|
disabled={loadingCaptcha}
|
|
onClick={refreshCaptcha}
|
|
type="button"
|
|
>
|
|
{loadingCaptcha ? 'Loading' : captcha?.prompt}
|
|
</button>
|
|
</span>
|
|
</label>
|
|
</div>
|
|
{error ? (
|
|
<p className="eversolo-login-form__error" role="alert">
|
|
{error}
|
|
</p>
|
|
) : null}
|
|
<button className="btn btn--style-primary btn--size-large" disabled={submitting || loadingCaptcha} type="submit">
|
|
{submitting ? 'Logging in...' : 'Login'}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</main>
|
|
)
|
|
}
|