1
0
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:
2026-02-27 15:17:07 +09:00
42 changed files with 2776 additions and 784 deletions

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

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

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

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

View File

@@ -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({

View File

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

View File

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

View 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("저장");
});
});

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ""
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"])
})
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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('취소')),
],
)
```

View 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)가 될 수 있습니다._

BIN
image.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 57 KiB

View File

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

View File

@@ -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 = "역할"

View File

@@ -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 = ""

View File

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

View File

@@ -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 = "로그인하기"

View File

@@ -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 = ""

View File

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