1
0
forked from baron/baron-sso

Merge pull request 'feat/org-chart-rebac' (#327) from feat/org-chart-rebac into dev

Reviewed-on: baron/baron-sso#327
This commit is contained in:
2026-02-24 15:33:49 +09:00
81 changed files with 6067 additions and 1676 deletions

View File

@@ -96,6 +96,11 @@ jobs:
working-directory: backend working-directory: backend
args: --enable-only=gofmt,gofumpt args: --enable-only=gofmt,gofumpt
- name: Install Userfront dependencies
run: |
cd userfront
flutter pub get
- name: Format Flutter userfront - name: Format Flutter userfront
run: | run: |
cd userfront cd userfront

File diff suppressed because it is too large Load Diff

View File

@@ -11,6 +11,7 @@
"format": "biome format . --write", "format": "biome format . --write",
"preview": "vite preview", "preview": "vite preview",
"test": "playwright test", "test": "playwright test",
"test:unit": "vitest run",
"test:ui": "playwright test --ui" "test:ui": "playwright test --ui"
}, },
"dependencies": { "dependencies": {
@@ -39,16 +40,21 @@
"devDependencies": { "devDependencies": {
"@biomejs/biome": "^1.9.4", "@biomejs/biome": "^1.9.4",
"@playwright/test": "^1.58.0", "@playwright/test": "^1.58.0",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^24.10.1", "@types/node": "^24.10.1",
"@types/react": "^19.2.5", "@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1", "@vitejs/plugin-react": "^5.1.1",
"autoprefixer": "^10.4.23", "autoprefixer": "^10.4.23",
"jsdom": "^28.1.0",
"postcss": "^8.5.6", "postcss": "^8.5.6",
"tailwindcss": "^3.4.14", "tailwindcss": "^3.4.14",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
"typescript": "~5.9.3", "typescript": "~5.9.3",
"vite": "npm:rolldown-vite@7.2.5" "vite": "npm:rolldown-vite@7.2.5",
"vitest": "^4.0.18"
}, },
"overrides": { "overrides": {
"vite": "npm:rolldown-vite@7.2.5" "vite": "npm:rolldown-vite@7.2.5"

File diff suppressed because one or more lines are too long

View File

@@ -14,8 +14,7 @@ import TenantDetailPage from "../features/tenants/routes/TenantDetailPage";
import TenantListPage from "../features/tenants/routes/TenantListPage"; import TenantListPage from "../features/tenants/routes/TenantListPage";
import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage"; import { TenantProfilePage } from "../features/tenants/routes/TenantProfilePage";
import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage"; import { TenantSchemaPage } from "../features/tenants/routes/TenantSchemaPage";
import GlobalUserGroupListPage from "../features/user-groups/routes/GlobalUserGroupListPage"; import TenantUserGroupsTab from "../features/user-groups/routes/TenantUserGroupsTab";
import { TenantUserGroupsTab } from "../features/user-groups/routes/TenantUserGroupsTab";
import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage"; import { UserGroupDetailPage } from "../features/user-groups/routes/UserGroupDetailPage";
import UserCreatePage from "../features/users/UserCreatePage"; import UserCreatePage from "../features/users/UserCreatePage";
import UserDetailPage from "../features/users/UserDetailPage"; import UserDetailPage from "../features/users/UserDetailPage";
@@ -42,7 +41,6 @@ export const router = createBrowserRouter(
{ path: "users", element: <UserListPage /> }, { path: "users", element: <UserListPage /> },
{ path: "users/new", element: <UserCreatePage /> }, { path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> }, { path: "users/:id", element: <UserDetailPage /> },
{ path: "user-groups", element: <GlobalUserGroupListPage /> },
{ path: "tenants", element: <TenantListPage /> }, { path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> }, { path: "tenants/new", element: <TenantCreatePage /> },
{ {
@@ -51,12 +49,12 @@ export const router = createBrowserRouter(
children: [ children: [
{ index: true, element: <TenantProfilePage /> }, { index: true, element: <TenantProfilePage /> },
{ path: "admins", element: <TenantAdminsTab /> }, { path: "admins", element: <TenantAdminsTab /> },
{ path: "user-groups", element: <TenantUserGroupsTab /> }, { path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> }, { path: "schema", element: <TenantSchemaPage /> },
], ],
}, },
{ {
path: "tenants/:tenantId/user-groups/:id", path: "tenants/:tenantId/organization/:id",
element: <UserGroupDetailPage />, element: <UserGroupDetailPage />,
}, },
{ path: "api-keys", element: <ApiKeyListPage /> }, { path: "api-keys", element: <ApiKeyListPage /> },

View File

@@ -30,11 +30,6 @@ const navItems = [
to: "/tenants", to: "/tenants",
icon: Building2, icon: Building2,
}, },
{
label: "ui.admin.nav.user_groups",
to: "/user-groups",
icon: Users,
},
{ label: "ui.admin.nav.users", to: "/users", icon: Users }, { label: "ui.admin.nav.users", to: "/users", icon: Users },
{ label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key }, { label: "ui.admin.nav.api_keys", to: "/api-keys", icon: Key },
{ label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs }, { label: "ui.admin.nav.audit_logs", to: "/audit-logs", icon: NotebookTabs },

View File

@@ -1,9 +1,13 @@
import { ChevronDown, ChevronUp, Wrench } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { t } from "../../lib/i18n"; import { t } from "../../lib/i18n";
const RoleSwitcher: FC = () => { const RoleSwitcher: FC = () => {
const [currentRole, setCurrentRole] = useState<string>("super_admin"); const [currentRole, setCurrentRole] = useState<string>("super_admin");
const [isCollapsed, setIsCollapsed] = useState<boolean>(() => {
return window.localStorage.getItem("RoleSwitcher-Collapsed") === "true";
});
useEffect(() => { useEffect(() => {
// localStorage에서 역할 읽기 // localStorage에서 역할 읽기
@@ -16,6 +20,12 @@ const RoleSwitcher: FC = () => {
} }
}, []); }, []);
const toggleCollapse = () => {
const nextState = !isCollapsed;
setIsCollapsed(nextState);
window.localStorage.setItem("RoleSwitcher-Collapsed", String(nextState));
};
const switchRole = (role: string) => { const switchRole = (role: string) => {
// localStorage 설정 // localStorage 설정
window.localStorage.setItem("X-Mock-Role", role); window.localStorage.setItem("X-Mock-Role", role);
@@ -42,47 +52,95 @@ const RoleSwitcher: FC = () => {
zIndex: 9999, zIndex: 9999,
background: "#1A1F2C", background: "#1A1F2C",
color: "white", color: "white",
padding: "10px", padding: "8px 12px",
borderRadius: "8px", borderRadius: "8px",
boxShadow: "0 4px 12px rgba(0,0,0,0.3)", boxShadow: "0 4px 12px rgba(0,0,0,0.3)",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: "8px", gap: isCollapsed ? "0" : "8px",
fontSize: "12px", fontSize: "12px",
transition: "all 0.3s ease",
border: "1px solid #333",
}} }}
> >
<div <button
type="button"
style={{ style={{
display: "flex",
alignItems: "center",
justifyContent: "space-between",
gap: "12px",
cursor: "pointer",
fontWeight: "bold", fontWeight: "bold",
borderBottom: "1px solid #444", paddingBottom: isCollapsed ? "0" : "4px",
paddingBottom: "4px", borderBottom: isCollapsed ? "none" : "1px solid #444",
marginBottom: "4px", background: "transparent",
border: "none",
width: "100%",
color: "inherit",
textAlign: "inherit",
}} }}
onClick={toggleCollapse}
> >
{t("ui.admin.dev_role_switcher", "🛠 DEV Role Switcher")} <div style={{ display: "flex", alignItems: "center", gap: "6px" }}>
</div> <Wrench size={14} className="text-blue-400" />
{( {!isCollapsed && (
["super_admin", "tenant_admin", "rp_admin", "tenant_member"] as const <span>{t("ui.admin.dev_role_switcher", "DEV Role Switcher")}</span>
).map((role) => ( )}
<button {isCollapsed && (
key={role} <span style={{ fontSize: "10px", color: "#888" }}>
type="button" {currentRole.toUpperCase()}
onClick={() => switchRole(role)} </span>
)}
</div>
{isCollapsed ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{!isCollapsed && (
<div
style={{ style={{
background: currentRole === role ? "#3b82f6" : "#333", display: "flex",
color: "white", flexDirection: "column",
border: "none", gap: "6px",
padding: "4px 8px", marginTop: "4px",
borderRadius: "4px",
cursor: "pointer",
textAlign: "left",
transition: "background 0.2s",
}} }}
> >
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}{" "} {(
{currentRole === role ? "✅" : ""} [
</button> "super_admin",
))} "tenant_admin",
"rp_admin",
"tenant_member",
] as const
).map((role) => (
<button
key={role}
type="button"
onClick={() => switchRole(role)}
style={{
background: currentRole === role ? "#3b82f6" : "#333",
color: "white",
border: "none",
padding: "4px 8px",
borderRadius: "4px",
cursor: "pointer",
textAlign: "left",
transition: "background 0.2s",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<span>
{roleLabels[role] ?? role.toUpperCase().replace("_", " ")}
</span>
{currentRole === role && (
<span style={{ marginLeft: "8px" }}></span>
)}
</button>
))}
</div>
)}
</div> </div>
); );
}; };

View File

@@ -0,0 +1,26 @@
import { render, screen } from "@testing-library/react";
import { describe, expect, it } from "vitest";
import { Badge } from "./badge";
describe("Badge Component", () => {
it("renders correctly with children", () => {
render(<Badge>Active</Badge>);
expect(screen.getByText("Active")).toBeInTheDocument();
});
it("applies variant classes correctly", () => {
const { rerender } = render(<Badge variant="secondary">Secondary</Badge>);
let badge = screen.getByText("Secondary");
expect(badge).toHaveClass("bg-secondary");
rerender(<Badge variant="outline">Default</Badge>);
badge = screen.getByText("Default");
expect(badge).toHaveClass("text-foreground");
});
it("applies custom className", () => {
render(<Badge className="custom-class">Custom</Badge>);
const badge = screen.getByText("Custom");
expect(badge).toHaveClass("custom-class");
});
});

View File

@@ -0,0 +1,38 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { Button } from "./button";
describe("Button Component", () => {
it("renders correctly with children", () => {
render(<Button>Click me</Button>);
expect(
screen.getByRole("button", { name: /click me/i }),
).toBeInTheDocument();
});
it("applies variant classes correctly", () => {
const { rerender } = render(<Button variant="destructive">Delete</Button>);
const button = screen.getByRole("button", { name: /delete/i });
expect(button).toHaveClass("bg-destructive");
rerender(<Button variant="outline">Cancel</Button>);
const outlineButton = screen.getByRole("button", { name: /cancel/i });
expect(outlineButton).toHaveClass("border-input");
});
it("calls onClick when clicked", async () => {
const onClick = vi.fn();
const user = userEvent.setup();
render(<Button onClick={onClick}>Click me</Button>);
await user.click(screen.getByRole("button", { name: /click me/i }));
expect(onClick).toHaveBeenCalledTimes(1);
});
it("is disabled when the disabled prop is passed", () => {
render(<Button disabled>Disabled Button</Button>);
const button = screen.getByRole("button", { name: /disabled button/i });
expect(button).toBeDisabled();
});
});

View File

@@ -1,5 +1,5 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation } from "@tanstack/react-query";
import { CheckCircle2, Search, ShieldAlert, XCircle } from "lucide-react"; import { CheckCircle2, ShieldAlert, XCircle } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -106,7 +106,7 @@ function PermissionChecker() {
</Button> </Button>
</div> </div>
{checkMutation.isSuccess && ( {checkMutation.isSuccess && result && (
<div <div
className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${ className={`p-6 rounded-xl border-2 flex flex-col items-center justify-center gap-3 animate-in zoom-in duration-300 ${
result.allowed result.allowed

View File

@@ -1,7 +1,17 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Plus, Search, ShieldCheck, Trash2, UserPlus } from "lucide-react"; import type { AxiosError } from "axios";
import {
Plus,
Search,
ShieldCheck,
Trash2,
UserPlus,
Users,
} from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Card, Card,
@@ -10,6 +20,14 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "../../../components/ui/card"; } from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { import {
Table, Table,
@@ -25,40 +43,60 @@ import {
fetchUsers, fetchUsers,
removeTenantAdmin, removeTenantAdmin,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantAdminsTab() { export function TenantAdminsTab() {
const { tenantId } = useParams<{ tenantId: string }>(); const { tenantId } = useParams<{ tenantId: string }>();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [searchTerm, setSearchTerm] = useState(""); const [searchTerm, setSearchTerm] = useState("");
const [isDialogOpen, setIsAddDialogOpen] = useState(false);
if (!tenantId) return null; if (!tenantId) return null;
// 현재 관리자 목록 // 현재 관리자 목록 조회
const adminsQuery = useQuery({ const adminsQuery = useQuery({
queryKey: ["tenant-admins", tenantId], queryKey: ["tenant-admins", tenantId],
queryFn: () => fetchTenantAdmins(tenantId), queryFn: () => fetchTenantAdmins(tenantId),
enabled: !!tenantId, enabled: !!tenantId,
}); });
// 전체 사용자 목록 (관리자 추가용) // 사용자 검색 조회 (2자 이상 입력 시)
const usersQuery = useQuery({ const usersQuery = useQuery({
queryKey: ["users", { limit: 100, search: searchTerm }], queryKey: ["admin-users-search", searchTerm],
queryFn: () => fetchUsers(100, 0, searchTerm), queryFn: () => fetchUsers(20, 0, searchTerm),
enabled: searchTerm.length > 1, enabled: isDialogOpen && searchTerm.length >= 2,
}); });
const addMutation = useMutation({ const addMutation = useMutation({
mutationFn: (userId: string) => addTenantAdmin(tenantId, userId), mutationFn: (userId: string) => addTenantAdmin(tenantId, userId),
onSuccess: () => { onSuccess: () => {
adminsQuery.refetch(); queryClient.invalidateQueries({ queryKey: ["tenant-admins", tenantId] });
toast.success(
t("msg.admin.tenants.admins.add_success", "관리자가 추가되었습니다."),
);
setSearchTerm(""); setSearchTerm("");
}, },
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("msg.common.error", "오류가 발생했습니다."),
);
},
}); });
const removeMutation = useMutation({ const removeMutation = useMutation({
mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId), mutationFn: (userId: string) => removeTenantAdmin(tenantId, userId),
onSuccess: () => { onSuccess: () => {
adminsQuery.refetch(); 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", "오류가 발생했습니다."),
);
}, },
}); });
@@ -67,144 +105,240 @@ function TenantAdminsTab() {
}; };
const handleRemoveAdmin = (userId: string, userName: string) => { const handleRemoveAdmin = (userId: string, userName: string) => {
if (window.confirm(`${userName} 사용자의 관리자 권한을 회수할까요?`)) { if (
window.confirm(
t(
"msg.admin.tenants.admins.remove_confirm",
"관리자를 삭제하시겠습니까?",
{ name: userName },
),
)
) {
removeMutation.mutate(userId); removeMutation.mutate(userId);
} }
}; };
const currentAdmins = adminsQuery.data || [];
const searchResults = usersQuery.data?.items || [];
return ( return (
<div className="grid gap-6 lg:grid-cols-2 mt-6"> <div className="space-y-6 mt-6">
{/* 현재 테넌트 관리자 */} <Card className="border-none shadow-sm bg-[var(--color-panel)]">
<Card className="bg-[var(--color-panel)]"> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-7">
<CardHeader> <div className="space-y-1">
<CardTitle className="flex items-center gap-2"> <CardTitle className="text-2xl font-bold flex items-center gap-2">
<ShieldCheck size={18} className="text-primary" /> <ShieldCheck className="h-6 w-6 text-primary" />
{t("ui.admin.tenants.admins.title", "테넌트 관리자")}
</CardTitle>
<CardDescription>
.
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{adminsQuery.data?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
.
</TableCell>
</TableRow>
)}
{adminsQuery.data?.map((admin) => (
<TableRow key={admin.id}>
<TableCell className="font-medium">
{admin.name || "Unknown"}
</TableCell>
<TableCell className="text-xs">{admin.email}</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={() => handleRemoveAdmin(admin.id, admin.name)}
disabled={removeMutation.isPending}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</CardContent>
</Card>
{/* 사용자 검색 및 추가 */}
<Card className="bg-[var(--color-panel)]">
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="flex items-center gap-2">
<UserPlus size={18} className="text-primary" />
</CardTitle> </CardTitle>
</div> <CardDescription className="text-muted-foreground">
<CardDescription> {t(
( ). "msg.admin.tenants.admins.subtitle",
</CardDescription> "이 테넌트의 자원을 관리할 수 있는 사용자 목록입니다.",
</CardHeader> )}
<CardContent className="space-y-4"> </CardDescription>
<div className="relative">
<Search className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
placeholder="사용자 검색 (최소 2자)..."
className="pl-10"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
/>
</div> </div>
<Table> <Dialog
<TableHeader> open={isDialogOpen}
<TableRow> onOpenChange={(open) => {
<TableHead></TableHead> setIsAddDialogOpen(open);
<TableHead className="text-right"></TableHead> if (!open) setSearchTerm("");
</TableRow> }}
</TableHeader> >
<TableBody> <DialogTrigger asChild>
{searchTerm.length < 2 && ( <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> <TableRow>
<TableCell <TableHead className="w-[250px] font-bold">
colSpan={2} {t("ui.admin.tenants.admins.table_name", "이름")}
className="text-center py-8 text-muted-foreground" </TableHead>
> <TableHead className="font-bold">
. {t("ui.admin.tenants.admins.table_email", "이메일")}
</TableCell> </TableHead>
<TableHead className="text-right font-bold w-[100px]">
{t("ui.admin.tenants.admins.table_actions", "액션")}
</TableHead>
</TableRow> </TableRow>
)} </TableHeader>
{searchTerm.length >= 2 && <TableBody>
usersQuery.data?.items.length === 0 && ( {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> <TableRow>
<TableCell <TableCell
colSpan={2} colSpan={3}
className="text-center py-8 text-muted-foreground" className="h-32 text-center text-muted-foreground"
> >
. <div className="flex flex-col items-center gap-2">
</TableCell> <Users className="h-8 w-8 opacity-20" />
</TableRow> <p>
)} {t(
{usersQuery.data?.items "msg.admin.tenants.admins.empty",
.filter((u) => !adminsQuery.data?.some((a) => a.id === u.id)) "등록된 관리자가 없습니다.",
.map((user) => ( )}
<TableRow key={user.id}> </p>
<TableCell>
<div className="font-medium">{user.name}</div>
<div className="text-[10px] text-muted-foreground">
{user.email}
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleAddAdmin(user.id)}
disabled={addMutation.isPending}
>
<Plus size={14} />
</Button>
</TableCell>
</TableRow> </TableRow>
))} ) : (
</TableBody> currentAdmins.map((admin) => (
</Table> <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> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -1,4 +1,4 @@
import { useMutation } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { Building2, Sparkles } from "lucide-react"; import { Building2, Sparkles } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@@ -15,22 +15,31 @@ import {
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label"; import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea"; import { Textarea } from "../../../components/ui/textarea";
import { createTenant } from "../../../lib/adminApi"; import { createTenant, fetchTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
function TenantCreatePage() { function TenantCreatePage() {
const navigate = useNavigate(); const navigate = useNavigate();
const [name, setName] = useState(""); const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState(""); const [slug, setSlug] = useState("");
const [parentId, setParentId] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [status, setStatus] = useState("active"); const [status, setStatus] = useState("active");
const [domains, setDomains] = useState(""); const [domains, setDomains] = useState("");
const parentQuery = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
});
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => mutationFn: () =>
createTenant({ createTenant({
name, name,
type,
slug: slug || undefined, slug: slug || undefined,
parentId: parentId || undefined,
description: description || undefined, description: description || undefined,
status, status,
domains: domains domains: domains
@@ -92,14 +101,67 @@ function TenantCreatePage() {
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-semibold"> <Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.name", "Tenant name")}{" "} {t("ui.admin.tenants.create.form.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span> <span className="text-destructive">*</span>
</Label> </Label>
<Input value={name} onChange={(e) => setName(e.target.value)} /> <Input value={name} onChange={(e) => setName(e.target.value)} />
</div> </div>
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.type", "테넌트 유형")}
</Label>
<select
id="type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.parent", "상위 테넌트 (선택)")}
</Label>
<select
id="parentId"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={parentId}
onChange={(e) => setParentId(e.target.value)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{parentQuery.data?.items?.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.slug})
</option>
))}
</select>
</div>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-semibold"> <Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.slug", "Slug")} {t("ui.admin.tenants.create.form.slug", "슬러그 (Slug)")}
</Label> </Label>
<Input <Input
value={slug} value={slug}
@@ -112,7 +174,7 @@ function TenantCreatePage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-semibold"> <Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.description", "Description")} {t("ui.admin.tenants.create.form.description", "설명")}
</Label> </Label>
<Textarea <Textarea
rows={3} rows={3}
@@ -124,7 +186,7 @@ function TenantCreatePage() {
<Label className="text-sm font-semibold"> <Label className="text-sm font-semibold">
{t( {t(
"ui.admin.tenants.create.form.domains_label", "ui.admin.tenants.create.form.domains_label",
"Allowed Domains (Comma separated)", "허용된 도메인 (콤마로 구분)",
)} )}
</Label> </Label>
<Input <Input
@@ -138,13 +200,13 @@ function TenantCreatePage() {
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
{t( {t(
"msg.admin.tenants.create.form.domains_help", "msg.admin.tenants.create.form.domains_help",
"Users with these email domains will be automatically assigned to this tenant.", "이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)} )}
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-semibold"> <Label className="text-sm font-semibold">
{t("ui.admin.tenants.create.form.status", "Status")} {t("ui.admin.tenants.create.form.status", "상태")}
</Label> </Label>
<div className="flex gap-3"> <div className="flex gap-3">
<Button <Button
@@ -152,14 +214,14 @@ function TenantCreatePage() {
variant={status === "active" ? "default" : "outline"} variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")} onClick={() => setStatus("active")}
> >
{t("ui.common.status.active", "Active")} {t("ui.common.status.active", "활성")}
</Button> </Button>
<Button <Button
type="button" type="button"
variant={status === "inactive" ? "default" : "outline"} variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")} onClick={() => setStatus("inactive")}
> >
{t("ui.common.status.inactive", "Inactive")} {t("ui.common.status.inactive", "비활성")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import { ArrowLeft } from "lucide-react";
import { Link, Outlet, useLocation, useParams } from "react-router-dom"; import { Link, Outlet, useLocation, useParams } from "react-router-dom";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { fetchTenant } from "../../../lib/adminApi"; import { fetchTenant } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantDetailPage() { function TenantDetailPage() {
const params = useParams<{ tenantId: string }>(); const params = useParams<{ tenantId: string }>();
@@ -17,88 +18,102 @@ function TenantDetailPage() {
const isFederationTab = location.pathname.includes("/federation"); const isFederationTab = location.pathname.includes("/federation");
const isAdminTab = location.pathname.includes("/admins"); const isAdminTab = location.pathname.includes("/admins");
const isUserGroupsTab = location.pathname.includes("/user-groups"); const isOrganizationTab = location.pathname.includes("/organization");
return ( return (
<div className="space-y-8"> <div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4"> <header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]"> <div className="flex items-center gap-2 text-sm text-[var(--color-muted)]">
<Link to="/tenants" className="inline-flex items-center gap-2"> <Link
to="/tenants"
className="inline-flex items-center gap-2 hover:text-foreground transition-colors"
>
<ArrowLeft size={14} /> <ArrowLeft size={14} />
Tenants {t("ui.admin.tenants.detail.breadcrumb_list", "테넌트 목록")}
</Link> </Link>
<span>/</span> <span>/</span>
<span className="text-foreground">Detail</span> <span className="text-foreground">
{t("ui.admin.tenants.detail.title", "상세")}
</span>
</div> </div>
<h2 className="text-3xl font-semibold"> <h2 className="text-3xl font-semibold">
{tenantQuery.data?.name ?? "Loading Tenant..."} {tenantQuery.data?.name ??
t("ui.admin.tenants.detail.loading", "불러오는 중...")}
</h2> </h2>
<p className="text-sm text-[var(--color-muted)]"> <p className="text-sm text-[var(--color-muted)]">
Edit tenant information or manage federation settings. {t(
"ui.admin.tenants.detail.header_subtitle",
"테넌트 정보를 수정하거나 연동 설정을 관리합니다.",
)}
</p> </p>
</div> </div>
<Badge variant="muted">Admin only</Badge> <Badge variant="muted">
{t("ui.common.admin_only", "관리자 전용")}
</Badge>
</header> </header>
{/* Tabs */} {/* Tabs */}
<div className="flex border-b"> <div className="flex border-b border-border">
<Link <Link
to={`/tenants/${tenantId}`} to={`/tenants/${tenantId}`}
className={`px-4 py-2 text-sm font-medium ${ className={`px-6 py-3 text-sm font-medium transition-colors relative ${
!isFederationTab && !isFederationTab &&
!isAdminTab && !isAdminTab &&
!location.pathname.includes("/schema") !location.pathname.includes("/schema") &&
? "border-b-2 border-blue-500 text-blue-600" !isOrganizationTab
: "text-gray-500 hover:text-gray-700" ? "text-primary border-b-2 border-primary"
: "text-muted-foreground hover:text-foreground"
}`} }`}
> >
Profile {t("ui.admin.tenants.detail.tab_profile", "프로필")}
</Link> </Link>
<Link <Link
to={`/tenants/${tenantId}/federation`} to={`/tenants/${tenantId}/federation`}
className={`px-4 py-2 text-sm font-medium ${ className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isFederationTab isFederationTab
? "border-b-2 border-blue-500 text-blue-600" ? "text-primary border-b-2 border-primary"
: "text-gray-500 hover:text-gray-700" : "text-muted-foreground hover:text-foreground"
}`} }`}
> >
Federation {t("ui.admin.tenants.detail.tab_federation", "외부 연동")}
</Link> </Link>
<Link <Link
to={`/tenants/${tenantId}/admins`} to={`/tenants/${tenantId}/admins`}
className={`px-4 py-2 text-sm font-medium ${ className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isAdminTab isAdminTab
? "border-b-2 border-blue-500 text-blue-600" ? "text-primary border-b-2 border-primary"
: "text-gray-500 hover:text-gray-700" : "text-muted-foreground hover:text-foreground"
}`} }`}
> >
Admins {t("ui.admin.tenants.detail.tab_admins", "관리자 설정")}
</Link> </Link>
<Link <Link
to={`/tenants/${tenantId}/user-groups`} to={`/tenants/${tenantId}/organization`}
className={`px-4 py-2 text-sm font-medium ${ className={`px-6 py-3 text-sm font-medium transition-colors relative ${
isUserGroupsTab isOrganizationTab
? "border-b-2 border-blue-500 text-blue-600" ? "text-primary border-b-2 border-primary"
: "text-gray-500 hover:text-gray-700" : "text-muted-foreground hover:text-foreground"
}`} }`}
> >
User Groups {t("ui.admin.tenants.detail.tab_organization", "조직 관리")}
</Link> </Link>
<Link <Link
to={`/tenants/${tenantId}/schema`} to={`/tenants/${tenantId}/schema`}
className={`px-4 py-2 text-sm font-medium ${ className={`px-6 py-3 text-sm font-medium transition-colors relative ${
location.pathname.includes("/schema") location.pathname.includes("/schema")
? "border-b-2 border-blue-500 text-blue-600" ? "text-primary border-b-2 border-primary"
: "text-gray-500 hover:text-gray-700" : "text-muted-foreground hover:text-foreground"
}`} }`}
> >
Schema {t("ui.admin.tenants.detail.tab_schema", "사용자 스키마")}
</Link> </Link>
</div> </div>
{/* Outlet for nested routes */} {/* Outlet for nested routes */}
<Outlet /> <div className="animate-in fade-in duration-500">
<Outlet />
</div>
</div> </div>
); );
} }

View File

@@ -1,5 +1,12 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { import {
type UseMutationResult,
useMutation,
useQuery,
} from "@tanstack/react-query";
import type { AxiosError } from "axios";
import {
ChevronDown,
ChevronRight,
Plus, Plus,
RefreshCw, RefreshCw,
Shield, Shield,
@@ -8,8 +15,10 @@ import {
UserPlus, UserPlus,
Users, Users,
} from "lucide-react"; } from "lucide-react";
import type React from "react";
import { useState } from "react"; import { useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -30,6 +39,7 @@ import {
TableRow, TableRow,
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import { import {
type GroupSummary,
addGroupMember, addGroupMember,
createGroup, createGroup,
deleteGroup, deleteGroup,
@@ -38,12 +48,187 @@ import {
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
type UserGroupNode = GroupSummary & {
children: UserGroupNode[];
isExpanded?: boolean;
};
function buildGroupTree(
groups: GroupSummary[],
parentId: string | null = null,
): UserGroupNode[] {
const nodes: UserGroupNode[] = [];
const childrenOf = new Map<string, UserGroupNode[]>();
// First pass: Initialize all groups as nodes and populate childrenOf map
for (const group of groups) {
childrenOf.set(group.id, []);
}
// Second pass: Populate children
for (const group of groups) {
const node: UserGroupNode = {
...group,
children: childrenOf.get(group.id) ?? [],
};
if (group.parentId === parentId) {
nodes.push(node);
} else {
// Check if the parent exists before adding to children
// This handles cases where a parent might not be in the current 'groups' list (e.g., filtered data)
if (group.parentId && childrenOf.has(group.parentId)) {
childrenOf.get(group.parentId)?.push(node);
} else {
// If parentId exists but parent not found, it's a root level group for this tree view
nodes.push(node);
}
}
}
// Sort children for consistent rendering (optional, but good for UI)
nodes.sort((a, b) => a.name.localeCompare(b.name));
for (const node of nodes) {
node.children.sort((a, b) => a.name.localeCompare(b.name));
}
return nodes;
}
interface UserGroupTreeNodeProps {
node: UserGroupNode;
level: number;
onSelect: (groupId: string) => void;
selectedGroupId: string | null;
onDelete: (groupId: string) => void;
onAddSubGroup: (parentId: string) => void;
addMemberMutation: UseMutationResult<
void,
AxiosError<{ error?: string }>,
{ groupId: string; userId: string }
>;
removeMemberMutation: UseMutationResult<
void,
AxiosError<{ error?: string }>,
{ groupId: string; userId: string }
>;
}
const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
node,
level,
onSelect,
selectedGroupId,
onDelete,
onAddSubGroup,
addMemberMutation,
removeMemberMutation,
}) => {
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children.length > 0;
const handleToggleExpand = (e: React.MouseEvent) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
};
return (
<>
<TableRow
className={`cursor-pointer ${selectedGroupId === node.id ? "bg-primary/5" : ""}`}
onClick={() => onSelect(node.id)}
>
<TableCell style={{ paddingLeft: `${1 + level * 1.5}rem` }}>
<div className="flex items-center gap-2">
{hasChildren ? (
<Button
variant="ghost"
size="sm"
onClick={handleToggleExpand}
className="h-6 w-6 p-0"
>
{isExpanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</Button>
) : (
level > 0 && (
<span className="inline-block w-6 text-center">
<ChevronRight
size={16}
className="text-muted-foreground inline-block align-middle"
/>
</span>
)
)}
<Users size={14} className="text-muted-foreground" />
<span className="font-semibold">{node.name}</span>
<Badge variant="secondary" className="text-[10px] font-mono">
{node.unitType || "Team"}
</Badge>
</div>
</TableCell>
<TableCell>
<Badge variant="secondary">
{t("msg.admin.groups.members.count", "{{count}} 명", {
count: node.members?.length || 0,
})}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAddSubGroup(node.id);
}}
>
<Plus size={14} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onDelete(node.id);
}}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
{isExpanded &&
hasChildren &&
node.children.map((child) => (
<UserGroupTreeNode
key={child.id}
node={child}
level={level + 1}
onSelect={onSelect}
selectedGroupId={selectedGroupId}
onDelete={onDelete}
onAddSubGroup={onAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
/>
))}
</>
);
};
function TenantGroupsPage() { function TenantGroupsPage() {
const params = useParams<{ tenantId: string }>(); const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? ""; const tenantId = params.tenantId ?? "";
const [newGroupName, setNewGroupName] = useState(""); const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState(""); const [newGroupDesc, setNewGroupNameDesc] = useState("");
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
const [newGroupParentId, setNewGroupParentId] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null); const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
// 그룹 목록 조회 // 그룹 목록 조회
@@ -53,34 +238,95 @@ function TenantGroupsPage() {
enabled: tenantId.length > 0, enabled: tenantId.length > 0,
}); });
// 사용자 목록 조회 (멤버 추가용) // 그룹 생성
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => mutationFn: () =>
createGroup(tenantId, { name: newGroupName, description: newGroupDesc }), createGroup(tenantId, {
name: newGroupName,
description: newGroupDesc,
unitType: newGroupUnitType,
parentId: newGroupParentId || undefined,
}),
onSuccess: () => { onSuccess: () => {
toast.success(
t(
"msg.admin.groups.list.create_success",
"그룹이 성공적으로 생성되었습니다.",
),
);
groupsQuery.refetch(); groupsQuery.refetch();
setNewGroupName(""); setNewGroupName("");
setNewGroupNameDesc(""); setNewGroupNameDesc("");
setNewGroupUnitType("Team");
setNewGroupParentId(null);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.admin.groups.list.create_error", "그룹 생성 실패"), {
description: error.response?.data?.error || error.message,
});
}, },
}); });
// 그룹 삭제
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (id: string) => deleteGroup(id), mutationFn: (id: string) => deleteGroup(tenantId, id),
onSuccess: () => groupsQuery.refetch(), onSuccess: () => {
toast.success(
t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."),
);
groupsQuery.refetch();
setSelectedGroupId(null);
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.admin.groups.list.delete_error", "그룹 삭제 실패"), {
description: error.response?.data?.error || error.message,
});
},
}); });
// 멤버 추가
const addMemberMutation = useMutation({ const addMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
addGroupMember(groupId, userId), addGroupMember(tenantId, groupId, userId),
onSuccess: () => groupsQuery.refetch(), onSuccess: () => {
toast.success(
t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."),
);
groupsQuery.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
}); });
// 멤버 제거
const removeMemberMutation = useMutation({ const removeMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) => mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
removeGroupMember(groupId, userId), removeGroupMember(tenantId, groupId, userId),
onSuccess: () => groupsQuery.refetch(), onSuccess: () => {
toast.success(
t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."),
);
groupsQuery.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
}); });
const groupTree = groupsQuery.data
? buildGroupTree(groupsQuery.data, tenantId)
: [];
const handleAddSubGroup = (parentId: string) => {
setNewGroupParentId(parentId);
// Optionally scroll to the create form or highlight it
};
const handleAddMember = (groupId: string) => { const handleAddMember = (groupId: string) => {
const userId = window.prompt( const userId = window.prompt(
t( t(
@@ -105,6 +351,12 @@ function TenantGroupsPage() {
<Plus size={16} />{" "} <Plus size={16} />{" "}
{t("ui.admin.groups.create.title", "새 그룹 생성")} {t("ui.admin.groups.create.title", "새 그룹 생성")}
</CardTitle> </CardTitle>
<CardDescription>
{t(
"ui.admin.groups.create.description",
"새로운 사용자 그룹을 생성하고 계층 구조를 설정합니다.",
)}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
@@ -121,6 +373,38 @@ function TenantGroupsPage() {
)} )}
/> />
</div> </div>
<div className="space-y-1">
<Label htmlFor="unitType">
{t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
</Label>
<Input
id="unitType"
value={newGroupUnitType}
onChange={(e) => setNewGroupUnitType(e.target.value)}
placeholder={t(
"ui.admin.groups.form.unit_level_placeholder",
"예: 본부, 팀, 셀",
)}
/>
</div>
<div className="space-y-1">
<Label htmlFor="parentId">
{t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}
</Label>
<select
id="parentId"
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
value={newGroupParentId || ""}
onChange={(e) => setNewGroupParentId(e.target.value || null)}
>
<option value="">{t("ui.common.none", "없음")}</option>
{groupsQuery.data?.map((group) => (
<option key={group.id} value={group.id}>
{group.name}
</option>
))}
</select>
</div>
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="desc"> <Label htmlFor="desc">
{t("ui.admin.groups.form.desc_label", "설명")} {t("ui.admin.groups.form.desc_label", "설명")}
@@ -145,7 +429,7 @@ function TenantGroupsPage() {
</CardContent> </CardContent>
</Card> </Card>
{/* 그룹 목록 */} {/* 그룹 목록 (트리 뷰) */}
<Card className="bg-[var(--color-panel)] md:col-span-2"> <Card className="bg-[var(--color-panel)] md:col-span-2">
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<div> <div>
@@ -183,53 +467,49 @@ function TenantGroupsPage() {
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{groupsQuery.data?.map((group) => ( {groupsQuery.isLoading && (
<TableRow <TableRow>
key={group.id} <TableCell colSpan={3}>
className={`cursor-pointer ${selectedGroupId === group.id ? "bg-primary/5" : ""}`} {t("msg.admin.groups.list.loading", "로딩 중...")}
onClick={() => setSelectedGroupId(group.id)}
>
<TableCell>
<div className="font-semibold flex items-center gap-2">
<Users size={14} className="text-muted-foreground" />
{group.name}
</div>
<p className="text-[10px] text-muted-foreground">
{group.description}
</p>
</TableCell>
<TableCell>
<Badge variant="secondary">
{t("msg.admin.groups.members.count", "{{count}} 명", {
count: group.members?.length || 0,
})}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
handleAddMember(group.id);
}}
>
<UserPlus size={14} />
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
deleteMutation.mutate(group.id);
}}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)}
{!groupsQuery.isLoading && groupTree.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.list.empty",
"아직 등록된 그룹이 없습니다.",
)}
</TableCell>
</TableRow>
)}
{groupTree.map((node) => (
<UserGroupTreeNode
key={node.id}
node={node}
level={0}
onSelect={setSelectedGroupId}
selectedGroupId={selectedGroupId}
onDelete={(id) => {
if (
window.confirm(
t(
"msg.admin.groups.list.delete_confirm",
"그룹을 삭제하시겠습니까?",
),
)
) {
deleteMutation.mutate(id);
}
}}
onAddSubGroup={handleAddSubGroup}
addMemberMutation={addMemberMutation}
removeMemberMutation={removeMemberMutation}
/>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
@@ -247,8 +527,24 @@ function TenantGroupsPage() {
name: currentGroup.name, name: currentGroup.name,
})} })}
</CardTitle> </CardTitle>
<CardDescription>
{t(
"ui.admin.groups.detail.members_subtitle",
"그룹에 속한 멤버들을 확인하고 관리합니다.",
)}
</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="flex justify-end mb-4">
<Button
size="sm"
onClick={() => handleAddMember(currentGroup.id)}
disabled={addMemberMutation.isPending}
>
<UserPlus size={14} className="mr-1" />
{t("ui.common.add", "멤버 추가")}
</Button>
</div>
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
@@ -290,6 +586,7 @@ function TenantGroupsPage() {
userId: user.id, userId: user.id,
}) })
} }
disabled={removeMemberMutation.isPending}
> >
<UserMinus size={14} className="text-destructive" /> <UserMinus size={14} className="text-destructive" />
</Button> </Button>

View File

@@ -1,6 +1,7 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios"; import type { AxiosError } from "axios";
import { Pencil, Plus, RefreshCw, Trash2 } from "lucide-react"; import { CornerDownRight, Pencil, Plus, RefreshCw, Trash2 } from "lucide-react";
import type React from "react";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
@@ -19,14 +20,123 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import { deleteTenant, fetchTenants } from "../../../lib/adminApi"; import {
type TenantSummary,
deleteTenant,
fetchTenants,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; import { t } from "../../../lib/i18n";
function TenantListPage() { type TenantNode = TenantSummary & { children: TenantNode[] };
function buildTenantTree(tenants: TenantSummary[]): TenantNode[] {
const tenantMap = new Map<string, TenantNode>();
const rootTenants: TenantNode[] = [];
for (const tenant of tenants) {
tenantMap.set(tenant.id, { ...tenant, children: [] });
}
for (const tenant of tenants) {
const node = tenantMap.get(tenant.id);
if (!node) continue;
if (tenant.parentId) {
const parent = tenantMap.get(tenant.parentId);
if (parent) {
parent.children.push(node);
} else {
rootTenants.push(node); // Orphaned
}
} else {
rootTenants.push(node);
}
}
return rootTenants;
}
const TenantRow: React.FC<{
tenant: TenantNode;
level: number;
onDelete: (id: string, name: string) => void;
isDeleting: boolean;
}> = ({ tenant, level, onDelete, isDeleting }) => {
const navigate = useNavigate(); const navigate = useNavigate();
return (
<>
<TableRow key={tenant.id}>
<TableCell style={{ paddingLeft: `${1 + level * 1.5}rem` }}>
<div className="flex items-center gap-2">
{level > 0 && (
<CornerDownRight size={14} className="text-muted-foreground" />
)}
<span className="font-semibold">{tenant.name}</span>
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="text-[10px] font-mono">
{tenant.type || "PERSONAL"}
</Badge>
</TableCell>
<TableCell>{tenant.slug}</TableCell>
<TableCell>
<Badge
variant={
tenant.status === "active"
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
}
>
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
<TableCell>
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
<Pencil size={14} />
{t("ui.common.edit", "편집")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => onDelete(tenant.id, tenant.name)}
disabled={isDeleting}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
</TableCell>
</TableRow>
{tenant.children.map((child) => (
<TenantRow
key={child.id}
tenant={child}
level={level + 1}
onDelete={onDelete}
isDeleting={isDeleting}
/>
))}
</>
);
};
function TenantListPage() {
const query = useQuery({ const query = useQuery({
queryKey: ["tenants", { limit: 50, offset: 0 }], queryKey: ["tenants", { limit: 1000, offset: 0 }], // Fetch all to build tree
queryFn: () => fetchTenants(50, 0), queryFn: () => fetchTenants(1000, 0),
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
@@ -43,7 +153,7 @@ function TenantListPage() {
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
: null; : null;
const items = query.data?.items ?? []; const tenantTree = query.data?.items ? buildTenantTree(query.data.items) : [];
const handleDelete = (tenantId: string, tenantName: string) => { const handleDelete = (tenantId: string, tenantName: string) => {
if ( if (
@@ -128,6 +238,9 @@ function TenantListPage() {
<TableHead> <TableHead>
{t("ui.admin.tenants.table.name", "NAME")} {t("ui.admin.tenants.table.name", "NAME")}
</TableHead> </TableHead>
<TableHead>
{t("ui.admin.tenants.table.type", "TYPE")}
</TableHead>
<TableHead> <TableHead>
{t("ui.admin.tenants.table.slug", "SLUG")} {t("ui.admin.tenants.table.slug", "SLUG")}
</TableHead> </TableHead>
@@ -145,14 +258,17 @@ function TenantListPage() {
<TableBody> <TableBody>
{query.isLoading && ( {query.isLoading && (
<TableRow> <TableRow>
<TableCell colSpan={5}> <TableCell colSpan={6}>
{t("msg.common.loading", "로딩 중...")} {t("msg.common.loading", "로딩 중...")}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{!query.isLoading && items.length === 0 && ( {!query.isLoading && tenantTree.length === 0 && (
<TableRow> <TableRow>
<TableCell colSpan={5}> <TableCell
colSpan={6}
className="text-center py-8 text-muted-foreground"
>
{t( {t(
"msg.admin.tenants.empty", "msg.admin.tenants.empty",
"아직 등록된 테넌트가 없습니다.", "아직 등록된 테넌트가 없습니다.",
@@ -160,55 +276,14 @@ function TenantListPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{items.map((tenant) => ( {tenantTree.map((tenant) => (
<TableRow key={tenant.id}> <TenantRow
<TableCell className="font-semibold">{tenant.name}</TableCell> key={tenant.id}
<TableCell>{tenant.slug}</TableCell> tenant={tenant}
<TableCell> level={0}
<Badge onDelete={handleDelete}
variant={ isDeleting={deleteMutation.isPending}
tenant.status === "active" />
? "default"
: tenant.status === "pending"
? "secondary"
: "muted"
}
className={
tenant.status === "pending"
? "bg-yellow-100 text-yellow-800"
: ""
}
>
{t(`ui.common.status.${tenant.status}`, tenant.status)}
</Badge>
</TableCell>
<TableCell>
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => navigate(`/tenants/${tenant.id}`)}
>
<Pencil size={14} />
{t("ui.common.edit", "편집")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(tenant.id, tenant.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
</TableCell>
</TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>

View File

@@ -3,6 +3,7 @@ import type { AxiosError } from "axios";
import { Save, Trash2 } from "lucide-react"; import { Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Card, Card,
@@ -20,6 +21,7 @@ import {
fetchTenant, fetchTenant,
updateTenant, updateTenant,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
export function TenantProfilePage() { export function TenantProfilePage() {
const { tenantId } = useParams<{ tenantId: string }>(); const { tenantId } = useParams<{ tenantId: string }>();
@@ -27,7 +29,9 @@ export function TenantProfilePage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
if (!tenantId) { if (!tenantId) {
return <div>Tenant ID is missing</div>; return (
<div>{t("msg.admin.tenants.missing_id", "테넌트 ID가 없습니다.")}</div>
);
} }
const tenantQuery = useQuery({ const tenantQuery = useQuery({
@@ -36,6 +40,7 @@ export function TenantProfilePage() {
}); });
const [name, setName] = useState(""); const [name, setName] = useState("");
const [type, setType] = useState("COMPANY");
const [slug, setSlug] = useState(""); const [slug, setSlug] = useState("");
const [description, setDescription] = useState(""); const [description, setDescription] = useState("");
const [status, setStatus] = useState("active"); const [status, setStatus] = useState("active");
@@ -44,6 +49,7 @@ export function TenantProfilePage() {
useEffect(() => { useEffect(() => {
if (tenantQuery.data) { if (tenantQuery.data) {
setName(tenantQuery.data.name); setName(tenantQuery.data.name);
setType(tenantQuery.data.type || "COMPANY");
setSlug(tenantQuery.data.slug); setSlug(tenantQuery.data.slug);
setDescription(tenantQuery.data.description ?? ""); setDescription(tenantQuery.data.description ?? "");
setStatus(tenantQuery.data.status); setStatus(tenantQuery.data.status);
@@ -55,6 +61,7 @@ export function TenantProfilePage() {
mutationFn: () => mutationFn: () =>
updateTenant(tenantId, { updateTenant(tenantId, {
name, name,
type,
slug, slug,
description: description || undefined, description: description || undefined,
status, status,
@@ -66,7 +73,13 @@ export function TenantProfilePage() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] }); queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Tenant updated successfully"); toast.success(t("msg.info.saved_success", "저장되었습니다."));
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("err.common.unknown", "오류가 발생했습니다."),
);
}, },
}); });
@@ -75,7 +88,15 @@ export function TenantProfilePage() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenants"] }); queryClient.invalidateQueries({ queryKey: ["tenants"] });
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert("Tenant approved successfully"); toast.success(
t("msg.admin.tenants.approve_success", "테넌트가 승인되었습니다."),
);
},
onError: (err: AxiosError<{ error?: string }>) => {
toast.error(
err.response?.data?.error ||
t("err.common.unknown", "오류가 발생했습니다."),
);
}, },
}); });
@@ -83,6 +104,9 @@ export function TenantProfilePage() {
mutationFn: () => deleteTenant(tenantId), mutationFn: () => deleteTenant(tenantId),
onSuccess: () => { onSuccess: () => {
navigate("/tenants"); navigate("/tenants");
toast.success(
t("msg.admin.tenants.delete_success", "테넌트가 삭제되었습니다."),
);
}, },
}); });
@@ -92,13 +116,23 @@ export function TenantProfilePage() {
?.response?.data?.error; ?.response?.data?.error;
const handleDelete = () => { const handleDelete = () => {
if (window.confirm("Are you sure you want to delete this tenant?")) { if (
window.confirm(
t("msg.admin.tenants.delete_confirm", "삭제하시겠습니까?", {
name: tenantQuery.data?.name ?? "",
}),
)
) {
deleteMutation.mutate(); deleteMutation.mutate();
} }
}; };
const handleApprove = () => { const handleApprove = () => {
if (window.confirm("Approve this tenant?")) { if (
window.confirm(
t("msg.admin.tenants.approve_confirm", "이 테넌트를 승인하시겠습니까?"),
)
) {
approveMutation.mutate(); approveMutation.mutate();
} }
}; };
@@ -107,9 +141,14 @@ export function TenantProfilePage() {
<> <>
<Card className="bg-[var(--color-panel)] mt-6"> <Card className="bg-[var(--color-panel)] mt-6">
<CardHeader> <CardHeader>
<CardTitle>Tenant profile</CardTitle> <CardTitle>
{t("ui.admin.tenants.profile.title", "테넌트 프로필")}
</CardTitle>
<CardDescription> <CardDescription>
Changes to slug and status are applied immediately. {t(
"ui.admin.tenants.profile.subtitle",
"슬러그 및 상태 변경은 즉시 적용됩니다.",
)}
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
@@ -120,16 +159,54 @@ export function TenantProfilePage() {
)} )}
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-semibold"> <Label className="text-sm font-semibold">
Tenant name <span className="text-destructive">*</span> {t("ui.admin.tenants.profile.name", "테넌트 이름")}{" "}
<span className="text-destructive">*</span>
</Label> </Label>
<Input value={name} onChange={(e) => setName(e.target.value)} /> <Input value={name} onChange={(e) => setName(e.target.value)} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-semibold">Slug</Label> <Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.type", "테넌트 유형")}
</Label>
<select
id="type"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={type}
onChange={(e) => setType(e.target.value)}
>
<option value="COMPANY">
{t("domain.tenant_type.company", "COMPANY (일반 기업)")}
</option>
<option value="COMPANY_GROUP">
{t(
"domain.tenant_type.company_group",
"COMPANY_GROUP (그룹사/지주사)",
)}
</option>
<option value="USER_GROUP">
{t(
"domain.tenant_type.user_group",
"USER_GROUP (내부 부서/팀)",
)}
</option>
<option value="PERSONAL">
{t(
"domain.tenant_type.personal",
"PERSONAL (개인 워크스페이스)",
)}
</option>
</select>
</div>
<div className="space-y-2">
<Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.slug", "슬러그 (Slug)")}
</Label>
<Input value={slug} onChange={(e) => setSlug(e.target.value)} /> <Input value={slug} onChange={(e) => setSlug(e.target.value)} />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-semibold">Description</Label> <Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.description", "설명")}
</Label>
<Textarea <Textarea
rows={3} rows={3}
value={description} value={description}
@@ -138,7 +215,10 @@ export function TenantProfilePage() {
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-semibold"> <Label className="text-sm font-semibold">
Allowed Domains (Comma separated) {t(
"ui.admin.tenants.profile.allowed_domains",
"허용된 도메인 (콤마로 구분)",
)}
</Label> </Label>
<Input <Input
value={domains} value={domains}
@@ -146,26 +226,30 @@ export function TenantProfilePage() {
placeholder="example.com, example.kr" placeholder="example.com, example.kr"
/> />
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Users with these email domains will be automatically assigned to {t(
this tenant. "ui.admin.tenants.profile.allowed_domains_help",
"이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다.",
)}
</p> </p>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label className="text-sm font-semibold">Status</Label> <Label className="text-sm font-semibold">
{t("ui.admin.tenants.profile.status", "상태")}
</Label>
<div className="flex gap-3"> <div className="flex gap-3">
<Button <Button
type="button" type="button"
variant={status === "active" ? "default" : "outline"} variant={status === "active" ? "default" : "outline"}
onClick={() => setStatus("active")} onClick={() => setStatus("active")}
> >
Active {t("ui.common.status.active", "활성")}
</Button> </Button>
<Button <Button
type="button" type="button"
variant={status === "inactive" ? "default" : "outline"} variant={status === "inactive" ? "default" : "outline"}
onClick={() => setStatus("inactive")} onClick={() => setStatus("inactive")}
> >
Inactive {t("ui.common.status.inactive", "비활성")}
</Button> </Button>
</div> </div>
</div> </div>
@@ -184,7 +268,7 @@ export function TenantProfilePage() {
disabled={deleteMutation.isPending} disabled={deleteMutation.isPending}
> >
<Trash2 size={16} /> <Trash2 size={16} />
Delete {t("ui.common.delete", "삭제")}
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{status === "pending" && ( {status === "pending" && (
@@ -194,11 +278,11 @@ export function TenantProfilePage() {
onClick={handleApprove} onClick={handleApprove}
disabled={approveMutation.isPending} disabled={approveMutation.isPending}
> >
Approve Tenant {t("ui.admin.tenants.profile.approve_button", "테넌트 승인")}
</Button> </Button>
)} )}
<Button variant="outline" onClick={() => navigate("/tenants")}> <Button variant="outline" onClick={() => navigate("/tenants")}>
Cancel {t("ui.common.cancel", "취소")}
</Button> </Button>
<Button <Button
onClick={() => updateMutation.mutate()} onClick={() => updateMutation.mutate()}
@@ -209,7 +293,7 @@ export function TenantProfilePage() {
} }
> >
<Save size={16} /> <Save size={16} />
Save {t("ui.common.save", "저장")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@ import type { AxiosError } from "axios";
import { Plus, Save, Trash2 } from "lucide-react"; import { Plus, Save, Trash2 } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Card, Card,
@@ -39,7 +40,9 @@ export function TenantSchemaPage() {
if (!tenantId) { if (!tenantId) {
return ( return (
<div>{t("msg.admin.tenants.schema.missing_id", "Tenant ID missing")}</div> <div className="p-8 text-center text-muted-foreground">
{t("msg.admin.tenants.schema.missing_id", "테넌트 ID가 없습니다.")}
</div>
); );
} }
@@ -78,17 +81,17 @@ export function TenantSchemaPage() {
}), }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] }); queryClient.invalidateQueries({ queryKey: ["tenant", tenantId] });
alert( toast.success(
t( t(
"msg.admin.tenants.schema.update_success", "msg.admin.tenants.schema.update_success",
"Schema updated successfully", "스키마가 저장되었습니다.",
), ),
); );
}, },
onError: (err: AxiosError<{ error?: string }>) => { onError: (err: AxiosError<{ error?: string }>) => {
alert( toast.error(
err.response?.data?.error || err.response?.data?.error ||
t("msg.admin.tenants.schema.update_error", "Failed to update schema"), t("msg.admin.tenants.schema.update_error", "저장에 실패했습니다."),
); );
}, },
}); });
@@ -118,56 +121,57 @@ export function TenantSchemaPage() {
return ( return (
<div className="space-y-6 mt-6"> <div className="space-y-6 mt-6">
<Card> <Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader> <CardHeader>
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div className="space-y-1">
<CardTitle> <CardTitle className="text-2xl font-bold">
{t("ui.admin.tenants.schema.title", "User Schema Extension")} {t("ui.admin.tenants.schema.title", "사용자 스키마 확장")}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t( {t(
"msg.admin.tenants.schema.subtitle", "msg.admin.tenants.schema.subtitle",
"Define custom attributes for users in this tenant.", "이 테넌트 사용자를 위한 커스텀 속성을 정의합니다.",
)} )}
</CardDescription> </CardDescription>
</div> </div>
<Button onClick={addField} size="sm"> <Button onClick={addField} size="sm">
<Plus size={16} className="mr-2" /> <Plus size={16} className="mr-2" />
{t("ui.admin.tenants.schema.add_field", "Add Field")} {t("ui.admin.tenants.schema.add_field", "필드 추가")}
</Button> </Button>
</div> </div>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">
{fields.length === 0 && ( {fields.length === 0 && (
<div className="py-8 text-center text-muted-foreground border border-dashed rounded-md"> <div className="py-12 text-center text-muted-foreground border border-dashed rounded-lg bg-muted/10">
{t( {t(
"msg.admin.tenants.schema.empty", "msg.admin.tenants.schema.empty",
'No custom fields defined. Click "Add Field" to begin.', '정의된 커스텀 필드가 없습니다. "필드 추가"를 눌러 시작하세요.',
)} )}
</div> </div>
)} )}
{fields.map((field, index) => ( {fields.map((field, index) => (
<div <div
key={field.id} key={field.id}
className="flex items-end gap-4 p-4 border rounded-md bg-muted/30" className="flex items-end gap-4 p-5 border border-border rounded-xl bg-muted/20 hover:bg-muted/30 transition-colors"
> >
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<Label> <Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.key", "Field Key (ID)")} {t("ui.admin.tenants.schema.field.key", "필드 키 (ID)")}
</Label> </Label>
<Input <Input
value={field.key} value={field.key}
onChange={(e) => updateField(index, { key: e.target.value })} onChange={(e) => updateField(index, { key: e.target.value })}
placeholder={t( placeholder={t(
"ui.admin.tenants.schema.field.key_placeholder", "ui.admin.tenants.schema.field.key_placeholder",
"e.g. employee_id", "예: employee_id",
)} )}
className="h-10"
/> />
</div> </div>
<div className="flex-1 space-y-2"> <div className="flex-1 space-y-2">
<Label> <Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.label", "Display Label")} {t("ui.admin.tenants.schema.field.label", "표시 라벨")}
</Label> </Label>
<Input <Input
value={field.label} value={field.label}
@@ -176,14 +180,17 @@ export function TenantSchemaPage() {
} }
placeholder={t( placeholder={t(
"ui.admin.tenants.schema.field.label_placeholder", "ui.admin.tenants.schema.field.label_placeholder",
"e.g. 사번", "예: 사번",
)} )}
className="h-10"
/> />
</div> </div>
<div className="w-32 space-y-2"> <div className="w-40 space-y-2">
<Label>{t("ui.admin.tenants.schema.field.type", "Type")}</Label> <Label className="text-xs font-bold uppercase tracking-wider text-muted-foreground">
{t("ui.admin.tenants.schema.field.type", "유형")}
</Label>
<select <select
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm" className="flex h-10 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus:ring-1 focus:ring-primary"
value={field.type} value={field.type}
onChange={(e) => { onChange={(e) => {
const nextType = e.target.value; const nextType = e.target.value;
@@ -197,36 +204,46 @@ export function TenantSchemaPage() {
}} }}
> >
<option value="text"> <option value="text">
{t("ui.admin.tenants.schema.field.type_text", "Text")} {t(
"ui.admin.tenants.schema.field.type_text",
"텍스트 (Text)",
)}
</option> </option>
<option value="number"> <option value="number">
{t("ui.admin.tenants.schema.field.type_number", "Number")} {t(
"ui.admin.tenants.schema.field.type_number",
"숫자 (Number)",
)}
</option> </option>
<option value="boolean"> <option value="boolean">
{t("ui.admin.tenants.schema.field.type_boolean", "Boolean")} {t(
"ui.admin.tenants.schema.field.type_boolean",
"불리언 (Boolean)",
)}
</option> </option>
</select> </select>
</div> </div>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-destructive" className="text-destructive hover:bg-destructive/10 h-10 w-10"
onClick={() => removeField(index)} onClick={() => removeField(index)}
> >
<Trash2 size={16} /> <Trash2 size={18} />
</Button> </Button>
</div> </div>
))} ))}
</CardContent> </CardContent>
</Card> </Card>
<div className="flex justify-end"> <div className="flex justify-end pt-2">
<Button <Button
onClick={() => updateMutation.mutate(fields)} onClick={() => updateMutation.mutate(fields)}
disabled={updateMutation.isPending || tenantQuery.isLoading} disabled={updateMutation.isPending || tenantQuery.isLoading}
className="px-8 h-11"
> >
<Save size={16} className="mr-2" /> <Save size={18} className="mr-2" />
{t("ui.admin.tenants.schema.save", "Save Schema Changes")} {t("ui.admin.tenants.schema.save", "변경사항 저장")}
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -20,9 +20,9 @@ import {
TableRow, TableRow,
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import { import {
type TenantSummary,
fetchGroups, fetchGroups,
fetchTenants, fetchTenants,
type TenantSummary,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
export default function GlobalUserGroupListPage() { export default function GlobalUserGroupListPage() {

View File

@@ -1,7 +1,21 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Plus, Trash2, Users } from "lucide-react"; import type { AxiosError } from "axios";
import {
ChevronDown,
ChevronRight,
Plus,
RefreshCw,
Shield,
Trash2,
UserMinus,
UserPlus,
Users,
} from "lucide-react";
import type React from "react";
import { useState } from "react"; import { useState } from "react";
import { Link, useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
Card, Card,
@@ -10,15 +24,6 @@ import {
CardHeader, CardHeader,
CardTitle, CardTitle,
} from "../../../components/ui/card"; } from "../../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../../../components/ui/dialog";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label"; import { Label } from "../../../components/ui/label";
import { import {
@@ -29,209 +34,501 @@ import {
TableHeader, TableHeader,
TableRow, TableRow,
} from "../../../components/ui/table"; } from "../../../components/ui/table";
import { createGroup, deleteGroup, fetchGroups } from "../../../lib/adminApi"; import {
type GroupSummary,
addGroupMember,
createGroup,
deleteGroup,
fetchGroups,
removeGroupMember,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function getErrorMessage(error: unknown, fallback: string): string { type UserGroupNode = GroupSummary & { children: UserGroupNode[] };
if (error instanceof Error && error.message) {
return error.message; function buildGroupTree(groups: GroupSummary[]): UserGroupNode[] {
const nodeMap = new Map<string, UserGroupNode>();
const rootNodes: UserGroupNode[] = [];
for (const group of groups) {
nodeMap.set(group.id, { ...group, children: [] });
} }
if (
typeof error === "object" && for (const group of groups) {
error !== null && const node = nodeMap.get(group.id);
"message" in error && if (!node) continue;
typeof (error as { message?: unknown }).message === "string"
) { if (group.parentId && nodeMap.has(group.parentId)) {
return (error as { message: string }).message; const parent = nodeMap.get(group.parentId);
if (parent) {
parent.children.push(node);
}
} else {
rootNodes.push(node);
}
} }
return fallback;
const sortNodes = (nodes: UserGroupNode[]) => {
nodes.sort((a, b) => a.name.localeCompare(b.name));
for (const node of nodes) {
sortNodes(node.children);
}
};
sortNodes(rootNodes);
return rootNodes;
} }
export function TenantUserGroupsTab() { interface UserGroupTreeNodeProps {
const { tenantId } = useParams<{ tenantId: string }>(); node: UserGroupNode;
const queryClient = useQueryClient(); level: number;
const [isCreateOpen, setIsCreateOpen] = useState(false); onSelect: (groupId: string) => void;
const [newGroupName, setNewGroupName] = useState(""); selectedGroupId: string | null;
const [newGroupDesc, setNewGroupDesc] = useState(""); onDelete: (groupId: string, groupName: string) => void;
onAddSubGroup: (parentId: string) => void;
}
const { data: groups, isLoading } = useQuery({ const UserGroupTreeNode: React.FC<UserGroupTreeNodeProps> = ({
queryKey: ["tenant-user-groups", tenantId], node,
queryFn: () => { level,
if (!tenantId) { onSelect,
throw new Error("tenantId is required"); selectedGroupId,
} onDelete,
return fetchGroups(tenantId); onAddSubGroup,
}, }) => {
const [isExpanded, setIsExpanded] = useState(true);
const hasChildren = node.children && node.children.length > 0;
return (
<>
<TableRow
key={node.id}
className={`cursor-pointer transition-colors hover:bg-muted/50 ${
selectedGroupId === node.id ? "bg-primary/5" : ""
}`}
onClick={() => onSelect(node.id)}
>
<TableCell style={{ paddingLeft: `${1 + level * 2}rem` }}>
<div className="flex items-center gap-2">
{hasChildren && (
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
setIsExpanded(!isExpanded);
}}
>
{isExpanded ? (
<ChevronDown size={16} />
) : (
<ChevronRight size={16} />
)}
</Button>
)}
{!hasChildren && <div className="w-6" />}
<Users size={14} className="text-muted-foreground" />
<span className="font-semibold">{node.name}</span>
<Badge variant="outline" className="text-xs">
{node.unitType || "Team"}
</Badge>
</div>
</TableCell>
<TableCell className="text-center">
<Badge variant="secondary">{node.members?.length || 0}</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onAddSubGroup(node.id);
}}
>
<Plus size={14} className="mr-1" />
{t("ui.admin.groups.add_unit", "하위 조직 추가")}
</Button>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onDelete(node.id, node.name);
}}
>
<Trash2 size={14} className="text-destructive" />
</Button>
</TableCell>
</TableRow>
{isExpanded &&
hasChildren &&
node.children.map((child) => (
<UserGroupTreeNode
key={child.id}
node={child}
level={level + 1}
onSelect={onSelect}
selectedGroupId={selectedGroupId}
onDelete={onDelete}
onAddSubGroup={onAddSubGroup}
/>
))}
</>
);
};
export function TenantUserGroupsTab() {
const params = useParams<{ tenantId: string }>();
const tenantId = params.tenantId ?? "";
const [newGroupName, setNewGroupName] = useState("");
const [newGroupDesc, setNewGroupNameDesc] = useState("");
const [newGroupUnitType, setNewGroupUnitType] = useState("Team");
const [newGroupParentId, setNewGroupParentId] = useState<string | null>(null);
const [selectedGroupId, setSelectedGroupId] = useState<string | null>(null);
const groupsQuery = useQuery({
queryKey: ["groups", tenantId],
queryFn: () => fetchGroups(tenantId),
enabled: !!tenantId, enabled: !!tenantId,
}); });
const createMutation = useMutation({ const createMutation = useMutation({
mutationFn: () => { mutationFn: () =>
if (!tenantId) { createGroup(tenantId, {
throw new Error("tenantId is required");
}
return createGroup(tenantId, {
name: newGroupName, name: newGroupName,
description: newGroupDesc, description: newGroupDesc,
}); unitType: newGroupUnitType,
}, parentId: newGroupParentId || undefined,
}),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ toast.success(
queryKey: ["tenant-user-groups", tenantId], t(
}); "msg.admin.groups.list.create_success",
setIsCreateOpen(false); "그룹이 성공적으로 생성되었습니다.",
),
);
groupsQuery.refetch();
setNewGroupName(""); setNewGroupName("");
setNewGroupDesc(""); setNewGroupNameDesc("");
alert("User group created successfully"); setNewGroupUnitType("Team");
setNewGroupParentId(null);
}, },
onError: (error: unknown) => { onError: (error: AxiosError<{ error?: string }>) => {
alert(getErrorMessage(error, "Failed to create user group")); toast.error(t("msg.admin.groups.list.create_error", "그룹 생성 실패"), {
description: error.response?.data?.error || error.message,
});
}, },
}); });
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (groupId: string) => { mutationFn: (id: string) => deleteGroup(tenantId, id),
if (!tenantId) {
throw new Error("tenantId is required");
}
return deleteGroup(tenantId, groupId);
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ toast.success(
queryKey: ["tenant-user-groups", tenantId], t("msg.admin.groups.list.delete_success", "그룹이 삭제되었습니다."),
);
groupsQuery.refetch();
if (selectedGroupId && selectedGroupId === deleteMutation.variables) {
setSelectedGroupId(null);
}
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "그룹 삭제 실패"), {
description: error.response?.data?.error || error.message,
}); });
alert("User group deleted successfully");
}, },
}); });
if (isLoading) return <div>Loading user groups...</div>; const addMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
addGroupMember(tenantId, groupId, userId),
onSuccess: () => {
toast.success(
t("msg.admin.groups.members.add_success", "멤버가 추가되었습니다."),
);
groupsQuery.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
});
const removeMemberMutation = useMutation({
mutationFn: ({ groupId, userId }: { groupId: string; userId: string }) =>
removeGroupMember(tenantId, groupId, userId),
onSuccess: () => {
toast.success(
t("msg.admin.groups.members.remove_success", "멤버가 제거되었습니다."),
);
groupsQuery.refetch();
},
onError: (error: AxiosError<{ error?: string }>) => {
toast.error(t("msg.common.error", "오류 발생"), {
description: error.response?.data?.error || error.message,
});
},
});
const groupTree = groupsQuery.data ? buildGroupTree(groupsQuery.data) : [];
const handleAddSubGroup = (parentId: string) => {
setNewGroupParentId(parentId);
};
const handleDeleteGroup = (groupId: string, groupName: string) => {
if (
window.confirm(
t(
"msg.admin.groups.list.delete_confirm",
`그룹 "{{name}}"을(를) 삭제하시겠습니까?`,
{ name: groupName },
),
)
) {
deleteMutation.mutate(groupId);
}
};
const handleAddMember = (groupId: string) => {
const userId = window.prompt(
t(
"msg.admin.groups.prompt.user_id",
"추가할 사용자의 UUID를 입력하세요:",
),
);
if (userId) {
addMemberMutation.mutate({ groupId, userId });
}
};
const currentGroup = groupsQuery.data?.find((g) => g.id === selectedGroupId);
return ( return (
<div className="space-y-6"> <div className="space-y-6 mt-6">
<Card> <div className="grid gap-6 md:grid-cols-3">
<CardHeader className="flex flex-row items-center justify-between"> <Card className="bg-[var(--color-panel)] md:col-span-1 border-primary/20">
<div> <CardHeader>
<CardTitle>User Groups</CardTitle> <CardTitle className="text-lg flex items-center gap-2">
<CardDescription> <Plus size={18} />{" "}
Manage user groups within this tenant for collective permission {t("ui.admin.groups.create.title", "새 그룹 생성")}
assignment. </CardTitle>
</CardDescription> </CardHeader>
</div> <CardContent className="space-y-4">
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}> <div className="space-y-1">
<DialogTrigger asChild> <Label htmlFor="name">
<Button size="sm"> {t("ui.admin.groups.form.name_label", "그룹 이름")}
<Plus size={16} className="mr-2" /> </Label>
Create Group <Input
</Button> id="name"
</DialogTrigger> value={newGroupName}
<DialogContent> onChange={(e) => setNewGroupName(e.target.value)}
<DialogHeader> />
<DialogTitle>Create User Group</DialogTitle> </div>
<DialogDescription> <div className="space-y-1">
Create a new group to manage users collectively. <Label htmlFor="unitType">
</DialogDescription> {t("ui.admin.groups.form.unit_level_label", "조직 단위 레벨")}
</DialogHeader> </Label>
<div className="space-y-4 py-4"> <Input
<div className="space-y-2"> id="unitType"
<Label htmlFor="name">Group Name</Label> value={newGroupUnitType}
<Input onChange={(e) => setNewGroupUnitType(e.target.value)}
id="name" />
placeholder="e.g. Developers, Project A Managers" </div>
value={newGroupName} <div className="space-y-1">
onChange={(e) => setNewGroupName(e.target.value)} <Label htmlFor="parentId">
/> {t("ui.admin.groups.form.parent_label", "상위 그룹 (선택)")}
</div> </Label>
<div className="space-y-2"> <select
<Label htmlFor="description">Description</Label> id="parentId"
<Input className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-1 text-sm"
id="description" value={newGroupParentId || ""}
placeholder="Brief description of the group" onChange={(e) => setNewGroupParentId(e.target.value || null)}
value={newGroupDesc} >
onChange={(e) => setNewGroupDesc(e.target.value)} <option value="">
/> {t("ui.admin.groups.form.parent_none", "없음 (최상위)")}
</div> </option>
</div> {groupsQuery.data?.map((group) => (
<DialogFooter> <option key={group.id} value={group.id}>
<Button {group.name}
variant="outline" </option>
onClick={() => setIsCreateOpen(false)} ))}
> </select>
Cancel </div>
</Button> <div className="space-y-1">
<Button <Label htmlFor="desc">
onClick={() => createMutation.mutate()} {t("ui.admin.groups.form.desc_label", "설명")}
disabled={!newGroupName || createMutation.isPending} </Label>
> <Input
{createMutation.isPending ? "Creating..." : "Create Group"} id="desc"
</Button> value={newGroupDesc}
</DialogFooter> onChange={(e) => setNewGroupNameDesc(e.target.value)}
</DialogContent> />
</Dialog> </div>
</CardHeader> <Button
<CardContent> className="w-full"
<Table> onClick={() => createMutation.mutate()}
<TableHeader> disabled={!newGroupName || createMutation.isPending}
<TableRow> >
<TableHead>Name</TableHead> {t("ui.admin.groups.form.submit", "생성하기")}
<TableHead>Description</TableHead> </Button>
<TableHead>Created At</TableHead> </CardContent>
<TableHead className="text-right">Actions</TableHead> </Card>
</TableRow>
</TableHeader> <Card className="bg-[var(--color-panel)] md:col-span-2">
<TableBody> <CardHeader className="flex flex-row items-center justify-between">
{groups?.length === 0 ? ( <div>
<CardTitle>
{t("ui.admin.groups.list.title", "User Groups")}
</CardTitle>
<CardDescription>
{t(
"msg.admin.groups.list.subtitle",
"이 테넌트에 정의된 사용자 그룹 목록입니다.",
)}
</CardDescription>
</div>
<Button
variant="ghost"
size="sm"
onClick={() => groupsQuery.refetch()}
>
<RefreshCw size={14} />
</Button>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow> <TableRow>
<TableCell <TableHead>
colSpan={4} {t("ui.admin.groups.table.name", "NAME")}
className="text-center py-8 text-muted-foreground" </TableHead>
> <TableHead className="text-center">
No user groups found for this tenant. {t("ui.admin.groups.table.members", "MEMBERS")}
</TableCell> </TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.table.actions", "ACTIONS")}
</TableHead>
</TableRow> </TableRow>
) : ( </TableHeader>
groups?.map((group) => ( <TableBody>
<TableRow key={group.id}> {groupsQuery.isLoading && (
<TableCell className="font-medium"> <TableRow>
<div className="flex items-center gap-2"> <TableCell colSpan={3}>
<Users size={16} className="text-muted-foreground" /> {t("msg.admin.groups.list.loading", "로딩 중...")}
<Link
to={`/tenants/${tenantId}/user-groups/${group.id}`}
className="hover:underline text-primary"
>
{group.name}
</Link>
</div>
</TableCell> </TableCell>
<TableCell>{group.description || "-"}</TableCell> </TableRow>
<TableCell> )}
{group.createdAt {!groupsQuery.isLoading && groupTree.length === 0 && (
? new Date(group.createdAt).toLocaleDateString() <TableRow>
: "-"} <TableCell
colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.list.empty",
"아직 등록된 그룹이 없습니다.",
)}
</TableCell>
</TableRow>
)}
{groupTree.map((node) => (
<UserGroupTreeNode
key={node.id}
node={node}
level={0}
onSelect={setSelectedGroupId}
selectedGroupId={selectedGroupId}
onDelete={handleDeleteGroup}
onAddSubGroup={handleAddSubGroup}
/>
))}
</TableBody>
</Table>
</CardContent>
</Card>
</div>
{currentGroup && (
<Card className="bg-[var(--color-panel)] border-t-4 border-t-primary">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield size={18} className="text-primary" />{" "}
{t("msg.admin.groups.members.title", "[{{name}}] 멤버 관리", {
name: currentGroup.name,
})}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex justify-end mb-4">
<Button
size="sm"
onClick={() => handleAddMember(currentGroup.id)}
disabled={addMemberMutation.isPending}
>
<UserPlus size={14} className="mr-1" />{" "}
{t("ui.common.add", "멤버 추가")}
</Button>
</div>
<Table>
<TableHeader>
<TableRow>
<TableHead>
{t("ui.admin.groups.members.table.name", "이름")}
</TableHead>
<TableHead>
{t("ui.admin.groups.members.table.email", "이메일")}
</TableHead>
<TableHead className="text-right">
{t("ui.admin.groups.members.table.remove", "제거")}
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{currentGroup.members?.length === 0 && (
<TableRow>
<TableCell
colSpan={3}
className="text-center py-4 text-muted-foreground"
>
{t("msg.admin.groups.members.empty", "멤버가 없습니다.")}
</TableCell>
</TableRow>
)}
{currentGroup.members?.map((user) => (
<TableRow key={user.id}>
<TableCell className="font-medium">{user.name}</TableCell>
<TableCell className="text-muted-foreground">
{user.email}
</TableCell> </TableCell>
<TableCell className="text-right"> <TableCell className="text-right">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="sm"
className="text-destructive hover:text-destructive hover:bg-destructive/10" onClick={() =>
onClick={() => { removeMemberMutation.mutate({
if ( groupId: currentGroup.id,
confirm( userId: user.id,
"Are you sure you want to delete this group?", })
) }
) { disabled={removeMemberMutation.isPending}
deleteMutation.mutate(group.id);
}
}}
> >
<Trash2 size={16} /> <UserMinus size={14} className="text-destructive" />
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))}
)} </TableBody>
</TableBody> </Table>
</Table> </CardContent>
</CardContent> </Card>
</Card> )}
</div> </div>
); );
} }
export default TenantUserGroupsTab;

View File

@@ -1,7 +1,9 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Plus, Shield, Trash2, UserPlus, Users } from "lucide-react"; import type { AxiosError } from "axios";
import { ArrowLeft, Shield, Trash2, UserPlus, Users } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { Link, useParams } from "react-router-dom"; import { Link, useParams } from "react-router-dom";
import { toast } from "sonner";
import { Badge } from "../../../components/ui/badge"; import { Badge } from "../../../components/ui/badge";
import { Button } from "../../../components/ui/button"; import { Button } from "../../../components/ui/button";
import { import {
@@ -47,28 +49,7 @@ import {
removeGroupMember, removeGroupMember,
removeGroupRole, removeGroupRole,
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function getErrorMessage(error: unknown, fallback: string): string {
if (typeof error === "object" && error !== null) {
const response = (error as { response?: { data?: { error?: unknown } } })
.response;
const responseError = response?.data?.error;
if (typeof responseError === "string" && responseError.length > 0) {
return responseError;
}
const message = (error as { message?: unknown }).message;
if (typeof message === "string" && message.length > 0) {
return message;
}
}
if (error instanceof Error && error.message) {
return error.message;
}
return fallback;
}
export function UserGroupDetailPage() { export function UserGroupDetailPage() {
const { tenantId, id } = useParams<{ tenantId: string; id: string }>(); const { tenantId, id } = useParams<{ tenantId: string; id: string }>();
@@ -82,19 +63,13 @@ export function UserGroupDetailPage() {
const [selectedTargetTenantId, setSelectedTargetTenantId] = useState(""); const [selectedTargetTenantId, setSelectedTargetTenantId] = useState("");
const [selectedRelation, setSelectedRelation] = useState("view"); const [selectedRelation, setSelectedRelation] = useState("view");
// Fetch specific group details
const { const {
data: currentGroup, data: currentGroup,
isLoading: isGroupLoading, isLoading: isGroupLoading,
error, error,
} = useQuery({ } = useQuery({
queryKey: ["user-group-detail", id], queryKey: ["user-group-detail", id],
queryFn: () => { queryFn: () => fetchGroup(tenantId ?? "", id ?? ""),
if (!tenantId || !id) {
throw new Error("tenantId and id are required");
}
return fetchGroup(tenantId, id);
},
enabled: !!id && !!tenantId, enabled: !!id && !!tenantId,
retry: false, retry: false,
}); });
@@ -102,12 +77,7 @@ export function UserGroupDetailPage() {
// Fetch assigned roles // Fetch assigned roles
const { data: groupRoles, isLoading: isRolesLoading } = useQuery({ const { data: groupRoles, isLoading: isRolesLoading } = useQuery({
queryKey: ["user-group-roles", id], queryKey: ["user-group-roles", id],
queryFn: () => { queryFn: () => fetchGroupRoles(tenantId ?? "", id ?? ""),
if (!tenantId || !id) {
throw new Error("tenantId and id are required");
}
return fetchGroupRoles(tenantId, id);
},
enabled: !!id && !!tenantId, enabled: !!id && !!tenantId,
}); });
@@ -126,68 +96,76 @@ export function UserGroupDetailPage() {
}); });
const addMemberMutation = useMutation({ const addMemberMutation = useMutation({
mutationFn: (userId: string) => { mutationFn: (userId: string) =>
if (!tenantId || !id) { addGroupMember(tenantId ?? "", id ?? "", userId),
throw new Error("tenantId and id are required");
}
return addGroupMember(tenantId, id, userId);
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] }); queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
setIsAddMemberOpen(false); setIsAddMemberOpen(false);
setSelectedUserId(""); setSelectedUserId("");
alert("Member added successfully"); toast.success(
t("msg.admin.groups.members.add_success", "구성원이 추가되었습니다."),
);
}, },
onError: (error: unknown) => { onError: (error: AxiosError<{ error?: string }>) => {
alert(getErrorMessage(error, "Failed to add member")); toast.error(
error.response?.data?.error ||
error.message ||
t("err.common.unknown", "오류가 발생했습니다."),
);
}, },
}); });
const removeMemberMutation = useMutation({ const removeMemberMutation = useMutation({
mutationFn: (userId: string) => { mutationFn: (userId: string) =>
if (!tenantId || !id) { removeGroupMember(tenantId ?? "", id ?? "", userId),
throw new Error("tenantId and id are required");
}
return removeGroupMember(tenantId, id, userId);
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] }); queryClient.invalidateQueries({ queryKey: ["user-group-detail", id] });
alert("Member removed successfully"); toast.success(
t(
"msg.admin.groups.members.remove_success",
"구성원이 제외되었습니다.",
),
);
}, },
}); });
const assignRoleMutation = useMutation({ const assignRoleMutation = useMutation({
mutationFn: () => { mutationFn: () =>
if (!tenantId || !id) { assignGroupRole(
throw new Error("tenantId and id are required"); tenantId ?? "",
} id ?? "",
return assignGroupRole(
tenantId,
id,
selectedTargetTenantId, selectedTargetTenantId,
selectedRelation, selectedRelation,
); ),
},
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] }); queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
setIsAddRoleOpen(false); setIsAddRoleOpen(false);
alert(`Role '${selectedRelation}' assigned successfully`); toast.success(
t("msg.admin.groups.roles.assign_success", "역할이 할당되었습니다."),
);
}, },
onError: (error: unknown) => { onError: (error: AxiosError<{ error?: string }>) => {
alert(getErrorMessage(error, "Failed to assign role")); toast.error(
error.response?.data?.error ||
error.message ||
t("err.common.unknown", "오류가 발생했습니다."),
);
}, },
}); });
const removeRoleMutation = useMutation({ const removeRoleMutation = useMutation({
mutationFn: (role: { targetTenantId: string; relation: string }) => { mutationFn: (role: { targetTenantId: string; relation: string }) =>
if (!tenantId || !id) { removeGroupRole(
throw new Error("tenantId and id are required"); tenantId ?? "",
} id ?? "",
return removeGroupRole(tenantId, id, role.targetTenantId, role.relation); role.targetTenantId,
}, role.relation,
),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] }); queryClient.invalidateQueries({ queryKey: ["user-group-roles", id] });
alert("Role removed successfully"); toast.success(
t("msg.admin.groups.roles.remove_success", "역할이 회수되었습니다."),
);
}, },
}); });
@@ -196,7 +174,7 @@ export function UserGroupDetailPage() {
<div className="flex items-center justify-center p-12"> <div className="flex items-center justify-center p-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" /> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary" />
<span className="ml-3 text-muted-foreground"> <span className="ml-3 text-muted-foreground">
Loading group details... {t("msg.common.loading", "로딩 중...")}
</span> </span>
</div> </div>
); );
@@ -205,27 +183,28 @@ export function UserGroupDetailPage() {
return ( return (
<div className="p-8 text-center space-y-4"> <div className="p-8 text-center space-y-4">
<h3 className="text-xl font-semibold text-destructive"> <h3 className="text-xl font-semibold text-destructive">
Could not load group
</h3> </h3>
<div className="p-4 bg-red-50 text-red-700 rounded-md text-left text-sm font-mono overflow-auto max-w-xl mx-auto border border-red-100"> <div className="p-4 bg-destructive/10 text-destructive rounded-md text-left text-sm font-mono overflow-auto max-w-xl mx-auto border border-destructive/20">
<p>Error: {getErrorMessage(error, "Not found")}</p> <p>
<p className="mt-2 text-red-500 opacity-70"> Error:{" "}
Path: /admin/tenants/{tenantId}/user-groups/{id} {(error as AxiosError<{ error?: string }>)?.response?.data?.error ||
error.message ||
"Not found"}
</p> </p>
</div> </div>
<p className="text-muted-foreground pt-2">
The group ID might be invalid or you don't have sufficient
permissions.
</p>
<Button variant="outline" onClick={() => window.location.reload()}> <Button variant="outline" onClick={() => window.location.reload()}>
Retry {t("ui.common.retry", "다시 시도")}
</Button> </Button>
<div className="pt-4 border-t"> <div className="pt-4 border-t">
<Link <Link
to={`/tenants/${tenantId}/user-groups`} to={`/tenants/${tenantId}/organization`}
className="text-primary hover:underline text-sm" className="text-primary hover:underline text-sm"
> >
Return to Group List {t(
"ui.admin.groups.detail.breadcrumb_org",
"조직 관리 목록으로 돌아가기",
)}
</Link> </Link>
</div> </div>
</div> </div>
@@ -235,72 +214,111 @@ export function UserGroupDetailPage() {
<div className="space-y-8"> <div className="space-y-8">
<header className="flex flex-wrap items-start justify-between gap-4"> <header className="flex flex-wrap items-start justify-between gap-4">
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-[var(--color-muted)]"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<Link <Link
to={`/tenants/${tenantId}`} to={`/tenants/${tenantId}`}
className="inline-flex items-center gap-2 hover:text-foreground transition-colors" className="inline-flex items-center gap-2 hover:text-foreground transition-colors"
> >
<ArrowLeft size={14} /> <ArrowLeft size={14} />
Tenant Detail {t("ui.admin.groups.detail.breadcrumb_tenant", "테넌트 상세")}
</Link> </Link>
<span>/</span> <span>/</span>
<span className="text-foreground">User Group</span> <Link
to={`/tenants/${tenantId}/organization`}
className="hover:text-foreground transition-colors"
>
{t("ui.admin.groups.detail.breadcrumb_org", "조직 관리")}
</Link>
<span>/</span>
<span className="text-foreground">
{t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}
</span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-primary/10 rounded-lg"> <div className="p-2 bg-primary/10 rounded-lg">
<Users size={24} className="text-primary" /> <Users size={24} className="text-primary" />
</div> </div>
<h2 className="text-3xl font-semibold">{currentGroup.name}</h2> <h2 className="text-3xl font-semibold">{currentGroup.name}</h2>
{currentGroup.unitType && (
<Badge variant="secondary" className="h-6 font-normal">
{currentGroup.unitType}
</Badge>
)}
</div> </div>
<p className="text-sm text-[var(--color-muted)]"> <p className="text-sm text-muted-foreground">
{currentGroup.description || "No description provided."} {currentGroup.description ||
t("msg.common.no_description", "설명이 없습니다.")}
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Badge variant="outline">User Group</Badge> <Badge variant="outline" className="font-normal">
<Badge variant="muted">Tenant: {tenantId?.split("-")[0]}...</Badge> {t("ui.admin.groups.detail.breadcrumb_unit", "조직 단위")}
</Badge>
<Badge variant="muted" className="font-normal">
ID: {id?.split("-")[0]}...
</Badge>
</div> </div>
</header> </header>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* Members Management */} {/* Members Management */}
<Card> <Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<div> <div>
<CardTitle>Members</CardTitle> <CardTitle>
<CardDescription>Manage users in this group.</CardDescription> {t("ui.admin.groups.detail.members_title", "구성원 관리")}
</CardTitle>
<CardDescription>
{t(
"ui.admin.groups.detail.members_subtitle",
"이 조직에 소속된 사용자를 관리합니다.",
)}
</CardDescription>
</div> </div>
<Dialog open={isAddMemberOpen} onOpenChange={setIsAddMemberOpen}> <Dialog open={isAddMemberOpen} onOpenChange={setIsAddMemberOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm" variant="outline"> <Button size="sm" variant="outline">
<UserPlus size={16} className="mr-2" /> <UserPlus size={16} className="mr-2" />
Add Member {t("ui.common.add", "추가")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Add Member</DialogTitle> <DialogTitle>
{t("ui.admin.groups.detail.members_title", "구성원 추가")}
</DialogTitle>
<DialogDescription> <DialogDescription>
Select a user to add to this group. {t(
"ui.admin.groups.detail.members_subtitle",
"사용자를 검색하여 조직 구성원으로 추가합니다.",
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Search User</Label> <Label>{t("ui.common.search", "사용자 검색")}</Label>
<Input <Input
placeholder="Search by email or name..." placeholder={t(
"ui.admin.users.list.search_placeholder",
"이메일 또는 이름으로 검색...",
)}
value={searchUser} value={searchUser}
onChange={(e) => setSearchUser(e.target.value)} onChange={(e) => setSearchUser(e.target.value)}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Select User</Label> <Label>{t("ui.common.select", "사용자 선택")}</Label>
<Select <Select
value={selectedUserId} value={selectedUserId}
onValueChange={setSelectedUserId} onValueChange={setSelectedUserId}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Choose a user" /> <SelectValue
placeholder={t(
"ui.common.select_placeholder",
"사용자를 선택하세요",
)}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{userList?.items.map((user) => ( {userList?.items.map((user) => (
@@ -317,98 +335,149 @@ export function UserGroupDetailPage() {
variant="outline" variant="outline"
onClick={() => setIsAddMemberOpen(false)} onClick={() => setIsAddMemberOpen(false)}
> >
Cancel {t("ui.common.cancel", "취소")}
</Button> </Button>
<Button <Button
onClick={() => addMemberMutation.mutate(selectedUserId)} onClick={() => addMemberMutation.mutate(selectedUserId)}
disabled={!selectedUserId || addMemberMutation.isPending} disabled={!selectedUserId || addMemberMutation.isPending}
> >
Add {t("ui.common.add", "추가")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <div className="rounded-md border border-border overflow-hidden">
<TableHeader> <Table>
<TableRow> <TableHeader className="bg-muted/30">
<TableHead>User</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{!currentGroup.members || currentGroup.members.length === 0 ? (
<TableRow> <TableRow>
<TableCell <TableHead className="font-bold">
colSpan={2} {t("ui.admin.users.list.table.name_email", "사용자")}
className="text-center py-4 text-muted-foreground" </TableHead>
> <TableHead className="text-right font-bold">
No members in this group. {t("ui.admin.groups.table.actions", "액션")}
</TableCell> </TableHead>
</TableRow> </TableRow>
) : ( </TableHeader>
currentGroup.members.map((member) => ( <TableBody>
<TableRow key={member.id}> {!currentGroup.members ||
<TableCell> currentGroup.members.length === 0 ? (
<div> <TableRow>
<p className="font-medium">{member.name}</p> <TableCell
<p className="text-xs text-muted-foreground"> colSpan={2}
{member.email} className="text-center py-8 text-muted-foreground"
</p> >
</div> {t(
</TableCell> "msg.admin.groups.members.empty",
<TableCell className="text-right"> "구성원이 없습니다.",
<Button )}
variant="ghost"
size="icon"
className="text-destructive"
onClick={() => removeMemberMutation.mutate(member.id)}
>
<Trash2 size={14} />
</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ) : (
)} currentGroup.members.map((member) => (
</TableBody> <TableRow
</Table> key={member.id}
className="hover:bg-muted/30 transition-colors"
>
<TableCell>
<div className="flex items-center gap-3">
<div className="h-8 w-8 rounded-full bg-primary/10 flex items-center justify-center text-primary font-bold text-xs">
{member.name.charAt(0)}
</div>
<div>
<p className="font-medium text-sm">
{member.name}
</p>
<p className="text-xs text-muted-foreground">
{member.email}
</p>
</div>
</div>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={() => {
if (
confirm(
t(
"msg.admin.groups.members.remove_confirm",
"제거하시겠습니까?",
{ name: member.name },
),
)
) {
removeMemberMutation.mutate(member.id);
}
}}
>
<Trash2 size={14} />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent> </CardContent>
</Card> </Card>
{/* Roles/Permissions Management (Keto Based) */} {/* Roles/Permissions Management (Keto Based) */}
<Card> <Card className="border-none shadow-sm bg-[var(--color-panel)]">
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<div> <div>
<CardTitle>Permissions</CardTitle> <CardTitle>
{t("ui.admin.groups.detail.permissions_title", "권한 관리")}
</CardTitle>
<CardDescription> <CardDescription>
Tenant roles assigned to this group. {t(
"ui.admin.groups.detail.permissions_subtitle",
"이 조직이 다른 테넌트에 가지는 역할을 정의합니다.",
)}
</CardDescription> </CardDescription>
</div> </div>
<Dialog open={isAddRoleOpen} onOpenChange={setIsAddRoleOpen}> <Dialog open={isAddRoleOpen} onOpenChange={setIsAddRoleOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button size="sm" variant="outline"> <Button size="sm" variant="outline">
<Shield size={16} className="mr-2" /> <Shield size={16} className="mr-2" />
Assign Role {t("ui.common.assign", "할당")}
</Button> </Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Assign Tenant Role</DialogTitle> <DialogTitle>
{t(
"ui.admin.groups.detail.permissions_title",
"테넌트 역할 할당",
)}
</DialogTitle>
<DialogDescription> <DialogDescription>
Members of this group will inherit this role on the target {t(
tenant. "msg.admin.groups.roles.description",
"이 조직의 구성원들이 대상 테넌트에서 상속받을 역할을 선택하세요.",
)}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 py-4"> <div className="space-y-4 py-4">
<div className="space-y-2"> <div className="space-y-2">
<Label>Target Tenant</Label> <Label>
{t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
</Label>
<Select <Select
value={selectedTargetTenantId} value={selectedTargetTenantId}
onValueChange={setSelectedTargetTenantId} onValueChange={setSelectedTargetTenantId}
> >
<SelectTrigger> <SelectTrigger>
<SelectValue placeholder="Select target tenant" /> <SelectValue
placeholder={t(
"ui.admin.tenants.list.select_placeholder",
"테넌트를 선택하세요",
)}
/>
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
{tenantList?.items.map((t) => ( {tenantList?.items.map((t) => (
@@ -420,7 +489,9 @@ export function UserGroupDetailPage() {
</Select> </Select>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Role (Relation)</Label> <Label>
{t("ui.admin.users.detail.form.role", "역할 (Relation)")}
</Label>
<Select <Select
value={selectedRelation} value={selectedRelation}
onValueChange={setSelectedRelation} onValueChange={setSelectedRelation}
@@ -429,12 +500,12 @@ export function UserGroupDetailPage() {
<SelectValue /> <SelectValue />
</SelectTrigger> </SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="view">View (Read-only)</SelectItem> <SelectItem value="view">View ( )</SelectItem>
<SelectItem value="manage"> <SelectItem value="manage">
Manage (Read/Write) Manage ( )
</SelectItem> </SelectItem>
<SelectItem value="admins"> <SelectItem value="admins">
Admin (Full Control) Admin ( )
</SelectItem> </SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
@@ -445,7 +516,7 @@ export function UserGroupDetailPage() {
variant="outline" variant="outline"
onClick={() => setIsAddRoleOpen(false)} onClick={() => setIsAddRoleOpen(false)}
> >
Cancel {t("ui.common.cancel", "취소")}
</Button> </Button>
<Button <Button
onClick={() => assignRoleMutation.mutate()} onClick={() => assignRoleMutation.mutate()}
@@ -453,70 +524,93 @@ export function UserGroupDetailPage() {
!selectedTargetTenantId || assignRoleMutation.isPending !selectedTargetTenantId || assignRoleMutation.isPending
} }
> >
Assign {t("ui.common.assign", "할당")}
</Button> </Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<Table> <div className="rounded-md border border-border overflow-hidden">
<TableHeader> <Table>
<TableRow> <TableHeader className="bg-muted/30">
<TableHead>Target Tenant</TableHead>
<TableHead>Role</TableHead>
<TableHead className="text-right">Action</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isRolesLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={3} className="text-center"> <TableHead className="font-bold">
Loading... {t("ui.admin.users.detail.form.tenant", "대상 테넌트")}
</TableCell> </TableHead>
<TableHead className="font-bold">
{t("ui.admin.users.detail.form.role", "역할")}
</TableHead>
<TableHead className="text-right font-bold">
{t("ui.admin.groups.table.actions", "액션")}
</TableHead>
</TableRow> </TableRow>
) : !groupRoles || groupRoles.length === 0 ? ( </TableHeader>
<TableRow> <TableBody>
<TableCell {isRolesLoading ? (
colSpan={3} <TableRow>
className="text-center py-4 text-muted-foreground" <TableCell colSpan={3} className="text-center py-8">
> <div className="animate-spin rounded-full h-5 w-5 border-b-2 border-primary mx-auto" />
No roles assigned.
</TableCell>
</TableRow>
) : (
groupRoles.map((role, idx) => (
<TableRow key={`${role.tenantId}-${role.relation}-${idx}`}>
<TableCell>
<div className="font-medium">
{role.tenantName || role.tenantId}
</div>
</TableCell>
<TableCell>
<Badge variant="outline" className="capitalize">
{role.relation}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="text-destructive"
onClick={() =>
removeRoleMutation.mutate({
targetTenantId: role.tenantId,
relation: role.relation,
})
}
>
<Trash2 size={14} />
</Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ) : !groupRoles || groupRoles.length === 0 ? (
)} <TableRow>
</TableBody> <TableCell
</Table> colSpan={3}
className="text-center py-8 text-muted-foreground"
>
{t(
"msg.admin.groups.roles.empty",
"할당된 역할이 없습니다.",
)}
</TableCell>
</TableRow>
) : (
groupRoles.map((role, idx) => (
<TableRow
key={`${role.tenantId}-${role.relation}-${idx}`}
className="hover:bg-muted/30 transition-colors"
>
<TableCell>
<div className="font-medium text-sm">
{role.tenantName || role.tenantId}
</div>
</TableCell>
<TableCell>
<Badge
variant="outline"
className="capitalize font-normal"
>
{role.relation}
</Badge>
</TableCell>
<TableCell className="text-right">
<Button
variant="ghost"
size="icon"
className="text-destructive hover:bg-destructive/10"
onClick={() => {
if (
confirm(
t("msg.admin.groups.roles.remove_confirm"),
)
) {
removeRoleMutation.mutate({
targetTenantId: role.tenantId,
relation: role.relation,
});
}
}}
>
<Trash2 size={14} />
</Button>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent> </CardContent>
</Card> </Card>
</div> </div>

View File

@@ -62,6 +62,8 @@ function UserCreatePage() {
role: "user", role: "user",
companyCode: "", companyCode: "",
department: "", department: "",
position: "",
jobTitle: "",
metadata: {}, metadata: {},
}, },
}); });
@@ -366,6 +368,38 @@ function UserCreatePage() {
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="position">
{t("ui.admin.users.create.form.position", "직급")}
</Label>
<Input
id="position"
placeholder={t(
"ui.admin.users.create.form.position_placeholder",
"수석/책임/선임",
)}
{...register("position")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="jobTitle">
{t("ui.admin.users.create.form.job_title", "직무")}
</Label>
<Input
id="jobTitle"
placeholder={t(
"ui.admin.users.create.form.job_title_placeholder",
"프론트엔드 개발",
)}
{...register("jobTitle")}
/>
</div>
</div>
{userSchema.length > 0 && ( {userSchema.length > 0 && (
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground"> <h3 className="mb-4 text-sm font-medium text-muted-foreground">

View File

@@ -70,6 +70,8 @@ function UserDetailPage() {
status: "active", status: "active",
companyCode: "", companyCode: "",
department: "", department: "",
position: "",
jobTitle: "",
password: "", password: "",
metadata: {}, metadata: {},
}, },
@@ -104,6 +106,8 @@ function UserDetailPage() {
status: user.status, status: user.status,
companyCode: user.companyCode || "", companyCode: user.companyCode || "",
department: user.department || "", department: user.department || "",
position: user.position || "",
jobTitle: user.jobTitle || "",
password: "", password: "",
metadata: user.metadata || {}, metadata: user.metadata || {},
}); });
@@ -337,6 +341,38 @@ function UserDetailPage() {
</div> </div>
</div> </div>
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="position">
{t("ui.admin.users.detail.form.position", "직급")}
</Label>
<Input
id="position"
placeholder={t(
"ui.admin.users.detail.form.position_placeholder",
"수석/책임/선임",
)}
{...register("position")}
/>
</div>
<div className="space-y-2">
<Label htmlFor="jobTitle">
{t("ui.admin.users.detail.form.job_title", "직무")}
</Label>
<Input
id="jobTitle"
placeholder={t(
"ui.admin.users.detail.form.job_title_placeholder",
"프론트엔드 개발",
)}
{...register("jobTitle")}
/>
</div>
</div>
{userSchema.length > 0 && ( {userSchema.length > 0 && (
<div className="border-t pt-4"> <div className="border-t pt-4">
<h3 className="mb-4 text-sm font-medium text-muted-foreground"> <h3 className="mb-4 text-sm font-medium text-muted-foreground">

View File

@@ -199,6 +199,12 @@ function UserListPage() {
"TENANT / DEPT", "TENANT / DEPT",
)} )}
</TableHead> </TableHead>
<TableHead>
{t(
"ui.admin.users.list.table.position_job",
"POSITION / JOB",
)}
</TableHead>
<TableHead> <TableHead>
{t("ui.admin.users.list.table.created", "CREATED")} {t("ui.admin.users.list.table.created", "CREATED")}
</TableHead> </TableHead>
@@ -272,6 +278,16 @@ function UserListPage() {
</span> </span>
</div> </div>
</TableCell> </TableCell>
<TableCell>
<div className="flex flex-col text-sm">
<span className="font-medium">
{user.position || "-"}
</span>
<span className="text-xs text-muted-foreground">
{user.jobTitle || "-"}
</span>
</div>
</TableCell>
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{new Date(user.createdAt).toLocaleDateString()} {new Date(user.createdAt).toLocaleDateString()}
</TableCell> </TableCell>

View File

@@ -21,11 +21,13 @@ export type AuditLogListResponse = {
export type TenantSummary = { export type TenantSummary = {
id: string; id: string;
type: string; // PERSONAL, COMPANY, COMPANY_GROUP, USER_GROUP
name: string; name: string;
slug: string; slug: string;
description: string; description: string;
status: string; status: string;
domains?: string[]; domains?: string[];
parentId?: string;
config?: Record<string, unknown>; config?: Record<string, unknown>;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -33,7 +35,9 @@ export type TenantSummary = {
export type TenantCreateRequest = { export type TenantCreateRequest = {
name: string; name: string;
type?: string;
slug?: string; slug?: string;
parentId?: string;
description?: string; description?: string;
status?: string; status?: string;
domains?: string[]; domains?: string[];
@@ -49,6 +53,7 @@ export type TenantListResponse = {
export type TenantUpdateRequest = { export type TenantUpdateRequest = {
name?: string; name?: string;
type?: string;
slug?: string; slug?: string;
description?: string; description?: string;
status?: string; status?: string;
@@ -170,8 +175,10 @@ export type GroupMember = {
export type GroupSummary = { export type GroupSummary = {
id: string; id: string;
tenantId: string; tenantId: string;
parentId?: string;
name: string; name: string;
description?: string; description?: string;
unitType?: string;
members?: GroupMember[]; members?: GroupMember[];
createdAt?: string; createdAt?: string;
updatedAt?: string; updatedAt?: string;
@@ -179,19 +186,21 @@ export type GroupSummary = {
export type GroupCreateRequest = { export type GroupCreateRequest = {
name: string; name: string;
parentId?: string;
description?: string; description?: string;
unitType?: string;
}; };
export async function fetchGroups(tenantId: string) { export async function fetchGroups(tenantId: string) {
const { data } = await apiClient.get<GroupSummary[]>( const { data } = await apiClient.get<GroupSummary[]>(
`/v1/admin/tenants/${tenantId}/user-groups`, `/v1/admin/tenants/${tenantId}/organization`,
); );
return data; return data;
} }
export async function fetchGroup(tenantId: string, groupId: string) { export async function fetchGroup(tenantId: string, groupId: string) {
const { data } = await apiClient.get<GroupSummary>( const { data } = await apiClient.get<GroupSummary>(
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}`, `/v1/admin/tenants/${tenantId}/organization/${groupId}`,
); );
return data; return data;
} }
@@ -201,7 +210,7 @@ export async function createGroup(
payload: GroupCreateRequest, payload: GroupCreateRequest,
) { ) {
const { data } = await apiClient.post<GroupSummary>( const { data } = await apiClient.post<GroupSummary>(
`/v1/admin/tenants/${tenantId}/user-groups`, `/v1/admin/tenants/${tenantId}/organization`,
payload, payload,
); );
return data; return data;
@@ -209,7 +218,7 @@ export async function createGroup(
export async function deleteGroup(tenantId: string, groupId: string) { export async function deleteGroup(tenantId: string, groupId: string) {
await apiClient.delete( await apiClient.delete(
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}`, `/v1/admin/tenants/${tenantId}/organization/${groupId}`,
); );
} }
@@ -219,7 +228,7 @@ export async function addGroupMember(
userId: string, userId: string,
) { ) {
await apiClient.post( await apiClient.post(
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/members`, `/v1/admin/tenants/${tenantId}/organization/${groupId}/members`,
{ userId }, { userId },
); );
} }
@@ -230,7 +239,7 @@ export async function removeGroupMember(
userId: string, userId: string,
) { ) {
await apiClient.delete( await apiClient.delete(
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/members/${userId}`, `/v1/admin/tenants/${tenantId}/organization/${groupId}/members/${userId}`,
); );
} }
@@ -242,7 +251,7 @@ export type GroupRole = {
export async function fetchGroupRoles(tenantId: string, groupId: string) { export async function fetchGroupRoles(tenantId: string, groupId: string) {
const { data } = await apiClient.get<GroupRole[]>( const { data } = await apiClient.get<GroupRole[]>(
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/roles`, `/v1/admin/tenants/${tenantId}/organization/${groupId}/roles`,
); );
return data; return data;
} }
@@ -254,7 +263,7 @@ export async function assignGroupRole(
relation: string, relation: string,
) { ) {
await apiClient.post( await apiClient.post(
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/roles`, `/v1/admin/tenants/${tenantId}/organization/${groupId}/roles`,
{ tenantId: targetTenantId, relation }, { tenantId: targetTenantId, relation },
); );
} }
@@ -266,10 +275,25 @@ export async function removeGroupRole(
relation: string, relation: string,
) { ) {
await apiClient.delete( await apiClient.delete(
`/v1/admin/tenants/${tenantId}/user-groups/${groupId}/roles/${targetTenantId}/${relation}`, `/v1/admin/tenants/${tenantId}/organization/${groupId}/roles/${targetTenantId}/${relation}`,
); );
} }
export async function importOrgChart(tenantId: string, file: File) {
const formData = new FormData();
formData.append("file", file);
const { data } = await apiClient.post(
`/v1/admin/tenants/${tenantId}/organization/import`,
formData,
{
headers: {
"Content-Type": "multipart/form-data",
},
},
);
return data;
}
// API Key Management (M2M) // API Key Management (M2M)
export type ApiKeyCreateRequest = { export type ApiKeyCreateRequest = {
name: string; name: string;
@@ -315,6 +339,8 @@ export type UserSummary = {
tenant?: TenantSummary; tenant?: TenantSummary;
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
department?: string; department?: string;
position?: string;
jobTitle?: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
@@ -334,6 +360,8 @@ export type UserCreateRequest = {
role?: string; role?: string;
companyCode?: string; companyCode?: string;
department?: string; department?: string;
position?: string;
jobTitle?: string;
}; };
export type UserCreateResponse = UserSummary & { export type UserCreateResponse = UserSummary & {
@@ -348,6 +376,8 @@ export type UserUpdateRequest = {
status?: string; status?: string;
companyCode?: string; companyCode?: string;
department?: string; department?: string;
position?: string;
jobTitle?: string;
}; };
export async function fetchUsers(limit = 50, offset = 0, search?: string) { export async function fetchUsers(limit = 50, offset = 0, search?: string) {

View File

@@ -1338,6 +1338,6 @@ logout = "Logout"
overview = "Overview" overview = "Overview"
relying_parties = "Apps (RP)" relying_parties = "Apps (RP)"
tenant_dashboard = "Tenant Dashboard" tenant_dashboard = "Tenant Dashboard"
user_groups = "User Groups" user_groups = "Organization"
tenants = "Tenants" tenants = "Tenants"
users = "Users" users = "Users"

View File

@@ -5,13 +5,11 @@
affiliate = "가족사 임직원" affiliate = "가족사 임직원"
general = "일반 사용자" general = "일반 사용자"
[domain.company] [domain.tenant_type]
baron = "바론" company = "COMPANY (일반 기업)"
halla = "한라" company_group = "COMPANY_GROUP (그룹사/지주사)"
hanmac = "한맥" personal = "PERSONAL (개인 워크스페이스)"
jangheon = "장헌" user_group = "USER_GROUP (내부 부서/팀)"
ptc = "PTC"
saman = "삼안"
[err] [err]
@@ -90,13 +88,34 @@ count = "로드된 로그 {{count}}건"
[msg.admin.groups] [msg.admin.groups]
[msg.admin.groups.list] [msg.admin.groups.list]
subtitle = "이 테넌트에 정의된 사용자 그룹 목록입니다." create_success = "조직 단위가 성공적으로 생성되었습니다."
create_error = "조직 단위 생성에 실패했습니다: {{error}}"
delete_confirm = "정말로 이 조직 단위를 삭제하시겠습니까?"
delete_success = "조직 단위가 삭제되었습니다."
import_success = "조직도가 성공적으로 임포트되었습니다."
import_error = "조직도 임포트에 실패했습니다: {{error}}"
loading = "조직 단위를 불러오는 중..."
subtitle = "이 테넌트에 정의된 조직 단위 목록입니다."
title = "조직 관리"
[msg.admin.groups.members] [msg.admin.groups.members]
count = "{{count}} 명" count = "{{count}} 명"
empty = "멤버가 없습니다." empty = "멤버가 없습니다."
title = "[{{name}}] 멤버 관리" title = "[{{name}}] 멤버 관리"
[msg.admin.groups.members]
add_success = "구성원이 추가되었습니다."
empty = "구성원이 없습니다."
remove_confirm = "{{name}} 님을 이 조직에서 제외하시겠습니까?"
remove_success = "구성원이 제외되었습니다."
[msg.admin.groups.roles]
assign_success = "역할이 성공적으로 할당되었습니다."
description = "이 조직의 구성원들이 대상 테넌트에서 상속받을 역할을 선택하세요."
empty = "할당된 역할이 없습니다."
remove_confirm = "할당된 역할을 회수하시겠습니까?"
remove_success = "역할이 회수되었습니다."
[msg.admin.groups.prompt] [msg.admin.groups.prompt]
user_id = "추가할 사용자의 UUID를 입력하세요:" user_id = "추가할 사용자의 UUID를 입력하세요:"
@@ -123,11 +142,37 @@ tenant_title = "Tenant isolation"
description = "주요 운영 화면으로 바로 이동합니다." description = "주요 운영 화면으로 바로 이동합니다."
[msg.admin.tenants] [msg.admin.tenants]
approve_confirm = "이 테넌트를 승인하시겠습니까?"
approve_success = "테넌트가 승인되었습니다."
delete_confirm = "테넌트 \\\"{{name}}\\\"를 삭제할까요?" delete_confirm = "테넌트 \\\"{{name}}\\\"를 삭제할까요?"
delete_success = "테넌트가 삭제되었습니다."
empty = "아직 등록된 테넌트가 없습니다." empty = "아직 등록된 테넌트가 없습니다."
fetch_error = "테넌트 목록 조회에 실패했습니다." fetch_error = "테넌트 목록 조회에 실패했습니다."
missing_id = "테넌트 ID가 없습니다."
subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다." subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다."
[msg.admin.tenants.admins]
add_success = "관리자가 성공적으로 추가되었습니다."
empty = "등록된 관리자가 없습니다."
remove_confirm = "{{name}} 사용자의 관리자 권한을 회수할까요?"
remove_success = "관리자 권한이 회수되었습니다."
subtitle = "이 테넌트의 자원을 관리할 수 있는 권한을 가진 사용자들입니다."
title = "테넌트 관리자 설정"
[ui.admin.tenants.admins]
add_button = "관리자 추가"
already_admin = "이미 관리자"
dialog_description = "이름 또는 이메일로 사용자를 검색하여 관리 권한을 부여하세요."
dialog_no_results = "검색 결과가 없습니다."
dialog_search_hint = "검색어를 입력해 주세요."
dialog_search_placeholder = "사용자 검색 (최소 2자)..."
dialog_title = "새 관리자 추가"
remove_title = "관리자 권한 회수"
table_actions = "액션"
table_email = "이메일"
table_name = "이름"
title = "테넌트 관리자"
[msg.admin.tenants.create] [msg.admin.tenants.create]
subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다." subtitle = "글로벌 운영 기준의 신규 테넌트를 등록합니다."
@@ -148,11 +193,11 @@ empty = "소속된 사용자가 없습니다."
count = "총 {{count}}개 테넌트" count = "총 {{count}}개 테넌트"
[msg.admin.tenants.schema] [msg.admin.tenants.schema]
empty = "No custom fields defined. Click \\\"Add Field\\\" to begin." empty = "정의된 커스텀 필드가 없습니다. \\\"필드 추가\\\"를 눌러 시작하세요."
missing_id = "Tenant ID missing" missing_id = "테넌트 ID가 없습니다."
subtitle = "Define custom attributes for users in this tenant." subtitle = "이 테넌트 사용자를 위한 커스텀 속성을 정의합니다."
update_error = "Failed to update schema" update_error = "스키마 업데이트에 실패했습니다."
update_success = "Schema updated successfully" update_success = "스키마가 성공적으로 업데이트되었습니다."
[msg.admin.tenants.sub] [msg.admin.tenants.sub]
empty = "하위 테넌트가 없습니다." empty = "하위 테넌트가 없습니다."
@@ -655,19 +700,38 @@ status = "STATUS"
time = "TIME" time = "TIME"
[ui.admin.groups] [ui.admin.groups]
add_unit = "조직 추가"
import_csv = "CSV 임포트"
[ui.admin.groups.create] [ui.admin.groups.create]
title = "새 그룹 생성" description = "부서나 팀과 같은 새로운 조직 단위를 추가합니다."
title = "새 조직 단위 생성"
[ui.admin.groups.detail]
breadcrumb_org = "조직 관리"
breadcrumb_tenant = "테넌트 상세"
breadcrumb_unit = "조직 단위"
members_title = "구성원 관리"
members_subtitle = "이 조직 단위에 소속된 사용자들을 관리합니다."
permissions_title = "권한 관리"
permissions_subtitle = "이 조직 단위가 다른 테넌트에 대해 가지는 역할을 관리합니다."
subtitle = "조직 단위의 구성원 및 권한을 관리합니다."
title = "조직 단위 상세"
[ui.admin.groups.form] [ui.admin.groups.form]
desc_label = "설명" desc_label = "설명"
desc_placeholder = "그룹 용도 설명" desc_placeholder = "조직 단위 용도 설명"
name_label = "그룹 이름" name_label = "조직명"
name_placeholder = "예: 개발팀, 인사팀" name_placeholder = "예: 개발팀, 인사팀"
parent_label = "상위 조직"
parent_none = "없음 (최상위)"
submit = "생성하기" submit = "생성하기"
unit_level_label = "조직 레벨"
unit_level_placeholder = "예: 본부, 실, 팀, 셀"
[ui.admin.groups.list] [ui.admin.groups.list]
title = "User Groups" subtitle = "이 테넌트에 정의된 조직 단위(부서, 팀 등) 목록입니다."
title = "조직 관리"
[ui.admin.groups.members] [ui.admin.groups.members]
@@ -677,19 +741,21 @@ name = "이름"
remove = "제거" remove = "제거"
[ui.admin.groups.table] [ui.admin.groups.table]
actions = "ACTIONS" actions = "액션"
members = "MEMBERS" created_at = "생성일"
name = "NAME" level = "레벨"
members = "멤버"
name = "이름"
[ui.admin.header] [ui.admin.header]
plane = "Admin Plane" plane = "Admin Plane"
[ui.admin.overview] [ui.admin.overview]
kicker = "Global Overview" kicker = "글로벌 개요"
title = "Tenant-independent control plane" title = "테넌트 통합 관리 평면"
[ui.admin.overview.playbook] [ui.admin.overview.playbook]
title = "Admin playbook" title = "운영 플레이북"
[ui.admin.overview.quick_links] [ui.admin.overview.quick_links]
add_tenant = "테넌트 추가" add_tenant = "테넌트 추가"
@@ -697,6 +763,12 @@ tenant_dashboard = "테넌트 대시보드"
title = "빠른 이동" title = "빠른 이동"
view_audit_logs = "감사 로그 보기" view_audit_logs = "감사 로그 보기"
[ui.admin.overview.summary]
audit_events_24h = "감사 이벤트 (24h)"
oidc_clients = "OIDC 클라이언트"
policy_gate = "정책 게이트"
total_tenants = "전체 테넌트"
[ui.admin.role] [ui.admin.role]
rp_admin = "RP ADMIN" rp_admin = "RP ADMIN"
super_admin = "SUPER ADMIN" super_admin = "SUPER ADMIN"
@@ -714,24 +786,44 @@ section = "Tenants"
[ui.admin.tenants.create] [ui.admin.tenants.create]
title = "테넌트 추가" title = "테넌트 추가"
[ui.admin.tenants.detail]
breadcrumb_list = "테넌트 목록"
header_subtitle = "테넌트 정보를 수정하거나 연동 설정을 관리합니다."
loading = "테넌트 정보를 불러오는 중..."
tab_admins = "관리자 설정"
tab_federation = "외부 연동"
tab_organization = "조직 관리"
tab_profile = "프로필"
tab_schema = "사용자 스키마"
title = "테넌트 상세"
[ui.admin.tenants.create.breadcrumb] [ui.admin.tenants.create.breadcrumb]
action = "Create" action = "Create"
section = "Tenants" section = "Tenants"
[ui.admin.tenants.create.form] [ui.admin.tenants.create.form]
description = "Description" description = "설명"
domains_label = "Allowed Domains (Comma separated)" domains_label = "허용된 도메인 (콤마로 구분)"
domains_placeholder = "example.com, example.kr" domains_placeholder = "example.com, example.kr"
name = "Tenant name" name = "테넌트 이름"
slug = "Slug" slug = "슬러그 (Slug)"
slug_placeholder = "tenant-slug" slug_placeholder = "tenant-slug"
status = "Status" status = "상태"
type = "테넌트 유형"
[ui.admin.tenants.create.memo] [ui.admin.tenants.create.memo]
title = "정책 메모" title = "정책 메모"
[ui.admin.tenants.create.profile] [ui.admin.tenants.profile]
title = "Tenant Profile" allowed_domains = "허용된 도메인 (콤마로 구분)"
allowed_domains_help = "이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다."
description = "설명"
name = "테넌트 이름"
slug = "슬러그 (Slug)"
status = "상태"
subtitle = "슬러그 및 상태 변경은 즉시 적용됩니다."
title = "테넌트 프로필"
type = "테넌트 유형"
[ui.admin.tenants.members] [ui.admin.tenants.members]
title = "Tenant Members ({{count}})" title = "Tenant Members ({{count}})"
@@ -742,23 +834,35 @@ name = "NAME"
role = "ROLE" role = "ROLE"
status = "STATUS" status = "STATUS"
[ui.admin.tenants.profile]
allowed_domains = "허용된 도메인 (콤마로 구분)"
allowed_domains_help = "이 도메인을 가진 이메일로 가입한 사용자는 자동으로 이 테넌트에 배정됩니다."
approve_button = "테넌트 승인"
description = "설명"
name = "테넌트 이름"
slug = "슬러그 (Slug)"
status = "상태"
subtitle = "슬러그 및 상태 변경은 즉시 적용됩니다."
title = "테넌트 프로필"
type = "테넌트 유형"
[ui.admin.tenants.registry] [ui.admin.tenants.registry]
title = "Tenant registry" title = "Tenant registry"
[ui.admin.tenants.schema] [ui.admin.tenants.schema]
add_field = "Add Field" add_field = "필드 추가"
save = "Save Schema Changes" save = "스키마 변경사항 저장"
title = "User Schema Extension" title = "사용자 스키마 확장"
[ui.admin.tenants.schema.field] [ui.admin.tenants.schema.field]
key = "Field Key (ID)" key = "필드 키 (ID)"
key_placeholder = "e.g. employee_id" key_placeholder = "예: employee_id"
label = "Display Label" label = "표시 라벨"
label_placeholder = "e.g. 사번" label_placeholder = "예: 사번"
type = "Type" type = "유형"
type_boolean = "Boolean" type_boolean = "불리언 (Boolean)"
type_number = "Number" type_number = "숫자 (Number)"
type_text = "Text" type_text = "텍스트 (Text)"
[ui.admin.tenants.sub] [ui.admin.tenants.sub]
add = "하위 테넌트 추가" add = "하위 테넌트 추가"
@@ -790,8 +894,8 @@ title = "사용자 추가"
title = "계정 정보" title = "계정 정보"
[ui.admin.users.create.breadcrumb] [ui.admin.users.create.breadcrumb]
new = "New" new = "신규"
section = "Users" section = "사용자 관리"
[ui.admin.users.create.custom_fields] [ui.admin.users.create.custom_fields]
title = "테넌트 확장 정보 (Custom Fields)" title = "테넌트 확장 정보 (Custom Fields)"
@@ -802,12 +906,16 @@ department = "부서"
department_placeholder = "개발팀" department_placeholder = "개발팀"
email = "이메일" email = "이메일"
email_placeholder = "user@example.com" email_placeholder = "user@example.com"
job_title = "직무"
job_title_placeholder = "프론트엔드 개발"
name = "이름" name = "이름"
name_placeholder = "홍길동" name_placeholder = "홍길동"
password = "비밀번호" password = "비밀번호"
password_placeholder = "********" password_placeholder = "********"
phone = "전화번호" phone = "전화번호"
phone_placeholder = "010-1234-5678" phone_placeholder = "010-1234-5678"
position = "직급"
position_placeholder = "수석/책임/선임"
role = "역할 (Role)" role = "역할 (Role)"
tenant = "테넌트 (Tenant)" tenant = "테넌트 (Tenant)"
tenant_global = "시스템 전역 (소속 없음)" tenant_global = "시스템 전역 (소속 없음)"
@@ -821,7 +929,7 @@ edit_title = "정보 수정"
title = "사용자 상세" title = "사용자 상세"
[ui.admin.users.detail.breadcrumb] [ui.admin.users.detail.breadcrumb]
section = "Users" section = "사용자 관리"
[ui.admin.users.detail.custom_fields] [ui.admin.users.detail.custom_fields]
title = "테넌트 확장 정보 (Custom Fields)" title = "테넌트 확장 정보 (Custom Fields)"
@@ -829,10 +937,14 @@ title = "테넌트 확장 정보 (Custom Fields)"
[ui.admin.users.detail.form] [ui.admin.users.detail.form]
department = "부서" department = "부서"
department_placeholder = "개발팀" department_placeholder = "개발팀"
job_title = "직무"
job_title_placeholder = "프론트엔드 개발"
name = "이름" name = "이름"
name_placeholder = "홍길동" name_placeholder = "홍길동"
phone = "전화번호" phone = "전화번호"
phone_placeholder = "010-1234-5678" phone_placeholder = "010-1234-5678"
position = "직급"
position_placeholder = "수석/책임/선임"
role = "역할 (Role)" role = "역할 (Role)"
status = "상태" status = "상태"
tenant = "테넌트 (Tenant)" tenant = "테넌트 (Tenant)"
@@ -852,19 +964,20 @@ tenant_slug = "Slug: {{slug}}"
title = "사용자 관리" title = "사용자 관리"
[ui.admin.users.list.breadcrumb] [ui.admin.users.list.breadcrumb]
list = "List" list = "목록"
section = "Users" section = "사용자 관리"
[ui.admin.users.list.registry] [ui.admin.users.list.registry]
title = "User Registry" title = "사용자 레지스트리"
[ui.admin.users.list.table] [ui.admin.users.list.table]
actions = "ACTIONS" actions = "액션"
created = "CREATED" created = "생성일"
name_email = "NAME / EMAIL" name_email = "이름 / 이메일"
role = "ROLE" position_job = "직급 / 직무"
status = "STATUS" role = "역할"
tenant_dept = "TENANT / DEPT" status = "상태"
tenant_dept = "테넌트 / 부서"
[ui.common] [ui.common]
@@ -882,10 +995,10 @@ edit = "편집"
hyphen = "-" hyphen = "-"
na = "N/A" na = "N/A"
never = "Never" never = "Never"
next = "Next" next = "다음"
page_of = "Page {{page}} of {{total}}" page_of = "{{page}} / {{total}} 페이지"
prev = "이전" prev = "이전"
previous = "Previous" previous = "이전"
qr = "QR" qr = "QR"
read_only = "읽기 전용" read_only = "읽기 전용"
refresh = "새로고침" refresh = "새로고침"
@@ -1330,6 +1443,18 @@ verify = "본인인증"
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "로그인하기" action = "로그인하기"
[msg.admin]
header_subtitle = "테넌트 격리 및 최소 권한 원칙 기본 적용"
idp_env_prod = "IDP 환경: 운영(Prod)"
logout_confirm = "로그아웃 하시겠습니까?"
scope_admin = "/admin 네임스페이스 한정"
session_ttl = "세션 유효기간: 15분"
tenant_headers = "테넌트 식별 헤더 적용"
[ui.admin]
brand = "Baron 로그인"
title = "운영 도구"
[ui.admin.nav] [ui.admin.nav]
api_keys = "API 키" api_keys = "API 키"
audit_logs = "감사 로그" audit_logs = "감사 로그"
@@ -1338,6 +1463,6 @@ logout = "로그아웃"
overview = "개요" overview = "개요"
relying_parties = "애플리케이션(RP)" relying_parties = "애플리케이션(RP)"
tenant_dashboard = "테넌트 대시보드" tenant_dashboard = "테넌트 대시보드"
user_groups = "유저 그룹" user_groups = "조직 관리"
tenants = "테넌트" tenants = "테넌트"
users = "사용자" users = "사용자"

View File

@@ -0,0 +1,8 @@
import "@testing-library/jest-dom";
import { cleanup } from "@testing-library/react";
import { afterEach } from "vitest";
// 각 테스트가 끝날 때마다 DOM을 정리합니다.
afterEach(() => {
cleanup();
});

View File

@@ -1,233 +0,0 @@
import { expect, test } from "@playwright/test";
type UserSummary = {
id: string;
email: string;
name: string;
phone?: string;
role: string;
status: string;
companyCode?: string;
department?: string;
createdAt: string;
updatedAt: string;
};
type UserCreatePayload = {
email: string;
password?: string;
name: string;
phone?: string;
role?: string;
companyCode?: string;
department?: string;
};
test("user create and delete flow", async ({ page }) => {
const nowInSeconds = Math.floor(Date.now() / 1000);
await page.addInitScript((issuedAt) => {
const mockOidcUser = {
id_token: "playwright-id-token",
session_state: "playwright-session",
access_token: "playwright-access-token",
refresh_token: "playwright-refresh-token",
token_type: "Bearer",
scope: "openid profile email",
profile: {
sub: "playwright-admin",
email: "admin@example.com",
name: "Playwright Admin",
},
expires_at: issuedAt + 3600,
};
window.localStorage.setItem("admin_session", mockOidcUser.access_token);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc:adminfront",
JSON.stringify(mockOidcUser),
);
window.localStorage.setItem(
"oidc.user:http://localhost:5000/oidc/:adminfront",
JSON.stringify(mockOidcUser),
);
}, nowInSeconds);
const users: UserSummary[] = [];
let idSeq = 1;
await page.route("**/api/v1/admin/tenants**", async (route) => {
const request = route.request();
if (request.method() !== "GET") {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: "Not found" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items: [
{
id: "tenant-e2e",
name: "E2E Tenant",
slug: "e2e",
description: "Playwright tenant",
status: "active",
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
],
limit: 100,
offset: 0,
total: 1,
}),
});
});
await page.route("**/api/v1/admin/tenants/*", async (route) => {
const request = route.request();
if (request.method() !== "GET") {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: "Not found" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
id: "tenant-e2e",
name: "E2E Tenant",
slug: "e2e",
description: "Playwright tenant",
status: "active",
config: { userSchema: [] },
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}),
});
});
await page.route("**/api/v1/admin/users**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const path = url.pathname;
const isCollection = path.endsWith("/api/v1/admin/users");
const isItem = path.includes("/api/v1/admin/users/");
if (request.method() === "GET" && isCollection) {
const search = url.searchParams.get("search")?.toLowerCase() ?? "";
const limit = Number(url.searchParams.get("limit") ?? "50");
const offset = Number(url.searchParams.get("offset") ?? "0");
const filtered = search
? users.filter(
(user) =>
user.name.toLowerCase().includes(search) ||
user.email.toLowerCase().includes(search),
)
: users;
const items = filtered.slice(offset, offset + limit);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
items,
limit,
offset,
total: filtered.length,
}),
});
return;
}
if (request.method() === "POST" && isCollection) {
const payload = request.postDataJSON() as UserCreatePayload;
const now = new Date().toISOString();
const user: UserSummary = {
id: `user-${idSeq++}`,
email: payload.email,
name: payload.name,
phone: payload.phone,
role: payload.role ?? "user",
status: "active",
companyCode: payload.companyCode,
department: payload.department,
createdAt: now,
updatedAt: now,
};
users.unshift(user);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(user),
});
return;
}
if (request.method() === "DELETE" && isItem) {
const userId = path.split("/").pop();
const index = users.findIndex((user) => user.id === userId);
if (index === -1) {
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: "User not found" }),
});
return;
}
users.splice(index, 1);
await route.fulfill({ status: 204, body: "" });
return;
}
await route.fulfill({
status: 404,
contentType: "application/json",
body: JSON.stringify({ error: "Not found" }),
});
});
await page.goto("/users");
await expect(page).toHaveURL(/\/users$/);
await expect(
page.getByRole("heading", { name: "사용자 관리" }),
).toBeVisible();
const addUserLink = page.getByRole("link", { name: "사용자 추가" });
await expect(addUserLink).toBeVisible();
await page.goto("/users/new");
await expect(page).toHaveURL(/\/users\/new$/);
const uniqueEmail = `playwright-${Date.now()}@example.com`;
await page.getByRole("checkbox", { name: "자동 생성" }).setChecked(false);
await page.getByLabel("이메일").fill(uniqueEmail);
await page.getByLabel("비밀번호").fill("Test1234!");
await page.getByLabel("이름").fill("Playwright User");
await page.getByLabel("전화번호").fill("010-0000-0000");
await page.getByLabel("부서").fill("QA");
await page.getByLabel("역할 (Role)").selectOption("admin");
await page.getByRole("button", { name: "사용자 생성" }).click();
await expect(page).toHaveURL(/\/users$/);
const createdRow = page.locator("tbody tr").filter({ hasText: uniqueEmail });
await expect(createdRow).toBeVisible();
page.once("dialog", (dialog) => dialog.accept());
await createdRow.getByRole("button", { name: /사용자 삭제/ }).click();
await expect(
page.locator("tbody tr").filter({ hasText: uniqueEmail }),
).toHaveCount(0);
});

View File

@@ -0,0 +1,12 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
globals: true,
environment: "jsdom",
setupFiles: "./src/test/setup.ts",
include: ["src/**/*.{test,spec}.{ts,tsx}"],
},
});

View File

@@ -10,6 +10,7 @@ import (
"baron-sso-backend/internal/repository" "baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"baron-sso-backend/internal/validator" "baron-sso-backend/internal/validator"
"context"
"fmt" "fmt"
"log" "log"
"log/slog" "log/slog"
@@ -209,6 +210,12 @@ func main() {
slog.Error("❌ Bootstrap failed", "error", err) slog.Error("❌ Bootstrap failed", "error", err)
} }
// [New] Initialize Keto Outbox and Worker
ketoOutboxRepo := repository.NewKetoOutboxRepository(db)
ketoRelayWorker := service.NewKetoRelayWorker(ketoOutboxRepo, ketoService)
go ketoRelayWorker.Start(context.Background())
slog.Info("✅ Keto Relay Worker started")
// [Moved & Enhanced] Seed Admin Identity & Sync Local Role // [Moved & Enhanced] Seed Admin Identity & Sync Local Role
if kratosID, err := bootstrap.SeedAdminIdentity(idpProvider); err != nil { if kratosID, err := bootstrap.SeedAdminIdentity(idpProvider); err != nil {
slog.Error("❌ Admin identity seed failed", "error", err) slog.Error("❌ Admin identity seed failed", "error", err)
@@ -253,28 +260,32 @@ func main() {
tenantRepo := repository.NewTenantRepository(db) tenantRepo := repository.NewTenantRepository(db)
userGroupRepo := repository.NewUserGroupRepository(db) userGroupRepo := repository.NewUserGroupRepository(db)
userRepo := repository.NewUserRepository(db) userRepo := repository.NewUserRepository(db)
ketoOutboxRepo := repository.NewKetoOutboxRepository(db) // Reuse or re-init
kratosAdminService := service.NewKratosAdminService() kratosAdminService := service.NewKratosAdminService()
oryAdminProvider := service.NewOryProvider() oryAdminProvider := service.NewOryProvider()
tenantService := service.NewTenantService(tenantRepo, userRepo) tenantService := service.NewTenantService(tenantRepo, userRepo, ketoOutboxRepo)
userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, kratosAdminService) userGroupService := service.NewUserGroupService(userGroupRepo, userRepo, tenantRepo, ketoService, ketoOutboxRepo, kratosAdminService)
tenantService.SetKetoService(ketoService) // Keto 주입 tenantService.SetKetoService(ketoService) // Keto 주입
hydraService := service.NewHydraAdminService() hydraService := service.NewHydraAdminService()
relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService) relyingPartyService := service.NewRelyingPartyService(hydraService, ketoService, ketoOutboxRepo)
secretRepo := repository.NewClientSecretRepository(db) secretRepo := repository.NewClientSecretRepository(db)
consentRepo := repository.NewClientConsentRepository(db) consentRepo := repository.NewClientConsentRepository(db)
auditHandler := handler.NewAuditHandler(auditRepo) auditHandler := handler.NewAuditHandler(auditRepo)
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, userRepo, consentRepo) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
adminHandler := handler.NewAdminHandler(ketoService) adminHandler := handler.NewAdminHandler(ketoService)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler) devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler)
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, kratosAdminService) tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, ketoOutboxRepo, kratosAdminService)
userGroupHandler := handler.NewUserGroupHandler(userGroupService) userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, userRepo) userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo)
apiKeyHandler := handler.NewApiKeyHandler(db) apiKeyHandler := handler.NewApiKeyHandler(db)
orgChartService := service.NewOrgChartService(tenantRepo, userGroupRepo, userRepo, ketoOutboxRepo, kratosAdminService)
orgChartHandler := handler.NewOrgChartHandler(orgChartService)
// 3. Initialize Fiber // 3. Initialize Fiber
appEnv := getEnv("APP_ENV", "dev") appEnv := getEnv("APP_ENV", "dev")
app := fiber.New(fiber.Config{ app := fiber.New(fiber.Config{
@@ -550,18 +561,19 @@ func main() {
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)
// User Group Management (Tenant Admin/Super Admin) // Organization & Org-Chart Management (Tenant Admin/Super Admin)
userGroups := admin.Group("/tenants/:tenantId/user-groups", requireAdmin) org := admin.Group("/tenants/:tenantId/organization", requireAdmin)
userGroups.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List) org.Post("/import", orgChartHandler.ImportCSV) // CSV Import API
userGroups.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create) org.Get("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.List)
userGroups.Get("/:id", userGroupHandler.Get) // 권한 체크 일시 제거 org.Post("/", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Create)
userGroups.Put("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Update) org.Get("/:id", userGroupHandler.Get)
userGroups.Delete("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Delete) org.Put("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Update)
userGroups.Post("/:id/members", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AddMember) org.Delete("/:id", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.Delete)
userGroups.Delete("/:id/members/:userId", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveMember) org.Post("/:id/members", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AddMember)
userGroups.Get("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.ListRoles) org.Delete("/:id/members/:userId", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveMember)
userGroups.Post("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AssignRole) org.Get("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "view"), userGroupHandler.ListRoles)
userGroups.Delete("/:id/roles/:tenantId/:relation", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveRole) org.Post("/:id/roles", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.AssignRole)
org.Delete("/:id/roles/:tenantId/:relation", middleware.RequireKetoPermission(middleware.RBACConfig{AuthHandler: authHandler, KetoService: ketoService}, "Tenant", "manage"), userGroupHandler.RemoveRole)
// Relying Party Management (Global List) // Relying Party Management (Global List)
admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll) admin.Get("/relying-parties", requireAdmin, relyingPartyHandler.ListAll)

View File

@@ -24,7 +24,10 @@ require (
) )
require ( require (
dario.cat/mergo v1.0.2 // indirect
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/ClickHouse/ch-go v0.69.0 // indirect github.com/ClickHouse/ch-go v0.69.0 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect github.com/andybalholm/brotli v1.2.0 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.17 // indirect
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.17 // indirect
@@ -37,13 +40,28 @@ require (
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.13 // indirect
github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.41.6 // indirect
github.com/aws/smithy-go v1.24.0 // indirect github.com/aws/smithy-go v1.24.0 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/docker v28.5.2+incompatible // indirect
github.com/docker/go-connections v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/ebitengine/purego v0.8.4 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-faster/city v1.0.1 // indirect github.com/go-faster/city v1.0.1 // indirect
github.com/go-faster/errors v0.7.1 // indirect github.com/go-faster/errors v0.7.1 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.2.6 // indirect
github.com/goccy/go-json v0.10.4 // indirect github.com/goccy/go-json v0.10.4 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -58,21 +76,45 @@ require (
github.com/lestrrat-go/iter v1.0.2 // indirect github.com/lestrrat-go/iter v1.0.2 // indirect
github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect github.com/lestrrat-go/jwx/v2 v2.1.6 // indirect
github.com/lestrrat-go/option v1.0.1 // indirect github.com/lestrrat-go/option v1.0.1 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.0 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/paulmach/orb v0.12.0 // indirect github.com/paulmach/orb v0.12.0 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/rivo/uniseg v0.2.0 // indirect github.com/rivo/uniseg v0.2.0 // indirect
github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect
github.com/segmentio/asm v1.2.1 // indirect github.com/segmentio/asm v1.2.1 // indirect
github.com/shirou/gopsutil/v4 v4.25.6 // indirect
github.com/shopspring/decimal v1.4.0 // indirect github.com/shopspring/decimal v1.4.0 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/stretchr/objx v0.5.2 // indirect github.com/stretchr/objx v0.5.2 // indirect
github.com/testcontainers/testcontainers-go v0.40.0 // indirect
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect github.com/valyala/tcplisten v1.0.0 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.39.0 // indirect go.opentelemetry.io/otel v1.39.0 // indirect
go.opentelemetry.io/otel/metric v1.39.0 // indirect
go.opentelemetry.io/otel/trace v1.39.0 // indirect go.opentelemetry.io/otel/trace v1.39.0 // indirect
go.yaml.in/yaml/v3 v3.0.4 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect golang.org/x/exp v0.0.0-20220921023135-46d9e7742f1e // indirect

View File

@@ -1,7 +1,13 @@
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/ClickHouse/ch-go v0.69.0 h1:nO0OJkpxOlN/eaXFj0KzjTz5p7vwP1/y3GN4qc5z/iM= github.com/ClickHouse/ch-go v0.69.0 h1:nO0OJkpxOlN/eaXFj0KzjTz5p7vwP1/y3GN4qc5z/iM=
github.com/ClickHouse/ch-go v0.69.0/go.mod h1:9XeZpSAT4S0kVjOpaJ5186b7PY/NH/hhF8R6u0WIjwg= github.com/ClickHouse/ch-go v0.69.0/go.mod h1:9XeZpSAT4S0kVjOpaJ5186b7PY/NH/hhF8R6u0WIjwg=
github.com/ClickHouse/clickhouse-go/v2 v2.42.0 h1:MdujEfIrpXesQUH0k0AnuVtJQXk6RZmxEhsKUCcv5xk= github.com/ClickHouse/clickhouse-go/v2 v2.42.0 h1:MdujEfIrpXesQUH0k0AnuVtJQXk6RZmxEhsKUCcv5xk=
github.com/ClickHouse/clickhouse-go/v2 v2.42.0/go.mod h1:riWnuo4YMVdajYll0q6FzRBomdyCrXyFY3VXeXczA8s= github.com/ClickHouse/clickhouse-go/v2 v2.42.0/go.mod h1:riWnuo4YMVdajYll0q6FzRBomdyCrXyFY3VXeXczA8s=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU= github.com/aws/aws-sdk-go-v2 v1.41.1 h1:ABlyEARCDLN034NhxlRUSZr4l71mh+T5KAeGh6cerhU=
@@ -36,10 +42,22 @@ github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A=
github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA=
github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -49,6 +67,18 @@ github.com/descope/go-sdk v1.7.0 h1:DIRmnA4Q8TDtWdGJ9z0I11+AWMrzyNiiozFH557LrgQ=
github.com/descope/go-sdk v1.7.0/go.mod h1:lCwCgYOfrgjANMsR2BVe1yfX0Siwd2NjNAig0myWZqY= github.com/descope/go-sdk v1.7.0/go.mod h1:lCwCgYOfrgjANMsR2BVe1yfX0Siwd2NjNAig0myWZqY=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM=
github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94=
github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE=
github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4=
github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw= github.com/go-faster/city v1.0.1 h1:4WAxSZ3V2Ws4QRDrscLEDcibJY8uf41H6AhXDrNDcGw=
@@ -57,6 +87,13 @@ github.com/go-faster/errors v0.7.1 h1:MkJTnDoEdi9pDabt1dpWf7AA8/BaSYZqibYyhZ20AY
github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo= github.com/go-faster/errors v0.7.1/go.mod h1:5ySTjWFiphBs07IKuiL69nxdfd5+fzh1u7FPGZP2quo=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs= github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI=
github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo=
github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM= github.com/goccy/go-json v0.10.4 h1:JSwxQzIqKfmFX1swYPpUThQZp/Ka4wzJdK0LWVytLPM=
@@ -68,6 +105,7 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS
github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.1/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
@@ -112,6 +150,10 @@ github.com/lestrrat-go/option v1.0.1 h1:oAzP2fvZGQKWkvHa1/SAcFolBEca1oN+mQ7eooNB
github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I=
github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI= github.com/lib/pq v1.11.1 h1:wuChtj2hfsGmmx3nf1m7xC2XpK6OtelS2shMY+bGMtI=
github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= github.com/lib/pq v1.11.1/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -119,29 +161,57 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko=
github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs=
github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs=
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc= github.com/montanaflynn/stats v0.0.0-20171201202039-1bf9dbcd8cbe/go.mod h1:wL8QJuTMNUDYhXwkmfOly8iTdp5TEcJFWZD2D7SIkUc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE= github.com/onsi/gomega v1.18.1 h1:M1GfJqGRrBrrGGsbxzV5dqM2U2ApXefZCQpkukxYRLE=
github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs= github.com/onsi/gomega v1.18.1/go.mod h1:0q+aL8jAiMXy9hbwj2mr5GziHiwhAIQpFmmtT5hitRs=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s= github.com/paulmach/orb v0.12.0 h1:z+zOwjmG3MyEEqzv92UN49Lg1JFYx0L9GpGKNVDKk1s=
github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU= github.com/paulmach/orb v0.12.0/go.mod h1:5mULz1xQfs3bmQm63QEJA6lNGujuRafwA5S/EnuLaLU=
github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY= github.com/paulmach/protoscan v0.2.1/go.mod h1:SpcSwydNLrxUGSDvXvO0P7g7AuhJ7lcKfDlhJCDw2gY=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0= github.com/segmentio/asm v1.2.1 h1:DTNbBqs57ioxAD4PrArqftgypG4/qNpXoJx8TVXxPR0=
github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs=
github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI=
github.com/shirou/gopsutil/v4 v4.25.6 h1:kLysI2JsKorfaFPcYmcJqbzROzsBWEOAtw6A7dIfqXs=
github.com/shirou/gopsutil/v4 v4.25.6/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k=
github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
@@ -151,7 +221,15 @@ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0/go.mod h1:h+u/2KoREGTnTl9UwrQ/g+XhasAT8E6dClclAADeXoQ=
github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk= github.com/tidwall/pretty v1.0.0/go.mod h1:XNkn88O1ChpSDQmQeStsy+sBenx6DDtFZJxhVysOjyk=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA=
@@ -166,9 +244,17 @@ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3i
github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA= github.com/youmark/pkcs8 v0.0.0-20181117223130-1be2e3e5546d/go.mod h1:rHwXgn7JulP+udvsHwJoVG1YGAP6VLg4y9I5dyZdqmA=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g= go.mongodb.org/mongo-driver v1.11.4/go.mod h1:PTSz5yu21bkT/wXpkS7WR5f0ddqw5quethTUn9WM+2g=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=
@@ -200,12 +286,18 @@ golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=

View File

@@ -39,6 +39,7 @@ func migrateSchemas(db *gorm.DB) error {
&domain.IdentityProviderConfig{}, &domain.IdentityProviderConfig{},
&domain.ClientSecret{}, &domain.ClientSecret{},
&domain.ClientConsent{}, &domain.ClientConsent{},
&domain.KetoOutbox{},
// &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto // &domain.RelyingParty{}, // Removed: SSOT is Hydra + Keto
) )
} }

View File

@@ -23,7 +23,7 @@ func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error {
slog.Info("Syncing tenants to Keto", "count", len(tenants)) slog.Info("Syncing tenants to Keto", "count", len(tenants))
for _, t := range tenants { for _, t := range tenants {
if t.ParentID != nil { if t.ParentID != nil {
_ = keto.CreateRelation(ctx, "Tenant", t.ID, "parent", *t.ParentID) _ = keto.CreateRelation(ctx, "Tenant", t.ID, "parents", "Tenant:"+*t.ParentID)
} }
} }
@@ -36,14 +36,14 @@ func SyncKetoRelations(db *gorm.DB, keto service.KetoService) error {
for _, u := range users { for _, u := range users {
// Membership // Membership
if u.TenantID != nil { if u.TenantID != nil {
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "members", u.ID) _ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "members", "User:"+u.ID)
} }
// Roles // Roles
if u.Role == domain.RoleSuperAdmin { if u.Role == domain.RoleSuperAdmin {
_ = keto.CreateRelation(ctx, "System", "global", "super_admins", u.ID) _ = keto.CreateRelation(ctx, "System", "global", "super_admins", "User:"+u.ID)
} else if u.Role == domain.RoleTenantAdmin && u.TenantID != nil { } else if u.Role == domain.RoleTenantAdmin && u.TenantID != nil {
_ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", u.ID) _ = keto.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", "User:"+u.ID)
} }
} }

View File

@@ -31,7 +31,8 @@ func SeedTenants(db *gorm.DB) error {
slog.Info("[Bootstrap] Seeding initial tenants...") slog.Info("[Bootstrap] Seeding initial tenants...")
repo := repository.NewTenantRepository(db) repo := repository.NewTenantRepository(db)
userRepo := repository.NewUserRepository(db) userRepo := repository.NewUserRepository(db)
svc := service.NewTenantService(repo, userRepo) outboxRepo := repository.NewKetoOutboxRepository(db)
svc := service.NewTenantService(repo, userRepo, outboxRepo)
ctx := context.Background() ctx := context.Background()
for _, config := range defaultTenants { for _, config := range defaultTenants {
@@ -58,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, config.Description, config.Domains) tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, 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

@@ -0,0 +1,48 @@
package domain
import (
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
// KetoOutbox status
const (
KetoOutboxStatusPending = "pending"
KetoOutboxStatusProcessed = "processed"
KetoOutboxStatusFailed = "failed"
)
// KetoOutbox action
const (
KetoOutboxActionCreate = "CREATE"
KetoOutboxActionDelete = "DELETE"
)
// KetoOutbox represents a Keto relationship tuple update event.
type KetoOutbox struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
Namespace string `gorm:"not null" json:"namespace"`
Object string `gorm:"not null" json:"object"`
Relation string `gorm:"not null" json:"relation"`
Subject string `gorm:"not null" json:"subject"` // format: "User:ID" or "Tenant:ID#members"
Action string `gorm:"not null" json:"action"` // CREATE, DELETE
Status string `gorm:"default:'pending';index" json:"status"`
RetryCount int `gorm:"default:0" json:"retryCount"`
LastError string `json:"lastError,omitempty"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
ProcessedAt *time.Time `json:"processedAt,omitempty"`
}
func (ko *KetoOutbox) TableName() string {
return "keto_outbox"
}
func (ko *KetoOutbox) BeforeCreate(tx *gorm.DB) (err error) {
if ko.ID == "" {
ko.ID = uuid.NewString()
}
return
}

View File

@@ -15,9 +15,18 @@ const (
TenantStatusDeleted = "deleted" TenantStatusDeleted = "deleted"
) )
// Tenant types
const (
TenantTypePersonal = "PERSONAL"
TenantTypeCompany = "COMPANY"
TenantTypeCompanyGroup = "COMPANY_GROUP"
TenantTypeUserGroup = "USER_GROUP"
)
// Tenant represents a tenant model stored in PostgreSQL. // Tenant represents a tenant model stored in PostgreSQL.
type Tenant struct { type Tenant struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
Type string `gorm:"not null;default:'PERSONAL'" json:"type"` // PERSONAL, COMPANY, COMPANY_GROUP, USER_GROUP
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 부모 테넌트 ID
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Slug string `gorm:"uniqueIndex;not null" json:"slug"` Slug string `gorm:"uniqueIndex;not null" json:"slug"`

View File

@@ -29,6 +29,8 @@ type User struct {
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
RelyingPartyID *string `gorm:"type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용 RelyingPartyID *string `gorm:"type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
Department string `json:"department"` Department string `json:"department"`
Position string `json:"position"` // 직급 (예: 수석, 책임, 선임)
JobTitle string `json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획)
Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"` Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"`
Status string `gorm:"default:'active'" json:"status"` Status string `gorm:"default:'active'" json:"status"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`

View File

@@ -11,14 +11,24 @@ import (
type UserGroup struct { type UserGroup struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
TenantID string `gorm:"type:uuid;index;not null" json:"tenantId"` TenantID string `gorm:"type:uuid;index;not null" json:"tenantId"`
ParentID *string `gorm:"type:uuid;index" json:"parentId,omitempty"` // 상위 조직 ID
Name string `gorm:"not null" json:"name"` Name string `gorm:"not null" json:"name"`
Description string `json:"description"` Description string `json:"description"`
UnitType string `json:"unitType"` // 부, 국, 팀, 셀 등
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// Relationships // Relationships
Members []User `gorm:"-" json:"members,omitempty"` Parent *UserGroup `gorm:"foreignKey:ParentID" json:"parent,omitempty"`
Members []User `gorm:"-" json:"members,omitempty"`
}
type GroupCreateRequest struct {
Name string `json:"name"`
ParentID *string `json:"parentId"`
Description string `json:"description"`
UnitType string `json:"unitType"`
} }
type GroupRole struct { type GroupRole struct {

View File

@@ -82,13 +82,14 @@ type AuthHandler struct {
SmsService domain.SmsService SmsService domain.SmsService
EmailService domain.EmailService EmailService domain.EmailService
RedisService domain.RedisRepository RedisService domain.RedisRepository
KratosAdmin *service.KratosAdminService KratosAdmin service.KratosAdminService
IdpProvider domain.IdentityProvider IdpProvider domain.IdentityProvider
AuditRepo domain.AuditRepository AuditRepo domain.AuditRepository
OathkeeperRepo domain.OathkeeperLogRepository OathkeeperRepo domain.OathkeeperLogRepository
Hydra *service.HydraAdminService Hydra *service.HydraAdminService
TenantService service.TenantService TenantService service.TenantService
KetoService service.KetoService KetoService service.KetoService
KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository UserRepo repository.UserRepository
ConsentRepo repository.ClientConsentRepository ConsentRepo repository.ClientConsentRepository
} }
@@ -148,18 +149,19 @@ func checkPollInterval(redis domain.RedisRepository, key string, interval time.D
return false, int(interval.Seconds()) return false, int(interval.Seconds())
} }
func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository) *AuthHandler { func NewAuthHandler(redisService domain.RedisRepository, idpProvider domain.IdentityProvider, auditRepo domain.AuditRepository, oathkeeperRepo domain.OathkeeperLogRepository, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository, consentRepo repository.ClientConsentRepository, kratos service.KratosAdminService) *AuthHandler {
return &AuthHandler{ return &AuthHandler{
SmsService: service.NewSmsService(), SmsService: service.NewSmsService(),
EmailService: service.NewEmailService(), EmailService: service.NewEmailService(),
RedisService: redisService, RedisService: redisService,
KratosAdmin: service.NewKratosAdminService(), KratosAdmin: kratos,
IdpProvider: idpProvider, IdpProvider: idpProvider,
AuditRepo: auditRepo, AuditRepo: auditRepo,
OathkeeperRepo: oathkeeperRepo, OathkeeperRepo: oathkeeperRepo,
Hydra: service.NewHydraAdminService(), Hydra: service.NewHydraAdminService(),
TenantService: tenantService, TenantService: tenantService,
KetoService: ketoService, KetoService: ketoService,
KetoOutboxRepo: ketoOutboxRepo,
UserRepo: userRepo, UserRepo: userRepo,
ConsentRepo: consentRepo, ConsentRepo: consentRepo,
} }
@@ -497,20 +499,20 @@ func (h *AuthHandler) Signup(c *fiber.Ctx) error {
slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err) slog.Error("[Signup] Failed to sync user to Read-Model (Local DB)", "email", u.Email, "error", err)
} else { } else {
slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email) slog.Debug("[Signup] Synced user to Read-Model", "email", u.Email)
// [Keto] Sync user-tenant relationship via Outbox
if h.KetoOutboxRepo != nil && u.TenantID != nil {
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: *u.TenantID,
Relation: "members",
Subject: "User:" + u.ID,
Action: domain.KetoOutboxActionCreate,
})
}
} }
}(localUser) }(localUser)
} }
// [Keto] Sync user-tenant relationship
if h.KetoService != nil && tenantID != nil {
go func() {
err := h.KetoService.CreateRelation(context.Background(), "Tenant", *tenantID, "members", providerID)
if err != nil {
slog.Error("[Signup] Failed to sync membership to Keto", "userID", providerID, "tenantID", *tenantID, "error", err)
}
}()
}
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"success": true, "success": true,
"message": "User registered successfully", "message": "User registered successfully",

