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('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/'];