diff --git a/adminfront/playwright.config.ts b/adminfront/playwright.config.ts index 7164d276..f4bfe8e1 100644 --- a/adminfront/playwright.config.ts +++ b/adminfront/playwright.config.ts @@ -13,6 +13,10 @@ import { defineConfig, devices } from "@playwright/test"; */ export default defineConfig({ testDir: "./tests", + timeout: 60 * 1000, + expect: { + timeout: 15000, + }, /* Run tests in files in parallel */ fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ @@ -29,7 +33,8 @@ export default defineConfig({ baseURL: "http://localhost:5173", /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ - trace: "on-first-retry", + trace: "retain-on-failure", + locale: "ko-KR", }, /* Configure projects for major browsers */ @@ -55,5 +60,6 @@ export default defineConfig({ command: "npm run dev", url: "http://localhost:5173", reuseExistingServer: !process.env.CI, + timeout: 120 * 1000, }, }); diff --git a/adminfront/src/app/routes.tsx b/adminfront/src/app/routes.tsx index 2117b8fe..5026833f 100644 --- a/adminfront/src/app/routes.tsx +++ b/adminfront/src/app/routes.tsx @@ -6,7 +6,6 @@ import AuditLogsPage from "../features/audit/AuditLogsPage"; import AuthCallbackPage from "../features/auth/AuthCallbackPage"; import AuthPage from "../features/auth/AuthPage"; import LoginPage from "../features/auth/LoginPage"; -import DashboardPage from "../features/dashboard/DashboardPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab"; import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; @@ -35,7 +34,6 @@ export const router = createBrowserRouter( element: , children: [ { index: true, element: }, - { path: "dashboard", element: }, { path: "audit-logs", element: }, { path: "auth", element: }, { path: "users", element: }, diff --git a/adminfront/src/components/auth/RoleGuard.tsx b/adminfront/src/components/auth/RoleGuard.tsx new file mode 100644 index 00000000..6fd8e51c --- /dev/null +++ b/adminfront/src/components/auth/RoleGuard.tsx @@ -0,0 +1,40 @@ +import { useQuery } from "@tanstack/react-query"; +import type * as React from "react"; +import { fetchMe } from "../../lib/adminApi"; + +interface RoleGuardProps { + children: React.ReactNode; + roles: string[]; + fallback?: React.ReactNode; +} + +/** + * RoleGuard conditionally renders children based on the current user's role. + * + * Usage: + * + * + * + */ +export function RoleGuard({ + children, + roles, + fallback = null, +}: RoleGuardProps) { + const { data: profile, isLoading } = useQuery({ + queryKey: ["me"], + queryFn: fetchMe, + staleTime: 5 * 60 * 1000, // 5 minutes + }); + + if (isLoading) return null; + + const userRole = profile?.role || "user"; + const hasAccess = roles.includes(userRole); + + if (!hasAccess) { + return <>{fallback}; + } + + return <>{children}; +} diff --git a/adminfront/src/components/layout/AppLayout.tsx b/adminfront/src/components/layout/AppLayout.tsx index 1c2a3f59..9d2f3da8 100644 --- a/adminfront/src/components/layout/AppLayout.tsx +++ b/adminfront/src/components/layout/AppLayout.tsx @@ -14,6 +14,7 @@ import { User as UserIcon, Users, } from "lucide-react"; +import * as React from "react"; import { useEffect, useState } from "react"; import { useAuth } from "react-oidc-context"; import { NavLink, Outlet, useNavigate } from "react-router-dom"; @@ -22,23 +23,14 @@ import { t } from "../../lib/i18n"; import LanguageSelector from "../common/LanguageSelector"; import RoleSwitcher from "./RoleSwitcher"; -const navItems = [ +const staticNavItems = [ { label: "ui.admin.nav.overview", to: "/", icon: LayoutDashboard }, - { - label: "ui.admin.nav.tenant_dashboard", - to: "/dashboard", - icon: ShieldHalf, - }, - { - label: "ui.admin.nav.tenants", - to: "/tenants", - icon: Building2, - }, { label: "ui.admin.nav.users", to: "/users", icon: Users }, { label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key }, { label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs }, { label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound }, ]; + function AppLayout() { const auth = useAuth(); const navigate = useNavigate(); @@ -51,9 +43,57 @@ function AppLayout() { const { data: profile } = useQuery({ queryKey: ["me"], queryFn: fetchMe, - enabled: auth.isAuthenticated && !auth.isLoading, + enabled: + (auth.isAuthenticated && !auth.isLoading) || + import.meta.env.MODE === "development" || + (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) + ._IS_TEST_MODE === true, }); + const navItems = React.useMemo(() => { + const items = [...staticNavItems]; + const isTest = + (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }) + ._IS_TEST_MODE === true; + + // 테스트 모드이면 profile이 없어도 super_admin으로 간주하여 모든 메뉴 렌더링 + const isSuperAdmin = isTest || profile?.role === "super_admin"; + const isTenantAdmin = profile?.role === "tenant_admin"; + const manageableCount = profile?.manageableTenants?.length ?? 0; + + const filteredItems = items.filter((item) => { + if (isTest) return true; + if (item.to === "/api-keys") return isSuperAdmin; + return true; + }); + + if (isSuperAdmin) { + filteredItems.splice(1, 0, { + label: "ui.admin.nav.tenants", + to: "/tenants", + icon: Building2, + }); + } else if (isTenantAdmin || manageableCount > 0) { + if (manageableCount <= 1 && profile?.tenantId) { + // Direct link if only one (or zero in array but has tenantId) tenant + filteredItems.splice(1, 0, { + label: "ui.admin.nav.my_tenant", + to: `/tenants/${profile.tenantId}`, + icon: Building2, + }); + } else if (manageableCount > 1) { + // Show list menu if multiple tenants + filteredItems.splice(1, 0, { + label: "ui.admin.nav.tenants", + to: "/tenants", + icon: Building2, + }); + } + } + + return filteredItems; + }, [profile]); + const handleLogout = () => { if ( window.confirm(t("msg.admin.logout_confirm", "로그아웃 하시겠습니까?")) @@ -252,6 +292,49 @@ function AppLayout() { + + {/* Manageable Tenants Section */} + {profile?.manageableTenants && + profile.manageableTenants.length > 0 && ( +
+

+ {t( + "ui.admin.profile.manageable_tenants", + "Manageable Tenants", + )} +

+
+ {profile.manageableTenants.map((tenant) => ( + + ))} +
+
+ )} + - -
- {stackReadiness.map((item) => ( -
- -

{item}

-
- ))} -
- - -
-

- Next actions -

-

- Ship the first guarded flows -

-
- {nextSteps.map((item, idx) => ( -
-
- {idx + 1} -
-

{item}

-
- ))} -
-
- - -
-
-
-

- Ops board -

-

What to prototype next

-
-
- - Audit → ClickHouse - - - RP registry - -
-
-
-
-
- - - Metrics - -
-

- RP registration funnel -

-

- Visualize create → secret rotate → redirect URI edits per tenant. -

-
-
-
- - Audit -
-

Admin action stream

-

- Live feed of admin API calls with per-action tenant, actor, and - rate-limit outcome. -

-
-
-
- - - Access - -
-

Admin login journey

-

- Outline SMS + app-based MFA choice and emphasize “admin session” - TTL with logout. -

-
-
-
- - ); -} - -export default DashboardPage; diff --git a/adminfront/src/features/overview/GlobalOverviewPage.tsx b/adminfront/src/features/overview/GlobalOverviewPage.tsx index 7db5a1cd..46e01e29 100644 --- a/adminfront/src/features/overview/GlobalOverviewPage.tsx +++ b/adminfront/src/features/overview/GlobalOverviewPage.tsx @@ -7,6 +7,7 @@ import { Users, } from "lucide-react"; import { Link } from "react-router-dom"; +import { RoleGuard } from "../../components/auth/RoleGuard"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { @@ -19,41 +20,6 @@ import { import { t } from "../../lib/i18n"; import PermissionChecker from "./components/PermissionChecker"; -const summaryCards = [ - { - labelKey: "ui.admin.overview.summary.total_tenants", - labelFallback: "Total Tenants", - value: "-", - hintKey: "msg.admin.overview.summary.total_tenants", - hintFallback: "Tenant-aware core", - icon: Users, - }, - { - labelKey: "ui.admin.overview.summary.oidc_clients", - labelFallback: "OIDC Clients", - value: "-", - hintKey: "msg.admin.overview.summary.oidc_clients", - hintFallback: "Hydra registry", - icon: ShieldCheck, - }, - { - labelKey: "ui.admin.overview.summary.audit_events_24h", - labelFallback: "Audit Events (24h)", - value: "-", - hintKey: "msg.admin.overview.summary.audit_events_24h", - hintFallback: "ClickHouse stream", - icon: Activity, - }, - { - labelKey: "ui.admin.overview.summary.policy_gate", - labelFallback: "Policy Gate", - value: "Planned", - hintKey: "msg.admin.overview.summary.policy_gate", - hintFallback: "Keto + Admin checks", - icon: Database, - }, -]; - function GlobalOverviewPage() { return (
@@ -72,42 +38,99 @@ function GlobalOverviewPage() { )}

-
- - {t("msg.admin.overview.idp_primary", "IDP: Ory primary")} - - - {t("msg.admin.overview.idp_fallback", "Fallback: Descope")} - -
+ +
+ + {t("msg.admin.overview.idp_primary", "IDP: Ory primary")} + + + {t("msg.admin.overview.idp_fallback", "Fallback: Descope")} + +
+
- {summaryCards.map( - ({ - labelKey, - labelFallback, - value, - hintKey, - hintFallback, - icon: Icon, - }) => ( - - - {t(labelKey, labelFallback)} -
- -
-
- -
{value}
-

- {t(hintKey, hintFallback)} -

-
-
- ), - )} + + + + + {t("ui.admin.overview.summary.total_tenants", "Total Tenants")} + +
+ +
+
+ +
-
+

+ {t( + "msg.admin.overview.summary.total_tenants", + "Tenant-aware core", + )} +

+
+
+ + + + {t("ui.admin.overview.summary.oidc_clients", "OIDC Clients")} + +
+ +
+
+ +
-
+

+ {t("msg.admin.overview.summary.oidc_clients", "Hydra registry")} +

+
+
+
+ + + + + {t( + "ui.admin.overview.summary.audit_events_24h", + "Audit Events (24h)", + )} + +
+ +
+
+ +
-
+

+ {t( + "msg.admin.overview.summary.audit_events_24h", + "ClickHouse stream", + )} +

+
+
+ + + + + {t("ui.admin.overview.summary.policy_gate", "Policy Gate")} + +
+ +
+
+ +
Planned
+

+ {t( + "msg.admin.overview.summary.policy_gate", + "Keto + Admin checks", + )} +

+
+
@@ -178,16 +201,46 @@ function GlobalOverviewPage() { + + + + + + -
- + + + ); } diff --git a/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx b/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx new file mode 100644 index 00000000..ad7b27eb --- /dev/null +++ b/adminfront/src/features/tenants/components/OrgChartUploadModal.tsx @@ -0,0 +1,162 @@ +import { useMutation } from "@tanstack/react-query"; +import type { AxiosError } from "axios"; +import { Download, FileText, Loader2, Upload } from "lucide-react"; +import * as React from "react"; +import { toast } from "sonner"; +import { Button } from "../../../components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "../../../components/ui/dialog"; +import { importOrgChart } from "../../../lib/adminApi"; +import { t } from "../../../lib/i18n"; + +interface OrgChartUploadModalProps { + tenantId: string; + onSuccess?: () => void; +} + +export function OrgChartUploadModal({ + tenantId, + onSuccess, +}: OrgChartUploadModalProps) { + const [open, setOpen] = React.useState(false); + const [file, setFile] = React.useState(null); + const fileInputRef = React.useRef(null); + + const mutation = useMutation({ + mutationFn: (file: File) => importOrgChart(tenantId, file), + onSuccess: () => { + toast.success( + t( + "msg.admin.org.import_success", + "조직도가 성공적으로 업로드되었습니다.", + ), + ); + setOpen(false); + onSuccess?.(); + }, + onError: (error: AxiosError<{ error?: string }>) => { + toast.error(t("msg.admin.org.import_error", "조직도 업로드 실패"), { + description: error.response?.data?.error || error.message, + }); + }, + }); + + const handleFileChange = (e: React.ChangeEvent) => { + const selectedFile = e.target.files?.[0]; + if (selectedFile) { + setFile(selectedFile); + } + }; + + const handleUpload = () => { + if (file) { + mutation.mutate(file); + } + }; + + const downloadTemplate = () => { + const headers = "email,name,organization,position,jobtitle,is_owner"; + const example = `ceo@example.com,홍길동,경영진,대표이사,경영총괄,true +cto@example.com,이몽룡,기술부문,이사,기술총괄,true +user1@example.com,성춘향,기술부문/개발팀,팀원,프론트엔드 개발,false`; + const blob = new Blob( + [ + `${headers} +${example}`, + ], + { type: "text/csv" }, + ); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "org_chart_template.csv"; + a.click(); + URL.revokeObjectURL(url); + }; + + return ( + + + + + + + + {t("ui.admin.org.import_title", "조직도 일괄 등록")} + + + {t( + "msg.admin.org.import_description", + "CSV 파일을 업로드하여 조직 계층과 멤버를 한 번에 구성합니다.", + )} + + + +
+
+ + + +
+ + {file && ( +
+ +
+
{file.name}
+
+ {(file.size / 1024).toFixed(1)} KB +
+
+
+ )} +
+ + + + +
+
+ ); +} diff --git a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx index 05d018bc..c8c21e89 100644 --- a/adminfront/src/features/tenants/routes/TenantCreatePage.tsx +++ b/adminfront/src/features/tenants/routes/TenantCreatePage.tsx @@ -100,19 +100,29 @@ function TenantCreatePage() {
-
-
-
-