diff --git a/visionA-frontend/src/app/layout.tsx b/visionA-frontend/src/app/layout.tsx index d095ef6..0871b98 100644 --- a/visionA-frontend/src/app/layout.tsx +++ b/visionA-frontend/src/app/layout.tsx @@ -1,7 +1,9 @@ import type { Metadata } from "next"; +import { Suspense } from "react"; import "./globals.css"; import { AppShell } from "@/components/layout/app-shell"; +import { AuthGuard } from "@/components/auth-guard"; import { StoreHydration } from "@/components/store-hydration"; import { ThemeProvider } from "@/components/theme-provider"; import { Toaster } from "@/components/ui/sonner"; @@ -55,7 +57,20 @@ export default function RootLayout({ {/* TooltipProvider 於 ThemeProvider 內以享用 data-[state] 主題變數。 delayDuration=0(由元件預設)讓 hover 即顯示,後續 F6 可依設計改 */} - {children} + {/* AuthGuard(2026-05-18 新增): + - public routes(/login, /register)→ 放行 + - protected routes 未登入 → router.replace('/login?next=') + - hydrate 進行中 → render null(避免閃過 protected UI) + 詳見 components/auth-guard.tsx + + Suspense boundary 必須包住——AuthGuard 內 useSearchParams() 在 Next.js 13+ + prerender 期需 Suspense boundary 才不會 break /_not-found 等 static pages。 + fallback 用 null 等同 AuthGuard 自己 hydrating 期的行為。 */} + + + {children} + + {/* Sonner Toast Portal — 置於 AppShell 之外以覆蓋任何 Dialog / Sheet */} diff --git a/visionA-frontend/src/app/login/login.test.tsx b/visionA-frontend/src/app/login/login.test.tsx index 3f671c2..40bb789 100644 --- a/visionA-frontend/src/app/login/login.test.tsx +++ b/visionA-frontend/src/app/login/login.test.tsx @@ -24,6 +24,7 @@ import LoginPage from "./page"; /* -------------------------------------------------------------------------- */ /* Phase 0.7:mock next/navigation 的 useRouter */ /* /login 頁新增了「已登入 → 跳回首頁」的 useEffect,需要 useRouter context */ +/* 2026-05-18:補 useSearchParams mock(AuthGuard next= flow 對齊) */ /* -------------------------------------------------------------------------- */ const replaceMock = vi.fn(); @@ -33,6 +34,9 @@ vi.mock("next/navigation", () => ({ replace: replaceMock, push: vi.fn(), }), + // 預設回 empty URLSearchParams(test 不帶 next= 時走 fallback "/" path)。 + // 個別 test 需要驗 next= 行為時可在該 test 內 vi.mocked(...).mockReturnValue(new URLSearchParams("next=/conversion")) + useSearchParams: () => new URLSearchParams(), })); /** diff --git a/visionA-frontend/src/app/login/page.tsx b/visionA-frontend/src/app/login/page.tsx index 04dc2a4..f62a608 100644 --- a/visionA-frontend/src/app/login/page.tsx +++ b/visionA-frontend/src/app/login/page.tsx @@ -25,7 +25,7 @@ */ import { useEffect } from "react"; -import { useRouter } from "next/navigation"; +import { useRouter, useSearchParams } from "next/navigation"; import { AlertTriangle, LogIn } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -40,12 +40,35 @@ import { useAuthStore } from "@/stores/auth-store"; * (瀏覽器要能跨 origin 跳轉,相對路徑會跳到 frontend 自己) * - Production 同源部署時:`NEXT_PUBLIC_API_BASE` 可設為空字串,這時組成的 URL * 就是純 `/api/auth/login`,瀏覽器照樣同 origin 跳轉 + * + * 2026-05-18 新增 returnTo 參數:對齊 AuthGuard 「未登入導 /login?next=」流程; + * backend `oidc_auth.go:88 sanitizeReturnTo` 接受同 origin path("/" 開頭、無 "//"、無 "://")。 */ -function buildLoginUrl(): string { +function buildLoginUrl(returnTo?: string): string { const apiBase = process.env.NEXT_PUBLIC_API_BASE ?? ""; // 去掉可能的 trailing slash,再接上 /api/auth/login const base = apiBase.replace(/\/$/, ""); - return `${base}/api/auth/login`; + const url = `${base}/api/auth/login`; + if (!returnTo) return url; + return `${url}?return_to=${encodeURIComponent(returnTo)}`; +} + +/** + * 驗證 next param 是否為合法的同 origin path。 + * + * 對齊 backend sanitizeReturnTo (`oidc_auth.go:382`): + * - 必須以 "/" 開頭 + * - 不含 "//"(protocol-relative URL) + * - 不含 "://" 或 "\\" + * + * 不合法 → 回 null(caller 用預設 "/")。 + */ +function sanitizeNext(raw: string | null): string | null { + if (!raw) return null; + if (!raw.startsWith("/")) return null; + if (raw.startsWith("//")) return null; + if (raw.includes("://") || raw.includes("\\")) return null; + return raw; } /** @@ -59,23 +82,33 @@ function getMemberCenterRegisterUrl(): string { export default function LoginPage() { const t = useT(); const router = useRouter(); + const searchParams = useSearchParams(); const memberCenterRegisterUrl = getMemberCenterRegisterUrl(); const hasRegisterUrl = memberCenterRegisterUrl !== "#"; + // 2026-05-18:AuthGuard 把 protected route 未登入請求導 `/login?next=`。 + // 這裡讀 next 並做兩件事: + // (1) 如果使用者已登入訪 /login → 跳 next 指定 path(而非固定 /) + // (2) 按「登入」時把 next 包進 backend `/api/auth/login?return_to=`、 + // backend OIDC callback 完成後 302 回該 path + const next = sanitizeNext(searchParams ? searchParams.get("next") : null); + // Phase 0.7 stage deployment fix(見 .autoflow/05-implementation/phase-0.7-frontend-fix.md) - // 已登入使用者進到 /login → 自動跳回首頁 + // 已登入使用者進到 /login → 自動跳回首頁(2026-05-18 改用 next param) // 場景: // 1. 使用者登入後手動點 /login 或 typing URL → 不該再次走 OIDC flow // 2. OIDC 完成後 backend 302 回 / 而非 /login,但若使用者前次離開時停在 /login, // browser 重新打開時會先跑 layout 的 StoreHydration(hydrate me)→ 已登入 → 跳走 + // 3. AuthGuard 把使用者導到 `/login?next=/conversion` 後使用者按上一頁/refresh 仍在 /login, + // 若已登入應該跳 next 指定的 path,不要固定回 / // 注意:StoreHydration 在 RootLayout 跑,hydrate() 是 async; // 此 effect 用 user 當依賴,hydrate 完 user 寫入 store 後會 re-run 並觸發 redirect const user = useAuthStore((s) => s.user); useEffect(() => { if (user) { - router.replace("/"); + router.replace(next ?? "/"); } - }, [user, router]); + }, [user, router, next]); /** * 觸發跨 origin redirect 到 backend OIDC login endpoint。 @@ -83,7 +116,7 @@ export default function LoginPage() { * Next.js 內部路由切換,無法跳到不同 origin 也無法觸發完整 page navigation。 */ function handleSignIn() { - window.location.assign(buildLoginUrl()); + window.location.assign(buildLoginUrl(next ?? undefined)); } return ( diff --git a/visionA-frontend/src/components/auth-guard.tsx b/visionA-frontend/src/components/auth-guard.tsx new file mode 100644 index 0000000..1ce855c --- /dev/null +++ b/visionA-frontend/src/components/auth-guard.tsx @@ -0,0 +1,85 @@ +/** + * AuthGuard — 全域未登入導向 /login 並記住原本要去的 path + * + * 行為: + * - public routes(/login, /register)→ 不檢查,直接放行 + * - 其他 routes: + * a. hydrate 未完成(isLoading=true 或第一次 mount)→ render `null`(避免閃 protected UI) + * b. hydrate 完成 + user 存在 → 放行 render children + * c. hydrate 完成 + user=null → router.replace('/login?next=') + * + * 為什麼用 router.replace 而非 router.push: + * - 不要把 protected path 留在 browser history(user 按上一頁不該回 protected 但沒登入的 UI) + * + * 為什麼 next param 包含 search params: + * - /devices?cluster=foo → 登入完要還原完整 query + * - 但不含 hash(hash 不會被 server 看到、frontend client-side 自己處理) + * + * 跟 /login 頁本身的合作: + * - /login 頁讀 `?next=...` 把 next 傳給 buildLoginUrl(next) → /api/auth/login?return_to=... + * - backend OIDC callback 完成後 302 回 frontend + 帶 return_to → 自動回到原本 path + * + * 對齊 backend sanitizeReturnTo (`oidc_auth.go:382`): + * - 必須以 "/" 開頭 + * - 不含 "//"、"://"、"\\" + * - 不合規 backend 會 fallback "/" + * - 本元件不重複驗證、信任 backend;只在組 next 時排除 absolute URL + */ + +"use client"; + +import { usePathname, useRouter, useSearchParams } from "next/navigation"; +import { useEffect } from "react"; + +import { useAuthStore } from "@/stores/auth-store"; + +/** + * 不需 auth 的 public routes(精確比對)。 + * 注意:sub-paths 不會自動 match(例:/register/foo 仍是 protected)。 + * 如果未來要加更多 public 子路徑,改成 prefix match 即可。 + */ +const PUBLIC_ROUTES = new Set(["/login", "/register"]); + +/** + * 從 pathname + searchParams 組出 next param(給 /login 用)。 + * 排除: + * - public routes(不該記成 next、登入後跳回 /login 沒意義) + * - 純根路徑 "/"(login 完成預設就跳 /,不需多帶 param) + */ +function buildNextParam(pathname: string, search: string): string | null { + if (PUBLIC_ROUTES.has(pathname)) return null; + if (pathname === "/" && !search) return null; + return search ? `${pathname}${search}` : pathname; +} + +export function AuthGuard({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const searchParams = useSearchParams(); + const router = useRouter(); + + const user = useAuthStore((s) => s.user); + const isLoading = useAuthStore((s) => s.isLoading); + + const isPublicRoute = PUBLIC_ROUTES.has(pathname ?? ""); + + useEffect(() => { + if (isPublicRoute) return; + if (isLoading) return; + if (user) return; + + // user === null + hydrate 完成 + 非 public route → 導 /login + const search = searchParams ? searchParams.toString() : ""; + const next = buildNextParam(pathname ?? "/", search ? `?${search}` : ""); + const loginUrl = next ? `/login?next=${encodeURIComponent(next)}` : "/login"; + router.replace(loginUrl); + }, [user, isLoading, isPublicRoute, pathname, searchParams, router]); + + // Public route 一律放行(包含未登入跑 /login 的合理情境) + if (isPublicRoute) return <>{children}; + + // 雛形 / hydrate 進行中 → render null 避免閃過 protected UI + // (hydrate 通常 < 100ms;StoreHydration 在 layout root 跑,user 進來時通常已完成) + if (isLoading || !user) return null; + + return <>{children}; +}