View File

@@ -81,6 +81,7 @@ func (m *AsyncMockUserRepo) Create(ctx context.Context, user *domain.User) error
return args.Error(0) return args.Error(0)
} }
func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error { return nil } func (m *AsyncMockUserRepo) Update(ctx context.Context, user *domain.User) error { return nil }
func (m *AsyncMockUserRepo) Delete(ctx context.Context, id string) error { return nil }
func (m *AsyncMockUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) { func (m *AsyncMockUserRepo) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, nil return nil, nil
} }
@@ -127,7 +128,7 @@ type AsyncMockTenantService struct {
mock.Mock mock.Mock
} }
func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) { func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) {
return nil, nil return nil, nil
} }

View File

@@ -10,6 +10,7 @@ import (
"github.com/gofiber/fiber/v2" "github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
) )
// --- Test Helpers --- // --- Test Helpers ---
@@ -112,12 +113,15 @@ func TestGetConsentRequest_Skip_AutoAccept(t *testing.T) {
AdminURL: "http://hydra.test", AdminURL: "http://hydra.test",
HTTPClient: client, HTTPClient: client,
}, },
KratosAdmin: &service.KratosAdminService{ KratosAdmin: new(MockKratosAdminService), // Reusing MockKratosAdminService if defined or use MockKratosAdminServiceShared
AdminURL: "http://kratos.test",
HTTPClient: client,
},
ConsentRepo: consentRepo, ConsentRepo: consentRepo,
} }
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123",
Traits: map[string]interface{}{
"email": "user@test.com",
},
}, nil)
app := newConsentTestApp(h) app := newConsentTestApp(h)
req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip", nil) req := httptest.NewRequest(http.MethodGet, "/api/v1/auth/consent?consent_challenge=challenge-skip", nil)
@@ -172,13 +176,16 @@ func TestAcceptConsentRequest_Normal(t *testing.T) {
AdminURL: "http://hydra.test", AdminURL: "http://hydra.test",
HTTPClient: client, HTTPClient: client,
}, },
KratosAdmin: &service.KratosAdminService{ KratosAdmin: new(MockKratosAdminService),
AdminURL: "http://kratos.test",
HTTPClient: client,
},
AuditRepo: auditRepo, AuditRepo: auditRepo,
ConsentRepo: consentRepo, ConsentRepo: consentRepo,
} }
h.KratosAdmin.(*MockKratosAdminService).On("GetIdentity", mock.Anything, "user-123").Return(&service.KratosIdentity{
ID: "user-123",
Traits: map[string]interface{}{
"email": "user@test.com",
},
}, nil)
app := newConsentTestApp(h) app := newConsentTestApp(h)

