1
0
forked from baron/baron-sso
Files
baron-sso/adminfront/src/features/tenants/routes/TenantAdminsAndOwnersTab.tsx

674 lines
25 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Crown,
Plus,
Search,
ShieldCheck,
UserPlus,
Users,
} from "lucide-react";
import { useState } from "react";
import { useAuth } from "react-oidc-context";
import { useNavigate, useParams } from "react-router-dom";
import { commonStickyTableHeaderClass } from "../../../../../common/ui/table";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { toast } from "../../../components/ui/use-toast";
import {
addTenantAdmin,
addTenantOwner,
fetchTenantAdmins,
fetchTenantOwners,
fetchUsers,
removeTenantAdmin,
removeTenantOwner,
type TenantAdmin,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
type DialogMode = "owner" | "admin";
function mergePendingMembers(
members: TenantAdmin[],
pendingMembers: TenantAdmin[],
) {
const existingIds = new Set(members.map((member) => member.id));
return [
...members,
...pendingMembers.filter((member) => !existingIds.has(member.id)),
];
}
export function TenantAdminsAndOwnersTab() {
const auth = useAuth();
const navigate = useNavigate();
const _currentUserId = auth.user?.profile.sub;
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdParam ?? "";
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
const [pendingOwners, setPendingOwners] = useState<TenantAdmin[]>([]);
const [pendingAdmins, setPendingAdmins] = useState<TenantAdmin[]>([]);
const ownersQuery = useQuery({
queryKey: ["tenant-owners", tenantId],
queryFn: () => fetchTenantOwners(tenantId),
enabled: !!tenantId,
});
const adminsQuery = useQuery({
queryKey: ["tenant-admins", tenantId],
queryFn: () => fetchTenantAdmins(tenantId),
enabled: !!tenantId,
});
const usersQuery = useQuery({
queryKey: ["admin-users-search", searchTerm],
queryFn: () => fetchUsers(20, 0, searchTerm),
enabled: dialogMode !== null && searchTerm.length >= 2,
});
const addOwnerMutation = useMutation({
mutationFn: (userId: string) => addTenantOwner(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-owners", tenantId],
});
const previousOwners = queryClient.getQueryData<TenantAdmin[]>([
"tenant-owners",
tenantId,
]);
// Optimistically add to the list to prevent immediate double clicks
const addedUser = searchResults.find((u) => u.id === userId);
if (addedUser) {
const optimisticOwner = {
id: userId,
name: addedUser.name,
email: addedUser.email,
};
setPendingOwners((old) =>
old.some((owner) => owner.id === userId)
? old
: [...old, optimisticOwner],
);
queryClient.setQueryData<TenantAdmin[]>(
["tenant-owners", tenantId],
(old) => {
if (!old) return [optimisticOwner];
if (old.some((o) => o.id === userId)) return old;
return [...old, optimisticOwner];
},
);
}
return { previousOwners };
},
onSuccess: () => {
// Delay invalidation slightly to give the backend outbox time to process
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-owners", tenantId],
});
}, 1000);
toast.success(
t("msg.admin.tenants.owners.add_success", "소유자가 추가되었습니다."),
);
setSearchTerm("");
},
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
if (context?.previousOwners) {
queryClient.setQueryData(
["tenant-owners", tenantId],
context.previousOwners,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
const removeOwnerMutation = useMutation({
mutationFn: (userId: string) => removeTenantOwner(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-owners", tenantId],
});
const previousOwners = queryClient.getQueryData<TenantAdmin[]>([
"tenant-owners",
tenantId,
]);
setPendingOwners((old) => old.filter((owner) => owner.id !== userId));
queryClient.setQueryData<TenantAdmin[]>(
["tenant-owners", tenantId],
(old) => (old ? old.filter((o) => o.id !== userId) : []),
);
return { previousOwners };
},
onSuccess: () => {
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-owners", tenantId],
});
}, 1000);
toast.success(
t(
"msg.admin.tenants.owners.remove_success",
"소유자 권한이 회수되었습니다.",
),
);
},
onError: (err: AxiosError<{ error?: string }>, _userId, context) => {
if (context?.previousOwners) {
queryClient.setQueryData(
["tenant-owners", tenantId],
context.previousOwners,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
const addAdminMutation = useMutation({
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-admins", tenantId],
});
const previousAdmins = queryClient.getQueryData<TenantAdmin[]>([
"tenant-admins",
tenantId,
]);
const addedUser = searchResults.find((u) => u.id === userId);
if (addedUser) {
const optimisticAdmin = {
id: userId,
name: addedUser.name,
email: addedUser.email,
};
setPendingAdmins((old) =>
old.some((admin) => admin.id === userId)
? old
: [...old, optimisticAdmin],
);
queryClient.setQueryData<TenantAdmin[]>(
["tenant-admins", tenantId],
(old) => {
if (!old) return [optimisticAdmin];
if (old.some((a) => a.id === userId)) return old;
return [...old, optimisticAdmin];
},
);
}
return { previousAdmins };
},
onSuccess: () => {
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-admins", tenantId],
});
}, 1000);
toast.success(
t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."),
);
setSearchTerm("");
},
onError: (err: AxiosError<{ error?: string }>, userId, context) => {
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
if (context?.previousAdmins) {
queryClient.setQueryData(
["tenant-admins", tenantId],
context.previousAdmins,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
const removeAdminMutation = useMutation({
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
onMutate: async (userId) => {
await queryClient.cancelQueries({
queryKey: ["tenant-admins", tenantId],
});
const previousAdmins = queryClient.getQueryData<TenantAdmin[]>([
"tenant-admins",
tenantId,
]);
setPendingAdmins((old) => old.filter((admin) => admin.id !== userId));
queryClient.setQueryData<TenantAdmin[]>(
["tenant-admins", tenantId],
(old) => (old ? old.filter((a) => a.id !== userId) : []),
);
return { previousAdmins };
},
onSuccess: () => {
setTimeout(() => {
queryClient.invalidateQueries({
queryKey: ["tenant-admins", tenantId],
});
}, 1000);
toast.success(
t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."),
);
},
onError: (err: AxiosError<{ error?: string }>, _userId, context) => {
if (context?.previousAdmins) {
queryClient.setQueryData(
["tenant-admins", tenantId],
context.previousAdmins,
);
}
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
});
const handleAddUser = (userId: string) => {
if (dialogMode === "owner") {
addOwnerMutation.mutate(userId);
} else if (dialogMode === "admin") {
addAdminMutation.mutate(userId);
}
};
const _handleRemoveOwner = (userId: string, userName: string) => {
if (
window.confirm(
t(
"msg.admin.tenants.owners.remove_confirm",
"소유자를 삭제하시겠습니까?",
{ name: userName },
),
)
) {
removeOwnerMutation.mutate(userId);
}
};
const _handleRemoveAdmin = (userId: string, userName: string) => {
if (
window.confirm(
t(
"msg.admin.tenants.admins.remove_confirm",
"관리자를 삭제하시겠습니까?",
{ name: userName },
),
)
) {
removeAdminMutation.mutate(userId);
}
};
if (!tenantId) return null;
const serverOwners = ownersQuery.data || [];
const serverAdmins = adminsQuery.data || [];
const currentOwners = mergePendingMembers(serverOwners, pendingOwners);
const currentAdmins = mergePendingMembers(serverAdmins, pendingAdmins);
const searchResults = usersQuery.data?.items || [];
const isDialogOpen = dialogMode !== null;
const dialogTitle =
dialogMode === "owner"
? t("ui.admin.tenants.owners.dialog_title", "새 소유자 추가")
: t("ui.admin.tenants.admins.dialog_title", "새 관리자 추가");
const dialogDescription =
dialogMode === "owner"
? t(
"ui.admin.tenants.owners.dialog_description",
"이름 또는 이메일로 사용자를 검색하세요.",
)
: t(
"ui.admin.tenants.admins.dialog_description",
"이름 또는 이메일로 사용자를 검색하세요.",
);
return (
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<div className="flex-1 flex flex-col lg:flex-row gap-8 min-h-0">
{/* Owners Card */}
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<Crown className="h-6 w-6 text-yellow-500" />
{t("ui.admin.tenants.owners.title", "테넌트 소유자")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.owners.subtitle",
"이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다.",
)}
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("owner")}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.owners.add_button", "소유자 추가")}
</Button>
</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">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.owners.table_name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.owners.table_email", "이메일")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{ownersQuery.isLoading ? (
<TableRow>
<TableCell colSpan={2} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : currentOwners.length === 0 ? (
<TableRow>
<TableCell
colSpan={2}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.admin.tenants.owners.empty",
"등록된 소유자가 없습니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
currentOwners.map((owner) => (
<TableRow
key={owner.id}
className="hover:bg-muted/30 transition-colors group cursor-pointer"
onClick={() => navigate(`/users/${owner.id}`)}
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
{owner.name.charAt(0)}
</div>
<span>{owner.name}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground italic">
{owner.email}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
{/* Admins Card */}
<Card className="flex-1 flex flex-col min-h-0 border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7 flex-shrink-0">
<div className="space-y-1">
<CardTitle className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck className="h-6 w-6 text-primary" />
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.admins.subtitle",
"이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
)}
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setDialogMode("admin")}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
</Button>
</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">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<Table>
<TableHeader className={commonStickyTableHeaderClass}>
<TableRow>
<TableHead className="w-[250px] font-bold">
{t("ui.admin.tenants.admins.table_name", "이름")}
</TableHead>
<TableHead className="font-bold">
{t("ui.admin.tenants.admins.table_email", "이메일")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminsQuery.isLoading ? (
<TableRow>
<TableCell colSpan={2} className="h-32 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</TableCell>
</TableRow>
) : currentAdmins.length === 0 ? (
<TableRow>
<TableCell
colSpan={2}
className="h-32 text-center text-muted-foreground"
>
<div className="flex flex-col items-center gap-2">
<Users className="h-8 w-8 opacity-20" />
<p>
{t(
"msg.admin.tenants.admins.empty",
"등록된 관리자가 없습니다.",
)}
</p>
</div>
</TableCell>
</TableRow>
) : (
currentAdmins.map((admin) => (
<TableRow
key={admin.id}
className="hover:bg-muted/30 transition-colors group cursor-pointer"
onClick={() => navigate(`/users/${admin.id}`)}
>
<TableCell className="font-medium">
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-lg bg-secondary flex items-center justify-center text-secondary-foreground font-bold text-xs">
{admin.name.charAt(0)}
</div>
<span>{admin.name}</span>
</div>
</TableCell>
<TableCell className="text-muted-foreground italic">
{admin.email}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</div>
</CardContent>
</Card>
</div>
{/* Common Dialog for adding users */}
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
if (!open) {
setDialogMode(null);
setSearchTerm("");
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{dialogTitle}
</DialogTitle>
<DialogDescription>{dialogDescription}</DialogDescription>
</DialogHeader>
<div className="py-4 space-y-4">
<div className="relative">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder={t(
"ui.admin.tenants.admins.dialog_search_placeholder",
"사용자 검색 (최소 2자)...",
)}
className="pl-10 h-11"
autoFocus
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div>
<div className="max-h-[300px] overflow-y-auto rounded-lg border border-border">
{searchTerm.length < 2 ? (
<div className="p-10 text-center text-muted-foreground flex flex-col items-center gap-2">
<Search className="h-8 w-8 opacity-20" />
<p className="text-sm">
{t(
"ui.admin.tenants.admins.dialog_search_hint",
"검색어를 입력해 주세요.",
)}
</p>
</div>
) : usersQuery.isLoading ? (
<div className="p-10 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary mx-auto" />
</div>
) : searchResults.length === 0 ? (
<div className="p-10 text-center text-muted-foreground">
{t(
"ui.admin.tenants.admins.dialog_no_results",
"검색 결과가 없습니다.",
)}
</div>
) : (
<div className="divide-y divide-border">
{searchResults.map((user) => {
const isAlreadyOwner = currentOwners.some(
(o) => o.id === user.id,
);
const isAlreadyAdmin = currentAdmins.some(
(a) => a.id === user.id,
);
const isAlreadyMember =
dialogMode === "owner" ? isAlreadyOwner : isAlreadyAdmin;
return (
<div
key={user.id}
className="flex items-center justify-between p-3 hover:bg-muted/50 transition-colors"
>
<div className="flex items-center gap-3">
<div className="h-9 w-9 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{user.name.charAt(0)}
</div>
<div className="flex flex-col">
<span className="text-sm font-medium">
{user.name}
</span>
<span className="text-xs text-muted-foreground">
{user.email}
</span>
</div>
</div>
<Button
size="sm"
variant={isAlreadyMember ? "ghost" : "outline"}
disabled={
isAlreadyMember ||
addOwnerMutation.isPending ||
addAdminMutation.isPending
}
onClick={() => handleAddUser(user.id)}
>
{isAlreadyMember ? (
<Badge variant="secondary" className="font-normal">
{dialogMode === "owner"
? t(
"ui.admin.tenants.owners.already_owner",
"이미 소유자",
)
: t(
"ui.admin.tenants.admins.already_admin",
"이미 관리자",
)}
</Badge>
) : (
<>
<Plus className="h-3 w-3 mr-1" />{" "}
{t("ui.common.add", "추가")}
</>
)}
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}
export default TenantAdminsAndOwnersTab;