forked from baron/baron-sso
adminfront: 탭별 세부 권한 격리 부여를 위한 독자적인 5번째 탭(세부 권한) 추가 및 연동 완료
This commit is contained in:
@@ -17,6 +17,7 @@ import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
|
||||
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
|
||||
import { TenantFineGrainedPermissionsTab } from "../features/tenants/routes/TenantFineGrainedPermissionsTab";
|
||||
import { TenantWorksmobilePage } from "../features/tenants/routes/TenantWorksmobilePage";
|
||||
import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
|
||||
import GlobalCustomClaimsPage from "../features/users/GlobalCustomClaimsPage";
|
||||
@@ -59,6 +60,7 @@ export const adminRoutes: RouteObject[] = [
|
||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||
{ path: "schema", element: <TenantSchemaPage /> },
|
||||
{ path: "relations", element: <TenantFineGrainedPermissionsTab /> },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createI18nMock } from "../../test/i18nMock";
|
||||
import { TenantAdminsAndOwnersTab } from "../tenants/routes/TenantAdminsAndOwnersTab";
|
||||
import { TenantFineGrainedPermissionsTab } from "../tenants/routes/TenantFineGrainedPermissionsTab";
|
||||
import TenantUserGroupsTab from "../user-groups/routes/TenantUserGroupsTab";
|
||||
|
||||
const exportUsersCSVMock = vi.hoisted(() =>
|
||||
@@ -106,6 +107,16 @@ vi.mock("../../lib/adminApi", () => ({
|
||||
addTenantAdmin: vi.fn(async () => undefined),
|
||||
removeTenantOwner: vi.fn(async () => undefined),
|
||||
removeTenantAdmin: vi.fn(async () => undefined),
|
||||
fetchTenantRelations: vi.fn(async () => [
|
||||
{
|
||||
userId: "user-relation-1",
|
||||
name: "Relation User",
|
||||
email: "relation@example.com",
|
||||
relations: ["profile_managers", "schema_viewers"],
|
||||
},
|
||||
]),
|
||||
addTenantRelation: vi.fn(async () => undefined),
|
||||
removeTenantRelation: vi.fn(async () => undefined),
|
||||
fetchUsers: vi.fn(async () => ({
|
||||
items: users,
|
||||
total: users.length,
|
||||
@@ -165,6 +176,22 @@ describe("admin tenant tab coverage smoke", () => {
|
||||
expect(screen.getByText("admin@example.com")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tenant fine-grained relations list", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
<Route
|
||||
path="/tenants/:tenantId/relations"
|
||||
element={<TenantFineGrainedPermissionsTab />}
|
||||
/>
|
||||
</Routes>,
|
||||
"/tenants/tenant-company/relations",
|
||||
);
|
||||
|
||||
expect(await screen.findByText("Relation User")).toBeInTheDocument();
|
||||
expect(screen.getByText("relation@example.com")).toBeInTheDocument();
|
||||
expect(screen.getByText("세부 권한 설정 (Fine-grained Permissions)")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders tenant hierarchy and selected organization members", async () => {
|
||||
renderWithProviders(
|
||||
<Routes>
|
||||
|
||||
@@ -365,7 +365,7 @@ export function TenantAdminsAndOwnersTab() {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-8 mt-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<div className="space-y-8 mt-6 flex flex-col h-auto pb-10">
|
||||
<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)]">
|
||||
|
||||
@@ -117,6 +117,18 @@ function TenantDetailPage() {
|
||||
{t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
|
||||
</Link>
|
||||
)}
|
||||
{hasPermission("view") && (
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/relations`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
location.pathname.includes("/relations")
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_relations", "세부 권한")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Outlet for nested routes */}
|
||||
|
||||
@@ -0,0 +1,431 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
Plus,
|
||||
Search,
|
||||
ShieldCheck,
|
||||
UserPlus,
|
||||
} from "lucide-react";
|
||||
import { useState } 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";
|
||||
|
||||
export function TenantFineGrainedPermissionsTab() {
|
||||
const { tenantId: tenantIdParam } = useParams<{ tenantId: string }>();
|
||||
const tenantId = tenantIdParam ?? "";
|
||||
const { hasPermission } = useTenantPermission(tenantId);
|
||||
const isWritable = hasPermission("manage_admins");
|
||||
const queryClient = useQueryClient();
|
||||
const [searchTerm, setSearchTerm] = useState("");
|
||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||
|
||||
const relationsQuery = useQuery({
|
||||
queryKey: ["tenant-relations", tenantId],
|
||||
queryFn: () => fetchTenantRelations(tenantId),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
const relations = relationsQuery.data ?? [];
|
||||
|
||||
const addRelationMutation = useMutation({
|
||||
mutationFn: (payload: { userId: string; relation: string }) =>
|
||||
addTenantRelation(tenantId, payload.userId, payload.relation),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] });
|
||||
toast.success(t("msg.admin.tenants.relations.add_success", "세부 권한이 추가되었습니다."));
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
|
||||
},
|
||||
});
|
||||
|
||||
const removeRelationMutation = useMutation({
|
||||
mutationFn: (payload: { userId: string; relation: string }) =>
|
||||
removeTenantRelation(tenantId, payload.userId, payload.relation),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["tenant-relations", tenantId] });
|
||||
toast.success(t("msg.admin.tenants.relations.remove_success", "세부 권한이 회수되었습니다."));
|
||||
},
|
||||
onError: (err: AxiosError<{ error?: string }>) => {
|
||||
toast.error(err.response?.data?.error || t("msg.common.error", "오류가 발생했습니다."));
|
||||
},
|
||||
});
|
||||
|
||||
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;
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
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 });
|
||||
}
|
||||
};
|
||||
|
||||
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" });
|
||||
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";
|
||||
|
||||
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={profileVal}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) =>
|
||||
handleRelationChange(
|
||||
user.userId,
|
||||
"profile",
|
||||
profileVal,
|
||||
e.target.value as "none" | "read" | "write",
|
||||
)
|
||||
}
|
||||
>
|
||||
<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={permissionsVal}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) =>
|
||||
handleRelationChange(
|
||||
user.userId,
|
||||
"permissions",
|
||||
permissionsVal,
|
||||
e.target.value as "none" | "read" | "write",
|
||||
)
|
||||
}
|
||||
>
|
||||
<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={organizationVal}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) =>
|
||||
handleRelationChange(
|
||||
user.userId,
|
||||
"organization",
|
||||
organizationVal,
|
||||
e.target.value as "none" | "read" | "write",
|
||||
)
|
||||
}
|
||||
>
|
||||
<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={schemaVal}
|
||||
disabled={!isWritable}
|
||||
onChange={(e) =>
|
||||
handleRelationChange(
|
||||
user.userId,
|
||||
"schema",
|
||||
schemaVal,
|
||||
e.target.value as "none" | "read" | "write",
|
||||
)
|
||||
}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -491,6 +491,41 @@ export async function removeTenantOwner(tenantId: string, userId: string) {
|
||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
|
||||
}
|
||||
|
||||
export type TenantRelation = {
|
||||
userId: string;
|
||||
name: string;
|
||||
email: string;
|
||||
relations: string[];
|
||||
};
|
||||
|
||||
export async function fetchTenantRelations(tenantId: string) {
|
||||
const { data } = await apiClient.get<{ items: TenantRelation[] }>(
|
||||
`/v1/admin/tenants/${tenantId}/relations`,
|
||||
);
|
||||
return data.items;
|
||||
}
|
||||
|
||||
export async function addTenantRelation(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
relation: string,
|
||||
) {
|
||||
await apiClient.post(`/v1/admin/tenants/${tenantId}/relations`, {
|
||||
userId,
|
||||
relation,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeTenantRelation(
|
||||
tenantId: string,
|
||||
userId: string,
|
||||
relation: string,
|
||||
) {
|
||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}/relations`, {
|
||||
data: { userId, relation },
|
||||
});
|
||||
}
|
||||
|
||||
// Group Management
|
||||
export type GroupMember = {
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user