forked from baron/baron-sso
feat: implement multi-tenant member management and UI improvements
- Add multi-tenant support (isAddTenant, isRemoveTenant) to backend UpdateUser API. - Update UserRepository to support searching in company_codes array. - Implement table sorting and align search bar layout in adminfront. - Add 'Assign Existing Member' and 'Exclude from Organization' features to TenantUsersPage. - Auto-populate tenantSlug in UserCreatePage via query parameters. - Add necessary localization keys for new UI elements. Resolves #644, #639, #642, #641
This commit is contained in:
@@ -1,13 +1,20 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Mail, User } from "lucide-react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Mail, MoreHorizontal, Plus, User, UserPlus, UserMinus, Loader2 } from "lucide-react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Badge } from "../../../components/ui/badge";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../../components/ui/card";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "../../../components/ui/dropdown-menu";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -16,12 +23,14 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { fetchTenant, fetchUsers } from "../../../lib/adminApi";
|
||||
import { toast } from "../../../components/ui/use-toast";
|
||||
import { fetchTenant, fetchUsers, updateUser } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantUsersPage() {
|
||||
const params = useParams<{ tenantId: string }>();
|
||||
const tenantId = params.tenantId ?? "";
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// 테넌트의 슬러그(tenantSlug)를 먼저 가져옴
|
||||
const tenantQuery = useQuery({
|
||||
@@ -39,17 +48,51 @@ function TenantUsersPage() {
|
||||
enabled: !!tenantSlug,
|
||||
});
|
||||
|
||||
const removeTenantMutation = useMutation({
|
||||
mutationFn: ({ userId, slug }: { userId: string; slug: string }) =>
|
||||
updateUser(userId, { tenantSlug: slug, isRemoveTenant: true }),
|
||||
onSuccess: () => {
|
||||
toast.success(t("msg.admin.tenants.members.remove_success", "조직에서 제외되었습니다."));
|
||||
usersQuery.refetch();
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
|
||||
},
|
||||
onError: (err: any) => {
|
||||
toast.error(err.response?.data?.error || t("msg.admin.tenants.members.remove_error", "제외 실패"));
|
||||
},
|
||||
});
|
||||
|
||||
const handleRemoveMember = (userId: string, userName: string) => {
|
||||
if (!tenantSlug) return;
|
||||
if (window.confirm(t("msg.admin.tenants.members.remove_confirm", "'{{name}}'님을 이 조직에서 제외하시겠습니까?", { name: userName }))) {
|
||||
removeTenantMutation.mutate({ userId, slug: tenantSlug });
|
||||
}
|
||||
};
|
||||
|
||||
const users = usersQuery.data?.items ?? [];
|
||||
|
||||
return (
|
||||
<Card className="mt-6 bg-[var(--color-panel)] flex-1 flex flex-col min-h-0 overflow-hidden">
|
||||
<CardHeader className="flex-shrink-0">
|
||||
<CardHeader className="flex-shrink-0 flex flex-row items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<User size={18} className="text-primary" />
|
||||
{t("ui.admin.tenants.members.title", "Tenant Members ({{count}})", {
|
||||
count: users.length,
|
||||
})}
|
||||
</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" asChild className="gap-2">
|
||||
<Link to={`/users?addTenant=${tenantSlug}`}>
|
||||
<UserPlus size={16} />
|
||||
{t("ui.admin.tenants.members.add_existing", "기존 멤버 배정")}
|
||||
</Link>
|
||||
</Button>
|
||||
<Button size="sm" asChild className="gap-2">
|
||||
<Link to={`/users/new?tenantSlug=${tenantSlug}`}>
|
||||
<Plus size={16} />
|
||||
{t("ui.admin.tenants.members.create_new", "신규 멤버 생성")}
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 pt-0">
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
@@ -69,13 +112,25 @@ function TenantUsersPage() {
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.members.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] text-right">
|
||||
{t("ui.admin.tenants.members.table.actions", "ACTIONS")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{users.length === 0 && (
|
||||
{usersQuery.isLoading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center py-20">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Loader2 className="animate-spin text-muted-foreground" size={24} />
|
||||
<span className="text-sm text-muted-foreground">{t("ui.common.loading", "Loading...")}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : users.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={4}
|
||||
colSpan={5}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -84,33 +139,59 @@ function TenantUsersPage() {
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-semibold">{user.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Mail size={12} className="text-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{t(
|
||||
`ui.common.role.${user.role}`,
|
||||
user.role.replace("_", " "),
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={user.status === "active" ? "default" : "muted"}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal size={16} />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem asChild>
|
||||
<Link to={`/users/${user.id}`}>
|
||||
<User size={14} className="mr-2" />
|
||||
{t("ui.admin.tenants.members.view_profile", "상세 정보")}
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive focus:text-destructive"
|
||||
onClick={() => handleRemoveMember(user.id, user.name)}
|
||||
disabled={removeTenantMutation.isPending}
|
||||
>
|
||||
<UserMinus size={14} className="mr-2" />
|
||||
{t("ui.admin.tenants.members.remove", "조직에서 제외")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
{users.map((user) => (
|
||||
<TableRow key={user.id}>
|
||||
<TableCell className="font-semibold">{user.name}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1 text-xs">
|
||||
<Mail size={12} className="text-muted-foreground" />
|
||||
{user.email}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="capitalize">
|
||||
{t(
|
||||
`ui.common.role.${user.role}`,
|
||||
user.role.replace("_", " "),
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge
|
||||
variant={user.status === "active" ? "default" : "muted"}
|
||||
>
|
||||
{t(`ui.common.status.${user.status}`, user.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user