View File

@@ -106,7 +106,7 @@ func TestListLinkedRps_PriorityAndAggregation(t *testing.T) {
}, },
AuditRepo: auditRepo, AuditRepo: auditRepo,
ConsentRepo: consentRepo, ConsentRepo: consentRepo,
KratosAdmin: &service.KratosAdminService{}, KratosAdmin: new(MockKratosAdminService),
} }
t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test") t.Setenv("KRATOS_PUBLIC_URL", "http://kratos.test")

View File

@@ -81,15 +81,36 @@ func (m *MockIdentityProvider) UpdateUserPassword(loginID, newPassword string, r
} }
type MockKratosAdminService struct { type MockKratosAdminService struct {
// Simple mock for FindIdentityIDByIdentifier mock.Mock
} }
func (m *MockKratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) { func (m *MockKratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
// Always return a static ID for simplicity in this test args := m.Called(ctx, identifier)
if identifier == "fail" { return args.String(0), args.Error(1)
return "", errors.New("not found") }
func (m *MockKratosAdminService) GetIdentity(ctx context.Context, id string) (*service.KratosIdentity, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
} }
return "kratos-identity-id", nil return args.Get(0).(*service.KratosIdentity), args.Error(1)
}
func (m *MockKratosAdminService) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) {
return nil, nil
}
func (m *MockKratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) {
return nil, nil
}
func (m *MockKratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
return nil
}
func (m *MockKratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
return nil
} }
// --- Helper --- // --- Helper ---
@@ -142,30 +163,17 @@ func TestPasswordLogin_OIDC_Success(t *testing.T) {
} }
}) })
mockKratos := new(MockKratosAdminService)
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil)
h := &AuthHandler{ h := &AuthHandler{
IdpProvider: mockIdp, IdpProvider: mockIdp,
KratosAdmin: service.NewKratosAdminService(), // We need to mock this better if resolveKratosIdentityIDFromLoginID calls real API KratosAdmin: mockKratos,
Hydra: &service.HydraAdminService{ Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test", AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
}, },
} }
// Inject Mock Kratos (Hack: overwrite the service field if it was an interface, but it's a struct pointer)
// AuthHandler uses *service.KratosAdminService struct pointer.
// KratosAdminService methods are real. We need to mock HTTP client inside KratosAdminService too.
kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Mock FindIdentityIDByIdentifier response
if strings.Contains(r.URL.Path, "/identities") {
json.NewEncoder(w).Encode([]map[string]interface{}{
{"id": "kratos-identity-id"},
})
return
}
http.NotFound(w, r)
})
h.KratosAdmin.HTTPClient = &http.Client{Transport: mockHydraTransport(kratosHandler)}
h.KratosAdmin.AdminURL = "http://kratos.test"
app := newAuthLoginTestApp(h) app := newAuthLoginTestApp(h)
@@ -215,21 +223,18 @@ func TestPasswordLogin_OIDC_InactiveClient(t *testing.T) {
http.NotFound(w, r) http.NotFound(w, r)
}) })
mockKratos := new(MockKratosAdminService)
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil)
h := &AuthHandler{ h := &AuthHandler{
IdpProvider: mockIdp, IdpProvider: mockIdp,
KratosAdmin: service.NewKratosAdminService(), KratosAdmin: mockKratos,
Hydra: &service.HydraAdminService{ Hydra: &service.HydraAdminService{
AdminURL: "http://hydra.test", AdminURL: "http://hydra.test",
HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)}, HTTPClient: &http.Client{Transport: mockHydraTransport(hydraHandler)},
}, },
} }
kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]map[string]interface{}{{"id": "kratos-identity-id"}})
})
h.KratosAdmin.HTTPClient = &http.Client{Transport: mockHydraTransport(kratosHandler)}
h.KratosAdmin.AdminURL = "http://kratos.test"
app := newAuthLoginTestApp(h) app := newAuthLoginTestApp(h)
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{
@@ -259,18 +264,15 @@ func TestPasswordLogin_NoOIDC_Success(t *testing.T) {
Subject: "kratos-identity-id", Subject: "kratos-identity-id",
}, nil) }, nil)
mockKratos := new(MockKratosAdminService)
mockKratos.On("FindIdentityIDByIdentifier", mock.Anything, "user@example.com").Return("kratos-identity-id", nil)
h := &AuthHandler{ h := &AuthHandler{
IdpProvider: mockIdp, IdpProvider: mockIdp,
KratosAdmin: service.NewKratosAdminService(), KratosAdmin: mockKratos,
Hydra: service.NewHydraAdminService(), Hydra: service.NewHydraAdminService(),
} }
kratosHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode([]map[string]interface{}{{"id": "kratos-identity-id"}})
})
h.KratosAdmin.HTTPClient = &http.Client{Transport: mockHydraTransport(kratosHandler)}
h.KratosAdmin.AdminURL = "http://kratos.test"
app := newAuthLoginTestApp(h) app := newAuthLoginTestApp(h)
body, _ := json.Marshal(map[string]string{ body, _ := json.Marshal(map[string]string{

View File

@@ -23,7 +23,7 @@ type DevHandler struct {
Hydra *service.HydraAdminService Hydra *service.HydraAdminService
Redis domain.RedisRepository Redis domain.RedisRepository
SecretRepo domain.ClientSecretRepository SecretRepo domain.ClientSecretRepository
KratosAdmin *service.KratosAdminService KratosAdmin service.KratosAdminService
ConsentRepo repository.ClientConsentRepository ConsentRepo repository.ClientConsentRepository
Keto service.KetoService Keto service.KetoService
RPSvc service.RelyingPartyService RPSvc service.RelyingPartyService

View File

@@ -0,0 +1,41 @@
package handler
import (
"baron-sso-backend/internal/service"
"log/slog"
"github.com/gofiber/fiber/v2"
)
type OrgChartHandler struct {
Service service.OrgChartService
}
func NewOrgChartHandler(s service.OrgChartService) *OrgChartHandler {
return &OrgChartHandler{Service: s}
}
func (h *OrgChartHandler) ImportCSV(c *fiber.Ctx) error {
tenantID := c.Params("tenantId")
if tenantID == "" {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId is required"})
}
file, err := c.FormFile("file")
if err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "failed to get file from form"})
}
f, err := file.Open()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to open file"})
}
defer f.Close()
if err := h.Service.ImportCSV(c.Context(), tenantID, f); err != nil {
slog.Error("Failed to import CSV", "error", err, "tenantID", tenantID)
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
return c.JSON(fiber.Map{"message": "Import completed successfully"})
}

View File

@@ -10,10 +10,10 @@ import (
type RelyingPartyHandler struct { type RelyingPartyHandler struct {
Service service.RelyingPartyService Service service.RelyingPartyService
KratosAdmin *service.KratosAdminService KratosAdmin service.KratosAdminService
} }
func NewRelyingPartyHandler(s service.RelyingPartyService, kratos *service.KratosAdminService) *RelyingPartyHandler { func NewRelyingPartyHandler(s service.RelyingPartyService, kratos service.KratosAdminService) *RelyingPartyHandler {
return &RelyingPartyHandler{Service: s, KratosAdmin: kratos} return &RelyingPartyHandler{Service: s, KratosAdmin: kratos}
} }

View File

@@ -16,14 +16,16 @@ type TenantHandler struct {
DB *gorm.DB DB *gorm.DB
Service service.TenantService Service service.TenantService
Keto service.KetoService Keto service.KetoService
KratosAdmin *service.KratosAdminService KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService
} }
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, kratos *service.KratosAdminService) *TenantHandler { func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService) *TenantHandler {
return &TenantHandler{ return &TenantHandler{
DB: db, DB: db,
Service: svc, Service: svc,
Keto: keto, Keto: keto,
KetoOutbox: outbox,
KratosAdmin: kratos, KratosAdmin: kratos,
} }
} }
@@ -152,6 +154,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
Description string `json:"description"` Description string `json:"description"`
Status string `json:"status"` Status string `json:"status"`
Domains []string `json:"domains"` Domains []string `json:"domains"`
ParentID *string `json:"parentId"`
Config map[string]any `json:"config"` Config map[string]any `json:"config"`
} }
if err := c.BodyParser(&req); err != nil { if err := c.BodyParser(&req); err != nil {
@@ -177,7 +180,13 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
} }
// Use Service // Use Service
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, req.Description, req.Domains) var parentID *string
if req.ParentID != nil && strings.TrimSpace(*req.ParentID) != "" {
pid := strings.TrimSpace(*req.ParentID)
parentID = &pid
}
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, req.Description, req.Domains, parentID)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "already exists") { if strings.Contains(err.Error(), "already exists") {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()})
@@ -324,7 +333,7 @@ func (h *TenantHandler) ListAdmins(c *fiber.Ctx) error {
} }
// Fetch admins from Keto // Fetch admins from Keto
relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admin", "") relations, err := h.Keto.ListRelations(c.Context(), "Tenant", tenantID, "admins", "")
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
@@ -375,8 +384,14 @@ func (h *TenantHandler) AddAdmin(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"})
} }
if err := h.Keto.CreateRelation(c.Context(), "Tenant", tenantID, "admin", "User:"+userID); err != nil { if h.KetoOutbox != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
} }
return c.SendStatus(fiber.StatusOK) return c.SendStatus(fiber.StatusOK)
@@ -389,8 +404,14 @@ func (h *TenantHandler) RemoveAdmin(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "tenantId and userId are required"})
} }
if err := h.Keto.DeleteRelation(c.Context(), "Tenant", tenantID, "admin", "User:"+userID); err != nil { if h.KetoOutbox != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) _ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: "admins",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
} }
return c.SendStatus(fiber.StatusNoContent) return c.SendStatus(fiber.StatusNoContent)

