feat(visionA-frontend): AuthGuard — 未登入自動導 /login + 記住原本 path
需求:
使用者未登入訪 protected route(如 /conversion、/devices)→ 卡在空 UI、無 redirect。
使用者反映「希望未登入直接跳登入頁、登入完跳回原本要去的頁」。
實作:
- 新增 src/components/auth-guard.tsx:
* public routes allowlist:/login, /register
* protected route + user=null + hydrate 完成 → router.replace('/login?next=<path>')
* hydrate 進行中 → render null(避免閃過 protected UI)
* buildNextParam helper:排除 public routes 和純 / 路徑
- 改 src/app/layout.tsx:
* 用 <Suspense fallback={null}> 包 AuthGuard(next/navigation useSearchParams 規範)
* AuthGuard 包 AppShell
- 改 src/app/login/page.tsx:
* buildLoginUrl 接受 returnTo param、組進 backend `/api/auth/login?return_to=<path>`
* sanitizeNext helper:對齊 backend oidc_auth.go:382 sanitizeReturnTo("/" 開頭、無 "//"、無 "://")
* 已登入 redirect:從 query ?next= 跳該 path(不再固定 /)
- 改 src/app/login/login.test.tsx:
* mock 補 useSearchParams(next/navigation mock 既有只 mock useRouter)
- backend oidc_auth.go:88 sanitizeReturnTo 已支援 return_to query param、無需改 backend code
驗證:
- tsc --noEmit 0 errors
- pnpm lint 0 errors
- pnpm build 通過(13 pages prerendered)
- login.test.tsx 12/12 pass
- stage deploy verify:SSR HTML 含 AuthGuard component bundle
不在本 commit 範圍:
- .env.stage CORS fix(VISIONA_CORS_ALLOWED_ORIGINS、stage host .env 直接改、不進 git)
- 5/16 deploy script typo fix(已在 commit fad17dd)
- converter multipart field mismatch(converter scheduler 跨 repo 處理)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
fad17ddde9
commit
9ebf46112b
@ -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 可依設計改 */}
|
||||
<TooltipProvider>
|
||||
<AppShell>{children}</AppShell>
|
||||
{/* AuthGuard(2026-05-18 新增):
|
||||
- public routes(/login, /register)→ 放行
|
||||
- protected routes 未登入 → router.replace('/login?next=<path>')
|
||||
- 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 期的行為。 */}
|
||||
<Suspense fallback={null}>
|
||||
<AuthGuard>
|
||||
<AppShell>{children}</AppShell>
|
||||
</AuthGuard>
|
||||
</Suspense>
|
||||
{/* Sonner Toast Portal — 置於 AppShell 之外以覆蓋任何 Dialog / Sheet */}
|
||||
<Toaster richColors closeButton position="top-right" />
|
||||
</TooltipProvider>
|
||||
|
||||
@ -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(),
|
||||
}));
|
||||
|
||||
/**
|
||||
|
||||
@ -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=<path>」流程;
|
||||
* 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=<path>`。
|
||||
// 這裡讀 next 並做兩件事:
|
||||
// (1) 如果使用者已登入訪 /login → 跳 next 指定 path(而非固定 /)
|
||||
// (2) 按「登入」時把 next 包進 backend `/api/auth/login?return_to=<path>`、
|
||||
// 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 (
|
||||
|
||||
85
visionA-frontend/src/components/auth-guard.tsx
Normal file
85
visionA-frontend/src/components/auth-guard.tsx
Normal file
@ -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=<current path+search>')
|
||||
*
|
||||
* 為什麼用 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<string>(["/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}</>;
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user