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 DashboardPage from "../features/dashboard/DashboardPage";
|
||||
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 TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
|
||||
import TenantListPage from "../features/tenants/routes/TenantListPage";
|
||||
@@ -48,7 +48,7 @@ export const router = createBrowserRouter(
|
||||
element: <TenantDetailPage />,
|
||||
children: [
|
||||
{ index: true, element: <TenantProfilePage /> },
|
||||
{ path: "admins", element: <TenantAdminsTab /> },
|
||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
||||
{ 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 isAdminTab = location.pathname.includes("/admins");
|
||||
const isPermissionsTab = location.pathname.includes("/permissions");
|
||||
const isOrganizationTab = location.pathname.includes("/organization");
|
||||
|
||||
return (
|
||||
@@ -59,7 +59,7 @@ function TenantDetailPage() {
|
||||
to={`/tenants/${tenantId}`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
!isFederationTab &&
|
||||
!isAdminTab &&
|
||||
!isPermissionsTab &&
|
||||
!location.pathname.includes("/schema") &&
|
||||
!isOrganizationTab
|
||||
? "text-primary border-b-2 border-primary"
|
||||
@@ -79,14 +79,14 @@ function TenantDetailPage() {
|
||||
{t("ui.admin.tenants.detail.tab_federation", "외부 연동")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/admins`}
|
||||
to={`/tenants/${tenantId}/permissions`}
|
||||
className={`px-6 py-3 text-sm font-medium transition-colors relative ${
|
||||
isAdminTab
|
||||
isPermissionsTab
|
||||
? "text-primary border-b-2 border-primary"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{t("ui.admin.tenants.detail.tab_admins", "관리자 설정")}
|
||||
{t("ui.admin.tenants.detail.tab_permissions", "권한")}
|
||||
</Link>
|
||||
<Link
|
||||
to={`/tenants/${tenantId}/organization`}
|
||||
|
||||
@@ -438,10 +438,16 @@ function UserCreatePage() {
|
||||
{...register("role")}
|
||||
>
|
||||
<option value="user">
|
||||
{t("ui.common.role.user", "User")}
|
||||
{t("ui.admin.role.user", "TENANT MEMBER")}
|
||||
</option>
|
||||
<option value="admin">
|
||||
{t("ui.common.role.admin", "Admin")}
|
||||
<option value="tenant_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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -287,10 +287,16 @@ function UserDetailPage() {
|
||||
{...register("role")}
|
||||
>
|
||||
<option value="user">
|
||||
{t("ui.common.role.user", "User")}
|
||||
{t("ui.admin.role.user", "TENANT MEMBER")}
|
||||
</option>
|
||||
<option value="admin">
|
||||
{t("ui.common.role.admin", "Admin")}
|
||||
<option value="tenant_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>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -245,7 +245,7 @@ function UserListPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">
|
||||
{t(`ui.common.role.${user.role}`, user.role)}
|
||||
{t(`ui.admin.role.${user.role}`, user.role)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -167,6 +167,21 @@ export async function removeTenantAdmin(tenantId: string, userId: string) {
|
||||
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
|
||||
export type GroupMember = {
|
||||
id: string;
|
||||
|
||||
@@ -703,7 +703,7 @@ view_audit_logs = "View Audit Logs"
|
||||
rp_admin = "RP ADMIN"
|
||||
super_admin = "SUPER ADMIN"
|
||||
tenant_admin = "TENANT ADMIN"
|
||||
tenant_member = "TENANT MEMBER"
|
||||
user = "TENANT MEMBER"
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = "Tenant Add"
|
||||
|
||||
@@ -772,10 +772,10 @@ policy_gate = "정책 게이트"
|
||||
total_tenants = "전체 테넌트"
|
||||
|
||||
[ui.admin.role]
|
||||
rp_admin = "RP ADMIN"
|
||||
super_admin = "SUPER ADMIN"
|
||||
tenant_admin = "TENANT ADMIN"
|
||||
tenant_member = "TENANT MEMBER"
|
||||
rp_admin = "서비스 관리자 (RP Admin)"
|
||||
super_admin = "시스템 관리자 (Super Admin)"
|
||||
tenant_admin = "테넌트 관리자 (Tenant Admin)"
|
||||
user = "일반 사용자 (Tenant Member)"
|
||||
|
||||
[ui.admin.tenants]
|
||||
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.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.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)
|
||||
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)
|
||||
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 {
|
||||
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", 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) {
|
||||
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"))
|
||||
isDev := appEnv == "dev" || appEnv == "development" || appEnv == ""
|
||||
|
||||
mockRole := c.Get("X-Test-Role")
|
||||
if mockRole == "" {
|
||||
mockRole = c.Get("X-Mock-Role")
|
||||
}
|
||||
|
||||
// Always log in development to see what's happening
|
||||
if appEnv == "dev" || appEnv == "development" || appEnv == "" {
|
||||
slog.Info("🔍 [AUTH_DEBUG] Checking mock role",
|
||||
"env", appEnv,
|
||||
"mockRole", mockRole,
|
||||
"X-Test-Role", c.Get("X-Test-Role"),
|
||||
"X-Mock-Role", c.Get("X-Mock-Role"),
|
||||
)
|
||||
token := h.getBearerToken(c)
|
||||
cookie := c.Get("Cookie")
|
||||
|
||||
var profile *domain.UserProfileResponse
|
||||
var err error
|
||||
cacheKey := ""
|
||||
|
||||
// 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
|
||||
if (appEnv == "dev" || appEnv == "development" || appEnv == "") && mockRole != "" {
|
||||
slog.Info("🔑 [AUTH_DEBUG] Mock bypass SUCCESS", "role", mockRole)
|
||||
mockProfile := &domain.UserProfileResponse{
|
||||
// 2. Role Override for real profile or fallback to Mock Profile
|
||||
if profile != nil {
|
||||
if isDev && mockRole != "" {
|
||||
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",
|
||||
Email: "mock@hmac.kr",
|
||||
Name: "Dev Mock User",
|
||||
Role: mockRole,
|
||||
}
|
||||
if tid := c.Get("X-Tenant-ID"); tid != "" {
|
||||
mockProfile.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
|
||||
}
|
||||
}
|
||||
profile.TenantID = &tid
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Fetch from Kratos (SoT)
|
||||
if token != "" {
|
||||
profile, err = h.getKratosProfile(token)
|
||||
} else {
|
||||
if cookie != "" {
|
||||
profile, err = h.getKratosProfileWithCookie(cookie)
|
||||
}
|
||||
}
|
||||
|
||||
if err != nil || profile == nil {
|
||||
if profile == nil {
|
||||
return nil, errors.New("invalid session (trace:resolve_profile)")
|
||||
}
|
||||
|
||||
// 3. Post-Process (Defaults & Metadata Enrichment)
|
||||
// Default Role if missing (migration safety)
|
||||
if profile.Role == "" {
|
||||
profile.Role = domain.RoleUser
|
||||
}
|
||||
|
||||
// Fetch Tenant Metadata if missing
|
||||
// Case A: Have TenantID from Kratos -> Fetch by ID
|
||||
if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
|
||||
if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil {
|
||||
profile.Tenant = tenant
|
||||
}
|
||||
}
|
||||
// Case B: Have CompanyCode but no TenantID -> Fetch by Slug
|
||||
if profile.Tenant == nil && profile.CompanyCode != "" {
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil {
|
||||
profile.Tenant = tenant
|
||||
@@ -4033,7 +4012,7 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
||||
}
|
||||
|
||||
// 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 {
|
||||
ttlStr := os.Getenv("PROFILE_CACHE_TTL")
|
||||
ttl := 30 * time.Minute // Default TTL
|
||||
@@ -5060,12 +5039,36 @@ func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]
|
||||
return nil
|
||||
}
|
||||
|
||||
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
|
||||
identityID, traits, err := h.getKratosIdentity(sessionToken)
|
||||
func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domain.UserProfileResponse, error) {
|
||||
intro, err := h.Hydra.IntrospectToken(ctx, token)
|
||||
if err != nil {
|
||||
slog.Error("Hydra introspection failed", "error", 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)
|
||||
name, _ := traits["name"].(string)
|
||||
phone, _ := traits["phone_number"].(string)
|
||||
@@ -5101,8 +5104,15 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile
|
||||
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) {
|
||||
@@ -5110,44 +5120,7 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
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
|
||||
return h.mapKratosIdentityToProfile(identityID, traits), nil
|
||||
}
|
||||
|
||||
// UpdateMe - Updates current user's profile with phone verification check
|
||||
|
||||
@@ -140,7 +140,7 @@ type AsyncMockTenantService struct {
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -221,7 +221,13 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
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 strings.Contains(err.Error(), "already exists") {
|
||||
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:")
|
||||
|
||||
// Fetch user details from Kratos
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
|
||||
if err != nil {
|
||||
admins = append(admins, adminInfo{ID: userID, Name: "Unknown", Email: "Unknown"})
|
||||
continue
|
||||
}
|
||||
// Fetch user details - Try Kratos first, then local DB
|
||||
name := "Unknown"
|
||||
email := "Unknown"
|
||||
|
||||
name := ""
|
||||
if n, ok := identity.Traits["name"].(string); ok {
|
||||
name = n
|
||||
}
|
||||
email := ""
|
||||
if e, ok := identity.Traits["email"].(string); ok {
|
||||
email = e
|
||||
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 (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{
|
||||
@@ -464,6 +478,14 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
|
||||
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)
|
||||
@@ -489,6 +511,113 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
|
||||
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 {
|
||||
domains := make([]string, 0, len(t.Domains))
|
||||
for _, d := range t.Domains {
|
||||
|
||||
@@ -21,8 +21,8 @@ type MockTenantService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
||||
args := m.Called(ctx, name, slug, tenantType, description, domains, parentID)
|
||||
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, creatorID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
@@ -133,7 +133,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
|
||||
}
|
||||
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)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
// Check with Keto
|
||||
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation)
|
||||
// Check with Keto - add User: prefix to subject
|
||||
allowed, err := config.KetoService.CheckPermission(c.Context(), "User:"+profile.ID, namespace, objectID, relation)
|
||||
if err != nil {
|
||||
slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID)
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
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)
|
||||
GetTenantByDomain(ctx context.Context, emailDomain 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)
|
||||
}
|
||||
|
||||
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
|
||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||
return nil, errors.New(msg)
|
||||
@@ -119,15 +119,49 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// [Keto] Sync hierarchy via Outbox if ParentID exists
|
||||
if s.outboxRepo != nil && tenant.ParentID != nil {
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + *tenant.ParentID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
// [Keto] Sync hierarchy and ownership via Outbox
|
||||
if s.outboxRepo != nil {
|
||||
// Sync hierarchy
|
||||
if tenant.ParentID != nil {
|
||||
if err := s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
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)
|
||||
@@ -187,12 +221,20 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
|
||||
// [Keto] Sync relation via Outbox
|
||||
if s.outboxRepo != nil {
|
||||
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
|
||||
if s.userRepo != nil {
|
||||
user, err := s.userRepo.FindByEmail(ctx, adminEmail)
|
||||
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{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
@@ -200,6 +242,13 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
|
||||
Subject: "User:" + user.ID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + user.ID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
} else {
|
||||
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
|
||||
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.Contains(t, err.Error(), "already exists")
|
||||
assert.Nil(t, tenant)
|
||||
@@ -32,11 +32,11 @@ func TestTenantService_RegisterTenant_InvalidSlug(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// 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)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
|
||||
@@ -162,13 +162,54 @@ func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
||||
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()
|
||||
|
||||
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.NotNil(t, tenant)
|
||||
assert.Equal(t, "t1", tenant.ID)
|
||||
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) {
|
||||
mockRepo := new(MockTenantRepoForSvc)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
@@ -215,9 +256,15 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
|
||||
mockRepo.On("Update", ctx, mock.Anything).Return(nil)
|
||||
mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, nil)
|
||||
// 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 {
|
||||
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+userID
|
||||
})).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)
|
||||
assert.NoError(t, err)
|
||||
|
||||
@@ -5,7 +5,7 @@ class User implements Namespace {}
|
||||
class Tenant implements Namespace {
|
||||
related: {
|
||||
owners: User[]
|
||||
admins: (User | SubjectSet<Tenant, "owners">)[]
|
||||
admins: User[]
|
||||
members: User[]
|
||||
parents: Tenant[]
|
||||
}
|
||||
@@ -14,12 +14,18 @@ class Tenant implements Namespace {
|
||||
view: (ctx: Context): boolean =>
|
||||
this.related.members.includes(ctx.subject) ||
|
||||
this.related.admins.includes(ctx.subject) ||
|
||||
this.related.owners.includes(ctx.subject) ||
|
||||
this.related.parents.traverse((p) => p.permits.view(ctx)),
|
||||
|
||||
manage: (ctx: Context): boolean =>
|
||||
this.related.admins.includes(ctx.subject) ||
|
||||
this.related.owners.includes(ctx.subject) ||
|
||||
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 =>
|
||||
this.permits.manage(ctx)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user