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