forked from baron/baron-sso
Merge pull request 'feature/af-is309' (#362) from feature/af-is309 into dev
Reviewed-on: baron/baron-sso#362
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 /> },
|
||||
],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BadgeCheck,
|
||||
Building2,
|
||||
ChevronDown,
|
||||
Key,
|
||||
KeyRound,
|
||||
LayoutDashboard,
|
||||
@@ -9,11 +11,13 @@ import {
|
||||
NotebookTabs,
|
||||
ShieldHalf,
|
||||
Sun,
|
||||
User as UserIcon,
|
||||
Users,
|
||||
} from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "react-oidc-context";
|
||||
import { NavLink, Outlet, useNavigate } from "react-router-dom";
|
||||
import { fetchMe } from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import LanguageSelector from "../common/LanguageSelector";
|
||||
import RoleSwitcher from "./RoleSwitcher";
|
||||
@@ -42,6 +46,13 @@ function AppLayout() {
|
||||
const stored = window.localStorage.getItem("admin_theme");
|
||||
return stored === "dark" ? "dark" : "light";
|
||||
});
|
||||
const [isProfileOpen, setIsProfileOpen] = useState(false);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
queryFn: fetchMe,
|
||||
enabled: auth.isAuthenticated && !auth.isLoading,
|
||||
});
|
||||
|
||||
const handleLogout = () => {
|
||||
if (
|
||||
@@ -59,6 +70,12 @@ function AppLayout() {
|
||||
}
|
||||
}, [auth.isLoading, auth.isAuthenticated, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
if (auth.user?.access_token) {
|
||||
window.localStorage.setItem("admin_session", auth.user.access_token);
|
||||
}
|
||||
}, [auth.user]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = document.documentElement;
|
||||
root.classList.remove("light", "dark");
|
||||
@@ -187,7 +204,84 @@ function AppLayout() {
|
||||
? t("ui.common.theme_light", "Light")
|
||||
: t("ui.common.theme_dark", "Dark")}
|
||||
</button>
|
||||
<span className="rounded-full border border-border px-3 py-2 text-muted-foreground">
|
||||
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsProfileOpen(!isProfileOpen)}
|
||||
className="inline-flex items-center gap-2 rounded-full border border-border bg-card px-3 py-1.5 text-muted-foreground transition hover:bg-muted/20"
|
||||
>
|
||||
<div className="flex h-7 w-7 items-center justify-center rounded-full bg-primary/10 text-primary font-bold text-xs uppercase">
|
||||
{profile?.name?.charAt(0) || <UserIcon size={14} />}
|
||||
</div>
|
||||
<span className="hidden max-w-[100px] truncate font-medium md:inline-block">
|
||||
{profile?.name || auth.user?.profile.name || "User"}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className={`transition-transform duration-200 ${isProfileOpen ? "rotate-180" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
|
||||
{isProfileOpen && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-30"
|
||||
onClick={() => setIsProfileOpen(false)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") setIsProfileOpen(false);
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={-1}
|
||||
aria-label="Close profile menu"
|
||||
/>
|
||||
<div className="absolute right-0 mt-2 w-56 origin-top-right rounded-xl border border-border bg-card p-2 shadow-xl ring-1 ring-black ring-opacity-5 focus:outline-none z-40 animate-in fade-in zoom-in-95 duration-200">
|
||||
<div className="px-3 py-3 border-b border-border/50 mb-1">
|
||||
<p className="text-sm font-semibold truncate">
|
||||
{profile?.name || auth.user?.profile.name}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground truncate">
|
||||
{profile?.email || auth.user?.profile.email}
|
||||
</p>
|
||||
<div className="mt-2">
|
||||
<span className="inline-flex items-center rounded-md bg-primary/10 px-2 py-0.5 text-[10px] font-medium text-primary uppercase">
|
||||
{t(
|
||||
`ui.admin.role.${profile?.role || "user"}`,
|
||||
profile?.role || "USER",
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
navigate(
|
||||
`/users/${profile?.id || auth.user?.profile.sub}`,
|
||||
);
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-muted-foreground transition hover:bg-muted/50 hover:text-foreground"
|
||||
>
|
||||
<UserIcon size={16} />
|
||||
<span>{t("ui.userfront.nav.profile", "내 정보")}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setIsProfileOpen(false);
|
||||
handleLogout();
|
||||
}}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-destructive transition hover:bg-destructive/10"
|
||||
>
|
||||
<LogOut size={16} />
|
||||
<span>{t("ui.admin.nav.logout", "Logout")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="hidden md:inline-flex rounded-full border border-border px-3 py-2 text-muted-foreground">
|
||||
{t("msg.admin.session_ttl", "Session TTL: 15m admin")}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -40,7 +40,7 @@ const RoleSwitcher: FC = () => {
|
||||
super_admin: t("ui.admin.role.super_admin", "SUPER ADMIN"),
|
||||
tenant_admin: t("ui.admin.role.tenant_admin", "TENANT ADMIN"),
|
||||
rp_admin: t("ui.admin.role.rp_admin", "RP ADMIN"),
|
||||
tenant_member: t("ui.admin.role.tenant_member", "TENANT MEMBER"),
|
||||
user: t("ui.admin.role.user", "TENANT MEMBER"),
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -105,40 +105,35 @@ const RoleSwitcher: FC = () => {
|
||||
marginTop: "4px",
|
||||
}}
|
||||
>
|
||||
{(
|
||||
[
|
||||
"super_admin",
|
||||
"tenant_admin",
|
||||
"rp_admin",
|
||||
"tenant_member",
|
||||
] as const
|
||||
).map((role) => (
|
||||
<button
|
||||
key={role}
|
||||
type="button"
|
||||
onClick={() => switchRole(role)}
|
||||
style={{
|
||||
background: currentRole === role ? "#3b82f6" : "#333",
|
||||
color: "white",
|
||||
border: "none",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "background 0.2s",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
|
||||
</span>
|
||||
{currentRole === role && (
|
||||
<span style={{ marginLeft: "8px" }}>✅</span>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
{(["super_admin", "tenant_admin", "rp_admin", "user"] as const).map(
|
||||
(role) => (
|
||||
<button
|
||||
key={role}
|
||||
type="button"
|
||||
onClick={() => switchRole(role)}
|
||||
style={{
|
||||
background: currentRole === role ? "#3b82f6" : "#333",
|
||||
color: "white",
|
||||
border: "none",
|
||||
padding: "4px 8px",
|
||||
borderRadius: "4px",
|
||||
cursor: "pointer",
|
||||
textAlign: "left",
|
||||
transition: "background 0.2s",
|
||||
display: "flex",
|
||||
justifyContent: "space-between",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
|
||||
</span>
|
||||
{currentRole === role && (
|
||||
<span style={{ marginLeft: "8px" }}>✅</span>
|
||||
)}
|
||||
</button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,547 @@
|
||||
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;
|
||||
@@ -421,6 +436,26 @@ export async function deleteUser(userId: string) {
|
||||
await apiClient.delete(`/v1/admin/users/${userId}`);
|
||||
}
|
||||
|
||||
export type UserProfileResponse = {
|
||||
id: string;
|
||||
email: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
role: string;
|
||||
department: string;
|
||||
affiliationType: string;
|
||||
companyCode?: string;
|
||||
tenantId?: string;
|
||||
metadata?: Record<string, unknown>;
|
||||
tenant?: TenantSummary;
|
||||
manageableTenants?: TenantSummary[];
|
||||
};
|
||||
|
||||
export async function fetchMe() {
|
||||
const { data } = await apiClient.get<UserProfileResponse>("/v1/user/me");
|
||||
return data;
|
||||
}
|
||||
|
||||
// Relying Party Management
|
||||
export type RelyingParty = {
|
||||
clientId: string;
|
||||
|
||||
@@ -130,6 +130,44 @@ not_found = "Tenant not found."
|
||||
remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?'
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.admin.tenants.admins]
|
||||
add_success = "Admin added successfully."
|
||||
empty = "No admins registered."
|
||||
remove_confirm = "Are you sure you want to remove admin permission for {{name}}?"
|
||||
remove_success = "Admin permission revoked."
|
||||
subtitle = "Users with permissions to manage this tenant's resources."
|
||||
title = "Tenant Admin Settings"
|
||||
|
||||
[msg.admin.tenants.owners]
|
||||
add_success = "Owner added successfully."
|
||||
empty = "No owners registered."
|
||||
remove_confirm = "Are you sure you want to remove owner permission for {{name}}?"
|
||||
remove_success = "Owner permission revoked."
|
||||
subtitle = "List of owners with top-level permissions for this tenant."
|
||||
|
||||
[ui.admin.tenants.admins]
|
||||
add_button = "Add Admin"
|
||||
already_admin = "Already Admin"
|
||||
dialog_description = "Search users by name or email to grant admin permissions."
|
||||
dialog_no_results = "No results found."
|
||||
dialog_search_hint = "Please enter a search term."
|
||||
dialog_search_placeholder = "Search users (min 2 chars)..."
|
||||
dialog_title = "Add New Admin"
|
||||
remove_title = "Revoke Admin Permission"
|
||||
table_actions = "Actions"
|
||||
table_email = "Email"
|
||||
table_name = "Name"
|
||||
title = "Tenant Admins"
|
||||
|
||||
[ui.admin.tenants.owners]
|
||||
add_button = "Add Owner"
|
||||
dialog_description = "Search users by name or email to grant owner permissions."
|
||||
dialog_title = "Add New Owner"
|
||||
table_actions = "Actions"
|
||||
table_email = "Email"
|
||||
table_name = "Name"
|
||||
title = "Tenant Owners"
|
||||
|
||||
[msg.admin.tenants.create]
|
||||
subtitle = "Subtitle"
|
||||
|
||||
@@ -703,7 +741,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"
|
||||
|
||||
@@ -161,6 +161,13 @@ remove_success = "관리자 권한이 회수되었습니다."
|
||||
subtitle = "이 테넌트의 자원을 관리할 수 있는 권한을 가진 사용자들입니다."
|
||||
title = "테넌트 관리자 설정"
|
||||
|
||||
[msg.admin.tenants.owners]
|
||||
add_success = "소유자가 성공적으로 추가되었습니다."
|
||||
empty = "등록된 소유자가 없습니다."
|
||||
remove_confirm = "{{name}} 사용자의 소유자 권한을 회수할까요?"
|
||||
remove_success = "소유자 권한이 회수되었습니다."
|
||||
subtitle = "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다."
|
||||
|
||||
[ui.admin.tenants.admins]
|
||||
add_button = "관리자 추가"
|
||||
already_admin = "이미 관리자"
|
||||
@@ -175,6 +182,15 @@ table_email = "이메일"
|
||||
table_name = "이름"
|
||||
title = "테넌트 관리자"
|
||||
|
||||
[ui.admin.tenants.owners]
|
||||
add_button = "소유자 추가"
|
||||
dialog_description = "이름 또는 이메일로 사용자를 검색하여 소유자 권한을 부여하세요."
|
||||
dialog_title = "새 소유자 추가"
|
||||
table_actions = "액션"
|
||||
table_email = "이메일"
|
||||
table_name = "이름"
|
||||
title = "테넌트 소유자"
|
||||
|
||||
[msg.admin.tenants.create]
|
||||
subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다."
|
||||
|
||||
@@ -772,10 +788,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 = "테넌트 추가"
|
||||
|
||||
@@ -19,6 +19,18 @@ test.describe("Authentication", () => {
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Default mock for user profile
|
||||
await page.route("**/api/v1/user/me", async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
id: "admin-user",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
role: "super_admin",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test("should redirect unauthorized users to login page", async ({ page }) => {
|
||||
@@ -74,13 +86,17 @@ test.describe("Authentication", () => {
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.locator("aside")).toBeVisible();
|
||||
|
||||
// Wait for the auth loading to finish
|
||||
await expect(page.locator(".animate-spin")).not.toBeVisible();
|
||||
|
||||
// Mock window.confirm
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
|
||||
// Click logout button (label: ui.admin.nav.logout)
|
||||
await page.click('button:has-text("Logout"), button:has-text("로그아웃")');
|
||||
// Click logout button in the sidebar (use nav container to be specific)
|
||||
await page.click(
|
||||
'nav button:has-text("Logout"), nav button:has-text("로그아웃")',
|
||||
);
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
|
||||
139
adminfront/tests/owners.spec.ts
Normal file
139
adminfront/tests/owners.spec.ts
Normal file
@@ -0,0 +1,139 @@
|
||||
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 user profile
|
||||
await page.route("**/api/v1/user/me", async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
id: "admin-user",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
role: "super_admin",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// 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" },
|
||||
],
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// Mock admins list (empty)
|
||||
await page.route(
|
||||
"**/api/v1/admin/tenants/tenant-1/admins**",
|
||||
async (route) => {
|
||||
await route.fulfill({ json: [] });
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/tenants/tenant-1/permissions");
|
||||
|
||||
// Check if the page title and the owner are visible
|
||||
await expect(page.getByText("테넌트 소유자")).toBeVisible();
|
||||
await expect(page.locator("table").first()).toContainText("Owner One");
|
||||
await expect(page.locator("table").first()).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 admins list (empty)
|
||||
await page.route(
|
||||
"**/api/v1/admin/tenants/tenant-1/admins**",
|
||||
async (route) => {
|
||||
await route.fulfill({ json: [] });
|
||||
},
|
||||
);
|
||||
|
||||
// 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/permissions");
|
||||
|
||||
// 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
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,18 @@ test.describe("Tenants Management", () => {
|
||||
},
|
||||
);
|
||||
|
||||
// Mock user profile
|
||||
await page.route("**/api/v1/user/me", async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
id: "admin-user",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
role: "super_admin",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Default mock for tenants to avoid proxy leaks
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
"fmt"
|
||||
"log"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -346,12 +347,41 @@ func main() {
|
||||
app.Use(middleware.ErrorCodeEnricher())
|
||||
|
||||
allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000")
|
||||
allowCredentials := allowedOrigins != "*"
|
||||
userfrontURL := getEnv("USERFRONT_URL", "http://sso.hmac.kr")
|
||||
baseDomain := ""
|
||||
if u, err := url.Parse(userfrontURL); err == nil {
|
||||
baseDomain = u.Hostname()
|
||||
}
|
||||
|
||||
app.Use(cors.New(cors.Config{
|
||||
AllowOrigins: allowedOrigins,
|
||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization",
|
||||
AllowOriginsFunc: func(origin string) bool {
|
||||
// 1. Check static allowed list
|
||||
for _, allowed := range strings.Split(allowedOrigins, ",") {
|
||||
if origin == strings.TrimSpace(allowed) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
// Parse origin URL
|
||||
u, err := url.Parse(origin)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
hostname := u.Hostname()
|
||||
|
||||
// 2. Check subdomains of base domain
|
||||
if baseDomain != "" && (hostname == baseDomain || strings.HasSuffix(hostname, "."+baseDomain)) {
|
||||
return true
|
||||
}
|
||||
|
||||
// 3. Check registered tenant domains
|
||||
// Use context.Background() as we don't have request context here easily
|
||||
allowed, _ := tenantService.IsDomainAllowed(context.Background(), hostname)
|
||||
return allowed
|
||||
},
|
||||
AllowHeaders: "Origin, Content-Type, Accept, Authorization, X-Test-Role, X-Mock-Role, X-Tenant-ID",
|
||||
AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS",
|
||||
AllowCredentials: allowCredentials,
|
||||
AllowCredentials: true,
|
||||
}))
|
||||
|
||||
// Ensure COOKIE_SECRET is exactly 32 bytes for AES-256
|
||||
@@ -483,6 +513,11 @@ func main() {
|
||||
// Public Tenant Registration
|
||||
api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic)
|
||||
|
||||
// Tenant Context Middleware (identifies tenant from Host header)
|
||||
api.Use(middleware.TenantContextMiddleware(middleware.TenantContextConfig{
|
||||
TenantService: tenantService,
|
||||
}))
|
||||
|
||||
// Auth Proxy Routes
|
||||
auth := api.Group("/auth")
|
||||
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||
@@ -491,21 +526,13 @@ func main() {
|
||||
auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
|
||||
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
|
||||
auth.Post("/password/login", authHandler.PasswordLogin)
|
||||
auth.Get("/tenant-info", authHandler.GetTenantInfo)
|
||||
auth.Get("/consent", authHandler.GetConsentRequest)
|
||||
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
|
||||
auth.Post("/consent/reject", authHandler.RejectConsentRequest)
|
||||
|
||||
auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest)
|
||||
|
||||
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink)
|
||||
auth.Post("/enchanted-link/poll", authHandler.PollEnchantedLink)
|
||||
auth.Post("/magic-link/verify", authHandler.VerifyMagicLink)
|
||||
auth.Post("/login/code/verify", authHandler.VerifyLoginCode)
|
||||
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
|
||||
auth.Post("/password/login", authHandler.PasswordLogin)
|
||||
auth.Get("/consent", authHandler.GetConsentRequest)
|
||||
auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
|
||||
|
||||
auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
|
||||
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption
|
||||
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
|
||||
@@ -557,8 +584,8 @@ func main() {
|
||||
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
|
||||
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
|
||||
|
||||
// Tenant Management (Super Admin Only)
|
||||
admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants)
|
||||
// Tenant Management (Mixed roles, handler filters results)
|
||||
admin.Get("/tenants", requireAdmin, tenantHandler.ListTenants)
|
||||
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
|
||||
admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant)
|
||||
admin.Get("/tenants/:id", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), tenantHandler.GetTenant)
|
||||
@@ -567,6 +594,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
|
||||
|
||||
@@ -538,6 +538,55 @@ func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string {
|
||||
return parts[1]
|
||||
}
|
||||
|
||||
func (h *AuthHandler) resolveUserfrontURL(c *fiber.Ctx) string {
|
||||
// 1. Try to use the Host header from the request
|
||||
host := c.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = c.Hostname()
|
||||
}
|
||||
|
||||
// 2. Determine scheme
|
||||
scheme := "https"
|
||||
if os.Getenv("APP_ENV") == "dev" || os.Getenv("APP_ENV") == "" || c.Protocol() == "http" {
|
||||
scheme = "http"
|
||||
}
|
||||
|
||||
// 3. Fallback to env if host is not available or is localhost (and not in dev)
|
||||
envURL := os.Getenv("USERFRONT_URL")
|
||||
if envURL == "" {
|
||||
envURL = "http://sso.hmac.kr"
|
||||
}
|
||||
|
||||
if host == "" || (host == "localhost" && os.Getenv("APP_ENV") != "dev") {
|
||||
return strings.TrimRight(envURL, "/")
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s://%s", scheme, host)
|
||||
}
|
||||
|
||||
func (h *AuthHandler) GetTenantInfo(c *fiber.Ctx) error {
|
||||
tenantID, _ := c.Locals("tenant_id").(string)
|
||||
if tenantID == "" {
|
||||
return c.JSON(fiber.Map{
|
||||
"isCentral": true,
|
||||
})
|
||||
}
|
||||
|
||||
tenant, err := h.TenantService.GetTenant(c.Context(), tenantID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusNotFound, "Tenant not found")
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"isCentral": false,
|
||||
"id": tenant.ID,
|
||||
"name": tenant.Name,
|
||||
"slug": tenant.Slug,
|
||||
"description": tenant.Description,
|
||||
"type": tenant.Type,
|
||||
})
|
||||
}
|
||||
|
||||
// normalizePhoneForLoginID는 전화번호를 IDP 조회에 적합한 형태(E.164)로 정규화합니다.
|
||||
func normalizePhoneForLoginID(phone string) string {
|
||||
normalized := strings.ReplaceAll(phone, "-", "")
|
||||
@@ -920,10 +969,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusNotFound, "User not registered")
|
||||
}
|
||||
|
||||
userfrontURL := os.Getenv("USERFRONT_URL")
|
||||
if userfrontURL == "" {
|
||||
userfrontURL = "http://sso.hmac.kr"
|
||||
}
|
||||
userfrontURL := h.resolveUserfrontURL(c)
|
||||
if req.URI != "" {
|
||||
userfrontURL = req.URI
|
||||
}
|
||||
@@ -1692,14 +1738,7 @@ func (h *AuthHandler) InitiatePasswordReset(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "Authentication service not configured")
|
||||
}
|
||||
|
||||
userfrontURL := os.Getenv("USERFRONT_URL")
|
||||
if userfrontURL == "" {
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
ale.LatencyMs = time.Since(startTime)
|
||||
ale.ProviderError = "USERFRONT_URL is not set"
|
||||
ale.Log(slog.LevelError, "USERFRONT_URL is not set")
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "USERFRONT_URL environment variable is not set")
|
||||
}
|
||||
userfrontURL := h.resolveUserfrontURL(c)
|
||||
// [Changed] Point to Backend API for verification (which then redirects to Frontend)
|
||||
redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL)
|
||||
ale.RedirectTo = redirectURL
|
||||
@@ -1863,10 +1902,7 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
|
||||
ale.LoginIDs["loginId"] = loginID
|
||||
ale.LoginIDs["loginId_normalized"] = loginID
|
||||
|
||||
userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/")
|
||||
if userfrontURL == "" {
|
||||
userfrontURL = "https://sso.hmac.kr"
|
||||
}
|
||||
userfrontURL := h.resolveUserfrontURL(c)
|
||||
redirectBase, parseErr := url.Parse(userfrontURL + "/reset-password")
|
||||
if parseErr != nil {
|
||||
ale.Status = fiber.StatusInternalServerError
|
||||
@@ -2000,10 +2036,7 @@ func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다.
|
||||
userfrontURL := os.Getenv("USERFRONT_URL")
|
||||
if userfrontURL == "" {
|
||||
userfrontURL = "https://sso.hmac.kr"
|
||||
}
|
||||
userfrontURL := h.resolveUserfrontURL(c)
|
||||
qrPayload := fmt.Sprintf("%s/ql/%s", strings.TrimRight(userfrontURL, "/"), qrRef)
|
||||
|
||||
slog.Info("[QR] Init", "pendingRef", pendingRef, "qrRef", qrRef, "url", qrPayload)
|
||||
@@ -3925,104 +3958,85 @@ 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 {
|
||||
slog.Debug("Profile loaded from cache", "token", token[:10]+"...", "role", profile.Role)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if profile == nil {
|
||||
// Fetch from Kratos (SoT)
|
||||
if token != "" {
|
||||
profile, err = h.getKratosProfile(token)
|
||||
if err != nil && h.Hydra != nil {
|
||||
// Fallback to Hydra introspection
|
||||
slog.Debug("Kratos session check failed, trying Hydra", "error", err)
|
||||
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] Overriding real profile role",
|
||||
"email", profile.Email, "originalRole", profile.Role, "overriddenRole", mockRole)
|
||||
profile.Role = mockRole
|
||||
}
|
||||
} else if isDev && mockRole != "" && token == "" && cookie == "" {
|
||||
slog.Info("🔑 [AUTH] Using full Mock Auth (no session)", "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 {
|
||||
slog.Warn("No profile resolved", "token_len", len(token), "cookie_len", len(cookie), "mockRole", mockRole)
|
||||
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 +4047,10 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
|
||||
}
|
||||
|
||||
// 4. Save to Redis Cache (Short TTL)
|
||||
if h.RedisService != nil && cacheKey != "" {
|
||||
// IMPORTANT: In dev mode, if role was overridden, we should NOT cache it under the token key
|
||||
// or we should include the mock role in the cache key.
|
||||
// For simplicity, let's skip caching if mockRole is present in dev.
|
||||
if h.RedisService != nil && cacheKey != "" && err == nil && !(isDev && mockRole != "") {
|
||||
if data, err := json.Marshal(profile); err == nil {
|
||||
ttlStr := os.Getenv("PROFILE_CACHE_TTL")
|
||||
ttl := 30 * time.Minute // Default TTL
|
||||
@@ -5060,12 +5077,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 +5142,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 +5158,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
|
||||
}
|
||||
|
||||
@@ -171,6 +171,9 @@ func (m *AsyncMockTenantService) ListTenants(ctx context.Context, limit, offset
|
||||
func (m *AsyncMockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
func (m *AsyncMockTenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
func (m *AsyncMockTenantService) ApproveTenant(ctx context.Context, id string) error { return nil }
|
||||
func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {}
|
||||
func (m *AsyncMockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error {
|
||||
|
||||
@@ -109,9 +109,36 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
tenants, total, err := h.Service.ListTenants(c.Context(), limit, offset, parentId)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
var tenants []domain.Tenant
|
||||
var total int64
|
||||
var err error
|
||||
|
||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
|
||||
// If Tenant Admin, only list manageable tenants
|
||||
if profile != nil && profile.Role == domain.RoleTenantAdmin {
|
||||
slog.Info("Listing manageable tenants for tenant admin", "userID", profile.ID)
|
||||
tenants, err = h.Service.ListManageableTenants(c.Context(), profile.ID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to list manageable tenants: "+err.Error())
|
||||
}
|
||||
total = int64(len(tenants))
|
||||
// Apply basic pagination if needed (optional for usually small number of manageable tenants)
|
||||
if offset < len(tenants) {
|
||||
end := offset + limit
|
||||
if end > len(tenants) {
|
||||
end = len(tenants)
|
||||
}
|
||||
tenants = tenants[offset:end]
|
||||
} else {
|
||||
tenants = []domain.Tenant{}
|
||||
}
|
||||
} else {
|
||||
// Super Admin case
|
||||
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch member counts for all tenants in one query using slugs (company codes)
|
||||
@@ -221,7 +248,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 +456,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 +505,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 +538,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)
|
||||
}
|
||||
@@ -71,6 +71,19 @@ func (m *MockTenantService) ListTenants(ctx context.Context, limit, offset int,
|
||||
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
|
||||
}
|
||||
|
||||
func (m *MockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
args := m.Called(ctx, userID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).([]domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
|
||||
args := m.Called(ctx, domainName)
|
||||
return args.Bool(0), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantService) SetKetoService(keto service.KetoService) {
|
||||
m.Called(keto)
|
||||
}
|
||||
@@ -133,7 +146,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,15 +59,15 @@ 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")
|
||||
}
|
||||
|
||||
if !allowed {
|
||||
slog.Warn("Keto permission denied", "userID", profile.ID, "namespace", namespace, "objectID", objectID, "relation", relation)
|
||||
slog.Warn("Keto permission denied", "userID", profile.ID, "userRole", profile.Role, "namespace", namespace, "objectID", objectID, "relation", relation, "X-Test-Role", c.Get("X-Test-Role"))
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: keto permission denied for "+namespace+":"+objectID)
|
||||
}
|
||||
|
||||
@@ -111,13 +111,11 @@ func RequireRole(config RBACConfig) fiber.Handler {
|
||||
"userRole", profile.Role,
|
||||
"allowedRoles", config.AllowedRoles,
|
||||
"path", c.Path(),
|
||||
"X-Test-Role", c.Get("X-Test-Role"),
|
||||
)
|
||||
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions")
|
||||
}
|
||||
|
||||
// Store profile in locals for further use in handlers
|
||||
c.Locals("user_profile", profile)
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -123,7 +123,7 @@ func TestRequireKetoPermission_Success(t *testing.T) {
|
||||
|
||||
profile := &domain.UserProfileResponse{ID: "user1", Role: "user"}
|
||||
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(profile, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "user1", "tenants", "tenant1", "read").Return(true, nil)
|
||||
mockKeto.On("CheckPermission", mock.Anything, "User:user1", "tenants", "tenant1", "read").Return(true, nil)
|
||||
|
||||
app.Get("/tenants/:id", RequireKetoPermission(config, "tenants", "read"), func(c *fiber.Ctx) error {
|
||||
return c.SendString("ok")
|
||||
|
||||
69
backend/internal/middleware/tenant_middleware.go
Normal file
69
backend/internal/middleware/tenant_middleware.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/service"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
)
|
||||
|
||||
// TenantContextConfig defines the configuration for Tenant context middleware
|
||||
type TenantContextConfig struct {
|
||||
TenantService service.TenantService
|
||||
}
|
||||
|
||||
// TenantContextMiddleware identifies the tenant based on the Host header (subdomain or custom domain)
|
||||
func TenantContextMiddleware(config TenantContextConfig) fiber.Handler {
|
||||
userfrontURL := os.Getenv("USERFRONT_URL")
|
||||
baseDomain := ""
|
||||
if userfrontURL != "" {
|
||||
if u, err := url.Parse(userfrontURL); err == nil {
|
||||
baseDomain = u.Hostname()
|
||||
}
|
||||
}
|
||||
|
||||
return func(c *fiber.Ctx) error {
|
||||
hostname := c.Hostname()
|
||||
if hostname == "" {
|
||||
hostname = string(c.Request().Header.Host())
|
||||
}
|
||||
|
||||
if hostname == "" {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// 1. If it's the exact base domain or localhost, no specific tenant context from host
|
||||
if hostname == baseDomain || hostname == "localhost" || hostname == "127.0.0.1" {
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// 2. Try to find by registered custom domain
|
||||
tenant, err := config.TenantService.GetTenantByDomain(c.Context(), hostname)
|
||||
if err == nil && tenant != nil {
|
||||
slog.Debug("Tenant identified by custom domain", "hostname", hostname, "tenantID", tenant.ID)
|
||||
c.Locals("tenant_id", tenant.ID)
|
||||
c.Locals("tenant_slug", tenant.Slug)
|
||||
return c.Next()
|
||||
}
|
||||
|
||||
// 3. Try to find by subdomain (slug.baseDomain)
|
||||
if baseDomain != "" && strings.HasSuffix(hostname, "."+baseDomain) {
|
||||
slug := strings.TrimSuffix(hostname, "."+baseDomain)
|
||||
// Handle cases like "www.sso.hmac.kr" if baseDomain is "sso.hmac.kr"
|
||||
if slug != "" && slug != "www" {
|
||||
tenant, err := config.TenantService.GetTenantBySlug(c.Context(), slug)
|
||||
if err == nil && tenant != nil {
|
||||
slog.Debug("Tenant identified by subdomain slug", "slug", slug, "tenantID", tenant.ID)
|
||||
c.Locals("tenant_id", tenant.ID)
|
||||
c.Locals("tenant_slug", tenant.Slug)
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return c.Next()
|
||||
}
|
||||
}
|
||||
110
backend/internal/middleware/tenant_middleware_test.go
Normal file
110
backend/internal/middleware/tenant_middleware_test.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/service"
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
|
||||
type MockTenantServiceForMiddleware struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) {
|
||||
args := m.Called(mock.Anything, emailDomain)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||
args := m.Called(mock.Anything, slug)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) GetTenant(ctx context.Context, id string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (m *MockTenantServiceForMiddleware) ApproveTenant(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
func (m *MockTenantServiceForMiddleware) SetKetoService(keto service.KetoService) {}
|
||||
|
||||
func TestTenantContextMiddleware(t *testing.T) {
|
||||
os.Setenv("USERFRONT_URL", "https://sso.hmac.kr")
|
||||
defer os.Unsetenv("USERFRONT_URL")
|
||||
|
||||
mockSvc := new(MockTenantServiceForMiddleware)
|
||||
app := fiber.New()
|
||||
app.Use(TenantContextMiddleware(TenantContextConfig{TenantService: mockSvc}))
|
||||
|
||||
app.Get("/test", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{
|
||||
"tenant_id": c.Locals("tenant_id"),
|
||||
"tenant_slug": c.Locals("tenant_slug"),
|
||||
})
|
||||
})
|
||||
|
||||
t.Run("Base Domain - No Tenant Context", func(t *testing.T) {
|
||||
req := httptest.NewRequest("GET", "http://sso.hmac.kr/test", nil)
|
||||
req.Host = "sso.hmac.kr"
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
mockSvc.AssertNotCalled(t, "GetTenantByDomain", mock.Anything, "sso.hmac.kr")
|
||||
})
|
||||
|
||||
t.Run("Subdomain - Identify by Slug", func(t *testing.T) {
|
||||
mockSvc.On("GetTenantByDomain", mock.Anything, "tenant1.sso.hmac.kr").Return(nil, nil).Once()
|
||||
mockSvc.On("GetTenantBySlug", mock.Anything, "tenant1").Return(&domain.Tenant{ID: "t1", Slug: "tenant1"}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Host = "tenant1.sso.hmac.kr"
|
||||
req.Header.Set("Host", "tenant1.sso.hmac.kr")
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
mockSvc.AssertExpectations(t)
|
||||
})
|
||||
|
||||
t.Run("Custom Domain - Identify by Domain", func(t *testing.T) {
|
||||
mockSvc.On("GetTenantByDomain", mock.Anything, "company.com").Return(&domain.Tenant{ID: "t2", Slug: "company"}, nil).Once()
|
||||
|
||||
req := httptest.NewRequest("GET", "/test", nil)
|
||||
req.Host = "company.com"
|
||||
req.Header.Set("Host", "company.com")
|
||||
resp, _ := app.Test(req)
|
||||
assert.Equal(t, 200, resp.StatusCode)
|
||||
mockSvc.AssertExpectations(t)
|
||||
})
|
||||
}
|
||||
@@ -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/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,12 +13,14 @@ 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)
|
||||
GetTenant(ctx context.Context, id string) (*domain.Tenant, error)
|
||||
ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error)
|
||||
ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error)
|
||||
IsDomainAllowed(ctx context.Context, domainName string) (bool, error)
|
||||
ApproveTenant(ctx context.Context, id string) error
|
||||
SetKetoService(keto KetoService) // 추가
|
||||
}
|
||||
@@ -90,7 +92,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 +121,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 +223,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 +244,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)
|
||||
}
|
||||
@@ -232,3 +283,14 @@ func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, pare
|
||||
// Let the repository handle the query and pagination
|
||||
return s.repo.List(ctx, limit, offset, parentID)
|
||||
}
|
||||
|
||||
func (s *tenantService) IsDomainAllowed(ctx context.Context, domainName string) (bool, error) {
|
||||
tenant, err := s.repo.FindByDomain(ctx, domainName)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
return tenant != nil && tenant.Status == domain.TenantStatusActive, nil
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -182,6 +182,13 @@ remove_confirm = "Remove Confirm"
|
||||
remove_success = "Remove Success"
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.admin.tenants.owners]
|
||||
add_success = "Owner added successfully."
|
||||
empty = "No owners registered."
|
||||
remove_confirm = "Are you sure you want to remove this owner?"
|
||||
remove_success = "Owner permission revoked."
|
||||
subtitle = "List of owners with top-level permissions for this tenant."
|
||||
|
||||
[msg.admin.tenants.create]
|
||||
subtitle = "Subtitle"
|
||||
|
||||
@@ -811,7 +818,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 = "Add Tenant"
|
||||
@@ -831,6 +838,17 @@ table_email = "Email"
|
||||
table_name = "Name"
|
||||
title = "Title"
|
||||
|
||||
[ui.admin.tenants.owners]
|
||||
add_button = "Add Owner"
|
||||
already_owner = "Already Owner"
|
||||
dialog_description = "Search users by name or email."
|
||||
dialog_title = "Add New Owner"
|
||||
remove_title = "Revoke Owner Permission"
|
||||
table_actions = "Actions"
|
||||
table_email = "Email"
|
||||
table_name = "Name"
|
||||
title = "Tenant Owners"
|
||||
|
||||
[ui.admin.tenants.breadcrumb]
|
||||
list = "List"
|
||||
section = "Tenants"
|
||||
@@ -863,9 +881,9 @@ title = "Tenant Profile"
|
||||
breadcrumb_list = "Tenant List"
|
||||
header_subtitle = "Header Subtitle"
|
||||
loading = "Loading"
|
||||
tab_admins = "Tab Admins"
|
||||
tab_federation = "Tab Federation"
|
||||
tab_organization = "Organization Manage"
|
||||
tab_permissions = "Permissions"
|
||||
tab_profile = "Profile"
|
||||
tab_schema = "Tab Schema"
|
||||
title = "Details"
|
||||
|
||||
@@ -182,6 +182,13 @@ remove_confirm = "관리자를 삭제하시겠습니까?"
|
||||
remove_success = "권한이 회수되었습니다."
|
||||
subtitle = "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다."
|
||||
|
||||
[msg.admin.tenants.owners]
|
||||
add_success = "소유자가 추가되었습니다."
|
||||
empty = "등록된 소유자가 없습니다."
|
||||
remove_confirm = "소유자를 삭제하시겠습니까?"
|
||||
remove_success = "소유자 권한이 회수되었습니다."
|
||||
subtitle = "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다."
|
||||
|
||||
[msg.admin.tenants.create]
|
||||
subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다."
|
||||
|
||||
@@ -811,7 +818,7 @@ 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 = "테넌트 추가"
|
||||
@@ -831,6 +838,17 @@ table_email = "이메일"
|
||||
table_name = "이름"
|
||||
title = "테넌트 관리자"
|
||||
|
||||
[ui.admin.tenants.owners]
|
||||
add_button = "소유자 추가"
|
||||
already_owner = "이미 소유자"
|
||||
dialog_description = "이름 또는 이메일로 사용자를 검색하세요."
|
||||
dialog_title = "새 소유자 추가"
|
||||
remove_title = "소유자 권한 회수"
|
||||
table_actions = "액션"
|
||||
table_email = "이메일"
|
||||
table_name = "이름"
|
||||
title = "테넌트 소유자"
|
||||
|
||||
[ui.admin.tenants.breadcrumb]
|
||||
list = "List"
|
||||
section = "Tenants"
|
||||
@@ -863,9 +881,9 @@ title = "Tenant Profile"
|
||||
breadcrumb_list = "테넌트 목록"
|
||||
header_subtitle = "테넌트 정보를 수정하거나 연동 설정을 관리합니다."
|
||||
loading = "불러오는 중..."
|
||||
tab_admins = "관리자 설정"
|
||||
tab_federation = "외부 연동"
|
||||
tab_organization = "조직 관리"
|
||||
tab_permissions = "권한"
|
||||
tab_profile = "프로필"
|
||||
tab_schema = "사용자 스키마"
|
||||
title = "상세"
|
||||
|
||||
@@ -734,7 +734,7 @@ view_audit_logs = ""
|
||||
rp_admin = ""
|
||||
super_admin = ""
|
||||
tenant_admin = ""
|
||||
tenant_member = ""
|
||||
user = ""
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = ""
|
||||
@@ -1479,6 +1479,13 @@ remove_confirm = ""
|
||||
remove_success = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.tenants.owners]
|
||||
add_success = ""
|
||||
empty = ""
|
||||
remove_confirm = ""
|
||||
remove_success = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.tenants]
|
||||
approve_confirm = ""
|
||||
approve_success = ""
|
||||
@@ -1524,6 +1531,17 @@ table_email = ""
|
||||
table_name = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.owners]
|
||||
add_button = ""
|
||||
already_owner = ""
|
||||
dialog_description = ""
|
||||
dialog_title = ""
|
||||
remove_title = ""
|
||||
table_actions = ""
|
||||
table_email = ""
|
||||
table_name = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.create.form]
|
||||
parent = ""
|
||||
type = ""
|
||||
@@ -1532,9 +1550,9 @@ type = ""
|
||||
breadcrumb_list = ""
|
||||
header_subtitle = ""
|
||||
loading = ""
|
||||
tab_admins = ""
|
||||
tab_federation = ""
|
||||
tab_organization = ""
|
||||
tab_permissions = ""
|
||||
tab_profile = ""
|
||||
tab_schema = ""
|
||||
title = ""
|
||||
|
||||
Reference in New Issue
Block a user