From db646c26fc39d75e3ec27bd1be4fbc639b6ee395 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 29 Sep 2025 20:05:23 +0800 Subject: [PATCH 1/3] refactor: prevent using middleware to improve static route --- app/(admin)/layout.tsx | 33 ++--- app/forbidden.tsx | 10 ++ app/unauthorized.tsx | 5 + .../forbidden-layout}/page.tsx | 10 +- .../forbidden-layout}/user-info.tsx | 0 components/login-form.tsx | 2 +- middleware.ts | 127 ------------------ next.config.ts | 1 + providers/use-protected-route.tsx | 21 +++ 9 files changed, 57 insertions(+), 152 deletions(-) create mode 100644 app/forbidden.tsx create mode 100644 app/unauthorized.tsx rename {app/forbidden => components/forbidden-layout}/page.tsx (87%) rename {app/forbidden => components/forbidden-layout}/user-info.tsx (100%) delete mode 100644 middleware.ts create mode 100644 providers/use-protected-route.tsx diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx index 5341392..c294111 100644 --- a/app/(admin)/layout.tsx +++ b/app/(admin)/layout.tsx @@ -1,5 +1,6 @@ import { AppSidebar } from "@/components/app-sidebar"; import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; +import ProtectedRoute from "@/providers/use-protected-route"; import { unstable_ViewTransition as ViewTransition } from "react"; export default function AdminLayout({ @@ -8,20 +9,22 @@ 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..0ff6904 --- /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 ; +} \ No newline at end of file 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/app/forbidden/page.tsx b/components/forbidden-layout/page.tsx similarity index 87% rename from app/forbidden/page.tsx rename to components/forbidden-layout/page.tsx index 4ac7d70..7b03fb3 100644 --- a/app/forbidden/page.tsx +++ b/components/forbidden-layout/page.tsx @@ -1,19 +1,11 @@ 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 { AlertTriangle } from "lucide-react"; import Link from "next/link"; 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 (
{ +export interface LoginFormProps extends React.ComponentProps<"div"> { error?: string; errorDescription?: string; message?: string; 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-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; +} From 0d98dade37951e670b4f0b81b609ec9a08c937f4 Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 29 Sep 2025 20:16:37 +0800 Subject: [PATCH 2/3] fix(auth): Set the cookie's SameSite policy to "lax" Safari's SameSite policy applies throughout the entire session. This means that when we redirect to Google, then back to the callback, and finally to the homepage, the last redirect does not meet the strict SameSite requirements. As a result, the user must log in again. According to https://datatracker.ietf.org/doc/html/draft-ietf-oauth-browser-based-apps#section-6.1.3.3.1, SameSite can be set to Lax. --- lib/auth.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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: "/", }); } From 1b80809ca3d5c17f55e97499eef10bf965de023f Mon Sep 17 00:00:00 2001 From: Yi-Jyun Pan Date: Mon, 29 Sep 2025 20:42:50 +0800 Subject: [PATCH 3/3] refactor: reduce dynamic routes --- app/(admin)/layout.tsx | 33 ++++---- app/forbidden.tsx | 4 +- app/layout.tsx | 13 ++- app/login/page.tsx | 23 +---- components/app-sidebar.tsx | 11 ++- components/forbidden-layout/page.tsx | 8 +- components/login-form.tsx | 107 ------------------------ components/login-form/error-alert.tsx | 49 +++++++++++ components/login-form/index.tsx | 50 +++++++++++ components/login-form/message-alert.tsx | 32 +++++++ providers/use-apollo.rsc.tsx | 14 ++++ 11 files changed, 188 insertions(+), 156 deletions(-) delete mode 100644 components/login-form.tsx create mode 100644 components/login-form/error-alert.tsx create mode 100644 components/login-form/index.tsx create mode 100644 components/login-form/message-alert.tsx create mode 100644 providers/use-apollo.rsc.tsx diff --git a/app/(admin)/layout.tsx b/app/(admin)/layout.tsx index c294111..e1a8aba 100644 --- a/app/(admin)/layout.tsx +++ b/app/(admin)/layout.tsx @@ -1,5 +1,6 @@ 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"; @@ -10,21 +11,23 @@ export default function AdminLayout({ }>) { return ( - - - - -
- {children} -
-
-
-
+ + + + + +
+ {children} +
+
+
+
+
); } diff --git a/app/forbidden.tsx b/app/forbidden.tsx index 0ff6904..9a93204 100644 --- a/app/forbidden.tsx +++ b/app/forbidden.tsx @@ -3,8 +3,8 @@ import type { Metadata } from "next"; export const metadata: Metadata = { title: "權限不足", -} +}; export default function ForbiddenPage() { return ; -} \ No newline at end of file +} 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/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/components/forbidden-layout/page.tsx b/components/forbidden-layout/page.tsx index 7b03fb3..051aa59 100644 --- a/components/forbidden-layout/page.tsx +++ b/components/forbidden-layout/page.tsx @@ -1,8 +1,10 @@ import { Logo } from "@/components/logo"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +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"; export default async function ForbiddenLayout() { @@ -44,7 +46,11 @@ export default async function ForbiddenLayout() { - + + + + + diff --git a/components/login-form.tsx b/components/login-form.tsx deleted file mode 100644 index 82d1ef4..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"; - -export 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/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} + + ); +}