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:
@@ -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
|
||||||
|
|||||||
1214
adminfront/package-lock.json
generated
1214
adminfront/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
|||||||
85
adminfront/playwright-report/index.html
Normal file
85
adminfront/playwright-report/index.html
Normal file
File diff suppressed because one or more lines are too long
@@ -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 /> },
|
||||||
|
|||||||
@@ -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 },
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
26
adminfront/src/components/ui/badge.test.tsx
Normal file
26
adminfront/src/components/ui/badge.test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
38
adminfront/src/components/ui/button.test.tsx
Normal file
38
adminfront/src/components/ui/button.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 = "사용자"
|
||||||
|
|||||||
8
adminfront/src/test/setup.ts
Normal file
8
adminfront/src/test/setup.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import "@testing-library/jest-dom";
|
||||||
|
import { cleanup } from "@testing-library/react";
|
||||||
|
import { afterEach } from "vitest";
|
||||||
|
|
||||||
|
// 각 테스트가 끝날 때마다 DOM을 정리합니다.
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
});
|
||||||
@@ -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);
|
|
||||||
});
|
|
||||||
12
adminfront/vitest.config.ts
Normal file
12
adminfront/vitest.config.ts
Normal 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}"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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=
|
||||||
|
|||||||
@@ -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
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
48
backend/internal/domain/keto_outbox.go
Normal file
48
backend/internal/domain/keto_outbox.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
41
backend/internal/handler/org_chart_handler.go
Normal file
41
backend/internal/handler/org_chart_handler.go
Normal 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"})
|
||||||
|
}
|
||||||
@@ -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}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
79
backend/internal/handler/user_handler_test.go
Normal file
79
backend/internal/handler/user_handler_test.go
Normal 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.
|
||||||
|
}
|
||||||
61
backend/internal/repository/keto_outbox_repository.go
Normal file
61
backend/internal/repository/keto_outbox_repository.go
Normal 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
|
||||||
|
}
|
||||||
68
backend/internal/repository/main_test.go
Normal file
68
backend/internal/repository/main_test.go
Normal 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())
|
||||||
|
}
|
||||||
121
backend/internal/repository/tenant_repository_test.go
Normal file
121
backend/internal/repository/tenant_repository_test.go
Normal 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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
76
backend/internal/repository/user_repository_test.go
Normal file
76
backend/internal/repository/user_repository_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
78
backend/internal/service/keto_relay_worker.go
Normal file
78
backend/internal/service/keto_relay_worker.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
112
backend/internal/service/mock_common_test.go
Normal file
112
backend/internal/service/mock_common_test.go
Normal 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)
|
||||||
|
}
|
||||||
239
backend/internal/service/org_chart_service.go
Normal file
239
backend/internal/service/org_chart_service.go
Normal 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: ¤tParentID,
|
||||||
|
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: ¤tParentID,
|
||||||
|
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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
114
backend/internal/service/tenant_service_edge_test.go
Normal file
114
backend/internal/service/tenant_service_edge_test.go
Normal 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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
103
backend/internal/service/user_group_service_edge_test.go
Normal file
103
backend/internal/service/user_group_service_edge_test.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
62
backend/internal/utils/password_policy_test.go
Normal file
62
backend/internal/utils/password_policy_test.go
Normal 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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
179
locales/en.toml
179
locales/en.toml
File diff suppressed because one or more lines are too long
201
locales/ko.toml
201
locales/ko.toml
File diff suppressed because one or more lines are too long
@@ -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 = ""
|
||||||
|
|||||||
@@ -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,
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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) ...[
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
Reference in New Issue
Block a user