1
0
forked from baron/baron-sso

75f192fb24 기준 병합 code-check 수정

This commit is contained in:
2026-06-02 11:46:40 +09:00
parent 38605ac8a3
commit 2c5eed1774
17 changed files with 276 additions and 188 deletions

View File

@@ -280,7 +280,11 @@ code-check-front-lint:
cd adminfront && npx biome format . cd adminfront && npx biome format .
@echo "==> devfront biome lint/format check" @echo "==> devfront biome lint/format check"
rm -rf devfront/playwright-report devfront/test-results 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 lint .
cd devfront && npx biome format . cd devfront && npx biome format .
@echo "==> orgfront biome lint/format check" @echo "==> orgfront biome lint/format check"
@@ -324,7 +328,14 @@ code-check-devfront-tests:
@mkdir -p reports/devfront @mkdir -p reports/devfront
@rm -rf reports/devfront/playwright-report reports/devfront/test-results @rm -rf reports/devfront/playwright-report reports/devfront/test-results
@status=0; \ @status=0; \
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=$$?; \ (cd devfront && npm ci --ignore-scripts) || status=$$?; \
fi; \
if [ $$status -eq 0 ]; then \ if [ $$status -eq 0 ]; then \
(cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \ (cd devfront && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \ fi; \
@@ -388,7 +399,7 @@ code-check-userfront-e2e-tests:
(cd "$$tmp_dir/userfront" && flutter build web --wasm --release) || status=$$?; \ (cd "$$tmp_dir/userfront" && flutter build web --wasm --release) || status=$$?; \
fi; \ fi; \
if [ $$status -eq 0 ]; then \ if [ $$status -eq 0 ]; then \
(cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_CHROMIUM)) || status=$$?; \ (cd "$$tmp_dir/userfront-e2e" && $(PLAYWRIGHT_INSTALL_ALL)) || status=$$?; \
fi; \ fi; \
if [ $$status -eq 0 ]; then \ 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();});")"; \ 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();});")"; \

View File

