This commit is contained in:
parent
a5f345293a
commit
8fc121220e
@ -6,6 +6,7 @@
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs --fix --ignore-path .gitignore"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@ -3,6 +3,7 @@ import {RouterView, useRoute} from 'vue-router'
|
||||
import HomeHeader from '@/views/components/HomeHeader/HomeHeader.vue'
|
||||
import HomeAside from '@/views/components/HomeAside/HomeAside.vue';
|
||||
import RecoverySetupPrompt from '@/views/components/RecoverySetupPrompt/RecoverySetupPrompt.vue'
|
||||
import StartupStatus from '@/views/components/StartupStatus/StartupStatus.vue'
|
||||
import {ref, watch} from 'vue'
|
||||
import {useGlobalStatusStore} from "@/stores/useGlobalStatusStore";
|
||||
|
||||
@ -21,7 +22,8 @@ watch(
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div id="main">
|
||||
<StartupStatus v-if="globalStatus.startupBlocked" />
|
||||
<div v-else id="main">
|
||||
<HomeHeader v-if="!standalonePages.includes(pageName)"/>
|
||||
<div id="content">
|
||||
<div id="aside" v-if="!standalonePages.includes(pageName)">
|
||||
|
||||
@ -2,6 +2,7 @@ import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import {router} from './router'
|
||||
import {useGlobalStatusStore} from './stores/useGlobalStatusStore'
|
||||
|
||||
import ElementPlus from 'element-plus'
|
||||
import 'element-plus/dist/index.css'
|
||||
@ -30,11 +31,15 @@ if (isReservedResetPath(window.location.pathname)) {
|
||||
|
||||
const app = createApp(App)
|
||||
const pinia = createPinia()
|
||||
const globalStatus = useGlobalStatusStore(pinia)
|
||||
|
||||
app.use(pinia)
|
||||
|
||||
router.onError((error) => {
|
||||
console.error('Router navigation failed', error)
|
||||
globalStatus.setStartupError('页面加载失败,请刷新后重试')
|
||||
})
|
||||
|
||||
app.use(router)
|
||||
app.use(ElementPlus)
|
||||
|
||||
router.isReady().then(() => {
|
||||
app.mount('#app')
|
||||
})
|
||||
app.mount('#app')
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { createRouter, createWebHashHistory } from "vue-router";
|
||||
import {useGlobalStatusStore} from "@/stores/useGlobalStatusStore";
|
||||
import {AUTH_STATUS} from "@/utils/auth.mjs";
|
||||
import {createLoginLocation} from "@/utils/navigation.mjs";
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHashHistory(),
|
||||
@ -65,13 +67,32 @@ const router = createRouter({
|
||||
router.beforeEach(async (to) => {
|
||||
const globalStatus = useGlobalStatusStore();
|
||||
const needsAuthentication = to.meta.requiresAuth || to.name === "force-password-change";
|
||||
const authenticated = globalStatus.isLogin || (needsAuthentication && await globalStatus.refresh());
|
||||
let authStatus = globalStatus.isLogin
|
||||
? AUTH_STATUS.AUTHENTICATED
|
||||
: globalStatus.authStatus;
|
||||
|
||||
if (needsAuthentication && authStatus !== AUTH_STATUS.AUTHENTICATED) {
|
||||
authStatus = await globalStatus.refresh();
|
||||
}
|
||||
|
||||
if (needsAuthentication && authStatus === AUTH_STATUS.UNAVAILABLE) {
|
||||
globalStatus.setAuthRetryPath(to.fullPath);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!needsAuthentication && authStatus === AUTH_STATUS.UNAVAILABLE) {
|
||||
globalStatus.resetAuthAvailability();
|
||||
authStatus = AUTH_STATUS.UNKNOWN;
|
||||
}
|
||||
|
||||
const authenticated = authStatus === AUTH_STATUS.AUTHENTICATED;
|
||||
|
||||
if (!authenticated && needsAuthentication) {
|
||||
return {
|
||||
name: "login",
|
||||
query: {redirect: to.fullPath}
|
||||
};
|
||||
return createLoginLocation(to.fullPath);
|
||||
}
|
||||
|
||||
if (authenticated) {
|
||||
globalStatus.clearAuthRetryPath();
|
||||
}
|
||||
|
||||
if (to.name === "force-password-change") {
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import { defineStore } from "pinia";
|
||||
import { http } from "@/utils/axios";
|
||||
|
||||
let authRefreshPromise = null;
|
||||
import {
|
||||
AUTH_STATUS,
|
||||
classifyAuthenticationError,
|
||||
classifyAuthenticationResponse,
|
||||
runSharedAuthRefresh
|
||||
} from "@/utils/auth.mjs";
|
||||
import {getSafeLoginRedirect} from "@/utils/navigation.mjs";
|
||||
|
||||
const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
|
||||
state() {
|
||||
@ -11,6 +16,10 @@ const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
|
||||
settingsDrawerVisible: false,
|
||||
authChecked: false,
|
||||
authLoading: false,
|
||||
authStatus: AUTH_STATUS.UNKNOWN,
|
||||
authError: "",
|
||||
authRetryPath: "/",
|
||||
startupError: "",
|
||||
pendingLogin: null,
|
||||
recoveryPromptDismissed: false
|
||||
};
|
||||
@ -26,42 +35,49 @@ const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
|
||||
return Array.isArray(state.userInfos.capabilities)
|
||||
? state.userInfos.capabilities
|
||||
: [];
|
||||
},
|
||||
startupBlocked(state) {
|
||||
return Boolean(state.startupError)
|
||||
|| (state.authLoading && Object.keys(state.userInfos).length === 0)
|
||||
|| state.authStatus === AUTH_STATUS.UNAVAILABLE;
|
||||
}
|
||||
},
|
||||
actions: {
|
||||
refresh() {
|
||||
if (authRefreshPromise) {
|
||||
return authRefreshPromise;
|
||||
}
|
||||
|
||||
this.authLoading = true;
|
||||
authRefreshPromise = (async () => {
|
||||
return runSharedAuthRefresh(async () => {
|
||||
this.authLoading = true;
|
||||
this.authStatus = AUTH_STATUS.LOADING;
|
||||
this.authError = "";
|
||||
try {
|
||||
const response = await http.post("/api/v1/user/info", {});
|
||||
if (response.errorNo === 0 && response.data) {
|
||||
const result = classifyAuthenticationResponse(response);
|
||||
if (result === AUTH_STATUS.AUTHENTICATED) {
|
||||
this.setUser(response.data, false);
|
||||
this.authChecked = true;
|
||||
return true;
|
||||
} else if (result === AUTH_STATUS.UNAUTHENTICATED) {
|
||||
this.clear();
|
||||
} else {
|
||||
this.markAuthUnavailable();
|
||||
}
|
||||
this.clear();
|
||||
return false;
|
||||
return result;
|
||||
} catch (error) {
|
||||
this.clear();
|
||||
return false;
|
||||
const result = classifyAuthenticationError(error);
|
||||
if (result === AUTH_STATUS.UNAUTHENTICATED) {
|
||||
this.clear();
|
||||
} else {
|
||||
this.markAuthUnavailable();
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
this.authLoading = false;
|
||||
authRefreshPromise = null;
|
||||
}
|
||||
})();
|
||||
|
||||
return authRefreshPromise;
|
||||
});
|
||||
},
|
||||
init(callback) {
|
||||
return this.refresh().then((authenticated) => {
|
||||
return this.refresh().then((status) => {
|
||||
if (typeof callback === "function") {
|
||||
callback(authenticated);
|
||||
callback(status);
|
||||
}
|
||||
return authenticated;
|
||||
return status;
|
||||
});
|
||||
},
|
||||
clear() {
|
||||
@ -69,6 +85,8 @@ const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
|
||||
this.pendingLogin = null;
|
||||
this.recoveryPromptDismissed = false;
|
||||
this.authChecked = true;
|
||||
this.authStatus = AUTH_STATUS.UNAUTHENTICATED;
|
||||
this.authError = "";
|
||||
},
|
||||
setUser(payload, resetPrompt = false) {
|
||||
const previousAccount = this.userInfos?.account || "";
|
||||
@ -77,6 +95,30 @@ const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
|
||||
this.recoveryPromptDismissed = false;
|
||||
}
|
||||
this.authChecked = true;
|
||||
this.authStatus = AUTH_STATUS.AUTHENTICATED;
|
||||
this.authError = "";
|
||||
},
|
||||
markAuthUnavailable() {
|
||||
this.authChecked = true;
|
||||
this.authStatus = AUTH_STATUS.UNAVAILABLE;
|
||||
this.authError = "认证服务暂时不可用,请检查网络后重试";
|
||||
},
|
||||
resetAuthAvailability() {
|
||||
this.authChecked = false;
|
||||
this.authStatus = AUTH_STATUS.UNKNOWN;
|
||||
this.authError = "";
|
||||
},
|
||||
setAuthRetryPath(path) {
|
||||
this.authRetryPath = typeof path === "string" && path ? path : "/";
|
||||
},
|
||||
clearAuthRetryPath() {
|
||||
this.authRetryPath = "/";
|
||||
},
|
||||
setStartupError(message) {
|
||||
this.startupError = message || "页面加载失败,请刷新后重试";
|
||||
},
|
||||
clearStartupError() {
|
||||
this.startupError = "";
|
||||
},
|
||||
dismissRecoveryPrompt() {
|
||||
this.recoveryPromptDismissed = true;
|
||||
@ -87,7 +129,7 @@ const useGlobalStatusStore = defineStore("useGlobalStatusStore", {
|
||||
account: payload?.account || "",
|
||||
email: payload?.email || "",
|
||||
expiresAt: payload?.expires_at || "",
|
||||
redirect: payload?.redirect || "/"
|
||||
redirect: getSafeLoginRedirect(payload?.redirect)
|
||||
};
|
||||
},
|
||||
clearPendingLogin() {
|
||||
|
||||
47
fe/src/utils/auth.mjs
Normal file
47
fe/src/utils/auth.mjs
Normal file
@ -0,0 +1,47 @@
|
||||
const AUTH_STATUS = Object.freeze({
|
||||
UNKNOWN: "unknown",
|
||||
LOADING: "loading",
|
||||
AUTHENTICATED: "authenticated",
|
||||
UNAUTHENTICATED: "unauthenticated",
|
||||
UNAVAILABLE: "unavailable"
|
||||
});
|
||||
|
||||
let authRefreshPromise = null;
|
||||
|
||||
function classifyAuthenticationResponse(response) {
|
||||
if (response?.errorNo === 0 && response.data && typeof response.data === "object") {
|
||||
return AUTH_STATUS.AUTHENTICATED;
|
||||
}
|
||||
if (response?.errorNo === 403) {
|
||||
return AUTH_STATUS.UNAUTHENTICATED;
|
||||
}
|
||||
return AUTH_STATUS.UNAVAILABLE;
|
||||
}
|
||||
|
||||
function classifyAuthenticationError(error) {
|
||||
if (error?.response?.status === 403 || error?.response?.data?.errorNo === 403) {
|
||||
return AUTH_STATUS.UNAUTHENTICATED;
|
||||
}
|
||||
return AUTH_STATUS.UNAVAILABLE;
|
||||
}
|
||||
|
||||
function runSharedAuthRefresh(task) {
|
||||
if (authRefreshPromise) {
|
||||
return authRefreshPromise;
|
||||
}
|
||||
|
||||
authRefreshPromise = Promise.resolve()
|
||||
.then(task)
|
||||
.finally(() => {
|
||||
authRefreshPromise = null;
|
||||
});
|
||||
|
||||
return authRefreshPromise;
|
||||
}
|
||||
|
||||
export {
|
||||
AUTH_STATUS,
|
||||
classifyAuthenticationError,
|
||||
classifyAuthenticationResponse,
|
||||
runSharedAuthRefresh
|
||||
};
|
||||
@ -1,6 +1,7 @@
|
||||
import axios from 'axios'
|
||||
import lang from '../i18n/i18n';
|
||||
import {router} from "@/router";
|
||||
import {createLoginRedirectHandler} from "@/utils/navigation.mjs";
|
||||
|
||||
//创建axios的一个实例
|
||||
const http = axios.create({
|
||||
@ -17,6 +18,15 @@ const http = axios.create({
|
||||
}
|
||||
});
|
||||
|
||||
let redirectToLogin = null;
|
||||
|
||||
function handleLoginRedirect() {
|
||||
if (!redirectToLogin) {
|
||||
redirectToLogin = createLoginRedirectHandler(router);
|
||||
}
|
||||
return redirectToLogin();
|
||||
}
|
||||
|
||||
//请求拦截器
|
||||
http.interceptors.request.use((config) => {
|
||||
//若请求方式为post,则将data参数转为JSON字符串
|
||||
@ -56,16 +66,17 @@ function isSetupRequest(config) {
|
||||
return path === '/api/setup' || path === '/api/v1/setup' || path === '/__smail_setup_bootstrap'
|
||||
}
|
||||
|
||||
function isAuthenticationRefreshRequest(config) {
|
||||
const rawUrl = String(config?.url || '').split('?')[0]
|
||||
return rawUrl === '/api/user/info' || rawUrl === '/api/v1/user/info'
|
||||
}
|
||||
|
||||
http.interceptors.response.use(async (response) => {
|
||||
//响应成功
|
||||
if (response.data?.errorNo === 403 && !isSetupRequest(response.config)) {
|
||||
|
||||
await router.replace({
|
||||
path: '/login',
|
||||
query: {
|
||||
redirect: router.currentRoute.value.fullPath
|
||||
}
|
||||
});
|
||||
if (response.data?.errorNo === 403
|
||||
&& !isSetupRequest(response.config)
|
||||
&& !isAuthenticationRefreshRequest(response.config)) {
|
||||
await handleLoginRedirect();
|
||||
}
|
||||
//响应成功
|
||||
if (response.data?.errorNo === 402 && !isSetupRequest(response.config)) {
|
||||
@ -91,13 +102,10 @@ http.interceptors.response.use(async (response) => {
|
||||
case 403:
|
||||
if (isSetupRequest(error.config)) {
|
||||
message = lang.setup_failed
|
||||
} else if (isAuthenticationRefreshRequest(error.config)) {
|
||||
message = '登录状态已失效'
|
||||
} else {
|
||||
await router.replace({
|
||||
path: '/login',
|
||||
query: {
|
||||
redirect: router.currentRoute.value.fullPath
|
||||
}
|
||||
});
|
||||
await handleLoginRedirect();
|
||||
}
|
||||
break;
|
||||
case 404:
|
||||
|
||||
90
fe/src/utils/navigation.mjs
Normal file
90
fe/src/utils/navigation.mjs
Normal file
@ -0,0 +1,90 @@
|
||||
const BLOCKED_REDIRECT_PATHS = ["/login", "/login-verification"];
|
||||
|
||||
function decodeRedirect(value) {
|
||||
let decoded = value;
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
try {
|
||||
const next = decodeURIComponent(decoded);
|
||||
if (next === decoded) {
|
||||
break;
|
||||
}
|
||||
decoded = next;
|
||||
} catch {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function getSafeLoginRedirect(value, fallback = "/") {
|
||||
if (typeof value !== "string") {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const redirect = value.trim();
|
||||
if (!redirect.startsWith("/") || redirect.startsWith("//")) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const decodedRedirect = decodeRedirect(redirect);
|
||||
if (!decodedRedirect.startsWith("/") || decodedRedirect.startsWith("//") || decodedRedirect.includes("\\")) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
const decodedPath = decodedRedirect
|
||||
.split(/[?#]/, 1)[0]
|
||||
.replace(/\/+$/, "")
|
||||
.toLowerCase() || "/";
|
||||
|
||||
if (BLOCKED_REDIRECT_PATHS.some((path) => decodedPath === path || decodedPath.startsWith(`${path}/`))) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return redirect;
|
||||
}
|
||||
|
||||
function createLoginLocation(redirect) {
|
||||
return {
|
||||
name: "login",
|
||||
query: {
|
||||
redirect: getSafeLoginRedirect(redirect)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentLoginRedirect(route) {
|
||||
if (route?.name === "login" || route?.name === "login-verification") {
|
||||
return getSafeLoginRedirect(route?.query?.redirect);
|
||||
}
|
||||
return getSafeLoginRedirect(route?.fullPath);
|
||||
}
|
||||
|
||||
function createLoginRedirectHandler(router) {
|
||||
let redirectPromise = null;
|
||||
|
||||
return function redirectToLogin() {
|
||||
const currentRoute = router.currentRoute.value;
|
||||
if (currentRoute?.name === "login" || currentRoute?.name === "login-verification") {
|
||||
return Promise.resolve(false);
|
||||
}
|
||||
if (redirectPromise) {
|
||||
return redirectPromise;
|
||||
}
|
||||
|
||||
const location = createLoginLocation(getCurrentLoginRedirect(currentRoute));
|
||||
redirectPromise = Promise.resolve(router.replace(location))
|
||||
.then(() => true)
|
||||
.finally(() => {
|
||||
redirectPromise = null;
|
||||
});
|
||||
|
||||
return redirectPromise;
|
||||
};
|
||||
}
|
||||
|
||||
export {
|
||||
createLoginLocation,
|
||||
createLoginRedirectHandler,
|
||||
getCurrentLoginRedirect,
|
||||
getSafeLoginRedirect
|
||||
};
|
||||
@ -53,10 +53,17 @@ export function usePage() {
|
||||
})
|
||||
}
|
||||
|
||||
const updateGroupList = function () {
|
||||
http.post("/api/v1/group/list").then(res => {
|
||||
const updateGroupList = async function () {
|
||||
try {
|
||||
const res = await http.get("/api/v1/group/list")
|
||||
if (res.errorNo !== 0) {
|
||||
throw new Error(res.errorMsg || "分组列表加载失败")
|
||||
}
|
||||
groupList.value = res.data || []
|
||||
})
|
||||
} catch (error) {
|
||||
groupList.value = []
|
||||
ElMessage.error(error?.message || "分组列表加载失败,请稍后重试")
|
||||
}
|
||||
}
|
||||
|
||||
updateList()
|
||||
|
||||
@ -9,6 +9,7 @@ import lang from "../../i18n/i18n"
|
||||
import { http } from "@/utils/axios"
|
||||
|
||||
import { useGlobalStatusStore } from "@/stores/useGlobalStatusStore"
|
||||
import { getSafeLoginRedirect } from "@/utils/navigation.mjs"
|
||||
|
||||
export function usePage() {
|
||||
const router = useRouter()
|
||||
@ -31,9 +32,7 @@ export function usePage() {
|
||||
ElMessage.error(res.errorMsg)
|
||||
return
|
||||
}
|
||||
const redirect = typeof router.currentRoute.value.query.redirect === "string"
|
||||
? router.currentRoute.value.query.redirect
|
||||
: "/"
|
||||
const redirect = getSafeLoginRedirect(router.currentRoute.value.query.redirect)
|
||||
if (res.data?.pending) {
|
||||
globalStatus.setPendingLogin({ ...res.data, redirect })
|
||||
await router.replace({ name: "login-verification" })
|
||||
|
||||
@ -6,6 +6,7 @@ import { useRouter } from "vue-router"
|
||||
|
||||
import { useGlobalStatusStore } from "@/stores/useGlobalStatusStore"
|
||||
import { http } from "@/utils/axios"
|
||||
import { getSafeLoginRedirect } from "@/utils/navigation.mjs"
|
||||
import {
|
||||
normalizeRequestOptions,
|
||||
serializeCredential,
|
||||
@ -77,7 +78,7 @@ export function usePage() {
|
||||
if (response.errorNo !== 0) {
|
||||
throw new Error(response.errorMsg || "验证码验证失败")
|
||||
}
|
||||
const redirect = pending.value.redirect || "/"
|
||||
const redirect = getSafeLoginRedirect(pending.value.redirect)
|
||||
globalStatus.clearPendingLogin()
|
||||
globalStatus.setUser(response.data, true)
|
||||
await router.replace(redirect)
|
||||
@ -145,7 +146,7 @@ export function usePage() {
|
||||
if (finish.errorNo !== 0) {
|
||||
throw new Error(finish.errorMsg || "安全密钥验证失败")
|
||||
}
|
||||
const redirect = pending.value?.redirect || "/"
|
||||
const redirect = getSafeLoginRedirect(pending.value?.redirect)
|
||||
globalStatus.clearPendingLogin()
|
||||
globalStatus.setUser(finish.data, true)
|
||||
await router.replace(redirect)
|
||||
|
||||
74
fe/src/views/components/StartupStatus/StartupStatus.css
Normal file
74
fe/src/views/components/StartupStatus/StartupStatus.css
Normal file
@ -0,0 +1,74 @@
|
||||
.startup-status {
|
||||
width: 100%;
|
||||
min-height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
box-sizing: border-box;
|
||||
background: var(--pm-bg-primary);
|
||||
}
|
||||
|
||||
.startup-status__card {
|
||||
width: min(440px, 100%);
|
||||
padding: 36px;
|
||||
box-sizing: border-box;
|
||||
text-align: center;
|
||||
background: var(--pm-surface-solid);
|
||||
border: 1px solid var(--pm-border-color);
|
||||
border-radius: var(--pm-radius-xl);
|
||||
box-shadow: var(--pm-shadow-md);
|
||||
}
|
||||
|
||||
.startup-status__label {
|
||||
display: inline-block;
|
||||
margin-bottom: 12px;
|
||||
color: var(--pm-primary-color);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.startup-status__card h1 {
|
||||
margin: 0;
|
||||
color: var(--pm-text-primary);
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.startup-status__card p {
|
||||
margin: 16px 0 24px;
|
||||
color: var(--pm-text-secondary);
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.startup-status__loading {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--pm-text-secondary);
|
||||
}
|
||||
|
||||
.startup-status__spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--pm-border-color);
|
||||
border-top-color: var(--pm-primary-color);
|
||||
border-radius: 50%;
|
||||
animation: startup-status-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes startup-status-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.startup-status {
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.startup-status__card {
|
||||
padding: 28px 22px;
|
||||
}
|
||||
}
|
||||
70
fe/src/views/components/StartupStatus/StartupStatus.js
Normal file
70
fe/src/views/components/StartupStatus/StartupStatus.js
Normal file
@ -0,0 +1,70 @@
|
||||
import { computed, ref } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
|
||||
import { useGlobalStatusStore } from "@/stores/useGlobalStatusStore"
|
||||
import { AUTH_STATUS } from "@/utils/auth.mjs"
|
||||
import { createLoginLocation, getSafeLoginRedirect } from "@/utils/navigation.mjs"
|
||||
|
||||
export function useStartupStatus() {
|
||||
const router = useRouter()
|
||||
const globalStatus = useGlobalStatusStore()
|
||||
const retrying = ref(false)
|
||||
|
||||
const isLoading = computed(() => globalStatus.authLoading || retrying.value)
|
||||
const hasStartupError = computed(() => Boolean(globalStatus.startupError))
|
||||
const title = computed(() => {
|
||||
if (hasStartupError.value) {
|
||||
return "页面加载失败"
|
||||
}
|
||||
if (isLoading.value) {
|
||||
return "正在恢复登录状态"
|
||||
}
|
||||
return "认证服务暂时不可用"
|
||||
})
|
||||
const description = computed(() => {
|
||||
if (hasStartupError.value) {
|
||||
return globalStatus.startupError
|
||||
}
|
||||
if (isLoading.value) {
|
||||
return "正在确认当前登录会话,请稍候。"
|
||||
}
|
||||
return globalStatus.authError || "暂时无法确认登录状态,请检查网络后重试。"
|
||||
})
|
||||
const buttonLabel = computed(() => hasStartupError.value ? "刷新页面" : "重新检查")
|
||||
|
||||
const retry = async () => {
|
||||
if (isLoading.value) {
|
||||
return
|
||||
}
|
||||
if (hasStartupError.value) {
|
||||
window.location.reload()
|
||||
return
|
||||
}
|
||||
|
||||
retrying.value = true
|
||||
const redirect = getSafeLoginRedirect(globalStatus.authRetryPath)
|
||||
try {
|
||||
const status = await globalStatus.refresh()
|
||||
if (status === AUTH_STATUS.AUTHENTICATED) {
|
||||
globalStatus.clearAuthRetryPath()
|
||||
await router.replace(redirect)
|
||||
} else if (status === AUTH_STATUS.UNAUTHENTICATED) {
|
||||
globalStatus.clearAuthRetryPath()
|
||||
await router.replace(createLoginLocation(redirect))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Authentication retry failed", error)
|
||||
globalStatus.setStartupError("页面加载失败,请刷新后重试")
|
||||
} finally {
|
||||
retrying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
buttonLabel,
|
||||
description,
|
||||
isLoading,
|
||||
retry,
|
||||
title
|
||||
}
|
||||
}
|
||||
35
fe/src/views/components/StartupStatus/StartupStatus.vue
Normal file
35
fe/src/views/components/StartupStatus/StartupStatus.vue
Normal file
@ -0,0 +1,35 @@
|
||||
<script setup>
|
||||
import { useStartupStatus } from "./StartupStatus.js"
|
||||
|
||||
const {
|
||||
buttonLabel,
|
||||
description,
|
||||
isLoading,
|
||||
retry,
|
||||
title
|
||||
} = useStartupStatus()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="startup-status" aria-live="polite">
|
||||
<section class="startup-status__card">
|
||||
<span class="startup-status__label">Smail</span>
|
||||
<h1>{{ title }}</h1>
|
||||
<p>{{ description }}</p>
|
||||
<el-button
|
||||
v-if="!isLoading"
|
||||
type="primary"
|
||||
size="large"
|
||||
@click="retry"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</el-button>
|
||||
<div v-else class="startup-status__loading" role="status">
|
||||
<span class="startup-status__spinner" aria-hidden="true"></span>
|
||||
<span>请稍候</span>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped src="./StartupStatus.css"></style>
|
||||
68
fe/tests/auth.test.mjs
Normal file
68
fe/tests/auth.test.mjs
Normal file
@ -0,0 +1,68 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
AUTH_STATUS,
|
||||
classifyAuthenticationError,
|
||||
classifyAuthenticationResponse,
|
||||
runSharedAuthRefresh
|
||||
} from "../src/utils/auth.mjs";
|
||||
|
||||
test("认证响应会区分已登录、未登录和服务不可用", () => {
|
||||
assert.equal(
|
||||
classifyAuthenticationResponse({errorNo: 0, data: {account: "user@example.com"}}),
|
||||
AUTH_STATUS.AUTHENTICATED
|
||||
);
|
||||
assert.equal(
|
||||
classifyAuthenticationResponse({errorNo: 403}),
|
||||
AUTH_STATUS.UNAUTHENTICATED
|
||||
);
|
||||
assert.equal(
|
||||
classifyAuthenticationResponse({errorNo: 500}),
|
||||
AUTH_STATUS.UNAVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
test("只有明确的 403 错误会被识别为未登录", () => {
|
||||
assert.equal(
|
||||
classifyAuthenticationError({response: {status: 403}}),
|
||||
AUTH_STATUS.UNAUTHENTICATED
|
||||
);
|
||||
assert.equal(
|
||||
classifyAuthenticationError({response: {status: 500}}),
|
||||
AUTH_STATUS.UNAVAILABLE
|
||||
);
|
||||
assert.equal(
|
||||
classifyAuthenticationError(new Error("Network Error")),
|
||||
AUTH_STATUS.UNAVAILABLE
|
||||
);
|
||||
});
|
||||
|
||||
test("并发认证刷新只执行一次请求并返回同一个结果", async () => {
|
||||
let requestCount = 0;
|
||||
let resolveRequest;
|
||||
const request = () => {
|
||||
requestCount += 1;
|
||||
return new Promise((resolve) => {
|
||||
resolveRequest = resolve;
|
||||
});
|
||||
};
|
||||
|
||||
const first = runSharedAuthRefresh(request);
|
||||
const second = runSharedAuthRefresh(request);
|
||||
|
||||
assert.strictEqual(first, second);
|
||||
await Promise.resolve();
|
||||
assert.equal(requestCount, 1);
|
||||
|
||||
resolveRequest(AUTH_STATUS.AUTHENTICATED);
|
||||
assert.equal(await first, AUTH_STATUS.AUTHENTICATED);
|
||||
assert.equal(await second, AUTH_STATUS.AUTHENTICATED);
|
||||
|
||||
const third = runSharedAuthRefresh(async () => {
|
||||
requestCount += 1;
|
||||
return AUTH_STATUS.UNAUTHENTICATED;
|
||||
});
|
||||
assert.equal(await third, AUTH_STATUS.UNAUTHENTICATED);
|
||||
assert.equal(requestCount, 2);
|
||||
});
|
||||
73
fe/tests/navigation.test.mjs
Normal file
73
fe/tests/navigation.test.mjs
Normal file
@ -0,0 +1,73 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
createLoginRedirectHandler,
|
||||
getCurrentLoginRedirect,
|
||||
getSafeLoginRedirect
|
||||
} from "../src/utils/navigation.mjs";
|
||||
|
||||
test("登录重定向只接受非登录页的站内路径", () => {
|
||||
assert.equal(getSafeLoginRedirect("/list?tag=inbox"), "/list?tag=inbox");
|
||||
assert.equal(getSafeLoginRedirect("https://example.com"), "/");
|
||||
assert.equal(getSafeLoginRedirect("//example.com"), "/");
|
||||
assert.equal(getSafeLoginRedirect("/%2fexample.com"), "/");
|
||||
assert.equal(getSafeLoginRedirect("/login?redirect=/list"), "/");
|
||||
assert.equal(getSafeLoginRedirect("/%6Cogin-verification"), "/");
|
||||
assert.equal(getSafeLoginRedirect("/login/extra"), "/");
|
||||
});
|
||||
|
||||
test("已在登录页时保留原有安全重定向", () => {
|
||||
assert.equal(getCurrentLoginRedirect({
|
||||
name: "login",
|
||||
fullPath: "/login?redirect=/list",
|
||||
query: {redirect: "/list"}
|
||||
}), "/list");
|
||||
|
||||
assert.equal(getCurrentLoginRedirect({
|
||||
name: "login",
|
||||
fullPath: "/login?redirect=/login",
|
||||
query: {redirect: "/login"}
|
||||
}), "/");
|
||||
});
|
||||
|
||||
test("并发 403 只执行一次登录跳转且不会嵌套 redirect", async () => {
|
||||
let replaceCount = 0;
|
||||
let finishNavigation;
|
||||
const router = {
|
||||
currentRoute: {
|
||||
value: {
|
||||
name: "list",
|
||||
fullPath: "/list?tag=inbox",
|
||||
query: {}
|
||||
}
|
||||
},
|
||||
replace(location) {
|
||||
replaceCount += 1;
|
||||
return new Promise((resolve) => {
|
||||
finishNavigation = () => {
|
||||
router.currentRoute.value = {
|
||||
name: "login",
|
||||
fullPath: "/login?redirect=/list?tag=inbox",
|
||||
query: location.query
|
||||
};
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const redirectToLogin = createLoginRedirectHandler(router);
|
||||
const first = redirectToLogin();
|
||||
const second = redirectToLogin();
|
||||
|
||||
assert.strictEqual(first, second);
|
||||
assert.equal(replaceCount, 1);
|
||||
finishNavigation();
|
||||
assert.equal(await first, true);
|
||||
assert.equal(router.currentRoute.value.query.redirect, "/list?tag=inbox");
|
||||
|
||||
assert.equal(await redirectToLogin(), false);
|
||||
assert.equal(replaceCount, 1);
|
||||
assert.equal(router.currentRoute.value.query.redirect, "/list?tag=inbox");
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user