forked from baron/baron-sso
445 lines
19 KiB
TypeScript
445 lines
19 KiB
TypeScript
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
import type { AxiosError } from "axios";
|
|
import { useState } from "react";
|
|
import {
|
|
fetchAllTenants,
|
|
fetchMe,
|
|
fetchUsers,
|
|
fetchSystemRelations,
|
|
addSystemRelation,
|
|
removeSystemRelation,
|
|
type TenantRelation,
|
|
} from "../../../lib/adminApi";
|
|
import { t } from "../../../lib/i18n";
|
|
import { TenantFineGrainedPermissionsTab } from "./TenantFineGrainedPermissionsTab";
|
|
import { ShieldCheck, Search, Plus, UserPlus, Trash2, Settings, Shield } from "lucide-react";
|
|
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";
|
|
|
|
export function TenantFineGrainedPermissionsPage() {
|
|
const queryClient = useQueryClient();
|
|
const [activeTab, setActiveTab] = useState<"tenant" | "system">("tenant");
|
|
const [selectedTenantId, setSelectedTenantId] = useState("");
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
|
|
|
const { data: profile } = useQuery({
|
|
queryKey: ["me"],
|
|
queryFn: fetchMe,
|
|
});
|
|
|
|
const isSuperAdmin = profile?.role === "super_admin";
|
|
|
|
const tenantsQuery = useQuery({
|
|
queryKey: ["tenants", "list-all"],
|
|
queryFn: () => fetchAllTenants(),
|
|
enabled: isSuperAdmin,
|
|
});
|
|
|
|
const tenants = isSuperAdmin
|
|
? (tenantsQuery.data?.items ?? [])
|
|
: (profile?.manageableTenants ?? []);
|
|
|
|
// System Relations (Admin Control) Queries & Mutations
|
|
const systemRelationsQuery = useQuery({
|
|
queryKey: ["system-relations"],
|
|
queryFn: fetchSystemRelations,
|
|
enabled: isSuperAdmin && activeTab === "system",
|
|
});
|
|
const systemRelations = systemRelationsQuery.data ?? [];
|
|
|
|
const addSystemRelationMutation = useMutation({
|
|
mutationFn: (payload: { userId: string; relation: string }) =>
|
|
addSystemRelation(payload.userId, payload.relation),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
|
|
toast.success(t("msg.admin.system.relations.add_success", "시스템 메뉴 권한이 추가되었습니다."));
|
|
},
|
|
onError: (err: AxiosError<{ error?: string }>) => {
|
|
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
|
|
},
|
|
});
|
|
|
|
const removeSystemRelationMutation = useMutation({
|
|
mutationFn: (payload: { userId: string; relation: string }) =>
|
|
removeSystemRelation(payload.userId, payload.relation),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: ["system-relations"] });
|
|
toast.success(t("msg.admin.system.relations.remove_success", "시스템 메뉴 권한이 회수되었습니다."));
|
|
},
|
|
onError: (err: AxiosError<{ error?: string }>) => {
|
|
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
|
|
},
|
|
});
|
|
|
|
const handleSystemRelationChange = async (
|
|
userId: string,
|
|
relation: string,
|
|
hasAccess: boolean,
|
|
) => {
|
|
if (hasAccess) {
|
|
await addSystemRelationMutation.mutateAsync({ userId, relation });
|
|
} else {
|
|
await removeSystemRelationMutation.mutateAsync({ userId, relation });
|
|
}
|
|
};
|
|
|
|
const handleRemoveAllSystemRelations = async (userId: string, userRelations: string[]) => {
|
|
if (!window.confirm(t("msg.admin.system.relations.remove_all_confirm", "이 사용자의 모든 시스템 메뉴 권한을 삭제하시겠습니까?"))) {
|
|
return;
|
|
}
|
|
for (const rel of userRelations) {
|
|
await removeSystemRelationMutation.mutateAsync({ userId, relation: rel });
|
|
}
|
|
};
|
|
|
|
const usersQuery = useQuery({
|
|
queryKey: ["admin-users-search", searchTerm],
|
|
queryFn: () => fetchUsers(20, 0, searchTerm),
|
|
enabled: isDialogOpen && searchTerm.length >= 2,
|
|
});
|
|
|
|
const handleAddSystemUser = (userId: string) => {
|
|
addSystemRelationMutation.mutate({ userId, relation: "overview_viewers" });
|
|
setIsDialogOpen(false);
|
|
setSearchTerm("");
|
|
};
|
|
|
|
const searchResults = usersQuery.data?.items || [];
|
|
|
|
const systemMenus = [
|
|
{ label: t("ui.admin.nav.overview", "개요"), relation: "overview_viewers" },
|
|
{ label: t("ui.admin.nav.tenants", "테넌트"), relation: "tenants_viewers" },
|
|
{ label: t("ui.admin.nav.org_chart", "조직도"), relation: "org_chart_viewers" },
|
|
{ label: t("ui.admin.nav.worksmobile", "Worksmobile"), relation: "worksmobile_viewers" },
|
|
{ label: t("ui.admin.nav.ory_ssot", "Ory SSOT"), relation: "ory_ssot_viewers" },
|
|
{ label: t("ui.admin.nav.data_integrity", "정합성"), relation: "data_integrity_viewers" },
|
|
{ label: t("ui.admin.nav.users", "사용자"), relation: "users_viewers" },
|
|
{ label: t("ui.admin.nav.permissions_direct", "권한 부여"), relation: "permissions_direct_viewers" },
|
|
{ label: t("ui.admin.nav.auth_guard", "인증 가드"), relation: "auth_guard_viewers" },
|
|
{ label: t("ui.admin.nav.api_keys", "API 키"), relation: "api_keys_viewers" },
|
|
{ label: t("ui.admin.nav.audit_logs", "감사 로그"), relation: "audit_logs_viewers" },
|
|
];
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div className="flex flex-col gap-1">
|
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
|
<ShieldCheck className="h-8 w-8 text-primary" />
|
|
{t("ui.admin.nav.permissions_direct", "권한 부여")}
|
|
</h1>
|
|
<p className="text-muted-foreground">
|
|
{t(
|
|
"msg.admin.permissions_direct.description",
|
|
"테넌트의 세부 기능 권한 및 글로벌 사이드바 메뉴 탭 접근 권한을 지정하고 부여합니다.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Tab Selectors */}
|
|
{isSuperAdmin && (
|
|
<div className="flex border-b border-border">
|
|
<button
|
|
onClick={() => setActiveTab("tenant")}
|
|
className={`px-6 py-3 text-sm font-semibold transition-colors relative ${
|
|
activeTab === "tenant"
|
|
? "text-primary border-b-2 border-primary"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
<Settings className="inline-block h-4 w-4 mr-2" />
|
|
{t("ui.admin.permissions_direct.tab_tenant", "테넌트 기능 권한")}
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab("system")}
|
|
className={`px-6 py-3 text-sm font-semibold transition-colors relative ${
|
|
activeTab === "system"
|
|
? "text-primary border-b-2 border-primary"
|
|
: "text-muted-foreground hover:text-foreground"
|
|
}`}
|
|
>
|
|
<Shield className="inline-block h-4 w-4 mr-2" />
|
|
{t("ui.admin.permissions_direct.tab_system", "시스템 메뉴 권한 (Admin Control)")}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{activeTab === "tenant" ? (
|
|
<>
|
|
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
|
<CardHeader className="pb-4">
|
|
<CardTitle className="text-lg font-semibold">
|
|
{t("ui.admin.permissions_direct.select_tenant", "대상 테넌트 선택")}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{t(
|
|
"msg.admin.permissions_direct.select_tenant_desc",
|
|
"세부 기능 권한을 부여할 대상 테넌트를 리스트에서 선택해 주세요.",
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<select
|
|
value={selectedTenantId}
|
|
onChange={(e) => setSelectedTenantId(e.target.value)}
|
|
className="flex h-10 w-full max-w-[360px] 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"
|
|
>
|
|
<option value="">{t("ui.admin.permissions_direct.placeholder", "-- 테넌트 선택 --")}</option>
|
|
{tenants.map((tenant) => (
|
|
<option key={tenant.id} value={tenant.id}>
|
|
{tenant.name} ({tenant.slug})
|
|
</option>
|
|
))}
|
|
</select>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{selectedTenantId ? (
|
|
<TenantFineGrainedPermissionsTab tenantIdProp={selectedTenantId} />
|
|
) : (
|
|
<div className="rounded-lg border border-dashed border-border p-12 text-center text-muted-foreground">
|
|
{t("msg.admin.permissions_direct.select_prompt", "상단에서 테넌트를 선택하면 세부 권한 격리 설정 격자가 노출됩니다.")}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
/* 시스템 메뉴 권한 (Admin Control) Tab Panel */
|
|
<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.permissions_direct.tab_system_title", "글로벌 메뉴 접근 제어 (Admin Control)")}
|
|
</CardTitle>
|
|
<CardDescription className="text-muted-foreground">
|
|
{t(
|
|
"msg.admin.permissions_direct.tab_system_desc",
|
|
"사이드바 각 메뉴별 접근 권한을 사용자에게 직접 부여합니다. 최고 관리자(super_admin)는 기본적으로 언제나 모든 권한을 우회 통과합니다.",
|
|
)}
|
|
</CardDescription>
|
|
</div>
|
|
<Button
|
|
className="bg-primary text-primary-foreground hover:bg-primary/90"
|
|
onClick={() => setIsDialogOpen(true)}
|
|
>
|
|
<UserPlus className="mr-2 h-4 w-4" />
|
|
{t("ui.admin.permissions_direct.add_system_user", "시스템 권한 사용자 추가")}
|
|
</Button>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<div className="rounded-md border border-border overflow-hidden overflow-x-auto">
|
|
<Table className="min-w-[1200px]">
|
|
<TableHeader className="bg-secondary/40">
|
|
<TableRow>
|
|
<TableHead className="font-bold sticky left-0 bg-background z-20 w-[180px]">{t("ui.common.name", "이름")}</TableHead>
|
|
{systemMenus.map((menu) => (
|
|
<TableHead key={menu.relation} className="font-bold text-center text-xs">
|
|
{menu.label}
|
|
</TableHead>
|
|
))}
|
|
<TableHead className="font-bold text-center w-[70px]">{t("ui.common.action", "작업")}</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{systemRelations.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={13} className="text-center py-12 text-muted-foreground">
|
|
{t("msg.admin.permissions_direct.system_empty", "지정된 글로벌 메뉴 세부 권한자가 없습니다. 사용자를 추가해 관리해 주세요.")}
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
systemRelations.map((user) => {
|
|
return (
|
|
<TableRow key={user.userId} className="hover:bg-muted/10 transition-colors">
|
|
<TableCell className="font-medium sticky left-0 bg-background z-10 shadow-[2px_0_5px_rgba(0,0,0,0.05)]">
|
|
<div className="flex flex-col">
|
|
<span className="font-semibold text-foreground">{user.name}</span>
|
|
<span className="text-[10px] text-muted-foreground italic truncate max-w-[150px]">{user.email}</span>
|
|
</div>
|
|
</TableCell>
|
|
{systemMenus.map((menu) => {
|
|
const hasAccess = user.relations.includes(menu.relation);
|
|
return (
|
|
<TableCell key={menu.relation} className="text-center">
|
|
<select
|
|
className="mx-auto flex h-8 w-[100px] rounded-md border border-input bg-background px-1.5 py-0.5 text-xs shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
|
value={hasAccess ? "access" : "none"}
|
|
onChange={(e) =>
|
|
handleSystemRelationChange(
|
|
user.userId,
|
|
menu.relation,
|
|
e.target.value === "access",
|
|
)
|
|
}
|
|
>
|
|
<option value="none">{t("ui.common.none", "X")}</option>
|
|
<option value="access">{t("ui.common.access", "허용")}</option>
|
|
</select>
|
|
</TableCell>
|
|
);
|
|
})}
|
|
<TableCell className="text-center">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => handleRemoveAllSystemRelations(user.userId, user.relations)}
|
|
>
|
|
<Trash2 className="h-4 w-4 text-destructive" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
);
|
|
})
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* User Search Dialog for System relations */}
|
|
<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.permissions_direct.dialog_title_system", "시스템 권한 관리 유저 추가")}
|
|
</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 = systemRelations.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 ||
|
|
addSystemRelationMutation.isPending
|
|
}
|
|
onClick={() => handleAddSystemUser(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>
|
|
);
|
|
}
|