1
0
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:
2026-03-03 17:30:47 +09:00
33 changed files with 1758 additions and 604 deletions

View File

@@ -8,7 +8,7 @@ import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage"; import LoginPage from "../features/auth/LoginPage";
import DashboardPage from "../features/dashboard/DashboardPage"; import DashboardPage from "../features/dashboard/DashboardPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage"; import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
import TenantAdminsTab from "../features/tenants/routes/TenantAdminsTab"; import { TenantAdminsAndOwnersTab } from "../features/tenants/routes/TenantAdminsAndOwnersTab";
import TenantCreatePage from "../features/tenants/routes/TenantCreatePage"; import TenantCreatePage from "../features/tenants/routes/TenantCreatePage";
import TenantDetailPage from "../features/tenants/routes/TenantDetailPage"; import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage"; import TenantListPage from "../features/tenants/routes/TenantListPage";
@@ -48,7 +48,7 @@ export const router = createBrowserRouter(
element: <TenantDetailPage />, element: <TenantDetailPage />,
children: [ children: [
{ index: true, element: <TenantProfilePage /> }, { index: true, element: <TenantProfilePage /> },
{ path: "admins", element: <TenantAdminsTab /> }, { path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> }, { path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> }, { path: "schema", element: <TenantSchemaPage /> },
], ],

View File

@@ -1,6 +1,8 @@
import { useQuery } from "@tanstack/react-query";
import { import {
BadgeCheck, BadgeCheck,
Building2, Building2,
ChevronDown,
Key, Key,
KeyRound, KeyRound,
LayoutDashboard, LayoutDashboard,
@@ -9,11 +11,13 @@ import {
NotebookTabs, NotebookTabs,
ShieldHalf, ShieldHalf,
Sun, Sun,
User as UserIcon,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useAuth } from "react-oidc-context"; import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useNavigate } from "react-router-dom"; import { NavLink, Outlet, useNavigate } from "react-router-dom";
import { fetchMe } from "../../lib/adminApi";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
import LanguageSelector from "../common/LanguageSelector"; import LanguageSelector from "../common/LanguageSelector";
import RoleSwitcher from "./RoleSwitcher"; import RoleSwitcher from "./RoleSwitcher";
@@ -42,6 +46,13 @@ function AppLayout() {
const stored = window.localStorage.getItem("admin_theme"); const stored = window.localStorage.getItem("admin_theme");
return stored === "dark" ? "dark" : "light"; 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 = () => { const handleLogout = () => {
if ( if (
@@ -59,6 +70,12 @@ function AppLayout() {
} }
}, [auth.isLoading, auth.isAuthenticated, navigate]); }, [auth.isLoading, auth.isAuthenticated, navigate]);
useEffect(() => {
if (auth.user?.access_token) {
window.localStorage.setItem("admin_session", auth.user.access_token);
}
}, [auth.user]);
useEffect(() => { useEffect(() => {
const root = document.documentElement; const root = document.documentElement;
root.classList.remove("light", "dark"); root.classList.remove("light", "dark");
@@ -187,7 +204,84 @@ function AppLayout() {
? t("ui.common.theme_light", "Light") ? t("ui.common.theme_light", "Light")
: t("ui.common.theme_dark", "Dark")} : t("ui.common.theme_dark", "Dark")}
</button> </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")} {t("msg.admin.session_ttl", "Session TTL: 15m admin")}
</span> </span>
</div> </div>

View File

@@ -40,7 +40,7 @@ const RoleSwitcher: FC = () => {
super_admin: t("ui.admin.role.super_admin", "SUPER ADMIN"), super_admin: t("ui.admin.role.super_admin", "SUPER ADMIN"),
tenant_admin: t("ui.admin.role.tenant_admin", "TENANT ADMIN"), tenant_admin: t("ui.admin.role.tenant_admin", "TENANT ADMIN"),
rp_admin: t("ui.admin.role.rp_admin", "RP 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 ( return (
@@ -105,40 +105,35 @@ const RoleSwitcher: FC = () => {
marginTop: "4px", marginTop: "4px",
}} }}
> >
{( {(["super_admin", "tenant_admin", "rp_admin", "user"] as const).map(
[ (role) => (
"super_admin", <button
"tenant_admin", key={role}
"rp_admin", type="button"
"tenant_member", onClick={() => switchRole(role)}
] as const style={{
).map((role) => ( background: currentRole === role ? "#3b82f6" : "#333",
<button color: "white",
key={role} border: "none",
type="button" padding: "4px 8px",
onClick={() => switchRole(role)} borderRadius: "4px",
style={{ cursor: "pointer",
background: currentRole === role ? "#3b82f6" : "#333", textAlign: "left",
color: "white", transition: "background 0.2s",
border: "none", display: "flex",
padding: "4px 8px", justifyContent: "space-between",
borderRadius: "4px", alignItems: "center",
cursor: "pointer", }}
textAlign: "left", >
transition: "background 0.2s", <span>
display: "flex", {roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
justifyContent: "space-between", </span>
alignItems: "center", {currentRole === role && (
}} <span style={{ marginLeft: "8px" }}></span>
> )}
<span> </button>
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")} ),
</span> )}
{currentRole === role && (
<span style={{ marginLeft: "8px" }}></span>
)}
</button>
))}
</div> </div>
)} )}
</div> </div>

View File

@@ -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;

View File

@@ -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;

View File

@@ -17,7 +17,7 @@ function TenantDetailPage() {
}); });
const isFederationTab = location.pathname.includes("/federation"); const isFederationTab = location.pathname.includes("/federation");
const isAdminTab = location.pathname.includes("/admins"); const isPermissionsTab = location.pathname.includes("/permissions");
const isOrganizationTab = location.pathname.includes("/organization"); const isOrganizationTab = location.pathname.includes("/organization");
return ( return (
@@ -59,7 +59,7 @@ function TenantDetailPage() {
to={`/tenants/${tenantId}`} to={`/tenants/${tenantId}`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${ className={`px-6 py-3 text-sm font-medium transition-colors relative ${
!isFederationTab && !isFederationTab &&
!isAdminTab && !isPermissionsTab &&
!location.pathname.includes("/schema") && !location.pathname.includes("/schema") &&
!isOrganizationTab !isOrganizationTab
? "text-primary border-b-2 border-primary" ? "text-primary border-b-2 border-primary"
@@ -79,14 +79,14 @@ function TenantDetailPage() {
{t("ui.admin.tenants.detail.tab_federation", "외부 연동")} {t("ui.admin.tenants.detail.tab_federation", "외부 연동")}
</Link> </Link>
<Link <Link
to={`/tenants/${tenantId}/admins`} to={`/tenants/${tenantId}/permissions`}
className={`px-6 py-3 text-sm font-medium transition-colors relative ${ className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isAdminTab isPermissionsTab
? "text-primary border-b-2 border-primary" ? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground" : "text-muted-foreground hover:text-foreground"
}`} }`}
> >
{t("ui.admin.tenants.detail.tab_admins", "관리자 설정")} {t("ui.admin.tenants.detail.tab_permissions", "권한")}
</Link> </Link>
<Link <Link
to={`/tenants/${tenantId}/organization`} to={`/tenants/${tenantId}/organization`}

View File

@@ -438,10 +438,16 @@ function UserCreatePage() {
{...register("role")} {...register("role")}
> >
<option value="user"> <option value="user">
{t("ui.common.role.user", "User")} {t("ui.admin.role.user", "TENANT MEMBER")}
</option> </option>
<option value="admin"> <option value="tenant_admin">
{t("ui.common.role.admin", "Admin")} {t("ui.admin.role.tenant_admin", "TENANT ADMIN")}
</option>
<option value="rp_admin">
{t("ui.admin.role.rp_admin", "RP ADMIN")}
</option>
<option value="super_admin">
{t("ui.admin.role.super_admin", "SUPER ADMIN")}
</option> </option>
</select> </select>
</div> </div>

View File

@@ -287,10 +287,16 @@ function UserDetailPage() {
{...register("role")} {...register("role")}
> >
<option value="user"> <option value="user">
{t("ui.common.role.user", "User")} {t("ui.admin.role.user", "TENANT MEMBER")}
</option> </option>
<option value="admin"> <option value="tenant_admin">
{t("ui.common.role.admin", "Admin")} {t("ui.admin.role.tenant_admin", "TENANT ADMIN")}
</option>
<option value="rp_admin">
{t("ui.admin.role.rp_admin", "RP ADMIN")}
</option>
<option value="super_admin">
{t("ui.admin.role.super_admin", "SUPER ADMIN")}
</option> </option>
</select> </select>
</div> </div>

View File

@@ -245,7 +245,7 @@ function UserListPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge variant="outline"> <Badge variant="outline">
{t(`ui.common.role.${user.role}`, user.role)} {t(`ui.admin.role.${user.role}`, user.role)}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>

View File

@@ -167,6 +167,21 @@ export async function removeTenantAdmin(tenantId: string, userId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`); await apiClient.delete(`/v1/admin/tenants/${tenantId}/admins/${userId}`);
} }
export async function fetchTenantOwners(tenantId: string) {
const { data } = await apiClient.get<TenantAdmin[]>(
`/v1/admin/tenants/${tenantId}/owners`,
);
return data;
}
export async function addTenantOwner(tenantId: string, userId: string) {
await apiClient.post(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
}
export async function removeTenantOwner(tenantId: string, userId: string) {
await apiClient.delete(`/v1/admin/tenants/${tenantId}/owners/${userId}`);
}
// Group Management // Group Management
export type GroupMember = { export type GroupMember = {
id: string; id: string;
@@ -421,6 +436,26 @@ export async function deleteUser(userId: string) {
await apiClient.delete(`/v1/admin/users/${userId}`); 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 // Relying Party Management
export type RelyingParty = { export type RelyingParty = {
clientId: string; clientId: string;

View File

@@ -130,6 +130,44 @@ not_found = "Tenant not found."
remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?' remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?'
subtitle = "Subtitle" 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] [msg.admin.tenants.create]
subtitle = "Subtitle" subtitle = "Subtitle"
@@ -703,7 +741,7 @@ view_audit_logs = "View Audit Logs"
rp_admin = "RP ADMIN" rp_admin = "RP ADMIN"
super_admin = "SUPER ADMIN" super_admin = "SUPER ADMIN"
tenant_admin = "TENANT ADMIN" tenant_admin = "TENANT ADMIN"
tenant_member = "TENANT MEMBER" user = "TENANT MEMBER"
[ui.admin.tenants] [ui.admin.tenants]
add = "Tenant Add" add = "Tenant Add"

View File

@@ -161,6 +161,13 @@ remove_success = "관리자 권한이 회수되었습니다."
subtitle = "이 테넌트의 자원을 관리할 수 있는 권한을 가진 사용자들입니다." subtitle = "이 테넌트의 자원을 관리할 수 있는 권한을 가진 사용자들입니다."
title = "테넌트 관리자 설정" title = "테넌트 관리자 설정"
[msg.admin.tenants.owners]
add_success = "소유자가 성공적으로 추가되었습니다."
empty = "등록된 소유자가 없습니다."
remove_confirm = "{{name}} 사용자의 소유자 권한을 회수할까요?"
remove_success = "소유자 권한이 회수되었습니다."
subtitle = "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다."
[ui.admin.tenants.admins] [ui.admin.tenants.admins]
add_button = "관리자 추가" add_button = "관리자 추가"
already_admin = "이미 관리자" already_admin = "이미 관리자"
@@ -175,6 +182,15 @@ table_email = "이메일"
table_name = "이름" table_name = "이름"
title = "테넌트 관리자" title = "테넌트 관리자"
[ui.admin.tenants.owners]
add_button = "소유자 추가"
dialog_description = "이름 또는 이메일로 사용자를 검색하여 소유자 권한을 부여하세요."
dialog_title = "새 소유자 추가"
table_actions = "액션"
table_email = "이메일"
table_name = "이름"
title = "테넌트 소유자"
[msg.admin.tenants.create] [msg.admin.tenants.create]
subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다." subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다."
@@ -772,10 +788,10 @@ policy_gate = "정책 게이트"
total_tenants = "전체 테넌트" total_tenants = "전체 테넌트"
[ui.admin.role] [ui.admin.role]
rp_admin = "RP ADMIN" rp_admin = "서비스 관리자 (RP Admin)"
super_admin = "SUPER ADMIN" super_admin = "시스템 관리자 (Super Admin)"
tenant_admin = "TENANT ADMIN" tenant_admin = "테넌트 관리자 (Tenant Admin)"
tenant_member = "TENANT MEMBER" user = "일반 사용자 (Tenant Member)"
[ui.admin.tenants] [ui.admin.tenants]
add = "테넌트 추가" add = "테넌트 추가"

View File

@@ -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 }) => { test("should redirect unauthorized users to login page", async ({ page }) => {
@@ -74,13 +86,17 @@ test.describe("Authentication", () => {
}); });
await page.goto("/"); 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 // Mock window.confirm
page.on("dialog", (dialog) => dialog.accept()); page.on("dialog", (dialog) => dialog.accept());
// Click logout button (label: ui.admin.nav.logout) // Click logout button in the sidebar (use nav container to be specific)
await page.click('button:has-text("Logout"), button:has-text("로그아웃")'); await page.click(
'nav button:has-text("Logout"), nav button:has-text("로그아웃")',
);
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/);
}); });

View 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
});
});

View File

@@ -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 // Default mock for tenants to avoid proxy leaks
await page.route("**/api/v1/admin/tenants**", async (route) => { await page.route("**/api/v1/admin/tenants**", async (route) => {
if (route.request().method() === "GET") { if (route.request().method() === "GET") {

View File

@@ -14,6 +14,7 @@ import (
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
"net/url"
"os" "os"
"strconv" "strconv"
"strings" "strings"
@@ -346,12 +347,41 @@ func main() {
app.Use(middleware.ErrorCodeEnricher()) app.Use(middleware.ErrorCodeEnricher())
allowedOrigins := getEnv("CORS_ALLOWED_ORIGINS", "http://localhost:5000") 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{ app.Use(cors.New(cors.Config{
AllowOrigins: allowedOrigins, AllowOriginsFunc: func(origin string) bool {
AllowHeaders: "Origin, Content-Type, Accept, Authorization", // 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", AllowMethods: "GET, POST, HEAD, PUT, DELETE, PATCH, OPTIONS",
AllowCredentials: allowCredentials, AllowCredentials: true,
})) }))
// Ensure COOKIE_SECRET is exactly 32 bytes for AES-256 // Ensure COOKIE_SECRET is exactly 32 bytes for AES-256
@@ -483,6 +513,11 @@ func main() {
// Public Tenant Registration // Public Tenant Registration
api.Post("/tenants/registration", tenantHandler.RegisterTenantPublic) 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 Proxy Routes
auth := api.Group("/auth") auth := api.Group("/auth")
auth.Post("/enchanted-link/init", authHandler.InitEnchantedLink) 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", authHandler.VerifyLoginCode)
auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode) auth.Post("/login/code/verify-short", authHandler.VerifyLoginShortCode)
auth.Post("/password/login", authHandler.PasswordLogin) auth.Post("/password/login", authHandler.PasswordLogin)
auth.Get("/tenant-info", authHandler.GetTenantInfo)
auth.Get("/consent", authHandler.GetConsentRequest) auth.Get("/consent", authHandler.GetConsentRequest)
auth.Post("/consent/accept", authHandler.AcceptConsentRequest) auth.Post("/consent/accept", authHandler.AcceptConsentRequest)
auth.Post("/consent/reject", authHandler.RejectConsentRequest) auth.Post("/consent/reject", authHandler.RejectConsentRequest)
auth.Post("/oidc/login/accept", authHandler.AcceptOidcLoginRequest) 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) auth.Post("/password/reset/initiate", authHandler.InitiatePasswordReset)
// [Changed] Use Interstitial Page for GET to prevent Scanner consumption // [Changed] Use Interstitial Page for GET to prevent Scanner consumption
auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage) auth.Get("/password/reset/verify", authHandler.VerifyPasswordResetPage)
@@ -557,8 +584,8 @@ func main() {
admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능) admin.Get("/check", adminHandler.CheckAuth) // 기본 Admin 체크는 requireAdmin 없이 ApiKeyAuth로만 보호될 수 있음 (또는 추가 가능)
admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats) admin.Get("/stats", requireSuperAdmin, adminHandler.GetSystemStats)
// Tenant Management (Super Admin Only) // Tenant Management (Mixed roles, handler filters results)
admin.Get("/tenants", requireSuperAdmin, tenantHandler.ListTenants) admin.Get("/tenants", requireAdmin, tenantHandler.ListTenants)
admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant) admin.Post("/tenants", requireSuperAdmin, tenantHandler.CreateTenant)
admin.Post("/tenants/:id/approve", requireSuperAdmin, tenantHandler.ApproveTenant) 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) 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.Get("/tenants/:id/admins", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListAdmins)
admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin) admin.Post("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddAdmin)
admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin) admin.Delete("/tenants/:id/admins/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveAdmin)
admin.Get("/tenants/:id/owners", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.ListOwners)
admin.Post("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.AddOwner)
admin.Delete("/tenants/:id/owners/:userId", requireAdmin, middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), tenantHandler.RemoveOwner)
// Organization & Org-Chart Management (Tenant Admin/Super Admin) // Organization & Org-Chart Management (Tenant Admin/Super Admin)
org := admin.Group("/tenants/:tenantId/organization", requireAdmin) org := admin.Group("/tenants/:tenantId/organization", requireAdmin)

View File

@@ -59,7 +59,7 @@ func SeedTenants(db *gorm.DB) error {
} }
slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug) slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug)
tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil) tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil, "")
if err != nil { if err != nil {
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err) slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
return err return err

View File

@@ -538,6 +538,55 @@ func (h *AuthHandler) getBearerToken(c *fiber.Ctx) string {
return parts[1] 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)로 정규화합니다. // normalizePhoneForLoginID는 전화번호를 IDP 조회에 적합한 형태(E.164)로 정규화합니다.
func normalizePhoneForLoginID(phone string) string { func normalizePhoneForLoginID(phone string) string {
normalized := strings.ReplaceAll(phone, "-", "") normalized := strings.ReplaceAll(phone, "-", "")
@@ -920,10 +969,7 @@ func (h *AuthHandler) InitEnchantedLink(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusNotFound, "User not registered") return errorJSON(c, fiber.StatusNotFound, "User not registered")
} }
userfrontURL := os.Getenv("USERFRONT_URL") userfrontURL := h.resolveUserfrontURL(c)
if userfrontURL == "" {
userfrontURL = "http://sso.hmac.kr"
}
if req.URI != "" { if req.URI != "" {
userfrontURL = 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") return errorJSON(c, fiber.StatusInternalServerError, "Authentication service not configured")
} }
userfrontURL := os.Getenv("USERFRONT_URL") userfrontURL := h.resolveUserfrontURL(c)
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")
}
// [Changed] Point to Backend API for verification (which then redirects to Frontend) // [Changed] Point to Backend API for verification (which then redirects to Frontend)
redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL) redirectURL := fmt.Sprintf("%s/api/v1/auth/password/reset/verify", userfrontURL)
ale.RedirectTo = redirectURL ale.RedirectTo = redirectURL
@@ -1863,10 +1902,7 @@ func (h *AuthHandler) ProcessPasswordResetToken(c *fiber.Ctx) error {
ale.LoginIDs["loginId"] = loginID ale.LoginIDs["loginId"] = loginID
ale.LoginIDs["loginId_normalized"] = loginID ale.LoginIDs["loginId_normalized"] = loginID
userfrontURL := strings.TrimRight(os.Getenv("USERFRONT_URL"), "/") userfrontURL := h.resolveUserfrontURL(c)
if userfrontURL == "" {
userfrontURL = "https://sso.hmac.kr"
}
redirectBase, parseErr := url.Parse(userfrontURL + "/reset-password") redirectBase, parseErr := url.Parse(userfrontURL + "/reset-password")
if parseErr != nil { if parseErr != nil {
ale.Status = fiber.StatusInternalServerError ale.Status = fiber.StatusInternalServerError
@@ -2000,10 +2036,7 @@ func (h *AuthHandler) InitQRLogin(c *fiber.Ctx) error {
} }
// QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다. // QR 코드 페이로드를 실제 접속 가능한 URL로 변경합니다.
userfrontURL := os.Getenv("USERFRONT_URL") userfrontURL := h.resolveUserfrontURL(c)
if userfrontURL == "" {
userfrontURL = "https://sso.hmac.kr"
}
qrPayload := fmt.Sprintf("%s/ql/%s", strings.TrimRight(userfrontURL, "/"), qrRef) qrPayload := fmt.Sprintf("%s/ql/%s", strings.TrimRight(userfrontURL, "/"), qrRef)
slog.Info("[QR] Init", "pendingRef", pendingRef, "qrRef", qrRef, "url", qrPayload) 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) { func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileResponse, error) {
slog.Info("🚨 [FATAL_DEBUG] ENVIRONMENT CHECK",
"APP_ENV", os.Getenv("APP_ENV"),
"GO_ENV", os.Getenv("GO_ENV"),
"X-Test-Role", c.Get("X-Test-Role"),
)
slog.Info("🚀 [TRACE] resolveCurrentProfile entry", "path", c.Path(), "method", c.Method())
// [Dev Only] Mock Role Bypass
appEnv := strings.ToLower(os.Getenv("APP_ENV")) appEnv := strings.ToLower(os.Getenv("APP_ENV"))
isDev := appEnv == "dev" || appEnv == "development" || appEnv == ""
mockRole := c.Get("X-Test-Role") mockRole := c.Get("X-Test-Role")
if mockRole == "" { if mockRole == "" {
mockRole = c.Get("X-Mock-Role") mockRole = c.Get("X-Mock-Role")
} }
// Always log in development to see what's happening token := h.getBearerToken(c)
if appEnv == "dev" || appEnv == "development" || appEnv == "" { cookie := c.Get("Cookie")
slog.Info("🔍 [AUTH_DEBUG] Checking mock role",
"env", appEnv, var profile *domain.UserProfileResponse
"mockRole", mockRole, var err error
"X-Test-Role", c.Get("X-Test-Role"), cacheKey := ""
"X-Mock-Role", c.Get("X-Mock-Role"),
) // 1. Try to fetch real profile if token/cookie exists
if token != "" || cookie != "" {
// Try Redis Cache
if h.RedisService != nil && token != "" {
cacheKey = "cache:profile:token:" + token
cached, _ := h.RedisService.Get(cacheKey)
if cached != "" {
if json.Unmarshal([]byte(cached), &profile) == nil {
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 // 2. Role Override for real profile or fallback to Mock Profile
if (appEnv == "dev" || appEnv == "development" || appEnv == "") && mockRole != "" { if profile != nil {
slog.Info("🔑 [AUTH_DEBUG] Mock bypass SUCCESS", "role", mockRole) if isDev && mockRole != "" {
mockProfile := &domain.UserProfileResponse{ slog.Info("🔑 [AUTH] 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", ID: "00000000-0000-0000-0000-000000000000",
Email: "mock@hmac.kr", Email: "mock@hmac.kr",
Name: "Dev Mock User", Name: "Dev Mock User",
Role: mockRole, Role: mockRole,
} }
if tid := c.Get("X-Tenant-ID"); tid != "" { if tid := c.Get("X-Tenant-ID"); tid != "" {
mockProfile.TenantID = &tid profile.TenantID = &tid
}
return mockProfile, nil
}
// Mock bypass failed - log headers for debugging if in dev
if appEnv == "dev" || appEnv == "development" || appEnv == "" {
slog.Warn("⚠️ [DEBUG] Mock auth bypass failed",
"appEnv", appEnv,
"X-Test-Role", c.Get("X-Test-Role"),
"X-Mock-Role", c.Get("X-Mock-Role"),
"path", c.Path())
}
var profile *domain.UserProfileResponse
var err error
token := h.getBearerToken(c)
cookie := c.Get("Cookie")
cacheKey := ""
// 1. Try Redis Cache
if h.RedisService != nil {
if token != "" {
cacheKey = "cache:profile:token:" + token
}
// Cookie based caching skipped for simplicity/safety
if cacheKey != "" {
cached, _ := h.RedisService.Get(cacheKey)
if cached != "" {
if json.Unmarshal([]byte(cached), &profile) == nil {
return profile, nil
}
}
} }
} }
// 2. Fetch from Kratos (SoT) if profile == nil {
if token != "" { slog.Warn("No profile resolved", "token_len", len(token), "cookie_len", len(cookie), "mockRole", mockRole)
profile, err = h.getKratosProfile(token)
} else {
if cookie != "" {
profile, err = h.getKratosProfileWithCookie(cookie)
}
}
if err != nil || profile == nil {
return nil, errors.New("invalid session (trace:resolve_profile)") return nil, errors.New("invalid session (trace:resolve_profile)")
} }
// 3. Post-Process (Defaults & Metadata Enrichment) // 3. Post-Process (Defaults & Metadata Enrichment)
// Default Role if missing (migration safety)
if profile.Role == "" { if profile.Role == "" {
profile.Role = domain.RoleUser profile.Role = domain.RoleUser
} }
// Fetch Tenant Metadata if missing // Fetch Tenant Metadata if missing
// Case A: Have TenantID from Kratos -> Fetch by ID
if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" { if profile.Tenant == nil && profile.TenantID != nil && *profile.TenantID != "" {
if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil { if tenant, err := h.TenantService.GetTenant(c.Context(), *profile.TenantID); err == nil {
profile.Tenant = tenant profile.Tenant = tenant
} }
} }
// Case B: Have CompanyCode but no TenantID -> Fetch by Slug
if profile.Tenant == nil && profile.CompanyCode != "" { if profile.Tenant == nil && profile.CompanyCode != "" {
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil { if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), profile.CompanyCode); err == nil && tenant != nil {
profile.Tenant = tenant profile.Tenant = tenant
@@ -4033,7 +4047,10 @@ func (h *AuthHandler) resolveCurrentProfile(c *fiber.Ctx) (*domain.UserProfileRe
} }
// 4. Save to Redis Cache (Short TTL) // 4. Save to Redis Cache (Short TTL)
if h.RedisService != nil && cacheKey != "" { // 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 { if data, err := json.Marshal(profile); err == nil {
ttlStr := os.Getenv("PROFILE_CACHE_TTL") ttlStr := os.Getenv("PROFILE_CACHE_TTL")
ttl := 30 * time.Minute // Default TTL ttl := 30 * time.Minute // Default TTL
@@ -5060,12 +5077,36 @@ func (h *AuthHandler) updateKratosIdentity(identityID string, traits map[string]
return nil return nil
} }
func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) { func (h *AuthHandler) getHydraProfile(ctx context.Context, token string) (*domain.UserProfileResponse, error) {
identityID, traits, err := h.getKratosIdentity(sessionToken) intro, err := h.Hydra.IntrospectToken(ctx, token)
if err != nil { if err != nil {
slog.Error("Hydra introspection failed", "error", err)
return nil, err return nil, err
} }
if !intro.Active {
slog.Warn("Hydra token is not active")
return nil, errors.New("token is not active")
}
slog.Info("Hydra token introspected", "subject", intro.Subject, "client_id", intro.ClientID)
// Fetch identity details from Kratos by subject (identityID)
identity, err := h.KratosAdmin.GetIdentity(ctx, intro.Subject)
if err != nil || identity == nil {
slog.Warn("Kratos identity not found for Hydra subject", "subject", intro.Subject)
// Fallback to minimal profile if Kratos identity not found
return &domain.UserProfileResponse{
ID: intro.Subject,
Email: "unknown@hydra.local",
Name: "Hydra User",
Role: domain.RoleUser,
}, nil
}
return h.mapKratosIdentityToProfile(identity.ID, identity.Traits), nil
}
func (h *AuthHandler) mapKratosIdentityToProfile(identityID string, traits map[string]interface{}) *domain.UserProfileResponse {
email, _ := traits["email"].(string) email, _ := traits["email"].(string)
name, _ := traits["name"].(string) name, _ := traits["name"].(string)
phone, _ := traits["phone_number"].(string) phone, _ := traits["phone_number"].(string)
@@ -5101,8 +5142,15 @@ func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfile
profile.Metadata[k] = v profile.Metadata[k] = v
} }
} }
return profile
}
return profile, nil func (h *AuthHandler) getKratosProfile(sessionToken string) (*domain.UserProfileResponse, error) {
identityID, traits, err := h.getKratosIdentity(sessionToken)
if err != nil {
return nil, err
}
return h.mapKratosIdentityToProfile(identityID, traits), nil
} }
func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) { func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserProfileResponse, error) {
@@ -5110,44 +5158,7 @@ func (h *AuthHandler) getKratosProfileWithCookie(cookie string) (*domain.UserPro
if err != nil { if err != nil {
return nil, err return nil, err
} }
return h.mapKratosIdentityToProfile(identityID, traits), nil
email, _ := traits["email"].(string)
name, _ := traits["name"].(string)
phone, _ := traits["phone_number"].(string)
dept, _ := traits["department"].(string)
affType, _ := traits["affiliationType"].(string)
compCode, _ := traits["companyCode"].(string)
role, _ := traits["role"].(string)
tenantID, _ := traits["tenant_id"].(string)
profile := &domain.UserProfileResponse{
ID: identityID,
Email: email,
Name: name,
Phone: h.formatPhoneForDisplay(phone),
Department: dept,
AffiliationType: affType,
CompanyCode: compCode,
Role: role,
Metadata: make(map[string]any),
}
if tenantID != "" {
profile.TenantID = &tenantID
}
coreTraits := map[string]bool{
"email": true, "name": true, "phone_number": true,
"grade": true, "companyCode": true, "department": true,
"affiliationType": true, "role": true, "tenant_id": true,
}
for k, v := range traits {
if !coreTraits[k] {
profile.Metadata[k] = v
}
}
return profile, nil
} }
// UpdateMe - Updates current user's profile with phone verification check // UpdateMe - Updates current user's profile with phone verification check

View File

@@ -140,7 +140,7 @@ type AsyncMockTenantService struct {
mock.Mock mock.Mock
} }
func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
return nil, nil return nil, nil
} }
@@ -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) { func (m *AsyncMockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
return nil, nil 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) ApproveTenant(ctx context.Context, id string) error { return nil }
func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {} func (m *AsyncMockTenantService) SetKetoService(keto service.KetoService) {}
func (m *AsyncMockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error { func (m *AsyncMockTenantService) AddTenantAdmin(ctx context.Context, tenantID, userID string) error {

View File

@@ -109,9 +109,36 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
offset = 0 offset = 0
} }
tenants, total, err := h.Service.ListTenants(c.Context(), limit, offset, parentId) var tenants []domain.Tenant
if err != nil { var total int64
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) 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) // 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 parentID = &pid
} }
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID) // Extract creator ID if present
creatorID := ""
if profile, ok := c.Locals("user_profile").(*domain.UserProfileResponse); ok {
creatorID = profile.ID
}
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID, creatorID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "already exists") { if strings.Contains(err.Error(), "already exists") {
return errorJSON(c, fiber.StatusConflict, err.Error()) return errorJSON(c, fiber.StatusConflict, err.Error())
@@ -423,20 +456,28 @@ func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
} }
userID := strings.TrimPrefix(rel.SubjectID, "User:") userID := strings.TrimPrefix(rel.SubjectID, "User:")
// Fetch user details from Kratos // Fetch user details - Try Kratos first, then local DB
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID) name := "Unknown"
if err != nil { email := "Unknown"
admins = append(admins, adminInfo{ID: userID, Name: "Unknown", Email: "Unknown"})
continue
}
name := "" identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if n, ok := identity.Traits["name"].(string); ok { if err == nil && identity != nil {
name = n if n, ok := identity.Traits["name"].(string); ok {
} name = n
email := "" }
if e, ok := identity.Traits["email"].(string); ok { if e, ok := identity.Traits["email"].(string); ok {
email = e email = e
}
} else if h.UserRepo != nil {
// Fallback to local DB (useful for Mock users or users not yet synced/migrated to Kratos)
user, err := h.UserRepo.FindByID(c.Context(), userID)
if err == nil && user != nil {
name = user.Name
email = user.Email
} else if userID == "00000000-0000-0000-0000-000000000000" {
name = "Dev Mock User"
email = "mock@hmac.kr"
}
} }
admins = append(admins, adminInfo{ admins = append(admins, adminInfo{
@@ -464,6 +505,14 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
Subject: "User:" + userID, Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate, Action: domain.KetoOutboxActionCreate,
}) })
// Also add as member for UI visibility/ReBAC logic
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
} }
return c.SendStatus(fiber.StatusOK) return c.SendStatus(fiber.StatusOK)
@@ -489,6 +538,113 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
return c.SendStatus(fiber.StatusNoContent) return c.SendStatus(fiber.StatusNoContent)
} }
func (h *TenantHandler) ListOwners(c *fiber.Ctx) error {
tenantID := c.Params("id")
if tenantID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenant id is required")
}
// Fetch owners from Keto
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "owners", "")
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
type ownerInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Email string `json:"email"`
}
owners := []ownerInfo{}
for _, rel := range relations {
if !strings.HasPrefix(rel.SubjectID, "User:") {
continue
}
userID := strings.TrimPrefix(rel.SubjectID, "User:")
// Fetch user details - Try Kratos first, then local DB
name := "Unknown"
email := "Unknown"
identity, err := h.KratosAdmin.GetIdentity(c.Context(), userID)
if err == nil && identity != nil {
if n, ok := identity.Traits["name"].(string); ok {
name = n
}
if e, ok := identity.Traits["email"].(string); ok {
email = e
}
} else if h.UserRepo != nil {
// Fallback to local DB
user, err := h.UserRepo.FindByID(c.Context(), userID)
if err == nil && user != nil {
name = user.Name
email = user.Email
} else if userID == "00000000-0000-0000-0000-000000000000" {
name = "Dev Mock User"
email = "mock@hmac.kr"
}
}
owners = append(owners, ownerInfo{
ID: userID,
Name: name,
Email: email,
})
}
return c.JSON(owners)
}
func (h *TenantHandler) AddOwner(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if tenantID == "" || userID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "owners",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
// Also add as member for UI visibility/ReBAC logic
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
}
return c.SendStatus(fiber.StatusOK)
}
func (h *TenantHandler) RemoveOwner(c *fiber.Ctx) error {
tenantID := c.Params("id")
userID := c.Params("userId")
if tenantID == "" || userID == "" {
return errorJSON(c, fiber.StatusBadRequest, "tenantId and userId are required")
}
if h.KetoOutbox != nil {
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "owners",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
}
return c.SendStatus(fiber.StatusNoContent)
}
func mapTenantSummary(t domain.Tenant) tenantSummary { func mapTenantSummary(t domain.Tenant) tenantSummary {
domains := make([]string, 0, len(t.Domains)) domains := make([]string, 0, len(t.Domains))
for _, d := range t.Domains { for _, d := range t.Domains {

View File

@@ -21,8 +21,8 @@ type MockTenantService struct {
mock.Mock mock.Mock
} }
func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
args := m.Called(ctx, name, slug, tenantType, description, domains, parentID) args := m.Called(ctx, name, slug, tenantType, description, domains, parentID, creatorID)
if args.Get(0) == nil { if args.Get(0) == nil {
return nil, args.Error(1) return nil, args.Error(1)
} }
@@ -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) 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) { func (m *MockTenantService) SetKetoService(keto service.KetoService) {
m.Called(keto) m.Called(keto)
} }
@@ -133,7 +146,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
} }
body, _ := json.Marshal(input) body, _ := json.Marshal(input)
mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil)). mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil), "").
Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil) Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil)
req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body)) req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))

View File

@@ -59,15 +59,15 @@ func RequireKetoPermission(config RBACConfig, namespace, relation string) fiber.
c.Locals("tenant_id", objectID) c.Locals("tenant_id", objectID)
} }
// Check with Keto // Check with Keto - add User: prefix to subject
allowed, err := config.KetoService.CheckPermission(c.Context(), profile.ID, namespace, objectID, relation) allowed, err := config.KetoService.CheckPermission(c.Context(), "User:"+profile.ID, namespace, objectID, relation)
if err != nil { if err != nil {
slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID) slog.Error("Keto service error", "error", err, "userID", profile.ID, "objectID", objectID)
return errorJSON(c, fiber.StatusInternalServerError, "permission check error") return errorJSON(c, fiber.StatusInternalServerError, "permission check error")
} }
if !allowed { 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) 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, "userRole", profile.Role,
"allowedRoles", config.AllowedRoles, "allowedRoles", config.AllowedRoles,
"path", c.Path(), "path", c.Path(),
"X-Test-Role", c.Get("X-Test-Role"),
) )
return errorJSON(c, fiber.StatusForbidden, "forbidden: insufficient permissions") 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() return c.Next()
} }
} }

View File

@@ -123,7 +123,7 @@ func TestRequireKetoPermission_Success(t *testing.T) {
profile := &domain.UserProfileResponse{ID: "user1", Role: "user"} profile := &domain.UserProfileResponse{ID: "user1", Role: "user"}
mockAuth.On("GetEnrichedProfile", mock.Anything).Return(profile, nil) 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 { app.Get("/tenants/:id", RequireKetoPermission(config, "tenants", "read"), func(c *fiber.Ctx) error {
return c.SendString("ok") return c.SendString("ok")

View 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()
}
}

View 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)
})
}

View File

@@ -596,3 +596,42 @@ func (s *HydraAdminService) AcceptLoginRequest(ctx context.Context, challenge st
return &AcceptLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil return &AcceptLoginRequestResponse{RedirectTo: hydraResp.RedirectTo}, nil
} }
type HydraIntrospectionResponse struct {
Active bool `json:"active"`
Subject string `json:"sub"`
ClientID string `json:"client_id"`
Scope string `json:"scope"`
ExpiresAt int64 `json:"exp"`
IssuedAt int64 `json:"iat"`
Ext map[string]interface{} `json:"ext"`
}
func (s *HydraAdminService) IntrospectToken(ctx context.Context, token string) (*HydraIntrospectionResponse, error) {
endpoint := fmt.Sprintf("%s/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
}

View File

@@ -13,12 +13,14 @@ import (
) )
type TenantService interface { type TenantService interface {
RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error)
RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error)
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
GetTenant(ctx context.Context, id 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) 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 ApproveTenant(ctx context.Context, id string) error
SetKetoService(keto KetoService) // 추가 SetKetoService(keto KetoService) // 추가
} }
@@ -90,7 +92,7 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
return s.repo.FindByIDs(ctx, allIDs) return s.repo.FindByIDs(ctx, allIDs)
} }
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) { func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string, creatorID string) (*domain.Tenant, error) {
// Validate Slug // Validate Slug
if ok, msg := utils.ValidateSlug(slug); !ok { if ok, msg := utils.ValidateSlug(slug); !ok {
return nil, errors.New(msg) return nil, errors.New(msg)
@@ -119,15 +121,49 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantTy
return nil, err return nil, err
} }
// [Keto] Sync hierarchy via Outbox if ParentID exists // [Keto] Sync hierarchy and ownership via Outbox
if s.outboxRepo != nil && tenant.ParentID != nil { if s.outboxRepo != nil {
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ // Sync hierarchy
Namespace: "Tenant", if tenant.ParentID != nil {
Object: tenant.ID, if err := s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Relation: "parents", Namespace: "Tenant",
Subject: "Tenant:" + *tenant.ParentID, Object: tenant.ID,
Action: domain.KetoOutboxActionCreate, Relation: "parents",
}) Subject: "Tenant:" + *tenant.ParentID,
Action: domain.KetoOutboxActionCreate,
}); err != nil {
slog.Error("Failed to create outbox entry for tenant hierarchy", "tenant", tenant.ID, "error", err)
}
}
// Sync creator ownership
if creatorID != "" {
slog.Info("Creating outbox entries for tenant creator", "tenant", tenant.ID, "creator", creatorID)
// Add as owner
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "owners",
Subject: "User:" + creatorID,
Action: domain.KetoOutboxActionCreate,
})
// Add as admin
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "admins",
Subject: "User:" + creatorID,
Action: domain.KetoOutboxActionCreate,
})
// Add as member
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "members",
Subject: "User:" + creatorID,
Action: domain.KetoOutboxActionCreate,
})
}
} }
// 3. Add Domains (Auto-verify for manual admin registration) // 3. Add Domains (Auto-verify for manual admin registration)
@@ -187,12 +223,20 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
// [Keto] Sync relation via Outbox // [Keto] Sync relation via Outbox
if s.outboxRepo != nil { if s.outboxRepo != nil {
if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" { if adminEmail, ok := tenant.Config["adminEmail"].(string); ok && adminEmail != "" {
slog.Info("Queueing tenant admin sync to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail) slog.Info("Queueing tenant admin/owner sync to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail)
// Check if user already exists in our Read-Model // Check if user already exists in our Read-Model
if s.userRepo != nil { if s.userRepo != nil {
user, err := s.userRepo.FindByEmail(ctx, adminEmail) user, err := s.userRepo.FindByEmail(ctx, adminEmail)
if err == nil && user != nil { if err == nil && user != nil {
// User exists, assign Admin role in Keto via Outbox // User exists, assign Admin, Owner, and Member roles in Keto via Outbox
slog.Info("Queueing tenant ownership/membership sync to Keto", "tenant", tenant.Slug, "userID", user.ID)
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "owners",
Subject: "User:" + user.ID,
Action: domain.KetoOutboxActionCreate,
})
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{ _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant", Namespace: "Tenant",
Object: tenant.ID, Object: tenant.ID,
@@ -200,6 +244,13 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
Subject: "User:" + user.ID, Subject: "User:" + user.ID,
Action: domain.KetoOutboxActionCreate, Action: domain.KetoOutboxActionCreate,
}) })
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "members",
Subject: "User:" + user.ID,
Action: domain.KetoOutboxActionCreate,
})
} else { } else {
slog.Info("Tenant admin user not found in local DB, will need manual sync or sync on signup", "email", adminEmail) slog.Info("Tenant admin user not found in local DB, will need manual sync or sync on signup", "email", adminEmail)
} }
@@ -232,3 +283,14 @@ func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, pare
// Let the repository handle the query and pagination // Let the repository handle the query and pagination
return s.repo.List(ctx, limit, offset, parentID) 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
}

View File

@@ -21,7 +21,7 @@ func TestTenantService_RegisterTenant_DuplicateSlug(t *testing.T) {
// Mock: slug already exists // Mock: slug already exists
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "existing-id", Slug: slug}, nil) mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "existing-id", Slug: slug}, nil)
tenant, err := svc.RegisterTenant(ctx, "New Name", slug, domain.TenantTypeCompany, "", nil, nil) tenant, err := svc.RegisterTenant(ctx, "New Name", slug, domain.TenantTypeCompany, "", nil, nil, "")
assert.Error(t, err) assert.Error(t, err)
assert.Contains(t, err.Error(), "already exists") assert.Contains(t, err.Error(), "already exists")
assert.Nil(t, tenant) assert.Nil(t, tenant)
@@ -32,11 +32,11 @@ func TestTenantService_RegisterTenant_InvalidSlug(t *testing.T) {
ctx := context.Background() ctx := context.Background()
// Case 1: Too short // Case 1: Too short
_, err := svc.RegisterTenant(ctx, "Name", "a", domain.TenantTypeCompany, "", nil, nil) _, err := svc.RegisterTenant(ctx, "Name", "a", domain.TenantTypeCompany, "", nil, nil, "")
assert.Error(t, err) assert.Error(t, err)
// Case 2: Invalid characters // Case 2: Invalid characters
_, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", domain.TenantTypeCompany, "", nil, nil) _, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", domain.TenantTypeCompany, "", nil, nil, "")
assert.Error(t, err) assert.Error(t, err)
} }

View File

@@ -162,13 +162,54 @@ func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil) mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil)
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once() mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once()
tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil) tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil, "")
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, tenant) assert.NotNil(t, tenant)
assert.Equal(t, "t1", tenant.ID) assert.Equal(t, "t1", tenant.ID)
mockRepo.AssertExpectations(t) mockRepo.AssertExpectations(t)
} }
func TestTenantService_RegisterTenant_WithCreator(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc)
mockOutbox := new(MockKetoOutboxRepositoryShared)
svc := NewTenantService(mockRepo, nil, mockOutbox)
ctx := context.Background()
name := "Creator Tenant"
slug := "creator-tenant"
creatorID := "creator-uuid"
tenantID := "t-new"
mockRepo.On("FindBySlug", ctx, slug).Return(nil, nil).Once()
mockRepo.On("Create", ctx, mock.MatchedBy(func(t *domain.Tenant) bool {
return t.Slug == slug
})).Run(func(args mock.Arguments) {
t := args.Get(1).(*domain.Tenant)
t.ID = tenantID
}).Return(nil)
// Expect owners sync
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "owners" && e.Subject == "User:"+creatorID
})).Return(nil)
// Expect admins sync
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+creatorID
})).Return(nil)
// Expect members sync
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+creatorID
})).Return(nil)
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: tenantID, Slug: slug}, nil).Once()
tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", nil, nil, creatorID)
assert.NoError(t, err)
assert.NotNil(t, tenant)
mockRepo.AssertExpectations(t)
mockOutbox.AssertExpectations(t)
}
func TestTenantService_RequestRegistration_NoVerify(t *testing.T) { func TestTenantService_RequestRegistration_NoVerify(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc) mockRepo := new(MockTenantRepoForSvc)
mockOutbox := new(MockKetoOutboxRepositoryShared) mockOutbox := new(MockKetoOutboxRepositoryShared)
@@ -215,9 +256,15 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
mockRepo.On("Update", ctx, mock.Anything).Return(nil) mockRepo.On("Update", ctx, mock.Anything).Return(nil)
mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, nil) mockUserRepo.On("FindByEmail", adminEmail).Return(&domain.User{ID: userID, Email: adminEmail}, nil)
// Now using Outbox instead of direct Keto call // Now using Outbox instead of direct Keto call
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "owners" && e.Subject == "User:"+userID
})).Return(nil)
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool { mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+userID return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+userID
})).Return(nil) })).Return(nil)
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "members" && e.Subject == "User:"+userID
})).Return(nil)
err := svc.ApproveTenant(ctx, tenantID) err := svc.ApproveTenant(ctx, tenantID)
assert.NoError(t, err) assert.NoError(t, err)

View File

@@ -5,7 +5,7 @@ class User implements Namespace {}
class Tenant implements Namespace { class Tenant implements Namespace {
related: { related: {
owners: User[] owners: User[]
admins: (User | SubjectSet<Tenant, "owners">)[] admins: User[]
members: User[] members: User[]
parents: Tenant[] parents: Tenant[]
} }
@@ -14,12 +14,18 @@ class Tenant implements Namespace {
view: (ctx: Context): boolean => view: (ctx: Context): boolean =>
this.related.members.includes(ctx.subject) || this.related.members.includes(ctx.subject) ||
this.related.admins.includes(ctx.subject) || this.related.admins.includes(ctx.subject) ||
this.related.owners.includes(ctx.subject) ||
this.related.parents.traverse((p) => p.permits.view(ctx)), this.related.parents.traverse((p) => p.permits.view(ctx)),
manage: (ctx: Context): boolean => manage: (ctx: Context): boolean =>
this.related.admins.includes(ctx.subject) || this.related.admins.includes(ctx.subject) ||
this.related.owners.includes(ctx.subject) ||
this.related.parents.traverse((p) => p.permits.manage(ctx)), this.related.parents.traverse((p) => p.permits.manage(ctx)),
manage_admins: (ctx: Context): boolean =>
this.related.owners.includes(ctx.subject) ||
this.related.parents.traverse((p) => p.permits.manage_admins(ctx)),
create_subtenant: (ctx: Context): boolean => create_subtenant: (ctx: Context): boolean =>
this.permits.manage(ctx) this.permits.manage(ctx)
} }

View File

@@ -182,6 +182,13 @@ remove_confirm = "Remove Confirm"
remove_success = "Remove Success" remove_success = "Remove Success"
subtitle = "Subtitle" 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] [msg.admin.tenants.create]
subtitle = "Subtitle" subtitle = "Subtitle"
@@ -811,7 +818,7 @@ view_audit_logs = "View Audit Logs"
rp_admin = "RP ADMIN" rp_admin = "RP ADMIN"
super_admin = "SUPER ADMIN" super_admin = "SUPER ADMIN"
tenant_admin = "TENANT ADMIN" tenant_admin = "TENANT ADMIN"
tenant_member = "TENANT MEMBER" user = "TENANT MEMBER"
[ui.admin.tenants] [ui.admin.tenants]
add = "Add Tenant" add = "Add Tenant"
@@ -831,6 +838,17 @@ table_email = "Email"
table_name = "Name" table_name = "Name"
title = "Title" 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] [ui.admin.tenants.breadcrumb]
list = "List" list = "List"
section = "Tenants" section = "Tenants"
@@ -863,9 +881,9 @@ title = "Tenant Profile"
breadcrumb_list = "Tenant List" breadcrumb_list = "Tenant List"
header_subtitle = "Header Subtitle" header_subtitle = "Header Subtitle"
loading = "Loading" loading = "Loading"
tab_admins = "Tab Admins"
tab_federation = "Tab Federation" tab_federation = "Tab Federation"
tab_organization = "Organization Manage" tab_organization = "Organization Manage"
tab_permissions = "Permissions"
tab_profile = "Profile" tab_profile = "Profile"
tab_schema = "Tab Schema" tab_schema = "Tab Schema"
title = "Details" title = "Details"

View File

@@ -182,6 +182,13 @@ remove_confirm = "관리자를 삭제하시겠습니까?"
remove_success = "권한이 회수되었습니다." remove_success = "권한이 회수되었습니다."
subtitle = "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다." subtitle = "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다."
[msg.admin.tenants.owners]
add_success = "소유자가 추가되었습니다."
empty = "등록된 소유자가 없습니다."
remove_confirm = "소유자를 삭제하시겠습니까?"
remove_success = "소유자 권한이 회수되었습니다."
subtitle = "이 테넌트의 최상위 권한을 가진 소유자(조직장) 목록입니다."
[msg.admin.tenants.create] [msg.admin.tenants.create]
subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다." subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다."
@@ -811,7 +818,7 @@ view_audit_logs = "감사 로그 보기"
rp_admin = "RP ADMIN" rp_admin = "RP ADMIN"
super_admin = "SUPER ADMIN" super_admin = "SUPER ADMIN"
tenant_admin = "TENANT ADMIN" tenant_admin = "TENANT ADMIN"
tenant_member = "TENANT MEMBER" user = "TENANT MEMBER"
[ui.admin.tenants] [ui.admin.tenants]
add = "테넌트 추가" add = "테넌트 추가"
@@ -831,6 +838,17 @@ table_email = "이메일"
table_name = "이름" table_name = "이름"
title = "테넌트 관리자" 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] [ui.admin.tenants.breadcrumb]
list = "List" list = "List"
section = "Tenants" section = "Tenants"
@@ -863,9 +881,9 @@ title = "Tenant Profile"
breadcrumb_list = "테넌트 목록" breadcrumb_list = "테넌트 목록"
header_subtitle = "테넌트 정보를 수정하거나 연동 설정을 관리합니다." header_subtitle = "테넌트 정보를 수정하거나 연동 설정을 관리합니다."
loading = "불러오는 중..." loading = "불러오는 중..."
tab_admins = "관리자 설정"
tab_federation = "외부 연동" tab_federation = "외부 연동"
tab_organization = "조직 관리" tab_organization = "조직 관리"
tab_permissions = "권한"
tab_profile = "프로필" tab_profile = "프로필"
tab_schema = "사용자 스키마" tab_schema = "사용자 스키마"
title = "상세" title = "상세"

View File

@@ -734,7 +734,7 @@ view_audit_logs = ""
rp_admin = "" rp_admin = ""
super_admin = "" super_admin = ""
tenant_admin = "" tenant_admin = ""
tenant_member = "" user = ""
[ui.admin.tenants] [ui.admin.tenants]
add = "" add = ""
@@ -1479,6 +1479,13 @@ remove_confirm = ""
remove_success = "" remove_success = ""
subtitle = "" subtitle = ""
[msg.admin.tenants.owners]
add_success = ""
empty = ""
remove_confirm = ""
remove_success = ""
subtitle = ""
[msg.admin.tenants] [msg.admin.tenants]
approve_confirm = "" approve_confirm = ""
approve_success = "" approve_success = ""
@@ -1524,6 +1531,17 @@ table_email = ""
table_name = "" table_name = ""
title = "" 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] [ui.admin.tenants.create.form]
parent = "" parent = ""
type = "" type = ""
@@ -1532,9 +1550,9 @@ type = ""
breadcrumb_list = "" breadcrumb_list = ""
header_subtitle = "" header_subtitle = ""
loading = "" loading = ""
tab_admins = ""
tab_federation = "" tab_federation = ""
tab_organization = "" tab_organization = ""
tab_permissions = ""
tab_profile = "" tab_profile = ""
tab_schema = "" tab_schema = ""
title = "" title = ""