1
0
forked from baron/baron-sso

chore: consolidate local integration changes

This commit is contained in:
2026-06-09 21:03:05 +09:00
parent aa2848c3b6
commit 1341f07ef9
158 changed files with 10995 additions and 1490 deletions

View File

@@ -16,10 +16,10 @@ describe("admin routes", () => {
expect(matches?.at(-1)?.route.path).toBe("/auth/callback");
});
it("registers the super-admin user projection management route", () => {
const matches = matchRoutes(adminRoutes, "/system/projections/users");
it("registers the super-admin Ory SSOT system route", () => {
const matches = matchRoutes(adminRoutes, "/system/ory-ssot");
expect(matches?.at(-1)?.route.path).toBe("system/projections/users");
expect(matches?.at(-1)?.route.path).toBe("system/ory-ssot");
});
it("registers the super-admin data integrity management route", () => {
@@ -28,6 +28,16 @@ describe("admin routes", () => {
expect(matches?.at(-1)?.route.path).toBe("system/data-integrity");
});
it("routes global custom claim settings before user detail id matching", () => {
const matches = matchRoutes(adminRoutes, "/users/custom-claims");
const leafRoute = matches?.at(-1)?.route;
expect(leafRoute?.path).toBe("users/custom-claims");
expect(getRouteElementName(leafRoute?.element)).toBe(
"GlobalCustomClaimsPage",
);
});
it("keeps protected admin pages behind an auth guard before mounting the layout", () => {
const rootRoute = adminRoutes.find((route) => route.path === "/");
const protectedShellRoute = rootRoute?.children?.[0];

View File

@@ -19,6 +19,7 @@ import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage"
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
import GlobalCustomClaimsPage from "../features/users/GlobalCustomClaimsPage";
import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage";
import UserListPage from "../features/users/UserListPage";
@@ -44,6 +45,7 @@ export const adminRoutes: RouteObject[] = [
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },
{ path: "users/custom-claims", element: <GlobalCustomClaimsPage /> },
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
@@ -65,7 +67,7 @@ export const adminRoutes: RouteObject[] = [
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ path: "system/projections/users", element: <UserProjectionPage /> },
{ path: "system/ory-ssot", element: <UserProjectionPage /> },
{ path: "system/data-integrity", element: <DataIntegrityPage /> },
],
},

View File

@@ -53,6 +53,8 @@ function LanguageSelector() {
return (
<select
id="admin-language-selector"
name="admin-language-selector"
value={locale}
onChange={(event) => handleChange(event.target.value as Locale)}
className="rounded-full border border-border bg-transparent px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/20"

View File

@@ -102,7 +102,7 @@ describe("admin AppLayout", () => {
expect(screen.getByText("Tenants")).toBeInTheDocument();
expect(screen.getByText("Org Chart")).toBeInTheDocument();
expect(screen.getByText("Worksmobile")).toBeInTheDocument();
expect(screen.getByText("User Projection")).toBeInTheDocument();
expect(screen.getByText("Ory SSOT System")).toBeInTheDocument();
expect(screen.getByText("Data Integrity")).toBeInTheDocument();
const navigation = screen.getByRole("navigation");
const navLabels = Array.from(navigation.querySelectorAll("a")).map((link) =>
@@ -113,7 +113,7 @@ describe("admin AppLayout", () => {
"Tenants",
"Org Chart",
"Worksmobile",
"User Projection",
"Ory SSOT System",
"Data Integrity",
"Users",
"Auth Guard",

View File

@@ -239,9 +239,9 @@ function AppLayout() {
});
}
filteredItems.splice(4, 0, {
labelKey: "ui.admin.nav.user_projection",
labelFallback: "User Projection",
to: "/system/projections/users",
labelKey: "ui.admin.nav.ory_ssot",
labelFallback: "Ory SSOT System",
to: "/system/ory-ssot",
icon: Database,
});
filteredItems.splice(5, 0, {

View File

@@ -0,0 +1,19 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Checkbox } from "./checkbox";
describe("Checkbox Component", () => {
it("adds a fallback id for browser autofill diagnostics", () => {
render(<Checkbox aria-label="Select row" />);
expect(screen.getByRole("checkbox")).toHaveAttribute("id");
});
it("keeps explicit id and name values", () => {
render(<Checkbox id="explicit-checkbox" name="explicit-name" />);
const checkbox = screen.getByRole("checkbox");
expect(checkbox).toHaveAttribute("id", "explicit-checkbox");
expect(checkbox).toHaveAttribute("name", "explicit-name");
});
});

View File

@@ -7,13 +7,18 @@ export interface CheckboxProps
}
const Checkbox = React.forwardRef<HTMLInputElement, CheckboxProps>(
({ className, onCheckedChange, ...props }, ref) => {
({ className, onCheckedChange, id, name, ...props }, ref) => {
const fallbackId = React.useId();
const fieldId = id ?? (name ? undefined : fallbackId);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
onCheckedChange?.(e.target.checked);
};
return (
<input
id={fieldId}
name={name}
type="checkbox"
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 accent-primary",

View File

@@ -9,6 +9,20 @@ describe("Input Component", () => {
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
});
it("adds a fallback id for browser autofill diagnostics", () => {
render(<Input placeholder="Enter text" />);
expect(screen.getByPlaceholderText("Enter text")).toHaveAttribute("id");
});
it("keeps explicit id and name values", () => {
render(<Input id="explicit-id" name="explicit-name" />);
const input = screen.getByRole("textbox");
expect(input).toHaveAttribute("id", "explicit-id");
expect(input).toHaveAttribute("name", "explicit-name");
});
it("handles value changes", async () => {
const onChange = vi.fn();
const user = userEvent.setup();

View File

@@ -6,9 +6,14 @@ export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
({ className, type, id, name, ...props }, ref) => {
const fallbackId = React.useId();
const fieldId = id ?? (name ? undefined : fallbackId);
return (
<input
id={fieldId}
name={name}
type={type}
className={cn(commonInputClass, className)}
ref={ref}

View File

@@ -0,0 +1,19 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Textarea } from "./textarea";
describe("Textarea Component", () => {
it("adds a fallback id for browser autofill diagnostics", () => {
render(<Textarea aria-label="Description" />);
expect(screen.getByRole("textbox")).toHaveAttribute("id");
});
it("keeps explicit id and name values", () => {
render(<Textarea id="explicit-textarea" name="explicit-name" />);
const textarea = screen.getByRole("textbox");
expect(textarea).toHaveAttribute("id", "explicit-textarea");
expect(textarea).toHaveAttribute("name", "explicit-name");
});
});

View File

@@ -5,9 +5,14 @@ export interface TextareaProps
extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
({ className, id, name, ...props }, ref) => {
const fallbackId = React.useId();
const fieldId = id ?? (name ? undefined : fallbackId);
return (
<textarea
id={fieldId}
name={name}
className={cn(
"flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,

View File

@@ -159,6 +159,8 @@ function AuditLogsPage() {
)}
/>
<select
id="audit-filter-status"
name="audit-filter-status"
data-testid="audit-filter-status"
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
value={statusFilter}

View File

@@ -64,6 +64,8 @@ function PermissionChecker() {
{t("ui.admin.auth_guard.checker.namespace.label", "Namespace")}
</Label>
<select
id="permission-checker-namespace"
name="permission-checker-namespace"
value={namespace}
onChange={(e) => setNamespace(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"

View File

@@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
@@ -7,6 +7,15 @@ import { createI18nMock } from "../../test/i18nMock";
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
const exportUsersCSVMock = vi.hoisted(() =>
vi.fn(async () => ({
blob: new Blob(["email,name\nmember@example.com,Member User\n"], {
type: "text/csv",
}),
filename: "users_export_20260609.csv",
})),
);
const tenants = [
{
id: "tenant-root",
@@ -104,6 +113,7 @@ vi.mock("../../lib/adminApi", () => ({
blob: new Blob(["name,slug"]),
filename: "tenants.csv",
})),
exportUsersCSV: exportUsersCSVMock,
}));
function renderWithProviders(ui: React.ReactElement, entry: string) {
@@ -125,6 +135,10 @@ describe("admin tenant tab coverage smoke", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
vi.spyOn(window.URL, "createObjectURL").mockReturnValue(
"blob:tenant-users-export",
);
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
});
it("renders tenant owners and admins lists", async () => {
@@ -159,4 +173,24 @@ describe("admin tenant tab coverage smoke", () => {
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
expect(await screen.findByText("Member User")).toBeInTheDocument();
});
it("exports selected organization users by tenant slug", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/organization"
element={<TenantUserGroupsTab />}
/>
</Routes>,
"/tenants/tenant-company/organization",
);
expect(await screen.findByText("Member User")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("tenant-current-users-export-btn"));
await waitFor(() => {
expect(exportUsersCSVMock).toHaveBeenCalledWith("", "gpdtdc", false);
});
});
});

View File

@@ -5,11 +5,11 @@ import {
deleteOrphanUserLoginIDs,
fetchDataIntegrityReport,
fetchMe,
fetchOrySSOTSystemStatus,
fetchOrphanUserLoginIDs,
fetchUserProjectionStatus,
reconcileUserProjection,
resetUserProjection,
flushIdentityCache,
} from "../../lib/adminApi";
import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics";
import { createI18nMock } from "../../test/i18nMock";
import DataIntegrityPage from "./DataIntegrityPage";
@@ -63,22 +63,27 @@ vi.mock("../../lib/adminApi", () => ({
],
total: 1,
})),
fetchUserProjectionStatus: vi.fn(async () => ({
name: "kratos_users",
status: "ready",
ready: true,
lastSyncedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
projectedUsers: 152,
fetchOrySSOTSystemStatus: vi.fn(async () => ({
userProjection: {
name: "kratos_users",
status: "ready",
ready: true,
lastSyncedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
projectedUsers: 152,
},
identityCache: {
status: "ready",
redisReady: true,
observedCount: 151,
keyCount: 153,
lastRefreshedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
},
})),
reconcileUserProjection: vi.fn(async () => ({
flushIdentityCache: vi.fn(async () => ({
status: "success",
syncedUsers: 152,
updatedAt: "2026-05-11T03:01:00Z",
})),
resetUserProjection: vi.fn(async () => ({
status: "success",
syncedUsers: 152,
flushedKeys: 153,
updatedAt: "2026-05-11T03:02:00Z",
})),
deleteOrphanUserLoginIDs: vi.fn(async () => ({
@@ -128,7 +133,7 @@ describe("DataIntegrityPage", () => {
screen.getByRole("tab", { name: "정합성 검사" }),
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "사용자 동기화" }),
screen.getByRole("tab", { name: "Ory SSOT 시스템" }),
).toBeInTheDocument();
expect(
await screen.findByText(
@@ -141,35 +146,32 @@ describe("DataIntegrityPage", () => {
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
it("renders user projection sync inside data integrity", async () => {
it("renders Ory SSOT cache management inside data integrity", async () => {
renderPage();
fireEvent.click(await screen.findByRole("tab", { name: "사용자 동기화" }));
fireEvent.click(await screen.findByRole("tab", { name: "Ory SSOT 시스템" }));
expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
expect(screen.getByText("준비됨")).toBeInTheDocument();
expect((await screen.findAllByText("Ory SSOT 시스템")).length).toBeGreaterThan(0);
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
expect(screen.getByText("152")).toBeInTheDocument();
expect(screen.getByText("151")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
await waitFor(() => {
expect(reconcileUserProjection).toHaveBeenCalledTimes(1);
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
});
fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ }));
await waitFor(() => {
expect(resetUserProjection).toHaveBeenCalledTimes(1);
});
expect(fetchUserProjectionStatus).toHaveBeenCalled();
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
});
it("shows orphan login ID targets and deletes selected rows", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
renderPage();
const { container } = renderPage();
expect(await screen.findByText("유령 로그인 ID 정리")).toBeInTheDocument();
expect(await screen.findByText("EMP001")).toBeInTheDocument();
expect(screen.getByText("삭제된 테넌트")).toBeInTheDocument();
expectNoAnonymousFormFields(container);
expect(fetchOrphanUserLoginIDs).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("checkbox", { name: "EMP001 선택" }));

View File

@@ -247,6 +247,7 @@ function OrphanLoginIDTable({
<tr key={item.id}>
<td className="px-3 py-2">
<input
name={`orphan-login-id-select-${item.id}`}
type="checkbox"
aria-label={t(
"ui.admin.integrity.table.select_item",
@@ -418,7 +419,7 @@ function DataIntegrityContent() {
className={pageTabClassName(activeTab === "projection")}
onClick={() => setActiveTab("projection")}
>
{t("ui.admin.integrity.tab_user_projection", "사용자 동기화")}
{t("ui.admin.integrity.tab_ory_ssot", "Ory SSOT 시스템")}
</button>
</div>

View File

@@ -2,9 +2,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetchUserProjectionStatus,
reconcileUserProjection,
resetUserProjection,
fetchOrySSOTSystemStatus,
flushIdentityCache,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import UserProjectionPage from "./UserProjectionPage";
@@ -15,22 +14,27 @@ let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchUserProjectionStatus: vi.fn(async () => ({
name: "kratos_users",
status: "ready",
ready: true,
lastSyncedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
projectedUsers: 152,
fetchOrySSOTSystemStatus: vi.fn(async () => ({
userProjection: {
name: "kratos_users",
status: "ready",
ready: true,
lastSyncedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
projectedUsers: 152,
},
identityCache: {
status: "ready",
redisReady: true,
observedCount: 151,
lastRefreshedAt: "2026-05-11T03:00:00Z",
updatedAt: "2026-05-11T03:00:10Z",
keyCount: 153,
},
})),
reconcileUserProjection: vi.fn(async () => ({
flushIdentityCache: vi.fn(async () => ({
status: "success",
syncedUsers: 152,
updatedAt: "2026-05-11T03:01:00Z",
})),
resetUserProjection: vi.fn(async () => ({
status: "success",
syncedUsers: 152,
flushedKeys: 153,
updatedAt: "2026-05-11T03:02:00Z",
})),
}));
@@ -58,35 +62,33 @@ describe("UserProjectionPage", () => {
window.localStorage.setItem("locale", "ko");
});
it("renders projection status for super_admin", async () => {
it("renders Ory SSOT and Redis identity cache status for super_admin", async () => {
renderPage();
expect(await screen.findByText("사용자 동기화 관리")).toBeInTheDocument();
expect(await screen.findByText("Ory SSOT 시스템")).toBeInTheDocument();
expect(
await screen.findByText(
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
),
).toBeInTheDocument();
expect(await screen.findByText("Kratos 사용자 동기화")).toBeInTheDocument();
expect(screen.getByText("준비됨")).toBeInTheDocument();
expect(await screen.findByText("Redis identity cache")).toBeInTheDocument();
expect(screen.getAllByText("준비됨").length).toBeGreaterThan(0);
expect(screen.getByText("관측 identity")).toBeInTheDocument();
expect(screen.getByText("152")).toBeInTheDocument();
expect(fetchUserProjectionStatus).toHaveBeenCalled();
expect(screen.getByText("151")).toBeInTheDocument();
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
});
it("runs reconcile and reset actions for super_admin", async () => {
it("flushes only the Redis identity cache for super_admin", async () => {
renderPage();
await screen.findByText("사용자 동기화 관리");
fireEvent.click(screen.getByRole("button", { name: /재동기화/ }));
await screen.findByText("Ory SSOT 시스템");
expect(screen.queryByRole("button", { name: /재동기화/ })).toBeNull();
expect(screen.queryByRole("button", { name: /초기화 후 재구축/ })).toBeNull();
fireEvent.click(screen.getByRole("button", { name: /Redis cache flush/ }));
await waitFor(() => {
expect(reconcileUserProjection).toHaveBeenCalledTimes(1);
});
fireEvent.click(screen.getByRole("button", { name: /초기화 후 재구축/ }));
await waitFor(() => {
expect(resetUserProjection).toHaveBeenCalledTimes(1);
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
});
});
@@ -96,21 +98,21 @@ describe("UserProjectionPage", () => {
renderPage();
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
expect(screen.queryByText("사용자 동기화 관리")).not.toBeInTheDocument();
expect(fetchUserProjectionStatus).not.toHaveBeenCalled();
expect(screen.queryByText("Ory SSOT 시스템")).not.toBeInTheDocument();
expect(fetchOrySSOTSystemStatus).not.toHaveBeenCalled();
});
it("renders localized labels in English", async () => {
window.localStorage.setItem("locale", "en");
renderPage();
expect(await screen.findByText("Ory SSOT System")).toBeInTheDocument();
expect(
await screen.findByText("User Projection Management"),
await screen.findByText(
"Review Kratos source-of-truth and Redis identity cache status separately.",
),
).toBeInTheDocument();
expect(
await screen.findByText("Review and sync the Kratos user read model."),
).toBeInTheDocument();
expect(screen.getByText("Re-sync")).toBeInTheDocument();
expect(await screen.findByText("ready")).toBeInTheDocument();
expect(screen.getByText("Redis cache flush")).toBeInTheDocument();
expect((await screen.findAllByText("ready")).length).toBeGreaterThan(0);
});
});

View File

@@ -1,56 +1,43 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AlertTriangle, RefreshCw, RotateCcw, Users } from "lucide-react";
import { AlertTriangle, Database, Trash2 } from "lucide-react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
fetchUserProjectionStatus,
reconcileUserProjection,
resetUserProjection,
fetchOrySSOTSystemStatus,
flushIdentityCache,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
function formatDateTime(value?: string) {
if (!value) {
return "-";
}
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(getAdminDateLocale(), {
dateStyle: "medium",
timeStyle: "medium",
}).format(date);
}
function ProjectionStatusBadge({
ready,
status,
}: {
ready: boolean;
status: string;
}) {
function StatusBadge({ ready, status }: { ready: boolean; status: string }) {
if (ready) {
return (
<Badge variant="success">
{t("ui.admin.user_projection.status.ready", "ready")}
{t("ui.admin.ory_ssot.status.ready", "ready")}
</Badge>
);
}
if (status === "failed") {
return (
<Badge variant="warning">
{t("ui.admin.user_projection.status.failed", "failed")}
{t("ui.admin.ory_ssot.status.failed", "failed")}
</Badge>
);
}
return (
<Badge variant="secondary">
{status
? status
: t("ui.admin.user_projection.status.not_ready", "not ready")}
{status ? status : t("ui.admin.ory_ssot.status.not_ready", "not ready")}
</Badge>
);
}
@@ -62,41 +49,31 @@ export function UserProjectionContent({
}) {
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["user-projection-status"],
queryFn: fetchUserProjectionStatus,
queryKey: ["ory-ssot-system-status"],
queryFn: fetchOrySSOTSystemStatus,
});
const invalidate = async () => {
await queryClient.invalidateQueries({
queryKey: ["user-projection-status"],
});
};
const reconcileMutation = useMutation({
mutationFn: reconcileUserProjection,
onSuccess: invalidate,
const flushMutation = useMutation({
mutationFn: flushIdentityCache,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ["ory-ssot-system-status"],
});
},
});
const resetMutation = useMutation({
mutationFn: resetUserProjection,
onSuccess: invalidate,
});
const handleReset = () => {
const handleFlush = () => {
const confirmed = window.confirm(
t(
"msg.admin.user_projection.reset_confirm",
"Rebuild user projection from the Kratos source of truth?",
"msg.admin.ory_ssot.flush_confirm",
"Flush only Redis identity cache keys?",
),
);
if (confirmed) {
resetMutation.mutate();
}
if (confirmed) flushMutation.mutate();
};
const isWorking = reconcileMutation.isPending || resetMutation.isPending;
const actionResult = reconcileMutation.data ?? resetMutation.data;
const actionError = reconcileMutation.error ?? resetMutation.error;
const projection = data?.userProjection;
const identityCache = data?.identityCache;
const header = (
<header
@@ -108,40 +85,32 @@ export function UserProjectionContent({
>
<div className="flex min-w-0 items-start gap-3">
<div className="mt-1 flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-primary/15 bg-primary/10 text-primary">
<Users size={20} />
<Database size={20} />
</div>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.user_projection.title", "User Projection Management")}
{t("ui.admin.ory_ssot.title", "Ory SSOT System")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.user_projection.subtitle",
"Review and sync the Kratos user read model.",
"msg.admin.ory_ssot.subtitle",
"Review Kratos source-of-truth and Redis identity cache status separately.",
)}
</p>
</div>
</div>
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => reconcileMutation.mutate()}
disabled={isWorking}
>
<RefreshCw size={16} />
{t("ui.admin.user_projection.actions.reconcile", "Re-sync")}
</Button>
<Button
type="button"
variant="destructive"
onClick={handleReset}
disabled={isWorking}
>
<RotateCcw size={16} />
{t("ui.admin.user_projection.actions.reset", "Reset and rebuild")}
</Button>
</div>
<Button
type="button"
variant="destructive"
onClick={handleFlush}
disabled={flushMutation.isPending}
>
<Trash2 size={16} />
{t(
"ui.admin.ory_ssot.actions.flush_identity_cache",
"Redis cache flush",
)}
</Button>
</header>
);
@@ -151,28 +120,28 @@ export function UserProjectionContent({
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
t(
"msg.admin.user_projection.load_error",
"Failed to load projection status.",
"msg.admin.ory_ssot.load_error",
"Failed to load Ory SSOT system status.",
)}
</section>
) : null}
{actionResult ? (
{flushMutation.data ? (
<section className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{t(
"msg.admin.user_projection.action_success",
"Refreshed the projection for {{count}} users.",
{ count: actionResult.syncedUsers },
"msg.admin.ory_ssot.flush_success",
"Flushed {{count}} Redis identity cache keys.",
{ count: flushMutation.data.flushedKeys },
)}
</section>
) : null}
{actionError ? (
{flushMutation.error ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(actionError as Error)?.message ||
{(flushMutation.error as Error)?.message ||
t(
"msg.admin.user_projection.action_error",
"Projection operation failed.",
"msg.admin.ory_ssot.flush_error",
"Redis identity cache flush failed.",
)}
</section>
) : null}
@@ -180,16 +149,16 @@ export function UserProjectionContent({
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
<h3 className="text-lg font-bold">
{t(
"ui.admin.user_projection.card.title",
"Kratos users projection",
"ui.admin.ory_ssot.projection_card.title",
"Backend user read model",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.user_projection.card.description",
"Current user read model state referenced by backend DB statistics.",
"ui.admin.ory_ssot.projection_card.description",
"PostgreSQL read model status used by admin search and statistics.",
)}
</p>
</div>
@@ -197,58 +166,131 @@ export function UserProjectionContent({
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.user_projection.loading", "Loading")}
{t("ui.admin.ory_ssot.loading", "Loading")}
</div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.user_projection.summary.status", "Status")}
{t("ui.admin.ory_ssot.summary.status", "Status")}
</dt>
<dd className="mt-1">
<ProjectionStatusBadge
ready={data?.ready ?? false}
status={data?.status ?? "unknown"}
<StatusBadge
ready={projection?.ready ?? false}
status={projection?.status ?? "unknown"}
/>
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.summary.local_users", "Local users")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{projection?.projectedUsers ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t(
"ui.admin.ory_ssot.summary.last_synced",
"Last read-model refresh",
)}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(projection?.lastSyncedAt)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.summary.updated_at", "Updated at")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(projection?.updatedAt)}
</dd>
</div>
</dl>
)}
{projection?.lastError ? (
<div className="flex gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
<span>{projection.lastError}</span>
</div>
) : null}
</section>
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex items-center gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold">
{t("ui.admin.ory_ssot.cache_card.title", "Redis identity cache")}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.ory_ssot.cache_card.description",
"Redis mirror/cache status for Kratos identity list and lookup operations.",
)}
</p>
</div>
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.loading", "Loading")}
</div>
) : (
<dl className="grid gap-4 py-5 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.summary.status", "Status")}
</dt>
<dd className="mt-1">
<StatusBadge
ready={
Boolean(identityCache?.redisReady) &&
identityCache?.status === "ready"
}
status={identityCache?.status ?? "unknown"}
/>
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t(
"ui.admin.user_projection.summary.projected_users",
"Projected users",
"ui.admin.ory_ssot.summary.observed_identities",
"Observed identities",
)}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.projectedUsers ?? 0}
{identityCache?.observedCount ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.ory_ssot.summary.cache_keys", "Cache keys")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{identityCache?.keyCount ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t(
"ui.admin.user_projection.summary.last_synced",
"Last synced",
"ui.admin.ory_ssot.summary.last_refreshed",
"Last refreshed",
)}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.lastSyncedAt)}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.user_projection.summary.updated_at", "Updated at")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.updatedAt)}
{formatDateTime(identityCache?.lastRefreshedAt)}
</dd>
</div>
</dl>
)}
{data?.lastError ? (
{identityCache?.lastError ? (
<div className="flex gap-2 rounded-lg border border-amber-200 bg-amber-50 p-3 text-sm text-amber-900 dark:border-amber-900 dark:bg-amber-950/40 dark:text-amber-200">
<AlertTriangle className="mt-0.5 shrink-0" size={16} />
<span>{data.lastError}</span>
<span>{identityCache.lastError}</span>
</div>
) : null}
</section>
@@ -280,11 +322,11 @@ export default function UserProjectionPage() {
<main className="p-6 md:p-8">
<section className="rounded-lg border border-border bg-card p-5">
<h2 className="text-lg font-semibold">
{t("ui.admin.user_projection.forbidden.title", "Access denied")}
{t("ui.admin.ory_ssot.forbidden.title", "Access denied")}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t(
"msg.admin.user_projection.forbidden.description",
"msg.admin.ory_ssot.forbidden.description",
"This screen is only available to super_admin users.",
)}
</p>

View File

@@ -161,6 +161,8 @@ export function ParentTenantSelector({
</DialogHeader>
<div className="space-y-3">
<input
id="parent-tenant-local-search"
name="parent-tenant-local-search"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={localSearch}
onChange={(event) => setLocalSearch(event.target.value)}

View File

@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
filterTenantsByScope,
getTenantSearchMatchIds,
getTenantViewRows,
resolveTenantSelectionIds,
tenantMatchesListSearch,
@@ -69,6 +70,7 @@ describe("TenantListPage tenant list helpers", () => {
expect(tenantMatchesListSearch(tenants[2], "team-1")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "platform")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "플랫폼")).toBe(true);
expect(tenantMatchesListSearch(tenants[2], "삼안")).toBe(false);
});
it("can return tree rows or same-level table rows", () => {
@@ -79,4 +81,20 @@ describe("TenantListPage tenant list helpers", () => {
[0, 0, 0, 0],
);
});
it("marks only direct search matches when tree search includes ancestors", () => {
const treeRows = getTenantViewRows(
tenants.filter((item) => item.id !== "company-2"),
"tree",
"",
true,
);
expect(treeRows.map((row) => row.id)).toEqual([
"company-1",
"dept-1",
"team-1",
]);
expect(getTenantSearchMatchIds(treeRows, "platform")).toEqual(["team-1"]);
});
});

View File

@@ -107,6 +107,7 @@ import {
} from "../utils/tenantCsvImport";
import {
filterTenantsByScope,
getTenantSearchMatchIds,
getTenantViewRows,
resolveTenantSelectionIds,
type TenantViewMode,
@@ -842,6 +843,7 @@ function TenantListPage() {
<RoleGuard roles={["super_admin"]}>
<input
ref={fileInputRef}
name="tenant-import-file"
type="file"
accept=".csv,text/csv"
className="hidden"
@@ -1368,6 +1370,8 @@ function TenantListPage() {
</TableCell>
<TableCell>
<select
id={`tenant-import-parent-select-${preview.row.rowNumber}`}
name={`tenant-import-parent-select-${preview.row.rowNumber}`}
className="h-9 w-full min-w-[220px] rounded-md border border-input bg-background px-3 text-sm"
value={
selectedParentRefs[preview.row.rowNumber] ??
@@ -1457,6 +1461,8 @@ function TenantListPage() {
<TableCell>
<div className="space-y-2">
<select
id={`tenant-import-match-select-${preview.row.rowNumber}`}
name={`tenant-import-match-select-${preview.row.rowNumber}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={
selectedMatches[preview.row.rowNumber] ??
@@ -1705,6 +1711,10 @@ const TenantHierarchyView: React.FC<{
const virtualRows = rowVirtualizer.getVirtualItems();
const shouldVirtualizeRows = !(isTest && flattenedRows.length < 100);
const searchMatchIds = React.useMemo(
() => new Set(getTenantSearchMatchIds(flattenedRows, search)),
[flattenedRows, search],
);
React.useEffect(() => {
if (isTest) return;
@@ -1757,6 +1767,7 @@ const TenantHierarchyView: React.FC<{
virtualRow?: { start: number; end: number },
) => {
const isSelected = selectedIds.includes(node.id);
const isSearchMatch = searchMatchIds.has(node.id);
const hasChildren =
viewMode === "tree" && node.children && node.children.length > 0;
const isExpanded =
@@ -1770,6 +1781,9 @@ const TenantHierarchyView: React.FC<{
ref={virtualRow ? rowVirtualizer.measureElement : undefined}
className={cn(
isSelected ? "bg-primary/5" : "",
isSearchMatch
? "bg-amber-50/80 ring-1 ring-inset ring-amber-300"
: "",
"h-[73px]",
virtualRow ? "absolute left-0 w-full" : "",
)}
@@ -1851,6 +1865,15 @@ const TenantHierarchyView: React.FC<{
{t("ui.admin.tenants.seed_badge", "초기 설정")}
</Badge>
)}
{isSearchMatch && (
<Badge
variant="outline"
className="flex-shrink-0 border-amber-300 bg-amber-100 text-[10px] font-semibold text-amber-900"
data-testid={`tenant-search-match-${node.id}`}
>
{t("ui.admin.tenants.search_match_badge", "검색 일치")}
</Badge>
)}
</div>
{(() => {
const parentPath = tenantParentPathMap.get(node.id) ?? [];

View File

@@ -343,6 +343,8 @@ export function TenantProfilePage() {
)}
</Label>
<select
id="tenant-org-unit-type"
name="tenant-org-unit-type"
data-testid="tenant-org-unit-type-select"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={orgUnitType}
@@ -361,6 +363,8 @@ export function TenantProfilePage() {
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
<select
id="tenant-visibility"
name="tenant-visibility"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={tenantVisibility}
onChange={(event) =>

View File

@@ -205,6 +205,8 @@ export function TenantSchemaPage() {
{t("ui.admin.tenants.schema.field.type", "유형")}
</Label>
<select
id={`tenant-schema-field-type-${field.key || index}`}
name={`tenant-schema-field-type-${field.key || index}`}
className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
value={field.type}
onChange={(e) => {
@@ -266,6 +268,7 @@ export function TenantSchemaPage() {
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-required-${field.key || index}`}
type="checkbox"
checked={field.required}
onChange={(e) =>
@@ -279,6 +282,7 @@ export function TenantSchemaPage() {
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-admin-only-${field.key || index}`}
type="checkbox"
checked={field.adminOnly}
onChange={(e) =>
@@ -295,6 +299,7 @@ export function TenantSchemaPage() {
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-login-id-${field.key || index}`}
type="checkbox"
checked={field.isLoginId || false}
onChange={(e) =>
@@ -315,6 +320,7 @@ export function TenantSchemaPage() {
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-indexed-${field.key || index}`}
type="checkbox"
checked={field.indexed || field.isLoginId || false}
disabled={field.isLoginId}
@@ -333,6 +339,7 @@ export function TenantSchemaPage() {
{(field.type === "number" || field.type === "float") && (
<label className="flex items-center gap-2 cursor-pointer">
<input
name={`tenant-schema-field-unsigned-${field.key || index}`}
type="checkbox"
checked={field.unsigned}
onChange={(e) =>

View File

@@ -0,0 +1,148 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../../test/i18nMock";
import TenantUsersPage from "./TenantUsersPage";
const exportUsersCSVMock = vi.hoisted(() => vi.fn());
const updateUserMock = vi.hoisted(() => vi.fn());
const fetchUsersMock = vi.hoisted(() => vi.fn());
vi.mock("../../../lib/i18n", () => createI18nMock());
vi.mock("../../../lib/adminApi", () => ({
fetchTenant: vi.fn(async () => ({
id: "tenant-team-id",
name: "기술기획팀",
slug: "tech-planning",
})),
fetchUsers: fetchUsersMock,
exportUsersCSV: exportUsersCSVMock,
updateUser: updateUserMock,
}));
function renderTenantUsersPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/tenants/tenant-team-id/users"]}>
<Routes>
<Route
path="/tenants/:tenantId/users"
element={<TenantUsersPage />}
/>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantUsersPage export", () => {
beforeEach(() => {
exportUsersCSVMock.mockReset();
updateUserMock.mockReset();
fetchUsersMock.mockReset();
fetchUsersMock.mockResolvedValue({
items: [
{
id: "user-1",
name: "Alice",
email: "alice@example.com",
role: "user",
status: "active",
},
],
total: 1,
});
exportUsersCSVMock.mockResolvedValue({
blob: new Blob(["email,name\nalice@example.com,Alice\n"], {
type: "text/csv",
}),
filename: "users_export_20260609.csv",
});
vi.spyOn(window.URL, "createObjectURL").mockReturnValue(
"blob:tenant-users-export",
);
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
});
it("exports only the currently opened tenant users by tenant slug", async () => {
renderTenantUsersPage();
await screen.findByText("Alice");
fireEvent.click(screen.getByTestId("tenant-users-export-menu-item"));
await waitFor(() => {
expect(exportUsersCSVMock).toHaveBeenCalledWith(
"",
"tech-planning",
false,
);
});
});
it("queues searched users and adds all queued users to the tenant at once", async () => {
fetchUsersMock
.mockResolvedValueOnce({ items: [], total: 0 })
.mockResolvedValueOnce({
items: [
{
id: "user-2",
name: "Bob",
email: "bob@example.com",
role: "user",
status: "active",
},
{
id: "user-3",
name: "Carol",
email: "carol@example.com",
role: "user",
status: "active",
},
],
total: 2,
})
.mockResolvedValue({ items: [], total: 0 });
updateUserMock.mockResolvedValue({});
renderTenantUsersPage();
const addButton = await screen.findByTestId(
"tenant-member-add-existing-btn",
);
await waitFor(() => expect(addButton).not.toBeDisabled());
fireEvent.click(addButton);
fireEvent.change(screen.getByTestId("tenant-member-search-input"), {
target: { value: "bo" },
});
fireEvent.click(await screen.findByText("Bob"));
fireEvent.click(await screen.findByText("Carol"));
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
"Bob",
);
expect(screen.getByTestId("tenant-member-add-queue")).toHaveTextContent(
"Carol",
);
fireEvent.click(screen.getByTestId("tenant-member-add-submit-btn"));
await waitFor(() => {
expect(updateUserMock).toHaveBeenCalledWith("user-2", {
tenantSlug: "tech-planning",
isAddTenant: true,
});
expect(updateUserMock).toHaveBeenCalledWith("user-3", {
tenantSlug: "tech-planning",
isAddTenant: true,
});
});
});
});

View File

@@ -1,6 +1,16 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Loader2, Mail, Plus, User, UserPlus } from "lucide-react";
import {
FileDown,
Loader2,
Mail,
Plus,
Search,
User,
UserPlus,
X,
} from "lucide-react";
import * as React from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
@@ -11,6 +21,15 @@ import {
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
@@ -20,7 +39,13 @@ import {
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import { fetchTenant, fetchUsers, updateUser } from "../../../lib/adminApi";
import {
exportUsersCSV,
fetchTenant,
fetchUsers,
type UserSummary,
updateUser,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantUsersPage() {
@@ -28,6 +53,9 @@ function TenantUsersPage() {
const navigate = useNavigate();
const tenantId = params.tenantId ?? "";
const queryClient = useQueryClient();
const [addMembersOpen, setAddMembersOpen] = React.useState(false);
const [memberSearch, setMemberSearch] = React.useState("");
const [queuedMembers, setQueuedMembers] = React.useState<UserSummary[]>([]);
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
const tenantQuery = useQuery({
@@ -45,6 +73,33 @@ function TenantUsersPage() {
enabled: !!tenantSlug,
});
const memberSearchTerm = memberSearch.trim();
const memberSearchQuery = useQuery({
queryKey: ["tenant-member-search", tenantSlug, memberSearchTerm],
queryFn: () => fetchUsers(20, 0, memberSearchTerm),
enabled: addMembersOpen && memberSearchTerm.length >= 2,
});
const exportMutation = useMutation({
mutationFn: (includeIds: boolean) =>
exportUsersCSV("", tenantSlug ?? "", includeIds),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: () => {
toast.error(
t("msg.admin.users.export_error", "사용자 내보내기에 실패했습니다."),
);
},
});
const removeTenantMutation = useMutation({
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
@@ -66,6 +121,38 @@ function TenantUsersPage() {
},
});
const addMembersMutation = useMutation({
mutationFn: async (members: UserSummary[]) => {
if (!tenantSlug || members.length === 0) return;
await Promise.all(
members.map((member) =>
updateUser(member.id, { tenantSlug, isAddTenant: true }),
),
);
},
onSuccess: () => {
const count = queuedMembers.length;
toast.success(
t(
"msg.admin.tenants.members.add_success",
"{{count}}명의 구성원이 추가되었습니다.",
{ count },
),
);
setQueuedMembers([]);
setMemberSearch("");
setAddMembersOpen(false);
usersQuery.refetch();
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("msg.admin.tenants.members.add_error", "구성원 추가 실패"),
);
},
});
const _handleRemoveMember = (userId: string, userName: string) => {
if (!tenantSlug) return;
if (
@@ -82,6 +169,28 @@ function TenantUsersPage() {
};
const users = usersQuery.data?.items ?? [];
const existingUserIds = React.useMemo(
() => new Set(users.map((user) => user.id)),
[users],
);
const queuedUserIds = React.useMemo(
() => new Set(queuedMembers.map((user) => user.id)),
[queuedMembers],
);
const searchResults = memberSearchQuery.data?.items ?? [];
const queueMember = (member: UserSummary) => {
if (existingUserIds.has(member.id) || queuedUserIds.has(member.id)) {
return;
}
setQueuedMembers((current) => [...current, member]);
};
const removeQueuedMember = (memberId: string) => {
setQueuedMembers((current) =>
current.filter((member) => member.id !== memberId),
);
};
return (
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
@@ -92,12 +201,39 @@ function TenantUsersPage() {
count: users.length,
})}
</CardTitle>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" asChild className="gap-2">
<Link to={`/users?addTenant=${tenantSlug}`}>
<UserPlus size={16} />
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
</Link>
<div className="flex flex-wrap items-center justify-end gap-2">
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={!tenantSlug || exportMutation.isPending}
data-testid="tenant-users-export-menu-item"
onClick={() => exportMutation.mutate(false)}
>
<FileDown size={16} />
{t("ui.common.export_without_ids", "UUID 제외 내보내기")}
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={!tenantSlug || exportMutation.isPending}
data-testid="tenant-users-export-with-ids-menu-item"
onClick={() => exportMutation.mutate(true)}
>
<FileDown size={16} />
{t("ui.common.export_with_ids", "UUID 포함 내보내기")}
</Button>
<Button
variant="outline"
size="sm"
className="gap-2"
disabled={!tenantSlug}
data-testid="tenant-member-add-existing-btn"
onClick={() => setAddMembersOpen(true)}
>
<UserPlus size={16} />
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
</Button>
<Button size="sm" asChild className="gap-2">
<Link to={`/users/new?tenantSlug=${tenantSlug}`}>
@@ -107,6 +243,143 @@ function TenantUsersPage() {
</Button>
</div>
</CardHeader>
<Dialog open={addMembersOpen} onOpenChange={setAddMembersOpen}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
</DialogTitle>
<DialogDescription>
{t(
"ui.admin.tenants.members.add_existing_description",
"검색 결과를 선택해 추가 명단에 담은 뒤 한 번에 배정합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="relative">
<Search
size={16}
className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground"
/>
<Input
value={memberSearch}
onChange={(event) => setMemberSearch(event.target.value)}
className="h-9 pl-9"
placeholder={t(
"ui.admin.tenants.members.search_placeholder",
"이름 또는 이메일 검색",
)}
data-testid="tenant-member-search-input"
/>
</div>
<div className="rounded-md border">
<div className="max-h-56 overflow-auto">
{memberSearchTerm.length < 2 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{t(
"ui.admin.tenants.members.search_min_length",
"두 글자 이상 입력하세요.",
)}
</div>
) : memberSearchQuery.isFetching ? (
<div className="flex items-center justify-center gap-2 px-3 py-6 text-sm text-muted-foreground">
<Loader2 size={16} className="animate-spin" />
{t("ui.common.searching", "검색 중...")}
</div>
) : searchResults.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{t("ui.common.no_results", "검색 결과가 없습니다.")}
</div>
) : (
<div className="divide-y">
{searchResults.map((user) => {
const disabled =
existingUserIds.has(user.id) ||
queuedUserIds.has(user.id);
return (
<button
key={user.id}
type="button"
className="flex w-full items-center justify-between gap-3 px-3 py-2 text-left text-sm hover:bg-muted/50 disabled:cursor-not-allowed disabled:opacity-50"
disabled={disabled}
onClick={() => queueMember(user)}
>
<span className="min-w-0">
<span className="block truncate font-medium">
{user.name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{user.email}
</span>
</span>
<Plus size={16} className="flex-shrink-0" />
</button>
);
})}
</div>
)}
</div>
</div>
<div
className="min-h-20 rounded-md border bg-muted/20 p-3"
data-testid="tenant-member-add-queue"
>
{queuedMembers.length === 0 ? (
<div className="flex h-14 items-center justify-center text-sm text-muted-foreground">
{t(
"ui.admin.tenants.members.queue_empty",
"추가할 구성원을 선택하세요.",
)}
</div>
) : (
<div className="flex flex-wrap gap-2">
{queuedMembers.map((user) => (
<span
key={user.id}
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-background px-2 py-1 text-sm"
>
<span className="max-w-52 truncate">{user.name}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => removeQueuedMember(user.id)}
aria-label={t(
"ui.admin.tenants.members.queue_remove",
"추가 명단에서 제거",
)}
>
<X size={14} />
</button>
</span>
))}
</div>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setAddMembersOpen(false)}
disabled={addMembersMutation.isPending}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => addMembersMutation.mutate(queuedMembers)}
disabled={
queuedMembers.length === 0 || addMembersMutation.isPending
}
data-testid="tenant-member-add-submit-btn"
>
{addMembersMutation.isPending && (
<Loader2 size={16} className="animate-spin" />
)}
{t("ui.admin.tenants.members.add_queued", "선택 구성원 추가")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">

View File

@@ -17,7 +17,9 @@ import {
getWorksmobileComparisonStatusLabel,
getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds,
getWorksmobileSelectedCreateUserIds,
getWorksmobileSelectedMissingExternalKeyOrgUnitIds,
getWorksmobileSelectedUpdateUserIds,
getWorksmobileSelectedWorksOnlyOrgUnitIds,
isImmutableWorksmobileAccount,
summarizeWorksmobileComparison,
@@ -225,6 +227,41 @@ describe("TenantWorksmobilePage comparison helpers", () => {
]);
});
it("separates selected WORKS user creation ids from update-needed user ids", () => {
const rows = [
{
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "baron-only",
},
{
resourceType: "USER",
status: "needs_update",
baronId: "needs-update",
worksmobileId: "works-needs-update",
},
{
resourceType: "USER",
status: "matched",
baronId: "matched",
worksmobileId: "works-matched",
},
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-only",
},
];
const selectedKeys = rows.map(getWorksmobileRowSelectionKey);
expect(getWorksmobileSelectedCreateUserIds(rows, selectedKeys)).toEqual([
"baron-only",
]);
expect(getWorksmobileSelectedUpdateUserIds(rows, selectedKeys)).toEqual([
"needs-update",
]);
});
it("uses compact comparison columns by default", () => {
expect(getDefaultWorksmobileComparisonColumns()).toEqual({
status: true,

View File

@@ -1,9 +1,6 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import {
ChevronDown,
ChevronRight,
Download,
KeyRound,
RefreshCw,
RotateCcw,
@@ -42,21 +39,16 @@ import {
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
deleteWorksmobileCredentialBatchPasswords,
deleteWorksmobilePendingJobs,
downloadWorksmobileInitialPasswordsCSV,
enqueueWorksmobileBackfillDryRun,
enqueueWorksmobileOrgUnitDelete,
enqueueWorksmobileOrgUnitSync,
enqueueWorksmobileUserSync,
fetchMe,
fetchWorksmobileComparison,
fetchWorksmobileCredentialBatches,
fetchWorksmobileOverview,
resetWorksmobileUserPassword,
retryWorksmobileJob,
type WorksmobileComparisonItem,
type WorksmobileCredentialBatch,
type WorksmobileOutboxItem,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
@@ -81,8 +73,9 @@ import {
getWorksmobileComparisonStatusLabel,
getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds,
getWorksmobileSelectedCreateUserIds,
getWorksmobileSelectedUpdateUserIds,
getWorksmobileSelectedWorksOnlyOrgUnitIds,
isImmutableWorksmobileAccount,
summarizeWorksmobileComparison,
type WorksmobileComparisonColumnKey,
type WorksmobileComparisonColumnVisibility,
@@ -90,17 +83,6 @@ import {
type WorksmobileComparisonSummary,
} from "./worksmobileComparison";
type InitialPasswordDownloadVariables = {
batchId?: string;
};
export function createWorksmobileCredentialBatchId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `worksmobile-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
}
function worksmobileJobPayloadString(job: WorksmobileOutboxItem, key: string) {
const value = job.payload?.[key];
return typeof value === "string" ? value.trim() : "";
@@ -238,12 +220,6 @@ export function TenantWorksmobilePage() {
enabled: tenantId.length > 0 && hasWorksmobileAccess,
});
const credentialBatchesQuery = useQuery({
queryKey: ["worksmobile-credential-batches", tenantId],
queryFn: () => fetchWorksmobileCredentialBatches(tenantId),
enabled: tenantId.length > 0 && hasWorksmobileAccess,
});
const dryRunMutation = useMutation({
mutationFn: () => enqueueWorksmobileBackfillDryRun(tenantId),
onSuccess: () => {
@@ -275,7 +251,6 @@ export function TenantWorksmobilePage() {
onSuccess: (result) => {
toast.success(`대기중 payload ${result.deletedCount}건을 삭제했습니다.`);
overviewQuery.refetch();
credentialBatchesQuery.refetch();
},
onError: (error) => {
toast.error("대기중 payload 삭제 실패", {
@@ -284,40 +259,6 @@ export function TenantWorksmobilePage() {
},
});
const initialPasswordDownloadMutation = useMutation({
mutationFn: (variables?: InitialPasswordDownloadVariables) =>
downloadWorksmobileInitialPasswordsCSV(tenantId, variables?.batchId),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: (error) => {
toast.error("초기 비밀번호 CSV 다운로드 실패", {
description: getErrorMessage(error),
});
},
});
const deleteCredentialBatchPasswordsMutation = useMutation({
mutationFn: (batchId: string) =>
deleteWorksmobileCredentialBatchPasswords(tenantId, batchId),
onSuccess: () => {
toast.success("비밀번호 값을 삭제했습니다.");
credentialBatchesQuery.refetch();
},
onError: (error) => {
toast.error("비밀번호 값 삭제 실패", {
description: getErrorMessage(error),
});
},
});
const orgUnitSyncMutation = useMutation({
mutationFn: () => enqueueWorksmobileOrgUnitSync(tenantId, orgUnitId.trim()),
onSuccess: () => {
@@ -348,20 +289,24 @@ export function TenantWorksmobilePage() {
mutationFn: async ({
resourceKind,
ids,
initialPassword,
}: {
resourceKind: "users" | "groups";
ids: string[];
initialPassword?: string;
}) => {
const credentialBatchId =
resourceKind === "users"
? createWorksmobileCredentialBatchId()
: undefined;
const trimmedInitialPassword = initialPassword?.trim();
const failures: string[] = [];
let successCount = 0;
for (const id of ids) {
try {
if (resourceKind === "users") {
await enqueueWorksmobileUserSync(tenantId, id, credentialBatchId);
await enqueueWorksmobileUserSync(
tenantId,
id,
undefined,
trimmedInitialPassword,
);
} else {
await enqueueWorksmobileOrgUnitSync(tenantId, id);
}
@@ -379,10 +324,6 @@ export function TenantWorksmobilePage() {
resourceKind,
count: successCount,
failureCount: failures.length,
credentialBatchId:
resourceKind === "users" && successCount > 0
? credentialBatchId
: undefined,
};
},
onSuccess: ({ resourceKind, count, failureCount }) => {
@@ -397,15 +338,11 @@ export function TenantWorksmobilePage() {
});
} else {
toast.success("WORKS 생성 작업을 등록했습니다.", {
description:
resourceKind === "users"
? `${count}건, 비밀번호 CSV는 배치 처리 완료 후 히스토리에서 다운로드할 수 있습니다.`
: `${count}`,
description: `${count}`,
});
}
overviewQuery.refetch();
comparisonQuery.refetch();
credentialBatchesQuery.refetch();
},
onError: (error) => {
toast.error("WORKS 생성 작업 등록 실패", {
@@ -414,30 +351,6 @@ export function TenantWorksmobilePage() {
},
});
const resetWorksmobilePasswordMutation = useMutation({
mutationFn: ({
userId,
credentialBatchId,
}: {
userId: string;
credentialBatchId: string;
}) => resetWorksmobileUserPassword(tenantId, userId, credentialBatchId),
onSuccess: () => {
toast.success("WORKS 비밀번호 재설정 작업을 등록했습니다.", {
description:
"비밀번호 CSV는 배치 처리 완료 후 히스토리에서 다운로드할 수 있습니다.",
});
overviewQuery.refetch();
comparisonQuery.refetch();
credentialBatchesQuery.refetch();
},
onError: (error) => {
toast.error("WORKS 비밀번호 재설정 등록 실패", {
description: getErrorMessage(error),
});
},
});
const syncSelectedOrgUnitsMutation = useMutation({
mutationFn: async ({
baronIds,
@@ -522,10 +435,7 @@ export function TenantWorksmobilePage() {
createSelectedMutation.isPending &&
createSelectedMutation.variables?.resourceKind === "users";
const isSyncingGroups = syncSelectedOrgUnitsMutation.isPending;
const isRefreshing =
overviewQuery.isFetching ||
comparisonQuery.isFetching ||
credentialBatchesQuery.isFetching;
const isRefreshing = overviewQuery.isFetching || comparisonQuery.isFetching;
return (
<div className="min-w-0 max-w-full space-y-6">
@@ -548,7 +458,6 @@ export function TenantWorksmobilePage() {
onClick={() => {
overviewQuery.refetch();
comparisonQuery.refetch();
credentialBatchesQuery.refetch();
}}
disabled={isRefreshing}
>
@@ -602,29 +511,6 @@ export function TenantWorksmobilePage() {
{activeTab === "history" ? (
<div className="space-y-4 animate-in fade-in duration-500">
<CredentialBatchHistory
batches={credentialBatchesQuery.data ?? []}
loading={credentialBatchesQuery.isLoading}
downloadingBatchId={
initialPasswordDownloadMutation.isPending
? initialPasswordDownloadMutation.variables?.batchId
: undefined
}
deletingBatchId={deleteCredentialBatchPasswordsMutation.variables}
onDownload={(batchId) =>
initialPasswordDownloadMutation.mutate({ batchId })
}
onDelete={(batchId) => {
if (
window.confirm(
"이 배치의 실제 비밀번호 값을 삭제할까요? 생성 이력은 유지됩니다.",
)
) {
deleteCredentialBatchPasswordsMutation.mutate(batchId);
}
}}
/>
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3">
<div>
@@ -742,6 +628,7 @@ export function TenantWorksmobilePage() {
<ComparisonTable
title={t("ui.admin.tenants.worksmobile.compare_users", "구성원")}
rows={filteredComparisonUsers}
totalRows={comparisonQuery.data?.users.length ?? 0}
loading={comparisonQuery.isLoading}
selectedKeys={selectedUserRowKeys}
onSelectedKeysChange={setSelectedUserRowKeys}
@@ -767,29 +654,21 @@ export function TenantWorksmobilePage() {
passwordManageTenantId={overview?.config.adminTenantId}
actionLabel="선택 구성원 WORKS에 생성"
actionDisabled={isCreatingUsers || createSelectedMutation.isPending}
onCreateSelected={(ids) =>
updateActionLabel="선택 구성원 업데이트 적용"
onCreateSelected={(ids, initialPassword) =>
createSelectedMutation.mutate({
resourceKind: "users",
ids,
initialPassword,
})
}
onUpdateSelected={(ids) =>
createSelectedMutation.mutate({
resourceKind: "users",
ids,
})
}
resettingPasswordUserId={
resetWorksmobilePasswordMutation.isPending
? resetWorksmobilePasswordMutation.variables?.userId
: undefined
}
onResetUserPassword={(userId) => {
if (
window.confirm(
"선택한 WORKS 계정의 비밀번호를 재설정할까요? 새 비밀번호는 배치 처리 완료 후 히스토리에서 CSV로 다운로드할 수 있습니다.",
)
) {
resetWorksmobilePasswordMutation.mutate({
userId,
credentialBatchId: createWorksmobileCredentialBatchId(),
});
}
}}
requireInitialPassword
/>
<Card data-testid="worksmobile-users-single-sync">
<CardContent className="flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between">
@@ -835,6 +714,7 @@ export function TenantWorksmobilePage() {
"조직/그룹",
)}
rows={filteredComparisonGroups}
totalRows={comparisonQuery.data?.groups.length ?? 0}
loading={comparisonQuery.isLoading}
selectedKeys={selectedGroupRowKeys}
onSelectedKeysChange={setSelectedGroupRowKeys}
@@ -940,6 +820,11 @@ const worksmobileComparisonColumnWidths: Record<
worksmobileOrg: 260,
manage: 112,
};
const worksmobileComparisonTableHeadClassName =
"h-12 whitespace-nowrap px-0 align-middle";
const worksmobileComparisonTableHeadContentClassName =
"flex h-full items-center px-4";
const worksmobileComparisonTableHeadCenterContentClassName = `${worksmobileComparisonTableHeadContentClassName} justify-center`;
function getDefaultGroupWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
return {
@@ -982,216 +867,6 @@ function getWorksmobileComparisonStatusVariant(status: string) {
return "secondary";
}
function formatCredentialBatchDate(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "-";
return date.toLocaleString("ko-KR", {
timeZone: "Asia/Seoul",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
}
function CredentialBatchHistory({
batches,
loading,
downloadingBatchId,
deletingBatchId,
onDownload,
onDelete,
}: {
batches: WorksmobileCredentialBatch[];
loading: boolean;
downloadingBatchId?: string;
deletingBatchId?: string;
onDownload: (batchId: string) => void;
onDelete: (batchId: string) => void;
}) {
const [expandedBatchIds, setExpandedBatchIds] = React.useState<string[]>([]);
const toggleExpanded = (batchId: string) => {
setExpandedBatchIds((current) =>
current.includes(batchId)
? current.filter((id) => id !== batchId)
: [...current, batchId],
);
};
return (
<Card className="min-w-0 overflow-hidden">
<CardHeader>
<CardTitle className="text-base"> </CardTitle>
<CardDescription>
CSV를
.
</CardDescription>
</CardHeader>
<CardContent>
<div className="w-full max-w-full overflow-x-auto rounded-md border">
<Table className="min-w-max">
<TableHeader>
<TableRow>
<TableHead className="min-w-56 whitespace-nowrap">
</TableHead>
<TableHead className="w-24 whitespace-nowrap"></TableHead>
<TableHead className="min-w-36 whitespace-nowrap">
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
</TableHead>
<TableHead className="min-w-44 whitespace-nowrap">
</TableHead>
<TableHead className="w-24 whitespace-nowrap"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading && (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
...
</TableCell>
</TableRow>
)}
{!loading && batches.length === 0 && (
<TableRow>
<TableCell colSpan={6} className="text-muted-foreground">
.
</TableCell>
</TableRow>
)}
{batches.map((batch) => {
const isComplete =
(batch.pendingCount ?? 0) === 0 &&
(batch.processingCount ?? 0) === 0;
const isExpanded = expandedBatchIds.includes(batch.batchId);
const failures = batch.failures ?? [];
return (
<React.Fragment key={batch.batchId}>
<TableRow>
<TableCell className="font-mono text-xs">
<div className="flex items-center gap-1">
{failures.length > 0 && (
<Button
type="button"
size="icon"
variant="ghost"
aria-label={`${batch.batchId} 실패 사유 보기`}
onClick={() => toggleExpanded(batch.batchId)}
>
{isExpanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</Button>
)}
<span>{batch.batchId}</span>
</div>
</TableCell>
<TableCell className="font-mono">
{batch.userCount}
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
<span className="mr-2">
{batch.processedCount ?? 0}
</span>
<span className="mr-2">
{batch.pendingCount ?? 0}
</span>
<span className="mr-2">
{batch.processingCount ?? 0}
</span>
<span> {batch.failedCount ?? 0}</span>
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{formatCredentialBatchDate(batch.createdAt)}
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{batch.hasPasswords
? "보관 중"
: formatCredentialBatchDate(batch.deletedAt)}
</TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button
type="button"
size="icon"
variant="ghost"
aria-label={`${batch.batchId} 비밀번호 CSV 다운로드`}
disabled={
!batch.hasPasswords ||
!isComplete ||
downloadingBatchId === batch.batchId
}
onClick={() => onDownload(batch.batchId)}
>
<Download size={16} />
</Button>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={`${batch.batchId} 비밀번호 값 삭제`}
disabled={
!batch.hasPasswords ||
deletingBatchId === batch.batchId
}
onClick={() => onDelete(batch.batchId)}
>
<Trash2 size={16} />
</Button>
</div>
</TableCell>
</TableRow>
{isExpanded && failures.length > 0 && (
<TableRow>
<TableCell colSpan={6} className="bg-muted/30">
<div className="space-y-2 text-xs">
{failures.map((failure) => (
<div
key={`${failure.userId ?? failure.email}:${failure.lastError}`}
className="grid gap-1 md:grid-cols-[minmax(12rem,1fr)_5rem_minmax(18rem,2fr)]"
>
<div>
<div className="font-medium">
{failure.email ?? failure.userId ?? "-"}
</div>
{failure.userId && (
<div className="font-mono text-muted-foreground">
{failure.userId}
</div>
)}
</div>
<div className="text-muted-foreground">
{failure.status} / retry{" "}
{failure.retryCount ?? 0}
</div>
<div className="break-words">
{failure.lastError ?? "-"}
</div>
</div>
))}
</div>
</TableCell>
</TableRow>
)}
</React.Fragment>
);
})}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
);
}
function ComparisonSummary({
title,
summary,
@@ -1304,6 +979,7 @@ function ComparisonFilterButtons<T extends string>({
function ComparisonTable({
title,
rows,
totalRows,
loading,
selectedKeys,
onSelectedKeysChange,
@@ -1321,17 +997,19 @@ function ComparisonTable({
showBaronIdColumn = true,
showManageColumn = true,
actionLabel,
updateActionLabel,
actionDisabled,
onCreateSelected,
onUpdateSelected,
onRunSelected,
deleteActionLabel,
deleteActionDisabled = false,
onDeleteSelected,
resettingPasswordUserId,
onResetUserPassword,
requireInitialPassword = false,
}: {
title: string;
rows: WorksmobileComparisonItem[];
totalRows: number;
loading: boolean;
selectedKeys: string[];
onSelectedKeysChange: (ids: string[]) => void;
@@ -1351,22 +1029,35 @@ function ComparisonTable({
showBaronIdColumn?: boolean;
showManageColumn?: boolean;
actionLabel: string;
updateActionLabel?: string;
actionDisabled: boolean;
onCreateSelected: (ids: string[]) => void;
onCreateSelected: (ids: string[], initialPassword?: string) => void;
onUpdateSelected?: (ids: string[]) => void;
onRunSelected?: (actionIds: string[], deleteIds: string[]) => void;
deleteActionLabel?: string;
deleteActionDisabled?: boolean;
onDeleteSelected?: (ids: string[]) => void;
resettingPasswordUserId?: string;
onResetUserPassword?: (userId: string) => void;
requireInitialPassword?: boolean;
}) {
const [columnSettingsOpen, setColumnSettingsOpen] = React.useState(false);
const [initialPasswordOpen, setInitialPasswordOpen] = React.useState(false);
const [initialPassword, setInitialPassword] = React.useState("");
const [pendingInitialPasswordIds, setPendingInitialPasswordIds] =
React.useState<string[]>([]);
const tableViewportRef = React.useRef<HTMLDivElement>(null);
const selectableKeys = rows
.filter(canSelectWorksmobileRow)
.map(getWorksmobileRowSelectionKey)
.filter(Boolean);
const selectedActionIds = getWorksmobileSelectedActionIds(rows, selectedKeys);
const selectedCreateUserIds = getWorksmobileSelectedCreateUserIds(
rows,
selectedKeys,
);
const selectedUpdateUserIds = getWorksmobileSelectedUpdateUserIds(
rows,
selectedKeys,
);
const selectedDeleteIds = getWorksmobileSelectedWorksOnlyOrgUnitIds(
rows,
selectedKeys,
@@ -1377,6 +1068,7 @@ function ComparisonTable({
selectedActionIds.length === 0 &&
selectedDeleteIds.length > 0 &&
canRunDeleteAction;
const canRunUserUpdateAction = Boolean(onUpdateSelected);
const selectedActionLabel = shouldRunDeleteAction
? deleteActionLabel
: actionLabel;
@@ -1388,7 +1080,11 @@ function ComparisonTable({
? selectedActionIds.length === 0 && selectedDeleteIds.length === 0
: shouldRunDeleteAction
? selectedDeleteIds.length === 0 || deleteActionDisabled
: selectedActionIds.length === 0) || actionDisabled;
: requireInitialPassword
? selectedCreateUserIds.length === 0
: selectedActionIds.length === 0) || actionDisabled;
const updateActionDisabled =
selectedUpdateUserIds.length === 0 || actionDisabled;
const allSelectableSelected =
selectableKeys.length > 0 &&
selectableKeys.every((key) => selectedKeys.includes(key));
@@ -1476,15 +1172,6 @@ function ComparisonTable({
window.open(url, "_blank", "noopener,noreferrer");
};
const canResetPassword = (row: WorksmobileComparisonItem) =>
Boolean(
onResetUserPassword &&
row.resourceType === "USER" &&
row.baronId &&
row.status !== "missing_in_worksmobile" &&
!isImmutableWorksmobileAccount(row),
);
const toggleColumn = (key: WorksmobileComparisonColumnKey) => {
onVisibleColumnsChange((current) => ({
...current,
@@ -1510,11 +1197,55 @@ function ComparisonTable({
);
};
const runSelectedAction = () => {
if (onRunSelected) {
onRunSelected(selectedActionIds, selectedDeleteIds);
return;
}
if (shouldRunDeleteAction && onDeleteSelected) {
onDeleteSelected(selectedDeleteIds);
return;
}
if (requireInitialPassword) {
setPendingInitialPasswordIds(selectedCreateUserIds);
setInitialPassword("");
setInitialPasswordOpen(true);
return;
}
onCreateSelected(selectedActionIds);
};
const runUpdateAction = () => {
if (!onUpdateSelected || selectedUpdateUserIds.length === 0) {
return;
}
onUpdateSelected(selectedUpdateUserIds);
};
const confirmInitialPassword = () => {
const password = initialPassword.trim();
if (!password) {
toast.error("WORKS 초기 비밀번호를 입력해 주세요.");
return;
}
onCreateSelected(pendingInitialPasswordIds, password);
setInitialPasswordOpen(false);
setInitialPassword("");
setPendingInitialPasswordIds([]);
};
return (
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-3">
<h4 className="text-lg font-semibold leading-none">{title}</h4>
<Badge
variant="outline"
data-testid={`worksmobile-${title}-row-count`}
className="font-mono"
>
{rows.length} / {totalRows}
</Badge>
<Input
type="search"
value={search}
@@ -1568,6 +1299,7 @@ function ComparisonTable({
className="flex cursor-pointer items-center gap-3 rounded-md p-2 hover:bg-muted/50"
>
<input
name={`worksmobile-column-${column.key}`}
type="checkbox"
className="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary"
checked={isColumnVisible(column.key)}
@@ -1594,21 +1326,69 @@ function ComparisonTable({
type="button"
size="sm"
variant={selectedActionVariant}
onClick={() => {
if (onRunSelected) {
onRunSelected(selectedActionIds, selectedDeleteIds);
return;
}
if (shouldRunDeleteAction && onDeleteSelected) {
onDeleteSelected(selectedDeleteIds);
return;
}
onCreateSelected(selectedActionIds);
}}
onClick={runSelectedAction}
disabled={selectedActionDisabled}
>
{selectedActionLabel}
</Button>
{canRunUserUpdateAction && (
<Button
type="button"
size="sm"
variant="outline"
onClick={runUpdateAction}
disabled={updateActionDisabled}
>
{updateActionLabel || "선택 구성원 업데이트 적용"}
</Button>
)}
<Dialog
open={initialPasswordOpen}
onOpenChange={(open) => {
setInitialPasswordOpen(open);
if (!open) {
setInitialPassword("");
setPendingInitialPasswordIds([]);
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>WORKS </DialogTitle>
<DialogDescription>
WORKS에
.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 py-2">
<label
className="text-sm font-medium"
htmlFor="worksmobile-initial-password"
>
</label>
<Input
id="worksmobile-initial-password"
type="password"
value={initialPassword}
onChange={(event) => setInitialPassword(event.target.value)}
autoComplete="new-password"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="secondary"
onClick={() => setInitialPasswordOpen(false)}
>
</Button>
<Button type="button" onClick={confirmInitialPassword}>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
<div
@@ -1625,54 +1405,100 @@ function ComparisonTable({
minWidth: tableMinWidth,
}}
>
<TableHead className="w-10 whitespace-nowrap">
<Checkbox
aria-label={`${title} 전체 선택`}
checked={allSelectableSelected}
disabled={selectableKeys.length === 0}
onCheckedChange={toggleAll}
/>
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={
worksmobileComparisonTableHeadCenterContentClassName
}
>
<Checkbox
aria-label={`${title} 전체 선택`}
checked={allSelectableSelected}
disabled={selectableKeys.length === 0}
onCheckedChange={toggleAll}
/>
</div>
</TableHead>
{isColumnVisible("status") && (
<TableHead className="w-24 whitespace-nowrap"></TableHead>
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
</div>
</TableHead>
)}
{showBaronIdColumn && isColumnVisible("baronId") && (
<TableHead className="min-w-44 whitespace-nowrap">
Baron ID
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
Baron ID
</div>
</TableHead>
)}
{isColumnVisible("baron") && (
<TableHead className="min-w-44 whitespace-nowrap">
Baron
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
Baron
</div>
</TableHead>
)}
{isColumnVisible("baronOrg") && (
<TableHead className="min-w-44 whitespace-nowrap">
{baronOrgColumnLabel}
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
{baronOrgColumnLabel}
</div>
</TableHead>
)}
{isColumnVisible("externalKey") && (
<TableHead className="min-w-40 whitespace-nowrap">
external_key
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
external_key
</div>
</TableHead>
)}
{isColumnVisible("worksmobileDomain") && (
<TableHead className="min-w-28 whitespace-nowrap">
WORKS
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
WORKS
</div>
</TableHead>
)}
{isColumnVisible("worksmobile") && (
<TableHead className="min-w-44 whitespace-nowrap">
WORKS
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
WORKS
</div>
</TableHead>
)}
{isColumnVisible("worksmobileOrg") && (
<TableHead className="min-w-52 whitespace-nowrap">
Works
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
Works
</div>
</TableHead>
)}
{showManageColumn && isColumnVisible("manage") && (
<TableHead className="w-14 whitespace-nowrap"></TableHead>
<TableHead className={worksmobileComparisonTableHeadClassName}>
<div
className={worksmobileComparisonTableHeadContentClassName}
>
</div>
</TableHead>
)}
</TableRow>
</TableHeader>
@@ -1887,23 +1713,6 @@ function ComparisonTable({
>
<KeyRound size={16} />
</Button>
<Button
type="button"
variant="ghost"
size="sm"
aria-label={`${row.worksmobileName ?? row.baronName ?? row.worksmobileId ?? "WORKS user"} 비밀번호 재설정`}
disabled={
!canResetPassword(row) ||
resettingPasswordUserId === row.baronId
}
onClick={() => {
if (row.baronId) {
onResetUserPassword?.(row.baronId);
}
}}
>
<RotateCcw size={16} />
</Button>
</div>
)}
</TableCell>

View File

@@ -16,6 +16,16 @@ export function tenantMatchesListSearch(
.some((value) => value.toLowerCase().includes(normalizedSearch));
}
export function getTenantSearchMatchIds(
rows: Array<Pick<TenantSummary, "id" | "name" | "slug" | "type">>,
search: string,
) {
if (!search.trim()) return [];
return rows
.filter((row) => tenantMatchesListSearch(row, search))
.map((row) => row.id);
}
function collectTenantTreeRows(
nodes: TenantNode[],
depth: number,
@@ -91,7 +101,8 @@ export function getTenantViewRows(
...(rowsById.get(tenant.id) ?? {
...tenant,
children: [],
recursiveMemberCount: Number(tenant.memberCount) || 0,
recursiveMemberCount:
Number(tenant.totalMemberCount ?? tenant.memberCount) || 0,
}),
depth: 0,
}));

View File

@@ -172,6 +172,38 @@ export function getWorksmobileSelectedActionIds(
.filter((id): id is string => Boolean(id));
}
export function getWorksmobileSelectedCreateUserIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter(
(row) =>
row.resourceType === "USER" &&
row.status === "missing_in_worksmobile" &&
selected.has(getWorksmobileRowSelectionKey(row)),
)
.map((row) => row.baronId)
.filter((id): id is string => Boolean(id));
}
export function getWorksmobileSelectedUpdateUserIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter(
(row) =>
row.resourceType === "USER" &&
row.status === "needs_update" &&
selected.has(getWorksmobileRowSelectionKey(row)),
)
.map((row) => row.baronId)
.filter((id): id is string => Boolean(id));
}
export function getWorksmobileSelectedMissingExternalKeyOrgUnitIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],

View File

@@ -62,6 +62,7 @@ import {
import { toast } from "../../../components/ui/use-toast";
import {
exportTenantsCSV,
exportUsersCSV,
fetchAllTenants,
fetchUsers,
type TenantSummary,
@@ -432,6 +433,24 @@ function TenantUserGroupsTab() {
),
});
const exportCurrentMembersMutation = useMutation({
mutationFn: (tenantSlug: string) => exportUsersCSV("", tenantSlug, false),
onSuccess: ({ blob, filename }) => {
const url = window.URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
},
onError: () =>
toast.error(
t("msg.admin.users.export_error", "사용자 내보내기에 실패했습니다."),
),
});
// Data Fetching
const {
data: allTenantsData,
@@ -623,6 +642,20 @@ function TenantUserGroupsTab() {
<UserPlus size={16} className="mr-2" />
{t("ui.admin.users.list.add", "멤버 추가")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
exportCurrentMembersMutation.mutate(selectedNode.slug)
}
disabled={
!selectedNode.slug || exportCurrentMembersMutation.isPending
}
data-testid="tenant-current-users-export-btn"
>
<Download size={16} className="mr-2" />
{t("ui.admin.tenants.members.export", "선택 조직 사용자 CSV")}
</Button>
<Button
variant="outline"
size="sm"

View File

@@ -0,0 +1,317 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Key, Plus, Save, Trash2, Users } from "lucide-react";
import * as React from "react";
import { Link } from "react-router-dom";
import { PageHeader } from "../../../../common/core/components/page";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { toast } from "../../components/ui/use-toast";
import {
fetchGlobalCustomClaimDefinitions,
type GlobalCustomClaimDefinition,
type GlobalCustomClaimPermission,
updateGlobalCustomClaimDefinitions,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
type ClaimDraft = GlobalCustomClaimDefinition & { id: string };
const valueTypes: GlobalCustomClaimDefinition["valueType"][] = [
"text",
"number",
"boolean",
"array",
"object",
"date",
"datetime",
];
const permissions: GlobalCustomClaimPermission[] = [
"admin_only",
"user_and_admin",
];
function toDrafts(items: GlobalCustomClaimDefinition[]): ClaimDraft[] {
return items.map((item, index) => ({
id: `${item.key || "claim"}-${index}`,
key: item.key,
label: item.label,
valueType: item.valueType || "text",
readPermission: item.readPermission || "admin_only",
writePermission: item.writePermission || "admin_only",
description: item.description || "",
}));
}
function toDefinitions(drafts: ClaimDraft[]): GlobalCustomClaimDefinition[] {
return drafts
.map((draft) => ({
key: draft.key.trim(),
label: draft.label.trim(),
valueType: draft.valueType,
readPermission: draft.readPermission,
writePermission: draft.writePermission,
description: draft.description?.trim(),
}))
.filter((draft) => draft.key.length > 0);
}
function permissionLabel(permission: GlobalCustomClaimPermission) {
return permission === "user_and_admin"
? t(
"ui.common.custom_claim_permission.user_and_admin",
"사용자 및 관리자 가능",
)
: t("ui.common.custom_claim_permission.admin_only", "관리자만 가능");
}
export default function GlobalCustomClaimsPage() {
const queryClient = useQueryClient();
const [drafts, setDrafts] = React.useState<ClaimDraft[]>([]);
const query = useQuery({
queryKey: ["global-custom-claim-definitions"],
queryFn: fetchGlobalCustomClaimDefinitions,
});
React.useEffect(() => {
if (query.data) {
setDrafts(toDrafts(query.data.items));
}
}, [query.data]);
const mutation = useMutation({
mutationFn: updateGlobalCustomClaimDefinitions,
onSuccess: (data) => {
queryClient.setQueryData(["global-custom-claim-definitions"], data);
toast.success(t("msg.info.saved_success", "저장되었습니다."));
},
onError: () => {
toast.error(t("err.common.unknown", "오류가 발생했습니다."));
},
});
const addClaim = () => {
setDrafts((current) => [
...current,
{
id: `global-claim-${Date.now()}`,
key: "",
label: "",
valueType: "text",
readPermission: "admin_only",
writePermission: "admin_only",
description: "",
},
]);
};
const updateClaim = (id: string, patch: Partial<ClaimDraft>) => {
setDrafts((current) =>
current.map((draft) =>
draft.id === id ? { ...draft, ...patch } : draft,
),
);
};
const removeClaim = (id: string) => {
setDrafts((current) => current.filter((draft) => draft.id !== id));
};
const saveClaims = () => {
mutation.mutate({ items: toDefinitions(drafts) });
};
return (
<div className="space-y-6">
<PageHeader
titleAs="h2"
icon={<Key size={20} />}
title={t(
"ui.admin.users.global_custom_claims.title",
"전역 Claim 설정",
)}
description={t(
"msg.admin.users.global_custom_claims.description",
"모든 RP에 공통 적용할 사용자 claim 정의와 읽기/쓰기 권한 기본값을 관리합니다.",
)}
actions={
<>
<Button asChild variant="outline" size="sm" className="h-9">
<Link to="/users">
<Users size={16} />
{t("ui.admin.users.list.title", "사용자 관리")}
</Link>
</Button>
<Button
type="button"
variant="outline"
size="sm"
className="h-9 gap-2"
onClick={addClaim}
>
<Plus size={16} />
{t("ui.common.add", "추가")}
</Button>
<Button
type="button"
size="sm"
className="h-9 gap-2"
disabled={mutation.isPending}
onClick={saveClaims}
>
<Save size={16} />
{t("ui.common.save", "저장")}
</Button>
</>
}
/>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-lg">
{t(
"ui.admin.users.global_custom_claims.registry",
"Global Claim Registry",
)}
</CardTitle>
<CardDescription>
{t(
"msg.admin.users.global_custom_claims.registry",
"정의된 claim key만 사용자 상세의 전역 claim 값 관리 대상이 됩니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{query.isLoading ? (
<div className="py-12 text-center text-sm text-muted-foreground">
{t("ui.common.loading", "로딩 중...")}
</div>
) : drafts.length === 0 ? (
<div className="rounded-lg border border-dashed py-12 text-center text-sm text-muted-foreground">
{t(
"msg.admin.users.global_custom_claims.empty",
"정의된 전역 claim이 없습니다.",
)}
</div>
) : (
drafts.map((claim) => (
<div
key={claim.id}
className="grid gap-3 rounded-md border bg-background p-3 lg:grid-cols-[minmax(160px,0.8fr)_minmax(160px,0.8fr)_130px_160px_160px_minmax(220px,1fr)_40px]"
>
<Input
value={claim.key}
className="font-mono text-xs"
placeholder="claim_key"
data-testid={`global-claim-definition-key-${claim.key || claim.id}`}
onChange={(event) =>
updateClaim(claim.id, { key: event.target.value })
}
/>
<Input
value={claim.label}
placeholder={t(
"ui.admin.users.global_custom_claims.label_placeholder",
"표시 이름",
)}
data-testid={`global-claim-definition-label-${claim.key || claim.id}`}
onChange={(event) =>
updateClaim(claim.id, { label: event.target.value })
}
/>
<select
aria-label={t(
"ui.admin.users.global_custom_claims.value_type",
"Claim 타입",
)}
value={claim.valueType}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
onChange={(event) =>
updateClaim(claim.id, {
valueType: event.target
.value as GlobalCustomClaimDefinition["valueType"],
})
}
>
{valueTypes.map((valueType) => (
<option key={valueType} value={valueType}>
{valueType}
</option>
))}
</select>
<select
aria-label={t(
"ui.admin.users.global_custom_claims.read_permission",
"읽기 권한",
)}
value={claim.readPermission}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
data-testid={`global-claim-definition-read-permission-${claim.key || claim.id}`}
onChange={(event) =>
updateClaim(claim.id, {
readPermission: event.target
.value as GlobalCustomClaimPermission,
})
}
>
{permissions.map((permission) => (
<option key={permission} value={permission}>
{permissionLabel(permission)}
</option>
))}
</select>
<select
aria-label={t(
"ui.admin.users.global_custom_claims.write_permission",
"쓰기 권한",
)}
value={claim.writePermission}
className="h-10 rounded-md border border-input bg-background px-3 text-sm"
data-testid={`global-claim-definition-write-permission-${claim.key || claim.id}`}
onChange={(event) =>
updateClaim(claim.id, {
writePermission: event.target
.value as GlobalCustomClaimPermission,
})
}
>
{permissions.map((permission) => (
<option key={permission} value={permission}>
{permissionLabel(permission)}
</option>
))}
</select>
<Input
value={claim.description || ""}
placeholder={t(
"ui.admin.users.global_custom_claims.description_placeholder",
"설명",
)}
onChange={(event) =>
updateClaim(claim.id, { description: event.target.value })
}
/>
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => removeClaim(claim.id)}
>
<Trash2 size={16} />
</Button>
</div>
))
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -720,6 +720,8 @@ function UserCreatePage() {
</Label>
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
id="auto-password"
name="auto-password"
type="checkbox"
checked={autoPassword}
onChange={(event) => setAutoPassword(event.target.checked)}

View File

@@ -34,6 +34,18 @@ vi.mock("../../lib/adminApi", () => ({
name: "Admin",
email: "admin@example.com",
})),
fetchGlobalCustomClaimDefinitions: vi.fn(async () => ({
items: [
{
key: "contract_date",
label: "계약일",
valueType: "date",
readPermission: "admin_only",
writePermission: "admin_only",
description: "",
},
],
})),
fetchPasswordPolicy: vi.fn(async () => ({ minLength: 12 })),
fetchTenant: vi.fn(),
fetchUser: vi.fn(async () => ({
@@ -65,6 +77,9 @@ vi.mock("../../lib/adminApi", () => ({
"4": "o",
"5": "n",
},
global_custom_claims: {
contract_date: "2026-06-09",
},
},
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
@@ -152,4 +167,43 @@ describe("UserDetailPage Worksmobile employee number", () => {
const payload = updateUserMock.mock.calls[0][1];
expect(payload.metadata).not.toHaveProperty("employee_id");
});
it("only allows editing per-user values for globally defined custom claims", async () => {
renderUserDetailPage();
const tab = await screen.findByTestId("global-custom-claim-tab");
fireEvent.click(tab);
expect(
screen.queryByRole("button", { name: "추가" }),
).not.toBeInTheDocument();
const valueInput = await screen.findByTestId(
"global-custom-claim-value-contract_date",
);
expect(screen.getByText("contract_date")).toBeInTheDocument();
expect(valueInput).toHaveValue("2026-06-09");
expect(valueInput).toHaveAttribute("type", "date");
fireEvent.change(valueInput, { target: { value: "2026-07-01" } });
fireEvent.click(screen.getByRole("button", { name: /사용자 Claim 값 저장/ }));
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
expect(updateUserMock).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({
metadata: expect.objectContaining({
global_custom_claims: expect.objectContaining({
contract_date: "2026-07-01",
}),
global_custom_claim_permissions: expect.objectContaining({
contract_date: {
readPermission: "admin_only",
writePermission: "admin_only",
},
}),
}),
}),
);
});
});

View File

@@ -60,10 +60,14 @@ import {
TabsTrigger,
} from "../../components/ui/tabs";
import { toast } from "../../components/ui/use-toast";
import type { PasswordPolicyResponse } from "../../lib/adminApi";
import type {
GlobalCustomClaimDefinition,
PasswordPolicyResponse,
} from "../../lib/adminApi";
import {
deleteUser,
fetchAllTenants,
fetchGlobalCustomClaimDefinitions,
fetchMe,
fetchPasswordPolicy,
fetchTenant,
@@ -111,6 +115,25 @@ type PickerTarget = { kind: "appointment"; index: number };
type AppointmentDraft = UserAppointment & {
draftId: string;
};
type GlobalCustomClaimType =
| "text"
| "number"
| "boolean"
| "array"
| "object"
| "date"
| "datetime";
type CustomClaimPermission = "admin_only" | "user_and_admin";
type GlobalCustomClaimRow = {
id: string;
key: string;
label: string;
value: string;
valueType: GlobalCustomClaimType;
readPermission: CustomClaimPermission;
writePermission: CustomClaimPermission;
description?: string;
};
const PASSWORD_RESET_MIN_LENGTH = 12;
@@ -179,6 +202,74 @@ function createDraftId() {
return globalThis.crypto?.randomUUID?.() ?? `appointment-${Date.now()}`;
}
function createGlobalCustomClaimRows(
metadata: Record<string, unknown>,
definitions: GlobalCustomClaimDefinition[],
): GlobalCustomClaimRow[] {
const rawClaims = isMetadataRecord(metadata.global_custom_claims)
? metadata.global_custom_claims
: {};
return definitions.map((definition, index) => {
const value = rawClaims[definition.key];
return {
id: `${definition.key}-${index}`,
key: definition.key,
label: definition.label,
description: definition.description,
value:
typeof value === "string"
? value
: value == null
? ""
: JSON.stringify(value),
valueType: definition.valueType,
readPermission: definition.readPermission,
writePermission: definition.writePermission,
};
});
}
function globalCustomClaimInputType(valueType: GlobalCustomClaimType) {
if (valueType === "date") {
return "date";
}
if (valueType === "datetime") {
return "datetime-local";
}
if (valueType === "number") {
return "number";
}
return "text";
}
function globalCustomClaimRowsToMetadata(rows: GlobalCustomClaimRow[]) {
const claims: Record<string, unknown> = {};
const types: Record<string, GlobalCustomClaimType> = {};
const permissions: Record<
string,
{
readPermission: CustomClaimPermission;
writePermission: CustomClaimPermission;
}
> = {};
for (const row of rows) {
const key = row.key.trim();
if (!key) {
continue;
}
claims[key] = row.value.trim();
types[key] = row.valueType;
permissions[key] = {
readPermission: row.readPermission,
writePermission: row.writePermission,
};
}
return { claims, types, permissions };
}
async function resolveTenantSelection(
selection: OrgChartTenantSelection,
tenants: TenantSummary[],
@@ -408,6 +499,9 @@ function UserDetailPage() {
const [additionalAppointments, setAdditionalAppointments] = React.useState<
AppointmentDraft[]
>([]);
const [globalCustomClaimRows, setGlobalCustomClaimRows] = React.useState<
GlobalCustomClaimRow[]
>([]);
const [pickerTarget, setPickerTarget] = React.useState<PickerTarget | null>(
null,
);
@@ -449,6 +543,14 @@ function UserDetailPage() {
queryKey: ["password-policy"],
queryFn: fetchPasswordPolicy,
});
const { data: globalCustomClaimDefinitionsData } = useQuery({
queryKey: ["global-custom-claim-definitions"],
queryFn: fetchGlobalCustomClaimDefinitions,
});
const globalCustomClaimDefinitions = React.useMemo(
() => globalCustomClaimDefinitionsData?.items ?? [],
[globalCustomClaimDefinitionsData?.items],
);
const {
register,
@@ -757,6 +859,9 @@ function UserDetailPage() {
? "hanmac"
: "external";
setUserCategory(resolvedUserCategory);
setGlobalCustomClaimRows(
createGlobalCustomClaimRows(metadata, globalCustomClaimDefinitions),
);
const familyFallbackTenants = [
...(user.joinedTenants ?? []),
...(user.tenant ? [user.tenant] : []),
@@ -814,7 +919,14 @@ function UserDetailPage() {
: [],
);
}
}, [hanmacFamilyTenantId, personalTenant, tenants, user, reset]);
}, [
globalCustomClaimDefinitions,
hanmacFamilyTenantId,
personalTenant,
tenants,
user,
reset,
]);
const mutation = useMutation({
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
@@ -963,6 +1075,29 @@ function UserDetailPage() {
}
};
const updateGlobalCustomClaimRow = (
id: string,
patch: Partial<GlobalCustomClaimRow>,
) => {
setGlobalCustomClaimRows((current) =>
current.map((row) => (row.id === id ? { ...row, ...patch } : row)),
);
};
const saveGlobalCustomClaims = () => {
const { claims, types, permissions } = globalCustomClaimRowsToMetadata(
globalCustomClaimRows,
);
mutation.mutate({
metadata: {
...((user?.metadata as Record<string, unknown> | undefined) ?? {}),
global_custom_claims: claims,
global_custom_claim_types: types,
global_custom_claim_permissions: permissions,
},
});
};
const userAffiliatedTenants = React.useMemo(() => {
const joined = user?.joinedTenants || [];
const primary = user?.tenant;
@@ -1121,6 +1256,17 @@ function UserDetailPage() {
<Building2 size={16} className="mr-2" />
{t("ui.admin.users.detail.tabs.tenants", "테넌트 프로필")}
</TabsTrigger>
<TabsTrigger
value="customClaims"
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
data-testid="global-custom-claim-tab"
>
<Key size={16} className="mr-2" />
{t(
"ui.admin.users.detail.tabs.custom_claims",
"전역 Custom Claims",
)}
</TabsTrigger>
<TabsTrigger
value="security"
className="px-6 py-2 rounded-lg data-[state=active]:shadow-sm"
@@ -1793,6 +1939,135 @@ function UserDetailPage() {
</Button>
</div>
</TabsContent>
<TabsContent
value="customClaims"
className="space-y-6 mt-0 animate-in fade-in slide-in-from-bottom-2"
>
<Card className="border-none shadow-sm bg-[var(--color-panel)] rounded-2xl">
<CardHeader className="pb-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div>
<CardTitle className="text-lg flex items-center gap-2">
<Key size={18} className="text-primary" />
{t(
"ui.admin.users.detail.custom_claims.title",
"사용자별 Custom Claim 값",
)}
</CardTitle>
<CardDescription>
{t(
"msg.admin.users.detail.custom_claims.description",
"전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다.",
)}
</CardDescription>
</div>
<Button
type="button"
variant="outline"
className="gap-2"
onClick={() => navigate("/users/custom-claims")}
>
<Key className="h-4 w-4" />
{t(
"ui.admin.users.global_custom_claims.manage_definitions",
"전역 정의 관리",
)}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4 p-8">
{globalCustomClaimRows.length === 0 ? (
<div className="rounded-2xl border-2 border-dashed bg-muted/5 py-12 text-center text-sm text-muted-foreground">
{t(
"msg.admin.users.detail.custom_claims.empty",
"전역으로 정의된 custom claim이 없습니다.",
)}
</div>
) : (
<div className="space-y-3">
{globalCustomClaimRows.map((claim) => (
<div
key={claim.id}
className="grid gap-3 lg:grid-cols-[minmax(180px,0.8fr)_130px_150px_160px_minmax(220px,1fr)]"
>
<div className="flex h-10 items-center rounded-md border bg-muted/30 px-3 font-mono text-xs">
{claim.key}
</div>
<Badge
variant="muted"
className="h-10 justify-center rounded-md px-3 font-mono text-xs"
>
{claim.valueType}
</Badge>
<Badge
variant="muted"
className="h-10 justify-center rounded-md px-3 text-xs"
>
{claim.readPermission === "user_and_admin"
? t(
"ui.common.custom_claim_permission.user_and_admin",
"사용자 및 관리자 가능",
)
: t(
"ui.common.custom_claim_permission.admin_only",
"관리자만 가능",
)}
</Badge>
<Badge
variant="muted"
className="h-10 justify-center rounded-md px-3 text-xs"
>
{claim.writePermission === "user_and_admin"
? t(
"ui.common.custom_claim_permission.user_and_admin",
"사용자 및 관리자 가능",
)
: t(
"ui.common.custom_claim_permission.admin_only",
"관리자만 가능",
)}
</Badge>
<Input
type={globalCustomClaimInputType(claim.valueType)}
value={claim.value}
onChange={(event) =>
updateGlobalCustomClaimRow(claim.id, {
value: event.target.value,
})
}
className="font-mono text-xs"
data-testid={`global-custom-claim-value-${claim.key || claim.id}`}
placeholder="claim value"
/>
</div>
))}
</div>
)}
</CardContent>
</Card>
<div className="flex justify-end pt-4">
<Button
type="button"
disabled={mutation.isPending}
onClick={saveGlobalCustomClaims}
className="px-12 h-12 rounded-xl shadow-lg transition-all hover:scale-105"
>
{mutation.isPending ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<Save className="mr-2 h-5 w-5" />
)}
<span className="text-base font-bold">
{t(
"ui.admin.users.detail.custom_claims.save",
"사용자 Claim 값 저장",
)}
</span>
</Button>
</div>
</TabsContent>
</form>
<TabsContent

View File

@@ -23,7 +23,7 @@ const users = Array.from({ length: 200 }, (_, index) => ({
const fetchUsersMock = vi.hoisted(() => vi.fn());
const searchRenderBudgetMs =
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 200;
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 300;
vi.mock("../../lib/i18n", () => createI18nMock());
@@ -157,6 +157,35 @@ describe("UserListPage search rendering", () => {
expect(content).toHaveClass("flex", "h-full", "items-center");
});
it("renders additional tenant appointments in the tenant column", async () => {
fetchUsersMock.mockResolvedValueOnce({
items: [
{
...users[0],
name: "Additional Tenant User",
metadata: {
additionalAppointments: [
{
tenantId: "tenant-2",
tenantSlug: "private-team",
tenantName: "비공개 팀",
isPrimary: false,
},
],
},
},
],
total: 1,
});
renderUserListPage();
expect(
await screen.findByText("Additional Tenant User"),
).toBeInTheDocument();
expect(screen.getByText("비공개 팀")).toBeInTheDocument();
});
it("centers the initial loading message across the user table", async () => {
const deferred = createDeferred<{ items: typeof users; total: number }>();
fetchUsersMock.mockReturnValueOnce(deferred.promise);

View File

@@ -13,6 +13,7 @@ import {
ChevronDown,
FileDown,
FileSpreadsheet,
Key,
LayoutDashboard,
Plus,
RefreshCw,
@@ -117,7 +118,7 @@ type UserSchemaField = {
type UserSortKey = string;
const USER_ROW_ESTIMATED_HEIGHT = 64;
const USER_ROW_OVERSCAN = 20;
const USER_ROW_OVERSCAN = 2;
const USER_TABLE_VIEWPORT_ESTIMATED_HEIGHT = 640;
const userFixedColumnWidths = [48, 160, 220, 160, 260, 170, 160, 220] as const;
const userMetadataColumnWidth = 160;
@@ -150,6 +151,52 @@ function assignableSystemRoleValue(role?: string | null) {
return isSuperAdminRole(role) ? "super_admin" : "user";
}
function collectAdditionalTenantLabels(user: UserSummary) {
const primaryKeys = new Set(
[user.tenant?.id, user.tenant?.slug, user.tenantSlug]
.filter((value): value is string => Boolean(value))
.map((value) => value.toLowerCase()),
);
const labels: string[] = [];
const seen = new Set<string>();
const addLabel = (
tenantId?: unknown,
tenantSlug?: unknown,
tenantName?: unknown,
) => {
const id = typeof tenantId === "string" ? tenantId.trim() : "";
const slug = typeof tenantSlug === "string" ? tenantSlug.trim() : "";
const name = typeof tenantName === "string" ? tenantName.trim() : "";
const key = (id || slug || name).toLowerCase();
if (!key || primaryKeys.has(key) || seen.has(key)) {
return;
}
seen.add(key);
labels.push(name || slug || id);
};
for (const tenant of user.joinedTenants ?? []) {
addLabel(tenant.id, tenant.slug, tenant.name);
}
const appointments = user.metadata?.additionalAppointments;
if (Array.isArray(appointments)) {
for (const appointment of appointments) {
if (!appointment || typeof appointment !== "object") {
continue;
}
const value = appointment as Record<string, unknown>;
addLabel(
value.tenantId,
value.tenantSlug ?? value.slug,
value.tenantName ?? value.name,
);
}
}
return labels;
}
function normalizeUserTableRect(rect: Rect, fallbackWidth: number): Rect {
return {
width: rect.width > 0 ? rect.width : fallbackWidth,
@@ -420,7 +467,7 @@ function UserListPage() {
name_email: (user) =>
`${user.name ?? ""} ${user.email ?? ""} ${user.phone ?? ""}`,
tenant_dept: (user) =>
`${user.tenant?.name ?? user.tenantSlug ?? ""} ${user.department ?? ""}`,
`${user.tenant?.name ?? user.tenantSlug ?? ""} ${collectAdditionalTenantLabels(user).join(" ")} ${user.department ?? ""}`,
},
),
[userSchema],
@@ -640,6 +687,15 @@ function UserListPage() {
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button asChild variant="outline" size="sm" className="h-9 gap-2">
<Link to="/users/custom-claims">
<Key size={16} />
{t(
"ui.admin.users.global_custom_claims.title",
"전역 Claim 설정",
)}
</Link>
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
@@ -963,6 +1019,8 @@ function UserListPage() {
virtualRows.map((virtualRow) => {
const user = items[virtualRow.index];
if (!user) return null;
const additionalTenantLabels =
collectAdditionalTenantLabels(user);
return (
<TableRow
@@ -1102,6 +1160,18 @@ function UserListPage() {
{user.department}
</span>
)}
{additionalTenantLabels.length > 0 && (
<div className="flex flex-wrap gap-1">
{additionalTenantLabels.map((label) => (
<span
key={label}
className="max-w-40 truncate rounded border bg-muted/40 px-1.5 py-0.5 text-xs text-muted-foreground"
>
{label}
</span>
))}
</div>
)}
</div>
</TableCell>
{/* Dynamic Metadata Cells */}

View File

@@ -191,6 +191,8 @@ export function UserBulkMoveGroupModal({
{t("ui.admin.users.create.form.tenant", "테넌트 선택")}
</label>
<select
id="bulk-move-target-tenant"
name="bulk-move-target-tenant"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={selectedTenantSlug}
onChange={(e) => {
@@ -290,6 +292,8 @@ export function UserBulkMoveGroupModal({
</div>
<label className="flex items-center gap-2 cursor-pointer mt-2 pt-2 border-t border-destructive/10">
<input
id="bulk-move-acknowledge-warning"
name="bulk-move-acknowledge-warning"
type="checkbox"
checked={acknowledgeWarning}
onChange={(e) => setAcknowledgeWarning(e.target.checked)}

View File

@@ -420,6 +420,7 @@ export function UserBulkUploadModal({
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
<input
name="user-bulk-upload-file"
type="file"
accept=".csv"
className="hidden"
@@ -482,6 +483,8 @@ export function UserBulkUploadModal({
</div>
<div className="space-y-2">
<select
id={`user-bulk-tenant-match-${preview.row.rowNumber}`}
name={`user-bulk-tenant-match-${preview.row.rowNumber}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 text-sm"
value={
selectedTenantMatches[preview.row.rowNumber] ??
@@ -512,6 +515,8 @@ export function UserBulkUploadModal({
{(selectedTenantMatches[preview.row.rowNumber] ??
"__create__") === "__create__" && (
<input
id={`user-bulk-tenant-create-slug-${preview.row.rowNumber}`}
name={`user-bulk-tenant-create-slug-${preview.row.rowNumber}`}
className="h-9 w-full rounded-md border border-input bg-background px-3 font-mono text-sm"
value={
selectedTenantCreateSlugs[
@@ -552,6 +557,8 @@ export function UserBulkUploadModal({
>
<td className="p-2">
<input
id={`user-bulk-email-preview-${index}`}
name={`user-bulk-email-preview-${index}`}
className="h-8 w-full min-w-[180px] rounded-md border border-input bg-background px-2 font-mono text-xs"
value={
hanmacEmailPreviews[index]?.finalEmail ??

View File

@@ -54,7 +54,7 @@ describe("adminApi endpoint contracts", () => {
await adminApi.fetchAdminOverviewStats();
await adminApi.fetchDataIntegrityReport();
await adminApi.fetchOrphanUserLoginIDs();
await adminApi.fetchUserProjectionStatus();
await adminApi.fetchOrySSOTSystemStatus();
await adminApi.fetchAdminRPUsageDaily({
days: 30,
period: "week",
@@ -90,6 +90,7 @@ describe("adminApi endpoint contracts", () => {
expect(apiClient.get).toHaveBeenCalledWith("/v1/audit", {
params: { limit: 10, cursor: "cursor-a" },
});
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/ory/ssot");
expect(apiClient.get).toHaveBeenCalledWith("/v1/admin/tenants", {
params: {
limit: 25,
@@ -133,8 +134,7 @@ describe("adminApi endpoint contracts", () => {
const adminApi = await import("./adminApi");
await adminApi.deleteOrphanUserLoginIDs(["orphan-1"]);
await adminApi.reconcileUserProjection();
await adminApi.resetUserProjection();
await adminApi.flushIdentityCache();
await adminApi.createTenant({ name: "Tenant", slug: "tenant" });
await adminApi.updateTenant("tenant-1", { status: "inactive" });
await adminApi.deleteTenant("tenant-1");
@@ -167,6 +167,7 @@ describe("adminApi endpoint contracts", () => {
"tenant-1",
"user-2",
"credential-batch-1",
"InputPass1!",
);
await adminApi.resetWorksmobileUserPassword(
"tenant-1",
@@ -199,7 +200,7 @@ describe("adminApi endpoint contracts", () => {
{ data: { ids: ["orphan-1"] } },
);
expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/projections/users/reconcile",
"/v1/admin/ory/ssot/identity-cache/flush",
);
expect(apiClient.put).toHaveBeenCalledWith("/v1/admin/users/user-1", {
status: "active",
@@ -209,7 +210,10 @@ describe("adminApi endpoint contracts", () => {
);
expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/tenants/tenant-1/worksmobile/users/user-2/sync",
{ credentialBatchId: "credential-batch-1" },
{
credentialBatchId: "credential-batch-1",
initialPassword: "InputPass1!",
},
);
expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/tenants/tenant-1/worksmobile/users/user-2/password/reset",

View File

@@ -31,7 +31,8 @@ export type TenantSummary = {
domains?: string[];
parentId?: string;
config?: Record<string, unknown>;
memberCount: number; // Added member count
memberCount: number; // 해당 테넌트 직접 소속 인원
totalMemberCount?: number; // 하위 테넌트 포함 전체 인원
createdAt: string;
updatedAt: string;
};
@@ -155,9 +156,24 @@ export type UserProjectionStatus = {
projectedUsers: number;
};
export type UserProjectionActionResult = {
export type IdentityCacheStatus = {
status: string;
syncedUsers: number;
redisReady: boolean;
observedCount: number;
keyCount: number;
lastRefreshedAt?: string;
lastError?: string;
updatedAt?: string;
};
export type OrySSOTSystemStatus = {
userProjection: UserProjectionStatus;
identityCache: IdentityCacheStatus;
};
export type IdentityCacheFlushResult = {
status: string;
flushedKeys: number;
updatedAt: string;
};
@@ -261,16 +277,15 @@ export async function fetchUserProjectionStatus() {
return data;
}
export async function reconcileUserProjection() {
const { data } = await apiClient.post<UserProjectionActionResult>(
"/v1/admin/projections/users/reconcile",
);
export async function fetchOrySSOTSystemStatus() {
const { data } =
await apiClient.get<OrySSOTSystemStatus>("/v1/admin/ory/ssot");
return data;
}
export async function resetUserProjection() {
const { data } = await apiClient.post<UserProjectionActionResult>(
"/v1/admin/projections/users/reset",
export async function flushIdentityCache() {
const { data } = await apiClient.post<IdentityCacheFlushResult>(
"/v1/admin/ory/ssot/identity-cache/flush",
);
return data;
}
@@ -716,6 +731,28 @@ export type UserUpdateRequest = {
metadata?: Record<string, unknown>;
};
export type GlobalCustomClaimPermission = "admin_only" | "user_and_admin";
export type GlobalCustomClaimDefinition = {
key: string;
label: string;
valueType:
| "text"
| "number"
| "boolean"
| "array"
| "object"
| "date"
| "datetime";
readPermission: GlobalCustomClaimPermission;
writePermission: GlobalCustomClaimPermission;
description?: string;
};
export type GlobalCustomClaimDefinitionsResponse = {
items: GlobalCustomClaimDefinition[];
};
export type UserAppointment = {
tenantId: string;
tenantSlug?: string;
@@ -906,6 +943,23 @@ export async function fetchUser(userId: string) {
return data;
}
export async function fetchGlobalCustomClaimDefinitions() {
const { data } = await apiClient.get<GlobalCustomClaimDefinitionsResponse>(
"/v1/admin/global-custom-claims",
);
return data;
}
export async function updateGlobalCustomClaimDefinitions(
payload: GlobalCustomClaimDefinitionsResponse,
) {
const { data } = await apiClient.put<GlobalCustomClaimDefinitionsResponse>(
"/v1/admin/global-custom-claims",
payload,
);
return data;
}
export async function createUser(payload: UserCreateRequest) {
const { data } = await apiClient.post<UserCreateResponse>(
"/v1/admin/users",
@@ -1040,14 +1094,21 @@ export async function enqueueWorksmobileUserSync(
tenantId: string,
userId: string,
credentialBatchId?: string,
initialPassword?: string,
) {
const trimmedBatchId = credentialBatchId?.trim();
const trimmedInitialPassword = initialPassword?.trim();
const path = `/v1/admin/tenants/${tenantId}/worksmobile/users/${userId}/sync`;
const { data } = trimmedBatchId
? await apiClient.post<WorksmobileOutboxItem>(path, {
credentialBatchId: trimmedBatchId,
})
: await apiClient.post<WorksmobileOutboxItem>(path);
const body = {
...(trimmedBatchId ? { credentialBatchId: trimmedBatchId } : {}),
...(trimmedInitialPassword
? { initialPassword: trimmedInitialPassword }
: {}),
};
const { data } =
Object.keys(body).length > 0
? await apiClient.post<WorksmobileOutboxItem>(path, body)
: await apiClient.post<WorksmobileOutboxItem>(path);
return data;
}

View File

@@ -63,6 +63,50 @@ describe("tenantTree utility", () => {
}
});
it("uses backend total member counts without double-counting children", () => {
const tenantsWithTotals: TenantSummary[] = [
{
...mockTenants[0],
memberCount: 10,
totalMemberCount: 17,
},
{
...mockTenants[1],
memberCount: 5,
totalMemberCount: 7,
},
{
...mockTenants[2],
memberCount: 2,
totalMemberCount: 2,
},
];
const { currentBase } = buildTenantFullTree(tenantsWithTotals, "root-1");
expect(currentBase?.recursiveMemberCount).toBe(17);
expect(currentBase?.children[0]?.recursiveMemberCount).toBe(7);
expect(currentBase?.children[0]?.children[0]?.recursiveMemberCount).toBe(
2,
);
});
it("keeps total member counts when descendants are not loaded on the current page", () => {
const { currentBase } = buildTenantFullTree(
[
{
...mockTenants[0],
memberCount: 10,
totalMemberCount: 17,
},
],
"root-1",
);
expect(currentBase?.recursiveMemberCount).toBe(17);
expect(currentBase?.children).toHaveLength(0);
});
it("returns null currentBase if rootId is not found", () => {
const { currentBase } = buildTenantFullTree(mockTenants, "non-existent");
expect(currentBase).toBeNull();

View File

@@ -21,7 +21,7 @@ export function buildTenantFullTree(
tenantMap.set(t.id, {
...t,
children: [],
recursiveMemberCount: Number(t.memberCount) || 0,
recursiveMemberCount: Number(t.totalMemberCount ?? t.memberCount) || 0,
});
}
@@ -48,6 +48,11 @@ export function buildTenantFullTree(
}
visitedForCalc.add(node.id);
if (typeof node.totalMemberCount === "number") {
node.recursiveMemberCount = Number(node.totalMemberCount) || 0;
return node.recursiveMemberCount;
}
let total = Number(node.memberCount) || 0;
for (const child of node.children) {
total += calculateRecursive(child);

View File

@@ -178,15 +178,14 @@ description = "Checks whether user_login_ids.user_id points to a missing or soft
[msg.admin.integrity.check.orphan_user_tenant_memberships]
description = "Checks whether users.tenant_id points to a missing or soft-deleted tenant."
[msg.admin.user_projection]
action_error = "Projection operation failed."
action_success = "Refreshed the projection for {{count}} users."
forbidden_description = "This screen is only available to super_admin users."
load_error = "Failed to load projection status."
reset_confirm = "Rebuild user projection from the Kratos source of truth?"
subtitle = "Review and sync the Kratos user read model."
[msg.admin.ory_ssot]
flush_confirm = "Flush only Redis identity cache keys?"
flush_error = "Redis identity cache flush failed."
flush_success = "Flushed {{count}} Redis identity cache keys."
load_error = "Failed to load Ory SSOT system status."
subtitle = "Review Kratos source-of-truth and Redis identity cache status separately."
[msg.admin.user_projection.forbidden]
[msg.admin.ory_ssot.forbidden]
description = "This screen is only available to super_admin users."
[msg.admin.groups.prompt]
@@ -348,6 +347,10 @@ not_found = "Not Found"
update_error = "Failed to User Edit."
update_success = "Update Success"
[msg.admin.users.detail.custom_claims]
description = "Manage this user's values for globally defined custom claims. Add claim definitions and change types only from the global settings screen."
empty = "No global custom claims have been defined."
[msg.admin.users.detail.form]
field_required = "Required."
invalid_format = "Invalid format."
@@ -890,6 +893,7 @@ loading = "Loading data integrity report..."
title = "Data Integrity Check"
fetch_error = "Unable to load the final integrity check result."
subtitle = "Review integrity status and inspect checks across the admin data model."
tab_ory_ssot = "Ory SSOT System"
[ui.admin.integrity.forbidden]
title = "Access denied"
@@ -970,33 +974,38 @@ relying_parties = "Apps (RP)"
tenant_dashboard = "Tenant Dashboard"
user_groups = "User Groups"
tenants = "Tenants"
user_projection = "User Projection"
ory_ssot = "Ory SSOT System"
users = "Users"
[ui.admin.user_projection]
loading = "Loading user projection data..."
subtitle = "Review and sync the Kratos user read model."
title = "User Projection Management"
[ui.admin.ory_ssot]
loading = "Loading"
title = "Ory SSOT System"
[ui.admin.user_projection.actions]
reconcile = "Re-sync"
reset = "Reset and rebuild"
[ui.admin.ory_ssot.actions]
flush_identity_cache = "Redis cache flush"
[ui.admin.user_projection.card]
description = "Current user read model state referenced by backend DB statistics."
title = "Kratos users projection"
[ui.admin.ory_ssot.cache_card]
description = "Redis mirror/cache status for Kratos identity list and lookup operations."
title = "Redis identity cache"
[ui.admin.user_projection.forbidden]
[ui.admin.ory_ssot.forbidden]
title = "Access denied"
[ui.admin.user_projection.status]
[ui.admin.ory_ssot.projection_card]
description = "PostgreSQL read model status used by admin search and statistics."
title = "Backend user read model"
[ui.admin.ory_ssot.status]
failed = "failed"
not_ready = "not ready"
ready = "ready"
[ui.admin.user_projection.summary]
last_synced = "Last synced"
projected_users = "Projected users"
[ui.admin.ory_ssot.summary]
cache_keys = "Cache keys"
last_refreshed = "Last refreshed"
last_synced = "Last read-model refresh"
local_users = "Local users"
observed_identities = "Observed identities"
status = "Status"
updated_at = "Updated at"
@@ -1357,6 +1366,10 @@ section = "Users"
[ui.admin.users.detail.custom_fields]
multi_title = "Per-tenant Profile Management"
[ui.admin.users.detail.custom_claims]
save = "Save User Claim Values"
title = "User Custom Claim Values"
[ui.admin.users.detail.form]
department = "Department"
department_placeholder = "Department Placeholder"
@@ -1393,6 +1406,9 @@ additional = "Additional Affiliated/Manageable Tenants"
primary = "Representative Affiliated Tenant"
title = "Affiliation & Organization Info"
[ui.admin.users.global_custom_claims]
manage_definitions = "Manage Global Definitions"
[ui.admin.users.list]
add = "Add User"
add_to_tenant = "Add to Tenant"

View File

@@ -181,15 +181,14 @@ description = "users.tenant_id가 존재하지 않거나 soft-deleted tenant를
[msg.admin.integrity]
subtitle = "정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다."
[msg.admin.user_projection]
action_error = "사용자 동기화 작업에 실패했습니다."
action_success = "{{count}}명 기준으로 사용자 동기화를 갱신했습니다."
forbidden_description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다."
load_error = "사용자 동기화 상태를 불러오지 못했습니다."
reset_confirm = "사용자 동기화를 Kratos 기준으로 다시 구축하시겠습니까?"
subtitle = "Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다."
[msg.admin.ory_ssot]
flush_confirm = "Redis identity cache 키만 비우시겠습니까?"
flush_error = "Redis identity cache flush에 실패했습니다."
flush_success = "Redis identity cache key {{count}}개를 비웠습니다."
load_error = "Ory SSOT 시스템 상태를 불러오지 못했습니다."
subtitle = "Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다."
[msg.admin.user_projection.forbidden]
[msg.admin.ory_ssot.forbidden]
description = "이 화면은 super_admin 권한으로만 접근할 수 있습니다."
[msg.admin.groups.prompt]
@@ -353,6 +352,10 @@ update_error = "사용자 수정에 실패했습니다."
update_success = "사용자 정보가 수정되었습니다."
self_delete_blocked = "본인 계정은 삭제할 수 없습니다."
[msg.admin.users.detail.custom_claims]
description = "전역으로 정의된 custom claim의 이 사용자 값을 관리합니다. Claim 정의 추가와 타입 변경은 전역 설정 화면에서만 가능합니다."
empty = "전역으로 정의된 custom claim이 없습니다."
[msg.admin.users.detail.form]
field_required = "필수입니다."
invalid_format = "형식이 올바르지 않습니다."
@@ -894,6 +897,7 @@ kicker = "시스템"
loading = "불러오는 중"
title = "데이터 정합성 검증"
fetch_error = "정합성 최종 검증 결과를 불러오지 못했습니다."
tab_ory_ssot = "Ory SSOT 시스템"
[ui.admin.integrity.forbidden]
title = "접근 권한이 없습니다"
@@ -974,32 +978,38 @@ relying_parties = "애플리케이션(RP)"
tenant_dashboard = "테넌트 대시보드"
user_groups = "유저 그룹"
tenants = "테넌트"
user_projection = "사용자 동기화"
ory_ssot = "Ory SSOT 시스템"
users = "사용자"
[ui.admin.user_projection]
[ui.admin.ory_ssot]
loading = "불러오는 중"
title = "사용자 동기화 관리"
title = "Ory SSOT 시스템"
[ui.admin.user_projection.actions]
reconcile = "재동기화"
reset = "초기화 후 재구축"
[ui.admin.ory_ssot.actions]
flush_identity_cache = "Redis cache flush"
[ui.admin.user_projection.card]
description = "Backend DB 통계가 참조하는 사용자 read model 상태입니다."
title = "Kratos 사용자 동기화"
[ui.admin.ory_ssot.cache_card]
description = "Kratos identity 목록 및 조회 작업을 위한 Redis mirror/cache 상태입니다."
title = "Redis identity cache"
[ui.admin.user_projection.forbidden]
[ui.admin.ory_ssot.forbidden]
title = "접근 권한이 없습니다"
[ui.admin.user_projection.status]
[ui.admin.ory_ssot.projection_card]
description = "관리자 검색과 통계에서 사용하는 PostgreSQL read model 상태입니다."
title = "Backend 사용자 read model"
[ui.admin.ory_ssot.status]
failed = "실패"
not_ready = "준비되지 않음"
ready = "준비됨"
[ui.admin.user_projection.summary]
last_synced = "마지막 동기화"
projected_users = "동기화 사용자"
[ui.admin.ory_ssot.summary]
cache_keys = "Cache keys"
last_refreshed = "마지막 refresh"
last_synced = "마지막 read-model refresh"
local_users = "Local users"
observed_identities = "관측 identity"
status = "상태"
updated_at = "상태 갱신"
@@ -1360,6 +1370,10 @@ section = "Users"
[ui.admin.users.detail.custom_fields]
multi_title = "테넌트별 프로필 관리"
[ui.admin.users.detail.custom_claims]
save = "사용자 Claim 값 저장"
title = "사용자별 Custom Claim 값"
[ui.admin.users.detail.form]
department = "부서"
department_placeholder = "개발팀"
@@ -1396,6 +1410,9 @@ additional = "추가 소속/관리 테넌트"
primary = "대표 소속 테넌트"
title = "소속 및 조직 정보"
[ui.admin.users.global_custom_claims]
manage_definitions = "전역 정의 관리"
[ui.admin.users.list]
add = "사용자 추가"
add_to_tenant = "테넌트에 추가"

View File

@@ -184,7 +184,7 @@ description = ""
[ui.admin.integrity]
tab_checks = ""
tab_user_projection = ""
tab_ory_ssot = ""
subtitle = ""
[ui.admin.tenants.profile]
@@ -194,15 +194,14 @@ worksmobile_sync = ""
allowed_domains = ""
[msg.admin.user_projection]
action_error = ""
action_success = ""
forbidden_description = ""
[msg.admin.ory_ssot]
flush_confirm = ""
flush_error = ""
flush_success = ""
load_error = ""
reset_confirm = ""
subtitle = ""
[msg.admin.user_projection.forbidden]
[msg.admin.ory_ssot.forbidden]
description = ""
[msg.admin.groups.prompt]
@@ -988,32 +987,38 @@ relying_parties = ""
tenant_dashboard = ""
user_groups = ""
tenants = ""
user_projection = ""
ory_ssot = ""
users = ""
[ui.admin.user_projection]
[ui.admin.ory_ssot]
loading = ""
title = ""
[ui.admin.user_projection.actions]
reconcile = ""
reset = ""
[ui.admin.ory_ssot.actions]
flush_identity_cache = ""
[ui.admin.user_projection.card]
[ui.admin.ory_ssot.cache_card]
description = ""
title = ""
[ui.admin.user_projection.forbidden]
[ui.admin.ory_ssot.forbidden]
title = ""
[ui.admin.user_projection.status]
[ui.admin.ory_ssot.projection_card]
description = ""
title = ""
[ui.admin.ory_ssot.status]
failed = ""
not_ready = ""
ready = ""
[ui.admin.user_projection.summary]
[ui.admin.ory_ssot.summary]
cache_keys = ""
last_refreshed = ""
last_synced = ""
projected_users = ""
local_users = ""
observed_identities = ""
status = ""
updated_at = ""

View File

@@ -0,0 +1,40 @@
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
const formFieldTagPattern = /<(input|select|textarea)\b[\s\S]*?(?:>|\/>)/g;
function sourceFiles(dir: string): string[] {
if (!existsSync(dir)) return [];
return readdirSync(dir).flatMap((entry) => {
const path = join(dir, entry);
const stat = statSync(path);
if (stat.isDirectory()) return sourceFiles(path);
if (!/\.(tsx|jsx)$/.test(entry)) return [];
if (/\.(test|spec)\./.test(entry)) return [];
return [path];
});
}
function lineNumber(source: string, index: number) {
return source.slice(0, index).split("\n").length;
}
describe("adminfront form field diagnostics", () => {
it("keeps raw rendered form fields identifiable for browser autofill diagnostics", () => {
const offenders: string[] = [];
for (const file of sourceFiles("src")) {
const source = readFileSync(file, "utf8");
let match: RegExpExecArray | null;
while ((match = formFieldTagPattern.exec(source))) {
const tag = match[0];
if (/\b(id|name)\s*=/.test(tag)) continue;
if (/\{\.\.\s*[^}]+\}/.test(tag)) continue;
offenders.push(`${file}:${lineNumber(source, match.index)}`);
}
}
expect(offenders).toEqual([]);
});
});

View File

@@ -0,0 +1,26 @@
import { expect } from "vitest";
export function anonymousFormFields(container: ParentNode) {
return Array.from(container.querySelectorAll("input, select, textarea")).filter(
(field) =>
!field.getAttribute("id")?.trim() &&
!field.getAttribute("name")?.trim(),
);
}
export function expectNoAnonymousFormFields(container: ParentNode) {
const fields = anonymousFormFields(container);
const diagnostics = fields.map((field) => {
const tag = field.tagName.toLowerCase();
const type = field.getAttribute("type");
const label =
field.getAttribute("aria-label") ||
field.getAttribute("placeholder") ||
field.getAttribute("data-testid") ||
field.textContent ||
"";
return `${tag}${type ? `[type=${type}]` : ""}${label ? `: ${label}` : ""}`;
});
expect(fields, diagnostics.join("\n")).toHaveLength(0);
}

View File

@@ -44,23 +44,37 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
"ui.admin.integrity.orphan_login_ids.title": "유령 로그인 ID 정리",
"ui.admin.integrity.forbidden.title": "접근 권한이 없습니다",
"ui.admin.integrity.summary.title": "정합성 최종 검증",
"ui.admin.user_projection.actions.reconcile": "재동기화",
"ui.admin.user_projection.actions.reset": "초기화 후 재구축",
"ui.admin.user_projection.card.description":
"Backend DB 통계가 참조하는 사용자 read model 상태입니다.",
"ui.admin.user_projection.card.title": "Kratos 사용자 동기화",
"ui.admin.user_projection.forbidden.title": "접근 권한이 없습니다",
"ui.admin.user_projection.loading": "불러오는 중",
"ui.admin.user_projection.status.failed": "실패",
"ui.admin.user_projection.status.not_ready": "준비되지 않음",
"ui.admin.user_projection.status.ready": "준비됨",
"ui.admin.user_projection.summary.last_synced": "마지막 동기화",
"ui.admin.user_projection.summary.projected_users": "동기화 사용자",
"ui.admin.user_projection.summary.status": "상태",
"ui.admin.user_projection.summary.updated_at": "상태 갱신",
"ui.admin.user_projection.title": "사용자 동기화 관리",
"msg.admin.user_projection.subtitle":
"Kratos 사용자 read model을 확인하고 동기화 상태를 갱신합니다.",
"ui.admin.integrity.tab_ory_ssot": "Ory SSOT 시스템",
"ui.admin.ory_ssot.actions.flush_identity_cache": "Redis cache flush",
"ui.admin.ory_ssot.cache_card.description":
"Kratos identity 목록 및 조회 작업을 위한 Redis mirror/cache 상태입니다.",
"ui.admin.ory_ssot.cache_card.title": "Redis identity cache",
"ui.admin.ory_ssot.forbidden.title": "접근 권한이 없습니다",
"ui.admin.ory_ssot.loading": "불러오는 중",
"ui.admin.ory_ssot.projection_card.description":
"관리자 검색과 통계에서 사용하는 PostgreSQL read model 상태입니다.",
"ui.admin.ory_ssot.projection_card.title": "Backend 사용자 read model",
"ui.admin.ory_ssot.status.failed": "실패",
"ui.admin.ory_ssot.status.not_ready": "준비되지 않음",
"ui.admin.ory_ssot.status.ready": "준비됨",
"ui.admin.ory_ssot.summary.cache_keys": "Cache keys",
"ui.admin.ory_ssot.summary.last_refreshed": "마지막 refresh",
"ui.admin.ory_ssot.summary.last_synced": "마지막 read-model refresh",
"ui.admin.ory_ssot.summary.local_users": "Local users",
"ui.admin.ory_ssot.summary.observed_identities": "관측 identity",
"ui.admin.ory_ssot.summary.status": "상태",
"ui.admin.ory_ssot.summary.updated_at": "상태 갱신",
"ui.admin.ory_ssot.title": "Ory SSOT 시스템",
"msg.admin.ory_ssot.flush_confirm":
"Redis identity cache 키만 비우시겠습니까?",
"msg.admin.ory_ssot.flush_error": "Redis identity cache flush에 실패했습니다.",
"msg.admin.ory_ssot.flush_success":
"Redis identity cache key {{count}}개를 비웠습니다.",
"msg.admin.ory_ssot.forbidden.description":
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
"msg.admin.ory_ssot.load_error": "Ory SSOT 시스템 상태를 불러오지 못했습니다.",
"msg.admin.ory_ssot.subtitle":
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
"msg.admin.users.list.subtitle": "시스템 사용자를 조회하고 관리합니다.",
"msg.admin.users.list.registry.count":
"총 {{count}}명의 사용자가 등록되어 있습니다.",
@@ -76,8 +90,6 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
"users.tenant_id가 존재하지 않거나 soft-deleted tenant를 참조하는지 검사합니다.",
"msg.admin.integrity.recheck.running": "정합성 검사를 실행 중입니다.",
"msg.admin.integrity.recheck.success": "검사가 완료되었습니다.",
"msg.admin.user_projection.forbidden.description":
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
},
en: {
"ui.admin.auth_guard.title": "Auth Guard",
@@ -123,23 +135,37 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
"ui.admin.integrity.orphan_login_ids.title": "Orphan Login ID Cleanup",
"ui.admin.integrity.forbidden.title": "Access denied",
"ui.admin.integrity.summary.title": "Final integrity check",
"ui.admin.user_projection.actions.reconcile": "Re-sync",
"ui.admin.user_projection.actions.reset": "Reset and rebuild",
"ui.admin.user_projection.card.description":
"Current user read model state referenced by backend DB statistics.",
"ui.admin.user_projection.card.title": "Kratos users projection",
"ui.admin.user_projection.forbidden.title": "Access denied",
"ui.admin.user_projection.loading": "Loading",
"ui.admin.user_projection.status.failed": "failed",
"ui.admin.user_projection.status.not_ready": "not ready",
"ui.admin.user_projection.status.ready": "ready",
"ui.admin.user_projection.summary.last_synced": "Last synced",
"ui.admin.user_projection.summary.projected_users": "Projected users",
"ui.admin.user_projection.summary.status": "Status",
"ui.admin.user_projection.summary.updated_at": "Updated at",
"ui.admin.user_projection.title": "User Projection Management",
"msg.admin.user_projection.subtitle":
"Review and sync the Kratos user read model.",
"ui.admin.integrity.tab_ory_ssot": "Ory SSOT System",
"ui.admin.ory_ssot.actions.flush_identity_cache": "Redis cache flush",
"ui.admin.ory_ssot.cache_card.description":
"Redis mirror/cache status for Kratos identity list and lookup operations.",
"ui.admin.ory_ssot.cache_card.title": "Redis identity cache",
"ui.admin.ory_ssot.forbidden.title": "Access denied",
"ui.admin.ory_ssot.loading": "Loading",
"ui.admin.ory_ssot.projection_card.description":
"PostgreSQL read model status used by admin search and statistics.",
"ui.admin.ory_ssot.projection_card.title": "Backend user read model",
"ui.admin.ory_ssot.status.failed": "failed",
"ui.admin.ory_ssot.status.not_ready": "not ready",
"ui.admin.ory_ssot.status.ready": "ready",
"ui.admin.ory_ssot.summary.cache_keys": "Cache keys",
"ui.admin.ory_ssot.summary.last_refreshed": "Last refreshed",
"ui.admin.ory_ssot.summary.last_synced": "Last read-model refresh",
"ui.admin.ory_ssot.summary.local_users": "Local users",
"ui.admin.ory_ssot.summary.observed_identities": "Observed identities",
"ui.admin.ory_ssot.summary.status": "Status",
"ui.admin.ory_ssot.summary.updated_at": "Updated at",
"ui.admin.ory_ssot.title": "Ory SSOT System",
"msg.admin.ory_ssot.flush_confirm":
"Flush only Redis identity cache keys?",
"msg.admin.ory_ssot.flush_error": "Redis identity cache flush failed.",
"msg.admin.ory_ssot.flush_success":
"Flushed {{count}} Redis identity cache keys.",
"msg.admin.ory_ssot.forbidden.description":
"This screen is only available to super_admin users.",
"msg.admin.ory_ssot.load_error": "Failed to load Ory SSOT system status.",
"msg.admin.ory_ssot.subtitle":
"Review Kratos source-of-truth and Redis identity cache status separately.",
"msg.admin.users.list.subtitle":
"Search and manage users registered in the current tenant.",
"msg.admin.users.list.registry.count": "{{count}} users loaded.",
@@ -155,8 +181,6 @@ const translations: Record<"ko" | "en", Record<string, string>> = {
"Checks whether users.tenant_id points to a missing or soft-deleted tenant.",
"msg.admin.integrity.recheck.running": "Running integrity check.",
"msg.admin.integrity.recheck.success": "Check completed.",
"msg.admin.user_projection.forbidden.description":
"This screen is only available to super_admin users.",
},
};