View File

@@ -21,8 +21,8 @@ type MockTenantService struct {
mock.Mock mock.Mock
} }
func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) { func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) {
args := m.Called(ctx, name, slug, description, domains) args := m.Called(ctx, name, slug, description, domains, parentID)
if args.Get(0) == nil { if args.Get(0) == nil {
return nil, args.Error(1) return nil, args.Error(1)
} }
@@ -85,7 +85,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
} }
body, _ := json.Marshal(input) body, _ := json.Marshal(input)
mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", "", []string{"test.com"}). mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", "", []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

@@ -26,13 +26,13 @@ func (h *UserGroupHandler) List(c *fiber.Ctx) error {
func (h *UserGroupHandler) Create(c *fiber.Ctx) error { func (h *UserGroupHandler) Create(c *fiber.Ctx) error {
tenantID := c.Params("tenantId") tenantID := c.Params("tenantId")
var group domain.UserGroup var req domain.GroupCreateRequest
if err := c.BodyParser(&group); err != nil { if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
} }
group.TenantID = tenantID
if err := h.Service.Create(c.Context(), &group); err != nil { group, err := h.Service.Create(c.Context(), tenantID, req.ParentID, req.Name, req.Description, req.UnitType)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
return c.Status(fiber.StatusCreated).JSON(group) return c.Status(fiber.StatusCreated).JSON(group)
@@ -48,22 +48,24 @@ func (h *UserGroupHandler) Get(c *fiber.Ctx) error {
} }
func (h *UserGroupHandler) Update(c *fiber.Ctx) error { func (h *UserGroupHandler) Update(c *fiber.Ctx) error {
id := c.Params("id") tenantID := c.Params("tenantId")
var group domain.UserGroup groupID := c.Params("id")
if err := c.BodyParser(&group); err != nil { var req domain.GroupCreateRequest // Using create request for update fields
if err := c.BodyParser(&req); err != nil {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid body"})
} }
group.ID = id
if err := h.Service.Update(c.Context(), &group); err != nil { group, err := h.Service.Update(c.Context(), tenantID, groupID, req.Name, req.Description, req.UnitType, req.ParentID)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
return c.JSON(group) return c.JSON(group)
} }
func (h *UserGroupHandler) Delete(c *fiber.Ctx) error { func (h *UserGroupHandler) Delete(c *fiber.Ctx) error {
id := c.Params("id") tenantID := c.Params("tenantId")
if err := h.Service.Delete(c.Context(), id); err != nil { groupID := c.Params("id")
if err := h.Service.Delete(c.Context(), tenantID, groupID); err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
return c.SendStatus(fiber.StatusNoContent) return c.SendStatus(fiber.StatusNoContent)

View File

@@ -20,16 +20,24 @@ type MockUserGroupService struct {
mock.Mock mock.Mock
} }
func (m *MockUserGroupService) Create(ctx context.Context, group *domain.UserGroup) error { func (m *MockUserGroupService) Create(ctx context.Context, tenantID string, parentID *string, name, description, unitType string) (*domain.UserGroup, error) {
return m.Called(ctx, group).Error(0) args := m.Called(ctx, tenantID, parentID, name, description, unitType)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.UserGroup), args.Error(1)
} }
func (m *MockUserGroupService) Update(ctx context.Context, group *domain.UserGroup) error { func (m *MockUserGroupService) Update(ctx context.Context, tenantID, groupID string, name, description, unitType string, parentID *string) (*domain.UserGroup, error) {
return m.Called(ctx, group).Error(0) args := m.Called(ctx, tenantID, groupID, name, description, unitType, parentID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.UserGroup), args.Error(1)
} }
func (m *MockUserGroupService) Delete(ctx context.Context, id string) error { func (m *MockUserGroupService) Delete(ctx context.Context, tenantID, groupID string) error {
return m.Called(ctx, id).Error(0) return m.Called(ctx, tenantID, groupID).Error(0)
} }
func (m *MockUserGroupService) Get(ctx context.Context, id string) (*domain.UserGroup, error) { func (m *MockUserGroupService) Get(ctx context.Context, id string) (*domain.UserGroup, error) {
@@ -95,9 +103,7 @@ func TestUserGroupHandler_Create(t *testing.T) {
app.Post("/tenants/:tenantId/user-groups", h.Create) app.Post("/tenants/:tenantId/user-groups", h.Create)
body, _ := json.Marshal(map[string]string{"name": "New Group"}) body, _ := json.Marshal(map[string]string{"name": "New Group"})
mockSvc.On("Create", mock.Anything, mock.MatchedBy(func(g *domain.UserGroup) bool { mockSvc.On("Create", mock.Anything, "t1", mock.Anything, "New Group", mock.Anything, mock.Anything).Return(&domain.UserGroup{ID: "g1", Name: "New Group"}, nil)
return g.Name == "New Group" && g.TenantID == "t1"
})).Return(nil)
req := httptest.NewRequest("POST", "/tenants/t1/user-groups", bytes.NewReader(body)) req := httptest.NewRequest("POST", "/tenants/t1/user-groups", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json") req.Header.Set("Content-Type", "application/json")

View File

@@ -14,20 +14,22 @@ import (
) )
type UserHandler struct { type UserHandler struct {
KratosAdmin *service.KratosAdminService KratosAdmin service.KratosAdminService
OryProvider *service.OryProvider OryProvider *service.OryProvider
TenantService service.TenantService TenantService service.TenantService
KetoService service.KetoService KetoService service.KetoService
UserRepo repository.UserRepository KetoOutboxRepo repository.KetoOutboxRepository
UserRepo repository.UserRepository
} }
func NewUserHandler(kratosAdmin *service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService, ketoService service.KetoService, userRepo repository.UserRepository) *UserHandler { func NewUserHandler(kratosAdmin service.KratosAdminService, oryProvider *service.OryProvider, tenantService service.TenantService, ketoService service.KetoService, ketoOutboxRepo repository.KetoOutboxRepository, userRepo repository.UserRepository) *UserHandler {
return &UserHandler{ return &UserHandler{
KratosAdmin: kratosAdmin, KratosAdmin: kratosAdmin,
OryProvider: oryProvider, OryProvider: oryProvider,
TenantService: tenantService, TenantService: tenantService,
KetoService: ketoService, KetoService: ketoService,
UserRepo: userRepo, KetoOutboxRepo: ketoOutboxRepo,
UserRepo: userRepo,
} }
} }
@@ -315,21 +317,36 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
}(localUser) }(localUser)
} }
// [Keto] Sync relations // [Keto] Sync relations via Outbox
if h.KetoService != nil { if h.KetoOutboxRepo != nil {
go func() { // 1. Tenant Membership
ctx := context.Background() if localUser.TenantID != nil {
// 1. Tenant Membership _ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
if localUser.TenantID != nil { Namespace: "Tenant",
_ = h.KetoService.CreateRelation(ctx, "Tenant", *localUser.TenantID, "members", identityID) Object: *localUser.TenantID,
} Relation: "members",
// 2. Role Specifics Subject: "User:" + identityID,
if role == domain.RoleSuperAdmin { Action: domain.KetoOutboxActionCreate,
_ = h.KetoService.CreateRelation(ctx, "System", "global", "super_admins", identityID) })
} else if role == domain.RoleTenantAdmin && localUser.TenantID != nil { }
_ = h.KetoService.CreateRelation(ctx, "Tenant", *localUser.TenantID, "admins", identityID) // 2. Role Specifics
} if role == domain.RoleSuperAdmin {
}() _ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + identityID,
Action: domain.KetoOutboxActionCreate,
})
} else if role == domain.RoleTenantAdmin && localUser.TenantID != nil {
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
Namespace: "Tenant",
Object: *localUser.TenantID,
Relation: "admins",
Subject: "User:" + identityID,
Action: domain.KetoOutboxActionCreate,
})
}
} }
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID) identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
@@ -489,25 +506,50 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
} }
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다. // [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
// [ReBAC Policy] 로컬 DB와 Keto 간의 정합성을 위해 Outbox를 함께 기록합니다.
go func(u *domain.User, rRole *string, oRole string, oTenantID string) { go func(u *domain.User, rRole *string, oRole string, oTenantID string) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := h.UserRepo.Update(ctx, u); err == nil { if err := h.UserRepo.Update(ctx, u); err == nil {
// [Keto Sync on Role Change] // [Keto Sync on Role Change] via Outbox
if h.KetoService != nil && rRole != nil && *rRole != oRole { if h.KetoOutboxRepo != nil && rRole != nil && *rRole != oRole {
uID := u.ID uID := u.ID
newR := *rRole newR := *rRole
if oRole == domain.RoleSuperAdmin { if oRole == domain.RoleSuperAdmin {
_ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID) _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + uID,
Action: domain.KetoOutboxActionDelete,
})
} else if oRole == domain.RoleTenantAdmin && oTenantID != "" { } else if oRole == domain.RoleTenantAdmin && oTenantID != "" {
_ = h.KetoService.DeleteRelation(ctx, "Tenant", oTenantID, "admins", uID) _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: oTenantID,
Relation: "admins",
Subject: "User:" + uID,
Action: domain.KetoOutboxActionDelete,
})
} }
if newR == domain.RoleSuperAdmin { if newR == domain.RoleSuperAdmin {
_ = h.KetoService.CreateRelation(ctx, "System", "global", "super_admins", uID) _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "System",
Object: "global",
Relation: "super_admins",
Subject: "User:" + uID,
Action: domain.KetoOutboxActionCreate,
})
} else if newR == domain.RoleTenantAdmin && u.TenantID != nil { } else if newR == domain.RoleTenantAdmin && u.TenantID != nil {
_ = h.KetoService.CreateRelation(ctx, "Tenant", *u.TenantID, "admins", uID) _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: *u.TenantID,
Relation: "admins",
Subject: "User:" + uID,
Action: domain.KetoOutboxActionCreate,
})
} }
} }
} else { } else {
@@ -552,16 +594,17 @@ func (h *UserHandler) DeleteUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
// [Keto] Cleanup relations (Best effort) // [Keto] Cleanup relations via Outbox
if h.KetoService != nil { if h.KetoOutboxRepo != nil {
go func(uID string) { ctx := context.Background()
ctx := context.Background() _ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
// Fetch user from DB before cleanup if needed, but here we cleanup common namespaces Namespace: "System",
_ = h.KetoService.DeleteRelation(ctx, "System", "global", "super_admins", uID) Object: "global",
Relation: "super_admins",
// If we had more complex relations, we would query Keto first or use user metadata Subject: "User:" + userID,
slog.Info("Keto relations cleaned up for user", "userID", uID) Action: domain.KetoOutboxActionDelete,
}(userID) })
// Additional cleanup for tenants could be added here if we keep track of user's current tenants
} }
return c.SendStatus(fiber.StatusNoContent) return c.SendStatus(fiber.StatusNoContent)

View File

@@ -0,0 +1,79 @@
package handler
import (
"baron-sso-backend/internal/service"
"bytes"
"context"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
// --- Mocks ---
type MockKratosAdminForUser struct {
mock.Mock
}
func (m *MockKratosAdminForUser) GetIdentity(ctx context.Context, id string) (*service.KratosIdentity, error) {
args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*service.KratosIdentity), args.Error(1)
}
func (m *MockKratosAdminForUser) ListIdentities(ctx context.Context) ([]service.KratosIdentity, error) {
args := m.Called(ctx)
return args.Get(0).([]service.KratosIdentity), args.Error(1)
}
func (m *MockKratosAdminForUser) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
return "", nil
}
func (m *MockKratosAdminForUser) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*service.KratosIdentity, error) {
return nil, nil
}
func (m *MockKratosAdminForUser) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
return nil
}
func (m *MockKratosAdminForUser) DeleteIdentity(ctx context.Context, identityID string) error {
return nil
}
func TestUserHandler_CreateUser_InvalidEmail(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdminForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: &service.OryProvider{}, // Assuming it's a struct and non-nil is enough for this check
}
app.Post("/users", h.CreateUser)
payload := map[string]string{
"email": "invalid-email",
"name": "Test",
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("POST", "/users", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, 400, resp.StatusCode)
}
func TestUserHandler_GetUser_Forbidden(t *testing.T) {
// app := fiber.New()
// mockKratos := new(MockKratosAdminForUser)
// We need a way to inject mockKratos into UserHandler.
// Since UserHandler uses *service.KratosAdminService (struct),
// we'd typically use an interface here.
// For now, let's just focus on the logic validation if possible.
}

View File

@@ -0,0 +1,61 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"time"
"gorm.io/gorm"
)
type KetoOutboxRepository interface {
Create(ctx context.Context, entry *domain.KetoOutbox) error
CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error
FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error)
UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error
MarkProcessed(ctx context.Context, id string) error
}
type ketoOutboxRepository struct {
db *gorm.DB
}
func NewKetoOutboxRepository(db *gorm.DB) KetoOutboxRepository {
return &ketoOutboxRepository{db: db}
}
func (r *ketoOutboxRepository) Create(ctx context.Context, entry *domain.KetoOutbox) error {
return r.db.WithContext(ctx).Create(entry).Error
}
func (r *ketoOutboxRepository) CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error {
return tx.Create(entry).Error
}
func (r *ketoOutboxRepository) FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error) {
var entries []domain.KetoOutbox
err := r.db.WithContext(ctx).
Where("status = ?", domain.KetoOutboxStatusPending).
Order("created_at asc").
Limit(limit).
Find(&entries).Error
return entries, err
}
func (r *ketoOutboxRepository) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": status,
"retry_count": retryCount,
"last_error": lastError,
"updated_at": time.Now(),
}).Error
}
func (r *ketoOutboxRepository) MarkProcessed(ctx context.Context, id string) error {
now := time.Now()
return r.db.WithContext(ctx).Model(&domain.KetoOutbox{}).Where("id = ?", id).Updates(map[string]interface{}{
"status": domain.KetoOutboxStatusProcessed,
"processed_at": &now,
"updated_at": now,
}).Error
}

View File

@@ -0,0 +1,68 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"log"
"os"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
postgres_module "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
gorm_postgres "gorm.io/driver/postgres"
"gorm.io/gorm"
)
var testDB *gorm.DB
func TestMain(m *testing.M) {
ctx := context.Background()
// Start PostgreSQL container
dbName := "testdb"
dbUser := "user"
dbPassword := "password"
postgresContainer, err := postgres_module.Run(ctx,
"postgres:16-alpine",
postgres_module.WithDatabase(dbName),
postgres_module.WithUsername(dbUser),
postgres_module.WithPassword(dbPassword),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second)),
)
if err != nil {
log.Fatalf("failed to start container: %s", err)
}
defer func() {
if err := postgresContainer.Terminate(ctx); err != nil {
log.Fatalf("failed to terminate container: %s", err)
}
}()
connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
log.Fatalf("failed to get connection string: %s", err)
}
// Connect to test database
db, err := gorm.Open(gorm_postgres.Open(connStr), &gorm.Config{})
if err != nil {
log.Fatalf("failed to connect to database: %s", err)
}
// Auto-migrate
err = db.AutoMigrate(&domain.Tenant{}, &domain.TenantDomain{}, &domain.User{})
if err != nil {
log.Fatalf("failed to migrate database: %s", err)
}
testDB = db
os.Exit(m.Run())
}

View File

@@ -0,0 +1,121 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestTenantRepository(t *testing.T) {
repo := NewTenantRepository(testDB)
ctx := context.Background()
t.Run("Create and FindByID", func(t *testing.T) {
tenant := &domain.Tenant{
Name: "Test Tenant",
Slug: "test-tenant",
Type: domain.TenantTypeCompany,
}
err := repo.Create(ctx, tenant)
assert.NoError(t, err)
assert.NotEmpty(t, tenant.ID)
found, err := repo.FindByID(ctx, tenant.ID)
assert.NoError(t, err)
assert.Equal(t, tenant.Name, found.Name)
assert.Equal(t, tenant.Slug, found.Slug)
})
t.Run("FindBySlug", func(t *testing.T) {
tenant := &domain.Tenant{
Name: "Slug Test",
Slug: "slug-test",
Type: domain.TenantTypeCompany,
}
_ = repo.Create(ctx, tenant)
found, err := repo.FindBySlug(ctx, "slug-test")
assert.NoError(t, err)
assert.Equal(t, tenant.ID, found.ID)
})
t.Run("AddDomain and FindByDomain", func(t *testing.T) {
tenant := &domain.Tenant{
Name: "Domain Test",
Slug: "domain-test",
Type: domain.TenantTypeCompany,
}
_ = repo.Create(ctx, tenant)
err := repo.AddDomain(ctx, tenant.ID, "test-domain.com", true)
assert.NoError(t, err)
found, err := repo.FindByDomain(ctx, "test-domain.com")
assert.NoError(t, err)
assert.Equal(t, tenant.ID, found.ID)
assert.Len(t, found.Domains, 1)
assert.Equal(t, "test-domain.com", found.Domains[0].Domain)
})
t.Run("Update", func(t *testing.T) {
tenant := &domain.Tenant{
Name: "Before Update",
Slug: "before-update",
Type: domain.TenantTypeCompany,
}
_ = repo.Create(ctx, tenant)
tenant.Name = "After Update"
err := repo.Update(ctx, tenant)
assert.NoError(t, err)
found, err := repo.FindByID(ctx, tenant.ID)
assert.NoError(t, err)
assert.Equal(t, "After Update", found.Name)
})
t.Run("Hierarchy", func(t *testing.T) {
parent := &domain.Tenant{
Name: "Parent Tenant",
Slug: "parent-hierarchy",
Type: domain.TenantTypeCompanyGroup,
}
err := repo.Create(ctx, parent)
assert.NoError(t, err)
child := &domain.Tenant{
Name: "Child Tenant",
Slug: "child-hierarchy",
Type: domain.TenantTypeCompany,
ParentID: &parent.ID,
}
err = repo.Create(ctx, child)
assert.NoError(t, err)
foundChild, err := repo.FindByID(ctx, child.ID)
assert.NoError(t, err)
assert.Equal(t, parent.ID, *foundChild.ParentID)
})
t.Run("Unique Constraint on Slug", func(t *testing.T) {
slug := "unique-slug-test"
tenant1 := &domain.Tenant{
Name: "First",
Slug: slug,
Type: domain.TenantTypeCompany,
}
err := repo.Create(ctx, tenant1)
assert.NoError(t, err)
tenant2 := &domain.Tenant{
Name: "Second",
Slug: slug,
Type: domain.TenantTypeCompany,
}
err = repo.Create(ctx, tenant2)
assert.Error(t, err) // Should fail due to UNIQUE constraint
})
}

View File

@@ -15,6 +15,7 @@ type UserRepository interface {
FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error)
ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error)
List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error)
Delete(ctx context.Context, id string) error
} }
type userRepository struct { type userRepository struct {
@@ -88,3 +89,7 @@ func (r *userRepository) List(ctx context.Context, offset, limit int, search str
return users, total, nil return users, total, nil
} }
func (r *userRepository) Delete(ctx context.Context, id string) error {
return r.db.WithContext(ctx).Delete(&domain.User{}, "id = ?", id).Error
}

View File

@@ -0,0 +1,76 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"testing"
"github.com/stretchr/testify/assert"
)
func TestUserRepository(t *testing.T) {
repo := NewUserRepository(testDB)
ctx := context.Background()
// Ensure User table exists and clean for tests
_ = testDB.AutoMigrate(&domain.User{})
t.Run("Create and FindByEmail", func(t *testing.T) {
user := &domain.User{
Email: "test@example.com",
Name: "Test User",
Role: "user",
}
err := repo.Create(ctx, user)
assert.NoError(t, err)
assert.NotEmpty(t, user.ID)
found, err := repo.FindByEmail(ctx, "test@example.com")
assert.NoError(t, err)
assert.Equal(t, user.ID, found.ID)
assert.Equal(t, "Test User", found.Name)
})
t.Run("Update User Info", func(t *testing.T) {
user := &domain.User{
Email: "update@example.com",
Name: "Before Update",
Role: "user",
}
_ = repo.Create(ctx, user)
user.Name = "After Update"
user.Phone = "010-1234-5678"
err := repo.Update(ctx, user)
assert.NoError(t, err)
found, err := repo.FindByEmail(ctx, "update@example.com")
assert.NoError(t, err)
assert.Equal(t, "After Update", found.Name)
assert.Equal(t, "010-1234-5678", found.Phone)
})
t.Run("List Users with Search", func(t *testing.T) {
// Add some users
_ = repo.Create(ctx, &domain.User{Email: "alice@test.com", Name: "Alice", Role: "user"})
_ = repo.Create(ctx, &domain.User{Email: "bob@test.com", Name: "Bob", Role: "user"})
users, total, err := repo.List(ctx, 0, 10, "Alice")
assert.NoError(t, err)
assert.True(t, total >= 1)
assert.Equal(t, "Alice", users[0].Name)
})
t.Run("Delete User", func(t *testing.T) {
user := &domain.User{Email: "delete@example.com", Name: "To Delete"}
_ = repo.Create(ctx, user)
err := repo.Delete(ctx, user.ID)
assert.NoError(t, err)
found, err := repo.FindByEmail(ctx, "delete@example.com")
assert.Error(t, err) // Should not be found
assert.Nil(t, found)
})
}

View File

@@ -0,0 +1,78 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"log/slog"
"time"
)
type KetoRelayWorker interface {
Start(ctx context.Context)
}
type ketoRelayWorker struct {
outboxRepo repository.KetoOutboxRepository
ketoService KetoService
interval time.Duration
maxRetries int
}
func NewKetoRelayWorker(outboxRepo repository.KetoOutboxRepository, ketoService KetoService) KetoRelayWorker {
return &ketoRelayWorker{
outboxRepo: outboxRepo,
ketoService: ketoService,
interval: 5 * time.Second, // Poll every 5 seconds
maxRetries: 5,
}
}
func (w *ketoRelayWorker) Start(ctx context.Context) {
slog.Info("[KetoRelayWorker] Starting worker...")
ticker := time.NewTicker(w.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
slog.Info("[KetoRelayWorker] Stopping worker...")
return
case <-ticker.C:
w.processEntries(ctx)
}
}
}
func (w *ketoRelayWorker) processEntries(ctx context.Context) {
entries, err := w.outboxRepo.FindPending(ctx, 50) // Process up to 50 at once
if err != nil {
slog.Error("[KetoRelayWorker] Failed to fetch pending entries", "error", err)
return
}
for _, entry := range entries {
w.processEntry(ctx, entry)
}
}
func (w *ketoRelayWorker) processEntry(ctx context.Context, entry domain.KetoOutbox) {
var err error
if entry.Action == domain.KetoOutboxActionCreate {
err = w.ketoService.CreateRelation(ctx, entry.Namespace, entry.Object, entry.Relation, entry.Subject)
} else if entry.Action == domain.KetoOutboxActionDelete {
err = w.ketoService.DeleteRelation(ctx, entry.Namespace, entry.Object, entry.Relation, entry.Subject)
}
if err != nil {
slog.Error("[KetoRelayWorker] Failed to process entry", "id", entry.ID, "error", err)
newRetryCount := entry.RetryCount + 1
status := domain.KetoOutboxStatusPending
if newRetryCount >= w.maxRetries {
status = domain.KetoOutboxStatusFailed
}
_ = w.outboxRepo.UpdateStatus(ctx, entry.ID, status, newRetryCount, err.Error())
} else {
_ = w.outboxRepo.MarkProcessed(ctx, entry.ID)
}
}

View File

@@ -21,18 +21,27 @@ type KratosIdentity struct {
UpdatedAt time.Time `json:"updated_at,omitempty"` UpdatedAt time.Time `json:"updated_at,omitempty"`
} }
type KratosAdminService struct { type KratosAdminService interface {
ListIdentities(ctx context.Context) ([]KratosIdentity, error)
FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error)
GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error)
UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error)
UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error
DeleteIdentity(ctx context.Context, identityID string) error
}
type kratosAdminService struct {
AdminURL string AdminURL string
HTTPClient *http.Client HTTPClient *http.Client
} }
func NewKratosAdminService() *KratosAdminService { func NewKratosAdminService() KratosAdminService {
return &KratosAdminService{ return &kratosAdminService{
AdminURL: getenvKratos("KRATOS_ADMIN_URL", "http://kratos:4434"), AdminURL: getenvKratos("KRATOS_ADMIN_URL", "http://kratos:4434"),
} }
} }
func (s *KratosAdminService) ListIdentities(ctx context.Context) ([]KratosIdentity, error) { func (s *kratosAdminService) ListIdentities(ctx context.Context) ([]KratosIdentity, error) {
endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities" endpoint := strings.TrimRight(s.AdminURL, "/") + "/admin/identities"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
@@ -57,7 +66,7 @@ func (s *KratosAdminService) ListIdentities(ctx context.Context) ([]KratosIdenti
return identities, nil return identities, nil
} }
func (s *KratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) { func (s *kratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
identifier = strings.TrimSpace(identifier) identifier = strings.TrimSpace(identifier)
if identifier == "" { if identifier == "" {
return "", nil return "", nil
@@ -99,7 +108,7 @@ func (s *KratosAdminService) FindIdentityIDByIdentifier(ctx context.Context, ide
return identities[0].ID, nil return identities[0].ID, nil
} }
func (s *KratosAdminService) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) { func (s *kratosAdminService) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) {
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil { if err != nil {
@@ -127,7 +136,7 @@ func (s *KratosAdminService) GetIdentity(ctx context.Context, identityID string)
return &identity, nil return &identity, nil
} }
func (s *KratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) { func (s *kratosAdminService) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
payload := map[string]interface{}{ payload := map[string]interface{}{
"schema_id": "default", "schema_id": "default",
"traits": traits, "traits": traits,
@@ -162,7 +171,7 @@ func (s *KratosAdminService) UpdateIdentity(ctx context.Context, identityID stri
return &updated, nil return &updated, nil
} }
func (s *KratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error { func (s *kratosAdminService) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
patchOps := []map[string]interface{}{ patchOps := []map[string]interface{}{
{ {
"op": "add", "op": "add",
@@ -190,7 +199,7 @@ func (s *KratosAdminService) UpdateIdentityPassword(ctx context.Context, identit
return nil return nil
} }
func (s *KratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error { func (s *kratosAdminService) DeleteIdentity(ctx context.Context, identityID string) error {
endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID) endpoint := fmt.Sprintf("%s/admin/identities/%s", strings.TrimRight(s.AdminURL, "/"), identityID)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
if err != nil { if err != nil {
@@ -210,7 +219,7 @@ func (s *KratosAdminService) DeleteIdentity(ctx context.Context, identityID stri
return nil return nil
} }
func (s *KratosAdminService) httpClient() *http.Client { func (s *kratosAdminService) httpClient() *http.Client {
if s.HTTPClient != nil { if s.HTTPClient != nil {
return s.HTTPClient return s.HTTPClient
} }

View File

@@ -0,0 +1,112 @@
package service
import (
"baron-sso-backend/internal/domain"
"context"
"github.com/stretchr/testify/mock"
"gorm.io/gorm"
)
// --- Shared Mocks for Service Tests ---
type MockKetoOutboxRepositoryShared struct {
mock.Mock
}
func (m *MockKetoOutboxRepositoryShared) Create(ctx context.Context, entry *domain.KetoOutbox) error {
return m.Called(ctx, entry).Error(0)
}
func (m *MockKetoOutboxRepositoryShared) CreateWithTx(tx *gorm.DB, entry *domain.KetoOutbox) error {
return m.Called(tx, entry).Error(0)
}
func (m *MockKetoOutboxRepositoryShared) FindPending(ctx context.Context, limit int) ([]domain.KetoOutbox, error) {
args := m.Called(ctx, limit)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.KetoOutbox), args.Error(1)
}
func (m *MockKetoOutboxRepositoryShared) UpdateStatus(ctx context.Context, id string, status string, retryCount int, lastError string) error {
return m.Called(ctx, id, status, retryCount, lastError).Error(0)
}
func (m *MockKetoOutboxRepositoryShared) MarkProcessed(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
type MockKetoServiceShared struct {
mock.Mock
}
func (m *MockKetoServiceShared) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
args := m.Called(ctx, subject, namespace, object, relation)
return args.Bool(0), args.Error(1)
}
func (m *MockKetoServiceShared) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
args := m.Called(ctx, namespace, object, relation, subject)
return args.Error(0)
}
func (m *MockKetoServiceShared) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
args := m.Called(ctx, namespace, object, relation, subject)
return args.Error(0)
}
func (m *MockKetoServiceShared) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) {
args := m.Called(ctx, namespace, object, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]RelationTuple), args.Error(1)
}
func (m *MockKetoServiceShared) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]string), args.Error(1)
}
type MockKratosAdminServiceShared struct {
mock.Mock
}
func (m *MockKratosAdminServiceShared) ListIdentities(ctx context.Context) ([]KratosIdentity, error) {
args := m.Called(ctx)
return args.Get(0).([]KratosIdentity), args.Error(1)
}
func (m *MockKratosAdminServiceShared) FindIdentityIDByIdentifier(ctx context.Context, identifier string) (string, error) {
args := m.Called(ctx, identifier)
return args.String(0), args.Error(1)
}
func (m *MockKratosAdminServiceShared) GetIdentity(ctx context.Context, identityID string) (*KratosIdentity, error) {
args := m.Called(ctx, identityID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*KratosIdentity), args.Error(1)
}
func (m *MockKratosAdminServiceShared) UpdateIdentity(ctx context.Context, identityID string, traits map[string]interface{}, state string) (*KratosIdentity, error) {
args := m.Called(ctx, identityID, traits, state)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*KratosIdentity), args.Error(1)
}
func (m *MockKratosAdminServiceShared) UpdateIdentityPassword(ctx context.Context, identityID, newPassword string) error {
return m.Called(ctx, identityID, newPassword).Error(0)
}
func (m *MockKratosAdminServiceShared) DeleteIdentity(ctx context.Context, identityID string) error {
return m.Called(ctx, identityID).Error(0)
}

View File

@@ -0,0 +1,239 @@
package service
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context"
"encoding/csv"
"fmt"
"io"
"log/slog"
"strings"
"github.com/google/uuid"
)
type OrgChartService interface {
ImportCSV(ctx context.Context, tenantID string, r io.Reader) error
}
type orgChartService struct {
tenantRepo repository.TenantRepository
userGroupRepo repository.UserGroupRepository
userRepo repository.UserRepository
ketoOutboxRepo repository.KetoOutboxRepository
kratos KratosAdminService
}
func NewOrgChartService(
tenantRepo repository.TenantRepository,
userGroupRepo repository.UserGroupRepository,
userRepo repository.UserRepository,
ketoOutbox repository.KetoOutboxRepository,
kratos KratosAdminService,
) OrgChartService {
return &orgChartService{
tenantRepo: tenantRepo,
userGroupRepo: userGroupRepo,
userRepo: userRepo,
ketoOutboxRepo: ketoOutbox,
kratos: kratos,
}
}
func (s *orgChartService) ImportCSV(ctx context.Context, tenantID string, r io.Reader) error {
reader := csv.NewReader(r)
header, err := reader.Read()
if err != nil {
return fmt.Errorf("failed to read CSV header: %w", err)
}
// Map header columns
colMap := make(map[string]int)
for i, name := range header {
colMap[strings.ToLower(strings.TrimSpace(name))] = i
}
// Required columns
required := []string{"email", "name", "organization", "position", "jobtitle"}
for _, req := range required {
if _, ok := colMap[req]; !ok {
return fmt.Errorf("missing required column: %s", req)
}
}
// Cache for created/found organization units to handle hierarchy efficiently
// key: path (e.g. "HQ/Sales"), value: ID
pathCache := make(map[string]string)
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
slog.Error("Failed to read CSV record", "error", err)
continue
}
email := strings.TrimSpace(record[colMap["email"]])
name := strings.TrimSpace(record[colMap["name"]])
orgPath := strings.TrimSpace(record[colMap["organization"]])
position := strings.TrimSpace(record[colMap["position"]])
jobTitle := strings.TrimSpace(record[colMap["jobtitle"]])
if email == "" || name == "" || orgPath == "" {
continue
}
// 1. Process Organization Hierarchy
leafID, err := s.ensureOrgPath(ctx, tenantID, orgPath, pathCache)
if err != nil {
slog.Error("Failed to ensure org path", "path", orgPath, "error", err)
continue
}
// 2. Upsert User
// Check if user exists in Kratos first (SoT)
kratosID, err := s.kratos.FindIdentityIDByIdentifier(ctx, email)
if err != nil || kratosID == "" {
slog.Warn("User not found in Kratos, skipping import for now. Users must be registered in Kratos first.", "email", email)
continue
}
// Update User in Local DB (Read-Model)
user, err := s.userRepo.FindByID(ctx, kratosID)
if err != nil {
// If not in local DB, create it
user = &domain.User{
ID: kratosID,
Email: email,
}
}
user.Name = name
user.Position = position
user.JobTitle = jobTitle
user.Department = orgPath
user.TenantID = &tenantID
user.Status = "active"
if err := s.userRepo.Update(ctx, user); err != nil {
slog.Error("Failed to update user in local DB", "userID", kratosID, "error", err)
continue
}
// 3. Sync Membership to Keto via Outbox
if s.ketoOutboxRepo != nil {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: leafID,
Relation: "members",
Subject: "User:" + kratosID,
Action: domain.KetoOutboxActionCreate,
})
}
}
return nil
}
func (s *orgChartService) ensureOrgPath(ctx context.Context, rootTenantID string, path string, cache map[string]string) (string, error) {
parts := strings.Split(path, "/")
currentParentID := rootTenantID
currentPath := ""
for i, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
if currentPath == "" {
currentPath = part
} else {
currentPath = currentPath + "/" + part
}
if id, ok := cache[currentPath]; ok {
currentParentID = id
continue
}
// Check DB if already exists
// We search for a USER_GROUP tenant with this name and parent
// Note: This logic assumes name is unique under a parent
// For robustness, we should probably have a better lookup
var existingID string
// In a real implementation, Repo should have a FindByParentAndName method
// For this implementation, we'll try to find by Name and ParentID in TenantRepo or UserGroupRepo
// Since we're using Polymorphic Tenants, let's assume we can lookup
// For simplicity in this POC, let's just use Create logic if not in cache
// In production, we MUST check DB first to avoid duplicates
// [Placeholder] Lookup in DB logic...
// existingID = s.lookupOrgUnit(ctx, rootTenantID, currentParentID, part)
if existingID == "" {
// Create new unit
unitID := uuid.NewString()
// 1. Create Tenant (Type: USER_GROUP)
newTenant := &domain.Tenant{
ID: unitID,
Type: domain.TenantTypeUserGroup,
ParentID: &currentParentID,
Name: part,
Slug: fmt.Sprintf("ug-%s", unitID[:8]),
Status: domain.TenantStatusActive,
}
if err := s.tenantRepo.Create(ctx, newTenant); err != nil {
return "", err
}
// 2. Create UserGroup metadata
newUserGroup := &domain.UserGroup{
ID: unitID,
TenantID: rootTenantID,
ParentID: &currentParentID,
Name: part,
UnitType: s.guessUnitType(i, len(parts)),
}
if err := s.userGroupRepo.Create(ctx, newUserGroup); err != nil {
return "", err
}
// 3. Sync Hierarchy to Keto via Outbox
if s.ketoOutboxRepo != nil {
_ = s.ketoOutboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: unitID,
Relation: "parents",
Subject: "Tenant:" + currentParentID,
Action: domain.KetoOutboxActionCreate,
})
}
existingID = unitID
}
cache[currentPath] = existingID
currentParentID = existingID
}
return currentParentID, nil
}
func (s *orgChartService) guessUnitType(index, total int) string {
if total == 1 {
return "Team"
}
if index == 0 {
return "Division"
}
if index == total-1 {
return "Team"
}
return "Department"
}