@@ -13,7 +13,7 @@ const configuredWorkers = process.env.PLAYWRIGHT_WORKERS
const skipWebServer = const skipWebServer =
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" || process.env.PLAYWRIGHT_SKIP_WEBSERVER === "1" ||
process.env.PLAYWRIGHT_SKIP_WEBSERVER === "true"; 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. * Read environment variables from file.
@@ -74,7 +74,7 @@ export default defineConfig({
? undefined ? undefined
: { : {
command: 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, url: baseURL,
reuseExistingServer: false, reuseExistingServer: false,
}, },

View File

@@ -158,7 +158,9 @@ describe("AuditLogsPage", () => {
}; };
const container = await renderPage(); const container = await renderPage();
expect(container.textContent).toContain("감사 로그는 개발자 권한이 있어야 볼 수 있습니다."); expect(container.textContent).toContain(
"감사 로그는 개발자 권한이 있어야 볼 수 있습니다.",
);
const button = Array.from(container.querySelectorAll("button")).find( const button = Array.from(container.querySelectorAll("button")).find(
(item) => item.textContent?.includes("개발자 권한 신청"), (item) => item.textContent?.includes("개발자 권한 신청"),
@@ -177,7 +179,9 @@ describe("AuditLogsPage", () => {
.spyOn(URL, "createObjectURL") .spyOn(URL, "createObjectURL")
.mockReturnValue("blob:csv"); .mockReturnValue("blob:csv");
const revokeObjectURL = vi.spyOn(URL, "revokeObjectURL").mockReturnValue(); 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(); const container = await renderPage();
expect(container.textContent).toContain("table:1"); expect(container.textContent).toContain("table:1");

View File

@@ -31,7 +31,8 @@ vi.mock("react-oidc-context", () => ({
})); }));
vi.mock("react-router-dom", async () => { vi.mock("react-router-dom", async () => {
const actual = await vi.importActual<typeof import("react-router-dom")>( const actual =
await vi.importActual<typeof import("react-router-dom")>(
"react-router-dom", "react-router-dom",
); );
return { return {
@@ -175,7 +176,9 @@ describe("ClientsPage", () => {
}); });
const container = await renderPage(); 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 6");
expect(container.textContent).toContain("App 2"); expect(container.textContent).toContain("App 2");
expect(container.textContent).not.toContain("App 1"); expect(container.textContent).not.toContain("App 1");
@@ -192,26 +195,27 @@ describe("ClientsPage", () => {
expect(container.textContent).toContain("App 6"); expect(container.textContent).toContain("App 6");
expect(container.textContent).toContain("접기"); expect(container.textContent).toContain("접기");
const advancedButton = Array.from(container.querySelectorAll("button")).find( const advancedButton = Array.from(
(button) => button.textContent === "Advanced Filters", container.querySelectorAll("button"),
); ).find((button) => button.textContent === "Advanced Filters");
expect(advancedButton).toBeTruthy(); expect(advancedButton).toBeTruthy();
await act(async () => { await act(async () => {
advancedButton?.dispatchEvent( advancedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
new MouseEvent("click", { bubbles: true }),
);
}); });
const searchInput = Array.from( const searchInput = Array.from(container.querySelectorAll("input")).find(
container.querySelectorAll("input"), (input) =>
).find((input) => input
input.getAttribute("placeholder")?.includes("클라이언트 이름/ID로 검색"), .getAttribute("placeholder")
?.includes("클라이언트 이름/ID로 검색"),
) as HTMLInputElement | undefined; ) as HTMLInputElement | undefined;
expect(searchInput).toBeTruthy(); if (!searchInput) {
throw new Error("Expected search input to be rendered");
}
await act(async () => { await act(async () => {
await setInputValue(searchInput!, "missing-client"); await setInputValue(searchInput, "missing-client");
}); });
expect(container.textContent).toContain("조건에 맞는 연동 앱이 없습니다."); expect(container.textContent).toContain("조건에 맞는 연동 앱이 없습니다.");
@@ -226,7 +230,7 @@ describe("ClientsPage", () => {
}); });
await act(async () => { await act(async () => {
await setInputValue(searchInput!, ""); await setInputValue(searchInput, "");
}); });
expect(container.textContent).toContain("App 1"); expect(container.textContent).toContain("App 1");

View File

@@ -532,10 +532,12 @@ function ClientsPage() {
t("ui.dev.clients.untitled", "Untitled")} t("ui.dev.clients.untitled", "Untitled")}
</p> </p>
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
<span aria-hidden="true">
{t( {t(
"ui.dev.clients.tenant_scoped", "ui.dev.clients.tenant_scoped",
"Tenant-scoped", "Tenant-scoped",
)} )}
</span>
</p> </p>
</div> </div>
</Link> </Link>
@@ -615,14 +617,9 @@ function ClientsPage() {
"ui.dev.clients.list.collapse_aria", "ui.dev.clients.list.collapse_aria",
"연동 앱 목록 접기", "연동 앱 목록 접기",
) )
: t( : t("ui.dev.clients.list.more_aria", "연동 앱 목록 더보기")
"ui.dev.clients.list.more_aria",
"연동 앱 목록 더보기",
)
}
onClick={() =>
setIsClientListExpanded((current) => !current)
} }
onClick={() => setIsClientListExpanded((current) => !current)}
> >
{isClientListExpanded {isClientListExpanded
? t("ui.common.collapse", "접기") ? t("ui.common.collapse", "접기")

View File

@@ -8,7 +8,7 @@ vi.mock("../../../components/ui/avatar", () => ({
Avatar: ({ children }: { children: ReactNode }) => ( Avatar: ({ children }: { children: ReactNode }) => (
<div data-testid="avatar">{children}</div> <div data-testid="avatar">{children}</div>
), ),
AvatarImage: (props: ComponentProps<"img">) => <img {...props} />, AvatarImage: (props: ComponentProps<"img">) => <img alt="" {...props} />,
AvatarFallback: ({ children }: { children: ReactNode }) => ( AvatarFallback: ({ children }: { children: ReactNode }) => (
<div data-testid="fallback">{children}</div> <div data-testid="fallback">{children}</div>
), ),

View File

@@ -9,7 +9,8 @@ const listIdpConfigsMock = vi.fn();
const createIdpConfigMock = vi.fn(); const createIdpConfigMock = vi.fn();
vi.mock("react-router-dom", async () => { vi.mock("react-router-dom", async () => {
const actual = await vi.importActual<typeof import("react-router-dom")>( const actual =
await vi.importActual<typeof import("react-router-dom")>(
"react-router-dom", "react-router-dom",
); );
return { return {
@@ -19,10 +20,8 @@ vi.mock("react-router-dom", async () => {
}); });
vi.mock("../../../lib/devApi", () => ({ vi.mock("../../../lib/devApi", () => ({
listIdpConfigsForClient: (clientId: string) => listIdpConfigsForClient: (clientId: string) => listIdpConfigsMock(clientId),
listIdpConfigsMock(clientId), createIdpConfigForClient: (payload: unknown) => createIdpConfigMock(payload),
createIdpConfigForClient: (payload: unknown) =>
createIdpConfigMock(payload),
})); }));
vi.mock("../../../lib/i18n", () => ({ vi.mock("../../../lib/i18n", () => ({
@@ -146,16 +145,15 @@ describe("ClientFederationPage", () => {
'input[name="oidc_client_secret"]', 'input[name="oidc_client_secret"]',
) as HTMLInputElement | null; ) as HTMLInputElement | null;
expect(displayName).toBeTruthy(); if (!displayName || !issuerUrl || !clientId || !clientSecret) {
expect(issuerUrl).toBeTruthy(); throw new Error("Expected federation form inputs to be rendered");
expect(clientId).toBeTruthy(); }
expect(clientSecret).toBeTruthy();
await act(async () => { await act(async () => {
await setInputValue(displayName!, "New Provider"); await setInputValue(displayName, "New Provider");
await setInputValue(issuerUrl!, "https://login.example"); await setInputValue(issuerUrl, "https://login.example");
await setInputValue(clientId!, "client-oidc"); await setInputValue(clientId, "client-oidc");
await setInputValue(clientSecret!, "secret-value"); await setInputValue(clientSecret, "secret-value");
}); });
const submitButton = Array.from(container.querySelectorAll("button")).find( const submitButton = Array.from(container.querySelectorAll("button")).find(

View File

@@ -159,10 +159,12 @@ describe("DeveloperRequestPage", () => {
const reasonField = container.querySelector( const reasonField = container.querySelector(
"textarea", "textarea",
) as HTMLTextAreaElement | null; ) as HTMLTextAreaElement | null;
expect(reasonField).toBeTruthy(); if (!reasonField) {
throw new Error("Expected reason textarea to be rendered");
}
await act(async () => { await act(async () => {
await setTextAreaValue(reasonField!, "Need RP access"); await setTextAreaValue(reasonField, "Need RP access");
}); });
const submitButton = Array.from(container.querySelectorAll("button")).find( const submitButton = Array.from(container.querySelectorAll("button")).find(

View File

@@ -119,9 +119,7 @@ function resolveAppLocale(): AppLocale {
return pathLocale; return pathLocale;
} }
return window.navigator.language.toLowerCase().startsWith("ko") return window.navigator.language.toLowerCase().startsWith("ko") ? "ko" : "en";
? "ko"
: "en";
} }
function formatRecentChangeTimestamp(value: string) { function formatRecentChangeTimestamp(value: string) {
@@ -390,7 +388,10 @@ function summarizeRecentChanges(
items: RecentClientChange[], items: RecentClientChange[],
period: RPUsagePeriod, period: RPUsagePeriod,
): RecentChangePoint[] { ): RecentChangePoint[] {
const byDate = new Map<string, { changeCount: number; actors: Set<string> }>(); const byDate = new Map<
string,
{ changeCount: number; actors: Set<string> }
>();
for (const item of items) { for (const item of items) {
const bucket = toPeriodBucket(item.timestamp.slice(0, 10), period); const bucket = toPeriodBucket(item.timestamp.slice(0, 10), period);
const current = byDate.get(bucket) ?? { const current = byDate.get(bucket) ?? {
@@ -447,7 +448,9 @@ function buildRecentChangeSeries(
items: RecentClientChange[], items: RecentClientChange[],
period: RPUsagePeriod, period: RPUsagePeriod,
): RecentChangeSeries[] { ): RecentChangeSeries[] {
const dates = summarizeRecentChanges(items, period).map((point) => point.date); const dates = summarizeRecentChanges(items, period).map(
(point) => point.date,
);
const byClient = new Map< const byClient = new Map<
string, string,
{ {
@@ -937,7 +940,7 @@ function GlobalOverviewPage() {
const [visibleRecentClientChangesCount, setVisibleRecentClientChangesCount] = const [visibleRecentClientChangesCount, setVisibleRecentClientChangesCount] =
useState(6); useState(6);
const [isRecentChangesDetailOpen, setIsRecentChangesDetailOpen] = const [isRecentChangesDetailOpen, setIsRecentChangesDetailOpen] =
useState(false); useState(true);
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90; const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
const statsQuery = useQuery({ const statsQuery = useQuery({
queryKey: ["dev-dashboard-stats"], queryKey: ["dev-dashboard-stats"],
@@ -1112,8 +1115,7 @@ function GlobalOverviewPage() {
), ),
[currentClientIdSet, recentClientChangesWithActors], [currentClientIdSet, recentClientChangesWithActors],
); );
const recentChangeFilterOptions = useMemo<ClientFilterOption[]>( const recentChangeFilterOptions = useMemo<ClientFilterOption[]>(() => {
() => {
const activeOptions = Array.from( const activeOptions = Array.from(
new Map( new Map(
recentClientChangesWithActors recentClientChangesWithActors
@@ -1133,19 +1135,14 @@ function GlobalOverviewPage() {
...activeOptions, ...activeOptions,
{ {
id: deletedRecentChangeFilterId, id: deletedRecentChangeFilterId,
label: t( label: t("ui.dev.dashboard.recent_changes.deleted_group", "삭제된 앱"),
"ui.dev.dashboard.recent_changes.deleted_group",
"삭제된 앱",
),
}, },
]; ];
}, }, [
[
currentClientIdSet, currentClientIdSet,
deletedRecentChangeClientIds.length, deletedRecentChangeClientIds.length,
recentClientChangesWithActors, recentClientChangesWithActors,
], ]);
);
const filteredRecentClientChanges = useMemo(() => { const filteredRecentClientChanges = useMemo(() => {
if (selectedRecentChangeClientIds.length === 0) { if (selectedRecentChangeClientIds.length === 0) {
return recentClientChangesWithActors; return recentClientChangesWithActors;
@@ -1155,7 +1152,8 @@ function GlobalOverviewPage() {
return recentClientChangesWithActors.filter( return recentClientChangesWithActors.filter(
(item) => (item) =>
selectedSet.has(item.clientId) || selectedSet.has(item.clientId) ||
(includeDeletedGroup && deletedRecentChangeClientIds.includes(item.clientId)), (includeDeletedGroup &&
deletedRecentChangeClientIds.includes(item.clientId)),
); );
}, [ }, [
deletedRecentChangeClientIds, deletedRecentChangeClientIds,
@@ -1163,7 +1161,8 @@ function GlobalOverviewPage() {
selectedRecentChangeClientIds, selectedRecentChangeClientIds,
]); ]);
const selectedRecentChangeSeries = useMemo( const selectedRecentChangeSeries = useMemo(
() => buildRecentChangeSeries(filteredRecentClientChanges, recentChangesPeriod), () =>
buildRecentChangeSeries(filteredRecentClientChanges, recentChangesPeriod),
[filteredRecentClientChanges, recentChangesPeriod], [filteredRecentClientChanges, recentChangesPeriod],
); );
const recentChangedClientCount = useMemo( const recentChangedClientCount = useMemo(
@@ -1180,7 +1179,9 @@ function GlobalOverviewPage() {
new Set( new Set(
filteredRecentClientChanges filteredRecentClientChanges
.map((item) => item.clientId) .map((item) => item.clientId)
.filter((clientId) => deletedRecentChangeClientIds.includes(clientId)), .filter((clientId) =>
deletedRecentChangeClientIds.includes(clientId),
),
).size, ).size,
[deletedRecentChangeClientIds, filteredRecentClientChanges], [deletedRecentChangeClientIds, filteredRecentClientChanges],
); );
@@ -1254,7 +1255,7 @@ function GlobalOverviewPage() {
setVisibleRecentClientChangesCount((current) => setVisibleRecentClientChangesCount((current) =>
Math.min(Math.max(6, current), filteredRecentClientChanges.length), Math.min(Math.max(6, current), filteredRecentClientChanges.length),
); );
}, [filteredRecentClientChanges.length, selectedRecentChangeClientIds]); }, [filteredRecentClientChanges.length]);
if (isLoadingDeveloperAccessGate) { if (isLoadingDeveloperAccessGate) {
return ( return (
@@ -1533,8 +1534,9 @@ function GlobalOverviewPage() {
</div> </div>
) : ( ) : (
visibleRecentClientChanges.map((item) => { visibleRecentClientChanges.map((item) => {
const { date, time } = const { date, time } = formatRecentChangeTimestamp(
formatRecentChangeTimestamp(item.timestamp); item.timestamp,
);
return ( return (
<div <div
key={item.eventId} key={item.eventId}
@@ -1600,7 +1602,6 @@ function GlobalOverviewPage() {
</div> </div>
) : null} ) : null}
</section> </section>
</div> </div>
); );
} }

View File

@@ -56,7 +56,9 @@ describe("recent client changes", () => {
mockLocale("en"); mockLocale("en");
expect(getRecentClientActionLabel("CREATE_CLIENT")).toBe("App creation"); 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( expect(getRecentClientActionLabel("UPDATE_CLIENT_STATUS")).toBe(
"Status changes", "Status changes",
); );
@@ -64,7 +66,9 @@ describe("recent client changes", () => {
"Client secret rotation", "Client secret rotation",
); );
expect(getRecentClientActionLabel("ADD_RELATION")).toBe("Add Relationship"); 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("DELETE_CLIENT")).toBe("App deletion");
expect(getRecentClientActionLabel("OTHER_ACTION")).toBe("OTHER_ACTION"); expect(getRecentClientActionLabel("OTHER_ACTION")).toBe("OTHER_ACTION");
@@ -90,12 +94,26 @@ describe("recent client changes", () => {
it("builds recent client changes with sorting, filtering, and detail slicing", () => { it("builds recent client changes with sorting, filtering, and detail slicing", () => {
mockLocale("ko"); mockLocale("ko");
const clients = [makeClient("client-a", "Alpha"), makeClient("client-b", "")]; const clients = [
makeClient("client-a", "Alpha"),
makeClient("client-b", ""),
];
const auditLogs = [ const auditLogs = [
makeAuditLog("evt-1", "2026-05-27T07:00:00.000Z", "CREATE_CLIENT", "client-a", { makeAuditLog(
"evt-1",
"2026-05-27T07:00:00.000Z",
"CREATE_CLIENT",
"client-a",
{
after: { name: "Alpha", type: "private", status: "active" }, after: { name: "Alpha", type: "private", status: "active" },
}), },
makeAuditLog("evt-2", "2026-05-27T08:00:00.000Z", "UPDATE_CLIENT", "client-a", { ),
makeAuditLog(
"evt-2",
"2026-05-27T08:00:00.000Z",
"UPDATE_CLIENT",
"client-a",
{
before: { before: {
name: "Alpha old", name: "Alpha old",
status: "inactive", status: "inactive",
@@ -108,41 +126,75 @@ describe("recent client changes", () => {
sameField: "same", sameField: "same",
newField: "new-value", newField: "new-value",
}, },
}), },
makeAuditLog("evt-3", "2026-05-27T09:00:00.000Z", "UPDATE_CLIENT_STATUS", "client-a", { ),
makeAuditLog(
"evt-3",
"2026-05-27T09:00:00.000Z",
"UPDATE_CLIENT_STATUS",
"client-a",
{
before: { status: "inactive" }, before: { status: "inactive" },
after: { status: "active" }, after: { status: "active" },
}), },
makeAuditLog("evt-4", "2026-05-27T10:00:00.000Z", "ADD_RELATION", "client-b", { ),
makeAuditLog(
"evt-4",
"2026-05-27T10:00:00.000Z",
"ADD_RELATION",
"client-b",
{
after: { after: {
relation: "audit_viewer", relation: "audit_viewer",
subject: "User:89692983-f512-4d96-845d-ac6123d08b95", subject: "User:89692983-f512-4d96-845d-ac6123d08b95",
}, },
}), },
makeAuditLog("evt-5", "2026-05-27T11:00:00.000Z", "REMOVE_RELATION", "client-b", { ),
makeAuditLog(
"evt-5",
"2026-05-27T11:00:00.000Z",
"REMOVE_RELATION",
"client-b",
{
before: { before: {
relation: "admins", relation: "admins",
subject: "User:89692983-f512-4d96-845d-ac6123d08b95", subject: "User:89692983-f512-4d96-845d-ac6123d08b95",
}, },
}), },
makeAuditLog("evt-6", "2026-05-27T12:00:00.000Z", "ROTATE_SECRET", "client-a", { ),
makeAuditLog(
"evt-6",
"2026-05-27T12:00:00.000Z",
"ROTATE_SECRET",
"client-a",
{
after: {}, after: {},
}), },
makeAuditLog("evt-7", "2026-05-27T13:00:00.000Z", "DELETE_CLIENT", "client-a", { ),
makeAuditLog(
"evt-7",
"2026-05-27T13:00:00.000Z",
"DELETE_CLIENT",
"client-a",
{
before: { before: {
name: "Alpha", name: "Alpha",
status: "inactive", status: "inactive",
}, },
}), },
makeAuditLog("evt-8", "2026-05-27T14:00:00.000Z", "UNSUPPORTED_ACTION", "client-a", { ),
makeAuditLog(
"evt-8",
"2026-05-27T14:00:00.000Z",
"UNSUPPORTED_ACTION",
"client-a",
{
after: { name: "Ignored" }, after: { name: "Ignored" },
}), },
),
]; ];
const changes = buildRecentClientChanges( const changes = buildRecentClientChanges(auditLogs, clients);
auditLogs,
clients,
);
expect(changes).toHaveLength(7); expect(changes).toHaveLength(7);
expect(changes[0]).toMatchObject({ expect(changes[0]).toMatchObject({
@@ -164,7 +216,7 @@ describe("recent client changes", () => {
expect(changes[2]).toMatchObject({ expect(changes[2]).toMatchObject({
eventId: "evt-5", eventId: "evt-5",
clientName: "client-b", clientName: "client-b",
actionLabel: "제", actionLabel: "관계 삭제",
detailLabels: [ detailLabels: [
{ label: "관계", value: "admins" }, { label: "관계", value: "admins" },
{ {

View File

@@ -49,7 +49,7 @@ export function getRecentClientActionLabel(action: string) {
case "ADD_RELATION": case "ADD_RELATION":
return t("ui.dev.clients.relationships.add_title", "관계 추가"); return t("ui.dev.clients.relationships.add_title", "관계 추가");
case "REMOVE_RELATION": case "REMOVE_RELATION":
return t("ui.common.remove", "Remove"); return t("ui.dev.clients.relationships.remove_title", "관계 삭제");
case "DELETE_CLIENT": case "DELETE_CLIENT":
return t("ui.dev.clients.recent_changes.guide.delete", "앱 삭제"); return t("ui.dev.clients.recent_changes.guide.delete", "앱 삭제");
default: default:
@@ -68,7 +68,7 @@ function getRecentClientFieldLabel(key: string) {
case "relation": case "relation":
return t("ui.dev.clients.relationships.relation", "관계"); return t("ui.dev.clients.relationships.relation", "관계");
case "subject": case "subject":
return t("ui.dev.clients.relationships.subject", "대상"); return t("ui.dev.clients.relationships.subject", "주체");
case "client_secret": case "client_secret":
return t( return t(
"ui.dev.clients.details.credentials.client_secret", "ui.dev.clients.details.credentials.client_secret",

View File

@@ -32,8 +32,9 @@ describe("apiClient", () => {
beforeEach(() => { beforeEach(() => {
vi.resetModules(); vi.resetModules();
vi.stubEnv("MODE", "test"); 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(); window.localStorage.clear();
getUserMock.mockResolvedValue(null); getUserMock.mockResolvedValue(null);
findPersistedOidcUserMock.mockReturnValue(undefined); findPersistedOidcUserMock.mockReturnValue(undefined);
@@ -47,7 +48,8 @@ describe("apiClient", () => {
window.localStorage.setItem("dev_tenant_id", "tenant-1"); window.localStorage.setItem("dev_tenant_id", "tenant-1");
const { default: apiClient } = await import("./apiClient"); 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: {} }); const result = await requestHandler?.({ headers: {} });
@@ -58,7 +60,8 @@ describe("apiClient", () => {
it("rejects non-auth response errors without redirecting", async () => { it("rejects non-auth response errors without redirecting", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { default: apiClient } = await import("./apiClient"); 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" } } }; const error = { response: { status: 500, data: { error: "boom" } } };
await expect(responseHandler?.(error)).rejects.toBe(error); await expect(responseHandler?.(error)).rejects.toBe(error);
@@ -69,7 +72,8 @@ describe("apiClient", () => {
it("warns and rejects auth failures in test mode", async () => { it("warns and rejects auth failures in test mode", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const { default: apiClient } = await import("./apiClient"); const { default: apiClient } = await import("./apiClient");
const responseHandler = apiClient.interceptors.response.handlers[0]?.rejected; const responseHandler =
apiClient.interceptors.response.handlers[0]?.rejected;
const error = { const error = {
response: { response: {
status: 403, status: 403,

View File

@@ -1601,6 +1601,7 @@ revoke_cache = "Revoke Cache"
[ui.dev.clients.relationships] [ui.dev.clients.relationships]
title = "Client Relationships" title = "Client Relationships"
add_title = "Add Relationship" add_title = "Add Relationship"
remove_title = "Remove Relationship"
relation = "Relation" relation = "Relation"
user_id = "User ID" user_id = "User ID"
user_id_placeholder = "kratos user id" user_id_placeholder = "kratos user id"

View File

@@ -1600,6 +1600,7 @@ revoke_cache = "캐시 삭제"
[ui.dev.clients.relationships] [ui.dev.clients.relationships]
title = "클라이언트 관계" title = "클라이언트 관계"
add_title = "관계 추가" add_title = "관계 추가"
remove_title = "관계 삭제"
relation = "관계" relation = "관계"
user_id = "사용자 ID" user_id = "사용자 ID"
user_id_placeholder = "kratos 사용자 id" user_id_placeholder = "kratos 사용자 id"

View File

@@ -1655,6 +1655,7 @@ revoke_cache = ""
[ui.dev.clients.relationships] [ui.dev.clients.relationships]
title = "" title = ""
add_title = "" add_title = ""
remove_title = ""
relation = "" relation = ""
user_id = "" user_id = ""
user_id_placeholder = "" user_id_placeholder = ""

View File

@@ -37,7 +37,9 @@ test("clients page loads correctly", async ({ page }) => {
// 페이지 내 주요 텍스트 확인 // 페이지 내 주요 텍스트 확인
await expect(page.getByText("연동 앱 목록")).toBeVisible(); 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( 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) => const clients = Array.from({ length: 6 }, (_, index) =>
makeClient(`client-${index + 1}`, { makeClient(`client-${index + 1}`, {
name: `Preview App ${index + 1}`, name: `Preview App ${index + 1}`,
createdAt: new Date( createdAt: new Date(Date.UTC(2026, 2, 3, 9, 10 - index, 0)).toISOString(),
Date.UTC(2026, 2, 3, 9, 10 - index, 0),
).toISOString(),
}), }),
); );
@@ -126,7 +126,11 @@ test("clients page shows only five apps by default and expands with more button"
page.getByRole("heading", { name: "연동 앱 목록" }), page.getByRole("heading", { name: "연동 앱 목록" }),
).toBeVisible(); ).toBeVisible();
await expect( await expect(
page.locator("table").first().locator("tbody tr").filter({ page
.locator("table")
.first()
.locator("tbody tr")
.filter({
hasText: /Preview App \d/, hasText: /Preview App \d/,
}), }),
).toHaveCount(5); ).toHaveCount(5);
@@ -142,13 +146,15 @@ test("clients page shows only five apps by default and expands with more button"
await moreButton.click(); await moreButton.click();
await expect( await expect(
page.locator("table").first().locator("tbody tr").filter({ page
.locator("table")
.first()
.locator("tbody tr")
.filter({
hasText: /Preview App \d/, hasText: /Preview App \d/,
}), }),
).toHaveCount(6); ).toHaveCount(6);
await expect( await expect(page.getByText("Preview App 6", { exact: true })).toBeVisible();
page.getByText("Preview App 6", { exact: true }),
).toBeVisible();
await expect( await expect(
page.getByRole("button", { name: "연동 앱 목록 더보기" }), page.getByRole("button", { name: "연동 앱 목록 더보기" }),
).toHaveCount(0); ).toHaveCount(0);
@@ -205,15 +211,13 @@ test("overview page shows user-delete relation cleanup in recent changes", async
).toBeVisible(); ).toBeVisible();
await expect(page.getByText("관계 삭제", { exact: true })).toBeVisible(); await expect(page.getByText("관계 삭제", { exact: true })).toBeVisible();
await expect(page.getByText(/관계:\s*config_editor/)).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( await expect(
page.getByText("cleanup-actor", { exact: true }).first(), page.getByText("cleanup-actor", { exact: true }).first(),
).toBeVisible(); ).toBeVisible();
}); });
test("clients page no longer shows recent changes card", async ({ test("clients page no longer shows recent changes card", async ({ page }) => {
page,
}) => {
await seedAuth(page, "super_admin"); await seedAuth(page, "super_admin");
const clients = Array.from({ length: 6 }, (_, index) => const clients = Array.from({ length: 6 }, (_, index) =>
makeClient(`client-${index + 1}`, { makeClient(`client-${index + 1}`, {

View File

@@ -45,10 +45,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: characters name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.4.1" version: "1.4.0"
cli_config: cli_config:
dependency: transitive dependency: transitive
description: description:
@@ -268,6 +268,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.5" version: "1.0.5"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
leak_tracker: leak_tracker:
dependency: transitive dependency: transitive
description: description:
@@ -320,18 +328,18 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: matcher name: matcher
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.12.19" version: "0.12.17"
material_color_utilities: material_color_utilities:
dependency: transitive dependency: transitive
description: description:
name: material_color_utilities name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.13.0" version: "0.11.1"
meta: meta:
dependency: transitive dependency: transitive
description: description:
@@ -653,26 +661,26 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: test name: test
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.30.0" version: "1.26.3"
test_api: test_api:
dependency: transitive dependency: transitive
description: description:
name: test_api name: test_api
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.10" version: "0.7.7"
test_core: test_core:
dependency: transitive dependency: transitive
description: description:
name: test_core name: test_core
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.6.16" version: "0.6.12"
toml: toml:
dependency: "direct main" dependency: "direct main"
description: description: