forked from baron/baron-sso
feat: implement bulk user group/tenant move functionality
This commit is contained in:
@@ -53,6 +53,7 @@ import {
|
|||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
import { UserBulkUploadModal } from "./components/UserBulkUploadModal";
|
||||||
|
import { UserBulkMoveGroupModal } from "./components/UserBulkMoveGroupModal";
|
||||||
|
|
||||||
type UserSchemaField = {
|
type UserSchemaField = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -552,6 +553,13 @@ function UserListPage() {
|
|||||||
>
|
>
|
||||||
{t("ui.common.status.inactive", "비활성화")}
|
{t("ui.common.status.inactive", "비활성화")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<UserBulkMoveGroupModal
|
||||||
|
userIds={selectedUserIds}
|
||||||
|
onSuccess={() => {
|
||||||
|
query.refetch();
|
||||||
|
setSelectedUserIds([]);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { FolderTree, Loader2, Search } from "lucide-react";
|
||||||
|
import * as React from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Button } from "../../../components/ui/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../../../components/ui/dialog";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import { ScrollArea } from "../../../components/ui/scroll-area";
|
||||||
|
import {
|
||||||
|
bulkUpdateUsers,
|
||||||
|
fetchGroups,
|
||||||
|
fetchTenants,
|
||||||
|
type TenantSummary,
|
||||||
|
type GroupSummary
|
||||||
|
} from "../../../lib/adminApi";
|
||||||
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
|
interface UserBulkMoveGroupModalProps {
|
||||||
|
userIds: string[];
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserBulkMoveGroupModal({ userIds, onSuccess }: UserBulkMoveGroupModalProps) {
|
||||||
|
const [open, setOpen] = React.useState(false);
|
||||||
|
const [selectedTenantSlug, setSelectedTenantSlug] = React.useState<string>("");
|
||||||
|
const [selectedGroupName, setSelectedGroupName] = React.useState<string>("");
|
||||||
|
const [searchTerm, setSearchTerm] = React.useState("");
|
||||||
|
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: tenantsData } = useQuery({
|
||||||
|
queryKey: ["tenants", { limit: 100 }],
|
||||||
|
queryFn: () => fetchTenants(100, 0),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
const tenants = tenantsData?.items ?? [];
|
||||||
|
|
||||||
|
const selectedTenantId = React.useMemo(() =>
|
||||||
|
tenants.find(t => t.slug === selectedTenantSlug)?.id ?? "",
|
||||||
|
[tenants, selectedTenantSlug]);
|
||||||
|
|
||||||
|
const { data: groups, isLoading: isGroupsLoading } = useQuery({
|
||||||
|
queryKey: ["tenant-groups", selectedTenantId],
|
||||||
|
queryFn: () => fetchGroups(selectedTenantId),
|
||||||
|
enabled: open && !!selectedTenantId,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: bulkUpdateUsers,
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t("msg.admin.users.bulk.move_success", "사용자들의 부서가 이동되었습니다."));
|
||||||
|
setOpen(false);
|
||||||
|
onSuccess?.();
|
||||||
|
},
|
||||||
|
onError: (error: any) => {
|
||||||
|
toast.error(t("msg.admin.users.bulk.move_error", "부서 이동 실패"), {
|
||||||
|
description: error.response?.data?.error || error.message,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleMove = () => {
|
||||||
|
if (!selectedTenantSlug) return;
|
||||||
|
mutation.mutate({
|
||||||
|
userIds,
|
||||||
|
companyCode: selectedTenantSlug,
|
||||||
|
department: selectedGroupName, // can be empty for "No Department"
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredGroups = React.useMemo(() => {
|
||||||
|
if (!groups) return [];
|
||||||
|
if (!searchTerm) return groups;
|
||||||
|
return groups.filter(g => g.name.toLowerCase().includes(searchTerm.toLowerCase()));
|
||||||
|
}, [groups, searchTerm]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" className="text-background hover:bg-background/10 h-8">
|
||||||
|
{t("ui.admin.users.bulk.move_group", "부서 이동")}
|
||||||
|
</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("ui.admin.users.bulk.move_title", "사용자 부서 이동")}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("msg.admin.users.bulk.move_description", "선택한 {{count}}명의 사용자를 이동할 테넌트와 부서를 선택하세요.", { count: userIds.length })}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">{t("ui.admin.users.create.form.tenant", "테넌트 선택")}</label>
|
||||||
|
<select
|
||||||
|
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||||
|
value={selectedTenantSlug}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSelectedTenantSlug(e.target.value);
|
||||||
|
setSelectedGroupName("");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">{t("ui.common.select", "선택하세요...")}</option>
|
||||||
|
{tenants.map((t) => (
|
||||||
|
<option key={t.id} value={t.slug}>{t.name} ({t.slug})</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedTenantSlug && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium">{t("ui.admin.users.bulk.select_group", "부서 선택")}</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||||
|
<Input
|
||||||
|
placeholder={t("ui.common.search", "검색...")}
|
||||||
|
className="pl-9 h-9"
|
||||||
|
value={searchTerm}
|
||||||
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<ScrollArea className="h-[200px] rounded-md border p-2">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedGroupName("")}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
|
||||||
|
selectedGroupName === "" ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FolderTree size={14} />
|
||||||
|
{t("ui.admin.users.bulk.no_department", "(부서 없음)")}
|
||||||
|
</button>
|
||||||
|
{isGroupsLoading ? (
|
||||||
|
<div className="flex justify-center py-4"><Loader2 className="animate-spin text-muted-foreground" /></div>
|
||||||
|
) : (
|
||||||
|
filteredGroups.map((group) => (
|
||||||
|
<button
|
||||||
|
key={group.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setSelectedGroupName(group.name)}
|
||||||
|
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-sm transition ${
|
||||||
|
selectedGroupName === group.name ? "bg-primary text-primary-foreground" : "hover:bg-muted"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<FolderTree size={14} />
|
||||||
|
{group.name}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
{t("ui.common.cancel", "취소")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleMove}
|
||||||
|
disabled={!selectedTenantSlug || mutation.isPending}
|
||||||
|
>
|
||||||
|
{mutation.isPending && <Loader2 size={16} className="mr-2 animate-spin" />}
|
||||||
|
{t("ui.admin.users.bulk.do_move", "이동 실행")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -673,9 +673,11 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
||||||
var req struct {
|
var req struct {
|
||||||
UserIDs []string `json:"userIds"`
|
UserIDs []string `json:"userIds"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
Role *string `json:"role"`
|
Role *string `json:"role"`
|
||||||
|
CompanyCode *string `json:"companyCode"`
|
||||||
|
Department *string `json:"department"`
|
||||||
}
|
}
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||||
@@ -690,7 +692,13 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build manageable slugs map if tenant_admin
|
// [New] Pre-fetch tenant cache if companyCode is being changed
|
||||||
|
type tenantCacheItem struct {
|
||||||
|
ID string
|
||||||
|
Schema []interface{}
|
||||||
|
}
|
||||||
|
tenantCache := make(map[string]tenantCacheItem)
|
||||||
|
|
||||||
manageableSlugs := make(map[string]bool)
|
manageableSlugs := make(map[string]bool)
|
||||||
if requester.Role == domain.RoleTenantAdmin {
|
if requester.Role == domain.RoleTenantAdmin {
|
||||||
for _, t := range requester.ManageableTenants {
|
for _, t := range requester.ManageableTenants {
|
||||||
@@ -711,12 +719,19 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Authorization check
|
// Authorization check
|
||||||
|
userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
|
||||||
if requester.Role == domain.RoleTenantAdmin {
|
if requester.Role == domain.RoleTenantAdmin {
|
||||||
userComp := strings.ToLower(extractTraitString(identity.Traits, "companyCode"))
|
|
||||||
if !manageableSlugs[userComp] {
|
if !manageableSlugs[userComp] {
|
||||||
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"})
|
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: user belongs to another tenant"})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
// If changing companyCode, must be to a manageable one
|
||||||
|
if req.CompanyCode != nil {
|
||||||
|
if !manageableSlugs[strings.ToLower(*req.CompanyCode)] {
|
||||||
|
results = append(results, map[string]any{"id": id, "success": false, "message": "forbidden: target tenant not manageable"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prepare updates
|
// Prepare updates
|
||||||
@@ -724,6 +739,24 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
if req.Role != nil {
|
if req.Role != nil {
|
||||||
traits["role"] = *req.Role
|
traits["role"] = *req.Role
|
||||||
}
|
}
|
||||||
|
if req.CompanyCode != nil {
|
||||||
|
traits["companyCode"] = *req.CompanyCode
|
||||||
|
|
||||||
|
// Resolve and update tenant_id in traits if changed
|
||||||
|
if tItem, exists := tenantCache[*req.CompanyCode]; exists {
|
||||||
|
traits["tenant_id"] = tItem.ID
|
||||||
|
} else if h.TenantService != nil {
|
||||||
|
tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode)
|
||||||
|
if err == nil && tenant != nil {
|
||||||
|
tItem.ID = tenant.ID
|
||||||
|
tenantCache[*req.CompanyCode] = tItem
|
||||||
|
traits["tenant_id"] = tenant.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.Department != nil {
|
||||||
|
traits["department"] = *req.Department
|
||||||
|
}
|
||||||
|
|
||||||
state := identity.State
|
state := identity.State
|
||||||
if req.Status != nil {
|
if req.Status != nil {
|
||||||
@@ -743,12 +776,10 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
// Sync to local DB
|
// Sync to local DB
|
||||||
if h.UserRepo != nil {
|
if h.UserRepo != nil {
|
||||||
localUser := h.mapToLocalUser(*identity)
|
localUser := h.mapToLocalUser(*identity)
|
||||||
if req.Role != nil {
|
if req.Role != nil { localUser.Role = *req.Role }
|
||||||
localUser.Role = *req.Role
|
if req.Status != nil { localUser.Status = *req.Status }
|
||||||
}
|
if req.CompanyCode != nil { localUser.CompanyCode = *req.CompanyCode }
|
||||||
if req.Status != nil {
|
if req.Department != nil { localUser.Department = *req.Department }
|
||||||
localUser.Status = *req.Status
|
|
||||||
}
|
|
||||||
_ = h.UserRepo.Update(c.Context(), localUser)
|
_ = h.UserRepo.Update(c.Context(), localUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user