Compare commits
2 Commits
a8fdb157a2
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 3211c3b78b | |||
| 12b067632f |
@@ -3,6 +3,10 @@ import type {
|
|||||||
AuthResponse,
|
AuthResponse,
|
||||||
CheckPhoneRequest,
|
CheckPhoneRequest,
|
||||||
CheckPhoneResponse,
|
CheckPhoneResponse,
|
||||||
|
ForgotPasswordRequest,
|
||||||
|
ForgotPasswordResponse,
|
||||||
|
ResetPasswordRequest,
|
||||||
|
ResetPasswordResponse,
|
||||||
LoginRequest,
|
LoginRequest,
|
||||||
LogoutResponse,
|
LogoutResponse,
|
||||||
RegisterRequest,
|
RegisterRequest,
|
||||||
@@ -36,6 +40,37 @@ export function updateEmail(email: string): Promise<UpdateEmailResponse> {
|
|||||||
.json<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。 */
|
/** 呼叫後端登出並讓後端清除登入 Cookie。 */
|
||||||
export function logout(): Promise<LogoutResponse> {
|
export function logout(): Promise<LogoutResponse> {
|
||||||
return api
|
return api
|
||||||
|
|||||||
@@ -157,6 +157,24 @@ export interface UpdateEmailResponse {
|
|||||||
/** 舊名稱保留為相容別名,新的 API 請使用 AuthResponse。 */
|
/** 舊名稱保留為相容別名,新的 API 請使用 AuthResponse。 */
|
||||||
export type LoginResponse = 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 {
|
export interface LogoutResponse {
|
||||||
messageCode: string;
|
messageCode: string;
|
||||||
|
|||||||
@@ -61,12 +61,12 @@
|
|||||||
class="test-item d-flex align-center justify-space-between rounded-lg px-3"
|
class="test-item d-flex align-center justify-space-between rounded-lg px-3"
|
||||||
>
|
>
|
||||||
<div class="mr-3">
|
<div class="mr-3">
|
||||||
<div class="text-body-2 font-weight-bold">
|
<div class="text-body-medium font-weight-bold">
|
||||||
{{ item.fitnessName }}
|
{{ item.fitnessName }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-body-1 font-weight-black text-no-wrap"
|
class="text-body-large font-weight-black text-no-wrap"
|
||||||
:class="item.fitnessScore === null ? 'text-grey' : 'text-primary'"
|
:class="item.fitnessScore === null ? 'text-grey' : 'text-primary'"
|
||||||
>
|
>
|
||||||
{{ item.fitnessScore ?? "無資料" }}
|
{{ item.fitnessScore ?? "無資料" }}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const isOpen = defineModel<boolean>({ required: true });
|
|||||||
個資同意與資料授權
|
個資同意與資料授權
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
<v-card-text class="pt-4 text-body-1">
|
<v-card-text class="pt-4 text-body-large">
|
||||||
<p class="mb-3">
|
<p class="mb-3">
|
||||||
「運動玩轉健康力」平台非常重視您的個人隱私。我們所蒐集的身體活動自評、血壓、體脂以及現場運動表現數據,將僅用於以下用途:
|
「運動玩轉健康力」平台非常重視您的個人隱私。我們所蒐集的身體活動自評、血壓、體脂以及現場運動表現數據,將僅用於以下用途:
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -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-large 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>
|
||||||
@@ -161,7 +161,7 @@
|
|||||||
v-if="store.requiresEmailCompletion"
|
v-if="store.requiresEmailCompletion"
|
||||||
color="warning"
|
color="warning"
|
||||||
prepend-icon="mdi-email-alert-outline"
|
prepend-icon="mdi-email-alert-outline"
|
||||||
title="補登 E-Mail"
|
title="註冊 E-Mail"
|
||||||
:to="emailCompletionLocation"
|
:to="emailCompletionLocation"
|
||||||
@click="drawer = false"
|
@click="drawer = false"
|
||||||
/>
|
/>
|
||||||
@@ -223,8 +223,8 @@
|
|||||||
<span class="text-title-small">
|
<span class="text-title-small">
|
||||||
{{
|
{{
|
||||||
EMAIL_COMPLETION_REQUIRED
|
EMAIL_COMPLETION_REQUIRED
|
||||||
? "請先補登 E-Mail,才能繼續使用平台。"
|
? "請先註冊 E-Mail,才能繼續使用平台。"
|
||||||
: "尚未設定 E-Mail,前往補登以接收重要通知。"
|
: "尚未設定 E-Mail,前往註冊以接收重要通知。"
|
||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -303,7 +303,7 @@
|
|||||||
個資同意與資料授權
|
個資同意與資料授權
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
|
|
||||||
<v-card-text class="pt-4 text-body-1">
|
<v-card-text class="pt-4 text-body-large">
|
||||||
<p class="mb-3">
|
<p class="mb-3">
|
||||||
「運動玩轉健康力」平台非常重視您的個人隱私。我們所蒐集的身體活動自評、血壓、體脂以及現場運動表現數據,將僅用於以下用途:
|
「運動玩轉健康力」平台非常重視您的個人隱私。我們所蒐集的身體活動自評、血壓、體脂以及現場運動表現數據,將僅用於以下用途:
|
||||||
</p>
|
</p>
|
||||||
@@ -409,8 +409,8 @@ async function handleLogout() {
|
|||||||
line-height: unset !important;
|
line-height: unset !important;
|
||||||
}
|
}
|
||||||
.v-card-text,
|
.v-card-text,
|
||||||
.text-body-1,
|
.text-body-large,
|
||||||
.text-body-2,
|
.text-body-medium,
|
||||||
p {
|
p {
|
||||||
font-size: 1.15rem !important;
|
font-size: 1.15rem !important;
|
||||||
line-height: 1.6 !important;
|
line-height: 1.6 !important;
|
||||||
|
|||||||
@@ -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) {
|
function start(seconds: number) {
|
||||||
clearTimer();
|
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) {
|
if (remainingSeconds.value === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const deadline = Date.now() + remainingSeconds.value * 1000;
|
||||||
intervalID = setInterval(() => {
|
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) {
|
if (remainingSeconds.value === 0) {
|
||||||
clearTimer();
|
clearTimer();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { createApp } from "vue";
|
|||||||
// Plugins
|
// Plugins
|
||||||
import { registerPlugins } from "@/plugins";
|
import { registerPlugins } from "@/plugins";
|
||||||
import { useAppStore } from "@/stores/app";
|
import { useAppStore } from "@/stores/app";
|
||||||
|
import { isPublicPasswordPath } from "@/utils/password-reset-token";
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
@@ -23,7 +24,9 @@ registerPlugins(app);
|
|||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
await store.initializeAuthentication();
|
if (!isPublicPasswordPath(window.location.pathname)) {
|
||||||
|
await store.initializeAuthentication();
|
||||||
|
}
|
||||||
app.mount("#app");
|
app.mount("#app");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,7 @@
|
|||||||
size="18"
|
size="18"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="text-body-2 text-grey-darken-3">
|
<div class="text-body-medium text-grey-darken-3">
|
||||||
<span class="font-weight-bold">{{ apt.date }}</span>
|
<span class="font-weight-bold">{{ apt.date }}</span>
|
||||||
<span class="ml-4 text-grey-darken-1">{{ apt.time }}</span>
|
<span class="ml-4 text-grey-darken-1">{{ apt.time }}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -95,7 +95,7 @@
|
|||||||
|
|
||||||
<!-- <div class="d-flex align-start mb-2">
|
<!-- <div class="d-flex align-start mb-2">
|
||||||
<v-icon class="mr-2 mt-0.5" color="primary" icon="mdi-map-marker" size="18" />
|
<v-icon class="mr-2 mt-0.5" color="primary" icon="mdi-map-marker" size="18" />
|
||||||
<div class="text-body-2 text-grey-darken-3">{{ apt.location }}</div>
|
<div class="text-body-medium text-grey-darken-3">{{ apt.location }}</div>
|
||||||
</div> -->
|
</div> -->
|
||||||
|
|
||||||
<div class="d-flex align-start mb-3">
|
<div class="d-flex align-start mb-3">
|
||||||
@@ -106,7 +106,7 @@
|
|||||||
size="18"
|
size="18"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="text-body-2 text-grey-darken-3">
|
<div class="text-body-medium text-grey-darken-3">
|
||||||
剩餘名額:<span
|
剩餘名額:<span
|
||||||
class="font-weight-bold"
|
class="font-weight-bold"
|
||||||
:class="apt.slots < 10 ? 'text-error' : 'text-success'"
|
:class="apt.slots < 10 ? 'text-error' : 'text-success'"
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
報告計算完成!
|
報告計算完成!
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p class="text-body-2 text-grey-darken-2 mb-6">
|
<p class="text-body-medium text-grey-darken-2 mb-6">
|
||||||
您的健康力五維雷達圖、與同齡區間的參考值以及健康力總分已成功計算更新。
|
您的健康力五維雷達圖、與同齡區間的參考值以及健康力總分已成功計算更新。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|||||||
@@ -191,7 +191,7 @@
|
|||||||
|
|
||||||
<v-divider class="mb-3" />
|
<v-divider class="mb-3" />
|
||||||
|
|
||||||
<div class="text-body-2 text-grey-darken-3 whitespace-pre-line">
|
<div class="text-body-medium text-grey-darken-3 whitespace-pre-line">
|
||||||
{{ station.reference }}
|
{{ station.reference }}
|
||||||
</div>
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|||||||
@@ -63,11 +63,11 @@ async function skipForNow() {
|
|||||||
<v-col cols="12" sm="8" md="6">
|
<v-col cols="12" sm="8" md="6">
|
||||||
<v-card class="rounded-xl mt-6" color="surface">
|
<v-card class="rounded-xl mt-6" color="surface">
|
||||||
<v-card-title class="text-primary font-weight-bold">
|
<v-card-title class="text-primary font-weight-bold">
|
||||||
補登 E-Mail
|
註冊 E-Mail
|
||||||
</v-card-title>
|
</v-card-title>
|
||||||
<v-card-text>
|
<v-card-text>
|
||||||
<p class="text-body-1 mb-5">
|
<p class="text-body-large mb-5">
|
||||||
設定 E-Mail,有助於日後接收帳號安全與重要服務通知。
|
填寫 E-Mail,有助於日後接收帳號安全與重要服務通知。
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<EmailCompletionForm
|
<EmailCompletionForm
|
||||||
|
|||||||
@@ -0,0 +1,123 @@
|
|||||||
|
<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-large">請查看信箱與垃圾郵件匣。</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>
|
||||||
@@ -170,49 +170,81 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-card class="rounded-xl elevation-2 mb-4 py-4" color="surface">
|
<v-card class="rounded-xl mb-4" color="surface" variant="flat">
|
||||||
<v-card-text>
|
<v-card-text class="pa-5 pa-sm-6">
|
||||||
<v-row density="compact">
|
<div class="d-flex align-center ga-4 mb-6">
|
||||||
<v-col cols="12" md="6">
|
<v-avatar
|
||||||
|
class="flex-shrink-0"
|
||||||
|
color="primary"
|
||||||
|
rounded="lg"
|
||||||
|
size="48"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<v-icon
|
||||||
|
aria-hidden="true"
|
||||||
|
icon="mdi-lock-open-outline"
|
||||||
|
size="26"
|
||||||
|
/>
|
||||||
|
</v-avatar>
|
||||||
|
<div>
|
||||||
|
<h2
|
||||||
|
class="text-title-medium font-weight-bold text-high-emphasis mb-1"
|
||||||
|
>
|
||||||
|
登入後可使用完整功能
|
||||||
|
</h2>
|
||||||
|
<p class="text-body-small text-medium-emphasis ma-0">
|
||||||
|
使用手機門號登入;既有帳號可先建立密碼。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-row dense>
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
<v-btn
|
<v-btn
|
||||||
block
|
block
|
||||||
class="rounded-lg font-weight-bold"
|
class="rounded-lg font-weight-bold"
|
||||||
color="primary"
|
color="primary"
|
||||||
prepend-icon="mdi-login"
|
prepend-icon="mdi-login"
|
||||||
|
size="large"
|
||||||
to="/login"
|
to="/login"
|
||||||
variant="outlined"
|
variant="flat"
|
||||||
>
|
>
|
||||||
登入平台
|
登入平台
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-col>
|
</v-col>
|
||||||
|
<v-col cols="12" sm="6">
|
||||||
<v-col cols="12" md="6">
|
|
||||||
<v-btn
|
<v-btn
|
||||||
block
|
block
|
||||||
class="rounded-lg font-weight-bold"
|
class="rounded-lg font-weight-bold"
|
||||||
color="primary"
|
color="primary"
|
||||||
prepend-icon="mdi-account-plus-outline"
|
prepend-icon="mdi-account-plus-outline"
|
||||||
|
size="large"
|
||||||
to="/register"
|
to="/register"
|
||||||
variant="text"
|
variant="outlined"
|
||||||
>
|
>
|
||||||
既有帳號建立密碼
|
既有帳號建立密碼
|
||||||
</v-btn>
|
</v-btn>
|
||||||
</v-col>
|
</v-col>
|
||||||
</v-row>
|
</v-row>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
<div class="d-flex align-center mt-4 px-4">
|
|
||||||
<v-icon
|
|
||||||
class="mr-3"
|
|
||||||
color="primary"
|
|
||||||
icon="mdi-lock-outline"
|
|
||||||
size="28"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div>
|
<v-divider />
|
||||||
<div class="font-weight-bold text-grey-darken-4">
|
|
||||||
登入後可使用完整功能
|
<div
|
||||||
</div>
|
class="d-flex flex-wrap align-center justify-space-between ga-2 px-5 px-sm-6 py-1 bg-background"
|
||||||
</div>
|
>
|
||||||
|
<span class="text-body-medium text-medium-emphasis"
|
||||||
|
>無法登入?</span
|
||||||
|
>
|
||||||
|
<v-btn
|
||||||
|
class="rounded-lg"
|
||||||
|
color="primary"
|
||||||
|
prepend-icon="mdi-lock-reset"
|
||||||
|
to="/forgot-password"
|
||||||
|
variant="text"
|
||||||
|
>
|
||||||
|
忘記密碼
|
||||||
|
</v-btn>
|
||||||
</div>
|
</div>
|
||||||
</v-card>
|
</v-card>
|
||||||
</template>
|
</template>
|
||||||
@@ -231,10 +263,12 @@
|
|||||||
<v-avatar class="mb-4" color="primary" size="64">
|
<v-avatar class="mb-4" color="primary" size="64">
|
||||||
<v-icon color="white" icon="mdi-refresh-circle" size="34" />
|
<v-icon color="white" icon="mdi-refresh-circle" size="34" />
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
<p class="text-body-1 font-weight-bold text-grey-darken-4 mb-2">
|
<p
|
||||||
|
class="text-body-large font-weight-bold text-grey-darken-4 mb-2"
|
||||||
|
>
|
||||||
確定要重新計算目前的體適能評級嗎?
|
確定要重新計算目前的體適能評級嗎?
|
||||||
</p>
|
</p>
|
||||||
<p class="text-body-2 text-grey-darken-2 mb-0">
|
<p class="text-body-medium text-grey-darken-2 mb-0">
|
||||||
系統會依各測驗項目的最新成績重新計算分組。
|
系統會依各測驗項目的最新成績重新計算分組。
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
@@ -304,10 +338,10 @@
|
|||||||
<v-icon color="white" icon="mdi-calendar-check" size="34" />
|
<v-icon color="white" icon="mdi-calendar-check" size="34" />
|
||||||
</v-avatar>
|
</v-avatar>
|
||||||
|
|
||||||
<p class="text-body-1 font-weight-bold text-grey-darken-4 mb-2">
|
<p class="text-body-large font-weight-bold text-grey-darken-4 mb-2">
|
||||||
預約普測,建立您的體適能分組
|
預約普測,建立您的體適能分組
|
||||||
</p>
|
</p>
|
||||||
<p class="text-body-2 text-grey-darken-2 mb-0">
|
<p class="text-body-medium text-grey-darken-2 mb-0">
|
||||||
完成預約並到現場參加量測後,系統才能建立您的體適能分組與課程建議。
|
完成預約並到現場參加量測後,系統才能建立您的體適能分組與課程建議。
|
||||||
</p>
|
</p>
|
||||||
</v-card-text>
|
</v-card-text>
|
||||||
|
|||||||
@@ -16,108 +16,137 @@
|
|||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
<!-- 登入卡片 -->
|
<!-- 登入卡片 -->
|
||||||
<v-card class="rounded-lg mt-4" color="surface">
|
<v-card
|
||||||
<v-card-title> 登入 </v-card-title>
|
class="rounded-xl mt-4 text-left"
|
||||||
<v-card-text> 請輸入您的手機號碼與登入密碼 </v-card-text>
|
color="surface"
|
||||||
<!-- 表單 -->
|
variant="flat"
|
||||||
<v-form class="pa-4" @submit.prevent="handleLogin">
|
>
|
||||||
<v-alert
|
<v-card-title class="text-center">登入</v-card-title>
|
||||||
v-if="csrfRecoveryMessage"
|
<v-form @submit.prevent="handleLogin">
|
||||||
class="mb-4"
|
<div class="pa-5 pa-sm-6">
|
||||||
density="compact"
|
<v-alert
|
||||||
type="warning"
|
v-if="route.query.passwordReset === 'success'"
|
||||||
variant="tonal"
|
class="mb-4"
|
||||||
|
type="success"
|
||||||
|
variant="tonal"
|
||||||
|
role="status"
|
||||||
|
>
|
||||||
|
密碼已更新,請使用新密碼登入。
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="csrfRecoveryMessage"
|
||||||
|
class="mb-4"
|
||||||
|
density="compact"
|
||||||
|
type="warning"
|
||||||
|
variant="tonal"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{{ csrfRecoveryMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-alert
|
||||||
|
v-if="errorMessage"
|
||||||
|
class="mb-4"
|
||||||
|
density="compact"
|
||||||
|
type="error"
|
||||||
|
variant="tonal"
|
||||||
|
role="alert"
|
||||||
|
>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="account"
|
||||||
|
autocomplete="tel-national"
|
||||||
|
inputmode="tel"
|
||||||
|
class="mb-5"
|
||||||
|
color="primary"
|
||||||
|
:disabled="loading"
|
||||||
|
hide-details="auto"
|
||||||
|
label="手機號碼"
|
||||||
|
placeholder="例如:0912345678"
|
||||||
|
prepend-inner-icon="mdi-phone"
|
||||||
|
required
|
||||||
|
rounded="lg"
|
||||||
|
density="comfortable"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-text-field
|
||||||
|
v-model="password"
|
||||||
|
autocomplete="current-password"
|
||||||
|
class="mb-6"
|
||||||
|
color="primary"
|
||||||
|
:disabled="loading"
|
||||||
|
hide-details="auto"
|
||||||
|
label="登入密碼"
|
||||||
|
prepend-inner-icon="mdi-lock-outline"
|
||||||
|
required
|
||||||
|
rounded="lg"
|
||||||
|
type="password"
|
||||||
|
density="comfortable"
|
||||||
|
variant="outlined"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold mb-3"
|
||||||
|
color="primary"
|
||||||
|
:disabled="loading || remainingSeconds > 0"
|
||||||
|
:loading="loading"
|
||||||
|
prepend-icon="mdi-login"
|
||||||
|
size="large"
|
||||||
|
type="submit"
|
||||||
|
variant="flat"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
remainingSeconds > 0
|
||||||
|
? `${remainingSeconds} 秒後再試`
|
||||||
|
: "登入健康平台"
|
||||||
|
}}
|
||||||
|
</v-btn>
|
||||||
|
|
||||||
|
<v-btn
|
||||||
|
block
|
||||||
|
class="rounded-lg font-weight-bold"
|
||||||
|
color="primary"
|
||||||
|
:disabled="loading"
|
||||||
|
prepend-icon="mdi-account-plus-outline"
|
||||||
|
size="large"
|
||||||
|
to="/register"
|
||||||
|
variant="outlined"
|
||||||
|
>
|
||||||
|
既有帳號建立密碼
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<v-divider />
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="d-flex flex-wrap align-center justify-space-between ga-2 px-5 px-sm-6 py-1 bg-background"
|
||||||
>
|
>
|
||||||
{{ csrfRecoveryMessage }}
|
<span class="text-body-medium text-medium-emphasis"
|
||||||
</v-alert>
|
>無法登入?</span
|
||||||
|
>
|
||||||
<v-alert
|
<v-btn
|
||||||
v-if="errorMessage"
|
class="rounded-lg"
|
||||||
class="mb-4"
|
color="primary"
|
||||||
density="compact"
|
:disabled="loading"
|
||||||
type="error"
|
prepend-icon="mdi-lock-reset"
|
||||||
variant="tonal"
|
to="/forgot-password"
|
||||||
>
|
variant="text"
|
||||||
{{ errorMessage }}
|
>
|
||||||
</v-alert>
|
忘記密碼
|
||||||
|
</v-btn>
|
||||||
<v-text-field
|
</div>
|
||||||
v-model="account"
|
|
||||||
class="mb-6"
|
|
||||||
color="primary"
|
|
||||||
hide-details="auto"
|
|
||||||
label="手機號碼"
|
|
||||||
placeholder="例如: 0910666888"
|
|
||||||
prepend-inner-icon="mdi-phone"
|
|
||||||
required
|
|
||||||
rounded="lg"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-text-field
|
|
||||||
v-model="password"
|
|
||||||
class="mb-6"
|
|
||||||
color="primary"
|
|
||||||
hide-details="auto"
|
|
||||||
label="登入密碼"
|
|
||||||
prepend-inner-icon="mdi-lock-outline"
|
|
||||||
required
|
|
||||||
rounded="lg"
|
|
||||||
type="password"
|
|
||||||
variant="outlined"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
block
|
|
||||||
class="glow-sweep-button rounded-lg font-weight-bold elevation-2 mb-4"
|
|
||||||
color="secondary"
|
|
||||||
:disabled="remainingSeconds > 0"
|
|
||||||
:loading="loading"
|
|
||||||
type="submit"
|
|
||||||
>
|
|
||||||
{{
|
|
||||||
remainingSeconds > 0
|
|
||||||
? `${remainingSeconds} 秒後再試`
|
|
||||||
: "登入健康平台"
|
|
||||||
}}
|
|
||||||
</v-btn>
|
|
||||||
|
|
||||||
<v-btn
|
|
||||||
block
|
|
||||||
class="rounded-lg font-weight-bold mb-3"
|
|
||||||
color="primary"
|
|
||||||
to="/register"
|
|
||||||
variant="outlined"
|
|
||||||
>
|
|
||||||
既有帳號建立密碼
|
|
||||||
</v-btn>
|
|
||||||
</v-form>
|
</v-form>
|
||||||
<!-- <div class="d-flex align-center justify-center my-4">
|
|
||||||
<v-divider class="mr-3" />
|
|
||||||
<span class="text-caption text-grey font-weight-bold">或使用以下方式快捷登入</span>
|
|
||||||
<v-divider class="ml-3" />
|
|
||||||
</div> -->
|
|
||||||
|
|
||||||
<!-- LINE 一鍵登入 (高齡場景極度實用) -->
|
<v-card-actions class="justify-center px-5 py-4">
|
||||||
<!-- <v-btn
|
<span class="text-body-small text-medium-emphasis text-center">
|
||||||
block
|
|
||||||
class="rounded-lg font-weight-bold text-white mb-4 text-button d-flex align-center justify-center"
|
|
||||||
color="#06C755"
|
|
||||||
:loading="lineLoading"
|
|
||||||
size="large"
|
|
||||||
style="text-transform: none"
|
|
||||||
variant="flat"
|
|
||||||
@click="handleLineLogin"
|
|
||||||
>
|
|
||||||
<v-icon class="mr-2" icon="mdi-message-text" />
|
|
||||||
使用 LINE 帳號一鍵登入
|
|
||||||
</v-btn> -->
|
|
||||||
|
|
||||||
<v-card-actions class="justify-center">
|
|
||||||
<span class="text-body-small text-grey">
|
|
||||||
登入即表示您同意本平台的
|
登入即表示您同意本平台的
|
||||||
<a
|
<a
|
||||||
class="text-accent text-decoration-none font-weight-bold"
|
class="text-primary text-decoration-none font-weight-bold"
|
||||||
href="#"
|
href="#"
|
||||||
>
|
>
|
||||||
個資授權條款
|
個資授權條款
|
||||||
@@ -201,6 +230,8 @@ async function finishAuthentication() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
|
if (loading.value || remainingSeconds.value > 0) return;
|
||||||
|
|
||||||
const phone = normalizePhone(account.value);
|
const phone = normalizePhone(account.value);
|
||||||
|
|
||||||
if (!/^09\d{8}$/.test(phone)) {
|
if (!/^09\d{8}$/.test(phone)) {
|
||||||
@@ -247,41 +278,3 @@ async function _handleLineLogin() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.glow-sweep-button {
|
|
||||||
overflow: hidden;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.glow-sweep-button::after {
|
|
||||||
position: absolute;
|
|
||||||
top: -50%;
|
|
||||||
left: -70%;
|
|
||||||
width: 45%;
|
|
||||||
height: 200%;
|
|
||||||
background: linear-gradient(
|
|
||||||
120deg,
|
|
||||||
transparent 0%,
|
|
||||||
rgba(255, 255, 255, 0.15) 35%,
|
|
||||||
rgba(255, 255, 255, 0.75) 50%,
|
|
||||||
rgba(255, 255, 255, 0.15) 65%,
|
|
||||||
transparent 100%
|
|
||||||
);
|
|
||||||
content: "";
|
|
||||||
transform: rotate(18deg);
|
|
||||||
animation: glow-sweep 2.4s ease-in-out infinite;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes glow-sweep {
|
|
||||||
0% {
|
|
||||||
left: -70%;
|
|
||||||
}
|
|
||||||
|
|
||||||
45%,
|
|
||||||
100% {
|
|
||||||
left: 125%;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { createRouter, createWebHistory } from "vue-router";
|
import {
|
||||||
|
createRouter,
|
||||||
|
createWebHistory,
|
||||||
|
isNavigationFailure,
|
||||||
|
NavigationFailureType,
|
||||||
|
} from "vue-router";
|
||||||
import Appointment from "@/pages/appointment.vue";
|
import Appointment from "@/pages/appointment.vue";
|
||||||
import BodyComposition from "@/pages/body-composition.vue";
|
import BodyComposition from "@/pages/body-composition.vue";
|
||||||
import Checkin from "@/pages/checkin.vue";
|
import Checkin from "@/pages/checkin.vue";
|
||||||
@@ -8,6 +13,15 @@ import Course from "@/pages/course.vue";
|
|||||||
import Index from "@/pages/index.vue";
|
import Index from "@/pages/index.vue";
|
||||||
import Login from "@/pages/login.vue";
|
import Login from "@/pages/login.vue";
|
||||||
import Register from "@/pages/register.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 SelfAssessment from "@/pages/self-assessment.vue";
|
||||||
import {
|
import {
|
||||||
APPOINTMENT_ENABLED,
|
APPOINTMENT_ENABLED,
|
||||||
@@ -19,6 +33,9 @@ import { useAppStore } from "@/stores/app";
|
|||||||
import { useCourseStore } from "@/stores/course";
|
import { useCourseStore } from "@/stores/course";
|
||||||
import { getApiErrorStatus } from "@/utils/api-error";
|
import { getApiErrorStatus } from "@/utils/api-error";
|
||||||
|
|
||||||
|
// 在 router 啟動初始導航與任何 Session API 之前移除敏感 query。
|
||||||
|
captureInitialPasswordResetToken();
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(import.meta.env.BASE_URL),
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
scrollBehavior(to, _from, savedPosition) {
|
scrollBehavior(to, _from, savedPosition) {
|
||||||
@@ -43,6 +60,18 @@ const router = createRouter({
|
|||||||
name: "login",
|
name: "login",
|
||||||
component: Login,
|
component: Login,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: "/forgot-password",
|
||||||
|
name: "forgot-password",
|
||||||
|
component: ForgotPassword,
|
||||||
|
meta: { title: "忘記密碼|運動玩轉健康力" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/change-password",
|
||||||
|
name: "change-password",
|
||||||
|
component: ChangePassword,
|
||||||
|
meta: { title: "重設密碼|運動玩轉健康力" },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: "/register",
|
path: "/register",
|
||||||
name: "register",
|
name: "register",
|
||||||
@@ -61,7 +90,7 @@ const router = createRouter({
|
|||||||
component: CompleteEmail,
|
component: CompleteEmail,
|
||||||
meta: {
|
meta: {
|
||||||
requiresAuth: true,
|
requiresAuth: true,
|
||||||
title: "補登 E-Mail|運動玩轉健康力",
|
title: "註冊 E-Mail|運動玩轉健康力",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -115,6 +144,16 @@ const CHECKIN_PATH = /^\/checkin(?:\/|$)/;
|
|||||||
|
|
||||||
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
|
// 簡單的導航守衛:只有標記需要登入的頁面會導向登入頁
|
||||||
router.beforeEach(async (to) => {
|
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 (
|
if (
|
||||||
(!APPOINTMENT_ENABLED && APPOINTMENT_PATH.test(to.path)) ||
|
(!APPOINTMENT_ENABLED && APPOINTMENT_PATH.test(to.path)) ||
|
||||||
(!BODY_COMPOSITION_ENABLED && BODY_COMPOSITION_PATH.test(to.path)) ||
|
(!BODY_COMPOSITION_ENABLED && BODY_COMPOSITION_PATH.test(to.path)) ||
|
||||||
@@ -178,7 +217,15 @@ router.beforeEach(async (to) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.afterEach((to) => {
|
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 =
|
document.title =
|
||||||
typeof to.meta.title === "string" ? to.meta.title : "運動玩轉健康力";
|
typeof to.meta.title === "string" ? to.meta.title : "運動玩轉健康力";
|
||||||
});
|
});
|
||||||
@@ -193,7 +240,11 @@ if (typeof window !== "undefined") {
|
|||||||
store.clearAuthentication();
|
store.clearAuthentication();
|
||||||
courseStore.resetGroup();
|
courseStore.resetGroup();
|
||||||
|
|
||||||
if (wasLoggedIn && currentRoute.name !== "login") {
|
if (
|
||||||
|
wasLoggedIn &&
|
||||||
|
currentRoute.name !== "login" &&
|
||||||
|
!isPublicPasswordPath(currentRoute.path)
|
||||||
|
) {
|
||||||
void router.replace({
|
void router.replace({
|
||||||
name: "login",
|
name: "login",
|
||||||
query: { redirect: currentRoute.fullPath },
|
query: { redirect: currentRoute.fullPath },
|
||||||
@@ -206,6 +257,11 @@ if (typeof window !== "undefined") {
|
|||||||
const store = useAppStore();
|
const store = useAppStore();
|
||||||
const courseStore = useCourseStore();
|
const courseStore = useCourseStore();
|
||||||
const currentRoute = router.currentRoute.value;
|
const currentRoute = router.currentRoute.value;
|
||||||
|
if (isPublicPasswordPath(currentRoute.path)) {
|
||||||
|
store.clearAuthentication();
|
||||||
|
courseStore.resetGroup();
|
||||||
|
return;
|
||||||
|
}
|
||||||
const redirect =
|
const redirect =
|
||||||
currentRoute.name === "login"
|
currentRoute.name === "login"
|
||||||
? String(currentRoute.query.redirect ?? "")
|
? String(currentRoute.query.redirect ?? "")
|
||||||
|
|||||||
@@ -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;
|
isLoggedIn.value = false;
|
||||||
userID.value = "";
|
userID.value = "";
|
||||||
authGroup.value = null;
|
authGroup.value = null;
|
||||||
|
|||||||
@@ -28,19 +28,34 @@ export function getApiErrorCode(error: unknown): string | null {
|
|||||||
return getApiErrorBody(error)?.code ?? 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 {
|
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;
|
const bodyValue = getApiErrorBody(error)?.retryAfterSeconds;
|
||||||
if (typeof bodyValue === "number" && Number.isFinite(bodyValue)) {
|
return typeof bodyValue === "number" &&
|
||||||
return Math.max(0, Math.ceil(bodyValue));
|
Number.isFinite(bodyValue) &&
|
||||||
}
|
bodyValue >= 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))
|
|
||||||
: null;
|
: 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