첫 커밋: 로컬 프로젝트 업로드
This commit is contained in:
@@ -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"]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
350
baron-sso/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx
Normal file
350
baron-sso/adminfront/src/features/api-keys/ApiKeyCreatePage.tsx
Normal 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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
467
baron-sso/adminfront/src/features/api-keys/ApiKeyListPage.tsx
Normal file
467
baron-sso/adminfront/src/features/api-keys/ApiKeyListPage.tsx
Normal 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;
|
||||
59
baron-sso/adminfront/src/features/api-keys/apiKeyScopes.ts
Normal file
59
baron-sso/adminfront/src/features/api-keys/apiKeyScopes.ts
Normal 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을 조회합니다.",
|
||||
},
|
||||
];
|
||||
197
baron-sso/adminfront/src/features/audit/AuditLogsPage.tsx
Normal file
197
baron-sso/adminfront/src/features/audit/AuditLogsPage.tsx
Normal 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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
56
baron-sso/adminfront/src/features/auth/AuthCallbackPage.tsx
Normal file
56
baron-sso/adminfront/src/features/auth/AuthCallbackPage.tsx
Normal 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;
|
||||
56
baron-sso/adminfront/src/features/auth/AuthGuard.test.tsx
Normal file
56
baron-sso/adminfront/src/features/auth/AuthGuard.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
59
baron-sso/adminfront/src/features/auth/AuthGuard.tsx
Normal file
59
baron-sso/adminfront/src/features/auth/AuthGuard.tsx
Normal 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 />;
|
||||
}
|
||||
38
baron-sso/adminfront/src/features/auth/AuthPage.test.tsx
Normal file
38
baron-sso/adminfront/src/features/auth/AuthPage.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
24
baron-sso/adminfront/src/features/auth/AuthPage.tsx
Normal file
24
baron-sso/adminfront/src/features/auth/AuthPage.tsx
Normal 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;
|
||||
76
baron-sso/adminfront/src/features/auth/LoginPage.test.tsx
Normal file
76
baron-sso/adminfront/src/features/auth/LoginPage.test.tsx
Normal 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",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
206
baron-sso/adminfront/src/features/auth/LoginPage.tsx
Normal file
206
baron-sso/adminfront/src/features/auth/LoginPage.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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));
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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"]);
|
||||
});
|
||||
});
|
||||
2136
baron-sso/adminfront/src/features/tenants/routes/TenantListPage.tsx
Normal file
2136
baron-sso/adminfront/src/features/tenants/routes/TenantListPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
@@ -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"],
|
||||
};
|
||||
@@ -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 도메인은 한맥가족 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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} 테넌트에 이미 설정되어 있습니다. 그래도 현재 테넌트에도 추가하시겠습니까?`;
|
||||
}
|
||||
@@ -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(
|
||||
"임원직속",
|
||||
);
|
||||
});
|
||||
});
|
||||
118
baron-sso/adminfront/src/features/tenants/utils/orgConfig.ts
Normal file
118
baron-sso/adminfront/src/features/tenants/utils/orgConfig.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,,");
|
||||
});
|
||||
});
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
1196
baron-sso/adminfront/src/features/users/UserCreatePage.tsx
Normal file
1196
baron-sso/adminfront/src/features/users/UserCreatePage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
2397
baron-sso/adminfront/src/features/users/UserDetailPage.tsx
Normal file
2397
baron-sso/adminfront/src/features/users/UserDetailPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
});
|
||||
});
|
||||
1318
baron-sso/adminfront/src/features/users/UserListPage.tsx
Normal file
1318
baron-sso/adminfront/src/features/users/UserListPage.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
319
baron-sso/adminfront/src/features/users/orgChartPicker.test.ts
Normal file
319
baron-sso/adminfront/src/features/users/orgChartPicker.test.ts
Normal 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([
|
||||
"사원",
|
||||
"대리",
|
||||
"과장",
|
||||
"차장",
|
||||
"부장",
|
||||
"이사",
|
||||
"상무",
|
||||
"전무",
|
||||
"부사장",
|
||||
"사장",
|
||||
"회장",
|
||||
]);
|
||||
});
|
||||
});
|
||||
362
baron-sso/adminfront/src/features/users/orgChartPicker.ts
Normal file
362
baron-sso/adminfront/src/features/users/orgChartPicker.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
18
baron-sso/adminfront/src/features/users/userSchemaFields.ts
Normal file
18
baron-sso/adminfront/src/features/users/userSchemaFields.ts
Normal 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;
|
||||
};
|
||||
43
baron-sso/adminfront/src/features/users/userStatus.test.ts
Normal file
43
baron-sso/adminfront/src/features/users/userStatus.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
45
baron-sso/adminfront/src/features/users/userStatus.ts
Normal file
45
baron-sso/adminfront/src/features/users/userStatus.ts
Normal 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);
|
||||
}
|
||||
190
baron-sso/adminfront/src/features/users/utils/csvParser.test.ts
Normal file
190
baron-sso/adminfront/src/features/users/utils/csvParser.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
462
baron-sso/adminfront/src/features/users/utils/csvParser.ts
Normal file
462
baron-sso/adminfront/src/features/users/utils/csvParser.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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, "\\$&");
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user