forked from baron/baron-sso
테넌트 소유자, 관리자 분리
This commit is contained in:
@@ -8,7 +8,7 @@ import AuthPage from "../features/auth/AuthPage";
|
|||||||
import LoginPage from "../features/auth/LoginPage";
|
import LoginPage from "../features/auth/LoginPage";
|
||||||
import DashboardPage from "../features/dashboard/DashboardPage";
|
import DashboardPage from "../features/dashboard/DashboardPage";
|
||||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||||
import TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab";
|
import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
|
||||||
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
|
||||||
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||||
@@ -48,7 +48,7 @@ export const router = createBrowserRouter(
|
|||||||
element: <TenantDetailPage />,
|
element: <TenantDetailPage />,
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <TenantProfilePage /> },
|
{ index: true, element: <TenantProfilePage /> },
|
||||||
{ path: "admins", element: <TenantAdminsTab /> },
|
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||||
{ path: "schema", element: <TenantSchemaPage /> },
|
{ path: "schema", element: <TenantSchemaPage /> },
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,549 @@
|
|||||||
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { AxiosError } from "axios";
|
||||||
|
import {
|
||||||
|
Crown,
|
||||||
|
Plus,
|
||||||
|
Search,
|
||||||
|
ShieldCheck,
|
||||||
|
Trash2,
|
||||||
|
UserPlus,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useParams } from "react-router-dom";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
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,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "../../../components/ui/dialog";
|
||||||
|
import { Input } from "../../../components/ui/input";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "../../../components/ui/table";
|
||||||
|
import {
|
||||||
|
addTenantAdmin,
|
||||||
|
addTenantOwner,
|
||||||
|
fetchTenantAdmins,
|
||||||
|
fetchTenantOwners,
|
||||||
|
fetchUsers,
|
||||||
|
removeTenantAdmin,
|
||||||
|
removeTenantOwner,
|
||||||
|
} from "../../../lib/adminApi";
|
||||||
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
|
type DialogMode = "owner" | "admin";
|
||||||
|
|
||||||
|
export function TenantAdminsAndOwnersTab() {
|
||||||
|
const { tenantId } = useParams<{ tenantId: string }>();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [searchTerm, setSearchTerm] = useState("");
|
||||||
|
const [dialogMode, setDialogMode] = useState<DialogMode | null>(null);
|
||||||
|
|
||||||
|
if (!tenantId) return null;
|
||||||
|
|
||||||
|
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),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId] });
|
||||||
|
toast.success(
|
||||||
|
t("msg.admin.tenants.owners.add_success", "소유자가 추가되었습니다."),
|
||||||
|
);
|
||||||
|
setSearchTerm("");
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
|
toast.error(
|
||||||
|
err.response?.data?.error ||
|
||||||
|
t("msg.common.error", "오류가 발생했습니다."),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeOwnerMutation = useMutation({
|
||||||
|
mutationFn: (userId: string) => removeTenantOwner(tenantId, userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant-owners", tenantId] });
|
||||||
|
toast.success(
|
||||||
|
t(
|
||||||
|
"msg.admin.tenants.owners.remove_success",
|
||||||
|
"소유자 권한이 회수되었습니다.",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
|
toast.error(
|
||||||
|
err.response?.data?.error ||
|
||||||
|
t("msg.common.error", "오류가 발생했습니다."),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const addAdminMutation = useMutation({
|
||||||
|
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] });
|
||||||
|
toast.success(
|
||||||
|
t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."),
|
||||||
|
);
|
||||||
|
setSearchTerm("");
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
|
toast.error(
|
||||||
|
err.response?.data?.error ||
|
||||||
|
t("msg.common.error", "오류가 발생했습니다."),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeAdminMutation = useMutation({
|
||||||
|
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] });
|
||||||
|
toast.success(
|
||||||
|
t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onError: (err: AxiosError<{ error?: string }>) => {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const currentOwners = ownersQuery.data || [];
|
||||||
|
const currentAdmins = adminsQuery.data || [];
|
||||||
|
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">
|
||||||
|
{/* Owners Card */}
|
||||||
|
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
|
||||||
|
<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>
|
||||||
|
<div className="rounded-xl border border-border overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-muted/30">
|
||||||
|
<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>
|
||||||
|
<TableHead className="text-right font-bold w-[100px]">
|
||||||
|
{t("ui.admin.tenants.owners.table_actions", "액션")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{ownersQuery.isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} 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={3}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||||
|
onClick={() =>
|
||||||
|
handleRemoveOwner(owner.id, owner.name)
|
||||||
|
}
|
||||||
|
disabled={removeOwnerMutation.isPending}
|
||||||
|
title={t(
|
||||||
|
"ui.admin.tenants.owners.remove_title",
|
||||||
|
"소유자 권한 회수",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Admins Card */}
|
||||||
|
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
||||||
|
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
|
||||||
|
<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>
|
||||||
|
<div className="rounded-xl border border-border overflow-hidden">
|
||||||
|
<Table>
|
||||||
|
<TableHeader className="bg-muted/30">
|
||||||
|
<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>
|
||||||
|
<TableHead className="text-right font-bold w-[100px]">
|
||||||
|
{t("ui.admin.tenants.admins.table_actions", "액션")}
|
||||||
|
</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{adminsQuery.isLoading ? (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell colSpan={3} 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={3}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 transition-all"
|
||||||
|
onClick={() =>
|
||||||
|
handleRemoveAdmin(admin.id, admin.name)
|
||||||
|
}
|
||||||
|
disabled={removeAdminMutation.isPending}
|
||||||
|
title={t(
|
||||||
|
"ui.admin.tenants.admins.remove_title",
|
||||||
|
"관리자 권한 회수",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* 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;
|
||||||
@@ -1,348 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import type { AxiosError } from "axios";
|
|
||||||
import {
|
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
ShieldCheck,
|
|
||||||
Trash2,
|
|
||||||
UserPlus,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
import { toast } from "sonner";
|
|
||||||
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,
|
|
||||||
DialogTrigger,
|
|
||||||
} from "../../../components/ui/dialog";
|
|
||||||
import { Input } from "../../../components/ui/input";
|
|
||||||
import {
|
|
||||||
Table,
|
|
||||||
TableBody,
|
|
||||||
TableCell,
|
|
||||||
TableHead,
|
|
||||||
TableHeader,
|
|
||||||
TableRow,
|
|
||||||
} from "../../../components/ui/table";
|
|
||||||
import {
|
|
||||||
addTenantAdmin,
|
|
||||||
fetchTenantAdmins,
|
|
||||||
fetchUsers,
|
|
||||||
removeTenantAdmin,
|
|
||||||
} from "../../../lib/adminApi";
|
|
||||||
import { t } from "../../../lib/i18n";
|
|
||||||
|
|
||||||
export function TenantAdminsTab() {
|
|
||||||
const { tenantId } = useParams<{ tenantId: string }>();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [searchTerm, setSearchTerm] = useState("");
|
|
||||||
const [isDialogOpen, setIsAddDialogOpen] = useState(false);
|
|
||||||
|
|
||||||
if (!tenantId) return null;
|
|
||||||
|
|
||||||
// 현재 관리자 목록 조회
|
|
||||||
const adminsQuery = useQuery({
|
|
||||||
queryKey: ["tenant-admins", tenantId],
|
|
||||||
queryFn: () => fetchTenantAdmins(tenantId),
|
|
||||||
enabled: !!tenantId,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 사용자 검색 조회 (2자 이상 입력 시)
|
|
||||||
const usersQuery = useQuery({
|
|
||||||
queryKey: ["admin-users-search", searchTerm],
|
|
||||||
queryFn: () => fetchUsers(20, 0, searchTerm),
|
|
||||||
enabled: isDialogOpen && searchTerm.length >= 2,
|
|
||||||
});
|
|
||||||
|
|
||||||
const addMutation = useMutation({
|
|
||||||
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] });
|
|
||||||
toast.success(
|
|
||||||
t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."),
|
|
||||||
);
|
|
||||||
setSearchTerm("");
|
|
||||||
},
|
|
||||||
onError: (err: AxiosError<{ error?: string }>) => {
|
|
||||||
toast.error(
|
|
||||||
err.response?.data?.error ||
|
|
||||||
t("msg.common.error", "오류가 발생했습니다."),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const removeMutation = useMutation({
|
|
||||||
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
|
|
||||||
onSuccess: () => {
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] });
|
|
||||||
toast.success(
|
|
||||||
t("msg.admin.tenants.admins.remove_success", "권한이 회수되었습니다."),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
onError: (err: AxiosError<{ error?: string }>) => {
|
|
||||||
toast.error(
|
|
||||||
err.response?.data?.error ||
|
|
||||||
t("msg.common.error", "오류가 발생했습니다."),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleAddAdmin = (userId: string) => {
|
|
||||||
addMutation.mutate(userId);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRemoveAdmin = (userId: string, userName: string) => {
|
|
||||||
if (
|
|
||||||
window.confirm(
|
|
||||||
t(
|
|
||||||
"msg.admin.tenants.admins.remove_confirm",
|
|
||||||
"관리자를 삭제하시겠습니까?",
|
|
||||||
{ name: userName },
|
|
||||||
),
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
removeMutation.mutate(userId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const currentAdmins = adminsQuery.data || [];
|
|
||||||
const searchResults = usersQuery.data?.items || [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6 mt-6">
|
|
||||||
<Card className="border-none shadow-sm bg-[var(--color-panel)]">
|
|
||||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<Dialog
|
|
||||||
open={isDialogOpen}
|
|
||||||
onOpenChange={(open) => {
|
|
||||||
setIsAddDialogOpen(open);
|
|
||||||
if (!open) setSearchTerm("");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<DialogTrigger asChild>
|
|
||||||
<Button className="bg-primary text-primary-foreground hover:bg-primary/90">
|
|
||||||
<UserPlus className="mr-2 h-4 w-4" />
|
|
||||||
{t("ui.admin.tenants.admins.add_button", "관리자 추가")}
|
|
||||||
</Button>
|
|
||||||
</DialogTrigger>
|
|
||||||
<DialogContent className="sm:max-w-[500px]">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="text-xl font-bold">
|
|
||||||
{t("ui.admin.tenants.admins.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 isAlreadyAdmin = currentAdmins.some(
|
|
||||||
(a) => a.id === 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={isAlreadyAdmin ? "ghost" : "outline"}
|
|
||||||
disabled={isAlreadyAdmin || addMutation.isPending}
|
|
||||||
onClick={() => handleAddAdmin(user.id)}
|
|
||||||
>
|
|
||||||
{isAlreadyAdmin ? (
|
|
||||||
<Badge
|
|
||||||
variant="secondary"
|
|
||||||
className="font-normal"
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
</CardHeader>
|
|
||||||
|
|
||||||
<CardContent>
|
|
||||||
<div className="rounded-xl border border-border overflow-hidden">
|
|
||||||
<Table>
|
|
||||||
<TableHeader className="bg-muted/30">
|
|
||||||
<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>
|
|
||||||
<TableHead className="text-right font-bold w-[100px]">
|
|
||||||
{t("ui.admin.tenants.admins.table_actions", "액션")}
|
|
||||||
</TableHead>
|
|
||||||
</TableRow>
|
|
||||||
</TableHeader>
|
|
||||||
<TableBody>
|
|
||||||
{adminsQuery.isLoading ? (
|
|
||||||
<TableRow>
|
|
||||||
<TableCell colSpan={3} 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={3}
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="opacity-0 group-hover:opacity-100 text-destructive hover:text-destructive hover:bg-destructive/10 transition-all"
|
|
||||||
onClick={() =>
|
|
||||||
handleRemoveAdmin(admin.id, admin.name)
|
|
||||||
}
|
|
||||||
disabled={removeMutation.isPending}
|
|
||||||
title={t(
|
|
||||||
"ui.admin.tenants.admins.remove_title",
|
|
||||||
"관리자 권한 회수",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TenantAdminsTab;
|
|
||||||
@@ -17,7 +17,7 @@ function TenantDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const isFederationTab = location.pathname.includes("/federation");
|
const isFederationTab = location.pathname.includes("/federation");
|
||||||
const isAdminTab = location.pathname.includes("/admins");
|
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||||
const isOrganizationTab = location.pathname.includes("/organization");
|
const isOrganizationTab = location.pathname.includes("/organization");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -59,7 +59,7 @@ function TenantDetailPage() {
|
|||||||
to={`/tenants/${tenantId}`}
|
to={`/tenants/${tenantId}`}
|
||||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||||
!isFederationTab &&
|
!isFederationTab &&
|
||||||
!isAdminTab &&
|
!isPermissionsTab &&
|
||||||
!location.pathname.includes("/schema") &&
|
!location.pathname.includes("/schema") &&
|
||||||
!isOrganizationTab
|
!isOrganizationTab
|
||||||
? "text-primary border-b-2 border-primary"
|
? "text-primary border-b-2 border-primary"
|
||||||
@@ -79,14 +79,14 @@ function TenantDetailPage() {
|
|||||||
{t("ui.admin.tenants.detail.tab_federation", "외부 연동")}
|
{t("ui.admin.tenants.detail.tab_federation", "외부 연동")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={`/tenants/${tenantId}/admins`}
|
to={`/tenants/${tenantId}/permissions`}
|
||||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||||
isAdminTab
|
isPermissionsTab
|
||||||
? "text-primary border-b-2 border-primary"
|
? "text-primary border-b-2 border-primary"
|
||||||
: "text-muted-foreground hover:text-foreground"
|
: "text-muted-foreground hover:text-foreground"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{t("ui.admin.tenants.detail.tab_admins", "관리자 설정")}
|
{t("ui.admin.tenants.detail.tab_permissions", "권한")}
|
||||||
</Link>
|
</Link>
|
||||||
<Link
|
<Link
|
||||||
to={`/tenants/${tenantId}/organization`}
|
to={`/tenants/${tenantId}/organization`}
|
||||||
|
|||||||
@@ -438,10 +438,16 @@ function UserCreatePage() {
|
|||||||
{...register("role")}
|
{...register("role")}
|
||||||
>
|
>
|
||||||
<option value="user">
|
<option value="user">
|
||||||
{t("ui.common.role.user", "User")}
|
{t("ui.admin.role.user", "TENANT MEMBER")}
|
||||||
</option>
|
</option>
|
||||||
<option value="admin">
|
<option value="tenant_admin">
|
||||||
{t("ui.common.role.admin", "Admin")}
|
{t("ui.admin.role.tenant_admin", "TENANT ADMIN")}
|
||||||
|
</option>
|
||||||
|
<option value="rp_admin">
|
||||||
|
{t("ui.admin.role.rp_admin", "RP ADMIN")}
|
||||||
|
</option>
|
||||||
|
<option value="super_admin">
|
||||||
|
{t("ui.admin.role.super_admin", "SUPER ADMIN")}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -287,10 +287,16 @@ function UserDetailPage() {
|
|||||||
{...register("role")}
|
{...register("role")}
|
||||||
>
|
>
|
||||||
<option value="user">
|
<option value="user">
|
||||||
{t("ui.common.role.user", "User")}
|
{t("ui.admin.role.user", "TENANT MEMBER")}
|
||||||
</option>
|
</option>
|
||||||
<option value="admin">
|
<option value="tenant_admin">
|
||||||
{t("ui.common.role.admin", "Admin")}
|
{t("ui.admin.role.tenant_admin", "TENANT ADMIN")}
|
||||||
|
</option>
|
||||||
|
<option value="rp_admin">
|
||||||
|
{t("ui.admin.role.rp_admin", "RP ADMIN")}
|
||||||
|
</option>
|
||||||
|
<option value="super_admin">
|
||||||
|
{t("ui.admin.role.super_admin", "SUPER ADMIN")}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -245,7 +245,7 @@ function UserListPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">
|
<Badge variant="outline">
|
||||||
{t(`ui.common.role.${user.role}`, user.role)}
|
{t(`ui.admin.role.${user.role}`, user.role)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
|
|||||||
@@ -167,6 +167,21 @@ export async function removeTenantAdmin(tenantId: string, userId: string) {
|
|||||||
await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
|
await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function fetchTenantOwners(tenantId: string) {
|
||||||
|
const { data } = await apiClient.get<TenantAdmin[]>(
|
||||||
|
`/v1/admin/tenants/${tenantId}/owners`,
|
||||||
|
);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function addTenantOwner(tenantId: string, userId: string) {
|
||||||
|
await apiClient.post(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function removeTenantOwner(tenantId: string, userId: string) {
|
||||||
|
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Group Management
|
// Group Management
|
||||||
export type GroupMember = {
|
export type GroupMember = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -703,7 +703,7 @@ view_audit_logs = "View Audit Logs"
|
|||||||
rp_admin = "RP ADMIN"
|
rp_admin = "RP ADMIN"
|
||||||
super_admin = "SUPER ADMIN"
|
super_admin = "SUPER ADMIN"
|
||||||
tenant_admin = "TENANT ADMIN"
|
tenant_admin = "TENANT ADMIN"
|
||||||
tenant_member = "TENANT MEMBER"
|
user = "TENANT MEMBER"
|
||||||
|
|
||||||
[ui.admin.tenants]
|
[ui.admin.tenants]
|
||||||
add = "Tenant Add"
|
add = "Tenant Add"
|
||||||
|
|||||||
@@ -772,10 +772,10 @@ policy_gate = "정책 게이트"
|
|||||||
total_tenants = "전체 테넌트"
|
total_tenants = "전체 테넌트"
|
||||||
|
|
||||||
[ui.admin.role]
|
[ui.admin.role]
|
||||||
rp_admin = "RP ADMIN"
|
rp_admin = "서비스 관리자 (RP Admin)"
|
||||||
super_admin = "SUPER ADMIN"
|
super_admin = "시스템 관리자 (Super Admin)"
|
||||||
tenant_admin = "TENANT ADMIN"
|
tenant_admin = "테넌트 관리자 (Tenant Admin)"
|
||||||
tenant_member = "TENANT MEMBER"
|
user = "일반 사용자 (Tenant Member)"
|
||||||
|
|
||||||
[ui.admin.tenants]
|
[ui.admin.tenants]
|
||||||
add = "테넌트 추가"
|
add = "테넌트 추가"
|
||||||
|
|||||||
101
adminfront/tests/owners.spec.ts
Normal file
101
adminfront/tests/owners.spec.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
|
test.describe("Tenant Owners Management", () => {
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
// Authenticate
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
const authority = "http://localhost:5000/oidc";
|
||||||
|
const client_id = "adminfront";
|
||||||
|
const key = `oidc.user:${authority}:${client_id}`;
|
||||||
|
const authData = {
|
||||||
|
access_token: "fake-token",
|
||||||
|
token_type: "Bearer",
|
||||||
|
profile: {
|
||||||
|
sub: "admin-user",
|
||||||
|
name: "Admin User",
|
||||||
|
email: "admin@example.com",
|
||||||
|
},
|
||||||
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
|
};
|
||||||
|
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock OIDC config to avoid redirects
|
||||||
|
await page.route(
|
||||||
|
"**/oidc/.well-known/openid-configuration",
|
||||||
|
async (route) => {
|
||||||
|
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mock tenant details
|
||||||
|
await page.route("**/api/v1/admin/tenants/tenant-1**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "tenant-1",
|
||||||
|
name: "Test Tenant",
|
||||||
|
slug: "test-tenant",
|
||||||
|
status: "active",
|
||||||
|
type: "COMPANY",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should list tenant owners", async ({ page }) => {
|
||||||
|
// Mock owners list
|
||||||
|
await page.route("**/api/v1/admin/tenants/tenant-1/owners**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: [
|
||||||
|
{ id: "owner-1", name: "Owner One", email: "owner1@example.com" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/tenants/tenant-1/owners");
|
||||||
|
|
||||||
|
// Check if the page title and the owner are visible
|
||||||
|
await expect(page.locator("h3")).toContainText("테넌트 소유자");
|
||||||
|
await expect(page.locator("table")).toContainText("Owner One");
|
||||||
|
await expect(page.locator("table")).toContainText("owner1@example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should add a new owner", async ({ page }) => {
|
||||||
|
// Mock owners list (initially empty)
|
||||||
|
await page.route("**/api/v1/admin/tenants/tenant-1/owners**", async (route) => {
|
||||||
|
if (route.request().method() === "GET") {
|
||||||
|
await route.fulfill({ json: [] });
|
||||||
|
} else if (route.request().method() === "POST") {
|
||||||
|
await route.fulfill({ status: 200 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock users search
|
||||||
|
await page.route("**/api/v1/admin/users?**", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
items: [
|
||||||
|
{ id: "user-2", name: "User Two", email: "user2@example.com" },
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto("/tenants/tenant-1/owners");
|
||||||
|
|
||||||
|
// Click add button
|
||||||
|
await page.click('button:has-text("소유자 추가")');
|
||||||
|
|
||||||
|
// Search for user
|
||||||
|
await page.fill('input[placeholder*="사용자 검색"]', "User Two");
|
||||||
|
|
||||||
|
// Wait for results and add - using a more specific selector to target the button in the dialog
|
||||||
|
const addButton = page.locator('role=dialog').getByRole('button', { name: '추가' });
|
||||||
|
await addButton.click();
|
||||||
|
|
||||||
|
// Verify toast or mutation (in a real app, the list would refresh)
|
||||||
|
// Here we just check if the dialog was closed or toast appears
|
||||||
|
// toast is shown on success
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -566,6 +566,9 @@ func main() {
|
|||||||
admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins)
|
admin.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins)
|
||||||
admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
|
admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
|
||||||
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
|
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
|
||||||
|
admin.Get("/tenants/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListOwners)
|
||||||
|
admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner)
|
||||||
|
admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner)
|
||||||
|
|
||||||
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
// Organization & Org-Chart Management (Tenant Admin/Super Admin)
|
||||||
org := admin.Group("/tenants/:tenantId/organization", requireAdmin)
|
org := admin.Group("/tenants/:tenantId/organization", requireAdmin)
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ func SeedTenants(db *gorm.DB) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug)
|
slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug)
|
||||||
tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil)
|
tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
|
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -3925,104 +3925,83 @@ func (h *AuthHandler) AcceptOidcLoginRequest(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
|
||||||
slog.Info("🚨 [FATAL_DEBUG] ENVIRONMENT CHECK",
|
|
||||||
"APP_ENV", os.Getenv("APP_ENV"),
|
|
||||||
"GO_ENV", os.Getenv("GO_ENV"),
|
|
||||||
"X-Test-Role", c.Get("X-Test-Role"),
|
|
||||||
)
|
|
||||||
slog.Info("🚀 [TRACE] resolveCurrentProfile entry", "path", c.Path(), "method", c.Method())
|
|
||||||
// [Dev Only] Mock Role Bypass
|
|
||||||
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
appEnv := strings.ToLower(os.Getenv("APP_ENV"))
|
||||||
|
isDev := appEnv == "dev" || appEnv == "development" || appEnv == ""
|
||||||
|
|
||||||
mockRole := c.Get("X-Test-Role")
|
mockRole := c.Get("X-Test-Role")
|
||||||
if mockRole == "" {
|
if mockRole == "" {
|
||||||
mockRole = c.Get("X-Mock-Role")
|
mockRole = c.Get("X-Mock-Role")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Always log in development to see what's happening
|
token := h.getBearerToken(c)
|
||||||
if appEnv == "dev" || appEnv == "development" || appEnv == "" {
|
cookie := c.Get("Cookie")
|
||||||
slog.Info("🔍 [AUTH_DEBUG] Checking mock role",
|
|
||||||
"env", appEnv,
|
var profile *domain.UserProfileResponse
|
||||||
"mockRole", mockRole,
|
var err error
|
||||||
"X-Test-Role", c.Get("X-Test-Role"),
|
cacheKey := ""
|
||||||
"X-Mock-Role", c.Get("X-Mock-Role"),
|
|
||||||
)
|
// 1. Try to fetch real profile if token/cookie exists
|
||||||
|
if token != "" || cookie != "" {
|
||||||
|
// Try Redis Cache
|
||||||
|
if h.RedisService != nil && token != "" {
|
||||||
|
cacheKey = "cache:profile:token:" + token
|
||||||
|
cached, _ := h.RedisService.Get(cacheKey)
|
||||||
|
if cached != "" {
|
||||||
|
if json.Unmarshal([]byte(cached), &profile) == nil {
|
||||||
|
// Fall through to role override check
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if profile == nil {
|
||||||
|
// Fetch from Kratos (SoT)
|
||||||
|
if token != "" {
|
||||||
|
profile, err = h.getKratosProfile(token)
|
||||||
|
if err != nil && h.Hydra != nil {
|
||||||
|
// Fallback to Hydra introspection
|
||||||
|
profile, err = h.getHydraProfile(c.Context(), token)
|
||||||
|
}
|
||||||
|
} else if cookie != "" {
|
||||||
|
profile, err = h.getKratosProfileWithCookie(cookie)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If in dev mode and we have a mock role, bypass Kratos
|
// 2. Role Override for real profile or fallback to Mock Profile
|
||||||
if (appEnv == "dev" || appEnv == "development" || appEnv == "") && mockRole != "" {
|
if profile != nil {
|
||||||
slog.Info("🔑 [AUTH_DEBUG] Mock bypass SUCCESS", "role", mockRole)
|
if isDev && mockRole != "" {
|
||||||
mockProfile := &domain.UserProfileResponse{
|
slog.Info("🔑 [AUTH_DEBUG] Overriding real profile role with mock role",
|
||||||
|
"email", profile.Email, "oldRole", profile.Role, "newRole", mockRole)
|
||||||
|
profile.Role = mockRole
|
||||||
|
}
|
||||||
|
} else if isDev && mockRole != "" {
|
||||||
|
slog.Info("🔑 [AUTH_DEBUG] No real session found, using full Mock Auth", "role", mockRole)
|
||||||
|
profile = &domain.UserProfileResponse{
|
||||||
ID: "00000000-0000-0000-0000-000000000000",
|
ID: "00000000-0000-0000-0000-000000000000",
|
||||||
Email: "mock@hmac.kr",
|
Email: "mock@hmac.kr",
|
||||||
Name: "Dev Mock User",
|
Name: "Dev Mock User",
|
||||||
Role: mockRole,
|
Role: mockRole,
|
||||||
}
|
}
|
||||||
if tid := c.Get("X-Tenant-ID"); tid != "" {
|
if tid := c.Get("X-Tenant-ID"); tid != "" {
|
||||||
mockProfile.TenantID = &tid
|
profile.TenantID = &tid
|
||||||
}
|
|
||||||
return mockProfile, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mock bypass failed - log headers for debugging if in dev
|
|
||||||
if appEnv == "dev" || appEnv == "development" || appEnv == "" {
|
|
||||||
slog.Warn("⚠️ [DEBUG] Mock auth bypass failed",
|
|
||||||
"appEnv", appEnv,
|
|
||||||
"X-Test-Role", c.Get("X-Test-Role"),
|
|
||||||
"X-Mock-Role", c.Get("X-Mock-Role"),
|
|
||||||
"path", c.Path())
|
|
||||||
}
|
|
||||||
|
|
||||||
var profile *domain.UserProfileResponse
|
|
||||||
var err error
|
|
||||||
|
|
||||||
token := h.getBearerToken(c)
|
|
||||||
cookie := c.Get("Cookie")
|
|
||||||
cacheKey := ""
|
|
||||||
|
|
||||||
// 1. Try Redis Cache
|
|
||||||
if h.RedisService != nil {
|
|
||||||
if token != "" {
|
|
||||||
cacheKey = "cache:profile:token:" + token
|
|
||||||
}
|
|
||||||
// Cookie based caching skipped for simplicity/safety
|
|
||||||
|
|
||||||
if cacheKey != "" {
|
|
||||||
cached, _ := h.RedisService.Get(cacheKey)
|
|
||||||
if cached != "" {
|
|
||||||
if json.Unmarshal([]byte(cached), &profile) == nil {
|
|
||||||
return profile, nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fetch from Kratos (SoT)
|
if profile == nil {
|
||||||
if token != "" {
|
|
||||||
profile, err = h.getKratosProfile(token)
|
|
||||||
} else {
|
|
||||||
if cookie != "" {
|
|
||||||
profile, err = h.getKratosProfileWithCookie(cookie)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil || profile == nil {
|
|
||||||
return nil, errors.New("invalid session (trace:resolve_profile)")
|
return nil, errors.New("invalid session (trace:resolve_profile)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Post-Process (Defaults & Metadata Enrichment)
|
// 3. Post-Process (Defaults & Metadata Enrichment)
|
||||||
// Default Role if missing (migration safety)
|
|
||||||
if profile.Role == "" {
|
if profile.Role == "" {
|
||||||
profile.Role = domain.RoleUser
|
profile.Role = domain.RoleUser
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch Tenant Metadata if missing
|
// Fetch Tenant Metadata if missing
|
||||||
// Case A: Have TenantID from Kratos -> Fetch by ID
|
|
||||||
if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
|
if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
|
||||||
if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil {
|
if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil {
|
||||||
profile.Tenant = tenant
|
profile.Tenant = tenant
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Case B: Have CompanyCode but no TenantID -> Fetch by Slug
|
|
||||||
if profile.Tenant == nil && profile.CompanyCode != "" {
|
if profile.Tenant == nil && profile.CompanyCode != "" {
|
||||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil {
|
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil {
|
||||||
profile.Tenant = tenant
|
profile.Tenant = tenant
|
||||||
@@ -4033,7 +4012,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 4. Save to Redis Cache (Short TTL)
|
// 4. Save to Redis Cache (Short TTL)
|
||||||
if h.RedisService != nil && cacheKey != "" {
|
if h.RedisService != nil && cacheKey != "" && err == nil {
|
||||||
if data, err := json.Marshal(profile); err == nil {
|
if data, err := json.Marshal(profile); err == nil {
|
||||||
ttlStr := os.Getenv("PROFILE_CACHE_TTL")
|
ttlStr := os.Getenv("PROFILE_CACHE_TTL")
|
||||||
ttl := 30 * time.Minute // Default TTL
|
ttl := 30 * time.Minute // Default TTL
|
||||||
@@ -5060,12 +5039,36 @@ func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
|
func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domain.UserProfileResponse, error) {
|
||||||
identityID, traits, err := h.getKratosIdentity(sessionToken)
|
intro, err := h.Hydra.IntrospectToken(ctx, token)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
slog.Error("Hydra introspection failed", "error", err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if !intro.Active {
|
||||||
|
slog.Warn("Hydra token is not active")
|
||||||
|
return nil, errors.New("token is not active")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("Hydra token introspected", "subject", intro.Subject, "client_id", intro.ClientID)
|
||||||
|
|
||||||
|
// Fetch identity details from Kratos by subject (identityID)
|
||||||
|
identity, err := h.KratosAdmin.GetIdentity(ctx, intro.Subject)
|
||||||
|
if err != nil || identity == nil {
|
||||||
|
slog.Warn("Kratos identity not found for Hydra subject", "subject", intro.Subject)
|
||||||
|
// Fallback to minimal profile if Kratos identity not found
|
||||||
|
return &domain.UserProfileResponse{
|
||||||
|
ID: intro.Subject,
|
||||||
|
Email: "unknown@hydra.local",
|
||||||
|
Name: "Hydra User",
|
||||||
|
Role: domain.RoleUser,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return h.mapKratosIdentityToProfile(identity.ID, identity.Traits), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[string]interface{}) *domain.UserProfileResponse {
|
||||||
email, _ := traits["email"].(string)
|
email, _ := traits["email"].(string)
|
||||||
name, _ := traits["name"].(string)
|
name, _ := traits["name"].(string)
|
||||||
phone, _ := traits["phone_number"].(string)
|
phone, _ := traits["phone_number"].(string)
|
||||||
@@ -5101,8 +5104,15 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile
|
|||||||
profile.Metadata[k] = v
|
profile.Metadata[k] = v
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return profile
|
||||||
|
}
|
||||||
|
|
||||||
return profile, nil
|
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
|
||||||
|
identityID, traits, err := h.getKratosIdentity(sessionToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return h.mapKratosIdentityToProfile(identityID, traits), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
|
func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
|
||||||
@@ -5110,44 +5120,7 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
return h.mapKratosIdentityToProfile(identityID, traits), nil
|
||||||
email, _ := traits["email"].(string)
|
|
||||||
name, _ := traits["name"].(string)
|
|
||||||
phone, _ := traits["phone_number"].(string)
|
|
||||||
dept, _ := traits["department"].(string)
|
|
||||||
affType, _ := traits["affiliationType"].(string)
|
|
||||||
compCode, _ := traits["companyCode"].(string)
|
|
||||||
role, _ := traits["role"].(string)
|
|
||||||
tenantID, _ := traits["tenant_id"].(string)
|
|
||||||
|
|
||||||
profile := &domain.UserProfileResponse{
|
|
||||||
ID: identityID,
|
|
||||||
Email: email,
|
|
||||||
Name: name,
|
|
||||||
Phone: h.formatPhoneForDisplay(phone),
|
|
||||||
Department: dept,
|
|
||||||
AffiliationType: affType,
|
|
||||||
CompanyCode: compCode,
|
|
||||||
Role: role,
|
|
||||||
Metadata: make(map[string]any),
|
|
||||||
}
|
|
||||||
|
|
||||||
if tenantID != "" {
|
|
||||||
profile.TenantID = &tenantID
|
|
||||||
}
|
|
||||||
|
|
||||||
coreTraits := map[string]bool{
|
|
||||||
"email": true, "name": true, "phone_number": true,
|
|
||||||
"grade": true, "companyCode": true, "department": true,
|
|
||||||
"affiliationType": true, "role": true, "tenant_id": true,
|
|
||||||
}
|
|
||||||
for k, v := range traits {
|
|
||||||
if !coreTraits[k] {
|
|
||||||
profile.Metadata[k] = v
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return profile, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateMe - Updates current user's profile with phone verification check
|
// UpdateMe - Updates current user's profile with phone verification check
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ type AsyncMockTenantService struct {
|
|||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -221,7 +221,13 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
|||||||
parentID = &pid
|
parentID = &pid
|
||||||
}
|
}
|
||||||
|
|
||||||
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID)
|
// Extract creator ID if present
|
||||||
|
creatorID := ""
|
||||||
|
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
|
||||||
|
creatorID = profile.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID, creatorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "already exists") {
|
if strings.Contains(err.Error(), "already exists") {
|
||||||
return errorJSON(c, fiber.StatusConflict, err.Error())
|
return errorJSON(c, fiber.StatusConflict, err.Error())
|
||||||
@@ -423,20 +429,28 @@ func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
userID := strings.TrimPrefix(rel.SubjectID, "User:")
|
userID := strings.TrimPrefix(rel.SubjectID, "User:")
|
||||||
|
|
||||||
// Fetch user details from Kratos
|
// Fetch user details - Try Kratos first, then local DB
|
||||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
name := "Unknown"
|
||||||
if err != nil {
|
email := "Unknown"
|
||||||
admins = append(admins, adminInfo{ID: userID, Name: "Unknown", Email: "Unknown"})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
name := ""
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||||
if n, ok := identity.Traits["name"].(string); ok {
|
if err == nil && identity != nil {
|
||||||
name = n
|
if n, ok := identity.Traits["name"].(string); ok {
|
||||||
}
|
name = n
|
||||||
email := ""
|
}
|
||||||
if e, ok := identity.Traits["email"].(string); ok {
|
if e, ok := identity.Traits["email"].(string); ok {
|
||||||
email = e
|
email = e
|
||||||
|
}
|
||||||
|
} else if h.UserRepo != nil {
|
||||||
|
// Fallback to local DB (useful for Mock users or users not yet synced/migrated to Kratos)
|
||||||
|
user, err := h.UserRepo.FindByID(c.Context(), userID)
|
||||||
|
if err == nil && user != nil {
|
||||||
|
name = user.Name
|
||||||
|
email = user.Email
|
||||||
|
} else if userID == "00000000-0000-0000-0000-000000000000" {
|
||||||
|
name = "Dev Mock User"
|
||||||
|
email = "mock@hmac.kr"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
admins = append(admins, adminInfo{
|
admins = append(admins, adminInfo{
|
||||||
@@ -464,6 +478,14 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
|
|||||||
Subject: "User:" + userID,
|
Subject: "User:" + userID,
|
||||||
Action: domain.KetoOutboxActionCreate,
|
Action: domain.KetoOutboxActionCreate,
|
||||||
})
|
})
|
||||||
|
// Also add as member for UI visibility/ReBAC logic
|
||||||
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: "members",
|
||||||
|
Subject: "User:" + userID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.SendStatus(fiber.StatusOK)
|
return c.SendStatus(fiber.StatusOK)
|
||||||
@@ -489,6 +511,113 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
|
|||||||
return c.SendStatus(fiber.StatusNoContent)
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) ListOwners(c *fiber.Ctx) error {
|
||||||
|
tenantID := c.Params("id")
|
||||||
|
if tenantID == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch owners from Keto
|
||||||
|
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", "")
|
||||||
|
if err != nil {
|
||||||
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
type ownerInfo struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Email string `json:"email"`
|
||||||
|
}
|
||||||
|
owners := []ownerInfo{}
|
||||||
|
|
||||||
|
for _, rel := range relations {
|
||||||
|
if !strings.HasPrefix(rel.SubjectID, "User:") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
userID := strings.TrimPrefix(rel.SubjectID, "User:")
|
||||||
|
|
||||||
|
// Fetch user details - Try Kratos first, then local DB
|
||||||
|
name := "Unknown"
|
||||||
|
email := "Unknown"
|
||||||
|
|
||||||
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||||
|
if err == nil && identity != nil {
|
||||||
|
if n, ok := identity.Traits["name"].(string); ok {
|
||||||
|
name = n
|
||||||
|
}
|
||||||
|
if e, ok := identity.Traits["email"].(string); ok {
|
||||||
|
email = e
|
||||||
|
}
|
||||||
|
} else if h.UserRepo != nil {
|
||||||
|
// Fallback to local DB
|
||||||
|
user, err := h.UserRepo.FindByID(c.Context(), userID)
|
||||||
|
if err == nil && user != nil {
|
||||||
|
name = user.Name
|
||||||
|
email = user.Email
|
||||||
|
} else if userID == "00000000-0000-0000-0000-000000000000" {
|
||||||
|
name = "Dev Mock User"
|
||||||
|
email = "mock@hmac.kr"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
owners = append(owners, ownerInfo{
|
||||||
|
ID: userID,
|
||||||
|
Name: name,
|
||||||
|
Email: email,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(owners)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) AddOwner(c *fiber.Ctx) error {
|
||||||
|
tenantID := c.Params("id")
|
||||||
|
userID := c.Params("userId")
|
||||||
|
if tenantID == "" || userID == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.KetoOutbox != nil {
|
||||||
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: "owners",
|
||||||
|
Subject: "User:" + userID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
|
// Also add as member for UI visibility/ReBAC logic
|
||||||
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: "members",
|
||||||
|
Subject: "User:" + userID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendStatus(fiber.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *TenantHandler) RemoveOwner(c *fiber.Ctx) error {
|
||||||
|
tenantID := c.Params("id")
|
||||||
|
userID := c.Params("userId")
|
||||||
|
if tenantID == "" || userID == "" {
|
||||||
|
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.KetoOutbox != nil {
|
||||||
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenantID,
|
||||||
|
Relation: "owners",
|
||||||
|
Subject: "User:" + userID,
|
||||||
|
Action: domain.KetoOutboxActionDelete,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.SendStatus(fiber.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
func mapTenantSummary(t domain.Tenant) tenantSummary {
|
func mapTenantSummary(t domain.Tenant) tenantSummary {
|
||||||
domains := make([]string, 0, len(t.Domains))
|
domains := make([]string, 0, len(t.Domains))
|
||||||
for _, d := range t.Domains {
|
for _, d := range t.Domains {
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ type MockTenantService struct {
|
|||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
|
||||||
args := m.Called(ctx, name, slug, tenantType, description, domains, parentID)
|
args := m.Called(ctx, name, slug, tenantType, description, domains, parentID, creatorID)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
}
|
}
|
||||||
@@ -133,7 +133,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
body, _ := json.Marshal(input)
|
body, _ := json.Marshal(input)
|
||||||
|
|
||||||
mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil)).
|
mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil), "").
|
||||||
Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil)
|
Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
|
|||||||
c.Locals("tenant_id", objectID)
|
c.Locals("tenant_id", objectID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check with Keto
|
// Check with Keto - add User: prefix to subject
|
||||||
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
|
allowed, err := config.KetoService.CheckPermission(c.Context(), "User:"+profile.ID, namespace, objectID, relation)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID)
|
slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID)
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
|
||||||
|
|||||||
@@ -596,3 +596,42 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
|
|||||||
|
|
||||||
return &AcceptLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil
|
return &AcceptLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HydraIntrospectionResponse struct {
|
||||||
|
Active bool `json:"active"`
|
||||||
|
Subject string `json:"sub"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
Scope string `json:"scope"`
|
||||||
|
ExpiresAt int64 `json:"exp"`
|
||||||
|
IssuedAt int64 `json:"iat"`
|
||||||
|
Ext map[string]interface{} `json:"ext"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (*HydraIntrospectionResponse, error) {
|
||||||
|
endpoint := fmt.Sprintf("%s/admin/oauth2/introspect", strings.TrimRight(s.AdminURL, "/"))
|
||||||
|
form := url.Values{}
|
||||||
|
form.Set("token", token)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
|
||||||
|
resp, err := s.httpClient().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
|
||||||
|
return nil, fmt.Errorf("hydra admin: introspection failed status=%d body=%s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var res HydraIntrospectionResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&res); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &res, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type TenantService interface {
|
type TenantService interface {
|
||||||
RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error)
|
RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error)
|
||||||
RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error)
|
RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error)
|
||||||
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
||||||
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
||||||
@@ -90,7 +90,7 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
|
|||||||
return s.repo.FindByIDs(ctx, allIDs)
|
return s.repo.FindByIDs(ctx, allIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
|
||||||
// Validate Slug
|
// Validate Slug
|
||||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||||
return nil, errors.New(msg)
|
return nil, errors.New(msg)
|
||||||
@@ -119,15 +119,49 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// [Keto] Sync hierarchy via Outbox if ParentID exists
|
// [Keto] Sync hierarchy and ownership via Outbox
|
||||||
if s.outboxRepo != nil && tenant.ParentID != nil {
|
if s.outboxRepo != nil {
|
||||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
// Sync hierarchy
|
||||||
Namespace: "Tenant",
|
if tenant.ParentID != nil {
|
||||||
Object: tenant.ID,
|
if err := s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
Relation: "parents",
|
Namespace: "Tenant",
|
||||||
Subject: "Tenant:" + *tenant.ParentID,
|
Object: tenant.ID,
|
||||||
Action: domain.KetoOutboxActionCreate,
|
Relation: "parents",
|
||||||
})
|
Subject: "Tenant:" + *tenant.ParentID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
}); err != nil {
|
||||||
|
slog.Error("Failed to create outbox entry for tenant hierarchy", "tenant", tenant.ID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sync creator ownership
|
||||||
|
if creatorID != "" {
|
||||||
|
slog.Info("Creating outbox entries for tenant creator", "tenant", tenant.ID, "creator", creatorID)
|
||||||
|
// Add as owner
|
||||||
|
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenant.ID,
|
||||||
|
Relation: "owners",
|
||||||
|
Subject: "User:" + creatorID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
|
// Add as admin
|
||||||
|
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenant.ID,
|
||||||
|
Relation: "admins",
|
||||||
|
Subject: "User:" + creatorID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
|
// Add as member
|
||||||
|
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenant.ID,
|
||||||
|
Relation: "members",
|
||||||
|
Subject: "User:" + creatorID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Add Domains (Auto-verify for manual admin registration)
|
// 3. Add Domains (Auto-verify for manual admin registration)
|
||||||
@@ -187,12 +221,20 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
|
|||||||
// [Keto] Sync relation via Outbox
|
// [Keto] Sync relation via Outbox
|
||||||
if s.outboxRepo != nil {
|
if s.outboxRepo != nil {
|
||||||
if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" {
|
if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" {
|
||||||
slog.Info("Queueing tenant admin sync to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail)
|
slog.Info("Queueing tenant admin/owner sync to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail)
|
||||||
// Check if user already exists in our Read-Model
|
// Check if user already exists in our Read-Model
|
||||||
if s.userRepo != nil {
|
if s.userRepo != nil {
|
||||||
user, err := s.userRepo.FindByEmail(ctx, adminEmail)
|
user, err := s.userRepo.FindByEmail(ctx, adminEmail)
|
||||||
if err == nil && user != nil {
|
if err == nil && user != nil {
|
||||||
// User exists, assign Admin role in Keto via Outbox
|
// User exists, assign Admin, Owner, and Member roles in Keto via Outbox
|
||||||
|
slog.Info("Queueing tenant ownership/membership sync to Keto", "tenant", tenant.Slug, "userID", user.ID)
|
||||||
|
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenant.ID,
|
||||||
|
Relation: "owners",
|
||||||
|
Subject: "User:" + user.ID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
Namespace: "Tenant",
|
Namespace: "Tenant",
|
||||||
Object: tenant.ID,
|
Object: tenant.ID,
|
||||||
@@ -200,6 +242,13 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
|
|||||||
Subject: "User:" + user.ID,
|
Subject: "User:" + user.ID,
|
||||||
Action: domain.KetoOutboxActionCreate,
|
Action: domain.KetoOutboxActionCreate,
|
||||||
})
|
})
|
||||||
|
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenant.ID,
|
||||||
|
Relation: "members",
|
||||||
|
Subject: "User:" + user.ID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
} else {
|
} else {
|
||||||
slog.Info("Tenant admin user not found in local DB, will need manual sync or sync on signup", "email", adminEmail)
|
slog.Info("Tenant admin user not found in local DB, will need manual sync or sync on signup", "email", adminEmail)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ func TestTenantService_RegisterTenant_DuplicateSlug(t *testing.T) {
|
|||||||
// Mock: slug already exists
|
// Mock: slug already exists
|
||||||
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "existing-id", Slug: slug}, nil)
|
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "existing-id", Slug: slug}, nil)
|
||||||
|
|
||||||
tenant, err := svc.RegisterTenant(ctx, "New Name", slug, domain.TenantTypeCompany, "", nil, nil)
|
tenant, err := svc.RegisterTenant(ctx, "New Name", slug, domain.TenantTypeCompany, "", nil, nil, "")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
assert.Contains(t, err.Error(), "already exists")
|
assert.Contains(t, err.Error(), "already exists")
|
||||||
assert.Nil(t, tenant)
|
assert.Nil(t, tenant)
|
||||||
@@ -32,11 +32,11 @@ func TestTenantService_RegisterTenant_InvalidSlug(t *testing.T) {
|
|||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// Case 1: Too short
|
// Case 1: Too short
|
||||||
_, err := svc.RegisterTenant(ctx, "Name", "a", domain.TenantTypeCompany, "", nil, nil)
|
_, err := svc.RegisterTenant(ctx, "Name", "a", domain.TenantTypeCompany, "", nil, nil, "")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
|
|
||||||
// Case 2: Invalid characters
|
// Case 2: Invalid characters
|
||||||
_, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", domain.TenantTypeCompany, "", nil, nil)
|
_, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", domain.TenantTypeCompany, "", nil, nil, "")
|
||||||
assert.Error(t, err)
|
assert.Error(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -162,13 +162,54 @@ func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
|||||||
mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil)
|
mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil)
|
||||||
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once()
|
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once()
|
||||||
|
|
||||||
tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil)
|
tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil, "")
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, tenant)
|
assert.NotNil(t, tenant)
|
||||||
assert.Equal(t, "t1", tenant.ID)
|
assert.Equal(t, "t1", tenant.ID)
|
||||||
mockRepo.AssertExpectations(t)
|
mockRepo.AssertExpectations(t)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestTenantService_RegisterTenant_WithCreator(t *testing.T) {
|
||||||
|
mockRepo := new(MockTenantRepoForSvc)
|
||||||
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
|
svc := NewTenantService(mockRepo, nil, mockOutbox)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
name := "Creator Tenant"
|
||||||
|
slug := "creator-tenant"
|
||||||
|
creatorID := "creator-uuid"
|
||||||
|
tenantID := "t-new"
|
||||||
|
|
||||||
|
mockRepo.On("FindBySlug", ctx, slug).Return(nil, nil).Once()
|
||||||
|
mockRepo.On("Create", ctx, mock.MatchedBy(func(t *domain.Tenant) bool {
|
||||||
|
return t.Slug == slug
|
||||||
|
})).Run(func(args mock.Arguments) {
|
||||||
|
t := args.Get(1).(*domain.Tenant)
|
||||||
|
t.ID = tenantID
|
||||||
|
}).Return(nil)
|
||||||
|
|
||||||
|
// Expect owners sync
|
||||||
|
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||||
|
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "owners" && e.Subject == "User:"+creatorID
|
||||||
|
})).Return(nil)
|
||||||
|
// Expect admins sync
|
||||||
|
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||||
|
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+creatorID
|
||||||
|
})).Return(nil)
|
||||||
|
// Expect members sync
|
||||||
|
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||||
|
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+creatorID
|
||||||
|
})).Return(nil)
|
||||||
|
|
||||||
|
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: tenantID, Slug: slug}, nil).Once()
|
||||||
|
|
||||||
|
tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", nil, nil, creatorID)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, tenant)
|
||||||
|
mockRepo.AssertExpectations(t)
|
||||||
|
mockOutbox.AssertExpectations(t)
|
||||||
|
}
|
||||||
|
|
||||||
func TestTenantService_RequestRegistration_NoVerify(t *testing.T) {
|
func TestTenantService_RequestRegistration_NoVerify(t *testing.T) {
|
||||||
mockRepo := new(MockTenantRepoForSvc)
|
mockRepo := new(MockTenantRepoForSvc)
|
||||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
@@ -215,9 +256,15 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
|
|||||||
mockRepo.On("Update", ctx, mock.Anything).Return(nil)
|
mockRepo.On("Update", ctx, mock.Anything).Return(nil)
|
||||||
mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, nil)
|
mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, nil)
|
||||||
// Now using Outbox instead of direct Keto call
|
// Now using Outbox instead of direct Keto call
|
||||||
|
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||||
|
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "owners" && e.Subject == "User:"+userID
|
||||||
|
})).Return(nil)
|
||||||
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||||
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+userID
|
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+userID
|
||||||
})).Return(nil)
|
})).Return(nil)
|
||||||
|
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
|
||||||
|
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID
|
||||||
|
})).Return(nil)
|
||||||
|
|
||||||
err := svc.ApproveTenant(ctx, tenantID)
|
err := svc.ApproveTenant(ctx, tenantID)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class User implements Namespace {}
|
|||||||
class Tenant implements Namespace {
|
class Tenant implements Namespace {
|
||||||
related: {
|
related: {
|
||||||
owners: User[]
|
owners: User[]
|
||||||
admins: (User | SubjectSet<Tenant, "owners">)[]
|
admins: User[]
|
||||||
members: User[]
|
members: User[]
|
||||||
parents: Tenant[]
|
parents: Tenant[]
|
||||||
}
|
}
|
||||||
@@ -14,12 +14,18 @@ class Tenant implements Namespace {
|
|||||||
view: (ctx: Context): boolean =>
|
view: (ctx: Context): boolean =>
|
||||||
this.related.members.includes(ctx.subject) ||
|
this.related.members.includes(ctx.subject) ||
|
||||||
this.related.admins.includes(ctx.subject) ||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
|
this.related.owners.includes(ctx.subject) ||
|
||||||
this.related.parents.traverse((p) => p.permits.view(ctx)),
|
this.related.parents.traverse((p) => p.permits.view(ctx)),
|
||||||
|
|
||||||
manage: (ctx: Context): boolean =>
|
manage: (ctx: Context): boolean =>
|
||||||
this.related.admins.includes(ctx.subject) ||
|
this.related.admins.includes(ctx.subject) ||
|
||||||
|
this.related.owners.includes(ctx.subject) ||
|
||||||
this.related.parents.traverse((p) => p.permits.manage(ctx)),
|
this.related.parents.traverse((p) => p.permits.manage(ctx)),
|
||||||
|
|
||||||
|
manage_admins: (ctx: Context): boolean =>
|
||||||
|
this.related.owners.includes(ctx.subject) ||
|
||||||
|
this.related.parents.traverse((p) => p.permits.manage_admins(ctx)),
|
||||||
|
|
||||||
create_subtenant: (ctx: Context): boolean =>
|
create_subtenant: (ctx: Context): boolean =>
|
||||||
this.permits.manage(ctx)
|
this.permits.manage(ctx)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user