1
0
forked from baron/baron-sso

production 푸시 초안

This commit is contained in:
2026-06-18 11:02:48 +09:00
parent 33249eb229
commit a56d68896f
37 changed files with 3573 additions and 114 deletions

View File

@@ -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",
}),
);
});
});

View File

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