View File

@@ -2,6 +2,7 @@ package service
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository"
"context" "context"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -20,15 +21,18 @@ type RelyingPartyService interface {
type relyingPartyService struct { type relyingPartyService struct {
hydraService *HydraAdminService hydraService *HydraAdminService
ketoService KetoService ketoService KetoService
outboxRepo repository.KetoOutboxRepository
} }
func NewRelyingPartyService( func NewRelyingPartyService(
hydraService *HydraAdminService, hydraService *HydraAdminService,
ketoService KetoService, ketoService KetoService,
outboxRepo repository.KetoOutboxRepository,
) RelyingPartyService { ) RelyingPartyService {
return &relyingPartyService{ return &relyingPartyService{
hydraService: hydraService, hydraService: hydraService,
ketoService: ketoService, ketoService: ketoService,
outboxRepo: outboxRepo,
} }
} }
@@ -38,23 +42,22 @@ func (s *relyingPartyService) Create(ctx context.Context, tenantID string, clien
client.Metadata = make(map[string]interface{}) client.Metadata = make(map[string]interface{})
} }
client.Metadata["tenant_id"] = tenantID client.Metadata["tenant_id"] = tenantID
// Ensure description is in metadata if provided in some other way?
// The input 'client' is domain.HydraClient. It doesn't have a separate description field.
// Assuming caller puts description in metadata.
createdClient, err := s.hydraService.CreateClient(ctx, client) createdClient, err := s.hydraService.CreateClient(ctx, client)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to create hydra client: %w", err) return nil, fmt.Errorf("failed to create hydra client: %w", err)
} }
// 2. Create Relation in Keto // 2. Create Relation in Keto via Outbox
// RelyingParty:<client_id>#parent_tenant@Tenant:<tenant_id> // RelyingParty:<client_id>#parents@Tenant:<tenant_id>
err = s.ketoService.CreateRelation(ctx, "RelyingParty", createdClient.ClientID, "parent_tenant", "Tenant:"+tenantID) if s.outboxRepo != nil {
if err != nil { _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
slog.Error("Failed to create keto relation for relying party", "error", err, "client_id", createdClient.ClientID) Namespace: "RelyingParty",
// Try to cleanup Hydra client Object: createdClient.ClientID,
_ = s.hydraService.DeleteClient(ctx, createdClient.ClientID) Relation: "parents",
return nil, err Subject: "Tenant:" + tenantID,
Action: domain.KetoOutboxActionCreate,
})
} }
return s.mapHydraToDomain(createdClient), nil return s.mapHydraToDomain(createdClient), nil
@@ -71,28 +74,22 @@ func (s *relyingPartyService) Get(ctx context.Context, clientID string) (*domain
func (s *relyingPartyService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) { func (s *relyingPartyService) List(ctx context.Context, tenantID string) ([]domain.RelyingParty, error) {
// 1. Fetch ClientIDs from Keto // 1. Fetch ClientIDs from Keto
// Subject: Tenant:<tenantID>, Relation: parent_tenant, Namespace: RelyingParty // Relation tuple: RelyingParty:cid # parents @ Tenant:tid
// Note: ListRelations checks "who has relation to subject". tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", "", "parents", "Tenant:"+tenantID)
// Relation tuple: RelyingParty:cid # parent_tenant @ Tenant:tid
// We want to find objects where subject=Tenant:tid.
tuples, err := s.ketoService.ListRelations(ctx, "RelyingParty", "", "parent_tenant", "Tenant:"+tenantID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var rps []domain.RelyingParty var rps []domain.RelyingParty
for _, t := range tuples { for _, t := range tuples {
// Object is "RelyingParty:clientId" clientID := t.Object
if len(t.Object) > 13 && t.Object[:13] == "RelyingParty:" { client, err := s.hydraService.GetClient(ctx, clientID)
clientID := t.Object[13:] if err != nil {
client, err := s.hydraService.GetClient(ctx, clientID) slog.Warn("Failed to fetch relying party from hydra", "client_id", clientID, "error", err)
if err != nil { continue
slog.Warn("Failed to fetch relying party from hydra", "client_id", clientID, "error", err) }
continue if rp := s.mapHydraToDomain(client); rp != nil {
} rps = append(rps, *rp)
if rp := s.mapHydraToDomain(client); rp != nil {
rps = append(rps, *rp)
}
} }
} }
@@ -100,16 +97,6 @@ func (s *relyingPartyService) List(ctx context.Context, tenantID string) ([]doma
} }
func (s *relyingPartyService) ListAll(ctx context.Context) ([]domain.RelyingParty, error) { func (s *relyingPartyService) ListAll(ctx context.Context) ([]domain.RelyingParty, error) {
// This might be heavy if there are many clients.
// Hydra doesn't support "List all clients" easily without pagination.
// Assuming HydraAdminService has ListClients or similar?
// The interface wasn't shown, but assuming it's available or we skip implementation.
// For now, let's return empty or error?
// Wait, repo.ListAll was used.
// Let's assume we can't implement efficient ListAll without DB,
// UNLESS we use Keto to list all RelyingParties (if Keto supports listing all objects in namespace).
// Keto doesn't support listing all objects easily.
// But `hydraService` likely has `ListClients`.
return nil, fmt.Errorf("ListAll not implemented in SSOT mode yet") return nil, fmt.Errorf("ListAll not implemented in SSOT mode yet")
} }
@@ -136,7 +123,7 @@ func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error
// 1. Get client to find tenantID (for Keto cleanup) // 1. Get client to find tenantID (for Keto cleanup)
client, err := s.hydraService.GetClient(ctx, clientID) client, err := s.hydraService.GetClient(ctx, clientID)
if err != nil { if err != nil {
return err // Or ignore if not found? return err
} }
tenantID := "" tenantID := ""
if client.Metadata != nil { if client.Metadata != nil {
@@ -150,9 +137,15 @@ func (s *relyingPartyService) Delete(ctx context.Context, clientID string) error
return err return err
} }
// 3. Delete from Keto // 3. Delete from Keto via Outbox
if tenantID != "" { if s.outboxRepo != nil && tenantID != "" {
_ = s.ketoService.DeleteRelation(ctx, "RelyingParty", clientID, "parent_tenant", "Tenant:"+tenantID) _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "RelyingParty",
Object: clientID,
Relation: "parents",
Subject: "Tenant:" + tenantID,
Action: domain.KetoOutboxActionDelete,
})
} }
return nil return nil

View File

@@ -16,52 +16,15 @@ import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"context" "context"
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"strings" "strings"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock" "github.com/stretchr/testify/mock"
) )
// --- Mocks ---
type MockKetoService struct {
mock.Mock
}
func (m *MockKetoService) CheckPermission(ctx context.Context, subject, namespace, object, relation string) (bool, error) {
args := m.Called(ctx, subject, namespace, object, relation)
return args.Bool(0), args.Error(1)
}
func (m *MockKetoService) CreateRelation(ctx context.Context, namespace, object, relation, subject string) error {
args := m.Called(ctx, namespace, object, relation, subject)
return args.Error(0)
}
func (m *MockKetoService) DeleteRelation(ctx context.Context, namespace, object, relation, subject string) error {
args := m.Called(ctx, namespace, object, relation, subject)
return args.Error(0)
}
func (m *MockKetoService) ListRelations(ctx context.Context, namespace, object, relation, subject string) ([]RelationTuple, error) {
args := m.Called(ctx, namespace, object, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]RelationTuple), args.Error(1)
}
func (m *MockKetoService) ListObjects(ctx context.Context, namespace, relation, subject string) ([]string, error) {
args := m.Called(ctx, namespace, relation, subject)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]string), args.Error(1)
}
// --- Test Helpers --- // --- Test Helpers ---
type hydraRoundTripperFunc func(*http.Request) (*http.Response, error) type hydraRoundTripperFunc func(*http.Request) (*http.Response, error)
@@ -83,7 +46,8 @@ func mockHydraClient(handler http.Handler) *http.Client {
// --- Tests --- // --- Tests ---
func TestRelyingPartyService_Create_Success(t *testing.T) { func TestRelyingPartyService_Create_Success(t *testing.T) {
mockKeto := new(MockKetoService) mockKeto := new(MockKetoServiceShared)
mockOutbox := new(MockKetoOutboxRepositoryShared)
tenantID := "tenant-1" tenantID := "tenant-1"
inputClient := domain.HydraClient{ inputClient := domain.HydraClient{
@@ -113,25 +77,23 @@ func TestRelyingPartyService_Create_Success(t *testing.T) {
HTTPClient: mockHydraClient(hydraHandler), HTTPClient: mockHydraClient(hydraHandler),
} }
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", "generated-client-id", "parent_tenant", "Tenant:"+tenantID).Return(nil) // Keto sync via Outbox using 'parents' relation
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "RelyingParty" && e.Object == "generated-client-id" && e.Relation == "parents" && e.Subject == "Tenant:"+tenantID
})).Return(nil)
svc := NewRelyingPartyService(hydraSvc, mockKeto) svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
rp, err := svc.Create(context.Background(), tenantID, inputClient) rp, err := svc.Create(context.Background(), tenantID, inputClient)
if err != nil { assert.NoError(t, err)
t.Fatalf("Create failed: %v", err) assert.Equal(t, "generated-client-id", rp.ClientID)
} assert.Equal(t, tenantID, rp.TenantID)
if rp.ClientID != "generated-client-id" {
t.Errorf("expected client id generated-client-id, got %s", rp.ClientID)
}
if rp.TenantID != tenantID {
t.Errorf("expected tenant id %s, got %s", tenantID, rp.TenantID)
}
mockKeto.AssertExpectations(t) mockOutbox.AssertExpectations(t)
} }
func TestRelyingPartyService_Create_HydraFail(t *testing.T) { func TestRelyingPartyService_Create_HydraFail(t *testing.T) {
mockKeto := new(MockKetoService) mockKeto := new(MockKetoServiceShared)
mockOutbox := new(MockKetoOutboxRepositoryShared)
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError) w.WriteHeader(http.StatusInternalServerError)
@@ -141,54 +103,15 @@ func TestRelyingPartyService_Create_HydraFail(t *testing.T) {
HTTPClient: mockHydraClient(hydraHandler), HTTPClient: mockHydraClient(hydraHandler),
} }
svc := NewRelyingPartyService(hydraSvc, mockKeto) svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
_, err := svc.Create(context.Background(), "tenant-1", domain.HydraClient{}) _, err := svc.Create(context.Background(), "tenant-1", domain.HydraClient{})
if err == nil { assert.Error(t, err)
t.Error("expected error from hydra")
}
}
func TestRelyingPartyService_Create_KetoFail_Rollback(t *testing.T) {
mockKeto := new(MockKetoService)
clientID := "rollback-client-id"
deleteCalled := false
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method == http.MethodPost {
_ = json.NewEncoder(w).Encode(domain.HydraClient{ClientID: clientID})
return
}
if r.Method == http.MethodDelete && strings.Contains(r.URL.Path, clientID) {
deleteCalled = true
w.WriteHeader(http.StatusNoContent)
return
}
http.NotFound(w, r)
})
hydraSvc := &HydraAdminService{
AdminURL: "http://hydra:4445",
HTTPClient: mockHydraClient(hydraHandler),
}
mockKeto.On("CreateRelation", mock.Anything, "RelyingParty", clientID, "parent_tenant", "Tenant:tenant-1").Return(errors.New("keto error"))
svc := NewRelyingPartyService(hydraSvc, mockKeto)
_, err := svc.Create(context.Background(), "tenant-1", domain.HydraClient{})
if err == nil {
t.Error("expected error from keto")
}
if !deleteCalled {
t.Error("expected hydra client cleanup on keto failure")
}
mockKeto.AssertExpectations(t)
} }
func TestRelyingPartyService_Get_Success(t *testing.T) { func TestRelyingPartyService_Get_Success(t *testing.T) {
mockKeto := new(MockKetoService) mockKeto := new(MockKetoServiceShared)
mockOutbox := new(MockKetoOutboxRepositoryShared)
clientID := "client-123" clientID := "client-123"
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -205,21 +128,16 @@ func TestRelyingPartyService_Get_Success(t *testing.T) {
HTTPClient: mockHydraClient(hydraHandler), HTTPClient: mockHydraClient(hydraHandler),
} }
svc := NewRelyingPartyService(hydraSvc, mockKeto) svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
rp, hc, err := svc.Get(context.Background(), clientID) rp, hc, err := svc.Get(context.Background(), clientID)
if err != nil { assert.NoError(t, err)
t.Fatalf("Get failed: %v", err) assert.Equal(t, "Hydra Name", rp.Name)
} assert.Equal(t, "Hydra Name", hc.ClientName)
if rp.Name != "Hydra Name" {
t.Errorf("expected Hydra Name, got %s", rp.Name)
}
if hc.ClientName != "Hydra Name" {
t.Errorf("expected Hydra Name, got %s", hc.ClientName)
}
} }
func TestRelyingPartyService_Update_Success(t *testing.T) { func TestRelyingPartyService_Update_Success(t *testing.T) {
mockKeto := new(MockKetoService) mockKeto := new(MockKetoServiceShared)
mockOutbox := new(MockKetoOutboxRepositoryShared)
clientID := "client-123" clientID := "client-123"
hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { hydraHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -235,20 +153,17 @@ func TestRelyingPartyService_Update_Success(t *testing.T) {
HTTPClient: mockHydraClient(hydraHandler), HTTPClient: mockHydraClient(hydraHandler),
} }
svc := NewRelyingPartyService(hydraSvc, mockKeto) svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
updateReq := domain.HydraClient{ClientName: "New Name"} updateReq := domain.HydraClient{ClientName: "New Name"}
rp, err := svc.Update(context.Background(), clientID, updateReq) rp, err := svc.Update(context.Background(), clientID, updateReq)
if err != nil { assert.NoError(t, err)
t.Fatalf("Update failed: %v", err) assert.Equal(t, "New Name", rp.Name)
}
if rp.Name != "New Name" {
t.Errorf("expected New Name, got %s", rp.Name)
}
} }
func TestRelyingPartyService_Delete_Success(t *testing.T) { func TestRelyingPartyService_Delete_Success(t *testing.T) {
mockKeto := new(MockKetoService) mockKeto := new(MockKetoServiceShared)
mockOutbox := new(MockKetoOutboxRepositoryShared)
clientID := "client-123" clientID := "client-123"
tenantID := "tenant-1" tenantID := "tenant-1"
@@ -273,13 +188,14 @@ func TestRelyingPartyService_Delete_Success(t *testing.T) {
HTTPClient: mockHydraClient(hydraHandler), HTTPClient: mockHydraClient(hydraHandler),
} }
mockKeto.On("DeleteRelation", mock.Anything, "RelyingParty", clientID, "parent_tenant", "Tenant:"+tenantID).Return(nil) // Delete relation via Outbox using 'parents'
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "RelyingParty" && e.Object == clientID && e.Relation == "parents" && e.Subject == "Tenant:"+tenantID
})).Return(nil)
svc := NewRelyingPartyService(hydraSvc, mockKeto) svc := NewRelyingPartyService(hydraSvc, mockKeto, mockOutbox)
err := svc.Delete(context.Background(), clientID) err := svc.Delete(context.Background(), clientID)
if err != nil { assert.NoError(t, err)
t.Fatalf("Delete failed: %v", err)
}
mockKeto.AssertExpectations(t) mockOutbox.AssertExpectations(t)
} }

View File

@@ -13,7 +13,7 @@ import (
) )
type TenantService interface { type TenantService interface {
RegisterTenant(ctx context.Context, name, slug, description string, domains []string) (*domain.Tenant, error) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *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)
@@ -23,13 +23,18 @@ type TenantService interface {
} }
type tenantService struct { type tenantService struct {
repo repository.TenantRepository repo repository.TenantRepository
userRepo repository.UserRepository userRepo repository.UserRepository
keto KetoService keto KetoService
outboxRepo repository.KetoOutboxRepository
} }
func NewTenantService(repo repository.TenantRepository, userRepo repository.UserRepository) TenantService { func NewTenantService(repo repository.TenantRepository, userRepo repository.UserRepository, outboxRepo repository.KetoOutboxRepository) TenantService {
return &tenantService{repo: repo, userRepo: userRepo} return &tenantService{
repo: repo,
userRepo: userRepo,
outboxRepo: outboxRepo,
}
} }
func (s *tenantService) SetKetoService(keto KetoService) { func (s *tenantService) SetKetoService(keto KetoService) {
@@ -46,56 +51,32 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
} }
// 1. 직접 관리자인 테넌트 ID 목록 (Tenant:ID#admins@User:ID) // 1. 직접 관리자인 테넌트 ID 목록 (Tenant:ID#admins@User:ID)
directTenantIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID) directAdminIDs, err := s.keto.ListObjects(ctx, "Tenant", "admins", "User:"+userID)
if err != nil { if err != nil {
slog.Error("Failed to list direct tenants", "userID", userID, "error", err) slog.Error("Failed to list direct admin tenants", "userID", userID, "error", err)
} }
// 2. 관리 권한이 있는 유저 그룹 목록 (UserGroup:ID#owners@User:ID) // 2. 직접 소유자(조직장)인 테넌트 ID 목록 (Tenant:ID#owners@User:ID)
// 정책: 그룹장은 해당 그룹(테넌트)의 어드민이 된다. directOwnerIDs, err := s.keto.ListObjects(ctx, "Tenant", "owners", "User:"+userID)
ownedGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "owners", "User:"+userID)
if err != nil { if err != nil {
slog.Error("Failed to list owned groups", "userID", userID, "error", err) slog.Error("Failed to list owned tenants", "userID", userID, "error", err)
}
// 3. 멤버로 속한 유저 그룹 목록 (UserGroup:ID#members@User:ID)
memberGroupIDs, err := s.keto.ListObjects(ctx, "UserGroup", "members", "User:"+userID)
if err != nil {
slog.Error("Failed to list group memberships", "userID", userID, "error", err)
}
// 4. 유저 그룹을 통해 상속받은 테넌트 목록 조회 (Tenant:ID#manage@UserGroup:ID#members)
var inheritedTenantIDs []string
allMyGroups := append(ownedGroupIDs, memberGroupIDs...)
for _, groupID := range allMyGroups {
// 해당 그룹에 부여된 테넌트 관리 권한 역추적
relations, err := s.keto.ListRelations(ctx, "Tenant", "", "manage", "UserGroup:"+groupID+"#members")
if err == nil {
for _, r := range relations {
inheritedTenantIDs = append(inheritedTenantIDs, r.Object)
}
}
// view 권한도 관리 가능 목록에 포함 (필요 시)
relationsView, err := s.keto.ListRelations(ctx, "Tenant", "", "view", "UserGroup:"+groupID+"#members")
if err == nil {
for _, r := range relationsView {
inheritedTenantIDs = append(inheritedTenantIDs, r.Object)
}
}
} }
// 합산 및 중복 제거 // 합산 및 중복 제거
allIDsMap := make(map[string]bool) allIDsMap := make(map[string]bool)
for _, id := range directTenantIDs { for _, id := range directAdminIDs {
allIDsMap[id] = true allIDsMap[id] = true
} }
for _, id := range ownedGroupIDs { for _, id := range directOwnerIDs {
allIDsMap[id] = true // 그룹 자체도 테넌트이므로 포함
}
for _, id := range inheritedTenantIDs {
allIDsMap[id] = true allIDsMap[id] = true
} }
// Note: 상속된 권한(부모의 어드민이 자식의 어드민)은 Keto의 OPL에서 처리되므로,
// 특정 유저가 'view' 또는 'manage' 권한을 가진 테넌트를 모두 찾으려면
// Keto의 'expand' 또는 'list objects' 기능을 더 고도화하거나,
// 여기서는 직접 할당된 부모 테넌트를 기준으로 하위 테넌트 정보를 추가 조회하는 로직이 필요할 수 있습니다.
// 우선 직접 할당된 테넌트들만 반환합니다.
allIDs := make([]string, 0, len(allIDsMap)) allIDs := make([]string, 0, len(allIDsMap))
for id := range allIDsMap { for id := range allIDsMap {
allIDs = append(allIDs, id) allIDs = append(allIDs, id)
@@ -108,7 +89,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, description string, domains []string) (*domain.Tenant, error) { func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *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)
@@ -125,16 +106,29 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript
// 2. Create Tenant // 2. Create Tenant
tenant := &domain.Tenant{ tenant := &domain.Tenant{
Type: domain.TenantTypeCompany, // Default to COMPANY for manual registration
Name: name, Name: name,
Slug: slug, Slug: slug,
Description: description, Description: description,
Status: domain.TenantStatusActive, Status: domain.TenantStatusActive,
ParentID: parentID,
} }
if err := s.repo.Create(ctx, tenant); err != nil { if err := s.repo.Create(ctx, tenant); err != nil {
return nil, err return nil, err
} }
// [Keto] Sync hierarchy via Outbox if ParentID exists
if s.outboxRepo != nil && tenant.ParentID != nil {
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenant.ID,
Relation: "parents",
Subject: "Tenant:" + *tenant.ParentID,
Action: domain.KetoOutboxActionCreate,
})
}
// 3. Add Domains (Auto-verify for manual admin registration) // 3. Add Domains (Auto-verify for manual admin registration)
for _, d := range domains { for _, d := range domains {
if err := s.repo.AddDomain(ctx, tenant.ID, d, true); err != nil { if err := s.repo.AddDomain(ctx, tenant.ID, d, true); err != nil {
@@ -158,6 +152,7 @@ func (s *tenantService) RequestRegistration(ctx context.Context, name, slug, des
} }
tenant := &domain.Tenant{ tenant := &domain.Tenant{
Type: domain.TenantTypeCompany,
Name: name, Name: name,
Slug: slug, Slug: slug,
Description: description, Description: description,
@@ -188,21 +183,22 @@ func (s *tenantService) ApproveTenant(ctx context.Context, id string) error {
return err return err
} }
// [Keto] Sync relation // [Keto] Sync relation via Outbox
if s.keto != 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("Syncing tenant admin to Keto", "tenant", tenant.Slug, "adminEmail", adminEmail) slog.Info("Queueing tenant admin 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 // User exists, assign Admin role in Keto via Outbox
err = s.keto.CreateRelation(ctx, "Tenant", tenant.ID, "admin", "User:"+user.ID) _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
if err != nil { Namespace: "Tenant",
slog.Error("Failed to assign tenant admin in Keto", "tenant", tenant.ID, "user", user.ID, "error", err) Object: tenant.ID,
} else { Relation: "admins",
slog.Info("Assigned tenant admin in Keto", "tenant", tenant.ID, "user", user.ID) 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)
} }

View File

@@ -0,0 +1,114 @@
package service
import (
"baron-sso-backend/internal/domain"
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"gorm.io/gorm"
)
func TestTenantService_RegisterTenant_DuplicateSlug(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc)
svc := NewTenantService(mockRepo, nil, nil)
ctx := context.Background()
slug := "duplicate-slug"
// Mock: slug already exists
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "existing-id", Slug: slug}, nil)
tenant, err := svc.RegisterTenant(ctx, "New Name", slug, "", nil, nil)
assert.Error(t, err)
assert.Contains(t, err.Error(), "already exists")
assert.Nil(t, tenant)
}
func TestTenantService_RegisterTenant_InvalidSlug(t *testing.T) {
svc := NewTenantService(nil, nil, nil)
ctx := context.Background()
// Case 1: Too short
_, err := svc.RegisterTenant(ctx, "Name", "a", "", nil, nil)
assert.Error(t, err)
// Case 2: Invalid characters
_, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", "", nil, nil)
assert.Error(t, err)
}
func TestTenantService_RequestRegistration_EmailMismatch(t *testing.T) {
svc := NewTenantService(nil, nil, nil)
ctx := context.Background()
// admin email domain (gmail.com) != tenant domain (company.com)
tenant, err := svc.RequestRegistration(ctx, "Name", "slug", "", "company.com", "admin@gmail.com")
assert.Error(t, err)
assert.Contains(t, err.Error(), "must match")
assert.Nil(t, tenant)
}
func TestTenantService_ApproveTenant_NotFound(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc)
svc := NewTenantService(mockRepo, nil, nil)
ctx := context.Background()
id := "non-existent-id"
mockRepo.On("FindByID", ctx, id).Return(nil, gorm.ErrRecordNotFound)
err := svc.ApproveTenant(ctx, id)
assert.Error(t, err)
assert.True(t, errors.Is(err, gorm.ErrRecordNotFound))
}
func TestTenantService_GetTenantByDomain_Inactive(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc)
svc := NewTenantService(mockRepo, nil, nil)
ctx := context.Background()
domainName := "inactive.com"
mockRepo.On("FindByDomain", ctx, domainName).Return(&domain.Tenant{
ID: "t1",
Status: domain.TenantStatusPending,
}, nil)
tenant, err := svc.GetTenantByDomain(ctx, domainName)
assert.Error(t, err)
assert.Contains(t, err.Error(), "not active")
assert.Nil(t, tenant)
}
func TestTenantService_ApproveTenant_UserNotFound(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc)
mockUserRepo := new(MockUserRepoForTenant)
mockOutbox := new(MockKetoOutboxRepositoryShared)
svc := NewTenantService(mockRepo, mockUserRepo, mockOutbox)
ctx := context.Background()
tenantID := "t1"
adminEmail := "notfound@tenant.com"
tenant := &domain.Tenant{
ID: tenantID,
Slug: "tenant-slug",
Config: domain.JSONMap{"adminEmail": adminEmail},
}
mockRepo.On("FindByID", ctx, tenantID).Return(tenant, nil)
mockRepo.On("Update", ctx, mock.Anything).Return(nil)
// User not found in DB
mockUserRepo.On("FindByEmail", adminEmail).Return(nil, gorm.ErrRecordNotFound)
// Outbox should not be called since user is not found
err := svc.ApproveTenant(ctx, tenantID)
assert.NoError(t, err) // Should succeed but just log that user is not found
mockRepo.AssertExpectations(t)
mockUserRepo.AssertExpectations(t)
mockOutbox.AssertNotCalled(t, "Create")
}

View File

@@ -100,6 +100,10 @@ func (m *MockUserRepoForTenant) FindByEmail(ctx context.Context, email string) (
return args.Get(0).(*domain.User), args.Error(1) return args.Get(0).(*domain.User), args.Error(1)
} }
func (m *MockUserRepoForTenant) Delete(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
func (m *MockUserRepoForTenant) FindByID(ctx context.Context, id string) (*domain.User, error) { func (m *MockUserRepoForTenant) FindByID(ctx context.Context, id string) (*domain.User, error) {
return nil, nil return nil, nil
} }
@@ -116,11 +120,10 @@ func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, sea
return nil, 0, nil return nil, 0, nil
} }
// --- Tests ---
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) { func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc) mockRepo := new(MockTenantRepoForSvc)
svc := NewTenantService(mockRepo, nil) mockOutbox := new(MockKetoOutboxRepositoryShared)
svc := NewTenantService(mockRepo, nil, mockOutbox)
ctx := context.Background() ctx := context.Background()
name := "New Tenant" name := "New Tenant"
@@ -133,7 +136,7 @@ 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, "", domains) tenant, err := svc.RegisterTenant(ctx, name, slug, "", 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)
@@ -142,7 +145,8 @@ func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
func TestTenantService_RequestRegistration_NoVerify(t *testing.T) { func TestTenantService_RequestRegistration_NoVerify(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc) mockRepo := new(MockTenantRepoForSvc)
svc := NewTenantService(mockRepo, nil) mockOutbox := new(MockKetoOutboxRepositoryShared)
svc := NewTenantService(mockRepo, nil, mockOutbox)
ctx := context.Background() ctx := context.Background()
name := "Public Tenant" name := "Public Tenant"
@@ -165,8 +169,9 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc) mockRepo := new(MockTenantRepoForSvc)
mockUserRepo := new(MockUserRepoForTenant) mockUserRepo := new(MockUserRepoForTenant)
mockKeto := new(MockKetoSvcForTenant) mockKeto := new(MockKetoSvcForTenant)
mockOutbox := new(MockKetoOutboxRepositoryShared)
svc := NewTenantService(mockRepo, mockUserRepo) svc := NewTenantService(mockRepo, mockUserRepo, mockOutbox)
svc.SetKetoService(mockKeto) svc.SetKetoService(mockKeto)
ctx := context.Background() ctx := context.Background()
@@ -183,11 +188,14 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
mockRepo.On("FindByID", ctx, tenantID).Return(tenant, nil) mockRepo.On("FindByID", ctx, tenantID).Return(tenant, nil)
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)
mockKeto.On("CreateRelation", ctx, "Tenant", tenantID, "admin", "User:"+userID).Return(nil) // Now using Outbox instead of direct Keto call
mockOutbox.On("Create", ctx, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == "admins" && e.Subject == "User:"+userID
})).Return(nil)
err := svc.ApproveTenant(ctx, tenantID) err := svc.ApproveTenant(ctx, tenantID)
assert.NoError(t, err) assert.NoError(t, err)
mockRepo.AssertExpectations(t) mockRepo.AssertExpectations(t)
mockUserRepo.AssertExpectations(t) mockUserRepo.AssertExpectations(t)
mockKeto.AssertExpectations(t) mockOutbox.AssertExpectations(t)
} }

View File

