forked from baron/baron-sso
Merge pull request 'feature/adminfront' (#347) from feature/adminfront into dev
Reviewed-on: baron/baron-sso#347
This commit is contained in:
35
adminfront/src/components/ui/card.test.tsx
Normal file
35
adminfront/src/components/ui/card.test.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./card";
|
||||
|
||||
describe("Card Component", () => {
|
||||
it("renders card structure correctly", () => {
|
||||
render(
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Card Title</CardTitle>
|
||||
<CardDescription>Card Description</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>Card Content</CardContent>
|
||||
<CardFooter>Card Footer</CardFooter>
|
||||
</Card>,
|
||||
);
|
||||
|
||||
expect(screen.getByText("Card Title")).toBeInTheDocument();
|
||||
expect(screen.getByText("Card Description")).toBeInTheDocument();
|
||||
expect(screen.getByText("Card Content")).toBeInTheDocument();
|
||||
expect(screen.getByText("Card Footer")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies custom className to Card", () => {
|
||||
const { container } = render(<Card className="custom-card" />);
|
||||
expect(container.firstChild).toHaveClass("custom-card");
|
||||
});
|
||||
});
|
||||
28
adminfront/src/components/ui/input.test.tsx
Normal file
28
adminfront/src/components/ui/input.test.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Input } from "./input";
|
||||
|
||||
describe("Input Component", () => {
|
||||
it("renders correctly", () => {
|
||||
render(<Input placeholder="Enter text" />);
|
||||
expect(screen.getByPlaceholderText("Enter text")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("handles value changes", async () => {
|
||||
const onChange = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(<Input placeholder="Enter text" onChange={onChange} />);
|
||||
const input = screen.getByPlaceholderText("Enter text");
|
||||
|
||||
await user.type(input, "Hello");
|
||||
expect(onChange).toHaveBeenCalled();
|
||||
expect(input).toHaveValue("Hello");
|
||||
});
|
||||
|
||||
it("is disabled when the disabled prop is passed", () => {
|
||||
render(<Input disabled />);
|
||||
const input = screen.getByRole("textbox");
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
});
|
||||
27
adminfront/src/components/ui/label.test.tsx
Normal file
27
adminfront/src/components/ui/label.test.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Label } from "./label";
|
||||
|
||||
describe("Label Component", () => {
|
||||
it("renders correctly with children", () => {
|
||||
render(<Label>Username</Label>);
|
||||
expect(screen.getByText("Username")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("applies custom className", () => {
|
||||
render(<Label className="custom-label">Password</Label>);
|
||||
const label = screen.getByText("Password");
|
||||
expect(label).toHaveClass("custom-label");
|
||||
});
|
||||
|
||||
it("is associated with an input via htmlFor", () => {
|
||||
render(
|
||||
<>
|
||||
<Label htmlFor="test-input">Label Text</Label>
|
||||
<input id="test-input" />
|
||||
</>,
|
||||
);
|
||||
const label = screen.getByText("Label Text");
|
||||
expect(label).toHaveAttribute("for", "test-input");
|
||||
});
|
||||
});
|
||||
87
adminfront/src/components/ui/tabs.tsx
Normal file
87
adminfront/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
import * as React from "react";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const TabsContext = React.createContext<{
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}>({});
|
||||
|
||||
const Tabs = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & {
|
||||
value?: string;
|
||||
onValueChange?: (value: string) => void;
|
||||
}
|
||||
>(({ className, value, onValueChange, ...props }, ref) => {
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, onValueChange }}>
|
||||
<div ref={ref} className={cn("w-full", className)} {...props} />
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
});
|
||||
Tabs.displayName = "Tabs";
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
));
|
||||
TabsList.displayName = "TabsList";
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
HTMLButtonElement,
|
||||
React.ButtonHTMLAttributes<HTMLButtonElement> & { value: string }
|
||||
>(({ className, value, ...props }, ref) => {
|
||||
const { value: activeValue, onValueChange } = React.useContext(TabsContext);
|
||||
const isSelected = activeValue === value;
|
||||
|
||||
return (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={isSelected}
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||
className,
|
||||
)}
|
||||
data-state={isSelected ? "active" : "inactive"}
|
||||
onClick={() => onValueChange?.(value)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
TabsTrigger.displayName = "TabsTrigger";
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & { value: string }
|
||||
>(({ className, value, ...props }, ref) => {
|
||||
const { value: activeValue } = React.useContext(TabsContext);
|
||||
const isSelected = activeValue === value;
|
||||
|
||||
if (!isSelected) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
role="tabpanel"
|
||||
className={cn(
|
||||
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
});
|
||||
TabsContent.displayName = "TabsContent";
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||
@@ -29,8 +29,8 @@ function TenantCreatePage() {
|
||||
const [domains, setDomains] = useState("");
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
queryKey: ["tenants", { limit: 1000 }],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
});
|
||||
|
||||
const mutation = useMutation({
|
||||
|
||||
@@ -27,118 +27,13 @@ import {
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
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();
|
||||
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({
|
||||
queryKey: ["tenants", { limit: 1000, offset: 0 }], // Fetch all to build tree
|
||||
queryKey: ["tenants", { limit: 1000, offset: 0 }],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
});
|
||||
|
||||
const navigate = useNavigate();
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (tenantId: string) => deleteTenant(tenantId),
|
||||
onSuccess: () => {
|
||||
@@ -153,7 +48,7 @@ function TenantListPage() {
|
||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||
: null;
|
||||
|
||||
const tenantTree = query.data?.items ? buildTenantTree(query.data.items) : [];
|
||||
const tenants = query.data?.items ?? [];
|
||||
|
||||
const handleDelete = (tenantId: string, tenantName: string) => {
|
||||
if (
|
||||
@@ -187,7 +82,7 @@ function TenantListPage() {
|
||||
<p className="text-sm text-[var(--color-muted)]">
|
||||
{t(
|
||||
"msg.admin.tenants.subtitle",
|
||||
"현재 등록된 테넌트를 확인하고 상태를 관리합니다.",
|
||||
"시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -213,7 +108,7 @@ function TenantListPage() {
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
<CardTitle>
|
||||
{t("ui.admin.tenants.registry.title", "Tenant registry")}
|
||||
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
|
||||
@@ -247,6 +142,9 @@ function TenantListPage() {
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||
</TableHead>
|
||||
<TableHead>
|
||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||
</TableHead>
|
||||
@@ -258,15 +156,15 @@ function TenantListPage() {
|
||||
<TableBody>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6}>
|
||||
<TableCell colSpan={7}>
|
||||
{t("msg.common.loading", "로딩 중...")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{!query.isLoading && tenantTree.length === 0 && (
|
||||
{!query.isLoading && tenants.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
colSpan={7}
|
||||
className="text-center py-8 text-muted-foreground"
|
||||
>
|
||||
{t(
|
||||
@@ -276,14 +174,63 @@ function TenantListPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{tenantTree.map((tenant) => (
|
||||
<TenantRow
|
||||
key={tenant.id}
|
||||
tenant={tenant}
|
||||
level={0}
|
||||
onDelete={handleDelete}
|
||||
isDeleting={deleteMutation.isPending}
|
||||
/>
|
||||
{tenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="font-semibold">{tenant.name}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
{t(
|
||||
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
|
||||
tenant.type,
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{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 className="font-medium">
|
||||
{tenant.memberCount}
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{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>
|
||||
</Table>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ export type TenantSummary = {
|
||||
domains?: string[];
|
||||
parentId?: string;
|
||||
config?: Record<string, unknown>;
|
||||
memberCount: number; // Added member count
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
@@ -55,6 +56,7 @@ export type TenantUpdateRequest = {
|
||||
name?: string;
|
||||
type?: string;
|
||||
slug?: string;
|
||||
parentId?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
domains?: string[];
|
||||
@@ -380,9 +382,14 @@ export type UserUpdateRequest = {
|
||||
jobTitle?: string;
|
||||
};
|
||||
|
||||
export async function fetchUsers(limit = 50, offset = 0, search?: string) {
|
||||
export async function fetchUsers(
|
||||
limit = 50,
|
||||
offset = 0,
|
||||
search?: string,
|
||||
companyCode?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
|
||||
params: { limit, offset, search },
|
||||
params: { limit, offset, search, companyCode },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
35
adminfront/src/lib/i18n.test.ts
Normal file
35
adminfront/src/lib/i18n.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { t } from "./i18n";
|
||||
|
||||
describe("i18n utility", () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns fallback if key not found", () => {
|
||||
expect(t("non.existent.key", "Fallback")).toBe("Fallback");
|
||||
});
|
||||
|
||||
it("returns key if fallback not provided and key not found", () => {
|
||||
expect(t("non.existent.key")).toBe("non.existent.key");
|
||||
});
|
||||
|
||||
it("replaces variables in template", () => {
|
||||
expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe(
|
||||
"Hello World",
|
||||
);
|
||||
});
|
||||
|
||||
it("respects locale in localStorage", () => {
|
||||
window.localStorage.setItem("locale", "en");
|
||||
// We expect some key that exists in en.toml
|
||||
// Let's use a common one or a fallback if we don't know the content
|
||||
expect(t("ui.common.save", "Save")).toBe("Save");
|
||||
});
|
||||
|
||||
it("defaults to ko if no locale set and browser language is ko", () => {
|
||||
vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR");
|
||||
expect(t("ui.common.save", "저장")).toBe("저장");
|
||||
});
|
||||
});
|
||||
93
adminfront/src/lib/tenantTree.test.ts
Normal file
93
adminfront/src/lib/tenantTree.test.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { TenantSummary } from "./adminApi";
|
||||
import { buildTenantFullTree } from "./tenantTree";
|
||||
|
||||
describe("tenantTree utility", () => {
|
||||
const mockTenants: TenantSummary[] = [
|
||||
{
|
||||
id: "root-1",
|
||||
name: "Root",
|
||||
slug: "root",
|
||||
type: "COMPANY",
|
||||
memberCount: 10,
|
||||
parentId: undefined,
|
||||
description: "",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "child-1",
|
||||
name: "Child 1",
|
||||
slug: "child-1",
|
||||
type: "USER_GROUP",
|
||||
memberCount: 5,
|
||||
parentId: "root-1",
|
||||
description: "",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
{
|
||||
id: "grandchild-1",
|
||||
name: "Grandchild 1",
|
||||
slug: "grandchild-1",
|
||||
type: "USER_GROUP",
|
||||
memberCount: 2,
|
||||
parentId: "child-1",
|
||||
description: "",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
];
|
||||
|
||||
it("calculates recursive member counts correctly", () => {
|
||||
const { currentBase } = buildTenantFullTree(mockTenants, "root-1");
|
||||
|
||||
expect(currentBase).not.toBeNull();
|
||||
if (currentBase) {
|
||||
// Direct: 10, Child: 5, Grandchild: 2 -> Total: 17
|
||||
expect(currentBase.recursiveMemberCount).toBe(17);
|
||||
expect(currentBase.children).toHaveLength(1);
|
||||
|
||||
const child = currentBase.children[0];
|
||||
// Direct: 5, Grandchild: 2 -> Total: 7
|
||||
expect(child.recursiveMemberCount).toBe(7);
|
||||
expect(child.children).toHaveLength(1);
|
||||
|
||||
const grandchild = child.children[0];
|
||||
// Direct: 2 -> Total: 2
|
||||
expect(grandchild.recursiveMemberCount).toBe(2);
|
||||
expect(grandchild.children).toHaveLength(0);
|
||||
}
|
||||
});
|
||||
|
||||
it("returns null currentBase if rootId is not found", () => {
|
||||
const { currentBase } = buildTenantFullTree(mockTenants, "non-existent");
|
||||
expect(currentBase).toBeNull();
|
||||
});
|
||||
|
||||
it("builds correct structure with multiple roots", () => {
|
||||
const multiRootTenants: TenantSummary[] = [
|
||||
...mockTenants,
|
||||
{
|
||||
id: "root-2",
|
||||
name: "Root 2",
|
||||
slug: "root-2",
|
||||
type: "COMPANY",
|
||||
memberCount: 3,
|
||||
parentId: undefined,
|
||||
description: "",
|
||||
status: "active",
|
||||
createdAt: "",
|
||||
updatedAt: "",
|
||||
},
|
||||
];
|
||||
|
||||
const { subTree } = buildTenantFullTree(multiRootTenants);
|
||||
expect(subTree).toHaveLength(2);
|
||||
expect(subTree.map((n) => n.id)).toContain("root-1");
|
||||
expect(subTree.map((n) => n.id)).toContain("root-2");
|
||||
});
|
||||
});
|
||||
69
adminfront/src/lib/tenantTree.ts
Normal file
69
adminfront/src/lib/tenantTree.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { TenantSummary } from "./adminApi";
|
||||
|
||||
export type TenantNode = TenantSummary & {
|
||||
children: TenantNode[];
|
||||
recursiveMemberCount: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds a hierarchical tree from a flat list of tenants and calculates
|
||||
* direct and recursive member counts for each node.
|
||||
*/
|
||||
export function buildTenantFullTree(
|
||||
allTenants: TenantSummary[],
|
||||
rootId?: string,
|
||||
): { currentBase: TenantNode | null; subTree: TenantNode[] } {
|
||||
if (allTenants.length === 0) return { currentBase: null, subTree: [] };
|
||||
|
||||
const tenantMap = new Map<string, TenantNode>();
|
||||
for (const t of allTenants) {
|
||||
tenantMap.set(t.id, {
|
||||
...t,
|
||||
children: [],
|
||||
recursiveMemberCount: t.memberCount || 0,
|
||||
});
|
||||
}
|
||||
|
||||
// Build initial children relations
|
||||
for (const t of allTenants) {
|
||||
if (t.parentId) {
|
||||
const parent = tenantMap.get(t.parentId);
|
||||
const child = tenantMap.get(t.id);
|
||||
if (parent && child) {
|
||||
parent.children.push(child);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Function to calculate recursive counts
|
||||
const calculateRecursive = (node: TenantNode): number => {
|
||||
let total = node.memberCount || 0;
|
||||
for (const child of node.children) {
|
||||
total += calculateRecursive(child);
|
||||
}
|
||||
node.recursiveMemberCount = total;
|
||||
return total;
|
||||
};
|
||||
|
||||
// Calculate for all top-level nodes (those without parent)
|
||||
for (const node of tenantMap.values()) {
|
||||
if (!node.parentId) {
|
||||
calculateRecursive(node);
|
||||
}
|
||||
}
|
||||
|
||||
// If a specific rootId is provided, find and return its subtree
|
||||
if (rootId) {
|
||||
const base = tenantMap.get(rootId);
|
||||
if (base) {
|
||||
// Re-calculate specifically for our current tenant to be sure if it wasn't a global root
|
||||
calculateRecursive(base);
|
||||
return { currentBase: base, subTree: base.children };
|
||||
}
|
||||
return { currentBase: null, subTree: [] };
|
||||
}
|
||||
|
||||
// If no rootId, return all top-level roots as subTree
|
||||
const roots = Array.from(tenantMap.values()).filter((n) => !n.parentId);
|
||||
return { currentBase: null, subTree: roots };
|
||||
}
|
||||
13
adminfront/src/lib/utils.test.ts
Normal file
13
adminfront/src/lib/utils.test.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { cn } from "./utils";
|
||||
|
||||
describe("cn utility", () => {
|
||||
it("merges class names correctly", () => {
|
||||
expect(cn("a", "b")).toBe("a b");
|
||||
expect(cn("a", { b: true, c: false })).toBe("a b");
|
||||
});
|
||||
|
||||
it("handles tailwind class conflicts", () => {
|
||||
expect(cn("px-2 py-2", "px-4")).toBe("py-2 px-4");
|
||||
});
|
||||
});
|
||||
@@ -126,6 +126,8 @@ description = "Description"
|
||||
delete_confirm = "Delete Tenant \\\"{{name}}\\\"?"
|
||||
empty = "Empty"
|
||||
fetch_error = "Fetch Error"
|
||||
not_found = "Tenant not found."
|
||||
remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?'
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.admin.tenants.create]
|
||||
@@ -760,10 +762,26 @@ type_boolean = "Boolean"
|
||||
type_number = "Number"
|
||||
type_text = "Text"
|
||||
|
||||
[ui.admin.tenants.detail]
|
||||
breadcrumb_list = "Tenant List"
|
||||
header_subtitle = "Update tenant information or manage integration settings."
|
||||
loading = "Loading tenant information..."
|
||||
tab_admins = "Admin Settings"
|
||||
tab_federation = "External Integration"
|
||||
tab_organization = "Sub-tenant Management"
|
||||
tab_profile = "Profile"
|
||||
tab_schema = "User Schema"
|
||||
title = "Tenant Details"
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
add = "Add"
|
||||
add = "Add Sub-tenant"
|
||||
add_existing = "Add Existing Tenant"
|
||||
add_dialog_title = "Add Sub-tenant"
|
||||
add_dialog_desc = "Search existing tenants to add as sub-tenants."
|
||||
search_placeholder = "Search name or slug..."
|
||||
no_candidates = "No available tenants found."
|
||||
manage = "Manage"
|
||||
title = "Sub-tenants ({{count}})"
|
||||
title = "Sub-tenant Management ({{count}})"
|
||||
|
||||
[ui.admin.tenants.sub.table]
|
||||
action = "ACTION"
|
||||
|
||||
@@ -149,6 +149,8 @@ delete_success = "테넌트가 삭제되었습니다."
|
||||
empty = "아직 등록된 테넌트가 없습니다."
|
||||
fetch_error = "테넌트 목록 조회에 실패했습니다."
|
||||
missing_id = "테넌트 ID가 없습니다."
|
||||
not_found = "테넌트를 찾을 수 없습니다."
|
||||
remove_sub_confirm = '테넌트 "{{name}}"을(를) 하위 조직에서 제외할까요?'
|
||||
subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다."
|
||||
|
||||
[msg.admin.tenants.admins]
|
||||
@@ -792,7 +794,7 @@ header_subtitle = "테넌트 정보를 수정하거나 연동 설정을 관리
|
||||
loading = "테넌트 정보를 불러오는 중..."
|
||||
tab_admins = "관리자 설정"
|
||||
tab_federation = "외부 연동"
|
||||
tab_organization = "조직 관리"
|
||||
tab_organization = "하위 테넌트 관리"
|
||||
tab_profile = "프로필"
|
||||
tab_schema = "사용자 스키마"
|
||||
title = "테넌트 상세"
|
||||
@@ -866,8 +868,13 @@ type_text = "텍스트 (Text)"
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
add = "하위 테넌트 추가"
|
||||
add_existing = "기존 테넌트 추가"
|
||||
add_dialog_title = "하위 테넌트 추가"
|
||||
add_dialog_desc = "기존에 등록된 테넌트를 검색하여 하위 조직으로 추가합니다."
|
||||
search_placeholder = "테넌트 이름 또는 슬러그 검색..."
|
||||
no_candidates = "추가 가능한 테넌트가 없습니다."
|
||||
manage = "관리"
|
||||
title = "Sub-tenants ({{count}})"
|
||||
title = "하위 테넌트 관리 ({{count}})"
|
||||
|
||||
[ui.admin.tenants.sub.table]
|
||||
action = "ACTION"
|
||||
|
||||
87
adminfront/tests/auth.spec.ts
Normal file
87
adminfront/tests/auth.spec.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Authentication", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock OIDC configuration
|
||||
await page.route(
|
||||
"**/oidc/.well-known/openid-configuration",
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
issuer: "http://localhost:5000/oidc",
|
||||
authorization_endpoint: "http://localhost:5000/oidc/auth",
|
||||
token_endpoint: "http://localhost:5000/oidc/token",
|
||||
jwks_uri: "http://localhost:5000/oidc/jwks",
|
||||
response_types_supported: ["code"],
|
||||
subject_types_supported: ["public"],
|
||||
id_token_signing_alg_values_supported: ["RS256"],
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("should redirect unauthorized users to login page", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
// Should be redirected to /login
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.locator("h1")).toContainText("Baron SSO");
|
||||
});
|
||||
|
||||
test("should allow access to dashboard when authenticated", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.addInitScript(() => {
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
const authData = {
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
profile: {
|
||||
sub: "admin-user",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
},
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
|
||||
// Wait for the auth loading to finish
|
||||
await expect(page.locator(".animate-spin")).not.toBeVisible();
|
||||
|
||||
// Should be on the dashboard/overview
|
||||
await expect(page.locator("aside")).toBeVisible();
|
||||
await expect(page.locator("h1")).toContainText(/Admin Control|운영 도구/);
|
||||
});
|
||||
|
||||
test("should logout and redirect to login page", async ({ page }) => {
|
||||
// Start authenticated
|
||||
await page.addInitScript(() => {
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
const authData = {
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
profile: { sub: "admin-user", name: "Admin" },
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.locator("aside")).toBeVisible();
|
||||
|
||||
// Mock window.confirm
|
||||
page.on("dialog", (dialog) => dialog.accept());
|
||||
|
||||
// Click logout button (label: ui.admin.nav.logout)
|
||||
await page.click('button:has-text("Logout"), button:has-text("로그아웃")');
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
});
|
||||
});
|
||||
225
adminfront/tests/tenants.spec.ts
Normal file
225
adminfront/tests/tenants.spec.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test.describe("Tenants Management", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Authenticate
|
||||
await page.addInitScript(() => {
|
||||
const authority = "http://localhost:5000/oidc";
|
||||
const client_id = "adminfront";
|
||||
const key = `oidc.user:${authority}:${client_id}`;
|
||||
const authData = {
|
||||
access_token: "fake-token",
|
||||
token_type: "Bearer",
|
||||
profile: {
|
||||
sub: "admin-user",
|
||||
name: "Admin User",
|
||||
email: "admin@example.com",
|
||||
},
|
||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||
};
|
||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||
});
|
||||
|
||||
// Mock OIDC config to avoid redirects
|
||||
await page.route(
|
||||
"**/oidc/.well-known/openid-configuration",
|
||||
async (route) => {
|
||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||
},
|
||||
);
|
||||
|
||||
// Default mock for tenants to avoid proxy leaks
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({
|
||||
json: { items: [], total: 0, limit: 100, offset: 0 },
|
||||
});
|
||||
} else {
|
||||
await route.continue();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("should list tenants", async ({ page }) => {
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: [
|
||||
{
|
||||
id: "1",
|
||||
name: "Tenant A",
|
||||
slug: "tenant-a",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
updatedAt: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto("/tenants");
|
||||
await expect(page.locator("h2")).toContainText("테넌트 목록");
|
||||
await expect(page.locator("table")).toContainText("Tenant A");
|
||||
});
|
||||
|
||||
test("should create a new tenant", async ({ page }) => {
|
||||
// Mock GET for list (empty) and for parents
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
await route.fulfill({
|
||||
json: { items: [], total: 0, limit: 100, offset: 0 },
|
||||
});
|
||||
} else if (route.request().method() === "POST") {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
id: "2",
|
||||
name: "New Tenant",
|
||||
slug: "new-tenant",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto("/tenants/new");
|
||||
|
||||
await page.fill("input >> nth=0", "New Tenant");
|
||||
await page.fill("input >> nth=1", "new-tenant");
|
||||
await page.fill("textarea", "Description");
|
||||
|
||||
await page.click('button:has-text("생성")');
|
||||
|
||||
await expect(page).toHaveURL(/\/tenants$/);
|
||||
});
|
||||
|
||||
test("should show validation error on empty name", async ({ page }) => {
|
||||
await page.goto("/tenants/new");
|
||||
const submitBtn = page.locator('button:has-text("생성")');
|
||||
await expect(submitBtn).toBeDisabled();
|
||||
|
||||
await page.fill("input >> nth=0", "Valid Name");
|
||||
await expect(submitBtn).not.toBeDisabled();
|
||||
});
|
||||
|
||||
test("should show organization hierarchy and member list distinction", async ({
|
||||
page,
|
||||
}) => {
|
||||
// Mock parent tenant and its children
|
||||
const mockTenants = [
|
||||
{
|
||||
id: "parent-1",
|
||||
name: "Parent Org",
|
||||
slug: "parent-slug",
|
||||
status: "active",
|
||||
type: "COMPANY",
|
||||
memberCount: 5,
|
||||
parentId: null,
|
||||
},
|
||||
{
|
||||
id: "child-1",
|
||||
name: "Child Team",
|
||||
slug: "child-slug",
|
||||
status: "active",
|
||||
type: "USER_GROUP",
|
||||
memberCount: 3,
|
||||
parentId: "parent-1",
|
||||
},
|
||||
];
|
||||
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: mockTenants,
|
||||
total: 2,
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// Mock members for parent and child
|
||||
await page.route(
|
||||
"**/api/v1/admin/users?*companyCode=parent-slug*",
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: [{ id: "u1", name: "User One", email: "u1@parent.com" }],
|
||||
total: 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.route(
|
||||
"**/api/v1/admin/users?*companyCode=child-slug*",
|
||||
async (route) => {
|
||||
await route.fulfill({
|
||||
json: {
|
||||
items: [{ id: "u2", name: "User Two", email: "u2@child.com" }],
|
||||
total: 1,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.goto("/tenants/parent-1/organization");
|
||||
|
||||
// Wait for the table to appear
|
||||
await expect(page.locator("table")).toBeVisible();
|
||||
|
||||
// Check if hierarchy shows correctly
|
||||
await expect(page.locator("table")).toContainText("Parent Org");
|
||||
await expect(page.locator("table")).toContainText("Child Team");
|
||||
|
||||
// Check if member counts (Direct/Total) are displayed
|
||||
// Parent should have Direct 5, Total 8
|
||||
const parentRow = page.locator("tr", { hasText: "Parent Org" });
|
||||
await expect(parentRow).toContainText("5"); // Direct
|
||||
await expect(parentRow).toContainText("8"); // Total (5 + 3)
|
||||
|
||||
// Check for either English or Korean labels
|
||||
const hasDirectLabel = await parentRow.evaluate(
|
||||
(el) =>
|
||||
el.textContent?.includes("Direct") || el.textContent?.includes("소속"),
|
||||
);
|
||||
const hasTotalLabel = await parentRow.evaluate(
|
||||
(el) =>
|
||||
el.textContent?.includes("Total") || el.textContent?.includes("전체"),
|
||||
);
|
||||
expect(hasDirectLabel).toBe(true);
|
||||
expect(hasTotalLabel).toBe(true);
|
||||
|
||||
// Open Member List Dialog - Click the members count button
|
||||
const memberButton = parentRow
|
||||
.getByRole("button")
|
||||
.filter({ hasText: /Direct|소속/ });
|
||||
await memberButton.click();
|
||||
|
||||
// Check Tabs in Member List Dialog
|
||||
// Use regex to match either language, ignoring the count suffix
|
||||
await expect(
|
||||
page
|
||||
.locator('button[role="tab"]')
|
||||
.filter({ hasText: /소속 멤버|Direct Members/ }),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page
|
||||
.locator('button[role="tab"]')
|
||||
.filter({ hasText: /하위 조직 멤버|Descendant Members/ }),
|
||||
).toBeVisible();
|
||||
|
||||
// Direct Members Tab should show parent's user
|
||||
await expect(page.locator("role=dialog")).toContainText("u1@parent.com");
|
||||
|
||||
// Switch to Descendant Members Tab
|
||||
await page.click(
|
||||
'button[role="tab"]:has-text("하위 조직 멤버"), button[role="tab"]:has-text("Descendant Members")',
|
||||
);
|
||||
await expect(page.locator("role=dialog")).toContainText("u2@child.com");
|
||||
});
|
||||
});
|
||||
@@ -277,7 +277,7 @@ func main() {
|
||||
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
|
||||
adminHandler := handler.NewAdminHandler(ketoService)
|
||||
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler)
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||
tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
|
||||
userGroupHandler := handler.NewUserGroupHandler(userGroupService)
|
||||
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
|
||||
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo)
|
||||
|
||||
@@ -59,7 +59,7 @@ func SeedTenants(db *gorm.DB) error {
|
||||
}
|
||||
|
||||
slog.Info("[Bootstrap] Creating default tenant", "name", config.Name, "slug", config.Slug)
|
||||
tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, config.Description, config.Domains, nil)
|
||||
tenant, err := svc.RegisterTenant(ctx, config.Name, config.Slug, domain.TenantTypeCompany, config.Description, config.Domains, nil)
|
||||
if err != nil {
|
||||
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
|
||||
return err
|
||||
|
||||
@@ -19,23 +19,23 @@ const (
|
||||
type User struct {
|
||||
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||
PasswordHash string `gorm:"not null" json:"-"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
Phone string `json:"phone"`
|
||||
Role string `gorm:"default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user
|
||||
AffiliationType string `json:"affiliationType"`
|
||||
CompanyCode string `json:"companyCode"`
|
||||
TenantID *string `gorm:"type:uuid;index" json:"tenantId,omitempty"`
|
||||
PasswordHash *string `gorm:"column:password_hash" json:"-"`
|
||||
Name string `gorm:"column:name;not null" json:"name"`
|
||||
Phone string `gorm:"column:phone" json:"phone"`
|
||||
Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user
|
||||
AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"`
|
||||
CompanyCode string `gorm:"column:company_code;index" json:"companyCode"`
|
||||
TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"`
|
||||
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
|
||||
RelyingPartyID *string `gorm:"type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
||||
Department string `json:"department"`
|
||||
Position string `json:"position"` // 직급 (예: 수석, 책임, 선임)
|
||||
JobTitle string `json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획)
|
||||
Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"`
|
||||
Status string `gorm:"default:'active'" json:"status"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
|
||||
Department string `gorm:"column:department" json:"department"`
|
||||
Position string `gorm:"column:position" json:"position"` // 직급 (예: 수석, 책임, 선임)
|
||||
JobTitle string `gorm:"column:job_title" json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획)
|
||||
Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"`
|
||||
Status string `gorm:"column:status;default:'active'" json:"status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"`
|
||||
}
|
||||
|
||||
// BeforeCreate hook to generate UUID if not present
|
||||
|
||||
@@ -98,10 +98,22 @@ func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) (
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) {
|
||||
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockUserRepo) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type AsyncMockRedisRepo struct {
|
||||
mock.Mock
|
||||
}
|
||||
@@ -128,7 +140,7 @@ type AsyncMockTenantService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
||||
func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -152,6 +164,10 @@ func (m *AsyncMockTenantService) GetTenant(ctx context.Context, id string) (*dom
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *AsyncMockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,9 @@ import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"baron-sso-backend/internal/service"
|
||||
"baron-sso-backend/internal/utils"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -15,15 +17,17 @@ import (
|
||||
type TenantHandler struct {
|
||||
DB *gorm.DB
|
||||
Service service.TenantService
|
||||
UserRepo repository.UserRepository
|
||||
Keto service.KetoService
|
||||
KetoOutbox repository.KetoOutboxRepository
|
||||
KratosAdmin service.KratosAdminService
|
||||
}
|
||||
|
||||
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService) *TenantHandler {
|
||||
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService) *TenantHandler {
|
||||
return &TenantHandler{
|
||||
DB: db,
|
||||
Service: svc,
|
||||
UserRepo: userRepo,
|
||||
Keto: keto,
|
||||
KetoOutbox: outbox,
|
||||
KratosAdmin: kratos,
|
||||
@@ -32,12 +36,15 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoS
|
||||
|
||||
type tenantSummary struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ParentID *string `json:"parentId"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Domains []string `json:"domains,omitempty"`
|
||||
Config domain.JSONMap `json:"config,omitempty"`
|
||||
MemberCount int64 `json:"memberCount"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
}
|
||||
@@ -91,12 +98,10 @@ func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
if h.DB == nil {
|
||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||
}
|
||||
|
||||
limit := c.QueryInt("limit", 50)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
parentId := c.Query("parentId")
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
@@ -104,19 +109,29 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
||||
offset = 0
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := h.DB.Model(&domain.Tenant{}).Count(&total).Error; err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
tenants, total, err := h.Service.ListTenants(c.Context(), limit, offset, parentId)
|
||||
if err != nil {
|
||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||
}
|
||||
|
||||
var tenants []domain.Tenant
|
||||
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
// Fetch member counts for all tenants in one query using slugs (company codes)
|
||||
slugs := make([]string, 0, len(tenants))
|
||||
for _, t := range tenants {
|
||||
slugs = append(slugs, t.Slug)
|
||||
}
|
||||
memberCounts, err := h.UserRepo.CountByCompanyCodes(c.Context(), slugs)
|
||||
if err != nil {
|
||||
slog.Warn("failed to count members for tenants", "error", err)
|
||||
memberCounts = make(map[string]int64)
|
||||
}
|
||||
|
||||
items := make([]tenantSummary, 0, len(tenants))
|
||||
for _, t := range tenants {
|
||||
items = append(items, mapTenantSummary(t))
|
||||
summary := mapTenantSummary(t)
|
||||
// Ensure robust matching by trimming and lowercasing the slug key
|
||||
key := strings.ToLower(strings.TrimSpace(t.Slug))
|
||||
summary.MemberCount = memberCounts[key]
|
||||
items = append(items, summary)
|
||||
}
|
||||
|
||||
return c.JSON(tenantListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
|
||||
@@ -140,7 +155,15 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
return c.JSON(mapTenantSummary(tenant))
|
||||
memberCounts, err := h.UserRepo.CountByCompanyCodes(c.Context(), []string{tenant.Slug})
|
||||
count := int64(0)
|
||||
if err == nil {
|
||||
count = memberCounts[strings.ToLower(tenant.Slug)]
|
||||
}
|
||||
summary := mapTenantSummary(tenant)
|
||||
summary.MemberCount = count
|
||||
|
||||
return c.JSON(summary)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
@@ -151,6 +174,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Type string `json:"type"`
|
||||
Description string `json:"description"`
|
||||
Status string `json:"status"`
|
||||
Domains []string `json:"domains"`
|
||||
@@ -166,9 +190,20 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "name is required")
|
||||
}
|
||||
|
||||
slug := normalizeTenantSlug(req.Slug)
|
||||
tenantType := normalizeTenantType(req.Type)
|
||||
if tenantType == "" {
|
||||
tenantType = domain.TenantTypeCompany // Default to COMPANY
|
||||
}
|
||||
|
||||
slug := req.Slug
|
||||
if slug == "" {
|
||||
slug = normalizeTenantSlug(name)
|
||||
slug = utils.GenerateUniqueSlug(name, func(s string) bool {
|
||||
var count int64
|
||||
h.DB.Unscoped().Model(&domain.Tenant{}).Where("slug = ?", s).Count(&count)
|
||||
return count > 0
|
||||
})
|
||||
} else {
|
||||
slug = utils.GenerateSlug(slug)
|
||||
}
|
||||
if slug == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "slug is required")
|
||||
@@ -186,7 +221,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
parentID = &pid
|
||||
}
|
||||
|
||||
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, req.Description, req.Domains, parentID)
|
||||
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "already exists") {
|
||||
return errorJSON(c, fiber.StatusConflict, err.Error())
|
||||
@@ -194,12 +229,16 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
summary := mapTenantSummary(*tenant)
|
||||
summary.MemberCount = 0
|
||||
|
||||
if req.Config != nil {
|
||||
tenant.Config = req.Config
|
||||
h.DB.Save(tenant)
|
||||
summary.Config = tenant.Config
|
||||
}
|
||||
|
||||
return c.Status(fiber.StatusCreated).JSON(mapTenantSummary(*tenant))
|
||||
return c.Status(fiber.StatusCreated).JSON(summary)
|
||||
}
|
||||
|
||||
func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
@@ -222,9 +261,11 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
|
||||
var req struct {
|
||||
Name *string `json:"name"`
|
||||
Type *string `json:"type"`
|
||||
Slug *string `json:"slug"`
|
||||
Description *string `json:"description"`
|
||||
Status *string `json:"status"`
|
||||
ParentID *string `json:"parentId"`
|
||||
Domains []string `json:"domains"`
|
||||
Config map[string]any `json:"config"`
|
||||
}
|
||||
@@ -239,8 +280,15 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
}
|
||||
tenant.Name = name
|
||||
}
|
||||
if req.Type != nil {
|
||||
tenantType := normalizeTenantType(*req.Type)
|
||||
if tenantType == "" {
|
||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant type"})
|
||||
}
|
||||
tenant.Type = tenantType
|
||||
}
|
||||
if req.Slug != nil {
|
||||
slug := normalizeTenantSlug(*req.Slug)
|
||||
slug := utils.GenerateSlug(*req.Slug)
|
||||
if slug == "" {
|
||||
return errorJSON(c, fiber.StatusBadRequest, "slug cannot be empty")
|
||||
}
|
||||
@@ -264,6 +312,30 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||
}
|
||||
tenant.Status = status
|
||||
}
|
||||
if req.ParentID != nil {
|
||||
pid := strings.TrimSpace(*req.ParentID)
|
||||
if pid == "" {
|
||||
tenant.ParentID = nil
|
||||
} else {
|
||||
tenant.ParentID = &pid
|
||||
}
|
||||
|
||||
// [Keto] Sync hierarchy via Outbox
|
||||
if h.KetoOutbox != nil {
|
||||
if tenant.ParentID != nil {
|
||||
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: tenant.ID,
|
||||
Relation: "parents",
|
||||
Subject: "Tenant:" + *tenant.ParentID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
} else {
|
||||
// We don't have enough info here to delete specific parent if we don't know the old one,
|
||||
// but for now we focus on adding.
|
||||
}
|
||||
}
|
||||
}
|
||||
if req.Config != nil {
|
||||
tenant.Config = req.Config
|
||||
}
|
||||
@@ -425,6 +497,8 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
|
||||
|
||||
return tenantSummary{
|
||||
ID: t.ID,
|
||||
Type: t.Type,
|
||||
ParentID: t.ParentID,
|
||||
Name: t.Name,
|
||||
Slug: t.Slug,
|
||||
Description: t.Description,
|
||||
@@ -436,18 +510,6 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeTenantSlug(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
value = strings.ReplaceAll(value, " ", "-")
|
||||
var b strings.Builder
|
||||
for _, r := range value {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '-' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-")
|
||||
}
|
||||
|
||||
func normalizeTenantStatus(value string) string {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
@@ -458,3 +520,13 @@ func normalizeTenantStatus(value string) string {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func normalizeTenantType(value string) string {
|
||||
value = strings.ToUpper(strings.TrimSpace(value))
|
||||
switch value {
|
||||
case domain.TenantTypePersonal, domain.TenantTypeCompany, domain.TenantTypeCompanyGroup, domain.TenantTypeUserGroup:
|
||||
return value
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ type MockTenantService struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
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, parentID)
|
||||
func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
||||
args := m.Called(ctx, name, slug, tenantType, description, domains, parentID)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
@@ -66,10 +66,58 @@ func (m *MockTenantService) GetTenant(ctx context.Context, id string) (*domain.T
|
||||
return args.Get(0).(*domain.Tenant), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockTenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
args := m.Called(ctx, limit, offset, parentID)
|
||||
return args.Get(0).([]domain.Tenant), args.Get(1).(int64), args.Error(2)
|
||||
}
|
||||
|
||||
func (m *MockTenantService) SetKetoService(keto service.KetoService) {
|
||||
m.Called(keto)
|
||||
}
|
||||
|
||||
type MockUserRepoForHandler struct {
|
||||
mock.Mock
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) Create(ctx context.Context, user *domain.User) error { return nil }
|
||||
func (m *MockUserRepoForHandler) Update(ctx context.Context, user *domain.User) error { return nil }
|
||||
func (m *MockUserRepoForHandler) Delete(ctx context.Context, id string) error { return nil }
|
||||
func (m *MockUserRepoForHandler) FindByEmail(ctx context.Context, email string) (*domain.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) FindByID(ctx context.Context, id string) (*domain.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForHandler) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
|
||||
args := m.Called(ctx, codes)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(map[string]int64), args.Error(1)
|
||||
}
|
||||
|
||||
func TestTenantHandler_CreateTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
@@ -85,7 +133,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
|
||||
}
|
||||
body, _ := json.Marshal(input)
|
||||
|
||||
mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", "", []string{"test.com"}, (*string)(nil)).
|
||||
mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil)).
|
||||
Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil)
|
||||
|
||||
req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))
|
||||
@@ -98,6 +146,47 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
|
||||
assert.Equal(t, "t1", got["id"])
|
||||
}
|
||||
|
||||
func TestTenantHandler_ListTenants(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
mockUserRepo := new(MockUserRepoForHandler)
|
||||
|
||||
h := &TenantHandler{
|
||||
Service: mockSvc,
|
||||
UserRepo: mockUserRepo,
|
||||
}
|
||||
|
||||
app.Get("/tenants", h.ListTenants)
|
||||
|
||||
tenants := []domain.Tenant{
|
||||
{ID: "t1", Name: "Tenant A", Slug: "slug-a"},
|
||||
{ID: "t2", Name: "Tenant B", Slug: "slug-b"},
|
||||
}
|
||||
mockSvc.On("ListTenants", mock.Anything, 10, 0, "").Return(tenants, int64(2), nil)
|
||||
mockUserRepo.On("CountByCompanyCodes", mock.Anything, []string{"slug-a", "slug-b"}).
|
||||
Return(map[string]int64{"slug-a": 5, "slug-b": 10}, nil)
|
||||
|
||||
req := httptest.NewRequest("GET", "/tenants?limit=10&offset=0", nil)
|
||||
resp, _ := app.Test(req)
|
||||
|
||||
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
|
||||
var res tenantListResponse
|
||||
json.NewDecoder(resp.Body).Decode(&res)
|
||||
|
||||
assert.Equal(t, int64(2), res.Total)
|
||||
assert.Len(t, res.Items, 2)
|
||||
|
||||
// Check if counts are mapped correctly
|
||||
for _, item := range res.Items {
|
||||
if item.Slug == "slug-a" {
|
||||
assert.Equal(t, int64(5), item.MemberCount)
|
||||
} else if item.Slug == "slug-b" {
|
||||
assert.Equal(t, int64(10), item.MemberCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTenantHandler_ApproveTenant(t *testing.T) {
|
||||
app := fiber.New()
|
||||
mockSvc := new(MockTenantService)
|
||||
|
||||
@@ -68,6 +68,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
limit := c.QueryInt("limit", 50)
|
||||
offset := c.QueryInt("offset", 0)
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
companyCode := strings.TrimSpace(c.Query("companyCode"))
|
||||
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
@@ -89,14 +90,21 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
|
||||
// Tenant Admin filtering
|
||||
if requesterRole == domain.RoleTenantAdmin {
|
||||
if requesterCompany == "" || compCode != requesterCompany {
|
||||
if requesterCompany == "" || !strings.EqualFold(compCode, requesterCompany) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Search filtering
|
||||
// Dedicated companyCode filter
|
||||
if companyCode != "" && !strings.EqualFold(compCode, companyCode) {
|
||||
continue
|
||||
}
|
||||
|
||||
// Search filtering (Keyword search in email, name, or companyCode)
|
||||
if search != "" {
|
||||
if !strings.Contains(email, searchLower) && !strings.Contains(name, searchLower) {
|
||||
if !strings.Contains(email, searchLower) &&
|
||||
!strings.Contains(name, searchLower) &&
|
||||
!strings.Contains(strings.ToLower(compCode), searchLower) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
@@ -118,14 +126,27 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
||||
items = append(items, summary)
|
||||
}
|
||||
|
||||
// [Lazy Sync] Asynchronously update local DB with fresh data from Kratos
|
||||
// This ensures that member counts (which use local DB) eventually match reality
|
||||
if h.UserRepo != nil {
|
||||
go func(ids []service.KratosIdentity) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
for _, identity := range ids {
|
||||
localUser := h.mapToLocalUser(identity)
|
||||
_ = h.UserRepo.Update(ctx, localUser)
|
||||
}
|
||||
}(filtered)
|
||||
}
|
||||
|
||||
return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
|
||||
}
|
||||
|
||||
// 2. Fallback to Local DB if Kratos is down (Development only recommended)
|
||||
// 2. Fallback to Local DB if Kratos is down
|
||||
slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err)
|
||||
|
||||
// Fetch from UserRepo
|
||||
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search)
|
||||
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, companyCode)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users from both kratos and local db")
|
||||
}
|
||||
@@ -289,66 +310,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// [New] Local DB Sync
|
||||
localUser := &domain.User{
|
||||
ID: identityID,
|
||||
Email: email,
|
||||
Name: name,
|
||||
Phone: normalizePhoneNumber(req.Phone),
|
||||
AffiliationType: "internal",
|
||||
CompanyCode: req.CompanyCode,
|
||||
Department: req.Department,
|
||||
Role: role,
|
||||
Status: "active",
|
||||
Metadata: req.Metadata,
|
||||
}
|
||||
if tenantID != "" {
|
||||
localUser.TenantID = &tenantID
|
||||
}
|
||||
|
||||
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
|
||||
if h.UserRepo != nil {
|
||||
go func(u *domain.User) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
if err := h.UserRepo.Create(ctx, u); err != nil {
|
||||
slog.Error("[UserHandler] Failed to sync user to local DB", "email", u.Email, "error", err)
|
||||
}
|
||||
}(localUser)
|
||||
}
|
||||
|
||||
// [Keto] Sync relations via Outbox
|
||||
if h.KetoOutboxRepo != nil {
|
||||
// 1. Tenant Membership
|
||||
if localUser.TenantID != nil {
|
||||
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: *localUser.TenantID,
|
||||
Relation: "members",
|
||||
Subject: "User:" + identityID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
// 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch the newly created identity to ensure we have all traits
|
||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
||||
if err != nil {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
@@ -357,6 +319,28 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
|
||||
}
|
||||
|
||||
// [New] Local DB Sync - Ensure user exists in read-model
|
||||
if h.UserRepo != nil {
|
||||
localUser := h.mapToLocalUser(*identity)
|
||||
|
||||
// Sync to local DB
|
||||
go func(u *domain.User, role string, tID *string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Use Update (upsert) instead of Create for robustness
|
||||
if err := h.UserRepo.Update(ctx, u); err != nil {
|
||||
slog.Error("[UserHandler] Failed to sync new user to local DB", "email", u.Email, "error", err)
|
||||
return
|
||||
}
|
||||
|
||||
// [Keto] Sync relations via Outbox
|
||||
if h.KetoOutboxRepo != nil {
|
||||
h.syncKetoRole(ctx, u.ID, role, "", "", tID)
|
||||
}
|
||||
}(localUser, role, localUser.TenantID)
|
||||
}
|
||||
|
||||
response := h.mapIdentitySummary(c.Context(), *identity)
|
||||
if generatedPassword != "" {
|
||||
response.InitialPassword = generatedPassword
|
||||
@@ -382,6 +366,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusNotFound, "user not found")
|
||||
}
|
||||
|
||||
// Capture current local state for transition comparison
|
||||
var oldRole string
|
||||
var oldTenantID string
|
||||
if h.UserRepo != nil {
|
||||
if local, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && local != nil {
|
||||
oldRole = local.Role
|
||||
if local.TenantID != nil {
|
||||
oldTenantID = *local.TenantID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// [New] Check access scope
|
||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||
@@ -420,7 +416,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
traits["name"] = strings.TrimSpace(*req.Name)
|
||||
}
|
||||
if req.Phone != nil {
|
||||
traits["phone_number"] = normalizePhoneNumber(strings.TrimSpace(*req.Phone))
|
||||
phone := normalizePhoneNumber(strings.TrimSpace(*req.Phone))
|
||||
if phone == "" {
|
||||
delete(traits, "phone_number")
|
||||
} else {
|
||||
traits["phone_number"] = phone
|
||||
}
|
||||
}
|
||||
if req.CompanyCode != nil {
|
||||
code := strings.TrimSpace(*req.CompanyCode)
|
||||
@@ -471,92 +472,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||
}
|
||||
|
||||
// [New] Local DB Sync
|
||||
// [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller
|
||||
if h.UserRepo != nil {
|
||||
if localUser, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && localUser != nil {
|
||||
oldRole := localUser.Role
|
||||
oldTenantID := ""
|
||||
if localUser.TenantID != nil {
|
||||
oldTenantID = *localUser.TenantID
|
||||
}
|
||||
updatedLocalUser := h.mapToLocalUser(*updated)
|
||||
|
||||
if req.Name != nil {
|
||||
localUser.Name = *req.Name
|
||||
}
|
||||
if req.Phone != nil {
|
||||
localUser.Phone = normalizePhoneNumber(*req.Phone)
|
||||
}
|
||||
if req.CompanyCode != nil {
|
||||
localUser.CompanyCode = *req.CompanyCode
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
|
||||
localUser.TenantID = &tenant.ID
|
||||
}
|
||||
}
|
||||
if req.Department != nil {
|
||||
localUser.Department = *req.Department
|
||||
}
|
||||
if req.Role != nil {
|
||||
localUser.Role = *req.Role
|
||||
}
|
||||
if req.Status != nil {
|
||||
localUser.Status = *req.Status
|
||||
}
|
||||
if req.Metadata != nil {
|
||||
localUser.Metadata = req.Metadata
|
||||
}
|
||||
|
||||
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
|
||||
// [ReBAC Policy] 로컬 DB와 Keto 간의 정합성을 위해 Outbox를 함께 기록합니다.
|
||||
go func(u *domain.User, rRole *string, oRole string, oTenantID string) {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
if err := h.UserRepo.Update(ctx, u); err == nil {
|
||||
// [Keto Sync on Role Change] via Outbox
|
||||
if h.KetoOutboxRepo != nil && rRole != nil && *rRole != oRole {
|
||||
uID := u.ID
|
||||
newR := *rRole
|
||||
if oRole == domain.RoleSuperAdmin {
|
||||
_ = 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 != "" {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: oTenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + uID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
|
||||
if newR == domain.RoleSuperAdmin {
|
||||
_ = 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 {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: *u.TenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + uID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
} else {
|
||||
slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", u.ID, "error", err)
|
||||
}
|
||||
}(localUser, req.Role, oldRole, oldTenantID)
|
||||
ctx := context.Background() // Use request context if appropriate, but sync must finish
|
||||
if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil {
|
||||
slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err)
|
||||
}
|
||||
|
||||
// [Keto Sync] asynchronously as it's less critical for immediate UI count
|
||||
go h.syncKetoRole(context.Background(), updatedLocalUser.ID,
|
||||
extractTraitString(updated.Traits, "grade"), oldRole, oldTenantID, updatedLocalUser.TenantID)
|
||||
}
|
||||
|
||||
if req.Password != nil && *req.Password != "" {
|
||||
@@ -654,6 +581,97 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
||||
return summary
|
||||
}
|
||||
|
||||
func (h *UserHandler) normalizePhoneNumber(phone string) string {
|
||||
return normalizePhoneNumber(phone)
|
||||
}
|
||||
|
||||
func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User {
|
||||
traits := identity.Traits
|
||||
role := extractTraitString(traits, "grade")
|
||||
if role == "" {
|
||||
role = "user"
|
||||
}
|
||||
compCode := extractTraitString(traits, "companyCode")
|
||||
|
||||
user := &domain.User{
|
||||
ID: identity.ID,
|
||||
Email: extractTraitString(traits, "email"),
|
||||
Name: extractTraitString(traits, "name"),
|
||||
Phone: extractTraitString(traits, "phone_number"),
|
||||
Role: role,
|
||||
Status: normalizeStatus(identity.State),
|
||||
CompanyCode: compCode,
|
||||
Department: extractTraitString(traits, "department"),
|
||||
AffiliationType: extractTraitString(traits, "affiliationType"),
|
||||
CreatedAt: identity.CreatedAt,
|
||||
UpdatedAt: identity.UpdatedAt,
|
||||
}
|
||||
|
||||
if compCode != "" && h.TenantService != nil {
|
||||
// Use a background context or a timeout-limited context for tenant lookup
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
|
||||
user.TenantID = &tenant.ID
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata
|
||||
user.Metadata = make(domain.JSONMap)
|
||||
coreTraits := map[string]bool{
|
||||
"email": true, "name": true, "phone_number": true,
|
||||
"grade": true, "companyCode": true, "department": true,
|
||||
"affiliationType": true, "role": true, "tenant_id": true,
|
||||
}
|
||||
for k, v := range traits {
|
||||
if !coreTraits[k] {
|
||||
user.Metadata[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
|
||||
func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole, oldTenantID string, newTenantID *string) {
|
||||
// Remove old roles
|
||||
if oldRole == domain.RoleSuperAdmin {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
} else if oldRole == domain.RoleTenantAdmin && oldTenantID != "" {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: oldTenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionDelete,
|
||||
})
|
||||
}
|
||||
|
||||
// Add new roles
|
||||
if newRole == domain.RoleSuperAdmin {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "System",
|
||||
Object: "global",
|
||||
Relation: "super_admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
} else if newRole == domain.RoleTenantAdmin && newTenantID != nil {
|
||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||
Namespace: "Tenant",
|
||||
Object: *newTenantID,
|
||||
Relation: "admins",
|
||||
Subject: "User:" + userID,
|
||||
Action: domain.KetoOutboxActionCreate,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func extractTraitString(traits map[string]interface{}, key string) string {
|
||||
if traits == nil {
|
||||
return ""
|
||||
|
||||
@@ -16,6 +16,7 @@ type TenantRepository interface {
|
||||
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
|
||||
FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error)
|
||||
AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) error
|
||||
List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error)
|
||||
}
|
||||
|
||||
type tenantRepository struct {
|
||||
@@ -90,3 +91,23 @@ func (r *tenantRepository) AddDomain(ctx context.Context, tenantID string, domai
|
||||
}
|
||||
return r.db.WithContext(ctx).Create(&td).Error
|
||||
}
|
||||
|
||||
func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
var tenants []domain.Tenant
|
||||
var total int64
|
||||
db := r.db.WithContext(ctx).Model(&domain.Tenant{})
|
||||
|
||||
if parentID != "" {
|
||||
db = db.Where("parent_id = ?", parentID)
|
||||
}
|
||||
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
if err := db.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return tenants, total, nil
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -14,7 +15,10 @@ type UserRepository interface {
|
||||
FindByID(ctx context.Context, id string) (*domain.User, error)
|
||||
FindByIDs(ctx context.Context, ids []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, companyCode string) ([]domain.User, int64, error)
|
||||
CountByTenant(ctx context.Context, tenantID string) (int64, error)
|
||||
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
|
||||
CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error)
|
||||
Delete(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
@@ -69,14 +73,110 @@ func (r *userRepository) ListByTenant(ctx context.Context, tenantID string) ([]d
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) {
|
||||
func (r *userRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).Model(&domain.User{}).Where("tenant_id = ?", tenantID).Count(&count).Error
|
||||
return count, err
|
||||
}
|
||||
|
||||
func (r *userRepository) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||
type result struct {
|
||||
TenantID string
|
||||
Count int64
|
||||
}
|
||||
var results []result
|
||||
if len(tenantIDs) == 0 {
|
||||
return make(map[string]int64), nil
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Model(&domain.User{}).
|
||||
Select("tenant_id, count(*) as count").
|
||||
Where("tenant_id IN ?", tenantIDs).
|
||||
Group("tenant_id").
|
||||
Find(&results).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
counts := make(map[string]int64)
|
||||
for _, res := range results {
|
||||
if res.TenantID != "" {
|
||||
counts[res.TenantID] = res.Count
|
||||
}
|
||||
}
|
||||
// Ensure all requested tenant IDs are in the map, even if count is 0
|
||||
for _, id := range tenantIDs {
|
||||
if _, ok := counts[id]; !ok {
|
||||
counts[id] = 0
|
||||
}
|
||||
}
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
|
||||
if len(codes) == 0 {
|
||||
return make(map[string]int64), nil
|
||||
}
|
||||
|
||||
// 1. Resolve IDs for these codes to support dual counting (slug or ID)
|
||||
var tenants []domain.Tenant
|
||||
_ = r.db.WithContext(ctx).Where("slug IN ?", codes).Find(&tenants).Error
|
||||
|
||||
idToSlug := make(map[string]string)
|
||||
slugToNormalized := make(map[string]string)
|
||||
|
||||
for _, code := range codes {
|
||||
slugToNormalized[strings.ToLower(strings.TrimSpace(code))] = code
|
||||
}
|
||||
for _, t := range tenants {
|
||||
idToSlug[t.ID] = t.Slug
|
||||
}
|
||||
|
||||
type result struct {
|
||||
CompanyCode string
|
||||
TenantID string
|
||||
Count int64
|
||||
}
|
||||
var results []result
|
||||
|
||||
// Use a more comprehensive aggregation
|
||||
err := r.db.WithContext(ctx).Model(&domain.User{}).
|
||||
Select("company_code, tenant_id, count(*) as count").
|
||||
Where("company_code IN ? OR tenant_id IN (SELECT id FROM tenants WHERE slug IN ?)", codes, codes).
|
||||
Group("company_code, tenant_id").
|
||||
Scan(&results).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
counts := make(map[string]int64)
|
||||
for _, res := range results {
|
||||
var slug string
|
||||
if res.CompanyCode != "" {
|
||||
slug = res.CompanyCode
|
||||
} else if res.TenantID != "" {
|
||||
slug = idToSlug[res.TenantID]
|
||||
}
|
||||
|
||||
if slug != "" {
|
||||
normalizedSlug := strings.ToLower(strings.TrimSpace(slug))
|
||||
counts[normalizedSlug] += res.Count
|
||||
}
|
||||
}
|
||||
|
||||
return counts, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
|
||||
var users []domain.User
|
||||
var total int64
|
||||
db := r.db.WithContext(ctx).Model(&domain.User{})
|
||||
|
||||
if companyCode != "" {
|
||||
db = db.Where("company_code = ?", companyCode)
|
||||
}
|
||||
|
||||
if search != "" {
|
||||
searchTerm := "%" + search + "%"
|
||||
db = db.Where("email LIKE ? OR name LIKE ?", searchTerm, searchTerm)
|
||||
db = db.Where("(email LIKE ? OR name LIKE ? OR company_code LIKE ?)", searchTerm, searchTerm, searchTerm)
|
||||
}
|
||||
|
||||
if err := db.Count(&total).Error; err != nil {
|
||||
|
||||
@@ -56,7 +56,7 @@ func TestUserRepository(t *testing.T) {
|
||||
_ = 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")
|
||||
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)
|
||||
@@ -73,4 +73,25 @@ func TestUserRepository(t *testing.T) {
|
||||
assert.Error(t, err) // Should not be found
|
||||
assert.Nil(t, found)
|
||||
})
|
||||
|
||||
t.Run("CountByCompanyCodes", func(t *testing.T) {
|
||||
// Clean start for this subtest
|
||||
testDB.Exec("DELETE FROM users")
|
||||
|
||||
users := []domain.User{
|
||||
{Email: "u1@a.com", Name: "U1", CompanyCode: "tenant-a"},
|
||||
{Email: "u2@a.com", Name: "U2", CompanyCode: "tenant-a"},
|
||||
{Email: "u3@b.com", Name: "U3", CompanyCode: "tenant-b"},
|
||||
{Email: "u4@none.com", Name: "U4", CompanyCode: ""},
|
||||
}
|
||||
for _, u := range users {
|
||||
_ = repo.Create(ctx, &u)
|
||||
}
|
||||
|
||||
counts, err := repo.CountByCompanyCodes(ctx, []string{"tenant-a", "tenant-b", "tenant-c"})
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(2), counts["tenant-a"])
|
||||
assert.Equal(t, int64(1), counts["tenant-b"])
|
||||
assert.Equal(t, int64(0), counts["tenant-c"])
|
||||
})
|
||||
}
|
||||
|
||||
@@ -13,11 +13,12 @@ import (
|
||||
)
|
||||
|
||||
type TenantService interface {
|
||||
RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error)
|
||||
RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error)
|
||||
RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error)
|
||||
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
||||
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
||||
GetTenant(ctx context.Context, id string) (*domain.Tenant, error)
|
||||
ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error)
|
||||
ApproveTenant(ctx context.Context, id string) error
|
||||
SetKetoService(keto KetoService) // 추가
|
||||
}
|
||||
@@ -89,7 +90,7 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
|
||||
return s.repo.FindByIDs(ctx, allIDs)
|
||||
}
|
||||
|
||||
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
||||
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
||||
// Validate Slug
|
||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||
return nil, errors.New(msg)
|
||||
@@ -106,7 +107,7 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript
|
||||
|
||||
// 2. Create Tenant
|
||||
tenant := &domain.Tenant{
|
||||
Type: domain.TenantTypeCompany, // Default to COMPANY for manual registration
|
||||
Type: tenantType,
|
||||
Name: name,
|
||||
Slug: slug,
|
||||
Description: description,
|
||||
@@ -226,3 +227,8 @@ func (s *tenantService) GetTenantByDomain(ctx context.Context, emailDomain strin
|
||||
func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
|
||||
return s.repo.FindBySlug(ctx, slug)
|
||||
}
|
||||
|
||||
func (s *tenantService) ListTenants(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
// Let the repository handle the query and pagination
|
||||
return s.repo.List(ctx, limit, offset, parentID)
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestTenantService_RegisterTenant_DuplicateSlug(t *testing.T) {
|
||||
// Mock: slug already exists
|
||||
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "existing-id", Slug: slug}, nil)
|
||||
|
||||
tenant, err := svc.RegisterTenant(ctx, "New Name", slug, "", nil, nil)
|
||||
tenant, err := svc.RegisterTenant(ctx, "New Name", slug, domain.TenantTypeCompany, "", nil, nil)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "already exists")
|
||||
assert.Nil(t, tenant)
|
||||
@@ -32,11 +32,11 @@ func TestTenantService_RegisterTenant_InvalidSlug(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
// Case 1: Too short
|
||||
_, err := svc.RegisterTenant(ctx, "Name", "a", "", nil, nil)
|
||||
_, err := svc.RegisterTenant(ctx, "Name", "a", domain.TenantTypeCompany, "", nil, nil)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Case 2: Invalid characters
|
||||
_, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", "", nil, nil)
|
||||
_, err = svc.RegisterTenant(ctx, "Name", "Invalid Slug!", domain.TenantTypeCompany, "", nil, nil)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
|
||||
@@ -59,6 +59,11 @@ func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, d
|
||||
return m.Called(ctx, tenantID, domainName, verified).Error(0)
|
||||
}
|
||||
|
||||
func (m *MockTenantRepoForSvc) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
args := m.Called(ctx, limit, offset, parentID)
|
||||
return args.Get(0).([]domain.Tenant), int64(args.Int(1)), args.Error(2)
|
||||
}
|
||||
|
||||
type MockKetoSvcForTenant struct {
|
||||
mock.Mock
|
||||
}
|
||||
@@ -116,10 +121,31 @@ func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID strin
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) {
|
||||
func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForTenant) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
args := m.Called(tenantID)
|
||||
return int64(args.Int(0)), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForTenant) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||
args := m.Called(tenantIDs)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(map[string]int64), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserRepoForTenant) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
|
||||
args := m.Called(ctx, codes)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(map[string]int64), args.Error(1)
|
||||
}
|
||||
|
||||
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
||||
mockRepo := new(MockTenantRepoForSvc)
|
||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||
@@ -136,7 +162,7 @@ func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
||||
mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil)
|
||||
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once()
|
||||
|
||||
tenant, err := svc.RegisterTenant(ctx, name, slug, "", domains, nil)
|
||||
tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, tenant)
|
||||
assert.Equal(t, "t1", tenant.ID)
|
||||
@@ -199,3 +225,18 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
|
||||
mockUserRepo.AssertExpectations(t)
|
||||
mockOutbox.AssertExpectations(t)
|
||||
}
|
||||
|
||||
func TestTenantService_ListTenants(t *testing.T) {
|
||||
mockRepo := new(MockTenantRepoForSvc)
|
||||
svc := NewTenantService(mockRepo, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
tenants := []domain.Tenant{{ID: "t1", Name: "Tenant 1"}}
|
||||
mockRepo.On("List", ctx, 10, 0, "").Return(tenants, 1, nil)
|
||||
|
||||
result, total, err := svc.ListTenants(ctx, 10, 0, "")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, int64(1), total)
|
||||
assert.Equal(t, tenants, result)
|
||||
mockRepo.AssertExpectations(t)
|
||||
}
|
||||
|
||||
@@ -77,10 +77,31 @@ func (m *MockUserRepository) ListByTenant(ctx context.Context, tenantID string)
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) {
|
||||
func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||
args := m.Called(tenantID)
|
||||
return int64(args.Int(0)), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||
args := m.Called(tenantIDs)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(map[string]int64), args.Error(1)
|
||||
}
|
||||
|
||||
func (m *MockUserRepository) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
|
||||
args := m.Called(ctx, codes)
|
||||
if args.Get(0) == nil {
|
||||
return nil, args.Error(1)
|
||||
}
|
||||
return args.Get(0).(map[string]int64), args.Error(1)
|
||||
}
|
||||
|
||||
type MockTenantRepository struct {
|
||||
mock.Mock
|
||||
}
|
||||
@@ -121,6 +142,10 @@ func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, d
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *MockTenantRepository) List(ctx context.Context, limit, offset int, parentID string) ([]domain.Tenant, int64, error) {
|
||||
return nil, 0, nil
|
||||
}
|
||||
|
||||
func TestUserGroupService_Create(t *testing.T) {
|
||||
mockRepo := new(MockUserGroupRepository)
|
||||
mockTenantRepo := new(MockTenantRepository)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
@@ -75,3 +76,46 @@ func ValidateSlug(slug string) (bool, string) {
|
||||
|
||||
return true, ""
|
||||
}
|
||||
|
||||
// GenerateSlug generates a base slug from a given string.
|
||||
// It removes special characters, replaces spaces with hyphens, and converts to lowercase.
|
||||
func GenerateSlug(name string) string {
|
||||
// Convert to lowercase
|
||||
s := strings.ToLower(strings.TrimSpace(name))
|
||||
|
||||
// Replace non-alphanumeric characters (including spaces) with a hyphen
|
||||
re := regexp.MustCompile(`[^a-z0-9]+`)
|
||||
s = re.ReplaceAllString(s, "-")
|
||||
|
||||
// Remove leading and trailing hyphens
|
||||
s = strings.Trim(s, "-")
|
||||
|
||||
// Handle empty slug
|
||||
if s == "" {
|
||||
s = "tenant"
|
||||
}
|
||||
|
||||
// Truncate to maximum length of 32 (reserving space for suffixes)
|
||||
if len(s) > 25 {
|
||||
s = s[:25]
|
||||
s = strings.TrimSuffix(s, "-")
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// GenerateUniqueSlug generates a unique slug by appending a suffix if the base slug exists.
|
||||
// It takes the base name and a checker function that returns true if the slug already exists.
|
||||
func GenerateUniqueSlug(name string, exists func(string) bool) string {
|
||||
baseSlug := GenerateSlug(name)
|
||||
|
||||
slug := baseSlug
|
||||
counter := 1
|
||||
|
||||
for reservedSlugs[slug] || exists(slug) {
|
||||
slug = fmt.Sprintf("%s-%d", baseSlug, counter)
|
||||
counter++
|
||||
}
|
||||
|
||||
return slug
|
||||
}
|
||||
|
||||
@@ -54,3 +54,62 @@ func TestValidateSlug_Format(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSlug(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{"Hello World", "hello-world"},
|
||||
{"My Company!@#", "my-company"},
|
||||
{"---Test---", "test"},
|
||||
{" Spaces ", "spaces"},
|
||||
{"A VERY LONG NAME THAT EXCEEDS THIRTY TWO CHARACTERS", "a-very-long-name-that-exc"},
|
||||
{"한글 테스트", "tenant"}, // Non-ascii characters will be replaced by hyphens and trimmed to empty, then fallback to "tenant"
|
||||
{"Test 한글 Mix", "test-mix"},
|
||||
{"", "tenant"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
slug := GenerateSlug(tt.name)
|
||||
assert.Equal(t, tt.expected, slug)
|
||||
// Ensure generated slug is valid (unless it's reserved like "slug" wasn't reserved, but let's check format)
|
||||
if !reservedSlugs[slug] {
|
||||
valid, _ := ValidateSlug(slug)
|
||||
assert.True(t, valid, "Generated slug should be valid format")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateUniqueSlug(t *testing.T) {
|
||||
existingSlugs := map[string]bool{
|
||||
"my-company": true,
|
||||
"my-company-1": true,
|
||||
"test": true,
|
||||
}
|
||||
|
||||
existsFunc := func(slug string) bool {
|
||||
return existingSlugs[slug]
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
expected string
|
||||
}{
|
||||
{"My Company", "my-company-2"},
|
||||
{"Test", "test-1"},
|
||||
{"New Company", "new-company"},
|
||||
{"admin", "admin-1"}, // "admin" is reserved, so it should append suffix
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
slug := GenerateUniqueSlug(tt.name, existsFunc)
|
||||
assert.Equal(t, tt.expected, slug)
|
||||
valid, _ := ValidateSlug(slug)
|
||||
assert.True(t, valid, "Generated unique slug should be valid")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
118
docs/UI_DESIGN_POLICY.md
Normal file
118
docs/UI_DESIGN_POLICY.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# UI 버튼 위치 및 정렬 정책 (UI Button Placement Policy)
|
||||
|
||||
본 문서는 Baron SSO 프로젝트 내 모든 프론트엔드 애플리케이션(`userfront`, `devfront`, `adminfront`)에서 일관된 사용자 경험(UX)을 제공하기 위한 UI 버튼 배치 및 정렬 가이드라인을 정의합니다. (관련 이슈: [#308](https://gitea.hmac.kr/baron/baron-sso/issues/308))
|
||||
|
||||
## 1. 버튼 종류별 위치 (Button Placement by Type)
|
||||
|
||||
버튼의 성격에 따라 다음과 같이 배치합니다.
|
||||
|
||||
* **Primary Action (주요 동작)**
|
||||
* **예시**: 저장, 확인, 제출, 생성 등
|
||||
* **위치**: 우측 하단 (Bottom Right) 또는 모달/다이얼로그의 우측 끝에 배치합니다. 사용자의 시선 흐름(좌에서 우, 위에서 아래)에 따라 최종 액션을 우측 하단에서 마무리하도록 유도합니다.
|
||||
* **Secondary Action (보조 동작)**
|
||||
* **예시**: 취소, 닫기, 이전으로 등
|
||||
* **위치**: Primary 버튼의 바로 **좌측**에 배치합니다.
|
||||
* **Destructive Action (파괴적 동작)**
|
||||
* **예시**: 삭제, 초기화, 권한 해제 등
|
||||
* **위치 및 스타일**: 붉은색(Red/Destructive) 스타일을 적용하여 시각적으로 명확히 구분합니다. Primary/Secondary 그룹과 물리적으로 분리하거나 (예: 좌측 끝 배치), Secondary 액션 위치에 두되 색상으로 강력한 경고를 줍니다.
|
||||
|
||||
## 2. 정렬 기준 (Alignment Rules)
|
||||
|
||||
* **폼(Form) 하단 버튼 그룹**
|
||||
* **기본 정렬**: 우측 정렬 (Right-aligned). "취소"는 왼쪽, "저장"은 오른쪽에 위치합니다. `[ 취소 ] [ 저장 ]`
|
||||
* **리스트 아이템 내부 액션 버튼**
|
||||
* **기본 정렬**: 리스트/테이블의 각 행(Row) 우측 끝에 배치합니다.
|
||||
* 버튼 개수가 많을 경우 (3개 이상), 툴팁이나 Dropdown 메뉴(예: 햄버거 버튼 또는 "더보기" 아이콘)로 숨겨 UI 복잡도를 낮춥니다.
|
||||
|
||||
## 3. 반응형 고려 (Responsive Design)
|
||||
|
||||
* **모바일 환경 (Mobile / Small Screens)**
|
||||
* 화면 너비가 좁은 모바일 기기(예: `userfront` 앱 환경, `devfront`/`adminfront`의 모바일 뷰)에서는 버튼 그룹을 **Full Width (화면 가득 채움)**로 변경하여 터치 영역을 확보합니다.
|
||||
* 여러 개의 버튼이 있는 경우 세로로 스택(Stack)하며, **Primary Action을 맨 위**에, Secondary Action을 그 아래에 배치합니다.
|
||||
* *데스크탑*: `[ 취소 ] [ 확인 ]`
|
||||
* *모바일*:
|
||||
```
|
||||
[ 확인 ]
|
||||
[ 취소 ]
|
||||
```
|
||||
|
||||
## 4. 로딩 및 피드백 (Loading & Feedback)
|
||||
|
||||
* **중복 제출 방지**: 폼 전송이나 API 호출을 발생시키는 버튼을 클릭하면 즉각적으로 버튼을 비활성화(Disabled) 상태로 변경하여 다중 클릭을 방지합니다.
|
||||
* **로딩 스피너**: 버튼 내부에 로딩 스피너(Spinner)를 표시하여 사용자에게 진행 상황을 시각적으로 알립니다.
|
||||
* **스켈레톤 로딩(Skeleton Loading)**: 화면 진입 시 전체 데이터를 로딩해야 하는 경우, 무의미한 빈 화면(빈 공간) 대신 스켈레톤 UI를 사용하여 로딩 중임을 직관적으로 알리고 체감 대기 시간을 줄입니다.
|
||||
* **작업 결과 안내**: 성공, 실패 등의 결과는 Toast 메시지 (혹은 스낵바)를 통해 화면 하단/상단에 일시적으로 노출하여 사용자가 흐름을 끊지 않고도 인지할 수 있게 돕습니다.
|
||||
|
||||
## 5. 빈 상태 처리 (Empty State)
|
||||
|
||||
* **빈 목록 안내**: 테이블이나 리스트에 표시할 항목이 없는 경우 단순히 빈 화면으로 두지 않고 중앙 정렬된 아이콘이나 일러스트와 함께 "데이터가 없습니다." 등의 명확한 문구를 표시합니다.
|
||||
* **콜 투 액션(Call to Action)**: 데이터가 비어 있는 경우 생성 버튼(Primary Action)을 빈 상태 안내 영역 아래에 배치하여 사용자가 즉시 데이터를 추가할 수 있도록 유도합니다.
|
||||
|
||||
## 6. 오류 표시 (Error Handling)
|
||||
|
||||
* **인라인(Inline) 오류**: 폼(Form)의 유효성 검사에서 실패한 경우, 각 입력 필드 바로 아래에 붉은색 텍스트로 실패 원인을 명확하게 표시합니다.
|
||||
* **포커스 이동**: 제출 버튼 클릭 시 오류가 있는 첫 번째 입력 필드로 자동 스크롤 하거나 포커스(Focus)를 이동시켜 수정이 용이하게 합니다.
|
||||
|
||||
## 7. 접근성 (Accessibility - a11y)
|
||||
|
||||
* **포커스 링(Focus Ring)**: 키보드를 통해 탐색(Tab)하는 사용자를 위해 버튼, 텍스트 입력창 등에 포커스가 갈 경우 외곽선을 명확히 렌더링(예: 파란색 테두리 등)해야 합니다. `outline: none`을 무분별하게 사용하지 않습니다.
|
||||
* **대체 텍스트**: 텍스트 없이 아이콘만 존재하는 버튼(예: X 형태의 닫기 버튼)의 경우 반드시 `aria-label` 속성(또는 Flutter의 `Semantics`)을 사용하여 스크린 리더 사용자가 해당 버튼의 역할을 알 수 있게 해야 합니다.
|
||||
|
||||
## 8. 프론트엔드 환경별 구현 가이드 (Implementation Guide)
|
||||
|
||||
현재 운영 중인 프론트엔드 환경에 맞춘 구현 가이드라인입니다.
|
||||
|
||||
### 8.1. React 환경 (`devfront`, `adminfront`)
|
||||
Tailwind CSS 기반의 컴포넌트를 사용하여 아래와 같이 구현합니다.
|
||||
|
||||
* **버튼 그룹 우측 정렬 (데스크탑)**: `flex justify-end gap-2`
|
||||
* **반응형 (모바일 세로 배치, 데스크탑 가로 배치)**: `flex flex-col-reverse sm:flex-row sm:justify-end gap-2`
|
||||
*(참고: `flex-col-reverse`를 사용하면 코드 상 먼저 작성된 취소 버튼이 모바일에서는 아래로, 나중에 작성된 확인 버튼이 위로 올라가게 배치할 수 있습니다.)*
|
||||
* **코드 예시**:
|
||||
```tsx
|
||||
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 mt-4">
|
||||
<Button variant="outline" onClick={onCancel} disabled={isLoading}>취소</Button>
|
||||
<Button variant="default" onClick={onSave} disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### 8.2. Flutter 환경 (`userfront`)
|
||||
Flutter 프레임워크를 사용하는 환경에서는 화면 너비에 따라 위젯 구성을 동적으로 처리해야 합니다.
|
||||
|
||||
* **폼 하단 정렬**: `Row` 위젯과 `MainAxisAlignment.end` 사용.
|
||||
* **반응형 대응**: 화면 너비(MediaQuery)에 따라 `Row`를 전체 너비를 채우는 `Column`으로 스위칭하거나, `OverflowBar` 위젯 등을 활용할 수 있습니다.
|
||||
* **코드 예시**:
|
||||
```dart
|
||||
// 데스크탑/태블릿용 (우측 정렬)
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(onPressed: isLoading ? null : onCancel, child: const Text('취소')),
|
||||
const SizedBox(width: 8),
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : onSave,
|
||||
child: isLoading
|
||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('확인')
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
// 모바일용 (전체 너비 세로 배치)
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: isLoading ? null : onSave,
|
||||
child: isLoading
|
||||
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Text('확인')
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
TextButton(onPressed: isLoading ? null : onCancel, child: const Text('취소')),
|
||||
],
|
||||
)
|
||||
```
|
||||
87
docs/keto-rebac-namespaces-diagram.md
Normal file
87
docs/keto-rebac-namespaces-diagram.md
Normal file
@@ -0,0 +1,87 @@
|
||||
# Ory Keto (ReBAC) 네임스페이스 및 권한 상속 다이어그램
|
||||
|
||||
이 문서는 `docker/ory/keto/namespaces.ts`에 정의된 Baron SSO 프로젝트의 Ory Keto(ReBAC) 네임스페이스와 각 네임스페이스 간의 권한 상속(Permits) 및 관계(Relations)를 나타내는 Mermaid 다이어그램입니다.
|
||||
|
||||
## 네임스페이스 설계 구조
|
||||
|
||||
Ory Keto는 다음과 같은 4개의 주요 네임스페이스로 구성되어 있습니다:
|
||||
|
||||
1. **`User`**: 권한의 주체가 되는 기본 사용자.
|
||||
2. **`System`**: 시스템 전역 권한 (최고 관리자 및 인증된 사용자).
|
||||
3. **`Tenant`**: 조직/회사/부서 등 모든 형태의 격리 공간. 상위-하위(`parents`) 계층 구조를 가짐.
|
||||
4. **`RelyingParty`**: OIDC 클라이언트(앱/리소스). 특정 `Tenant`에 종속될 수 있음.
|
||||
|
||||
---
|
||||
|
||||
## Mermaid 다이어그램
|
||||
|
||||
```mermaid
|
||||
classDiagram
|
||||
class User {
|
||||
<<Namespace>>
|
||||
}
|
||||
|
||||
class System {
|
||||
<<Namespace>>
|
||||
-- Relations --
|
||||
super_admins: User[]
|
||||
authenticated_users: User[]
|
||||
-- Permits --
|
||||
manage_all: super_admins
|
||||
}
|
||||
|
||||
class Tenant {
|
||||
<<Namespace>>
|
||||
-- Relations --
|
||||
owners: User[]
|
||||
admins: User[] | SubjectSet~Tenant, owners~
|
||||
members: User[]
|
||||
parents: Tenant[]
|
||||
-- Permits --
|
||||
view: members OR admins OR parents.view
|
||||
manage: admins OR parents.manage
|
||||
create_subtenant: manage
|
||||
}
|
||||
|
||||
class RelyingParty {
|
||||
<<Namespace>>
|
||||
-- Relations --
|
||||
admins: User[]
|
||||
parents: Tenant[]
|
||||
access: User[] | SubjectSet~Tenant, members~ | SubjectSet~System, authenticated_users~
|
||||
-- Permits --
|
||||
view: admins OR parents.view
|
||||
manage: admins OR parents.manage
|
||||
access: access OR manage
|
||||
}
|
||||
|
||||
%% Relationship lines indicating references (SubjectSets or Direct inclusion)
|
||||
User ..> System : super_admins, authenticated_users
|
||||
User ..> Tenant : owners, admins, members
|
||||
User ..> RelyingParty : admins, access
|
||||
|
||||
Tenant "1" --> "*" Tenant : parents (상위 조직 상속)
|
||||
Tenant ..> RelyingParty : parents (소유권 상속)
|
||||
Tenant ..> RelyingParty : access (members 접근 권한)
|
||||
|
||||
System ..> RelyingParty : access (authenticated_users)
|
||||
|
||||
%% Styling
|
||||
style User fill:#e1f5fe,stroke:#333,stroke-width:2px
|
||||
style System fill:#ffe0b2,stroke:#333,stroke-width:2px
|
||||
style Tenant fill:#fff9c4,stroke:#333,stroke-width:2px
|
||||
style RelyingParty fill:#e1bee7,stroke:#333,stroke-width:2px
|
||||
```
|
||||
|
||||
### 권한 평가(Permit) 상세 로직 설명
|
||||
|
||||
- **Tenant (테넌트/조직):**
|
||||
- `view` (조회): 테넌트의 일반 멤버(`members`), 관리자(`admins`), 그리고 **상위 테넌트(parents)에서 조회 권한을 가진 자**가 조회할 수 있습니다.
|
||||
- `manage` (관리): 테넌트의 관리자(`admins`), 그리고 **상위 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다.
|
||||
- _참고:_ 조직장(`owners`)은 자동으로 `admins` 집합(SubjectSet)에 포함됩니다.
|
||||
|
||||
- **RelyingParty (OIDC 앱):**
|
||||
- `view` (조회): 앱의 직접 관리자(`admins`) 또는 **이 앱을 소유한 테넌트(parents)에서 조회 권한을 가진 자**가 조회할 수 있습니다.
|
||||
- `manage` (관리): 앱의 직접 관리자(`admins`) 또는 **이 앱을 소유한 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다.
|
||||
- `access` (접근/로그인 가능 여부): 이 앱에 직접 접근 권한을 부여받은 유저/그룹(`access`), 또는 앱을 관리할 수 있는 권한(`manage`)을 가진 사람이 접근할 수 있습니다.
|
||||
- _접근 대상(access)은 특정 유저, 특정 테넌트의 전 멤버, 또는 전역 인증된 유저(System:authenticated_users)가 될 수 있습니다._
|
||||
@@ -196,7 +196,9 @@ subtitle = "Subtitle"
|
||||
subtitle = "Subtitle"
|
||||
|
||||
[msg.admin.tenants.members]
|
||||
empty = "Empty"
|
||||
empty = "No members found."
|
||||
desc = "View the list of users belonging to this organization."
|
||||
limit_notice = "Showing members from the first 10 descendant organizations due to size limits."
|
||||
|
||||
[msg.admin.tenants.registry]
|
||||
count = "Count"
|
||||
@@ -804,8 +806,8 @@ tenant_admin = "TENANT ADMIN"
|
||||
tenant_member = "TENANT MEMBER"
|
||||
|
||||
[ui.admin.tenants]
|
||||
add = "Tenant Add"
|
||||
title = "Tenant List"
|
||||
add = "Add Tenant"
|
||||
title = "Tenant Registry"
|
||||
|
||||
[ui.admin.tenants.admins]
|
||||
add_button = "Add Button"
|
||||
@@ -865,6 +867,11 @@ select_placeholder = "Select Placeholder"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
title = "Tenant Members ({{count}})"
|
||||
direct_label = "Direct"
|
||||
total_label = "Total"
|
||||
list_title = "Member Management"
|
||||
direct = "Direct Members"
|
||||
descendants = "Descendant Members"
|
||||
|
||||
[ui.admin.tenants.members.table]
|
||||
email = "EMAIL"
|
||||
@@ -1060,6 +1067,8 @@ theme_light = "Light"
|
||||
theme_toggle = "Theme Toggle"
|
||||
unknown = "Unknown"
|
||||
view = "View"
|
||||
manage = "Manage"
|
||||
remove = "Remove"
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
@@ -1079,6 +1088,12 @@ ok = "Ok"
|
||||
pending = "Pending"
|
||||
success = "Success"
|
||||
|
||||
[test]
|
||||
key = "Test"
|
||||
|
||||
[non.existent]
|
||||
key = "Non-existent key"
|
||||
|
||||
[ui.dev]
|
||||
brand = "Brand"
|
||||
console_title = "Developer Console"
|
||||
@@ -1525,3 +1540,25 @@ verify = "Verify"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "Action"
|
||||
|
||||
[msg.admin.tenants]
|
||||
not_found = "Tenant not found."
|
||||
remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?'
|
||||
|
||||
[msg.admin.users.create]
|
||||
success = "User created successfully."
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
add_dialog_desc = "Select a tenant to add as a sub-tenant."
|
||||
add_dialog_title = "Add Sub-tenant"
|
||||
add_existing = "Add Existing Tenant"
|
||||
no_candidates = "No available tenants to add."
|
||||
search_placeholder = "Search by name or slug..."
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
members = "Members"
|
||||
|
||||
[ui.admin.users.table]
|
||||
email = "Email"
|
||||
name = "Name"
|
||||
role = "Role"
|
||||
|
||||
@@ -197,6 +197,8 @@ subtitle = "필수 정보만 입력해도 생성 가능합니다. Slug는 없으
|
||||
|
||||
[msg.admin.tenants.members]
|
||||
empty = "소속된 사용자가 없습니다."
|
||||
desc = "조직에 소속된 사용자 목록을 확인합니다."
|
||||
limit_notice = "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다."
|
||||
|
||||
[msg.admin.tenants.registry]
|
||||
count = "총 {{count}}개 테넌트"
|
||||
@@ -865,6 +867,11 @@ select_placeholder = "테넌트를 선택하세요"
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
title = "Tenant Members ({{count}})"
|
||||
direct_label = "소속"
|
||||
total_label = "전체"
|
||||
list_title = "구성원 관리"
|
||||
direct = "소속 멤버"
|
||||
descendants = "하위 조직 멤버"
|
||||
|
||||
[ui.admin.tenants.members.table]
|
||||
email = "EMAIL"
|
||||
@@ -1060,6 +1067,8 @@ theme_light = "Light"
|
||||
theme_toggle = "테마 전환"
|
||||
unknown = "Unknown"
|
||||
view = "보기"
|
||||
manage = "관리"
|
||||
remove = "제외"
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
@@ -1079,6 +1088,12 @@ ok = "정상"
|
||||
pending = "준비 중"
|
||||
success = "성공"
|
||||
|
||||
[test]
|
||||
key = "테스트"
|
||||
|
||||
[non.existent]
|
||||
key = "존재하지 않는 키"
|
||||
|
||||
[ui.dev]
|
||||
brand = "Baron 로그인"
|
||||
console_title = "Developer Console"
|
||||
@@ -1525,3 +1540,25 @@ verify = "본인인증"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "로그인하기"
|
||||
|
||||
[msg.admin.tenants]
|
||||
not_found = "테넌트를 찾을 수 없습니다."
|
||||
remove_sub_confirm = '테넌트 "{{name}}"을(를) 하위 조직에서 제외할까요?'
|
||||
|
||||
[msg.admin.users.create]
|
||||
success = "사용자가 생성되었습니다."
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
add_dialog_desc = "하위 조직으로 추가할 테넌트를 선택하세요."
|
||||
add_dialog_title = "하위 조직 추가"
|
||||
add_existing = "기존 테넌트 추가"
|
||||
no_candidates = "추가 가능한 테넌트가 없습니다."
|
||||
search_placeholder = "테넌트 이름 또는 슬러그로 검색..."
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
members = "멤버수"
|
||||
|
||||
[ui.admin.users.table]
|
||||
email = "이메일"
|
||||
name = "이름"
|
||||
role = "역할"
|
||||
|
||||
@@ -126,6 +126,8 @@ description = ""
|
||||
delete_confirm = ""
|
||||
empty = ""
|
||||
fetch_error = ""
|
||||
not_found = ""
|
||||
remove_sub_confirm = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.tenants.create]
|
||||
@@ -142,7 +144,9 @@ subtitle = ""
|
||||
subtitle = ""
|
||||
|
||||
[msg.admin.tenants.members]
|
||||
desc = ""
|
||||
empty = ""
|
||||
limit_notice = ""
|
||||
|
||||
[msg.admin.tenants.registry]
|
||||
count = ""
|
||||
@@ -163,6 +167,7 @@ subtitle = ""
|
||||
[msg.admin.users.create]
|
||||
error = ""
|
||||
password_required = ""
|
||||
success = ""
|
||||
|
||||
[msg.admin.users.create.account]
|
||||
subtitle = ""
|
||||
@@ -754,7 +759,12 @@ title = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.members]
|
||||
descendants = ""
|
||||
direct = ""
|
||||
direct_label = ""
|
||||
list_title = ""
|
||||
title = ""
|
||||
total_label = ""
|
||||
|
||||
[ui.admin.tenants.members.table]
|
||||
email = ""
|
||||
@@ -782,7 +792,12 @@ type_text = ""
|
||||
|
||||
[ui.admin.tenants.sub]
|
||||
add = ""
|
||||
add_dialog_desc = ""
|
||||
add_dialog_title = ""
|
||||
add_existing = ""
|
||||
manage = ""
|
||||
no_candidates = ""
|
||||
search_placeholder = ""
|
||||
title = ""
|
||||
|
||||
[ui.admin.tenants.sub.table]
|
||||
@@ -793,6 +808,7 @@ status = ""
|
||||
|
||||
[ui.admin.tenants.table]
|
||||
actions = ""
|
||||
members = ""
|
||||
name = ""
|
||||
slug = ""
|
||||
status = ""
|
||||
@@ -886,9 +902,16 @@ role = ""
|
||||
status = ""
|
||||
tenant_dept = ""
|
||||
|
||||
[ui.admin.users.table]
|
||||
email = ""
|
||||
name = ""
|
||||
role = ""
|
||||
|
||||
|
||||
[ui.common]
|
||||
add = ""
|
||||
admin_only = ""
|
||||
assign = ""
|
||||
back = ""
|
||||
cancel = ""
|
||||
close = ""
|
||||
@@ -901,9 +924,11 @@ details = ""
|
||||
edit = ""
|
||||
view = ""
|
||||
hyphen = ""
|
||||
manage = ""
|
||||
na = ""
|
||||
never = ""
|
||||
next = ""
|
||||
none = ""
|
||||
page_of = ""
|
||||
prev = ""
|
||||
previous = ""
|
||||
@@ -911,10 +936,14 @@ qr = ""
|
||||
reset = ""
|
||||
read_only = ""
|
||||
refresh = ""
|
||||
remove = ""
|
||||
requesting = ""
|
||||
resend = ""
|
||||
retry = ""
|
||||
save = ""
|
||||
search = ""
|
||||
select = ""
|
||||
select_placeholder = ""
|
||||
show_more = ""
|
||||
language = ""
|
||||
language_ko = ""
|
||||
@@ -942,6 +971,12 @@ ok = ""
|
||||
pending = ""
|
||||
success = ""
|
||||
|
||||
[test]
|
||||
key = ""
|
||||
|
||||
[non.existent]
|
||||
key = ""
|
||||
|
||||
[ui.dev]
|
||||
brand = ""
|
||||
console_title = ""
|
||||
@@ -1508,10 +1543,3 @@ position_placeholder = ""
|
||||
|
||||
[ui.admin.users.list.table]
|
||||
position_job = ""
|
||||
|
||||
[ui.common]
|
||||
admin_only = ""
|
||||
assign = ""
|
||||
none = ""
|
||||
select = ""
|
||||
select_placeholder = ""
|
||||
|
||||
@@ -336,6 +336,8 @@ theme_light = "Light"
|
||||
theme_toggle = "Theme Toggle"
|
||||
unknown = "Unknown"
|
||||
view = "View"
|
||||
manage = "Manage"
|
||||
remove = "Remove"
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
@@ -567,3 +569,4 @@ verify = "Verify"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "Action"
|
||||
|
||||
|
||||
@@ -336,6 +336,8 @@ theme_light = "Light"
|
||||
theme_toggle = "테마 전환"
|
||||
unknown = "Unknown"
|
||||
view = "보기"
|
||||
manage = "관리"
|
||||
remove = "제외"
|
||||
|
||||
[ui.common.badge]
|
||||
admin_only = "Admin only"
|
||||
@@ -567,3 +569,4 @@ verify = "본인인증"
|
||||
|
||||
[ui.userfront.signup.success]
|
||||
action = "로그인하기"
|
||||
|
||||
|
||||
@@ -305,6 +305,7 @@ details = ""
|
||||
edit = ""
|
||||
view = ""
|
||||
hyphen = ""
|
||||
manage = ""
|
||||
na = ""
|
||||
never = ""
|
||||
next = ""
|
||||
@@ -315,6 +316,8 @@ qr = ""
|
||||
reset = ""
|
||||
read_only = ""
|
||||
refresh = ""
|
||||
remove = ""
|
||||
requesting = ""
|
||||
resend = ""
|
||||
retry = ""
|
||||
save = ""
|
||||
@@ -557,9 +560,3 @@ action = ""
|
||||
|
||||
# Auto-added missing keys
|
||||
|
||||
[ui.common]
|
||||
admin_only = ""
|
||||
assign = ""
|
||||
none = ""
|
||||
select = ""
|
||||
select_placeholder = ""
|
||||
|
||||
@@ -86,8 +86,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
void _onNameFocusChange() {
|
||||
if (!mounted) return;
|
||||
if (!_nameFocus.hasFocus && _nameTouched) {
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _autoSaveIfEditing(profile, 'name');
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _autoSaveIfEditing(profile, 'name');
|
||||
});
|
||||
} else if (_nameFocus.hasFocus) {
|
||||
_nameTouched = true;
|
||||
}
|
||||
@@ -101,8 +104,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
hasFocus: _departmentFocus.hasFocus,
|
||||
);
|
||||
if (!_departmentFocus.hasFocus && _departmentTouched) {
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _autoSaveIfEditing(profile, 'department');
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _autoSaveIfEditing(profile, 'department');
|
||||
});
|
||||
} else if (_departmentFocus.hasFocus) {
|
||||
_departmentTouched = true;
|
||||
}
|
||||
@@ -111,8 +117,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
void _onPhoneFocusChange() {
|
||||
if (!mounted) return;
|
||||
if (!_phoneFocus.hasFocus && _phoneTouched) {
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _handlePhoneFocusChange(profile);
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _handlePhoneFocusChange(profile);
|
||||
});
|
||||
} else if (_phoneFocus.hasFocus) {
|
||||
_phoneTouched = true;
|
||||
}
|
||||
@@ -121,8 +130,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
void _onPhoneCodeFocusChange() {
|
||||
if (!mounted) return;
|
||||
if (!_phoneCodeFocus.hasFocus && _phoneCodeTouched) {
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _handlePhoneFocusChange(profile);
|
||||
Future.microtask(() {
|
||||
if (!mounted) return;
|
||||
final profile = ref.read(profileProvider).value ?? _cachedProfile;
|
||||
if (profile != null) _handlePhoneFocusChange(profile);
|
||||
});
|
||||
} else if (_phoneCodeFocus.hasFocus) {
|
||||
_phoneCodeTouched = true;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user