Browse Source

feat: add global configuration management and error handling component

Introduce a new error handling component to display user-friendly error messages based on HTTP status codes. Implement global configuration management to control registration availability across the application. Update various components and pages to utilize the new configuration, ensuring consistent behavior for registration links and error handling. Enhance middleware to bypass authentication for specific paths and improve overall user experience.
feat/auth-access-control
npmrun 2 days ago
parent
commit
be8f4f8512
  1. 4
      app/components/AppShell.vue
  2. 36
      app/composables/useGlobalConfig.ts
  3. 126
      app/error.vue
  4. 9
      app/middleware/auth.global.ts
  5. 3
      app/pages/index/index.vue
  6. 3
      app/pages/login/index.vue
  7. 9
      app/pages/register/index.vue
  8. 8
      server/api/auth/register.post.ts
  9. 4
      server/api/config/global.get.ts
  10. 2
      server/plugins/00.req-time.ts
  11. 4
      server/plugins/01.context.ts
  12. 2
      server/plugins/02.well-known-ignore.ts

4
app/components/AppShell.vue

@ -44,6 +44,8 @@ const menuItems = [
}, },
], ],
] ]
const { allowRegister } = useGlobalConfig()
</script> </script>
<template> <template>
@ -62,7 +64,7 @@ const menuItems = [
<div v-if="showAuthActions" class="flex items-center gap-2"> <div v-if="showAuthActions" class="flex items-center gap-2">
<UButton color="neutral" variant="ghost" to="/login" label="登录" /> <UButton color="neutral" variant="ghost" to="/login" label="登录" />
<UButton color="neutral" variant="outline" to="/register" label="注册" /> <UButton v-if="allowRegister" color="neutral" variant="outline" to="/register" label="注册" />
</div> </div>
</UContainer> </UContainer>
</header> </header>

36
app/composables/useGlobalConfig.ts

@ -0,0 +1,36 @@
import { request, unwrapApiBody, type ApiResponse } from '../utils/http/factory'
export type GlobalConfig = {
allowRegister: boolean
}
type GlobalConfigResult = {
config: GlobalConfig
}
const DEFAULT_GLOBAL_CONFIG: GlobalConfig = {
allowRegister: true,
}
export function useGlobalConfig() {
const { data, pending, error, refresh } = useAsyncData(
'global:config',
async () => {
const payload = await request<ApiResponse<GlobalConfigResult>>('/api/config/global')
return unwrapApiBody(payload).config
},
{
default: () => ({ ...DEFAULT_GLOBAL_CONFIG }),
},
)
const config = computed<GlobalConfig>(() => data.value ?? DEFAULT_GLOBAL_CONFIG)
return {
config,
allowRegister: computed(() => config.value.allowRegister),
pending,
error,
refresh,
}
}

126
app/error.vue

@ -0,0 +1,126 @@
<script setup lang="ts">
import type { NuxtError } from "#app";
const props = defineProps<{
error: NuxtError;
}>();
const statusCode = computed(() => props.error?.statusCode ?? 500);
const statusMessage = computed(() => {
if (props.error?.statusMessage) {
return props.error.statusMessage;
}
return statusCode.value >= 500 ? "服务暂时不可用,请稍后重试" : "请求失败,请检查后重试";
});
const errorTitle = computed(() => {
if (statusCode.value === 401) return "未登录或会话已失效";
if (statusCode.value === 403) return "当前账号无访问权限";
if (statusCode.value === 404) return "页面不存在";
if (statusCode.value >= 500) return "服务开小差了";
return "发生了一点异常";
});
const tips = computed(() => {
if (statusCode.value === 401) return "请重新登录后再继续操作。";
if (statusCode.value === 403) return "如需访问此页面,请联系管理员开通权限。";
if (statusCode.value === 404) return "你访问的地址可能已变更或被移除。";
if (statusCode.value >= 500) return "我们正在处理该问题,请稍后再试。";
return "你可以先返回首页,或点击重试。";
});
const levelTag = computed(() => {
if (statusCode.value >= 500) return "P1 / 高优先级";
if (statusCode.value === 401 || statusCode.value === 403) return "P2 / 权限类";
if (statusCode.value === 404) return "P3 / 资源类";
return "P3 / 一般异常";
});
const actionHints = computed(() => {
if (statusCode.value === 401) {
return ["确认登录状态是否过期", "重新登录后刷新页面", "检查接口鉴权中间件配置"];
}
if (statusCode.value === 403) {
return ["确认账号角色与权限策略", "检查后端权限拦截规则", "联系管理员开通访问权限"];
}
if (statusCode.value === 404) {
return ["确认访问路径是否正确", "检查路由或接口是否已下线", "核对部署环境与 baseURL 配置"];
}
if (statusCode.value >= 500) {
return ["查看服务端日志定位堆栈", "检查依赖服务和数据库连接", "确认最新变更是否引入回归"];
}
return ["查看浏览器控制台错误信息", "检查网络请求返回内容", "重试并记录复现路径"];
});
const handleBackHome = () => clearError({ redirect: "/" });
const handleRetry = async () => {
if (statusCode.value === 401 || statusCode.value === 403) {
await clearError({ redirect: "/login" });
return;
}
if (import.meta.client) {
window.location.reload();
return;
}
await clearError({ redirect: "/" });
};
</script>
<template>
<main class="min-h-screen bg-default text-default">
<section class="mx-auto w-full max-w-5xl px-4 py-10 sm:px-6">
<header class="mb-4 flex items-center justify-between rounded-xl border border-default bg-elevated px-4 py-3">
<div class="flex items-center gap-2">
<span class="size-2 rounded-full bg-error" />
<p class="text-sm font-semibold text-toned">运行异常面板</p>
</div>
<span class="rounded-md border border-default px-2 py-1 text-xs text-muted">
{{ levelTag }}
</span>
</header>
<div class="grid gap-4 lg:grid-cols-[1.35fr,1fr]">
<article class="rounded-xl border border-default bg-elevated p-6 sm:p-7">
<p class="mb-1 text-xs tracking-wide text-muted">状态码</p>
<p class="mb-3 font-mono text-5xl font-semibold leading-none text-error sm:text-6xl">
{{ statusCode }}
</p>
<h1 class="mb-2 text-3xl font-semibold tracking-tight">
{{ errorTitle }}
</h1>
<p class="mb-2 text-toned">
{{ statusMessage }}
</p>
<p class="mb-6 text-sm text-muted">
{{ tips }}
</p>
<div class="flex flex-wrap gap-3">
<UButton color="error" variant="solid" size="md" @click="handleRetry">
立即重试
</UButton>
<UButton color="neutral" variant="outline" size="md" @click="handleBackHome">
返回首页
</UButton>
</div>
</article>
<aside class="rounded-xl border border-default bg-elevated p-6 sm:p-7">
<p class="mb-3 text-xs tracking-wide text-muted">建议排查项</p>
<ul class="mb-5 space-y-2 text-sm text-toned">
<li v-for="hint in actionHints" :key="hint" class="rounded-lg border border-default bg-default px-3 py-2">
{{ hint }}
</li>
</ul>
<details v-if="error?.message" class="rounded-lg border border-default bg-default p-3 mt-2">
<summary class="cursor-pointer select-none text-sm font-medium text-toned">
查看错误详情
</summary>
<p class="mt-2 break-words text-xs text-muted">
{{ error.message }}
</p>
</details>
</aside>
</div>
</section>
</main>
</template>

9
app/middleware/auth.global.ts

@ -7,6 +7,15 @@ import {
import { useAuthSession } from "../composables/useAuthSession"; import { useAuthSession } from "../composables/useAuthSession";
export default defineNuxtRouteMiddleware(async (to) => { export default defineNuxtRouteMiddleware(async (to) => {
if(to.path.startsWith("/__nuxt_error")) {
return
}
if(to.path.startsWith("/api")) {
return
}
if(to.path.startsWith("/public")) {
return
}
const { initialized, loggedIn, refresh } = useAuthSession(); const { initialized, loggedIn, refresh } = useAuthSession();
if (!initialized.value) { if (!initialized.value) {
await refresh(); await refresh();

3
app/pages/index/index.vue

@ -3,6 +3,7 @@ import { request, unwrapApiBody, type ApiResponse } from '../../utils/http/facto
import { useAuthSession } from '../../composables/useAuthSession' import { useAuthSession } from '../../composables/useAuthSession'
const { loggedIn, user, clear } = useAuthSession() const { loggedIn, user, clear } = useAuthSession()
const { allowRegister } = useGlobalConfig()
const logoutLoading = ref(false) const logoutLoading = ref(false)
@ -80,7 +81,7 @@ async function logout() {
<div class="mt-5 flex gap-3"> <div class="mt-5 flex gap-3">
<UButton to="/login">去登录</UButton> <UButton to="/login">去登录</UButton>
<UButton to="/register" color="neutral" variant="outline">去注册</UButton> <UButton v-if="allowRegister" to="/register" color="neutral" variant="outline">去注册</UButton>
</div> </div>
</UCard> </UCard>
</div> </div>

3
app/pages/login/index.vue

@ -33,6 +33,7 @@ const resultType = ref<'success' | 'error' | ''>('')
const resultMessage = ref('') const resultMessage = ref('')
const route = useRoute() const route = useRoute()
const { refresh } = useAuthSession() const { refresh } = useAuthSession()
const { allowRegister } = useGlobalConfig()
const validate = (formState: LoginFormState): FormError[] => { const validate = (formState: LoginFormState): FormError[] => {
const errors: FormError[] = [] const errors: FormError[] = []
@ -139,7 +140,7 @@ const onSubmit = async (_event: FormSubmitEvent<LoginFormState>) => {
class="mt-4" class="mt-4"
/> />
<div class="mt-4 flex items-center justify-center text-sm"> <div v-if="allowRegister" class="mt-4 flex items-center justify-center text-sm">
<NuxtLink to="/register" class="text-primary hover:underline"> <NuxtLink to="/register" class="text-primary hover:underline">
没有账号去注册 没有账号去注册
</NuxtLink> </NuxtLink>

9
app/pages/register/index.vue

@ -6,6 +6,15 @@ import { normalizeSafeRedirect } from '../../utils/auth-routes'
definePageMeta({ definePageMeta({
title: '注册', title: '注册',
layout: 'blank', layout: 'blank',
middleware: [
async () => {
const { allowRegister, refresh } = useGlobalConfig()
await refresh()
if (!allowRegister.value) {
return navigateTo('/login')
}
},
],
}) })
type RegisterFormState = { type RegisterFormState = {

8
server/api/auth/register.post.ts

@ -7,6 +7,14 @@ type RegisterBody = {
}; };
export default defineWrappedResponseHandler(async (event) => { export default defineWrappedResponseHandler(async (event) => {
const allowRegister = await event.context.config.getGlobal("allowRegister");
if (!allowRegister) {
throw createError({
statusCode: 403,
statusMessage: "当前已关闭注册",
});
}
try { try {
const body = await readBody<RegisterBody>(event); const body = await readBody<RegisterBody>(event);
const user = await registerUser(body); const user = await registerUser(body);

4
server/api/config/global.get.ts

@ -1,10 +1,10 @@
import { KNOWN_CONFIG_KEYS } from "#server/service/config/registry"; import { KNOWN_CONFIG_KEYS, KnownConfigKey, KnownConfigValue } from "#server/service/config/registry";
export default defineWrappedResponseHandler(async (event) => { export default defineWrappedResponseHandler(async (event) => {
const entries = await Promise.all( const entries = await Promise.all(
KNOWN_CONFIG_KEYS.map(async (key) => { KNOWN_CONFIG_KEYS.map(async (key) => {
const value = await event.context.config.getGlobal(key); const value = await event.context.config.getGlobal(key);
return [key, value] as const; return [key, value] as const satisfies [KnownConfigKey, KnownConfigValue<KnownConfigKey>];
}), }),
); );

2
server/plugins/01.req-time.ts → server/plugins/00.req-time.ts

@ -4,7 +4,7 @@ import { randomUUID } from "crypto";
const logger = log4js.getLogger("APP") const logger = log4js.getLogger("APP")
if (import.meta.dev) { if (import.meta.dev) {
console.log("plugin: 1.error-handler"); console.log("plugin: 00.req-time");
} }
declare module "http" { declare module "http" {

4
server/plugins/00.global.ts → server/plugins/01.context.ts

@ -4,10 +4,10 @@ import { getGlobalConfigValue, getMergedConfigValue, getUserConfigValue } from "
import type { KnownConfigKey, KnownConfigValue } from "../service/config/registry"; import type { KnownConfigKey, KnownConfigValue } from "../service/config/registry";
if (import.meta.dev) { if (import.meta.dev) {
console.log("plugin: 00.global"); console.log("plugin: 01.context");
} }
export default defineNitroPlugin(async (nitroApp) => { export default defineNitroPlugin((nitroApp) => {
nitroApp.hooks.hook("request", (event) => { nitroApp.hooks.hook("request", (event) => {
const requestCache = new Map<string, Promise<unknown>>(); const requestCache = new Map<string, Promise<unknown>>();
event.context.auth = createAuthContext(event); event.context.auth = createAuthContext(event);

2
server/plugins/02.well-known-ignore.ts

@ -1,5 +1,5 @@
if (import.meta.dev) { if (import.meta.dev) {
console.log("plugin: 01.well-known-ignore"); console.log("plugin: 02.well-known-ignore");
} }
export default defineNitroPlugin(() => { export default defineNitroPlugin(() => {

Loading…
Cancel
Save