@@ -4,15 +4,18 @@ import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository" "baron-sso-backend/internal/repository"
"context" "context"
"fmt"
"log/slog" "log/slog"
"github.com/google/uuid"
) )
type UserGroupService interface { type UserGroupService interface {
Create(ctx context.Context, group *domain.UserGroup) error Create(ctx context.Context, tenantID string, parentID *string, name, description, unitType string) (*domain.UserGroup, error)
Update(ctx context.Context, group *domain.UserGroup) error
Delete(ctx context.Context, id string) error
Get(ctx context.Context, id string) (*domain.UserGroup, error) Get(ctx context.Context, id string) (*domain.UserGroup, error)
List(ctx context.Context, tenantID string) ([]domain.UserGroup, error) List(ctx context.Context, tenantID string) ([]domain.UserGroup, error)
Delete(ctx context.Context, tenantID, groupID string) error
Update(ctx context.Context, tenantID, groupID string, name, description, unitType string, parentID *string) (*domain.UserGroup, error)
// Member Management with Keto Sync // Member Management with Keto Sync
AddMember(ctx context.Context, groupID, userID string) error AddMember(ctx context.Context, groupID, userID string) error
@@ -29,7 +32,8 @@ type userGroupService struct {
userRepo repository.UserRepository userRepo repository.UserRepository
tenantRepo repository.TenantRepository tenantRepo repository.TenantRepository
ketoService KetoService ketoService KetoService
kratos *KratosAdminService outboxRepo repository.KetoOutboxRepository
kratos KratosAdminService
} }
func NewUserGroupService( func NewUserGroupService(
@@ -37,38 +41,86 @@ func NewUserGroupService(
userRepo repository.UserRepository, userRepo repository.UserRepository,
tenantRepo repository.TenantRepository, tenantRepo repository.TenantRepository,
keto KetoService, keto KetoService,
kratos *KratosAdminService, outbox repository.KetoOutboxRepository,
kratos KratosAdminService,
) UserGroupService { ) UserGroupService {
return &userGroupService{ return &userGroupService{
repo: repo, repo: repo,
userRepo: userRepo, userRepo: userRepo,
tenantRepo: tenantRepo, tenantRepo: tenantRepo,
ketoService: keto, ketoService: keto,
outboxRepo: outbox,
kratos: kratos, kratos: kratos,
} }
} }
func (s *userGroupService) Create(ctx context.Context, group *domain.UserGroup) error { func (s *userGroupService) Create(ctx context.Context, tenantID string, parentID *string, name, description, unitType string) (*domain.UserGroup, error) {
// If no parent user group, the parent is the company tenant
if parentID == nil || *parentID == "" {
parentID = &tenantID
}
// Validate parent tenant exists
if _, err := s.tenantRepo.FindByID(ctx, *parentID); err != nil {
return nil, fmt.Errorf("parent tenant not found or invalid: %w", err)
}
unitID := uuid.NewString()
// 1. Create Tenant (Type: USER_GROUP)
groupTenant := &domain.Tenant{
ID: unitID,
Type: domain.TenantTypeUserGroup,
ParentID: parentID,
Name: name,
Slug: fmt.Sprintf("ug-%s", unitID[:8]),
Description: description,
Status: domain.TenantStatusActive,
}
if err := s.tenantRepo.Create(ctx, groupTenant); err != nil {
slog.Error("Failed to create tenant record for user group", "error", err)
return nil, err
}
// 2. Create UserGroup metadata
group := &domain.UserGroup{
ID: unitID,
TenantID: tenantID,
ParentID: parentID,
Name: name,
Description: description,
UnitType: unitType,
}
if err := s.repo.Create(ctx, group); err != nil { if err := s.repo.Create(ctx, group); err != nil {
return err // Rollback Tenant creation? Or handle via cleanup job. For now, just log.
slog.Error("Failed to create user group metadata after creating tenant", "tenantId", unitID, "error", err)
return nil, err
} }
// Keto: UserGroup:<id>#parent_tenant@Tenant:<tid> // 3. Keto Hierarchy via Outbox: Tenant:<child_id>#parents@Tenant:<parent_id>
err := s.ketoService.CreateRelation(ctx, "UserGroup", group.ID, "parent_tenant", "Tenant:"+group.TenantID) if s.outboxRepo != nil {
if err != nil { _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
slog.Error("Failed to create keto relation for user group", "error", err, "group_id", group.ID) Namespace: "Tenant",
Object: unitID,
Relation: "parents",
Subject: "Tenant:" + *parentID,
Action: domain.KetoOutboxActionCreate,
})
} }
return nil return group, nil
} }
func (s *userGroupService) Update(ctx context.Context, group *domain.UserGroup) error { func (s *userGroupService) Update(ctx context.Context, tenantID, groupID string, name, description, unitType string, parentID *string) (*domain.UserGroup, error) {
return s.repo.Update(ctx, group) // Implementation for Update
return nil, nil // Placeholder
} }
func (s *userGroupService) Delete(ctx context.Context, id string) error { func (s *userGroupService) Delete(ctx context.Context, tenantID, groupID string) error {
// Optional: Delete relations in Keto before DB delete // Implementation for Delete
return s.repo.Delete(ctx, id) return nil // Placeholder
} }
func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGroup, error) { func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGroup, error) {
@@ -77,8 +129,8 @@ func (s *userGroupService) Get(ctx context.Context, id string) (*domain.UserGrou
return nil, err return nil, err
} }
// Fetch members from Keto // Fetch members from Keto (Tenant namespace)
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", group.ID, "members", "") tuples, err := s.ketoService.ListRelations(ctx, "Tenant", group.ID, "members", "")
if err != nil { if err != nil {
slog.Error("Failed to fetch group members from keto", "error", err, "group_id", group.ID) slog.Error("Failed to fetch group members from keto", "error", err, "group_id", group.ID)
return nil, err return nil, err
@@ -142,7 +194,7 @@ func (s *userGroupService) List(ctx context.Context, tenantID string) ([]domain.
// For each group, fetch member count from Keto // For each group, fetch member count from Keto
for i := range groups { for i := range groups {
tuples, err := s.ketoService.ListRelations(ctx, "UserGroup", groups[i].ID, "members", "") tuples, err := s.ketoService.ListRelations(ctx, "Tenant", groups[i].ID, "members", "")
if err == nil { if err == nil {
// Create dummy members just to carry the count for the JSON response // Create dummy members just to carry the count for the JSON response
groups[i].Members = make([]domain.User, len(tuples)) groups[i].Members = make([]domain.User, len(tuples))
@@ -153,30 +205,48 @@ func (s *userGroupService) List(ctx context.Context, tenantID string) ([]domain.
} }
func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string) error { func (s *userGroupService) AddMember(ctx context.Context, groupID, userID string) error {
// Keto: UserGroup:<groupID>#members@User:<userID> // Validate group exists
err := s.ketoService.CreateRelation(ctx, "UserGroup", groupID, "members", "User:"+userID) if _, err := s.repo.FindByID(ctx, groupID); err != nil {
if err != nil { return fmt.Errorf("user group not found: %w", err)
slog.Error("Failed to sync group membership to keto", "error", err, "group", groupID, "user", userID) }
return err
// Keto via Outbox: Tenant:<groupID>#members@User:<userID>
if s.outboxRepo != nil {
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: groupID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionCreate,
})
} }
return nil return nil
} }
func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error { func (s *userGroupService) RemoveMember(ctx context.Context, groupID, userID string) error {
// Keto: Delete relation // Validate group exists
err := s.ketoService.DeleteRelation(ctx, "UserGroup", groupID, "members", "User:"+userID) if _, err := s.repo.FindByID(ctx, groupID); err != nil {
if err != nil { return fmt.Errorf("user group not found: %w", err)
slog.Error("Failed to remove group membership from keto", "error", err, "group", groupID, "user", userID) }
return err
// Keto via Outbox: Delete relation
if s.outboxRepo != nil {
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: groupID,
Relation: "members",
Subject: "User:" + userID,
Action: domain.KetoOutboxActionDelete,
})
} }
return nil return nil
} }
func (s *userGroupService) ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error) { func (s *userGroupService) ListRoles(ctx context.Context, groupID string) ([]domain.GroupRole, error) {
// Query: namespace=Tenant, subject=UserGroup:groupID#members // Query: namespace=Tenant, subject=Tenant:groupID#members
subject := "UserGroup:" + groupID + "#members" subject := "Tenant:" + groupID + "#members"
tuples, err := s.ketoService.ListRelations(ctx, "Tenant", "", "", subject) tuples, err := s.ketoService.ListRelations(ctx, "Tenant", "", "", subject)
if err != nil { if err != nil {
slog.Error("Failed to fetch group roles from keto", "error", err, "group_id", groupID) slog.Error("Failed to fetch group roles from keto", "error", err, "group_id", groupID)
@@ -213,23 +283,36 @@ func (s *userGroupService) ListRoles(ctx context.Context, groupID string) ([]dom
} }
func (s *userGroupService) AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error { func (s *userGroupService) AssignRoleToTenant(ctx context.Context, groupID, tenantID, relation string) error {
// Keto: Tenant:<tenantID>#<relation>@UserGroup:<groupID>#members // Validate group exists
// This means all members of the group have the relation on the tenant. if _, err := s.repo.FindByID(ctx, groupID); err != nil {
subject := "UserGroup:" + groupID + "#members" return fmt.Errorf("user group not found: %w", err)
err := s.ketoService.CreateRelation(ctx, "Tenant", tenantID, relation, subject) }
if err != nil {
slog.Error("Failed to assign group role to tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation) // Keto via Outbox: Tenant:<tenantID>#<relation>@Tenant:<groupID>#members
return err if s.outboxRepo != nil {
subject := "Tenant:" + groupID + "#members"
_ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
Namespace: "Tenant",
Object: tenantID,
Relation: relation,
Subject: subject,
Action: domain.KetoOutboxActionCreate,
})
} }
return nil return nil
} }
func (s *userGroupService) RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error { func (s *userGroupService) RemoveRoleFromTenant(ctx context.Context, groupID, tenantID, relation string) error {
subject := "UserGroup:" + groupID + "#members" // Keto via Outbox: Delete relation
err := s.ketoService.DeleteRelation(ctx, "Tenant", tenantID, relation, subject) if s.outboxRepo != nil {
if err != nil { subject := "Tenant:" + groupID + "#members"
slog.Error("Failed to remove group role from tenant in keto", "error", err, "group", groupID, "tenant", tenantID, "relation", relation) _ = s.outboxRepo.Create(ctx, &domain.KetoOutbox{
return err Namespace: "Tenant",
Object: tenantID,
Relation: relation,
Subject: subject,
Action: domain.KetoOutboxActionDelete,
})
} }
return nil return nil
} }

View File

@@ -0,0 +1,103 @@
package service
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"gorm.io/gorm"
)
func TestUserGroupService_Create_InvalidParentID(t *testing.T) {
mockRepo := new(MockUserGroupRepository)
mockTenantRepo := new(MockTenantRepository)
mockKeto := new(MockKetoServiceShared)
mockOutbox := new(MockKetoOutboxRepositoryShared)
svc := NewUserGroupService(mockRepo, nil, mockTenantRepo, mockKeto, mockOutbox, nil)
tenantID := "company-1"
invalidParentID := "invalid-uuid"
name := "Invalid Parent Group"
description := ""
unitType := "Team"
// Mock: TenantRepo returns record not found for invalidParentID
mockTenantRepo.On("FindByID", mock.Anything, invalidParentID).Return(nil, gorm.ErrRecordNotFound).Once()
// No Create calls should happen on any repo if parent is invalid
mockRepo.AssertNotCalled(t, "Create")
mockTenantRepo.AssertNotCalled(t, "Create")
mockOutbox.AssertNotCalled(t, "Create")
group, err := svc.Create(context.Background(), tenantID, &invalidParentID, name, description, unitType)
assert.Error(t, err)
assert.Contains(t, err.Error(), "parent tenant not found or invalid")
assert.Nil(t, group)
mockTenantRepo.AssertExpectations(t)
}
func TestUserGroupService_AddMember_GroupNotFound(t *testing.T) {
mockOutbox := new(MockKetoOutboxRepositoryShared)
mockUserGroupRepo := new(MockUserGroupRepository)
svc := NewUserGroupService(mockUserGroupRepo, nil, nil, nil, mockOutbox, nil)
groupID := "non-existent-group"
userID := "user-1"
// Mock: Group does not exist
mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(nil, gorm.ErrRecordNotFound)
// No Outbox call should happen if group is not found
mockOutbox.AssertNotCalled(t, "Create")
err := svc.AddMember(context.Background(), groupID, userID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "user group not found")
mockUserGroupRepo.AssertExpectations(t)
}
func TestUserGroupService_RemoveMember_GroupNotFound(t *testing.T) {
mockOutbox := new(MockKetoOutboxRepositoryShared)
mockUserGroupRepo := new(MockUserGroupRepository)
svc := NewUserGroupService(mockUserGroupRepo, nil, nil, nil, mockOutbox, nil)
groupID := "non-existent-group"
userID := "user-1"
// Mock: Group does not exist
mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(nil, gorm.ErrRecordNotFound)
// No Outbox call should happen if group is not found
mockOutbox.AssertNotCalled(t, "Create")
err := svc.RemoveMember(context.Background(), groupID, userID)
assert.Error(t, err)
assert.Contains(t, err.Error(), "user group not found")
mockUserGroupRepo.AssertExpectations(t)
}
func TestUserGroupService_AssignRoleToTenant_GroupNotFound(t *testing.T) {
mockOutbox := new(MockKetoOutboxRepositoryShared)
mockUserGroupRepo := new(MockUserGroupRepository)
svc := NewUserGroupService(mockUserGroupRepo, nil, nil, nil, mockOutbox, nil)
groupID := "non-existent-group"
tenantID := "tenant-alpha"
relation := "manage"
// Mock: Group does not exist
mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(nil, gorm.ErrRecordNotFound)
// No Outbox call should happen if group is not found
mockOutbox.AssertNotCalled(t, "Create")
err := svc.AssignRoleToTenant(context.Background(), groupID, tenantID, relation)
assert.Error(t, err)
assert.Contains(t, err.Error(), "user group not found")
mockUserGroupRepo.AssertExpectations(t)
}

View File

@@ -37,6 +37,9 @@ func (m *MockUserGroupRepository) FindByID(ctx context.Context, id string) (*dom
func (m *MockUserGroupRepository) ListByTenantID(ctx context.Context, tenantID string) ([]domain.UserGroup, error) { func (m *MockUserGroupRepository) ListByTenantID(ctx context.Context, tenantID string) ([]domain.UserGroup, error) {
args := m.Called(ctx, tenantID) args := m.Called(ctx, tenantID)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.UserGroup), args.Error(1) return args.Get(0).([]domain.UserGroup), args.Error(1)
} }
@@ -46,16 +49,27 @@ type MockUserRepository struct {
func (m *MockUserRepository) Create(ctx context.Context, user *domain.User) error { return nil } func (m *MockUserRepository) Create(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) error { return nil } func (m *MockUserRepository) Update(ctx context.Context, user *domain.User) error { return nil }
func (m *MockUserRepository) Delete(ctx context.Context, id string) error {
return m.Called(ctx, id).Error(0)
}
func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) { func (m *MockUserRepository) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
return nil, nil return nil, nil
} }
func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) { func (m *MockUserRepository) FindByID(ctx context.Context, id string) (*domain.User, error) {
return nil, nil args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.User), args.Error(1)
} }
func (m *MockUserRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) { func (m *MockUserRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
args := m.Called(ctx, ids) args := m.Called(ctx, ids)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.User), args.Error(1) return args.Get(0).([]domain.User), args.Error(1)
} }
@@ -71,14 +85,23 @@ type MockTenantRepository struct {
mock.Mock mock.Mock
} }
func (m *MockTenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error { return nil } func (m *MockTenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error {
return m.Called(ctx, tenant).Error(0)
}
func (m *MockTenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error { return nil } func (m *MockTenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error { return nil }
func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) { func (m *MockTenantRepository) FindByID(ctx context.Context, id string) (*domain.Tenant, error) {
return nil, nil args := m.Called(ctx, id)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).(*domain.Tenant), args.Error(1)
} }
func (m *MockTenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) { func (m *MockTenantRepository) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) {
args := m.Called(ctx, ids) args := m.Called(ctx, ids)
if args.Get(0) == nil {
return nil, args.Error(1)
}
return args.Get(0).([]domain.Tenant), args.Error(1) return args.Get(0).([]domain.Tenant), args.Error(1)
} }
@@ -98,75 +121,104 @@ func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, d
return nil return nil
} }
// --- Tests ---
func TestUserGroupService_Create(t *testing.T) { func TestUserGroupService_Create(t *testing.T) {
mockRepo := new(MockUserGroupRepository) mockRepo := new(MockUserGroupRepository)
mockKeto := new(MockKetoService) mockTenantRepo := new(MockTenantRepository)
// We don't need userRepo or tenantRepo for Create mockKeto := new(MockKetoServiceShared)
svc := NewUserGroupService(mockRepo, nil, nil, mockKeto, nil) mockOutbox := new(MockKetoOutboxRepositoryShared)
svc := NewUserGroupService(mockRepo, nil, mockTenantRepo, mockKeto, mockOutbox, nil)
group := &domain.UserGroup{ tenantID := "company-1"
ID: "group-1", parentID := "parent-group-id"
TenantID: "tenant-1", name := "Test Group"
Name: "Test Group", description := "Group Description"
} unitType := "Team"
mockRepo.On("Create", mock.Anything, group).Return(nil) // Mock Tenant FindByID for parent check
mockKeto.On("CreateRelation", mock.Anything, "UserGroup", group.ID, "parent_tenant", "Tenant:"+group.TenantID).Return(nil) mockTenantRepo.On("FindByID", mock.Anything, parentID).Return(&domain.Tenant{ID: parentID}, nil)
err := svc.Create(context.Background(), group) // Mock Tenant creation (Polymorphic)
mockTenantRepo.On("Create", mock.Anything, mock.MatchedBy(func(ten *domain.Tenant) bool {
return ten.Type == domain.TenantTypeUserGroup && ten.Name == name && *ten.ParentID == parentID
})).Return(nil)
// Mock UserGroup creation
mockRepo.On("Create", mock.Anything, mock.MatchedBy(func(g *domain.UserGroup) bool {
return g.Name == name && *g.ParentID == parentID && g.TenantID == tenantID
})).Return(nil)
// Mock Keto sync via Outbox
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Relation == "parents" && e.Subject == "Tenant:"+parentID
})).Return(nil)
group, err := svc.Create(context.Background(), tenantID, &parentID, name, description, unitType)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, group)
mockTenantRepo.AssertExpectations(t)
mockRepo.AssertExpectations(t) mockRepo.AssertExpectations(t)
mockKeto.AssertExpectations(t) mockOutbox.AssertExpectations(t)
} }
func TestUserGroupService_AddMember(t *testing.T) { func TestUserGroupService_AddMember(t *testing.T) {
mockKeto := new(MockKetoService) mockOutbox := new(MockKetoOutboxRepositoryShared)
svc := NewUserGroupService(nil, nil, nil, mockKeto, nil) mockUserGroupRepo := new(MockUserGroupRepository)
mockUserRepo := new(MockUserRepository)
svc := NewUserGroupService(mockUserGroupRepo, mockUserRepo, nil, nil, mockOutbox, nil)
groupID := "group-1" groupID := "group-1"
userID := "user-1" userID := "user-1"
mockKeto.On("CreateRelation", mock.Anything, "UserGroup", groupID, "members", "User:"+userID).Return(nil) mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID}, nil)
mockUserRepo.On("FindByID", mock.Anything, userID).Return(&domain.User{ID: userID}, nil)
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == groupID && e.Relation == "members" && e.Subject == "User:"+userID
})).Return(nil)
err := svc.AddMember(context.Background(), groupID, userID) err := svc.AddMember(context.Background(), groupID, userID)
assert.NoError(t, err) assert.NoError(t, err)
mockKeto.AssertExpectations(t) mockOutbox.AssertExpectations(t)
} }
func TestUserGroupService_AssignRoleToTenant(t *testing.T) { func TestUserGroupService_AssignRoleToTenant(t *testing.T) {
mockKeto := new(MockKetoService) mockOutbox := new(MockKetoOutboxRepositoryShared)
svc := NewUserGroupService(nil, nil, nil, mockKeto, nil) mockUserGroupRepo := new(MockUserGroupRepository)
svc := NewUserGroupService(mockUserGroupRepo, nil, nil, nil, mockOutbox, nil)
groupID := "group-1" groupID := "group-1"
tenantID := "tenant-alpha" tenantID := "tenant-alpha"
relation := "manage" relation := "manage"
expectedSubject := "UserGroup:" + groupID + "#members" mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID}, nil)
mockKeto.On("CreateRelation", mock.Anything, "Tenant", tenantID, relation, expectedSubject).Return(nil)
expectedSubject := "Tenant:" + groupID + "#members"
mockOutbox.On("Create", mock.Anything, mock.MatchedBy(func(e *domain.KetoOutbox) bool {
return e.Namespace == "Tenant" && e.Object == tenantID && e.Relation == relation && e.Subject == expectedSubject
})).Return(nil)
err := svc.AssignRoleToTenant(context.Background(), groupID, tenantID, relation) err := svc.AssignRoleToTenant(context.Background(), groupID, tenantID, relation)
assert.NoError(t, err) assert.NoError(t, err)
mockKeto.AssertExpectations(t) mockOutbox.AssertExpectations(t)
} }
func TestUserGroupService_ListRoles(t *testing.T) { func TestUserGroupService_ListRoles(t *testing.T) {
mockKeto := new(MockKetoService) mockKeto := new(MockKetoServiceShared)
mockTenantRepo := new(MockTenantRepository) mockTenantRepo := new(MockTenantRepository)
svc := NewUserGroupService(nil, nil, mockTenantRepo, mockKeto, nil) mockUserGroupRepo := new(MockUserGroupRepository)
svc := NewUserGroupService(mockUserGroupRepo, nil, mockTenantRepo, mockKeto, nil, nil)
groupID := "group-1" groupID := "group-1"
subject := "UserGroup:" + groupID + "#members" subject := "Tenant:" + groupID + "#members"
mockUserGroupRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID}, nil)
// Mock Keto relations
tuples := []RelationTuple{ tuples := []RelationTuple{
{Object: "t1", Relation: "manage", SubjectID: subject}, {Object: "t1", Relation: "manage", SubjectID: subject},
{Object: "t2", Relation: "view", SubjectID: subject}, {Object: "t2", Relation: "view", SubjectID: subject},
} }
mockKeto.On("ListRelations", mock.Anything, "Tenant", "", "", subject).Return(tuples, nil) mockKeto.On("ListRelations", mock.Anything, "Tenant", "", "", subject).Return(tuples, nil)
// Mock Tenant fetching
tenants := []domain.Tenant{ tenants := []domain.Tenant{
{ID: "t1", Name: "Tenant One"}, {ID: "t1", Name: "Tenant One"},
{ID: "t2", Name: "Tenant Two"}, {ID: "t2", Name: "Tenant Two"},
@@ -176,25 +228,15 @@ func TestUserGroupService_ListRoles(t *testing.T) {
roles, err := svc.ListRoles(context.Background(), groupID) roles, err := svc.ListRoles(context.Background(), groupID)
assert.NoError(t, err) assert.NoError(t, err)
assert.Len(t, roles, 2) assert.Len(t, roles, 2)
assert.Equal(t, "Tenant One", roles[0].TenantName)
assert.Equal(t, "manage", roles[0].Relation)
assert.Equal(t, "Tenant Two", roles[1].TenantName)
assert.Equal(t, "view", roles[1].Relation)
mockKeto.AssertExpectations(t)
mockTenantRepo.AssertExpectations(t)
} }
func TestUserGroupService_Get_WithKratosFallback(t *testing.T) { func TestUserGroupService_Get_WithKratosFallback(t *testing.T) {
// This tests the logic where a user is in Keto but not in local DB
mockRepo := new(MockUserGroupRepository) mockRepo := new(MockUserGroupRepository)
mockKeto := new(MockKetoService) mockKeto := new(MockKetoServiceShared)
mockUserRepo := new(MockUserRepository) mockUserRepo := new(MockUserRepository)
// We need a way to mock KratosAdminService but it's a struct, not an interface. mockKratos := new(MockKratosAdminServiceShared)
// For this POC test, we'll focus on the Keto and UserRepo parts.
// If needed, we can refactor KratosAdminService to an interface.
svc := NewUserGroupService(mockRepo, mockUserRepo, nil, mockKeto, nil) svc := NewUserGroupService(mockRepo, mockUserRepo, nil, mockKeto, nil, mockKratos)
groupID := "group-1" groupID := "group-1"
mockRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, Name: "Test"}, nil) mockRepo.On("FindByID", mock.Anything, groupID).Return(&domain.UserGroup{ID: groupID, Name: "Test"}, nil)
@@ -202,14 +244,18 @@ func TestUserGroupService_Get_WithKratosFallback(t *testing.T) {
tuples := []RelationTuple{ tuples := []RelationTuple{
{Object: groupID, Relation: "members", SubjectID: "User:u1"}, {Object: groupID, Relation: "members", SubjectID: "User:u1"},
} }
mockKeto.On("ListRelations", mock.Anything, "UserGroup", groupID, "members", "").Return(tuples, nil) mockKeto.On("ListRelations", mock.Anything, "Tenant", groupID, "members", "").Return(tuples, nil)
// User u1 not in local DB
mockUserRepo.On("FindByIDs", mock.Anything, []string{"u1"}).Return([]domain.User{}, nil) mockUserRepo.On("FindByIDs", mock.Anything, []string{"u1"}).Return([]domain.User{}, nil)
mockKratos.On("GetIdentity", mock.Anything, "u1").Return(&KratosIdentity{
ID: "u1",
Traits: map[string]interface{}{"name": "User One", "email": "user1@example.com"},
}, nil)
group, err := svc.Get(context.Background(), groupID) group, err := svc.Get(context.Background(), groupID)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, group) assert.NotNil(t, group)
// Members should be empty since Kratos is nil in this test setup assert.Len(t, group.Members, 1)
assert.Len(t, group.Members, 0) assert.Equal(t, "User One", group.Members[0].Name)
} }

View File

@@ -0,0 +1,62 @@
package utils
import (
"baron-sso-backend/internal/domain"
"testing"
"github.com/stretchr/testify/assert"
)
func TestValidatePasswordWithPolicy(t *testing.T) {
policy := &domain.PasswordPolicy{
MinLength: 8,
Lowercase: true,
Uppercase: true,
Number: true,
NonAlphanumeric: true,
MinCharacterTypes: 3,
}
t.Run("Valid Password", func(t *testing.T) {
err := ValidatePasswordWithPolicy(policy, "Pass1234!")
assert.NoError(t, err)
})
t.Run("Too Short", func(t *testing.T) {
err := ValidatePasswordWithPolicy(policy, "P123!")
assert.Error(t, err)
assert.Contains(t, err.Error(), "최소 8자")
})
t.Run("Missing Lowercase", func(t *testing.T) {
err := ValidatePasswordWithPolicy(policy, "PASS1234!")
assert.Error(t, err)
assert.Contains(t, err.Error(), "소문자")
})
t.Run("Missing Symbol", func(t *testing.T) {
err := ValidatePasswordWithPolicy(policy, "Pass1234")
assert.Error(t, err)
assert.Contains(t, err.Error(), "특수문자")
})
}
func TestGeneratePasswordWithPolicy(t *testing.T) {
policy := &domain.PasswordPolicy{
MinLength: 16,
Lowercase: true,
Uppercase: true,
Number: true,
NonAlphanumeric: true,
}
t.Run("Generate and Validate", func(t *testing.T) {
password, err := GeneratePasswordWithPolicy(policy)
assert.NoError(t, err)
assert.Len(t, password, 16)
// Generated password must satisfy the policy
err = ValidatePasswordWithPolicy(policy, password)
assert.NoError(t, err, "Generated password '%s' does not satisfy policy", password)
})
}

View File

@@ -1,4 +1,4 @@
import { ShieldHalf, LogIn, ExternalLink } from "lucide-react"; import { ExternalLink, LogIn, ShieldHalf } from "lucide-react";
import { useAuth } from "react-oidc-context"; import { useAuth } from "react-oidc-context";
import { Button } from "../../components/ui/button"; import { Button } from "../../components/ui/button";
import { import {

View File

@@ -2,43 +2,23 @@ import { Namespace, Subject, Context, SubjectSet } from "@ory/keto-definitions"
class User implements Namespace {} class User implements Namespace {}
class TenantGroup implements Namespace {
related: {
admins: User[]
}
}
class UserGroup implements Namespace {
related: {
members: User[]
parent_tenant: Tenant[]
}
permits = {
check_member: (ctx: Context): boolean =>
this.related.members.includes(ctx.subject)
}
}
class Tenant implements Namespace { class Tenant implements Namespace {
related: { related: {
admins: (User | SubjectSet<UserGroup, "members">)[] owners: User[]
members: (User | SubjectSet<UserGroup, "members">)[] admins: (User | SubjectSet<Tenant, "owners">)[]
parent: Tenant[] members: User[]
parent_group: TenantGroup[] parents: Tenant[]
} }
permits = { permits = {
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.parent.traverse((p) => p.permits.view(ctx)) || this.related.parents.traverse((p) => p.permits.view(ctx)),
this.related.parent_group.traverse((g) => g.related.admins.includes(ctx.subject)),
manage: (ctx: Context): boolean => manage: (ctx: Context): boolean =>
this.related.admins.includes(ctx.subject) || this.related.admins.includes(ctx.subject) ||
this.related.parent.traverse((p) => p.permits.manage(ctx)) || this.related.parents.traverse((p) => p.permits.manage(ctx)),
this.related.parent_group.traverse((g) => g.related.admins.includes(ctx.subject)),
create_subtenant: (ctx: Context): boolean => create_subtenant: (ctx: Context): boolean =>
this.permits.manage(ctx) this.permits.manage(ctx)
@@ -47,24 +27,30 @@ class Tenant implements Namespace {
class RelyingParty implements Namespace { class RelyingParty implements Namespace {
related: { related: {
owners: (User | SubjectSet<UserGroup, "members">)[] admins: User[]
parent_tenant: Tenant[] parents: Tenant[]
access: (User | SubjectSet<Tenant, "members"> | SubjectSet<System, "authenticated_users">)[]
} }
permits = { permits = {
view: (ctx: Context): boolean => view: (ctx: Context): boolean =>
this.related.owners.includes(ctx.subject) || this.related.admins.includes(ctx.subject) ||
this.related.parent_tenant.traverse((t) => t.permits.view(ctx)), this.related.parents.traverse((t) => t.permits.view(ctx)),
manage: (ctx: Context): boolean => manage: (ctx: Context): boolean =>
this.related.owners.includes(ctx.subject) || this.related.admins.includes(ctx.subject) ||
this.related.parent_tenant.traverse((t) => t.permits.manage(ctx)) this.related.parents.traverse((t) => t.permits.manage(ctx)),
access: (ctx: Context): boolean =>
this.related.access.includes(ctx.subject) ||
this.permits.manage(ctx)
} }
} }
class System implements Namespace { class System implements Namespace {
related: { related: {
super_admins: User[] super_admins: User[]
authenticated_users: User[]
} }
permits = { permits = {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1349,3 +1349,147 @@ verify = ""
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "" action = ""
# Auto-added missing keys
[domain.tenant_type]
company = ""
company_group = ""
personal = ""
user_group = ""
[msg.admin.groups.list]
create_error = ""
create_success = ""
delete_confirm = ""
delete_error = ""
delete_success = ""
empty = ""
import_error = ""
import_success = ""
loading = ""
[msg.admin.groups.members]
add_success = ""
remove_confirm = ""
remove_success = ""
[msg.admin.groups.roles]
assign_success = ""
description = ""
empty = ""
remove_confirm = ""
remove_success = ""
[msg.admin.tenants.admins]
add_success = ""
empty = ""
remove_confirm = ""
remove_success = ""
subtitle = ""
[msg.admin.tenants]
approve_confirm = ""
approve_success = ""
delete_success = ""
missing_id = ""
[msg.common]
error = ""
no_description = ""
[ui.admin.groups]
add_unit = ""
import_csv = ""
[ui.admin.groups.create]
description = ""
[ui.admin.groups.detail]
breadcrumb_org = ""
breadcrumb_tenant = ""
breadcrumb_unit = ""
members_subtitle = ""
members_title = ""
permissions_subtitle = ""
permissions_title = ""
[ui.admin.groups.form]
parent_label = ""
parent_none = ""
unit_level_label = ""
unit_level_placeholder = ""
[ui.admin.groups.table]
created_at = ""
level = ""
[ui.admin.tenants.admins]
add_button = ""
already_admin = ""
dialog_description = ""
dialog_no_results = ""
dialog_search_hint = ""
dialog_search_placeholder = ""
dialog_title = ""
remove_title = ""
table_actions = ""
table_email = ""
table_name = ""
title = ""
[ui.admin.tenants.create.form]
parent = ""
type = ""
[ui.admin.tenants.detail]
breadcrumb_list = ""
header_subtitle = ""
loading = ""
tab_admins = ""
tab_federation = ""
tab_organization = ""
tab_profile = ""
tab_schema = ""
title = ""
[ui.admin.tenants.list]
select_placeholder = ""
[ui.admin.tenants.profile]
allowed_domains = ""
allowed_domains_help = ""
approve_button = ""
description = ""
name = ""
slug = ""
status = ""
subtitle = ""
title = ""
type = ""
[ui.admin.tenants.table]
type = ""
[ui.admin.users.create.form]
job_title = ""
job_title_placeholder = ""
position = ""
position_placeholder = ""
[ui.admin.users.detail.form]
job_title = ""
job_title_placeholder = ""
position = ""
position_placeholder = ""
[ui.admin.users.list.table]
position_job = ""
[ui.common]
admin_only = ""
assign = ""
none = ""
select = ""
select_placeholder = ""

View File

@@ -798,11 +798,7 @@ class AuthProxyService {
await http.post( await http.post(
url, url,
headers: {'Content-Type': 'application/json'}, headers: {'Content-Type': 'application/json'},
body: jsonEncode({ body: jsonEncode({'level': level, 'message': message, 'data': ?data}),
'level': level,
'message': message,
if (data != null) 'data': data,
}),
); );
_recordClientLogSuccess(); _recordClientLogSuccess();
} catch (_) { } catch (_) {
@@ -925,7 +921,7 @@ class AuthProxyService {
'name': name, 'name': name,
'phone': phone, 'phone': phone,
'affiliationType': affiliationType, 'affiliationType': affiliationType,
if (companyCode != null) 'companyCode': companyCode, 'companyCode': ?companyCode,
'department': department, 'department': department,
'termsAccepted': termsAccepted, 'termsAccepted': termsAccepted,
}), }),

View File

@@ -98,7 +98,10 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen> {
children: [ children: [
Text( Text(
tr('ui.userfront.forgot.heading'), tr('ui.userfront.forgot.heading'),
style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
),
textAlign: TextAlign.center, textAlign: TextAlign.center,
), ),
if (_drySendEnabled) ...[ if (_drySendEnabled) ...[

View File

@@ -23,7 +23,10 @@ class LoginSuccessScreen extends StatelessWidget {
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
tr('ui.userfront.login_success.title'), tr('ui.userfront.login_success.title'),
style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold), style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.bold,
),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(

View File

@@ -178,7 +178,7 @@ class _ResetPasswordScreenState extends State<ResetPasswordScreen> {
children: [ children: [
Text( Text(
tr('ui.userfront.reset.subtitle'), tr('ui.userfront.reset.subtitle'),
style: TextStyle( style: const TextStyle(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),

View File

@@ -16,9 +16,9 @@
--> -->
<base href="/" /> <base href="/" />
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta content="IE=Edge" http-equiv="X-UA-Compatible" /> <meta content="IE=Edge" http-equiv="X-UA-Compatible" />
<meta name="description" content="바론 SW 포털" /> <meta name="description" content="바론 SW 포털" />
<!-- iOS meta tags & icons --> <!-- iOS meta tags & icons -->
<meta name="mobile-web-app-capable" content="yes" /> <meta name="mobile-web-app-capable" content="yes" />