From 581e70b2ca57fc281f3c95b63e1bccbc54faa8ca Mon Sep 17 00:00:00 2001 From: smail Date: Wed, 16 Sep 2026 13:06:15 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E7=8B=AC=E7=AB=8B=E5=8C=BF?= =?UTF-8?q?=E5=90=8D=E5=AF=86=E7=A0=81=E6=81=A2=E5=A4=8D=E9=A1=B5=E9=9D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/listen/http_server/public_recovery.go | 6 +- .../http_server/public_recovery_test.go | 28 +++ frontend/src/main-app.js | 43 ++++ frontend/src/main.js | 46 +--- frontend/src/public-recovery.js | 4 + frontend/src/utils/publicRecoveryHttp.js | 26 +++ frontend/src/views/Login/Login.js | 2 +- .../PublicPasswordReset.css | 218 ++++++++++++++++++ .../PublicPasswordReset.js | 186 +++++++++++++++ .../PublicPasswordReset.vue | 111 +++++++++ 10 files changed, 624 insertions(+), 46 deletions(-) create mode 100644 frontend/src/main-app.js create mode 100644 frontend/src/public-recovery.js create mode 100644 frontend/src/utils/publicRecoveryHttp.js create mode 100644 frontend/src/views/PublicPasswordReset/PublicPasswordReset.css create mode 100644 frontend/src/views/PublicPasswordReset/PublicPasswordReset.js create mode 100644 frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue diff --git a/backend/listen/http_server/public_recovery.go b/backend/listen/http_server/public_recovery.go index cf3fada..1d302f8 100644 --- a/backend/listen/http_server/public_recovery.go +++ b/backend/listen/http_server/public_recovery.go @@ -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) { diff --git a/backend/listen/http_server/public_recovery_test.go b/backend/listen/http_server/public_recovery_test.go index e2d435e..a1e6ff2 100644 --- a/backend/listen/http_server/public_recovery_test.go +++ b/backend/listen/http_server/public_recovery_test.go @@ -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() diff --git a/frontend/src/main-app.js b/frontend/src/main-app.js new file mode 100644 index 0000000..1efd517 --- /dev/null +++ b/frontend/src/main-app.js @@ -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') diff --git a/frontend/src/main.js b/frontend/src/main.js index 70dba9a..c6d95f8 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -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') diff --git a/frontend/src/public-recovery.js b/frontend/src/public-recovery.js new file mode 100644 index 0000000..f9a7aa0 --- /dev/null +++ b/frontend/src/public-recovery.js @@ -0,0 +1,4 @@ +import { createApp } from 'vue' +import PublicPasswordReset from './views/PublicPasswordReset/PublicPasswordReset.vue' + +createApp(PublicPasswordReset).mount('#app') diff --git a/frontend/src/utils/publicRecoveryHttp.js b/frontend/src/utils/publicRecoveryHttp.js new file mode 100644 index 0000000..55b4ba9 --- /dev/null +++ b/frontend/src/utils/publicRecoveryHttp.js @@ -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 + } +} diff --git a/frontend/src/views/Login/Login.js b/frontend/src/views/Login/Login.js index 3f0a312..2ee72ae 100644 --- a/frontend/src/views/Login/Login.js +++ b/frontend/src/views/Login/Login.js @@ -49,7 +49,7 @@ export function usePage() { } const goForgotPassword = () => { - router.push({ name: "forgot-password" }) + window.location.assign('/public-password-recovery') } return { diff --git a/frontend/src/views/PublicPasswordReset/PublicPasswordReset.css b/frontend/src/views/PublicPasswordReset/PublicPasswordReset.css new file mode 100644 index 0000000..f0aaba1 --- /dev/null +++ b/frontend/src/views/PublicPasswordReset/PublicPasswordReset.css @@ -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%; + } +} diff --git a/frontend/src/views/PublicPasswordReset/PublicPasswordReset.js b/frontend/src/views/PublicPasswordReset/PublicPasswordReset.js new file mode 100644 index 0000000..49854bc --- /dev/null +++ b/frontend/src/views/PublicPasswordReset/PublicPasswordReset.js @@ -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 + } +} diff --git a/frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue b/frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue new file mode 100644 index 0000000..9a63fc5 --- /dev/null +++ b/frontend/src/views/PublicPasswordReset/PublicPasswordReset.vue @@ -0,0 +1,111 @@ + + + + +