1
0
forked from baron/baron-sso

정합성 위반사항 확인 및 조치기능 추가

This commit is contained in:
2026-05-14 09:04:33 +09:00
parent 9ca73e8774
commit df543d6203
17 changed files with 988 additions and 78 deletions

View File

@@ -1,7 +1,12 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen } from "@testing-library/react";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchDataIntegrityReport, fetchMe } from "../../lib/adminApi";
import {
deleteOrphanUserLoginIDs,
fetchDataIntegrityReport,
fetchMe,
fetchOrphanUserLoginIDs,
} from "../../lib/adminApi";
import DataIntegrityPage from "./DataIntegrityPage";
let currentRole = "super_admin";
@@ -36,6 +41,35 @@ vi.mock("../../lib/adminApi", () => ({
},
],
})),
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,
})),
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() {
@@ -69,6 +103,26 @@ describe("DataIntegrityPage", () => {
expect(fetchDataIntegrityReport).toHaveBeenCalledTimes(1);
});
it("shows orphan login ID targets and deletes selected rows", async () => {
vi.spyOn(window, "confirm").mockReturnValue(true);
renderPage();
expect(await screen.findByText("유령 로그인 ID 정리")).toBeInTheDocument();
expect(await screen.findByText("EMP001")).toBeInTheDocument();
expect(screen.getByText("삭제된 테넌트")).toBeInTheDocument();
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("blocks non-super admins", async () => {
currentRole = "tenant_admin";

View File

@@ -1,17 +1,21 @@
import { useQuery } from "@tanstack/react-query";
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,
type OrphanUserLoginID,
deleteOrphanUserLoginIDs,
fetchDataIntegrityReport,
fetchOrphanUserLoginIDs,
} from "../../lib/adminApi";
function statusLabel(status: DataIntegrityStatus) {
@@ -58,11 +62,138 @@ function CheckIcon({ check }: { check: DataIntegrityCheck }) {
return <ShieldAlert className="text-destructive" size={18} />;
}
function reasonLabel(reason: string) {
switch (reason) {
case "missing_user":
return "사용자 없음";
case "deleted_user":
return "삭제된 사용자";
case "missing_tenant":
return "테넌트 없음";
case "deleted_tenant":
return "삭제된 테넌트";
default:
return reason;
}
}
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">
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"></th>
<th className="px-3 py-2">Login ID</th>
<th className="px-3 py-2">Field</th>
<th className="px-3 py-2">User</th>
<th className="px-3 py-2">Tenant</th>
<th className="px-3 py-2"></th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{items.map((item) => (
<tr key={item.id}>
<td className="px-3 py-2">
<input
type="checkbox"
aria-label={`${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 [selectedOrphanIds, setSelectedOrphanIds] = useState<string[]>([]);
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(
`선택한 ${selectedOrphanIds.length}개의 유령 로그인 ID를 삭제하시겠습니까?`,
);
if (confirmed) {
deleteMutation.mutate(selectedOrphanIds);
}
};
return (
<main className="space-y-6 p-6 md:p-8">
@@ -184,6 +315,44 @@ function DataIntegrityContent() {
</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-base font-semibold"> ID </h3>
<p className="mt-1 text-sm text-muted-foreground">
/ ID를
.
</p>
</div>
<Button
type="button"
variant="destructive"
onClick={handleDeleteSelected}
disabled={
selectedOrphanIds.length === 0 || deleteMutation.isPending
}
>
</Button>
</div>
{orphanLoginIDsQuery.isError ? (
<div className="mb-3 rounded border border-destructive/30 bg-destructive/10 p-3 text-sm text-destructive">
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">
{deleteMutation.data.deletedCount} ID를
.
</div>
) : null}
<OrphanLoginIDTable
items={orphanItems}
selectedIds={selectedOrphanIds}
onToggle={toggleOrphanID}
/>
</section>
</main>
);
}

View File

@@ -648,12 +648,6 @@ function UserCreatePage() {
<option value="super_admin">
{t("ui.admin.role.super_admin", "시스템 관리자")}
</option>
<option value="tenant_admin">
{t("ui.admin.role.tenant_admin", "테넌트 관리자")}
</option>
<option value="rp_admin">
{t("ui.admin.role.rp_admin", "서비스 관리자")}
</option>
<option value="user">
{t("ui.admin.role.user", "일반 사용자")}
</option>

View File

@@ -67,7 +67,7 @@ import {
} from "../../lib/adminApi";
import type { PasswordPolicyResponse } from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { isSuperAdminRole, normalizeAdminRole } from "../../lib/roles";
import { normalizeAdminRole } from "../../lib/roles";
import { generateSecurePassword } from "../../lib/utils";
import {
type OrgChartTenantSelection,
@@ -740,6 +740,7 @@ function UserDetailPage() {
...data,
metadata,
};
payload.role = undefined;
if (userCategory === "personal") {
try {
@@ -1059,38 +1060,6 @@ function UserDetailPage() {
</span>
</div>
</div>
<div className="space-y-2">
<Label
htmlFor="role"
className="text-xs font-bold uppercase text-muted-foreground"
>
{t("ui.admin.users.detail.form.role", "역할")}
</Label>
<div className="flex h-11 items-center gap-3">
<select
id="role"
className="flex h-11 w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-primary disabled:opacity-50"
{...register("role")}
disabled={
!isSuperAdminRole(profile?.role) ||
profile?.id === user?.id
}
>
<option value="super_admin">
{t("ui.admin.role.super_admin", "시스템 관리자")}
</option>
<option value="tenant_admin">
{t("ui.admin.role.tenant_admin", "테넌트 관리자")}
</option>
<option value="rp_admin">
{t("ui.admin.role.rp_admin", "서비스 관리자")}
</option>
<option value="user">
{t("ui.admin.role.user", "일반 사용자")}
</option>
</select>
</div>
</div>
</div>
<Tabs

View File

@@ -92,16 +92,6 @@ const bulkPermissionOptions = [
labelKey: "ui.admin.role.super_admin",
fallback: "시스템 관리자",
},
{
value: "tenant_admin",
labelKey: "ui.admin.role.tenant_admin",
fallback: "테넌트 관리자",
},
{
value: "rp_admin",
labelKey: "ui.admin.role.rp_admin",
fallback: "서비스 관리자",
},
{
value: "user",
labelKey: "ui.admin.role.user",
@@ -109,6 +99,10 @@ const bulkPermissionOptions = [
},
] as const;
function assignableSystemRoleValue(role?: string | null) {
return isSuperAdminRole(role) ? "super_admin" : "user";
}
function UserListPage() {
const navigate = useNavigate();
const [page, setPage] = React.useState(1);
@@ -771,7 +765,7 @@ function UserListPage() {
</TableCell>
<TableCell>
<Select
defaultValue={user.role}
value={assignableSystemRoleValue(user.role)}
onValueChange={(value) =>
bulkUpdateMutation.mutate({
userIds: [user.id],
@@ -788,23 +782,14 @@ function UserListPage() {
<SelectValue />
</SelectTrigger>
<SelectContent>
{isSuperAdminRole(profile?.role) && (
<SelectItem value="super_admin">
{t(
"ui.admin.role.super_admin",
"시스템 관리자",
)}
{bulkPermissionOptions.map((option) => (
<SelectItem
key={option.value}
value={option.value}
>
{t(option.labelKey, option.fallback)}
</SelectItem>
)}
<SelectItem value="tenant_admin">
{t("ui.admin.role.tenant_admin", "테넌트 관리자")}
</SelectItem>
<SelectItem value="rp_admin">
{t("ui.admin.role.rp_admin", "서비스 관리자")}
</SelectItem>
<SelectItem value="user">
{t("ui.admin.role.user", "일반 사용자")}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>

View File

@@ -180,6 +180,30 @@ export type DataIntegrityReport = {
sections: DataIntegritySection[];
};
export type OrphanUserLoginID = {
id: string;
userId: string;
userEmail?: string;
userDeletedAt?: string;
tenantId: string;
tenantSlug?: string;
tenantDeletedAt?: string;
fieldKey: string;
loginId: string;
reasons: string[];
};
export type OrphanUserLoginIDListResponse = {
items: OrphanUserLoginID[];
total: number;
};
export type DeleteOrphanUserLoginIDsResult = {
deletedCount: number;
deleted: OrphanUserLoginID[];
skippedIds: string[];
};
export async function fetchAuditLogs(limit = 50, cursor?: string) {
const { data } = await apiClient.get<AuditLogListResponse>("/v1/audit", {
params: { limit, cursor },
@@ -199,6 +223,21 @@ export async function fetchDataIntegrityReport() {
return data;
}
export async function fetchOrphanUserLoginIDs() {
const { data } = await apiClient.get<OrphanUserLoginIDListResponse>(
"/v1/admin/integrity/orphan-user-login-ids",
);
return data;
}
export async function deleteOrphanUserLoginIDs(ids: string[]) {
const { data } = await apiClient.delete<DeleteOrphanUserLoginIDsResult>(
"/v1/admin/integrity/orphan-user-login-ids",
{ data: { ids } },
);
return data;
}
export async function fetchUserProjectionStatus() {
const { data } = await apiClient.get<UserProjectionStatus>(
"/v1/admin/projections/users",