From c4487b9334a3fc26312cbbf9aa5781004cb65b65 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 1 Jun 2026 10:44:38 +0900 Subject: [PATCH 1/7] =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=95=B1=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/features/clients/ClientsPage.tsx | 56 ++++++++++++++++--- devfront/tests/clients.spec.ts | 53 ++++++++++++++++++ 2 files changed, 102 insertions(+), 7 deletions(-) diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 630b7ade..aed8bb51 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -82,6 +82,7 @@ type RecentClientChange = { const recentClientChangesInitialCount = 5; const recentClientChangesBatchSize = 5; +const clientListPreviewCount = 5; const recentClientActions = new Set([ "CREATE_CLIENT", @@ -316,6 +317,7 @@ function ClientsPage() { const [isRequestModalOpen, setIsRequestModalOpen] = useState(false); const [isRecentChangesGuideOpen, setIsRecentChangesGuideOpen] = useState(false); + const [isClientListExpanded, setIsClientListExpanded] = useState(false); const [visibleRecentClientChangesCount, setVisibleRecentClientChangesCount] = useState(recentClientChangesInitialCount); const [sortConfig, setSortConfig] = @@ -425,6 +427,14 @@ function ClientsPage() { const authFailures = statsData?.auth_failures_24h ?? 0; const hasFilterResult = filteredClients.length > 0; const isFilteredOut = clients.length > 0 && !hasFilterResult; + const visibleClients = useMemo(() => { + if (isClientListExpanded) { + return filteredClients; + } + + return filteredClients.slice(0, clientListPreviewCount); + }, [filteredClients, isClientListExpanded]); + const canToggleClientList = filteredClients.length > clientListPreviewCount; const currentTenant = tenants?.find( (tenant) => tenant.id === tenantId || tenant.slug === companyCode, ); @@ -784,15 +794,20 @@ function ClientsPage() { /> -
+
{stats.map((item) => ( - - + + {t(item.labelKey, item.labelFallback)} -
- {item.value} +
+ + {item.value} + @@ -954,7 +969,7 @@ function ClientsPage() { )} - {filteredClients.map((client) => ( + {visibleClients.map((client) => (
+ {canToggleClientList ? ( +
+ +
+ ) : null}
diff --git a/devfront/tests/clients.spec.ts b/devfront/tests/clients.spec.ts index 157e10d2..9777876f 100644 --- a/devfront/tests/clients.spec.ts +++ b/devfront/tests/clients.spec.ts @@ -100,6 +100,59 @@ test("clients page shows recent RP changes", async ({ page }) => { ).toBeVisible(); }); +test("clients page shows only five apps by default and expands with more button", async ({ + page, +}) => { + await seedAuth(page, "super_admin"); + const clients = Array.from({ length: 6 }, (_, index) => + makeClient(`client-${index + 1}`, { + name: `Preview App ${index + 1}`, + createdAt: new Date( + Date.UTC(2026, 2, 3, 9, 10 - index, 0), + ).toISOString(), + }), + ); + + await installDevApiMock(page, { + clients, + consents: [] as Consent[], + auditLogs: [] as AuditLog[], + auditLogsByCursor: undefined, + }); + + await page.goto("/clients"); + await expect( + page.getByRole("heading", { name: "연동 앱 목록" }), + ).toBeVisible(); + await expect( + page.locator("table").first().locator("tbody tr").filter({ + hasText: /Preview App \d/, + }), + ).toHaveCount(5); + await expect( + page.getByText("Preview App 6", { exact: true }), + ).not.toBeVisible(); + + const moreButton = page.getByRole("button", { + name: "연동 앱 목록 더보기", + }); + await expect(moreButton).toBeVisible(); + await expect(moreButton).toHaveCount(1); + await moreButton.click(); + + await expect( + page.locator("table").first().locator("tbody tr").filter({ + hasText: /Preview App \d/, + }), + ).toHaveCount(6); + await expect( + page.getByText("Preview App 6", { exact: true }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "연동 앱 목록 더보기" }), + ).toHaveCount(0); +}); + test("clients page shows user-delete relation cleanup in recent changes", async ({ page, }) => { From d40e443d48c731bd90391e78858addd1636850c6 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 1 Jun 2026 15:16:21 +0900 Subject: [PATCH 2/7] =?UTF-8?q?=EC=B5=9C=EA=B7=BC=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EB=90=9C=20=EC=95=B1=20=EB=8C=80=EC=8B=9C=EB=B3=B4=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/features/clients/ClientsPage.tsx | 506 +--------- .../features/overview/GlobalOverviewPage.tsx | 911 +++++++++++++++--- .../features/overview/recentClientChanges.ts | 183 ++++ devfront/src/locales/en.toml | 18 + devfront/src/locales/ko.toml | 18 + devfront/src/locales/template.toml | 18 + devfront/tests/clients.spec.ts | 29 +- 7 files changed, 1034 insertions(+), 649 deletions(-) create mode 100644 devfront/src/features/overview/recentClientChanges.ts diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index aed8bb51..f155d9e9 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery } from "@tanstack/react-query"; import type { AxiosError } from "axios"; -import { Filter, Info, Plus, Search, ShieldHalf, X } from "lucide-react"; +import { Filter, Plus, Search, ShieldHalf, X } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; import { useAuth } from "react-oidc-context"; import { Link, useNavigate } from "react-router-dom"; @@ -44,10 +44,7 @@ import { import { Textarea } from "../../components/ui/textarea"; import { type ClientSummary, - type DevAuditLog, - fetchDevUser, fetchClients, - fetchDevAuditLogs, fetchDeveloperRequestStatus, fetchDevStats, fetchMyTenants, @@ -59,198 +56,10 @@ import { cn } from "../../lib/utils"; import { fetchMe } from "../auth/authApi"; import { resolveClientCreateAccess } from "./clientCreateAccess"; import { ClientLogo } from "./components/ClientLogo"; -import { - formatAuditDateParts, - formatAuditValue, - parseAuditDetails, - resolveAuditActor, -} from "../../../../common/core/audit"; type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt"; - -type RecentClientChange = { - eventId: string; - clientId: string; - clientName: string; - actorId: string; - actorName: string; - action: string; - actionLabel: string; - timestamp: string; - detailLabels: Array<{ label: string; value: string }>; -}; - -const recentClientChangesInitialCount = 5; -const recentClientChangesBatchSize = 5; const clientListPreviewCount = 5; -const recentClientActions = new Set([ - "CREATE_CLIENT", - "UPDATE_CLIENT", - "UPDATE_CLIENT_STATUS", - "ROTATE_SECRET", - "ADD_RELATION", - "REMOVE_RELATION", - "DELETE_CLIENT", -]); - -const recentChangeGuideItems = [ - { - titleKey: "ui.dev.clients.recent_changes.guide.create", - titleFallback: "앱 생성", - descriptionKey: "msg.dev.clients.recent_changes.guide.create_desc", - descriptionFallback: - "새 애플리케이션이 등록되면 이름, 유형, 기본 상태와 함께 표시됩니다.", - }, - { - titleKey: "ui.dev.clients.recent_changes.guide.settings", - titleFallback: "설정 변경", - descriptionKey: "msg.dev.clients.recent_changes.guide.settings_desc", - descriptionFallback: - "앱 이름, 스코프, 테넌트 접근 제한, 커스텀 클레임, 보안 설정, 로그아웃 URI, JWKS 변경이 포함됩니다.", - }, - { - titleKey: "ui.dev.clients.recent_changes.guide.status", - titleFallback: "상태 변경", - descriptionKey: "msg.dev.clients.recent_changes.guide.status_desc", - descriptionFallback: "Active / Inactive 전환이 여기에 포함됩니다.", - }, - { - titleKey: "ui.dev.clients.recent_changes.guide.relation", - titleFallback: "관계 변경", - descriptionKey: "msg.dev.clients.recent_changes.guide.relation_desc", - descriptionFallback: "관계 추가와 삭제가 함께 표시됩니다.", - }, - { - titleKey: "ui.dev.clients.recent_changes.guide.secret", - titleFallback: "클라이언트 시크릿 재발급", - descriptionKey: "msg.dev.clients.recent_changes.guide.secret_desc", - descriptionFallback: "시크릿 재발급 이력이 보입니다.", - }, - { - titleKey: "ui.dev.clients.recent_changes.guide.delete", - titleFallback: "앱 삭제", - descriptionKey: "msg.dev.clients.recent_changes.guide.delete_desc", - descriptionFallback: "앱 삭제도 최근 변경 이력에 포함됩니다.", - }, -] as const; - -const recentClientFieldLabels: Record = { - name: "이름", - type: "유형", - status: "상태", - scopes: "스코프", - tenant_access_restricted: "테넌트 접근 제한", - allowed_tenants: "허용 테넌트", - id_token_claims: "커스텀 클레임", - token_endpoint_auth_method: "인증 방식", - jwks_uri: "JWKS URI", - backchannel_logout_uri: "Backchannel Logout URI", - backchannel_logout_session_required: "세션 필수", - headless_login_enabled: "헤드리스 로그인", - headless_token_endpoint_auth_method: "헤드리스 인증 방식", - headless_jwks_uri: "헤드리스 JWKS URI", - redirect_uri_count: "Redirect URI 수", - scope_count: "Scope 수", - relation: "관계", - subject: "대상", -}; - -function isRecord(value: unknown): value is Record { - return Boolean(value) && typeof value === "object" && !Array.isArray(value); -} - -function getRecentClientActionLabel(action: string) { - switch (action) { - case "CREATE_CLIENT": - return "클라이언트 생성"; - case "UPDATE_CLIENT": - return "설정 변경"; - case "UPDATE_CLIENT_STATUS": - return "상태 변경"; - case "ROTATE_SECRET": - return "클라이언트 시크릿 재발급"; - case "ADD_RELATION": - return "관계 추가"; - case "REMOVE_RELATION": - return "관계 삭제"; - case "DELETE_CLIENT": - return "클라이언트 삭제"; - default: - return action; - } -} - -function buildRecentClientChangeDetails( - action: string, - details: ReturnType, -) { - const before = isRecord(details.before) ? details.before : {}; - const after = isRecord(details.after) ? details.after : {}; - - if (action === "ROTATE_SECRET") { - return [{ label: "클라이언트 시크릿", value: "재발급" }]; - } - - if (action === "ADD_RELATION" || action === "REMOVE_RELATION") { - const source = action === "ADD_RELATION" ? after : before; - return [ - ...(source.relation - ? [{ label: "관계", value: formatAuditValue(source.relation) }] - : []), - ...(source.subject - ? [{ label: "대상", value: formatAuditValue(source.subject) }] - : []), - ]; - } - - const keys = Array.from( - new Set([...Object.keys(before), ...Object.keys(after)]), - ); - - const changes = keys - .map((key) => { - const beforeValue = before[key]; - const afterValue = after[key]; - - if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") { - if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) { - return null; - } - } - - const label = recentClientFieldLabels[key] ?? key; - if (action === "CREATE_CLIENT") { - if (afterValue === undefined) { - return null; - } - return { label, value: formatAuditValue(afterValue) }; - } - if (action === "DELETE_CLIENT") { - if (beforeValue === undefined) { - return null; - } - return { label, value: formatAuditValue(beforeValue) }; - } - if (beforeValue === undefined && afterValue === undefined) { - return null; - } - if (beforeValue === undefined) { - return { label, value: formatAuditValue(afterValue) }; - } - if (afterValue === undefined) { - return { label, value: formatAuditValue(beforeValue) }; - } - return { - label, - value: `${formatAuditValue(beforeValue)} → ${formatAuditValue(afterValue)}`, - }; - }) - .filter((item): item is { label: string; value: string } => Boolean(item)); - - return changes.slice(0, 3); -} - function ClientsPage() { const navigate = useNavigate(); const auth = useAuth(); @@ -315,11 +124,7 @@ function ClientsPage() { const [statusFilter, setStatusFilter] = useState("all"); const [isAdvancedFilterOpen, setIsAdvancedFilterOpen] = useState(false); const [isRequestModalOpen, setIsRequestModalOpen] = useState(false); - const [isRecentChangesGuideOpen, setIsRecentChangesGuideOpen] = - useState(false); const [isClientListExpanded, setIsClientListExpanded] = useState(false); - const [visibleRecentClientChangesCount, setVisibleRecentClientChangesCount] = - useState(recentClientChangesInitialCount); const [sortConfig, setSortConfig] = useState | null>({ key: "createdAt", @@ -327,61 +132,6 @@ function ClientsPage() { }); const clients = data?.items || []; - const visibleClientIds = useMemo( - () => clients.map((client) => client.id).filter(Boolean), - [clients], - ); - - const { data: recentAuditData, isLoading: isLoadingRecentAudit } = useQuery({ - queryKey: ["dev-audit-logs", "clients-recent", visibleClientIds.join("|")], - queryFn: async () => { - const globalLogs = await fetchDevAuditLogs(50); - if (globalLogs.items.length > 0 || profileRole === "super_admin") { - return globalLogs; - } - - if (visibleClientIds.length === 0) { - return globalLogs; - } - - const perClientLogs = await Promise.all( - visibleClientIds.slice(0, 20).map(async (clientId) => { - try { - const result = await fetchDevAuditLogs(5, undefined, { - client_id: clientId, - }); - return result.items; - } catch { - return []; - } - }), - ); - - const merged = perClientLogs - .flat() - .filter( - (item, index, self) => - self.findIndex( - (candidate) => candidate.event_id === item.event_id, - ) === index, - ) - .sort( - (left, right) => - new Date(right.timestamp).getTime() - - new Date(left.timestamp).getTime(), - ) - .slice(0, 50); - - return { - items: merged, - limit: 50, - cursor: globalLogs.cursor, - next_cursor: globalLogs.next_cursor, - }; - }, - enabled: hasAccessToken && clients.length > 0 && Boolean(profileRole), - retry: false, - }); const clientSortResolvers = useMemo< SortResolverMap @@ -488,105 +238,9 @@ function ClientsPage() { }, ]; - const recentClientChanges = useMemo(() => { - const clientNameById = new Map( - clients.map((client) => [client.id, client.name || client.id]), - ); - return (recentAuditData?.items || []) - .map((item: DevAuditLog) => { - const details = parseAuditDetails(item.details); - const action = details.action || ""; - const clientId = String(details.target_id || ""); - if (!recentClientActions.has(action) || !clientId) { - return null; - } - return { - eventId: item.event_id, - clientId, - clientName: clientNameById.get(clientId) || clientId, - actorId: resolveAuditActor(item, details), - actorName: "", - action, - actionLabel: getRecentClientActionLabel(action), - timestamp: item.timestamp, - detailLabels: buildRecentClientChangeDetails(action, details), - }; - }) - .filter((item): item is RecentClientChange => Boolean(item)) - .sort( - (left, right) => - new Date(right.timestamp).getTime() - - new Date(left.timestamp).getTime(), - ); - }, [clients, recentAuditData?.items]); - - const recentClientActorIds = useMemo(() => { - return Array.from( - new Set( - recentClientChanges - .map((item) => item.actorId.trim()) - .filter((actorId) => actorId && actorId !== "-"), - ), - ); - }, [recentClientChanges]); - - const { data: recentClientActors } = useQuery({ - queryKey: ["recent-client-actors", recentClientActorIds], - queryFn: async () => { - const entries = await Promise.all( - recentClientActorIds.map(async (actorId) => { - try { - const user = await fetchDevUser(actorId); - return [actorId, user.name || actorId] as const; - } catch { - return [actorId, actorId] as const; - } - }), - ); - return Object.fromEntries(entries); - }, - enabled: recentClientActorIds.length > 0, - }); - - const recentClientChangesWithActors = useMemo(() => { - return recentClientChanges.map((item) => ({ - ...item, - actorName: recentClientActors?.[item.actorId] || item.actorId, - })); - }, [recentClientActors, recentClientChanges]); - - const recentChangedClientCount = useMemo(() => { - return new Set(recentClientChangesWithActors.map((item) => item.clientId)) - .size; - }, [recentClientChangesWithActors]); - - const visibleRecentClientChanges = useMemo(() => { - return recentClientChangesWithActors.slice( - 0, - visibleRecentClientChangesCount, - ); - }, [recentClientChangesWithActors, visibleRecentClientChangesCount]); - - const hasMoreRecentClientChanges = - recentClientChangesWithActors.length > visibleRecentClientChanges.length; - - useEffect(() => { - if ( - visibleRecentClientChangesCount > recentClientChangesWithActors.length - ) { - setVisibleRecentClientChangesCount( - Math.max( - recentClientChangesInitialCount, - recentClientChangesWithActors.length, - ), - ); - } - }, [recentClientChangesWithActors.length, visibleRecentClientChangesCount]); - const isLoading = isLoadingClients || isLoadingStats || - isLoadingRecentAudit || isLoadingRequest || (hasAccessToken && !profileRole && isLoadingMe); @@ -1084,164 +738,6 @@ function ClientsPage() {
- - -
-
- - {t("ui.dev.clients.recent_changes.title", "최근 변경된 앱")} - - -
- - {t( - "msg.dev.clients.recent_changes.description", - "총 {{count}}개의 애플리케이션이 변경된 이력이 있습니다.", - { count: recentChangedClientCount }, - )} - -

- {t( - "msg.dev.clients.recent_changes.permission_note", - "'감사 로그 조회' 관계가 있어야 최근 변경된 앱을 볼 수 있습니다.", - )} -

- {isRecentChangesGuideOpen && ( -
-

- {t( - "ui.dev.clients.recent_changes.guide_title", - "최근 변경 항목 안내", - )} -

-
- {recentChangeGuideItems.map((item) => ( -
-

- {t(item.titleKey, item.titleFallback)} -

-

- {t(item.descriptionKey, item.descriptionFallback)} -

-
- ))} -

- {t( - "msg.dev.clients.recent_changes.guide.audit_only", - "동의 철회는 최근 변경된 앱 카드에 포함하지 않고, 감사 로그에서 확인합니다.", - )} -

-
-
- )} -
- -
- - {visibleRecentClientChanges.length === 0 ? ( -
- {t( - "msg.dev.clients.recent_changes.empty", - "최근 변경 로그가 아직 없습니다.", - )} -
- ) : ( - visibleRecentClientChanges.map((item) => { - const { date, time } = formatAuditDateParts(item.timestamp); - return ( -
-
-
- - {item.clientName} - - - {item.clientId} - - {item.actorName} - - {item.actorId} - - {item.actionLabel} -
-
- {item.detailLabels.length > 0 ? ( - item.detailLabels.map((detail) => ( - - {detail.label}: {detail.value} - - )) - ) : ( - - {t( - "msg.dev.clients.recent_changes.no_detail", - "변경 항목을 확인할 수 없습니다.", - )} - - )} -
-

- {date} {time} -

-
- -
- ); - }) - )} - {hasMoreRecentClientChanges ? ( -
- -
- ) : null} -
-
- setIsRequestModalOpen(false)} diff --git a/devfront/src/features/overview/GlobalOverviewPage.tsx b/devfront/src/features/overview/GlobalOverviewPage.tsx index 44925543..f60c244d 100644 --- a/devfront/src/features/overview/GlobalOverviewPage.tsx +++ b/devfront/src/features/overview/GlobalOverviewPage.tsx @@ -4,31 +4,42 @@ import { Activity, AlertTriangle, CheckCircle2, + Clock3, + ChevronDown, Layers3, LayoutDashboard, ShieldCheck, } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import { useAuth } from "react-oidc-context"; -import { useNavigate } from "react-router-dom"; +import { Link, useNavigate } from "react-router-dom"; import { OverviewAxisNotes, OverviewMetric, OverviewSelectionChips, } from "../../../../common/core/components/overview"; +import { formatAuditDateParts } from "../../../../common/core/audit"; import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard"; +import { Badge } from "../../components/ui/badge"; +import { Button } from "../../components/ui/button"; import { useDeveloperAccessGate } from "../developer-access/developerAccessGate"; import { type ClientSummary, fetchClients, + fetchDevAuditLogs, fetchDevRPUsageDaily, fetchDevStats, + fetchDevUser, type RPUsageDailyMetric, type RPUsagePeriod, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { resolveProfileRole } from "../../lib/role"; import { fetchMe } from "../auth/authApi"; +import { + buildRecentClientChanges, + type RecentClientChange, +} from "./recentClientChanges"; type ClientDistribution = { activeClients: number; @@ -69,6 +80,28 @@ type UsageChartPalette = { point: string; }; +const deletedRecentChangeFilterId = "__deleted_recent_clients__"; + +type RecentChangePoint = { + date: string; + changeCount: number; + uniqueActors: number; +}; + +type RecentChangeSeriesSummary = { + key: string; + clientLabel: string; + changeCount: number; + uniqueActors: number; +}; + +type RecentChangeSeries = { + key: string; + clientLabel: string; + color: UsageChartPalette; + points: RecentChangePoint[]; +}; + const usageChartPalettes: UsageChartPalette[] = [ { bar: "#7dd3fc", line: "#10b981", point: "#059669" }, { bar: "#f9a8d4", line: "#f97316", point: "#ea580c" }, @@ -154,6 +187,24 @@ function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] { ); } +function toPeriodBucket(date: string, period: RPUsagePeriod) { + const parts = parseDateParts(date); + if (!parts) { + return date; + } + if (period === "month") { + return `${parts.year}-${parts.monthText}-01`; + } + if (period === "week") { + const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day); + const weekYear = weekThursday.getUTCFullYear(); + const weekMonth = String(weekThursday.getUTCMonth() + 1).padStart(2, "0"); + const weekDay = String(weekThursday.getUTCDate()).padStart(2, "0"); + return `${weekYear}-${weekMonth}-${weekDay}`; + } + return date; +} + function buildMultiLineSeries(rows: RPUsageDailyMetric[]): MultiLineSeries[] { const dates = summarizeDaily(rows).map((point) => point.date); const byClient = new Map< @@ -268,6 +319,119 @@ function formatMetric(value: number | undefined) { return value === undefined ? "-" : value.toLocaleString(); } +function summarizeRecentChanges( + items: RecentClientChange[], + period: RPUsagePeriod, +): RecentChangePoint[] { + const byDate = new Map }>(); + for (const item of items) { + const bucket = toPeriodBucket(item.timestamp.slice(0, 10), period); + const current = byDate.get(bucket) ?? { + changeCount: 0, + actors: new Set(), + }; + current.changeCount += 1; + if (item.actorId && item.actorId !== "-") { + current.actors.add(item.actorId); + } + byDate.set(bucket, current); + } + + return Array.from(byDate.entries()) + .map(([date, current]) => ({ + date, + changeCount: current.changeCount, + uniqueActors: current.actors.size, + })) + .sort((left, right) => left.date.localeCompare(right.date)); +} + +function summarizeRecentChangeSeries( + items: RecentClientChange[], +): RecentChangeSeriesSummary[] { + const bySeries = new Map< + string, + { clientLabel: string; changeCount: number; actors: Set } + >(); + for (const item of items) { + const current = bySeries.get(item.clientId) ?? { + clientLabel: item.clientName, + changeCount: 0, + actors: new Set(), + }; + current.changeCount += 1; + if (item.actorId && item.actorId !== "-") { + current.actors.add(item.actorId); + } + bySeries.set(item.clientId, current); + } + + return Array.from(bySeries.entries()) + .map(([key, current]) => ({ + key, + clientLabel: current.clientLabel, + changeCount: current.changeCount, + uniqueActors: current.actors.size, + })) + .sort((left, right) => right.changeCount - left.changeCount); +} + +function buildRecentChangeSeries( + items: RecentClientChange[], + period: RPUsagePeriod, +): RecentChangeSeries[] { + const dates = summarizeRecentChanges(items, period).map((point) => point.date); + const byClient = new Map< + string, + { + clientLabel: string; + byDate: Map; + actorIdsByDate: Map>; + } + >(); + + for (const item of items) { + const bucket = toPeriodBucket(item.timestamp.slice(0, 10), period); + const current = byClient.get(item.clientId) ?? { + clientLabel: item.clientName, + byDate: new Map(), + actorIdsByDate: new Map>(), + }; + const point = current.byDate.get(bucket) ?? { + date: bucket, + changeCount: 0, + uniqueActors: 0, + }; + point.changeCount += 1; + const actorIds = current.actorIdsByDate.get(bucket) ?? new Set(); + if (item.actorId && item.actorId !== "-") { + actorIds.add(item.actorId); + } + point.uniqueActors = actorIds.size; + current.byDate.set(bucket, point); + current.actorIdsByDate.set(bucket, actorIds); + byClient.set(item.clientId, current); + } + + return Array.from(byClient.entries()) + .sort((left, right) => + left[1].clientLabel.localeCompare(right[1].clientLabel), + ) + .map(([clientId, entry], index) => ({ + key: clientId, + clientLabel: entry.clientLabel, + color: usageChartPalettes[index % usageChartPalettes.length], + points: dates.map( + (date) => + entry.byDate.get(date) ?? { + date, + changeCount: 0, + uniqueActors: 0, + }, + ), + })); +} + function RPUsageMixedChart({ period, rows, @@ -475,6 +639,215 @@ function RPUsageMixedChart({ ); } +function RecentClientChangesChart({ + items, + period, + multiLineSeries, +}: { + items: RecentClientChange[]; + period: RPUsagePeriod; + multiLineSeries?: RecentChangeSeries[]; +}) { + const daily = summarizeRecentChanges(items, period); + const series = summarizeRecentChangeSeries(items); + const topSeries = series.slice(0, 5); + const seriesByKey = new Map(series.map((item) => [item.key, item])); + const chartWidth = 720; + const chartHeight = 230; + const padX = 48; + const padTop = 40; + const padBottom = 34; + const innerWidth = chartWidth - padX * 2; + const innerHeight = chartHeight - padTop - padBottom; + const maxValue = Math.max(1, ...daily.map((point) => point.changeCount)); + const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth; + const y = (value: number) => + padTop + innerHeight - (value / maxValue) * innerHeight; + const x = (index: number) => padX + slot * index + slot / 2; + const linePoints = daily + .map((point, index) => `${x(index)},${y(point.changeCount)}`) + .join(" "); + const multiLinePoints = multiLineSeries?.map((seriesItem) => ({ + ...seriesItem, + pointsAttr: seriesItem.points + .map((point, index) => `${x(index)},${y(point.changeCount)}`) + .join(" "), + })); + + if (daily.length === 0) { + return ( +
+ {t( + "msg.dev.dashboard.recent_changes.empty", + "최근 변경 로그가 아직 없습니다.", + )} +
+ ); + } + + return ( +
+
+ + + {t("ui.dev.dashboard.recent_changes.aria", "최근 변경된 앱 현황")} + + {[0, 0.25, 0.5, 0.75, 1].map((ratio) => { + const gridY = padTop + innerHeight * ratio; + const label = Math.round(maxValue * (1 - ratio)); + return ( + + + + {label} + + + ); + })} + {daily.map((point, index) => ( + + + {formatPeriodLabel(point.date, period)} + + + ))} + {!multiLinePoints || multiLinePoints.length === 0 ? ( + <> + + {daily.map((point, index) => ( + + ))} + + ) : ( + multiLinePoints.map((seriesItem) => ( + + + {seriesItem.points.map((point, index) => ( + + ))} + + )) + )} + +
+ + + + {multiLinePoints && multiLinePoints.length > 0 ? ( +
+ {multiLinePoints.map((item) => { + const seriesItem = seriesByKey.get(item.key); + return ( +
+ + {item.clientLabel} + {seriesItem ? ( + + {t( + "ui.dev.dashboard.recent_changes.series", + "변경 {{changes}} / 작업자 {{actors}}", + { + changes: seriesItem.changeCount.toLocaleString(), + actors: seriesItem.uniqueActors.toLocaleString(), + }, + )} + + ) : null} +
+ ); + })} +
+ ) : topSeries.length > 0 ? ( +
+ {topSeries.map((item) => ( +
+ {item.clientLabel} + + {t( + "ui.dev.dashboard.recent_changes.series", + "변경 {{changes}} / 작업자 {{actors}}", + { + changes: item.changeCount.toLocaleString(), + actors: item.uniqueActors.toLocaleString(), + }, + )} + +
+ ))} +
+ ) : null} +
+ ); +} + function GlobalOverviewPage() { const navigate = useNavigate(); const auth = useAuth(); @@ -490,6 +863,14 @@ function GlobalOverviewPage() { const profileRole = me?.role?.trim() || role; const [period, setPeriod] = useState("day"); const [selectedClientIds, setSelectedClientIds] = useState([]); + const [recentChangesPeriod, setRecentChangesPeriod] = + useState("week"); + const [selectedRecentChangeClientIds, setSelectedRecentChangeClientIds] = + useState([]); + const [visibleRecentClientChangesCount, setVisibleRecentClientChangesCount] = + useState(6); + const [isRecentChangesDetailOpen, setIsRecentChangesDetailOpen] = + useState(false); const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90; const statsQuery = useQuery({ queryKey: ["dev-dashboard-stats"], @@ -527,21 +908,6 @@ function GlobalOverviewPage() { () => buildClientDistribution(clients), [clients], ); - const visibleClients = useMemo( - () => - [...clients] - .sort((left, right) => { - const statusCompare = (left.status || "").localeCompare( - right.status || "", - ); - if (statusCompare !== 0) { - return statusCompare; - } - return (left.name || left.id).localeCompare(right.name || right.id); - }) - .slice(0, 6), - [clients], - ); const clientFilterOptions = useMemo( () => [...clients] @@ -552,6 +918,14 @@ function GlobalOverviewPage() { .sort((left, right) => left.label.localeCompare(right.label)), [clients], ); + const visibleClientIds = useMemo( + () => clients.map((client) => client.id).filter(Boolean), + [clients], + ); + const currentClientIdSet = useMemo( + () => new Set(visibleClientIds), + [visibleClientIds], + ); const stats = statsQuery.data; const usageRows = usageQuery.data?.items ?? []; const filteredUsageRows = useMemo(() => { @@ -565,6 +939,194 @@ function GlobalOverviewPage() { () => buildMultiLineSeries(filteredUsageRows), [filteredUsageRows], ); + const { data: recentAuditData } = useQuery({ + queryKey: [ + "dev-dashboard-audit-logs", + "clients-recent", + visibleClientIds.join("|"), + profileRole, + ], + queryFn: async () => { + const globalLogs = await fetchDevAuditLogs(50); + if (globalLogs.items.length > 0 || profileRole === "super_admin") { + return globalLogs; + } + + if (visibleClientIds.length === 0) { + return globalLogs; + } + + const perClientLogs = await Promise.all( + visibleClientIds.slice(0, 20).map(async (clientId) => { + try { + const result = await fetchDevAuditLogs(5, undefined, { + client_id: clientId, + }); + return result.items; + } catch { + return []; + } + }), + ); + + const merged = perClientLogs + .flat() + .filter( + (item, index, self) => + self.findIndex( + (candidate) => candidate.event_id === item.event_id, + ) === index, + ) + .sort( + (left, right) => + new Date(right.timestamp).getTime() - + new Date(left.timestamp).getTime(), + ) + .slice(0, 50); + + return { + items: merged, + limit: 50, + cursor: globalLogs.cursor, + next_cursor: globalLogs.next_cursor, + }; + }, + enabled: hasAccessToken && clients.length > 0 && Boolean(profileRole), + retry: false, + }); + const recentClientChanges = useMemo( + () => buildRecentClientChanges(recentAuditData?.items ?? [], clients), + [clients, recentAuditData?.items], + ); + const recentClientActorIds = useMemo( + () => + Array.from( + new Set( + recentClientChanges + .map((item) => item.actorId.trim()) + .filter((actorId) => actorId && actorId !== "-"), + ), + ), + [recentClientChanges], + ); + const { data: recentClientActors } = useQuery({ + queryKey: ["dev-dashboard-recent-client-actors", recentClientActorIds], + queryFn: async () => { + const entries = await Promise.all( + recentClientActorIds.map(async (actorId) => { + try { + const user = await fetchDevUser(actorId); + return [actorId, user.name || actorId] as const; + } catch { + return [actorId, actorId] as const; + } + }), + ); + return Object.fromEntries(entries); + }, + enabled: recentClientActorIds.length > 0, + }); + const recentClientChangesWithActors = useMemo( + () => + recentClientChanges.map((item) => ({ + ...item, + actorName: recentClientActors?.[item.actorId] || item.actorId, + })), + [recentClientActors, recentClientChanges], + ); + const deletedRecentChangeClientIds = useMemo( + () => + Array.from( + new Set( + recentClientChangesWithActors + .map((item) => item.clientId) + .filter((clientId) => !currentClientIdSet.has(clientId)), + ), + ), + [currentClientIdSet, recentClientChangesWithActors], + ); + const recentChangeFilterOptions = useMemo( + () => { + const activeOptions = Array.from( + new Map( + recentClientChangesWithActors + .filter((item) => currentClientIdSet.has(item.clientId)) + .map((item) => [ + item.clientId, + { id: item.clientId, label: item.clientName }, + ]), + ).values(), + ).sort((left, right) => left.label.localeCompare(right.label)); + + if (deletedRecentChangeClientIds.length === 0) { + return activeOptions; + } + + return [ + ...activeOptions, + { + id: deletedRecentChangeFilterId, + label: t( + "ui.dev.dashboard.recent_changes.deleted_group", + "삭제된 RP", + ), + }, + ]; + }, + [ + currentClientIdSet, + deletedRecentChangeClientIds.length, + recentClientChangesWithActors, + ], + ); + const filteredRecentClientChanges = useMemo(() => { + if (selectedRecentChangeClientIds.length === 0) { + return recentClientChangesWithActors; + } + const selectedSet = new Set(selectedRecentChangeClientIds); + const includeDeletedGroup = selectedSet.has(deletedRecentChangeFilterId); + return recentClientChangesWithActors.filter( + (item) => + selectedSet.has(item.clientId) || + (includeDeletedGroup && deletedRecentChangeClientIds.includes(item.clientId)), + ); + }, [ + deletedRecentChangeClientIds, + recentClientChangesWithActors, + selectedRecentChangeClientIds, + ]); + const selectedRecentChangeSeries = useMemo( + () => buildRecentChangeSeries(filteredRecentClientChanges, recentChangesPeriod), + [filteredRecentClientChanges, recentChangesPeriod], + ); + const recentChangedClientCount = useMemo( + () => + new Set( + filteredRecentClientChanges + .map((item) => item.clientId) + .filter((clientId) => currentClientIdSet.has(clientId)), + ).size, + [currentClientIdSet, filteredRecentClientChanges], + ); + const deletedRecentChangedClientCount = useMemo( + () => + new Set( + filteredRecentClientChanges + .map((item) => item.clientId) + .filter((clientId) => deletedRecentChangeClientIds.includes(clientId)), + ).size, + [deletedRecentChangeClientIds, filteredRecentClientChanges], + ); + const recentChangeCount = filteredRecentClientChanges.length; + const latestRecentChange = filteredRecentClientChanges[0]; + const visibleRecentClientChanges = filteredRecentClientChanges.slice( + 0, + visibleRecentClientChangesCount, + ); + const hasMoreRecentClientChanges = + filteredRecentClientChanges.length > visibleRecentClientChanges.length; + const isAllRecentChangeClientsSelected = + selectedRecentChangeClientIds.length === 0; const usageError = usageQuery.error as AxiosError<{ error?: string }> | null; const usageStatus = usageError?.response?.status; const usageErrorMessage = @@ -609,6 +1171,23 @@ function GlobalOverviewPage() { const selectAllClients = () => { setSelectedClientIds([]); }; + const toggleRecentChangeClientSelection = (clientId: string) => { + setSelectedRecentChangeClientIds((current) => { + if (current.includes(clientId)) { + return current.filter((item) => item !== clientId); + } + return [...current, clientId]; + }); + }; + const selectAllRecentChangeClients = () => { + setSelectedRecentChangeClientIds([]); + }; + + useEffect(() => { + setVisibleRecentClientChangesCount((current) => + Math.min(Math.max(6, current), filteredRecentClientChanges.length), + ); + }, [filteredRecentClientChanges.length, selectedRecentChangeClientIds]); if (isLoadingDeveloperAccessGate) { return ( @@ -696,7 +1275,7 @@ function GlobalOverviewPage() {
-

+

{t( "ui.dev.dashboard.chart.title", "애플리케이션별 로그인요청/기타 요청 현황", @@ -756,116 +1335,204 @@ function GlobalOverviewPage() { )}

-
-
-
- -

+
+
+
+

+ {t("ui.dev.dashboard.recent_changes.title", "최근 변경된 앱")} +

+

{t( - "ui.dev.dashboard.distribution.title", - "애플리케이션 구성 요약", + "msg.dev.dashboard.recent_changes.description", + "변경 또는 삭제된 애플리케이션을 대시보드에서 추이를 확인합니다.", )} -

+

-

- {t( - "msg.dev.dashboard.distribution.description", - "애플리케이션 유형 분포와 server side app의 headless login 사용 현황을 빠르게 확인합니다.", - )} -

-
-
-

- {t("ui.dev.dashboard.distribution.private", "Server side App")} -

-

- {distribution.privateClients.toLocaleString()} -

-

- {t( - "ui.dev.dashboard.distribution.headless_hint", - "이 중 Headless Login 사용 {{count}}", - { - count: distribution.headlessClients.toLocaleString(), - }, - )} -

-
-
-

- {t("ui.dev.dashboard.distribution.pkce", "PKCE")} -

-

- {distribution.pkceClients.toLocaleString()} -

-
-
-
- -
- -

- {t("ui.dev.dashboard.recent.title", "내 애플리케이션")} -

-
-

- {t( - "msg.dev.dashboard.recent.empty", - "현재 계정이 접근할 수 있는 RP를 확인합니다.", - )} -

-
- {visibleClients.length === 0 ? ( -

- {t( - "msg.dev.dashboard.recent.none", - "표시할 연동 앱이 없습니다.", - )} -

- ) : ( - visibleClients.map((client) => ( -
+ {[ + ["day", t("ui.common.chart.period.day", "일")], + ["week", t("ui.common.chart.period.week", "주")], + ["month", t("ui.common.chart.period.month", "월")], + ].map(([value, label]) => ( +
- )) - )} + {label} + + ))} +
-
-
+
+ +
+ } + label={t( + "ui.dev.dashboard.recent_changes.summary.total_changes", + "최근 변경 건수", + )} + value={recentChangeCount.toLocaleString()} + /> + } + label={t( + "ui.dev.dashboard.recent_changes.summary.changed_clients", + "변경된 앱 수", + )} + value={recentChangedClientCount.toLocaleString()} + /> + } + label={t( + "ui.dev.dashboard.recent_changes.summary.deleted_clients", + "삭제된 RP 수", + )} + value={deletedRecentChangedClientCount.toLocaleString()} + /> + } + label={t( + "ui.dev.dashboard.recent_changes.summary.latest_change", + "마지막 변경일", + )} + value={formatDate(latestRecentChange?.timestamp)} + /> +
+ + + + {isAllRecentChangeClientsSelected ? ( + + ) : ( + + )} + + + + {isRecentChangesDetailOpen ? ( +
+
+ {filteredRecentClientChanges.length === 0 ? ( +
+ {t( + "msg.dev.dashboard.recent_changes.empty", + "최근 변경 로그가 아직 없습니다.", + )} +
+ ) : ( + visibleRecentClientChanges.map((item) => { + const { date, time } = formatAuditDateParts(item.timestamp); + return ( +
+
+ + {item.clientName} + + {item.actionLabel} + + {item.actorName} + +
+
+ {item.detailLabels.length > 0 ? ( + item.detailLabels.map((detail) => ( + + {detail.label}: {detail.value} + + )) + ) : ( + + {t( + "msg.dev.clients.recent_changes.no_detail", + "변경 항목을 확인할 수 없습니다.", + )} + + )} +
+

+ {date} {time} +

+
+ ); + }) + )} + {hasMoreRecentClientChanges ? ( +
+ +
+ ) : null} +
+
+ ) : null} + +
); } diff --git a/devfront/src/features/overview/recentClientChanges.ts b/devfront/src/features/overview/recentClientChanges.ts new file mode 100644 index 00000000..dd31e081 --- /dev/null +++ b/devfront/src/features/overview/recentClientChanges.ts @@ -0,0 +1,183 @@ +import { + formatAuditValue, + parseAuditDetails, + resolveAuditActor, + type AuditDetails, + type CommonAuditLog, +} from "../../../../common/core/audit"; +import type { ClientSummary, DevAuditLog } from "../../lib/devApi"; + +export type RecentClientChange = { + eventId: string; + clientId: string; + clientName: string; + actorId: string; + action: string; + actionLabel: string; + timestamp: string; + detailLabels: Array<{ label: string; value: string }>; +}; + +const recentClientActions = new Set([ + "CREATE_CLIENT", + "UPDATE_CLIENT", + "UPDATE_CLIENT_STATUS", + "ROTATE_SECRET", + "ADD_RELATION", + "REMOVE_RELATION", + "DELETE_CLIENT", +]); + +const recentClientFieldLabels: Record = { + name: "이름", + type: "유형", + status: "상태", + scopes: "스코프", + tenant_access_restricted: "테넌트 접근 제한", + allowed_tenants: "허용 테넌트", + id_token_claims: "커스텀 클레임", + token_endpoint_auth_method: "인증 방식", + jwks_uri: "JWKS URI", + backchannel_logout_uri: "Backchannel Logout URI", + backchannel_logout_session_required: "세션 필수", + headless_login_enabled: "헤드리스 로그인", + headless_token_endpoint_auth_method: "헤드리스 인증 방식", + headless_jwks_uri: "헤드리스 JWKS URI", + redirect_uri_count: "Redirect URI 수", + scope_count: "Scope 수", + relation: "관계", + subject: "대상", +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +export function getRecentClientActionLabel(action: string) { + switch (action) { + case "CREATE_CLIENT": + return "클라이언트 생성"; + case "UPDATE_CLIENT": + return "설정 변경"; + case "UPDATE_CLIENT_STATUS": + return "상태 변경"; + case "ROTATE_SECRET": + return "클라이언트 시크릿 재발급"; + case "ADD_RELATION": + return "관계 추가"; + case "REMOVE_RELATION": + return "관계 삭제"; + case "DELETE_CLIENT": + return "클라이언트 삭제"; + default: + return action; + } +} + +export function buildRecentClientChangeDetails( + action: string, + details: AuditDetails, +) { + const before = isRecord(details.before) ? details.before : {}; + const after = isRecord(details.after) ? details.after : {}; + + if (action === "ROTATE_SECRET") { + return [{ label: "클라이언트 시크릿", value: "재발급" }]; + } + + if (action === "ADD_RELATION" || action === "REMOVE_RELATION") { + const source = action === "ADD_RELATION" ? after : before; + return [ + ...(source.relation + ? [{ label: "관계", value: formatAuditValue(source.relation) }] + : []), + ...(source.subject + ? [{ label: "대상", value: formatAuditValue(source.subject) }] + : []), + ]; + } + + const keys = Array.from( + new Set([...Object.keys(before), ...Object.keys(after)]), + ); + + const changes = keys + .map((key) => { + const beforeValue = before[key]; + const afterValue = after[key]; + + if (action !== "CREATE_CLIENT" && action !== "DELETE_CLIENT") { + if (JSON.stringify(beforeValue) === JSON.stringify(afterValue)) { + return null; + } + } + + const label = recentClientFieldLabels[key] ?? key; + if (action === "CREATE_CLIENT") { + if (afterValue === undefined) { + return null; + } + return { label, value: formatAuditValue(afterValue) }; + } + if (action === "DELETE_CLIENT") { + if (beforeValue === undefined) { + return null; + } + return { label, value: formatAuditValue(beforeValue) }; + } + if (beforeValue === undefined && afterValue === undefined) { + return null; + } + if (beforeValue === undefined) { + return { label, value: formatAuditValue(afterValue) }; + } + if (afterValue === undefined) { + return { label, value: formatAuditValue(beforeValue) }; + } + return { + label, + value: `${formatAuditValue(beforeValue)} → ${formatAuditValue(afterValue)}`, + }; + }) + .filter((item): item is { label: string; value: string } => Boolean(item)); + + return changes.slice(0, 3); +} + +export function buildRecentClientChanges( + auditLogs: DevAuditLog[], + clients: ClientSummary[], +) { + const clientNameById = new Map( + clients.map((client) => [client.id, client.name || client.id]), + ); + + return auditLogs + .map((item) => { + const details = parseAuditDetails(item.details); + const action = details.action || ""; + const clientId = String(details.target_id || ""); + if (!recentClientActions.has(action) || !clientId) { + return null; + } + return { + eventId: item.event_id, + clientId, + clientName: clientNameById.get(clientId) || clientId, + actorId: resolveAuditActor( + item as Pick, + details, + ), + action, + actionLabel: getRecentClientActionLabel(action), + timestamp: item.timestamp, + detailLabels: buildRecentClientChangeDetails(action, details), + } satisfies RecentClientChange; + }) + .filter((item): item is RecentClientChange => Boolean(item)) + .sort( + (left, right) => + new Date(right.timestamp).getTime() - + new Date(left.timestamp).getTime(), + ); +} diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index b94625b6..b41f378e 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -544,6 +544,10 @@ new_client = "Configure redirect URIs, grant types, and authentication methods." empty = "Review the relying parties this account can access." none = "No connected applications to display." +[msg.dev.dashboard.recent_changes] +description = "Review trends for changed or deleted applications on the dashboard." +empty = "There are no recent change logs yet." + [msg.dev.dashboard.notice] consent_audit = "Consent Audit" dev_scope = "Dev Scope" @@ -1741,6 +1745,20 @@ title = "Quick links" [ui.dev.dashboard.recent] title = "My Applications" +[ui.dev.dashboard.recent_changes] +deleted_group = "Deleted RPs" +aria = "Recent application changes" +period = "Recent change aggregation period" +series = "Changes {{changes}} / Actors {{actors}}" +title = "Recently Changed Applications" +y_axis = "Y axis: change count" + +[ui.dev.dashboard.recent_changes.summary] +changed_clients = "Changed apps" +deleted_clients = "Deleted RPs" +latest_change = "Latest change" +total_changes = "Recent changes" + [ui.dev.dashboard.stack] notes = "Setup notes" subtitle = "Devfront baseline" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 42fe9402..d169bd38 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -544,6 +544,10 @@ new_client = "redirect URI, grant type, 인증 방식을 설정합니다." empty = "현재 계정이 접근할 수 있는 RP를 확인합니다." none = "표시할 연동 앱이 없습니다." +[msg.dev.dashboard.recent_changes] +description = "변경 또는 삭제된 애플리케이션을 대시보드에서 추이를 확인합니다." +empty = "최근 변경 로그가 아직 없습니다." + [msg.dev.dashboard.notice] consent_audit = "Consent 회수는 감사 로그와 연계" dev_scope = "RP 정책은 dev scope에서만 적용" @@ -1740,6 +1744,20 @@ title = "빠른 이동" [ui.dev.dashboard.recent] title = "내 애플리케이션" +[ui.dev.dashboard.recent_changes] +deleted_group = "삭제된 RP" +aria = "최근 변경된 앱 현황" +period = "최근 변경 집계 단위" +series = "변경 {{changes}} / 작업자 {{actors}}" +title = "최근 변경된 앱" +y_axis = "Y축: 변경 수" + +[ui.dev.dashboard.recent_changes.summary] +changed_clients = "변경된 앱 수" +deleted_clients = "삭제된 RP 수" +latest_change = "마지막 변경일" +total_changes = "최근 변경 건수" + [ui.dev.dashboard.stack] notes = "Setup notes" subtitle = "Devfront baseline" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 85b2c1dc..141eacba 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -582,6 +582,10 @@ new_client = "" empty = "" none = "" +[msg.dev.dashboard.recent_changes] +description = "" +empty = "" + [msg.dev.dashboard.notice] consent_audit = "" dev_scope = "" @@ -1797,6 +1801,20 @@ title = "" [ui.dev.dashboard.recent] title = "" +[ui.dev.dashboard.recent_changes] +deleted_group = "" +aria = "" +period = "" +series = "" +title = "" +y_axis = "" + +[ui.dev.dashboard.recent_changes.summary] +changed_clients = "" +deleted_clients = "" +latest_change = "" +total_changes = "" + [ui.dev.dashboard.stack] notes = "" subtitle = "" diff --git a/devfront/tests/clients.spec.ts b/devfront/tests/clients.spec.ts index 9777876f..f5fe0a0d 100644 --- a/devfront/tests/clients.spec.ts +++ b/devfront/tests/clients.spec.ts @@ -47,7 +47,7 @@ test("clients page loads correctly", async ({ page }) => { ).toBeVisible(); }); -test("clients page shows recent RP changes", async ({ page }) => { +test("overview page shows recent RP changes", async ({ page }) => { await seedAuth(page, "super_admin"); await installDevApiMock(page, { clients: [ @@ -89,7 +89,7 @@ test("clients page shows recent RP changes", async ({ page }) => { auditLogsByCursor: undefined, }); - await page.goto("/clients"); + await page.goto("/"); await expect( page.getByRole("heading", { name: "최근 변경된 앱" }), ).toBeVisible(); @@ -153,7 +153,7 @@ test("clients page shows only five apps by default and expands with more button" ).toHaveCount(0); }); -test("clients page shows user-delete relation cleanup in recent changes", async ({ +test("overview page shows user-delete relation cleanup in recent changes", async ({ page, }) => { await seedAuth(page, "super_admin"); @@ -195,7 +195,7 @@ test("clients page shows user-delete relation cleanup in recent changes", async auditLogsByCursor: undefined, }); - await page.goto("/clients"); + await page.goto("/"); await expect( page.getByRole("heading", { name: "최근 변경된 앱" }), ).toBeVisible(); @@ -210,7 +210,7 @@ test("clients page shows user-delete relation cleanup in recent changes", async ).toBeVisible(); }); -test("clients page expands recent changes with more button", async ({ +test("clients page no longer shows recent changes card", async ({ page, }) => { await seedAuth(page, "super_admin"); @@ -246,23 +246,8 @@ test("clients page expands recent changes with more button", async ({ await page.goto("/clients"); await expect( page.getByRole("heading", { name: "최근 변경된 앱" }), - ).toBeVisible(); + ).toHaveCount(0); await expect( - page.getByRole("link", { name: "Recent App 1", exact: true }), + page.getByRole("heading", { name: "연동 앱 목록" }), ).toBeVisible(); - await expect( - page.getByRole("link", { name: "Recent App 5", exact: true }), - ).toBeVisible(); - await expect( - page.getByRole("link", { name: "Recent App 6", exact: true }), - ).not.toBeVisible(); - - const moreButton = page.getByRole("button", { name: "더 보기" }); - await expect(moreButton).toBeVisible(); - await moreButton.click(); - - await expect( - page.getByRole("link", { name: "Recent App 6", exact: true }), - ).toBeVisible(); - await expect(moreButton).toHaveCount(0); }); From d2a7ebd82fdfb6675153b7e04f92516166f5020d Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 1 Jun 2026 15:47:18 +0900 Subject: [PATCH 3/7] =?UTF-8?q?=EC=97=B0=EB=8F=99=20=EC=95=B1=20=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20UI=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- devfront/src/features/clients/ClientsPage.tsx | 163 ++++-------------- devfront/tests/clients.spec.ts | 1 + locales/en.toml | 18 ++ locales/ko.toml | 18 ++ locales/template.toml | 18 ++ 5 files changed, 84 insertions(+), 134 deletions(-) diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index f155d9e9..2057783e 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -46,7 +46,6 @@ import { type ClientSummary, fetchClients, fetchDeveloperRequestStatus, - fetchDevStats, fetchMyTenants, requestDeveloperAccess, } from "../../lib/devApi"; @@ -79,12 +78,6 @@ function ClientsPage() { enabled: hasAccessToken, }); - const { data: statsData, isLoading: isLoadingStats } = useQuery({ - queryKey: ["dev-stats"], - queryFn: fetchDevStats, - enabled: hasAccessToken, - }); - const { data: me, isLoading: isLoadingMe } = useQuery({ queryKey: ["userMe"], queryFn: fetchMe, @@ -172,9 +165,6 @@ function ClientsPage() { typeFilter, ]); - const totalClients = statsData?.total_clients ?? clients.length; - const activeSessions = statsData?.active_sessions ?? 0; - const authFailures = statsData?.auth_failures_24h ?? 0; const hasFilterResult = filteredClients.length > 0; const isFilteredOut = clients.length > 0 && !hasFilterResult; const visibleClients = useMemo(() => { @@ -198,49 +188,8 @@ function ClientsPage() { ""; const profileRoleLabel = t(`ui.admin.role.${profileRole}`, profileRole); - type StatTone = "up" | "down" | "stable"; - type StatItem = { - labelKey: string; - labelFallback: string; - value: string; - deltaKey: string; - deltaFallback: string; - tone: StatTone; - }; - - const stats: StatItem[] = [ - { - labelKey: "ui.dev.clients.stats.total", - labelFallback: "Total Applications", - value: totalClients.toString(), - deltaKey: "ui.dev.clients.stats.realtime", - deltaFallback: "Realtime", - tone: "up" as const, - }, - { - labelKey: "ui.dev.clients.stats.active_sessions", - labelFallback: "Active Sessions", - value: activeSessions.toString(), - deltaKey: "ui.dev.clients.stats.realtime", - deltaFallback: "Realtime", - tone: "up" as const, - }, - { - labelKey: "ui.dev.clients.stats.auth_failures", - labelFallback: "Auth Failures (24h)", - value: authFailures.toString(), - deltaKey: - authFailures > 0 - ? "ui.dev.clients.stats.alert" - : "ui.dev.clients.stats.stable", - deltaFallback: authFailures > 0 ? "Check Logs" : "Stable", - tone: authFailures > 0 ? ("down" as const) : ("stable" as const), - }, - ]; - const isLoading = isLoadingClients || - isLoadingStats || isLoadingRequest || (hasAccessToken && !profileRole && isLoadingMe); @@ -285,7 +234,7 @@ function ClientsPage() { canCreateClient ? ( -
- - {t( - "ui.dev.clients.badge.tenant_selected", - "테넌트: 선택됨", - )} - - - {t("ui.dev.clients.badge.dev_session", "DevFront 세션")} - -
- + } advancedOpen={isAdvancedFilterOpen} advanced={ @@ -447,59 +395,6 @@ function ClientsPage() { } /> - -
- {stats.map((item) => ( - - - - {t(item.labelKey, item.labelFallback)} - -
- - {item.value} - - - {t(item.deltaKey, item.deltaFallback)} - -
-
-
- ))} -
-
- - - - -
- - {t("ui.dev.clients.list.title", "클라이언트 목록")} - - - {t( - "msg.dev.clients.showing", - "총 {{shown}}개의 애플리케이션이 등록되어 있습니다.", - { shown: totalClients }, - )} - -
-
diff --git a/devfront/tests/clients.spec.ts b/devfront/tests/clients.spec.ts index f5fe0a0d..aa6e1946 100644 --- a/devfront/tests/clients.spec.ts +++ b/devfront/tests/clients.spec.ts @@ -37,6 +37,7 @@ test("clients page loads correctly", async ({ page }) => { // 페이지 내 주요 텍스트 확인 await expect(page.getByText("연동 앱 목록")).toBeVisible(); + await expect(page.getByText("Total Applications", { exact: true })).toHaveCount(0); // 테이블 헤더 확인 await expect( diff --git a/locales/en.toml b/locales/en.toml index bed58b50..7bd5e3cf 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -595,6 +595,10 @@ description = "Quickly review application types and headless login usage." empty = "Review the RPs this account can access." none = "No linked applications are available." +[msg.dev.dashboard.recent_changes] +description = "Review trends for changed or deleted applications on the dashboard." +empty = "There are no recent change logs yet." + [msg.dev.dashboard.notice] consent_audit = "Consent Audit" dev_scope = "Dev Scope" @@ -2308,6 +2312,20 @@ title = "Quick links" [ui.dev.dashboard.recent] title = "My Applications" +[ui.dev.dashboard.recent_changes] +aria = "Recent changed application status" +deleted_group = "Deleted RPs" +period = "Recent change aggregation period" +series = "Changes {{changes}} / Actors {{actors}}" +title = "Recent Changed Apps" +y_axis = "Y axis: change count" + +[ui.dev.dashboard.recent_changes.summary] +changed_clients = "Changed applications" +deleted_clients = "Deleted RPs" +latest_change = "Latest change" +total_changes = "Recent change count" + [ui.dev.dashboard.stack] notes = "Setup notes" subtitle = "Devfront baseline" diff --git a/locales/ko.toml b/locales/ko.toml index 97bb1270..1f99c3d7 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -1087,6 +1087,10 @@ description = "애플리케이션 유형과 headless login 사용 현황을 빠 empty = "현재 계정이 접근할 수 있는 RP를 확인합니다." none = "표시할 연동 앱이 없습니다." +[msg.dev.dashboard.recent_changes] +description = "변경 또는 삭제된 애플리케이션을 대시보드에서 추이를 확인합니다." +empty = "최근 변경 로그가 아직 없습니다." + [msg.dev.dashboard.notice] consent_audit = "Consent 회수는 감사 로그와 연계" dev_scope = "RP 정책은 dev scope에서만 적용" @@ -2772,6 +2776,20 @@ title = "빠른 이동" [ui.dev.dashboard.recent] title = "내 애플리케이션" +[ui.dev.dashboard.recent_changes] +aria = "최근 변경된 앱 현황" +deleted_group = "삭제된 RP" +period = "최근 변경 집계 단위" +series = "변경 {{changes}} / 작업자 {{actors}}" +title = "최근 변경된 앱" +y_axis = "Y축: 변경 수" + +[ui.dev.dashboard.recent_changes.summary] +changed_clients = "변경된 앱 수" +deleted_clients = "삭제된 RP 수" +latest_change = "마지막 변경일" +total_changes = "최근 변경 건수" + [ui.dev.dashboard.stack] notes = "Setup notes" subtitle = "Devfront baseline" diff --git a/locales/template.toml b/locales/template.toml index 455e60bc..08639c33 100644 --- a/locales/template.toml +++ b/locales/template.toml @@ -947,6 +947,10 @@ description = "" empty = "" none = "" +[msg.dev.dashboard.recent_changes] +description = "" +empty = "" + [msg.dev.dashboard.notice] consent_audit = "" dev_scope = "" @@ -2653,6 +2657,20 @@ title = "" [ui.dev.dashboard.recent] title = "" +[ui.dev.dashboard.recent_changes] +aria = "" +deleted_group = "" +period = "" +series = "" +title = "" +y_axis = "" + +[ui.dev.dashboard.recent_changes.summary] +changed_clients = "" +deleted_clients = "" +latest_change = "" +total_changes = "" + [ui.dev.dashboard.stack] notes = "" subtitle = "" From d0f44de2d1fae6bd541d0346802addad26b5d48f Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 1 Jun 2026 16:26:52 +0900 Subject: [PATCH 4/7] =?UTF-8?q?=EC=B5=9C=EA=B7=BC=20=EB=B3=80=EA=B2=BD=20?= =?UTF-8?q?=EC=95=B1=20=EC=83=81=EC=84=B8=20=EB=8B=A4=EA=B5=AD=EC=96=B4=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/overview/GlobalOverviewPage.tsx | 80 +++++++++++++++++-- .../features/overview/recentClientChanges.ts | 78 ++++++++++-------- devfront/src/locales/en.toml | 4 +- devfront/src/locales/ko.toml | 4 +- locales/en.toml | 4 +- locales/ko.toml | 4 +- 6 files changed, 128 insertions(+), 46 deletions(-) diff --git a/devfront/src/features/overview/GlobalOverviewPage.tsx b/devfront/src/features/overview/GlobalOverviewPage.tsx index f60c244d..4ab57bc1 100644 --- a/devfront/src/features/overview/GlobalOverviewPage.tsx +++ b/devfront/src/features/overview/GlobalOverviewPage.tsx @@ -18,7 +18,6 @@ import { OverviewMetric, OverviewSelectionChips, } from "../../../../common/core/components/overview"; -import { formatAuditDateParts } from "../../../../common/core/audit"; import { DeveloperAccessRequestCard } from "../../components/common/DeveloperAccessRequestCard"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; @@ -81,6 +80,7 @@ type UsageChartPalette = { }; const deletedRecentChangeFilterId = "__deleted_recent_clients__"; +const localeStorageKey = "locale"; type RecentChangePoint = { date: string; @@ -102,6 +102,73 @@ type RecentChangeSeries = { points: RecentChangePoint[]; }; +type AppLocale = "ko" | "en"; + +function resolveAppLocale(): AppLocale { + if (typeof window === "undefined") { + return "ko"; + } + + const stored = window.localStorage.getItem(localeStorageKey); + if (stored === "ko" || stored === "en") { + return stored; + } + + const pathLocale = window.location.pathname.split("/")[1]; + if (pathLocale === "ko" || pathLocale === "en") { + return pathLocale; + } + + return window.navigator.language.toLowerCase().startsWith("ko") + ? "ko" + : "en"; +} + +function formatRecentChangeTimestamp(value: string) { + if (!value) { + return { date: "-", time: "-" }; + } + + const parsed = new Date(value); + if (Number.isNaN(parsed.getTime())) { + return { date: value, time: "-" }; + } + + const locale = resolveAppLocale(); + if (locale === "ko") { + const date = parsed.toISOString().slice(0, 10); + const timeParts = new Intl.DateTimeFormat("ko-KR", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }).formatToParts(parsed); + const hour = timeParts.find((part) => part.type === "hour")?.value ?? "00"; + const minute = + timeParts.find((part) => part.type === "minute")?.value ?? "00"; + const second = + timeParts.find((part) => part.type === "second")?.value ?? "00"; + + return { + date, + time: `${hour}시 ${minute}분 ${second}초`, + }; + } + + return { + date: new Intl.DateTimeFormat("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }).format(parsed), + time: new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + second: "2-digit", + }).format(parsed), + }; +} + const usageChartPalettes: UsageChartPalette[] = [ { bar: "#7dd3fc", line: "#10b981", point: "#059669" }, { bar: "#f9a8d4", line: "#f97316", point: "#ea580c" }, @@ -1068,7 +1135,7 @@ function GlobalOverviewPage() { id: deletedRecentChangeFilterId, label: t( "ui.dev.dashboard.recent_changes.deleted_group", - "삭제된 RP", + "삭제된 앱", ), }, ]; @@ -1399,9 +1466,9 @@ function GlobalOverviewPage() { } label={t( - "ui.dev.dashboard.recent_changes.summary.deleted_clients", - "삭제된 RP 수", - )} + "ui.dev.dashboard.recent_changes.summary.deleted_clients", + "삭제된 앱 수", + )} value={deletedRecentChangedClientCount.toLocaleString()} /> ) : ( visibleRecentClientChanges.map((item) => { - const { date, time } = formatAuditDateParts(item.timestamp); + const { date, time } = + formatRecentChangeTimestamp(item.timestamp); return (
= { - name: "이름", - type: "유형", - status: "상태", - scopes: "스코프", - tenant_access_restricted: "테넌트 접근 제한", - allowed_tenants: "허용 테넌트", - id_token_claims: "커스텀 클레임", - token_endpoint_auth_method: "인증 방식", - jwks_uri: "JWKS URI", - backchannel_logout_uri: "Backchannel Logout URI", - backchannel_logout_session_required: "세션 필수", - headless_login_enabled: "헤드리스 로그인", - headless_token_endpoint_auth_method: "헤드리스 인증 방식", - headless_jwks_uri: "헤드리스 JWKS URI", - redirect_uri_count: "Redirect URI 수", - scope_count: "Scope 수", - relation: "관계", - subject: "대상", -}; - function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } @@ -56,24 +36,43 @@ function isRecord(value: unknown): value is Record { export function getRecentClientActionLabel(action: string) { switch (action) { case "CREATE_CLIENT": - return "클라이언트 생성"; + return t("ui.dev.clients.recent_changes.guide.create", "앱 생성"); case "UPDATE_CLIENT": - return "설정 변경"; + return t("ui.dev.clients.recent_changes.guide.settings", "설정 변경"); case "UPDATE_CLIENT_STATUS": - return "상태 변경"; + return t("ui.dev.clients.recent_changes.guide.status", "상태 변경"); case "ROTATE_SECRET": - return "클라이언트 시크릿 재발급"; + return t( + "ui.dev.clients.recent_changes.guide.secret", + "클라이언트 시크릿 재발급", + ); case "ADD_RELATION": - return "관계 추가"; + return t("ui.dev.clients.relationships.add_title", "관계 추가"); case "REMOVE_RELATION": - return "관계 삭제"; + return t("ui.common.remove", "Remove"); case "DELETE_CLIENT": - return "클라이언트 삭제"; + return t("ui.dev.clients.recent_changes.guide.delete", "앱 삭제"); default: return action; } } +function getRecentClientFieldLabel(key: string) { + switch (key) { + case "relation": + return t("ui.dev.clients.relationships.relation", "관계"); + case "subject": + return t("ui.dev.clients.relationships.subject", "대상"); + case "client_secret": + return t( + "ui.dev.clients.details.credentials.client_secret", + "클라이언트 시크릿", + ); + default: + return key; + } +} + export function buildRecentClientChangeDetails( action: string, details: AuditDetails, @@ -82,17 +81,32 @@ export function buildRecentClientChangeDetails( const after = isRecord(details.after) ? details.after : {}; if (action === "ROTATE_SECRET") { - return [{ label: "클라이언트 시크릿", value: "재발급" }]; + return [ + { + label: getRecentClientFieldLabel("client_secret"), + value: t("msg.dev.clients.secret_rotated", "재발급"), + }, + ]; } if (action === "ADD_RELATION" || action === "REMOVE_RELATION") { const source = action === "ADD_RELATION" ? after : before; return [ ...(source.relation - ? [{ label: "관계", value: formatAuditValue(source.relation) }] + ? [ + { + label: getRecentClientFieldLabel("relation"), + value: formatAuditValue(source.relation), + }, + ] : []), ...(source.subject - ? [{ label: "대상", value: formatAuditValue(source.subject) }] + ? [ + { + label: getRecentClientFieldLabel("subject"), + value: formatAuditValue(source.subject), + }, + ] : []), ]; } @@ -112,7 +126,7 @@ export function buildRecentClientChangeDetails( } } - const label = recentClientFieldLabels[key] ?? key; + const label = getRecentClientFieldLabel(key); if (action === "CREATE_CLIENT") { if (afterValue === undefined) { return null; diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index b41f378e..460fb853 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -1746,7 +1746,7 @@ title = "Quick links" title = "My Applications" [ui.dev.dashboard.recent_changes] -deleted_group = "Deleted RPs" +deleted_group = "Deleted applications" aria = "Recent application changes" period = "Recent change aggregation period" series = "Changes {{changes}} / Actors {{actors}}" @@ -1755,7 +1755,7 @@ y_axis = "Y axis: change count" [ui.dev.dashboard.recent_changes.summary] changed_clients = "Changed apps" -deleted_clients = "Deleted RPs" +deleted_clients = "Deleted applications" latest_change = "Latest change" total_changes = "Recent changes" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index d169bd38..444c776e 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -1745,7 +1745,7 @@ title = "빠른 이동" title = "내 애플리케이션" [ui.dev.dashboard.recent_changes] -deleted_group = "삭제된 RP" +deleted_group = "삭제된 앱" aria = "최근 변경된 앱 현황" period = "최근 변경 집계 단위" series = "변경 {{changes}} / 작업자 {{actors}}" @@ -1754,7 +1754,7 @@ y_axis = "Y축: 변경 수" [ui.dev.dashboard.recent_changes.summary] changed_clients = "변경된 앱 수" -deleted_clients = "삭제된 RP 수" +deleted_clients = "삭제된 앱 수" latest_change = "마지막 변경일" total_changes = "최근 변경 건수" diff --git a/locales/en.toml b/locales/en.toml index 7bd5e3cf..48d6f898 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -2314,7 +2314,7 @@ title = "My Applications" [ui.dev.dashboard.recent_changes] aria = "Recent changed application status" -deleted_group = "Deleted RPs" +deleted_group = "Deleted applications" period = "Recent change aggregation period" series = "Changes {{changes}} / Actors {{actors}}" title = "Recent Changed Apps" @@ -2322,7 +2322,7 @@ y_axis = "Y axis: change count" [ui.dev.dashboard.recent_changes.summary] changed_clients = "Changed applications" -deleted_clients = "Deleted RPs" +deleted_clients = "Deleted applications" latest_change = "Latest change" total_changes = "Recent change count" diff --git a/locales/ko.toml b/locales/ko.toml index 1f99c3d7..cf67c4ea 100644 --- a/locales/ko.toml +++ b/locales/ko.toml @@ -2778,7 +2778,7 @@ title = "내 애플리케이션" [ui.dev.dashboard.recent_changes] aria = "최근 변경된 앱 현황" -deleted_group = "삭제된 RP" +deleted_group = "삭제된 앱" period = "최근 변경 집계 단위" series = "변경 {{changes}} / 작업자 {{actors}}" title = "최근 변경된 앱" @@ -2786,7 +2786,7 @@ y_axis = "Y축: 변경 수" [ui.dev.dashboard.recent_changes.summary] changed_clients = "변경된 앱 수" -deleted_clients = "삭제된 RP 수" +deleted_clients = "삭제된 앱 수" latest_change = "마지막 변경일" total_changes = "최근 변경 건수" From a4d457073a7064770088a82fc874ccf22dd6b274 Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 1 Jun 2026 16:54:58 +0900 Subject: [PATCH 5/7] =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=BB=A4?= =?UTF-8?q?=EB=B2=84=EB=A6=AC=EC=A7=80=20=EB=B3=B4=EA=B0=95=20=EB=B0=8F=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=20=EC=9C=A0=ED=8B=B8=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/LanguageSelector.test.tsx | 77 +++++++ .../src/components/ui/copy-button.test.tsx | 82 +++++++ .../src/features/coverage/commonAudit.test.ts | 72 +++++++ .../src/features/coverage/commonAuth.test.ts | 72 +++++++ .../overview/recentClientChanges.test.ts | 201 ++++++++++++++++++ .../features/overview/recentClientChanges.ts | 8 +- 6 files changed, 511 insertions(+), 1 deletion(-) create mode 100644 devfront/src/components/common/LanguageSelector.test.tsx create mode 100644 devfront/src/components/ui/copy-button.test.tsx create mode 100644 devfront/src/features/coverage/commonAudit.test.ts create mode 100644 devfront/src/features/coverage/commonAuth.test.ts create mode 100644 devfront/src/features/overview/recentClientChanges.test.ts diff --git a/devfront/src/components/common/LanguageSelector.test.tsx b/devfront/src/components/common/LanguageSelector.test.tsx new file mode 100644 index 00000000..302dd23c --- /dev/null +++ b/devfront/src/components/common/LanguageSelector.test.tsx @@ -0,0 +1,77 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../lib/i18n", () => ({ + t: (_key: string, fallback?: string) => fallback ?? _key, +})); + +import LanguageSelector from "./LanguageSelector"; + +const roots: Root[] = []; + +beforeEach(() => { + window.localStorage.clear(); + window.history.replaceState({}, "", "/"); + document.body.innerHTML = ""; +}); + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.restoreAllMocks(); +}); + +function renderSelector() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + + act(() => { + root.render(); + }); + + return container; +} + +describe("LanguageSelector", () => { + it("prefers the locale stored in localStorage", () => { + window.localStorage.setItem("locale", "en"); + + const container = renderSelector(); + const select = container.querySelector("select") as HTMLSelectElement; + + expect(select.value).toBe("en"); + }); + + it("falls back to the path locale when storage is empty", () => { + window.history.replaceState({}, "", "/ko"); + + const container = renderSelector(); + const select = container.querySelector("select") as HTMLSelectElement; + + expect(select.value).toBe("ko"); + }); + + it("saves the selected locale and dispatches a development event", () => { + vi.stubEnv("MODE", "development"); + const dispatchEvent = vi.spyOn(window, "dispatchEvent"); + window.history.replaceState({}, "", "/ko"); + + const container = renderSelector(); + const select = container.querySelector("select") as HTMLSelectElement; + + act(() => { + select.value = "en"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + + expect(window.localStorage.getItem("locale")).toBe("en"); + expect(dispatchEvent).toHaveBeenCalled(); + expect(select.value).toBe("en"); + }); +}); diff --git a/devfront/src/components/ui/copy-button.test.tsx b/devfront/src/components/ui/copy-button.test.tsx new file mode 100644 index 00000000..9ddea7e5 --- /dev/null +++ b/devfront/src/components/ui/copy-button.test.tsx @@ -0,0 +1,82 @@ +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { CopyButton } from "./copy-button"; + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.restoreAllMocks(); + delete (navigator as Navigator & { clipboard?: unknown }).clipboard; + Object.defineProperty(window, "isSecureContext", { + value: false, + configurable: true, + }); + document.body.innerHTML = ""; +}); + +function renderCopyButton(value: string, onCopy = vi.fn()) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + + act(() => { + root.render(); + }); + + return { container, onCopy }; +} + +describe("CopyButton", () => { + it("copies with the clipboard API when secure context is available", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + Object.defineProperty(window, "isSecureContext", { + value: true, + configurable: true, + }); + + const { container, onCopy } = renderCopyButton("client-secret"); + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(writeText).toHaveBeenCalledWith("client-secret"); + expect(onCopy).toHaveBeenCalledTimes(1); + }); + + it("falls back to execCommand when clipboard API is unavailable", async () => { + const execCommand = vi.fn(() => true); + Object.defineProperty(document, "execCommand", { + value: execCommand, + configurable: true, + }); + Object.defineProperty(window, "isSecureContext", { + value: false, + configurable: true, + }); + + const { container, onCopy } = renderCopyButton("client-secret"); + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(onCopy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/devfront/src/features/coverage/commonAudit.test.ts b/devfront/src/features/coverage/commonAudit.test.ts new file mode 100644 index 00000000..43add445 --- /dev/null +++ b/devfront/src/features/coverage/commonAudit.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + formatAuditDateParts, + formatAuditValue, + parseAuditDetails, + resolveAuditAction, + resolveAuditActor, + resolveAuditTarget, +} from "../../../../common/core/audit"; + +describe("common audit helpers", () => { + it("parses audit details and falls back on invalid payloads", () => { + expect(parseAuditDetails()).toEqual({}); + expect(parseAuditDetails("not-json")).toEqual({}); + expect(parseAuditDetails('{"action":"ADD_RELATION"}')).toEqual({ + action: "ADD_RELATION", + }); + }); + + it("formats audit values and dates", () => { + const circular: Record = {}; + circular.self = circular; + + expect(formatAuditValue(null)).toBe("-"); + expect(formatAuditValue("hello")).toBe("hello"); + expect(formatAuditValue({ a: 1 })).toBe('{"a":1}'); + expect(formatAuditValue(circular)).toBe("[object Object]"); + + expect(formatAuditDateParts("")).toEqual({ date: "-", time: "-" }); + expect(formatAuditDateParts("invalid")).toEqual({ + date: "invalid", + time: "-", + }); + + const parsed = formatAuditDateParts("2026-05-27T07:43:39.000Z"); + expect(parsed.date).toBe("2026-05-27"); + expect(parsed.time).not.toBe("-"); + }); + + it("resolves audit actor, action, and target consistently", () => { + expect( + resolveAuditActor( + { user_id: "actor-1" }, + { actor_id: "actor-2" }, + ), + ).toBe("actor-1"); + expect( + resolveAuditActor({ user_id: "" }, { actor_id: "actor-2" }), + ).toBe("actor-2"); + expect(resolveAuditActor({ user_id: "" }, {})).toBe("-"); + + expect( + resolveAuditAction( + { event_type: "UPDATE_CLIENT" }, + { action: "ADD_RELATION" }, + ), + ).toBe("ADD_RELATION"); + expect( + resolveAuditAction( + { event_type: "UPDATE_CLIENT" }, + { method: "POST", path: "/dev/clients" }, + ), + ).toBe("POST /dev/clients"); + expect(resolveAuditAction({ event_type: "UPDATE_CLIENT" }, {})).toBe( + "UPDATE_CLIENT", + ); + + expect(resolveAuditTarget({ target: "target-1" })).toBe("target-1"); + expect(resolveAuditTarget({ target_id: "target-2" })).toBe("target-2"); + expect(resolveAuditTarget({})).toBe("-"); + }); +}); diff --git a/devfront/src/features/coverage/commonAuth.test.ts b/devfront/src/features/coverage/commonAuth.test.ts new file mode 100644 index 00000000..5618e53d --- /dev/null +++ b/devfront/src/features/coverage/commonAuth.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_OIDC_REDIRECT_PATH, + DEFAULT_OIDC_SCOPE, + buildCommonOidcRuntimeConfig, + buildCommonUserManagerSettings, + shouldStartLoginRedirect, +} from "../../../../common/core/auth"; + +describe("common auth helpers", () => { + it("builds the runtime OIDC config with sensible defaults", () => { + const config = buildCommonOidcRuntimeConfig({ + authority: "https://issuer.example.com", + clientId: "client-1", + userStore: { kind: "store" }, + }); + + expect(config).toEqual({ + authority: "https://issuer.example.com", + client_id: "client-1", + redirect_uri: `${window.location.origin}${DEFAULT_OIDC_REDIRECT_PATH}`, + response_type: "code", + scope: DEFAULT_OIDC_SCOPE, + post_logout_redirect_uri: window.location.origin, + popup_redirect_uri: `${window.location.origin}${DEFAULT_OIDC_REDIRECT_PATH}`, + userStore: { kind: "store" }, + automaticSilentRenew: false, + }); + }); + + it("copies user manager config and fills missing string fields", () => { + expect( + buildCommonUserManagerSettings({ + authority: "https://issuer.example.com", + }), + ).toEqual({ + authority: "https://issuer.example.com", + client_id: "", + redirect_uri: "", + }); + }); + + it("decides when to start login redirects", () => { + expect( + shouldStartLoginRedirect({ + pathname: "/clients", + isRedirecting: false, + }), + ).toBe(true); + + expect( + shouldStartLoginRedirect({ + pathname: "/login", + isRedirecting: false, + }), + ).toBe(false); + + expect( + shouldStartLoginRedirect({ + pathname: `${DEFAULT_OIDC_REDIRECT_PATH}/callback`, + isRedirecting: false, + }), + ).toBe(false); + + expect( + shouldStartLoginRedirect({ + pathname: "/clients", + isRedirecting: true, + }), + ).toBe(false); + }); +}); diff --git a/devfront/src/features/overview/recentClientChanges.test.ts b/devfront/src/features/overview/recentClientChanges.test.ts new file mode 100644 index 00000000..8163f10e --- /dev/null +++ b/devfront/src/features/overview/recentClientChanges.test.ts @@ -0,0 +1,201 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { ClientSummary, DevAuditLog } from "../../lib/devApi"; +import { + buildRecentClientChangeDetails, + buildRecentClientChanges, + getRecentClientActionLabel, +} from "./recentClientChanges"; + +function makeClient(id: string, name = id): ClientSummary { + return { + id, + name, + type: "private", + status: "active", + createdAt: "2026-05-27T00:00:00.000Z", + redirectUris: [], + scopes: [], + }; +} + +function makeAuditLog( + eventId: string, + timestamp: string, + action: string, + targetId: string, + details: Record, +): DevAuditLog { + return { + event_id: eventId, + timestamp, + user_id: "actor-1", + event_type: "AUDIT", + status: "success", + ip_address: "127.0.0.1", + user_agent: "vitest", + details: JSON.stringify({ + action, + target_id: targetId, + ...details, + }), + }; +} + +describe("recent client changes", () => { + beforeEach(() => { + window.localStorage.clear(); + window.history.replaceState({}, "", "/"); + }); + + function mockLocale(locale: "ko" | "en") { + window.localStorage.clear(); + window.history.replaceState({}, "", `/${locale}`); + } + + it("translates action labels and relation details by locale", () => { + mockLocale("en"); + + expect(getRecentClientActionLabel("CREATE_CLIENT")).toBe("App creation"); + expect(getRecentClientActionLabel("UPDATE_CLIENT")).toBe("Settings changes"); + expect(getRecentClientActionLabel("UPDATE_CLIENT_STATUS")).toBe( + "Status changes", + ); + expect(getRecentClientActionLabel("ROTATE_SECRET")).toBe( + "Client secret rotation", + ); + expect(getRecentClientActionLabel("ADD_RELATION")).toBe("Add Relationship"); + expect(getRecentClientActionLabel("REMOVE_RELATION")).toBe("Remove"); + expect(getRecentClientActionLabel("DELETE_CLIENT")).toBe("App deletion"); + expect(getRecentClientActionLabel("OTHER_ACTION")).toBe("OTHER_ACTION"); + + expect( + buildRecentClientChangeDetails("ROTATE_SECRET", { + after: {}, + }), + ).toEqual([{ label: "Client Secret", value: "Secret Rotated" }]); + + expect( + buildRecentClientChangeDetails("ADD_RELATION", { + after: { + relation: "admins", + subject: "User:1", + }, + }), + ).toEqual([ + { label: "Relation", value: "admins" }, + { label: "Subject", value: "User:1" }, + ]); + }); + + it("builds recent client changes with sorting, filtering, and detail slicing", () => { + mockLocale("ko"); + + const clients = [makeClient("client-a", "Alpha"), makeClient("client-b", "")]; + const auditLogs = [ + makeAuditLog("evt-1", "2026-05-27T07:00:00.000Z", "CREATE_CLIENT", "client-a", { + after: { name: "Alpha", type: "private", status: "active" }, + }), + makeAuditLog("evt-2", "2026-05-27T08:00:00.000Z", "UPDATE_CLIENT", "client-a", { + before: { + name: "Alpha old", + status: "inactive", + sameField: "same", + oldField: "old-value", + }, + after: { + name: "Alpha new", + status: "active", + sameField: "same", + newField: "new-value", + }, + }), + makeAuditLog("evt-3", "2026-05-27T09:00:00.000Z", "UPDATE_CLIENT_STATUS", "client-a", { + before: { status: "inactive" }, + after: { status: "active" }, + }), + makeAuditLog("evt-4", "2026-05-27T10:00:00.000Z", "ADD_RELATION", "client-b", { + after: { + relation: "audit_viewer", + subject: "User:89692983-f512-4d96-845d-ac6123d08b95", + }, + }), + makeAuditLog("evt-5", "2026-05-27T11:00:00.000Z", "REMOVE_RELATION", "client-b", { + before: { + relation: "admins", + subject: "User:89692983-f512-4d96-845d-ac6123d08b95", + }, + }), + makeAuditLog("evt-6", "2026-05-27T12:00:00.000Z", "ROTATE_SECRET", "client-a", { + after: {}, + }), + makeAuditLog("evt-7", "2026-05-27T13:00:00.000Z", "DELETE_CLIENT", "client-a", { + before: { + name: "Alpha", + status: "inactive", + }, + }), + makeAuditLog("evt-8", "2026-05-27T14:00:00.000Z", "UNSUPPORTED_ACTION", "client-a", { + after: { name: "Ignored" }, + }), + ]; + + const changes = buildRecentClientChanges( + auditLogs, + clients, + ); + + expect(changes).toHaveLength(7); + expect(changes[0]).toMatchObject({ + eventId: "evt-7", + clientName: "Alpha", + actionLabel: "앱 삭제", + }); + expect(changes[1]).toMatchObject({ + eventId: "evt-6", + clientName: "Alpha", + actionLabel: "클라이언트 시크릿 재발급", + detailLabels: [ + { + label: "클라이언트 시크릿", + value: "Client Secret이 재발급되었습니다.", + }, + ], + }); + expect(changes[2]).toMatchObject({ + eventId: "evt-5", + clientName: "client-b", + actionLabel: "제외", + detailLabels: [ + { label: "관계", value: "admins" }, + { + label: "주체", + value: "User:89692983-f512-4d96-845d-ac6123d08b95", + }, + ], + }); + expect(changes[4]).toMatchObject({ + eventId: "evt-3", + actionLabel: "상태 변경", + clientName: "Alpha", + detailLabels: [{ value: "inactive → active" }], + }); + expect(changes[5]).toMatchObject({ + eventId: "evt-2", + actionLabel: "설정 변경", + detailLabels: [ + { label: "애플리케이션", value: "Alpha old → Alpha new" }, + { label: "상태", value: "inactive → active" }, + { label: "oldField", value: "old-value" }, + ], + }); + expect(changes[6]).toMatchObject({ + eventId: "evt-1", + actionLabel: "앱 생성", + detailLabels: [ + { label: "애플리케이션", value: "Alpha" }, + { label: "유형", value: "private" }, + { label: "상태", value: "active" }, + ], + }); + }); +}); diff --git a/devfront/src/features/overview/recentClientChanges.ts b/devfront/src/features/overview/recentClientChanges.ts index 6284f58d..534cb86a 100644 --- a/devfront/src/features/overview/recentClientChanges.ts +++ b/devfront/src/features/overview/recentClientChanges.ts @@ -59,6 +59,12 @@ export function getRecentClientActionLabel(action: string) { function getRecentClientFieldLabel(key: string) { switch (key) { + case "name": + return t("ui.dev.clients.table.application", "Application"); + case "type": + return t("ui.dev.clients.table.type", "Type"); + case "status": + return t("ui.dev.clients.table.status", "Status"); case "relation": return t("ui.dev.clients.relationships.relation", "관계"); case "subject": @@ -84,7 +90,7 @@ export function buildRecentClientChangeDetails( return [ { label: getRecentClientFieldLabel("client_secret"), - value: t("msg.dev.clients.secret_rotated", "재발급"), + value: t("msg.dev.clients.details.secret_rotated", "재발급"), }, ]; } From 38605ac8a32cea209354119b056fe8713675f87d Mon Sep 17 00:00:00 2001 From: kyy Date: Mon, 1 Jun 2026 17:37:13 +0900 Subject: [PATCH 6/7] =?UTF-8?q?devfront=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BB=A4=EB=B2=84=EB=A6=AC=EC=A7=80=20=EC=B6=94=EA=B0=80=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../components/audit/AuditLogTable.test.tsx | 148 ++++++++++ .../src/components/ui/copy-button.test.tsx | 25 ++ .../src/features/audit/AuditLogsPage.test.tsx | 208 +++++++++++++ devfront/src/features/auth/authApi.test.ts | 19 ++ .../src/features/clients/ClientsPage.test.tsx | 276 ++++++++++++++++++ .../clients/components/ClientLogo.test.tsx | 65 +++++ .../routes/ClientFederationPage.test.tsx | 181 ++++++++++++ .../features/coverage/AuditLogTable.test.tsx | 123 ++++++++ .../src/features/coverage/pageSmoke.test.tsx | 89 ++++++ .../DeveloperRequestPage.test.tsx | 186 ++++++++++++ devfront/src/lib/apiClient.test.ts | 84 ++++++ devfront/src/lib/oidcStorage.test.ts | 76 +++++ devfront/src/lib/role.test.ts | 33 +++ 13 files changed, 1513 insertions(+) create mode 100644 common/core/components/audit/AuditLogTable.test.tsx create mode 100644 devfront/src/features/audit/AuditLogsPage.test.tsx create mode 100644 devfront/src/features/auth/authApi.test.ts create mode 100644 devfront/src/features/clients/ClientsPage.test.tsx create mode 100644 devfront/src/features/clients/components/ClientLogo.test.tsx create mode 100644 devfront/src/features/clients/routes/ClientFederationPage.test.tsx create mode 100644 devfront/src/features/coverage/AuditLogTable.test.tsx create mode 100644 devfront/src/features/developer-request/DeveloperRequestPage.test.tsx create mode 100644 devfront/src/lib/apiClient.test.ts create mode 100644 devfront/src/lib/oidcStorage.test.ts create mode 100644 devfront/src/lib/role.test.ts diff --git a/common/core/components/audit/AuditLogTable.test.tsx b/common/core/components/audit/AuditLogTable.test.tsx new file mode 100644 index 00000000..d5e6e368 --- /dev/null +++ b/common/core/components/audit/AuditLogTable.test.tsx @@ -0,0 +1,148 @@ +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CommonAuditLog } from "../../audit"; +import { AuditLogTable } from "./AuditLogTable"; + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.restoreAllMocks(); + document.body.innerHTML = ""; +}); + +function renderTable(props: Parameters[0]) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + + act(() => { + root.render(); + }); + + return { container }; +} + +const logs: CommonAuditLog[] = [ + { + event_id: "evt-1", + timestamp: "2026-05-28T06:07:18.000Z", + user_id: "user-1", + event_type: "CLIENT_UPDATE", + status: "success", + ip_address: "127.0.0.1", + user_agent: "Vitest", + device_id: "device-1", + details: JSON.stringify({ + request_id: "req-1", + method: "POST", + path: "/api/v1/clients", + latency_ms: 120, + tenant_id: "tenant-1", + actor_id: "user-1", + action: "업데이트", + target_id: "client-a", + before: { status: "inactive" }, + after: { status: "active" }, + }), + }, +]; + +describe("AuditLogTable", () => { + it("renders loading and empty states", () => { + const { container: loadingContainer } = renderTable({ + logs: [], + t: (key, fallback) => fallback ?? key, + loading: true, + hasNextPage: false, + isFetchingNextPage: false, + onLoadMore: vi.fn(), + }); + + expect(loadingContainer.textContent).toContain("Loading audit logs..."); + + const { container: emptyContainer } = renderTable({ + logs: [], + t: (key, fallback) => fallback ?? key, + loading: false, + hasNextPage: false, + isFetchingNextPage: false, + onLoadMore: vi.fn(), + }); + + expect(emptyContainer.textContent).toContain("No audit logs found."); + expect(emptyContainer.textContent).toContain("End of audit feed"); + }); + + it("renders rows, expands details, copies fields, and loads more", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + const onLoadMore = vi.fn(); + const { container } = renderTable({ + logs, + t: (key, fallback, vars) => { + let text = fallback ?? key; + for (const [name, value] of Object.entries(vars ?? {})) { + text = text.replaceAll(`{{${name}}}`, String(value)); + } + return text; + }, + loading: false, + hasNextPage: true, + isFetchingNextPage: false, + onLoadMore, + }); + + expect(container.textContent).toContain("user-1"); + expect(container.textContent).toContain("업데이트"); + expect(container.textContent).toContain("client-a"); + expect(container.textContent).toContain("success"); + + const buttons = Array.from(container.querySelectorAll("button")); + const actorCopyButton = buttons.find( + (button) => button.getAttribute("aria-label") === "Copy User ID", + ); + const targetCopyButton = buttons.find( + (button) => button.getAttribute("aria-label") === "Copy Client ID", + ); + const expandButton = buttons.find( + (button) => !button.getAttribute("aria-label") && !button.textContent, + ); + const loadMoreButton = buttons.find( + (button) => button.textContent === "Load more", + ); + + expect(actorCopyButton).toBeTruthy(); + expect(targetCopyButton).toBeTruthy(); + expect(expandButton).toBeTruthy(); + expect(loadMoreButton).toBeTruthy(); + + await act(async () => { + actorCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + targetCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(writeText).toHaveBeenCalledWith("user-1"); + expect(writeText).toHaveBeenCalledWith("client-a"); + expect(container.textContent).toContain("Request ID · req-1"); + expect(container.textContent).toContain("Actor"); + expect(container.textContent).toContain("Result"); + + await act(async () => { + loadMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/devfront/src/components/ui/copy-button.test.tsx b/devfront/src/components/ui/copy-button.test.tsx index 9ddea7e5..17fe1514 100644 --- a/devfront/src/components/ui/copy-button.test.tsx +++ b/devfront/src/components/ui/copy-button.test.tsx @@ -79,4 +79,29 @@ describe("CopyButton", () => { expect(execCommand).toHaveBeenCalledWith("copy"); expect(onCopy).toHaveBeenCalledTimes(1); }); + + it("keeps running when the fallback copy flow fails", async () => { + const execCommand = vi.fn(() => false); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + Object.defineProperty(document, "execCommand", { + value: execCommand, + configurable: true, + }); + Object.defineProperty(window, "isSecureContext", { + value: false, + configurable: true, + }); + + const { container, onCopy } = renderCopyButton("client-secret"); + const button = container.querySelector("button"); + expect(button).not.toBeNull(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(execCommand).toHaveBeenCalledWith("copy"); + expect(onCopy).not.toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalled(); + }); }); diff --git a/devfront/src/features/audit/AuditLogsPage.test.tsx b/devfront/src/features/audit/AuditLogsPage.test.tsx new file mode 100644 index 00000000..8e7f3c70 --- /dev/null +++ b/devfront/src/features/audit/AuditLogsPage.test.tsx @@ -0,0 +1,208 @@ +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import AuditLogsPage from "./AuditLogsPage"; + +const navigateMock = vi.fn(); +const fetchMeMock = vi.fn(); +const fetchDevAuditLogsMock = vi.fn(); +let gateState = { + hasDeveloperAccess: true, + isDeveloperRequestPending: false, + canRequestDeveloperAccess: false, + isLoadingDeveloperAccessGate: false, +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => ({ + isAuthenticated: true, + isLoading: false, + user: { + access_token: "access-token", + profile: { + role: "super_admin", + tenant_id: "tenant-1", + }, + }, + }), +})); + +vi.mock("react-router-dom", () => ({ + useNavigate: () => navigateMock, +})); + +vi.mock("../developer-access/developerAccessGate", () => ({ + useDeveloperAccessGate: () => gateState, +})); + +vi.mock("../../lib/devApi", () => ({ + fetchDevAuditLogs: (...args: unknown[]) => fetchDevAuditLogsMock(...args), +})); + +vi.mock("../auth/authApi", () => ({ + fetchMe: (...args: unknown[]) => fetchMeMock(...args), +})); + +vi.mock("../../../../common/core/components/audit", () => ({ + AuditLogTable: ({ + logs, + onLoadMore, + }: { + logs: Array<{ event_id: string }>; + onLoadMore: () => void; + }) => ( +
+
table:{logs.length}
+ +
+ ), +})); + +vi.mock("../../components/common/ForbiddenMessage", () => ({ + ForbiddenMessage: ({ resourceToken }: { resourceToken: string }) => ( +
Forbidden:{resourceToken}
+ ), +})); + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +beforeEach(() => { + gateState = { + hasDeveloperAccess: true, + isDeveloperRequestPending: false, + canRequestDeveloperAccess: false, + isLoadingDeveloperAccessGate: false, + }; + fetchMeMock.mockResolvedValue({ + id: "user-1", + role: "super_admin", + }); + fetchDevAuditLogsMock.mockResolvedValue({ + items: [ + { + event_id: "evt-1", + timestamp: "2026-05-28T06:07:18.000Z", + user_id: "user-1", + event_type: "CLIENT_UPDATE", + status: "success", + ip_address: "127.0.0.1", + user_agent: "Vitest", + details: JSON.stringify({ + action: "업데이트", + target_id: "client-a", + }), + }, + ], + limit: 50, + }); +}); + +async function renderPage() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return container; +} + +describe("AuditLogsPage", () => { + it("shows the loading gate state", async () => { + gateState = { + hasDeveloperAccess: false, + isDeveloperRequestPending: false, + canRequestDeveloperAccess: false, + isLoadingDeveloperAccessGate: true, + }; + + const container = await renderPage(); + expect(container.textContent).toContain("로딩 중..."); + }); + + it("renders the access request card when access is denied", async () => { + gateState = { + hasDeveloperAccess: false, + isDeveloperRequestPending: false, + canRequestDeveloperAccess: true, + isLoadingDeveloperAccessGate: false, + }; + + const container = await renderPage(); + expect(container.textContent).toContain("감사 로그는 개발자 권한이 있어야 볼 수 있습니다."); + + const button = Array.from(container.querySelectorAll("button")).find( + (item) => item.textContent?.includes("개발자 권한 신청"), + ); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(navigateMock).toHaveBeenCalledWith("/developer-requests"); + }); + + it("exports the fetched logs as CSV", async () => { + const createObjectURL = vi + .spyOn(URL, "createObjectURL") + .mockReturnValue("blob:csv"); + const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockReturnValue(); + const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + + const container = await renderPage(); + expect(container.textContent).toContain("table:1"); + + const button = Array.from(container.querySelectorAll("button")).find( + (item) => item.textContent === "CSV 내보내기", + ); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(createObjectURL).toHaveBeenCalled(); + expect(clickSpy).toHaveBeenCalled(); + expect(revokeObjectURL).toHaveBeenCalledWith("blob:csv"); + }); + + it("renders the forbidden state on 403 errors", async () => { + fetchDevAuditLogsMock.mockRejectedValueOnce({ + response: { status: 403 }, + message: "Forbidden", + }); + + const container = await renderPage(); + expect(container.textContent).toContain("Forbidden:audit"); + }); +}); diff --git a/devfront/src/features/auth/authApi.test.ts b/devfront/src/features/auth/authApi.test.ts new file mode 100644 index 00000000..d5efc516 --- /dev/null +++ b/devfront/src/features/auth/authApi.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchMe } from "./authApi"; + +const getMock = vi.fn(); + +vi.mock("../../lib/apiClient", () => ({ + default: { + get: (...args: unknown[]) => getMock(...args), + }, +})); + +describe("fetchMe", () => { + it("returns the response payload from the API client", async () => { + getMock.mockResolvedValueOnce({ data: { id: "user-1", name: "Dev" } }); + + await expect(fetchMe()).resolves.toEqual({ id: "user-1", name: "Dev" }); + expect(getMock).toHaveBeenCalledWith("/user/me"); + }); +}); diff --git a/devfront/src/features/clients/ClientsPage.test.tsx b/devfront/src/features/clients/ClientsPage.test.tsx new file mode 100644 index 00000000..eba48ea8 --- /dev/null +++ b/devfront/src/features/clients/ClientsPage.test.tsx @@ -0,0 +1,276 @@ +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import ClientsPage from "./ClientsPage"; + +const navigateMock = vi.fn(); +const fetchClientsMock = vi.fn(); +const fetchMeMock = vi.fn(); +const fetchDeveloperRequestStatusMock = vi.fn(); +const fetchMyTenantsMock = vi.fn(); +const requestDeveloperAccessMock = vi.fn(); + +let authState = { + user: { + access_token: "access-token", + profile: { + role: "super_admin", + tenant_id: "tenant-1", + companyCode: "HANMAC", + name: "Dev Admin", + email: "dev@example.com", + phone: "010-0000-0000", + }, + }, +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => authState, +})); + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual( + "react-router-dom", + ); + return { + ...actual, + useNavigate: () => navigateMock, + }; +}); + +vi.mock("../../lib/devApi", () => ({ + fetchClients: () => fetchClientsMock(), + fetchMe: () => fetchMeMock(), + fetchDeveloperRequestStatus: () => fetchDeveloperRequestStatusMock(), + fetchMyTenants: () => fetchMyTenantsMock(), + requestDeveloperAccess: (...args: unknown[]) => + requestDeveloperAccessMock(...args), +})); + +vi.mock("../../lib/i18n", () => ({ + t: (key: string, fallback?: string, vars?: Record) => { + let text = fallback ?? key; + for (const [name, value] of Object.entries(vars ?? {})) { + text = text.replaceAll(`{{${name}}}`, String(value)); + } + return text; + }, +})); + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +beforeEach(() => { + authState = { + user: { + access_token: "access-token", + profile: { + role: "super_admin", + tenant_id: "tenant-1", + companyCode: "HANMAC", + name: "Dev Admin", + email: "dev@example.com", + phone: "010-0000-0000", + }, + }, + }; + + fetchClientsMock.mockResolvedValue({ + items: [], + limit: 100, + offset: 0, + }); + fetchMeMock.mockResolvedValue({ + role: "super_admin", + name: "Dev Admin", + email: "dev@example.com", + phone: "010-0000-0000", + }); + fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" }); + fetchMyTenantsMock.mockResolvedValue([ + { + id: "tenant-1", + name: "Hanmac", + slug: "hanmac", + type: "COMPANY", + parentId: null, + description: "", + status: "active", + memberCount: 10, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + ]); +}); + +function makeClients(count: number) { + return Array.from({ length: count }, (_, index) => ({ + id: `client-${index + 1}`, + name: `App ${index + 1}`, + type: index % 2 === 0 ? "private" : "pkce", + status: index % 2 === 0 ? "active" : "inactive", + createdAt: `2026-05-${String(index + 1).padStart(2, "0")}T00:00:00Z`, + redirectUris: [], + scopes: [], + metadata: {}, + })); +} + +async function setInputValue(input: HTMLInputElement, value: string) { + const descriptor = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + ); + descriptor?.set?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function renderPage() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + + + , + ); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return container; +} + +describe("ClientsPage", () => { + it("expands the list and applies search filters", async () => { + fetchClientsMock.mockResolvedValue({ + items: makeClients(6), + limit: 100, + offset: 0, + }); + + const container = await renderPage(); + expect(container.textContent).toContain("총 6개의 애플리케이션이 등록되어 있습니다."); + expect(container.textContent).toContain("App 6"); + expect(container.textContent).toContain("App 2"); + expect(container.textContent).not.toContain("App 1"); + + const moreButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "더보기", + ); + expect(moreButton).toBeTruthy(); + + await act(async () => { + moreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("App 6"); + expect(container.textContent).toContain("접기"); + + const advancedButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Advanced Filters", + ); + expect(advancedButton).toBeTruthy(); + + await act(async () => { + advancedButton?.dispatchEvent( + new MouseEvent("click", { bubbles: true }), + ); + }); + + const searchInput = Array.from( + container.querySelectorAll("input"), + ).find((input) => + input.getAttribute("placeholder")?.includes("클라이언트 이름/ID로 검색"), + ) as HTMLInputElement | undefined; + expect(searchInput).toBeTruthy(); + + await act(async () => { + await setInputValue(searchInput!, "missing-client"); + }); + + expect(container.textContent).toContain("조건에 맞는 연동 앱이 없습니다."); + + const resetButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "초기화", + ); + expect(resetButton).toBeTruthy(); + + await act(async () => { + resetButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + await act(async () => { + await setInputValue(searchInput!, ""); + }); + + expect(container.textContent).toContain("App 1"); + }); + + it("navigates to the developer request page from empty states", async () => { + authState = { + user: { + access_token: "access-token", + profile: { + role: "user", + tenant_id: "tenant-1", + companyCode: "HANMAC", + name: "Requester", + email: "requester@example.com", + phone: "010-1234-5678", + }, + }, + }; + fetchClientsMock.mockResolvedValue({ + items: [], + limit: 100, + offset: 0, + }); + fetchDeveloperRequestStatusMock.mockResolvedValue({ status: "none" }); + fetchMeMock.mockResolvedValue({ + role: "user", + name: "Requester", + email: "requester@example.com", + phone: "010-1234-5678", + }); + + const container = await renderPage(); + expect(container.textContent).toContain("개발자 등록 신청하기"); + + const requestButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "개발자 등록 신청하기", + ); + expect(requestButton).toBeTruthy(); + + await act(async () => { + requestButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(navigateMock).toHaveBeenCalledWith("/developer-requests"); + }); +}); diff --git a/devfront/src/features/clients/components/ClientLogo.test.tsx b/devfront/src/features/clients/components/ClientLogo.test.tsx new file mode 100644 index 00000000..353c9f27 --- /dev/null +++ b/devfront/src/features/clients/components/ClientLogo.test.tsx @@ -0,0 +1,65 @@ +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import type { ReactNode, ComponentProps } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ClientLogo } from "./ClientLogo"; + +vi.mock("../../../components/ui/avatar", () => ({ + Avatar: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), + AvatarImage: (props: ComponentProps<"img">) => , + AvatarFallback: ({ children }: { children: ReactNode }) => ( +
{children}
+ ), +})); + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + document.body.innerHTML = ""; +}); + +function renderLogo(client: Parameters[0]["client"]) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + + act(() => { + root.render(); + }); + + return container; +} + +describe("ClientLogo", () => { + it("renders the fallback icon when no logo url exists", () => { + const container = renderLogo({ + name: "", + type: "private", + metadata: {}, + }); + + expect(container.querySelectorAll("svg").length).toBeGreaterThan(0); + }); + + it("uses the logo image when a trimmed url is provided", () => { + const container = renderLogo({ + name: "Gitea", + type: "pkce", + metadata: { logo_url: " https://example.com/logo.png " }, + }); + + const image = container.querySelector("img"); + expect(image).not.toBeNull(); + expect(container.querySelector("[data-testid='fallback']")).not.toBeNull(); + expect(image?.getAttribute("alt")).toContain("Gitea"); + expect(image?.getAttribute("src")).toBe("https://example.com/logo.png"); + }); +}); diff --git a/devfront/src/features/clients/routes/ClientFederationPage.test.tsx b/devfront/src/features/clients/routes/ClientFederationPage.test.tsx new file mode 100644 index 00000000..d1dace77 --- /dev/null +++ b/devfront/src/features/clients/routes/ClientFederationPage.test.tsx @@ -0,0 +1,181 @@ +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { ClientFederationPage } from "./ClientFederationPage"; + +let params: { id?: string } = { id: "client-a" }; +const listIdpConfigsMock = vi.fn(); +const createIdpConfigMock = vi.fn(); + +vi.mock("react-router-dom", async () => { + const actual = await vi.importActual( + "react-router-dom", + ); + return { + ...actual, + useParams: () => params, + }; +}); + +vi.mock("../../../lib/devApi", () => ({ + listIdpConfigsForClient: (clientId: string) => + listIdpConfigsMock(clientId), + createIdpConfigForClient: (payload: unknown) => + createIdpConfigMock(payload), +})); + +vi.mock("../../../lib/i18n", () => ({ + t: (key: string, fallback?: string, vars?: Record) => { + let text = fallback ?? key; + for (const [name, value] of Object.entries(vars ?? {})) { + text = text.replaceAll(`{{${name}}}`, String(value)); + } + return text; + }, +})); + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +beforeEach(() => { + params = { id: "client-a" }; + listIdpConfigsMock.mockResolvedValue([ + { + id: "idp-1", + client_id: "client-a", + provider_type: "oidc", + display_name: "Workspace OIDC", + status: "active", + issuer_url: "https://accounts.example", + oidc_client_id: "oidc-client", + scopes: "openid email profile", + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + ]); + createIdpConfigMock.mockResolvedValue({ + id: "idp-2", + client_id: "client-a", + provider_type: "oidc", + display_name: "New Provider", + status: "active", + createdAt: "2026-05-02T00:00:00Z", + updatedAt: "2026-05-02T00:00:00Z", + }); +}); + +async function setInputValue(input: HTMLInputElement, value: string) { + const descriptor = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + ); + descriptor?.set?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function renderPage() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return container; +} + +describe("ClientFederationPage", () => { + it("shows a missing client id message when no route param exists", async () => { + params = {}; + const container = await renderPage(); + expect(container.textContent).toContain("Client ID is missing"); + }); + + it("opens the create modal and submits a new IdP config", async () => { + const container = await renderPage(); + expect(container.textContent).toContain("Workspace OIDC"); + + const addButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Add Provider", + ); + expect(addButton).toBeTruthy(); + + await act(async () => { + addButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("Add Identity Provider"); + + const displayName = container.querySelector( + 'input[name="display_name"]', + ) as HTMLInputElement | null; + const issuerUrl = container.querySelector( + 'input[name="issuer_url"]', + ) as HTMLInputElement | null; + const clientId = container.querySelector( + 'input[name="oidc_client_id"]', + ) as HTMLInputElement | null; + const clientSecret = container.querySelector( + 'input[name="oidc_client_secret"]', + ) as HTMLInputElement | null; + + expect(displayName).toBeTruthy(); + expect(issuerUrl).toBeTruthy(); + expect(clientId).toBeTruthy(); + expect(clientSecret).toBeTruthy(); + + await act(async () => { + await setInputValue(displayName!, "New Provider"); + await setInputValue(issuerUrl!, "https://login.example"); + await setInputValue(clientId!, "client-oidc"); + await setInputValue(clientSecret!, "secret-value"); + }); + + const submitButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "Save Configuration", + ); + expect(submitButton).toBeTruthy(); + + await act(async () => { + submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(createIdpConfigMock).toHaveBeenCalledWith( + expect.objectContaining({ + client_id: "client-a", + display_name: "New Provider", + issuer_url: "https://login.example", + oidc_client_id: "client-oidc", + oidc_client_secret: "secret-value", + }), + ); + }); +}); diff --git a/devfront/src/features/coverage/AuditLogTable.test.tsx b/devfront/src/features/coverage/AuditLogTable.test.tsx new file mode 100644 index 00000000..92397481 --- /dev/null +++ b/devfront/src/features/coverage/AuditLogTable.test.tsx @@ -0,0 +1,123 @@ +import { act } from "../../../../common/node_modules/react-dom/test-utils"; +import { createRoot, type Root } from "../../../../common/node_modules/react-dom/client"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { CommonAuditLog } from "../../../../common/core/audit"; +import { AuditLogTable } from "../../../../common/core/components/audit"; + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.restoreAllMocks(); + document.body.innerHTML = ""; +}); + +function renderTable(props: Parameters[0]) { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + + act(() => { + root.render(); + }); + + return { container }; +} + +const logs: CommonAuditLog[] = [ + { + event_id: "evt-1", + timestamp: "2026-05-28T06:07:18.000Z", + user_id: "user-1", + event_type: "CLIENT_UPDATE", + status: "success", + ip_address: "127.0.0.1", + user_agent: "Vitest", + device_id: "device-1", + details: JSON.stringify({ + request_id: "req-1", + method: "POST", + path: "/api/v1/clients", + latency_ms: 120, + tenant_id: "tenant-1", + actor_id: "user-1", + action: "업데이트", + target_id: "client-a", + before: { status: "inactive" }, + after: { status: "active" }, + }), + }, +]; + +describe("AuditLogTable", () => { + it("renders rows, expands details, copies fields, and loads more", async () => { + const writeText = vi.fn().mockResolvedValue(undefined); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + + const onLoadMore = vi.fn(); + const { container } = renderTable({ + logs, + t: (key, fallback, vars) => { + let text = fallback ?? key; + for (const [name, value] of Object.entries(vars ?? {})) { + text = text.replaceAll(`{{${name}}}`, String(value)); + } + return text; + }, + loading: false, + hasNextPage: true, + isFetchingNextPage: false, + onLoadMore, + }); + + expect(container.textContent).toContain("user-1"); + expect(container.textContent).toContain("업데이트"); + expect(container.textContent).toContain("client-a"); + expect(container.textContent).toContain("success"); + + const buttons = Array.from(container.querySelectorAll("button")); + const actorCopyButton = buttons.find( + (button) => button.getAttribute("aria-label") === "Copy User ID", + ); + const targetCopyButton = buttons.find( + (button) => button.getAttribute("aria-label") === "Copy Client ID", + ); + const expandButton = buttons.find( + (button) => !button.getAttribute("aria-label") && !button.textContent, + ); + const loadMoreButton = buttons.find( + (button) => button.textContent === "Load more", + ); + + expect(actorCopyButton).toBeTruthy(); + expect(targetCopyButton).toBeTruthy(); + expect(expandButton).toBeTruthy(); + expect(loadMoreButton).toBeTruthy(); + + await act(async () => { + actorCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + targetCopyButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expandButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(writeText).toHaveBeenCalledWith("user-1"); + expect(writeText).toHaveBeenCalledWith("client-a"); + expect(container.textContent).toContain("Request ID · req-1"); + expect(container.textContent).toContain("Actor"); + expect(container.textContent).toContain("Result"); + + await act(async () => { + loadMoreButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(onLoadMore).toHaveBeenCalledTimes(1); + }); +}); diff --git a/devfront/src/features/coverage/pageSmoke.test.tsx b/devfront/src/features/coverage/pageSmoke.test.tsx index 2c7140f4..c7e5a44c 100644 --- a/devfront/src/features/coverage/pageSmoke.test.tsx +++ b/devfront/src/features/coverage/pageSmoke.test.tsx @@ -13,6 +13,11 @@ import { ClientFederationPage } from "../clients/routes/ClientFederationPage"; import DeveloperRequestPage from "../developer-request/DeveloperRequestPage"; import GlobalOverviewPage from "../overview/GlobalOverviewPage"; import ProfilePage from "../profile/ProfilePage"; +import { + approveDeveloperRequest, + cancelDeveloperRequestApproval, + rejectDeveloperRequest, +} from "../../lib/devApi"; const authProfile = { sub: "user-1", @@ -282,6 +287,19 @@ vi.mock("../../lib/devApi", () => ({ createdAt: "2026-05-01T00:00:00Z", updatedAt: "2026-05-01T00:00:00Z", }, + { + id: 2, + userId: "user-4", + tenantId: "tenant-1", + name: "Approved Requester", + organization: "Hanmac", + email: "approved@example.com", + reason: "Need elevated access", + status: "approved", + adminNotes: "Reviewed and approved", + createdAt: "2026-05-02T00:00:00Z", + updatedAt: "2026-05-02T00:00:00Z", + }, ]), approveDeveloperRequest: vi.fn(async () => ({ status: "approved" })), rejectDeveloperRequest: vi.fn(async () => ({ status: "rejected" })), @@ -348,6 +366,16 @@ async function renderPage( return container; } +async function setInputValue(input: HTMLInputElement, value: string) { + const descriptor = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + "value", + ); + descriptor?.set?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + describe("devfront coverage smoke pages", () => { it("renders overview, client list, audit, developer request, and profile pages", async () => { const overview = await renderPage(); @@ -397,4 +425,65 @@ describe("devfront coverage smoke pages", () => { }); expect(relations.textContent).toContain("Dev Admin"); }); + + it("covers developer request actions", async () => { + const alertSpy = vi.spyOn(window, "alert").mockImplementation(() => undefined); + const requests = await renderPage(); + + expect(requests.textContent).toContain("Requester"); + expect(requests.textContent).toContain("Approved Requester"); + + const pendingNote = Array.from( + requests.querySelectorAll("input"), + ).find((input) => input.getAttribute("placeholder") === "메모 입력 (선택)...") as HTMLInputElement | undefined; + const cancelNote = Array.from( + requests.querySelectorAll("input"), + ).find( + (input) => input.getAttribute("placeholder") === "승인 취소 사유 입력...", + ) as HTMLInputElement | undefined; + + expect(pendingNote).toBeTruthy(); + expect(cancelNote).toBeTruthy(); + + await act(async () => { + await setInputValue(pendingNote!, ""); + }); + + const buttons = Array.from(requests.querySelectorAll("button")); + const rejectButton = buttons.find((button) => button.textContent === "반려"); + const approveButton = buttons.find((button) => button.textContent === "승인"); + const cancelButton = buttons.find( + (button) => button.textContent === "승인 취소", + ); + + expect(rejectButton).toBeTruthy(); + expect(approveButton).toBeTruthy(); + expect(cancelButton).toBeTruthy(); + + await act(async () => { + rejectButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(alertSpy).toHaveBeenCalledWith("반려 사유를 입력해주세요."); + + await act(async () => { + await setInputValue(pendingNote!, "Need more context"); + approveButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + await act(async () => { + await setInputValue(cancelNote!, "Approve needs revision"); + cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(approveDeveloperRequest).toHaveBeenCalledWith(1, "Need more context"); + expect(rejectDeveloperRequest).not.toHaveBeenCalled(); + expect(cancelDeveloperRequestApproval).toHaveBeenCalledWith( + 2, + "Approve needs revision", + ); + + alertSpy.mockRestore(); + }); }); diff --git a/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx b/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx new file mode 100644 index 00000000..7616e525 --- /dev/null +++ b/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx @@ -0,0 +1,186 @@ +import { act } from "react-dom/test-utils"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import DeveloperRequestPage from "./DeveloperRequestPage"; + +const fetchDeveloperRequestsMock = vi.fn(); +const fetchMyTenantsMock = vi.fn(); +const fetchMeMock = vi.fn(); +const requestDeveloperAccessMock = vi.fn(); + +let authState = { + user: { + access_token: "access-token", + profile: { + role: "user", + tenant_id: "tenant-1", + companyCode: "HANMAC", + name: "Requester", + email: "requester@example.com", + phone: "010-1234-5678", + }, + }, +}; + +vi.mock("react-oidc-context", () => ({ + useAuth: () => authState, +})); + +vi.mock("../auth/authApi", () => ({ + fetchMe: () => fetchMeMock(), +})); + +vi.mock("../../lib/devApi", () => ({ + fetchDeveloperRequests: () => fetchDeveloperRequestsMock(), + fetchMyTenants: () => fetchMyTenantsMock(), + requestDeveloperAccess: (...args: unknown[]) => + requestDeveloperAccessMock(...args), + approveDeveloperRequest: vi.fn(), + rejectDeveloperRequest: vi.fn(), + cancelDeveloperRequestApproval: vi.fn(), +})); + +vi.mock("../../lib/i18n", () => ({ + t: (key: string, fallback?: string, vars?: Record) => { + let text = fallback ?? key; + for (const [name, value] of Object.entries(vars ?? {})) { + text = text.replaceAll(`{{${name}}}`, String(value)); + } + return text; + }, +})); + +const roots: Root[] = []; + +afterEach(() => { + for (const root of roots.splice(0)) { + act(() => { + root.unmount(); + }); + } + vi.clearAllMocks(); + document.body.innerHTML = ""; +}); + +beforeEach(() => { + authState = { + user: { + access_token: "access-token", + profile: { + role: "user", + tenant_id: "tenant-1", + companyCode: "HANMAC", + name: "Requester", + email: "requester@example.com", + phone: "010-1234-5678", + }, + }, + }; + + fetchDeveloperRequestsMock.mockResolvedValue([]); + fetchMyTenantsMock.mockResolvedValue([ + { + id: "tenant-1", + name: "Hanmac", + slug: "hanmac", + type: "COMPANY", + parentId: null, + description: "", + status: "active", + memberCount: 10, + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }, + ]); + fetchMeMock.mockResolvedValue({ + id: "user-1", + name: "Requester", + email: "requester@example.com", + phone: "010-1234-5678", + role: "user", + }); + requestDeveloperAccessMock.mockResolvedValue({ status: "pending" }); +}); + +async function setTextAreaValue(input: HTMLTextAreaElement, value: string) { + const descriptor = Object.getOwnPropertyDescriptor( + HTMLTextAreaElement.prototype, + "value", + ); + descriptor?.set?.call(input, value); + input.dispatchEvent(new Event("input", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + +async function renderPage() { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + roots.push(root); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + return container; +} + +describe("DeveloperRequestPage", () => { + it("opens the request modal and submits a request", async () => { + const container = await renderPage(); + expect(container.textContent).toContain("신규 신청하기"); + + const actionButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent?.includes("신규 신청하기"), + ); + expect(actionButton).toBeTruthy(); + + await act(async () => { + actionButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + expect(container.textContent).toContain("개발자 등록 신청"); + + const reasonField = container.querySelector( + "textarea", + ) as HTMLTextAreaElement | null; + expect(reasonField).toBeTruthy(); + + await act(async () => { + await setTextAreaValue(reasonField!, "Need RP access"); + }); + + const submitButton = Array.from(container.querySelectorAll("button")).find( + (button) => button.textContent === "신청하기", + ); + expect(submitButton).toBeTruthy(); + + await act(async () => { + submitButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(requestDeveloperAccessMock).toHaveBeenCalled(); + expect(requestDeveloperAccessMock.mock.calls[0]?.[0]).toEqual({ + name: "Requester", + organization: "Hanmac", + reason: "Need RP access", + tenantId: "tenant-1", + }); + }); +}); diff --git a/devfront/src/lib/apiClient.test.ts b/devfront/src/lib/apiClient.test.ts new file mode 100644 index 00000000..9458a557 --- /dev/null +++ b/devfront/src/lib/apiClient.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const getUserMock = vi.fn(); +const findPersistedOidcUserMock = vi.fn(); +const removeUserMock = vi.fn(); +const shouldStartLoginRedirectMock = vi.fn(); +const shouldSuppressDevelopmentSessionRedirectMock = vi.fn(); + +vi.mock("./auth", () => ({ + userManager: { + getUser: (...args: unknown[]) => getUserMock(...args), + removeUser: (...args: unknown[]) => removeUserMock(...args), + }, +})); + +vi.mock("./oidcStorage", () => ({ + findPersistedOidcUser: (...args: unknown[]) => + findPersistedOidcUserMock(...args), +})); + +vi.mock("../../../common/core/auth", () => ({ + shouldStartLoginRedirect: (...args: unknown[]) => + shouldStartLoginRedirectMock(...args), +})); + +vi.mock("../../../common/core/session", () => ({ + shouldSuppressDevelopmentSessionRedirect: (...args: unknown[]) => + shouldSuppressDevelopmentSessionRedirectMock(...args), +})); + +describe("apiClient", () => { + beforeEach(() => { + vi.resetModules(); + vi.stubEnv("MODE", "test"); + (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE = + true; + window.localStorage.clear(); + getUserMock.mockResolvedValue(null); + findPersistedOidcUserMock.mockReturnValue(undefined); + removeUserMock.mockResolvedValue(undefined); + shouldStartLoginRedirectMock.mockReturnValue(true); + shouldSuppressDevelopmentSessionRedirectMock.mockReturnValue(false); + }); + + it("injects authorization and tenant headers into requests", async () => { + getUserMock.mockResolvedValueOnce({ access_token: "live-token" }); + window.localStorage.setItem("dev_tenant_id", "tenant-1"); + + const { default: apiClient } = await import("./apiClient"); + const requestHandler = apiClient.interceptors.request.handlers[0]?.fulfilled; + + const result = await requestHandler?.({ headers: {} }); + + expect(result.headers.Authorization).toBe("Bearer live-token"); + expect(result.headers["X-Tenant-ID"]).toBe("tenant-1"); + }); + + it("rejects non-auth response errors without redirecting", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { default: apiClient } = await import("./apiClient"); + const responseHandler = apiClient.interceptors.response.handlers[0]?.rejected; + const error = { response: { status: 500, data: { error: "boom" } } }; + + await expect(responseHandler?.(error)).rejects.toBe(error); + expect(warnSpy).not.toHaveBeenCalled(); + expect(removeUserMock).not.toHaveBeenCalled(); + }); + + it("warns and rejects auth failures in test mode", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const { default: apiClient } = await import("./apiClient"); + const responseHandler = apiClient.interceptors.response.handlers[0]?.rejected; + const error = { + response: { + status: 403, + data: { error: "authentication required" }, + }, + }; + + await expect(responseHandler?.(error)).rejects.toBe(error); + expect(warnSpy).toHaveBeenCalled(); + expect(removeUserMock).not.toHaveBeenCalled(); + }); +}); diff --git a/devfront/src/lib/oidcStorage.test.ts b/devfront/src/lib/oidcStorage.test.ts new file mode 100644 index 00000000..c5c35f59 --- /dev/null +++ b/devfront/src/lib/oidcStorage.test.ts @@ -0,0 +1,76 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { findPersistedOidcUser } from "./oidcStorage"; + +class MemoryStorage implements Storage { + private data = new Map(); + + get length() { + return this.data.size; + } + + clear(): void { + this.data.clear(); + } + + getItem(key: string): string | null { + return this.data.get(key) ?? null; + } + + key(index: number): string | null { + return Array.from(this.data.keys())[index] ?? null; + } + + removeItem(key: string): void { + this.data.delete(key); + } + + setItem(key: string, value: string): void { + this.data.set(key, value); + } +} + +describe("findPersistedOidcUser", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-06-01T00:00:00.000Z")); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("returns the first valid, unexpired devfront user entry", () => { + const storage = new MemoryStorage(); + storage.setItem("oidc.user:issuer:other-client", JSON.stringify({})); + const expiresAt = Math.floor(Date.now() / 1000) + 3600; + storage.setItem( + "oidc.user:issuer:devfront", + JSON.stringify({ + access_token: "token-1", + expires_at: expiresAt, + profile: { name: "Dev Admin" }, + }), + ); + + expect(findPersistedOidcUser(storage)).toEqual({ + access_token: "token-1", + expires_at: expiresAt, + profile: { name: "Dev Admin" }, + }); + }); + + it("skips malformed, empty, and expired entries", () => { + const storage = new MemoryStorage(); + storage.setItem("random", "value"); + storage.setItem("oidc.user:issuer:devfront", "not-json"); + storage.setItem( + "oidc.user:issuer:devfront", + JSON.stringify({ + access_token: "expired", + expires_at: Math.floor(Date.now() / 1000) - 1, + }), + ); + + expect(findPersistedOidcUser(storage)).toBeNull(); + }); +}); diff --git a/devfront/src/lib/role.test.ts b/devfront/src/lib/role.test.ts new file mode 100644 index 00000000..f91d910d --- /dev/null +++ b/devfront/src/lib/role.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { normalizeRole, resolveProfileRole } from "./role"; + +describe("normalizeRole", () => { + it("normalizes known role aliases", () => { + expect(normalizeRole("tenant_member")).toBe("user"); + expect(normalizeRole("admin")).toBe("tenant_admin"); + expect(normalizeRole("superadmin")).toBe("super_admin"); + expect(normalizeRole("tenantadmin")).toBe("tenant_admin"); + expect(normalizeRole("rpadmin")).toBe("rp_admin"); + }); + + it("returns a trimmed lowercase role for unknown values", () => { + expect(normalizeRole(" custom_role ")).toBe("custom_role"); + expect(normalizeRole(123)).toBe(""); + }); +}); + +describe("resolveProfileRole", () => { + it("prefers the first non-empty normalized role candidate", () => { + expect( + resolveProfileRole({ + role: " ", + grade: "tenant_member", + "custom:role": "admin", + }), + ).toBe("user"); + }); + + it("returns an empty string when no role is present", () => { + expect(resolveProfileRole(undefined)).toBe(""); + }); +}); From 2c5eed17742301a425d58296617997502c82e054 Mon Sep 17 00:00:00 2001 From: kyy Date: Tue, 2 Jun 2026 11:46:40 +0900 Subject: [PATCH 7/7] =?UTF-8?q?75f192fb24=20=EA=B8=B0=EC=A4=80=20=EB=B3=91?= =?UTF-8?q?=ED=95=A9=20code-check=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Makefile | 17 +- devfront/playwright.config.ts | 4 +- .../src/features/audit/AuditLogsPage.test.tsx | 8 +- .../src/features/clients/ClientsPage.test.tsx | 38 +++-- devfront/src/features/clients/ClientsPage.tsx | 19 +-- .../clients/components/ClientLogo.test.tsx | 2 +- .../routes/ClientFederationPage.test.tsx | 28 ++-- .../DeveloperRequestPage.test.tsx | 6 +- .../features/overview/GlobalOverviewPage.tsx | 103 ++++++------ .../overview/recentClientChanges.test.ts | 148 ++++++++++++------ .../features/overview/recentClientChanges.ts | 4 +- devfront/src/lib/apiClient.test.ts | 14 +- devfront/src/locales/en.toml | 1 + devfront/src/locales/ko.toml | 1 + devfront/src/locales/template.toml | 1 + devfront/tests/clients.spec.ts | 38 +++-- userfront/pubspec.lock | 32 ++-- 17 files changed, 276 insertions(+), 188 deletions(-) diff --git a/Makefile b/Makefile index 941b14b2..25346795 100644 --- a/Makefile +++ b/Makefile @@ -280,7 +280,11 @@ code-check-front-lint: cd adminfront && npx biome format . @echo "==> devfront biome lint/format check" rm -rf devfront/playwright-report devfront/test-results - cd devfront && npm ci --ignore-scripts + @if [ -d devfront/node_modules ]; then \ + echo "devfront/node_modules already present; skipping npm install."; \ + else \ + cd devfront && npm ci --ignore-scripts; \ + fi cd devfront && npx biome lint . cd devfront && npx biome format . @echo "==> orgfront biome lint/format check" @@ -324,7 +328,14 @@ code-check-devfront-tests: @mkdir -p reports/devfront @rm -rf reports/devfront/playwright-report reports/devfront/test-results @status=0; \ - (cd devfront && npm ci --ignore-scripts) || status=$$?; \ + preview_pattern='[v]ite preview --host 127.0.0.1 --strictPort --port 4174'; \ + pkill -f "$$preview_pattern" >/dev/null 2>&1 || true; \ + trap 'pkill -f "$$preview_pattern" >/dev/null 2>&1 || true' EXIT INT TERM; \ + if [ -d devfront/node_modules ]; then \ + echo "devfront/node_modules already present; skipping npm install."; \ + else \ + (cd devfront && npm ci --ignore-scripts) || status=$$?; \ + fi; \ if [ $$status -eq 0 ]; then \ (cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \ fi; \ @@ -388,7 +399,7 @@ code-check-userfront-e2e-tests: (cd "$$tmp_dir/userfront" && flutter build web --wasm --release) || status=$$?; \ fi; \ if [ $$status -eq 0 ]; then \ - (cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_CHROMIUM)) || status=$$?; \ + (cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \ fi; \ if [ $$status -eq 0 ]; then \ port="$$(node -e "const net=require('node:net'); const s=net.createServer(); s.listen(0,'127.0.0.1',()=>{console.log(s.address().port); s.close();});")"; \ diff --git a/devfront/playwright.config.ts b/devfront/playwright.config.ts index a792b3f5..cfb1eb55 100644 --- a/devfront/playwright.config.ts +++ b/devfront/playwright.config.ts @@ -13,7 +13,7 @@ const configuredWorkers = process.env.PLAYWRIGHT_WORKERS const skipWebServer = process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" || process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true"; -const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:5176"; +const baseURL = process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:4174"; /** * Read environment variables from file. @@ -74,7 +74,7 @@ export default defineConfig({ ? undefined : { command: - "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port 5176", + "VITE_OIDC_AUTHORITY=http://localhost:5000/oidc ./node_modules/.bin/vite build && ./node_modules/.bin/vite preview --host 127.0.0.1 --strictPort --port 4174", url: baseURL, reuseExistingServer: false, }, diff --git a/devfront/src/features/audit/AuditLogsPage.test.tsx b/devfront/src/features/audit/AuditLogsPage.test.tsx index 8e7f3c70..021bbd51 100644 --- a/devfront/src/features/audit/AuditLogsPage.test.tsx +++ b/devfront/src/features/audit/AuditLogsPage.test.tsx @@ -158,7 +158,9 @@ describe("AuditLogsPage", () => { }; const container = await renderPage(); - expect(container.textContent).toContain("감사 로그는 개발자 권한이 있어야 볼 수 있습니다."); + expect(container.textContent).toContain( + "감사 로그는 개발자 권한이 있어야 볼 수 있습니다.", + ); const button = Array.from(container.querySelectorAll("button")).find( (item) => item.textContent?.includes("개발자 권한 신청"), @@ -177,7 +179,9 @@ describe("AuditLogsPage", () => { .spyOn(URL, "createObjectURL") .mockReturnValue("blob:csv"); const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockReturnValue(); - const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + const clickSpy = vi + .spyOn(HTMLAnchorElement.prototype, "click") + .mockImplementation(() => {}); const container = await renderPage(); expect(container.textContent).toContain("table:1"); diff --git a/devfront/src/features/clients/ClientsPage.test.tsx b/devfront/src/features/clients/ClientsPage.test.tsx index eba48ea8..6c99d8f3 100644 --- a/devfront/src/features/clients/ClientsPage.test.tsx +++ b/devfront/src/features/clients/ClientsPage.test.tsx @@ -31,9 +31,10 @@ vi.mock("react-oidc-context", () => ({ })); vi.mock("react-router-dom", async () => { - const actual = await vi.importActual( - "react-router-dom", - ); + const actual = + await vi.importActual( + "react-router-dom", + ); return { ...actual, useNavigate: () => navigateMock, @@ -175,7 +176,9 @@ describe("ClientsPage", () => { }); const container = await renderPage(); - expect(container.textContent).toContain("총 6개의 애플리케이션이 등록되어 있습니다."); + expect(container.textContent).toContain( + "총 6개의 애플리케이션이 등록되어 있습니다.", + ); expect(container.textContent).toContain("App 6"); expect(container.textContent).toContain("App 2"); expect(container.textContent).not.toContain("App 1"); @@ -192,26 +195,27 @@ describe("ClientsPage", () => { expect(container.textContent).toContain("App 6"); expect(container.textContent).toContain("접기"); - const advancedButton = Array.from(container.querySelectorAll("button")).find( - (button) => button.textContent === "Advanced Filters", - ); + const advancedButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent === "Advanced Filters"); expect(advancedButton).toBeTruthy(); await act(async () => { - advancedButton?.dispatchEvent( - new MouseEvent("click", { bubbles: true }), - ); + advancedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); - const searchInput = Array.from( - container.querySelectorAll("input"), - ).find((input) => - input.getAttribute("placeholder")?.includes("클라이언트 이름/ID로 검색"), + const searchInput = Array.from(container.querySelectorAll("input")).find( + (input) => + input + .getAttribute("placeholder") + ?.includes("클라이언트 이름/ID로 검색"), ) as HTMLInputElement | undefined; - expect(searchInput).toBeTruthy(); + if (!searchInput) { + throw new Error("Expected search input to be rendered"); + } await act(async () => { - await setInputValue(searchInput!, "missing-client"); + await setInputValue(searchInput, "missing-client"); }); expect(container.textContent).toContain("조건에 맞는 연동 앱이 없습니다."); @@ -226,7 +230,7 @@ describe("ClientsPage", () => { }); await act(async () => { - await setInputValue(searchInput!, ""); + await setInputValue(searchInput, ""); }); expect(container.textContent).toContain("App 1"); diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 2057783e..c82680f6 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -532,10 +532,12 @@ function ClientsPage() { t("ui.dev.clients.untitled", "Untitled")}

- {t( - "ui.dev.clients.tenant_scoped", - "Tenant-scoped", - )} +

@@ -615,14 +617,9 @@ function ClientsPage() { "ui.dev.clients.list.collapse_aria", "연동 앱 목록 접기", ) - : t( - "ui.dev.clients.list.more_aria", - "연동 앱 목록 더보기", - ) - } - onClick={() => - setIsClientListExpanded((current) => !current) + : t("ui.dev.clients.list.more_aria", "연동 앱 목록 더보기") } + onClick={() => setIsClientListExpanded((current) => !current)} > {isClientListExpanded ? t("ui.common.collapse", "접기") diff --git a/devfront/src/features/clients/components/ClientLogo.test.tsx b/devfront/src/features/clients/components/ClientLogo.test.tsx index 353c9f27..1fbe8fde 100644 --- a/devfront/src/features/clients/components/ClientLogo.test.tsx +++ b/devfront/src/features/clients/components/ClientLogo.test.tsx @@ -8,7 +8,7 @@ vi.mock("../../../components/ui/avatar", () => ({ Avatar: ({ children }: { children: ReactNode }) => (
{children}
), - AvatarImage: (props: ComponentProps<"img">) => , + AvatarImage: (props: ComponentProps<"img">) => , AvatarFallback: ({ children }: { children: ReactNode }) => (
{children}
), diff --git a/devfront/src/features/clients/routes/ClientFederationPage.test.tsx b/devfront/src/features/clients/routes/ClientFederationPage.test.tsx index d1dace77..f97300cd 100644 --- a/devfront/src/features/clients/routes/ClientFederationPage.test.tsx +++ b/devfront/src/features/clients/routes/ClientFederationPage.test.tsx @@ -9,9 +9,10 @@ const listIdpConfigsMock = vi.fn(); const createIdpConfigMock = vi.fn(); vi.mock("react-router-dom", async () => { - const actual = await vi.importActual( - "react-router-dom", - ); + const actual = + await vi.importActual( + "react-router-dom", + ); return { ...actual, useParams: () => params, @@ -19,10 +20,8 @@ vi.mock("react-router-dom", async () => { }); vi.mock("../../../lib/devApi", () => ({ - listIdpConfigsForClient: (clientId: string) => - listIdpConfigsMock(clientId), - createIdpConfigForClient: (payload: unknown) => - createIdpConfigMock(payload), + listIdpConfigsForClient: (clientId: string) => listIdpConfigsMock(clientId), + createIdpConfigForClient: (payload: unknown) => createIdpConfigMock(payload), })); vi.mock("../../../lib/i18n", () => ({ @@ -146,16 +145,15 @@ describe("ClientFederationPage", () => { 'input[name="oidc_client_secret"]', ) as HTMLInputElement | null; - expect(displayName).toBeTruthy(); - expect(issuerUrl).toBeTruthy(); - expect(clientId).toBeTruthy(); - expect(clientSecret).toBeTruthy(); + if (!displayName || !issuerUrl || !clientId || !clientSecret) { + throw new Error("Expected federation form inputs to be rendered"); + } await act(async () => { - await setInputValue(displayName!, "New Provider"); - await setInputValue(issuerUrl!, "https://login.example"); - await setInputValue(clientId!, "client-oidc"); - await setInputValue(clientSecret!, "secret-value"); + await setInputValue(displayName, "New Provider"); + await setInputValue(issuerUrl, "https://login.example"); + await setInputValue(clientId, "client-oidc"); + await setInputValue(clientSecret, "secret-value"); }); const submitButton = Array.from(container.querySelectorAll("button")).find( diff --git a/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx b/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx index 7616e525..a357c1ec 100644 --- a/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx +++ b/devfront/src/features/developer-request/DeveloperRequestPage.test.tsx @@ -159,10 +159,12 @@ describe("DeveloperRequestPage", () => { const reasonField = container.querySelector( "textarea", ) as HTMLTextAreaElement | null; - expect(reasonField).toBeTruthy(); + if (!reasonField) { + throw new Error("Expected reason textarea to be rendered"); + } await act(async () => { - await setTextAreaValue(reasonField!, "Need RP access"); + await setTextAreaValue(reasonField, "Need RP access"); }); const submitButton = Array.from(container.querySelectorAll("button")).find( diff --git a/devfront/src/features/overview/GlobalOverviewPage.tsx b/devfront/src/features/overview/GlobalOverviewPage.tsx index 4ab57bc1..77bae531 100644 --- a/devfront/src/features/overview/GlobalOverviewPage.tsx +++ b/devfront/src/features/overview/GlobalOverviewPage.tsx @@ -119,9 +119,7 @@ function resolveAppLocale(): AppLocale { return pathLocale; } - return window.navigator.language.toLowerCase().startsWith("ko") - ? "ko" - : "en"; + return window.navigator.language.toLowerCase().startsWith("ko") ? "ko" : "en"; } function formatRecentChangeTimestamp(value: string) { @@ -390,7 +388,10 @@ function summarizeRecentChanges( items: RecentClientChange[], period: RPUsagePeriod, ): RecentChangePoint[] { - const byDate = new Map }>(); + const byDate = new Map< + string, + { changeCount: number; actors: Set } + >(); for (const item of items) { const bucket = toPeriodBucket(item.timestamp.slice(0, 10), period); const current = byDate.get(bucket) ?? { @@ -447,7 +448,9 @@ function buildRecentChangeSeries( items: RecentClientChange[], period: RPUsagePeriod, ): RecentChangeSeries[] { - const dates = summarizeRecentChanges(items, period).map((point) => point.date); + const dates = summarizeRecentChanges(items, period).map( + (point) => point.date, + ); const byClient = new Map< string, { @@ -937,7 +940,7 @@ function GlobalOverviewPage() { const [visibleRecentClientChangesCount, setVisibleRecentClientChangesCount] = useState(6); const [isRecentChangesDetailOpen, setIsRecentChangesDetailOpen] = - useState(false); + useState(true); const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90; const statsQuery = useQuery({ queryKey: ["dev-dashboard-stats"], @@ -1112,40 +1115,34 @@ function GlobalOverviewPage() { ), [currentClientIdSet, recentClientChangesWithActors], ); - const recentChangeFilterOptions = useMemo( - () => { - const activeOptions = Array.from( - new Map( - recentClientChangesWithActors - .filter((item) => currentClientIdSet.has(item.clientId)) - .map((item) => [ - item.clientId, - { id: item.clientId, label: item.clientName }, - ]), - ).values(), - ).sort((left, right) => left.label.localeCompare(right.label)); + const recentChangeFilterOptions = useMemo(() => { + const activeOptions = Array.from( + new Map( + recentClientChangesWithActors + .filter((item) => currentClientIdSet.has(item.clientId)) + .map((item) => [ + item.clientId, + { id: item.clientId, label: item.clientName }, + ]), + ).values(), + ).sort((left, right) => left.label.localeCompare(right.label)); - if (deletedRecentChangeClientIds.length === 0) { - return activeOptions; - } + if (deletedRecentChangeClientIds.length === 0) { + return activeOptions; + } - return [ - ...activeOptions, - { - id: deletedRecentChangeFilterId, - label: t( - "ui.dev.dashboard.recent_changes.deleted_group", - "삭제된 앱", - ), - }, - ]; - }, - [ - currentClientIdSet, - deletedRecentChangeClientIds.length, - recentClientChangesWithActors, - ], - ); + return [ + ...activeOptions, + { + id: deletedRecentChangeFilterId, + label: t("ui.dev.dashboard.recent_changes.deleted_group", "삭제된 앱"), + }, + ]; + }, [ + currentClientIdSet, + deletedRecentChangeClientIds.length, + recentClientChangesWithActors, + ]); const filteredRecentClientChanges = useMemo(() => { if (selectedRecentChangeClientIds.length === 0) { return recentClientChangesWithActors; @@ -1155,7 +1152,8 @@ function GlobalOverviewPage() { return recentClientChangesWithActors.filter( (item) => selectedSet.has(item.clientId) || - (includeDeletedGroup && deletedRecentChangeClientIds.includes(item.clientId)), + (includeDeletedGroup && + deletedRecentChangeClientIds.includes(item.clientId)), ); }, [ deletedRecentChangeClientIds, @@ -1163,7 +1161,8 @@ function GlobalOverviewPage() { selectedRecentChangeClientIds, ]); const selectedRecentChangeSeries = useMemo( - () => buildRecentChangeSeries(filteredRecentClientChanges, recentChangesPeriod), + () => + buildRecentChangeSeries(filteredRecentClientChanges, recentChangesPeriod), [filteredRecentClientChanges, recentChangesPeriod], ); const recentChangedClientCount = useMemo( @@ -1180,7 +1179,9 @@ function GlobalOverviewPage() { new Set( filteredRecentClientChanges .map((item) => item.clientId) - .filter((clientId) => deletedRecentChangeClientIds.includes(clientId)), + .filter((clientId) => + deletedRecentChangeClientIds.includes(clientId), + ), ).size, [deletedRecentChangeClientIds, filteredRecentClientChanges], ); @@ -1251,10 +1252,10 @@ function GlobalOverviewPage() { }; useEffect(() => { - setVisibleRecentClientChangesCount((current) => - Math.min(Math.max(6, current), filteredRecentClientChanges.length), - ); - }, [filteredRecentClientChanges.length, selectedRecentChangeClientIds]); + setVisibleRecentClientChangesCount((current) => + Math.min(Math.max(6, current), filteredRecentClientChanges.length), + ); + }, [filteredRecentClientChanges.length]); if (isLoadingDeveloperAccessGate) { return ( @@ -1466,9 +1467,9 @@ function GlobalOverviewPage() { } label={t( - "ui.dev.dashboard.recent_changes.summary.deleted_clients", - "삭제된 앱 수", - )} + "ui.dev.dashboard.recent_changes.summary.deleted_clients", + "삭제된 앱 수", + )} value={deletedRecentChangedClientCount.toLocaleString()} /> ) : ( visibleRecentClientChanges.map((item) => { - const { date, time } = - formatRecentChangeTimestamp(item.timestamp); + const { date, time } = formatRecentChangeTimestamp( + item.timestamp, + ); return (
) : null} -
); } diff --git a/devfront/src/features/overview/recentClientChanges.test.ts b/devfront/src/features/overview/recentClientChanges.test.ts index 8163f10e..b69c9a28 100644 --- a/devfront/src/features/overview/recentClientChanges.test.ts +++ b/devfront/src/features/overview/recentClientChanges.test.ts @@ -56,7 +56,9 @@ describe("recent client changes", () => { mockLocale("en"); expect(getRecentClientActionLabel("CREATE_CLIENT")).toBe("App creation"); - expect(getRecentClientActionLabel("UPDATE_CLIENT")).toBe("Settings changes"); + expect(getRecentClientActionLabel("UPDATE_CLIENT")).toBe( + "Settings changes", + ); expect(getRecentClientActionLabel("UPDATE_CLIENT_STATUS")).toBe( "Status changes", ); @@ -64,7 +66,9 @@ describe("recent client changes", () => { "Client secret rotation", ); expect(getRecentClientActionLabel("ADD_RELATION")).toBe("Add Relationship"); - expect(getRecentClientActionLabel("REMOVE_RELATION")).toBe("Remove"); + expect(getRecentClientActionLabel("REMOVE_RELATION")).toBe( + "Remove Relationship", + ); expect(getRecentClientActionLabel("DELETE_CLIENT")).toBe("App deletion"); expect(getRecentClientActionLabel("OTHER_ACTION")).toBe("OTHER_ACTION"); @@ -90,59 +94,107 @@ describe("recent client changes", () => { it("builds recent client changes with sorting, filtering, and detail slicing", () => { mockLocale("ko"); - const clients = [makeClient("client-a", "Alpha"), makeClient("client-b", "")]; + const clients = [ + makeClient("client-a", "Alpha"), + makeClient("client-b", ""), + ]; const auditLogs = [ - makeAuditLog("evt-1", "2026-05-27T07:00:00.000Z", "CREATE_CLIENT", "client-a", { - after: { name: "Alpha", type: "private", status: "active" }, - }), - makeAuditLog("evt-2", "2026-05-27T08:00:00.000Z", "UPDATE_CLIENT", "client-a", { - before: { - name: "Alpha old", - status: "inactive", - sameField: "same", - oldField: "old-value", + makeAuditLog( + "evt-1", + "2026-05-27T07:00:00.000Z", + "CREATE_CLIENT", + "client-a", + { + after: { name: "Alpha", type: "private", status: "active" }, }, - after: { - name: "Alpha new", - status: "active", - sameField: "same", - newField: "new-value", + ), + makeAuditLog( + "evt-2", + "2026-05-27T08:00:00.000Z", + "UPDATE_CLIENT", + "client-a", + { + before: { + name: "Alpha old", + status: "inactive", + sameField: "same", + oldField: "old-value", + }, + after: { + name: "Alpha new", + status: "active", + sameField: "same", + newField: "new-value", + }, }, - }), - makeAuditLog("evt-3", "2026-05-27T09:00:00.000Z", "UPDATE_CLIENT_STATUS", "client-a", { - before: { status: "inactive" }, - after: { status: "active" }, - }), - makeAuditLog("evt-4", "2026-05-27T10:00:00.000Z", "ADD_RELATION", "client-b", { - after: { - relation: "audit_viewer", - subject: "User:89692983-f512-4d96-845d-ac6123d08b95", + ), + makeAuditLog( + "evt-3", + "2026-05-27T09:00:00.000Z", + "UPDATE_CLIENT_STATUS", + "client-a", + { + before: { status: "inactive" }, + after: { status: "active" }, }, - }), - makeAuditLog("evt-5", "2026-05-27T11:00:00.000Z", "REMOVE_RELATION", "client-b", { - before: { - relation: "admins", - subject: "User:89692983-f512-4d96-845d-ac6123d08b95", + ), + makeAuditLog( + "evt-4", + "2026-05-27T10:00:00.000Z", + "ADD_RELATION", + "client-b", + { + after: { + relation: "audit_viewer", + subject: "User:89692983-f512-4d96-845d-ac6123d08b95", + }, }, - }), - makeAuditLog("evt-6", "2026-05-27T12:00:00.000Z", "ROTATE_SECRET", "client-a", { - after: {}, - }), - makeAuditLog("evt-7", "2026-05-27T13:00:00.000Z", "DELETE_CLIENT", "client-a", { - before: { - name: "Alpha", - status: "inactive", + ), + makeAuditLog( + "evt-5", + "2026-05-27T11:00:00.000Z", + "REMOVE_RELATION", + "client-b", + { + before: { + relation: "admins", + subject: "User:89692983-f512-4d96-845d-ac6123d08b95", + }, }, - }), - makeAuditLog("evt-8", "2026-05-27T14:00:00.000Z", "UNSUPPORTED_ACTION", "client-a", { - after: { name: "Ignored" }, - }), + ), + makeAuditLog( + "evt-6", + "2026-05-27T12:00:00.000Z", + "ROTATE_SECRET", + "client-a", + { + after: {}, + }, + ), + makeAuditLog( + "evt-7", + "2026-05-27T13:00:00.000Z", + "DELETE_CLIENT", + "client-a", + { + before: { + name: "Alpha", + status: "inactive", + }, + }, + ), + makeAuditLog( + "evt-8", + "2026-05-27T14:00:00.000Z", + "UNSUPPORTED_ACTION", + "client-a", + { + after: { name: "Ignored" }, + }, + ), ]; - const changes = buildRecentClientChanges( - auditLogs, - clients, - ); + const changes = buildRecentClientChanges(auditLogs, clients); expect(changes).toHaveLength(7); expect(changes[0]).toMatchObject({ @@ -164,7 +216,7 @@ describe("recent client changes", () => { expect(changes[2]).toMatchObject({ eventId: "evt-5", clientName: "client-b", - actionLabel: "제외", + actionLabel: "관계 삭제", detailLabels: [ { label: "관계", value: "admins" }, { diff --git a/devfront/src/features/overview/recentClientChanges.ts b/devfront/src/features/overview/recentClientChanges.ts index 534cb86a..2084a226 100644 --- a/devfront/src/features/overview/recentClientChanges.ts +++ b/devfront/src/features/overview/recentClientChanges.ts @@ -49,7 +49,7 @@ export function getRecentClientActionLabel(action: string) { case "ADD_RELATION": return t("ui.dev.clients.relationships.add_title", "관계 추가"); case "REMOVE_RELATION": - return t("ui.common.remove", "Remove"); + return t("ui.dev.clients.relationships.remove_title", "관계 삭제"); case "DELETE_CLIENT": return t("ui.dev.clients.recent_changes.guide.delete", "앱 삭제"); default: @@ -68,7 +68,7 @@ function getRecentClientFieldLabel(key: string) { case "relation": return t("ui.dev.clients.relationships.relation", "관계"); case "subject": - return t("ui.dev.clients.relationships.subject", "대상"); + return t("ui.dev.clients.relationships.subject", "주체"); case "client_secret": return t( "ui.dev.clients.details.credentials.client_secret", diff --git a/devfront/src/lib/apiClient.test.ts b/devfront/src/lib/apiClient.test.ts index 9458a557..7ac620b9 100644 --- a/devfront/src/lib/apiClient.test.ts +++ b/devfront/src/lib/apiClient.test.ts @@ -32,8 +32,9 @@ describe("apiClient", () => { beforeEach(() => { vi.resetModules(); vi.stubEnv("MODE", "test"); - (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE = - true; + ( + window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean } + )._IS_TEST_MODE = true; window.localStorage.clear(); getUserMock.mockResolvedValue(null); findPersistedOidcUserMock.mockReturnValue(undefined); @@ -47,7 +48,8 @@ describe("apiClient", () => { window.localStorage.setItem("dev_tenant_id", "tenant-1"); const { default: apiClient } = await import("./apiClient"); - const requestHandler = apiClient.interceptors.request.handlers[0]?.fulfilled; + const requestHandler = + apiClient.interceptors.request.handlers[0]?.fulfilled; const result = await requestHandler?.({ headers: {} }); @@ -58,7 +60,8 @@ describe("apiClient", () => { it("rejects non-auth response errors without redirecting", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const { default: apiClient } = await import("./apiClient"); - const responseHandler = apiClient.interceptors.response.handlers[0]?.rejected; + const responseHandler = + apiClient.interceptors.response.handlers[0]?.rejected; const error = { response: { status: 500, data: { error: "boom" } } }; await expect(responseHandler?.(error)).rejects.toBe(error); @@ -69,7 +72,8 @@ describe("apiClient", () => { it("warns and rejects auth failures in test mode", async () => { const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const { default: apiClient } = await import("./apiClient"); - const responseHandler = apiClient.interceptors.response.handlers[0]?.rejected; + const responseHandler = + apiClient.interceptors.response.handlers[0]?.rejected; const error = { response: { status: 403, diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 460fb853..92eb923b 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -1601,6 +1601,7 @@ revoke_cache = "Revoke Cache" [ui.dev.clients.relationships] title = "Client Relationships" add_title = "Add Relationship" +remove_title = "Remove Relationship" relation = "Relation" user_id = "User ID" user_id_placeholder = "kratos user id" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 444c776e..2e1963b6 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -1600,6 +1600,7 @@ revoke_cache = "캐시 삭제" [ui.dev.clients.relationships] title = "클라이언트 관계" add_title = "관계 추가" +remove_title = "관계 삭제" relation = "관계" user_id = "사용자 ID" user_id_placeholder = "kratos 사용자 id" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index 141eacba..4f3295de 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -1655,6 +1655,7 @@ revoke_cache = "" [ui.dev.clients.relationships] title = "" add_title = "" +remove_title = "" relation = "" user_id = "" user_id_placeholder = "" diff --git a/devfront/tests/clients.spec.ts b/devfront/tests/clients.spec.ts index aa6e1946..af5b5491 100644 --- a/devfront/tests/clients.spec.ts +++ b/devfront/tests/clients.spec.ts @@ -37,7 +37,9 @@ test("clients page loads correctly", async ({ page }) => { // 페이지 내 주요 텍스트 확인 await expect(page.getByText("연동 앱 목록")).toBeVisible(); - await expect(page.getByText("Total Applications", { exact: true })).toHaveCount(0); + await expect( + page.getByText("Total Applications", { exact: true }), + ).toHaveCount(0); // 테이블 헤더 확인 await expect( @@ -108,9 +110,7 @@ test("clients page shows only five apps by default and expands with more button" const clients = Array.from({ length: 6 }, (_, index) => makeClient(`client-${index + 1}`, { name: `Preview App ${index + 1}`, - createdAt: new Date( - Date.UTC(2026, 2, 3, 9, 10 - index, 0), - ).toISOString(), + createdAt: new Date(Date.UTC(2026, 2, 3, 9, 10 - index, 0)).toISOString(), }), ); @@ -126,9 +126,13 @@ test("clients page shows only five apps by default and expands with more button" page.getByRole("heading", { name: "연동 앱 목록" }), ).toBeVisible(); await expect( - page.locator("table").first().locator("tbody tr").filter({ - hasText: /Preview App \d/, - }), + page + .locator("table") + .first() + .locator("tbody tr") + .filter({ + hasText: /Preview App \d/, + }), ).toHaveCount(5); await expect( page.getByText("Preview App 6", { exact: true }), @@ -142,13 +146,15 @@ test("clients page shows only five apps by default and expands with more button" await moreButton.click(); await expect( - page.locator("table").first().locator("tbody tr").filter({ - hasText: /Preview App \d/, - }), + page + .locator("table") + .first() + .locator("tbody tr") + .filter({ + hasText: /Preview App \d/, + }), ).toHaveCount(6); - await expect( - page.getByText("Preview App 6", { exact: true }), - ).toBeVisible(); + await expect(page.getByText("Preview App 6", { exact: true })).toBeVisible(); await expect( page.getByRole("button", { name: "연동 앱 목록 더보기" }), ).toHaveCount(0); @@ -205,15 +211,13 @@ test("overview page shows user-delete relation cleanup in recent changes", async ).toBeVisible(); await expect(page.getByText("관계 삭제", { exact: true })).toBeVisible(); await expect(page.getByText(/관계:\s*config_editor/)).toBeVisible(); - await expect(page.getByText(/대상:\s*User:deleted-user/)).toBeVisible(); + await expect(page.getByText(/주체:\s*User:deleted-user/)).toBeVisible(); await expect( page.getByText("cleanup-actor", { exact: true }).first(), ).toBeVisible(); }); -test("clients page no longer shows recent changes card", async ({ - page, -}) => { +test("clients page no longer shows recent changes card", async ({ page }) => { await seedAuth(page, "super_admin"); const clients = Array.from({ length: 6 }, (_, index) => makeClient(`client-${index + 1}`, { diff --git a/userfront/pubspec.lock b/userfront/pubspec.lock index b23d80a9..8b6fff8c 100644 --- a/userfront/pubspec.lock +++ b/userfront/pubspec.lock @@ -45,10 +45,10 @@ packages: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" cli_config: dependency: transitive description: @@ -268,6 +268,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + js: + dependency: transitive + description: + name: js + sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" + url: "https://pub.dev" + source: hosted + version: "0.7.2" leak_tracker: dependency: transitive description: @@ -320,18 +328,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -653,26 +661,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.12" toml: dependency: "direct main" description: