첫 커밋: 로컬 프로젝트 업로드

This commit is contained in:
2026-06-10 15:51:34 +09:00
commit 6a8dbeb2e9
1211 changed files with 312864 additions and 0 deletions

View File

@@ -0,0 +1,72 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createApiKey } from "../../lib/adminApi";
import ApiKeyCreatePage from "./ApiKeyCreatePage";
vi.mock("../../lib/adminApi", () => ({
createApiKey: vi.fn(async () => ({
apiKey: {
id: "api-key-id",
name: "org-context-client",
client_id: "client-id",
scopes: ["audit:read", "user:read", "org-context:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
},
clientSecret: "secret",
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ApiKeyCreatePage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("ApiKeyCreatePage", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("renders org-context:read as a selectable API key scope", () => {
renderPage();
expect(screen.getByText("조직 Context 조회")).toBeInTheDocument();
expect(screen.getByText("ID: org-context:read")).toBeInTheDocument();
});
it("includes org-context:read in the create request when selected", async () => {
const user = userEvent.setup();
renderPage();
await user.type(
screen.getByLabelText("서비스 또는 목적 식별 이름"),
"org-context-client",
);
await user.click(screen.getByRole("button", { name: /조직 Context 조회/ }));
await user.click(screen.getByRole("button", { name: /API 키 발급하기/ }));
await waitFor(() => {
expect(createApiKey).toHaveBeenCalledWith(
expect.objectContaining({
name: "org-context-client",
scopes: expect.arrayContaining(["org-context:read"]),
}),
);
});
});
});

View File

@@ -0,0 +1,350 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
AlertCircle,
Check,
ChevronLeft,
Copy,
Loader2,
Save,
ShieldCheck,
} from "lucide-react";
import * as React from "react";
import { useForm } from "react-hook-form";
import { Link, useNavigate } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import { Label } from "../../components/ui/label";
import {
type ApiKeyCreateRequest,
type ApiKeyCreateResponse,
createApiKey,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
function ApiKeyCreatePage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [error, setError] = React.useState<string | null>(null);
const [createdResult, setCreatedResult] =
React.useState<ApiKeyCreateResponse | null>(null);
const [selectedScopes, setSelectedScopes] = React.useState<string[]>([
"audit:read",
"user:read",
]);
const {
register,
handleSubmit,
formState: { errors },
} = useForm<{ name: string }>({
defaultValues: { name: "" },
});
const mutation = useMutation({
mutationFn: (payload: ApiKeyCreateRequest) => createApiKey(payload),
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ["api-keys"] });
setCreatedResult(data);
},
onError: (err: AxiosError<{ error?: string }>) => {
setError(
err.response?.data?.error ||
t("msg.admin.api_keys.create.error", "API 키 생성에 실패했습니다."),
);
},
});
const toggleScope = (scopeId: string) => {
setSelectedScopes((prev) =>
prev.includes(scopeId)
? prev.filter((s) => s !== scopeId)
: [...prev, scopeId],
);
};
const onSubmit = (data: { name: string }) => {
if (selectedScopes.length === 0) {
setError(
t(
"msg.admin.api_keys.create.scope_required",
"최소 하나 이상의 권한을 선택해야 합니다.",
),
);
return;
}
setError(null);
mutation.mutate({ name: data.name, scopes: selectedScopes });
};
const handleCopy = (text: string) => {
navigator.clipboard.writeText(text);
};
if (createdResult) {
return (
<div className="max-w-xl mx-auto py-12 space-y-8">
<div className="text-center space-y-3">
<div className="mx-auto w-16 h-16 bg-primary/10 text-primary rounded-full flex items-center justify-center">
<ShieldCheck size={32} />
</div>
<h2 className="text-3xl font-bold tracking-tight">
{t("ui.admin.api_keys.create.success.title", "API 키 생성 완료")}
</h2>
<p className="text-muted-foreground">
{t(
"msg.admin.api_keys.create.success.notice",
"아래의 비밀번호(Secret)는 보안을 위해 ",
)}
<span className="text-destructive font-bold">
{t(
"msg.admin.api_keys.create.success.notice_emphasis",
"지금 한 번만",
)}
</span>{" "}
{t(
"msg.admin.api_keys.create.success.notice_suffix",
"표시됩니다.",
)}
</p>
</div>
<Card className="border-2 border-primary/20 shadow-xl">
<CardHeader className="bg-primary/5 border-b">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<AlertCircle size={16} className="text-primary" />
{t(
"ui.admin.api_keys.create.success.copy_secret",
"보안 시크릿 복사",
)}
</CardTitle>
</CardHeader>
<CardContent className="pt-8 pb-8 space-y-6">
<div className="space-y-3">
<Label className="text-xs font-bold text-muted-foreground uppercase tracking-widest">
X-Baron-Key-Secret
</Label>
<div className="relative group">
<Input
readOnly
value={createdResult.clientSecret}
className="font-mono text-lg py-6 pr-12 border-primary/30 bg-muted/30 focus-visible:ring-0"
/>
<Button
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 -translate-y-1/2 hover:bg-primary/10"
onClick={() => handleCopy(createdResult.clientSecret)}
>
<Copy size={20} className="text-primary" />
</Button>
</div>
<p className="text-[11px] text-center text-muted-foreground italic">
{t(
"msg.admin.api_keys.create.success.copy_hint",
"복사 버튼을 눌러 안전한 곳(비밀번호 관리자 등)에 저장하세요.",
)}
</p>
</div>
<div className="pt-4 flex flex-col gap-2">
<Button size="lg" className="w-full font-bold" asChild>
<Link to="/api-keys">
{t(
"ui.admin.api_keys.create.success.go_list",
"저장했습니다. 목록으로 이동",
)}
</Link>
</Button>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="max-w-3xl mx-auto space-y-10">
<header className="flex items-center justify-between">
<div className="space-y-1">
<Button
variant="ghost"
size="sm"
className="-ml-3 text-muted-foreground"
onClick={() => navigate("/api-keys")}
>
<ChevronLeft size={16} className="mr-1" />
{t("ui.common.back", "돌아가기")}
</Button>
<h2 className="text-3xl font-bold tracking-tight">
{t("ui.admin.api_keys.create.title", "새 API 키 생성")}
</h2>
<p className="text-muted-foreground">
{t(
"msg.admin.api_keys.create.subtitle",
"내부 시스템 연동을 위한 보안 인증 키를 구성합니다.",
)}
</p>
</div>
</header>
<div className="space-y-8">
{/* 섹션 1: 이름 설정 */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">
1
</span>
<h3 className="font-semibold text-lg">
{t("ui.admin.api_keys.create.section_name", "키 이름 지정")}
</h3>
</div>
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<Label htmlFor="name" className="text-sm font-medium">
{t(
"ui.admin.api_keys.create.name_label",
"서비스 또는 목적 식별 이름",
)}
</Label>
<Input
id="name"
placeholder={t(
"ui.admin.api_keys.create.name_placeholder",
"예: Jenkins-CI, Grafana-Dashboard",
)}
className="text-base py-5"
{...register("name", {
required: t(
"msg.admin.api_keys.create.name_required",
"이름은 필수입니다.",
),
})}
/>
{errors.name && (
<p className="text-sm text-destructive mt-1">
{errors.name.message}
</p>
)}
</div>
</CardContent>
</Card>
</section>
{/* 섹션 2: 권한 선택 */}
<section className="space-y-4">
<div className="flex items-center gap-2 pb-2 border-b">
<span className="flex items-center justify-center w-6 h-6 rounded-full bg-primary text-primary-foreground text-xs font-bold">
2
</span>
<h3 className="font-semibold text-lg">
{t(
"ui.admin.api_keys.create.section_scopes",
"권한 범위(Scopes) 선택",
)}
</h3>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
const isSelected = selectedScopes.includes(scope.id);
return (
<button
key={scope.id}
type="button"
onClick={() => toggleScope(scope.id)}
className={cn(
"flex flex-col items-start gap-2 p-4 rounded-xl border-2 text-left transition-all",
isSelected
? "border-primary bg-primary/5 shadow-md"
: "border-border bg-card hover:border-muted-foreground/30",
)}
>
<div className="flex items-center justify-between w-full">
<span
className={cn(
"font-bold text-sm",
isSelected ? "text-primary" : "",
)}
>
{t(scope.labelKey, scope.labelFallback)}
</span>
<div
className={cn(
"h-5 w-5 rounded-md flex items-center justify-center border",
isSelected
? "bg-primary border-primary"
: "border-muted-foreground/30",
)}
>
{isSelected && (
<Check size={12} className="text-primary-foreground" />
)}
</div>
</div>
<p className="text-[11px] text-muted-foreground leading-snug">
{t(scope.descKey, scope.descFallback)}
</p>
<code className="text-[9px] font-mono opacity-60 mt-1 uppercase tracking-tighter">
ID: {scope.id}
</code>
</button>
);
})}
</div>
</section>
{/* 하단 실행 버튼 */}
<div className="pt-6 flex flex-col gap-4">
{error && (
<div className="bg-destructive/10 border border-destructive/20 text-destructive p-4 rounded-lg flex items-center gap-3">
<AlertCircle size={20} />
<p className="text-sm font-medium">{error}</p>
</div>
)}
<div className="flex items-center justify-between p-6 bg-muted/30 rounded-2xl border">
<div>
<p className="text-sm font-bold">
{t(
"msg.admin.api_keys.create.scopes_count",
"총 {{count}}개의 권한이 할당됩니다.",
{ count: selectedScopes.length },
)}
</p>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.api_keys.create.scopes_hint",
"생성 즉시 활성화되어 사용 가능합니다.",
)}
</p>
</div>
<Button
onClick={handleSubmit(onSubmit)}
size="lg"
className="px-8 font-bold shadow-lg shadow-primary/20"
disabled={mutation.isPending}
>
{mutation.isPending ? (
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
) : (
<Save className="mr-2 h-5 w-5" />
)}
{t("ui.admin.api_keys.create.submit", "API 키 발급하기")}
</Button>
</div>
</div>
</div>
</div>
);
}
export default ApiKeyCreatePage;

View File

@@ -0,0 +1,125 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetchApiKeys,
rotateApiKeySecret,
updateApiKeyScopes,
} from "../../lib/adminApi";
import ApiKeyListPage from "./ApiKeyListPage";
vi.mock("../../lib/i18n", () => ({
t: (_key: string, fallback?: string) => fallback ?? "",
}));
vi.mock("../../lib/adminApi", () => ({
fetchApiKeys: vi.fn(async () => ({
items: [
{
id: "api-key-id",
name: "org-context-client",
client_id: "client-id-stable",
scopes: ["audit:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
},
],
total: 1,
})),
deleteApiKey: vi.fn(async () => undefined),
updateApiKeyScopes: vi.fn(async () => ({
id: "api-key-id",
name: "org-context-client",
client_id: "client-id-stable",
scopes: ["audit:read", "org-context:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
})),
rotateApiKeySecret: vi.fn(async () => ({
apiKey: {
id: "api-key-id",
name: "org-context-client",
client_id: "client-id-stable",
scopes: ["audit:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
},
clientSecret: "rotated-secret",
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ApiKeyListPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("ApiKeyListPage", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("updates scopes without changing client_id", async () => {
const user = userEvent.setup();
renderPage();
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /권한 수정/ }));
await user.click(screen.getByRole("button", { name: /조직 Context 조회/ }));
await user.click(screen.getByRole("button", { name: /권한 저장/ }));
await waitFor(() => {
expect(updateApiKeyScopes).toHaveBeenCalledWith("api-key-id", {
scopes: expect.arrayContaining(["audit:read", "org-context:read"]),
});
});
});
it("rotates only the secret and shows the one-time secret", async () => {
const user = userEvent.setup();
renderPage();
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Secret 재발급/ }));
await waitFor(() => {
expect(rotateApiKeySecret).toHaveBeenCalledWith("api-key-id");
});
expect(
await screen.findByDisplayValue("rotated-secret"),
).toBeInTheDocument();
expect(fetchApiKeys).toHaveBeenCalled();
});
it("refresh button refetches the list without navigation", async () => {
const user = userEvent.setup();
renderPage();
await screen.findByText("client-id-stable");
const refreshButton = screen.getByRole("button", { name: /새로고침/ });
expect(refreshButton).toHaveAttribute("type", "button");
await user.click(refreshButton);
await waitFor(() => {
expect(fetchApiKeys).toHaveBeenCalledTimes(2);
});
});
});

View File

@@ -0,0 +1,467 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Copy,
Edit3,
Key,
Plus,
RefreshCw,
RotateCcw,
Save,
Trash2,
} from "lucide-react";
import * as React from "react";
import { Link } from "react-router-dom";
import { PageHeader } from "../../../../common/core/components/page";
import { commonStickyTableHeaderClass } from "../../../../common/ui/table";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
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,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../components/ui/table";
import {
type ApiKeySummary,
deleteApiKey,
fetchApiKeys,
rotateApiKeySecret,
updateApiKeyScopes,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
function ApiKeyListPage() {
const [editingKey, setEditingKey] = React.useState<ApiKeySummary | null>(
null,
);
const [draftScopes, setDraftScopes] = React.useState<string[]>([]);
const [rotatedSecret, setRotatedSecret] = React.useState<{
key: ApiKeySummary;
clientSecret: string;
} | null>(null);
const query = useQuery({
queryKey: ["api-keys", { limit: 50, offset: 0 }],
queryFn: () => fetchApiKeys(50, 0),
});
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteApiKey(id),
onSuccess: () => {
query.refetch();
},
});
const updateScopesMutation = useMutation({
mutationFn: ({ id, scopes }: { id: string; scopes: string[] }) =>
updateApiKeyScopes(id, { scopes }),
onSuccess: () => {
setEditingKey(null);
setDraftScopes([]);
query.refetch();
},
});
const rotateSecretMutation = useMutation({
mutationFn: (id: string) => rotateApiKeySecret(id),
onSuccess: (data) => {
setRotatedSecret({
key: data.apiKey,
clientSecret: data.clientSecret,
});
query.refetch();
},
});
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
!errorMsg && query.isError
? t(
"msg.admin.api_keys.list.fetch_error",
"API 키 목록 조회에 실패했습니다.",
)
: null;
const items = query.data?.items ?? [];
const handleDelete = (id: string, name: string) => {
if (
!window.confirm(
t(
"msg.admin.api_keys.list.delete_confirm",
'API 키 "{{name}}"를 삭제할까요?',
{ name },
),
)
) {
return;
}
deleteMutation.mutate(id);
};
const openScopeEditor = (key: ApiKeySummary) => {
setEditingKey(key);
setDraftScopes(key.scopes);
};
const toggleDraftScope = (scopeId: string) => {
setDraftScopes((current) =>
current.includes(scopeId)
? current.filter((scope) => scope !== scopeId)
: [...current, scopeId],
);
};
const saveScopes = () => {
if (!editingKey || draftScopes.length === 0) return;
updateScopesMutation.mutate({ id: editingKey.id, scopes: draftScopes });
};
const handleRotateSecret = (key: ApiKeySummary) => {
if (
!window.confirm(
t(
"msg.admin.api_keys.list.rotate_confirm",
'API 키 "{{name}}"의 Secret을 재발급할까요? 기존 Secret은 더 이상 사용할 수 없습니다.',
{ name: key.name },
),
)
) {
return;
}
rotateSecretMutation.mutate(key.id);
};
const copyRotatedSecret = () => {
if (!rotatedSecret) return;
navigator.clipboard.writeText(rotatedSecret.clientSecret);
};
return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<PageHeader
sticky
titleAs="h2"
icon={<Key size={20} />}
title={t("ui.admin.api_keys.list.title", "API 키 관리 (M2M)")}
description={t(
"msg.admin.api_keys.list.subtitle",
"서버 간 통신(Machine-to-Machine)을 위한 API 키를 발급하고 관리합니다.",
)}
actions={
<>
<Button
type="button"
variant="outline"
onClick={() => query.refetch()}
disabled={query.isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button asChild>
<Link to="/api-keys/new">
<Plus size={16} />
{t("ui.admin.api_keys.list.add", "API 키 생성")}
</Link>
</Button>
</>
}
/>
<Card className="bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle className="text-lg font-bold flex items-center gap-2">
{t("ui.admin.apikeys.registry.title", "API Key Registry")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.apikeys.registry.count",
"총 {{count}}개의 활성 키가 등록되어 있습니다.",
{ count: query.data?.items?.length ?? 0 },
)}
</CardDescription>
</div>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
{(errorMsg || fallbackError) && (
<div className="mb-4 rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive flex-shrink-0">
{errorMsg ?? fallbackError}
</div>
)}
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead>
{t("ui.admin.api_keys.list.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.client_id", "CLIENT ID")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.scopes", "SCOPES")}
</TableHead>
<TableHead>
{t("ui.admin.api_keys.list.table.last_used", "LAST USED")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.api_keys.list.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{query.isLoading && (
<TableRow>
<TableCell colSpan={5}>
{t("msg.common.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!query.isLoading && items.length === 0 && (
<TableRow>
<TableCell colSpan={5}>
{t(
"msg.admin.api_keys.list.empty",
"등록된 API 키가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{items.map((key) => (
<TableRow key={key.id}>
<TableCell className="font-semibold">
<div className="flex items-center gap-2">
<Key
size={14}
className="text-[var(--color-muted)]"
/>
{key.name}
</div>
</TableCell>
<TableCell>
<code>{key.client_id}</code>
</TableCell>
<TableCell>
<div className="flex flex-wrap gap-1">
{key.scopes.map((scope) => (
<Badge
key={scope}
variant="muted"
className="text-[10px]"
>
{scope}
</Badge>
))}
</div>
</TableCell>
<TableCell>
{key.lastUsedAt
? new Date(key.lastUsedAt).toLocaleString("ko-KR")
: t("ui.common.never", "Never")}
</TableCell>
<TableCell className="text-right">
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openScopeEditor(key)}
>
<Edit3 size={14} />
{t(
"ui.admin.api_keys.list.edit_scopes",
"권한 수정",
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleRotateSecret(key)}
disabled={rotateSecretMutation.isPending}
>
<RotateCcw size={14} />
{t(
"ui.admin.api_keys.list.rotate_secret",
"Secret 재발급",
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
<Dialog
open={editingKey !== null}
onOpenChange={() => setEditingKey(null)}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.api_keys.list.edit_scopes", "권한 수정")}
</DialogTitle>
<DialogDescription>
{editingKey
? t(
"msg.admin.api_keys.list.edit_scopes_desc",
"{{clientId}}의 CLIENT_ID는 유지하고 권한만 변경합니다.",
{ clientId: editingKey.client_id },
)
: null}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
const isSelected = draftScopes.includes(scope.id);
return (
<button
key={scope.id}
type="button"
onClick={() => toggleDraftScope(scope.id)}
className={cn(
"flex flex-col items-start gap-2 rounded-lg border-2 p-4 text-left transition-all",
isSelected
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/30",
)}
>
<span className="font-bold text-sm">
{t(scope.labelKey, scope.labelFallback)}
</span>
<span className="text-[11px] text-muted-foreground leading-snug">
{t(scope.descKey, scope.descFallback)}
</span>
<code className="text-[9px] font-mono opacity-60 uppercase tracking-tighter">
ID: {scope.id}
</code>
</button>
);
})}
</div>
{draftScopes.length === 0 && (
<p className="text-sm text-destructive">
{t(
"msg.admin.api_keys.create.scope_required",
"최소 하나 이상의 권한을 선택해야 합니다.",
)}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditingKey(null)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={saveScopes}
disabled={
updateScopesMutation.isPending || draftScopes.length === 0
}
>
<Save size={16} />
{t("ui.admin.api_keys.list.save_scopes", "권한 저장")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={rotatedSecret !== null}
onOpenChange={() => setRotatedSecret(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t(
"ui.admin.api_keys.list.rotate_secret_done",
"Secret 재발급 완료",
)}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.api_keys.list.rotate_secret_notice",
"새 Secret은 지금 한 번만 표시됩니다. CLIENT_ID는 변경되지 않았습니다.",
)}
</DialogDescription>
</DialogHeader>
{rotatedSecret && (
<div className="space-y-4">
<div className="space-y-2">
<p className="text-xs font-bold text-muted-foreground">
CLIENT ID
</p>
<code className="block rounded-md bg-muted px-3 py-2 text-sm">
{rotatedSecret.key.client_id}
</code>
</div>
<div className="space-y-2">
<p className="text-xs font-bold text-muted-foreground">
X-Baron-Key-Secret
</p>
<div className="relative">
<Input
readOnly
value={rotatedSecret.clientSecret}
className="font-mono pr-12"
/>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2"
onClick={copyRotatedSecret}
>
<Copy size={16} />
</Button>
</div>
</div>
</div>
)}
<DialogFooter>
<Button onClick={() => setRotatedSecret(null)}>
{t("ui.common.confirm", "확인")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export default ApiKeyListPage;

View File

@@ -0,0 +1,59 @@
export type ApiKeyScopeOption = {
id: string;
labelKey: string;
labelFallback: string;
descKey: string;
descFallback: string;
};
export const AVAILABLE_API_KEY_SCOPES: ApiKeyScopeOption[] = [
{
id: "audit:read",
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
labelFallback: "감사 로그 조회",
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.",
},
{
id: "audit:write",
labelKey: "ui.admin.api_keys.scopes.audit_write.title",
labelFallback: "감사 로그 생성",
descKey: "msg.admin.api_keys.scopes.audit_write.desc",
descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.",
},
{
id: "user:read",
labelKey: "ui.admin.api_keys.scopes.user_read.title",
labelFallback: "사용자 조회",
descKey: "msg.admin.api_keys.scopes.user_read.desc",
descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.",
},
{
id: "user:write",
labelKey: "ui.admin.api_keys.scopes.user_write.title",
labelFallback: "사용자 관리",
descKey: "msg.admin.api_keys.scopes.user_write.desc",
descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.",
},
{
id: "tenant:read",
labelKey: "ui.admin.api_keys.scopes.tenant_read.title",
labelFallback: "테넌트 조회",
descKey: "msg.admin.api_keys.scopes.tenant_read.desc",
descFallback: "등록된 모든 조직 정보를 조회합니다.",
},
{
id: "tenant:write",
labelKey: "ui.admin.api_keys.scopes.tenant_write.title",
labelFallback: "테넌트 관리",
descKey: "msg.admin.api_keys.scopes.tenant_write.desc",
descFallback: "테넌트 정보를 직접 제어합니다.",
},
{
id: "org-context:read",
labelKey: "ui.admin.api_keys.scopes.org_context_read.title",
labelFallback: "조직 Context 조회",
descKey: "msg.admin.api_keys.scopes.org_context_read.desc",
descFallback: "외부 연동앱이 OrgFront SSOT 조직 JSON을 조회합니다.",
},
];

View File

@@ -0,0 +1,197 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Download, NotebookTabs, RefreshCw, Search } from "lucide-react";
import * as React from "react";
import { PageHeader } from "../../../../common/core/components/page";
import { SearchFilterBar } from "../../../../common/ui/search-filter-bar";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { Input } from "../../components/ui/input";
import type { AuditLog } from "../../lib/adminApi";
import { fetchAuditLogs } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { VirtualizedAuditLogTable } from "./VirtualizedAuditLogTable";
function AuditLogsPage() {
const [searchActorId, setSearchActorId] = React.useState("");
const [searchAction, setSearchAction] = React.useState("");
const [statusFilter, setStatusFilter] = React.useState("all");
const deferredSearchActorId = React.useDeferredValue(searchActorId.trim());
const deferredSearchAction = React.useDeferredValue(searchAction.trim());
const {
data,
isLoading,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isFetching,
refetch,
} = useInfiniteQuery({
queryKey: [
"audit-logs",
deferredSearchActorId,
deferredSearchAction,
statusFilter,
],
queryFn: ({ pageParam }) => {
const search = [deferredSearchActorId, deferredSearchAction]
.filter(Boolean)
.join(" ");
return fetchAuditLogs(
50,
pageParam,
search || undefined,
statusFilter === "all" ? undefined : statusFilter,
);
},
initialPageParam: undefined as string | undefined,
getNextPageParam: (lastPage) => lastPage.next_cursor || undefined,
});
const logs =
data?.pages?.flatMap(
(page) =>
page?.items?.filter((item): item is AuditLog => Boolean(item)) ?? [],
) ?? [];
return (
<div className="space-y-6">
<PageHeader
title={t("ui.common.audit.title", "감사 로그")}
description={t(
"msg.admin.audit.subtitle",
"관리자 작업 이력을 조회합니다.",
)}
icon={<NotebookTabs size={20} />}
actions={
<>
<Badge variant="muted">
{t("msg.common.audit.registry.count", "총 {{count}}개 로그", {
count: logs.length,
})}
</Badge>
<Button
variant="outline"
onClick={() => refetch()}
disabled={isFetching}
>
<RefreshCw size={16} />
{t("ui.common.refresh", "새로고침")}
</Button>
<Button>
<Download size={16} />
{t("ui.common.export_csv", "CSV 내보내기")}
</Button>
</>
}
/>
<Card className="glass-panel">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg font-bold flex items-center gap-2">
{t("ui.common.audit.registry.title", "Audit registry")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.audit.registry.description",
"최근 감사 로그를 검색 조건에 맞춰 필터링하고, 작업 이력을 빠르게 확인합니다.",
)}
</CardDescription>
</div>
</CardHeader>
{isLoading ? (
<div className="p-8 text-center" data-testid="audit-loading">
{t("msg.common.audit.loading", "Loading audit logs...")}
</div>
) : error ? (
<div
className="p-8 text-center text-red-500"
data-testid="audit-error"
>
{t("msg.common.audit.load_error", "Error loading logs: {{error}}", {
error:
(error as AxiosError<{ error?: string }>).response?.data
?.error ?? (error as Error).message,
})}
</div>
) : (
<CardContent className="space-y-4 pt-0">
<SearchFilterBar
primary={
<form
onSubmit={(e) => {
e.preventDefault();
refetch();
}}
className="grid flex-1 gap-2 md:grid-cols-[1fr,1fr,180px]"
>
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
className="pl-10"
data-testid="audit-search-user-id"
value={searchActorId}
onChange={(event) => setSearchActorId(event.target.value)}
placeholder={t(
"ui.common.audit.filters.user_id",
"Filter by User ID",
)}
/>
</div>
<Input
data-testid="audit-search-action"
value={searchAction}
onChange={(event) =>
setSearchAction(event.target.value.toUpperCase())
}
placeholder={t(
"ui.common.audit.filters.action",
"Filter by Action (e.g. ROTATE_SECRET)",
)}
/>
<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}
onChange={(event) => setStatusFilter(event.target.value)}
>
<option value="all">
{t("ui.common.audit.filters.status_all", "All Status")}
</option>
<option value="success">
{t("ui.common.status.success", "Success")}
</option>
<option value="failure">
{t("ui.common.status.failure", "Failure")}
</option>
</select>
</form>
}
/>
<VirtualizedAuditLogTable
logs={logs}
t={t}
loading={isLoading}
hasNextPage={Boolean(hasNextPage)}
isFetchingNextPage={isFetchingNextPage}
onLoadMore={() => fetchNextPage()}
/>
</CardContent>
)}
</Card>
</div>
);
}
export default AuditLogsPage;

View File

@@ -0,0 +1,475 @@
import { useVirtualizer } from "@tanstack/react-virtual";
import { ChevronDown, ChevronUp, Copy } from "lucide-react";
import * as React from "react";
import {
formatAuditDateParts,
formatAuditValue,
parseAuditDetails,
resolveAuditAction,
resolveAuditActor,
resolveAuditTarget,
} from "../../../../common/core/audit";
import {
type CommonBadgeVariant,
getCommonBadgeClasses,
} from "../../../../common/ui/badge";
import { getCommonButtonClasses } from "../../../../common/ui/button";
import {
commonStickyTableHeaderClass,
commonTableBodyClass,
commonTableCellClass,
commonTableClass,
commonTableHeadClass,
commonTableHeaderClass,
commonTableRowClass,
commonTableShellClass,
commonTableViewportClass,
commonTableWrapperClass,
} from "../../../../common/ui/table";
import { Button } from "../../components/ui/button";
import type { AuditLog } from "../../lib/adminApi";
type AuditTranslate = (
key: string,
fallback: string,
vars?: Record<string, string | number>,
) => string;
type VirtualizedAuditLogTableProps = {
logs: AuditLog[];
t: AuditTranslate;
loading: boolean;
hasNextPage: boolean;
isFetchingNextPage: boolean;
onLoadMore: () => void;
className?: string;
};
function cx(...classNames: Array<string | false | null | undefined>) {
return classNames.filter(Boolean).join(" ");
}
function statusVariant(status: string): CommonBadgeVariant {
return status === "success" || status === "ok" ? "success" : "warning";
}
export function VirtualizedAuditLogTable({
logs,
t,
loading,
hasNextPage,
isFetchingNextPage,
onLoadMore,
className,
}: VirtualizedAuditLogTableProps) {
const [expandedRows, setExpandedRows] = React.useState<
Record<string, boolean>
>({});
const viewportRef = React.useRef<HTMLDivElement>(null);
const isTest =
(typeof process !== "undefined" && process.env.NODE_ENV === "test") ||
(typeof window !== "undefined" &&
(window as Window & { _IS_TEST_MODE?: boolean })._IS_TEST_MODE);
const handleCopy = (value: string) => {
if (!value) {
return;
}
navigator.clipboard.writeText(value);
};
const rowVirtualizer = useVirtualizer({
count: logs.length,
getScrollElement: () => viewportRef.current,
estimateSize: () => 80,
measureElement: (el) => el.getBoundingClientRect().height,
overscan: isTest ? logs.length : 10,
initialRect: isTest ? { width: 1010, height: 1000 } : undefined,
});
const virtualRows = rowVirtualizer.getVirtualItems();
React.useEffect(() => {
if (isTest) {
return;
}
const lastItem = virtualRows[virtualRows.length - 1];
if (!lastItem) return;
if (
lastItem.index >= logs.length - 1 &&
hasNextPage &&
!isFetchingNextPage
) {
onLoadMore();
}
}, [
virtualRows,
logs.length,
hasNextPage,
isFetchingNextPage,
onLoadMore,
isTest,
]);
const tableMinWidth = 1010;
const renderRow = (
row: AuditLog,
index: number,
virtualRow?: { start: number; end: number },
) => {
if (!row) return null;
const details = parseAuditDetails(row.details);
const actorLabel = resolveAuditActor(row, details);
const actionLabel = resolveAuditAction(row, details);
const targetLabel = resolveAuditTarget(details);
const rowKey = `${row.event_id}-${row.timestamp}-${index}`;
const expanded = Boolean(expandedRows[rowKey]);
const { date, time } = formatAuditDateParts(row.timestamp);
return (
<tr
key={rowKey}
data-index={index}
ref={virtualRow ? rowVirtualizer.measureElement : undefined}
className={cx(
commonTableRowClass,
"bg-card/40",
virtualRow ? "absolute left-0 w-full" : "",
)}
style={
virtualRow
? {
transform: `translateY(${virtualRow.start}px)`,
}
: undefined
}
>
<td colSpan={6} className="p-0">
<div className={cx("flex items-center", expanded && "border-b")}>
<div
className={cx(
commonTableCellClass,
"w-[190px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="space-y-1">
<div>{date}</div>
<div>{time}</div>
</div>
</div>
<div className={cx(commonTableCellClass, "w-[180px] shrink-0")}>
<div className="flex items-center gap-2">
<code className="rounded-md bg-secondary/60 px-2 py-1 text-xs text-muted-foreground">
{actorLabel}
</code>
{actorLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.actor_id",
"Copy User ID",
)}
onClick={() => handleCopy(actorLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</div>
<div
className={cx(
commonTableCellClass,
"w-[180px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="font-semibold text-foreground">{actionLabel}</div>
</div>
<div
className={cx(
commonTableCellClass,
"w-[260px] shrink-0 text-xs text-muted-foreground",
)}
>
<div className="flex items-center gap-2">
<span className="break-all">{targetLabel}</span>
{targetLabel !== "-" ? (
<button
type="button"
className={cx(
getCommonButtonClasses({
variant: "ghost",
size: "icon",
}),
"h-7 w-7 text-muted-foreground hover:text-primary",
)}
aria-label={t(
"ui.common.audit.copy.target",
"Copy Client ID",
)}
onClick={() => handleCopy(targetLabel)}
>
<Copy className="h-3 w-3" />
</button>
) : null}
</div>
</div>
<div className={cx(commonTableCellClass, "w-[120px] shrink-0")}>
<span
className={getCommonBadgeClasses({
variant: statusVariant(row.status),
})}
>
{row.status}
</span>
</div>
<div
className={cx(
commonTableCellClass,
"w-[80px] shrink-0 text-right",
)}
>
<button
type="button"
className={getCommonButtonClasses({
variant: "ghost",
size: "sm",
})}
onClick={() => {
setExpandedRows((prev) => ({
...prev,
[rowKey]: !expanded,
}));
// Re-measure after state change
setTimeout(() => rowVirtualizer.measure(), 0);
}}
>
{expanded ? (
<ChevronUp className="h-4 w-4" />
) : (
<ChevronDown className="h-4 w-4" />
)}
</button>
</div>
</div>
{expanded && (
<div className={cx(commonTableCellClass, "bg-card/20 text-xs")}>
<div className="grid gap-4 text-muted-foreground md:grid-cols-3">
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.request", "Request")}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.request_id",
"Request ID · {{value}}",
{ value: formatAuditValue(details.request_id) },
)}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.event_id",
"Event ID · {{value}}",
{ value: formatAuditValue(row.event_id) },
)}
</div>
<div>
{t("ui.common.audit.details.ip", "IP · {{value}}", {
value: formatAuditValue(row.ip_address),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.method", "Method · {{value}}", {
value: formatAuditValue(details.method),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.path", "Path · {{value}}", {
value: formatAuditValue(details.path),
})}
</div>
<div>
{t(
"ui.common.audit.details.latency",
"Latency · {{value}}",
{
value:
details.latency_ms !== undefined
? `${details.latency_ms}ms`
: "-",
},
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.actor", "Actor")}
</div>
<div>
{t(
"ui.common.audit.details.actor_id",
"User ID · {{value}}",
{ value: actorLabel },
)}
</div>
<div>
{t("ui.common.audit.details.tenant", "Tenant · {{value}}", {
value: formatAuditValue(details.tenant_id),
})}
</div>
<div>
{t("ui.common.audit.details.device", "Device · {{value}}", {
value: formatAuditValue(row.device_id),
})}
</div>
<div className="break-all">
{t(
"ui.common.audit.details.target",
"Client ID · {{value}}",
{ value: targetLabel },
)}
</div>
</div>
<div className="space-y-1">
<div className="uppercase tracking-[0.16em]">
{t("ui.common.audit.details.result", "Result")}
</div>
<div className="break-all">
{t("ui.common.audit.details.error", "Error · {{value}}", {
value: formatAuditValue(details.error),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.before", "Before · {{value}}", {
value: formatAuditValue(details.before),
})}
</div>
<div className="break-all">
{t("ui.common.audit.details.after", "After · {{value}}", {
value: formatAuditValue(details.after),
})}
</div>
</div>
</div>
</div>
)}
</td>
</tr>
);
};
return (
<div className={cx(commonTableShellClass, className)}>
<div
ref={viewportRef}
className={cx(commonTableViewportClass, "flex-1")}
data-testid="audit-table-viewport"
>
<div
className={commonTableWrapperClass}
style={{ minWidth: tableMinWidth }}
>
<table
className={cx(commonTableClass, "table-fixed w-full")}
style={{ borderCollapse: "separate", borderSpacing: 0 }}
>
<thead
className={cx(
commonTableHeaderClass,
commonStickyTableHeaderClass,
)}
>
<tr className={commonTableRowClass}>
<th className={cx(commonTableHeadClass, "w-[190px]")}>
{t("ui.common.audit.table.time", "Time")}
</th>
<th className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.user_id", "User ID")}
</th>
<th className={cx(commonTableHeadClass, "w-[180px]")}>
{t("ui.common.audit.table.action", "Action")}
</th>
<th className={cx(commonTableHeadClass, "w-[260px]")}>
{t("ui.common.audit.table.client_id", "Client ID")}
</th>
<th className={cx(commonTableHeadClass, "w-[120px]")}>
{t("ui.common.audit.table.status", "Status")}
</th>
<th className={cx(commonTableHeadClass, "w-[80px]")} />
</tr>
</thead>
<tbody
className={commonTableBodyClass}
style={
!isTest
? {
height: `${rowVirtualizer.getTotalSize()}px`,
position: "relative",
}
: undefined
}
>
{isTest
? logs.map((row, index) => renderRow(row, index))
: virtualRows.map((virtualRow) =>
renderRow(
logs[virtualRow.index],
virtualRow.index,
virtualRow,
),
)}
{logs.length === 0 && !loading && (
<tr>
<td
colSpan={6}
className={cx(
commonTableCellClass,
"text-center py-8 text-muted-foreground",
)}
>
{t("ui.common.audit.table.no_logs", "No audit logs found")}
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
<div className="flex-shrink-0 border-t bg-background/50 p-4 text-center backdrop-blur-sm">
{hasNextPage ? (
<div className="flex flex-col items-center gap-2">
{isFetchingNextPage && (
<span className="animate-pulse text-xs text-muted-foreground">
{t("msg.common.loading", "Loading more...")}
</span>
)}
<Button
variant="outline"
size="sm"
onClick={onLoadMore}
disabled={isFetchingNextPage}
>
{isFetchingNextPage
? t("msg.common.loading", "Loading...")
: t("ui.common.audit.load_more", "더 보기")}
</Button>
</div>
) : logs.length > 0 ? (
<span className="text-xs text-muted-foreground">
{t("msg.common.audit.end", "End of audit feed")}
</span>
) : null}
</div>
</div>
);
}

View File

@@ -0,0 +1,56 @@
import { ShieldHalf } from "lucide-react";
import { useEffect } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate } from "react-router-dom";
import { debugLog } from "../../lib/debugLog";
function AuthCallbackPage() {
const auth = useAuth();
const navigate = useNavigate();
useEffect(() => {
debugLog("[AuthCallbackPage] State:", {
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
error: auth.error,
});
if (auth.isAuthenticated) {
// Save token to localStorage for existing API clients that might still use it
const user = auth.user;
if (user?.access_token) {
window.localStorage.setItem("admin_session", user.access_token);
}
const returnTo =
typeof auth.user?.state === "object" &&
auth.user?.state !== null &&
"returnTo" in auth.user.state &&
typeof auth.user.state.returnTo === "string"
? auth.user.state.returnTo
: "/";
console.info(
"[AuthCallbackPage] Auth successful, navigating to",
returnTo,
);
navigate(returnTo, { replace: true });
} else if (auth.error) {
console.error("[AuthCallbackPage] Auth Error:", auth.error);
navigate("/login", { replace: true });
}
}, [auth.isAuthenticated, auth.error, navigate, auth.user, auth.isLoading]);
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-lg animate-pulse">
<ShieldHalf size={32} />
</div>
<div className="text-lg font-semibold"> ...</div>
<p className="text-sm text-muted-foreground">
.
</p>
</div>
</div>
);
}
export default AuthCallbackPage;

View File

@@ -0,0 +1,56 @@
import { render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import AuthGuard from "./AuthGuard";
const authState = {
activeNavigator: undefined,
error: undefined as Error | undefined,
isAuthenticated: false,
isLoading: false,
removeUser: vi.fn(async () => undefined),
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
function renderAuthGuard(initialEntry = "/users") {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<Routes>
<Route path="/" element={<AuthGuard />}>
<Route path="users" element={<div>Users outlet</div>} />
</Route>
<Route path="/login" element={<div>Login outlet</div>} />
</Routes>
</MemoryRouter>,
);
}
describe("AuthGuard", () => {
beforeEach(() => {
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = false;
authState.activeNavigator = undefined;
authState.error = undefined;
authState.isAuthenticated = false;
authState.isLoading = false;
authState.removeUser.mockClear();
window.localStorage.clear();
});
it("clears stale auth state and returns to login when OIDC reports an error", async () => {
window.localStorage.setItem("admin_session", "stale-token");
authState.error = new Error("stale session");
renderAuthGuard();
await waitFor(() => {
expect(authState.removeUser).toHaveBeenCalled();
});
await screen.findByText("Login outlet");
expect(window.localStorage.getItem("admin_session")).toBeNull();
});
});

View File

@@ -0,0 +1,59 @@
import { useEffect, useRef } from "react";
import { useAuth } from "react-oidc-context";
import { Navigate, Outlet, useLocation, useNavigate } from "react-router-dom";
import { clearStoredAdminAuthSession } from "../../lib/auth";
export default function AuthGuard() {
const auth = useAuth();
const location = useLocation();
const navigate = useNavigate();
const handledAuthErrorRef = useRef(false);
const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
useEffect(() => {
if (!auth.error || handledAuthErrorRef.current || isTest) {
return;
}
handledAuthErrorRef.current = true;
clearStoredAdminAuthSession();
void Promise.resolve(
auth.removeUser ? auth.removeUser() : undefined,
).finally(() => {
navigate("/login", { replace: true });
});
}, [auth, auth.error, isTest, navigate]);
if (isTest) {
return <Outlet />;
}
if (auth.isLoading || auth.activeNavigator) {
return <div>Loading...</div>;
}
if (auth.error) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
<div className="mb-4 text-destructive">
<h2 className="text-xl font-bold"> </h2>
<p>{auth.error.message}</p>
</div>
</div>
);
}
if (!auth.isAuthenticated) {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
return (
<Navigate
to={`/login?returnTo=${encodeURIComponent(returnTo)}`}
replace
/>
);
}
return <Outlet />;
}

View File

@@ -0,0 +1,38 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import AuthPage from "./AuthPage";
vi.mock("../../lib/i18n", () => createI18nMock());
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<AuthPage />
</QueryClientProvider>,
);
}
describe("AuthPage", () => {
beforeEach(() => {
window.localStorage.setItem("locale", "en");
});
it("renders localized auth guard labels in English", () => {
renderPage();
expect(screen.getByText("Auth Guard")).toBeInTheDocument();
expect(screen.getByText("ReBAC permission checker")).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Check permission" }),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,24 @@
import { ShieldHalf } from "lucide-react";
import { PageHeader } from "../../../../common/core/components/page";
import { t } from "../../lib/i18n";
import PermissionChecker from "./components/PermissionChecker";
function AuthPage() {
return (
<div className="space-y-6">
<PageHeader
titleAs="h2"
icon={<ShieldHalf size={20} />}
title={t("ui.admin.auth_guard.title", "Auth Guard")}
description={t(
"ui.admin.auth_guard.subtitle",
"Verify admin privileges and ReBAC relationships against the policy engine.",
)}
/>
<PermissionChecker />
</div>
);
}
export default AuthPage;

View File

@@ -0,0 +1,76 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import LoginPage from "./LoginPage";
const mockSigninRedirect = vi.fn();
const mockUseAuth = vi.fn();
vi.mock("react-oidc-context", () => ({
useAuth: () => mockUseAuth(),
}));
function renderLoginPage(initialEntry: string) {
return render(
<MemoryRouter initialEntries={[initialEntry]}>
<LoginPage />
</MemoryRouter>,
);
}
describe("LoginPage", () => {
beforeEach(() => {
Object.defineProperty(window, "crypto", {
configurable: true,
value: {},
});
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: false,
});
mockSigninRedirect.mockReset();
mockUseAuth.mockReturnValue({
activeNavigator: undefined,
error: undefined,
isAuthenticated: false,
isLoading: false,
signinRedirect: mockSigninRedirect,
});
});
it("shows an actionable error instead of starting PKCE when WebCrypto is unavailable", async () => {
renderLoginPage("/login?returnTo=%2F");
await userEvent.click(
screen.getByRole("button", { name: /SSO 계정으로 로그인/i }),
);
expect(mockSigninRedirect).not.toHaveBeenCalled();
expect(screen.getByRole("alert")).toHaveTextContent(
/SSO 로그인을 시작할 수 없습니다/,
);
});
it("preserves the returnTo query when starting SSO manually", async () => {
Object.defineProperty(window, "crypto", {
configurable: true,
value: { subtle: {} },
});
Object.defineProperty(window, "isSecureContext", {
configurable: true,
value: true,
});
renderLoginPage("/login?returnTo=%2Fusers%3Fpage%3D2");
await userEvent.click(
screen.getByRole("button", { name: /SSO 계정으로 로그인/i }),
);
expect(mockSigninRedirect).toHaveBeenCalledWith({
state: {
returnTo: "/users?page=2",
},
});
});
});

View File

@@ -0,0 +1,206 @@
import { AlertTriangle, ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../components/ui/card";
import { canStartBrowserPkceLogin } from "../../lib/authConfig";
import { debugLog } from "../../lib/debugLog";
const insecurePkceMessage =
"이 주소에서는 브라우저 보안 정책 때문에 SSO 로그인을 시작할 수 없습니다. HTTPS 또는 localhost로 접속하거나, 내부망/host.docker.internal 개발 접속은 Chrome의 insecure-origin secure context 옵션에 실제 auth UI origin(예: http://host.docker.internal:5000)을 정확히 등록해 주세요.";
function isPkceSetupFailure(error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return /Crypto\.subtle|WebCrypto|PKCE|secure context|subtle/i.test(message);
}
function LoginPage() {
const auth = useAuth();
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const autoStartedRef = useRef(false);
const [loginError, setLoginError] = useState<string | null>(null);
const returnTo = searchParams.get("returnTo") || "/";
const shouldAutoLogin = searchParams.get("auto") === "1";
const authErrorMessage = useMemo(() => {
const message = auth.error?.message;
if (!message) {
return null;
}
if (message.includes("Crypto.subtle")) {
return insecurePkceMessage;
}
return message;
}, [auth.error?.message]);
const visibleLoginError = loginError || authErrorMessage;
useEffect(() => {
debugLog("[LoginPage] Auth state check:", {
isAuthenticated: auth.isAuthenticated,
isLoading: auth.isLoading,
returnTo,
});
if (auth.isAuthenticated) {
console.info(
"[LoginPage] User is authenticated, redirecting to",
returnTo,
);
navigate(returnTo, { replace: true });
}
}, [auth.isAuthenticated, navigate, returnTo, auth.isLoading]);
useEffect(() => {
if (!shouldAutoLogin) {
return;
}
if (autoStartedRef.current || auth.isLoading || auth.activeNavigator) {
return;
}
if (!canStartBrowserPkceLogin()) {
setLoginError(insecurePkceMessage);
return;
}
autoStartedRef.current = true;
void auth
.signinRedirect({
state: {
returnTo,
},
})
.catch((error) => {
if (isPkceSetupFailure(error)) {
setLoginError(insecurePkceMessage);
return;
}
console.error("Auto login redirect failed", error);
});
}, [auth, auth.activeNavigator, auth.isLoading, returnTo, shouldAutoLogin]);
const handleSSOLogin = async () => {
try {
setLoginError(null);
if (!canStartBrowserPkceLogin()) {
setLoginError(insecurePkceMessage);
return;
}
await auth.signinRedirect({
state: {
returnTo,
},
});
} catch (error) {
if (isPkceSetupFailure(error)) {
setLoginError(insecurePkceMessage);
return;
}
console.error("Redirect login failed", error);
}
};
return (
<div className="flex min-h-screen items-center justify-center bg-background px-4 py-12 sm:px-6 lg:px-8 bg-[radial-gradient(ellipse_at_top,_var(--tw-gradient-stops))] from-primary/10 via-background to-background">
<div className="w-full max-w-md space-y-8">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/15 text-primary shadow-[0_20px_50px_rgba(54,211,153,0.3)]">
<ShieldHalf size={32} />
</div>
<div className="space-y-2">
<h1 className="text-3xl font-bold tracking-tight">Baron SSO</h1>
<p className="text-sm text-muted-foreground uppercase tracking-[0.2em]">
Admin Control Plane
</p>
</div>
</div>
{auth.error && (
<div className="rounded-lg bg-destructive/15 p-4 text-sm text-destructive border border-destructive/20 animate-in fade-in slide-in-from-top-1">
<div className="font-bold flex items-center gap-2 mb-1">
<ShieldHalf size={16} />
</div>
<p className="opacity-90">{auth.error.message}</p>
<Button
variant="ghost"
className="p-0 h-auto text-destructive underline mt-2 hover:bg-transparent"
onClick={() => {
void handleSSOLogin();
}}
>
</Button>
</div>
)}
<Card className="border-primary/20 bg-card/50 backdrop-blur-xl shadow-2xl">
<CardHeader className="space-y-1">
<CardTitle className="text-2xl flex items-center gap-2">
<LogIn size={20} className="text-primary" />
</CardTitle>
<CardDescription>
Baron (SSO) .
</CardDescription>
</CardHeader>
<CardContent className="pt-4 pb-8 space-y-3">
<Button
onClick={handleSSOLogin}
className="w-full h-14 text-lg font-semibold flex gap-3 shadow-lg"
disabled={auth.isLoading}
>
{auth.isLoading ? (
<>
<div className="h-5 w-5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
...
</>
) : (
<>
<ShieldHalf size={22} />
SSO
<ExternalLink size={16} className="opacity-50" />
</>
)}
</Button>
{visibleLoginError ? (
<div
role="alert"
className="flex gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm leading-5 text-destructive"
>
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0" />
<span>{visibleLoginError}</span>
</div>
) : null}
<p className="mt-6 text-xs text-center text-muted-foreground leading-relaxed">
15 .
<br />
.
</p>
</CardContent>
</Card>
<div className="flex justify-center gap-4">
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
<div className="h-1 w-1 rounded-full bg-primary/30" />
</div>
<p className="px-8 text-center text-sm text-muted-foreground">
<br />
.
</p>
</div>
</div>
);
}
export default LoginPage;

View File

@@ -0,0 +1,188 @@
import { useMutation } from "@tanstack/react-query";
import { CheckCircle2, XCircle } from "lucide-react";
import { useState } from "react";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import apiClient from "../../../lib/apiClient";
import { t } from "../../../lib/i18n";
type CheckPermissionResponse = {
allowed: boolean;
query: {
namespace: string;
object: string;
relation: string;
subject: string;
};
};
function PermissionChecker() {
const [namespace, setNamespace] = useState("Tenant");
const [object, setObject] = useState("");
const [relation, setRelation] = useState("manage");
const [subject, setSubject] = useState("");
const checkMutation = useMutation({
mutationFn: async () => {
const { data } = await apiClient.get<CheckPermissionResponse>(
"/v1/admin/debug/check-permission",
{
params: { namespace, object, relation, subject },
},
);
return data;
},
});
const result = checkMutation.data;
return (
<Card className="border-primary/20 bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="text-lg font-bold">
{t("ui.admin.auth_guard.checker.title", "ReBAC permission checker")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.auth_guard.checker.description",
"Check in real time whether a subject has access to a resource through Ory Keto.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<div className="space-y-2">
<Label>
{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"
>
<option value="Tenant">
{t("ui.admin.auth_guard.checker.namespace.tenant", "Tenant")}
</option>
<option value="TenantGroup">
{t(
"ui.admin.auth_guard.checker.namespace.tenant_group",
"TenantGroup",
)}
</option>
<option value="RelyingParty">
{t(
"ui.admin.auth_guard.checker.namespace.relying_party",
"RelyingParty",
)}
</option>
<option value="System">
{t("ui.admin.auth_guard.checker.namespace.system", "System")}
</option>
</select>
</div>
<div className="space-y-2">
<Label>
{t("ui.admin.auth_guard.checker.relation", "Relation")}
</Label>
<Input
placeholder={t(
"ui.admin.auth_guard.checker.relation_placeholder",
"view, manage, admins...",
)}
value={relation}
onChange={(e) => setRelation(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>
{t("ui.admin.auth_guard.checker.object_id", "Object ID")}
</Label>
<Input
placeholder={t(
"ui.admin.auth_guard.checker.object_id_placeholder",
"Tenant UUID, etc.",
)}
value={object}
onChange={(e) => setObject(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>
{t("ui.admin.auth_guard.checker.subject", "Subject (User:ID)")}
</Label>
<Input
placeholder={t(
"ui.admin.auth_guard.checker.subject_placeholder",
"User:uuid or Namespace:ID#Relation",
)}
value={subject}
onChange={(e) => setSubject(e.target.value)}
/>
</div>
</div>
<div className="flex justify-center">
<Button
onClick={() => checkMutation.mutate()}
disabled={!object || !subject || checkMutation.isPending}
className="w-full px-12 md:w-auto"
>
{checkMutation.isPending
? t("ui.admin.auth_guard.checker.checking", "Checking...")
: t("ui.admin.auth_guard.checker.check", "Check permission")}
</Button>
</div>
{checkMutation.isSuccess && result && (
<div
className={`flex flex-col items-center justify-center gap-3 rounded-xl border-2 p-6 animate-in zoom-in duration-300 ${
result.allowed
? "border-green-500/50 bg-green-500/10 text-green-600"
: "border-destructive/50 bg-destructive/10 text-destructive"
}`}
>
{result.allowed ? (
<>
<CheckCircle2 size={48} />
<div className="text-lg font-bold">
{t("ui.admin.auth_guard.checker.allowed", "Access ALLOWED")}
</div>
<p className="text-center text-sm opacity-80">
{t(
"ui.admin.auth_guard.checker.allowed_description",
"The subject has access to the requested resource, including inherited permissions.",
)}
</p>
</>
) : (
<>
<XCircle size={48} />
<div className="text-lg font-bold">
{t("ui.admin.auth_guard.checker.denied", "Access DENIED")}
</div>
<p className="text-center text-sm opacity-80">
{t(
"ui.admin.auth_guard.checker.denied_description",
"The subject does not have access to the requested resource.",
)}
</p>
</>
)}
</div>
)}
</CardContent>
</Card>
);
}
export default PermissionChecker;

View File

@@ -0,0 +1,192 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } 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";
import { createI18nMock } from "../../test/i18nMock";
import AuditLogsPage from "../audit/AuditLogsPage";
import AuthCallbackPage from "../auth/AuthCallbackPage";
import AuthGuard from "../auth/AuthGuard";
const authState = {
isAuthenticated: true,
isLoading: false,
activeNavigator: undefined as string | undefined,
error: null as Error | null,
user: {
access_token: "access-token",
state: undefined as unknown,
},
};
vi.mock("react-oidc-context", () => ({
useAuth: () => authState,
}));
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../../../common/core/components/audit", () => ({
AuditLogTable: ({
logs,
}: {
logs: Array<{ user_id: string; event_type: string }>;
}) => (
<div>
{logs.map((log) => (
<div key={`${log.user_id}-${log.event_type}`}>
<span>{log.user_id}</span>
<span>{log.event_type}</span>
</div>
))}
</div>
),
}));
vi.mock("../../lib/adminApi", () => ({
fetchAuditLogs: vi.fn(async () => ({
items: [
{
event_id: "event-1",
timestamp: "2026-05-01T00:00:00Z",
user_id: "admin-1",
event_type: "USER_UPDATE",
status: "success",
ip_address: "127.0.0.1",
user_agent: "Vitest",
details: JSON.stringify({ action: "USER_UPDATE", actor: "Admin" }),
},
{
event_id: "event-2",
timestamp: "2026-05-01T01:00:00Z",
user_id: "admin-2",
event_type: "LOGIN_FAILED",
status: "failure",
ip_address: "127.0.0.2",
user_agent: "Vitest",
details: "{}",
},
],
limit: 50,
})),
}));
function renderWithProviders(ui: React.ReactElement, entry = "/") {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin audit and auth coverage smoke", () => {
beforeEach(() => {
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = false;
authState.isAuthenticated = true;
authState.isLoading = false;
authState.activeNavigator = undefined;
authState.error = null;
authState.user = {
access_token: "access-token",
state: undefined,
};
window.localStorage.clear();
});
it("renders audit log table with fetched events", async () => {
renderWithProviders(<AuditLogsPage />);
expect(await screen.findByText("감사 로그")).toBeInTheDocument();
expect(await screen.findByText("admin-1")).toBeInTheDocument();
expect(screen.getByText("USER_UPDATE")).toBeInTheDocument();
});
it("renders AuthGuard loading, error, redirect, test, and outlet states", async () => {
authState.isLoading = true;
renderWithProviders(
<Routes>
<Route path="/secure" element={<AuthGuard />}>
<Route index element={<div>Secure outlet</div>} />
</Route>
</Routes>,
"/secure",
);
expect(screen.getByText("Loading...")).toBeInTheDocument();
authState.isLoading = false;
authState.error = new Error("OIDC failed");
renderWithProviders(
<Routes>
<Route path="/secure" element={<AuthGuard />}>
<Route index element={<div>Secure outlet</div>} />
</Route>
</Routes>,
"/secure",
);
expect(screen.getByText("인증 오류")).toBeInTheDocument();
authState.error = null;
authState.isAuthenticated = false;
renderWithProviders(
<Routes>
<Route path="/secure" element={<AuthGuard />}>
<Route index element={<div>Secure outlet</div>} />
</Route>
<Route path="/login" element={<div>Login outlet</div>} />
</Routes>,
"/secure?x=1",
);
expect(screen.getByText("Login outlet")).toBeInTheDocument();
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
renderWithProviders(
<Routes>
<Route path="/secure" element={<AuthGuard />}>
<Route index element={<div>Secure outlet</div>} />
</Route>
</Routes>,
"/secure",
);
expect(screen.getByText("Secure outlet")).toBeInTheDocument();
});
it("stores callback token and navigates by auth result", async () => {
authState.isAuthenticated = true;
authState.user = {
access_token: "callback-token",
state: { returnTo: "/users" },
};
renderWithProviders(
<Routes>
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/users" element={<div>Users outlet</div>} />
<Route path="/login" element={<div>Login outlet</div>} />
</Routes>,
"/auth/callback",
);
expect(await screen.findByText("Users outlet")).toBeInTheDocument();
expect(window.localStorage.getItem("admin_session")).toBe("callback-token");
authState.isAuthenticated = false;
authState.error = new Error("callback failed");
renderWithProviders(
<Routes>
<Route path="/auth/callback" element={<AuthCallbackPage />} />
<Route path="/login" element={<div>Login outlet</div>} />
</Routes>,
"/auth/callback",
);
expect(await screen.findByText("Login outlet")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,506 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
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";
import { createI18nMock } from "../../test/i18nMock";
import * as adminApi from "../../lib/adminApi";
import { TenantWorksmobilePage } from "../tenants/routes/TenantWorksmobilePage";
import TenantListPage from "../tenants/routes/TenantListPage";
import UserCreatePage from "../users/UserCreatePage";
import UserDetailPage from "../users/UserDetailPage";
const tenantItems = [
{
id: "tenant-root",
type: "COMPANY_GROUP",
name: "한맥 가족",
slug: "hanmac-family",
description: "root",
status: "active",
memberCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-company",
type: "COMPANY",
parentId: "tenant-root",
name: "GPDTDC",
slug: "gpdtdc",
description: "company",
status: "active",
memberCount: 2,
config: {
userSchema: [
{
key: "employee_id",
label: "사번",
type: "text",
required: false,
},
],
},
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-leaf",
type: "ORGANIZATION",
parentId: "tenant-company",
name: "기술연구팀",
slug: "gpdtdc-rnd",
description: "leaf",
status: "active",
memberCount: 1,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
];
const userDetail = {
id: "user-1",
email: "engineer@example.com",
name: "Engineer User",
phone: "010-0000-0000",
role: "user",
status: "active",
tenantSlug: "gpdtdc-rnd",
tenantId: "tenant-leaf",
department: "기술연구팀",
grade: "책임",
position: "팀장",
jobTitle: "Backend",
metadata: {
employee_id: "EMP001",
sub_email: ["engineer.sub@example.com"],
},
tenant: tenantItems[2],
appointments: [
{
tenantId: "tenant-leaf",
tenantSlug: "gpdtdc-rnd",
tenantName: "기술연구팀",
isPrimary: true,
isOwner: false,
isAdmin: false,
isManager: true,
department: "기술연구팀",
grade: "책임",
position: "팀장",
jobTitle: "Backend",
metadata: { employee_id: "EMP001" },
},
],
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-02T00:00:00Z",
};
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../components/auth/RoleGuard", () => ({
RoleGuard: ({ children }: { children: React.ReactNode }) => <>{children}</>,
}));
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({
id: "admin-1",
role: "super_admin",
name: "Admin User",
email: "admin@example.com",
})),
fetchAllTenants: vi.fn(async () => ({
items: tenantItems,
total: tenantItems.length,
})),
fetchTenants: vi.fn(async () => ({
items: tenantItems,
limit: 500,
offset: 0,
total: tenantItems.length,
nextCursor: null,
})),
fetchTenant: vi.fn(async (id: string) => {
return tenantItems.find((tenant) => tenant.id === id) ?? tenantItems[1];
}),
createUser: vi.fn(async () => ({
id: "created-user",
email: "created@example.com",
generatedPassword: "GeneratedPassword!1",
})),
fetchUser: vi.fn(async () => userDetail),
fetchUserRpHistory: vi.fn(async () => [
{
client_id: "orgfront",
client_name: "OrgFront",
last_login_at: "2026-05-01T00:00:00Z",
login_count: 3,
},
]),
fetchGlobalCustomClaimDefinitions: vi.fn(async () => ({ items: [] })),
fetchPasswordPolicy: vi.fn(async () => ({
minLength: 12,
lowercase: true,
uppercase: true,
number: true,
nonAlphanumeric: true,
minCharacterTypes: 3,
})),
updateUser: vi.fn(async () => userDetail),
deleteUser: vi.fn(async () => undefined),
updateTenant: vi.fn(async () => tenantItems[1]),
deleteTenantsBulk: vi.fn(async () => ({ deleted: 1 })),
exportTenantsCSV: vi.fn(async () => new Blob(["name,slug\nGPDTDC,gpdtdc"])),
importTenantsCSV: vi.fn(async () => ({
created: 1,
updated: 0,
failed: 0,
errors: [],
})),
fetchWorksmobileOverview: vi.fn(async () => ({
tenant: tenantItems[1],
config: {
enabled: true,
tokenConfigured: true,
adminTenantId: "works-admin",
domainMappings: { "example.com": 1001 },
},
recentJobs: [
{
id: "job-1",
resourceType: "USER",
resourceId: "user-1",
action: "SYNC",
status: "failed",
retryCount: 1,
lastError: "temporary failure",
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:10:00Z",
},
],
})),
fetchWorksmobileComparison: vi.fn(async () => ({
users: [
{
resourceType: "USER",
baronId: "user-1",
baronName: "Engineer User",
baronEmail: "engineer@example.com",
baronPrimaryOrgId: "tenant-leaf",
baronPrimaryOrgName: "기술연구팀",
worksmobileId: "works-user-1",
worksmobileName: "Engineer User",
worksmobileEmail: "engineer@example.com",
worksmobileDomainId: 1001,
worksmobilePrimaryOrgId: "works-org-1",
worksmobilePrimaryOrgName: "기술연구팀",
status: "matched",
},
{
resourceType: "USER",
baronId: "user-2",
baronName: "New User",
baronEmail: "new@example.com",
worksmobileJobStatus: "failed",
worksmobileJobRetryCount: 2,
worksmobileLastError: "worksmobile api failed",
status: "missing_in_worksmobile",
},
{
resourceType: "USER",
baronId: "user-3",
baronName: "Next User",
baronEmail: "next@example.com",
status: "missing_in_worksmobile",
},
],
groups: [
{
resourceType: "ORG_UNIT",
baronId: "tenant-leaf",
baronSlug: "gpdtdc-rnd",
baronName: "기술연구팀",
worksmobileId: "works-org-1",
worksmobileName: "기술연구팀",
status: "needs_update",
},
],
})),
fetchWorksmobileCredentialBatches: vi.fn(async () => [
{
batchId: "credential-batch-1",
operation: "worksmobile_user_sync",
userCount: 1,
processedCount: 1,
failedCount: 1,
hasPasswords: true,
failures: [
{
userId: "failed-user",
email: "failed-user@samaneng.com",
status: "failed",
retryCount: 2,
lastError: "worksmobile api failed",
updatedAt: "2026-06-01T04:05:00Z",
},
],
createdAt: "2026-06-01T04:00:00Z",
updatedAt: "2026-06-01T04:00:00Z",
},
{
batchId: "credential-batch-pending",
operation: "worksmobile_user_sync",
userCount: 2,
pendingCount: 1,
processingCount: 1,
processedCount: 0,
failedCount: 0,
hasPasswords: true,
createdAt: "2026-06-01T04:10:00Z",
updatedAt: "2026-06-01T04:10:00Z",
},
]),
enqueueWorksmobileBackfillDryRun: vi.fn(async () => ({ id: "job-dry" })),
retryWorksmobileJob: vi.fn(async () => ({ id: "job-retry" })),
downloadWorksmobileInitialPasswordsCSV: vi.fn(async () => ({
blob: new Blob(["id"]),
filename: "worksmobile_initial_passwords.csv",
})),
enqueueWorksmobileOrgUnitSync: vi.fn(async () => ({ id: "job-org" })),
enqueueWorksmobileOrgUnitDelete: vi.fn(async () => ({ id: "job-delete" })),
enqueueWorksmobileUserSync: vi.fn(async () => ({ id: "job-user" })),
resetWorksmobileUserPassword: vi.fn(async () => ({ id: "job-reset" })),
deleteWorksmobileCredentialBatchPasswords: vi.fn(async () => ({
batchId: "credential-batch-1",
userCount: 1,
hasPasswords: false,
})),
}));
function renderWithProviders(ui: React.ReactElement, entry = "/") {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("adminfront large page coverage smoke", () => {
beforeEach(() => {
vi.clearAllMocks();
if (typeof window !== "undefined") {
(window as any)._IS_TEST_MODE = true;
}
});
it("renders user creation form with tenant context", async () => {
renderWithProviders(
<Routes>
<Route path="/users/new" element={<UserCreatePage />} />
</Routes>,
"/users/new?tenantSlug=gpdtdc-rnd",
);
expect(await screen.findByText("사용자 추가")).toBeInTheDocument();
expect(screen.getByLabelText("이메일")).toBeInTheDocument();
});
it("renders user detail form and RP history", async () => {
renderWithProviders(
<Routes>
<Route path="/users/:id" element={<UserDetailPage />} />
</Routes>,
"/users/user-1",
);
expect(await screen.findByDisplayValue("Engineer User")).toBeInTheDocument();
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
expect(screen.getByDisplayValue("engineer@example.com")).toBeInTheDocument();
});
it("renders tenant list hierarchy", async () => {
renderWithProviders(
<Routes>
<Route path="/tenants" element={<TenantListPage />} />
</Routes>,
"/tenants",
);
expect(await screen.findByText("GPDTDC")).toBeInTheDocument();
expect(screen.getByText("기술연구팀")).toBeInTheDocument();
});
it("renders worksmobile comparison screens", async () => {
cleanup();
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/worksmobile"
element={<TenantWorksmobilePage />}
/>
</Routes>,
"/tenants/tenant-company/worksmobile",
);
expect(await screen.findByText("Worksmobile 연동")).toBeInTheDocument();
expect(await screen.findByText("Baron / Works 비교")).toBeInTheDocument();
expect(
await screen.findByText("최근 실패: worksmobile api failed"),
).toBeInTheDocument();
expect(screen.getByText("Backfill Dry-run")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "초기 비밀번호 CSV" })).toBeNull();
});
it("does not automatically download the selected Worksmobile user credential batch after create enqueue", async () => {
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/worksmobile"
element={<TenantWorksmobilePage />}
/>
</Routes>,
"/tenants/tenant-company/worksmobile",
);
await screen.findByText("New User");
fireEvent.click(screen.getByRole("checkbox", { name: "New User 선택" }));
fireEvent.click(
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
);
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
target: { value: "InitialPassword!1" },
});
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
await waitFor(() =>
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledWith(
"tenant-company",
"user-2",
undefined,
"InitialPassword!1",
),
);
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
});
it("continues selected Worksmobile user create enqueue after one row fails", async () => {
vi.mocked(adminApi.enqueueWorksmobileUserSync)
.mockRejectedValueOnce(new Error("sync failed"))
.mockResolvedValueOnce({ id: "job-user-3" } as never);
vi.spyOn(window.URL, "createObjectURL").mockReturnValue("blob:test");
vi.spyOn(window.URL, "revokeObjectURL").mockImplementation(() => {});
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/worksmobile"
element={<TenantWorksmobilePage />}
/>
</Routes>,
"/tenants/tenant-company/worksmobile",
);
await screen.findByText("New User");
fireEvent.click(screen.getByRole("checkbox", { name: "New User 선택" }));
fireEvent.click(screen.getByRole("checkbox", { name: "Next User 선택" }));
fireEvent.click(
screen.getByRole("button", { name: "선택 구성원 WORKS에 생성" }),
);
fireEvent.change(screen.getByLabelText("초기 비밀번호"), {
target: { value: "InitialPassword!1" },
});
fireEvent.click(screen.getByRole("button", { name: "생성 작업 등록" }));
await waitFor(() =>
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenCalledTimes(2),
);
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
1,
"tenant-company",
"user-2",
undefined,
"InitialPassword!1",
);
expect(adminApi.enqueueWorksmobileUserSync).toHaveBeenNthCalledWith(
2,
"tenant-company",
"user-3",
undefined,
"InitialPassword!1",
);
expect(adminApi.downloadWorksmobileInitialPasswordsCSV).not.toHaveBeenCalled();
});
it("renders and retries Worksmobile jobs from history", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/worksmobile"
element={<TenantWorksmobilePage />}
/>
</Routes>,
"/tenants/tenant-company/worksmobile",
);
fireEvent.click(screen.getByRole("tab", { name: "이력" }));
expect((await screen.findAllByText("user-1")).length).toBeGreaterThan(0);
expect(screen.getByText("failed")).toBeInTheDocument();
fireEvent.click(screen.getAllByRole("button", { name: "" })[0]);
await waitFor(() =>
expect(adminApi.retryWorksmobileJob).toHaveBeenCalledWith(
"tenant-company",
"job-1",
),
);
});
it("opens Worksmobile password management for matched users", async () => {
const openSpy = vi.spyOn(window, "open").mockReturnValue(null);
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/worksmobile"
element={<TenantWorksmobilePage />}
/>
</Routes>,
"/tenants/tenant-company/worksmobile",
);
await screen.findByText("Worksmobile 연동");
fireEvent.click(screen.getAllByRole("button", { name: "양쪽 다 있음" })[0]);
await screen.findAllByText("Engineer User");
fireEvent.click(
screen.getByRole("button", {
name: "Engineer User 비밀번호 관리",
}),
);
expect(openSpy).toHaveBeenCalledWith(
expect.stringContaining(
"https://auth.worksmobile.com/integrate/password/manage",
),
"_blank",
"noopener,noreferrer",
);
const [url] = openSpy.mock.calls[0] ?? [];
const parsed = new URL(String(url));
expect(parsed.searchParams.get("targetUserTenantId")).toBe("works-admin");
expect(parsed.searchParams.get("targetUserDomainId")).toBe("1001");
expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1");
});
});

View File

@@ -0,0 +1,129 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } 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";
import { createI18nMock } from "../../test/i18nMock";
import TenantCreatePage from "../tenants/routes/TenantCreatePage";
import { TenantProfilePage } from "../tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../tenants/routes/TenantSchemaPage";
const tenants = [
{
id: "tenant-root",
type: "COMPANY_GROUP",
name: "한맥 가족",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 0,
domains: ["hmac.kr"],
config: { visibility: "public" },
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-company",
type: "COMPANY",
parentId: "tenant-root",
name: "GPDTDC",
slug: "gpdtdc",
description: "실 조직",
status: "active",
memberCount: 2,
domains: ["gpdtdc.example.com"],
config: {
visibility: "public",
userSchema: [
{
key: "employee_id",
label: "사번",
type: "text",
required: false,
adminOnly: false,
isLoginId: true,
indexed: true,
},
],
},
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
];
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({
id: "admin-1",
role: "super_admin",
})),
fetchAllTenants: vi.fn(async () => ({
items: tenants,
total: tenants.length,
})),
fetchTenant: vi.fn(async (id: string) => {
return tenants.find((tenant) => tenant.id === id) ?? tenants[1];
}),
createTenant: vi.fn(async () => tenants[1]),
updateTenant: vi.fn(async () => tenants[1]),
deleteTenant: vi.fn(async () => undefined),
approveTenant: vi.fn(async () => tenants[1]),
}));
function renderWithProviders(ui: React.ReactElement, entry: string) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin tenant detail page coverage smoke", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("renders tenant create page with parent context", async () => {
renderWithProviders(
<Routes>
<Route path="/tenants/new" element={<TenantCreatePage />} />
</Routes>,
"/tenants/new?parentId=tenant-root",
);
expect(await screen.findByText("테넌트 생성")).toBeInTheDocument();
expect(screen.getByText("Tenant Profile")).toBeInTheDocument();
expect(screen.getByText("정책 메모")).toBeInTheDocument();
});
it("renders tenant profile and schema management pages", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId"
element={
<>
<TenantProfilePage />
<TenantSchemaPage />
</>
}
/>
</Routes>,
"/tenants/tenant-company",
);
expect(await screen.findByDisplayValue("GPDTDC")).toBeInTheDocument();
expect(screen.getByDisplayValue("gpdtdc")).toBeInTheDocument();
expect(await screen.findByText("사용자 스키마 확장")).toBeInTheDocument();
expect(screen.getByDisplayValue("employee_id")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,116 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } 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";
import { createI18nMock } from "../../test/i18nMock";
import TenantGroupsPage from "../tenants/routes/TenantGroupsPage";
const tenant = {
id: "tenant-company",
type: "COMPANY",
name: "GPDTDC",
slug: "gpdtdc",
description: "",
status: "active",
memberCount: 2,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
};
const members = [
{
id: "user-1",
name: "Member User",
email: "member@example.com",
},
];
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
fetchTenant: vi.fn(async () => tenant),
fetchUsers: vi.fn(async () => ({
items: [
{
id: "user-1",
name: "Member User",
email: "member@example.com",
role: "user",
status: "active",
},
{
id: "user-2",
name: "Candidate User",
email: "candidate@example.com",
role: "user",
status: "active",
},
],
total: 2,
})),
fetchGroups: vi.fn(async () => [
{
id: "group-root",
tenantId: "tenant-company",
name: "연구소",
description: "root group",
members,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "group-child",
tenantId: "tenant-company",
parentId: "group-root",
name: "플랫폼팀",
description: "child group",
members: [],
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]),
createGroup: vi.fn(async () => undefined),
deleteGroup: vi.fn(async () => undefined),
addGroupMember: vi.fn(async () => undefined),
removeGroupMember: vi.fn(async () => undefined),
}));
function renderWithProviders(ui: React.ReactElement, entry: string) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantGroupsPage coverage smoke", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("renders group hierarchy and selected group members", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/groups"
element={<TenantGroupsPage />}
/>
</Routes>,
"/tenants/tenant-company/groups",
);
expect((await screen.findAllByText("연구소")).length).toBeGreaterThan(0);
expect(screen.getAllByText("플랫폼팀").length).toBeGreaterThan(0);
expect(screen.getByText("새 그룹 생성")).toBeInTheDocument();
expect(screen.getByText("조직 단위 레벨")).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,196 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
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";
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",
type: "COMPANY_GROUP",
name: "한맥 가족",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-company",
type: "COMPANY",
parentId: "tenant-root",
name: "GPDTDC",
slug: "gpdtdc",
description: "",
status: "active",
memberCount: 2,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
{
id: "tenant-leaf",
type: "ORGANIZATION",
parentId: "tenant-company",
name: "기술연구팀",
slug: "gpdtdc-rnd",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
];
const users = [
{
id: "user-owner",
name: "Owner User",
email: "owner@example.com",
role: "tenant_admin",
status: "active",
},
{
id: "user-admin",
name: "Admin User",
email: "admin@example.com",
role: "tenant_admin",
status: "active",
},
{
id: "user-member",
name: "Member User",
email: "member@example.com",
role: "user",
status: "active",
tenantSlug: "gpdtdc-rnd",
tenant: tenants[2],
},
];
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("react-oidc-context", () => ({
useAuth: () => ({
user: {
profile: {
sub: "admin-1",
},
},
}),
}));
vi.mock("../../lib/adminApi", () => ({
fetchTenantOwners: vi.fn(async () => [users[0]]),
fetchTenantAdmins: vi.fn(async () => [users[1]]),
addTenantOwner: vi.fn(async () => undefined),
addTenantAdmin: vi.fn(async () => undefined),
removeTenantOwner: vi.fn(async () => undefined),
removeTenantAdmin: vi.fn(async () => undefined),
fetchUsers: vi.fn(async () => ({
items: users,
total: users.length,
})),
fetchAllTenants: vi.fn(async () => ({
items: tenants,
total: tenants.length,
})),
updateTenant: vi.fn(async () => tenants[2]),
updateUser: vi.fn(async () => users[2]),
exportTenantsCSV: vi.fn(async () => ({
blob: new Blob(["name,slug"]),
filename: "tenants.csv",
})),
exportUsersCSV: exportUsersCSVMock,
}));
function renderWithProviders(ui: React.ReactElement, entry: string) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[entry]}>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
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 () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/permissions"
element={<TenantAdminsAndOwnersTab />}
/>
</Routes>,
"/tenants/tenant-company/permissions",
);
expect(await screen.findByText("Owner User")).toBeInTheDocument();
expect(screen.getByText("Admin User")).toBeInTheDocument();
expect(screen.getByText("owner@example.com")).toBeInTheDocument();
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
});
it("renders tenant hierarchy and selected organization members", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/organization"
element={<TenantUserGroupsTab />}
/>
</Routes>,
"/tenants/tenant-company/organization",
);
expect((await screen.findAllByText("GPDTDC")).length).toBeGreaterThan(0);
expect(screen.getAllByText("기술연구팀").length).toBeGreaterThan(0);
expect(await screen.findByText("Member User")).toBeInTheDocument();
});
it("exports selected organization users by tenant slug", async () => {
renderWithProviders(
<Routes>
<Route
path="/tenants/:tenantId/organization"
element={<TenantUserGroupsTab />}
/>
</Routes>,
"/tenants/tenant-company/organization",
);
expect(await screen.findByText("Member User")).toBeInTheDocument();
fireEvent.click(screen.getByTestId("tenant-current-users-export-btn"));
await waitFor(() => {
expect(exportUsersCSVMock).toHaveBeenCalledWith("", "gpdtdc", false);
});
});
});

View File

@@ -0,0 +1,250 @@
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 {
deleteOrphanUserLoginIDs,
fetchDataIntegrityReport,
fetchMe,
fetchOrySSOTSystemStatus,
fetchOrphanUserLoginIDs,
flushIdentityCache,
} from "../../lib/adminApi";
import { expectNoAnonymousFormFields } from "../../test/formFieldDiagnostics";
import { createI18nMock } from "../../test/i18nMock";
import DataIntegrityPage from "./DataIntegrityPage";
vi.mock("../../lib/i18n", () => createI18nMock());
let currentRole = "super_admin";
const integrityReport = {
status: "fail",
checkedAt: "2026-05-14T00:00:00Z",
summary: {
totalChecks: 2,
passed: 1,
warnings: 0,
failures: 1,
},
sections: [
{
key: "tenant_integrity",
label: "테넌트 정합성",
status: "fail",
checks: [
{
key: "duplicate_tenant_slugs",
label: "중복 테넌트 slug",
description: "active tenant slug의 대소문자 무시 중복을 검사합니다.",
status: "fail",
severity: "error",
count: 1,
},
],
},
],
};
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchDataIntegrityReport: vi.fn(async () => integrityReport),
fetchOrphanUserLoginIDs: vi.fn(async () => ({
items: [
{
id: "login-id-1",
userId: "user-1",
userEmail: "missing@example.com",
tenantId: "tenant-1",
tenantSlug: "deleted-tenant",
fieldKey: "emp_id",
loginId: "EMP001",
reasons: ["deleted_tenant"],
},
],
total: 1,
})),
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",
},
})),
flushIdentityCache: vi.fn(async () => ({
status: "success",
flushedKeys: 153,
updatedAt: "2026-05-11T03:02:00Z",
})),
deleteOrphanUserLoginIDs: vi.fn(async () => ({
deletedCount: 1,
deleted: [
{
id: "login-id-1",
userId: "user-1",
tenantId: "tenant-1",
fieldKey: "emp_id",
loginId: "EMP001",
reasons: ["deleted_tenant"],
},
],
skippedIds: [],
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<DataIntegrityPage />
</QueryClientProvider>,
);
}
describe("DataIntegrityPage", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
window.localStorage.setItem("locale", "ko");
});
it("renders integrity report for super_admin", async () => {
renderPage();
expect(await screen.findByText("데이터 정합성 검증")).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "정합성 검사" }),
).toBeInTheDocument();
expect(
screen.getByRole("tab", { name: "Ory SSOT 시스템" }),
).toBeInTheDocument();
expect(
await screen.findByText(
"정합성 상태를 확인하고 데이터 모델 전반의 검증 결과를 살펴봅니다.",
),
).toBeInTheDocument();
expect(await screen.findByText("테넌트 정합성")).toBeInTheDocument();
expect(screen.getByText("중복 테넌트 slug")).toBeInTheDocument();
expect(screen.getAllByText("1").length).toBeGreaterThan(0);
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
it("renders Ory SSOT cache management inside data integrity", async () => {
renderPage();
fireEvent.click(
await screen.findByRole("tab", { name: "Ory SSOT 시스템" }),
);
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: /Redis cache flush/ }));
await waitFor(() => {
expect(flushIdentityCache).toHaveBeenCalledTimes(1);
});
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
});
it("shows orphan login ID targets and deletes selected rows", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
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 선택" }));
fireEvent.click(screen.getByRole("button", { name: "선택 삭제" }));
await waitFor(() => {
expect(deleteOrphanUserLoginIDs).toHaveBeenCalled();
});
expect(vi.mocked(deleteOrphanUserLoginIDs).mock.calls[0][0]).toEqual([
"login-id-1",
]);
});
it("disables recheck button and shows manual recheck progress", async () => {
let finishRecheck: (value: typeof integrityReport) => void = () => {};
const pendingRecheck = new Promise<typeof integrityReport>((resolve) => {
finishRecheck = resolve;
});
renderPage();
expect(await screen.findByText("중복 테넌트 slug")).toBeInTheDocument();
vi.mocked(fetchDataIntegrityReport).mockImplementationOnce(
() => pendingRecheck,
);
fireEvent.click(screen.getByRole("button", { name: "다시 검사" }));
expect(screen.getByRole("button", { name: "검사 중" })).toBeDisabled();
expect(
screen.getByText("정합성 검사를 실행 중입니다."),
).toBeInTheDocument();
finishRecheck(integrityReport);
await waitFor(() => {
expect(screen.getByRole("button", { name: "다시 검사" })).toBeEnabled();
});
expect(screen.getByText("검사가 완료되었습니다.")).toBeInTheDocument();
});
it("blocks non-super admins", async () => {
currentRole = "tenant_admin";
renderPage();
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
expect(fetchMe).toHaveBeenCalled();
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
});
it("renders localized integrity labels in English", async () => {
window.localStorage.setItem("locale", "en");
renderPage();
expect(await screen.findByText("Data Integrity Check")).toBeInTheDocument();
expect(
await screen.findByText(
"Review integrity status and inspect checks across the admin data model.",
),
).toBeInTheDocument();
expect(await screen.findByText("Tenant integrity")).toBeInTheDocument();
expect(
await screen.findByText("Duplicate tenant slug"),
).toBeInTheDocument();
expect(
await screen.findByText(
"Checks duplicate active tenant slugs using LOWER(TRIM(slug)).",
),
).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,640 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
AlertTriangle,
CheckCircle2,
Database,
ShieldAlert,
} from "lucide-react";
import { useState } from "react";
import { RoleGuard } from "../../components/auth/RoleGuard";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
import {
type DataIntegrityCheck,
type DataIntegrityStatus,
deleteOrphanUserLoginIDs,
fetchDataIntegrityReport,
fetchOrphanUserLoginIDs,
type OrphanUserLoginID,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
import { UserProjectionContent } from "../projections/UserProjectionPage";
function statusLabel(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return t("ui.admin.integrity.status.pass", "정상");
case "warning":
return t("ui.admin.integrity.status.warning", "주의");
case "fail":
return t("ui.admin.integrity.status.fail", "실패");
default:
return status;
}
}
function statusBadgeVariant(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return "success";
case "warning":
return "warning";
default:
return "warning";
}
}
function formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(getAdminDateLocale(), {
dateStyle: "medium",
timeStyle: "medium",
}).format(date);
}
function CheckIcon({ check }: { check: DataIntegrityCheck }) {
if (check.status === "pass") {
return <CheckCircle2 className="text-emerald-600" size={18} />;
}
if (check.status === "warning") {
return <AlertTriangle className="text-amber-600" size={18} />;
}
return <ShieldAlert className="text-destructive" size={18} />;
}
function reasonLabel(reason: string) {
switch (reason) {
case "missing_user":
return t("ui.admin.integrity.reason.missing_user", "사용자 없음");
case "deleted_user":
return t("ui.admin.integrity.reason.deleted_user", "삭제된 사용자");
case "missing_tenant":
return t("ui.admin.integrity.reason.missing_tenant", "테넌트 없음");
case "deleted_tenant":
return t("ui.admin.integrity.reason.deleted_tenant", "삭제된 테넌트");
default:
return reason;
}
}
function integritySectionLabel(key: string, fallback: string) {
switch (key) {
case "tenant_integrity":
return t("ui.admin.integrity.section.tenant_integrity", fallback);
case "user_integrity":
return t("ui.admin.integrity.section.user_integrity", fallback);
default:
return fallback;
}
}
function integritySectionDescription(key: string) {
switch (key) {
case "tenant_integrity":
return t(
"msg.admin.integrity.section.tenant_integrity.description",
"테넌트 slug 중복과 부모 관계 이상을 확인합니다.",
);
case "user_integrity":
return t(
"msg.admin.integrity.section.user_integrity.description",
"사용자와 로그인 ID 참조의 고아 레코드를 확인합니다.",
);
default:
return "";
}
}
function integrityCheckLabel(key: string, fallback: string) {
switch (key) {
case "duplicate_tenant_slugs":
return t(
"ui.admin.integrity.check.duplicate_tenant_slugs.title",
fallback,
);
case "orphan_tenant_parents":
return t(
"ui.admin.integrity.check.orphan_tenant_parents.title",
fallback,
);
case "orphan_user_tenant_memberships":
return t(
"ui.admin.integrity.check.orphan_user_tenant_memberships.title",
fallback,
);
case "orphan_user_login_id_tenants":
return t(
"ui.admin.integrity.check.orphan_user_login_id_tenants.title",
fallback,
);
case "orphan_user_login_id_users":
return t(
"ui.admin.integrity.check.orphan_user_login_id_users.title",
fallback,
);
default:
return fallback;
}
}
function integrityCheckDescription(key: string, fallback: string) {
switch (key) {
case "duplicate_tenant_slugs":
return t(
"msg.admin.integrity.check.duplicate_tenant_slugs.description",
fallback,
);
case "orphan_tenant_parents":
return t(
"msg.admin.integrity.check.orphan_tenant_parents.description",
fallback,
);
case "orphan_user_tenant_memberships":
return t(
"msg.admin.integrity.check.orphan_user_tenant_memberships.description",
fallback,
);
case "orphan_user_login_id_tenants":
return t(
"msg.admin.integrity.check.orphan_user_login_id_tenants.description",
fallback,
);
case "orphan_user_login_id_users":
return t(
"msg.admin.integrity.check.orphan_user_login_id_users.description",
fallback,
);
default:
return fallback;
}
}
function recheckStatusText(status: "idle" | "running" | "success" | "error") {
switch (status) {
case "running":
return t(
"msg.admin.integrity.recheck.running",
"정합성 검사를 실행 중입니다.",
);
case "success":
return t("msg.admin.integrity.recheck.success", "검사가 완료되었습니다.");
case "error":
return t("msg.admin.integrity.recheck.error", "검사에 실패했습니다.");
default:
return "";
}
}
function pageTabClassName(active: boolean) {
return `relative px-6 py-3 text-sm font-medium transition-colors ${
active
? "border-b-2 border-primary text-primary"
: "text-muted-foreground hover:text-foreground"
}`;
}
function OrphanLoginIDTable({
items,
selectedIds,
onToggle,
}: {
items: OrphanUserLoginID[];
selectedIds: string[];
onToggle: (id: string) => void;
}) {
if (items.length === 0) {
return (
<div className="rounded border border-border/60 px-3 py-6 text-center text-sm text-muted-foreground">
{t(
"msg.admin.integrity.orphan_login_ids.empty",
"삭제할 유령 로그인 ID가 없습니다.",
)}
</div>
);
}
const selectedSet = new Set(selectedIds);
return (
<div className="overflow-x-auto rounded border border-border/60">
<table className="w-full min-w-[760px] text-sm">
<thead className="bg-muted/50 text-left text-muted-foreground">
<tr>
<th className="w-12 px-3 py-2">
{t("ui.admin.integrity.table.select", "선택")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.login_id", "Login ID")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.field", "Field")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.user", "User")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.tenant", "Tenant")}
</th>
<th className="px-3 py-2">
{t("ui.admin.integrity.table.reason", "사유")}
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item) => (
<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",
"{{loginId}} 선택",
{ loginId: item.loginId },
)}
checked={selectedSet.has(item.id)}
onChange={() => onToggle(item.id)}
className="h-4 w-4 rounded border-input"
/>
</td>
<td className="px-3 py-2 font-medium">{item.loginId}</td>
<td className="px-3 py-2 text-muted-foreground">
{item.fieldKey}
</td>
<td className="px-3 py-2">
<div>{item.userEmail || "-"}</div>
<div className="text-xs text-muted-foreground">
{item.userId}
</div>
</td>
<td className="px-3 py-2">
<div>{item.tenantSlug || "-"}</div>
<div className="text-xs text-muted-foreground">
{item.tenantId}
</div>
</td>
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1">
{item.reasons.map((reason) => (
<Badge key={reason} variant="warning">
{reasonLabel(reason)}
</Badge>
))}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}
function DataIntegrityContent() {
const queryClient = useQueryClient();
const [activeTab, setActiveTab] = useState<"integrity" | "projection">(
"integrity",
);
const [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
const [recheckStatus, setRecheckStatus] = useState<
"idle" | "running" | "success" | "error"
>("idle");
const { data, isLoading, isError, error, refetch, isFetching } = useQuery({
queryKey: ["data-integrity-report"],
queryFn: fetchDataIntegrityReport,
});
const orphanLoginIDsQuery = useQuery({
queryKey: ["orphan-user-login-ids"],
queryFn: fetchOrphanUserLoginIDs,
});
const deleteMutation = useMutation({
mutationFn: deleteOrphanUserLoginIDs,
onSuccess: async () => {
setSelectedOrphanIds([]);
await Promise.all([
queryClient.invalidateQueries({ queryKey: ["data-integrity-report"] }),
queryClient.invalidateQueries({ queryKey: ["orphan-user-login-ids"] }),
]);
},
});
const orphanItems = orphanLoginIDsQuery.data?.items ?? [];
const toggleOrphanID = (id: string) => {
setSelectedOrphanIds((current) =>
current.includes(id)
? current.filter((selectedID) => selectedID !== id)
: [...current, id],
);
};
const handleDeleteSelected = () => {
if (selectedOrphanIds.length === 0) {
return;
}
const confirmed = window.confirm(
t(
"msg.admin.integrity.orphan_login_ids.delete_confirm",
"선택한 {{count}}개의 유령 로그인 ID를 삭제하시겠습니까?",
{ count: selectedOrphanIds.length },
),
);
if (confirmed) {
deleteMutation.mutate(selectedOrphanIds);
}
};
const isManualRechecking = recheckStatus === "running";
const handleRecheck = async () => {
if (isManualRechecking) {
return;
}
setRecheckStatus("running");
const result = await refetch();
setRecheckStatus(result.isError ? "error" : "success");
};
const recheckMessage = recheckStatusText(recheckStatus);
return (
<main className="space-y-6">
<header className="flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur">
<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">
<Database size={20} />
</div>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.integrity.title", "데이터 정합성 검증")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.integrity.subtitle",
"Review integrity status and inspect checks across the admin data model.",
)}
</p>
</div>
</div>
{activeTab === "integrity" ? (
<div className="flex flex-col items-end gap-1">
<Button
type="button"
variant="outline"
onClick={handleRecheck}
disabled={isLoading || isFetching || isManualRechecking}
>
<Database size={16} />
{isManualRechecking
? t("ui.admin.integrity.recheck.running", "검사 중")
: t("ui.admin.integrity.recheck.run", "다시 검사")}
</Button>
{recheckMessage ? (
<output
aria-live="polite"
className="text-xs text-muted-foreground"
>
{recheckMessage}
</output>
) : null}
</div>
) : null}
</header>
<div
className="flex border-b border-border"
role="tablist"
aria-label="데이터 정합성 탭"
>
<button
type="button"
role="tab"
aria-selected={activeTab === "integrity"}
className={pageTabClassName(activeTab === "integrity")}
onClick={() => setActiveTab("integrity")}
>
{t("ui.admin.integrity.tab_checks", "정합성 검사")}
</button>
<button
type="button"
role="tab"
aria-selected={activeTab === "projection"}
className={pageTabClassName(activeTab === "projection")}
onClick={() => setActiveTab("projection")}
>
{t("ui.admin.integrity.tab_ory_ssot", "Ory SSOT 시스템")}
</button>
</div>
{activeTab === "integrity" ? (
<div className="space-y-4 pb-6 animate-in fade-in duration-500">
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
t(
"msg.admin.integrity.report.load_error",
"정합성 리포트를 불러오지 못했습니다.",
)}
</section>
) : null}
<section className="rounded-lg border border-border bg-card p-5">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-border pb-4">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.read_model.title",
"Read model integrity",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.integrity.read_model.description",
"Ory SoT를 덮어쓰지 않고 backend DB read model의 이상 징후만 확인합니다.",
)}
</p>
</div>
{data ? (
<Badge variant={statusBadgeVariant(data.status)}>
{statusLabel(data.status)}
</Badge>
) : null}
</div>
{isLoading ? (
<div className="py-8 text-sm text-muted-foreground">
{t("ui.admin.integrity.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.integrity.summary.total_checks", "검사 항목")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.totalChecks ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.passed", "정상")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.passed ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.failures", "실패 건수")}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{data?.summary.failures ?? 0}
</dd>
</div>
<div>
<dt className="text-sm text-muted-foreground">
{t("ui.admin.integrity.summary.checked_at", "검사 시각")}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(data?.checkedAt)}
</dd>
</div>
</dl>
)}
</section>
<div className="space-y-4">
{(data?.sections ?? []).map((section) => (
<section
key={section.key}
className="rounded-lg border border-border bg-card p-5"
>
<div className="mb-4 flex items-center justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{integritySectionLabel(section.key, section.label)}
</h3>
<p className="text-sm text-muted-foreground">
{integritySectionDescription(section.key)}
</p>
</div>
<Badge variant={statusBadgeVariant(section.status)}>
{statusLabel(section.status)}
</Badge>
</div>
<div className="divide-y divide-border">
{section.checks.map((check) => (
<div
key={check.key}
className="grid gap-3 py-4 md:grid-cols-[1fr_auto]"
>
<div className="flex gap-3">
<CheckIcon check={check} />
<div>
<div className="font-medium">
{integrityCheckLabel(check.key, check.label)}
</div>
<p className="mt-1 text-sm text-muted-foreground">
{integrityCheckDescription(
check.key,
check.description,
)}
</p>
</div>
</div>
<div className="flex items-center gap-3 md:justify-end">
<Badge variant={statusBadgeVariant(check.status)}>
{statusLabel(check.status)}
</Badge>
<span className="min-w-12 text-right text-lg font-semibold tabular-nums">
{check.count}
</span>
</div>
</div>
))}
</div>
</section>
))}
</div>
<section className="rounded-lg border border-border bg-card p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.integrity.orphan_login_ids.title",
"유령 로그인 ID 정리",
)}
</h3>
<p className="mt-1 text-sm text-muted-foreground">
{t(
"msg.admin.integrity.orphan_login_ids.description",
"삭제되었거나 존재하지 않는 사용자/테넌트를 참조하는 로그인 ID를 확인한 뒤 선택 삭제합니다.",
)}
</p>
</div>
<Button
type="button"
variant="destructive"
onClick={handleDeleteSelected}
disabled={
selectedOrphanIds.length === 0 || deleteMutation.isPending
}
>
{t("ui.admin.integrity.orphan_login_ids.delete", "선택 삭제")}
</Button>
</div>
{orphanLoginIDsQuery.isError ? (
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
{t(
"msg.admin.integrity.orphan_login_ids.load_error",
"유령 로그인 ID 대상을 불러오지 못했습니다.",
)}
</div>
) : null}
{deleteMutation.data ? (
<div className="mb-3 rounded border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800 dark:border-emerald-900 dark:bg-emerald-950/40 dark:text-emerald-200">
{t(
"msg.admin.integrity.orphan_login_ids.delete_success",
"{{count}}개의 유령 로그인 ID를 삭제했습니다.",
{ count: deleteMutation.data.deletedCount },
)}
</div>
) : null}
<OrphanLoginIDTable
items={orphanItems}
selectedIds={selectedOrphanIds}
onToggle={toggleOrphanID}
/>
</section>
</div>
) : (
<div className="animate-in fade-in duration-500">
<UserProjectionContent embedded />
</div>
)}
</main>
);
}
export default function DataIntegrityPage() {
return (
<RoleGuard
roles={["super_admin"]}
fallback={
<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.integrity.forbidden.title", "접근 권한이 없습니다")}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t(
"msg.admin.integrity.forbidden.description",
"이 화면은 super_admin 권한으로만 접근할 수 있습니다.",
)}
</p>
</section>
</main>
}
>
<DataIntegrityContent />
</RoleGuard>
);
}

View File

@@ -0,0 +1,266 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import type React from "react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetchAdminRPUsageDaily,
fetchDataIntegrityReport,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import AuthPage from "../auth/AuthPage";
import GlobalOverviewPage from "./GlobalOverviewPage";
vi.mock("../../lib/i18n", () => createI18nMock());
let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })),
fetchAdminOverviewStats: vi.fn(async () => ({
totalTenants: 10,
totalUsers: 152,
oidcClients: 3,
auditEvents24h: 18,
})),
fetchAllTenants: vi.fn(async () => ({
items: [
{
id: "group-1",
type: "COMPANY_GROUP",
name: "한맥그룹",
slug: "hanmac-group",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
{
id: "company-1",
type: "COMPANY",
name: "한맥",
slug: "hanmac",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
{
id: "org-1",
type: "ORGANIZATION",
name: "개발팀",
slug: "dev-team",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
{
id: "personal-1",
type: "PERSONAL",
name: "개인",
slug: "personal",
description: "",
status: "active",
memberCount: 0,
createdAt: "2026-05-06T00:00:00Z",
updatedAt: "2026-05-06T00:00:00Z",
},
],
limit: 1000,
offset: 0,
total: 4,
})),
fetchAdminRPUsageDaily: vi.fn(async () => ({
days: 14,
period: "day",
items: [
{
date: "2026-05-05",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "orgfront",
clientName: "OrgFront",
loginRequests: 12,
otherRequests: 4,
uniqueSubjects: 8,
},
{
date: "2026-05-06",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "adminfront",
clientName: "AdminFront",
loginRequests: 7,
otherRequests: 3,
uniqueSubjects: 5,
},
{
date: "2026-09-28",
tenantId: "company-1",
tenantType: "COMPANY",
tenantName: "한맥",
clientId: "devfront",
clientName: "DevFront",
loginRequests: 2,
otherRequests: 1,
uniqueSubjects: 2,
},
],
})),
fetchDataIntegrityReport: vi.fn(async () => ({
status: "fail",
checkedAt: "2026-05-14T00:00:00Z",
summary: {
totalChecks: 5,
passed: 4,
warnings: 0,
failures: 1,
},
sections: [
{
key: "tenant_integrity",
label: "테넌트 정합성",
status: "pass",
checks: [],
},
{
key: "user_integrity",
label: "사용자 정합성",
status: "fail",
checks: [],
},
],
})),
}));
function renderWithProviders(ui: React.ReactElement) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>{ui}</MemoryRouter>
</QueryClientProvider>,
);
}
describe("admin overview and auth guard pages", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
});
it("renders usage trend chart without quick navigation or permission checker", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(
await screen.findByText("회사별 앱별 로그인 요청 현황"),
).toBeInTheDocument();
expect(
await screen.findByLabelText("일 단위 RP 요청 현황"),
).toBeInTheDocument();
expect(await screen.findByText("05.05")).toBeInTheDocument();
expect(await screen.findByText("05.06")).toBeInTheDocument();
expect(screen.queryByText("빠른 작업")).not.toBeInTheDocument();
expect(screen.queryByText("빠른 이동")).not.toBeInTheDocument();
expect(screen.queryByText("테넌트 추가")).not.toBeInTheDocument();
expect(screen.queryByText("ReBAC 권한 검증 도구")).not.toBeInTheDocument();
});
it("renders overview tenant count from the fully fetched tenant list", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(
(await screen.findByText("전체 테넌트 수")).parentElement,
).toHaveTextContent("4");
expect(screen.getByText("OIDC 클라이언트").parentElement).toHaveTextContent(
"3",
);
expect(screen.getByText("전체 사용자 수").parentElement).toHaveTextContent(
"152",
);
expect(screen.getByText("24시간 이벤트").parentElement).toHaveTextContent(
"18",
);
});
it("limits the overview graph choices to company tenants", async () => {
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인 요청 현황");
expect(
await screen.findByRole("checkbox", { name: "한맥 (hanmac)" }),
).toBeInTheDocument();
expect(
screen.queryByText("한맥그룹 (hanmac-group)"),
).not.toBeInTheDocument();
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
});
it("changes the RP usage perspective and targets a permitted company", async () => {
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인 요청 현황");
fireEvent.click(screen.getByRole("button", { name: "주" }));
expect(await screen.findAllByText("19(05월1주)")).not.toHaveLength(0);
expect(await screen.findAllByText("40(10월1주)")).not.toHaveLength(0);
fireEvent.click(screen.getByRole("button", { name: "월" }));
fireEvent.click(screen.getByRole("checkbox", { name: "한맥 (hanmac)" }));
await waitFor(() => {
expect(fetchAdminRPUsageDaily).toHaveBeenLastCalledWith({
days: 90,
period: "month",
});
});
expect(
screen.queryByText("한맥그룹 (hanmac-group)"),
).not.toBeInTheDocument();
expect(screen.queryByText("개발팀 (dev-team)")).not.toBeInTheDocument();
expect(screen.queryByText("개인 (personal)")).not.toBeInTheDocument();
});
it("shows the latest integrity summary at the bottom for super admins only", async () => {
renderWithProviders(<GlobalOverviewPage />);
expect(await screen.findByText("정합성 최종 검증")).toBeInTheDocument();
expect(screen.getByText("실패 1건")).toBeInTheDocument();
expect(screen.getByText("테넌트 정합성")).toBeInTheDocument();
expect(screen.getByText("사용자 정합성")).toBeInTheDocument();
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
it("does not fetch or show the integrity summary for non-super admins", async () => {
currentRole = "tenant_admin";
renderWithProviders(<GlobalOverviewPage />);
await screen.findByText("회사별 앱별 로그인 요청 현황");
expect(screen.queryByText("정합성 최종 검증")).not.toBeInTheDocument();
expect(fetchDataIntegrityReport).not.toHaveBeenCalled();
});
it("moves the permission checker to the auth guard page and removes mock guardrails", () => {
renderWithProviders(<AuthPage />);
expect(screen.getByText("인증 가드")).toBeInTheDocument();
expect(screen.getByText("ReBAC 권한 검증 도구")).toBeInTheDocument();
expect(screen.queryByText("Admin auth guardrails")).not.toBeInTheDocument();
expect(
screen.queryByText("IDP session placeholder"),
).not.toBeInTheDocument();
expect(screen.queryByText("Admin login")).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,610 @@
import { useQuery } from "@tanstack/react-query";
import {
Activity,
AlertTriangle,
CheckCircle2,
Database,
LayoutDashboard,
ShieldCheck,
Users,
} from "lucide-react";
import { type ReactNode, useMemo, useState } from "react";
import {
OverviewAxisNotes,
OverviewMetric,
OverviewSelectionChips,
} from "../../../../common/core/components/overview";
import { RoleGuard } from "../../components/auth/RoleGuard";
import {
type DataIntegrityStatus,
fetchAdminOverviewStats,
fetchAdminRPUsageDaily,
fetchAllTenants,
fetchDataIntegrityReport,
type RPUsageDailyMetric,
type RPUsagePeriod,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
type DailyPoint = {
date: string;
loginRequests: number;
otherRequests: number;
};
type SeriesSummary = {
key: string;
clientLabel: string;
loginRequests: number;
uniqueSubjects: number;
};
function summarizeDaily(rows: RPUsageDailyMetric[]): DailyPoint[] {
const byDate = new Map<string, DailyPoint>();
for (const row of rows) {
const current =
byDate.get(row.date) ??
({
date: row.date,
loginRequests: 0,
otherRequests: 0,
} satisfies DailyPoint);
current.loginRequests += row.loginRequests;
current.otherRequests += row.otherRequests;
byDate.set(row.date, current);
}
return Array.from(byDate.values()).sort((a, b) =>
a.date.localeCompare(b.date),
);
}
function summarizeSeries(rows: RPUsageDailyMetric[]): SeriesSummary[] {
const bySeries = new Map<string, SeriesSummary>();
for (const row of rows) {
const key = row.clientId;
const current =
bySeries.get(key) ??
({
key,
clientLabel: row.clientName || row.clientId,
loginRequests: 0,
uniqueSubjects: 0,
} satisfies SeriesSummary);
current.loginRequests += row.loginRequests;
current.uniqueSubjects = Math.max(
current.uniqueSubjects,
row.uniqueSubjects,
);
bySeries.set(key, current);
}
return Array.from(bySeries.values())
.sort((a, b) => b.loginRequests - a.loginRequests)
.slice(0, 5);
}
function parseDateParts(date: string) {
const parts = date.split("-");
if (parts.length === 3) {
return {
year: Number(parts[0]),
month: Number(parts[1]),
day: Number(parts[2]),
monthText: parts[1],
dayText: parts[2],
};
}
return null;
}
function getISOWeekNumber(year: number, month: number, day: number) {
const date = new Date(Date.UTC(year, month - 1, day));
const dayOfWeek = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1));
return Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7);
}
function getISOWeekThursday(year: number, month: number, day: number) {
const date = new Date(Date.UTC(year, month - 1, day));
const dayOfWeek = date.getUTCDay() || 7;
date.setUTCDate(date.getUTCDate() + 4 - dayOfWeek);
return date;
}
function formatPeriodLabel(date: string, period: RPUsagePeriod) {
const parts = parseDateParts(date);
if (!parts) {
return date;
}
if (period === "month") {
return `${parts.monthText}`;
}
if (period === "week") {
const weekNumber = String(
getISOWeekNumber(parts.year, parts.month, parts.day),
).padStart(2, "0");
const weekThursday = getISOWeekThursday(parts.year, parts.month, parts.day);
const weekMonth = weekThursday.getUTCMonth() + 1;
const weekDay = weekThursday.getUTCDate();
const weekMonthText = String(weekMonth).padStart(2, "0");
const weekOfMonth = Math.min(5, Math.max(1, Math.ceil(weekDay / 7)));
return `${weekNumber}(${weekMonthText}${weekOfMonth}주)`;
}
return `${parts.monthText}.${parts.dayText}`;
}
function formatOverviewDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat("ko-KR", {
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
function integrityStatusText(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return t("ui.admin.integrity.status.pass", "정상");
case "warning":
return t("ui.admin.integrity.status.warning", "주의");
default:
return t("ui.admin.integrity.status.fail", "실패");
}
}
function integrityStatusClass(status: DataIntegrityStatus) {
switch (status) {
case "pass":
return "text-emerald-700 dark:text-emerald-300";
case "warning":
return "text-amber-700 dark:text-amber-300";
default:
return "text-destructive";
}
}
function IntegrityOverviewSummary() {
const { data, isError } = useQuery({
queryKey: ["admin-overview-integrity"],
queryFn: fetchDataIntegrityReport,
retry: false,
});
if (isError) {
return (
<section className="border-t border-border/60 pt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<AlertTriangle size={16} />
<span>
{t(
"ui.admin.integrity.fetch_error",
"정합성 최종 검증 결과를 불러오지 못했습니다.",
)}
</span>
</div>
</section>
);
}
if (!data) {
return null;
}
return (
<section className="border-t border-border/60 pt-4">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="flex items-center gap-2">
{data.status === "pass" ? (
<CheckCircle2 size={18} className="text-emerald-600" />
) : (
<AlertTriangle size={18} className="text-amber-600" />
)}
<h3 className="text-lg font-bold flex items-center gap-2">
{t("ui.admin.integrity.summary.title", "정합성 최종 검증")}
</h3>
</div>
<div className="flex flex-wrap items-center gap-3 text-sm">
<span
className={`font-semibold ${integrityStatusClass(data.status)}`}
>
{integrityStatusText(data.status)}
</span>
<span className="tabular-nums">
{t("ui.admin.integrity.summary.failures_text", "실패 {{count}}건", {
count: data.summary.failures,
})}
</span>
<span className="text-muted-foreground">
{formatOverviewDateTime(data.checkedAt)}
</span>
</div>
</div>
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
{data.sections.map((section) => (
<div
key={section.key}
className="flex items-center justify-between gap-3 rounded border border-border/60 px-3 py-2"
>
<span>{integritySectionLabel(section.key, section.label)}</span>
<span
className={`font-medium ${integrityStatusClass(section.status)}`}
>
{integrityStatusText(section.status)}
</span>
</div>
))}
</div>
</section>
);
}
function integritySectionLabel(key: string, fallback: string) {
switch (key) {
case "tenant_integrity":
return t("ui.admin.integrity.section.tenant_integrity", fallback);
case "user_integrity":
return t("ui.admin.integrity.section.user_integrity", fallback);
default:
return fallback;
}
}
function RPUsageMixedChart({
rows,
periodControls,
filters,
period,
}: {
rows: RPUsageDailyMetric[];
periodControls: ReactNode;
filters: ReactNode;
period: RPUsagePeriod;
}) {
const daily = summarizeDaily(rows);
const series = summarizeSeries(rows);
const chartWidth = 720;
const chartHeight = 230;
const padX = 48;
const padTop = 32;
const padBottom = 34;
const innerWidth = chartWidth - padX * 2;
const innerHeight = chartHeight - padTop - padBottom;
const maxValue = Math.max(
1,
...daily.map((point) => point.loginRequests + point.otherRequests),
...daily.map((point) => point.loginRequests),
);
const slot = daily.length > 0 ? innerWidth / daily.length : innerWidth;
const barWidth = Math.min(28, Math.max(10, slot * 0.42));
const y = (value: number) =>
padTop + innerHeight - (value / maxValue) * innerHeight;
const x = (index: number) => padX + slot * index + slot / 2;
const linePoints = daily
.map((point, index) => `${x(index)},${y(point.loginRequests)}`)
.join(" ");
return (
<section className="space-y-3">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{t("ui.admin.overview.chart.title", "회사별 앱별 로그인 요청 현황")}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.overview.chart.description",
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
)}
</p>
</div>
{periodControls}
</div>
{filters}
{daily.length === 0 ? (
<div className="flex min-h-[210px] items-center justify-center text-sm text-muted-foreground">
RP .
</div>
) : (
<div className="space-y-3">
<div className="overflow-x-auto">
<svg
role="img"
aria-label="일 단위 RP 요청 현황"
viewBox={`0 0 ${chartWidth} ${chartHeight}`}
className="h-[235px] min-w-[720px] w-full"
>
<title> RP </title>
{[0, 0.25, 0.5, 0.75, 1].map((ratio) => {
const gridY = padTop + innerHeight * ratio;
const label = Math.round(maxValue * (1 - ratio));
return (
<g key={ratio}>
<line
x1={padX}
x2={chartWidth - padX}
y1={gridY}
y2={gridY}
stroke="currentColor"
className="text-border"
strokeWidth="1"
/>
<text
x={padX - 12}
y={gridY + 4}
textAnchor="end"
className="fill-muted-foreground text-[11px]"
>
{label}
</text>
</g>
);
})}
{daily.map((point, index) => {
const center = x(index);
const otherHeight =
(point.otherRequests / maxValue) * innerHeight;
return (
<g key={point.date}>
<rect
x={center - barWidth / 2}
y={padTop + innerHeight - otherHeight}
width={barWidth}
height={otherHeight}
rx="3"
className="fill-sky-500/70"
/>
<text
x={center}
y={chartHeight - 12}
textAnchor="middle"
className="fill-muted-foreground text-[11px]"
>
{formatPeriodLabel(point.date, period)}
</text>
</g>
);
})}
<polyline
points={linePoints}
fill="none"
className="stroke-emerald-500"
strokeWidth="3"
strokeLinecap="round"
strokeLinejoin="round"
/>
{daily.map((point, index) => (
<circle
key={`${point.date}-login`}
cx={x(index)}
cy={y(point.loginRequests)}
r="4"
className="fill-emerald-500 stroke-background"
strokeWidth="2"
/>
))}
</svg>
</div>
<OverviewAxisNotes
xAxisLabel={t("ui.common.chart.axis.x", "X축: 기간")}
yAxisLabel={t("ui.common.chart.axis.y", "Y축: 로그인 요청 수")}
/>
</div>
)}
{series.length > 0 && (
<div className="grid gap-x-6 gap-y-2 border-t border-border/60 pt-2 text-xs md:grid-cols-2 xl:grid-cols-3">
{series.map((item) => (
<div
key={item.key}
className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1"
>
<span className="font-medium">{item.clientLabel}</span>
<span className="whitespace-nowrap tabular-nums text-muted-foreground">
{t(
"ui.common.chart.series_summary.login_users",
"로그인 {{login}} / 사용자 {{subjects}}",
{
login: item.loginRequests.toLocaleString(),
subjects: item.uniqueSubjects.toLocaleString(),
},
)}
</span>
</div>
))}
</div>
)}
</section>
);
}
function GlobalOverviewPage() {
const [period, setPeriod] = useState<RPUsagePeriod>("day");
const [selectedTenantIds, setSelectedTenantIds] = useState<string[]>([]);
const usageDays = period === "day" ? 14 : period === "week" ? 84 : 90;
const statsQuery = useQuery({
queryKey: ["admin-overview-stats"],
queryFn: fetchAdminOverviewStats,
retry: false,
});
const tenantsQuery = useQuery({
queryKey: ["admin-overview-tenant-options"],
queryFn: () => fetchAllTenants(),
retry: false,
});
const tenantOptions = useMemo(() => {
return (tenantsQuery.data?.items ?? []).filter(
(tenant) => tenant.type === "COMPANY",
);
}, [tenantsQuery.data?.items]);
const usageQuery = useQuery({
queryKey: ["admin-rp-usage-daily", usageDays, period],
queryFn: () =>
fetchAdminRPUsageDaily({
days: usageDays,
period,
}),
retry: false,
});
const stats = statsQuery.data;
const visibleTenantCount = tenantsQuery.data?.items.length;
const usageRows = usageQuery.data?.items ?? [];
const filteredUsageRows = useMemo(() => {
if (selectedTenantIds.length === 0) {
return usageRows;
}
const selectedSet = new Set(selectedTenantIds);
return usageRows.filter((row) => selectedSet.has(row.tenantId));
}, [selectedTenantIds, usageRows]);
const metric = (value: number | undefined) =>
value === undefined ? "-" : value.toLocaleString();
const periodControls = (
<fieldset className="flex h-8 items-center gap-1" aria-label="집계 단위">
{[
["day", t("ui.common.chart.period.day", "일")],
["week", t("ui.common.chart.period.week", "주")],
["month", t("ui.common.chart.period.month", "월")],
].map(([value, label]) => (
<button
key={value}
type="button"
aria-pressed={period === value}
onClick={() => setPeriod(value as RPUsagePeriod)}
className={`h-8 rounded px-3 text-xs font-medium transition-colors ${
period === value
? "bg-primary text-primary-foreground"
: "bg-muted/60 hover:bg-muted"
}`}
>
{label}
</button>
))}
</fieldset>
);
const chartFilters = (
<div>
<OverviewSelectionChips
allLabel="전체"
options={tenantOptions.map((tenant) => ({
id: tenant.id,
label: `${tenant.name} (${tenant.slug})`,
}))}
selectedIds={selectedTenantIds}
onSelectAll={() => setSelectedTenantIds([])}
onToggle={(tenantId) => {
setSelectedTenantIds((current) =>
current.includes(tenantId)
? current.filter((item) => item !== tenantId)
: [...current, tenantId],
);
}}
/>
</div>
);
return (
<div className="space-y-4 animate-in fade-in duration-500">
<div className="flex flex-wrap items-start justify-between gap-4">
<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">
<LayoutDashboard size={20} />
</div>
<div className="space-y-1">
<h2 className="text-3xl font-semibold">
{t("ui.common.overview.title", "운영 현황")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.overview.description",
"시스템 전반의 주요 현황을 확인하고 관리합니다.",
)}
</p>
</div>
</div>
</div>
<div className="flex flex-wrap items-center gap-x-6 gap-y-2 border-y border-border/60 py-2">
<RoleGuard roles={["super_admin"]}>
<OverviewMetric
icon={<Users size={14} />}
label={t(
"ui.admin.overview.summary.total_tenants",
"전체 테넌트 수",
)}
value={metric(visibleTenantCount ?? stats?.totalTenants)}
/>
<OverviewMetric
icon={<ShieldCheck size={14} />}
label={t(
"ui.admin.overview.summary.oidc_clients",
"OIDC 클라이언트",
)}
value={metric(stats?.oidcClients)}
/>
<OverviewMetric
icon={<Users size={14} />}
label={t("ui.admin.overview.summary.total_users", "전체 사용자 수")}
value={metric(stats?.totalUsers)}
/>
</RoleGuard>
<OverviewMetric
icon={<Activity size={14} />}
label={t(
"ui.admin.overview.summary.audit_events_24h",
"24시간 이벤트",
)}
value={metric(stats?.auditEvents24h)}
/>
<OverviewMetric
icon={<Database size={14} />}
label={t("ui.admin.overview.summary.policy_gate", "정책 상태")}
value="Active"
/>
</div>
{usageQuery.isError ? (
<section className="space-y-2">
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="space-y-1">
<h3 className="text-lg font-bold flex items-center gap-2">
{t(
"ui.admin.overview.chart.title",
"회사별 앱별 로그인 요청 현황",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.overview.chart.description",
"전체 또는 선택한 조직 기준으로 그래프를 확인합니다.",
)}
</p>
</div>
{periodControls}
</div>
{chartFilters}
<div className="text-sm text-muted-foreground">
RP Query API . backend
`rp_usage_daily_aggregate`
.
</div>
</section>
) : (
<RPUsageMixedChart
rows={filteredUsageRows}
periodControls={periodControls}
filters={chartFilters}
period={period}
/>
)}
<RoleGuard roles={["super_admin"]}>
<IntegrityOverviewSummary />
</RoleGuard>
</div>
);
}
export default GlobalOverviewPage;

View File

@@ -0,0 +1,120 @@
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 {
fetchOrySSOTSystemStatus,
flushIdentityCache,
} from "../../lib/adminApi";
import { createI18nMock } from "../../test/i18nMock";
import UserProjectionPage from "./UserProjectionPage";
vi.mock("../../lib/i18n", () => createI18nMock());
let currentRole = "super_admin";
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: currentRole })),
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,
},
})),
flushIdentityCache: vi.fn(async () => ({
status: "success",
flushedKeys: 153,
updatedAt: "2026-05-11T03:02:00Z",
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<UserProjectionPage />
</QueryClientProvider>,
);
}
describe("UserProjectionPage", () => {
beforeEach(() => {
currentRole = "super_admin";
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
window.localStorage.setItem("locale", "ko");
});
it("renders Ory SSOT and Redis identity cache status for super_admin", async () => {
renderPage();
expect(await screen.findByText("Ory SSOT 시스템")).toBeInTheDocument();
expect(
await screen.findByText(
"Kratos 원장과 Redis identity cache 상태를 분리해서 확인합니다.",
),
).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(screen.getByText("151")).toBeInTheDocument();
expect(fetchOrySSOTSystemStatus).toHaveBeenCalled();
});
it("flushes only the Redis identity cache for super_admin", async () => {
renderPage();
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(flushIdentityCache).toHaveBeenCalledTimes(1);
});
});
it("blocks non-super admins", async () => {
currentRole = "tenant_admin";
renderPage();
expect(await screen.findByText("접근 권한이 없습니다")).toBeInTheDocument();
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(
"Review Kratos source-of-truth and Redis identity cache status separately.",
),
).toBeInTheDocument();
expect(screen.getByText("Redis cache flush")).toBeInTheDocument();
expect((await screen.findAllByText("ready")).length).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,340 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
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 {
fetchOrySSOTSystemStatus,
flushIdentityCache,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { getAdminDateLocale } from "../../lib/locale";
function formatDateTime(value?: string) {
if (!value) return "-";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return value;
return new Intl.DateTimeFormat(getAdminDateLocale(), {
dateStyle: "medium",
timeStyle: "medium",
}).format(date);
}
function StatusBadge({ ready, status }: { ready: boolean; status: string }) {
if (ready) {
return (
<Badge variant="success">
{t("ui.admin.ory_ssot.status.ready", "ready")}
</Badge>
);
}
if (status === "failed") {
return (
<Badge variant="warning">
{t("ui.admin.ory_ssot.status.failed", "failed")}
</Badge>
);
}
return (
<Badge variant="secondary">
{status ? status : t("ui.admin.ory_ssot.status.not_ready", "not ready")}
</Badge>
);
}
export function UserProjectionContent({
embedded = false,
}: {
embedded?: boolean;
}) {
const queryClient = useQueryClient();
const { data, isLoading, isError, error } = useQuery({
queryKey: ["ory-ssot-system-status"],
queryFn: fetchOrySSOTSystemStatus,
});
const flushMutation = useMutation({
mutationFn: flushIdentityCache,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ["ory-ssot-system-status"],
});
},
});
const handleFlush = () => {
const confirmed = window.confirm(
t(
"msg.admin.ory_ssot.flush_confirm",
"Flush only Redis identity cache keys?",
),
);
if (confirmed) flushMutation.mutate();
};
const projection = data?.userProjection;
const identityCache = data?.identityCache;
const header = (
<header
className={
embedded
? "flex flex-shrink-0 flex-wrap items-start justify-between gap-4"
: "flex flex-shrink-0 flex-wrap items-start justify-between gap-4 sticky top-[-2.5rem] z-20 -mt-4 bg-background/95 pb-2 pt-4 backdrop-blur"
}
>
<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">
<Database size={20} />
</div>
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.ory_ssot.title", "Ory SSOT System")}
</h2>
<p className="text-sm text-muted-foreground">
{t(
"msg.admin.ory_ssot.subtitle",
"Review Kratos source-of-truth and Redis identity cache status separately.",
)}
</p>
</div>
</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>
);
const body = (
<>
{isError ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(error as Error)?.message ||
t(
"msg.admin.ory_ssot.load_error",
"Failed to load Ory SSOT system status.",
)}
</section>
) : null}
{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.ory_ssot.flush_success",
"Flushed {{count}} Redis identity cache keys.",
{ count: flushMutation.data.flushedKeys },
)}
</section>
) : null}
{flushMutation.error ? (
<section className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive">
{(flushMutation.error as Error)?.message ||
t(
"msg.admin.ory_ssot.flush_error",
"Redis identity cache flush failed.",
)}
</section>
) : null}
<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.projection_card.title",
"Backend user read model",
)}
</h3>
<p className="text-sm text-muted-foreground">
{t(
"ui.admin.ory_ssot.projection_card.description",
"PostgreSQL read model status used by admin search and statistics.",
)}
</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={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.ory_ssot.summary.observed_identities",
"Observed identities",
)}
</dt>
<dd className="mt-1 text-xl font-semibold tabular-nums">
{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.ory_ssot.summary.last_refreshed",
"Last refreshed",
)}
</dt>
<dd className="mt-1 text-sm">
{formatDateTime(identityCache?.lastRefreshedAt)}
</dd>
</div>
</dl>
)}
{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>{identityCache.lastError}</span>
</div>
) : null}
</section>
</>
);
if (embedded) {
return (
<div className="space-y-4 pb-6">
{header}
{body}
</div>
);
}
return (
<main className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
{header}
{body}
</main>
);
}
export default function UserProjectionPage() {
return (
<RoleGuard
roles={["super_admin"]}
fallback={
<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.ory_ssot.forbidden.title", "Access denied")}
</h2>
<p className="mt-2 text-sm text-muted-foreground">
{t(
"msg.admin.ory_ssot.forbidden.description",
"This screen is only available to super_admin users.",
)}
</p>
</section>
</main>
}
>
<UserProjectionContent />
</RoleGuard>
);
}

View File

@@ -0,0 +1,53 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { DomainTagInput } from "./DomainTagInput";
describe("DomainTagInput", () => {
it("shows a clear duplicate tenant warning and adds the domain after confirmation", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const onConfirmedConflictsChange = vi.fn();
render(
<DomainTagInput
value={[]}
onChange={onChange}
tenants={[
{
id: "tenant-1",
name: "한맥가족",
slug: "hanmac-family",
type: "COMPANY",
description: "",
status: "active",
domains: ["samaneng.com"],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
]}
currentTenantId="tenant-2"
confirmedConflicts={[]}
onConfirmedConflictsChange={onConfirmedConflictsChange}
placeholder="example.com"
/>,
);
await user.type(
screen.getByPlaceholderText("example.com"),
"samaneng.com ",
);
expect(
await screen.findByText(
"samaneng.com 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
),
).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "계속 진행" }));
expect(onChange).toHaveBeenCalledWith(["samaneng.com"]);
expect(onConfirmedConflictsChange).toHaveBeenCalledWith(["samaneng.com"]);
});
});

View File

@@ -0,0 +1,187 @@
import { X } from "lucide-react";
import { useState } from "react";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import type { TenantSummary } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
type DomainConflict,
findDomainConflict,
formatDomainConflictMessage,
normalizeDomainTokens,
} from "../utils/domainTags";
type DomainTagInputProps = {
id?: string;
value: string[];
onChange: (domains: string[]) => void;
tenants?: TenantSummary[];
currentTenantId?: string;
confirmedConflicts?: string[];
onConfirmedConflictsChange?: (domains: string[]) => void;
placeholder?: string;
};
export function DomainTagInput({
id,
value,
onChange,
tenants = [],
currentTenantId,
confirmedConflicts = [],
onConfirmedConflictsChange,
placeholder,
}: DomainTagInputProps) {
const [input, setInput] = useState("");
const [pendingConflict, setPendingConflict] = useState<DomainConflict | null>(
null,
);
const addConfirmedConflict = (domain: string) => {
if (!confirmedConflicts.includes(domain)) {
onConfirmedConflictsChange?.([...confirmedConflicts, domain]);
}
};
const removeConfirmedConflict = (domain: string) => {
if (confirmedConflicts.includes(domain)) {
onConfirmedConflictsChange?.(
confirmedConflicts.filter((item) => item !== domain),
);
}
};
const addDomain = (domain: string, confirmed = false) => {
if (value.includes(domain)) {
return;
}
onChange([...value, domain]);
if (confirmed) {
addConfirmedConflict(domain);
}
};
const tokenizeInput = () => {
const tokens = normalizeDomainTokens(input);
if (tokens.length === 0) {
setInput("");
return;
}
for (const token of tokens) {
if (value.includes(token)) {
continue;
}
const conflict = findDomainConflict(token, tenants, currentTenantId);
if (conflict && !confirmedConflicts.includes(token)) {
setPendingConflict(conflict);
setInput("");
return;
}
addDomain(token, confirmedConflicts.includes(token));
}
setInput("");
};
const removeDomain = (domain: string) => {
onChange(value.filter((item) => item !== domain));
removeConfirmedConflict(domain);
};
return (
<>
<div className="flex min-h-10 w-full flex-wrap items-center gap-2 rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm focus-within:ring-1 focus-within:ring-ring">
{value.map((domain) => (
<Badge
key={domain}
variant={confirmedConflicts.includes(domain) ? "warning" : "muted"}
className="gap-1 rounded-md"
>
<span>{domain}</span>
<button
type="button"
className="inline-flex h-4 w-4 items-center justify-center rounded-sm hover:bg-background/60"
onClick={() => removeDomain(domain)}
aria-label={t("ui.common.remove", "삭제")}
>
<X size={12} />
</button>
</Badge>
))}
<Input
id={id}
value={input}
onChange={(event) => setInput(event.target.value)}
onBlur={tokenizeInput}
onKeyDown={(event) => {
if (
event.key === " " ||
event.key === "Enter" ||
event.key === "," ||
event.key === ";"
) {
event.preventDefault();
tokenizeInput();
}
}}
className="h-7 min-w-[180px] flex-1 border-0 px-0 py-0 shadow-none focus-visible:ring-0"
placeholder={value.length === 0 ? placeholder : undefined}
/>
</div>
<Dialog
open={pendingConflict !== null}
onOpenChange={(open) => {
if (!open) {
setPendingConflict(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.domain_conflict.title", "도메인 충돌")}
</DialogTitle>
<DialogDescription>
{pendingConflict
? t(
"ui.admin.tenants.domain_conflict.description",
formatDomainConflictMessage(pendingConflict),
)
: ""}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => setPendingConflict(null)}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button
type="button"
onClick={() => {
if (pendingConflict) {
addDomain(pendingConflict.domain, true);
}
setPendingConflict(null);
}}
>
{t("ui.common.continue", "계속 진행")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,21 @@
import type { TenantSummary } from "../../../lib/adminApi";
const companyParentTypes = new Set(["COMPANY", "COMPANY_GROUP"]);
export function filterParentTenants(
tenants: TenantSummary[],
search: string,
companyOnly: boolean,
excludeTenantId = "",
) {
const normalizedSearch = search.trim().toLowerCase();
return tenants.filter((tenant) => {
if (excludeTenantId && tenant.id === excludeTenantId) return false;
if (companyOnly && !companyParentTypes.has(tenant.type)) return false;
if (!normalizedSearch) return true;
return [tenant.name, tenant.slug, tenant.type]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(normalizedSearch));
});
}

View File

@@ -0,0 +1,137 @@
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import { ParentTenantSelector } from "./ParentTenantSelector";
const tenants: TenantSummary[] = [
{
id: "company-1",
type: "COMPANY",
name: "Saman Engineering",
slug: "saman",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "group-1",
type: "COMPANY_GROUP",
name: "Hanmac Family",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
describe("ParentTenantSelector picker", () => {
it("opens an org-chart picker modal and applies tenant selection messages", async () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
/>,
);
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
expect(screen.getByRole("dialog")).toBeInTheDocument();
const pickerSrc = screen.getByTitle("테넌트 선택").getAttribute("src");
expect(pickerSrc).toContain("/login");
expect(decodeURIComponent(pickerSrc ?? "")).toContain("/embed/picker");
fireEvent(
window,
new MessageEvent("message", {
data: {
type: "orgfront:picker:confirm",
payload: {
selections: [
{
type: "tenant",
id: "company-1",
name: "Saman Engineering",
},
],
},
},
}),
);
await waitFor(() => expect(onChange).toHaveBeenCalledWith("company-1"));
});
it("keeps the current tenant out of picker message selections", async () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
excludeTenantId="company-1"
/>,
);
fireEvent.click(screen.getByRole("button", { name: /테넌트 선택/ }));
fireEvent(
window,
new MessageEvent("message", {
data: {
type: "orgfront:picker:confirm",
payload: {
selections: [
{
type: "tenant",
id: "company-1",
name: "Saman Engineering",
},
],
},
},
}),
);
await waitFor(() => expect(onChange).not.toHaveBeenCalled());
});
it("selects a non-hanmac parent from the local tenant picker", async () => {
const onChange = vi.fn();
render(
<ParentTenantSelector
id="parentId"
label="상위 테넌트"
value=""
onChange={onChange}
tenants={tenants}
noneLabel="없음"
orgChartPickerLabel="한맥가족에서 선택"
localPickerLabel="다른 테넌트 선택"
localTenantFilter={(tenant) => tenant.slug !== "hanmac-family"}
/>,
);
fireEvent.click(screen.getByRole("button", { name: "다른 테넌트 선택" }));
fireEvent.change(
screen.getByPlaceholderText("테넌트 이름 또는 슬러그 검색"),
{ target: { value: "saman" } },
);
fireEvent.click(screen.getByRole("button", { name: /Saman Engineering/ }));
expect(onChange).toHaveBeenCalledWith("company-1");
});
});

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import { filterParentTenants } from "./ParentTenantSelector.helpers";
const tenants: TenantSummary[] = [
{
id: "company-1",
type: "COMPANY",
name: "Saman Engineering",
slug: "saman",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "group-1",
type: "COMPANY_GROUP",
name: "Hanmac Family",
slug: "hanmac-family",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "org-1",
type: "ORGANIZATION",
name: "기획부",
slug: "planning",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
describe("filterParentTenants", () => {
it("searches parent candidates by name and slug", () => {
expect(
filterParentTenants(tenants, "saman", false).map((t) => t.id),
).toEqual(["company-1"]);
expect(
filterParentTenants(tenants, "family", false).map((t) => t.id),
).toEqual(["group-1"]);
});
it("can limit parent candidates to company and company group tenants", () => {
expect(filterParentTenants(tenants, "", true).map((t) => t.id)).toEqual([
"company-1",
"group-1",
]);
});
});

View File

@@ -0,0 +1,257 @@
import { Building2, X } from "lucide-react";
import type { ReactNode } from "react";
import { useEffect, useState } from "react";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Label } from "../../../components/ui/label";
import type { TenantSummary } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
parseOrgChartTenantSelection,
} from "../../users/orgChartPicker";
import { filterParentTenants } from "./ParentTenantSelector.helpers";
type ParentTenantSelectorProps = {
id: string;
label: string;
value: string;
onChange: (value: string) => void;
tenants: TenantSummary[];
noneLabel: string;
helpText?: string;
excludeTenantId?: string;
labelAction?: ReactNode;
contextLabel?: string;
orgChartPickerLabel?: string;
localPickerLabel?: string;
localTenantFilter?: (tenant: TenantSummary) => boolean;
compact?: boolean;
controlTestId?: string;
};
export function ParentTenantSelector({
id,
label,
value,
onChange,
tenants,
noneLabel,
helpText,
excludeTenantId,
labelAction,
contextLabel,
orgChartPickerLabel,
localPickerLabel,
localTenantFilter,
compact = false,
controlTestId,
}: ParentTenantSelectorProps) {
const [pickerOpen, setPickerOpen] = useState(false);
const [localPickerOpen, setLocalPickerOpen] = useState(false);
const [localSearch, setLocalSearch] = useState("");
const selectedTenant = tenants.find((tenant) => tenant.id === value);
const localCandidates = filterParentTenants(
localTenantFilter ? tenants.filter(localTenantFilter) : tenants,
localSearch,
false,
excludeTenantId,
);
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL,
);
useEffect(() => {
if (!pickerOpen) return;
const onMessage = (event: MessageEvent) => {
const selection = parseOrgChartTenantSelection(event.data);
if (!selection) return;
if (excludeTenantId && selection.id === excludeTenantId) return;
onChange(selection.id);
setPickerOpen(false);
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [excludeTenantId, onChange, pickerOpen]);
return (
<div className={compact ? "space-y-1" : "space-y-2"}>
<div
className={
compact
? "flex min-h-5 flex-wrap items-center justify-between gap-2"
: "flex min-h-8 flex-wrap items-center justify-between gap-2"
}
>
<Label className="text-sm font-semibold">{label}</Label>
{labelAction}
</div>
<input id={id} name={id} type="hidden" value={value} readOnly />
<div
data-testid={controlTestId}
className={
compact
? "flex h-10 min-w-0 items-center gap-2 rounded-lg border border-input bg-background px-2"
: "flex min-h-10 flex-wrap items-center gap-2 rounded-md border border-input bg-background px-3 py-2"
}
>
<Dialog open={pickerOpen} onOpenChange={setPickerOpen}>
<DialogTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className={compact ? "h-8 shrink-0 px-2" : undefined}
>
<Building2 className="h-4 w-4" />
{orgChartPickerLabel ??
(compact ? undefined : selectedTenant?.name) ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.picker_description",
"org-chart에서 테넌트를 선택하면 상위 테넌트에 반영됩니다.",
)}
</DialogDescription>
</DialogHeader>
<iframe
title={t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
src={pickerUrl}
className="h-[600px] w-full rounded-md border"
/>
</DialogContent>
</Dialog>
{localPickerLabel && (
<Dialog open={localPickerOpen} onOpenChange={setLocalPickerOpen}>
<DialogTrigger asChild>
<Button type="button" variant="outline" size="sm">
<Building2 className="h-4 w-4" />
{localPickerLabel}
</Button>
</DialogTrigger>
<DialogContent className="max-w-[460px] p-4">
<DialogHeader>
<DialogTitle>
{localPickerLabel ??
t("ui.admin.tenants.parent.pick_tenant", "테넌트 선택")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.tenants.parent.local_picker_description",
"테넌트 목록에서 상위 테넌트로 사용할 항목을 선택합니다.",
)}
</DialogDescription>
</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)}
placeholder={t(
"ui.admin.tenants.parent.local_search_placeholder",
"테넌트 이름 또는 슬러그 검색",
)}
/>
<div className="max-h-[360px] space-y-2 overflow-y-auto">
{localCandidates.map((tenant) => (
<Button
key={tenant.id}
type="button"
variant="outline"
className="h-auto w-full justify-start px-3 py-2 text-left"
onClick={() => {
onChange(tenant.id);
setLocalPickerOpen(false);
setLocalSearch("");
}}
>
<span>
<span className="block text-sm font-medium">
{tenant.name}
</span>
<span className="block text-xs text-muted-foreground">
{tenant.slug} · {tenant.type}
</span>
</span>
</Button>
))}
{localCandidates.length === 0 && (
<p className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t(
"msg.admin.tenants.parent.local_picker_empty",
"선택할 수 있는 테넌트가 없습니다.",
)}
</p>
)}
</div>
</div>
</DialogContent>
</Dialog>
)}
{selectedTenant ? (
<>
<span
className={
compact
? "min-w-0 flex-1 truncate text-xs text-muted-foreground"
: "text-xs text-muted-foreground"
}
title={`${selectedTenant.name} · ${selectedTenant.slug} · ${selectedTenant.type}`}
>
{compact
? `${selectedTenant.name} · ${selectedTenant.slug}`
: `${selectedTenant.slug} · ${selectedTenant.type}`}
</span>
<Button
type="button"
variant="ghost"
size="icon"
className={compact ? "h-7 w-7 shrink-0" : "h-8 w-8"}
onClick={() => onChange("")}
aria-label={noneLabel}
>
<X className="h-4 w-4" />
</Button>
</>
) : (
<span
className={
compact
? "min-w-0 flex-1 truncate text-xs text-muted-foreground"
: "text-xs text-muted-foreground"
}
>
{noneLabel}
</span>
)}
{contextLabel && (
<span className="rounded-md border px-2 py-1 text-xs font-medium text-muted-foreground">
{contextLabel}
</span>
)}
</div>
{helpText && (
<p className="mt-1 text-xs text-muted-foreground">{helpText}</p>
)}
</div>
);
}

View File

@@ -0,0 +1,673 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Crown,
Plus,
Search,
ShieldCheck,
UserPlus,
Users,
} from "lucide-react";
import { useState } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate, useParams } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
addTenantAdmin,
addTenantOwner,
fetchTenantAdmins,
fetchTenantOwners,
fetchUsers,
removeTenantAdmin,
removeTenantOwner,
type TenantAdmin,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type DialogMode = "owner" | "admin";
function mergePendingMembers(
members: TenantAdmin[],
pendingMembers: TenantAdmin[],
) {
const existingIds = new Set(members.map((member) => member.id));
return [
...members,
...pendingMembers.filter((member) => !existingIds.has(member.id)),
];
}
export function TenantAdminsAndOwnersTab() {
const auth = useAuth();
const navigate = useNavigate();
const _currentUserId = auth.user?.profile.sub;
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdParam ?? "";
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
const [pendingOwners, setPendingOwners] = useState<TenantAdmin[]>([]);
const [pendingAdmins, setPendingAdmins] = useState<TenantAdmin[]>([]);
const ownersQuery = useQuery({
queryKey: ["tenant-owners", tenantId],
queryFn: () => fetchTenantOwners(tenantId),
enabled: !!tenantId,
});
const adminsQuery = useQuery({
queryKey: ["tenant-admins", tenantId],
queryFn: () => fetchTenantAdmins(tenantId),
enabled: !!tenantId,
});
const usersQuery = useQuery({
queryKey: ["admin-users-search", searchTerm],
queryFn: () => fetchUsers(20, 0, searchTerm),
enabled: dialogMode !== null && searchTerm.length >= 2,
});
const addOwnerMutation = useMutation({
mutationFn: (userId: string) => addTenantOwner(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-owners", tenantId],
});
const previousOwners = queryClient.getQueryData<TenantAdmin[]>([
"tenant-owners",
tenantId,
]);
// Optimistically add to the list to prevent immediate double clicks
const addedUser = searchResults.find((u) => u.id === userId);
if (addedUser) {
const optimisticOwner = {
id: userId,
name: addedUser.name,
email: addedUser.email,
};
setPendingOwners((old) =>
old.some((owner) => owner.id === userId)
? old
: [...old, optimisticOwner],
);
queryClient.setQueryData<TenantAdmin[]>(
["tenant-owners", tenantId],
(old) => {
if (!old) return [optimisticOwner];
if (old.some((o) => o.id === userId)) return old;
return [...old, optimisticOwner];
},
);
}
return { previousOwners };
},
onSuccess: () => {
// Delay invalidation slightly to give the backend outbox time to process
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-owners", tenantId],
});
}, 1000);
toast.success(
t("msg.admin.tenants.owners.add_success", "소유자가 추가되었습니다."),
);
setSearchTerm("");
},
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
if (context?.previousOwners) {
queryClient.setQueryData(
["tenant-owners", tenantId],
context.previousOwners,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
const removeOwnerMutation = useMutation({
mutationFn: (userId: string) => removeTenantOwner(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-owners", tenantId],
});
const previousOwners = queryClient.getQueryData<TenantAdmin[]>([
"tenant-owners",
tenantId,
]);
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
queryClient.setQueryData<TenantAdmin[]>(
["tenant-owners", tenantId],
(old) => (old ? old.filter((o) => o.id !== userId) : []),
);
return { previousOwners };
},
onSuccess: () => {
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-owners", tenantId],
});
}, 1000);
toast.success(
t(
"msg.admin.tenants.owners.remove_success",
"소유자 권한이 회수되었습니다.",
),
);
},
onError: (err: AxiosError<{ error?: string }>, _userId, context) => {
if (context?.previousOwners) {
queryClient.setQueryData(
["tenant-owners", tenantId],
context.previousOwners,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
const addAdminMutation = useMutation({
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-admins", tenantId],
});
const previousAdmins = queryClient.getQueryData<TenantAdmin[]>([
"tenant-admins",
tenantId,
]);
const addedUser = searchResults.find((u) => u.id === userId);
if (addedUser) {
const optimisticAdmin = {
id: userId,
name: addedUser.name,
email: addedUser.email,
};
setPendingAdmins((old) =>
old.some((admin) => admin.id === userId)
? old
: [...old, optimisticAdmin],
);
queryClient.setQueryData<TenantAdmin[]>(
["tenant-admins", tenantId],
(old) => {
if (!old) return [optimisticAdmin];
if (old.some((a) => a.id === userId)) return old;
return [...old, optimisticAdmin];
},
);
}
return { previousAdmins };
},
onSuccess: () => {
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-admins", tenantId],
});
}, 1000);
toast.success(
t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."),
);
setSearchTerm("");
},
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
if (context?.previousAdmins) {
queryClient.setQueryData(
["tenant-admins", tenantId],
context.previousAdmins,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
const removeAdminMutation = useMutation({
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-admins", tenantId],
});
const previousAdmins = queryClient.getQueryData<TenantAdmin[]>([
"tenant-admins",
tenantId,
]);
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
queryClient.setQueryData<TenantAdmin[]>(
["tenant-admins", tenantId],
(old) => (old ? old.filter((a) => a.id !== userId) : []),
);
return { previousAdmins };
},
onSuccess: () => {
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-admins", tenantId],
});
}, 1000);
toast.success(
t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."),
);
},
onError: (err: AxiosError<{ error?: string }>, _userId, context) => {
if (context?.previousAdmins) {
queryClient.setQueryData(
["tenant-admins", tenantId],
context.previousAdmins,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
const handleAddUser = (userId: string) => {
if (dialogMode === "owner") {
addOwnerMutation.mutate(userId);
} else if (dialogMode === "admin") {
addAdminMutation.mutate(userId);
}
};
const _handleRemoveOwner = (userId: string, userName: string) => {
if (
window.confirm(
t(
"msg.admin.tenants.owners.remove_confirm",
"소유자를 삭제하시겠습니까?",
{ name: userName },
),
)
) {
removeOwnerMutation.mutate(userId);
}
};
const _handleRemoveAdmin = (userId: string, userName: string) => {
if (
window.confirm(
t(
"msg.admin.tenants.admins.remove_confirm",
"관리자를 삭제하시겠습니까?",
{ name: userName },
),
)
) {
removeAdminMutation.mutate(userId);
}
};
if (!tenantId) return null;
const serverOwners = ownersQuery.data || [];
const serverAdmins = adminsQuery.data || [];
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
const currentAdmins = mergePendingMembers(serverAdmins, pendingAdmins);
const searchResults = usersQuery.data?.items || [];
const isDialogOpen = dialogMode !== null;
const dialogTitle =
dialogMode === "owner"
? t("ui.admin.tenants.owners.dialog_title", "새 소유자 추가")
: t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가");
const dialogDescription =
dialogMode === "owner"
? t(
"ui.admin.tenants.owners.dialog_description",
"이름 또는 이메일로 사용자를 검색하세요.",
)
: t(
"ui.admin.tenants.admins.dialog_description",
"이름 또는 이메일로 사용자를 검색하세요.",
);
return (
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
{/* Owners Card */}
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<Crown className="h-6 w-6 text-yellow-500" />
{t("ui.admin.tenants.owners.title", "테넌트 소유자")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.owners.subtitle",
"이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.",
)}
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("owner")}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
</Button>
</CardHeader>
<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">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.owners.table_name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.owners.table_email", "이메일")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ownersQuery.isLoading ? (
<TableRow>
<TableCell colSpan={2} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : currentOwners.length === 0 ? (
<TableRow>
<TableCell
colSpan={2}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.admin.tenants.owners.empty",
"등록된 소유자가 없습니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
currentOwners.map((owner) => (
<TableRow
key={owner.id}
className="hover:bg-muted/30 transition-colors group cursor-pointer"
onClick={() => navigate(`/users/${owner.id}`)}
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
{owner.name.charAt(0)}
</div>
<span>{owner.name}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground italic">
{owner.email}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
{/* Admins Card */}
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck className="h-6 w-6 text-primary" />
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.admins.subtitle",
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
)}
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("admin")}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
</Button>
</CardHeader>
<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">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.admins.table_name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.admins.table_email", "이메일")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminsQuery.isLoading ? (
<TableRow>
<TableCell colSpan={2} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : currentAdmins.length === 0 ? (
<TableRow>
<TableCell
colSpan={2}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.admin.tenants.admins.empty",
"등록된 관리자가 없습니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
currentAdmins.map((admin) => (
<TableRow
key={admin.id}
className="hover:bg-muted/30 transition-colors group cursor-pointer"
onClick={() => navigate(`/users/${admin.id}`)}
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
{admin.name.charAt(0)}
</div>
<span>{admin.name}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground italic">
{admin.email}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Common Dialog for adding users */}
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
if (!open) {
setDialogMode(null);
setSearchTerm("");
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{dialogTitle}
</DialogTitle>
<DialogDescription>{dialogDescription}</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.admins.dialog_search_placeholder",
"사용자 검색 (최소 2자)...",
)}
className="pl-10 h-11"
autoFocus
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
{searchTerm.length < 2 ? (
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
<Search className="h-8 w-8 opacity-20" />
<p className="text-sm">
{t(
"ui.admin.tenants.admins.dialog_search_hint",
"검색어를 입력해 주세요.",
)}
</p>
</div>
) : usersQuery.isLoading ? (
<div className="p-10 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</div>
) : searchResults.length === 0 ? (
<div className="p-10 text-center text-muted-foreground">
{t(
"ui.admin.tenants.admins.dialog_no_results",
"검색 결과가 없습니다.",
)}
</div>
) : (
<div className="divide-y divide-border">
{searchResults.map((user) => {
const isAlreadyOwner = currentOwners.some(
(o) => o.id === user.id,
);
const isAlreadyAdmin = currentAdmins.some(
(a) => a.id === user.id,
);
const isAlreadyMember =
dialogMode === "owner" ? isAlreadyOwner : isAlreadyAdmin;
return (
<div
key={user.id}
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{user.name.charAt(0)}
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.name}
</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
<Button
size="sm"
variant={isAlreadyMember ? "ghost" : "outline"}
disabled={
isAlreadyMember ||
addOwnerMutation.isPending ||
addAdminMutation.isPending
}
onClick={() => handleAddUser(user.id)}
>
{isAlreadyMember ? (
<Badge variant="secondary" className="font-normal">
{dialogMode === "owner"
? t(
"ui.admin.tenants.owners.already_owner",
"이미 소유자",
)
: t(
"ui.admin.tenants.admins.already_admin",
"이미 관리자",
)}
</Badge>
) : (
<>
<Plus className="h-3 w-3 mr-1" />{" "}
{t("ui.common.add", "추가")}
</>
)}
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
export default TenantAdminsAndOwnersTab;

View File

@@ -0,0 +1,515 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Building2, Sparkles } from "lucide-react";
import { useCallback, useMemo, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Checkbox } from "../../../components/ui/checkbox";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { createTenant, fetchAllTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector";
import {
formatDomainConflictMessage,
type ServerDomainConflict,
} from "../utils/domainTags";
import {
mergeTenantOrgConfig,
ORG_UNIT_TYPE_OPTIONS,
shouldAllowHanmacOrgConfig,
TENANT_VISIBILITY_OPTIONS,
type TenantVisibility,
} from "../utils/orgConfig";
type AdminFrontTestHooks = {
selectTenantParent?: (tenantId: string) => Promise<void>;
};
function TenantCreatePage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
const [parentId, setParentId] = useState(
() => searchParams.get("parentId") ?? "",
);
const [parentStepConfirmed, setParentStepConfirmed] = useState(false);
const [orgUnitType, setOrgUnitType] = useState("");
const [visibility, setVisibility] = useState<TenantVisibility>("public");
const [worksmobileExcluded, setWorksmobileExcluded] = useState(false);
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState<string[]>([]);
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
[],
);
const parentQuery = useQuery({
queryKey: ["tenants", "all"],
queryFn: () => fetchAllTenants(),
});
const tenants = parentQuery.data?.items ?? [];
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
const canConfigureHanmacOrg = useMemo(() => {
if (!selectedParentTenant) return false;
if (selectedParentTenant.slug.toLowerCase() === "hanmac-family") {
return true;
}
return shouldAllowHanmacOrgConfig(selectedParentTenant, tenants);
}, [selectedParentTenant, tenants]);
const canEditTenantDetails =
parentStepConfirmed || Boolean(selectedParentTenant);
const parentContextLabel = selectedParentTenant
? canConfigureHanmacOrg
? t(
"ui.admin.tenants.create.parent_context.hanmac",
"한맥가족 하위 테넌트",
)
: t("ui.admin.tenants.create.parent_context.general", "일반 하위 테넌트")
: parentStepConfirmed
? t("ui.admin.tenants.create.parent_context.root", "최상위 테넌트")
: t(
"ui.admin.tenants.create.parent_context.pick_required",
"상위 테넌트 선택 필요",
);
const handleParentChange = useCallback((nextParentId: string) => {
setParentId(nextParentId);
setParentStepConfirmed(false);
}, []);
if (typeof window !== "undefined") {
const testWindow = window as Window &
typeof globalThis & {
__adminfrontTestHooks?: AdminFrontTestHooks;
};
const hooks = testWindow.__adminfrontTestHooks ?? {};
hooks.selectTenantParent = async (tenantId: string) => {
handleParentChange(tenantId);
};
testWindow.__adminfrontTestHooks = hooks;
}
const mutation = useMutation({
mutationFn: (overrideForceDomains?: string[]) =>
createTenant({
name,
type,
slug: slug || undefined,
parentId: parentId || undefined,
description: description || undefined,
status,
domains,
config: canConfigureHanmacOrg
? mergeTenantOrgConfig(undefined, {
orgUnitType,
visibility,
worksmobileExcluded,
})
: undefined,
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
}),
onSuccess: () => {
navigate("/tenants");
},
onError: (
err: AxiosError<{
code?: string;
error?: string;
conflicts?: ServerDomainConflict[];
}>,
) => {
const conflicts = err.response?.data?.conflicts ?? [];
if (
err.response?.data?.code === "tenant_domain_conflict" &&
conflicts.length > 0
) {
const nextForceDomains = Array.from(
new Set([...forceDomainConflicts, ...conflicts.map((c) => c.domain)]),
);
const message = conflicts.map(formatDomainConflictMessage).join("\n");
if (window.confirm(message)) {
setForceDomainConflicts(nextForceDomains);
mutation.mutate(nextForceDomains);
}
}
},
});
const errorMsg = (mutation.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
return (
<div className="space-y-8">
<header className="space-y-2">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="space-y-2">
<h2 className="text-3xl font-semibold">
{t("ui.admin.tenants.create.title", "테넌트 생성")}
</h2>
<p className="text-sm text-[var(--color-muted)]">
{t(
"msg.admin.tenants.create.subtitle",
"새로운 테넌트를 시스템에 등록합니다.",
)}
</p>
</div>
</div>
</header>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} />
{t("ui.admin.tenants.create.profile.title", "Tenant Profile")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.tenants.create.profile.subtitle",
"필수 정보만 입력해도 생성 가능합니다. Slug는 없으면 자동 생성됩니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div
data-testid="tenant-parent-org-config-layout"
className="grid gap-4 md:grid-cols-4"
>
<div
data-testid="tenant-parent-picker-slot"
className={
canConfigureHanmacOrg ? "md:col-span-2" : "md:col-span-4"
}
>
<ParentTenantSelector
id="parentId"
label={t(
"ui.admin.tenants.create.form.parent",
"상위 테넌트 (선택)",
)}
value={parentId}
onChange={handleParentChange}
tenants={tenants}
noneLabel={t("ui.common.none", "없음")}
contextLabel={parentContextLabel}
orgChartPickerLabel={t(
"ui.admin.tenants.create.form.pick_hanmac_parent",
"한맥가족에서 선택",
)}
localPickerLabel={t(
"ui.admin.tenants.create.form.pick_other_parent",
"다른 테넌트 선택",
)}
localTenantFilter={(tenant) =>
tenant.slug.toLowerCase() !== "hanmac-family" &&
!shouldAllowHanmacOrgConfig(tenant, tenants)
}
labelAction={
!selectedParentTenant ? (
<Button
type="button"
variant={parentStepConfirmed ? "default" : "outline"}
size="sm"
onClick={() => setParentStepConfirmed(true)}
>
{t(
"ui.admin.tenants.create.form.root_tenant",
"최상위 테넌트로 생성",
)}
</Button>
) : null
}
/>
<button
type="button"
data-testid="tenant-test-select-hanmac-parent"
hidden
onClick={() => handleParentChange("family-1")}
>
test-select-hanmac-parent
</button>
</div>
{canConfigureHanmacOrg && (
<>
<div
data-testid="tenant-org-unit-type-slot"
className="space-y-2"
>
<Label
htmlFor="tenant-org-unit-type"
className="text-sm font-semibold"
>
{t(
"ui.admin.tenants.profile.org_unit_type",
"조직 세부타입",
)}
</Label>
<select
id="tenant-org-unit-type"
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}
onChange={(event) => setOrgUnitType(event.target.value)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{ORG_UNIT_TYPE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div data-testid="tenant-visibility-slot" className="space-y-2">
<Label
htmlFor="tenant-visibility"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.profile.visibility", "공개 범위")}
</Label>
<select
id="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={visibility}
onChange={(event) =>
setVisibility(event.target.value as TenantVisibility)
}
>
{TENANT_VISIBILITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div
data-testid="tenant-worksmobile-excluded-slot"
className="flex min-h-9 items-center gap-2 rounded-md border border-input px-3 py-2"
>
<Checkbox
id="worksmobileExcluded"
checked={worksmobileExcluded}
onCheckedChange={(checked) =>
setWorksmobileExcluded(checked === true)
}
/>
<Label
htmlFor="worksmobileExcluded"
className="cursor-pointer text-sm font-semibold"
>
{t(
"ui.admin.tenants.profile.worksmobile_excluded",
"WORKS 연동 제외",
)}
</Label>
</div>
</>
)}
</div>
{canEditTenantDetails && (
<>
<div className="space-y-2">
<Label htmlFor="tenant-name" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input
id="tenant-name"
name="name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.name_placeholder",
"테넌트 이름을 입력하세요",
)}
/>
</div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label
htmlFor="tenant-type"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
</Label>
<select
id="tenant-type"
name="type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="tenant-slug" className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
</Label>
<Input
id="tenant-slug"
name="slug"
value={slug}
onChange={(e) => setSlug(e.target.value)}
placeholder={t(
"ui.admin.tenants.create.form.slug_placeholder",
"tenant-slug",
)}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="tenant-description"
className="text-sm font-semibold"
>
{t("ui.admin.tenants.create.form.description", "설명")}
</Label>
<Textarea
id="tenant-description"
name="description"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label
htmlFor="tenant-domains"
className="text-sm font-semibold"
>
{t(
"ui.admin.tenants.create.form.domains_label",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<DomainTagInput
id="tenant-domains"
value={domains}
onChange={setDomains}
tenants={tenants}
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder={t(
"ui.admin.tenants.create.form.domains_placeholder",
"example.com, example.kr",
)}
/>
<p className="text-xs text-muted-foreground">
{t(
"msg.admin.tenants.create.form.domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.status", "상태")}
</Label>
<div className="flex gap-3">
<Button
type="button"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>
</>
)}
{!canEditTenantDetails && (
<div className="rounded-md border border-dashed px-3 py-4 text-sm text-muted-foreground">
{t(
"msg.admin.tenants.create.pick_parent_first",
"상위 테넌트를 먼저 선택하세요.",
)}
</div>
)}
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg}
</div>
)}
</CardContent>
</Card>
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Sparkles size={18} />
{t("ui.admin.tenants.create.memo.title", "정책 메모")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.tenants.create.memo.subtitle",
"Tenant 권한 정책은 추후 Keto 연계로 확장 예정입니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="text-sm text-[var(--color-muted)]">
{t(
"msg.admin.tenants.create.memo.body",
"생성 직후에는 기본 활성 상태로 부여되며, 필요 시 상태를 수정하세요.",
)}
</CardContent>
</Card>
<div className="flex items-center justify-end gap-3">
<Button variant="outline" onClick={() => navigate("/tenants")}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => mutation.mutate(undefined)}
disabled={mutation.isPending || name.trim() === ""}
>
{t("ui.common.create", "생성")}
</Button>
</div>
</div>
);
}
export default TenantCreatePage;

View File

@@ -0,0 +1,135 @@
import { useQuery } from "@tanstack/react-query";
import { Copy } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import { fetchMe, fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
function TenantDetailPage() {
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const location = useLocation();
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const { data: profile } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const canAccessSchema = profileRole === "super_admin";
const isPermissionsTab = location.pathname.includes("/permissions");
const isOrganizationTab = location.pathname.includes("/organization");
const isWorksmobileTab = location.pathname.includes("/worksmobile");
return (
<div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2">
<div
className="flex flex-wrap items-center gap-3"
data-testid="tenant-detail-title-row"
>
<h2 className="text-3xl font-semibold">
{tenantQuery.data?.name ??
t("ui.admin.tenants.detail.loading", "불러오는 중...")}
</h2>
{tenantQuery.data?.id && (
<div
className="flex items-center gap-1.5"
data-testid="tenant-detail-uuid"
>
<code className="select-all rounded-md border border-border bg-muted/40 px-2 py-1 font-mono text-xs text-foreground">
{tenantQuery.data.id}
</code>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => {
void navigator.clipboard?.writeText(tenantQuery.data.id);
}}
aria-label="테넌트 UUID 복사"
title="테넌트 UUID 복사"
data-testid="tenant-detail-copy-uuid"
>
<Copy className="h-4 w-4" />
</Button>
</div>
)}
</div>
<p className="text-sm text-[var(--color-muted)]">
{t(
"ui.admin.tenants.detail.header_subtitle",
"테넌트 정보를 수정하거나 연동 설정을 관리합니다.",
)}
</p>
</div>
</header>
{/* Tabs */}
<div className="flex border-b border-border">
<Link
to={`/tenants/${tenantId}`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
!isPermissionsTab &&
!location.pathname.includes("/schema") &&
!isWorksmobileTab &&
!isOrganizationTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_profile", "프로필")}
</Link>
<Link
to={`/tenants/${tenantId}/permissions`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isPermissionsTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_permissions", "권한")}
</Link>
<Link
to={`/tenants/${tenantId}/organization`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isOrganizationTab
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
</Link>
{canAccessSchema && (
<Link
to={`/tenants/${tenantId}/schema`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
location.pathname.includes("/schema")
? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`}
>
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</Link>
)}
</div>
{/* Outlet for nested routes */}
<div className="animate-in fade-in duration-500">
<Outlet />
</div>
</div>
);
}
export default TenantDetailPage;

View File

@@ -0,0 +1,51 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import TenantDetailPage from "./TenantDetailPage";
vi.mock("../../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({ role: "super_admin" })),
fetchTenant: vi.fn(async () => ({
id: "hanmac-family-id",
name: "한맥 가족",
slug: "hanmac-family",
parentId: null,
})),
}));
function renderTenantDetailPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/tenants/hanmac-family-id"]}>
<Routes>
<Route path="/tenants/:tenantId/*" element={<TenantDetailPage />}>
<Route index element={<div>profile</div>} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("TenantDetailPage Worksmobile navigation", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("does not render Worksmobile as a tenant detail tab", async () => {
renderTenantDetailPage();
await screen.findByText("프로필");
expect(screen.queryByRole("link", { name: /Worksmobile/i })).toBeNull();
});
});

View File

@@ -0,0 +1,903 @@
import {
type UseMutationResult,
useMutation,
useQuery,
useQueryClient,
} from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowRightLeft,
ChevronDown,
ChevronRight,
Plus,
RefreshCw,
Search,
Shield,
Trash2,
UserMinus,
UserPlus,
Users,
} from "lucide-react";
import type React from "react";
import { useState } from "react";
import { useParams } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
addGroupMember,
createGroup,
deleteGroup,
fetchGroups,
fetchTenant,
fetchUsers,
type GroupSummary,
removeGroupMember,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type UserGroupNode = GroupSummary & {
children: UserGroupNode[];
isExpanded?: boolean;
};
function buildGroupTree(
groups: GroupSummary[],
parentId: string | null = null,
): UserGroupNode[] {
const nodes: UserGroupNode[] = [];
const childrenOf = new Map<string, UserGroupNode[]>();
// First pass: Initialize all groups as nodes and populate childrenOf map
for (const group of groups) {
childrenOf.set(group.id, []);
}
// Second pass: Populate children
for (const group of groups) {
const node: UserGroupNode = {
...group,
children: childrenOf.get(group.id) ?? [],
};
if (group.parentId === parentId) {
nodes.push(node);
} else {
// Check if the parent exists before adding to children
// This handles cases where a parent might not be in the current 'groups' list (e.g., filtered data)
if (group.parentId && childrenOf.has(group.parentId)) {
childrenOf.get(group.parentId)?.push(node);
} else {
// If parentId exists but parent not found, it's a root level group for this tree view
nodes.push(node);
}
}
}
// Sort children for consistent rendering (optional, but good for UI)
nodes.sort((a, b) => a.name.localeCompare(b.name));
for (const node of nodes) {
node.children.sort((a, b) => a.name.localeCompare(b.name));
}
return nodes;
}
interface UserGroupTreeNodeProps {
node: UserGroupNode;
level: number;
onSelect: (groupId: string) => void;
selectedGroupId: string | null;
onDelete: (groupId: string) => void;
onAddSubGroup: (parentId: string) => void;
addMemberMutation: UseMutationResult<
void,
AxiosError<{ error?: string }>,
{ groupId: string; userId: string }
>;
removeMemberMutation: UseMutationResult<
void,
AxiosError<{ error?: string }>,
{ groupId: string; userId: string }
>;
}
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
node,
level,
onSelect,
selectedGroupId,
onDelete,
onAddSubGroup,
addMemberMutation,
removeMemberMutation,
}) => {
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children.length > 0;
const handleToggleExpand = (e: React.MouseEvent) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
};
return (
<>
<TableRow
className={`cursor-pointer ${selectedGroupId === node.id ? "bg-primary/5" : ""}`}
onClick={() => onSelect(node.id)}
>
<TableCell style={{ paddingLeft: `${1 + level * 1.5}rem` }}>
<div className="flex items-center gap-2">
{hasChildren ? (
<Button
variant="ghost"
size="sm"
onClick={handleToggleExpand}
className="h-6 w-6 p-0"
>
{isExpanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</Button>
) : (
level > 0 && (
<span className="inline-block w-6 text-center">
<ChevronRight
size={16}
className="text-muted-foreground inline-block align-middle"
/>
</span>
)
)}
<Users size={14} className="text-muted-foreground" />
<span className="font-semibold">{node.name}</span>
<Badge variant="secondary" className="text-[10px] font-mono">
{node.unitType || "Team"}
</Badge>
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">
{t("msg.admin.groups.members.count", "{{count}} 명", {
count: node.members?.length || 0,
})}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAddSubGroup(node.id);
}}
>
<Plus size={14} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onDelete(node.id);
}}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
{isExpanded &&
hasChildren &&
node.children.map((child) => (
<UserGroupTreeNode
key={child.id}
node={child}
level={level + 1}
onSelect={onSelect}
selectedGroupId={selectedGroupId}
onDelete={onDelete}
onAddSubGroup={onAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
/>
))}
</>
);
};
function TenantGroupsPage() {
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const _queryClient = useQueryClient();
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState("");
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
const [newGroupParentId, setNewGroupParentId] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
// Modal States
const [isAddMemberModalOpen, setIsAddMemberModalOpen] = useState(false);
const [isMoveMemberModalOpen, setIsMoveMemberModalOpen] = useState(false);
const [memberActionTargetUserId, setMemberActionTargetUserId] = useState<
string | null
>(null);
const [userSearchTerm, setUserSearchTerm] = useState("");
const [groupSearchTerm, setGroupSearchTerm] = useState("");
// 테넌트 정보 조회 (slug 획득)
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const tenantSlug = tenantQuery.data?.slug;
// 해당 테넌트의 사용자 목록 조회
const usersQuery = useQuery({
queryKey: ["users", { tenantSlug }],
queryFn: () => fetchUsers(1000, 0, undefined, tenantSlug),
enabled: !!tenantSlug,
});
const users = usersQuery.data?.items ?? [];
// 그룹 목록 조회
const groupsQuery = useQuery({
queryKey: ["groups", tenantId],
queryFn: () => fetchGroups(tenantId),
enabled: tenantId.length > 0,
});
// 그룹 생성
const createMutation = useMutation({
mutationFn: () =>
createGroup(tenantId, {
name: newGroupName,
description: newGroupDesc,
unitType: newGroupUnitType,
parentId: newGroupParentId || undefined,
}),
onSuccess: () => {
toast.success(
t(
"msg.admin.groups.list.create_success",
"그룹이 성공적으로 생성되었습니다.",
),
);
groupsQuery.refetch();
setNewGroupName("");
setNewGroupNameDesc("");
setNewGroupUnitType("Team");
setNewGroupParentId(null);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.admin.groups.list.create_error", "그룹 생성 실패"), {
description: error.response?.data?.error || error.message,
});
},
});
// 그룹 삭제
const deleteMutation = useMutation({
mutationFn: (id: string) => deleteGroup(tenantId, id),
onSuccess: () => {
toast.success(
t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."),
);
groupsQuery.refetch();
setSelectedGroupId(null);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.admin.groups.list.delete_error", "그룹 삭제 실패"), {
description: error.response?.data?.error || error.message,
});
},
});
// 멤버 추가
const addMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
addGroupMember(tenantId, groupId, userId),
onSuccess: () => {
toast.success(
t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."),
);
groupsQuery.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
});
// 멤버 제거
const removeMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
removeGroupMember(tenantId, groupId, userId),
onSuccess: () => {
toast.success(
t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."),
);
groupsQuery.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
});
// 멤버 이동 (Remove -> Add)
const moveMemberMutation = useMutation({
mutationFn: async ({
sourceGroupId,
targetGroupId,
userId,
}: {
sourceGroupId: string;
targetGroupId: string;
userId: string;
}) => {
await removeGroupMember(tenantId, sourceGroupId, userId);
await addGroupMember(tenantId, targetGroupId, userId);
},
onSuccess: () => {
toast.success(
t("msg.admin.groups.members.move_success", "멤버가 이동되었습니다."),
);
groupsQuery.refetch();
setIsMoveMemberModalOpen(false);
setMemberActionTargetUserId(null);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
});
const groupTree = groupsQuery.data
? buildGroupTree(groupsQuery.data, tenantId)
: [];
const handleAddSubGroup = (parentId: string) => {
setNewGroupParentId(parentId);
};
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
return (
<>
<div className="space-y-6 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<div className="grid gap-6 md:grid-cols-3 flex-1 min-h-0">
{/* 그룹 생성 폼 */}
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<CardHeader className="flex-shrink-0">
<CardTitle className="text-sm flex items-center gap-2">
<Plus size={16} />{" "}
{t("ui.admin.groups.create.title", "새 그룹 생성")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.groups.create.description",
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4 flex-1 overflow-auto">
<div className="space-y-1">
<Label htmlFor="name">
{t("ui.admin.groups.form.name_label", "그룹 이름")}
</Label>
<Input
id="name"
value={newGroupName}
onChange={(e) => setNewGroupName(e.target.value)}
placeholder={t(
"ui.admin.groups.form.name_placeholder",
"예: 개발팀, 인사팀",
)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="unitType">
{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
</Label>
<Input
id="unitType"
value={newGroupUnitType}
onChange={(e) => setNewGroupUnitType(e.target.value)}
placeholder={t(
"ui.admin.groups.form.unit_level_placeholder",
"예: 본부, 팀, 셀",
)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="parentId">
{t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}
</Label>
<select
id="parentId"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={newGroupParentId || ""}
onChange={(e) => setNewGroupParentId(e.target.value || null)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{groupsQuery.data?.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label htmlFor="desc">
{t("ui.admin.groups.form.desc_label", "설명")}
</Label>
<Input
id="desc"
value={newGroupDesc}
onChange={(e) => setNewGroupNameDesc(e.target.value)}
placeholder={t(
"ui.admin.groups.form.desc_placeholder",
"그룹 용도 설명",
)}
/>
</div>
<Button
className="w-full"
onClick={() => createMutation.mutate()}
disabled={!newGroupName || createMutation.isPending}
>
{t("ui.admin.groups.form.submit", "생성하기")}
</Button>
</CardContent>
</Card>
{/* 그룹 목록 (트리 뷰) */}
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.groups.list.title", "User Groups")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.groups.list.subtitle",
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
)}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => groupsQuery.refetch()}
>
<RefreshCw size={14} />
</Button>
</div>
</CardHeader>
<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">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead>
{t("ui.admin.groups.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.groups.table.members", "MEMBERS")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.table.actions", "ACTIONS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{groupsQuery.isLoading && (
<TableRow>
<TableCell colSpan={3}>
{t("msg.admin.groups.list.loading", "로딩 중...")}
</TableCell>
</TableRow>
)}
{!groupsQuery.isLoading && groupTree.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.list.empty",
"아직 등록된 그룹이 없습니다.",
)}
</TableCell>
</TableRow>
)}
{groupTree.map((node) => (
<UserGroupTreeNode
key={node.id}
node={node}
level={0}
onSelect={setSelectedGroupId}
selectedGroupId={selectedGroupId}
onDelete={(id) => {
if (
window.confirm(
t(
"msg.admin.groups.list.delete_confirm",
"그룹을 삭제하시겠습니까?",
),
)
) {
deleteMutation.mutate(id);
}
}}
onAddSubGroup={handleAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
/>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
{/* 멤버 관리 섹션 (선택된 그룹이 있을 때) */}
{currentGroup && (
<Card className="flex flex-col min-h-0 flex-1 bg-[var(--color-panel)] border-t-4 border-t-primary">
<CardHeader className="flex-shrink-0">
<CardTitle className="flex items-center gap-2">
<Shield size={18} className="text-primary" />
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
name: currentGroup.name,
})}
</CardTitle>
<CardDescription>
{t(
"ui.admin.groups.detail.members_subtitle",
"그룹에 속한 멤버들을 확인하고 관리합니다.",
)}
</CardDescription>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className="flex justify-end mb-4 flex-shrink-0">
<Button
size="sm"
onClick={() => setIsAddMemberModalOpen(true)}
disabled={addMemberMutation.isPending}
>
<UserPlus size={14} className="mr-1" />
{t("ui.common.add", "멤버 추가")}
</Button>
</div>
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead>
{t("ui.admin.groups.members.table.name", "이름")}
</TableHead>
<TableHead>
{t("ui.admin.groups.members.table.email", "이메일")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.members.table.actions", "관리")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentGroup.members?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-4 text-muted-foreground"
>
{t(
"msg.admin.groups.members.empty",
"멤버가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{currentGroup.members?.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">
{user.name}
</TableCell>
<TableCell className="text-muted-foreground">
{user.email}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => {
setMemberActionTargetUserId(user.id);
setIsMoveMemberModalOpen(true);
}}
disabled={moveMemberMutation.isPending}
title={t("ui.common.move", "이동")}
>
<ArrowRightLeft
size={14}
className="text-primary"
/>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (
window.confirm(
t(
"msg.admin.groups.members.remove_confirm",
"'{{name}}' 님을 이 그룹에서 제외하시겠습니까?",
{ name: user.name },
),
)
) {
removeMemberMutation.mutate({
groupId: currentGroup.id,
userId: user.id,
});
}
}}
disabled={removeMemberMutation.isPending}
title={t("ui.common.remove", "제거")}
>
<UserMinus
size={14}
className="text-destructive"
/>
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
)}
</div>
{/* Add Member Modal */}
<Dialog
open={isAddMemberModalOpen}
onOpenChange={(val) => {
setIsAddMemberModalOpen(val);
if (!val) setUserSearchTerm("");
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("ui.admin.groups.members.add_modal_title", "그룹에 멤버 추가")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.groups.members.add_modal_desc",
"이 테넌트에 속한 사용자 중 추가할 멤버를 검색하여 선택하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "검색...")}
className="pl-9 h-9"
value={userSearchTerm}
onChange={(e) => setUserSearchTerm(e.target.value)}
/>
</div>
<ScrollArea className="h-[250px] rounded-md border p-2">
<div className="space-y-1">
{usersQuery.isLoading ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("ui.common.loading", "로딩 중...")}
</div>
) : (
users
.filter((u) => {
const term = userSearchTerm.toLowerCase();
return (
u.name.toLowerCase().includes(term) ||
u.email.toLowerCase().includes(term)
);
})
.filter(
(u) => !currentGroup?.members?.some((m) => m.id === u.id),
) // Exclude existing members
.map((user) => (
<div
key={user.id}
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
>
<div>
<p className="font-medium">{user.name}</p>
<p className="text-xs text-muted-foreground">
{user.email}
</p>
</div>
<Button
size="sm"
variant="secondary"
onClick={() => {
if (currentGroup) {
addMemberMutation.mutate({
groupId: currentGroup.id,
userId: user.id,
});
}
}}
disabled={addMemberMutation.isPending}
>
{t("ui.common.add", "추가")}
</Button>
</div>
))
)}
{users.length > 0 &&
users.filter(
(u) => !currentGroup?.members?.some((m) => m.id === u.id),
).length === 0 && (
<div className="p-4 text-center text-sm text-muted-foreground">
{t(
"msg.admin.groups.members.all_added",
"모든 테넌트 멤버가 이미 이 그룹에 속해 있습니다.",
)}
</div>
)}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddMemberModalOpen(false)}
>
{t("ui.common.close", "닫기")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Move Member Modal */}
<Dialog
open={isMoveMemberModalOpen}
onOpenChange={(val) => {
setIsMoveMemberModalOpen(val);
if (!val) {
setMemberActionTargetUserId(null);
setGroupSearchTerm("");
}
}}
>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("ui.admin.groups.members.move_modal_title", "부서 이동")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.groups.members.move_modal_desc",
"선택한 멤버를 이동할 대상 그룹을 선택하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ui.common.search_group", "그룹 검색...")}
className="pl-9 h-9"
value={groupSearchTerm}
onChange={(e) => setGroupSearchTerm(e.target.value)}
/>
</div>
<ScrollArea className="h-[250px] rounded-md border p-2">
<div className="space-y-1">
{groupsQuery.isLoading ? (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("ui.common.loading", "로딩 중...")}
</div>
) : groupsQuery.data && groupsQuery.data.length > 0 ? (
groupsQuery.data
.filter((g) =>
g.name
.toLowerCase()
.includes(groupSearchTerm.toLowerCase()),
)
.filter((g) => g.id !== currentGroup?.id) // Exclude current group
.map((group) => (
<div
key={group.id}
className="flex items-center justify-between rounded-lg px-3 py-2 text-sm transition hover:bg-muted"
>
<div className="flex items-center gap-2">
<Users size={14} className="text-muted-foreground" />
<span className="font-medium">{group.name}</span>
</div>
<Button
size="sm"
variant="outline"
onClick={() => {
if (currentGroup && memberActionTargetUserId) {
moveMemberMutation.mutate({
sourceGroupId: currentGroup.id,
targetGroupId: group.id,
userId: memberActionTargetUserId,
});
}
}}
disabled={moveMemberMutation.isPending}
>
{t("ui.common.move", "이동")}
</Button>
</div>
))
) : (
<div className="p-4 text-center text-sm text-muted-foreground">
{t("msg.admin.groups.list.no_results", "그룹이 없습니다.")}
</div>
)}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsMoveMemberModalOpen(false)}
>
{t("ui.common.cancel", "취소")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
export default TenantGroupsPage;

View File

@@ -0,0 +1,100 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
filterTenantsByScope,
getTenantSearchMatchIds,
getTenantViewRows,
resolveTenantSelectionIds,
tenantMatchesListSearch,
} from "./tenantListView";
function tenant(
id: string,
name: string,
slug: string,
parentId?: string,
): TenantSummary {
return {
id,
name,
slug,
parentId,
type: parentId ? "ORGANIZATION" : "COMPANY",
description: "",
status: "active",
memberCount: 0,
createdAt: "",
updatedAt: "",
};
}
const tenants = [
tenant("company-1", "한맥기술", "hanmac"),
tenant("dept-1", "기술기획", "planning", "company-1"),
tenant("team-1", "플랫폼팀", "platform", "dept-1"),
tenant("company-2", "삼안", "saman"),
];
describe("TenantListPage tenant list helpers", () => {
it("selects a parent tenant together with every descendant", () => {
expect(
resolveTenantSelectionIds({
currentIds: [],
tenant: tenants[0],
checked: true,
tenants,
deletableTenants: tenants,
}),
).toEqual(["company-1", "dept-1", "team-1"]);
});
it("removes a parent tenant together with every descendant", () => {
expect(
resolveTenantSelectionIds({
currentIds: ["company-1", "dept-1", "team-1", "company-2"],
tenant: tenants[0],
checked: false,
tenants,
deletableTenants: tenants,
}),
).toEqual(["company-2"]);
});
it("filters to descendants of the selected scope tenant", () => {
expect(
filterTenantsByScope(tenants, "company-1").map((item) => item.id),
).toEqual(["dept-1", "team-1"]);
});
it("searches tenants by name, slug, and UUID", () => {
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", () => {
expect(getTenantViewRows(tenants, "tree").map((row) => row.depth)).toEqual([
0, 1, 2, 0,
]);
expect(getTenantViewRows(tenants, "table").map((row) => row.depth)).toEqual(
[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"]);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,525 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { toast } from "../../../components/ui/use-toast";
import {
approveTenant,
deleteTenant,
fetchAllTenants,
fetchTenant,
updateTenant,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector";
import {
formatDomainConflictMessage,
type ServerDomainConflict,
} from "../utils/domainTags";
import {
getOrgUnitTypeOptionsForTenantType,
mergeTenantOrgConfig,
readTenantOrgConfig,
removeTenantOrgConfig,
shouldAllowHanmacOrgConfig,
TENANT_VISIBILITY_OPTIONS,
type TenantVisibility,
} from "../utils/orgConfig";
import { isSeedTenant } from "../utils/protectedTenants";
export function TenantProfilePage() {
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdParam ?? "";
const navigate = useNavigate();
const queryClient = useQueryClient();
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const parentQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchAllTenants(),
});
const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [status, setStatus] = useState("active");
const [domains, setDomains] = useState<string[]>([]);
const [forceDomainConflicts, setForceDomainConflicts] = useState<string[]>(
[],
);
const [parentId, setParentId] = useState("");
const [orgUnitType, setOrgUnitType] = useState("");
const [tenantVisibility, setTenantVisibility] =
useState<TenantVisibility>("public");
const [worksmobileExcluded, setWorksmobileExcluded] = useState(false);
useEffect(() => {
if (tenantQuery.data) {
const orgConfig = readTenantOrgConfig(tenantQuery.data.config);
setName(tenantQuery.data.name);
setType(tenantQuery.data.type || "COMPANY");
setSlug(tenantQuery.data.slug);
setDescription(tenantQuery.data.description ?? "");
setStatus(tenantQuery.data.status);
setDomains(tenantQuery.data.domains ?? []);
setForceDomainConflicts([]);
setParentId(tenantQuery.data.parentId ?? "");
setOrgUnitType(orgConfig.orgUnitType);
setTenantVisibility(orgConfig.visibility);
setWorksmobileExcluded(orgConfig.worksmobileExcluded);
}
}, [tenantQuery.data]);
const allTenants = parentQuery.data?.items ?? [];
const orgConfigCandidate = tenantQuery.data
? {
...tenantQuery.data,
parentId: parentId || undefined,
slug,
}
: undefined;
const canEditOrgConfig = orgConfigCandidate
? shouldAllowHanmacOrgConfig(orgConfigCandidate, [
...allTenants,
orgConfigCandidate,
])
: false;
const orgUnitTypeOptions = getOrgUnitTypeOptionsForTenantType(type);
const updateMutation = useMutation({
mutationFn: (overrideForceDomains?: string[]) => {
const baseConfig = tenantQuery.data?.config;
const config = canEditOrgConfig
? mergeTenantOrgConfig(baseConfig, {
orgUnitType,
visibility: tenantVisibility,
worksmobileExcluded,
})
: removeTenantOrgConfig(baseConfig);
return updateTenant(tenantId, {
name,
type,
slug,
description: description || undefined,
status,
parentId: parentId || undefined,
domains,
forceDomainConflicts: overrideForceDomains ?? forceDomainConflicts,
config,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
toast.success(t("msg.info.saved_success", "저장되었습니다."));
},
onError: (
err: AxiosError<{
code?: string;
error?: string;
conflicts?: ServerDomainConflict[];
}>,
) => {
const conflicts = err.response?.data?.conflicts ?? [];
if (
err.response?.data?.code === "tenant_domain_conflict" &&
conflicts.length > 0
) {
const nextForceDomains = Array.from(
new Set([...forceDomainConflicts, ...conflicts.map((c) => c.domain)]),
);
const message = conflicts.map(formatDomainConflictMessage).join("\n");
if (window.confirm(message)) {
setForceDomainConflicts(nextForceDomains);
updateMutation.mutate(nextForceDomains);
}
return;
}
toast.error(
err.response?.data?.error ||
t("err.common.unknown", "오류가 발생했습니다."),
);
},
});
const approveMutation = useMutation({
mutationFn: () => approveTenant(tenantId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
toast.success(
t("msg.admin.tenants.approve_success", "테넌트가 승인되었습니다."),
);
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("err.common.unknown", "오류가 발생했습니다."),
);
},
});
const deleteMutation = useMutation({
mutationFn: () => deleteTenant(tenantId),
onSuccess: () => {
navigate("/tenants");
toast.success(
t("msg.admin.tenants.delete_success", "테넌트가 삭제되었습니다."),
);
},
});
const errorMsg = (updateMutation.error as AxiosError<{ error?: string }>)
?.response?.data?.error;
const loadError = (tenantQuery.error as AxiosError<{ error?: string }>)
?.response?.data?.error;
const isProtectedSeedTenant = tenantQuery.data
? isSeedTenant(tenantQuery.data)
: false;
if (!tenantId) {
return (
<div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>
);
}
const handleDelete = () => {
if (isProtectedSeedTenant) {
return;
}
if (
window.confirm(
t("msg.admin.tenants.delete_confirm", "삭제하시겠습니까?", {
name: tenantQuery.data?.name ?? "",
}),
)
) {
deleteMutation.mutate();
}
};
const handleApprove = () => {
if (
window.confirm(
t("msg.admin.tenants.approve_confirm", "이 테넌트를 승인하시겠습니까?"),
)
) {
approveMutation.mutate();
}
};
return (
<>
<Card className="mt-4 bg-[var(--color-panel)]">
<CardHeader className="px-5 py-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<CardTitle className="text-lg">
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.tenants.profile.subtitle",
"슬러그 및 상태 변경은 즉시 적용됩니다.",
)}
</CardDescription>
</div>
</div>
</CardHeader>
<CardContent className="space-y-3 px-5 pb-4">
{loadError && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{loadError}
</div>
)}
<div
data-testid="tenant-profile-primary-row"
className="grid gap-3 lg:grid-cols-[minmax(180px,1fr)_minmax(160px,0.8fr)_minmax(320px,1.4fr)]"
>
<div data-testid="tenant-name-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</div>
<div data-testid="tenant-slug-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div>
<div data-testid="tenant-parent-picker-slot" className="min-w-0">
<ParentTenantSelector
id="parentId"
label={t(
"ui.admin.tenants.profile.form.parent",
"상위 테넌트 (선택)",
)}
value={parentId}
onChange={setParentId}
tenants={parentQuery.data?.items ?? []}
noneLabel={t("ui.common.none", "없음 (최상위)")}
excludeTenantId={tenantId}
compact
controlTestId="tenant-parent-picker-control"
/>
</div>
</div>
<div
data-testid="tenant-profile-config-row"
className="grid gap-3 lg:grid-cols-[minmax(190px,1fr)_minmax(150px,0.8fr)_minmax(150px,0.8fr)_minmax(190px,0.9fr)]"
>
<div data-testid="tenant-type-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
</Label>
<select
id="type"
data-testid="tenant-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 file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="ORGANIZATION">
{t(
"domain.tenant_type.organization",
"ORGANIZATION (정규 조직)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
{canEditOrgConfig && (
<>
<div
data-testid="tenant-org-unit-type-slot"
className="space-y-1"
>
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.profile.org_unit_type",
"조직 세부타입",
)}
</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}
onChange={(event) => setOrgUnitType(event.target.value)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{orgUnitTypeOptions.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
</div>
<div data-testid="tenant-visibility-slot" className="space-y-1">
<Label className="text-sm font-semibold">
{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) =>
setTenantVisibility(
event.target.value as TenantVisibility,
)
}
>
{TENANT_VISIBILITY_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div
data-testid="tenant-worksmobile-excluded-slot"
className="space-y-1"
>
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.profile.worksmobile_sync",
"WORKS 연동",
)}
</Label>
<select
id="worksmobileExcluded"
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={worksmobileExcluded ? "excluded" : "enabled"}
onChange={(event) =>
setWorksmobileExcluded(event.target.value === "excluded")
}
>
<option value="enabled">
{t(
"ui.admin.tenants.profile.worksmobile_enabled",
"연동",
)}
</option>
<option value="excluded">
{t(
"ui.admin.tenants.profile.worksmobile_excluded",
"제외",
)}
</option>
</select>
</div>
</>
)}
</div>
<div className="grid gap-3 lg:grid-cols-[minmax(260px,0.9fr)_minmax(360px,1.4fr)_auto]">
<div className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.description", "설명")}
</Label>
<Textarea
rows={2}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">
{t(
"ui.admin.tenants.profile.allowed_domains",
"허용된 도메인 (콤마로 구분)",
)}
</Label>
<DomainTagInput
id="tenant-domains"
value={domains}
onChange={setDomains}
tenants={parentQuery.data?.items ?? []}
currentTenantId={tenantId}
confirmedConflicts={forceDomainConflicts}
onConfirmedConflictsChange={setForceDomainConflicts}
placeholder="example.com, example.kr"
/>
</div>
<div className="space-y-1">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.status", "상태")}
</Label>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")}
>
{t("ui.common.status.active", "활성")}
</Button>
<Button
type="button"
size="sm"
variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")}
>
{t("ui.common.status.inactive", "비활성")}
</Button>
</div>
</div>
</div>
{errorMsg && (
<div className="rounded-lg border border-destructive/40 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{errorMsg}
</div>
)}
</CardContent>
</Card>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<Button
variant="outline"
onClick={handleDelete}
disabled={deleteMutation.isPending || isProtectedSeedTenant}
title={
isProtectedSeedTenant
? t(
"msg.admin.tenants.seed_delete_blocked",
"초기 설정 테넌트는 삭제할 수 없습니다.",
)
: undefined
}
>
<Trash2 size={16} />
{t("ui.common.delete", "삭제")}
</Button>
<div className="flex items-center gap-2">
{status === "pending" && (
<Button
variant="default"
className="bg-green-600 hover:bg-green-700"
onClick={handleApprove}
disabled={approveMutation.isPending}
>
{t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
</Button>
)}
<Button variant="outline" onClick={() => navigate("/tenants")}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => updateMutation.mutate(undefined)}
disabled={
updateMutation.isPending ||
tenantQuery.isLoading ||
name.trim() === ""
}
>
<Save size={16} />
{t("ui.common.save", "저장")}
</Button>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { createSchemaField, normalizeSchemaField } from "./tenantSchemaFields";
describe("TenantSchemaPage schema field helpers", () => {
it("creates text fields without varchar maxLength policy", () => {
const field = createSchemaField();
expect(field.type).toBe("text");
expect("maxLength" in field).toBe(false);
expect(field.indexed).toBe(false);
});
it("does not add maxLength to legacy text schema fields", () => {
const field = normalizeSchemaField({
key: "emp_id",
label: "사번",
type: "text",
});
expect("maxLength" in field).toBe(false);
});
it("forces indexed when a field can be used as login ID", () => {
const field = normalizeSchemaField({
key: "emp_id",
label: "사번",
type: "text",
indexed: false,
isLoginId: true,
});
expect(field.indexed).toBe(true);
expect(field.isLoginId).toBe(true);
});
});

View File

@@ -0,0 +1,400 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Plus, Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { toast } from "../../../components/ui/use-toast";
import { fetchMe, fetchTenant, updateTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { normalizeAdminRole } from "../../../lib/roles";
import {
createSchemaField,
isSchemaFieldType,
normalizeSchemaField,
type SchemaField,
} from "./tenantSchemaFields";
export function TenantSchemaPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient();
const { data: profile, isLoading: isProfileLoading } = useQuery({
queryKey: ["me"],
queryFn: fetchMe,
});
const profileRole = normalizeAdminRole(profile?.role);
const canAccess = profileRole === "super_admin";
const tenantQuery = useQuery({
queryKey: ["tenant", tenantId],
queryFn: () => {
if (!tenantId) throw new Error("Tenant ID is required");
return fetchTenant(tenantId);
},
enabled: !!tenantId && canAccess,
});
const [fields, setFields] = useState<SchemaField[]>([]);
useEffect(() => {
const rawSchema = tenantQuery.data?.config?.userSchema;
if (Array.isArray(rawSchema)) {
setFields(rawSchema.map(normalizeSchemaField));
}
}, [tenantQuery.data]);
const updateMutation = useMutation({
mutationFn: (newFields: SchemaField[]) => {
if (!tenantId) throw new Error("Tenant ID is required");
// Remove legacy loginIdField, keep isLoginId natively in userSchema
const newConfig = { ...tenantQuery.data?.config };
newConfig.loginIdField = undefined;
newConfig.userSchema = newFields;
return updateTenant(tenantId, {
config: newConfig,
});
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
toast.success(
t(
"msg.admin.tenants.schema.update_success",
"스키마가 저장되었습니다.",
),
);
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("msg.admin.tenants.schema.update_error", "저장에 실패했습니다."),
);
},
});
if (isProfileLoading) {
return (
<div className="p-8 text-center animate-pulse text-muted-foreground">
{t("msg.common.loading", "로딩 중...")}
</div>
);
}
if (!canAccess) {
return (
<div className="p-12 text-center space-y-4 bg-destructive/5 rounded-2xl border border-destructive/20 mt-6">
<h3 className="text-xl font-bold text-destructive">
{t("msg.common.forbidden", "접근 권한이 없습니다.")}
</h3>
<p className="text-muted-foreground">
{t(
"msg.admin.tenants.schema.forbidden_desc",
"사용자 스키마 설정은 관리자만 접근할 수 있습니다.",
)}
</p>
</div>
);
}
if (!tenantId) {
return (
<div className="p-8 text-center text-muted-foreground">
{t("msg.admin.tenants.schema.missing_id", "테넌트 ID가 없습니다.")}
</div>
);
}
const addField = () => {
setFields([...fields, createSchemaField()]);
};
const removeField = (index: number) => {
setFields(fields.filter((_, i) => i !== index));
};
const updateField = (index: number, updates: Partial<SchemaField>) => {
const newFields = [...fields];
newFields[index] = { ...newFields[index], ...updates };
setFields(newFields);
};
return (
<div className="space-y-6 mt-6">
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader>
<div className="flex items-center justify-between">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold">
{t("ui.admin.tenants.schema.title", "사용자 스키마 확장")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.tenants.schema.subtitle",
"이 테넌트 사용자를 위한 커스텀 속성을 정의합니다.",
)}
</CardDescription>
</div>
<Button onClick={addField} size="sm">
<Plus size={16} className="mr-2" />
{t("ui.admin.tenants.schema.add_field", "필드 추가")}
</Button>
</div>
</CardHeader>
<CardContent className="space-y-6">
{fields.length === 0 && (
<div className="py-12 text-center text-muted-foreground border border-dashed rounded-lg bg-muted/10">
{t(
"msg.admin.tenants.schema.empty",
'정의된 커스텀 필드가 없습니다. "필드 추가"를 눌러 시작하세요.',
)}
</div>
)}
{fields.map((field, index) => (
<div
key={field.id}
className="p-5 border border-border rounded-xl bg-muted/20 hover:bg-muted/30 transition-colors space-y-4"
>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.key", "필드 키 (ID)")}
</Label>
<Input
value={field.key}
onChange={(e) =>
updateField(index, { key: e.target.value })
}
placeholder={t(
"ui.admin.tenants.schema.field.key_placeholder",
"예: employee_id",
)}
className="h-10"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.label", "표시 라벨")}
</Label>
<Input
value={field.label}
onChange={(e) =>
updateField(index, { label: e.target.value })
}
placeholder={t(
"ui.admin.tenants.schema.field.label_placeholder",
"예: 사번",
)}
className="h-10"
/>
</div>
<div className="space-y-2">
<Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{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) => {
const nextType = e.target.value;
if (isSchemaFieldType(nextType)) {
updateField(index, {
type: nextType,
isLoginId:
nextType === "text" ? field.isLoginId : false,
indexed:
nextType === "text"
? field.indexed || field.isLoginId || false
: field.indexed,
});
}
}}
>
<option value="text">
{t(
"ui.admin.tenants.schema.field.type_text",
"텍스트 (Text)",
)}
</option>
<option value="number">
{t(
"ui.admin.tenants.schema.field.type_number",
"숫자 (Integer)",
)}
</option>
<option value="float">
{t(
"ui.admin.tenants.schema.field.type_float",
"실수 (Float)",
)}
</option>
<option value="boolean">
{t(
"ui.admin.tenants.schema.field.type_boolean",
"불리언 (Boolean)",
)}
</option>
<option value="date">
{t(
"ui.admin.tenants.schema.field.type_date",
"날짜 (Date)",
)}
</option>
<option value="datetime">
{t(
"ui.admin.tenants.schema.field.type_datetime",
"일시 (DateTime)",
)}
</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 items-center">
<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) =>
updateField(index, { required: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-sm font-medium">
{t("ui.admin.tenants.schema.field.required", "필수 입력")}
</span>
</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) =>
updateField(index, { adminOnly: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-sm font-medium">
{t(
"ui.admin.tenants.schema.field.admin_only",
"관리자 전용",
)}
</span>
</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) =>
updateField(index, {
isLoginId: e.target.checked,
indexed: e.target.checked ? true : field.indexed,
type: e.target.checked ? "text" : field.type,
})
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-sm font-medium text-blue-600">
{t(
"ui.admin.tenants.schema.field.is_login_id",
"로그인 ID로 사용",
)}
</span>
</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}
onChange={(e) =>
updateField(index, { indexed: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary disabled:opacity-60"
/>
<span className="text-sm font-medium">
{t(
"ui.admin.tenants.schema.field.indexed",
"검색 인덱스 필요",
)}
</span>
</label>
{(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) =>
updateField(index, { unsigned: e.target.checked })
}
className="w-4 h-4 rounded border-gray-300 text-primary focus:ring-primary"
/>
<span className="text-sm font-medium">
{t(
"ui.admin.tenants.schema.field.unsigned",
"음수 불가",
)}
</span>
</label>
)}
</div>
<div className="space-y-2">
<Input
value={field.validation}
onChange={(e) =>
updateField(index, { validation: e.target.value })
}
placeholder={t(
"ui.admin.tenants.schema.field.validation_placeholder",
"정규식 (예: ^[0-9]+$)",
)}
className="h-9 text-xs font-mono"
/>
</div>
<div className="flex justify-end">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10 h-10 w-10"
onClick={() => removeField(index)}
>
<Trash2 size={18} />
</Button>
</div>
</div>
</div>
))}
</CardContent>
</Card>
<div className="flex justify-end pt-2">
<Button
onClick={() => updateMutation.mutate(fields)}
disabled={updateMutation.isPending || tenantQuery.isLoading}
className="px-8 h-11"
>
<Save size={18} className="mr-2" />
{t("ui.admin.tenants.schema.save", "변경사항 저장")}
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,128 @@
import { useQuery } from "@tanstack/react-query";
import { Building2, Plus } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom";
import {
commonStickyTableHeaderClass,
commonTableShellClass,
commonTableViewportClass,
} from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { fetchAllTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantSubTenantsPage() {
const { tenantId } = useParams<{ tenantId: string }>();
const navigate = useNavigate();
const { data } = useQuery({
queryKey: ["sub-tenants", tenantId],
queryFn: () => fetchAllTenants({ parentId: tenantId ?? undefined }),
enabled: !!tenantId,
});
const subTenants = data?.items ?? [];
return (
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle className="flex items-center gap-2">
<Building2 size={18} className="text-primary" />
{t("ui.admin.tenants.sub.title", "Sub-tenants ({{count}})", {
count: subTenants.length,
})}
</CardTitle>
<CardDescription>
{t(
"msg.admin.tenants.sub.subtitle",
"현재 테넌트 하위에 생성된 조직입니다.",
)}
</CardDescription>
</div>
<Button size="sm" asChild>
<Link to={`/tenants/new?parentId=${tenantId}`}>
<Plus size={14} className="mr-1" />
{t("ui.admin.tenants.sub.add", "하위 테넌트 추가")}
</Link>
</Button>
</CardHeader>
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className={commonTableShellClass}>
<div className={commonTableViewportClass}>
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead>
{t("ui.admin.tenants.sub.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.sub.table.slug", "SLUG")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.sub.table.status", "STATUS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{subTenants.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.tenants.sub.empty",
"하위 테넌트가 없습니다.",
)}
</TableCell>
</TableRow>
)}
{subTenants.map((tenant) => (
<TableRow
key={tenant.id}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
<TableCell className="font-semibold">
{tenant.name}
</TableCell>
<TableCell className="text-xs font-mono">
{tenant.slug}
</TableCell>
<TableCell>
<Badge
variant={
tenant.status === "active" ? "default" : "secondary"
}
>
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
);
}
export default TenantSubTenantsPage;

View File

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

View File

@@ -0,0 +1,475 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
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";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
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,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
exportUsersCSV,
fetchTenant,
fetchUsers,
type UserSummary,
updateUser,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantUsersPage() {
const params = useParams<{ tenantId: string }>();
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({
queryKey: ["tenant", tenantId],
queryFn: () => fetchTenant(tenantId),
enabled: tenantId.length > 0,
});
const tenantSlug = tenantQuery.data?.slug;
// 해당 슬러그로 사용자 검색
const usersQuery = useQuery({
queryKey: ["users", { tenantSlug }],
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
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 }),
onSuccess: () => {
toast.success(
t(
"msg.admin.tenants.members.remove_success",
"조직에서 제외되었습니다.",
),
);
usersQuery.refetch();
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("msg.admin.tenants.members.remove_error", "제외 실패"),
);
},
});
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 (
window.confirm(
t(
"msg.admin.tenants.members.remove_confirm",
"'{{name}}'님을 이 조직에서 제외하시겠습니까?",
{ name: userName },
),
)
) {
removeTenantMutation.mutate({ userId, slug: tenantSlug });
}
};
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">
<CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
<CardTitle className="flex items-center gap-2">
<User size={18} className="text-primary" />
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
count: users.length,
})}
</CardTitle>
<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}`}>
<Plus size={16} />
{t("ui.admin.tenants.members.create_new", "신규 멤버 생성")}
</Link>
</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">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead>
{t("ui.admin.tenants.members.table.name", "NAME")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.email", "EMAIL")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.role", "ROLE")}
</TableHead>
<TableHead>
{t("ui.admin.tenants.members.table.status", "STATUS")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{usersQuery.isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center py-20">
<div className="flex flex-col items-center gap-2">
<Loader2
className="animate-spin text-muted-foreground"
size={24}
/>
<span className="text-sm text-muted-foreground">
{t("ui.common.loading", "Loading...")}
</span>
</div>
</TableCell>
</TableRow>
) : users.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.tenants.members.empty",
"소속된 사용자가 없습니다.",
)}
</TableCell>
</TableRow>
) : (
users.map((user) => (
<TableRow
key={user.id}
className="cursor-pointer hover:bg-muted/50 transition-colors"
onClick={() => navigate(`/users/${user.id}`)}
>
<TableCell className="font-semibold">
{user.name}
</TableCell>
<TableCell>
<div className="flex items-center gap-1 text-xs">
<Mail size={12} className="text-muted-foreground" />
{user.email}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{t(
`ui.common.role.${user.role}`,
user.role.replace("_", " "),
)}
</Badge>
</TableCell>
<TableCell>
<Badge
variant={
user.status === "active" ? "default" : "muted"
}
>
{t(`ui.common.status.${user.status}`, user.status)}
</Badge>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
);
}
export default TenantUsersPage;

View File

@@ -0,0 +1,627 @@
import { describe, expect, it } from "vitest";
import {
buildWorksmobilePasswordManageUrl,
canCreateWorksmobileRow,
canOpenWorksmobilePasswordManage,
canSelectWorksmobileRow,
comparisonFilterOptions,
filterVisibleWorksmobileComparisonRows,
filterWorksmobileComparisonRows,
filterWorksmobileComparisonRowsBySearch,
formatWorksmobileOrgDetails,
formatWorksmobilePersonName,
formatWorksmobileUpdateDetails,
getDefaultGroupComparisonFilters,
getDefaultUserComparisonFilters,
getDefaultWorksmobileComparisonColumns,
getWorksmobileComparisonStatusLabel,
getWorksmobileRowSelectionKey,
getWorksmobileSelectedActionIds,
getWorksmobileSelectedCreateUserIds,
getWorksmobileSelectedMissingExternalKeyOrgUnitIds,
getWorksmobileSelectedUpdateUserIds,
getWorksmobileSelectedWorksOnlyOrgUnitIds,
isImmutableWorksmobileAccount,
summarizeWorksmobileComparison,
userFilterOptions,
} from "./worksmobileComparison";
describe("TenantWorksmobilePage comparison helpers", () => {
it("summarizes comparison rows by status", () => {
const summary = summarizeWorksmobileComparison([
{ resourceType: "USER", status: "matched" },
{ resourceType: "GROUP", status: "needs_update" },
{ resourceType: "USER", status: "missing_in_worksmobile" },
{ resourceType: "USER", status: "missing_in_baron" },
{ resourceType: "USER", status: "missing_external_key" },
{ resourceType: "USER", status: "missing_in_baron" },
]);
expect(summary).toEqual({
total: 6,
matched: 1,
needsUpdate: 1,
missingInWorksmobile: 1,
missingInBaron: 2,
missingExternalKey: 1,
});
});
it("returns Korean labels for known comparison statuses", () => {
expect(getWorksmobileComparisonStatusLabel("matched")).toBe("일치");
expect(getWorksmobileComparisonStatusLabel("missing_in_worksmobile")).toBe(
"WORKS 없음",
);
expect(getWorksmobileComparisonStatusLabel("missing_in_baron")).toBe(
"Baron 없음",
);
expect(getWorksmobileComparisonStatusLabel("missing_external_key")).toBe(
"ex_key 없음",
);
expect(getWorksmobileComparisonStatusLabel("needs_update")).toBe(
"업데이트 필요",
);
expect(getWorksmobileComparisonStatusLabel("unknown_status")).toBe(
"unknown_status",
);
});
it("allows WORKS creation only for Baron rows missing in WORKS", () => {
expect(
canCreateWorksmobileRow({
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "user-1",
}),
).toBe(true);
expect(
canCreateWorksmobileRow({
resourceType: "USER",
status: "missing_in_worksmobile",
}),
).toBe(false);
expect(
canCreateWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-user-1",
}),
).toBe(false);
});
it("allows selection for Baron-only, WORKS-only, and matched rows", () => {
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "user-1",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-user-1",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "matched",
baronId: "user-2",
worksmobileId: "works-user-2",
}),
).toBe(true);
});
it("does not allow selection for immutable WORKS accounts", () => {
expect(
isImmutableWorksmobileAccount({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "cyhan@samaneng.com",
worksmobileId: "works-cyhan",
}),
).toBe(true);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "CYHAN1@HANMACENG.CO.KR",
worksmobileId: "works-cyhan1",
}),
).toBe(false);
expect(
canSelectWorksmobileRow({
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "normal@samaneng.com",
worksmobileId: "works-normal",
}),
).toBe(true);
});
it("does not allow password management for immutable WORKS accounts", () => {
expect(
canOpenWorksmobilePasswordManage(
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "su-@samaneng.com",
worksmobileDomainId: 300285955,
worksmobileId: "works-su",
},
"works-tenant-1",
),
).toBe(false);
});
it("hides protected WORKS member accounts from comparison lists", () => {
const rows = [
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "su-@samaneng.com",
worksmobileId: "works-su",
},
{
resourceType: "USER",
status: "matched",
baronEmail: "CYHAN1@HANMACENG.CO.KR",
baronId: "baron-cyhan1",
worksmobileEmail: "cyhan1@hanmaceng.co.kr",
worksmobileId: "works-cyhan1",
},
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileEmail: "normal@samaneng.com",
worksmobileId: "works-normal",
},
{
resourceType: "GROUP",
status: "missing_in_baron",
worksmobileEmail: "su-@samaneng.com",
worksmobileId: "works-group",
},
];
expect(filterVisibleWorksmobileComparisonRows(rows)).toEqual([
rows[2],
rows[3],
]);
});
it("keeps row selection keys separate from Baron action ids", () => {
const rows = [
{
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "baron-only",
},
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-only",
},
{
resourceType: "USER",
status: "matched",
baronId: "matched-baron",
worksmobileId: "matched-works",
},
];
const selectedKeys = rows.map(getWorksmobileRowSelectionKey);
expect(selectedKeys).toEqual([
"USER:baron:baron-only",
"USER:works:works-only",
"USER:baron:matched-baron",
]);
expect(getWorksmobileSelectedActionIds(rows, selectedKeys)).toEqual([
"baron-only",
"matched-baron",
]);
});
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,
baronId: false,
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: false,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,
manage: true,
});
});
it("filters user comparison rows by selected relationship", () => {
const rows = [
{
resourceType: "USER",
status: "missing_in_worksmobile",
baronId: "baron-only",
baronName: "Baron only",
},
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileId: "works-only",
worksmobileName: "WORKS only",
},
{
resourceType: "USER",
status: "matched",
baronId: "matched",
worksmobileId: "works-matched",
},
{
resourceType: "USER",
status: "missing_external_key",
worksmobileId: "missing-external-key",
},
];
expect(filterWorksmobileComparisonRows(rows, ["baron_only"])).toEqual([
rows[0],
]);
expect(filterWorksmobileComparisonRows(rows, ["works_only"])).toEqual([
rows[1],
rows[3],
]);
expect(filterWorksmobileComparisonRows(rows, ["matched"])).toEqual([
rows[2],
]);
expect(
filterWorksmobileComparisonRows(rows, ["baron_only", "works_only"]),
).toEqual([rows[0], rows[1], rows[3]]);
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
expect(filterWorksmobileComparisonRows(rows, [])).toEqual([]);
expect(
filterWorksmobileComparisonRows(rows, [
"baron_only",
"works_only",
"matched",
]),
).toEqual(rows);
expect(
filterWorksmobileComparisonRows(
rows,
["baron_only", "works_only", "matched"],
true,
),
).toEqual([rows[0], rows[2], rows[3]]);
});
it("narrows works-only rows to missing external key rows from the detail filter", () => {
const rows = [
{
resourceType: "GROUP",
status: "missing_in_worksmobile",
baronId: "baron-only",
baronName: "Baron only",
},
{
resourceType: "GROUP",
status: "missing_in_baron",
worksmobileId: "works-only",
worksmobileName: "WORKS only",
},
{
resourceType: "GROUP",
status: "missing_external_key",
worksmobileId: "missing-external-key",
},
{
resourceType: "GROUP",
status: "matched",
baronId: "matched",
worksmobileId: "works-matched",
},
];
expect(
filterWorksmobileComparisonRows(rows, ["works_only"], false),
).toEqual([rows[1], rows[2]]);
expect(filterWorksmobileComparisonRows(rows, ["works_only"], true)).toEqual(
[rows[2]],
);
expect(filterWorksmobileComparisonRows(rows, [], true)).toEqual([]);
expect(filterWorksmobileComparisonRows(rows, ["baron_only"], true)).toEqual(
[rows[0]],
);
});
it("filters comparison rows by names and identifiers in real time", () => {
const rows = [
{
resourceType: "USER",
status: "matched",
baronId: "baron-user-uuid",
baronName: "홍길동",
worksmobileName: "Hong Gildong",
},
{
resourceType: "GROUP",
status: "missing_external_key",
worksmobileId: "works-org-uuid",
worksmobileName: "기술연구소",
worksmobileParentName: "한맥가족",
},
{
resourceType: "GROUP",
status: "missing_in_worksmobile",
baronId: "baron-org-uuid",
baronSlug: "baron-group-design",
baronName: "디자인팀",
},
];
expect(filterWorksmobileComparisonRowsBySearch(rows, "")).toEqual(rows);
expect(filterWorksmobileComparisonRowsBySearch(rows, "홍길동")).toEqual([
rows[0],
]);
expect(filterWorksmobileComparisonRowsBySearch(rows, "WORKS-ORG")).toEqual([
rows[1],
]);
expect(filterWorksmobileComparisonRowsBySearch(rows, "design")).toEqual([
rows[2],
]);
expect(filterWorksmobileComparisonRowsBySearch(rows, "없음")).toEqual([]);
});
it("returns only selected missing-external-key WORKS orgunit ids for delete", () => {
const rows = [
{
resourceType: "GROUP",
status: "missing_external_key",
worksmobileId: "works-missing-key",
},
{
resourceType: "GROUP",
status: "missing_in_baron",
worksmobileId: "works-only",
},
{
resourceType: "USER",
status: "missing_external_key",
worksmobileId: "works-user-missing-key",
},
];
expect(
getWorksmobileSelectedMissingExternalKeyOrgUnitIds(rows, [
getWorksmobileRowSelectionKey(rows[0]),
getWorksmobileRowSelectionKey(rows[1]),
getWorksmobileRowSelectionKey(rows[2]),
]),
).toEqual(["works-missing-key"]);
});
it("returns selected WORKS-only orgunit ids for Baron SSOT cleanup", () => {
const rows = [
{
resourceType: "GROUP",
status: "missing_external_key",
worksmobileId: "works-missing-key",
},
{
resourceType: "GROUP",
status: "missing_in_baron",
worksmobileId: "works-only",
externalKey: "legacy-external-key",
},
{
resourceType: "GROUP",
status: "matched",
baronId: "baron-matched",
worksmobileId: "works-matched",
},
];
expect(
getWorksmobileSelectedWorksOnlyOrgUnitIds(
rows,
rows.map(getWorksmobileRowSelectionKey),
),
).toEqual(["works-missing-key", "works-only"]);
});
it("orders user comparison filter options from Baron-only first", () => {
expect(userFilterOptions.map((option) => option.value)).toEqual([
"baron_only",
"needs_update",
"works_only",
"matched",
]);
});
it("keeps all organization/group comparison filter labels available", () => {
expect(comparisonFilterOptions).toEqual([
{ value: "baron_only", label: "바론에만 있음" },
{ value: "needs_update", label: "업데이트 필요" },
{ value: "works_only", label: "웍스에만 있음" },
{ value: "matched", label: "양쪽 다 있음" },
]);
});
it("shows update-needed group rows by default", () => {
const rows = [
{ resourceType: "GROUP", status: "needs_update", baronId: "org-1" },
{ resourceType: "GROUP", status: "matched", baronId: "org-2" },
];
expect(
filterWorksmobileComparisonRows(rows, getDefaultGroupComparisonFilters()),
).toEqual([rows[0]]);
});
it("shows update-needed user rows by default", () => {
const rows = [
{ resourceType: "USER", status: "needs_update", baronId: "user-1" },
{ resourceType: "USER", status: "matched", baronId: "user-2" },
];
expect(
filterWorksmobileComparisonRows(rows, getDefaultUserComparisonFilters()),
).toEqual([rows[0]]);
});
it("formats update details for changed organization rows", () => {
expect(
formatWorksmobileUpdateDetails({
resourceType: "GROUP",
status: "needs_update",
baronId: "818c856b-9545-442f-b827-d1c569f200b0",
baronName: "삼안기술개발센터(조직도용)",
worksmobileName: "기술개발센터(조직도용)",
baronParentId: "9caf62e1-297d-4e8f-870b-61780998bbeb",
baronParentWorksmobileId: "works-saman",
baronParentWorksmobileName: "삼안",
worksmobileParentId: "works-other",
worksmobileParentName: "다른 상위",
}),
).toEqual([
"이름: 기술개발센터(조직도용) -> 삼안기술개발센터(조직도용)",
"상위: 다른 상위 -> 삼안",
]);
});
it("formats WORKS account name with level on one line", () => {
expect(
formatWorksmobilePersonName({
resourceType: "USER",
status: "matched",
worksmobileName: "홍길동",
worksmobileLevelName: "책임",
}),
).toBe("홍길동 책임");
});
it("formats WORKS organization details with task and manager status", () => {
expect(
formatWorksmobileOrgDetails({
resourceType: "USER",
status: "matched",
worksmobileTask: "기술검토",
worksmobilePrimaryOrgPositionName: "팀장",
worksmobilePrimaryOrgIsManager: true,
}),
).toEqual(["직책 팀장", "직무 기술검토", "조직장"]);
});
it("builds the WORKS admin password management URL from remote user identifiers", () => {
const url = buildWorksmobilePasswordManageUrl({
tenantId: " works-tenant-1 ",
domainId: 300285955,
userIdNo: " works-user-1 ",
});
const parsed = new URL(url);
expect(parsed.origin + parsed.pathname).toBe(
"https://auth.worksmobile.com/integrate/password/manage",
);
expect(parsed.searchParams.get("usage")).toBe("admin");
expect(parsed.searchParams.get("targetUserTenantId")).toBe(
"works-tenant-1",
);
expect(parsed.searchParams.get("targetUserDomainId")).toBe("300285955");
expect(parsed.searchParams.get("targetUserIdNo")).toBe("works-user-1");
expect(parsed.searchParams.get("accessUrl")).toBe(
"https://admin.worksmobile.com/assets/self-close.html",
);
});
it("does not open WORKS password management without required identifiers", () => {
const row = {
resourceType: "USER",
status: "matched",
worksmobileDomainId: 300285955,
worksmobileId: "works-user-1",
};
expect(canOpenWorksmobilePasswordManage(row, "works-tenant-1")).toBe(true);
expect(canOpenWorksmobilePasswordManage(row, undefined)).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, worksmobileDomainId: undefined },
"works-tenant-1",
),
).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, worksmobileId: undefined },
"works-tenant-1",
),
).toBe(false);
expect(
canOpenWorksmobilePasswordManage(
{ ...row, resourceType: "GROUP" },
"works-tenant-1",
),
).toBe(false);
expect(
buildWorksmobilePasswordManageUrl({
tenantId: "works-tenant-1",
domainId: 0,
userIdNo: "works-user-1",
}),
).toBe("");
});
it("allows WORKS password management for WORKS-only user rows", () => {
expect(
canOpenWorksmobilePasswordManage(
{
resourceType: "USER",
status: "missing_in_baron",
worksmobileDomainId: 300285955,
worksmobileId: "works-user-1",
},
"works-tenant-1",
),
).toBe(true);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,142 @@
import type { TenantSummary } from "../../../lib/adminApi";
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
export type TenantViewMode = "tree" | "table";
export type TenantViewRow = TenantNode & { depth: number };
export function tenantMatchesListSearch(
tenant: Pick<TenantSummary, "id" | "name" | "slug" | "type">,
search: string,
) {
const normalizedSearch = search.trim().toLowerCase();
if (!normalizedSearch) return true;
return [tenant.name, tenant.slug, tenant.id, tenant.type]
.filter(Boolean)
.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,
rows: TenantViewRow[],
) {
for (const node of nodes) {
rows.push({ ...node, depth });
collectTenantTreeRows(node.children, depth + 1, rows);
}
}
function collectTenantDescendantIds(
tenantId: string,
tenants: TenantSummary[],
) {
const childrenByParent = new Map<string, TenantSummary[]>();
for (const tenant of tenants) {
if (!tenant.parentId) continue;
const children = childrenByParent.get(tenant.parentId) ?? [];
children.push(tenant);
childrenByParent.set(tenant.parentId, children);
}
const ids: string[] = [];
const visitedIds = new Set<string>();
const visit = (parentId: string) => {
for (const child of childrenByParent.get(parentId) ?? []) {
if (visitedIds.has(child.id)) continue;
visitedIds.add(child.id);
ids.push(child.id);
visit(child.id);
}
};
visit(tenantId);
return ids;
}
export function filterTenantsByScope(
tenants: TenantSummary[],
scopeTenantId: string,
) {
if (!scopeTenantId) return tenants;
const descendantIds = new Set(
collectTenantDescendantIds(scopeTenantId, tenants),
);
return tenants.filter((tenant) => descendantIds.has(tenant.id));
}
export function getTenantViewRows(
tenants: TenantSummary[],
viewMode: TenantViewMode,
scopeTenantId = "",
isSearchActive = false,
): TenantViewRow[] {
const { subTree } = buildTenantFullTree(
tenants,
scopeTenantId || undefined,
isSearchActive,
);
const treeRows: TenantViewRow[] = [];
collectTenantTreeRows(subTree, 0, treeRows);
if (viewMode === "tree") {
return treeRows;
}
const rowsById = new Map(treeRows.map((row) => [row.id, row]));
const flatSource = scopeTenantId
? filterTenantsByScope(tenants, scopeTenantId)
: tenants;
return flatSource.map((tenant) => ({
...(rowsById.get(tenant.id) ?? {
...tenant,
children: [],
recursiveMemberCount:
Number(tenant.totalMemberCount ?? tenant.memberCount) || 0,
}),
depth: 0,
}));
}
export function resolveTenantSelectionIds({
currentIds,
tenant,
checked,
tenants,
deletableTenants,
}: {
currentIds: string[];
tenant: TenantSummary;
checked: boolean;
tenants: TenantSummary[];
deletableTenants: TenantSummary[];
}) {
const allowedIds = new Set(deletableTenants.map((item) => item.id));
const targetIds = [
tenant.id,
...collectTenantDescendantIds(tenant.id, tenants),
].filter((id) => allowedIds.has(id));
const next = new Set(currentIds.filter((id) => allowedIds.has(id)));
if (checked) {
for (const id of targetIds) {
next.add(id);
}
} else {
for (const id of targetIds) {
next.delete(id);
}
}
return Array.from(next);
}

View File

@@ -0,0 +1,74 @@
export type SchemaFieldType =
| "text"
| "number"
| "boolean"
| "date"
| "float"
| "datetime";
export type SchemaField = {
id: string;
key: string;
label: string;
type: SchemaFieldType;
required: boolean;
adminOnly: boolean;
validation?: string;
unsigned?: boolean;
isLoginId?: boolean;
indexed?: boolean;
};
function createFieldId() {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
export function isSchemaFieldType(value: unknown): value is SchemaFieldType {
return (
value === "text" ||
value === "number" ||
value === "boolean" ||
value === "date" ||
value === "float" ||
value === "datetime"
);
}
export function normalizeSchemaField(field: unknown): SchemaField {
const source =
typeof field === "object" && field !== null
? (field as Record<string, unknown>)
: {};
const type = isSchemaFieldType(source.type) ? source.type : "text";
const isLoginId = Boolean(source.isLoginId);
return {
id: typeof source.id === "string" ? source.id : createFieldId(),
key: typeof source.key === "string" ? source.key : "",
label: typeof source.label === "string" ? source.label : "",
type,
required: Boolean(source.required),
adminOnly: Boolean(source.adminOnly),
validation: typeof source.validation === "string" ? source.validation : "",
unsigned: Boolean(source.unsigned),
isLoginId,
indexed: isLoginId || Boolean(source.indexed),
};
}
export function createSchemaField(): SchemaField {
return {
id: createFieldId(),
key: "",
label: "",
type: "text",
required: false,
adminOnly: false,
validation: "",
unsigned: false,
indexed: false,
};
}

View File

@@ -0,0 +1,57 @@
import { describe, expect, it } from "vitest";
import {
canAccessWorksmobile,
HANMAC_FAMILY_TENANT_ID,
} from "./worksmobileAccess";
describe("worksmobile access", () => {
it("allows super admins", () => {
expect(canAccessWorksmobile({ role: "super_admin" })).toBe(true);
});
it("allows hanmac-family tenant managers", () => {
expect(
canAccessWorksmobile({
role: "tenant_admin",
manageableTenants: [{ id: HANMAC_FAMILY_TENANT_ID }],
}),
).toBe(true);
expect(
canAccessWorksmobile({
role: "tenant_admin",
manageableTenants: [{ slug: "hanmac-family" }],
}),
).toBe(true);
});
it("rejects admins that do not manage hanmac-family", () => {
expect(
canAccessWorksmobile({
role: "tenant_admin",
manageableTenants: [{ slug: "other-company" }],
}),
).toBe(false);
expect(
canAccessWorksmobile({
role: "user",
tenantId: HANMAC_FAMILY_TENANT_ID,
tenantSlug: "hanmac-family",
}),
).toBe(false);
expect(canAccessWorksmobile({ role: "user" })).toBe(false);
});
it("rejects admins that only manage Worksmobile-excluded hanmac-family tenants", () => {
expect(
canAccessWorksmobile({
role: "tenant_admin",
manageableTenants: [
{
slug: "hanmac-family",
config: { worksmobileExcluded: true },
},
],
}),
).toBe(false);
});
});

View File

@@ -0,0 +1,61 @@
import { isSuperAdminRole } from "../../../lib/roles";
export const HANMAC_FAMILY_TENANT_ID = "038326b6-954a-48a7-a85f-efd83f62b82a";
export const HANMAC_FAMILY_TENANT_SLUG = "hanmac-family";
export type WorksmobileAccessProfile = {
role?: string;
tenantId?: string;
tenantSlug?: string;
tenant?: {
id?: string;
slug?: string;
config?: Record<string, unknown>;
};
manageableTenants?: Array<{
id?: string;
slug?: string;
config?: Record<string, unknown>;
}>;
};
export function isWorksmobileExcludedConfig(config?: Record<string, unknown>) {
const rawValue = config?.worksmobileExcluded;
return (
rawValue === true ||
String(rawValue ?? "")
.trim()
.toLowerCase() === "true"
);
}
function isProfileTenantWorksmobileExcluded(
profile?: WorksmobileAccessProfile | null,
) {
if (isWorksmobileExcludedConfig(profile?.tenant?.config)) {
return true;
}
return (profile?.manageableTenants ?? []).some((tenant) => {
const isCurrentTenant =
(profile?.tenantId && tenant.id === profile.tenantId) ||
(profile?.tenantSlug && tenant.slug === profile.tenantSlug);
return isCurrentTenant && isWorksmobileExcludedConfig(tenant.config);
});
}
export function canAccessWorksmobile(
profile?: WorksmobileAccessProfile | null,
) {
if (isSuperAdminRole(profile?.role)) {
return true;
}
if (isProfileTenantWorksmobileExcluded(profile)) {
return false;
}
return (profile?.manageableTenants ?? []).some(
(tenant) =>
!isWorksmobileExcludedConfig(tenant.config) &&
(tenant.id === HANMAC_FAMILY_TENANT_ID ||
tenant.slug === HANMAC_FAMILY_TENANT_SLUG),
);
}

View File

@@ -0,0 +1,462 @@
import type { WorksmobileComparisonItem } from "../../../lib/adminApi";
export type WorksmobileComparisonFilter =
| "works_only"
| "baron_only"
| "needs_update"
| "matched";
export type WorksmobileComparisonSummary = {
total: number;
matched: number;
needsUpdate: number;
missingInWorksmobile: number;
missingInBaron: number;
missingExternalKey: number;
};
export type WorksmobileComparisonColumnKey =
| "status"
| "baronId"
| "baron"
| "baronOrg"
| "worksmobileId"
| "externalKey"
| "worksmobileDomain"
| "worksmobile"
| "worksmobileOrg"
| "manage";
export type WorksmobileComparisonColumnVisibility = Record<
WorksmobileComparisonColumnKey,
boolean
>;
export function getDefaultWorksmobileComparisonColumns(): WorksmobileComparisonColumnVisibility {
return {
status: true,
baronId: false,
baron: true,
baronOrg: true,
worksmobileId: false,
externalKey: false,
worksmobileDomain: true,
worksmobile: true,
worksmobileOrg: true,
manage: true,
};
}
export function summarizeWorksmobileComparison(
rows: WorksmobileComparisonItem[],
): WorksmobileComparisonSummary {
return rows.reduce<WorksmobileComparisonSummary>(
(summary, row) => {
if (row.status === "matched") {
summary.matched += 1;
} else if (row.status === "needs_update") {
summary.needsUpdate += 1;
} else if (row.status === "missing_in_worksmobile") {
summary.missingInWorksmobile += 1;
} else if (row.status === "missing_in_baron") {
summary.missingInBaron += 1;
} else if (row.status === "missing_external_key") {
summary.missingExternalKey += 1;
}
return summary;
},
{
total: rows.length,
matched: 0,
needsUpdate: 0,
missingInWorksmobile: 0,
missingInBaron: 0,
missingExternalKey: 0,
},
);
}
export function getWorksmobileComparisonStatusLabel(status: string) {
switch (status) {
case "matched":
return "일치";
case "missing_in_worksmobile":
return "WORKS 없음";
case "needs_update":
return "업데이트 필요";
case "missing_in_baron":
return "Baron 없음";
case "missing_external_key":
return "ex_key 없음";
default:
return status;
}
}
export function canCreateWorksmobileRow(row: WorksmobileComparisonItem) {
return row.status === "missing_in_worksmobile" && Boolean(row.baronId);
}
const immutableWorksmobileAccountEmails = new Set([
"cyhan@samaneng.com",
"cyhan1@hanmaceng.co.kr",
"cyhan2@baroncs.co.kr",
"cyhan3@brsw.kr",
"su-@samaneng.com",
]);
const hiddenWorksmobileMemberEmails = new Set([
"su-@samaneng.com",
"cyhan1@hanmaceng.co.kr",
"cyhan2@baroncs.co.kr",
"cyhan3@brsw.kr",
]);
function normalizeWorksmobileEmail(email?: string) {
return email?.trim().toLowerCase() ?? "";
}
export function isImmutableWorksmobileAccount(row: WorksmobileComparisonItem) {
return (
row.resourceType === "USER" &&
immutableWorksmobileAccountEmails.has(
normalizeWorksmobileEmail(row.worksmobileEmail),
)
);
}
export function isHiddenWorksmobileMember(row: WorksmobileComparisonItem) {
if (row.resourceType !== "USER") {
return false;
}
return [row.worksmobileEmail, row.baronEmail].some((email) =>
hiddenWorksmobileMemberEmails.has(normalizeWorksmobileEmail(email)),
);
}
export function filterVisibleWorksmobileComparisonRows(
rows: WorksmobileComparisonItem[],
) {
return rows.filter((row) => !isHiddenWorksmobileMember(row));
}
export function getWorksmobileRowSelectionKey(row: WorksmobileComparisonItem) {
if (row.baronId) {
return `${row.resourceType}:baron:${row.baronId}`;
}
if (row.worksmobileId) {
return `${row.resourceType}:works:${row.worksmobileId}`;
}
if (row.externalKey) {
return `${row.resourceType}:external:${row.externalKey}`;
}
return "";
}
export function canSelectWorksmobileRow(row: WorksmobileComparisonItem) {
return (
Boolean(getWorksmobileRowSelectionKey(row)) &&
!isImmutableWorksmobileAccount(row)
);
}
export function getWorksmobileSelectedActionIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter((row) => selected.has(getWorksmobileRowSelectionKey(row)))
.map((row) => row.baronId)
.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[],
) {
return getWorksmobileSelectedWorksOnlyOrgUnitIds(rows, selectedKeys).filter(
(id) =>
rows.some(
(row) =>
row.worksmobileId === id && row.status === "missing_external_key",
),
);
}
export function getWorksmobileSelectedWorksOnlyOrgUnitIds(
rows: WorksmobileComparisonItem[],
selectedKeys: string[],
) {
const selected = new Set(selectedKeys);
return rows
.filter(
(row) =>
row.resourceType === "GROUP" &&
(row.status === "missing_external_key" ||
row.status === "missing_in_baron") &&
selected.has(getWorksmobileRowSelectionKey(row)),
)
.map((row) => row.worksmobileId)
.filter((id): id is string => Boolean(id));
}
const worksmobileComparisonSearchFields: Array<
keyof WorksmobileComparisonItem
> = [
"baronId",
"baronSlug",
"baronName",
"baronEmail",
"baronPrimaryOrgId",
"baronPrimaryOrgSlug",
"baronPrimaryOrgName",
"baronParentId",
"baronParentSlug",
"baronParentName",
"worksmobileId",
"externalKey",
"worksmobileName",
"worksmobileEmail",
"worksmobileLevelId",
"worksmobileLevelName",
"worksmobileTask",
"worksmobileDomainId",
"worksmobileDomainName",
"worksmobilePrimaryOrgId",
"worksmobilePrimaryOrgName",
"worksmobilePrimaryOrgPositionId",
"worksmobilePrimaryOrgPositionName",
"baronParentWorksmobileId",
"baronParentWorksmobileName",
"baronParentWorksmobileEmail",
"worksmobileParentId",
"worksmobileParentName",
"worksmobileParentEmail",
"worksmobileParentExternalKey",
];
export function filterWorksmobileComparisonRowsBySearch(
rows: WorksmobileComparisonItem[],
search: string,
) {
const keyword = search.trim().toLowerCase();
if (!keyword) {
return rows;
}
return rows.filter((row) =>
worksmobileComparisonSearchFields.some((field) => {
const value = row[field];
if (value === undefined || value === null) {
return false;
}
return String(value).toLowerCase().includes(keyword);
}),
);
}
export function filterWorksmobileComparisonRows(
rows: WorksmobileComparisonItem[],
filters: WorksmobileComparisonFilter[],
onlyMissingExternalKey = false,
) {
const allowedStatuses = new Set(
filters.flatMap((filter) => worksmobileFilterStatuses[filter]),
);
if (filters.includes("works_only")) {
if (onlyMissingExternalKey) {
allowedStatuses.delete("missing_in_baron");
}
allowedStatuses.add("missing_external_key");
}
return rows.filter((row) => allowedStatuses.has(row.status));
}
export function formatWorksmobilePersonName(row: WorksmobileComparisonItem) {
return [
row.worksmobileName,
row.worksmobileLevelName ?? row.worksmobileLevelId,
]
.filter(Boolean)
.join(" ");
}
export function formatWorksmobileOrgDetails(row: WorksmobileComparisonItem) {
const details: string[] = [];
const position =
row.worksmobilePrimaryOrgPositionName ??
row.worksmobilePrimaryOrgPositionId;
if (position) {
details.push(`직책 ${position}`);
}
if (row.worksmobileTask) {
details.push(`직무 ${row.worksmobileTask}`);
}
if (typeof row.worksmobilePrimaryOrgIsManager === "boolean") {
details.push(row.worksmobilePrimaryOrgIsManager ? "조직장" : "조직장 아님");
}
return details;
}
export function formatWorksmobileUpdateDetails(row: WorksmobileComparisonItem) {
if (row.status === "missing_in_worksmobile" && row.worksmobileLastError) {
return [`최근 실패: ${row.worksmobileLastError}`];
}
if (row.status !== "needs_update") {
return [];
}
const details: string[] = [];
const baronName = row.baronName?.trim();
const worksmobileName = row.worksmobileName?.trim();
if (baronName && worksmobileName && baronName !== worksmobileName) {
details.push(`이름: ${worksmobileName} -> ${baronName}`);
}
if (row.resourceType === "USER") {
const expectedExternalKey = row.baronId?.trim() ?? "";
const actualExternalKey = row.externalKey?.trim() ?? "";
if (expectedExternalKey && expectedExternalKey !== actualExternalKey) {
details.push(
`external_key: ${actualExternalKey || "없음"} -> ${expectedExternalKey}`,
);
}
const expectedEmail = row.baronEmail?.trim().toLowerCase() ?? "";
const actualEmail = row.worksmobileEmail?.trim().toLowerCase() ?? "";
if (expectedEmail && actualEmail && expectedEmail !== actualEmail) {
details.push(`이메일: ${actualEmail} -> ${expectedEmail}`);
}
return details;
}
const expectedParent =
row.baronParentWorksmobileName ??
row.baronParentName ??
row.baronParentWorksmobileId ??
row.baronParentId ??
"";
const actualParent =
row.worksmobileParentName ??
row.worksmobileParentExternalKey ??
row.worksmobileParentId ??
"";
const expectedParentKey =
row.baronParentWorksmobileId ?? row.baronParentId ?? "";
const actualParentKey =
row.worksmobileParentId ?? row.worksmobileParentExternalKey ?? "";
if (expectedParentKey !== actualParentKey) {
details.push(
`상위: ${actualParent || "없음"} -> ${expectedParent || "없음"}`,
);
}
return details;
}
export function buildWorksmobilePasswordManageUrl({
tenantId,
domainId,
userIdNo,
}: {
tenantId?: string;
domainId?: number;
userIdNo?: string;
}) {
const normalizedTenantId = tenantId?.trim();
const normalizedUserIdNo = userIdNo?.trim();
if (
!normalizedTenantId ||
!domainId ||
domainId <= 0 ||
!normalizedUserIdNo
) {
return "";
}
const url = new URL("https://auth.worksmobile.com/integrate/password/manage");
url.searchParams.set("usage", "admin");
url.searchParams.set("targetUserTenantId", normalizedTenantId);
url.searchParams.set("targetUserDomainId", String(domainId));
url.searchParams.set("targetUserIdNo", normalizedUserIdNo);
url.searchParams.set(
"accessUrl",
"https://admin.worksmobile.com/assets/self-close.html",
);
return url.toString();
}
export function canOpenWorksmobilePasswordManage(
row: WorksmobileComparisonItem,
tenantId?: string,
) {
return (
row.resourceType === "USER" &&
!isImmutableWorksmobileAccount(row) &&
Boolean(
buildWorksmobilePasswordManageUrl({
tenantId,
domainId: row.worksmobileDomainId,
userIdNo: row.worksmobileId,
}),
)
);
}
export const comparisonFilterOptions: Array<{
value: WorksmobileComparisonFilter;
label: string;
}> = [
{ value: "baron_only", label: "바론에만 있음" },
{ value: "needs_update", label: "업데이트 필요" },
{ value: "works_only", label: "웍스에만 있음" },
{ value: "matched", label: "양쪽 다 있음" },
];
export const userFilterOptions = comparisonFilterOptions;
export function getDefaultUserComparisonFilters(): WorksmobileComparisonFilter[] {
return ["baron_only", "needs_update", "works_only"];
}
export function getDefaultGroupComparisonFilters(): WorksmobileComparisonFilter[] {
return ["baron_only", "needs_update", "works_only"];
}
const worksmobileFilterStatuses: Record<WorksmobileComparisonFilter, string[]> =
{
baron_only: ["missing_in_worksmobile"],
needs_update: ["needs_update"],
works_only: ["missing_in_baron"],
matched: ["matched"],
};

View File

@@ -0,0 +1,67 @@
import { describe, expect, it } from "vitest";
import {
findDomainConflict,
formatDomainConflictMessage,
normalizeDomainTokens,
} from "./domainTags";
describe("domainTags", () => {
it("splits domains by comma and whitespace", () => {
expect(
normalizeDomainTokens("samaneng.com, hanmaceng.co.kr login.hmac.kr"),
).toEqual(["samaneng.com", "hanmaceng.co.kr", "login.hmac.kr"]);
});
it("finds a domain already assigned to another tenant", () => {
const conflict = findDomainConflict("hanmaceng.co.kr", [
{
id: "tenant-1",
name: "한맥기술",
slug: "hanmac",
type: "COMPANY",
description: "",
status: "active",
domains: ["hanmaceng.co.kr"],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
]);
expect(conflict?.tenant.name).toBe("한맥기술");
});
it("ignores the current tenant when checking domain conflicts", () => {
const conflict = findDomainConflict(
"hanmaceng.co.kr",
[
{
id: "tenant-1",
name: "한맥기술",
slug: "hanmac",
type: "COMPANY",
description: "",
status: "active",
domains: ["hanmaceng.co.kr"],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
],
"tenant-1",
);
expect(conflict).toBeNull();
});
it("formats a duplicate domain message with tenant context", () => {
expect(
formatDomainConflictMessage({
domain: "samaneng.com",
tenantName: "한맥가족",
}),
).toBe(
"samaneng.com 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
);
});
});

View File

@@ -0,0 +1,62 @@
import type { TenantSummary } from "../../../lib/adminApi";
export type DomainConflict = {
domain: string;
tenant: TenantSummary;
};
export type ServerDomainConflict = {
domain: string;
tenantId?: string;
tenantName?: string;
tenantSlug?: string;
};
export function normalizeDomainTokens(value: string): string[] {
const seen = new Set<string>();
const tokens: string[] = [];
for (const raw of value.split(/[,\s;]+/)) {
const token = raw.trim().toLowerCase();
if (!token || seen.has(token)) {
continue;
}
seen.add(token);
tokens.push(token);
}
return tokens;
}
export function findDomainConflict(
domain: string,
tenants: TenantSummary[] = [],
currentTenantId?: string,
): DomainConflict | null {
const normalized = domain.trim().toLowerCase();
if (!normalized) {
return null;
}
for (const tenant of tenants) {
if (tenant.id === currentTenantId) {
continue;
}
const domains = tenant.domains ?? [];
if (domains.some((item) => item.trim().toLowerCase() === normalized)) {
return { domain: normalized, tenant };
}
}
return null;
}
export function formatDomainConflictMessage(
conflict: DomainConflict | ServerDomainConflict,
): string {
const tenantName =
"tenant" in conflict
? conflict.tenant.name
: conflict.tenantName ||
conflict.tenantSlug ||
conflict.tenantId ||
"다른";
return `${conflict.domain} 도메인은 ${tenantName} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`;
}

View File

@@ -0,0 +1,126 @@
import { describe, expect, it } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
mergeTenantOrgConfig,
ORG_UNIT_TYPE_OPTIONS,
readTenantOrgConfig,
removeTenantOrgConfig,
shouldAllowHanmacOrgConfig,
} from "./orgConfig";
function tenant(
id: string,
type: string,
name: string,
slug: string,
parentId?: string,
): TenantSummary {
return {
id,
type,
name,
slug,
description: "",
status: "active",
parentId,
memberCount: 0,
createdAt: "2026-05-11T00:00:00.000Z",
updatedAt: "2026-05-11T00:00:00.000Z",
};
}
describe("tenant org config", () => {
it("allows org config only for hanmac-family descendants", () => {
const family = tenant(
"family",
"COMPANY_GROUP",
"한맥가족",
"hanmac-family",
);
const saman = tenant("saman", "COMPANY", "삼안", "saman", "family");
const team = tenant("team", "USER_GROUP", "기획팀", "planning", "saman");
const outsider = tenant("outsider", "COMPANY", "외부", "outsider");
const tenants = [family, saman, team, outsider];
expect(shouldAllowHanmacOrgConfig(team, tenants)).toBe(true);
expect(shouldAllowHanmacOrgConfig(family, tenants)).toBe(false);
expect(shouldAllowHanmacOrgConfig(outsider, tenants)).toBe(false);
});
it("reads and writes tenant visibility and org unit type", () => {
expect(
readTenantOrgConfig({ visibility: "private", orgUnitType: "팀" }),
).toEqual({
orgUnitType: "팀",
visibility: "private",
worksmobileExcluded: false,
});
expect(
readTenantOrgConfig({ visibility: "internal", orgUnitType: "센터" }),
).toEqual({
orgUnitType: "센터",
visibility: "internal",
worksmobileExcluded: false,
});
expect(
mergeTenantOrgConfig(
{ userSchema: [], visibility: "private", orgUnitType: "팀" },
{
orgUnitType: "",
visibility: "internal",
worksmobileExcluded: false,
},
),
).toEqual({
userSchema: [],
visibility: "internal",
worksmobileExcluded: false,
});
});
it("reads, writes, and removes the Worksmobile exclusion flag", () => {
expect(readTenantOrgConfig({ worksmobileExcluded: true })).toMatchObject({
worksmobileExcluded: true,
});
expect(readTenantOrgConfig({ worksmobileExcluded: "true" })).toMatchObject({
worksmobileExcluded: true,
});
expect(
mergeTenantOrgConfig(
{ userSchema: [], worksmobileExcluded: false },
{
orgUnitType: "팀",
visibility: "private",
worksmobileExcluded: true,
},
),
).toEqual({
userSchema: [],
orgUnitType: "팀",
visibility: "private",
worksmobileExcluded: true,
});
expect(
removeTenantOrgConfig({
userSchema: [],
orgUnitType: "팀",
visibility: "private",
worksmobileExcluded: true,
}),
).toEqual({ userSchema: [] });
});
it("includes task-force and executive-direct org unit types", () => {
expect(ORG_UNIT_TYPE_OPTIONS).toEqual(
expect.arrayContaining(["TF", "TF팀", "임원직속"]),
);
expect(readTenantOrgConfig({ orgUnitType: "TF" }).orgUnitType).toBe("TF");
expect(readTenantOrgConfig({ orgUnitType: "TF팀" }).orgUnitType).toBe(
"TF팀",
);
expect(readTenantOrgConfig({ orgUnitType: "임원직속" }).orgUnitType).toBe(
"임원직속",
);
});
});

View File

@@ -0,0 +1,118 @@
import type { TenantSummary } from "../../../lib/adminApi";
export const ORG_UNIT_TYPE_OPTIONS = [
"실",
"팀",
"TF",
"TF팀",
"센터",
"디비전",
"셀",
"본부",
"지역본부",
"부",
"임원직속",
] as const;
export const USER_GROUP_ORG_UNIT_TYPE_OPTIONS = [
"팀",
"TF",
"TF팀",
"셀",
] as const;
export const TENANT_VISIBILITY_OPTIONS = [
{ label: "공개", value: "public" },
{ label: "내부", value: "internal" },
{ label: "비공개", value: "private" },
] as const;
export type TenantVisibility =
(typeof TENANT_VISIBILITY_OPTIONS)[number]["value"];
export type TenantOrgConfig = {
orgUnitType: string;
visibility: TenantVisibility;
worksmobileExcluded: boolean;
};
const ORG_UNIT_TYPE_SET = new Set<string>(ORG_UNIT_TYPE_OPTIONS);
const TENANT_VISIBILITY_SET = new Set<string>(
TENANT_VISIBILITY_OPTIONS.map((option) => option.value),
);
export function shouldAllowHanmacOrgConfig(
tenant: Pick<TenantSummary, "id" | "parentId" | "slug">,
tenants: Array<Pick<TenantSummary, "id" | "parentId" | "slug">>,
) {
if (tenant.slug.toLowerCase() === "hanmac-family") return false;
const byId = new Map(tenants.map((item) => [item.id, item]));
let parentId = tenant.parentId;
const visited = new Set<string>();
while (parentId) {
if (visited.has(parentId)) return false;
visited.add(parentId);
const parent = byId.get(parentId);
if (!parent) return false;
if (parent.slug.toLowerCase() === "hanmac-family") return true;
parentId = parent.parentId;
}
return false;
}
export function getOrgUnitTypeOptionsForTenantType(type: string) {
return type === "USER_GROUP"
? USER_GROUP_ORG_UNIT_TYPE_OPTIONS
: ORG_UNIT_TYPE_OPTIONS;
}
export function readTenantOrgConfig(
config: Record<string, unknown> | undefined,
): TenantOrgConfig {
const rawVisibility = String(config?.visibility ?? "public").toLowerCase();
const rawOrgUnitType = String(config?.orgUnitType ?? "");
const rawWorksmobileExcluded = config?.worksmobileExcluded;
return {
orgUnitType: ORG_UNIT_TYPE_SET.has(rawOrgUnitType) ? rawOrgUnitType : "",
visibility: TENANT_VISIBILITY_SET.has(rawVisibility)
? (rawVisibility as TenantVisibility)
: "public",
worksmobileExcluded:
rawWorksmobileExcluded === true ||
String(rawWorksmobileExcluded ?? "")
.trim()
.toLowerCase() === "true",
};
}
export function mergeTenantOrgConfig(
config: Record<string, unknown> | undefined,
next: TenantOrgConfig,
) {
const { orgUnitType: _orgUnitType, ...rest } = config ?? {};
const merged = { ...rest };
merged.visibility = next.visibility;
merged.worksmobileExcluded = next.worksmobileExcluded;
if (next.orgUnitType) {
merged.orgUnitType = next.orgUnitType;
}
return merged;
}
export function removeTenantOrgConfig(
config: Record<string, unknown> | undefined,
) {
const {
orgUnitType: _orgUnitType,
visibility: _visibility,
worksmobileExcluded: _worksmobileExcluded,
...rest
} = config ?? {};
return rest;
}

View File

@@ -0,0 +1,12 @@
import { describe, expect, it } from "vitest";
import { getSeedTenantSlugs, isSeedTenant } from "./protectedTenants";
describe("protectedTenants", () => {
it("marks tenants from seed-tenant.csv as protected", () => {
expect(getSeedTenantSlugs()).toEqual(
expect.arrayContaining(["hanmac-family", "personal"]),
);
expect(isSeedTenant({ slug: "hanmac-family" })).toBe(true);
expect(isSeedTenant({ slug: "normal-tenant" })).toBe(false);
});
});

View File

@@ -0,0 +1,19 @@
// Vite ?raw import는 seed CSV를 빌드 타임 상수로 번들합니다.
// eslint-disable-next-line import/no-unresolved
import seedTenantCSVRaw from "../../../../seed-tenant.csv?raw";
import type { TenantSummary } from "../../../lib/adminApi";
import { parseTenantCSV } from "./tenantCsvImport";
const seedTenantSlugs = new Set(
parseTenantCSV(seedTenantCSVRaw)
.map((row) => row.slug.trim().toLowerCase())
.filter(Boolean),
);
export function isSeedTenant(tenant: Pick<TenantSummary, "slug">): boolean {
return seedTenantSlugs.has(tenant.slug.trim().toLowerCase());
}
export function getSeedTenantSlugs(): string[] {
return Array.from(seedTenantSlugs);
}

View File

@@ -0,0 +1,383 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import type { TenantSummary } from "../../../lib/adminApi";
import {
buildTenantImportParentOptionGroups,
buildTenantImportPreview,
inferTenantImportRootParentSlug,
parseTenantCSV,
serializeTenantImportCSV,
} from "./tenantCsvImport";
const tenants: TenantSummary[] = [
{
id: "tenant-1",
type: "COMPANY",
name: "Hanmac Technology",
slug: "hanmac",
description: "",
status: "active",
domains: ["hanmac.example.com"],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "tenant-2",
type: "COMPANY",
name: "Saman Engineering",
slug: "saman",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "tenant-3",
type: "COMPANY_GROUP",
name: "Hanmac Family",
slug: "hanmac-family",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "tenant-4",
type: "ORGANIZATION",
name: "기획부",
slug: "planning",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
describe("tenantCsvImport", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("parses tenant CSV rows with the supported import columns", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,internal,센터,no\n",
);
expect(rows).toEqual([
{
rowNumber: 2,
tenantId: "",
name: "Hanmac Tech",
type: "COMPANY",
parentTenantId: "",
parentTenantSlug: "",
slug: "hanmac-tech",
memo: "Memo",
emailDomain: "hanmac-tech.example.com",
visibility: "internal",
orgUnitType: "센터",
worksmobileSync: "no",
},
]);
});
it("puts tenant_id-less rows with exact or similar matches first", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\n,New Tenant,COMPANY,,new-tenant,,\n,Hanmac Tech,COMPANY,,hanmac-tech,,\n,Saman Engineering,COMPANY,,saman-copy,,\n",
);
const preview = buildTenantImportPreview(rows, tenants);
expect(preview.map((row) => row.row.name)).toEqual([
"Saman Engineering",
"Hanmac Tech",
"New Tenant",
]);
expect(preview[0].candidates[0]).toMatchObject({
tenantId: "tenant-2",
reason: "exact_name",
});
expect(preview[1].candidates[0]).toMatchObject({
tenantId: "tenant-1",
reason: "similar_name",
});
expect(preview[2].candidates).toEqual([]);
});
it("serializes selected matches by filling tenant_id before upload", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync\n,Hanmac Tech,COMPANY,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀,no\n",
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: "tenant-1",
});
expect(csv.split("\n")[0]).toBe(
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync",
);
expect(csv).toContain(
"tenant-1,Hanmac Tech,COMPANY,,,hanmac-tech,Memo,hanmac-tech.example.com,private,팀,no",
);
});
it("serializes create resolutions by resetting external tenant id and conflicting slug", () => {
const rows = parseTenantCSV(
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain\nlocal-tenant-id,Hanmac Technology,COMPANY,,hanmac,Memo,hanmac.example.com\n",
);
const preview = buildTenantImportPreview(rows, tenants);
expect(preview[0].conflicts).toEqual(
expect.arrayContaining(["external_tenant_id", "slug_exists"]),
);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
tenantId: "staging-new-tenant-id",
slug: "hanmac-imported",
},
});
expect(csv).toContain(
"staging-new-tenant-id,Hanmac Technology,COMPANY,,,hanmac-imported,Memo,hanmac.example.com",
);
expect(csv).not.toContain("local-tenant-id");
});
it("preserves source tenant_id when a create resolution does not override it", () => {
const exportedTenantId = "11111111-2222-4333-8444-555555555555";
const rows = parseTenantCSV(
`tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain
${exportedTenantId},Tenant With UUID,COMPANY,,,tenant-with-uuid,Memo,tenant-with-uuid.example.com
`,
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
slug: "tenant-with-uuid",
},
});
expect(csv).toContain(
`${exportedTenantId},Tenant With UUID,COMPANY,,,tenant-with-uuid,Memo,tenant-with-uuid.example.com`,
);
});
it("remaps child parent_tenant_id from source ids to selected staging ids", () => {
const rows = parseTenantCSV(
[
"tenant_id,name,type,parent_tenant_id,slug,memo,email_domain",
"local-parent-id,Parent Tenant,COMPANY,,parent-local,,",
"local-child-id,Child Tenant,ORGANIZATION,local-parent-id,child-local,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
tenantId: "staging-parent-id",
slug: "parent-staging",
},
3: {
mode: "create",
tenantId: "staging-child-id",
slug: "child-staging",
},
});
expect(csv).toContain(
"staging-parent-id,Parent Tenant,COMPANY,,,parent-staging,,",
);
expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,,child-staging,,",
);
expect(csv).not.toContain("local-parent-id");
expect(csv).not.toContain("local-child-id");
});
it("parses parent_tenant_slug and remaps it to selected staging ids", () => {
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"Parent Tenant,COMPANY,,parent-slug,,",
"Child Tenant,ORGANIZATION,parent-slug,child-slug,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
tenantId: "staging-parent-id",
slug: "parent-slug",
},
3: {
mode: "create",
tenantId: "staging-child-id",
slug: "child-slug",
},
});
expect(rows[1].parentTenantSlug).toBe("parent-slug");
expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,",
);
});
it("keeps parent_tenant_slug in the serialized CSV as a fallback for hierarchy import", () => {
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"Parent Tenant,COMPANY,,parent-slug,,",
"Child Tenant,ORGANIZATION,parent-slug,child-slug,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
tenantId: "staging-parent-id",
slug: "parent-slug",
},
3: {
mode: "create",
tenantId: "staging-child-id",
slug: "child-slug",
},
});
expect(csv.split("\n")[0]).toBe(
"tenant_id,name,type,parent_tenant_id,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type,worksmobile_sync",
);
expect(csv).toContain(
"staging-child-id,Child Tenant,ORGANIZATION,staging-parent-id,parent-slug,child-slug,,,,,yes",
);
});
it("parses Naver Works organization CSV columns into tenant import rows", () => {
const rows = parseTenantCSV(
[
'"조직명","멤버 수","조직장","조직 다국어명","설명","메일링 리스트","상위 조직"',
'"기술개발센터","1","","","","tdc@samaneng.com",""',
'"기획부","1","","","","planning@samaneng.com","기술개발센터(tdc@samaneng.com)"',
'"업무팀","0","","","","t_226wn@samaneng.com","기획부(planning@samaneng.com)"',
].join("\n"),
{ rootParentSlug: "saman" },
);
expect(rows).toMatchObject([
{
name: "기술개발센터",
type: "ORGANIZATION",
slug: "tdc",
parentTenantSlug: "saman",
},
{
name: "기획부",
type: "ORGANIZATION",
slug: "planning",
parentTenantSlug: "tdc",
},
{
name: "업무팀",
type: "ORGANIZATION",
slug: "t-226wn",
parentTenantSlug: "planning",
},
]);
});
it("infers root parent slug from an organization CSV file prefix that matches an existing slug", () => {
expect(inferTenantImportRootParentSlug("saman_org.csv", tenants)).toBe(
"saman",
);
expect(
inferTenantImportRootParentSlug("/tmp/hanmac-family_org.csv", tenants),
).toBe("hanmac-family");
expect(
inferTenantImportRootParentSlug("saman_org_slugged.csv", tenants),
).toBe("saman");
expect(inferTenantImportRootParentSlug("unknown_org.csv", tenants)).toBe(
"",
);
expect(inferTenantImportRootParentSlug("tenant-import.csv", tenants)).toBe(
"",
);
});
it("groups existing parent candidates by company group, company, and organization", () => {
const groups = buildTenantImportParentOptionGroups(tenants);
expect(groups.map((group) => group.type)).toEqual([
"COMPANY_GROUP",
"COMPANY",
"ORGANIZATION",
]);
expect(
groups.map((group) => group.tenants.map((tenant) => tenant.id)),
).toEqual([["tenant-3"], ["tenant-1", "tenant-2"], ["tenant-4"]]);
});
it("keeps generated ids stable and follows edited parent slugs for child rows", () => {
const randomUUID = vi
.fn()
.mockReturnValueOnce("parent-generated-id")
.mockReturnValueOnce("child-generated-id");
vi.stubGlobal("crypto", { randomUUID });
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"기술개발센터,ORGANIZATION,saman,t-536fc,,",
"일반구조물 div,ORGANIZATION,t-536fc,t-568cz,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: { mode: "create", slug: "tech-center" },
3: { mode: "create", slug: "structure-div" },
});
expect(csv).toContain(
"parent-generated-id,기술개발센터,ORGANIZATION,,saman,tech-center,,",
);
expect(csv).toContain(
"child-generated-id,일반구조물 div,ORGANIZATION,parent-generated-id,tech-center,structure-div,,",
);
});
it("serializes explicit parent tenant selections from the import preview", () => {
const rows = parseTenantCSV(
[
"name,type,parent_tenant_slug,slug,memo,email_domain",
"기술개발센터,ORGANIZATION,saman,t-536fc,,",
"일반구조물 div,ORGANIZATION,t-536fc,t-568cz,,",
].join("\n"),
);
const preview = buildTenantImportPreview(rows, tenants);
const csv = serializeTenantImportCSV(preview, {
2: {
mode: "create",
slug: "tech-center",
parentTenantId: "tenant-2",
parentTenantSlug: "",
},
3: {
mode: "create",
slug: "structure-div",
parentTenantSlug: "tech-center",
},
});
expect(csv).toContain("기술개발센터,ORGANIZATION,tenant-2,,tech-center,,");
expect(csv).toContain(",일반구조물 div,ORGANIZATION,");
expect(csv).toContain(",tech-center,structure-div,,");
});
});

View File

@@ -0,0 +1,679 @@
import type { TenantSummary } from "../../../lib/adminApi";
export type TenantCSVRow = {
rowNumber: number;
tenantId: string;
name: string;
type: string;
parentTenantId: string;
parentTenantSlug: string;
slug: string;
memo: string;
emailDomain: string;
visibility: string;
orgUnitType: string;
worksmobileSync: string;
};
export type TenantCSVParseOptions = {
rootParentSlug?: string;
};
type TenantCSVSourceKey = keyof TenantCSVRow | "mailingList" | "parentOrg";
export type TenantImportCandidate = {
tenantId: string;
name: string;
slug: string;
score: number;
reason: "exact_name" | "exact_slug" | "similar_name";
};
export type TenantImportPreviewRow = {
row: TenantCSVRow;
candidates: TenantImportCandidate[];
defaultTenantId: string;
defaultCreateSlug: string;
conflicts: TenantImportConflict[];
};
export type TenantImportParentOptionGroupType =
| "COMPANY_GROUP"
| "COMPANY"
| "ORGANIZATION";
export type TenantImportParentOptionGroup = {
type: TenantImportParentOptionGroupType;
tenants: TenantSummary[];
};
export type TenantImportConflict =
| "external_tenant_id"
| "slug_exists"
| "parent_tenant_id_unresolved";
export type TenantImportResolution =
| {
mode: "existing";
tenantId: string;
parentTenantId?: string;
parentTenantSlug?: string;
}
| {
mode: "create";
tenantId?: string;
slug?: string;
parentTenantId?: string;
parentTenantSlug?: string;
}
| {
mode: "skip";
};
const importHeaders = [
"tenant_id",
"name",
"type",
"parent_tenant_id",
"parent_tenant_slug",
"slug",
"memo",
"email_domain",
"visibility",
"org_unit_type",
"worksmobile_sync",
];
const headerAliases: Record<string, TenantCSVSourceKey> = {
id: "tenantId",
tenantid: "tenantId",
tenant_id: "tenantId",
name: "name",
: "name",
type: "type",
parentid: "parentTenantId",
parent_id: "parentTenantId",
parenttenantid: "parentTenantId",
parent_tenant_id: "parentTenantId",
parenttenantslug: "parentTenantSlug",
parent_tenant_slug: "parentTenantSlug",
_조직: "parentOrg",
slug: "slug",
memo: "memo",
description: "memo",
: "memo",
_리스트: "mailingList",
"email-domain": "emailDomain",
emaildomain: "emailDomain",
email_domain: "emailDomain",
domain: "emailDomain",
domains: "emailDomain",
visibility: "visibility",
public_setting: "visibility",
publicsetting: "visibility",
orgunittype: "orgUnitType",
org_unit_type: "orgUnitType",
"org-unit-type": "orgUnitType",
organizationtype: "orgUnitType",
organization_type: "orgUnitType",
orgtype: "orgUnitType",
org_type: "orgUnitType",
worksmobile: "worksmobileSync",
worksmobilesync: "worksmobileSync",
worksmobile_sync: "worksmobileSync",
works_sync: "worksmobileSync",
works: "worksmobileSync",
};
export function parseTenantCSV(
text: string,
options: TenantCSVParseOptions = {},
): TenantCSVRow[] {
const records = parseCSV(text.replace(/^\uFEFF/, ""));
if (records.length === 0) return [];
const header = new Map<TenantCSVSourceKey, number>();
records[0].forEach((column, index) => {
const normalized = normalizeHeader(column);
const key = headerAliases[normalized];
if (key) header.set(key, index);
});
const isOrgChartCSV = header.has("mailingList") || header.has("parentOrg");
const sourceRows = records.slice(1).flatMap((record, index) => {
if (record.every((value) => value.trim() === "")) return [];
const value = (key: TenantCSVSourceKey) => {
const columnIndex = header.get(key);
if (columnIndex === undefined) return "";
return (record[columnIndex] ?? "").trim();
};
return {
raw: record,
rowNumber: index + 2,
name: value("name"),
slug: value("slug") || slugFromMailingList(value("mailingList")),
mailingList: value("mailingList"),
parentOrg: value("parentOrg"),
value,
};
});
const slugByName = new Map(
sourceRows
.filter((row) => row.name && row.slug)
.map((row) => [row.name, row.slug] as const),
);
return sourceRows.map(({ rowNumber, name, slug, parentOrg, value }) => {
const parentTenantSlug =
value("parentTenantSlug") ||
slugFromParentOrg(parentOrg, slugByName) ||
(isOrgChartCSV ? options.rootParentSlug || "" : "");
return {
rowNumber,
tenantId: value("tenantId"),
name,
type: value("type") || (isOrgChartCSV ? "ORGANIZATION" : ""),
parentTenantId: value("parentTenantId"),
parentTenantSlug,
slug,
memo: value("memo"),
emailDomain: value("emailDomain"),
visibility: value("visibility"),
orgUnitType: value("orgUnitType"),
worksmobileSync: normalizeWorksmobileSync(value("worksmobileSync")),
};
});
}
export function inferTenantImportRootParentSlug(
fileName: string,
tenants: TenantSummary[] = [],
) {
const baseName = fileName.trim().split(/[\\/]/).pop()?.toLowerCase() ?? "";
const [prefix = ""] = baseName.split("_");
if (!prefix) return "";
const existingTenant = tenants.find(
(tenant) => tenant.slug.toLowerCase() === prefix,
);
return existingTenant ? prefix : "";
}
export function buildTenantImportParentOptionGroups(
tenants: TenantSummary[],
): TenantImportParentOptionGroup[] {
const orderedTypes: TenantImportParentOptionGroupType[] = [
"COMPANY_GROUP",
"COMPANY",
"ORGANIZATION",
];
return orderedTypes
.map((type) => ({
type,
tenants: tenants.filter((tenant) => tenant.type?.toUpperCase() === type),
}))
.filter((group) => group.tenants.length > 0);
}
export function buildTenantImportPreview(
rows: TenantCSVRow[],
tenants: TenantSummary[],
): TenantImportPreviewRow[] {
return rows
.map((row) => {
const candidates = findTenantCandidates(row, tenants);
const conflicts = findTenantImportConflicts(row, tenants);
return {
row,
candidates,
conflicts,
defaultTenantId:
candidates[0] && candidates[0].score >= 0.95
? candidates[0].tenantId
: "",
defaultCreateSlug: suggestUniqueTenantSlug(
row.slug || row.name,
tenants,
),
};
})
.sort((a, b) => {
const aScore = a.candidates[0]?.score ?? 0;
const bScore = b.candidates[0]?.score ?? 0;
if (bScore !== aScore) return bScore - aScore;
return a.row.rowNumber - b.row.rowNumber;
});
}
export function serializeTenantImportCSV(
previewRows: TenantImportPreviewRow[],
selectedTenantIds: Record<number, string | TenantImportResolution>,
) {
const lines = [importHeaders];
const sortedRows = [...previewRows].sort(
(a, b) => a.row.rowNumber - b.row.rowNumber,
);
const targetTenantIds = buildTargetTenantIds(sortedRows, selectedTenantIds);
for (const preview of sortedRows) {
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
if (typeof resolution === "object" && resolution.mode === "skip") {
continue;
}
const selectedTenantId =
typeof resolution === "string"
? resolution
: resolution.mode === "existing"
? resolution.tenantId
: "";
const slug =
typeof resolution === "object" && resolution.mode === "create"
? resolution.slug || preview.defaultCreateSlug
: preview.row.slug;
const hasParentTenantIdOverride =
typeof resolution === "object" &&
Object.hasOwn(resolution, "parentTenantId");
const hasParentTenantSlugOverride =
typeof resolution === "object" &&
Object.hasOwn(resolution, "parentTenantSlug");
const sourceParentTenantSlug = hasParentTenantSlugOverride
? resolution.parentTenantSlug || ""
: preview.row.parentTenantSlug;
const parentTenantId =
typeof resolution === "object"
? hasParentTenantIdOverride
? resolution.parentTenantId || ""
: remapParentTenantId(
preview.row.parentTenantId,
sourceParentTenantSlug,
targetTenantIds,
)
: preview.row.parentTenantId;
const parentTenantSlug = remapParentTenantSlug(
sourceParentTenantSlug,
targetTenantIds,
);
const tenantId =
targetTenantIds.byRowNumber.get(preview.row.rowNumber) ??
selectedTenantId ??
preview.row.tenantId;
lines.push([
tenantId,
preview.row.name,
preview.row.type,
parentTenantId,
parentTenantSlug,
slug,
preview.row.memo,
preview.row.emailDomain,
preview.row.visibility,
preview.row.orgUnitType,
preview.row.worksmobileSync || "yes",
]);
}
return `${lines.map(formatCSVRecord).join("\n")}\n`;
}
function buildTargetTenantIds(
previewRows: TenantImportPreviewRow[],
selectedTenantIds: Record<number, string | TenantImportResolution>,
) {
const byRowNumber = new Map<number, string>();
const bySourceId = new Map<string, string>();
const bySourceSlug = new Map<string, string>();
const bySourceSlugToTargetSlug = new Map<string, string>();
for (const preview of previewRows) {
const resolution = selectedTenantIds[preview.row.rowNumber] ?? "";
if (typeof resolution === "object" && resolution.mode === "skip") {
continue;
}
const sourceTenantId = isUUIDLikeTenantId(preview.row.tenantId)
? preview.row.tenantId
: "";
const targetTenantId =
typeof resolution === "string"
? resolution || sourceTenantId
: resolution.mode === "existing"
? resolution.tenantId
: resolution.tenantId || sourceTenantId || createTenantImportId();
const targetSlug =
typeof resolution === "object" && resolution.mode === "create"
? resolution.slug || preview.defaultCreateSlug
: preview.row.slug;
if (targetTenantId) {
byRowNumber.set(preview.row.rowNumber, targetTenantId);
}
if (preview.row.tenantId) {
bySourceId.set(preview.row.tenantId, targetTenantId);
}
if (preview.row.slug) {
bySourceSlug.set(preview.row.slug.toLowerCase(), targetTenantId);
bySourceSlugToTargetSlug.set(preview.row.slug.toLowerCase(), targetSlug);
}
if (targetSlug) {
bySourceSlug.set(targetSlug.toLowerCase(), targetTenantId);
bySourceSlugToTargetSlug.set(targetSlug.toLowerCase(), targetSlug);
}
}
return { byRowNumber, bySourceId, bySourceSlug, bySourceSlugToTargetSlug };
}
function remapParentTenantId(
parentTenantId: string,
parentTenantSlug: string,
targetTenantIds: {
byRowNumber: Map<number, string>;
bySourceId: Map<string, string>;
bySourceSlug: Map<string, string>;
bySourceSlugToTargetSlug: Map<string, string>;
},
) {
if (parentTenantId) {
return targetTenantIds.bySourceId.get(parentTenantId) ?? parentTenantId;
}
if (parentTenantSlug) {
return (
targetTenantIds.bySourceSlug.get(parentTenantSlug.toLowerCase()) ?? ""
);
}
return "";
}
function remapParentTenantSlug(
parentTenantSlug: string,
targetTenantIds: {
bySourceSlugToTargetSlug: Map<string, string>;
},
) {
if (!parentTenantSlug) return "";
return (
targetTenantIds.bySourceSlugToTargetSlug.get(
parentTenantSlug.toLowerCase(),
) ?? parentTenantSlug
);
}
function createTenantImportId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `00000000-0000-4000-8000-${Math.random()
.toString(16)
.slice(2, 14)
.padEnd(12, "0")}`;
}
function isUUIDLikeTenantId(value: string) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value,
);
}
function findTenantImportConflicts(
row: TenantCSVRow,
tenants: TenantSummary[],
): TenantImportConflict[] {
const conflicts: TenantImportConflict[] = [];
const matchingId = row.tenantId
? tenants.find((tenant) => tenant.id === row.tenantId)
: undefined;
const matchingSlug = row.slug
? tenants.find(
(tenant) => normalizeToken(tenant.slug) === normalizeToken(row.slug),
)
: undefined;
if (row.tenantId && !matchingId) {
conflicts.push("external_tenant_id");
}
if (matchingSlug && matchingSlug.id !== row.tenantId) {
conflicts.push("slug_exists");
}
if (
row.parentTenantId &&
!tenants.some((tenant) => tenant.id === row.parentTenantId)
) {
conflicts.push("parent_tenant_id_unresolved");
}
return conflicts;
}
function findTenantCandidates(
row: TenantCSVRow,
tenants: TenantSummary[],
): TenantImportCandidate[] {
return tenants
.map((tenant) => {
const nameScore = similarity(row.name, tenant.name);
const slugScore =
normalizeToken(row.slug) &&
normalizeToken(row.slug) === normalizeToken(tenant.slug)
? 0.98
: 0;
const exactName =
normalizeToken(row.name) === normalizeToken(tenant.name);
const score = exactName ? 1 : Math.max(slugScore, nameScore);
const reason: TenantImportCandidate["reason"] = exactName
? "exact_name"
: slugScore >= 0.98
? "exact_slug"
: "similar_name";
return {
tenantId: tenant.id,
name: tenant.name,
slug: tenant.slug,
score,
reason,
};
})
.filter((candidate) => candidate.score >= 0.45)
.sort((a, b) => b.score - a.score)
.slice(0, 3);
}
function parseCSV(text: string): string[][] {
const rows: string[][] = [];
let current = "";
let row: string[] = [];
let quoted = false;
for (let i = 0; i < text.length; i += 1) {
const char = text[i];
const next = text[i + 1];
if (char === '"' && quoted && next === '"') {
current += '"';
i += 1;
continue;
}
if (char === '"') {
quoted = !quoted;
continue;
}
if (char === "," && !quoted) {
row.push(current);
current = "";
continue;
}
if ((char === "\n" || char === "\r") && !quoted) {
if (char === "\r" && next === "\n") i += 1;
row.push(current);
rows.push(row);
row = [];
current = "";
continue;
}
current += char;
}
if (current !== "" || row.length > 0) {
row.push(current);
rows.push(row);
}
return rows;
}
function formatCSVRecord(record: string[]) {
return record
.map((value) => {
if (!/[",\r\n]/.test(value)) return value;
return `"${value.replaceAll('"', '""')}"`;
})
.join(",");
}
function normalizeHeader(value: string) {
return value.trim().toLowerCase().replaceAll(" ", "_");
}
function normalizeWorksmobileSync(value: string) {
const normalized = value.trim().toLowerCase();
if (
[
"no",
"n",
"false",
"0",
"off",
"none",
"excluded",
"exclude",
"not_sync",
"not-synced",
"미연동",
"연동안함",
"제외",
].includes(normalized)
) {
return "no";
}
return "yes";
}
function slugFromMailingList(value: string) {
if (!value) return "";
return normalizeTenantSlug(value.split("@")[0] ?? value);
}
function slugFromParentOrg(value: string, slugByName: Map<string, string>) {
const trimmed = value.trim();
if (!trimmed) return "";
const match = trimmed.match(/\(([^)]+)\)/);
if (match?.[1]) {
return slugFromMailingList(match[1]);
}
return slugByName.get(trimmed) ?? normalizeTenantSlug(trimmed);
}
function normalizeTenantSlug(value: string) {
let slug = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-");
slug = slug.replace(/^-+|-+$/g, "");
if (slug.length > 25) {
slug = slug.slice(0, 25).replace(/-+$/g, "");
}
return slug;
}
function normalizeToken(value: string) {
return value
.trim()
.toLowerCase()
.replace(/[\s_-]+/g, "")
.replace(/[^\p{L}\p{N}]/gu, "");
}
function suggestUniqueTenantSlug(value: string, tenants: TenantSummary[]) {
const base = slugify(value) || "tenant";
const used = new Set(tenants.map((tenant) => tenant.slug.toLowerCase()));
if (!used.has(base)) {
return base;
}
let index = 2;
while (used.has(`${base}-${index}`)) {
index += 1;
}
return `${base}-${index}`;
}
function slugify(value: string) {
// 한글 조직명을 영어로 유추하거나 정규화하는 맵 (자주 쓰이는 단어)
const commonMappings: Record<string, string> = {
: "gpd",
: "tdc",
: "planning",
: "sales",
: "infra",
: "construction",
: "ops",
: "env",
: "biz",
: "hq",
: "dept",
: "team",
: "support",
};
const result = value.trim();
// 1. 전체 매칭 확인
if (commonMappings[result]) {
return commonMappings[result];
}
// 2. 부분 단어 치환 및 정규화
return result
.toLowerCase()
.replace(/[^a-z0-9가-힣ㄱ-ㅎㅏ-ㅣ]+/g, "-") // 특수문자 제거
.split("-")
.map((part) => commonMappings[part] || part) // 부분 단어 변환
.join("-")
.replace(/^-+|-+$/g, "");
}
function similarity(left: string, right: string) {
const a = normalizeToken(left);
const b = normalizeToken(right);
if (!a || !b) return 0;
if (a === b) return 1;
if (a.includes(b) || b.includes(a)) {
return Math.min(a.length, b.length) / Math.max(a.length, b.length);
}
const distance = levenshtein(a, b);
return 1 - distance / Math.max(a.length, b.length);
}
function levenshtein(left: string, right: string) {
const previous = Array.from({ length: right.length + 1 }, (_, i) => i);
const current = Array.from({ length: right.length + 1 }, () => 0);
for (let i = 1; i <= left.length; i += 1) {
current[0] = i;
for (let j = 1; j <= right.length; j += 1) {
const cost = left[i - 1] === right[j - 1] ? 0 : 1;
current[j] = Math.min(
current[j - 1] + 1,
previous[j] + 1,
previous[j - 1] + cost,
);
}
previous.splice(0, previous.length, ...current);
}
return previous[right.length];
}

View File

@@ -0,0 +1,149 @@
import { useQuery } from "@tanstack/react-query";
import { Building2, Plus, Users } from "lucide-react";
import { Link } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import {
fetchAllTenants,
fetchGroups,
type TenantSummary,
} from "../../../lib/adminApi";
export default function GlobalUserGroupListPage() {
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
queryKey: ["admin-tenants"],
queryFn: () => fetchAllTenants(),
});
if (isTenantsLoading)
return <div className="p-8">Loading tenants and groups...</div>;
return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex items-start justify-between flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-1">
<h2 className="text-3xl font-bold tracking-tight">User Groups</h2>
<p className="text-muted-foreground">
.
.
</p>
</div>
</header>
<div className="grid gap-6 flex-1 overflow-auto p-1">
{tenantList?.items.map((tenant) => (
<TenantGroupCard key={tenant.id} tenant={tenant} />
))}
</div>
</div>
);
}
function TenantGroupCard({ tenant }: { tenant: TenantSummary }) {
const { data: groups, isLoading } = useQuery({
queryKey: ["tenant-user-groups", tenant.id],
queryFn: () => fetchGroups(tenant.id),
});
return (
<Card className="flex flex-col min-h-0 bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2 flex-shrink-0">
<div className="space-y-1">
<CardTitle className="text-xl flex items-center gap-2">
<Building2 size={20} className="text-muted-foreground" />
{tenant.name}
<Badge variant="outline" className="ml-2">
{tenant.slug}
</Badge>
</CardTitle>
<CardDescription>
.
</CardDescription>
</div>
<Button size="sm" variant="outline" asChild>
<Link to={`/tenants/${tenant.id}/user-groups`}>
<Plus size={16} className="mr-2" />
</Link>
</Button>
</CardHeader>
<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">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead className="w-[250px]"></TableHead>
<TableHead></TableHead>
<TableHead className="w-[100px]"> </TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4} className="text-center">
Loading...
</TableCell>
</TableRow>
) : groups?.length === 0 ? (
<TableRow>
<TableCell
colSpan={4}
className="text-center text-muted-foreground py-4"
>
.
</TableCell>
</TableRow>
) : (
groups?.map((group) => (
<TableRow key={group.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
<Users size={14} className="text-primary" />
<Link
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
className="hover:underline"
>
{group.name}
</Link>
</div>
</TableCell>
<TableCell>{group.description || "-"}</TableCell>
<TableCell>{group.members?.length || 0} </TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" asChild>
<Link
to={`/tenants/${tenant.id}/user-groups/${group.id}`}
>
</Link>
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,995 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ArrowRight,
Briefcase,
Building2,
ChevronDown,
ChevronRight,
Download,
ExternalLink,
FolderOpen,
LayoutDashboard,
MoreHorizontal,
Network,
Plus,
RefreshCw,
Search,
Settings,
Trash2,
UserCircle,
UserPlus,
Users,
} from "lucide-react";
import * as React from "react";
import { useMemo, useState } from "react";
import { Link, useNavigate, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../../../components/ui/dropdown-menu";
import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
exportTenantsCSV,
exportUsersCSV,
fetchAllTenants,
fetchUsers,
type TenantSummary,
type UserSummary,
updateTenant,
updateUser,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
// --- Icons & Helpers ---
const getTenantIcon = (type?: string) => {
switch (type?.toUpperCase()) {
case "COMPANY_GROUP":
return Briefcase;
case "PERSONAL":
return UserCircle;
case "ORGANIZATION":
case "USER_GROUP":
return Network;
default:
return Building2;
}
};
// --- Components ---
const SidebarNode: React.FC<{
node: TenantNode;
level: number;
selectedId: string;
onSelect: (id: string) => void;
searchTerm: string;
}> = ({ node, level, selectedId, onSelect, searchTerm }) => {
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children && node.children.length > 0;
const isSelected = selectedId === node.id;
const TypeIcon = getTenantIcon(node.type);
// Auto-expand on search
React.useEffect(() => {
if (searchTerm) {
const matchInDescendants = (n: TenantNode): boolean => {
return n.children.some(
(c) =>
c.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
matchInDescendants(c),
);
};
if (matchInDescendants(node)) setIsExpanded(true);
}
}, [searchTerm, node]);
const isMatching =
searchTerm && node.name.toLowerCase().includes(searchTerm.toLowerCase());
return (
<div className="flex flex-col">
<button
type="button"
className={`w-full text-left flex items-center group px-2 py-1.5 rounded-md cursor-pointer transition-colors ${
isSelected
? "bg-primary text-primary-foreground font-semibold"
: "hover:bg-muted/60 text-muted-foreground hover:text-foreground"
} ${isMatching ? "ring-1 ring-primary/30 bg-primary/5" : ""}`}
onClick={() => {
onSelect(node.id);
if (hasChildren) setIsExpanded(!isExpanded);
}}
>
<div className="flex items-center flex-1 min-w-0">
{/* Indent & Expander */}
<div style={{ width: `${level * 1.2}rem` }} className="shrink-0" />
<div className="w-5 h-5 flex items-center justify-center mr-1">
{hasChildren ? (
<span className="rounded p-0.5">
{isExpanded ? (
<ChevronDown size={14} />
) : (
<ChevronRight size={14} />
)}
</span>
) : (
level > 0 && <div className="w-1 h-1 rounded-full bg-border" />
)}
</div>
<TypeIcon
size={16}
className={`shrink-0 mr-2 ${isSelected ? "text-primary-foreground" : "text-primary/70"}`}
/>
<span className="text-sm truncate">{node.name}</span>
</div>
<Badge
variant={isSelected ? "secondary" : "outline"}
className={`ml-2 text-[10px] px-1 h-4 min-w-[1.5rem] justify-center ${
isSelected ? "" : "opacity-60"
}`}
>
{node.recursiveMemberCount}
</Badge>
</button>
{isExpanded && hasChildren && (
<div className="flex flex-col">
{node.children.map((child) => (
<SidebarNode
key={child.id}
node={child}
level={level + 1}
selectedId={selectedId}
onSelect={onSelect}
searchTerm={searchTerm}
/>
))}
</div>
)}
</div>
);
};
const MemberTable: React.FC<{
tenantSlug: string;
onRefreshTrigger?: number;
allTenants?: TenantSummary[];
}> = ({ tenantSlug, onRefreshTrigger, allTenants }) => {
const queryClient = useQueryClient();
const { data, isLoading, refetch } = useQuery({
queryKey: ["tenant-members-v2", tenantSlug, onRefreshTrigger],
queryFn: () => fetchUsers(100, 0, undefined, tenantSlug),
enabled: !!tenantSlug,
});
const members = data?.items ?? [];
const [isMoveOpen, setIsMoveOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserSummary | null>(null);
const [targetTenantSlug, setTargetTenantSlug] = useState("");
const [searchTenant, setSearchTenant] = useState("");
const moveMutation = useMutation({
mutationFn: (newSlug: string) => {
if (!selectedUser) throw new Error("No user selected");
return updateUser(selectedUser.id, { tenantSlug: newSlug });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(
t("msg.info.saved_success", "사용자 조직이 변경되었습니다."),
);
setIsMoveOpen(false);
setSelectedUser(null);
refetch();
},
onError: () => toast.error(t("msg.common.error", "오류가 발생했습니다.")),
});
const removeMutation = useMutation({
mutationFn: (userId: string) =>
updateUser(userId, { tenantSlug, isRemoveTenant: true }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(t("msg.info.saved_success", "조직에서 제외되었습니다."));
refetch();
},
onError: () => toast.error(t("msg.common.error", "오류가 발생했습니다.")),
});
const handleMoveClick = (user: UserSummary) => {
setSelectedUser(user);
setTargetTenantSlug("");
setIsMoveOpen(true);
};
const filteredTenants = React.useMemo(() => {
if (!allTenants) return [];
if (!searchTenant) return allTenants;
return allTenants.filter(
(t) =>
t.name.toLowerCase().includes(searchTenant.toLowerCase()) ||
t.slug.toLowerCase().includes(searchTenant.toLowerCase()),
);
}, [allTenants, searchTenant]);
if (isLoading)
return (
<div className="py-20 text-center text-muted-foreground animate-pulse">
{t("msg.common.loading", "멤버 정보를 불러오는 중...")}
</div>
);
if (members.length === 0)
return (
<div className="py-20 flex flex-col items-center justify-center text-muted-foreground opacity-50 border-2 border-dashed rounded-lg">
<Users size={48} className="mb-4" />
<p>
{t("msg.admin.users.list.empty", "이 조직에 소속된 멤버가 없습니다.")}
</p>
</div>
);
return (
<div className="border rounded-md overflow-hidden bg-[var(--color-panel)]">
<Table>
<TableHeader className="bg-muted/30">
<TableRow>
<TableHead>{t("ui.admin.users.table.name", "이름")}</TableHead>
<TableHead>{t("ui.admin.users.table.email", "이메일")}</TableHead>
<TableHead className="text-right">
{t("ui.admin.users.table.role", "역할")}
</TableHead>
<TableHead className="w-[50px]" />
</TableRow>
</TableHeader>
<TableBody>
{members.map((user) => (
<TableRow
key={user.id}
className="hover:bg-muted/30 transition-colors"
>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-xs font-mono">{user.email}</TableCell>
<TableCell className="text-right">
<Badge
variant="secondary"
className="text-[10px] font-bold uppercase"
>
{user.role}
</Badge>
</TableCell>
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal size={14} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link to={`/users/${user.id}`}>
<ExternalLink size={14} className="mr-2" />
{t("ui.common.detail", "상세보기")}
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleMoveClick(user)}>
<ArrowRight size={14} className="mr-2" />
{t("ui.common.move_org", "타 조직으로 이동")}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
if (
window.confirm(
t(
"msg.admin.users.confirm_remove_org",
"이 조직에서 사용자를 제외하시겠습니까?",
),
)
) {
removeMutation.mutate(user.id);
}
}}
>
<FolderOpen size={14} className="mr-2" />
{t("ui.common.remove_org", "조직에서 제외")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Dialog open={isMoveOpen} onOpenChange={setIsMoveOpen}>
<DialogContent className="sm:max-w-[400px]">
<DialogHeader>
<DialogTitle>
{t("ui.common.move_org", "타 조직으로 이동")}
</DialogTitle>
<DialogDescription>
{selectedUser?.name} .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<Input
placeholder={t("ui.common.search", "조직 검색...")}
value={searchTenant}
onChange={(e) => setSearchTenant(e.target.value)}
/>
<ScrollArea className="h-48 border rounded-md p-2">
<div className="space-y-1">
{filteredTenants.map((tItem) => (
<Button
key={tItem.id}
variant={
targetTenantSlug === tItem.slug ? "secondary" : "ghost"
}
className="w-full justify-start text-sm"
onClick={() => setTargetTenantSlug(tItem.slug)}
>
{React.createElement(getTenantIcon(tItem.type), {
size: 14,
className: "mr-2 opacity-70",
})}
<span>{tItem.name}</span>
<span className="ml-2 text-[10px] text-muted-foreground opacity-50">
{tItem.slug}
</span>
</Button>
))}
{filteredTenants.length === 0 && (
<div className="py-4 text-center text-sm text-muted-foreground">
{t("msg.common.no_results", "검색 결과가 없습니다.")}
</div>
)}
</div>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setIsMoveOpen(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => moveMutation.mutate(targetTenantSlug)}
disabled={!targetTenantSlug || moveMutation.isPending}
>
{moveMutation.isPending ? "..." : t("ui.common.move", "이동")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
};
// --- Main Component ---
function TenantUserGroupsTab() {
const { tenantId } = useParams<{ tenantId: string }>();
const _navigate = useNavigate();
const queryClient = useQueryClient();
const [selectedNodeId, setSelectedNodeId] = useState<string>(tenantId || "");
const [treeSearch, setTreeSearch] = useState("");
const [refreshMembersCount, setRefreshMembersCount] = useState(0);
const [isUserAddOpen, setIsUserAddOpen] = useState(false);
const [isAddExistingOpen, setIsAddExistingOpen] = useState(false);
const [existingSearch, setExistingSearch] = useState("");
const exportChildrenMutation = useMutation({
mutationFn: (parentId: string) => exportTenantsCSV(true, parentId),
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.tenants.export_error", "테넌트 내보내기에 실패했습니다."),
),
});
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,
isLoading: isTenantsLoading,
refetch: refetchTree,
} = useQuery({
queryKey: ["tenants-full-tree-v2"],
queryFn: () => fetchAllTenants(),
});
const { currentBase } = useMemo(() => {
const allItems = allTenantsData?.items ?? [];
return buildTenantFullTree(allItems, tenantId);
}, [allTenantsData, tenantId]);
const selectedNode = useMemo(() => {
// Find selected node in the built tree
const findNode = (nodes: TenantNode[], id: string): TenantNode | null => {
if (!currentBase) return null;
if (currentBase.id === id) return currentBase;
for (const node of nodes) {
if (node.id === id) return node;
if (node.children.length > 0) {
const found = findNode(node.children, id);
if (found) return found;
}
}
return null;
};
if (!currentBase) return null;
return findNode(currentBase.children, selectedNodeId) || currentBase;
}, [currentBase, selectedNodeId]);
// Mutations
const updateParentMutation = useMutation({
mutationFn: ({
id,
parentId,
}: {
id: string;
parentId: string | undefined;
}) => updateTenant(id, { parentId: parentId || "" }),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(
t("msg.info.saved_success", "조직 구조가 업데이트되었습니다."),
);
setIsAddExistingOpen(false);
},
});
const handleRemoveNode = (id: string, name: string) => {
if (
window.confirm(
t(
"msg.admin.tenants.remove_sub_confirm",
`${name} 조직을 하위에서 제외할까요?`,
{ name },
),
)
) {
updateParentMutation.mutate({ id, parentId: undefined });
if (selectedNodeId === id) setSelectedNodeId(tenantId || "");
}
};
if (isTenantsLoading)
return (
<div className="p-12 text-center text-muted-foreground animate-pulse">
{t("msg.common.loading", "조직 정보를 불러오는 중...")}
</div>
);
if (!currentBase)
return (
<div className="p-12 text-center text-muted-foreground">
.
</div>
);
const candidates = (allTenantsData?.items ?? []).filter(
(t) =>
t.id !== tenantId &&
t.parentId !== tenantId &&
(existingSearch === "" ||
t.name.toLowerCase().includes(existingSearch.toLowerCase()) ||
t.slug.toLowerCase().includes(existingSearch.toLowerCase())),
);
return (
<div className="flex h-[calc(100vh-theme(spacing.32))] gap-6 mt-6 overflow-hidden">
{/* --- Left Panel: Sidebar Tree --- */}
<Card className="w-80 flex flex-col shrink-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="p-4 pb-2 space-y-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-bold flex items-center gap-2">
<Network size={16} className="text-primary" />
</CardTitle>
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={() => refetchTree()}
>
<RefreshCw size={14} />
</Button>
</div>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-3.5 w-3.5 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "조직 검색...")}
className="pl-8 h-8 text-xs bg-muted/40"
value={treeSearch}
onChange={(e) => setTreeSearch(e.target.value)}
/>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-2">
<ScrollArea className="h-full pr-2">
<SidebarNode
node={currentBase}
level={0}
selectedId={selectedNodeId}
onSelect={setSelectedNodeId}
searchTerm={treeSearch}
/>
</ScrollArea>
</CardContent>
<div className="p-3 border-t bg-muted/5">
<Button
variant="outline"
size="sm"
className="w-full text-xs h-8"
onClick={() => setIsAddExistingOpen(true)}
>
<Plus size={14} className="mr-1" />
{t("ui.admin.tenants.sub.add_existing", "기존 테넌트 연결")}
</Button>
</div>
</Card>
{/* --- Right Panel: Selected Node Content --- */}
<div className="flex-1 flex flex-col min-w-0">
{selectedNode ? (
<Card className="flex-1 flex flex-col border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="border-b bg-muted/5 py-4 flex flex-row items-center justify-between">
<div className="flex items-center gap-3">
<div className="p-2 rounded-lg bg-primary/10 text-primary">
{React.createElement(getTenantIcon(selectedNode.type), {
size: 24,
})}
</div>
<div>
<div className="flex items-center gap-2">
<CardTitle className="text-xl font-bold">
{selectedNode.name}
</CardTitle>
<Badge
variant="outline"
className="text-[10px] font-mono opacity-60"
>
{selectedNode.slug}
</Badge>
</div>
<div className="text-sm text-muted-foreground flex items-center gap-2 mt-0.5">
<span className="flex items-center gap-1">
<Users size={12} /> {selectedNode.recursiveMemberCount}{" "}
{t("ui.admin.tenants.table.members", "명")}
</span>
<span className="opacity-30">|</span>
<Badge variant="secondary" className="text-[10px] h-4">
{t(
`domain.tenant_type.${selectedNode.type.toLowerCase()}`,
selectedNode.type,
)}
</Badge>
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setIsUserAddOpen(true)}
>
<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"
onClick={() => exportChildrenMutation.mutate(selectedNode.id)}
disabled={exportChildrenMutation.isPending}
data-testid="tenant-subtree-export-btn"
>
<Download size={16} className="mr-2" />
{t("ui.admin.tenants.sub.export", "하위 조직 CSV")}
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-9 w-9">
<Settings size={16} />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>
{t("ui.common.manage", "조직 관리")}
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link to={`/tenants/${selectedNode.id}`}>
<LayoutDashboard size={14} className="mr-2" />
</Link>
</DropdownMenuItem>
{!selectedNode.parentId && (
<DropdownMenuItem asChild>
<Link to={`/tenants/new?parentId=${selectedNode.id}`}>
<Plus size={14} className="mr-2" />
{t("ui.admin.tenants.sub.add", "하위 부서 생성")}
</Link>
</DropdownMenuItem>
)}
{selectedNode.id !== tenantId && (
<DropdownMenuItem
className="text-destructive"
onClick={() =>
handleRemoveNode(selectedNode.id, selectedNode.name)
}
>
<Trash2 size={14} className="mr-2" />
{t("ui.common.remove", "조직 계층에서 제외")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
</CardHeader>
<CardContent className="flex-1 overflow-hidden p-0 flex flex-col">
<ScrollArea className="flex-1">
<div className="p-6 space-y-8">
{selectedNode.children.length > 0 && (
<div className="space-y-4">
<h3 className="text-sm font-bold flex items-center gap-2">
<Network size={16} className="text-muted-foreground" />
({selectedNode.children.length})
</h3>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{selectedNode.children?.map((child) => (
<Card
key={child.id}
className="cursor-pointer hover:border-primary/50 transition-colors bg-muted/20"
onClick={() => setSelectedNodeId(child.id)}
>
<CardHeader className="p-4 space-y-1">
<div className="flex items-center justify-between">
<div className="p-1.5 rounded bg-background">
{React.createElement(
getTenantIcon(child.type),
{
size: 14,
className: "text-primary",
},
)}
</div>
<Badge variant="outline" className="text-[9px]">
{child.recursiveMemberCount}{" "}
{t("ui.admin.tenants.table.members", "명")}
</Badge>
</div>
<CardTitle className="text-sm">
{child.name}
</CardTitle>
<CardDescription className="text-[10px] truncate">
{child.slug}
</CardDescription>
</CardHeader>
</Card>
))}
</div>
</div>
)}
<div className="space-y-4">
<h3 className="text-sm font-bold flex items-center gap-2">
<Users size={16} className="text-muted-foreground" />
{t("ui.admin.tenants.members.list_title", "소속 멤버")}
</h3>
<MemberTable
tenantSlug={selectedNode.slug}
onRefreshTrigger={refreshMembersCount}
allTenants={allTenantsData?.items ?? []}
/>
</div>
</div>
</ScrollArea>
</CardContent>
</Card>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground opacity-30 border-2 border-dashed rounded-lg">
<div className="text-center">
<FolderOpen size={64} className="mx-auto mb-4" />
<p> .</p>
</div>
</div>
)}
</div>
{/* --- Dialogs --- */}
<UserAddDialog
tenantSlug={selectedNode?.slug || ""}
tenantName={selectedNode?.name || ""}
open={isUserAddOpen}
onOpenChange={(open) => {
setIsUserAddOpen(open);
if (!open) setRefreshMembersCount((prev) => prev + 1);
}}
/>
<Dialog open={isAddExistingOpen} onOpenChange={setIsAddExistingOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{t("ui.admin.tenants.sub.add_existing", "기존 테넌트 연결")}
</DialogTitle>
<DialogDescription>
[{currentBase.name}] .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder="검색..."
className="pl-9"
value={existingSearch}
onChange={(e) => setExistingSearch(e.target.value)}
/>
</div>
<ScrollArea className="h-60 border rounded-md">
<Table>
<TableBody>
{candidates?.map((tenantItem) => (
<TableRow
key={tenantItem.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={() =>
updateParentMutation.mutate({
id: tenantItem.id,
parentId: tenantId,
})
}
>
<TableCell>
<div className="flex items-center gap-2">
{React.createElement(getTenantIcon(tenantItem.type), {
size: 14,
className: "text-muted-foreground",
})}
<div>
<p className="text-sm font-medium">
{tenantItem.name}
</p>
<p className="text-[10px] font-mono text-muted-foreground">
{tenantItem.slug}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="sm" className="h-7 px-2">
<Plus size={14} className="mr-1" />{" "}
{t("ui.common.add", "추가")}
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
</DialogContent>
</Dialog>
</div>
);
}
// --- Internal Support Components ---
const UserAddDialog: React.FC<{
tenantSlug: string;
tenantName: string;
open: boolean;
onOpenChange: (open: boolean) => void;
}> = ({ tenantSlug, tenantName, open, onOpenChange }) => {
const queryClient = useQueryClient();
const [userSearch, setUserSearch] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const handleSearch = async () => {
if (!userSearch) return;
setIsSearching(true);
try {
const res = await fetchUsers(20, 0, userSearch);
setSearchResults(res.items);
} catch (_err) {
toast.error(t("msg.admin.users.list.fetch_error", "사용자 검색 실패"));
} finally {
setIsSearching(false);
}
};
const handleAssign = async () => {
if (!selectedUserId) return;
setIsSubmitting(true);
try {
await updateUser(selectedUserId, { tenantSlug });
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다."));
onOpenChange(false);
resetFields();
} catch (err) {
const error = err as AxiosError<{ error?: string }>;
toast.error(
error.response?.data?.error ||
t("msg.admin.users.detail.update_error", "배정 실패"),
);
} finally {
setIsSubmitting(false);
}
};
const resetFields = () => {
setUserSearch("");
setSearchResults([]);
setSelectedUserId(null);
};
return (
<Dialog
open={open}
onOpenChange={(v) => {
onOpenChange(v);
if (!v) resetFields();
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.create.title", "멤버 추가")}
</DialogTitle>
<DialogDescription>
[{tenantName}] .
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이메일 검색...",
)}
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button
variant="secondary"
onClick={handleSearch}
disabled={isSearching}
>
<Search size={16} />
</Button>
</div>
<ScrollArea className="h-60 border rounded-md">
<Table>
<TableBody>
{searchResults?.map((user) => (
<TableRow
key={user.id}
className={`cursor-pointer hover:bg-muted/50 ${selectedUserId === user.id ? "bg-primary/5" : ""}`}
onClick={() => setSelectedUserId(user.id)}
>
<TableCell>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{user.name}</p>
<p className="text-[10px] text-muted-foreground">
{user.email}
</p>
</div>
{selectedUserId === user.id && (
<ChevronRight size={16} className="text-primary" />
)}
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={handleAssign}
disabled={isSubmitting || !selectedUserId}
>
{t("ui.common.add", "배정")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
export default TenantUserGroupsTab;

View File

@@ -0,0 +1,628 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react";
import { useState } from "react";
import { Link, useParams } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../../components/ui/select";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
addGroupMember,
assignGroupRole,
fetchAllTenants,
fetchGroup,
fetchGroupRoles,
fetchUsers,
removeGroupMember,
removeGroupRole,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
export function UserGroupDetailPage() {
const { tenantId, id } = useParams<{ tenantId: string; id: string }>();
const queryClient = useQueryClient();
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
const [selectedUserId, setSelectedUserId] = useState("");
const [searchUser, setSearchUser] = useState("");
const [isAddRoleOpen, setIsAddRoleOpen] = useState(false);
const [selectedTargetTenantId, setSelectedTargetTenantId] = useState("");
const [selectedRelation, setSelectedRelation] = useState("view");
const {
data: currentGroup,
isLoading: isGroupLoading,
error,
} = useQuery({
queryKey: ["user-group-detail", id],
queryFn: () => fetchGroup(tenantId ?? "", id ?? ""),
enabled: !!id && !!tenantId,
retry: false,
});
// Fetch assigned roles
const { data: groupRoles, isLoading: isRolesLoading } = useQuery({
queryKey: ["user-group-roles", id],
queryFn: () => fetchGroupRoles(tenantId ?? "", id ?? ""),
enabled: !!id && !!tenantId,
});
// Fetch all users for selection
const { data: userList } = useQuery({
queryKey: ["admin-users", searchUser],
queryFn: () => fetchUsers(20, 0, searchUser),
enabled: isAddMemberOpen,
});
// Fetch all tenants for role assignment
const { data: tenantList } = useQuery({
queryKey: ["admin-tenants"],
queryFn: () => fetchAllTenants(),
enabled: isAddRoleOpen,
});
const addMemberMutation = useMutation({
mutationFn: (userId: string) =>
addGroupMember(tenantId ?? "", id ?? "", userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
setIsAddMemberOpen(false);
setSelectedUserId("");
toast.success(
t("msg.admin.groups.members.add_success", "구성원이 추가되었습니다."),
);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(
error.response?.data?.error ||
error.message ||
t("err.common.unknown", "오류가 발생했습니다."),
);
},
});
const removeMemberMutation = useMutation({
mutationFn: (userId: string) =>
removeGroupMember(tenantId ?? "", id ?? "", userId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
toast.success(
t(
"msg.admin.groups.members.remove_success",
"구성원이 제외되었습니다.",
),
);
},
});
const assignRoleMutation = useMutation({
mutationFn: () =>
assignGroupRole(
tenantId ?? "",
id ?? "",
selectedTargetTenantId,
selectedRelation,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
setIsAddRoleOpen(false);
toast.success(
t("msg.admin.groups.roles.assign_success", "역할이 할당되었습니다."),
);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(
error.response?.data?.error ||
error.message ||
t("err.common.unknown", "오류가 발생했습니다."),
);
},
});
const removeRoleMutation = useMutation({
mutationFn: (role: { targetTenantId: string; relation: string }) =>
removeGroupRole(
tenantId ?? "",
id ?? "",
role.targetTenantId,
role.relation,
),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
toast.success(
t("msg.admin.groups.roles.remove_success", "역할이 회수되었습니다."),
);
},
});
if (isGroupLoading)
return (
<div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<span className="ml-3 text-muted-foreground">
{t("msg.common.loading", "로딩 중...")}
</span>
</div>
);
if (error || !currentGroup)
return (
<div className="p-8 text-center space-y-4">
<h3 className="text-xl font-semibold text-destructive">
</h3>
<div className="p-4 bg-destructive/10 text-destructive rounded-md text-left text-sm font-mono overflow-auto max-w-xl mx-auto border border-destructive/20">
<p>
Error:{" "}
{(error as AxiosError<{ error?: string }>)?.response?.data?.error ||
(error instanceof Error ? error.message : String(error)) ||
"Not found"}
</p>
</div>
<Button
variant="outline"
onClick={() => {
void queryClient.invalidateQueries({
queryKey: ["user-group-detail", id],
});
}}
>
{t("ui.common.retry", "다시 시도")}
</Button>
<div className="pt-4 border-t">
<Link
to={`/tenants/${tenantId}/organization`}
className="text-primary hover:underline text-sm"
>
{t(
"ui.admin.groups.detail.breadcrumb_org",
"조직 관리 목록으로 돌아가기",
)}
</Link>
</div>
</div>
);
return (
<div className="space-y-8 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link
to={`/tenants/${tenantId}`}
className="inline-flex items-center gap-2 hover:text-foreground transition-colors"
>
<ArrowLeft size={14} />
{t("ui.admin.groups.detail.breadcrumb_tenant", "테넌트 상세")}
</Link>
<span>/</span>
<Link
to={`/tenants/${tenantId}/organization`}
className="hover:text-foreground transition-colors"
>
{t("ui.admin.groups.detail.breadcrumb_org", "조직 관리")}
</Link>
<span>/</span>
<span className="text-foreground">
{t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}
</span>
</div>
<div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg">
<Users size={24} className="text-primary" />
</div>
<h2 className="text-3xl font-semibold">{currentGroup.name}</h2>
{currentGroup.unitType && (
<Badge variant="secondary" className="h-6 font-normal">
{currentGroup.unitType}
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{currentGroup.description ||
t("msg.common.no_description", "설명이 없습니다.")}
</p>
</div>
<div className="flex gap-2">
<Badge variant="secondary" className="font-normal">
{t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}
</Badge>
</div>
</header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 flex-1 min-h-0">
{/* Members Management */}
<Card className="flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.groups.detail.members_title", "구성원 관리")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.groups.detail.members_subtitle",
"이 조직에 소속된 사용자를 관리합니다.",
)}
</CardDescription>
</div>
<Dialog open={isAddMemberOpen} onOpenChange={setIsAddMemberOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<UserPlus size={16} className="mr-2" />
{t("ui.common.add", "추가")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t("ui.admin.groups.detail.members_title", "구성원 추가")}
</DialogTitle>
<DialogDescription>
{t(
"ui.admin.groups.detail.members_subtitle",
"사용자를 검색하여 조직 구성원으로 추가합니다.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>{t("ui.common.search", "사용자 검색")}</Label>
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이메일 또는 이름으로 검색...",
)}
value={searchUser}
onChange={(e) => setSearchUser(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>{t("ui.common.select", "사용자 선택")}</Label>
<Select
value={selectedUserId}
onValueChange={setSelectedUserId}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"ui.common.select_placeholder",
"사용자를 선택하세요",
)}
/>
</SelectTrigger>
<SelectContent>
{userList?.items.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddMemberOpen(false)}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => addMemberMutation.mutate(selectedUserId)}
disabled={!selectedUserId || addMemberMutation.isPending}
>
{t("ui.common.add", "추가")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardHeader>
<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">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead className="font-bold">
{t("ui.admin.users.list.table.name_email", "사용자")}
</TableHead>
<TableHead className="text-right font-bold">
{t("ui.admin.groups.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!currentGroup.members ||
currentGroup.members.length === 0 ? (
<TableRow>
<TableCell
colSpan={2}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.members.empty",
"구성원이 없습니다.",
)}
</TableCell>
</TableRow>
) : (
currentGroup.members.map((member) => (
<TableRow
key={member.id}
className="hover:bg-muted/30 transition-colors"
>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{member.name.charAt(0)}
</div>
<div>
<p className="font-medium text-sm">
{member.name}
</p>
<p className="text-xs text-muted-foreground">
{member.email}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={() => {
if (
confirm(
t(
"msg.admin.groups.members.remove_confirm",
"제거하시겠습니까?",
{ name: member.name },
),
)
) {
removeMemberMutation.mutate(member.id);
}
}}
>
<Trash2 size={14} />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
{/* Roles/Permissions Management (Keto Based) */}
<Card className="flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)] overflow-hidden">
<CardHeader className="flex flex-row items-center justify-between flex-shrink-0">
<div>
<CardTitle>
{t("ui.admin.groups.detail.permissions_title", "권한 관리")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.groups.detail.permissions_subtitle",
"이 조직이 다른 테넌트에 가지는 역할을 정의합니다.",
)}
</CardDescription>
</div>
<Dialog open={isAddRoleOpen} onOpenChange={setIsAddRoleOpen}>
<DialogTrigger asChild>
<Button size="sm" variant="outline">
<Shield size={16} className="mr-2" />
{t("ui.common.assign", "할당")}
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t(
"ui.admin.groups.detail.permissions_title",
"테넌트 역할 할당",
)}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.groups.roles.description",
"이 조직의 구성원들이 대상 테넌트에서 상속받을 역할을 선택하세요.",
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<Label>
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
</Label>
<Select
value={selectedTargetTenantId}
onValueChange={setSelectedTargetTenantId}
>
<SelectTrigger>
<SelectValue
placeholder={t(
"ui.admin.tenants.list.select_placeholder",
"테넌트를 선택하세요",
)}
/>
</SelectTrigger>
<SelectContent>
{tenantList?.items.map((t) => (
<SelectItem key={t.id} value={t.id}>
{t.name} ({t.slug})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>
{t("ui.admin.users.detail.form.role", "역할 (Relation)")}
</Label>
<Select
value={selectedRelation}
onValueChange={setSelectedRelation}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="view">View ( )</SelectItem>
<SelectItem value="manage">
Manage ( )
</SelectItem>
<SelectItem value="admins">
Admin ( )
</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => setIsAddRoleOpen(false)}
>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={() => assignRoleMutation.mutate()}
disabled={
!selectedTargetTenantId || assignRoleMutation.isPending
}
>
{t("ui.common.assign", "할당")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</CardHeader>
<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">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead className="font-bold">
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.users.detail.form.role", "역할")}
</TableHead>
<TableHead className="text-right font-bold">
{t("ui.admin.groups.table.actions", "액션")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isRolesLoading ? (
<TableRow>
<TableCell colSpan={3} className="text-center py-8">
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : !groupRoles || groupRoles.length === 0 ? (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.roles.empty",
"할당된 역할이 없습니다.",
)}
</TableCell>
</TableRow>
) : (
groupRoles.map((role) => (
<TableRow
key={`${role.tenantId}-${role.relation}`}
className="hover:bg-muted/30 transition-colors"
>
<TableCell>
<div className="font-medium text-sm">
{role.tenantName || role.tenantId}
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
className="capitalize font-normal"
>
{role.relation}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={() => {
if (
confirm(
t("msg.admin.groups.roles.remove_confirm"),
)
) {
removeRoleMutation.mutate({
targetTenantId: role.tenantId,
relation: role.relation,
});
}
}}
>
<Trash2 size={14} />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,323 @@
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}
name={`global-claim-definition-key-${claim.id}`}
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}
name={`global-claim-definition-label-${claim.id}`}
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}
name={`global-claim-definition-value-type-${claim.id}`}
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}
name={`global-claim-definition-read-permission-${claim.id}`}
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}
name={`global-claim-definition-write-permission-${claim.id}`}
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 || ""}
name={`global-claim-definition-description-${claim.id}`}
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>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,211 @@
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 UserDetailPage from "./UserDetailPage";
const updateUserMock = vi.hoisted(() => vi.fn());
const profileRoleMock = vi.hoisted(() => ({ role: "super_admin" }));
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
deleteUser: vi.fn(),
fetchAllTenants: vi.fn(async () => ({
items: [
{
id: "tenant-hanmac",
type: "COMPANY",
name: "한맥기술",
slug: "hanmac",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
],
total: 1,
})),
fetchMe: vi.fn(async () => ({
id: "admin-user",
role: profileRoleMock.role,
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 () => ({
id: "user-1",
email: "user@example.com",
name: "사용자",
phone: "01012345678",
role: "user",
status: "active",
tenantSlug: "hanmac",
tenant: {
id: "tenant-hanmac",
type: "COMPANY",
name: "한맥기술",
slug: "hanmac",
description: "",
status: "active",
memberCount: 1,
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
},
joinedTenants: [],
metadata: {
employee_id: {
"0": "h",
"1": "j",
"2": "k",
"3": "w",
"4": "o",
"5": "n",
},
global_custom_claims: {
contract_date: "2026-06-09",
},
},
createdAt: "2026-06-01T00:00:00Z",
updatedAt: "2026-06-01T00:00:00Z",
})),
fetchUserRpHistory: vi.fn(async () => []),
updateUser: updateUserMock,
}));
function renderUserDetailPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={["/users/user-1"]}>
<Routes>
<Route path="/users/:id" element={<UserDetailPage />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("UserDetailPage Worksmobile employee number", () => {
beforeEach(() => {
updateUserMock.mockReset();
updateUserMock.mockResolvedValue({});
profileRoleMock.role = "super_admin";
});
it("shows and saves metadata employee_id from the user edit form", async () => {
renderUserDetailPage();
const employeeInput = await screen.findByLabelText("사번");
expect(employeeInput).toHaveValue("hjkwon");
fireEvent.change(employeeInput, { target: { value: "EMP001" } });
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
expect(updateUserMock).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({
metadata: expect.objectContaining({ employee_id: "EMP001" }),
}),
);
});
it("allows super admin to save a changed email", async () => {
renderUserDetailPage();
const emailInput = await screen.findByLabelText("이메일");
fireEvent.change(emailInput, { target: { value: "changed@example.com" } });
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
expect(updateUserMock).toHaveBeenCalledWith(
"user-1",
expect.objectContaining({
email: "changed@example.com",
}),
);
});
it("shows forbidden message for non-super admin", async () => {
profileRoleMock.role = "tenant_admin";
renderUserDetailPage();
expect(
await screen.findByText("이 작업을 수행할 권한이 없습니다."),
).toBeInTheDocument();
});
it("removes metadata employee_id when the field is cleared", async () => {
renderUserDetailPage();
const employeeInput = await screen.findByLabelText("사번");
fireEvent.change(employeeInput, { target: { value: "" } });
fireEvent.click(screen.getByRole("button", { name: /저장하기/ }));
await waitFor(() => expect(updateUserMock).toHaveBeenCalled());
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",
},
}),
}),
}),
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,236 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createI18nMock } from "../../test/i18nMock";
import UserListPage from "./UserListPage";
const selectRenderCounter = vi.hoisted(() => ({ count: 0 }));
const users = Array.from({ length: 200 }, (_, index) => ({
id: `user-${index}`,
name: `User ${index}`,
email: `user${index}@example.com`,
phone: `010-${String(index).padStart(4, "0")}-0000`,
role: "user",
status: "active",
tenantSlug: "hanmac",
tenant: { id: "tenant-1", name: "한맥", slug: "hanmac" },
metadata: {},
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
}));
const fetchUsersMock = vi.hoisted(() => vi.fn());
const searchRenderBudgetMs =
process.env.npm_lifecycle_event === "test:coverage" ? 500 : 300;
vi.mock("../../lib/i18n", () => createI18nMock());
vi.mock("../../lib/adminApi", () => ({
fetchMe: vi.fn(async () => ({
id: "admin-user",
role: "super_admin",
name: "Admin",
email: "admin@example.com",
})),
fetchAllTenants: vi.fn(async () => ({
items: [{ id: "tenant-1", name: "한맥", slug: "hanmac" }],
total: 1,
})),
fetchTenant: vi.fn(async () => ({
id: "tenant-1",
name: "한맥",
slug: "hanmac",
config: { userSchema: [] },
})),
fetchUsers: fetchUsersMock,
bulkCreateUsers: vi.fn(),
bulkDeleteUsers: vi.fn(),
bulkUpdateUsers: vi.fn(),
deleteUser: vi.fn(),
exportUsersCSV: vi.fn(),
updateUser: vi.fn(),
}));
vi.mock("../../components/ui/select", () => ({
Select: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectTrigger: ({
children,
...props
}: React.ButtonHTMLAttributes<HTMLButtonElement>) => {
selectRenderCounter.count += 1;
return (
<button type="button" {...props}>
{children}
</button>
);
},
SelectValue: () => <span />,
SelectContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
SelectItem: ({
children,
value: _value,
}: {
children: React.ReactNode;
value: string;
}) => <div>{children}</div>,
}));
function renderUserListPage() {
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<UserListPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
function createDeferred<T>() {
let resolve: (value: T) => void = () => {};
const promise = new Promise<T>((promiseResolve) => {
resolve = promiseResolve;
});
return { promise, resolve };
}
describe("UserListPage search rendering", () => {
beforeEach(() => {
selectRenderCounter.count = 0;
fetchUsersMock.mockReset();
fetchUsersMock.mockImplementation(
async (_limit: number, _offset: number, search?: string) => {
const normalizedSearch = search?.trim().toLowerCase();
const items = normalizedSearch
? users.filter((user) =>
`${user.name} ${user.email}`
.toLowerCase()
.includes(normalizedSearch),
)
: users;
return { items, total: items.length };
},
);
});
it("does not rerender user table controls while typing a draft search", async () => {
renderUserListPage();
await screen.findByText("User 0");
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색");
const renderCountBeforeTyping = selectRenderCounter.count;
fireEvent.change(searchInput, { target: { value: "u" } });
expect(searchInput).toHaveValue("u");
expect(selectRenderCounter.count).toBe(renderCountBeforeTyping);
});
it("keeps rendered row controls below the full 200-user result set", async () => {
renderUserListPage();
await screen.findByText("User 0");
expect(screen.getAllByTestId(/^user-status-select-/).length).toBeLessThan(
200,
);
});
it("renders compact vertically centered user table headers", async () => {
renderUserListPage();
await screen.findByText("User 0");
const nameHeader = screen.getByRole("columnheader", { name: /이름/ });
const content = nameHeader.firstElementChild;
expect(nameHeader).toHaveClass("h-9", "py-1", "align-middle", "text-xs");
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);
renderUserListPage();
const loadingCell = await screen.findByTestId("user-table-loading-cell");
expect(loadingCell).toHaveClass(
"flex",
"items-center",
"justify-center",
"text-center",
);
expect(loadingCell).toHaveStyle({ gridColumn: "1 / -1" });
deferred.resolve({ items: users, total: users.length });
});
it("renders a 200-user search result update within 200ms after search submit", async () => {
renderUserListPage();
await screen.findByText("User 0");
const searchInput = screen.getByPlaceholderText("이름 또는 이메일 검색");
const startedAt = performance.now();
fireEvent.change(searchInput, { target: { value: "user 19" } });
fireEvent.keyDown(searchInput, { key: "Enter" });
expect(await screen.findByText("User 19")).toBeInTheDocument();
expect(screen.queryByText("User 0")).not.toBeInTheDocument();
expect(performance.now() - startedAt).toBeLessThan(searchRenderBudgetMs);
});
it("keeps rendered form fields identifiable for browser autofill diagnostics", async () => {
const { container } = renderUserListPage();
await screen.findByText("User 0");
const anonymousFields = Array.from(
container.querySelectorAll("input, select, textarea"),
).filter(
(field) =>
!field.getAttribute("id")?.trim() &&
!field.getAttribute("name")?.trim(),
);
expect(anonymousFields).toHaveLength(0);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,334 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { AlertTriangle, FolderTree, Loader2, Search } from "lucide-react";
import * as React from "react";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import { ScrollArea } from "../../../components/ui/scroll-area";
import { toast } from "../../../components/ui/use-toast";
import {
bulkUpdateUsers,
fetchAllTenants,
fetchGroups,
type UserSummary,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type UserSchemaField = {
key: string;
label: string;
required?: boolean;
};
interface UserBulkMoveGroupModalProps {
userIds: string[];
selectedUsers?: UserSummary[];
onSuccess?: () => void;
}
export function UserBulkMoveGroupModal({
userIds,
selectedUsers = [],
onSuccess,
}: UserBulkMoveGroupModalProps) {
const [open, setOpen] = React.useState(false);
const [selectedTenantSlug, setSelectedTenantSlug] =
React.useState<string>("");
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
const [searchTerm, setSearchTerm] = React.useState("");
const [acknowledgeWarning, setAcknowledgeWarning] = React.useState(false);
const _queryClient = useQueryClient();
const { data: tenantsData } = useQuery({
queryKey: ["tenants", "all"],
queryFn: () => fetchAllTenants(),
enabled: open,
});
const tenants = tenantsData?.items ?? [];
const selectedTenant = React.useMemo(
() => tenants.find((t) => t.slug === selectedTenantSlug),
[tenants, selectedTenantSlug],
);
const selectedTenantId = selectedTenant?.id ?? "";
const { data: groups, isLoading: isGroupsLoading } = useQuery({
queryKey: ["tenant-groups", selectedTenantId],
queryFn: () => fetchGroups(selectedTenantId),
enabled: open && !!selectedTenantId,
});
const schemaWarnings = React.useMemo(() => {
if (!selectedTenant || selectedUsers.length === 0) return null;
const targetSchema =
(selectedTenant.config?.userSchema as UserSchemaField[]) || [];
const targetSchemaKeys = new Set(targetSchema.map((f) => f.key));
const requiredKeys = targetSchema
.filter((f) => f.required)
.map((f) => f.key);
const missingRequiredFields = new Set<string>();
const incompatibleFields = new Set<string>();
for (const user of selectedUsers) {
const userMeta = user.metadata || {};
// 1. Check for missing required fields
for (const key of requiredKeys) {
if (
userMeta[key] === undefined ||
userMeta[key] === null ||
userMeta[key] === ""
) {
missingRequiredFields.add(key);
}
}
// 2. Check for fields that exist in user metadata but not in the target schema (data loss)
for (const key of Object.keys(userMeta)) {
if (!targetSchemaKeys.has(key)) {
incompatibleFields.add(key);
}
}
}
if (missingRequiredFields.size === 0 && incompatibleFields.size === 0) {
return null;
}
return {
missing: Array.from(missingRequiredFields),
incompatible: Array.from(incompatibleFields),
};
}, [selectedTenant, selectedUsers]);
const mutation = useMutation({
mutationFn: bulkUpdateUsers,
onSuccess: () => {
toast.success(
t(
"msg.admin.users.bulk.move_success",
"사용자들의 부서가 이동되었습니다.",
),
);
setOpen(false);
onSuccess?.();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.admin.users.bulk.move_error", "부서 이동 실패"), {
description: error.response?.data?.error || error.message,
});
},
});
const handleMove = () => {
if (!selectedTenantSlug) return;
mutation.mutate({
userIds,
tenantSlug: selectedTenantSlug,
department: selectedGroupName, // can be empty for "No Department"
});
};
const filteredGroups = React.useMemo(() => {
if (!groups) return [];
if (!searchTerm) return groups;
return groups.filter((g) =>
g.name.toLowerCase().includes(searchTerm.toLowerCase()),
);
}, [groups, searchTerm]);
return (
<Dialog
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) {
setSelectedTenantSlug("");
setSelectedGroupName("");
setAcknowledgeWarning(false);
setSearchTerm("");
}
}}
>
<DialogTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8"
>
{t("ui.admin.users.bulk.move_group", "부서 이동")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.bulk.move_title", "사용자 부서 이동")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.users.bulk.move_description",
"선택한 {{count}}명의 사용자를 이동할 테넌트와 부서를 선택하세요.",
{ count: userIds.length },
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="space-y-2">
<label className="text-sm font-medium">
{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) => {
setSelectedTenantSlug(e.target.value);
setSelectedGroupName("");
setAcknowledgeWarning(false);
}}
>
<option value="">{t("ui.common.select", "선택하세요...")}</option>
{tenants.map((t) => (
<option key={t.id} value={t.slug}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
{selectedTenantSlug && (
<div className="space-y-2">
<label className="text-sm font-medium">
{t("ui.admin.users.bulk.select_group", "부서 선택")}
</label>
<div className="relative">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t("ui.common.search", "검색...")}
className="pl-9 h-9"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<ScrollArea className="h-[200px] rounded-md border p-2">
<div className="space-y-1">
<button
type="button"
onClick={() => setSelectedGroupName("")}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
selectedGroupName === ""
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
>
<FolderTree size={14} />
{t("ui.admin.users.bulk.no_department", "(부서 없음)")}
</button>
{isGroupsLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="animate-spin text-muted-foreground" />
</div>
) : (
filteredGroups.map((group) => (
<button
key={group.id}
type="button"
onClick={() => setSelectedGroupName(group.name)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
selectedGroupName === group.name
? "bg-primary text-primary-foreground"
: "hover:bg-muted"
}`}
>
<FolderTree size={14} />
{group.name}
</button>
))
)}
</div>
</ScrollArea>
</div>
)}
{schemaWarnings && (
<div className="rounded-lg border border-destructive/20 bg-destructive/10 p-3 space-y-2 mt-4 text-sm">
<div className="flex items-center gap-2 text-destructive font-semibold">
<AlertTriangle size={16} />
{t("ui.admin.users.bulk.schema_warning", "스키마 호환성 경고")}
</div>
<div className="text-destructive/80 text-xs">
{schemaWarnings.missing.length > 0 && (
<p>
{t(
"msg.admin.users.bulk.schema_missing",
"대상 테넌트의 필수 필드가 누락되어 있습니다:",
)}{" "}
<strong>{schemaWarnings.missing.join(", ")}</strong>
</p>
)}
{schemaWarnings.incompatible.length > 0 && (
<p>
{t(
"msg.admin.users.bulk.schema_incompatible",
"대상 테넌트 스키마에 없는 필드는 유실될 수 있습니다:",
)}{" "}
<strong>{schemaWarnings.incompatible.join(", ")}</strong>
</p>
)}
</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)}
className="w-4 h-4 rounded border-gray-300 text-destructive focus:ring-destructive"
/>
<span className="font-medium text-destructive/90">
{t(
"ui.admin.users.bulk.acknowledge_warning",
"경고를 확인했으며 계속 진행합니다.",
)}
</span>
</label>
</div>
)}
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={handleMove}
disabled={
!selectedTenantSlug ||
mutation.isPending ||
(!!schemaWarnings && !acknowledgeWarning)
}
>
{mutation.isPending && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.users.bulk.do_move", "이동 실행")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,815 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import {
AlertCircle,
CheckCircle2,
Download,
FileText,
Loader2,
Upload,
} from "lucide-react";
import * as React from "react";
import { Button } from "../../../components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { DropdownMenuItem } from "../../../components/ui/dropdown-menu";
import { ScrollArea } from "../../../components/ui/scroll-area";
import {
type BulkUserItem,
type BulkUserResult,
bulkCreateUsers,
createTenant,
fetchAllTenants,
fetchUsers,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import {
buildTenantImportPreview,
type TenantCSVRow,
type TenantImportPreviewRow,
} from "../../tenants/utils/tenantCsvImport";
import { isHanmacFamilyTenant, isHanmacFamilyUser } from "../orgChartPicker";
import { parseUserCSV } from "../utils/csvParser";
import { applyGeneralPlanningOfficePriority } from "../utils/generalPlanningOfficePriority";
import {
buildHanmacImportEmailPreview,
type HanmacImportEmailPreview,
} from "../utils/hanmacImportEmail";
interface UserBulkUploadModalProps {
onSuccess?: () => void;
variant?: "button" | "dropdown" | "custom";
open?: boolean;
onOpenChange?: (open: boolean) => void;
}
function buildUserTenantPreviewRows(
users: BulkUserItem[],
tenants: Parameters<typeof buildTenantImportPreview>[1],
) {
const rowsByKey = new Map<string, TenantCSVRow>();
users.forEach((user, index) => {
const key = tenantImportKeyFromUser(user);
if (!key || rowsByKey.has(key)) {
return;
}
rowsByKey.set(key, {
rowNumber: index + 2,
tenantId: user.tenantImport?.sourceTenantId ?? "",
name: user.tenantImport?.name || user.tenantSlug || key,
type: user.tenantImport?.type || "COMPANY",
parentTenantId: user.tenantImport?.parentTenantId ?? "",
parentTenantSlug: user.tenantImport?.parentTenantSlug ?? "",
slug: user.tenantImport?.slug || user.tenantSlug || key,
memo: user.tenantImport?.memo ?? "",
emailDomain: user.tenantImport?.emailDomain ?? "",
visibility: "public",
orgUnitType: "node",
worksmobileSync: "yes",
});
});
return buildTenantImportPreview([...rowsByKey.values()], tenants);
}
function tenantImportKeyFromUser(user: BulkUserItem) {
return (
user.tenantImport?.sourceTenantId ||
user.tenantImport?.slug ||
user.tenantSlug ||
user.tenantImport?.name ||
""
);
}
function tenantImportKeyFromRow(row: TenantCSVRow) {
return row.tenantId || row.slug || row.name;
}
function splitTenantImportDomains(value: string) {
return value
.replaceAll("\n", ";")
.replaceAll(",", ";")
.split(";")
.map((domain) => domain.trim().toLowerCase())
.filter(Boolean);
}
function emailLocalPart(email: string) {
return email.trim().toLowerCase().split("@")[0] || "";
}
function hanmacEmailStatusLabel(preview?: HanmacImportEmailPreview) {
if (!preview) return "";
if (preview.status === "suggested") return "제안";
if (preview.status === "needsReview") return "확인 필요";
if (preview.status === "ruleMismatch") return "규칙 확인";
if (preview.status === "blockingError") return "오류";
return "";
}
function userImportErrorLabel(user: BulkUserItem) {
if (!user.importErrors?.includes("duplicateEmail")) {
return "";
}
return "중복 이메일";
}
function hanmacEmailStatusClass(preview?: HanmacImportEmailPreview) {
if (!preview) return "text-muted-foreground";
if (preview.status === "blockingError") return "text-destructive";
if (preview.status === "ruleMismatch" || preview.status === "needsReview") {
return "text-amber-600";
}
if (preview.status === "suggested") return "text-blue-600";
return "text-muted-foreground";
}
export const downloadUserTemplate = () => {
const headers =
"email,sub_email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1";
const example =
"user1@example.com,sub1@test.com;sub2@test.com,홍길동,010-1234-5678,user,tenant-slug,개발팀,수석,팀장,프론트엔드,EMP001,second-tenant,센터,책임,,Architecture,EMP002";
const blob = new Blob([`${headers}\n${example}`], {
type: "text/csv;charset=utf-8;",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "user_bulk_template.csv";
a.click();
URL.revokeObjectURL(url);
};
export function UserBulkUploadModal({
onSuccess,
variant = "button",
open: controlledOpen,
onOpenChange: controlledOnOpenChange,
}: UserBulkUploadModalProps) {
const [localOpen, setLocalOpen] = React.useState(false);
const open = controlledOpen !== undefined ? controlledOpen : localOpen;
const setOpen = (val: boolean) => {
setLocalOpen(val);
controlledOnOpenChange?.(val);
};
const [file, setFile] = React.useState<File | null>(null);
const [parsing, setParsing] = React.useState(false);
const [previewData, setPreviewData] = React.useState<BulkUserItem[]>([]);
const [tenantPreviewRows, setTenantPreviewRows] = React.useState<
TenantImportPreviewRow[]
>([]);
const [selectedTenantMatches, setSelectedTenantMatches] = React.useState<
Record<number, string>
>({});
const [selectedTenantCreateSlugs, setSelectedTenantCreateSlugs] =
React.useState<Record<number, string>>({});
const [results, setResults] = React.useState<BulkUserResult[] | null>(null);
const [preparing, setPreparing] = React.useState(false);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const tenantQuery = useQuery({
queryKey: ["tenants", "user-bulk-import"],
queryFn: () => fetchAllTenants(),
});
const usersQuery = useQuery({
queryKey: ["users", "user-bulk-import-email-policy"],
queryFn: () => fetchUsers(10000, 0),
enabled: open,
});
const mutation = useMutation({
mutationFn: bulkCreateUsers,
onSuccess: (data) => {
setResults(data.results);
onSuccess?.();
},
});
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = e.target.files?.[0];
if (selectedFile) {
setFile(selectedFile);
parseCSV(selectedFile);
}
};
const parseCSV = (file: File) => {
setParsing(true);
const reader = new FileReader();
reader.onload = (e) => {
const text = e.target?.result as string;
const data = parseUserCSV(text);
setPreviewData(data);
const tenantRows = buildUserTenantPreviewRows(
data,
tenantQuery.data?.items ?? [],
);
setTenantPreviewRows(tenantRows);
setSelectedTenantMatches(
Object.fromEntries(
tenantRows.map((row) => [
row.row.rowNumber,
row.defaultTenantId || "__create__",
]),
),
);
setSelectedTenantCreateSlugs(
Object.fromEntries(
tenantRows.map((row) => [row.row.rowNumber, row.defaultCreateSlug]),
),
);
setParsing(false);
};
reader.readAsText(file);
};
const handleUpload = async () => {
if (previewData.length > 0) {
setPreparing(true);
try {
const users = await resolveUserImportTenants();
mutation.mutate(users);
} finally {
setPreparing(false);
}
}
};
const resolveUserImportTenants = async () => {
const tenants = tenantQuery.data?.items ?? [];
const tenantByKey = new Map<
string,
{ id: string; slug: string; emailDomain: string }
>();
for (const preview of tenantPreviewRows) {
const key = tenantImportKeyFromRow(preview.row);
const selected =
selectedTenantMatches[preview.row.rowNumber] ?? "__create__";
if (selected !== "__create__") {
const tenant = tenants.find((item) => item.id === selected);
if (tenant) {
tenantByKey.set(key, {
id: tenant.id,
slug: tenant.slug,
emailDomain: preview.row.emailDomain,
});
}
continue;
}
const created = await createTenant({
name: preview.row.name || preview.row.slug,
slug:
selectedTenantCreateSlugs[preview.row.rowNumber] ||
preview.defaultCreateSlug,
type: preview.row.type || "COMPANY",
parentId: preview.row.parentTenantId || undefined,
description: preview.row.memo,
domains: splitTenantImportDomains(preview.row.emailDomain),
status: "active",
});
tenantByKey.set(key, {
id: created.id,
slug: created.slug,
emailDomain: preview.row.emailDomain,
});
}
return previewData.map((user, index) => {
const finalUser = applyGeneralPlanningOfficePriority(user, tenants);
const key = tenantImportKeyFromUser(finalUser);
const resolvedTenant = key ? tenantByKey.get(key) : undefined;
const emailPreview = hanmacEmailPreviews[index];
const { tenantImport: _tenantImport, ...payload } = finalUser;
return {
...payload,
email: emailPreview?.finalEmail ?? payload.email,
tenantId: resolvedTenant?.id ?? payload.tenantId,
tenantSlug: resolvedTenant?.slug ?? payload.tenantSlug,
emailDomain: resolvedTenant?.emailDomain ?? payload.emailDomain,
};
});
};
const reset = () => {
setFile(null);
setPreviewData([]);
setTenantPreviewRows([]);
setSelectedTenantMatches({});
setSelectedTenantCreateSlugs({});
setResults(null);
if (fileInputRef.current) fileInputRef.current.value = "";
};
const successCount = results?.filter((r) => r.success).length ?? 0;
const failCount = results ? results.length - successCount : 0;
const tenants = tenantQuery.data?.items ?? [];
const existingHanmacLocalParts = React.useMemo(() => {
const values = new Set<string>();
for (const user of usersQuery.data?.items ?? []) {
if (!isHanmacFamilyUser(user, tenants)) {
continue;
}
const localPart = emailLocalPart(user.email);
if (localPart) values.add(localPart);
}
return values;
}, [tenants, usersQuery.data?.items]);
const hanmacEmailPreviews = React.useMemo(() => {
const batchLocalParts = new Set<string>();
return previewData.map((user) => {
const tenant = tenants.find(
(item) =>
item.slug.toLowerCase() === user.tenantSlug?.trim().toLowerCase(),
);
if (!isHanmacFamilyTenant(tenant, tenants)) {
return undefined;
}
return buildHanmacImportEmailPreview(
user,
existingHanmacLocalParts,
batchLocalParts,
);
});
}, [existingHanmacLocalParts, previewData, tenants]);
const hasBlockingHanmacEmailRows = hanmacEmailPreviews.some(
(preview) => preview?.status === "blockingError",
);
const hasBlockingImportRows = previewData.some(
(user) => (user.importErrors?.length ?? 0) > 0,
);
const triggerProps = {
disabled: mutation.isPending,
"data-testid": "bulk-import-btn",
};
const triggerNode =
variant === "dropdown" ? (
<DropdownMenuItem
onClick={() => setOpen(true)}
className="cursor-pointer"
{...triggerProps}
>
<Upload size={16} className="mr-2 opacity-50" />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</DropdownMenuItem>
) : variant === "custom" ? null : (
<DialogTrigger asChild>
<Button variant="outline" className="gap-2" {...triggerProps}>
<Upload size={16} />
{t("ui.admin.users.list.bulk_import", "일괄 등록 (CSV)")}
</Button>
</DialogTrigger>
);
return (
<>
{variant === "dropdown" ? triggerNode : null}
<Dialog
open={open}
onOpenChange={(val) => {
setOpen(val);
if (!val) reset();
}}
>
{variant !== "dropdown" && variant !== "custom" && triggerNode}
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle data-testid="bulk-upload-title">
{t("ui.admin.users.bulk.title", "사용자 일괄 등록")}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.users.bulk.description",
"CSV 파일을 업로드하여 여러 사용자를 한 번에 등록합니다.",
)}
</DialogDescription>
</DialogHeader>
{!results ? (
<div className="space-y-4 py-4">
<div className="flex justify-between items-center">
<Button
variant="ghost"
size="sm"
onClick={downloadUserTemplate}
className="gap-2"
>
<Download size={14} />
{t(
"ui.admin.users.bulk.download_template",
"템플릿 다운로드",
)}
</Button>
<Button asChild variant="secondary" className="cursor-pointer">
<label>
{file
? t("ui.common.change_file", "파일 변경")
: t("ui.common.select_file", "파일 선택")}
<input
name="user-bulk-upload-file"
type="file"
accept=".csv"
className="hidden"
ref={fileInputRef}
onChange={handleFileChange}
onClick={(e) => {
// Allow picking the same file again if it was cleared
(e.target as HTMLInputElement).value = "";
}}
/>
</label>
</Button>
</div>
{file && (
<div className="rounded-lg border p-4 bg-muted/20">
<div className="flex items-center gap-3 mb-2">
<FileText className="text-primary" />
<span className="font-medium">{file.name}</span>
<span className="text-xs text-muted-foreground">
({(file.size / 1024).toFixed(1)} KB)
</span>
</div>
{parsing ? (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 size={14} className="animate-spin" />
{t("msg.common.parsing", "파싱 중...")}
</div>
) : (
<div className="text-sm text-muted-foreground">
{t(
"msg.admin.users.bulk.parsed_count",
"{{count}}명의 사용자가 감지되었습니다.",
{ count: previewData.length },
)}
</div>
)}
</div>
)}
{tenantPreviewRows.length > 0 && (
<div
className="rounded-md border p-3 text-sm"
data-testid="user-import-tenant-resolution"
>
<div className="mb-2 font-medium">
{t("ui.admin.users.bulk.tenant_resolution", "테넌트 매핑")}
</div>
<div className="space-y-2">
{tenantPreviewRows.map((preview) => (
<div
key={preview.row.rowNumber}
className="grid gap-2 sm:grid-cols-[1fr_1fr]"
>
<div>
<div className="font-medium">{preview.row.name}</div>
<div className="font-mono text-xs text-muted-foreground">
{preview.row.slug}
</div>
</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] ??
"__create__"
}
onChange={(event) =>
setSelectedTenantMatches((prev) => ({
...prev,
[preview.row.rowNumber]: event.target.value,
}))
}
>
<option value="__create__">
{t(
"ui.admin.users.bulk.create_missing_tenant",
"신규 생성",
)}
</option>
{preview.candidates.map((candidate) => (
<option
key={candidate.tenantId}
value={candidate.tenantId}
>
{candidate.name} ({candidate.slug})
</option>
))}
</select>
{(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[
preview.row.rowNumber
] ?? ""
}
onChange={(event) =>
setSelectedTenantCreateSlugs((prev) => ({
...prev,
[preview.row.rowNumber]: event.target.value,
}))
}
/>
)}
</div>
</div>
))}
</div>
</div>
)}
{previewData.length > 0 && (
<ScrollArea className="h-[200px] rounded-md border">
<table className="w-full text-sm">
<thead className="bg-muted sticky top-0">
<tr>
<th className="p-2 text-left">Email</th>
<th className="p-2 text-left">Name</th>
<th className="p-2 text-left">Tenant</th>
<th className="p-2 text-left">Status</th>
</tr>
</thead>
<tbody>
{previewData.slice(0, 10).map((u, index) => (
<tr
key={`${u.email}-${u.tenantSlug ?? ""}-${u.name}`}
className="border-t"
>
<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 ??
u.email
}
onChange={(event) =>
setPreviewData((prev) =>
prev.map((item, itemIndex) =>
itemIndex === index
? { ...item, email: event.target.value }
: item,
),
)
}
/>
</td>
<td className="p-2">{u.name}</td>
<td className="p-2">{u.tenantSlug || "-"}</td>
<td
className={`p-2 text-xs ${
u.importErrors?.length
? "text-destructive"
: hanmacEmailStatusClass(
hanmacEmailPreviews[index],
)
}`}
>
{u.importErrors?.length
? "오류"
: hanmacEmailStatusLabel(
hanmacEmailPreviews[index],
)}
{u.importErrors?.length ? (
<div>{userImportErrorLabel(u)}</div>
) : null}
{hanmacEmailPreviews[index]?.reason && (
<div>{hanmacEmailPreviews[index]?.reason}</div>
)}
</td>
</tr>
))}
{previewData.length > 10 && (
<tr>
<td
colSpan={4}
className="p-2 text-center text-muted-foreground italic"
>
... and {previewData.length - 10} more users
</td>
</tr>
)}
</tbody>
</table>
</ScrollArea>
)}
</div>
) : (
<div className="space-y-4 py-4">
<div className="flex items-center gap-4 p-4 rounded-lg bg-muted/30 border">
{results.some((r) => r.success && r.status === "created") && (
<>
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-green-600">
{
results.filter(
(r) => r.success && r.status === "created",
).length
}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.status.new", "신규")}
</div>
</div>
<div className="w-px h-10 bg-border" />
</>
)}
{results.some((r) => r.success && r.status === "updated") && (
<>
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-blue-600">
{
results.filter(
(r) => r.success && r.status === "updated",
).length
}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.status.updated", "수정")}
</div>
</div>
<div className="w-px h-10 bg-border" />
</>
)}
{results.some((r) => r.success && r.status === "unchanged") && (
<>
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-slate-500">
{
results.filter(
(r) => r.success && r.status === "unchanged",
).length
}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.status.unchanged", "동일")}
</div>
</div>
<div className="w-px h-10 bg-border" />
</>
)}
{!results.some((r) => r.success && r.status) &&
successCount > 0 && (
<>
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-green-600">
{successCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.success", "성공")}
</div>
</div>
<div className="w-px h-10 bg-border" />
</>
)}
<div className="flex-1 text-center">
<div className="text-2xl font-bold text-destructive">
{failCount}
</div>
<div className="text-xs text-muted-foreground uppercase">
{t("ui.common.fail", "실패")}
</div>
</div>
</div>
<ScrollArea className="h-[250px] rounded-md border">
<div className="p-2 space-y-2">
{results.map((r) => (
<div
key={r.email}
className="flex items-start gap-3 p-2 rounded border bg-card text-sm"
>
{r.success ? (
<CheckCircle2
size={16}
className="text-green-500 mt-0.5"
/>
) : (
<AlertCircle
size={16}
className="text-destructive mt-0.5"
/>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<div className="font-medium truncate">{r.email}</div>
{r.success && r.status === "created" && (
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-bold">
{t("ui.common.status.new", "신규")}
</span>
)}
{r.success && r.status === "updated" && (
<span className="px-1.5 py-0.5 rounded-full bg-blue-100 text-blue-700 text-[10px] font-bold">
{t("ui.common.status.updated", "수정")}
</span>
)}
{r.success && r.status === "unchanged" && (
<span className="px-1.5 py-0.5 rounded-full bg-slate-100 text-slate-600 text-[10px] font-bold">
{t("ui.common.status.unchanged", "동일")}
</span>
)}
{r.success && !r.status && (
<span className="px-1.5 py-0.5 rounded-full bg-green-100 text-green-700 text-[10px] font-bold">
{t("ui.common.success", "성공")}
</span>
)}
</div>
{r.success && r.status === "updated" && (
<div className="mt-1 text-[10px] text-muted-foreground flex flex-wrap gap-1 items-center">
<span className="font-medium">
{t(
"ui.admin.users.bulk.modified_fields",
"수정 항목:",
)}
</span>
{r.modifiedFields &&
r.modifiedFields.length > 0 &&
r.modifiedFields.map((field) => (
<span
key={field}
className="px-1 py-0.5 rounded bg-blue-50 text-blue-600 border border-blue-100"
>
{t(
`ui.admin.users.field.${field.toLowerCase()}`,
field,
)}
</span>
))}
</div>
)}
{r.success && r.status === "unchanged" && (
<div className="mt-1 text-[10px] text-muted-foreground italic">
{t(
"ui.admin.users.bulk.no_changes",
"기존 데이터와 동일 (변경 사항 없음)",
)}
</div>
)}
{!r.success && (
<div className="text-xs text-destructive">
{r.message}
</div>
)}
</div>
</div>
))}
</div>
</ScrollArea>
</div>
)}
<DialogFooter>
{!results ? (
<Button
onClick={handleUpload}
disabled={
previewData.length === 0 ||
mutation.isPending ||
preparing ||
hasBlockingHanmacEmailRows ||
hasBlockingImportRows
}
className="w-full sm:w-auto"
data-testid="bulk-start-btn"
>
{(mutation.isPending || preparing) && (
<Loader2 size={16} className="mr-2 animate-spin" />
)}
{t("ui.admin.users.bulk.start_upload", "등록 시작")}
</Button>
) : (
<Button
onClick={() => setOpen(false)}
className="w-full sm:w-auto"
data-testid="bulk-close-dialog-btn"
>
{t("ui.common.close", "닫기")}
</Button>
)}
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,319 @@
import { describe, expect, it } from "vitest";
import {
buildAuthenticatedOrgChartTenantPickerUrl,
buildAuthenticatedOrgChartUrl,
buildOrgChartTenantPickerUrl,
filterNonHanmacFamilyTenants,
getTenantGradeOptions,
isHanmacFamilyUser,
parseOrgChartTenantSelection,
} from "./orgChartPicker";
describe("orgChartPicker", () => {
it("builds the tenant picker embed URL from ORGFRONT_URL", () => {
expect(buildOrgChartTenantPickerUrl("https://orgchart.example.com/")).toBe(
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600",
);
});
it("adds internal visibility to tenant picker URLs only when requested", () => {
expect(
buildOrgChartTenantPickerUrl("https://orgchart.example.com/", {
includeInternal: true,
}),
).toBe(
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600&includeInternal=true",
);
});
it("adds tenantId to the tenant picker URL when Hanmac family scope is known", () => {
expect(
buildOrgChartTenantPickerUrl("https://orgchart.example.com/", {
tenantId: "hanmac-family-id",
}),
).toBe(
"https://orgchart.example.com/embed/picker?mode=single&select=tenant&width=400&height=600&tenantId=hanmac-family-id",
);
});
it("wraps the picker URL with the org-chart auto login entry", () => {
expect(
buildAuthenticatedOrgChartTenantPickerUrl(
"https://orgchart.example.com",
{
tenantId: "hanmac-family-id",
},
),
).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fembed%2Fpicker%3Fmode%3Dsingle%26select%3Dtenant%26width%3D400%26height%3D600%26tenantId%3Dhanmac-family-id%26includeInternal%3Dtrue",
);
});
it("builds the admin chart navigation URL with internal visibility enabled", () => {
expect(buildAuthenticatedOrgChartUrl("https://orgchart.example.com/")).toBe(
"https://orgchart.example.com/login?auto=1&returnTo=%2Fchart%3FincludeInternal%3Dtrue",
);
});
it("can build chart navigation URL without internal visibility", () => {
expect(
buildAuthenticatedOrgChartUrl("https://orgchart.example.com/", {
includeInternal: false,
}),
).toBe("https://orgchart.example.com/login?auto=1&returnTo=%2Fchart");
});
it("parses the first tenant id and name from orgfront confirm messages", () => {
expect(
parseOrgChartTenantSelection({
type: "orgfront:picker:confirm",
payload: {
mode: "single",
selections: [
{
type: "tenant",
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
name: "기술기획",
},
],
},
}),
).toEqual({
id: "03dbe16b-e47b-4f72-927b-782807d67a35",
name: "기술기획",
});
});
it("ignores non-tenant or malformed picker messages", () => {
expect(
parseOrgChartTenantSelection({
type: "orgfront:picker:confirm",
payload: {
mode: "single",
selections: [{ type: "user", id: "u-1", name: "User" }],
},
}),
).toBeNull();
expect(parseOrgChartTenantSelection({ type: "other" })).toBeNull();
});
it("filters Hanmac family subtree and system tenants from non-family tenant choices", () => {
const visibleTenants = filterNonHanmacFamilyTenants(
[
{
id: "system-id",
slug: "system",
name: "System",
type: "SYSTEM",
parentId: undefined,
},
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
{
id: "internal-id",
slug: "internal",
name: "Internal",
type: "COMPANY",
parentId: undefined,
config: { visibility: "internal" },
},
{
id: "private-id",
slug: "private",
name: "Private",
type: "COMPANY",
parentId: undefined,
visibility: "private",
},
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "한맥팀",
type: "USER_GROUP",
parentId: "hanmac-company-id",
},
],
"hanmac-family-id",
);
expect(visibleTenants.map((tenant) => tenant.slug)).toEqual(["external"]);
});
it("detects existing users as Hanmac family from tenant subtree without metadata flag", () => {
const tenants = [
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "기술기획",
type: "USER_GROUP",
parentId: "hanmac-company-id",
},
];
expect(
isHanmacFamilyUser(
{
companyCode: "external",
tenant: tenants[0],
joinedTenants: [tenants[3]],
metadata: {},
},
tenants,
"hanmac-family-id",
),
).toBe(true);
});
it("does not treat legacy hanmacFamily metadata as Hanmac family without tenant evidence", () => {
const tenants = [
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
];
expect(
isHanmacFamilyUser(
{
companyCode: "external",
tenant: tenants[1],
metadata: { hanmacFamily: true },
},
tenants,
"hanmac-family-id",
),
).toBe(false);
});
it("does not treat userType metadata as Hanmac family without tenant evidence", () => {
const tenants = [
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
];
expect(
isHanmacFamilyUser(
{
companyCode: "external",
tenant: tenants[1],
metadata: { userType: "hanmac" },
},
tenants,
"hanmac-family-id",
),
).toBe(false);
});
it("returns GPDTDC rank options for GPDTDC subtree and general Hanmac ranks otherwise", () => {
const tenants = [
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "gpdtdc-id",
slug: "gpdtdc",
name: "총괄기획&기술개발센터",
type: "COMPANY",
parentId: "hanmac-family-id",
},
{
id: "gpdtdc-team-id",
slug: "gpdtdc-team",
name: "연구팀",
type: "USER_GROUP",
parentId: "gpdtdc-id",
},
{
id: "hanmac-id",
slug: "hanmac",
name: "한맥기술",
type: "COMPANY",
parentId: "hanmac-family-id",
},
];
expect(
getTenantGradeOptions({ tenantId: "gpdtdc-team-id" }, tenants),
).toEqual(["연구원", "선임", "책임", "수석", "부사장", "사장"]);
expect(getTenantGradeOptions({ tenantSlug: "hanmac" }, tenants)).toEqual([
"사원",
"대리",
"과장",
"차장",
"부장",
"이사",
"상무",
"전무",
"부사장",
"사장",
"회장",
]);
});
});

View File

@@ -0,0 +1,362 @@
export type OrgChartTenantSelection = {
id: string;
name: string;
};
export type TenantFilterTarget = {
id?: string;
tenantId?: string;
slug?: string;
tenantSlug?: string;
type?: string;
parentId?: string | null;
name?: string;
tenantName?: string;
visibility?: string;
config?: Record<string, unknown>;
};
export type HanmacFamilyUserTarget = {
companyCode?: string;
tenantSlug?: string;
tenant?: TenantFilterTarget;
joinedTenants?: TenantFilterTarget[];
metadata?: Record<string, unknown>;
};
type OrgChartPickerMessage = {
type?: unknown;
payload?: {
selections?: Array<{
type?: unknown;
id?: unknown;
name?: unknown;
}>;
};
};
type OrgChartTenantPickerOptions = {
includeInternal?: boolean;
tenantId?: string;
};
type OrgChartLoginOptions = {
includeInternal?: boolean;
returnTo?: string;
};
export const GPDTDC_GRADE_OPTIONS = [
"연구원",
"선임",
"책임",
"수석",
"부사장",
"사장",
] as const;
export const HANMAC_FAMILY_GRADE_OPTIONS = [
"사원",
"대리",
"과장",
"차장",
"부장",
"이사",
"상무",
"전무",
"부사장",
"사장",
"회장",
] as const;
function isSystemTenant(tenant: TenantFilterTarget) {
const slug = tenant.slug?.trim().toLowerCase();
const type = tenant.type?.trim().toUpperCase();
return (
!tenant.id?.trim() ||
!tenant.slug?.trim() ||
type === "SYSTEM" ||
slug === "system" ||
slug === "global"
);
}
function resolveTenantTarget<T extends TenantFilterTarget>(
target: TenantFilterTarget | undefined,
tenants: T[],
) {
if (!target) return undefined;
const tenantID = target.id ?? target.tenantId ?? "";
const tenantSlug = target.slug ?? target.tenantSlug ?? "";
return (
tenants.find((tenant) => tenantID && tenant.id === tenantID) ??
tenants.find(
(tenant) =>
tenantSlug &&
tenant.slug?.trim().toLowerCase() === tenantSlug.trim().toLowerCase(),
) ??
target
);
}
function isGPDTDCTenant<T extends TenantFilterTarget>(
target: TenantFilterTarget | undefined,
tenants: T[],
) {
const tenant = resolveTenantTarget(target, tenants);
if (!tenant) return false;
const tenantById = new Map(
tenants
.filter((item) => item.id?.trim())
.map((item) => [item.id as string, item]),
);
let current: TenantFilterTarget | undefined = tenant;
const visited = new Set<string>();
while (current) {
const slug = current.slug?.trim().toLowerCase();
if (slug === "gpdtdc") {
return true;
}
const parentId = current.parentId ?? "";
if (!parentId || visited.has(parentId)) {
return false;
}
visited.add(parentId);
current = tenantById.get(parentId);
}
return false;
}
export function getTenantGradeOptions<T extends TenantFilterTarget>(
target: TenantFilterTarget | undefined,
tenants: T[],
) {
return isGPDTDCTenant(target, tenants)
? [...GPDTDC_GRADE_OPTIONS]
: [...HANMAC_FAMILY_GRADE_OPTIONS];
}
function isPublicRepresentativeTenant(tenant: TenantFilterTarget) {
const visibility = String(
tenant.visibility ?? tenant.config?.visibility ?? "public",
)
.trim()
.toLowerCase();
return visibility !== "internal" && visibility !== "private";
}
function isInTenantSubtree<T extends TenantFilterTarget>(
tenant: T,
rootTenantId: string,
tenantById: Map<string, T>,
) {
if (!rootTenantId) {
return false;
}
if (tenant.id === rootTenantId) {
return true;
}
const visitedTenantIds = new Set<string>();
let parentId = tenant.parentId ?? "";
while (parentId) {
if (parentId === rootTenantId) {
return true;
}
if (visitedTenantIds.has(parentId)) {
return false;
}
visitedTenantIds.add(parentId);
parentId = tenantById.get(parentId)?.parentId ?? "";
}
return false;
}
function resolveHanmacFamilyTenantId<T extends TenantFilterTarget>(
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const envTenantId = hanmacFamilyTenantId?.trim();
if (envTenantId) return envTenantId;
return (
tenants.find((tenant) => tenant.slug?.toLowerCase() === "hanmac-family")
?.id ?? ""
);
}
export function isHanmacFamilyTenant<T extends TenantFilterTarget>(
tenant: T | undefined,
tenants: T[],
hanmacFamilyTenantId?: string,
) {
if (!tenant?.id) return false;
const rootTenantId = resolveHanmacFamilyTenantId(
tenants,
hanmacFamilyTenantId,
);
if (!rootTenantId) return false;
const tenantById = new Map(
tenants
.filter((item) => item.id?.trim())
.map((item) => [item.id as string, item]),
);
const target = tenantById.get(tenant.id) ?? tenant;
return isInTenantSubtree(target, rootTenantId, tenantById);
}
export function isHanmacFamilyUser<T extends TenantFilterTarget>(
user: HanmacFamilyUserTarget,
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const metadataAppointments = Array.isArray(
user.metadata?.additionalAppointments,
)
? user.metadata.additionalAppointments
.map((appointment) => appointment as TenantFilterTarget)
.filter(
(appointment) =>
typeof appointment.tenantId === "string" ||
typeof appointment.id === "string" ||
typeof appointment.tenantSlug === "string" ||
typeof appointment.slug === "string",
)
.map((appointment) => ({
id: appointment.id ?? appointment.tenantId,
slug: appointment.slug ?? appointment.tenantSlug,
parentId: appointment.parentId,
type: appointment.type,
name: appointment.name ?? appointment.tenantName,
}))
: [];
const tenantBySlug = new Map(
tenants
.filter((tenant) => tenant.slug?.trim())
.map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]),
);
const tenantById = new Map(
tenants
.filter((tenant) => tenant.id?.trim())
.map((tenant) => [tenant.id as string, tenant]),
);
const tenantCandidates = [
user.tenant,
...(user.joinedTenants ?? []),
...metadataAppointments,
...metadataAppointments.map((appointment) =>
tenantById.get(appointment.id ?? ""),
),
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
];
return tenantCandidates.some((tenant) =>
isHanmacFamilyTenant(tenant, tenants, hanmacFamilyTenantId),
);
}
export function filterNonHanmacFamilyTenants<T extends TenantFilterTarget>(
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const rootTenantId = resolveHanmacFamilyTenantId(
tenants,
hanmacFamilyTenantId,
);
const tenantById = new Map(
tenants
.filter((tenant) => tenant.id?.trim())
.map((tenant) => [tenant.id as string, tenant]),
);
return tenants.filter(
(tenant) =>
!isSystemTenant(tenant) &&
isPublicRepresentativeTenant(tenant) &&
!isInTenantSubtree(tenant, rootTenantId, tenantById),
);
}
export function buildOrgChartTenantPickerUrl(
baseUrl?: string,
options: OrgChartTenantPickerOptions = {},
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
const params = new URLSearchParams({
mode: "single",
select: "tenant",
width: "400",
height: "600",
});
const tenantId = options.tenantId?.trim();
if (tenantId) {
params.set("tenantId", tenantId);
}
if (options.includeInternal) {
params.set("includeInternal", "true");
}
return `${normalizedBase}/embed/picker?${params.toString()}`;
}
export function buildAuthenticatedOrgChartTenantPickerUrl(
baseUrl?: string,
options: OrgChartTenantPickerOptions = {},
) {
const pickerUrl = buildOrgChartTenantPickerUrl("", {
includeInternal: true,
...options,
});
return buildAuthenticatedOrgChartUrl(baseUrl, { returnTo: pickerUrl });
}
export function buildAuthenticatedOrgChartUrl(
baseUrl?: string,
options: OrgChartLoginOptions = { includeInternal: true },
) {
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
let returnTo = options.returnTo?.trim() || "/chart";
if (options.includeInternal && returnTo.startsWith("/chart")) {
const [path, query = ""] = returnTo.split("?", 2);
const params = new URLSearchParams(query);
params.set("includeInternal", "true");
returnTo = `${path}?${params.toString()}`;
}
const params = new URLSearchParams({
auto: "1",
returnTo,
});
return `${normalizedBase}/login?${params.toString()}`;
}
export function parseOrgChartTenantSelection(
message: unknown,
): OrgChartTenantSelection | null {
const data = message as OrgChartPickerMessage;
if (data?.type !== "orgfront:picker:confirm") {
return null;
}
const selection = data.payload?.selections?.[0];
if (
selection?.type !== "tenant" ||
typeof selection.id !== "string" ||
typeof selection.name !== "string" ||
selection.id.trim() === ""
) {
return null;
}
return {
id: selection.id,
name: selection.name,
};
}

View File

@@ -0,0 +1,18 @@
export type UserSchemaFieldType =
| "text"
| "number"
| "boolean"
| "date"
| "float"
| "datetime";
export type UserSchemaField = {
key: string;
label?: string;
type?: UserSchemaFieldType;
required?: boolean;
adminOnly?: boolean;
validation?: string;
isLoginId?: boolean;
indexed?: boolean;
};

View File

@@ -0,0 +1,43 @@
// @vitest-environment node
import { describe, expect, it, vi } from "vitest";
import {
normalizeUserStatusValue,
userStatusLabel,
userStatusValues,
} from "./userStatus";
vi.mock("../../lib/i18n", () => ({
t: (key: string, fallback?: string) => fallback ?? key,
}));
describe("userStatus", () => {
it("exposes canonical user status values", () => {
expect(userStatusValues).toEqual([
"active",
"temporary_leave",
"suspended",
"preboarding",
"baron_guest",
"extended_leave",
"archived",
]);
});
it("normalizes legacy status values", () => {
expect(normalizeUserStatusValue("inactive")).toBe("preboarding");
expect(normalizeUserStatusValue("leave_of_absence")).toBe(
"temporary_leave",
);
expect(normalizeUserStatusValue("baron_only")).toBe("baron_guest");
});
it("falls back to preboarding when status is missing", () => {
expect(normalizeUserStatusValue(undefined)).toBe("preboarding");
expect(normalizeUserStatusValue(null)).toBe("preboarding");
});
it("uses canonical labels for legacy status values", () => {
expect(userStatusLabel("baron_only")).toBe("baron_guest");
});
});

View File

@@ -0,0 +1,45 @@
import { t } from "../../lib/i18n";
export const userStatusValues = [
"active",
"temporary_leave",
"suspended",
"preboarding",
"baron_guest",
"extended_leave",
"archived",
] as const;
export type UserStatusValue = (typeof userStatusValues)[number];
export function normalizeUserStatusValue(
status?: string | null,
): UserStatusValue {
switch ((status ?? "").trim().toLowerCase()) {
case "active":
return "active";
case "temporary_leave":
case "leave_of_absence":
return "temporary_leave";
case "suspended":
case "blocked":
return "suspended";
case "preboarding":
case "inactive":
return "preboarding";
case "baron_guest":
case "baron_only":
return "baron_guest";
case "extended_leave":
return "extended_leave";
case "archived":
return "archived";
default:
return "preboarding";
}
}
export function userStatusLabel(status: string) {
const normalized = normalizeUserStatusValue(status);
return t(`ui.common.status.${normalized}`, normalized);
}

View File

@@ -0,0 +1,190 @@
import { describe, expect, it } from "vitest";
import { parseUserCSV } from "./csvParser";
describe("parseUserCSV", () => {
it("should parse valid CSV correctly", () => {
const csv = `email,name,phone,role,tenant,department,emp_id
user1@test.com,Hong Gil Dong,010-1111-2222,user,baron,HR,E001
user2@test.com,Kim Cheol Su,,admin,baron,IT,E002`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(2);
expect(result[0]).toEqual({
email: "user1@test.com",
name: "Hong Gil Dong",
phone: "010-1111-2222",
role: "user",
tenantSlug: "baron",
department: "HR",
metadata: {
emp_id: "E001",
},
});
expect(result[1].email).toBe("user2@test.com");
expect(result[1].metadata.emp_id).toBe("E002");
});
it("should return empty array for empty input", () => {
expect(parseUserCSV("")).toEqual([]);
});
it("should skip rows without email or name", () => {
const csv = `email,name
,Only Name
no-name@test.com,`;
expect(parseUserCSV(csv)).toHaveLength(0);
});
it("should handle mixed case headers", () => {
const csv = `EMAIL,Name,Tenant
test@test.com,Test,baron`;
const result = parseUserCSV(csv);
expect(result[0].email).toBe("test@test.com");
expect(result[0].tenantSlug).toBe("baron");
});
it("should parse NAVERWORKS member CSV sample into Baron bulk user fields", () => {
const csv = `"LastName","FirstName","ID","Personal email","Sub email","Nickname","User type","Level","Organization","Position","CompanyMainPhone","Mobile/Country code","Mobile/Numbers","Language","Responsibilities","Workplace","SNS","SNS_ID","Birthday (solar, lunar)","Birthday","Entry Date","Employee number","Account activation time"
"Doe","John","john.doe","john@naver.com","john1@company.com; john2@company.com","John","Permanent Employee","Manager","org.1|org.2|org.3|myteam","Manager","02-0000-0000","+1","9144812222","English","Sales management","New York","Facebook","john","solar","19830415","20230415","AB001","20230415 08:00"`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
email: "john1@company.com",
loginId: "john.doe",
name: "John Doe",
phone: "+19144812222",
department: "myteam",
grade: "Manager",
position: "Manager",
jobTitle: "Sales management",
tenantImport: {
name: "myteam",
parentTenantName: "org.3",
},
metadata: {
personal_email: "john@naver.com",
employee_id: "AB001",
naverworks_user_type: "Permanent Employee",
naverworks_level: "Manager",
naverworks_organization_path: "org.1|org.2|org.3|myteam",
naverworks_workplace: "New York",
},
});
});
it("should parse tenant conflict metadata for import resolution", () => {
const csv = `email,name,tenant_id,tenant_slug,tenant_name,tenant_type,parent_tenant_slug,tenant_memo,email_domain
test@test.com,Test,local-tenant-id,missing-slug,Missing Tenant,COMPANY,parent-slug,Imported memo,missing.example.com`;
const result = parseUserCSV(csv);
expect(result[0]).toMatchObject({
tenantId: "local-tenant-id",
tenantSlug: "missing-slug",
emailDomain: "missing.example.com",
tenantImport: {
sourceTenantId: "local-tenant-id",
slug: "missing-slug",
name: "Missing Tenant",
type: "COMPANY",
parentTenantSlug: "parent-slug",
memo: "Imported memo",
emailDomain: "missing.example.com",
},
});
});
it("should ignore exported user_id during user CSV import", () => {
const csv = `user_id,email,name,tenant_id,tenant_slug
9f8cc1b1-af8d-45d4-946c-924a529c2556,restore@test.com,Restore User,tenant-id,restore-tenant`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
email: "restore@test.com",
name: "Restore User",
tenantId: "tenant-id",
tenantSlug: "restore-tenant",
});
expect(result[0]).not.toHaveProperty("id");
expect(result[0]).not.toHaveProperty("uuid");
});
it("should parse one nullable additional appointment from numbered columns", () => {
const csv = `email,name,phone,role,tenant_slug,department,grade,position,jobTitle,employee_id,tenant_slug1,department1,grade1,position1,jobTitle1,employee_id1
dual@test.com,Dual User,010-0000-0000,user,primary-tenant,개발팀,책임,팀장,Backend,EMP001,second-tenant,센터,수석,,Architecture,EMP002
nullable@test.com,Nullable User,010-1111-1111,user,primary-tenant,개발팀,책임,팀장,Backend,EMP003,,,,,,`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(2);
expect(result[0]).toMatchObject({
tenantSlug: "primary-tenant",
department: "개발팀",
grade: "책임",
position: "팀장",
jobTitle: "Backend",
metadata: {
employee_id: "EMP001",
},
additionalAppointments: [
{
tenantSlug: "second-tenant",
department: "센터",
grade: "수석",
jobTitle: "Architecture",
metadata: {
employee_id: "EMP002",
},
},
],
});
expect(result[1].additionalAppointments).toBeUndefined();
});
it("should preserve sub_email as secondary email metadata without replacing primary email", () => {
const csv = `email,name,tenant_slug,employee_id,sub_email
primary@samaneng.com,Primary User,rnd-saman,EMP001,secondary@hanmaceng.co.kr`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(1);
expect(result[0]).toMatchObject({
email: "primary@samaneng.com",
tenantSlug: "rnd-saman",
metadata: {
employee_id: "EMP001",
sub_email: ["secondary@hanmaceng.co.kr"],
aliasEmails: ["secondary@hanmaceng.co.kr"],
},
});
});
it("should mark duplicate bulk alias emails as blocking import errors", () => {
const csv = `email,name,tenant_slug,sub_email
user1@samaneng.com,User One,rnd-saman,shared@hanmaceng.co.kr
user2@samaneng.com,User Two,rnd-saman,shared@hanmaceng.co.kr`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(2);
expect(result[0].importErrors).toContain("duplicateEmail");
expect(result[1].importErrors).toContain("duplicateEmail");
});
it("should mark a primary email reused as a sub email as a blocking import error", () => {
const csv = `email,name,tenant_slug,sub_email
user1@samaneng.com,User One,rnd-saman,user2@samaneng.com
user2@samaneng.com,User Two,rnd-saman,alias@hanmaceng.co.kr`;
const result = parseUserCSV(csv);
expect(result).toHaveLength(2);
expect(result[0].importErrors).toContain("duplicateEmail");
expect(result[1].importErrors).toContain("duplicateEmail");
});
});

View File

@@ -0,0 +1,462 @@
import type { BulkUserAppointment, BulkUserItem } from "../../../lib/adminApi";
export function parseUserCSV(text: string): BulkUserItem[] {
const records = parseCSVRecords(text.replace(/^\uFEFF/, ""));
if (records.length < 2) {
return [];
}
const headers = records[0].map(normalizeHeader);
const data: BulkUserItem[] = [];
for (let i = 1; i < records.length; i++) {
const values = records[i].map((v) => v.trim());
if (values.every((value) => value === "")) continue;
const item: Partial<BulkUserItem> & {
metadata: Record<string, unknown>;
} = {
metadata: {},
};
const additionalAppointment: BulkUserAppointment & {
metadata: Record<string, unknown>;
} = {
metadata: {},
};
for (let index = 0; index < headers.length; index++) {
const header = headers[index];
const value = values[index];
if (value === undefined || value === "") continue;
if (header === "email") {
item.email = value;
} else if (header === "name") {
item.name = value;
} else if (header === "phone") {
item.phone = value;
} else if (header === "role") {
item.role = value;
} else if (header === "tenant") {
item.tenantSlug = value;
} else if (header === "tenant_slug" || header === "companycode") {
item.tenantSlug = value;
item.tenantImport = {
...(item.tenantImport ?? {}),
slug: value,
};
} else if (header === "tenant_id") {
item.tenantId = value;
item.tenantImport = {
...(item.tenantImport ?? {}),
sourceTenantId: value,
};
} else if (header === "tenant_name") {
item.tenantImport = {
...(item.tenantImport ?? {}),
name: value,
};
} else if (header === "tenant_type") {
item.tenantImport = {
...(item.tenantImport ?? {}),
type: value,
};
} else if (header === "parent_tenant_id") {
item.tenantImport = {
...(item.tenantImport ?? {}),
parentTenantId: value,
};
} else if (header === "parent_tenant_slug") {
item.tenantImport = {
...(item.tenantImport ?? {}),
parentTenantSlug: value,
};
} else if (header === "parent_tenant_name") {
item.tenantImport = {
...(item.tenantImport ?? {}),
parentTenantName: value,
};
} else if (header === "tenant_memo") {
item.tenantImport = {
...(item.tenantImport ?? {}),
memo: value,
};
} else if (header === "email_domain" || header === "tenant_domain") {
item.emailDomain = value;
item.tenantImport = {
...(item.tenantImport ?? {}),
emailDomain: value,
};
} else if (header === "department") {
item.department = value;
} else if (header === "grade") {
item.grade = value;
} else if (header === "position") {
item.position = value;
} else if (header === "jobtitle") {
item.jobTitle = value;
} else if (header === "employee_id") {
item.metadata.employee_id = value;
} else if (header === "secondary_emails") {
applySecondaryEmailMetadata(item, value);
} else if (header === "tenant_slug1") {
additionalAppointment.tenantSlug = value;
} else if (header === "department1") {
additionalAppointment.department = value;
} else if (header === "grade1") {
additionalAppointment.grade = value;
} else if (header === "position1") {
additionalAppointment.position = value;
} else if (header === "jobtitle1") {
additionalAppointment.jobTitle = value;
} else if (header === "employee_id1") {
additionalAppointment.metadata.employee_id = value;
} else if (header === "lastname") {
item.metadata.naverworks_last_name = value;
} else if (header === "firstname") {
item.metadata.naverworks_first_name = value;
} else if (header === "id") {
item.loginId = value;
item.metadata.naverworks_id = value;
} else if (header === "personalemail") {
item.metadata.personal_email = value;
} else if (header === "subemail") {
item.metadata.naverworks_sub_email = value;
addWorksmobileAliasEmails(item, splitEmailTokens(value).slice(1));
item.email = firstEmailToken(value) || item.email;
} else if (header === "nickname") {
item.metadata.naverworks_nickname = value;
} else if (header === "usertype") {
item.metadata.naverworks_user_type = value;
} else if (header === "level") {
item.grade = value;
item.metadata.naverworks_level = value;
} else if (header === "organization") {
item.metadata.naverworks_organization_path = value;
const parts = splitOrganizationPath(value);
const leaf = parts.at(-1) ?? "";
const parent = parts.at(-2) ?? "";
if (leaf) {
item.department = leaf;
item.tenantImport = {
...(item.tenantImport ?? {}),
name: leaf,
parentTenantName: parent,
};
}
} else if (header === "companymainphone") {
item.metadata.naverworks_company_main_phone = value;
} else if (header === "mobilecountrycode") {
item.metadata.naverworks_mobile_country_code = value;
} else if (header === "mobilenumbers") {
item.metadata.naverworks_mobile_numbers = value;
} else if (header === "language") {
item.metadata.naverworks_language = value;
} else if (header === "responsibilities") {
item.jobTitle = value;
} else if (header === "workplace") {
item.metadata.naverworks_workplace = value;
} else if (header === "sns") {
item.metadata.naverworks_sns = value;
} else if (header === "snsid") {
item.metadata.naverworks_sns_id = value;
} else if (header === "birthdaysolarlunar") {
item.metadata.naverworks_birthday_calendar = value;
} else if (header === "birthday") {
item.metadata.naverworks_birthday = value;
} else if (header === "entrydate") {
item.metadata.naverworks_entry_date = value;
} else if (header === "employeenumber") {
item.metadata.employee_id = value;
} else if (header === "accountactivationtime") {
item.metadata.naverworks_account_activation_time = value;
} else {
item.metadata[header] = value;
}
}
applyNaverWorksFallbacks(item);
if (additionalAppointment.tenantSlug) {
item.additionalAppointments = [
cleanAdditionalAppointment(additionalAppointment),
];
}
if (item.email && item.name) {
data.push(item as BulkUserItem);
}
}
return markBulkEmailDuplicateErrors(data);
}
function cleanAdditionalAppointment(
appointment: BulkUserAppointment & { metadata: Record<string, unknown> },
) {
const metadata =
Object.keys(appointment.metadata).length > 0
? appointment.metadata
: undefined;
return {
...(appointment.tenantId ? { tenantId: appointment.tenantId } : {}),
...(appointment.tenantSlug ? { tenantSlug: appointment.tenantSlug } : {}),
...(appointment.tenantName ? { tenantName: appointment.tenantName } : {}),
...(appointment.isPrimary !== undefined
? { isPrimary: appointment.isPrimary }
: {}),
...(appointment.isOwner !== undefined
? { isOwner: appointment.isOwner }
: {}),
...(appointment.isAdmin !== undefined
? { isAdmin: appointment.isAdmin }
: {}),
...(appointment.isManager !== undefined
? { isManager: appointment.isManager }
: {}),
...(appointment.department ? { department: appointment.department } : {}),
...(appointment.grade ? { grade: appointment.grade } : {}),
...(appointment.position ? { position: appointment.position } : {}),
...(appointment.jobTitle ? { jobTitle: appointment.jobTitle } : {}),
...(metadata ? { metadata } : {}),
};
}
function normalizeHeader(header: string) {
const raw = header.trim().replace(/^\uFEFF/, "");
const lower = raw.toLowerCase();
const separatorNormalized = lower.replace(/-+/g, "_").replace(/_+/g, "_");
const compactKorean = raw.replace(/\s+/g, "");
if (
[
"sub_email",
"secondary_email",
"secondary_emails",
"additional_email",
"additional_emails",
"alias_email",
"alias_emails",
"worksmobile_alias_email",
"worksmobile_alias_emails",
].includes(separatorNormalized) ||
["보조이메일", "보조메일", "추가이메일", "추가메일"].includes(compactKorean)
) {
return "secondary_emails";
}
return raw
.trim()
.toLowerCase()
.replace(/^\uFEFF/, "")
.replace(/[^a-z0-9_]/g, "");
}
function parseCSVRecords(text: string) {
const records: string[][] = [];
let field = "";
let row: string[] = [];
let quoted = false;
for (let index = 0; index < text.length; index++) {
const char = text[index];
const next = text[index + 1];
if (char === '"') {
if (quoted && next === '"') {
field += '"';
index++;
} else {
quoted = !quoted;
}
continue;
}
if (char === "," && !quoted) {
row.push(field);
field = "";
continue;
}
if ((char === "\n" || char === "\r") && !quoted) {
if (char === "\r" && next === "\n") index++;
row.push(field);
records.push(row);
field = "";
row = [];
continue;
}
field += char;
}
if (field !== "" || row.length > 0) {
row.push(field);
records.push(row);
}
return records;
}
function firstEmailToken(value: string) {
return splitEmailTokens(value)[0] ?? "";
}
function splitEmailTokens(value: string) {
return value
.split(/[;,\n\r\t]/)
.map((token) => token.trim())
.filter((token) => token.includes("@"));
}
function metadataString(value: unknown) {
return typeof value === "string" ? value : "";
}
function metadataEmailList(value: unknown) {
if (Array.isArray(value)) {
return value
.map((item) => (typeof item === "string" ? item.trim() : ""))
.filter(Boolean);
}
if (typeof value === "string") {
return splitEmailTokens(value);
}
return [];
}
function uniqueEmails(values: string[]) {
const seen = new Set<string>();
const result: string[] = [];
for (const value of values) {
const normalized = value.trim().toLowerCase();
if (!normalized || seen.has(normalized)) continue;
seen.add(normalized);
result.push(normalized);
}
return result;
}
function bulkUserImportErrorList(user: BulkUserItem) {
return Array.isArray(user.importErrors) ? user.importErrors : [];
}
function withBulkUserImportError(user: BulkUserItem, error: string) {
const errors = Array.from(new Set([...bulkUserImportErrorList(user), error]));
return { ...user, importErrors: errors };
}
function bulkUserAliasEmails(user: BulkUserItem) {
return uniqueEmails([
...metadataEmailList(user.metadata.sub_email),
...metadataEmailList(user.metadata.aliasEmails),
...metadataEmailList(user.metadata.secondary_emails),
...metadataEmailList(user.metadata.worksmobileAliasEmails),
]);
}
function markBulkEmailDuplicateErrors(users: BulkUserItem[]) {
const duplicateIndexes = new Set<number>();
const owners = new Map<string, Set<number>>();
users.forEach((user, index) => {
const primaryEmail = user.email.trim().toLowerCase();
const aliases = bulkUserAliasEmails(user);
const rowEmails = new Set<string>();
if (primaryEmail) {
rowEmails.add(primaryEmail);
}
for (const alias of aliases) {
if (primaryEmail && alias === primaryEmail) {
duplicateIndexes.add(index);
}
rowEmails.add(alias);
}
for (const email of rowEmails) {
const existing = owners.get(email) ?? new Set<number>();
existing.add(index);
owners.set(email, existing);
}
});
for (const indexes of owners.values()) {
if (indexes.size < 2) {
continue;
}
for (const index of indexes) {
duplicateIndexes.add(index);
}
}
return users.map((user, index) =>
duplicateIndexes.has(index)
? withBulkUserImportError(user, "duplicateEmail")
: user,
);
}
function addWorksmobileAliasEmails(
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
emails: string[],
) {
const aliases = uniqueEmails([
...metadataEmailList(item.metadata.aliasEmails),
...emails,
]);
if (aliases.length > 0) {
item.metadata.aliasEmails = aliases;
item.metadata.worksmobileAliasEmails = aliases;
}
}
function applySecondaryEmailMetadata(
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
value: string,
) {
const emails = splitEmailTokens(value);
const uniqueSecondaryEmails = uniqueEmails([
...metadataEmailList(item.metadata.secondary_emails),
...emails,
]);
item.metadata.sub_email = emails;
item.metadata.secondary_emails = uniqueSecondaryEmails;
addWorksmobileAliasEmails(item, emails);
}
function splitOrganizationPath(value: string) {
return value
.split("|")
.map((part) => part.trim())
.filter(Boolean);
}
function applyNaverWorksFallbacks(
item: Partial<BulkUserItem> & { metadata: Record<string, unknown> },
) {
if (!item.name) {
const firstName = metadataString(item.metadata.naverworks_first_name);
const lastName = metadataString(item.metadata.naverworks_last_name);
item.name = [firstName, lastName].filter(Boolean).join(" ").trim();
const nickname = metadataString(item.metadata.naverworks_nickname);
if (!item.name && nickname) {
item.name = nickname;
}
}
if (!item.email) {
item.email = metadataString(item.metadata.personal_email);
}
if (!item.phone) {
const countryCode = metadataString(
item.metadata.naverworks_mobile_country_code,
);
const number = metadataString(item.metadata.naverworks_mobile_numbers);
item.phone = `${countryCode}${number}`.replace(/\s/g, "");
}
const level = metadataString(item.metadata.naverworks_level);
if (!item.grade && level) {
item.grade = level;
}
}

View File

@@ -0,0 +1,160 @@
import type { BulkUserItem, TenantSummary } from "../../../lib/adminApi";
import { applyGeneralPlanningOfficePriority } from "./generalPlanningOfficePriority";
function tenant(
id: string,
name: string,
slug: string,
parentId?: string,
): TenantSummary {
return {
id,
type: "COMPANY",
name,
slug,
description: "",
status: "active",
parentId,
memberCount: 0,
createdAt: "",
updatedAt: "",
};
}
describe("applyGeneralPlanningOfficePriority", () => {
it("promotes the general planning office appointment and preserves string employee IDs", () => {
const user: BulkUserItem = {
email: "dual@test.com",
name: "Dual User",
tenantSlug: "hanmac-tech",
department: "개발팀",
grade: "책임",
position: "팀장",
jobTitle: "Backend",
metadata: {
employee_id: "EMP001",
},
additionalAppointments: [
{
tenantSlug: "planning-team",
tenantName: "경영기획팀",
department: "센터",
grade: "수석",
jobTitle: "Architecture",
metadata: {
employee_id: "EMP002",
},
},
],
};
const result = applyGeneralPlanningOfficePriority(user, [
tenant("gpo", "총괄기획실", "gpo"),
tenant("planning", "경영기획팀", "planning-team", "gpo"),
tenant("tech", "한맥기술", "hanmac-tech"),
]);
expect(result.tenantSlug).toBe("planning-team");
expect(result.department).toBe("센터");
expect(result.grade).toBe("수석");
expect(result.jobTitle).toBe("Architecture");
expect(result.metadata.employee_id).toBe("EMP002");
expect(result.additionalAppointments?.[0]).toMatchObject({
tenantSlug: "hanmac-tech",
department: "개발팀",
grade: "책임",
position: "팀장",
jobTitle: "Backend",
metadata: {
employee_id: "EMP001",
},
});
});
it("does not write non-string employee IDs into string metadata", () => {
const user: BulkUserItem = {
email: "dual@test.com",
name: "Dual User",
tenantSlug: "hanmac-tech",
metadata: {
employee_id: "EMP001",
},
additionalAppointments: [
{
tenantSlug: "gpo",
tenantName: "총괄기획실",
metadata: {
employee_id: 1002,
},
},
],
};
const result = applyGeneralPlanningOfficePriority(user, [
tenant("gpo", "총괄기획실", "gpo"),
tenant("tech", "한맥기술", "hanmac-tech"),
]);
expect(result.tenantSlug).toBe("gpo");
expect(result.metadata.employee_id).toBeUndefined();
expect(result.additionalAppointments?.[0].metadata).toMatchObject({
employee_id: "EMP001",
});
});
it("uses GPDTDC as the Baron representative while keeping the first affiliation primary for WorksMobile", () => {
const user: BulkUserItem = {
email: "gpdtdc-dual@test.com",
name: "GPDTDC Dual User",
tenantSlug: "rnd-saman",
department: "삼안기술연구소",
grade: "책임",
metadata: {
employee_id: "SAMAN001",
},
additionalAppointments: [
{
tenantSlug: "tdc",
tenantName: "기술개발센터",
grade: "책임연구원",
metadata: {
employee_id: "B24051",
},
},
],
};
const result = applyGeneralPlanningOfficePriority(user, [
tenant("family", "한맥가족사", "hanmac-family"),
tenant("gpdtdc", "총괄기획&기술개발센터", "gpdtdc", "family"),
tenant("tdc", "기술개발센터", "tdc", "gpdtdc"),
tenant("saman", "삼안", "rnd-saman"),
]);
expect(result.tenantSlug).toBe("gpdtdc");
expect(result.tenantImport).toMatchObject({
slug: "gpdtdc",
name: "총괄기획&기술개발센터",
});
expect(result.metadata.employee_id).toBe("SAMAN001");
expect(result.additionalAppointments).toEqual([
expect.objectContaining({
tenantSlug: "rnd-saman",
isPrimary: true,
department: "삼안기술연구소",
grade: "책임",
metadata: {
employee_id: "SAMAN001",
},
}),
expect.objectContaining({
tenantSlug: "tdc",
isPrimary: false,
grade: "책임연구원",
metadata: {
employee_id: "B24051",
},
}),
]);
});
});

View File

@@ -0,0 +1,184 @@
import type {
BulkUserAppointment,
BulkUserItem,
TenantSummary,
} from "../../../lib/adminApi";
export function applyGeneralPlanningOfficePriority(
user: BulkUserItem,
tenants: TenantSummary[],
): BulkUserItem {
const gpdtdcRepresentative = applyGPDTDCRepresentativeTenant(user, tenants);
if (gpdtdcRepresentative) {
return gpdtdcRepresentative;
}
const firstAdditional = user.additionalAppointments?.[0];
const secondarySlug = firstAdditional?.tenantSlug;
if (
!firstAdditional ||
!secondarySlug ||
!isUnderGeneralPlanningOffice(secondarySlug, tenants) ||
isUnderGeneralPlanningOffice(user.tenantSlug || "", tenants)
) {
return user;
}
const primaryEmployeeId = stringValue(user.metadata.employee_id);
const secondaryEmployeeId = stringValue(
firstAdditional.metadata?.employee_id,
);
const metadata = { ...user.metadata };
if (secondaryEmployeeId) {
metadata.employee_id = secondaryEmployeeId;
} else {
delete metadata.employee_id;
}
const primaryAppointmentMetadata: Record<string, unknown> = {
...firstAdditional.metadata,
};
if (primaryEmployeeId) {
primaryAppointmentMetadata.employee_id = primaryEmployeeId;
} else {
delete primaryAppointmentMetadata.employee_id;
}
return {
...user,
tenantSlug: firstAdditional.tenantSlug,
tenantImport: user.tenantImport
? {
...user.tenantImport,
slug: firstAdditional.tenantSlug || "",
name: firstAdditional.tenantName || firstAdditional.tenantSlug || "",
}
: user.tenantImport,
department: firstAdditional.department,
grade: firstAdditional.grade,
position: firstAdditional.position,
jobTitle: firstAdditional.jobTitle,
metadata,
additionalAppointments: [
{
...firstAdditional,
tenantSlug: user.tenantSlug,
department: user.department,
grade: user.grade,
position: user.position,
jobTitle: user.jobTitle,
metadata: primaryAppointmentMetadata,
},
...(user.additionalAppointments?.slice(1) ?? []),
],
};
}
function applyGPDTDCRepresentativeTenant(
user: BulkUserItem,
tenants: TenantSummary[],
): BulkUserItem | undefined {
const root = findGPDTDCRootTenant(tenants);
if (!root) return undefined;
const primarySlug = user.tenantSlug || "";
const hasPrimaryUnderRoot = isUnderTenant(primarySlug, root, tenants);
const hasAppointmentUnderRoot = (user.additionalAppointments ?? []).some(
(appointment) => isUnderTenant(appointment.tenantSlug || "", root, tenants),
);
if (!hasPrimaryUnderRoot && !hasAppointmentUnderRoot) return undefined;
if (primarySlug === root.slug) return undefined;
const worksmobileAppointments: BulkUserAppointment[] = [];
const seen = new Set<string>();
const addAppointment = (
appointment: BulkUserAppointment,
fallbackKey: string,
) => {
const key = appointment.tenantSlug || appointment.tenantId || fallbackKey;
if (!key || key === root.slug || seen.has(key)) return;
seen.add(key);
worksmobileAppointments.push(appointment);
};
addAppointment(buildPrimaryAppointment(user), "primary");
for (const appointment of user.additionalAppointments ?? []) {
addAppointment(
{ ...appointment, isPrimary: false },
appointment.tenantSlug || "",
);
}
return {
...user,
tenantSlug: root.slug,
tenantImport: {
...(user.tenantImport ?? {}),
sourceTenantId: undefined,
slug: root.slug,
name: root.name,
},
additionalAppointments:
worksmobileAppointments.length > 0 ? worksmobileAppointments : undefined,
};
}
function buildPrimaryAppointment(user: BulkUserItem): BulkUserAppointment {
return {
...(user.tenantId ? { tenantId: user.tenantId } : {}),
...(user.tenantSlug ? { tenantSlug: user.tenantSlug } : {}),
isPrimary: true,
isOwner: false,
...(user.department ? { department: user.department } : {}),
...(user.grade ? { grade: user.grade } : {}),
...(user.position ? { position: user.position } : {}),
...(user.jobTitle ? { jobTitle: user.jobTitle } : {}),
metadata: { ...user.metadata },
};
}
function findGPDTDCRootTenant(tenants: TenantSummary[]) {
return tenants.find((tenant) => {
const slug = tenant.slug.trim().toLowerCase();
const name = tenant.name.replace(/\s+/g, "").toLowerCase();
return (
slug === "gpdtdc" ||
name === "gpdtdc" ||
name.includes("총괄기획&기술개발센터") ||
name.includes("총괄기획기술개발센터")
);
});
}
function isUnderGeneralPlanningOffice(
tenantSlug: string,
tenants: TenantSummary[],
): boolean {
let current = tenants.find((tenant) => tenant.slug === tenantSlug);
while (current) {
if (current.name === "총괄기획실") return true;
if (!current.parentId) break;
current = tenants.find((tenant) => tenant.id === current?.parentId);
}
return false;
}
function isUnderTenant(
tenantSlug: string,
root: TenantSummary,
tenants: TenantSummary[],
): boolean {
let current = tenants.find((tenant) => tenant.slug === tenantSlug);
while (current) {
if (current.id === root.id || current.slug === root.slug) return true;
if (!current.parentId) break;
current = tenants.find((tenant) => tenant.id === current?.parentId);
}
return false;
}
function stringValue(value: unknown) {
return typeof value === "string" ? value : undefined;
}

View File

@@ -0,0 +1,73 @@
import { describe, expect, it } from "vitest";
import {
buildHanmacImportEmailPreview,
buildKoreanNameEmailBase,
matchesSuggestedNameRule,
} from "./hanmacImportEmail";
describe("hanmac import email policy", () => {
it("builds name initials plus surname base", () => {
expect(buildKoreanNameEmailBase("한치영")).toEqual({
base: "cyhan",
needsReview: false,
});
});
it("matches base plus numeric suffix only", () => {
expect(matchesSuggestedNameRule("cyhan", "cyhan")).toBe(true);
expect(matchesSuggestedNameRule("cyhan2", "cyhan")).toBe(true);
expect(matchesSuggestedNameRule("hcy", "cyhan")).toBe(false);
expect(matchesSuggestedNameRule("cyhan-a", "cyhan")).toBe(false);
});
it("suggests the next available local part for domain-only email", () => {
const preview = buildHanmacImportEmailPreview(
{
email: "@hanmaceng.co.kr",
name: "한치영",
tenantSlug: "hanmac",
metadata: {},
},
new Set(["cyhan", "cyhan1"]),
new Set(),
);
expect(preview.finalEmail).toBe("cyhan2@hanmaceng.co.kr");
expect(preview.status).toBe("suggested");
expect(preview.warnings).toContain("suggested");
});
it("marks rule mismatch as a warning without blocking the row", () => {
const preview = buildHanmacImportEmailPreview(
{
email: "hcy@hanmaceng.co.kr",
name: "한치영",
tenantSlug: "hanmac",
metadata: {},
},
new Set(),
new Set(),
);
expect(preview.finalEmail).toBe("hcy@hanmaceng.co.kr");
expect(preview.status).toBe("ruleMismatch");
expect(preview.warnings).toContain("ruleMismatch");
expect(preview.blockingErrors).toEqual([]);
});
it("blocks duplicate full local part for Hanmac family", () => {
const preview = buildHanmacImportEmailPreview(
{
email: "han@samaneng.com",
name: "한치영",
tenantSlug: "hanmac",
metadata: {},
},
new Set(["han"]),
new Set(),
);
expect(preview.status).toBe("blockingError");
expect(preview.blockingErrors).toContain("duplicateLocalPart");
});
});

View File

@@ -0,0 +1,296 @@
import type { BulkUserItem } from "../../../lib/adminApi";
export type HanmacImportEmailStatus =
| "valid"
| "suggested"
| "needsReview"
| "ruleMismatch"
| "blockingError";
export type HanmacImportEmailPreview = {
originalEmail: string;
suggestedEmail?: string;
finalEmail: string;
status: HanmacImportEmailStatus;
warnings: string[];
blockingErrors: string[];
reason?: string;
localPart?: string;
};
const surnameRomanization: Record<string, string> = {
: "han",
: "kim",
: "lee",
: "park",
: "choi",
: "jung",
: "cho",
: "kang",
: "yoon",
: "jang",
: "lim",
: "lim",
: "shin",
: "oh",
: "seo",
: "kwon",
: "hwang",
: "ahn",
: "song",
: "jeon",
: "hong",
: "yoo",
: "ko",
: "moon",
: "yang",
: "son",
: "bae",
: "baek",
: "heo",
: "nam",
: "sim",
: "noh",
: "ha",
: "kwak",
: "sung",
: "cha",
: "joo",
: "woo",
: "koo",
: "min",
: "ryu",
: "na",
: "jin",
: "ji",
: "um",
: "chae",
: "won",
: "cheon",
: "bang",
: "gong",
: "hyun",
: "ham",
: "yeo",
: "choo",
: "do",
: "so",
: "seok",
: "sun",
: "seol",
: "ma",
: "gil",
: "yeon",
: "wi",
: "pyo",
: "myung",
: "ki",
: "ban",
: "ra",
: "wang",
: "geum",
: "ok",
: "yook",
: "in",
: "maeng",
: "je",
: "mo",
: "tak",
: "guk",
: "eo",
: "eun",
: "pyeon",
: "yong",
};
const initialRomanization = [
"g",
"g",
"n",
"d",
"d",
"r",
"m",
"b",
"b",
"s",
"s",
"y",
"j",
"j",
"c",
"k",
"t",
"p",
"h",
];
export function buildKoreanNameEmailBase(name: string) {
const runes = [...name.trim()].filter((char) => !/\s/.test(char));
if (runes.length < 2) {
return { base: "", needsReview: true };
}
const surname = surnameRomanization[runes[0]];
if (!surname) {
return { base: "", needsReview: true };
}
const initials = runes.slice(1).map((char) => romanizedHangulInitial(char));
if (initials.some((value) => !value)) {
return { base: "", needsReview: true };
}
return { base: `${initials.join("")}${surname}`, needsReview: false };
}
export function matchesSuggestedNameRule(localPart: string, base: string) {
const normalizedLocalPart = localPart.trim().toLowerCase();
const normalizedBase = base.trim().toLowerCase();
if (!normalizedLocalPart || !normalizedBase) {
return false;
}
if (normalizedLocalPart === normalizedBase) {
return true;
}
return new RegExp(`^${escapeRegExp(normalizedBase)}\\d+$`).test(
normalizedLocalPart,
);
}
export function buildHanmacImportEmailPreview(
user: BulkUserItem,
existingLocalParts: Set<string>,
batchLocalParts: Set<string>,
): HanmacImportEmailPreview {
const originalEmail = user.email.trim();
const split = splitEmailDomain(originalEmail);
if (!split) {
return {
originalEmail,
finalEmail: originalEmail,
status: "blockingError",
warnings: [],
blockingErrors: ["invalidEmail"],
reason: "이메일 형식을 확인해 주세요.",
};
}
const usedLocalParts = new Set([
...[...existingLocalParts].map((value) => value.toLowerCase()),
...[...batchLocalParts].map((value) => value.toLowerCase()),
]);
const { base, needsReview } = buildKoreanNameEmailBase(user.name);
const warnings: string[] = [];
if (needsReview) {
warnings.push("needsReview");
}
if (!split.localPart) {
if (!base) {
return {
originalEmail,
finalEmail: originalEmail,
status: "blockingError",
warnings,
blockingErrors: ["missingLocalPart"],
reason: "이름으로 이메일 ID를 제안할 수 없습니다.",
};
}
const nextLocalPart = nextAvailableLocalPart(base, usedLocalParts);
const finalEmail = `${nextLocalPart}@${split.domain}`;
batchLocalParts.add(nextLocalPart);
return {
originalEmail,
suggestedEmail: finalEmail,
finalEmail,
status: "suggested",
warnings: appendUnique(warnings, "suggested"),
blockingErrors: [],
localPart: nextLocalPart,
};
}
const localPart = split.localPart.toLowerCase();
if (usedLocalParts.has(localPart)) {
return {
originalEmail,
finalEmail: originalEmail,
status: "blockingError",
warnings,
blockingErrors: ["duplicateLocalPart"],
reason: "한맥가족 내에서 이미 사용 중인 이메일 ID입니다.",
localPart,
};
}
batchLocalParts.add(localPart);
if (base && !matchesSuggestedNameRule(localPart, base)) {
return {
originalEmail,
finalEmail: originalEmail,
status: "ruleMismatch",
warnings: appendUnique(warnings, "ruleMismatch"),
blockingErrors: [],
reason: "권장 이메일 ID 규칙과 다릅니다.",
localPart,
};
}
return {
originalEmail,
finalEmail: originalEmail,
status: warnings.includes("needsReview") ? "needsReview" : "valid",
warnings,
blockingErrors: [],
localPart,
};
}
function splitEmailDomain(email: string) {
const normalized = email.trim().toLowerCase();
const parts = normalized.split("@");
if (parts.length !== 2 || !parts[1] || !parts[1].includes(".")) {
return null;
}
return {
localPart: parts[0],
domain: parts[1],
};
}
function romanizedHangulInitial(char: string) {
const code = char.codePointAt(0);
if (code === undefined || code < 0xac00 || code > 0xd7a3) {
return "";
}
const index = Math.floor((code - 0xac00) / 588);
return initialRomanization[index] ?? "";
}
function nextAvailableLocalPart(base: string, usedLocalParts: Set<string>) {
const normalizedBase = base.trim().toLowerCase();
if (!usedLocalParts.has(normalizedBase)) {
return normalizedBase;
}
let index = 1;
while (usedLocalParts.has(`${normalizedBase}${index}`)) {
index += 1;
}
return `${normalizedBase}${index}`;
}
function appendUnique(values: string[], value: string) {
if (values.includes(value)) {
return values;
}
return [...values, value];
}
function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
GLOBAL_PERSONAL_TENANT_ID,
resolvePersonalTenant,
} from "./personalTenant";
describe("resolvePersonalTenant", () => {
it("uses the fixed global Personal tenant when it is not included in the paged tenant list", () => {
expect(resolvePersonalTenant([])).toMatchObject({
id: GLOBAL_PERSONAL_TENANT_ID,
slug: "personal",
name: "Personal",
type: "PERSONAL",
status: "active",
});
});
it("prefers the tenant returned by the API when available", () => {
expect(
resolvePersonalTenant([
{
id: "api-personal-id",
slug: "personal",
name: "Personal",
type: "PERSONAL",
status: "active",
memberCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]),
).toMatchObject({
id: "api-personal-id",
slug: "personal",
});
});
});

View File

@@ -0,0 +1,34 @@
import type { TenantSummary } from "../../../lib/adminApi";
export const GLOBAL_PERSONAL_TENANT_ID =
import.meta.env.VITE_PERSONAL_TENANT_ID ||
"9607eb7b-04d2-42ab-80fe-780fe21c7e8f";
export const GLOBAL_PERSONAL_TENANT_SLUG =
import.meta.env.VITE_PERSONAL_TENANT_SLUG || "personal";
export function isPersonalTenant(
tenant: Pick<TenantSummary, "name" | "slug" | "type">,
) {
return (
tenant.slug === GLOBAL_PERSONAL_TENANT_SLUG ||
(tenant.type === "PERSONAL" && tenant.name.toLowerCase() === "personal")
);
}
export function resolvePersonalTenant(tenants: TenantSummary[]): TenantSummary {
const tenant = tenants.find(isPersonalTenant);
if (tenant) return tenant;
return {
id: GLOBAL_PERSONAL_TENANT_ID,
slug: GLOBAL_PERSONAL_TENANT_SLUG,
name: "Personal",
type: "PERSONAL",
description: "개인 사용자 기본 루트 테넌트",
status: "active",
memberCount: 0,
createdAt: "2026-05-04T06:52:59.187802Z",
updatedAt: "2026-05-04T06:52:59.191145Z",
};
}