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; +}