feat: 忘記密碼
This commit is contained in:
@@ -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<UpdateEmailResponse> {
|
||||
.json<UpdateEmailResponse>();
|
||||
}
|
||||
|
||||
// 禁止一般自動重送;共用 hook 仍可強制進行一次 CSRF 恢復。
|
||||
const passwordResetRetry = { limit: 0, methods: [], retryOnTimeout: false };
|
||||
|
||||
/** 申請重設信;前端一律顯示相同的接受訊息。 */
|
||||
export function forgotPassword(
|
||||
request: ForgotPasswordRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ForgotPasswordResponse> {
|
||||
return api
|
||||
.post("v1/auth/forgot-password", {
|
||||
json: request,
|
||||
signal,
|
||||
retry: passwordResetRetry,
|
||||
})
|
||||
.json<ForgotPasswordResponse>();
|
||||
}
|
||||
|
||||
/** 使用 Email 連結的 opaque Token 重設密碼,不自動登入。 */
|
||||
export function resetPassword(
|
||||
request: ResetPasswordRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<ResetPasswordResponse> {
|
||||
return api
|
||||
.post("v1/auth/reset-password", {
|
||||
json: request,
|
||||
signal,
|
||||
retry: passwordResetRetry,
|
||||
})
|
||||
.json<ResetPasswordResponse>();
|
||||
}
|
||||
|
||||
/** 呼叫後端登出並讓後端清除登入 Cookie。 */
|
||||
export function logout(): Promise<LogoutResponse> {
|
||||
return api
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, shallowRef } from "vue";
|
||||
const props = defineProps<{
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
errors: string[];
|
||||
remainingSeconds: number;
|
||||
}>();
|
||||
const emit = defineEmits<{ submit: [phone: string] }>();
|
||||
const phone = shallowRef("");
|
||||
const localError = shallowRef("");
|
||||
const fieldErrors = computed(() =>
|
||||
localError.value ? [localError.value] : props.errors,
|
||||
);
|
||||
|
||||
function handleSubmit() {
|
||||
if (props.disabled) return;
|
||||
localError.value = "";
|
||||
const normalizedPhone = phone.value.replace(/\D/g, "");
|
||||
if (!/^09\d{8}$/.test(normalizedPhone)) {
|
||||
localError.value = "請輸入正確的手機門號格式(09 開頭,共 10 碼)。";
|
||||
return;
|
||||
}
|
||||
emit("submit", normalizedPhone);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-form @submit.prevent="handleSubmit">
|
||||
<v-text-field
|
||||
v-model="phone"
|
||||
autocomplete="tel-national"
|
||||
inputmode="tel"
|
||||
class="mb-4"
|
||||
color="primary"
|
||||
:disabled="loading"
|
||||
:error-messages="fieldErrors"
|
||||
label="手機門號"
|
||||
placeholder="例如:0912345678"
|
||||
prepend-inner-icon="mdi-phone"
|
||||
required
|
||||
rounded="lg"
|
||||
variant="outlined"
|
||||
@update:model-value="localError = ''"
|
||||
/>
|
||||
<v-btn
|
||||
block
|
||||
class="rounded-lg font-weight-bold"
|
||||
color="secondary"
|
||||
:disabled="disabled"
|
||||
:loading="loading"
|
||||
type="submit"
|
||||
>
|
||||
{{
|
||||
remainingSeconds > 0 ? `${remainingSeconds} 秒後再試` : "寄送密碼重設信"
|
||||
}}
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</template>
|
||||
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import BrandAvatar from "@/components/BrandAvatar.vue";
|
||||
defineProps<{ title: string; description?: string }>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-app class="bg-background">
|
||||
<v-container>
|
||||
<v-row justify="center" no-gutters>
|
||||
<v-col cols="12" sm="8" md="6" lg="5">
|
||||
<v-card class="bg-transparent text-center" variant="flat">
|
||||
<BrandAvatar />
|
||||
<v-card-title class="text-primary font-weight-bold"
|
||||
>運動玩轉健康力</v-card-title
|
||||
>
|
||||
<v-card-subtitle class="text-primary"
|
||||
>Taipei Masters Hub</v-card-subtitle
|
||||
>
|
||||
</v-card>
|
||||
<v-card class="rounded-lg mt-4" color="surface">
|
||||
<v-card-title class="text-center">{{ title }}</v-card-title>
|
||||
<v-card-text>
|
||||
<slot name="description">
|
||||
<p v-if="description" class="text-body-1 mb-5">
|
||||
{{ description }}
|
||||
</p>
|
||||
</slot>
|
||||
<slot />
|
||||
<v-btn
|
||||
block
|
||||
class="mt-4"
|
||||
color="primary"
|
||||
to="/login"
|
||||
variant="text"
|
||||
>
|
||||
返回登入
|
||||
</v-btn>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
<p class="mt-8 text-center text-grey-darken-1">© 2026 SKYTEK</p>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-container>
|
||||
</v-app>
|
||||
</template>
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onScopeDispose, shallowRef } from "vue";
|
||||
const props = defineProps<{
|
||||
loading: boolean;
|
||||
disabled: boolean;
|
||||
errors: string[];
|
||||
remainingSeconds: number;
|
||||
}>();
|
||||
const emit = defineEmits<{ submit: [newPassword: string] }>();
|
||||
const newPassword = shallowRef("");
|
||||
const confirmPassword = shallowRef("");
|
||||
const passwordError = shallowRef("");
|
||||
const confirmationError = shallowRef("");
|
||||
const fieldErrors = computed(() =>
|
||||
passwordError.value ? [passwordError.value] : props.errors,
|
||||
);
|
||||
|
||||
function handleSubmit() {
|
||||
if (props.disabled) return;
|
||||
passwordError.value =
|
||||
newPassword.value.length < 8 ? "密碼至少需要 8 碼。" : "";
|
||||
confirmationError.value =
|
||||
newPassword.value !== confirmPassword.value ? "兩次輸入的密碼不一致。" : "";
|
||||
if (passwordError.value || confirmationError.value) return;
|
||||
emit("submit", newPassword.value);
|
||||
}
|
||||
|
||||
onScopeDispose(() => {
|
||||
newPassword.value = "";
|
||||
confirmPassword.value = "";
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<v-form @submit.prevent="handleSubmit">
|
||||
<v-text-field
|
||||
v-model="newPassword"
|
||||
autocomplete="new-password"
|
||||
class="mb-4"
|
||||
color="primary"
|
||||
:disabled="loading"
|
||||
:error-messages="fieldErrors"
|
||||
label="新密碼"
|
||||
hint="密碼至少需要 8 碼"
|
||||
persistent-hint
|
||||
prepend-inner-icon="mdi-lock-outline"
|
||||
required
|
||||
rounded="lg"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
@update:model-value="passwordError = ''"
|
||||
/>
|
||||
<v-text-field
|
||||
v-model="confirmPassword"
|
||||
autocomplete="new-password"
|
||||
class="mb-4"
|
||||
color="primary"
|
||||
:disabled="loading"
|
||||
:error-messages="confirmationError"
|
||||
label="確認新密碼"
|
||||
prepend-inner-icon="mdi-lock-check-outline"
|
||||
required
|
||||
rounded="lg"
|
||||
type="password"
|
||||
variant="outlined"
|
||||
@update:model-value="confirmationError = ''"
|
||||
/>
|
||||
<v-btn
|
||||
block
|
||||
class="rounded-lg font-weight-bold"
|
||||
color="secondary"
|
||||
:disabled="disabled"
|
||||
:loading="loading"
|
||||
type="submit"
|
||||
>
|
||||
{{ remainingSeconds > 0 ? `${remainingSeconds} 秒後再試` : "重設密碼" }}
|
||||
</v-btn>
|
||||
</v-form>
|
||||
</template>
|
||||
@@ -6,9 +6,7 @@
|
||||
<v-app-bar-nav-icon variant="text" @click="drawer = !drawer" />
|
||||
</template>
|
||||
|
||||
<v-app-bar-title class="font-weight-bold flex-grow-1">
|
||||
運動玩轉健康力
|
||||
</v-app-bar-title>
|
||||
<v-app-bar-title class="font-weight-bold flex-grow-1"> 運動玩轉健康力 </v-app-bar-title>
|
||||
|
||||
<template #append>
|
||||
<div class="d-flex align-center">
|
||||
@@ -21,10 +19,7 @@
|
||||
variant="text"
|
||||
@click="isLargeText = !isLargeText"
|
||||
>
|
||||
<v-icon
|
||||
:color="isLargeText ? 'accent' : 'white'"
|
||||
icon="mdi-format-size"
|
||||
/>
|
||||
<v-icon :color="isLargeText ? 'accent' : 'white'" icon="mdi-format-size" />
|
||||
</v-btn>
|
||||
</div>
|
||||
</template>
|
||||
@@ -66,9 +61,7 @@
|
||||
</v-avatar>
|
||||
</template>
|
||||
|
||||
<v-list-item-subtitle class="text-white-50"
|
||||
>未登入訪客</v-list-item-subtitle
|
||||
>
|
||||
<v-list-item-subtitle class="text-white-50">未登入訪客</v-list-item-subtitle>
|
||||
</v-list-item>
|
||||
|
||||
<v-divider />
|
||||
@@ -81,12 +74,7 @@
|
||||
<v-list-item-title>大字體模式</v-list-item-title>
|
||||
|
||||
<template #append>
|
||||
<v-switch
|
||||
v-model="isLargeText"
|
||||
color="secondary"
|
||||
density="compact"
|
||||
hide-details
|
||||
/>
|
||||
<v-switch v-model="isLargeText" color="secondary" density="compact" hide-details />
|
||||
</template>
|
||||
</v-list-item>
|
||||
|
||||
@@ -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 @@
|
||||
</v-navigation-drawer>
|
||||
|
||||
<!-- 主要內容區 -->
|
||||
<v-main
|
||||
class="bg-white min-vh-100"
|
||||
:class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }"
|
||||
>
|
||||
<v-main class="bg-white min-vh-100" :class="{ 'pb-16': BOTTOM_NAVIGATION_ENABLED }">
|
||||
<v-container class="px-4 py-2">
|
||||
<v-alert
|
||||
v-if="
|
||||
store.isLoggedIn &&
|
||||
store.requiresEmailCompletion &&
|
||||
route.name !== 'complete-email'
|
||||
store.isLoggedIn && store.requiresEmailCompletion && route.name !== 'complete-email'
|
||||
"
|
||||
class="mb-2"
|
||||
color="warning"
|
||||
@@ -223,16 +206,11 @@
|
||||
<span class="text-title-small">
|
||||
{{
|
||||
EMAIL_COMPLETION_REQUIRED
|
||||
? "請先補登 E-Mail,才能繼續使用平台。"
|
||||
: "尚未設定 E-Mail,前往補登以接收重要通知。"
|
||||
? '請先註冊 E-Mail,才能繼續使用平台。'
|
||||
: '尚未設定 E-Mail,前往註冊以接收重要通知。'
|
||||
}}
|
||||
</span>
|
||||
<v-btn
|
||||
color="warning"
|
||||
size="small"
|
||||
:to="emailCompletionLocation"
|
||||
variant="flat"
|
||||
>
|
||||
<v-btn color="warning" size="small" :to="emailCompletionLocation" variant="flat">
|
||||
前往補登
|
||||
</v-btn>
|
||||
</div>
|
||||
@@ -252,9 +230,7 @@
|
||||
tag="div"
|
||||
>
|
||||
<v-btn to="/" value="home">
|
||||
<v-icon :color="getBottomNavIconColor('home')"
|
||||
>mdi-view-dashboard</v-icon
|
||||
>
|
||||
<v-icon :color="getBottomNavIconColor('home')">mdi-view-dashboard</v-icon>
|
||||
<span class="bottom-nav-text">首頁</span>
|
||||
</v-btn>
|
||||
|
||||
@@ -270,25 +246,13 @@
|
||||
<span class="bottom-nav-text">自評</span>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
v-if="CHECKIN_ENABLED && store.isLoggedIn"
|
||||
to="/checkin"
|
||||
value="checkin"
|
||||
>
|
||||
<v-icon :color="getBottomNavIconColor('checkin')"
|
||||
>mdi-qrcode-scan</v-icon
|
||||
>
|
||||
<v-btn v-if="CHECKIN_ENABLED && store.isLoggedIn" to="/checkin" value="checkin">
|
||||
<v-icon :color="getBottomNavIconColor('checkin')">mdi-qrcode-scan</v-icon>
|
||||
<span class="bottom-nav-text">量測</span>
|
||||
</v-btn>
|
||||
|
||||
<v-btn
|
||||
v-if="APPOINTMENT_ENABLED && store.isLoggedIn"
|
||||
to="/appointment"
|
||||
value="appointment"
|
||||
>
|
||||
<v-icon :color="getBottomNavIconColor('appointment')"
|
||||
>mdi-calendar-check</v-icon
|
||||
>
|
||||
<v-btn v-if="APPOINTMENT_ENABLED && store.isLoggedIn" to="/appointment" value="appointment">
|
||||
<v-icon :color="getBottomNavIconColor('appointment')">mdi-calendar-check</v-icon>
|
||||
<span class="bottom-nav-text">預約</span>
|
||||
</v-btn>
|
||||
</v-bottom-navigation>
|
||||
@@ -296,9 +260,7 @@
|
||||
<!-- 隱私授權對話框 -->
|
||||
<v-dialog v-model="showPrivacy" max-width="500">
|
||||
<v-card class="rounded-xl">
|
||||
<v-card-title
|
||||
class="bg-primary text-white font-weight-bold d-flex align-center"
|
||||
>
|
||||
<v-card-title class="bg-primary text-white font-weight-bold d-flex align-center">
|
||||
<v-icon class="mr-2" icon="mdi-shield-check" />
|
||||
個資同意與資料授權
|
||||
</v-card-title>
|
||||
@@ -331,8 +293,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
APPOINTMENT_ENABLED,
|
||||
BODY_COMPOSITION_ENABLED,
|
||||
@@ -340,54 +302,53 @@ import {
|
||||
CHECKIN_ENABLED,
|
||||
EMAIL_COMPLETION_REQUIRED,
|
||||
SELF_ASSESSMENT_ENABLED,
|
||||
} from "@/config/features";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useCourseStore } from "@/stores/course";
|
||||
} from '@/config/features'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useCourseStore } from '@/stores/course'
|
||||
|
||||
const store = useAppStore();
|
||||
const courseStore = useCourseStore();
|
||||
const router = useRouter();
|
||||
const route = useRoute();
|
||||
const store = useAppStore()
|
||||
const courseStore = useCourseStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const drawer = ref(false);
|
||||
const activeTab = ref("home");
|
||||
const showPrivacy = ref(false);
|
||||
const drawer = ref(false)
|
||||
const activeTab = ref('home')
|
||||
const showPrivacy = ref(false)
|
||||
const emailCompletionLocation = computed(() => ({
|
||||
name: "complete-email",
|
||||
name: 'complete-email',
|
||||
query: {
|
||||
redirect: route.name === "complete-email" ? "/" : route.fullPath,
|
||||
redirect: route.name === 'complete-email' ? '/' : route.fullPath,
|
||||
},
|
||||
}));
|
||||
}))
|
||||
|
||||
// 無障礙狀態 (可持久化儲存於 localStorage)
|
||||
const isLargeText = ref(localStorage.getItem("mode-large-text") === "true");
|
||||
const isLargeText = ref(localStorage.getItem('mode-large-text') === 'true')
|
||||
|
||||
watch(isLargeText, (newVal) => {
|
||||
localStorage.setItem("mode-large-text", String(newVal));
|
||||
});
|
||||
localStorage.setItem('mode-large-text', String(newVal))
|
||||
})
|
||||
|
||||
// 同步底部導航狀態與當前路由
|
||||
watch(
|
||||
() => route.path,
|
||||
(path) => {
|
||||
if (path === "/") activeTab.value = "home";
|
||||
else if (path.startsWith("/self-assessment"))
|
||||
activeTab.value = "self-assessment";
|
||||
else if (path.startsWith("/appointment")) activeTab.value = "appointment";
|
||||
else if (path.startsWith("/checkin")) activeTab.value = "checkin";
|
||||
if (path === '/') activeTab.value = 'home'
|
||||
else if (path.startsWith('/self-assessment')) activeTab.value = 'self-assessment'
|
||||
else if (path.startsWith('/appointment')) activeTab.value = 'appointment'
|
||||
else if (path.startsWith('/checkin')) activeTab.value = 'checkin'
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
function getBottomNavIconColor(value: string) {
|
||||
return activeTab.value === value ? "secondary" : "secondary-darken-1";
|
||||
return activeTab.value === value ? 'secondary' : 'secondary-darken-1'
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await store.logout();
|
||||
courseStore.resetGroup();
|
||||
await router.push("/login");
|
||||
drawer.value = false;
|
||||
await store.logout()
|
||||
courseStore.resetGroup()
|
||||
await router.push('/login')
|
||||
drawer.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<string[]>([]);
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
if (!isPublicPasswordPath(window.location.pathname)) {
|
||||
await store.initializeAuthentication();
|
||||
}
|
||||
app.mount("#app");
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import PasswordRecoveryShell from "@/components/auth/PasswordRecoveryShell.vue";
|
||||
import ResetPasswordForm from "@/components/auth/ResetPasswordForm.vue";
|
||||
import { usePasswordReset } from "@/composables/usePasswordReset";
|
||||
const {
|
||||
hasToken,
|
||||
formVersion,
|
||||
loading,
|
||||
disabled,
|
||||
errorMessage,
|
||||
fieldErrors,
|
||||
remainingSeconds,
|
||||
submit,
|
||||
} = usePasswordReset();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PasswordRecoveryShell
|
||||
title="重設密碼"
|
||||
description="請設定新的登入密碼,完成後使用新密碼登入。"
|
||||
>
|
||||
<template v-if="!hasToken">
|
||||
<v-alert type="warning" variant="tonal" role="alert">
|
||||
密碼重設連結無效或已過期。若剛重新整理頁面,請重新開啟信件連結或重新申請密碼重設。
|
||||
</v-alert>
|
||||
<v-btn
|
||||
block
|
||||
class="mt-4"
|
||||
color="secondary"
|
||||
to="/forgot-password"
|
||||
variant="flat"
|
||||
>重新申請密碼重設</v-btn
|
||||
>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-alert
|
||||
v-if="errorMessage"
|
||||
class="mb-4"
|
||||
type="error"
|
||||
variant="tonal"
|
||||
role="alert"
|
||||
>{{ errorMessage }}</v-alert
|
||||
>
|
||||
<ResetPasswordForm
|
||||
:key="formVersion"
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
:errors="fieldErrors"
|
||||
:remaining-seconds="remainingSeconds"
|
||||
@submit="submit"
|
||||
/>
|
||||
</template>
|
||||
</PasswordRecoveryShell>
|
||||
</template>
|
||||
@@ -1,58 +1,54 @@
|
||||
<script setup lang="ts">
|
||||
import { shallowRef } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import EmailCompletionForm from "@/components/auth/EmailCompletionForm.vue";
|
||||
import Layout from "@/components/layout/DefaultLayout.vue";
|
||||
import { EMAIL_COMPLETION_REQUIRED } from "@/config/features";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { getApiErrorStatus } from "@/utils/api-error";
|
||||
import { shallowRef } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import EmailCompletionForm from '@/components/auth/EmailCompletionForm.vue'
|
||||
import Layout from '@/components/layout/DefaultLayout.vue'
|
||||
import { EMAIL_COMPLETION_REQUIRED } from '@/config/features'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { getApiErrorStatus } from '@/utils/api-error'
|
||||
|
||||
const store = useAppStore();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const loading = shallowRef(false);
|
||||
const errorMessage = shallowRef("");
|
||||
const store = useAppStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const loading = shallowRef(false)
|
||||
const errorMessage = shallowRef('')
|
||||
|
||||
function redirectTarget() {
|
||||
const target = String(route.query.redirect ?? "");
|
||||
if (
|
||||
!target.startsWith("/") ||
|
||||
target.startsWith("//") ||
|
||||
target.startsWith("/complete-email")
|
||||
) {
|
||||
return "/";
|
||||
const target = String(route.query.redirect ?? '')
|
||||
if (!target.startsWith('/') || target.startsWith('//') || target.startsWith('/complete-email')) {
|
||||
return '/'
|
||||
}
|
||||
|
||||
return target;
|
||||
return target
|
||||
}
|
||||
|
||||
async function handleSubmit(email: string) {
|
||||
errorMessage.value = "";
|
||||
loading.value = true;
|
||||
errorMessage.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
await store.updateEmail(email);
|
||||
await router.replace(redirectTarget());
|
||||
await store.updateEmail(email)
|
||||
await router.replace(redirectTarget())
|
||||
} catch (error) {
|
||||
const status = getApiErrorStatus(error);
|
||||
const status = getApiErrorStatus(error)
|
||||
if (status === 409) {
|
||||
errorMessage.value = "使用者資料已更新,請重新整理後再試。";
|
||||
errorMessage.value = '使用者資料已更新,請重新整理後再試。'
|
||||
} else if (status === 422) {
|
||||
errorMessage.value = "請輸入正確且不超過 50 碼的 E-Mail。";
|
||||
errorMessage.value = '請輸入正確且不超過 50 碼的 E-Mail。'
|
||||
} else if (status !== 401) {
|
||||
errorMessage.value = "E-Mail 儲存失敗,請稍後再試。";
|
||||
errorMessage.value = 'E-Mail 儲存失敗,請稍後再試。'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function skipForNow() {
|
||||
if (EMAIL_COMPLETION_REQUIRED) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
await router.replace(redirectTarget());
|
||||
await router.replace(redirectTarget())
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -62,13 +58,9 @@ async function skipForNow() {
|
||||
<v-row justify="center" no-gutters>
|
||||
<v-col cols="12" sm="8" md="6">
|
||||
<v-card class="rounded-xl mt-6" color="surface">
|
||||
<v-card-title class="text-primary font-weight-bold">
|
||||
補登 E-Mail
|
||||
</v-card-title>
|
||||
<v-card-title class="text-primary font-weight-bold"> 註冊 E-Mail </v-card-title>
|
||||
<v-card-text>
|
||||
<p class="text-body-1 mb-5">
|
||||
設定 E-Mail,有助於日後接收帳號安全與重要服務通知。
|
||||
</p>
|
||||
<p class="text-body-1 mb-5">填寫 E-Mail,有助於日後接收帳號安全與重要服務通知。</p>
|
||||
|
||||
<EmailCompletionForm
|
||||
:error-message="errorMessage"
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
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()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PasswordRecoveryShell title="忘記密碼">
|
||||
<template #description>
|
||||
<v-sheet class="pa-4 mb-6" color="background" rounded="lg">
|
||||
<ol aria-label="密碼重設流程" class="recovery-flow ma-0 pa-0">
|
||||
<li v-for="(step, index) in recoverySteps" :key="step.title" class="recovery-step">
|
||||
<v-avatar
|
||||
class="recovery-step-icon"
|
||||
color="primary"
|
||||
rounded="lg"
|
||||
size="40"
|
||||
variant="tonal"
|
||||
>
|
||||
<v-icon aria-hidden="true" :icon="step.icon" size="22" />
|
||||
</v-avatar>
|
||||
<div class="recovery-step-content">
|
||||
<span class="recovery-step-number text-primary">
|
||||
步驟 {{ String(index + 1).padStart(2, '0') }}
|
||||
</span>
|
||||
<p class="text-title-small text-high-emphasis ma-0">
|
||||
{{ step.title }}
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</v-sheet>
|
||||
</template>
|
||||
<template v-if="accepted">
|
||||
<v-alert type="success" variant="tonal" role="status">
|
||||
如果帳號存在,系統將寄送密碼重設信件。
|
||||
</v-alert>
|
||||
<p class="mt-4 text-body-1">請查看信箱與垃圾郵件匣。</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<v-alert v-if="errorMessage" class="mb-4" type="error" variant="tonal" role="alert">{{
|
||||
errorMessage
|
||||
}}</v-alert>
|
||||
<ForgotPasswordForm
|
||||
:loading="loading"
|
||||
:disabled="disabled"
|
||||
:errors="fieldErrors"
|
||||
:remaining-seconds="remainingSeconds"
|
||||
@submit="submit"
|
||||
/>
|
||||
</template>
|
||||
</PasswordRecoveryShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.recovery-flow {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.recovery-step {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 40px minmax(0, 1fr);
|
||||
align-items: center;
|
||||
column-gap: 16px;
|
||||
}
|
||||
|
||||
.recovery-step:not(:last-child) {
|
||||
padding-bottom: 24px;
|
||||
}
|
||||
|
||||
.recovery-step:not(:last-child)::after {
|
||||
position: absolute;
|
||||
top: 48px;
|
||||
bottom: 4px;
|
||||
left: 19px;
|
||||
width: 2px;
|
||||
background: rgba(var(--v-theme-primary), 0.18);
|
||||
content: '';
|
||||
}
|
||||
|
||||
.recovery-step-icon {
|
||||
align-self: start;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.recovery-step-content {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.recovery-step-number {
|
||||
display: block;
|
||||
margin-bottom: 2px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.5;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
</style>
|
||||
@@ -198,6 +198,19 @@
|
||||
既有帳號建立密碼
|
||||
</v-btn>
|
||||
</v-col>
|
||||
|
||||
<v-col cols="12">
|
||||
<v-btn
|
||||
block
|
||||
class="rounded-lg"
|
||||
color="primary"
|
||||
prepend-icon="mdi-lock-reset"
|
||||
to="/forgot-password"
|
||||
variant="text"
|
||||
>
|
||||
忘記密碼
|
||||
</v-btn>
|
||||
</v-col>
|
||||
</v-row>
|
||||
</v-card-text>
|
||||
<div class="d-flex align-center mt-4 px-4">
|
||||
|
||||
@@ -19,6 +19,14 @@
|
||||
<v-card class="rounded-lg mt-4" color="surface">
|
||||
<v-card-title> 登入 </v-card-title>
|
||||
<v-card-text> 請輸入您的手機號碼與登入密碼 </v-card-text>
|
||||
<v-alert
|
||||
v-if="route.query.passwordReset === 'success'"
|
||||
class="mx-4 mb-4"
|
||||
type="success"
|
||||
variant="tonal"
|
||||
>
|
||||
密碼已更新,請使用新密碼登入。
|
||||
</v-alert>
|
||||
<!-- 表單 -->
|
||||
<v-form class="pa-4" @submit.prevent="handleLogin">
|
||||
<v-alert
|
||||
@@ -91,6 +99,9 @@
|
||||
>
|
||||
既有帳號建立密碼
|
||||
</v-btn>
|
||||
<v-btn block color="primary" to="/forgot-password" variant="text">
|
||||
忘記密碼
|
||||
</v-btn>
|
||||
</v-form>
|
||||
<!-- <div class="d-flex align-center justify-center my-4">
|
||||
<v-divider class="mr-3" />
|
||||
|
||||
@@ -1,226 +1,277 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
import Appointment from "@/pages/appointment.vue";
|
||||
import BodyComposition from "@/pages/body-composition.vue";
|
||||
import Checkin from "@/pages/checkin.vue";
|
||||
import CheckinDetail from "@/pages/checkinDetail.vue";
|
||||
import CompleteEmail from "@/pages/complete-email.vue";
|
||||
import Course from "@/pages/course.vue";
|
||||
import Index from "@/pages/index.vue";
|
||||
import Login from "@/pages/login.vue";
|
||||
import Register from "@/pages/register.vue";
|
||||
import SelfAssessment from "@/pages/self-assessment.vue";
|
||||
import {
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
isNavigationFailure,
|
||||
NavigationFailureType,
|
||||
} from 'vue-router'
|
||||
import Appointment from '@/pages/appointment.vue'
|
||||
import BodyComposition from '@/pages/body-composition.vue'
|
||||
import Checkin from '@/pages/checkin.vue'
|
||||
import CheckinDetail from '@/pages/checkinDetail.vue'
|
||||
import CompleteEmail from '@/pages/complete-email.vue'
|
||||
import Course from '@/pages/course.vue'
|
||||
import Index from '@/pages/index.vue'
|
||||
import Login from '@/pages/login.vue'
|
||||
import Register from '@/pages/register.vue'
|
||||
import ForgotPassword from '@/pages/forgot-password.vue'
|
||||
import ChangePassword from '@/pages/change-password.vue'
|
||||
import {
|
||||
captureInitialPasswordResetToken,
|
||||
clearPasswordResetToken,
|
||||
deliverPasswordResetToken,
|
||||
isPublicPasswordPath,
|
||||
stagePasswordResetToken,
|
||||
} from '@/utils/password-reset-token'
|
||||
import SelfAssessment from '@/pages/self-assessment.vue'
|
||||
import {
|
||||
APPOINTMENT_ENABLED,
|
||||
BODY_COMPOSITION_ENABLED,
|
||||
CHECKIN_ENABLED,
|
||||
EMAIL_COMPLETION_REQUIRED,
|
||||
} from "@/config/features";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useCourseStore } from "@/stores/course";
|
||||
import { getApiErrorStatus } from "@/utils/api-error";
|
||||
} from '@/config/features'
|
||||
import { useAppStore } from '@/stores/app'
|
||||
import { useCourseStore } from '@/stores/course'
|
||||
import { getApiErrorStatus } from '@/utils/api-error'
|
||||
|
||||
// 在 router 啟動初始導航與任何 Session API 之前移除敏感 query。
|
||||
captureInitialPasswordResetToken()
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
scrollBehavior(to, _from, savedPosition) {
|
||||
if (savedPosition) {
|
||||
return savedPosition;
|
||||
return savedPosition
|
||||
}
|
||||
|
||||
if (to.hash) {
|
||||
return { el: to.hash, top: 68 };
|
||||
return { el: to.hash, top: 68 }
|
||||
}
|
||||
|
||||
return { left: 0, top: 0 };
|
||||
return { left: 0, top: 0 }
|
||||
},
|
||||
routes: [
|
||||
{
|
||||
path: "/",
|
||||
name: "home",
|
||||
path: '/',
|
||||
name: 'home',
|
||||
component: Index,
|
||||
},
|
||||
{
|
||||
path: "/login",
|
||||
name: "login",
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: Login,
|
||||
},
|
||||
{
|
||||
path: "/register",
|
||||
name: "register",
|
||||
component: Register,
|
||||
meta: { title: "註冊|運動玩轉健康力" },
|
||||
path: '/forgot-password',
|
||||
name: 'forgot-password',
|
||||
component: ForgotPassword,
|
||||
meta: { title: '忘記密碼|運動玩轉健康力' },
|
||||
},
|
||||
{
|
||||
path: "/self-assessment",
|
||||
name: "self-assessment",
|
||||
path: '/change-password',
|
||||
name: 'change-password',
|
||||
component: ChangePassword,
|
||||
meta: { title: '重設密碼|運動玩轉健康力' },
|
||||
},
|
||||
{
|
||||
path: '/register',
|
||||
name: 'register',
|
||||
component: Register,
|
||||
meta: { title: '註冊|運動玩轉健康力' },
|
||||
},
|
||||
{
|
||||
path: '/self-assessment',
|
||||
name: 'self-assessment',
|
||||
component: SelfAssessment,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/complete-email",
|
||||
name: "complete-email",
|
||||
path: '/complete-email',
|
||||
name: 'complete-email',
|
||||
component: CompleteEmail,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
title: "補登 E-Mail|運動玩轉健康力",
|
||||
title: '註冊 E-Mail|運動玩轉健康力',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/profile",
|
||||
redirect: { name: "home" },
|
||||
path: '/profile',
|
||||
redirect: { name: 'home' },
|
||||
},
|
||||
{
|
||||
path: "/body-composition",
|
||||
name: "body-composition",
|
||||
path: '/body-composition',
|
||||
name: 'body-composition',
|
||||
component: BodyComposition,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresUserID: true,
|
||||
title: "身體數值分析|運動玩轉健康力",
|
||||
title: '身體數值分析|運動玩轉健康力',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/appointment",
|
||||
name: "appointment",
|
||||
path: '/appointment',
|
||||
name: 'appointment',
|
||||
component: Appointment,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/course",
|
||||
name: "course",
|
||||
path: '/course',
|
||||
name: 'course',
|
||||
component: Course,
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
requiresFitnessGroup: true,
|
||||
title: "課程建議|運動玩轉健康力",
|
||||
title: '課程建議|運動玩轉健康力',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: "/checkin",
|
||||
name: "checkin",
|
||||
path: '/checkin',
|
||||
name: 'checkin',
|
||||
component: Checkin,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: "/checkin/:code",
|
||||
name: "checkin-detail",
|
||||
path: '/checkin/:code',
|
||||
name: 'checkin-detail',
|
||||
component: CheckinDetail,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
});
|
||||
})
|
||||
|
||||
const APPOINTMENT_PATH = /^\/appointment(?:\/|$)/;
|
||||
const BODY_COMPOSITION_PATH = /^\/body-composition(?:\/|$)/;
|
||||
const CHECKIN_PATH = /^\/checkin(?:\/|$)/;
|
||||
const APPOINTMENT_PATH = /^\/appointment(?:\/|$)/
|
||||
const BODY_COMPOSITION_PATH = /^\/body-composition(?:\/|$)/
|
||||
const CHECKIN_PATH = /^\/checkin(?:\/|$)/
|
||||
|
||||
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
|
||||
router.beforeEach(async (to) => {
|
||||
if (to.name === 'change-password' && Object.hasOwn(to.query, 'token')) {
|
||||
stagePasswordResetToken(to.query.token)
|
||||
const query = { ...to.query }
|
||||
delete query.token
|
||||
return { name: 'change-password', query, hash: to.hash, replace: true }
|
||||
}
|
||||
|
||||
// 公開密碼流程不依賴 Profile,也不受強制補登 E-Mail 限制。
|
||||
if (isPublicPasswordPath(to.path)) return
|
||||
|
||||
if (
|
||||
(!APPOINTMENT_ENABLED && APPOINTMENT_PATH.test(to.path)) ||
|
||||
(!BODY_COMPOSITION_ENABLED && BODY_COMPOSITION_PATH.test(to.path)) ||
|
||||
(!CHECKIN_ENABLED && CHECKIN_PATH.test(to.path))
|
||||
) {
|
||||
return { name: "home" };
|
||||
return { name: 'home' }
|
||||
}
|
||||
|
||||
const store = useAppStore();
|
||||
await store.initializeAuthentication();
|
||||
const store = useAppStore()
|
||||
await store.initializeAuthentication()
|
||||
|
||||
if (to.meta.requiresAuth && !store.isLoggedIn) {
|
||||
return {
|
||||
name: "login",
|
||||
name: 'login',
|
||||
query: { redirect: to.fullPath },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (to.meta.requiresUserID && !store.userID.trim()) {
|
||||
return {
|
||||
name: "login",
|
||||
name: 'login',
|
||||
query: { redirect: to.fullPath },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
EMAIL_COMPLETION_REQUIRED &&
|
||||
store.isLoggedIn &&
|
||||
store.requiresEmailCompletion &&
|
||||
to.name !== "complete-email"
|
||||
to.name !== 'complete-email'
|
||||
) {
|
||||
return {
|
||||
name: "complete-email",
|
||||
name: 'complete-email',
|
||||
query: { redirect: to.fullPath },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (to.meta.requiresFitnessGroup) {
|
||||
const courseStore = useCourseStore();
|
||||
const courseStore = useCourseStore()
|
||||
|
||||
try {
|
||||
await courseStore.ensureGroup();
|
||||
await courseStore.ensureGroup()
|
||||
} catch (error) {
|
||||
if (getApiErrorStatus(error) === 401 || !store.isLoggedIn) {
|
||||
return {
|
||||
name: "login",
|
||||
name: 'login',
|
||||
query: { redirect: to.fullPath },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (courseStore.groupMissing) {
|
||||
return {
|
||||
name: "appointment",
|
||||
query: { reason: "missing-fitness-group" },
|
||||
};
|
||||
name: 'appointment',
|
||||
query: { reason: 'missing-fitness-group' },
|
||||
}
|
||||
}
|
||||
|
||||
// 技術性錯誤不應被誤導成尚未分組,返回首頁讓使用者重試。
|
||||
return { name: "home" };
|
||||
return { name: 'home' }
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
router.afterEach((to) => {
|
||||
document.title =
|
||||
typeof to.meta.title === "string" ? to.meta.title : "運動玩轉健康力";
|
||||
});
|
||||
router.afterEach((to, _from, failure) => {
|
||||
if (!failure && to.name !== 'change-password') clearPasswordResetToken()
|
||||
// 清理後的同路由導航可能被判定 duplicate,仍需移交新的 Token。
|
||||
if (
|
||||
to.name === 'change-password' &&
|
||||
(!failure || isNavigationFailure(failure, NavigationFailureType.duplicated))
|
||||
) {
|
||||
deliverPasswordResetToken()
|
||||
}
|
||||
document.title = typeof to.meta.title === 'string' ? to.meta.title : '運動玩轉健康力'
|
||||
})
|
||||
|
||||
// 共用 HTTP client 收到登入後 API 的 401 時,清除前端狀態並保留原頁面供登入後返回。
|
||||
if (typeof window !== "undefined") {
|
||||
window.addEventListener("auth-expired", () => {
|
||||
const store = useAppStore();
|
||||
const courseStore = useCourseStore();
|
||||
const currentRoute = router.currentRoute.value;
|
||||
const wasLoggedIn = store.isLoggedIn;
|
||||
store.clearAuthentication();
|
||||
courseStore.resetGroup();
|
||||
if (typeof window !== 'undefined') {
|
||||
window.addEventListener('auth-expired', () => {
|
||||
const store = useAppStore()
|
||||
const courseStore = useCourseStore()
|
||||
const currentRoute = router.currentRoute.value
|
||||
const wasLoggedIn = store.isLoggedIn
|
||||
store.clearAuthentication()
|
||||
courseStore.resetGroup()
|
||||
|
||||
if (wasLoggedIn && currentRoute.name !== "login") {
|
||||
if (wasLoggedIn && currentRoute.name !== 'login' && !isPublicPasswordPath(currentRoute.path)) {
|
||||
void router.replace({
|
||||
name: "login",
|
||||
name: 'login',
|
||||
query: { redirect: currentRoute.fullPath },
|
||||
});
|
||||
})
|
||||
}
|
||||
});
|
||||
})
|
||||
|
||||
// CSRF Token 刷新並重送後仍失敗時,回到登入頁並提示使用者重新整理。
|
||||
window.addEventListener("csrf-recovery-required", () => {
|
||||
const store = useAppStore();
|
||||
const courseStore = useCourseStore();
|
||||
const currentRoute = router.currentRoute.value;
|
||||
window.addEventListener('csrf-recovery-required', () => {
|
||||
const store = useAppStore()
|
||||
const courseStore = useCourseStore()
|
||||
const currentRoute = router.currentRoute.value
|
||||
if (isPublicPasswordPath(currentRoute.path)) {
|
||||
store.clearAuthentication()
|
||||
courseStore.resetGroup()
|
||||
return
|
||||
}
|
||||
const redirect =
|
||||
currentRoute.name === "login"
|
||||
? String(currentRoute.query.redirect ?? "")
|
||||
: currentRoute.fullPath;
|
||||
store.clearAuthentication();
|
||||
courseStore.resetGroup();
|
||||
currentRoute.name === 'login'
|
||||
? String(currentRoute.query.redirect ?? '')
|
||||
: currentRoute.fullPath
|
||||
store.clearAuthentication()
|
||||
courseStore.resetGroup()
|
||||
|
||||
void router.replace({
|
||||
name: "login",
|
||||
name: 'login',
|
||||
query: {
|
||||
...(redirect ? { redirect } : {}),
|
||||
csrf: "refresh",
|
||||
csrf: 'refresh',
|
||||
},
|
||||
});
|
||||
});
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export default router;
|
||||
export default router
|
||||
|
||||
@@ -421,7 +421,9 @@ export const useAppStore = defineStore("app", () => {
|
||||
}
|
||||
|
||||
/** 清除前端登入狀態;不觸碰尚未正式保存的訪客草稿。 */
|
||||
function clearAuthentication() {
|
||||
function clearAuthentication(options: { skipSessionRestore?: boolean } = {}) {
|
||||
// 重設密碼後不可在回到登入頁時從舊 Cookie 自動還原登入。
|
||||
if (options.skipSessionRestore) isAuthInitialized.value = true;
|
||||
isLoggedIn.value = false;
|
||||
userID.value = "";
|
||||
authGroup.value = null;
|
||||
|
||||
@@ -28,19 +28,34 @@ export function getApiErrorCode(error: unknown): string | null {
|
||||
return getApiErrorBody(error)?.code ?? null;
|
||||
}
|
||||
|
||||
export function getApiFieldErrors(error: unknown, field: string): string[] {
|
||||
const errors = getApiErrorBody(error)?.errors?.[field];
|
||||
return Array.isArray(errors)
|
||||
? errors.filter((message): message is string => typeof message === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
export function getApiRetryAfterSeconds(error: unknown): number | null {
|
||||
if (error instanceof HTTPError) {
|
||||
const header = error.response.headers.get("Retry-After")?.trim();
|
||||
if (header) {
|
||||
if (/^\d+$/.test(header)) {
|
||||
const seconds = Number(header);
|
||||
if (Number.isFinite(seconds)) return seconds;
|
||||
} else {
|
||||
const deadline = Date.parse(header);
|
||||
if (Number.isFinite(deadline)) {
|
||||
return Math.max(0, Math.ceil((deadline - Date.now()) / 1000));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const bodyValue = getApiErrorBody(error)?.retryAfterSeconds;
|
||||
if (typeof bodyValue === "number" && Number.isFinite(bodyValue)) {
|
||||
return Math.max(0, Math.ceil(bodyValue));
|
||||
}
|
||||
|
||||
if (!(error instanceof HTTPError)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const headerValue = Number(error.response.headers.get("Retry-After"));
|
||||
return Number.isFinite(headerValue)
|
||||
? Math.max(0, Math.ceil(headerValue))
|
||||
return typeof bodyValue === "number" &&
|
||||
Number.isFinite(bodyValue) &&
|
||||
bodyValue >= 0
|
||||
? Math.ceil(bodyValue)
|
||||
: null;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
// Token 不進入 Vue/Pinia 狀態或持久化儲存,只在導航完成後移交頁面。
|
||||
let pendingToken: string | null = null;
|
||||
let hasPendingToken = false;
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
export function isPasswordResetPath(path: string): boolean {
|
||||
const base = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||
return path === "/change-password" || path === `${base}/change-password`;
|
||||
}
|
||||
|
||||
export function isPublicPasswordPath(path: string): boolean {
|
||||
const base = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||
return (
|
||||
isPasswordResetPath(path) ||
|
||||
path === "/forgot-password" ||
|
||||
path === `${base}/forgot-password`
|
||||
);
|
||||
}
|
||||
|
||||
export function stagePasswordResetToken(value: unknown) {
|
||||
pendingToken = typeof value === "string" && value.length > 0 ? value : null;
|
||||
hasPendingToken = true;
|
||||
}
|
||||
|
||||
export function captureInitialPasswordResetToken() {
|
||||
if (!isPasswordResetPath(window.location.pathname)) return;
|
||||
const url = new URL(window.location.href);
|
||||
const tokens = url.searchParams.getAll("token");
|
||||
stagePasswordResetToken(tokens.length === 1 ? tokens[0] : null);
|
||||
url.searchParams.delete("token");
|
||||
window.history.replaceState(
|
||||
window.history.state,
|
||||
"",
|
||||
url.pathname + url.search + url.hash,
|
||||
);
|
||||
}
|
||||
|
||||
export function takePasswordResetToken(): { token: string | null } | null {
|
||||
if (!hasPendingToken) return null;
|
||||
const result = { token: pendingToken };
|
||||
clearPasswordResetToken();
|
||||
return result;
|
||||
}
|
||||
|
||||
export function clearPasswordResetToken() {
|
||||
pendingToken = null;
|
||||
hasPendingToken = false;
|
||||
}
|
||||
|
||||
export function subscribePasswordResetToken(listener: () => void) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
};
|
||||
}
|
||||
|
||||
export function deliverPasswordResetToken() {
|
||||
if (hasPendingToken) {
|
||||
for (const listener of listeners) listener();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user