From 12b067632feae8fe6650519cc4ddb9f84de9d6f4 Mon Sep 17 00:00:00 2001 From: skytek_xinliang Date: Tue, 6 Oct 2026 09:29:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BF=98=E8=A8=98=E5=AF=86=E7=A2=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/citizen-frontend/src/api/auth.ts | 35 +++ app/citizen-frontend/src/api/types.ts | 18 ++ .../components/auth/ForgotPasswordForm.vue | 59 +++++ .../components/auth/PasswordRecoveryShell.vue | 45 ++++ .../src/components/auth/ResetPasswordForm.vue | 79 ++++++ .../src/components/layout/DefaultLayout.vue | 129 ++++----- .../src/composables/useForgotPassword.ts | 26 ++ .../src/composables/usePasswordReset.ts | 87 ++++++ .../composables/usePasswordResetFeedback.ts | 57 ++++ .../src/composables/useRetryCountdown.ts | 10 +- app/citizen-frontend/src/main.ts | 5 +- .../src/pages/change-password.vue | 54 ++++ .../src/pages/complete-email.vue | 66 ++--- .../src/pages/forgot-password.vue | 107 ++++++++ app/citizen-frontend/src/pages/index.vue | 13 + app/citizen-frontend/src/pages/login.vue | 11 + app/citizen-frontend/src/router/index.ts | 247 +++++++++++------- app/citizen-frontend/src/stores/app.ts | 4 +- app/citizen-frontend/src/utils/api-error.ts | 37 ++- .../src/utils/password-reset-token.ts | 61 +++++ 20 files changed, 916 insertions(+), 234 deletions(-) create mode 100644 app/citizen-frontend/src/components/auth/ForgotPasswordForm.vue create mode 100644 app/citizen-frontend/src/components/auth/PasswordRecoveryShell.vue create mode 100644 app/citizen-frontend/src/components/auth/ResetPasswordForm.vue create mode 100644 app/citizen-frontend/src/composables/useForgotPassword.ts create mode 100644 app/citizen-frontend/src/composables/usePasswordReset.ts create mode 100644 app/citizen-frontend/src/composables/usePasswordResetFeedback.ts create mode 100644 app/citizen-frontend/src/pages/change-password.vue create mode 100644 app/citizen-frontend/src/pages/forgot-password.vue create mode 100644 app/citizen-frontend/src/utils/password-reset-token.ts diff --git a/app/citizen-frontend/src/api/auth.ts b/app/citizen-frontend/src/api/auth.ts index af6978a..90c39ef 100644 --- a/app/citizen-frontend/src/api/auth.ts +++ b/app/citizen-frontend/src/api/auth.ts @@ -3,6 +3,10 @@ import type { AuthResponse, CheckPhoneRequest, CheckPhoneResponse, + ForgotPasswordRequest, + ForgotPasswordResponse, + ResetPasswordRequest, + ResetPasswordResponse, LoginRequest, LogoutResponse, RegisterRequest, @@ -36,6 +40,37 @@ export function updateEmail(email: string): Promise { .json(); } +// 禁止一般自動重送;共用 hook 仍可強制進行一次 CSRF 恢復。 +const passwordResetRetry = { limit: 0, methods: [], retryOnTimeout: false }; + +/** 申請重設信;前端一律顯示相同的接受訊息。 */ +export function forgotPassword( + request: ForgotPasswordRequest, + signal?: AbortSignal, +): Promise { + return api + .post("v1/auth/forgot-password", { + json: request, + signal, + retry: passwordResetRetry, + }) + .json(); +} + +/** 使用 Email 連結的 opaque Token 重設密碼,不自動登入。 */ +export function resetPassword( + request: ResetPasswordRequest, + signal?: AbortSignal, +): Promise { + return api + .post("v1/auth/reset-password", { + json: request, + signal, + retry: passwordResetRetry, + }) + .json(); +} + /** 呼叫後端登出並讓後端清除登入 Cookie。 */ export function logout(): Promise { return api diff --git a/app/citizen-frontend/src/api/types.ts b/app/citizen-frontend/src/api/types.ts index 1fa0950..9a8b8b7 100644 --- a/app/citizen-frontend/src/api/types.ts +++ b/app/citizen-frontend/src/api/types.ts @@ -157,6 +157,24 @@ export interface UpdateEmailResponse { /** 舊名稱保留為相容別名,新的 API 請使用 AuthResponse。 */ export type LoginResponse = AuthResponse; +/** 忘記密碼申請;成功不代表帳號存在或寄信成功。 */ +export interface ForgotPasswordRequest { + phone: string; +} + +export interface ForgotPasswordResponse { + message: string; +} + +export interface ResetPasswordRequest { + token: string; + newPassword: string; +} + +export interface ResetPasswordResponse { + message: string; +} + /** 登出成功回應。 */ export interface LogoutResponse { messageCode: string; diff --git a/app/citizen-frontend/src/components/auth/ForgotPasswordForm.vue b/app/citizen-frontend/src/components/auth/ForgotPasswordForm.vue new file mode 100644 index 0000000..4700eeb --- /dev/null +++ b/app/citizen-frontend/src/components/auth/ForgotPasswordForm.vue @@ -0,0 +1,59 @@ + + + diff --git a/app/citizen-frontend/src/components/auth/PasswordRecoveryShell.vue b/app/citizen-frontend/src/components/auth/PasswordRecoveryShell.vue new file mode 100644 index 0000000..9ea02be --- /dev/null +++ b/app/citizen-frontend/src/components/auth/PasswordRecoveryShell.vue @@ -0,0 +1,45 @@ + + + diff --git a/app/citizen-frontend/src/components/auth/ResetPasswordForm.vue b/app/citizen-frontend/src/components/auth/ResetPasswordForm.vue new file mode 100644 index 0000000..b81f85a --- /dev/null +++ b/app/citizen-frontend/src/components/auth/ResetPasswordForm.vue @@ -0,0 +1,79 @@ + + + diff --git a/app/citizen-frontend/src/components/layout/DefaultLayout.vue b/app/citizen-frontend/src/components/layout/DefaultLayout.vue index 1ac462d..48c7623 100644 --- a/app/citizen-frontend/src/components/layout/DefaultLayout.vue +++ b/app/citizen-frontend/src/components/layout/DefaultLayout.vue @@ -6,9 +6,7 @@ - - 運動玩轉健康力 - + 運動玩轉健康力 @@ -66,9 +61,7 @@ - 未登入訪客 + 未登入訪客 @@ -81,12 +74,7 @@ 大字體模式 @@ -161,7 +149,7 @@ v-if="store.requiresEmailCompletion" color="warning" prepend-icon="mdi-email-alert-outline" - title="補登 E-Mail" + title="註冊 E-Mail" :to="emailCompletionLocation" @click="drawer = false" /> @@ -203,16 +191,11 @@ - + {{ EMAIL_COMPLETION_REQUIRED - ? "請先補登 E-Mail,才能繼續使用平台。" - : "尚未設定 E-Mail,前往補登以接收重要通知。" + ? '請先註冊 E-Mail,才能繼續使用平台。' + : '尚未設定 E-Mail,前往註冊以接收重要通知。' }} - + 前往補登 @@ -252,9 +230,7 @@ tag="div" > - mdi-view-dashboard + mdi-view-dashboard 首頁 @@ -270,25 +246,13 @@ 自評 - - mdi-qrcode-scan + + mdi-qrcode-scan 量測 - - mdi-calendar-check + + mdi-calendar-check 預約 @@ -296,9 +260,7 @@ - + 個資同意與資料授權 @@ -331,8 +293,8 @@ diff --git a/app/citizen-frontend/src/composables/useForgotPassword.ts b/app/citizen-frontend/src/composables/useForgotPassword.ts new file mode 100644 index 0000000..69bac6f --- /dev/null +++ b/app/citizen-frontend/src/composables/useForgotPassword.ts @@ -0,0 +1,26 @@ +import { onScopeDispose, shallowRef } from "vue"; +import { forgotPassword } from "@/api/auth"; +import { usePasswordResetFeedback } from "@/composables/usePasswordResetFeedback"; + +export function useForgotPassword() { + const feedback = usePasswordResetFeedback(); + const accepted = shallowRef(false); + const controller = new AbortController(); + + async function submit(phone: string) { + if (feedback.disabled.value || accepted.value) return; + feedback.clearErrors(); + feedback.loading.value = true; + try { + await forgotPassword({ phone }, controller.signal); + if (!controller.signal.aborted) accepted.value = true; + } catch (error) { + if (!controller.signal.aborted) feedback.handleError(error, "Phone"); + } finally { + feedback.loading.value = false; + } + } + + onScopeDispose(() => controller.abort()); + return { ...feedback, accepted, submit }; +} diff --git a/app/citizen-frontend/src/composables/usePasswordReset.ts b/app/citizen-frontend/src/composables/usePasswordReset.ts new file mode 100644 index 0000000..500716b --- /dev/null +++ b/app/citizen-frontend/src/composables/usePasswordReset.ts @@ -0,0 +1,87 @@ +import { onScopeDispose, shallowRef } from "vue"; +import { useRouter } from "vue-router"; +import { resetPassword } from "@/api/auth"; +import { clearCsrfToken } from "@/api/client"; +import { usePasswordResetFeedback } from "@/composables/usePasswordResetFeedback"; +import { useAppStore } from "@/stores/app"; +import { useCourseStore } from "@/stores/course"; +import { getApiErrorCode } from "@/utils/api-error"; +import { + clearPasswordResetToken, + subscribePasswordResetToken, + takePasswordResetToken, +} from "@/utils/password-reset-token"; + +export function usePasswordReset() { + const router = useRouter(); + const store = useAppStore(); + const courseStore = useCourseStore(); + const feedback = usePasswordResetFeedback(); + const hasToken = shallowRef(false); + const formVersion = shallowRef(0); + // 敏感 Token 不回傳給 template,也不放入 reactive 狀態。 + let token: string | null = null; + let controller: AbortController | null = null; + + function receiveToken() { + const pending = takePasswordResetToken(); + if (!pending) return; + controller?.abort(); + controller = null; + token = pending.token; + hasToken.value = token !== null; + feedback.loading.value = false; + feedback.clearErrors(); + formVersion.value += 1; + } + + receiveToken(); + const unsubscribe = subscribePasswordResetToken(receiveToken); + + async function submit(newPassword: string) { + if (!token || feedback.disabled.value) return; + const requestController = new AbortController(); + controller = requestController; + feedback.clearErrors(); + feedback.loading.value = true; + try { + await resetPassword({ token, newPassword }, requestController.signal); + if (requestController.signal.aborted) return; + token = null; + hasToken.value = false; + formVersion.value += 1; + clearPasswordResetToken(); + store.clearAuthentication({ skipSessionRestore: true }); + courseStore.resetGroup(); + clearCsrfToken(); + await router.replace({ + name: "login", + query: { passwordReset: "success" }, + }); + } catch (error) { + if (requestController.signal.aborted) return; + if (getApiErrorCode(error) === "PASSWORD_RESET_TOKEN_INVALID") { + token = null; + hasToken.value = false; + formVersion.value += 1; + clearPasswordResetToken(); + } else { + feedback.handleError(error, "NewPassword"); + } + } finally { + if (controller === requestController) { + feedback.loading.value = false; + controller = null; + } + } + } + + onScopeDispose(() => { + controller?.abort(); + token = null; + clearPasswordResetToken(); + unsubscribe(); + }); + + return { ...feedback, hasToken, formVersion, submit }; +} diff --git a/app/citizen-frontend/src/composables/usePasswordResetFeedback.ts b/app/citizen-frontend/src/composables/usePasswordResetFeedback.ts new file mode 100644 index 0000000..c760a00 --- /dev/null +++ b/app/citizen-frontend/src/composables/usePasswordResetFeedback.ts @@ -0,0 +1,57 @@ +import { computed, onScopeDispose, shallowRef } from "vue"; +import { useRetryCountdown } from "@/composables/useRetryCountdown"; +import { + getApiErrorCode, + getApiErrorStatus, + getApiFieldErrors, + getApiRetryAfterSeconds, +} from "@/utils/api-error"; + +export function usePasswordResetFeedback() { + const loading = shallowRef(false); + const errorMessage = shallowRef(""); + const fieldErrors = shallowRef([]); + const { remainingSeconds, isWaiting, start } = useRetryCountdown(); + const disabled = computed(() => loading.value || isWaiting.value); + + function clearErrors() { + errorMessage.value = ""; + fieldErrors.value = []; + } + + function showCsrfRecovery() { + errorMessage.value = + "安全驗證失敗,請再試一次;若仍無法完成,請重新整理並重新開啟信件連結。"; + } + + function handleError(error: unknown, field: string) { + if (getApiErrorStatus(error) === 422) { + fieldErrors.value = getApiFieldErrors(error, field); + if (!fieldErrors.value.length) + errorMessage.value = "欄位驗證失敗,請檢查輸入內容。"; + } else if (getApiErrorStatus(error) === 429) { + const seconds = getApiRetryAfterSeconds(error); + if (seconds !== null) start(seconds); + errorMessage.value = "請求次數過多,請稍後再試。"; + } else if (getApiErrorCode(error) === "CSRF_VALIDATION_FAILED") { + showCsrfRecovery(); + } else if (!errorMessage.value) { + errorMessage.value = "操作失敗,請稍後再試。"; + } + } + + window.addEventListener("csrf-recovery-required", showCsrfRecovery); + onScopeDispose(() => + window.removeEventListener("csrf-recovery-required", showCsrfRecovery), + ); + + return { + loading, + errorMessage, + fieldErrors, + remainingSeconds, + disabled, + clearErrors, + handleError, + }; +} diff --git a/app/citizen-frontend/src/composables/useRetryCountdown.ts b/app/citizen-frontend/src/composables/useRetryCountdown.ts index 1a45949..e1da57e 100644 --- a/app/citizen-frontend/src/composables/useRetryCountdown.ts +++ b/app/citizen-frontend/src/composables/useRetryCountdown.ts @@ -15,14 +15,20 @@ export function useRetryCountdown() { function start(seconds: number) { clearTimer(); - remainingSeconds.value = Math.max(0, Math.ceil(seconds)); + remainingSeconds.value = Number.isFinite(seconds) + ? Math.max(0, Math.ceil(seconds)) + : 0; if (remainingSeconds.value === 0) { return; } + const deadline = Date.now() + remainingSeconds.value * 1000; intervalID = setInterval(() => { - remainingSeconds.value = Math.max(0, remainingSeconds.value - 1); + remainingSeconds.value = Math.max( + 0, + Math.ceil((deadline - Date.now()) / 1000), + ); if (remainingSeconds.value === 0) { clearTimer(); } diff --git a/app/citizen-frontend/src/main.ts b/app/citizen-frontend/src/main.ts index dd7266e..58ef54e 100644 --- a/app/citizen-frontend/src/main.ts +++ b/app/citizen-frontend/src/main.ts @@ -10,6 +10,7 @@ import { createApp } from "vue"; // Plugins import { registerPlugins } from "@/plugins"; import { useAppStore } from "@/stores/app"; +import { isPublicPasswordPath } from "@/utils/password-reset-token"; // Components import App from "./App.vue"; @@ -23,7 +24,9 @@ registerPlugins(app); async function bootstrap() { const store = useAppStore(); - await store.initializeAuthentication(); + if (!isPublicPasswordPath(window.location.pathname)) { + await store.initializeAuthentication(); + } app.mount("#app"); } diff --git a/app/citizen-frontend/src/pages/change-password.vue b/app/citizen-frontend/src/pages/change-password.vue new file mode 100644 index 0000000..27332e1 --- /dev/null +++ b/app/citizen-frontend/src/pages/change-password.vue @@ -0,0 +1,54 @@ + + + diff --git a/app/citizen-frontend/src/pages/complete-email.vue b/app/citizen-frontend/src/pages/complete-email.vue index 3d62a11..c0a9034 100644 --- a/app/citizen-frontend/src/pages/complete-email.vue +++ b/app/citizen-frontend/src/pages/complete-email.vue @@ -1,58 +1,54 @@ @@ -62,13 +58,9 @@ async function skipForNow() { - - 補登 E-Mail - + 註冊 E-Mail -

- 設定 E-Mail,有助於日後接收帳號安全與重要服務通知。 -

+

填寫 E-Mail,有助於日後接收帳號安全與重要服務通知。

+import ForgotPasswordForm from '@/components/auth/ForgotPasswordForm.vue' +import PasswordRecoveryShell from '@/components/auth/PasswordRecoveryShell.vue' +import { useForgotPassword } from '@/composables/useForgotPassword' +const recoverySteps = [ + { title: '輸入已註冊手機門號', icon: 'mdi-phone' }, + { title: '收起E-Mail,使用連結進行重設密碼', icon: 'mdi-email-outline' }, +] + +const { accepted, loading, disabled, errorMessage, fieldErrors, remainingSeconds, submit } = + useForgotPassword() + + + + + diff --git a/app/citizen-frontend/src/pages/index.vue b/app/citizen-frontend/src/pages/index.vue index bb39a73..f5d4613 100644 --- a/app/citizen-frontend/src/pages/index.vue +++ b/app/citizen-frontend/src/pages/index.vue @@ -198,6 +198,19 @@ 既有帳號建立密碼
+ + + + 忘記密碼 + +
diff --git a/app/citizen-frontend/src/pages/login.vue b/app/citizen-frontend/src/pages/login.vue index 53e0183..5e0cc98 100644 --- a/app/citizen-frontend/src/pages/login.vue +++ b/app/citizen-frontend/src/pages/login.vue @@ -19,6 +19,14 @@ 登入 請輸入您的手機號碼與登入密碼 + + 密碼已更新,請使用新密碼登入。 + 既有帳號建立密碼 + + 忘記密碼 +