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);
}