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:
jim800121chen 2026-05-18 10:38:16 +08:00
parent fad17ddde9
commit 9ebf46112b
4 changed files with 145 additions and 8 deletions

View File

@ -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>
{/* AuthGuard2026-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>

View File

@ -24,6 +24,7 @@ import LoginPage from "./page";
/* -------------------------------------------------------------------------- */
/* Phase 0.7mock next/navigation 的 useRouter */
/* /login 頁新增了「已登入 → 跳回首頁」的 useEffect需要 useRouter context */
/* 2026-05-18補 useSearchParams mockAuthGuard next= flow 對齊) */
/* -------------------------------------------------------------------------- */
const replaceMock = vi.fn();
@ -33,6 +34,9 @@ vi.mock("next/navigation", () => ({
replace: replaceMock,
push: vi.fn(),
}),
// 預設回 empty URLSearchParamstest 不帶 next= 時走 fallback "/" path
// 個別 test 需要驗 next= 行為時可在該 test 內 vi.mocked(...).mockReturnValue(new URLSearchParams("next=/conversion"))
useSearchParams: () => new URLSearchParams(),
}));
/**

View File

@ -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
* - "://" "\\"
*
* nullcaller "/"
*/
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-18AuthGuard 把 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 的 StoreHydrationhydrate 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 (

View 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 historyuser protected UI
*
* next param search params
* - /devices?cluster=foo query
* - hashhash 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 通常 < 100msStoreHydration 在 layout root 跑user 進來時通常已完成)
if (isLoading || !user) return null;
return <>{children}</>;
}