1
0
forked from baron/baron-sso

org chart 자동로그인 보완. seed-tenant 삭제불가 조치

This commit is contained in:
2026-04-30 17:02:24 +09:00
parent 6eb4c293ff
commit 3dcdd97882
13 changed files with 490 additions and 32 deletions

View File

@@ -57,6 +57,7 @@ import {
parseTenantCSV,
serializeTenantImportCSV,
} from "../utils/tenantCsvImport";
import { isSeedTenant } from "../utils/protectedTenants";
const tenantCSVTemplate =
"name,type,parent_tenant_slug,slug,memo,email_domain\n";
@@ -206,36 +207,48 @@ function TenantListPage() {
);
}, [allTenants, search]);
const deletableTenants = React.useMemo(
() => tenants.filter((tenant) => !isSeedTenant(tenant)),
[tenants],
);
const handleSelectAll = (checked: boolean) => {
if (checked) {
setSelectedIds(tenants.map((t) => t.id));
setSelectedIds(deletableTenants.map((t) => t.id));
} else {
setSelectedIds([]);
}
};
const handleSelect = (id: string, checked: boolean) => {
const handleSelect = (tenant: TenantSummary, checked: boolean) => {
if (isSeedTenant(tenant)) {
return;
}
if (checked) {
setSelectedIds((prev) => [...prev, id]);
setSelectedIds((prev) => [...prev, tenant.id]);
} else {
setSelectedIds((prev) => prev.filter((i) => i !== id));
setSelectedIds((prev) => prev.filter((i) => i !== tenant.id));
}
};
const handleDeleteBulk = () => {
if (selectedIds.length === 0) return;
const deletableIds = selectedIds.filter((id) =>
deletableTenants.some((tenant) => tenant.id === id),
);
if (deletableIds.length === 0) return;
if (
!window.confirm(
t(
"msg.admin.tenants.delete_bulk_confirm",
"선택한 {{count}}개 테넌트를 삭제할까요?",
{ count: selectedIds.length },
{ count: deletableIds.length },
),
)
) {
return;
}
deleteBulkMutation.mutate(selectedIds);
deleteBulkMutation.mutate(deletableIds);
};
const handleTemplateDownload = () => {
@@ -312,6 +325,10 @@ function TenantListPage() {
};
const handleDelete = (tenantId: string, tenantName: string) => {
const tenant = allTenants.find((item) => item.id === tenantId);
if (tenant && isSeedTenant(tenant)) {
return;
}
if (
!window.confirm(
t(
@@ -473,7 +490,8 @@ function TenantListPage() {
<Checkbox
checked={
tenants.length > 0 &&
selectedIds.length === tenants.length
deletableTenants.length > 0 &&
selectedIds.length === deletableTenants.length
}
onCheckedChange={(checked) =>
handleSelectAll(!!checked)
@@ -529,13 +547,17 @@ function TenantListPage() {
)}
{tenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell>
<Checkbox
checked={selectedIds.includes(tenant.id)}
onCheckedChange={(checked) =>
handleSelect(tenant.id, !!checked)
}
/>
<TableCell className="text-center">
{isSeedTenant(tenant) ? (
<span className="inline-block h-4 w-4" />
) : (
<Checkbox
checked={selectedIds.includes(tenant.id)}
onCheckedChange={(checked) =>
handleSelect(tenant, !!checked)
}
/>
)}
</TableCell>
<TableCell
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
@@ -544,7 +566,17 @@ function TenantListPage() {
{tenant.id}
</TableCell>
<TableCell className="font-semibold">
{tenant.name}
<div className="flex flex-wrap items-center gap-2">
<span>{tenant.name}</span>
{isSeedTenant(tenant) && (
<Badge variant="secondary" className="text-[10px]">
{t(
"ui.admin.tenants.seed_badge",
"초기 설정",
)}
</Badge>
)}
</div>
</TableCell>
<TableCell>
<Badge
@@ -598,7 +630,17 @@ function TenantListPage() {
variant="outline"
size="sm"
onClick={() => handleDelete(tenant.id, tenant.name)}
disabled={deleteMutation.isPending}
disabled={
deleteMutation.isPending || isSeedTenant(tenant)
}
title={
isSeedTenant(tenant)
? t(
"msg.admin.tenants.seed_delete_blocked",
"초기 설정 테넌트는 삭제할 수 없습니다.",
)
: undefined
}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}

View File

@@ -28,6 +28,7 @@ import {
formatDomainConflictMessage,
type ServerDomainConflict,
} from "../utils/domainTags";
import { isSeedTenant } from "../utils/protectedTenants";
export function TenantProfilePage() {
const { tenantId } = useParams<{ tenantId: string }>();
@@ -154,8 +155,14 @@ export function TenantProfilePage() {
?.response?.data?.error;
const loadError = (tenantQuery.error as AxiosError<{ error?: string }>)
?.response?.data?.error;
const isProtectedSeedTenant = tenantQuery.data
? isSeedTenant(tenantQuery.data)
: false;
const handleDelete = () => {
if (isProtectedSeedTenant) {
return;
}
if (
window.confirm(
t("msg.admin.tenants.delete_confirm", "삭제하시겠습니까?", {
@@ -335,7 +342,15 @@ export function TenantProfilePage() {
<Button
variant="outline"
onClick={handleDelete}
disabled={deleteMutation.isPending}
disabled={deleteMutation.isPending || isProtectedSeedTenant}
title={
isProtectedSeedTenant
? t(
"msg.admin.tenants.seed_delete_blocked",
"초기 설정 테넌트는 삭제할 수 없습니다.",
)
: undefined
}
>
<Trash2 size={16} />
{t("ui.common.delete", "삭제")}

View File

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

View File

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

View File

@@ -0,0 +1,117 @@
import { expect, test } from "@playwright/test";
const tenants = [
{
id: "seed-hanmac",
name: "한맥가족",
slug: "hanmac-family",
type: "COMPANY_GROUP",
description: "한맥가족 기본 루트 테넌트",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
{
id: "normal-tenant",
name: "일반 테넌트",
slug: "normal-tenant",
type: "COMPANY",
description: "",
status: "active",
domains: [],
memberCount: 0,
createdAt: "",
updatedAt: "",
},
];
test.describe("Seed tenant protection", () => {
test.beforeEach(async ({ page }) => {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "ko");
window.localStorage.setItem("admin_session", "fake-token");
window.localStorage.setItem("RoleSwitcher-Collapsed", "true");
(
window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean }
)._IS_TEST_MODE = true;
const authority = "http://localhost:5000/oidc";
const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`;
window.localStorage.setItem(
key,
JSON.stringify({
access_token: "fake-token",
token_type: "Bearer",
profile: { sub: "admin-user", name: "Admin", role: "super_admin" },
expires_at: Math.floor(Date.now() / 1000) + 36000,
}),
);
});
await page.route("**/oidc/**", async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
});
await page.route("**/api/v1/**", async (route) => {
const url = route.request().url();
const headers = { "Access-Control-Allow-Origin": "*" };
if (url.includes("/user/me")) {
return route.fulfill({
json: {
id: "admin-user",
name: "Admin",
role: "super_admin",
manageableTenants: [],
},
headers,
});
}
if (url.includes("/admin/tenants/seed-hanmac")) {
return route.fulfill({ json: tenants[0], headers });
}
if (url.includes("/admin/tenants")) {
return route.fulfill({
json: {
items: tenants,
total: tenants.length,
limit: 1000,
offset: 0,
},
headers,
});
}
return route.fulfill({ json: {}, headers });
});
});
test("removes selection and disables delete action for seed tenants in the list", async ({
page,
}) => {
await page.goto("/tenants");
const seedRow = page.getByRole("row", { name: /한맥가족/ });
await expect(seedRow.getByRole("checkbox")).toHaveCount(0);
await expect(seedRow.getByText("초기 설정")).toBeVisible();
await expect(seedRow.getByRole("button", { name: /삭제/ })).toBeDisabled();
const normalRow = page.getByRole("row", { name: /일반 테넌트/ });
await expect(normalRow.getByRole("checkbox")).toBeEnabled();
await expect(
normalRow.getByRole("button", { name: /삭제/ }),
).toBeEnabled();
});
test("disables delete action on seed tenant profile", async ({ page }) => {
await page.goto("/tenants/seed-hanmac");
await expect(page.getByRole("heading", { name: "한맥가족" })).toBeVisible();
await expect(page.getByRole("button", { name: "삭제" })).toBeDisabled();
});
});