forked from baron/baron-sso
custom claim 권한체크 확인
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user