From 05606dfb56947a4f0a1d7492d2e229ed87b5db26 Mon Sep 17 00:00:00 2001 From: ryan Date: Wed, 2 Sep 2026 18:39:11 +0800 Subject: [PATCH] feat(frontend): add a dedicated 403 forbidden page Show /403 instead of toasting or staying on the denied screen when the API returns 403 or a non-admin opens an admin route. --- frontend/app/403/page.tsx | 7 ++ frontend/app/forbidden.tsx | 8 ++ frontend/components/auth/require-auth.tsx | 15 ++-- frontend/components/layout/forbidden-page.tsx | 78 +++++++++++++++++++ .../components/providers/title-updater.tsx | 2 + frontend/lib/services/core/api-client.ts | 9 ++- frontend/messages/en.json | 9 ++- frontend/messages/zh-CN.json | 9 ++- frontend/proxy.ts | 1 + 9 files changed, 129 insertions(+), 9 deletions(-) create mode 100644 frontend/app/403/page.tsx create mode 100644 frontend/app/forbidden.tsx create mode 100644 frontend/components/layout/forbidden-page.tsx diff --git a/frontend/app/403/page.tsx b/frontend/app/403/page.tsx new file mode 100644 index 00000000..96e03646 --- /dev/null +++ b/frontend/app/403/page.tsx @@ -0,0 +1,7 @@ +'use client'; + +import { ForbiddenPage } from '@/components/layout/forbidden-page'; + +export default function ForbiddenRoutePage() { + return ; +} diff --git a/frontend/app/forbidden.tsx b/frontend/app/forbidden.tsx new file mode 100644 index 00000000..e461ba74 --- /dev/null +++ b/frontend/app/forbidden.tsx @@ -0,0 +1,8 @@ +'use client'; + +import { ForbiddenPage } from '@/components/layout/forbidden-page'; + +/** Next.js convention: rendered when forbidden() is called. */ +export default function Forbidden() { + return ; +} diff --git a/frontend/components/auth/require-auth.tsx b/frontend/components/auth/require-auth.tsx index 9b50afe2..600e8bf7 100644 --- a/frontend/components/auth/require-auth.tsx +++ b/frontend/components/auth/require-auth.tsx @@ -1,11 +1,10 @@ 'use client'; -import type { ReactNode } from 'react'; +import { useEffect, type ReactNode } from 'react'; +import { useRouter } from 'next/navigation'; import { Loader2 } from 'lucide-react'; -import { ErrorPage } from '@/components/layout/error'; import { useUser } from '@/contexts/user-context'; -import { useTranslations } from 'next-intl'; type RequireAuthProps = { children: ReactNode; @@ -50,7 +49,13 @@ type RequireAdminAuthProps = { /** Guards admin routes after the shared shell has rendered. */ export function RequireAdminAuth({ children }: RequireAdminAuthProps) { const { user, loading } = useUser(); - const t = useTranslations('auth.requireAuth'); + const router = useRouter(); + + useEffect(() => { + if (!loading && user && !user.is_admin) { + router.replace('/403'); + } + }, [loading, user, router]); if (loading) { return ( @@ -61,7 +66,7 @@ export function RequireAdminAuth({ children }: RequireAdminAuthProps) { } if (!user?.is_admin) { - return ; + return null; } return <>{children}; diff --git a/frontend/components/layout/forbidden-page.tsx b/frontend/components/layout/forbidden-page.tsx new file mode 100644 index 00000000..1a21e242 --- /dev/null +++ b/frontend/components/layout/forbidden-page.tsx @@ -0,0 +1,78 @@ +'use client'; + +import { useRouter } from 'next/navigation'; +import Link from 'next/link'; +import { motion } from 'motion/react'; +import { ArrowLeft, Home } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { useTranslations } from 'next-intl'; + +/** Full-page 403 UI, used by /403 and Next.js forbidden(). */ +export function ForbiddenPage() { + const router = useRouter(); + const t = useTranslations('forbidden'); + + return ( +
+
+
+
+ +
+ +

+ 403 +

+
+

+ {t('label')} +

+
+
+ + +

+ {t('description')} +

+ +
+ + + + + +
+
+
+ +

+ ERR_HTTP_FORBIDDEN +

+
+ ); +} diff --git a/frontend/components/providers/title-updater.tsx b/frontend/components/providers/title-updater.tsx index 7479ccd5..e05bee4e 100644 --- a/frontend/components/providers/title-updater.tsx +++ b/frontend/components/providers/title-updater.tsx @@ -27,6 +27,8 @@ export function SiteTitleUpdater() { suffix = ` - ${t('admin')}`; } else if (pathname === '/home') { suffix = ` - ${t('dashboard')}`; + } else if (pathname === '/403') { + suffix = ` - ${t('forbidden')}`; } else if (pathname === '/') { suffix = ''; } diff --git a/frontend/lib/services/core/api-client.ts b/frontend/lib/services/core/api-client.ts index eb5f3b0a..7b2113e4 100644 --- a/frontend/lib/services/core/api-client.ts +++ b/frontend/lib/services/core/api-client.ts @@ -161,10 +161,15 @@ apiClient.interceptors.response.use( return Promise.reject(new UnauthorizedError(message)); } - /* 403:已登录但权限不足,留在当前页。 */ + /* 403:已登录但权限不足,进入独立 403 页,不清 cookie。 */ if (error.response?.status === 403) { const message = error.response.data?.error_msg || '权限不足'; - toast.error(message, { id: 'forbidden-error' }); + if ( + typeof window !== 'undefined' && + window.location.pathname !== '/403' + ) { + window.location.replace('/403'); + } return Promise.reject( new ForbiddenError( message, diff --git a/frontend/messages/en.json b/frontend/messages/en.json index a82923e9..98b3c01b 100644 --- a/frontend/messages/en.json +++ b/frontend/messages/en.json @@ -1919,12 +1919,19 @@ "previousPage": "Previous page", "home": "Home" }, + "forbidden": { + "label": "Forbidden", + "description": "You are signed in, but you do not have permission to access this resource. Ask an administrator if you need access.", + "previousPage": "Previous page", + "home": "Home" + }, "providers": { "titleUpdater": { "login": "Login", "register": "Register", "admin": "Admin", - "dashboard": "Dashboard" + "dashboard": "Dashboard", + "forbidden": "Forbidden" } }, "metadata": { diff --git a/frontend/messages/zh-CN.json b/frontend/messages/zh-CN.json index 1bdc0b2c..d6b84f32 100644 --- a/frontend/messages/zh-CN.json +++ b/frontend/messages/zh-CN.json @@ -1919,12 +1919,19 @@ "previousPage": "上一页", "home": "首页" }, + "forbidden": { + "label": "Forbidden", + "description": "您已登录,但没有访问该资源的权限。如需开通,请联系管理员。", + "previousPage": "上一页", + "home": "首页" + }, "providers": { "titleUpdater": { "login": "登录", "register": "注册", "admin": "后台管理", - "dashboard": "控制台" + "dashboard": "控制台", + "forbidden": "权限不足" } }, "metadata": { diff --git a/frontend/proxy.ts b/frontend/proxy.ts index 30755e7b..4b0e189e 100644 --- a/frontend/proxy.ts +++ b/frontend/proxy.ts @@ -120,6 +120,7 @@ export function proxy(request: NextRequest) { '/privacy', '/terms', '/icon', + '/403', ]; const publicPrefixes = ['/docs/', '/epay/'];