forked from baron/baron-sso
chore: consolidate local integration changes
This commit is contained in:
@@ -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];
|
||||
|
||||
@@ -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 /> },
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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, {
|
||||
|
||||
19
adminfront/src/components/ui/checkbox.test.tsx
Normal file
19
adminfront/src/components/ui/checkbox.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
19
adminfront/src/components/ui/textarea.test.tsx
Normal file
19
adminfront/src/components/ui/textarea.test.tsx
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 선택" }));
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) ?? [];
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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"
|
||||
|
||||
317
adminfront/src/features/users/GlobalCustomClaimsPage.tsx
Normal file
317
adminfront/src/features/users/GlobalCustomClaimsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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 ??
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 = "테넌트에 추가"
|
||||
|
||||
@@ -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 = ""
|
||||
|
||||
|
||||
40
adminfront/src/test/formFieldDiagnostics.test.ts
Normal file
40
adminfront/src/test/formFieldDiagnostics.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
26
adminfront/src/test/formFieldDiagnostics.ts
Normal file
26
adminfront/src/test/formFieldDiagnostics.ts
Normal 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);
|
||||
}
|
||||
@@ -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.",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user