- {t( - "ui.admin.user_projection.title", - "User Projection Management", - )} -
-- {t( - "msg.admin.user_projection.subtitle", - "Review and sync the Kratos user read model.", - )} -
-+ {t("ui.admin.user_projection.title", "User Projection Management")} +
++ {t( + "msg.admin.user_projection.subtitle", + "Review and sync the Kratos user read model.", + )} +
- {t( - "ui.admin.tenants.profile.allowed_domains_help", - "이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.", - )} -
-- {t( - "ui.dev.clients.tenant_scoped", - "Tenant-scoped", - )} +
- {t( - "msg.dev.clients.recent_changes.permission_note", - "'감사 로그 조회' 관계가 있어야 최근 변경된 앱을 볼 수 있습니다.", - )} -
- {isRecentChangesGuideOpen && ( -- {t( - "ui.dev.clients.recent_changes.guide_title", - "최근 변경 항목 안내", - )} -
-- {t(item.titleKey, item.titleFallback)} -
-- {t(item.descriptionKey, item.descriptionFallback)} -
-- {t( - "msg.dev.clients.recent_changes.guide.audit_only", - "동의 철회는 최근 변경된 앱 카드에 포함하지 않고, 감사 로그에서 확인합니다.", - )} -
-
- {item.clientId}
-
- {item.actorName}
-
- {item.actorId}
-
- - {date} {time} -
-
+
{t(
"ui.dev.dashboard.chart.title",
"애플리케이션별 로그인요청/기타 요청 현황",
@@ -756,116 +1403,205 @@ 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]) => (
+ setRecentChangesPeriod(value as RPUsagePeriod)}
+ className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
+ recentChangesPeriod === value
+ ? "bg-primary text-primary-foreground"
+ : "bg-muted/60 hover:bg-muted"
+ }`}
>
-
-
- {client.name || t("ui.dev.clients.untitled", "Untitled")}
-
-
- {client.id}
-
-
-
-
- {client.metadata?.headless_login_enabled === true
- ? t(
- "ui.dev.clients.type.private_headless",
- "Server side App (Headless Login)",
- )
- : client.type === "private"
- ? t("ui.dev.clients.type.private", "Server side App")
- : t("ui.dev.clients.type.pkce", "PKCE")}
-
-
- {client.status === "active"
- ? t("ui.dev.clients.status.active", "활성")
- : client.status === "inactive"
- ? t("ui.dev.clients.status.inactive", "비활성")
- : client.status || "-"}
-
-
- {t("ui.dev.clients.table.created_at", "생성일")}{" "}
- {formatDate(client.createdAt)}
-
-
-
- ))
- )}
+ {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",
+ "삭제된 앱 수",
+ )}
+ value={deletedRecentChangedClientCount.toLocaleString()}
+ />
+ }
+ label={t(
+ "ui.dev.dashboard.recent_changes.summary.latest_change",
+ "마지막 변경일",
+ )}
+ value={formatDate(latestRecentChange?.timestamp)}
+ />
+
+
+
+
+ {isAllRecentChangeClientsSelected ? (
+
+ ) : (
+
+ )}
+
+ setIsRecentChangesDetailOpen((current) => !current)}
+ >
+
+ {isRecentChangesDetailOpen
+ ? t("ui.common.collapse", "접기")
+ : t("ui.common.details", "상세정보")}
+
+
+
+
+ {isRecentChangesDetailOpen ? (
+
+
+ {filteredRecentClientChanges.length === 0 ? (
+
+ {t(
+ "msg.dev.dashboard.recent_changes.empty",
+ "최근 변경 로그가 아직 없습니다.",
+ )}
+
+ ) : (
+ visibleRecentClientChanges.map((item) => {
+ const { date, time } = formatRecentChangeTimestamp(
+ 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 ? (
+
+
+ setVisibleRecentClientChangesCount((current) =>
+ Math.min(
+ current + 6,
+ filteredRecentClientChanges.length,
+ ),
+ )
+ }
+ >
+ {t("ui.common.load_more", "더 보기")}
+
+
+ ) : null}
+
+
+ ) : null}
+
);
}
diff --git a/devfront/src/features/overview/recentClientChanges.test.ts b/devfront/src/features/overview/recentClientChanges.test.ts
new file mode 100644
index 00000000..b69c9a28
--- /dev/null
+++ b/devfront/src/features/overview/recentClientChanges.test.ts
@@ -0,0 +1,253 @@
+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 Relationship",
+ );
+ 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
new file mode 100644
index 00000000..2084a226
--- /dev/null
+++ b/devfront/src/features/overview/recentClientChanges.ts
@@ -0,0 +1,203 @@
+import {
+ formatAuditValue,
+ parseAuditDetails,
+ resolveAuditActor,
+ type AuditDetails,
+ type CommonAuditLog,
+} from "../../../../common/core/audit";
+import { t } from "../../lib/i18n";
+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",
+]);
+
+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 t("ui.dev.clients.recent_changes.guide.create", "앱 생성");
+ case "UPDATE_CLIENT":
+ return t("ui.dev.clients.recent_changes.guide.settings", "설정 변경");
+ case "UPDATE_CLIENT_STATUS":
+ return t("ui.dev.clients.recent_changes.guide.status", "상태 변경");
+ case "ROTATE_SECRET":
+ return t(
+ "ui.dev.clients.recent_changes.guide.secret",
+ "클라이언트 시크릿 재발급",
+ );
+ case "ADD_RELATION":
+ return t("ui.dev.clients.relationships.add_title", "관계 추가");
+ case "REMOVE_RELATION":
+ return t("ui.dev.clients.relationships.remove_title", "관계 삭제");
+ case "DELETE_CLIENT":
+ return t("ui.dev.clients.recent_changes.guide.delete", "앱 삭제");
+ default:
+ return action;
+ }
+}
+
+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":
+ 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,
+) {
+ const before = isRecord(details.before) ? details.before : {};
+ const after = isRecord(details.after) ? details.after : {};
+
+ if (action === "ROTATE_SECRET") {
+ return [
+ {
+ label: getRecentClientFieldLabel("client_secret"),
+ value: t("msg.dev.clients.details.secret_rotated", "재발급"),
+ },
+ ];
+ }
+
+ if (action === "ADD_RELATION" || action === "REMOVE_RELATION") {
+ const source = action === "ADD_RELATION" ? after : before;
+ return [
+ ...(source.relation
+ ? [
+ {
+ label: getRecentClientFieldLabel("relation"),
+ value: formatAuditValue(source.relation),
+ },
+ ]
+ : []),
+ ...(source.subject
+ ? [
+ {
+ label: getRecentClientFieldLabel("subject"),
+ 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 = getRecentClientFieldLabel(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/lib/apiClient.test.ts b/devfront/src/lib/apiClient.test.ts
new file mode 100644
index 00000000..7ac620b9
--- /dev/null
+++ b/devfront/src/lib/apiClient.test.ts
@@ -0,0 +1,88 @@
+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("");
+ });
+});
diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml
index b94625b6..92eb923b 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"
@@ -1597,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"
@@ -1741,6 +1746,20 @@ title = "Quick links"
[ui.dev.dashboard.recent]
title = "My Applications"
+[ui.dev.dashboard.recent_changes]
+deleted_group = "Deleted applications"
+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 applications"
+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..2e1963b6 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에서만 적용"
@@ -1596,6 +1600,7 @@ revoke_cache = "캐시 삭제"
[ui.dev.clients.relationships]
title = "클라이언트 관계"
add_title = "관계 추가"
+remove_title = "관계 삭제"
relation = "관계"
user_id = "사용자 ID"
user_id_placeholder = "kratos 사용자 id"
@@ -1740,6 +1745,20 @@ title = "빠른 이동"
[ui.dev.dashboard.recent]
title = "내 애플리케이션"
+[ui.dev.dashboard.recent_changes]
+deleted_group = "삭제된 앱"
+aria = "최근 변경된 앱 현황"
+period = "최근 변경 집계 단위"
+series = "변경 {{changes}} / 작업자 {{actors}}"
+title = "최근 변경된 앱"
+y_axis = "Y축: 변경 수"
+
+[ui.dev.dashboard.recent_changes.summary]
+changed_clients = "변경된 앱 수"
+deleted_clients = "삭제된 앱 수"
+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..4f3295de 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 = ""
@@ -1651,6 +1655,7 @@ revoke_cache = ""
[ui.dev.clients.relationships]
title = ""
add_title = ""
+remove_title = ""
relation = ""
user_id = ""
user_id_placeholder = ""
@@ -1797,6 +1802,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 157e10d2..af5b5491 100644
--- a/devfront/tests/clients.spec.ts
+++ b/devfront/tests/clients.spec.ts
@@ -37,6 +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(
@@ -47,7 +50,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 +92,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();
@@ -100,7 +103,64 @@ test("clients page shows recent RP changes", async ({ page }) => {
).toBeVisible();
});
-test("clients page shows user-delete relation cleanup in recent changes", async ({
+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("overview page shows user-delete relation cleanup in recent changes", async ({
page,
}) => {
await seedAuth(page, "super_admin");
@@ -142,7 +202,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();
@@ -151,15 +211,13 @@ test("clients 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 expands recent changes with more button", 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}`, {
@@ -193,23 +251,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);
});
diff --git a/docs/worksmobile-halla-domain-migration-plan.md b/docs/worksmobile-halla-domain-migration-plan.md
new file mode 100644
index 00000000..bd9c3091
--- /dev/null
+++ b/docs/worksmobile-halla-domain-migration-plan.md
@@ -0,0 +1,126 @@
+# 한라 WORKS 도메인 분리 및 조직 연동 계획
+
+## 현황 확인
+
+- 로컬 seed 기준 `halla`는 `hanmac-family` 직속 `COMPANY`입니다.
+- 로컬 DB 기준 `halla`는 `hanmac-family` 직속이 맞지만, 타입은 현재 `ORGANIZATION`입니다.
+- 한라 하위 테넌트는 로컬 DB 기준 전체 43개입니다.
+- 한라 직속 하위 조직은 10개입니다.
+ - 경영지원본부
+ - 기반사업본부
+ - 기술영업본부
+ - 시공현장
+ - 안전관리본부
+ - 업무총괄
+ - 영업총괄
+ - 운영사업소
+ - 임원실
+ - 환경플랜트사업본부
+
+## 현재 코드의 문제
+
+현재 WORKS 도메인 분류는 다음 네 가지 도메인만 알고 있습니다.
+
+- `SAMAN_DOMAIN_ID`
+- `HANMAC_DOMAIN_ID`
+- `GPDTDC_DOMAIN_ID`
+- `BARONGROUP_DOMAIN_ID`
+
+`HALLA_DOMAIN_ID`가 없기 때문에 `halla`와 `hallasanup.com`은 별도 도메인 루트로 판정되지 않습니다. 이 상태에서 조직 연동을 실행하면 한라 하위 조직이 HALLA 도메인이 아니라 fallback 도메인으로 분류될 수 있습니다.
+
+또한 로컬 DB에서 `halla` 타입이 `ORGANIZATION`이면 “별도 회사 도메인 루트”라는 의미가 약합니다. seed와 실제 DB를 `COMPANY`로 맞추는 마이그레이션이 필요합니다.
+
+## 목표 구조
+
+Baron 내부 구조:
+
+- `hanmac-family`
+ - `halla` (`COMPANY`, WORKS domain root, `HALLA_DOMAIN_ID`, `hallasanup.com`)
+ - 한라 하위 조직들 (`ORGANIZATION`)
+
+한맥가족 직속 회사/그룹 배치 순서:
+
+1. `gpdtdc` - 총괄기획&기술개발센터
+2. `saman` - 삼안
+3. `hanmac` - 한맥기술
+4. `baron-group` - 바론그룹
+5. `halla` - 한라산업개발
+
+WORKS Mobile 구조:
+
+- `HALLA_DOMAIN_ID`
+ - 한라 depth 1 조직은 HALLA 도메인의 최상위 org unit으로 생성합니다.
+ - 한라 depth 2 이상 조직은 Baron의 부모 조직 external key를 따라 하위 org unit으로 생성합니다.
+ - `halla` 회사 테넌트 자체는 WORKS org unit으로 만들지 않습니다. 도메인 루트 분류 기준으로만 사용합니다.
+
+예상 매핑:
+
+- `halla-mgmt-support-hq` -> `HALLA_DOMAIN_ID`, `parentOrgUnitId=""`
+- `halla-mgmt-support` -> `HALLA_DOMAIN_ID`, `parentOrgUnitId="externalKey:d656c134-a50b-43b9-8c2d-fb3738dd0f9f"`
+- `site-gyeongsan-road` -> `HALLA_DOMAIN_ID`, 실제 Baron parent external key 유지
+
+## 구현 계획
+
+1. 도메인 루트 판정 추가
+ - `isWorksmobileDomainRootTenant`에 `halla`, `hallasanup.com`, `한라산업개발`을 추가합니다.
+ - `worksmobileTenantDomainIDEnvKey`가 한라 테넌트를 `HALLA_DOMAIN_ID`로 반환하도록 추가합니다.
+
+2. 이메일 도메인 판정 추가
+ - `ResolveWorksmobileAccountDomainIDFromEmail`에 `hallasanup.com -> HALLA_DOMAIN_ID`를 추가합니다.
+ - `worksmobileDomainIDEnvKeyFromEmail`에도 같은 매핑을 추가합니다.
+ - 사용자 주 이메일 또는 alias가 `hallasanup.com`이면 HALLA 계정/조직 이메일로 매핑되도록 검증합니다.
+
+3. WORKS remote 조회 범위 추가
+ - `worksmobileDomainEnvMappings`에 `HALLA_DOMAIN_ID`와 label `한라산업개발`을 추가합니다.
+ - `WorksmobileDomainIDsFromEnv`가 HALLA 도메인도 remote user/group 조회 대상으로 포함해야 합니다.
+
+4. 로컬 데이터 마이그레이션
+ - `halla`가 `hanmac-family` 직속인지 확인합니다.
+ - `halla` 타입을 `COMPANY`로 맞춥니다.
+ - `halla` 도메인을 `hallasanup.com`으로 유지합니다.
+ - 필요한 경우 `halla` 관련 기존 WORKS outbox pending 작업을 정리하거나 재등록합니다.
+
+5. 조직 연동 순서
+ - 먼저 comparison dry-run으로 HALLA 도메인 예상값을 확인합니다.
+ - 한라 하위 조직만 대상으로 org unit upsert를 등록합니다.
+ - WORKS에서 같은 external key가 다른 도메인에 이미 붙어 있으면 기존 도메인 external key를 clear한 뒤 HALLA 도메인에 재등록합니다.
+ - 조직 연동 성공 후 사용자 연동을 진행합니다.
+
+6. 사용자 연동 기준
+ - Baron representative/primary가 `halla` 또는 한라 하위 조직이면 `HALLA_DOMAIN_ID` 조직 membership을 생성합니다.
+ - 주 이메일이 `hallasanup.com`이면 HALLA domain account로 생성합니다.
+ - 다른 회사 주 이메일을 가진 겸직 사용자는 계정 domain은 주 이메일 기준으로 유지하고, HALLA 조직은 `organizations[]`의 추가 조직으로 매핑합니다.
+
+## 테스트 계획
+
+- `worksmobile_mapper_test.go`
+ - `halla`와 `hallasanup.com`이 `HALLA_DOMAIN_ID`로 resolve되는지 검증합니다.
+ - `hallasanup.com` 이메일이 HALLA account domain으로 resolve되는지 검증합니다.
+ - `WorksmobileDomainIDsFromEnv`에 HALLA 도메인이 포함되는지 검증합니다.
+
+- `worksmobile_sync_service_test.go`
+ - 한맥가족 직속 회사 `halla`를 domain root로 판정하는 테스트를 추가합니다.
+ - 한라 depth 1 조직은 `parentOrgUnitId=""`로 생성되는지 검증합니다.
+ - 한라 depth 2 조직은 `parentOrgUnitId="externalKey:"`로 생성되는지 검증합니다.
+ - comparison에서 같은 external key가 다른 domain에 있으면 HALLA domain 기준으로 update/rekey 대상이 되는지 검증합니다.
+
+- live/E2E
+ - `HALLA_DOMAIN_ID`가 설정된 환경에서 한라 하위 조직 org unit provisioning dry-run을 실행합니다.
+ - 실제 upsert 후 worksmobile 메뉴의 최근 작업에서 processed/failed 및 실패 사유를 확인합니다.
+
+## 운영 순서 제안
+
+1. `HALLA_DOMAIN_ID` 환경값을 dev/local에 먼저 설정합니다.
+2. 코드에 HALLA 도메인 분류를 추가하고 테스트를 통과시킵니다.
+3. 로컬 DB의 `halla` 타입을 `COMPANY`로 마이그레이션합니다.
+4. worksmobile comparison에서 한라 하위 조직만 필터링해 예상 도메인과 parent를 확인합니다.
+5. 조직 upsert를 먼저 수행합니다.
+6. 실패 작업이 있으면 최근 작업 이력에서 원인을 확인하고 external key 충돌부터 해소합니다.
+7. 조직이 모두 정상 처리된 뒤 사용자 sync를 진행합니다.
+
+## 주의점
+
+- `halla` 회사 테넌트 자체를 org unit으로 만들면 HALLA 도메인 최상위에 “한라산업개발” 조직이 중복으로 생길 수 있습니다.
+- 기존에 한라 하위 조직 external key가 `BARONGROUP_DOMAIN_ID`에 생성되어 있으면, WORKS API가 같은 external key의 다른 domain 중복을 허용하지 않을 수 있습니다.
+- 사용자 sync는 조직 upsert가 끝난 뒤 진행해야 `organizations[].orgUnits[].orgUnitId` 참조 실패를 줄일 수 있습니다.
+- 로컬 DB 타입과 seed 타입이 다르면 이후 seed/마이그레이션 테스트가 계속 흔들릴 수 있으므로 DB 보정이 먼저 필요합니다.
diff --git a/locales/en.toml b/locales/en.toml
index bed58b50..48d6f898 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 applications"
+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 applications"
+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..cf67c4ea 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 = "삭제된 앱"
+period = "최근 변경 집계 단위"
+series = "변경 {{changes}} / 작업자 {{actors}}"
+title = "최근 변경된 앱"
+y_axis = "Y축: 변경 수"
+
+[ui.dev.dashboard.recent_changes.summary]
+changed_clients = "변경된 앱 수"
+deleted_clients = "삭제된 앱 수"
+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 = ""
diff --git a/orgfront/src/features/orgchart/hanmacFamilyOrder.test.ts b/orgfront/src/features/orgchart/hanmacFamilyOrder.test.ts
index df2df94a..f1eb6b91 100644
--- a/orgfront/src/features/orgchart/hanmacFamilyOrder.test.ts
+++ b/orgfront/src/features/orgchart/hanmacFamilyOrder.test.ts
@@ -1,52 +1,64 @@
import { describe, expect, it } from "vitest";
import {
- getHanmacFamilyTenantOrderRank,
- orderHanmacFamilyChildren,
- orderHanmacFamilyTenants,
+ getHanmacFamilyTenantOrderRank,
+ orderHanmacFamilyChildren,
+ orderHanmacFamilyTenants,
} from "./hanmacFamilyOrder";
function tenant(name: string, slug: string) {
- return { name, slug };
+ return { name, slug };
}
describe("hanmac family organization order", () => {
- it("orders the top hanmac-family siblings by policy", () => {
- const ordered = orderHanmacFamilyTenants([
- tenant("바론그룹", "baron-group"),
- tenant("한맥기술", "hanmac"),
- tenant("삼안", "saman"),
- tenant("총괄기획&기술개발센터", "gpdtdc"),
- ]);
+ it("orders the top hanmac-family siblings by policy", () => {
+ const ordered = orderHanmacFamilyTenants([
+ tenant("한라산업개발", "halla"),
+ tenant("바론그룹", "baron-group"),
+ tenant("한맥기술", "hanmac"),
+ tenant("삼안", "saman"),
+ tenant("총괄기획&기술개발센터", "gpdtdc"),
+ ]);
- expect(ordered.map((item) => item.name)).toEqual([
- "총괄기획&기술개발센터",
- "삼안",
- "한맥기술",
- "바론그룹",
- ]);
- });
+ expect(ordered.map((item) => item.name)).toEqual([
+ "총괄기획&기술개발센터",
+ "삼안",
+ "한맥기술",
+ "바론그룹",
+ "한라산업개발",
+ ]);
+ });
- it("keeps hanmac-family as the root before ordered descendants", () => {
- const family = tenant("한맥가족", "hanmac-family");
- const children = orderHanmacFamilyChildren(family, [
- tenant("바론그룹", "baron-group"),
- tenant("총괄기획&기술개발센터", "gpdtdc"),
- tenant("삼안", "saman"),
- tenant("한맥기술", "hanmac"),
- ]);
+ it("keeps hanmac-family as the root before ordered descendants", () => {
+ const family = tenant("한맥가족", "hanmac-family");
+ const children = orderHanmacFamilyChildren(family, [
+ tenant("바론그룹", "baron-group"),
+ tenant("총괄기획&기술개발센터", "gpdtdc"),
+ tenant("삼안", "saman"),
+ tenant("한라산업개발", "halla"),
+ tenant("한맥기술", "hanmac"),
+ ]);
- expect([family, ...children].map((item) => item.name)).toEqual([
- "한맥가족",
- "총괄기획&기술개발센터",
- "삼안",
- "한맥기술",
- "바론그룹",
- ]);
- });
+ expect([family, ...children].map((item) => item.name)).toEqual([
+ "한맥가족",
+ "총괄기획&기술개발센터",
+ "삼안",
+ "한맥기술",
+ "바론그룹",
+ "한라산업개발",
+ ]);
+ });
- it("does not rank generic technical centers as GPDTDC", () => {
- expect(
- getHanmacFamilyTenantOrderRank(tenant("기술개발센터", "rnd-center")),
- ).toBe(Number.MAX_SAFE_INTEGER);
- });
+ it("does not rank generic technical centers as GPDTDC", () => {
+ expect(
+ getHanmacFamilyTenantOrderRank(
+ tenant("기술개발센터", "rnd-center"),
+ ),
+ ).toBe(Number.MAX_SAFE_INTEGER);
+ });
+
+ it("ranks Halla as the fifth hanmac-family company", () => {
+ expect(
+ getHanmacFamilyTenantOrderRank(tenant("한라산업개발", "halla")),
+ ).toBe(4);
+ });
});
diff --git a/orgfront/src/features/orgchart/hanmacFamilyOrder.ts b/orgfront/src/features/orgchart/hanmacFamilyOrder.ts
index 0a34dc8c..50594280 100644
--- a/orgfront/src/features/orgchart/hanmacFamilyOrder.ts
+++ b/orgfront/src/features/orgchart/hanmacFamilyOrder.ts
@@ -1,65 +1,67 @@
export type HanmacFamilyOrderTenant = {
- name: string;
- slug: string;
+ name: string;
+ slug: string;
};
export const HANMAC_FAMILY_ROOT_SLUG = "hanmac-family";
export const HANMAC_FAMILY_TENANT_ORDER = [
- "gpdtdc",
- "saman",
- "hanmac",
- "baron-group",
+ "gpdtdc",
+ "saman",
+ "hanmac",
+ "baron-group",
+ "halla",
] as const;
function normalizedTenantText(tenant: HanmacFamilyOrderTenant) {
- return `${tenant.slug} ${tenant.name}`.trim().toLowerCase();
+ return `${tenant.slug} ${tenant.name}`.trim().toLowerCase();
}
export function isHanmacFamilyRootTenant(tenant: HanmacFamilyOrderTenant) {
- return (
- tenant.slug.toLowerCase() === HANMAC_FAMILY_ROOT_SLUG ||
- tenant.name.includes("한맥가족")
- );
+ return (
+ tenant.slug.toLowerCase() === HANMAC_FAMILY_ROOT_SLUG ||
+ tenant.name.includes("한맥가족")
+ );
}
export function getHanmacFamilyTenantOrderRank(
- tenant: HanmacFamilyOrderTenant,
+ tenant: HanmacFamilyOrderTenant,
) {
- const text = normalizedTenantText(tenant);
- if (text.includes("gpdtdc") || text.includes("총괄기획")) return 0;
- if (text.includes("saman") || text.includes("삼안")) return 1;
- if (
- (text.includes("hanmac") || text.includes("한맥기술")) &&
- !isHanmacFamilyRootTenant(tenant)
- ) {
- return 2;
- }
- if (text.includes("baron-group") || text.includes("바론그룹")) return 3;
- return Number.MAX_SAFE_INTEGER;
+ const text = normalizedTenantText(tenant);
+ if (text.includes("gpdtdc") || text.includes("총괄기획")) return 0;
+ if (text.includes("saman") || text.includes("삼안")) return 1;
+ if (
+ (text.includes("hanmac") || text.includes("한맥기술")) &&
+ !isHanmacFamilyRootTenant(tenant)
+ ) {
+ return 2;
+ }
+ if (text.includes("baron-group") || text.includes("바론그룹")) return 3;
+ if (text.includes("halla") || text.includes("한라산업개발")) return 4;
+ return Number.MAX_SAFE_INTEGER;
}
export function compareHanmacFamilyTenants(
- a: T,
- b: T,
+ a: T,
+ b: T,
) {
- const rankDiff =
- getHanmacFamilyTenantOrderRank(a) - getHanmacFamilyTenantOrderRank(b);
- if (rankDiff !== 0) return rankDiff;
- return a.name.localeCompare(b.name);
+ const rankDiff =
+ getHanmacFamilyTenantOrderRank(a) - getHanmacFamilyTenantOrderRank(b);
+ if (rankDiff !== 0) return rankDiff;
+ return a.name.localeCompare(b.name);
}
export function orderHanmacFamilyTenants(
- tenants: readonly T[],
+ tenants: readonly T[],
) {
- return [...tenants].sort(compareHanmacFamilyTenants);
+ return [...tenants].sort(compareHanmacFamilyTenants);
}
export function orderHanmacFamilyChildren(
- parent: HanmacFamilyOrderTenant,
- children: readonly T[],
+ parent: HanmacFamilyOrderTenant,
+ children: readonly T[],
) {
- return isHanmacFamilyRootTenant(parent)
- ? orderHanmacFamilyTenants(children)
- : [...children];
+ return isHanmacFamilyRootTenant(parent)
+ ? orderHanmacFamilyTenants(children)
+ : [...children];
}
diff --git a/orgfront/src/features/orgchart/pickerTree.test.ts b/orgfront/src/features/orgchart/pickerTree.test.ts
index 5d30a618..5d60e445 100644
--- a/orgfront/src/features/orgchart/pickerTree.test.ts
+++ b/orgfront/src/features/orgchart/pickerTree.test.ts
@@ -3,179 +3,237 @@ import type { TenantSummary, UserSummary } from "../../lib/adminApi";
import { buildOrgPickerTree } from "./pickerTree";
function tenant(
- id: string,
- type: string,
- name: string,
- slug: string,
- parentId?: string,
+ id: string,
+ type: string,
+ name: string,
+ slug: string,
+ parentId?: string,
): TenantSummary {
- return {
- id,
- type,
- name,
- slug,
- description: "",
- status: "active",
- parentId,
- memberCount: 0,
- createdAt: "2026-05-11T00:00:00.000Z",
- updatedAt: "2026-05-11T00:00:00.000Z",
- };
+ return {
+ id,
+ type,
+ name,
+ slug,
+ description: "",
+ status: "active",
+ parentId,
+ memberCount: 0,
+ createdAt: "2026-05-11T00:00:00.000Z",
+ updatedAt: "2026-05-11T00:00:00.000Z",
+ };
}
describe("buildOrgPickerTree", () => {
- it("uses the hanmac-family company-group as the default picker root", () => {
- const tenants = [
- tenant("wrong-group", "COMPANY_GROUP", "Wrong Group", "wrong-group"),
- tenant(
- "wrong-company",
- "COMPANY",
- "Wrong Company",
- "wrong-company",
- "wrong-group",
- ),
- tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
- tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
- ];
+ it("uses the hanmac-family company-group as the default picker root", () => {
+ const tenants = [
+ tenant(
+ "wrong-group",
+ "COMPANY_GROUP",
+ "Wrong Group",
+ "wrong-group",
+ ),
+ tenant(
+ "wrong-company",
+ "COMPANY",
+ "Wrong Company",
+ "wrong-company",
+ "wrong-group",
+ ),
+ tenant(
+ "hanmac-family-id",
+ "COMPANY_GROUP",
+ "한맥가족",
+ "hanmac-family",
+ ),
+ tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
+ ];
- const tree = buildOrgPickerTree({
- tenants,
- users: [] satisfies UserSummary[],
+ const tree = buildOrgPickerTree({
+ tenants,
+ users: [] satisfies UserSummary[],
+ });
+
+ expect(tree.companyGroupId).toBe("hanmac-family-id");
+ expect(tree.roots).toHaveLength(1);
+ expect(tree.roots[0]?.id).toBe("hanmac-family-id");
+ expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
+ "saman-id",
+ ]);
});
- expect(tree.companyGroupId).toBe("hanmac-family-id");
- expect(tree.roots).toHaveLength(1);
- expect(tree.roots[0]?.id).toBe("hanmac-family-id");
- expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
- "saman-id",
- ]);
- });
+ it("orders hanmac-family children by the shared organization policy", () => {
+ const tenants = [
+ tenant(
+ "hanmac-family-id",
+ "COMPANY_GROUP",
+ "한맥가족",
+ "hanmac-family",
+ ),
+ tenant(
+ "baron-group-id",
+ "COMPANY_GROUP",
+ "바론그룹",
+ "baron-group",
+ "hanmac-family-id",
+ ),
+ tenant(
+ "hanmac-id",
+ "COMPANY",
+ "한맥기술",
+ "hanmac",
+ "hanmac-family-id",
+ ),
+ tenant(
+ "halla-id",
+ "COMPANY",
+ "한라산업개발",
+ "halla",
+ "hanmac-family-id",
+ ),
+ tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
+ tenant(
+ "gpdtdc-id",
+ "ORGANIZATION",
+ "총괄기획&기술개발센터",
+ "gpdtdc",
+ "hanmac-family-id",
+ ),
+ ];
- it("orders hanmac-family children by the shared organization policy", () => {
- const tenants = [
- tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
- tenant(
- "baron-group-id",
- "COMPANY_GROUP",
- "바론그룹",
- "baron-group",
- "hanmac-family-id",
- ),
- tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"),
- tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
- tenant(
- "gpdtdc-id",
- "ORGANIZATION",
- "총괄기획&기술개발센터",
- "gpdtdc",
- "hanmac-family-id",
- ),
- ];
+ const tree = buildOrgPickerTree({
+ tenants,
+ users: [] satisfies UserSummary[],
+ });
- const tree = buildOrgPickerTree({
- tenants,
- users: [] satisfies UserSummary[],
+ expect(tree.roots[0]?.children.map((node) => node.name)).toEqual([
+ "총괄기획&기술개발센터",
+ "삼안",
+ "한맥기술",
+ "바론그룹",
+ "한라산업개발",
+ ]);
});
- expect(tree.roots[0]?.children.map((node) => node.name)).toEqual([
- "총괄기획&기술개발센터",
- "삼안",
- "한맥기술",
- "바론그룹",
- ]);
- });
+ it("scopes descendant filtering by tenant slug", () => {
+ const tenants = [
+ tenant(
+ "hanmac-family-id",
+ "COMPANY_GROUP",
+ "한맥가족",
+ "hanmac-family",
+ ),
+ tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
+ tenant(
+ "planning-id",
+ "ORGANIZATION",
+ "기획팀",
+ "planning",
+ "saman-id",
+ ),
+ tenant(
+ "hanmac-id",
+ "COMPANY",
+ "한맥기술",
+ "hanmac",
+ "hanmac-family-id",
+ ),
+ ];
- it("scopes descendant filtering by tenant slug", () => {
- const tenants = [
- tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
- tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
- tenant("planning-id", "ORGANIZATION", "기획팀", "planning", "saman-id"),
- tenant("hanmac-id", "COMPANY", "한맥기술", "hanmac", "hanmac-family-id"),
- ];
+ const tree = buildOrgPickerTree({
+ tenants,
+ users: [] satisfies UserSummary[],
+ tenantId: "saman",
+ });
- const tree = buildOrgPickerTree({
- tenants,
- users: [] satisfies UserSummary[],
- tenantId: "saman",
+ expect(tree.roots).toHaveLength(1);
+ expect(tree.roots[0]?.id).toBe("saman-id");
+ expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
+ "planning-id",
+ ]);
});
- expect(tree.roots).toHaveLength(1);
- expect(tree.roots[0]?.id).toBe("saman-id");
- expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
- "planning-id",
- ]);
- });
+ it("excludes internal and private tenants from picker choices by default", () => {
+ const tenants = [
+ tenant(
+ "hanmac-family-id",
+ "COMPANY_GROUP",
+ "한맥가족",
+ "hanmac-family",
+ ),
+ tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
+ {
+ ...tenant(
+ "internal-id",
+ "ORGANIZATION",
+ "내부 조직",
+ "internal",
+ "saman-id",
+ ),
+ config: { visibility: "internal" },
+ },
+ {
+ ...tenant(
+ "secret-id",
+ "ORGANIZATION",
+ "비공개 조직",
+ "secret",
+ "saman-id",
+ ),
+ config: { visibility: "private" },
+ },
+ tenant(
+ "secret-child-id",
+ "USER_GROUP",
+ "비공개 하위",
+ "secret-child",
+ "secret-id",
+ ),
+ tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
+ ];
- it("excludes internal and private tenants from picker choices by default", () => {
- const tenants = [
- tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
- tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
- {
- ...tenant(
- "internal-id",
- "ORGANIZATION",
- "내부 조직",
- "internal",
- "saman-id",
- ),
- config: { visibility: "internal" },
- },
- {
- ...tenant(
- "secret-id",
- "ORGANIZATION",
- "비공개 조직",
- "secret",
- "saman-id",
- ),
- config: { visibility: "private" },
- },
- tenant(
- "secret-child-id",
- "USER_GROUP",
- "비공개 하위",
- "secret-child",
- "secret-id",
- ),
- tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
- ];
+ const tree = buildOrgPickerTree({
+ tenants,
+ users: [] satisfies UserSummary[],
+ tenantId: "saman",
+ });
- const tree = buildOrgPickerTree({
- tenants,
- users: [] satisfies UserSummary[],
- tenantId: "saman",
+ expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
+ "open-id",
+ ]);
});
- expect(tree.roots[0]?.children.map((node) => node.id)).toEqual(["open-id"]);
- });
+ it("includes internal tenants when explicitly requested", () => {
+ const tenants = [
+ tenant(
+ "hanmac-family-id",
+ "COMPANY_GROUP",
+ "한맥가족",
+ "hanmac-family",
+ ),
+ tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
+ {
+ ...tenant(
+ "internal-id",
+ "ORGANIZATION",
+ "내부 조직",
+ "internal",
+ "saman-id",
+ ),
+ config: { visibility: "internal" },
+ },
+ tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
+ ];
- it("includes internal tenants when explicitly requested", () => {
- const tenants = [
- tenant("hanmac-family-id", "COMPANY_GROUP", "한맥가족", "hanmac-family"),
- tenant("saman-id", "COMPANY", "삼안", "saman", "hanmac-family-id"),
- {
- ...tenant(
- "internal-id",
- "ORGANIZATION",
- "내부 조직",
- "internal",
- "saman-id",
- ),
- config: { visibility: "internal" },
- },
- tenant("open-id", "ORGANIZATION", "공개 조직", "open", "saman-id"),
- ];
+ const tree = buildOrgPickerTree({
+ includeInternal: true,
+ tenants,
+ users: [] satisfies UserSummary[],
+ tenantId: "saman",
+ });
- const tree = buildOrgPickerTree({
- includeInternal: true,
- tenants,
- users: [] satisfies UserSummary[],
- tenantId: "saman",
+ expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
+ "internal-id",
+ "open-id",
+ ]);
});
-
- expect(tree.roots[0]?.children.map((node) => node.id)).toEqual([
- "internal-id",
- "open-id",
- ]);
- });
});
diff --git a/tenants_2605.csv b/tenants_2605.csv
index dd23ad90..191dbecf 100644
--- a/tenants_2605.csv
+++ b/tenants_2605.csv
@@ -1,46 +1,46 @@
tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type
cd1ebc22-4b5e-4242-bb87-eb88db32286c,업무,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,operations,,,public,팀
-0e13d342-d3cf-46b5-8096-4e7883b79b01,서산시자원회수시설,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,hanlla-operation-sites,ops-seosan-recovery,,,public,
-41118f16-7f5c-4209-bd83-183822bc00ed,안성제4차산업단지폐수처리,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,hanlla-operation-sites,ops-anseong-wwtp,,,public,
-ad6f20e9-7928-4322-932c-7c3cb2a313cb,온산바이오,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,hanlla-operation-sites,ops-onsan-bio,,,public,
-03d8cf87-4b40-4784-a6cf-fcc11371f40f,울산민자소각,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,hanlla-operation-sites,ops-ulsan-incineration,,,public,
-d7379c32-0b79-482e-9c4d-d83ad425c3fc,운영사업소,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-operation-sites,,,public,
-551991d8-1f74-4ad0-a0c5-bc5a11968398,부산항 신항,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-busan-new-port,,,public,
-e77b4bf1-a126-4b4e-a18a-8d905e958873,수도권광역급행철도B 제4공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-gtx-b-4,,,public,
-3b5151f6-1a01-484a-bfb7-2e60d2aa0b49,경산시 국도대체,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-gyeongsan-road,,,public,
-44c6e400-daf0-42a2-90df-945921788f99,인덕원 동탄 복선전철 제7공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-indeokwon-dongtan-7,,,public,
-2bc22118-9a70-4d5b-8a3f-cf65432a8bbb,인덕원 동탄 복선전철 제3공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-indeokwon-dongtan-3,,,public,
-1134fa6a-9b0b-4702-a1e7-39948c8c451a,제주공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-jeju-sewage,,,public,
-36aec47e-90fc-42cb-8229-3e20423d0424,성남시생활폐기물처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-seongnam-waste,,,public,
-5b0b806c-f189-46ea-8771-ebdafcf45afa,광탄공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-gwangtan-sewage,,,public,
-d2323d9a-c959-48c0-831b-4bb71e48b2e5,인천국제공항 화물,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-incheon-air-cargo,,,public,
-32a83ce1-03f1-4daa-b60f-8e64fad83ac6,수도권매립지 제2매립장,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-sudokwon-landfill-2,,,public,
-e39ba0af-c3e9-429b-91ec-0a3453a5692e,온산하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-onsan-sewage,,,public,
-25f51047-3108-4ff3-98f4-b7f5bce334c5,신천공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-sincheon-sewage,,,public,
-f0ae9e81-65a5-4bab-a98d-79349bbaa501,장량공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-jangnyang-sewage,,,public,
-b662cfdb-aae3-48b7-b1d3-2ef050dce027,아포공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-apo-sewage,,,public,
-76808046-cd35-4813-b240-c323291fa2d8,광주공공폐수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-gwangju-wastewater,,,public,
-bda54a49-8282-4f91-9773-645e6a1f2a3b,도척 실촌간 도로,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-docheok-silchon-road,,,public,
-02a9e89b-a0a0-4202-adde-870194c35351,여주부평천,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-yeoju-bupyeongcheon,,,public,
-9b1fb915-f50b-49b9-a9f9-a9089a825b1f,옥정 공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-okjeong-sewage,,,public,
-29d9fa54-6d8d-49f5-98ca-2c720aced55e,부천시 굴포천,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,hanlla-construction-sites,site-bucheon-gulpocheon,,,public,
-99199302-f04f-47ad-9f9f-2afe2db9826a,시공현장,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-construction-sites,,,public,
-2dbbdbf8-26b2-461e-bbd7-1fe09b4e36ee,안전관리팀,ORGANIZATION,4b81d408-d81c-43c7-9f87-b6c806db4d7b,hanlla-safety-hq,hanlla-safety-team,,,public,
-4b81d408-d81c-43c7-9f87-b6c806db4d7b,안전관리본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-safety-hq,,,public,
-69aa9667-4997-41f8-9898-e470cfc778e5,기술영업팀,ORGANIZATION,1512e429-fb95-4c0d-9409-f0a3286061f2,hanlla-tech-sales-hq,hanlla-tech-sales-team,,,public,
-1512e429-fb95-4c0d-9409-f0a3286061f2,기술영업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-tech-sales-hq,,,public,
-6f9e45f7-63fb-464e-b47c-915fa25f782f,설계팀,ORGANIZATION,2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,hanlla-env-plant-hq,hanlla-env-plant-design,,,public,
-69d6d246-b281-4da7-be83-fede8e3dc5bd,사업관리팀,ORGANIZATION,2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,hanlla-env-plant-hq,hanlla-env-project-mgmt,,,public,
-2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,환경플랜트사업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-env-plant-hq,,,public,
-45519f6d-ba67-42e2-9b54-f80f1a950a8c,사업관리팀,ORGANIZATION,1d5da961-7f32-4032-a86c-26e8edbcb8ee,hanlla-infra-business-hq,hanlla-infra-project-mgmt,,,public,
-1d5da961-7f32-4032-a86c-26e8edbcb8ee,기반사업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-infra-business-hq,,,public,
-03af0690-af91-468a-9892-0152c7309a4b,운영사업실,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,hanlla-mgmt-support-hq,hanlla-operations-office,,,public,
-6e9b627f-5304-4e7d-99fc-77fc2328d004,경영지원팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,hanlla-mgmt-support-hq,hanlla-mgmt-support,,,public,
-43c0fb29-84dd-49d2-a2b8-33b6659f4607,사업지원팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,hanlla-mgmt-support-hq,hanlla-business-support,,,public,
-d656c134-a50b-43b9-8c2d-fb3738dd0f9f,경영지원본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-mgmt-support-hq,,,public,
-940cc09c-32f5-4a02-8213-fb02521189d0,영업총괄,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-general-sales,,,public,
-57496cae-a081-4836-a20e-75c78b62257f,업무총괄,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-general-business,,,public,
-81e94e6c-e27a-4e36-b0f9-bf8823c96493,임원실,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,hanlla,hanlla-executive,,,public,
+0e13d342-d3cf-46b5-8096-4e7883b79b01,서산시자원회수시설,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-seosan-recovery,,,public,
+41118f16-7f5c-4209-bd83-183822bc00ed,안성제4차산업단지폐수처리,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-anseong-wwtp,,,public,
+ad6f20e9-7928-4322-932c-7c3cb2a313cb,온산바이오,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-onsan-bio,,,public,
+03d8cf87-4b40-4784-a6cf-fcc11371f40f,울산민자소각,ORGANIZATION,d7379c32-0b79-482e-9c4d-d83ad425c3fc,halla-operation-sites,ops-ulsan-incineration,,,public,
+d7379c32-0b79-482e-9c4d-d83ad425c3fc,운영사업소,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-operation-sites,,,public,
+551991d8-1f74-4ad0-a0c5-bc5a11968398,부산항 신항,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-busan-new-port,,,public,
+e77b4bf1-a126-4b4e-a18a-8d905e958873,수도권광역급행철도B 제4공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gtx-b-4,,,public,
+3b5151f6-1a01-484a-bfb7-2e60d2aa0b49,경산시 국도대체,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gyeongsan-road,,,public,
+44c6e400-daf0-42a2-90df-945921788f99,인덕원 동탄 복선전철 제7공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-indeokwon-dongtan-7,,,public,
+2bc22118-9a70-4d5b-8a3f-cf65432a8bbb,인덕원 동탄 복선전철 제3공구,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-indeokwon-dongtan-3,,,public,
+1134fa6a-9b0b-4702-a1e7-39948c8c451a,제주공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-jeju-sewage,,,public,
+36aec47e-90fc-42cb-8229-3e20423d0424,성남시생활폐기물처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-seongnam-waste,,,public,
+5b0b806c-f189-46ea-8771-ebdafcf45afa,광탄공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gwangtan-sewage,,,public,
+d2323d9a-c959-48c0-831b-4bb71e48b2e5,인천국제공항 화물,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-incheon-air-cargo,,,public,
+32a83ce1-03f1-4daa-b60f-8e64fad83ac6,수도권매립지 제2매립장,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-sudokwon-landfill-2,,,public,
+e39ba0af-c3e9-429b-91ec-0a3453a5692e,온산하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-onsan-sewage,,,public,
+25f51047-3108-4ff3-98f4-b7f5bce334c5,신천공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-sincheon-sewage,,,public,
+f0ae9e81-65a5-4bab-a98d-79349bbaa501,장량공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-jangnyang-sewage,,,public,
+b662cfdb-aae3-48b7-b1d3-2ef050dce027,아포공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-apo-sewage,,,public,
+76808046-cd35-4813-b240-c323291fa2d8,광주공공폐수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-gwangju-wastewater,,,public,
+bda54a49-8282-4f91-9773-645e6a1f2a3b,도척 실촌간 도로,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-docheok-silchon-road,,,public,
+02a9e89b-a0a0-4202-adde-870194c35351,여주부평천,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-yeoju-bupyeongcheon,,,public,
+9b1fb915-f50b-49b9-a9f9-a9089a825b1f,옥정 공공하수처리,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-okjeong-sewage,,,public,
+29d9fa54-6d8d-49f5-98ca-2c720aced55e,부천시 굴포천,ORGANIZATION,99199302-f04f-47ad-9f9f-2afe2db9826a,halla-construction-sites,site-bucheon-gulpocheon,,,public,
+99199302-f04f-47ad-9f9f-2afe2db9826a,시공현장,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-construction-sites,,,public,
+2dbbdbf8-26b2-461e-bbd7-1fe09b4e36ee,안전관리팀,ORGANIZATION,4b81d408-d81c-43c7-9f87-b6c806db4d7b,halla-safety-hq,halla-safety-team,,,public,
+4b81d408-d81c-43c7-9f87-b6c806db4d7b,안전관리본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-safety-hq,,,public,
+69aa9667-4997-41f8-9898-e470cfc778e5,기술영업팀,ORGANIZATION,1512e429-fb95-4c0d-9409-f0a3286061f2,halla-tech-sales-hq,halla-tech-sales-team,,,public,
+1512e429-fb95-4c0d-9409-f0a3286061f2,기술영업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-tech-sales-hq,,,public,
+6f9e45f7-63fb-464e-b47c-915fa25f782f,설계팀,ORGANIZATION,2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,halla-env-plant-hq,halla-env-plant-design,,,public,
+69d6d246-b281-4da7-be83-fede8e3dc5bd,사업관리팀,ORGANIZATION,2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,halla-env-plant-hq,halla-env-project-mgmt,,,public,
+2ea01ea1-6d09-4997-ba67-73cbae7aa7dd,환경플랜트사업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-env-plant-hq,,,public,
+45519f6d-ba67-42e2-9b54-f80f1a950a8c,사업관리팀,ORGANIZATION,1d5da961-7f32-4032-a86c-26e8edbcb8ee,halla-infra-business-hq,halla-infra-project-mgmt,,,public,
+1d5da961-7f32-4032-a86c-26e8edbcb8ee,기반사업본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-infra-business-hq,,,public,
+03af0690-af91-468a-9892-0152c7309a4b,운영사업실,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-operations-office,,,public,
+6e9b627f-5304-4e7d-99fc-77fc2328d004,경영지원팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-mgmt-support,,,public,
+43c0fb29-84dd-49d2-a2b8-33b6659f4607,사업지원팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-business-support,,,public,
+d656c134-a50b-43b9-8c2d-fb3738dd0f9f,경영지원본부,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-mgmt-support-hq,,,public,
+940cc09c-32f5-4a02-8213-fb02521189d0,영업총괄,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-general-sales,,,public,
+57496cae-a081-4836-a20e-75c78b62257f,업무총괄,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-general-business,,,public,
+81e94e6c-e27a-4e36-b0f9-bf8823c96493,임원실,ORGANIZATION,5a03efd2-e62f-4243-800d-58334bf48b2f,halla,halla-executive,,,public,
786dd00c-b0c1-4db9-b25b-1afecd6a7a41,안전관리,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-safety-management,,,public,
5fbf6f2c-6b12-4124-a457-d1064dbb8677,현장,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-site,,,public,
dd82bb7b-43d8-4744-ab65-9b47ea492ac4,공무,ORGANIZATION,94c23f79-a213-4a9e-9c5d-7751777f2fe8,js-construction-hq,js-construction-admin,,,public,
@@ -125,7 +125,7 @@ fe58cad4-1fa6-4b87-a2eb-51b9ac41320e,사업개발실,ORGANIZATION,9caf62e1-297d-
01fcbee1-df33-4ee9-bf2b-6d9eb81917d9,대외협력팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,external-relations,,,public,
cdc40c0b-f985-461a-be18-f8c8e82f31e8,재무회계팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,finance,,,public,
c6aa2133-ded0-451c-b51b-27faa8b56507,PQ팀,ORGANIZATION,a16f49c4-6828-4fde-a164-43099c4560c4,planning,pq-team,,,public,
-ca54cffe-ad30-4f9e-983a-88a85c70404d,업무팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,hanlla-mgmt-support-hq,hanlla-operations,,,public,
+ca54cffe-ad30-4f9e-983a-88a85c70404d,업무팀,ORGANIZATION,d656c134-a50b-43b9-8c2d-fb3738dd0f9f,halla-mgmt-support-hq,halla-operations,,,public,
a16f49c4-6828-4fde-a164-43099c4560c4,기획부,ORGANIZATION,9bf67270-e15e-4278-b407-02dec5672876,business-strategy,planning,,,public,
9bf67270-e15e-4278-b407-02dec5672876,경영전략본부,ORGANIZATION,9caf62e1-297d-4e8f-870b-61780998bbeb,saman,business-strategy,,,public,
896da8ab-50b7-4a63-abbc-c85037b63acc,시공BIM,ORGANIZATION,56cd0fd7-b62a-43c0-8db9-74a30468d7cb,tdc,construction-bim,,,public,
@@ -179,7 +179,7 @@ c6b1266c-564b-4543-baba-d78807a3d1b4,경영기획,ORGANIZATION,761a8725-9c19-442
761a8725-9c19-442c-986c-0319e33a5b1e,총괄기획실,ORGANIZATION,5530ca6e-c5e6-4bf0-84d6-76c6a8fb70ee,gpdtdc,gpd,,,public,
e57cb22c-383e-4489-8c2f-0c5431917e86,PTC,COMPANY,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,ptc,,,public,
9607eb7b-04d2-42ab-80fe-780fe21c7e8f,Personal,PERSONAL,,,personal,개인 사용자 기본 루트 테넌트,,public,
-5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,hanlla,,,public,
+5a03efd2-e62f-4243-800d-58334bf48b2f,한라산업개발,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,halla,,,public,
b2fcf17f-7085-4bfe-9663-d8a2f2f4b2d6,장헌산업,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,jangheon-sanup,,,public,
c18a8284-0008-48aa-9cdf-9f47ab79a2a9,(주)장헌,ORGANIZATION,96369f12-6b66-4b2a-a916-d1c99d326f02,baron-group,jangheon,,,public,
96369f12-6b66-4b2a-a916-d1c99d326f02,바론그룹,COMPANY_GROUP,038326b6-954a-48a7-a85f-efd83f62b82a,hanmac-family,baron-group,네이버웍스 바론그룹 BARONGROUP_DOMAIN_ID,,public,
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:
+
+
+
+
+ {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]) => (
+ setRecentChangesPeriod(value as RPUsagePeriod)}
+ className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
+ recentChangesPeriod === value
+ ? "bg-primary text-primary-foreground"
+ : "bg-muted/60 hover:bg-muted"
+ }`}
>
-
-
- {client.name || t("ui.dev.clients.untitled", "Untitled")}
-
-
- {client.id}
-
-
-
-
- {client.metadata?.headless_login_enabled === true
- ? t(
- "ui.dev.clients.type.private_headless",
- "Server side App (Headless Login)",
- )
- : client.type === "private"
- ? t("ui.dev.clients.type.private", "Server side App")
- : t("ui.dev.clients.type.pkce", "PKCE")}
-
-
- {client.status === "active"
- ? t("ui.dev.clients.status.active", "활성")
- : client.status === "inactive"
- ? t("ui.dev.clients.status.inactive", "비활성")
- : client.status || "-"}
-
-
- {t("ui.dev.clients.table.created_at", "생성일")}{" "}
- {formatDate(client.createdAt)}
-
-
-
- ))
- )}
+ {label}
+
+ ))}
+
-
-
+
+ {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를 확인합니다.", - )} -
-- {t( - "msg.dev.dashboard.recent.none", - "표시할 연동 앱이 없습니다.", - )} -
- ) : ( - visibleClients.map((client) => ( -- {client.name || t("ui.dev.clients.untitled", "Untitled")} -
-- {client.id} -
-- {client.metadata?.headless_login_enabled === true - ? t( - "ui.dev.clients.type.private_headless", - "Server side App (Headless Login)", - ) - : client.type === "private" - ? t("ui.dev.clients.type.private", "Server side App") - : t("ui.dev.clients.type.pkce", "PKCE")} -
-- {client.status === "active" - ? t("ui.dev.clients.status.active", "활성") - : client.status === "inactive" - ? t("ui.dev.clients.status.inactive", "비활성") - : client.status || "-"} -
-- {t("ui.dev.clients.table.created_at", "생성일")}{" "} - {formatDate(client.createdAt)} -
-+ {date} {time} +
+