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

567 lines
25 KiB
TypeScript

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
Plus,
Search,
ShieldCheck,
UserPlus,
} from "lucide-react";
import { useState, useEffect } from "react";
import { useParams } from "react-router-dom";
import { useTenantPermission } from "../hooks/useTenantPermission";
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 {
fetchUsers,
fetchTenantRelations,
addTenantRelation,
removeTenantRelation,
type TenantRelation,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { Trash2 } from "lucide-react";
interface TenantFineGrainedPermissionsTabProps {
tenantIdProp?: string;
}
export function TenantFineGrainedPermissionsTab({ tenantIdProp }: TenantFineGrainedPermissionsTabProps = {}) {
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
const tenantId = tenantIdProp || tenantIdParam || "";
const { hasPermission } = useTenantPermission(tenantId);
const isWritable = hasPermission("manage_admins");
const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState("");
const [isDialogOpen, setIsDialogOpen] = useState(false);
// 🌟 테넌트 탭별 드롭다운 즉각 변경을 위한 임시 로컬 맵 선언
const [localTenantPermissions, setLocalTenantPermissions] = useState<Record<string, Record<string, "none" | "read" | "write">>>({});
const relationsQuery = useQuery({
queryKey: ["tenant-relations", tenantId],
queryFn: () => fetchTenantRelations(tenantId),
enabled: !!tenantId,
});
const relationsData = relationsQuery.data ?? [];
// 🌟 서버 데이터를 수신하면 로컬 변경 상태 맵을 실시간 동기화
useEffect(() => {
if (relationsQuery.data) {
const initialMap: Record<string, Record<string, "none" | "read" | "write">> = {};
for (const user of relationsQuery.data) {
initialMap[user.userId] = {};
const tabs = ["profile", "permissions", "organization", "schema"];
for (const tab of tabs) {
const isWrite = user.relations.includes(`${tab}_managers`);
const isRead = user.relations.includes(`${tab}_viewers`);
initialMap[user.userId][tab] = isWrite ? "write" : isRead ? "read" : "none";
}
}
setLocalTenantPermissions(initialMap);
}
}, [relationsQuery.data]);
const relations = relationsQuery.data ?? [];
const invalidateAllQueries = () => {
queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
queryClient.invalidateQueries({ queryKey: ["me"] });
setTimeout(() => {
queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
queryClient.invalidateQueries({ queryKey: ["me"] });
}, 500);
};
const addRelationMutation = useMutation({
mutationFn: (payload: { userId: string; relation: string }) =>
addTenantRelation(tenantId, payload.userId, payload.relation),
onMutate: async (newRelation) => {
await queryClient.cancelQueries({ queryKey: ["tenant-relations", tenantId] });
const previousRelations = queryClient.getQueryData<TenantRelation[]>(["tenant-relations", tenantId]);
queryClient.setQueryData<TenantRelation[]>(["tenant-relations", tenantId], (old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === newRelation.userId) {
return {
...user,
relations: user.relations.includes(newRelation.relation)
? user.relations
: [...user.relations, newRelation.relation],
};
}
return user;
});
});
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(["tenant-relations", tenantId], context.previousRelations);
}
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
},
onSuccess: () => {
// Quiet mutate
},
});
const removeRelationMutation = useMutation({
mutationFn: (payload: { userId: string; relation: string }) =>
removeTenantRelation(tenantId, payload.userId, payload.relation),
onMutate: async (targetRelation) => {
await queryClient.cancelQueries({ queryKey: ["tenant-relations", tenantId] });
const previousRelations = queryClient.getQueryData<TenantRelation[]>(["tenant-relations", tenantId]);
queryClient.setQueryData<TenantRelation[]>(["tenant-relations", tenantId], (old) => {
if (!old) return [];
return old.map((user) => {
if (user.userId === targetRelation.userId) {
return {
...user,
relations: user.relations.filter((r) => r !== targetRelation.relation),
};
}
return user;
});
});
return { previousRelations };
},
onError: (err: AxiosError<{ error?: string }>, _, context) => {
if (context?.previousRelations) {
queryClient.setQueryData(["tenant-relations", tenantId], context.previousRelations);
}
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
},
onSuccess: () => {
// Quiet mutate
},
});
const handleRelationChange = async (
userId: string,
tab: "profile" | "permissions" | "organization" | "schema",
currentVal: "none" | "read" | "write",
newVal: "none" | "read" | "write",
) => {
const readRel = `${tab}_viewers`;
const writeRel = `${tab}_managers`;
if (currentVal === newVal) return;
try {
if (currentVal === "read") {
await removeRelationMutation.mutateAsync({ userId, relation: readRel });
} else if (currentVal === "write") {
await removeRelationMutation.mutateAsync({ userId, relation: writeRel });
}
if (newVal === "read") {
await addRelationMutation.mutateAsync({ userId, relation: readRel });
} else if (newVal === "write") {
await addRelationMutation.mutateAsync({ userId, relation: writeRel });
}
invalidateAllQueries();
// 🌟 Trigger a single consolidated success toast at the very end
toast.success(t("msg.admin.tenants.relations.update_success", "세부 권한이 성공적으로 변경되었습니다."));
} catch {
// Individual mutations handle error toast via onError
}
};
const handleRemoveAllRelations = async (userId: string, userRelations: string[]) => {
if (!window.confirm(t("msg.admin.tenants.relations.remove_all_confirm", "이 사용자의 모든 세부 권한을 삭제하시겠습니까?"))) {
return;
}
for (const rel of userRelations) {
await removeRelationMutation.mutateAsync({ userId, relation: rel });
}
invalidateAllQueries();
};
const usersQuery = useQuery({
queryKey: ["admin-users-search", searchTerm],
queryFn: () => fetchUsers(20, 0, searchTerm),
enabled: isDialogOpen && searchTerm.length >= 2,
});
const handleAddUser = (userId: string) => {
addRelationMutation.mutate({ userId, relation: "profile_viewers" }, {
onSettled: () => {
invalidateAllQueries();
}
});
setIsDialogOpen(false);
setSearchTerm("");
};
if (!tenantId) return null;
const searchResults = usersQuery.data?.items || [];
return (
<div className="space-y-8 mt-6 flex flex-col h-auto pb-10">
<Card className="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.relations.title", "세부 권한 설정 (Fine-grained Permissions)")}
</CardTitle>
<CardDescription className="text-muted-foreground">
{t(
"msg.admin.tenants.relations.subtitle",
"사용자별로 각 탭의 세부 조회 및 수정 권한을 격리하여 할당합니다. 상위 상속 권한은 자동으로 보존됩니다.",
)}
</CardDescription>
</div>
<Button
className="bg-primary text-primary-foreground hover:bg-primary/90"
onClick={() => setIsDialogOpen(true)}
disabled={!isWritable}
>
<UserPlus className="mr-2 h-4 w-4" />
{t("ui.admin.tenants.relations.add_button", "세부 권한 사용자 추가")}
</Button>
</CardHeader>
<CardContent className="pt-0">
<div className="rounded-md border border-border overflow-hidden">
<Table>
<TableHeader className="bg-secondary/40">
<TableRow>
<TableHead className="font-bold">{t("ui.common.name", "이름")}</TableHead>
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_profile", "테넌트 프로필")}</TableHead>
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_permissions", "권한 관리")}</TableHead>
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_organization", "조직 관리")}</TableHead>
<TableHead className="font-bold">{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}</TableHead>
<TableHead className="font-bold text-center w-20">{t("ui.common.action", "작업")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{relations.length === 0 ? (
<TableRow>
<TableCell colSpan={6} className="text-center py-12 text-muted-foreground">
{t("msg.admin.tenants.relations.empty", "세부 권한이 지정된 사용자가 없습니다. 사용자를 추가해 설정하세요.")}
</TableCell>
</TableRow>
) : (
relations.map((user) => {
const profileVal = user.relations.includes("profile_managers")
? "write"
: user.relations.includes("profile_viewers")
? "read"
: "none";
const permissionsVal = user.relations.includes("permissions_managers")
? "write"
: user.relations.includes("permissions_viewers")
? "read"
: "none";
const organizationVal = user.relations.includes("organization_managers")
? "write"
: user.relations.includes("organization_viewers")
? "read"
: "none";
const schemaVal = user.relations.includes("schema_managers")
? "write"
: user.relations.includes("schema_viewers")
? "read"
: "none";
const curProfileVal = localTenantPermissions[user.userId]?.profile ?? profileVal;
const curPermissionsVal = localTenantPermissions[user.userId]?.permissions ?? permissionsVal;
const curOrganizationVal = localTenantPermissions[user.userId]?.organization ?? organizationVal;
const curSchemaVal = localTenantPermissions[user.userId]?.schema ?? schemaVal;
return (
<TableRow key={user.userId} className="hover:bg-muted/10 transition-colors">
<TableCell className="font-medium">
<div className="flex flex-col">
<span className="font-semibold text-foreground">{user.name}</span>
<span className="text-xs text-muted-foreground italic">{user.email}</span>
</div>
</TableCell>
<TableCell>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={curProfileVal}
disabled={!isWritable}
name={`tenant-fine-grained-profile-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as "none" | "read" | "write";
setLocalTenantPermissions(prev => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
profile: nextVal
}
}));
handleRelationChange(
user.userId,
"profile",
profileVal,
nextVal,
);
}}
>
<option value="none">{t("ui.common.none", "권한 없음")}</option>
<option value="read">{t("ui.common.read", "조회 가능 (Read)")}</option>
<option value="write">{t("ui.common.write", "수정 가능 (Write)")}</option>
</select>
</TableCell>
<TableCell>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={curPermissionsVal}
disabled={!isWritable}
name={`tenant-fine-grained-permissions-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as "none" | "read" | "write";
setLocalTenantPermissions(prev => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
permissions: nextVal
}
}));
handleRelationChange(
user.userId,
"permissions",
permissionsVal,
nextVal,
);
}}
>
<option value="none">{t("ui.common.none", "권한 없음")}</option>
<option value="read">{t("ui.common.read", "조회 가능 (Read)")}</option>
<option value="write">{t("ui.common.write", "수정 가능 (Write)")}</option>
</select>
</TableCell>
<TableCell>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={curOrganizationVal}
disabled={!isWritable}
name={`tenant-fine-grained-organization-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as "none" | "read" | "write";
setLocalTenantPermissions(prev => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
organization: nextVal
}
}));
handleRelationChange(
user.userId,
"organization",
organizationVal,
nextVal,
);
}}
>
<option value="none">{t("ui.common.none", "권한 없음")}</option>
<option value="read">{t("ui.common.read", "조회 가능 (Read)")}</option>
<option value="write">{t("ui.common.write", "수정 가능 (Write)")}</option>
</select>
</TableCell>
<TableCell>
<select
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={curSchemaVal}
disabled={!isWritable}
name={`tenant-fine-grained-schema-${user.userId}`}
onChange={(e) => {
const nextVal = e.target.value as "none" | "read" | "write";
setLocalTenantPermissions(prev => ({
...prev,
[user.userId]: {
...(prev[user.userId] ?? {}),
schema: nextVal
}
}));
handleRelationChange(
user.userId,
"schema",
schemaVal,
nextVal,
);
}}
>
<option value="none">{t("ui.common.none", "권한 없음")}</option>
<option value="read">{t("ui.common.read", "조회 가능 (Read)")}</option>
<option value="write">{t("ui.common.write", "수정 가능 (Write)")}</option>
</select>
</TableCell>
<TableCell className="text-center">
<Button
variant="ghost"
size="icon"
disabled={!isWritable}
onClick={() => handleRemoveAllRelations(user.userId, user.relations)}
>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{/* Common Dialog for adding users */}
<Dialog
open={isDialogOpen}
onOpenChange={(open) => {
if (!open) {
setIsDialogOpen(false);
setSearchTerm("");
}
}}
>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-xl font-bold">
{t("ui.admin.tenants.relations.dialog_title", "세부 권한 관리 유저 추가")}
</DialogTitle>
<DialogDescription>
{t(
"ui.admin.tenants.admins.dialog_description",
"이름 또는 이메일로 사용자를 검색하세요.",
)}
</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 isAlreadyInMatrix = relations.some(
(r) => r.userId === user.id,
);
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={isAlreadyInMatrix ? "ghost" : "outline"}
disabled={
isAlreadyInMatrix ||
addRelationMutation.isPending
}
onClick={() => handleAddUser(user.id)}
>
{isAlreadyInMatrix ? (
<Badge variant="secondary" className="font-normal">
{t(
"ui.admin.tenants.relations.already_added",
"이미 추가됨",
)}
</Badge>
) : (
<>
<Plus className="h-3 w-3 mr-1" />{" "}
{t("ui.common.add", "추가")}
</>
)}
</Button>
</div>
);
})}
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
</div>
);
}