1
0
forked from baron/baron-sso

custom claim 권한체크 확인

This commit is contained in:
2026-06-11 08:29:25 +09:00
parent 839ca9d407
commit 4d77060b5d
79 changed files with 4268 additions and 670 deletions

View File

@@ -61,6 +61,7 @@ import {
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
bulkUpdateUsers,
exportTenantsCSV,
exportUsersCSV,
fetchAllTenants,
@@ -72,6 +73,10 @@ import {
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { buildTenantFullTree, type TenantNode } from "../../../lib/tenantTree";
import {
buildAuthenticatedOrgChartUserMultiPickerUrl,
parseOrgChartUserSelections,
} from "../../users/orgChartPicker";
// --- Icons & Helpers ---
const getTenantIcon = (type?: string) => {
@@ -224,8 +229,10 @@ const MemberTable: React.FC<{
const removeMutation = useMutation({
mutationFn: (userId: string) =>
updateUser(userId, { tenantSlug, isRemoveTenant: true }),
onSuccess: () => {
onSuccess: (_result, userId) => {
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
queryClient.invalidateQueries({ queryKey: ["users"] });
queryClient.invalidateQueries({ queryKey: ["user", userId] });
toast.success(t("msg.info.saved_success", "조직에서 제외되었습니다."));
refetch();
},
@@ -297,7 +304,12 @@ const MemberTable: React.FC<{
<TableCell>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
data-testid={`tenant-org-member-actions-${user.id}`}
>
<MoreHorizontal size={14} />
</Button>
</DropdownMenuTrigger>
@@ -314,6 +326,7 @@ const MemberTable: React.FC<{
{t("ui.common.move_org", "타 조직으로 이동")}
</DropdownMenuItem>
<DropdownMenuItem
data-testid={`tenant-org-member-remove-${user.id}`}
onClick={() => {
if (
window.confirm(
@@ -635,9 +648,11 @@ function TenantUserGroupsTab() {
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setIsUserAddOpen(true)}
data-testid="tenant-org-member-add-open-btn"
>
<UserPlus size={16} className="mr-2" />
{t("ui.admin.users.list.add", "멤버 추가")}
@@ -869,8 +884,19 @@ const UserAddDialog: React.FC<{
const [userSearch, setUserSearch] = useState("");
const [isSearching, setIsSearching] = useState(false);
const [searchResults, setSearchResults] = useState<UserSummary[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string | null>(null);
const [queuedUsers, setQueuedUsers] = useState<UserSummary[]>([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const orgChartMemberPickerUrl = React.useMemo(
() =>
buildAuthenticatedOrgChartUserMultiPickerUrl(
import.meta.env.ORGFRONT_URL,
),
[],
);
const queuedUserIds = React.useMemo(
() => new Set(queuedUsers.map((user) => user.id)),
[queuedUsers],
);
const handleSearch = async () => {
if (!userSearch) return;
@@ -886,12 +912,22 @@ const UserAddDialog: React.FC<{
};
const handleAssign = async () => {
if (!selectedUserId) return;
if (queuedUsers.length === 0) return;
setIsSubmitting(true);
try {
await updateUser(selectedUserId, { tenantSlug });
await bulkUpdateUsers({
userIds: queuedUsers.map((user) => user.id),
tenantSlug,
isAddTenant: true,
});
queryClient.invalidateQueries({ queryKey: ["tenants-full-tree-v2"] });
toast.success(t("msg.info.saved_success", "사용자가 배정되었습니다."));
toast.success(
t(
"msg.admin.tenants.members.add_success",
"{{count}}명의 구성원이 추가되었습니다.",
{ count: queuedUsers.length },
),
);
onOpenChange(false);
resetFields();
} catch (err) {
@@ -908,9 +944,54 @@ const UserAddDialog: React.FC<{
const resetFields = () => {
setUserSearch("");
setSearchResults([]);
setSelectedUserId(null);
setQueuedUsers([]);
};
const queueUsers = React.useCallback((users: UserSummary[]) => {
setQueuedUsers((current) => {
const blockedIds = new Set(current.map((user) => user.id));
const next = [...current];
for (const user of users) {
if (blockedIds.has(user.id)) continue;
blockedIds.add(user.id);
next.push(user);
}
return next;
});
}, []);
const queueUser = (user: UserSummary) => {
queueUsers([user]);
};
const removeQueuedUser = (userId: string) => {
setQueuedUsers((current) => current.filter((user) => user.id !== userId));
};
React.useEffect(() => {
if (!open) return;
const onMessage = (event: MessageEvent) => {
const selections = parseOrgChartUserSelections(event.data);
if (selections.length === 0) return;
queueUsers(
selections.map((selection) => ({
id: selection.id,
name: selection.name,
email: selection.email,
role: "user",
status: "active",
createdAt: "",
updatedAt: "",
})),
);
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [open, queueUsers]);
return (
<Dialog
open={open}
@@ -919,7 +1000,7 @@ const UserAddDialog: React.FC<{
if (!v) resetFields();
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogContent className="max-w-5xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.users.create.title", "멤버 추가")}
@@ -929,52 +1010,103 @@ const UserAddDialog: React.FC<{
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div className="flex gap-2">
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이메일 검색...",
)}
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
<Button
variant="secondary"
onClick={handleSearch}
disabled={isSearching}
>
<Search size={16} />
</Button>
</div>
<ScrollArea className="h-60 border rounded-md">
<Table>
<TableBody>
{searchResults?.map((user) => (
<TableRow
key={user.id}
className={`cursor-pointer hover:bg-muted/50 ${selectedUserId === user.id ? "bg-primary/5" : ""}`}
onClick={() => setSelectedUserId(user.id)}
>
<TableCell>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{user.name}</p>
<p className="text-[10px] text-muted-foreground">
{user.email}
</p>
<div className="grid gap-4 py-4 lg:grid-cols-[minmax(0,1fr)_minmax(360px,1.2fr)]">
<div className="space-y-3">
<div className="flex gap-2">
<Input
placeholder={t(
"ui.admin.users.list.search_placeholder",
"이메일 검색...",
)}
value={userSearch}
onChange={(e) => setUserSearch(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
data-testid="tenant-org-member-search-input"
/>
<Button
variant="secondary"
onClick={handleSearch}
disabled={isSearching}
data-testid="tenant-org-member-search-btn"
>
<Search size={16} />
</Button>
</div>
<ScrollArea className="h-60 rounded-md border">
<Table>
<TableBody>
{searchResults?.map((user) => (
<TableRow
key={user.id}
data-testid={`tenant-org-member-search-result-${user.id}`}
className={`cursor-pointer hover:bg-muted/50 ${queuedUserIds.has(user.id) ? "bg-primary/5 opacity-60" : ""}`}
onClick={() => queueUser(user)}
>
<TableCell>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium">{user.name}</p>
<p className="text-[10px] text-muted-foreground">
{user.email}
</p>
</div>
{queuedUserIds.has(user.id) && (
<ChevronRight size={16} className="text-primary" />
)}
</div>
{selectedUserId === user.id && (
<ChevronRight size={16} className="text-primary" />
)}
</div>
</TableCell>
</TableRow>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
<div className="min-h-[360px] overflow-hidden rounded-md border">
<iframe
title={t(
"ui.admin.tenants.members.org_picker_title",
"조직도에서 구성원 선택",
)}
src={orgChartMemberPickerUrl}
className="h-[420px] w-full"
data-testid="tenant-org-member-picker-frame"
/>
</div>
<div
className="min-h-16 rounded-md border bg-muted/20 p-3 lg:col-span-2"
data-testid="tenant-org-member-add-queue"
>
{queuedUsers.length === 0 ? (
<div className="flex h-10 items-center justify-center text-sm text-muted-foreground">
{t(
"ui.admin.tenants.members.queue_empty",
"추가할 구성원을 선택하세요.",
)}
</div>
) : (
<div className="flex flex-wrap gap-2">
{queuedUsers.map((user) => (
<span
key={user.id}
className="inline-flex max-w-full items-center gap-2 rounded-md border bg-background px-2 py-1 text-sm"
>
<span className="max-w-52 truncate">{user.name}</span>
<button
type="button"
className="text-muted-foreground hover:text-foreground"
onClick={() => removeQueuedUser(user.id)}
aria-label={t(
"ui.admin.tenants.members.queue_remove",
"추가 명단에서 제거",
)}
>
<Trash2 size={14} />
</button>
</span>
))}
</TableBody>
</Table>
</ScrollArea>
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>
@@ -982,7 +1114,8 @@ const UserAddDialog: React.FC<{
</Button>
<Button
onClick={handleAssign}
disabled={isSubmitting || !selectedUserId}
disabled={isSubmitting || queuedUsers.length === 0}
data-testid="tenant-org-member-add-submit-btn"
>
{t("ui.common.add", "배정")}
</Button>