From 1c3985ce19356273fb1f32f573868de9fb9605d6 Mon Sep 17 00:00:00 2001 From: chan Date: Tue, 3 Mar 2026 12:56:57 +0900 Subject: [PATCH] feat(adminfront): add user profile dropdown and enhance session sync --- .../src/components/layout/AppLayout.tsx | 96 ++++++++++++++++++- .../routes/TenantAdminsAndOwnersTab.tsx | 4 +- adminfront/src/lib/adminApi.ts | 20 ++++ adminfront/tests/owners.spec.ts | 48 ++++++---- backend/internal/handler/auth_handler.go | 2 +- .../internal/service/hydra_admin_service.go | 2 +- 6 files changed, 146 insertions(+), 26 deletions(-) diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index fcec20bd..df69e221 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -1,6 +1,8 @@ +import { useQuery } from "@tanstack/react-query"; import { BadgeCheck, Building2, + ChevronDown, Key, KeyRound, LayoutDashboard, @@ -9,11 +11,13 @@ import { NotebookTabs, ShieldHalf, Sun, + User as UserIcon, Users, } from "lucide-react"; import { useEffect, useState } from "react"; import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; +import { fetchMe } from "../../lib/adminApi"; import { t } from "../../lib/i18n"; import LanguageSelector from "../common/LanguageSelector"; import RoleSwitcher from "./RoleSwitcher"; @@ -42,6 +46,13 @@ function AppLayout() { const stored = window.localStorage.getItem("admin_theme"); return stored === "dark" ? "dark" : "light"; }); + const [isProfileOpen, setIsProfileOpen] = useState(false); + + const { data: profile } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + enabled: auth.isAuthenticated && !auth.isLoading, + }); const handleLogout = () => { if ( @@ -59,6 +70,12 @@ function AppLayout() { } }, [auth.isLoading, auth.isAuthenticated, navigate]); + useEffect(() => { + if (auth.user?.access_token) { + window.localStorage.setItem("admin_session", auth.user.access_token); + } + }, [auth.user]); + useEffect(() => { const root = document.documentElement; root.classList.remove("light", "dark"); @@ -187,7 +204,84 @@ function AppLayout() { ? t("ui.common.theme_light", "Light") : t("ui.common.theme_dark", "Dark")} - + +
+ + + {isProfileOpen && ( + <> +
setIsProfileOpen(false)} + onKeyDown={(e) => { + if (e.key === "Escape") setIsProfileOpen(false); + }} + role="button" + tabIndex={-1} + aria-label="Close profile menu" + /> +
+
+

+ {profile?.name || auth.user?.profile.name} +

+

+ {profile?.email || auth.user?.profile.email} +

+
+ + {t( + `ui.admin.role.${profile?.role || "user"}`, + profile?.role || "USER", + )} + +
+
+ + +
+ + )} +
+ + {t("msg.admin.session_ttl", "Session TTL: 15m admin")}
diff --git a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx index 36f0f260..80841deb 100644 --- a/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx +++ b/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx @@ -482,9 +482,7 @@ export function TenantAdminsAndOwnersTab() { (a) => a.id === user.id, ); const isAlreadyMember = - dialogMode === "owner" - ? isAlreadyOwner - : isAlreadyAdmin; + dialogMode === "owner" ? isAlreadyOwner : isAlreadyAdmin; return (
; + tenant?: TenantSummary; + manageableTenants?: TenantSummary[]; +}; + +export async function fetchMe() { + const { data } = await apiClient.get("/v1/user/me"); + return data; +} + // Relying Party Management export type RelyingParty = { clientId: string; diff --git a/adminfront/tests/owners.spec.ts b/adminfront/tests/owners.spec.ts index 24cffac9..056f42b7 100644 --- a/adminfront/tests/owners.spec.ts +++ b/adminfront/tests/owners.spec.ts @@ -44,16 +44,19 @@ test.describe("Tenant Owners Management", () => { test("should list tenant owners", async ({ page }) => { // Mock owners list - await page.route("**/api/v1/admin/tenants/tenant-1/owners**", async (route) => { - await route.fulfill({ - json: [ - { id: "owner-1", name: "Owner One", email: "owner1@example.com" }, - ], - }); - }); + await page.route( + "**/api/v1/admin/tenants/tenant-1/owners**", + async (route) => { + await route.fulfill({ + json: [ + { id: "owner-1", name: "Owner One", email: "owner1@example.com" }, + ], + }); + }, + ); await page.goto("/tenants/tenant-1/owners"); - + // Check if the page title and the owner are visible await expect(page.locator("h3")).toContainText("테넌트 소유자"); await expect(page.locator("table")).toContainText("Owner One"); @@ -62,13 +65,16 @@ test.describe("Tenant Owners Management", () => { test("should add a new owner", async ({ page }) => { // Mock owners list (initially empty) - await page.route("**/api/v1/admin/tenants/tenant-1/owners**", async (route) => { - if (route.request().method() === "GET") { - await route.fulfill({ json: [] }); - } else if (route.request().method() === "POST") { - await route.fulfill({ status: 200 }); - } - }); + await page.route( + "**/api/v1/admin/tenants/tenant-1/owners**", + async (route) => { + if (route.request().method() === "GET") { + await route.fulfill({ json: [] }); + } else if (route.request().method() === "POST") { + await route.fulfill({ status: 200 }); + } + }, + ); // Mock users search await page.route("**/api/v1/admin/users?**", async (route) => { @@ -83,17 +89,19 @@ test.describe("Tenant Owners Management", () => { }); await page.goto("/tenants/tenant-1/owners"); - + // Click add button await page.click('button:has-text("소유자 추가")'); - + // Search for user await page.fill('input[placeholder*="사용자 검색"]', "User Two"); - + // Wait for results and add - using a more specific selector to target the button in the dialog - const addButton = page.locator('role=dialog').getByRole('button', { name: '추가' }); + const addButton = page + .locator("role=dialog") + .getByRole("button", { name: "추가" }); await addButton.click(); - + // Verify toast or mutation (in a real app, the list would refresh) // Here we just check if the dialog was closed or toast appears // toast is shown on success diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 1797b6a3..9f30dc1d 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -3974,7 +3974,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe "email", profile.Email, "oldRole", profile.Role, "newRole", mockRole) profile.Role = mockRole } - } else if isDev && mockRole != "" { + } else if isDev && mockRole != "" && token == "" && cookie == "" { slog.Info("🔑 [AUTH_DEBUG] No real session found, using full Mock Auth", "role", mockRole) profile = &domain.UserProfileResponse{ ID: "00000000-0000-0000-0000-000000000000", diff --git a/backend/internal/service/hydra_admin_service.go b/backend/internal/service/hydra_admin_service.go index 1be1cbd9..6bafc76c 100644 --- a/backend/internal/service/hydra_admin_service.go +++ b/backend/internal/service/hydra_admin_service.go @@ -608,7 +608,7 @@ type HydraIntrospectionResponse struct { } func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (*HydraIntrospectionResponse, error) { - endpoint := fmt.Sprintf("%s/admin/oauth2/introspect", strings.TrimRight(s.AdminURL, "/")) + endpoint := fmt.Sprintf("%s/oauth2/introspect", strings.TrimRight(s.AdminURL, "/")) form := url.Values{} form.Set("token", token)