diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx
index 5341392..e1a8aba 100644
--- a/app/(admin)/layout.tsx
+++ b/app/(admin)/layout.tsx
@@ -1,5 +1,7 @@
import { AppSidebar } from "@/components/app-sidebar";
import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar";
+import AuthorizedApolloWrapper from "@/providers/use-apollo.rsc";
+import ProtectedRoute from "@/providers/use-protected-route";
import { unstable_ViewTransition as ViewTransition } from "react";
export default function AdminLayout({
@@ -8,20 +10,24 @@ export default function AdminLayout({
children: React.ReactNode;
}>) {
return (
-
-
-
-
-
- {children}
-
-
-
-
+
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+
);
}
diff --git a/app/forbidden.tsx b/app/forbidden.tsx
new file mode 100644
index 0000000..9a93204
--- /dev/null
+++ b/app/forbidden.tsx
@@ -0,0 +1,10 @@
+import ForbiddenLayout from "@/components/forbidden-layout/page";
+import type { Metadata } from "next";
+
+export const metadata: Metadata = {
+ title: "權限不足",
+};
+
+export default function ForbiddenPage() {
+ return ;
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index 38c8b4d..b0a07e1 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -2,8 +2,6 @@ import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
-import { getAuthToken } from "@/lib/auth";
-import { ApolloWrapper } from "@/providers/use-apollo";
import { ProgressProvider } from "@/providers/use-progress-provider";
import { PreloadResources } from "./preload-resources";
@@ -18,7 +16,10 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
- title: { template: "%s | 管理介面 | 資料庫練功坊", default: "管理介面 | 資料庫練功坊" },
+ title: {
+ template: "%s | 管理介面 | 資料庫練功坊",
+ default: "管理介面 | 資料庫練功坊",
+ },
description: "管理資料庫練功坊的題目、使用者、做題記錄等。",
};
@@ -29,8 +30,6 @@ export default async function RootLayout({
}: Readonly<{
children: React.ReactNode;
}>) {
- const token = await getAuthToken();
-
return (
@@ -48,9 +47,7 @@ export default async function RootLayout({
font-sans antialiased
`}
>
-
- {children}
-
+ {children}
diff --git a/app/login/page.tsx b/app/login/page.tsx
index 07d79be..f75a910 100644
--- a/app/login/page.tsx
+++ b/app/login/page.tsx
@@ -1,28 +1,13 @@
import { LoginForm } from "@/components/login-form";
import { Logo } from "@/components/logo";
-import { redirectIfAuthenticated } from "@/lib/auth.rsc";
import type { Metadata } from "next";
import Link from "next/link";
-interface LoginPageProps {
- searchParams: Promise<{
- error?: string;
- error_description?: string;
- message?: string;
- redirect?: string;
- }>;
-}
-
export const metadata: Metadata = {
title: "登入",
};
-export default async function LoginPage({ searchParams }: LoginPageProps) {
- // Redirect if already authenticated
- await redirectIfAuthenticated();
-
- const params = await searchParams;
-
+export default function LoginPage() {
return (
Database Playground
-
+
);
diff --git a/app/unauthorized.tsx b/app/unauthorized.tsx
new file mode 100644
index 0000000..b3f0f28
--- /dev/null
+++ b/app/unauthorized.tsx
@@ -0,0 +1,5 @@
+import { redirect } from "next/navigation";
+
+export default async function UnauthorizedPage() {
+ redirect("/login");
+}
diff --git a/components/app-sidebar.tsx b/components/app-sidebar.tsx
index 8e8a691..38c9701 100644
--- a/components/app-sidebar.tsx
+++ b/components/app-sidebar.tsx
@@ -84,7 +84,8 @@ const buildNavbar = (
title: "題庫管理",
url: "/questions",
icon: LibrarySquare,
- isActive: pathname.startsWith("/questions") || pathname.startsWith("/database"),
+ isActive: pathname.startsWith("/questions")
+ || pathname.startsWith("/database"),
items: [
{
title: "題庫",
@@ -167,7 +168,13 @@ export function AppSidebar({ ...props }: React.ComponentProps) {
- {data.navMain.map((group) => )}
+ {data.navMain.map((group) => (
+
+ ))}
diff --git a/app/forbidden/page.tsx b/components/forbidden-layout/page.tsx
similarity index 84%
rename from app/forbidden/page.tsx
rename to components/forbidden-layout/page.tsx
index 4ac7d70..051aa59 100644
--- a/app/forbidden/page.tsx
+++ b/components/forbidden-layout/page.tsx
@@ -1,19 +1,13 @@
import { Logo } from "@/components/logo";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
-import { redirectIfAuthenticated } from "@/lib/auth.rsc";
+import AuthorizedApolloWrapper from "@/providers/use-apollo.rsc";
import { AlertTriangle } from "lucide-react";
import Link from "next/link";
+import { Suspense } from "react";
import { UserInfo } from "./user-info";
-import type { Metadata } from "next";
-export const metadata: Metadata = {
- title: "權限不足",
-};
-
-export default async function ForbiddenPage() {
- await redirectIfAuthenticated();
-
+export default async function ForbiddenLayout() {
return (
diff --git a/app/forbidden/user-info.tsx b/components/forbidden-layout/user-info.tsx
similarity index 100%
rename from app/forbidden/user-info.tsx
rename to components/forbidden-layout/user-info.tsx
diff --git a/components/login-form.tsx b/components/login-form.tsx
deleted file mode 100644
index e1cb20b..0000000
--- a/components/login-form.tsx
+++ /dev/null
@@ -1,107 +0,0 @@
-import { Alert, AlertDescription } from "@/components/ui/alert";
-import { Button } from "@/components/ui/button";
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
-import { cn } from "@/lib/utils";
-import { AlertCircle, CheckCircle } from "lucide-react";
-
-interface LoginFormProps extends React.ComponentProps<"div"> {
- error?: string;
- errorDescription?: string;
- message?: string;
-}
-
-export function LoginForm({
- className,
- error,
- errorDescription,
- message,
- ...props
-}: LoginFormProps) {
- return (
-
- {/* Display error messages */}
- {error && (
-
-
-
- {getErrorMessage(error, errorDescription)}
-
-
- )}
-
- {/* Display success messages */}
- {message && (
-
-
-
- {getSuccessMessage(message)}
-
-
- )}
-
-
-
- 登入管理介面
-
- 使用具有管理員權限的帳號登入資料庫練功坊。
-
-
-
-
-
-
-
- );
-}
-
-function getErrorMessage(error: string, description?: string): string {
- if (description) return description;
-
- switch (error) {
- case "invalid_request":
- return "登入請求無效,請重試。";
- case "unauthorized":
- return "您沒有權限存取此應用程式。";
- case "access_denied":
- return "登入已取消或拒絕。";
- case "server_error":
- return "伺服器發生錯誤,請稍後再試。";
- case "temporarily_unavailable":
- return "服務暫時無法使用,請稍後再試。";
- case "auth_error":
- return "認證過程中發生錯誤,請重新登入。";
- case "logout_failed":
- return "登出時發生錯誤,但您的本地工作階段已清除。";
- case "forbidden":
- return "您沒有權限存取此應用程式。";
- default:
- return "登入時發生未知錯誤,請重試。";
- }
-}
-
-function getSuccessMessage(message: string): string {
- switch (message) {
- case "logged_out":
- return "您已成功登出。";
- default:
- return message;
- }
-}
diff --git a/components/login-form/error-alert.tsx b/components/login-form/error-alert.tsx
new file mode 100644
index 0000000..f3de3b9
--- /dev/null
+++ b/components/login-form/error-alert.tsx
@@ -0,0 +1,49 @@
+"use client";
+
+import { AlertCircle } from "lucide-react";
+import { useSearchParams } from "next/navigation";
+import { Alert, AlertDescription } from "../ui/alert";
+
+export function ErrorAlert() {
+ const searchParams = useSearchParams();
+ const error = searchParams.get("error");
+ const errorDescription = searchParams.get("error_description");
+
+ if (!error || !errorDescription) {
+ return null;
+ }
+
+ return (
+
+
+
+ {getErrorMessage(error, errorDescription)}
+
+
+ );
+}
+
+function getErrorMessage(error: string, description?: string | null): string {
+ if (description) return description;
+
+ switch (error) {
+ case "invalid_request":
+ return "登入請求無效,請重試。";
+ case "unauthorized":
+ return "您沒有權限存取此應用程式。";
+ case "access_denied":
+ return "登入已取消或拒絕。";
+ case "server_error":
+ return "伺服器發生錯誤,請稍後再試。";
+ case "temporarily_unavailable":
+ return "服務暫時無法使用,請稍後再試。";
+ case "auth_error":
+ return "認證過程中發生錯誤,請重新登入。";
+ case "logout_failed":
+ return "登出時發生錯誤,但您的本地工作階段已清除。";
+ case "forbidden":
+ return "您沒有權限存取此應用程式。";
+ default:
+ return "登入時發生未知錯誤,請重試。";
+ }
+}
diff --git a/components/login-form/index.tsx b/components/login-form/index.tsx
new file mode 100644
index 0000000..4c88a87
--- /dev/null
+++ b/components/login-form/index.tsx
@@ -0,0 +1,50 @@
+import { Button } from "@/components/ui/button";
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
+import { cn } from "@/lib/utils";
+import { Suspense } from "react";
+import { ErrorAlert } from "./error-alert";
+import { MessageAlert } from "./message-alert";
+
+export function LoginForm({
+ className,
+ ...props
+}: React.ComponentPropsWithoutRef<"div">) {
+ return (
+
+
+
+
+
+
+
+
+ 登入管理介面
+
+ 使用具有管理員權限的帳號登入資料庫練功坊。
+
+
+
+
+
+
+
+ );
+}
diff --git a/components/login-form/message-alert.tsx b/components/login-form/message-alert.tsx
new file mode 100644
index 0000000..2910582
--- /dev/null
+++ b/components/login-form/message-alert.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { AlertCircle } from "lucide-react";
+import { useSearchParams } from "next/navigation";
+import { Alert, AlertDescription } from "../ui/alert";
+
+export function MessageAlert() {
+ const searchParams = useSearchParams();
+ const message = searchParams.get("message");
+
+ if (!message) {
+ return null;
+ }
+
+ return (
+
+
+
+ {getSuccessMessage(message)}
+
+
+ );
+}
+
+function getSuccessMessage(message: string): string {
+ switch (message) {
+ case "logged_out":
+ return "您已成功登出。";
+ default:
+ return message;
+ }
+}
diff --git a/lib/auth.ts b/lib/auth.ts
index eccbae5..3f150c8 100644
--- a/lib/auth.ts
+++ b/lib/auth.ts
@@ -50,7 +50,7 @@ export async function setAuthToken(
cookieStore.set(OAUTH_CONFIG.TOKEN_COOKIE_NAME, token, {
httpOnly: true,
secure: true,
- sameSite: "strict",
+ sameSite: "lax",
maxAge: expiresIn,
path: "/",
});
@@ -68,7 +68,7 @@ export async function clearAuthToken(): Promise {
name: OAUTH_CONFIG.TOKEN_COOKIE_NAME,
httpOnly: true,
secure: true,
- sameSite: "strict",
+ sameSite: "lax",
path: "/",
});
}
diff --git a/middleware.ts b/middleware.ts
deleted file mode 100644
index 9013012..0000000
--- a/middleware.ts
+++ /dev/null
@@ -1,127 +0,0 @@
-import { getAuthStatus, OAUTH_CONFIG } from "@/lib/auth";
-import { NextRequest, NextResponse } from "next/server";
-
-// Define public routes that don't require authentication
-const PUBLIC_ROUTES = [
- "/login",
- "/forbidden",
- "/api/auth/login",
- "/api/auth/callback",
- "/api/auth/logout",
- "/_next",
- "/favicon.ico",
- "/robots.txt",
- "/logo.svg",
-];
-
-// Define API routes that should return JSON errors instead of redirects
-const API_ROUTES = ["/api/"];
-
-function isPublicRoute(pathname: string): boolean {
- return PUBLIC_ROUTES.some(route => pathname.startsWith(route));
-}
-
-function isApiRoute(pathname: string): boolean {
- return API_ROUTES.some(route => pathname.startsWith(route));
-}
-
-export async function middleware(request: NextRequest) {
- const { pathname } = request.nextUrl;
-
- // Skip middleware for public routes
- if (isPublicRoute(pathname)) {
- return NextResponse.next();
- }
-
- const token = request.cookies.get(OAUTH_CONFIG.TOKEN_COOKIE_NAME)?.value ?? null;
- if (!token) {
- return unauthorized(request);
- }
-
- try {
- const { role, loggedIn } = await getAuthStatus(token);
-
- if (!loggedIn) {
- return unauthorized(request);
- }
-
- if (role !== "admin") {
- return forbidden(request);
- }
- } catch (error) {
- console.error("Middleware authentication error:", error);
- return serverError(request);
- }
-
- return NextResponse.next();
-}
-
-function unauthorized(request: NextRequest): NextResponse {
- const { pathname } = request.nextUrl;
-
- // Handle unauthenticated requests
- if (isApiRoute(pathname)) {
- // Return JSON error for API routes
- return NextResponse.json(
- {
- error: "unauthorized",
- error_description: "Authentication required",
- },
- { status: 401 },
- );
- }
-
- // Redirect to login for web routes
- const loginUrl = new URL("/login", request.url);
- loginUrl.searchParams.set("redirect", pathname);
- return NextResponse.redirect(loginUrl);
-}
-
-function forbidden(request: NextRequest): NextResponse {
- const { pathname } = request.nextUrl;
-
- if (isApiRoute(pathname)) {
- return NextResponse.json(
- {
- error: "forbidden",
- error_description: "You must be an admin to access this resource",
- },
- { status: 403 },
- );
- }
-
- const loginUrl = new URL("/forbidden", request.url);
- return NextResponse.redirect(loginUrl);
-}
-
-function serverError(request: NextRequest): NextResponse {
- const { pathname } = request.nextUrl;
-
- if (isApiRoute(pathname)) {
- return NextResponse.json(
- {
- error: "server_error",
- error_description: "Could not validate authentication",
- },
- { status: 500 },
- );
- }
-
- const loginUrl = new URL("/login", request.url);
- loginUrl.searchParams.set("error", "server_error");
- return NextResponse.redirect(loginUrl);
-}
-
-// Configure which routes the middleware should run on
-export const config = {
- matcher: [
- /*
- * Match all request paths except for the ones starting with:
- * - _next/static (static files)
- * - _next/image (image optimization files)
- * - favicon.ico (favicon file)
- * - public files (public folder)
- */
- "/((?!_next/static|_next/image|favicon.ico|robots.txt|logo.svg).*)",
- ],
-};
diff --git a/next.config.ts b/next.config.ts
index 3367248..0af181c 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -11,6 +11,7 @@ const nextConfig: NextConfig = {
],
ppr: "incremental",
turbopackPersistentCaching: true,
+ authInterrupts: true,
},
};
diff --git a/providers/use-apollo.rsc.tsx b/providers/use-apollo.rsc.tsx
new file mode 100644
index 0000000..8065201
--- /dev/null
+++ b/providers/use-apollo.rsc.tsx
@@ -0,0 +1,14 @@
+"use server";
+
+import { getAuthToken } from "@/lib/auth";
+import { ApolloWrapper } from "./use-apollo";
+
+export default async function AuthorizedApolloWrapper({ children }: { children: React.ReactNode }) {
+ const token = await getAuthToken();
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/providers/use-protected-route.tsx b/providers/use-protected-route.tsx
new file mode 100644
index 0000000..6a1499b
--- /dev/null
+++ b/providers/use-protected-route.tsx
@@ -0,0 +1,21 @@
+"use server";
+
+import { getAuthStatus, getAuthToken } from "@/lib/auth";
+import { forbidden, unauthorized } from "next/navigation";
+
+export default async function ProtectedRoute({ children }: { children: React.ReactNode }) {
+ const token = await getAuthToken();
+ if (!token) {
+ unauthorized();
+ }
+
+ const { loggedIn, role } = await getAuthStatus(token);
+ if (!loggedIn) {
+ unauthorized();
+ }
+ if (role !== "admin") {
+ forbidden();
+ }
+
+ return children;
+}