forked from baron/baron-sso
production 푸시 초안
This commit is contained in:
@@ -82,6 +82,7 @@ describe("TenantFineGrainedPermissionsPage Super Admin role tab", () => {
|
||||
id: "regular-user",
|
||||
name: "Regular User",
|
||||
email: "regular@example.com",
|
||||
phone: "010-0000-0001",
|
||||
role: "user",
|
||||
status: "active",
|
||||
createdAt: "2026-06-17T00:00:00Z",
|
||||
@@ -126,4 +127,38 @@ describe("TenantFineGrainedPermissionsPage Super Admin role tab", () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("searches regular users and grants the Super Admin role from the target queue", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/permissions-direct"
|
||||
element={<TenantFineGrainedPermissionsPage />}
|
||||
/>
|
||||
</Routes>,
|
||||
);
|
||||
|
||||
fireEvent.click(
|
||||
await screen.findByRole("tab", { name: "Super Admin 역할" }),
|
||||
);
|
||||
|
||||
fireEvent.change(
|
||||
await screen.findByPlaceholderText("UUID, 이름, 이메일, 전화번호 검색"),
|
||||
{ target: { value: "010-0000-0001" } },
|
||||
);
|
||||
fireEvent.click(screen.getByRole("button", { name: "부여 대상 추가" }));
|
||||
|
||||
expect(
|
||||
screen.getByTestId("super-admin-grant-target-regular-user"),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Super Admin 부여" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(bulkUpdateUsersMock).toHaveBeenCalledWith({
|
||||
userIds: ["regular-user"],
|
||||
role: "super_admin",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -67,6 +67,20 @@ function isBootstrapSuperAdminUser(user: UserSummary) {
|
||||
return user.metadata?.bootstrapSuperAdmin === true;
|
||||
}
|
||||
|
||||
function normalizeUserSearchText(value: string | undefined) {
|
||||
return (value ?? "").trim().toLowerCase();
|
||||
}
|
||||
|
||||
function matchesUserIdentitySearch(user: UserSummary, normalizedTerm: string) {
|
||||
if (!normalizedTerm) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return [user.id, user.name, user.email, user.phone]
|
||||
.map((value) => normalizeUserSearchText(value))
|
||||
.some((value) => value.includes(normalizedTerm));
|
||||
}
|
||||
|
||||
export function TenantFineGrainedPermissionsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const navigate = useNavigate();
|
||||
@@ -88,6 +102,10 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
const [selectedSuperAdminUserIds, setSelectedSuperAdminUserIds] = useState<
|
||||
string[]
|
||||
>([]);
|
||||
const [superAdminGrantSearch, setSuperAdminGrantSearch] = useState("");
|
||||
const [queuedSuperAdminGrantUsers, setQueuedSuperAdminGrantUsers] = useState<
|
||||
UserSummary[]
|
||||
>([]);
|
||||
const [assignmentSearchTerm, setAssignmentSearchTerm] = useState("");
|
||||
const [assignmentSort, setAssignmentSort] = useState<
|
||||
"user" | "relation" | "level"
|
||||
@@ -149,6 +167,28 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
});
|
||||
}, [profile?.email, profile?.id, superAdminUsersQuery.data?.items]);
|
||||
|
||||
const queuedSuperAdminGrantUserIds = useMemo(
|
||||
() => new Set(queuedSuperAdminGrantUsers.map((user) => user.id)),
|
||||
[queuedSuperAdminGrantUsers],
|
||||
);
|
||||
|
||||
const superAdminGrantSearchResults = useMemo(() => {
|
||||
const normalizedTerm = normalizeUserSearchText(superAdminGrantSearch);
|
||||
if (!normalizedTerm) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return (superAdminUsersQuery.data?.items ?? [])
|
||||
.filter((user) => user.role !== "super_admin")
|
||||
.filter((user) => !queuedSuperAdminGrantUserIds.has(user.id))
|
||||
.filter((user) => matchesUserIdentitySearch(user, normalizedTerm))
|
||||
.slice(0, 20);
|
||||
}, [
|
||||
queuedSuperAdminGrantUserIds,
|
||||
superAdminGrantSearch,
|
||||
superAdminUsersQuery.data?.items,
|
||||
]);
|
||||
|
||||
const tenantRelationsQuery = useQuery({
|
||||
queryKey: ["tenant-relations", targetTenantId],
|
||||
queryFn: () => fetchTenantRelations(targetTenantId),
|
||||
@@ -325,6 +365,10 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
),
|
||||
);
|
||||
setSelectedSuperAdminUserIds([]);
|
||||
if (variables.role === "super_admin") {
|
||||
setQueuedSuperAdminGrantUsers([]);
|
||||
setSuperAdminGrantSearch("");
|
||||
}
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
toast.error(
|
||||
@@ -342,6 +386,21 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const queueSuperAdminGrantUser = (user: UserSummary) => {
|
||||
setQueuedSuperAdminGrantUsers((current) => {
|
||||
if (current.some((queuedUser) => queuedUser.id === user.id)) {
|
||||
return current;
|
||||
}
|
||||
return [...current, user];
|
||||
});
|
||||
};
|
||||
|
||||
const removeQueuedSuperAdminGrantUser = (userId: string) => {
|
||||
setQueuedSuperAdminGrantUsers((current) =>
|
||||
current.filter((user) => user.id !== userId),
|
||||
);
|
||||
};
|
||||
|
||||
const resolveBulkRelation = () => {
|
||||
if (bulkRelationMode === "page") {
|
||||
return bulkPageRelation;
|
||||
@@ -434,6 +493,22 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
});
|
||||
};
|
||||
|
||||
const handleGrantSuperAdminRole = () => {
|
||||
if (queuedSuperAdminGrantUsers.length === 0) {
|
||||
toast.error(
|
||||
t(
|
||||
"msg.admin.permissions_direct.super_admin_grant_users_required",
|
||||
"부여할 사용자를 하나 이상 추가하세요.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
updateUserRoleMutation.mutate({
|
||||
userIds: queuedSuperAdminGrantUsers.map((user) => user.id),
|
||||
role: "super_admin",
|
||||
});
|
||||
};
|
||||
|
||||
const queueTargetUsers = useCallback((users: UserSummary[]) => {
|
||||
setQueuedTargetUsers((current) => {
|
||||
const next = [...current];
|
||||
@@ -1378,84 +1453,238 @@ export function TenantFineGrainedPermissionsPage() {
|
||||
<h2 className="text-lg font-bold">
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_title",
|
||||
"Super Admin 역할 회수",
|
||||
"Super Admin 역할 관리",
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.super_admin_description",
|
||||
"현재 로그인한 관리자와 최초 관리자를 제외한 Super Admin 역할을 회수합니다.",
|
||||
"사용자를 검색해 Super Admin 역할을 부여하고, 현재 로그인한 관리자와 최초 관리자를 제외한 기존 역할은 회수합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 rounded-lg border border-border/70 bg-muted/10 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_users",
|
||||
"대상 사용자",
|
||||
)}
|
||||
</h3>
|
||||
<Badge variant="secondary">
|
||||
{selectedSuperAdminUserIds.length}
|
||||
{t("ui.admin.permissions_direct.bulk_selected", "명 선택")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
|
||||
{superAdminUsersQuery.isFetching ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t("msg.common.loading", "불러오는 중입니다.")}
|
||||
</div>
|
||||
) : revocableSuperAdminUsers.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
<div className="mt-5 grid gap-5 lg:grid-cols-[minmax(0,0.9fr)_minmax(0,1.1fr)]">
|
||||
<div className="rounded-lg border border-border/70 bg-muted/10 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.no_users_found",
|
||||
"등록된 사용자가 없습니다.",
|
||||
"ui.admin.permissions_direct.super_admin_search",
|
||||
"사용자 검색",
|
||||
)}
|
||||
</h3>
|
||||
<Badge variant="outline">
|
||||
{superAdminGrantSearchResults.length}
|
||||
{t("ui.admin.permissions_direct.search_results", "건")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
value={superAdminGrantSearch}
|
||||
onChange={(event) =>
|
||||
setSuperAdminGrantSearch(event.target.value)
|
||||
}
|
||||
placeholder={t(
|
||||
"ui.admin.permissions_direct.super_admin_search_placeholder",
|
||||
"UUID, 이름, 이메일, 전화번호 검색",
|
||||
)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-3 max-h-96 space-y-2 overflow-y-auto pr-1">
|
||||
{superAdminUsersQuery.isFetching ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t("msg.common.loading", "불러오는 중입니다.")}
|
||||
</div>
|
||||
) : !superAdminGrantSearch.trim() ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.super_admin_search_empty",
|
||||
"검색어를 입력하세요.",
|
||||
)}
|
||||
</div>
|
||||
) : superAdminGrantSearchResults.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.no_users_found",
|
||||
"등록된 사용자가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
superAdminGrantSearchResults.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
className="flex items-center gap-3 rounded-md border border-border/60 bg-background px-3 py-2 text-sm"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{user.phone || user.id}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => queueSuperAdminGrantUser(user)}
|
||||
>
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_grant_queue_add",
|
||||
"부여 대상 추가",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border border-border/70 bg-muted/10 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_grant_targets",
|
||||
"부여 대상자",
|
||||
)}
|
||||
</h3>
|
||||
<Badge variant="secondary">
|
||||
{queuedSuperAdminGrantUsers.length}
|
||||
{t("ui.admin.permissions_direct.bulk_selected", "명 선택")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="max-h-48 space-y-1 overflow-y-auto pr-1">
|
||||
{queuedSuperAdminGrantUsers.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.super_admin_grant_queue_empty",
|
||||
"부여할 사용자를 왼쪽 검색 결과에서 추가하세요.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
queuedSuperAdminGrantUsers.map((user) => (
|
||||
<div
|
||||
key={user.id}
|
||||
data-testid={`super-admin-grant-target-${user.id}`}
|
||||
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
aria-label={t(
|
||||
"ui.admin.permissions_direct.super_admin_grant_queue_remove",
|
||||
"부여 대상 제거",
|
||||
)}
|
||||
onClick={() =>
|
||||
removeQueuedSuperAdminGrantUser(user.id)
|
||||
}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
revocableSuperAdminUsers.map((user) => (
|
||||
<label
|
||||
key={user.id}
|
||||
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm hover:bg-muted/40"
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
onClick={handleGrantSuperAdminRole}
|
||||
disabled={
|
||||
updateUserRoleMutation.isPending ||
|
||||
queuedSuperAdminGrantUsers.length === 0
|
||||
}
|
||||
>
|
||||
<input
|
||||
name={`super-admin-role-user-${user.id}`}
|
||||
type="checkbox"
|
||||
data-testid={`super-admin-role-user-${user.id}`}
|
||||
checked={selectedSuperAdminUserIds.includes(user.id)}
|
||||
onChange={(event) =>
|
||||
toggleSuperAdminUser(user.id, event.target.checked)
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_grant",
|
||||
"Super Admin 부여",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex justify-end">
|
||||
<Button
|
||||
onClick={handleRevokeSuperAdminRole}
|
||||
disabled={
|
||||
updateUserRoleMutation.isPending ||
|
||||
selectedSuperAdminUserIds.length === 0
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_revoke",
|
||||
"Super Admin 회수",
|
||||
)}
|
||||
</Button>
|
||||
<div className="rounded-lg border border-border/70 bg-muted/10 p-4">
|
||||
<div className="mb-3 flex items-center justify-between gap-3">
|
||||
<h3 className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_users",
|
||||
"현재 Super Admin",
|
||||
)}
|
||||
</h3>
|
||||
<Badge variant="secondary">
|
||||
{selectedSuperAdminUserIds.length}
|
||||
{t("ui.admin.permissions_direct.bulk_selected", "명 선택")}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="max-h-80 space-y-1 overflow-y-auto pr-1">
|
||||
{superAdminUsersQuery.isFetching ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t("msg.common.loading", "불러오는 중입니다.")}
|
||||
</div>
|
||||
) : revocableSuperAdminUsers.length === 0 ? (
|
||||
<div className="py-8 text-center text-xs text-muted-foreground">
|
||||
{t(
|
||||
"msg.admin.permissions_direct.no_users_found",
|
||||
"등록된 사용자가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
revocableSuperAdminUsers.map((user) => (
|
||||
<label
|
||||
key={user.id}
|
||||
className="flex items-center gap-3 rounded-md px-2 py-2 text-sm hover:bg-muted/40"
|
||||
>
|
||||
<input
|
||||
name={`super-admin-role-user-${user.id}`}
|
||||
type="checkbox"
|
||||
data-testid={`super-admin-role-user-${user.id}`}
|
||||
checked={selectedSuperAdminUserIds.includes(user.id)}
|
||||
onChange={(event) =>
|
||||
toggleSuperAdminUser(user.id, event.target.checked)
|
||||
}
|
||||
/>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate font-medium">
|
||||
{user.name}
|
||||
</span>
|
||||
<span className="block truncate text-xs text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
<Button
|
||||
onClick={handleRevokeSuperAdminRole}
|
||||
disabled={
|
||||
updateUserRoleMutation.isPending ||
|
||||
selectedSuperAdminUserIds.length === 0
|
||||
}
|
||||
>
|
||||
{t(
|
||||
"ui.admin.permissions_direct.super_admin_revoke",
|
||||
"Super Admin 회수",
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user