This commit is contained in:
parent
a8a65170d3
commit
581e70b2ca
@ -233,12 +233,12 @@ func requestMatchesHTTPSAuthority(r *http.Request, configured string) bool {
|
||||
if r == nil {
|
||||
return false
|
||||
}
|
||||
expectedHost, _, ok := configuredHTTPSAuthority(configured)
|
||||
expectedHost, expectedPort, ok := configuredHTTPSAuthority(configured)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
requestHost, _, ok := normalizeAuthority(r.Host, "443")
|
||||
return ok && requestHost == expectedHost
|
||||
requestHost, requestPort, ok := normalizeAuthority(r.Host, "443")
|
||||
return ok && requestHost == expectedHost && requestPort == expectedPort
|
||||
}
|
||||
|
||||
func configuredHTTPSAuthority(value string) (string, string, bool) {
|
||||
|
||||
@ -63,6 +63,34 @@ func TestPublicRecoveryPageIsServedOnlyFromMailDomain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRecoveryPageRequiresConfiguredHTTPSPort(t *testing.T) {
|
||||
configurePublicRecoveryRouteTest(t)
|
||||
config.Instance.HttpsPort = 8443
|
||||
handler, err := newPublicRecoveryHandler()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
allowed := httptest.NewRecorder()
|
||||
handler.ServeHTTP(allowed, publicRecoveryRequest(http.MethodGet, "https://mx.example.com:8443/rest/"))
|
||||
if allowed.Code != http.StatusOK {
|
||||
t.Fatalf("configured HTTPS port returned %d, want %d", allowed.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
for _, target := range []string{
|
||||
"https://mx.example.com/rest/",
|
||||
"https://mx.example.com:9443/rest/",
|
||||
} {
|
||||
t.Run(target, func(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
handler.ServeHTTP(recorder, publicRecoveryRequest(http.MethodGet, target))
|
||||
if recorder.Code != http.StatusNotFound {
|
||||
t.Fatalf("unexpected recovery page status %d for %q", recorder.Code, target)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicRecoveryLoginEntryRedirectsOnlyFromWebDomain(t *testing.T) {
|
||||
configurePublicRecoveryRouteTest(t)
|
||||
handler, err := newPublicRecoveryHandler()
|
||||
|
||||
43
frontend/src/main-app.js
Normal file
43
frontend/src/main-app.js
Normal file
@ -0,0 +1,43 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import { router } from './router'
|
||||
import { useGlobalStatusStore } from './stores/useGlobalStatusStore'
|
||||
|
||||
import './styles/element-services.css'
|
||||
import './assets/main.css'
|
||||
|
||||
const isReservedResetPath = (pathname) => {
|
||||
let decoded = pathname.trim()
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const next = decodeURIComponent(decoded)
|
||||
if (next === decoded) break
|
||||
decoded = next
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
const normalized = decoded.replace(/\/+$/, '').toLowerCase()
|
||||
return normalized === '/reset' || normalized.startsWith('/reset/')
|
||||
}
|
||||
|
||||
// Hash History 会将 /reset#/ 解析为普通首页,启动前先切断这个回退路径。
|
||||
if (isReservedResetPath(window.location.pathname)) {
|
||||
window.history.replaceState({}, document.title, '/')
|
||||
window.location.hash = '#/reset-blocked'
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
const globalStatus = useGlobalStatusStore(pinia)
|
||||
|
||||
app.use(pinia)
|
||||
|
||||
router.onError((error) => {
|
||||
console.error('Router navigation failed', error)
|
||||
globalStatus.setStartupError('页面加载失败,请刷新后重试')
|
||||
})
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
@ -1,43 +1,5 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import {router} from './router'
|
||||
import {useGlobalStatusStore} from './stores/useGlobalStatusStore'
|
||||
|
||||
import './styles/element-services.css'
|
||||
import './assets/main.css'
|
||||
|
||||
const isReservedResetPath = (pathname) => {
|
||||
let decoded = pathname.trim()
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const next = decodeURIComponent(decoded)
|
||||
if (next === decoded) break
|
||||
decoded = next
|
||||
} catch {
|
||||
break
|
||||
}
|
||||
}
|
||||
const normalized = decoded.replace(/\/+$/, '').toLowerCase()
|
||||
return normalized === '/reset' || normalized.startsWith('/reset/')
|
||||
if (window.location.pathname === '/rest' || window.location.pathname === '/rest/') {
|
||||
import('./public-recovery.js')
|
||||
} else {
|
||||
import('./main-app.js')
|
||||
}
|
||||
|
||||
// Hash History 会把 /reset#/ 解析成普通首页,启动前先切断这个回退路径。
|
||||
if (isReservedResetPath(window.location.pathname)) {
|
||||
window.history.replaceState({}, document.title, '/')
|
||||
window.location.hash = '#/reset-blocked'
|
||||
}
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
const globalStatus = useGlobalStatusStore(pinia)
|
||||
|
||||
app.use(pinia)
|
||||
|
||||
router.onError((error) => {
|
||||
console.error('Router navigation failed', error)
|
||||
globalStatus.setStartupError('页面加载失败,请刷新后重试')
|
||||
})
|
||||
|
||||
app.use(router)
|
||||
app.mount('#app')
|
||||
|
||||
4
frontend/src/public-recovery.js
Normal file
4
frontend/src/public-recovery.js
Normal file
@ -0,0 +1,4 @@
|
||||
import { createApp } from 'vue'
|
||||
import PublicPasswordReset from './views/PublicPasswordReset/PublicPasswordReset.vue'
|
||||
|
||||
createApp(PublicPasswordReset).mount('#app')
|
||||
26
frontend/src/utils/publicRecoveryHttp.js
Normal file
26
frontend/src/utils/publicRecoveryHttp.js
Normal file
@ -0,0 +1,26 @@
|
||||
const publicRecoveryAPIBase = '/rest/api/recovery'
|
||||
|
||||
export async function postPublicRecovery(endpoint, payload) {
|
||||
const response = await fetch(`${publicRecoveryAPIBase}/${endpoint}`, {
|
||||
method: 'POST',
|
||||
credentials: 'omit',
|
||||
cache: 'no-store',
|
||||
referrerPolicy: 'no-referrer',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
let result = null
|
||||
try {
|
||||
result = await response.json()
|
||||
} catch {
|
||||
result = null
|
||||
}
|
||||
|
||||
return {
|
||||
ok: response.ok,
|
||||
data: result
|
||||
}
|
||||
}
|
||||
@ -49,7 +49,7 @@ export function usePage() {
|
||||
}
|
||||
|
||||
const goForgotPassword = () => {
|
||||
router.push({ name: "forgot-password" })
|
||||
window.location.assign('/public-password-recovery')
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
218
frontend/src/views/PublicPasswordReset/PublicPasswordReset.css
Normal file
218
frontend/src/views/PublicPasswordReset/PublicPasswordReset.css
Normal file
@ -0,0 +1,218 @@
|
||||
:global(html),
|
||||
:global(body),
|
||||
:global(#app) {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
:global(body) {
|
||||
color: #172033;
|
||||
background:
|
||||
radial-gradient(circle at 0% 0%, rgba(37, 99, 235, 0.16), transparent 34%),
|
||||
linear-gradient(135deg, #eef4ff, #f8fafc 55%, #edf5ff);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
:global(*) {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.public-recovery-page {
|
||||
display: grid;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
padding: 24px;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
.public-recovery-card {
|
||||
width: min(100%, 520px);
|
||||
padding: clamp(28px, 5vw, 42px);
|
||||
background: rgba(255, 255, 255, 0.94);
|
||||
border: 1px solid rgba(148, 163, 184, 0.3);
|
||||
border-radius: 24px;
|
||||
box-shadow: 0 24px 70px rgba(30, 64, 175, 0.16);
|
||||
}
|
||||
|
||||
.public-recovery-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.public-recovery-logo {
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 14px;
|
||||
object-fit: contain;
|
||||
box-shadow: 0 6px 16px rgba(37, 99, 235, 0.2);
|
||||
}
|
||||
|
||||
.public-recovery-eyebrow {
|
||||
margin: 0 0 3px;
|
||||
color: #2563eb;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
h1,
|
||||
h2 {
|
||||
margin: 0;
|
||||
color: #111827;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: clamp(23px, 4vw, 28px);
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.public-recovery-form {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.public-recovery-copy,
|
||||
.public-recovery-complete p {
|
||||
margin: 0 0 10px;
|
||||
color: #526174;
|
||||
font-size: 14px;
|
||||
line-height: 1.65;
|
||||
}
|
||||
|
||||
label {
|
||||
margin-top: 6px;
|
||||
color: #344156;
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
min-height: 46px;
|
||||
padding: 11px 13px;
|
||||
color: #172033;
|
||||
font: inherit;
|
||||
background: #f8fafc;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 12px;
|
||||
outline: none;
|
||||
transition: border-color 0.16s ease, box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #2563eb;
|
||||
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14);
|
||||
}
|
||||
|
||||
input:disabled,
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.public-recovery-status {
|
||||
margin: 4px 0 0;
|
||||
padding: 10px 12px;
|
||||
color: #475569;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
background: #f1f5f9;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.public-recovery-status.success {
|
||||
color: #166534;
|
||||
background: #f0fdf4;
|
||||
}
|
||||
|
||||
.public-recovery-status.error {
|
||||
color: #b42318;
|
||||
background: #fff1f2;
|
||||
}
|
||||
|
||||
.public-recovery-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
|
||||
.single-action {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
padding: 0 18px;
|
||||
color: #fff;
|
||||
font: inherit;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
background: #2563eb;
|
||||
border: 1px solid #2563eb;
|
||||
border-radius: 12px;
|
||||
transition: background 0.16s ease, transform 0.16s ease;
|
||||
}
|
||||
|
||||
button:not(:disabled):hover {
|
||||
background: #1d4ed8;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.text-button {
|
||||
color: #2563eb;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.text-button:not(:disabled):hover {
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.public-recovery-complete {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 13px;
|
||||
padding: 12px 0 4px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.public-recovery-complete h2 {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
.public-recovery-success-icon {
|
||||
display: grid;
|
||||
width: 66px;
|
||||
height: 66px;
|
||||
color: #fff;
|
||||
font-size: 34px;
|
||||
font-weight: 800;
|
||||
background: #16a34a;
|
||||
border-radius: 50%;
|
||||
place-items: center;
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.public-recovery-page {
|
||||
padding: 14px;
|
||||
}
|
||||
|
||||
.public-recovery-card {
|
||||
padding: 24px 20px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.public-recovery-actions {
|
||||
align-items: stretch;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.public-recovery-actions button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
186
frontend/src/views/PublicPasswordReset/PublicPasswordReset.js
Normal file
186
frontend/src/views/PublicPasswordReset/PublicPasswordReset.js
Normal file
@ -0,0 +1,186 @@
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
|
||||
import { isValidPasswordLength } from '@/utils/password'
|
||||
import { postPublicRecovery } from '@/utils/publicRecoveryHttp'
|
||||
|
||||
const requestHash = '#/forgot-password'
|
||||
const resetHashPrefix = '#/reset-password/'
|
||||
|
||||
function currentResetToken() {
|
||||
const hash = window.location.hash
|
||||
if (!hash.startsWith(resetHashPrefix)) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const encodedToken = hash.slice(resetHashPrefix.length)
|
||||
if (!encodedToken || encodedToken.includes('/')) {
|
||||
return ''
|
||||
}
|
||||
|
||||
try {
|
||||
const token = decodeURIComponent(encodedToken)
|
||||
return token.length <= 512 ? token : ''
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function loginURL() {
|
||||
const value = document.querySelector('#app')?.dataset.loginUrl
|
||||
if (!value) {
|
||||
return '/#/login'
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
export function usePage() {
|
||||
const page = ref('request')
|
||||
const loading = ref(false)
|
||||
const submitted = ref(false)
|
||||
const available = ref(false)
|
||||
const requiresRecoveryCode = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const resetToken = ref('')
|
||||
const form = reactive({
|
||||
identifier: '',
|
||||
newPassword: '',
|
||||
confirmPassword: '',
|
||||
recoveryCode: ''
|
||||
})
|
||||
|
||||
const passwordMismatch = computed(() => form.confirmPassword !== '' && form.newPassword !== form.confirmPassword)
|
||||
const canSubmitReset = computed(() => (
|
||||
form.newPassword !== '' &&
|
||||
form.confirmPassword !== '' &&
|
||||
!passwordMismatch.value &&
|
||||
isValidPasswordLength(form.newPassword) &&
|
||||
(!requiresRecoveryCode.value || form.recoveryCode.trim() !== '')
|
||||
))
|
||||
|
||||
const resetRequestForm = () => {
|
||||
page.value = 'request'
|
||||
submitted.value = false
|
||||
available.value = false
|
||||
requiresRecoveryCode.value = false
|
||||
errorMessage.value = ''
|
||||
resetToken.value = ''
|
||||
form.newPassword = ''
|
||||
form.confirmPassword = ''
|
||||
form.recoveryCode = ''
|
||||
}
|
||||
|
||||
const loadRoute = async () => {
|
||||
const token = currentResetToken()
|
||||
if (!token) {
|
||||
if (window.location.hash !== requestHash) {
|
||||
window.history.replaceState({}, document.title, `${window.location.pathname}${window.location.search}${requestHash}`)
|
||||
}
|
||||
resetRequestForm()
|
||||
return
|
||||
}
|
||||
|
||||
page.value = 'reset'
|
||||
submitted.value = false
|
||||
available.value = false
|
||||
requiresRecoveryCode.value = false
|
||||
errorMessage.value = ''
|
||||
resetToken.value = token
|
||||
form.newPassword = ''
|
||||
form.confirmPassword = ''
|
||||
form.recoveryCode = ''
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await postPublicRecovery('requirements', { token })
|
||||
if (resetToken.value !== token || page.value !== 'reset') {
|
||||
return
|
||||
}
|
||||
if (!result.ok || !result.data) {
|
||||
errorMessage.value = '恢复链接无效或已失效,请重新申请。'
|
||||
return
|
||||
}
|
||||
requiresRecoveryCode.value = result.data.requires_recovery_code === true
|
||||
available.value = true
|
||||
} catch {
|
||||
if (resetToken.value === token && page.value === 'reset') {
|
||||
errorMessage.value = '暂时无法验证恢复链接,请稍后重试。'
|
||||
}
|
||||
} finally {
|
||||
if (resetToken.value === token) {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const requestReset = async () => {
|
||||
if (loading.value || !form.identifier.trim()) {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
await postPublicRecovery('begin', { identifier: form.identifier.trim() })
|
||||
submitted.value = true
|
||||
} catch {
|
||||
submitted.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const completeReset = async () => {
|
||||
if (loading.value || !canSubmitReset.value || !resetToken.value) {
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
errorMessage.value = ''
|
||||
try {
|
||||
const result = await postPublicRecovery('complete', {
|
||||
token: resetToken.value,
|
||||
new_password: form.newPassword,
|
||||
recovery_code: requiresRecoveryCode.value ? form.recoveryCode.trim() : ''
|
||||
})
|
||||
if (!result.ok || result.data?.reset !== true) {
|
||||
errorMessage.value = '重置失败。恢复链接可能已失效,请重新申请。'
|
||||
return
|
||||
}
|
||||
resetToken.value = ''
|
||||
form.newPassword = ''
|
||||
form.confirmPassword = ''
|
||||
form.recoveryCode = ''
|
||||
page.value = 'success'
|
||||
} catch {
|
||||
errorMessage.value = '重置失败。恢复链接可能已失效,请重新申请。'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goLogin = () => window.location.assign(loginURL())
|
||||
const goRequest = () => window.location.assign(`${window.location.pathname}${window.location.search}${requestHash}`)
|
||||
|
||||
onMounted(() => {
|
||||
void loadRoute()
|
||||
window.addEventListener('hashchange', loadRoute)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('hashchange', loadRoute)
|
||||
resetToken.value = ''
|
||||
})
|
||||
|
||||
return {
|
||||
page,
|
||||
loading,
|
||||
submitted,
|
||||
available,
|
||||
requiresRecoveryCode,
|
||||
errorMessage,
|
||||
form,
|
||||
passwordMismatch,
|
||||
canSubmitReset,
|
||||
requestReset,
|
||||
completeReset,
|
||||
goLogin,
|
||||
goRequest
|
||||
}
|
||||
}
|
||||
111
frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue
Normal file
111
frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue
Normal file
@ -0,0 +1,111 @@
|
||||
<template>
|
||||
<main class="public-recovery-page">
|
||||
<section class="public-recovery-card" aria-labelledby="public-recovery-title">
|
||||
<header class="public-recovery-header">
|
||||
<img class="public-recovery-logo" src="/smail-logo.png" alt="Smail" />
|
||||
<div>
|
||||
<p class="public-recovery-eyebrow">Smail 安全恢复</p>
|
||||
<h1 id="public-recovery-title">{{ page === 'request' ? '找回登录密码' : '重设登录密码' }}</h1>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<form v-if="page === 'request'" class="public-recovery-form" @submit.prevent="requestReset">
|
||||
<p class="public-recovery-copy">输入账号或完整邮箱。若该账号允许外部邮箱恢复,系统会向已验证的安全邮箱发送一次性恢复链接。</p>
|
||||
<label for="public-recovery-identifier">账号或完整邮箱</label>
|
||||
<input
|
||||
id="public-recovery-identifier"
|
||||
v-model="form.identifier"
|
||||
autocomplete="username"
|
||||
maxlength="320"
|
||||
placeholder="例如:name@example.com"
|
||||
:disabled="loading"
|
||||
required
|
||||
/>
|
||||
<p v-if="submitted" class="public-recovery-status success" role="status">请求已受理。若信息匹配,请检查已验证的外部安全邮箱。</p>
|
||||
<div class="public-recovery-actions">
|
||||
<button type="button" class="text-button" :disabled="loading" @click="goLogin">返回登录</button>
|
||||
<button type="submit" :disabled="loading || !form.identifier.trim()">{{ loading ? '正在提交…' : '发送恢复链接' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<section v-else-if="page === 'reset'" class="public-recovery-form" aria-live="polite">
|
||||
<p class="public-recovery-copy">请设置新的登录密码。为保护账号安全,此链接仅可使用一次。</p>
|
||||
<p v-if="loading" class="public-recovery-status">正在验证恢复链接…</p>
|
||||
<template v-else-if="available">
|
||||
<label for="public-recovery-password">新密码</label>
|
||||
<input
|
||||
id="public-recovery-password"
|
||||
v-model="form.newPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
minlength="8"
|
||||
maxlength="12"
|
||||
placeholder="8 至 12 位密码"
|
||||
/>
|
||||
<label for="public-recovery-confirm-password">确认新密码</label>
|
||||
<input
|
||||
id="public-recovery-confirm-password"
|
||||
v-model="form.confirmPassword"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
minlength="8"
|
||||
maxlength="12"
|
||||
placeholder="再次输入新密码"
|
||||
/>
|
||||
<p v-if="passwordMismatch" class="public-recovery-status error">两次输入的新密码不一致。</p>
|
||||
<template v-if="requiresRecoveryCode">
|
||||
<label for="public-recovery-code">离线恢复码</label>
|
||||
<input
|
||||
id="public-recovery-code"
|
||||
v-model="form.recoveryCode"
|
||||
type="password"
|
||||
autocomplete="one-time-code"
|
||||
maxlength="64"
|
||||
placeholder="输入未使用的离线恢复码"
|
||||
/>
|
||||
</template>
|
||||
<p v-if="errorMessage" class="public-recovery-status error">{{ errorMessage }}</p>
|
||||
<div class="public-recovery-actions">
|
||||
<button type="button" class="text-button" :disabled="loading" @click="goRequest">重新申请</button>
|
||||
<button type="button" :disabled="loading || !canSubmitReset" @click="completeReset">{{ loading ? '正在重置…' : '确认重置' }}</button>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p class="public-recovery-status error">{{ errorMessage || '恢复链接无效或已失效,请重新申请。' }}</p>
|
||||
<div class="public-recovery-actions single-action">
|
||||
<button type="button" @click="goRequest">重新申请恢复链接</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<section v-else class="public-recovery-complete" aria-live="polite">
|
||||
<div class="public-recovery-success-icon" aria-hidden="true">✓</div>
|
||||
<h2>密码已重置</h2>
|
||||
<p>请使用新密码重新登录系统。</p>
|
||||
<button type="button" @click="goLogin">前往登录</button>
|
||||
</section>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { usePage } from './PublicPasswordReset.js'
|
||||
|
||||
const {
|
||||
page,
|
||||
loading,
|
||||
submitted,
|
||||
available,
|
||||
requiresRecoveryCode,
|
||||
errorMessage,
|
||||
form,
|
||||
passwordMismatch,
|
||||
canSubmitReset,
|
||||
requestReset,
|
||||
completeReset,
|
||||
goLogin,
|
||||
goRequest
|
||||
} = usePage()
|
||||
</script>
|
||||
|
||||
<style scoped src="./PublicPasswordReset.css"></style>
|
||||
Loading…
Reference in New Issue
Block a user