1
0
forked from baron/baron-sso

테넌트 목록 및 조직 계층 구조 개선

This commit is contained in:
2026-02-27 10:29:15 +09:00
parent 600961f33d
commit ca45a14bae
27 changed files with 1906 additions and 806 deletions

View File

@@ -19,7 +19,7 @@ describe("Label Component", () => {
<> <>
<Label htmlFor="test-input">Label Text</Label> <Label htmlFor="test-input">Label Text</Label>
<input id="test-input" /> <input id="test-input" />
</> </>,
); );
const label = screen.getByText("Label Text"); const label = screen.getByText("Label Text");
expect(label).toHaveAttribute("for", "test-input"); 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 [domains, setDomains] = useState("");
const parentQuery = useQuery({ const parentQuery = useQuery({
queryKey: ["tenants", { limit: 100 }], queryKey: ["tenants", { limit: 1000 }],
queryFn: () => fetchTenants(100, 0), queryFn: () => fetchTenants(1000, 0),
}); });
const mutation = useMutation({ const mutation = useMutation({

View File

@@ -27,118 +27,13 @@ import {
} from "../../../lib/adminApi"; } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n"; 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() { function TenantListPage() {
const query = useQuery({ const query = useQuery({
queryKey: ["tenants", { limit: 1000, offset: 0 }], // Fetch all to build tree queryKey: ["tenants", { limit: 1000, offset: 0 }],
queryFn: () => fetchTenants(1000, 0), queryFn: () => fetchTenants(1000, 0),
}); });
const navigate = useNavigate();
const deleteMutation = useMutation({ const deleteMutation = useMutation({
mutationFn: (tenantId: string) => deleteTenant(tenantId), mutationFn: (tenantId: string) => deleteTenant(tenantId),
onSuccess: () => { onSuccess: () => {
@@ -153,7 +48,7 @@ function TenantListPage() {
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.") ? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
: null; : null;
const tenantTree = query.data?.items ? buildTenantTree(query.data.items) : []; const tenants = query.data?.items ?? [];
const handleDelete = (tenantId: string, tenantName: string) => { const handleDelete = (tenantId: string, tenantName: string) => {
if ( if (
@@ -182,12 +77,12 @@ function TenantListPage() {
</span> </span>
</div> </div>
<h2 className="text-3xl font-semibold"> <h2 className="text-3xl font-semibold">
{t("ui.admin.tenants.title", "테넌트 목록")} {t("ui.admin.tenants.title", "테넌트 레지스트리")}
</h2> </h2>
<p className="text-sm text-[var(--color-muted)]"> <p className="text-sm text-[var(--color-muted)]">
{t( {t(
"msg.admin.tenants.subtitle", "msg.admin.tenants.subtitle",
"현재 등록된 테넌트를 확인하고 상태를 관리합니다.", "시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.",
)} )}
</p> </p>
</div> </div>
@@ -213,7 +108,7 @@ function TenantListPage() {
<CardHeader className="flex flex-row items-center justify-between"> <CardHeader className="flex flex-row items-center justify-between">
<div> <div>
<CardTitle> <CardTitle>
{t("ui.admin.tenants.registry.title", "Tenant registry")} {t("ui.admin.tenants.registry.title", "Tenant Registry")}
</CardTitle> </CardTitle>
<CardDescription> <CardDescription>
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", { {t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
@@ -247,6 +142,9 @@ function TenantListPage() {
<TableHead> <TableHead>
{t("ui.admin.tenants.table.status", "STATUS")} {t("ui.admin.tenants.table.status", "STATUS")}
</TableHead> </TableHead>
<TableHead>
{t("ui.admin.tenants.table.members", "MEMBERS")}
</TableHead>
<TableHead> <TableHead>
{t("ui.admin.tenants.table.updated", "UPDATED")} {t("ui.admin.tenants.table.updated", "UPDATED")}
</TableHead> </TableHead>
@@ -258,15 +156,15 @@ function TenantListPage() {
<TableBody> <TableBody>
{query.isLoading && ( {query.isLoading && (
<TableRow> <TableRow>
<TableCell colSpan={6}> <TableCell colSpan={7}>
{t("msg.common.loading", "로딩 중...")} {t("msg.common.loading", "로딩 중...")}
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{!query.isLoading && tenantTree.length === 0 && ( {!query.isLoading && tenants.length === 0 && (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={7}
className="text-center py-8 text-muted-foreground" className="text-center py-8 text-muted-foreground"
> >
{t( {t(
@@ -276,14 +174,63 @@ function TenantListPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
)} )}
{tenantTree.map((tenant) => ( {tenants.map((tenant) => (
<TenantRow <TableRow key={tenant.id}>
key={tenant.id} <TableCell className="font-semibold">{tenant.name}</TableCell>
tenant={tenant} <TableCell>
level={0} <Badge variant="outline" className="text-[10px] font-mono">
onDelete={handleDelete} {t(
isDeleting={deleteMutation.isPending} `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> </TableBody>
</Table> </Table>

View File

@@ -29,6 +29,7 @@ export type TenantSummary = {
domains?: string[]; domains?: string[];
parentId?: string; parentId?: string;
config?: Record<string, unknown>; config?: Record<string, unknown>;
memberCount: number; // Added member count
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
@@ -55,6 +56,7 @@ export type TenantUpdateRequest = {
name?: string; name?: string;
type?: string; type?: string;
slug?: string; slug?: string;
parentId?: string;
description?: string; description?: string;
status?: string; status?: string;
domains?: string[]; domains?: string[];
@@ -380,9 +382,14 @@ export type UserUpdateRequest = {
jobTitle?: string; 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", { const { data } = await apiClient.get<UserListResponse>("/v1/admin/users", {
params: { limit, offset, search }, params: { limit, offset, search, companyCode },
}); });
return data; return data;
} }

View File

@@ -16,7 +16,9 @@ describe("i18n utility", () => {
}); });
it("replaces variables in template", () => { it("replaces variables in template", () => {
expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe("Hello World"); expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe(
"Hello World",
);
}); });
it("respects locale in localStorage", () => { it("respects locale in localStorage", () => {
@@ -27,7 +29,7 @@ describe("i18n utility", () => {
}); });
it("defaults to ko if no locale set and browser language is ko", () => { it("defaults to ko if no locale set and browser language is ko", () => {
vi.spyOn(window.navigator, 'language', 'get').mockReturnValue('ko-KR'); vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR");
expect(t("ui.common.save", "저장")).toBe("저장"); expect(t("ui.common.save", "저장")).toBe("저장");
}); });
}); });

View File

@@ -126,6 +126,8 @@ description = "Description"
delete_confirm = "Delete Tenant \\\"{{name}}\\\"?" delete_confirm = "Delete Tenant \\\"{{name}}\\\"?"
empty = "Empty" empty = "Empty"
fetch_error = "Fetch Error" fetch_error = "Fetch Error"
not_found = "Tenant not found."
remove_sub_confirm = 'Remove tenant "{{name}}" from sub-tenants?'
subtitle = "Subtitle" subtitle = "Subtitle"
[msg.admin.tenants.create] [msg.admin.tenants.create]
@@ -760,10 +762,26 @@ type_boolean = "Boolean"
type_number = "Number" type_number = "Number"
type_text = "Text" 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] [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" manage = "Manage"
title = "Sub-tenants ({{count}})" title = "Sub-tenant Management ({{count}})"
[ui.admin.tenants.sub.table] [ui.admin.tenants.sub.table]
action = "ACTION" action = "ACTION"

View File

@@ -149,6 +149,8 @@ delete_success = "테넌트가 삭제되었습니다."
empty = "아직 등록된 테넌트가 없습니다." empty = "아직 등록된 테넌트가 없습니다."
fetch_error = "테넌트 목록 조회에 실패했습니다." fetch_error = "테넌트 목록 조회에 실패했습니다."
missing_id = "테넌트 ID가 없습니다." missing_id = "테넌트 ID가 없습니다."
not_found = "테넌트를 찾을 수 없습니다."
remove_sub_confirm = '테넌트 "{{name}}"을(를) 하위 조직에서 제외할까요?'
subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다." subtitle = "현재 등록된 테넌트를 확인하고 상태를 관리합니다."
[msg.admin.tenants.admins] [msg.admin.tenants.admins]
@@ -792,7 +794,7 @@ header_subtitle = "테넌트 정보를 수정하거나 연동 설정을 관리
loading = "테넌트 정보를 불러오는 중..." loading = "테넌트 정보를 불러오는 중..."
tab_admins = "관리자 설정" tab_admins = "관리자 설정"
tab_federation = "외부 연동" tab_federation = "외부 연동"
tab_organization = "조직 관리" tab_organization = "하위 테넌트 관리"
tab_profile = "프로필" tab_profile = "프로필"
tab_schema = "사용자 스키마" tab_schema = "사용자 스키마"
title = "테넌트 상세" title = "테넌트 상세"
@@ -866,8 +868,13 @@ type_text = "텍스트 (Text)"
[ui.admin.tenants.sub] [ui.admin.tenants.sub]
add = "하위 테넌트 추가" add = "하위 테넌트 추가"
add_existing = "기존 테넌트 추가"
add_dialog_title = "하위 테넌트 추가"
add_dialog_desc = "기존에 등록된 테넌트를 검색하여 하위 조직으로 추가합니다."
search_placeholder = "테넌트 이름 또는 슬러그 검색..."
no_candidates = "추가 가능한 테넌트가 없습니다."
manage = "관리" manage = "관리"
title = "Sub-tenants ({{count}})" title = "하위 테넌트 관리 ({{count}})"
[ui.admin.tenants.sub.table] [ui.admin.tenants.sub.table]
action = "ACTION" action = "ACTION"

View File

@@ -1,79 +1,84 @@
import { test, expect } from '@playwright/test'; import { expect, test } from "@playwright/test";
test.describe('Authentication', () => { test.describe("Authentication", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// Mock OIDC configuration // Mock OIDC configuration
await page.route('**/oidc/.well-known/openid-configuration', async route => { await page.route(
await route.fulfill({ "**/oidc/.well-known/openid-configuration",
json: { async (route) => {
issuer: "http://localhost:5000/oidc", await route.fulfill({
authorization_endpoint: "http://localhost:5000/oidc/auth", json: {
token_endpoint: "http://localhost:5000/oidc/token", issuer: "http://localhost:5000/oidc",
jwks_uri: "http://localhost:5000/oidc/jwks", authorization_endpoint: "http://localhost:5000/oidc/auth",
response_types_supported: ["code"], token_endpoint: "http://localhost:5000/oidc/token",
subject_types_supported: ["public"], jwks_uri: "http://localhost:5000/oidc/jwks",
id_token_signing_alg_values_supported: ["RS256"] 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 }) => { test("should redirect unauthorized users to login page", async ({ page }) => {
await page.goto('/'); await page.goto("/");
// Should be redirected to /login // Should be redirected to /login
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/);
await expect(page.locator('h1')).toContainText('Baron SSO'); await expect(page.locator("h1")).toContainText("Baron SSO");
}); });
test('should allow access to dashboard when authenticated', async ({ page }) => { test("should allow access to dashboard when authenticated", async ({
page,
}) => {
await page.addInitScript(() => { await page.addInitScript(() => {
const authority = "http://localhost:5000/oidc"; const authority = "http://localhost:5000/oidc";
const client_id = "adminfront"; const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`; const key = `oidc.user:${authority}:${client_id}`;
const authData = { const authData = {
access_token: 'fake-token', access_token: "fake-token",
token_type: 'Bearer', token_type: "Bearer",
profile: { profile: {
sub: 'admin-user', sub: "admin-user",
name: 'Admin User', name: "Admin User",
email: 'admin@example.com' email: "admin@example.com",
}, },
expires_at: Math.floor(Date.now() / 1000) + 3600, expires_at: Math.floor(Date.now() / 1000) + 3600,
}; };
window.localStorage.setItem(key, JSON.stringify(authData)); window.localStorage.setItem(key, JSON.stringify(authData));
}); });
await page.goto('/'); await page.goto("/");
// Wait for the auth loading to finish // Wait for the auth loading to finish
await expect(page.locator('.animate-spin')).not.toBeVisible(); await expect(page.locator(".animate-spin")).not.toBeVisible();
// Should be on the dashboard/overview // Should be on the dashboard/overview
await expect(page.locator('aside')).toBeVisible(); await expect(page.locator("aside")).toBeVisible();
await expect(page.locator('h1')).toContainText('Admin Control'); await expect(page.locator("h1")).toContainText("Admin Control");
}); });
test('should logout and redirect to login page', async ({ page }) => { test("should logout and redirect to login page", async ({ page }) => {
// Start authenticated // Start authenticated
await page.addInitScript(() => { await page.addInitScript(() => {
const authority = "http://localhost:5000/oidc"; const authority = "http://localhost:5000/oidc";
const client_id = "adminfront"; const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`; const key = `oidc.user:${authority}:${client_id}`;
const authData = { const authData = {
access_token: 'fake-token', access_token: "fake-token",
token_type: 'Bearer', token_type: "Bearer",
profile: { sub: 'admin-user', name: 'Admin' }, profile: { sub: "admin-user", name: "Admin" },
expires_at: Math.floor(Date.now() / 1000) + 3600, expires_at: Math.floor(Date.now() / 1000) + 3600,
}; };
window.localStorage.setItem(key, JSON.stringify(authData)); window.localStorage.setItem(key, JSON.stringify(authData));
}); });
await page.goto('/'); await page.goto("/");
await expect(page.locator('aside')).toBeVisible(); await expect(page.locator("aside")).toBeVisible();
// Mock window.confirm // Mock window.confirm
page.on('dialog', dialog => dialog.accept()); page.on("dialog", (dialog) => dialog.accept());
// Click logout button (label: ui.admin.nav.logout) // Click logout button (label: ui.admin.nav.logout)
await page.click('button:has-text("Logout"), button:has-text("로그아웃")'); await page.click('button:has-text("Logout"), button:has-text("로그아웃")');

View File

@@ -1,6 +1,6 @@
import { test, expect } from '@playwright/test'; import { expect, test } from "@playwright/test";
test.describe('Tenants Management', () => { test.describe("Tenants Management", () => {
test.beforeEach(async ({ page }) => { test.beforeEach(async ({ page }) => {
// Authenticate // Authenticate
await page.addInitScript(() => { await page.addInitScript(() => {
@@ -8,68 +8,90 @@ test.describe('Tenants Management', () => {
const client_id = "adminfront"; const client_id = "adminfront";
const key = `oidc.user:${authority}:${client_id}`; const key = `oidc.user:${authority}:${client_id}`;
const authData = { const authData = {
access_token: 'fake-token', access_token: "fake-token",
token_type: 'Bearer', token_type: "Bearer",
profile: { sub: 'admin-user', name: 'Admin User', email: 'admin@example.com' }, profile: {
sub: "admin-user",
name: "Admin User",
email: "admin@example.com",
},
expires_at: Math.floor(Date.now() / 1000) + 3600, expires_at: Math.floor(Date.now() / 1000) + 3600,
}; };
window.localStorage.setItem(key, JSON.stringify(authData)); window.localStorage.setItem(key, JSON.stringify(authData));
}); });
// Mock OIDC config to avoid redirects // Mock OIDC config to avoid redirects
await page.route('**/oidc/.well-known/openid-configuration', async route => { await page.route(
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } }); "**/oidc/.well-known/openid-configuration",
}); async (route) => {
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
},
);
}); });
test('should list tenants', async ({ page }) => { test("should list tenants", async ({ page }) => {
await page.route('**/api/v1/admin/tenants*', async route => { await page.route("**/api/v1/admin/tenants*", async (route) => {
await route.fulfill({ await route.fulfill({
json: { json: {
items: [ items: [
{ id: '1', name: 'Tenant A', slug: 'tenant-a', status: 'active', type: 'COMPANY', updatedAt: new Date().toISOString() }, {
id: "1",
name: "Tenant A",
slug: "tenant-a",
status: "active",
type: "COMPANY",
updatedAt: new Date().toISOString(),
},
], ],
total: 1, total: 1,
limit: 1000, limit: 1000,
offset: 0 offset: 0,
} },
}); });
}); });
await page.goto('/tenants'); await page.goto("/tenants");
await expect(page.locator('h2')).toContainText('테넌트 목록'); await expect(page.locator("h2")).toContainText("테넌트 목록");
await expect(page.locator('table')).toContainText('Tenant A'); await expect(page.locator("table")).toContainText("Tenant A");
}); });
test('should create a new tenant', async ({ page }) => { test("should create a new tenant", async ({ page }) => {
// Mock GET for list (empty) and for parents // Mock GET for list (empty) and for parents
await page.route('**/api/v1/admin/tenants*', async route => { await page.route("**/api/v1/admin/tenants*", async (route) => {
if (route.request().method() === 'GET') { if (route.request().method() === "GET") {
await route.fulfill({ json: { items: [], total: 0, limit: 100, offset: 0 } }); await route.fulfill({
} else if (route.request().method() === 'POST') { json: { items: [], total: 0, limit: 100, offset: 0 },
await route.fulfill({ });
json: { id: '2', name: 'New Tenant', slug: 'new-tenant', status: 'active', type: 'COMPANY' } } 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.goto("/tenants/new");
await page.fill('input >> nth=0', 'New Tenant'); await page.fill("input >> nth=0", "New Tenant");
await page.fill('input >> nth=1', 'new-tenant'); await page.fill("input >> nth=1", "new-tenant");
await page.fill('textarea', 'Description'); await page.fill("textarea", "Description");
await page.click('button:has-text("생성")'); await page.click('button:has-text("생성")');
await expect(page).toHaveURL(/\/tenants$/); await expect(page).toHaveURL(/\/tenants$/);
}); });
test('should show validation error on empty name', async ({ page }) => { test("should show validation error on empty name", async ({ page }) => {
await page.goto('/tenants/new'); await page.goto("/tenants/new");
const submitBtn = page.locator('button:has-text("생성")'); const submitBtn = page.locator('button:has-text("생성")');
await expect(submitBtn).toBeDisabled(); await expect(submitBtn).toBeDisabled();
await page.fill('input >> nth=0', 'Valid Name'); await page.fill("input >> nth=0", "Valid Name");
await expect(submitBtn).not.toBeDisabled(); await expect(submitBtn).not.toBeDisabled();
}); });
}); });

View File

@@ -277,7 +277,7 @@ func main() {
authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService) authHandler := handler.NewAuthHandler(redisService, idpProvider, auditRepo, oathkeeperRepo, tenantService, ketoService, ketoOutboxRepo, userRepo, consentRepo, kratosAdminService)
adminHandler := handler.NewAdminHandler(ketoService) adminHandler := handler.NewAdminHandler(ketoService)
devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler) devHandler := handler.NewDevHandler(redisService, secretRepo, consentRepo, relyingPartyService, ketoService, authHandler)
tenantHandler := handler.NewTenantHandler(db, tenantService, ketoService, ketoOutboxRepo, kratosAdminService) tenantHandler := handler.NewTenantHandler(db, tenantService, userRepo, ketoService, ketoOutboxRepo, kratosAdminService)
userGroupHandler := handler.NewUserGroupHandler(userGroupService) userGroupHandler := handler.NewUserGroupHandler(userGroupService)
relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService) relyingPartyHandler := handler.NewRelyingPartyHandler(relyingPartyService, kratosAdminService)
userHandler := handler.NewUserHandler(kratosAdminService, oryAdminProvider, tenantService, ketoService, ketoOutboxRepo, userRepo) 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) 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 { if err != nil {
slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err) slog.Error("Failed to seed tenant", "slug", config.Slug, "error", err)
return err return err

View File

@@ -19,23 +19,23 @@ const (
type User struct { type User struct {
ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"` ID string `gorm:"primaryKey;type:uuid;default:gen_random_uuid()" json:"id"`
Email string `gorm:"uniqueIndex;not null" json:"email"` Email string `gorm:"uniqueIndex;not null" json:"email"`
PasswordHash string `gorm:"not null" json:"-"` PasswordHash *string `gorm:"column:password_hash" json:"-"`
Name string `gorm:"not null" json:"name"` Name string `gorm:"column:name;not null" json:"name"`
Phone string `json:"phone"` Phone string `gorm:"column:phone" json:"phone"`
Role string `gorm:"default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user Role string `gorm:"column:role;default:'user';not null" json:"role"` // super_admin, tenant_admin, rp_admin, user
AffiliationType string `json:"affiliationType"` AffiliationType string `gorm:"column:affiliation_type" json:"affiliationType"`
CompanyCode string `json:"companyCode"` CompanyCode string `gorm:"column:company_code;index" json:"companyCode"`
TenantID *string `gorm:"type:uuid;index" json:"tenantId,omitempty"` TenantID *string `gorm:"column:tenant_id;type:uuid;index" json:"tenantId,omitempty"`
Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"` Tenant *Tenant `gorm:"foreignKey:TenantID" json:"tenant,omitempty"`
RelyingPartyID *string `gorm:"type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용 RelyingPartyID *string `gorm:"column:relying_party_id;type:uuid;index" json:"relyingPartyId,omitempty"` // RP Admin용
Department string `json:"department"` Department string `gorm:"column:department" json:"department"`
Position string `json:"position"` // 직급 (예: 수석, 책임, 선임) Position string `gorm:"column:position" json:"position"` // 직급 (예: 수석, 책임, 선임)
JobTitle string `json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획) JobTitle string `gorm:"column:job_title" json:"jobTitle"` // 직무 (예: 프론트엔드 개발, 기획)
Metadata JSONMap `gorm:"type:jsonb" json:"metadata,omitempty"` Metadata JSONMap `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"`
Status string `gorm:"default:'active'" json:"status"` Status string `gorm:"column:status;default:'active'" json:"status"`
CreatedAt time.Time `json:"createdAt"` CreatedAt time.Time `gorm:"column:created_at" json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"` UpdatedAt time.Time `gorm:"column:updated_at" json:"updatedAt"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"` DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"-"`
} }
// BeforeCreate hook to generate UUID if not present // BeforeCreate hook to generate UUID if not present

View File

@@ -102,6 +102,15 @@ func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search
return nil, 0, nil 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
}
type AsyncMockRedisRepo struct { type AsyncMockRedisRepo struct {
mock.Mock mock.Mock
} }
@@ -128,7 +137,7 @@ type AsyncMockTenantService struct {
mock.Mock 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 return nil, nil
} }

View File

@@ -6,6 +6,7 @@ import (
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils" "baron-sso-backend/internal/utils"
"errors" "errors"
"log/slog"
"strings" "strings"
"time" "time"
@@ -16,15 +17,17 @@ import (
type TenantHandler struct { type TenantHandler struct {
DB *gorm.DB DB *gorm.DB
Service service.TenantService Service service.TenantService
UserRepo repository.UserRepository
Keto service.KetoService Keto service.KetoService
KetoOutbox repository.KetoOutboxRepository KetoOutbox repository.KetoOutboxRepository
KratosAdmin service.KratosAdminService 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{ return &TenantHandler{
DB: db, DB: db,
Service: svc, Service: svc,
UserRepo: userRepo,
Keto: keto, Keto: keto,
KetoOutbox: outbox, KetoOutbox: outbox,
KratosAdmin: kratos, KratosAdmin: kratos,
@@ -33,12 +36,15 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoS
type tenantSummary struct { type tenantSummary struct {
ID string `json:"id"` ID string `json:"id"`
Type string `json:"type"`
ParentID *string `json:"parentId"`
Name string `json:"name"` Name string `json:"name"`
Slug string `json:"slug"` Slug string `json:"slug"`
Description string `json:"description"` Description string `json:"description"`
Status string `json:"status"` Status string `json:"status"`
Domains []string `json:"domains,omitempty"` Domains []string `json:"domains,omitempty"`
Config domain.JSONMap `json:"config,omitempty"` Config domain.JSONMap `json:"config,omitempty"`
MemberCount int64 `json:"memberCount"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
} }
@@ -98,6 +104,8 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50) limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0) offset := c.QueryInt("offset", 0)
parentId := c.Query("parentId")
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
} }
@@ -105,19 +113,45 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
offset = 0 offset = 0
} }
// Use separate queries for count and find to avoid GORM statement contamination
countQuery := h.DB.Model(&domain.Tenant{})
if parentId != "" {
countQuery = countQuery.Where("parent_id = ?", parentId)
}
var total int64 var total int64
if err := h.DB.Model(&domain.Tenant{}).Count(&total).Error; err != nil { if err := countQuery.Count(&total).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
findQuery := h.DB.Model(&domain.Tenant{})
if parentId != "" {
findQuery = findQuery.Where("parent_id = ?", parentId)
}
var tenants []domain.Tenant var tenants []domain.Tenant
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { if err := findQuery.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": 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)) items := make([]tenantSummary, 0, len(tenants))
for _, t := range 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}) return c.JSON(tenantListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
@@ -141,7 +175,15 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
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 { func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
@@ -152,6 +194,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
Slug string `json:"slug"` Slug string `json:"slug"`
Type string `json:"type"`
Description string `json:"description"` Description string `json:"description"`
Status string `json:"status"` Status string `json:"status"`
Domains []string `json:"domains"` Domains []string `json:"domains"`
@@ -167,6 +210,11 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"}) return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
} }
tenantType := normalizeTenantType(req.Type)
if tenantType == "" {
tenantType = domain.TenantTypeCompany // Default to COMPANY
}
slug := req.Slug slug := req.Slug
if slug == "" { if slug == "" {
slug = utils.GenerateUniqueSlug(name, func(s string) bool { slug = utils.GenerateUniqueSlug(name, func(s string) bool {
@@ -193,7 +241,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
parentID = &pid 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 err != nil {
if strings.Contains(err.Error(), "already exists") { if strings.Contains(err.Error(), "already exists") {
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()})
@@ -201,12 +249,16 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
summary := mapTenantSummary(*tenant)
summary.MemberCount = 0
if req.Config != nil { if req.Config != nil {
tenant.Config = req.Config tenant.Config = req.Config
h.DB.Save(tenant) 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 { func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
@@ -229,9 +281,11 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
var req struct { var req struct {
Name *string `json:"name"` Name *string `json:"name"`
Type *string `json:"type"`
Slug *string `json:"slug"` Slug *string `json:"slug"`
Description *string `json:"description"` Description *string `json:"description"`
Status *string `json:"status"` Status *string `json:"status"`
ParentID *string `json:"parentId"`
Domains []string `json:"domains"` Domains []string `json:"domains"`
Config map[string]any `json:"config"` Config map[string]any `json:"config"`
} }
@@ -246,6 +300,13 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
} }
tenant.Name = name 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 { if req.Slug != nil {
slug := utils.GenerateSlug(*req.Slug) slug := utils.GenerateSlug(*req.Slug)
if slug == "" { if slug == "" {
@@ -271,6 +332,30 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
} }
tenant.Status = status 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 { if req.Config != nil {
tenant.Config = req.Config tenant.Config = req.Config
} }
@@ -432,6 +517,8 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
return tenantSummary{ return tenantSummary{
ID: t.ID, ID: t.ID,
Type: t.Type,
ParentID: t.ParentID,
Name: t.Name, Name: t.Name,
Slug: t.Slug, Slug: t.Slug,
Description: t.Description, Description: t.Description,
@@ -453,3 +540,13 @@ func normalizeTenantStatus(value string) string {
} }
return value 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 mock.Mock
} }
func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) { func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) {
args := m.Called(ctx, name, slug, description, domains, parentID) args := m.Called(ctx, name, slug, tenantType, description, domains, parentID)
if args.Get(0) == nil { if args.Get(0) == nil {
return nil, args.Error(1) return nil, args.Error(1)
} }
@@ -85,7 +85,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
} }
body, _ := json.Marshal(input) body, _ := json.Marshal(input)
mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", "", []string{"test.com"}, (*string)(nil)). mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil)).
Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil) Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil)
req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body)) req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))

View File

@@ -68,6 +68,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50) limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0) offset := c.QueryInt("offset", 0)
search := strings.TrimSpace(c.Query("search")) search := strings.TrimSpace(c.Query("search"))
companyCode := strings.TrimSpace(c.Query("companyCode"))
if limit <= 0 { if limit <= 0 {
limit = 50 limit = 50
@@ -89,14 +90,21 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
// Tenant Admin filtering // Tenant Admin filtering
if requesterRole == domain.RoleTenantAdmin { if requesterRole == domain.RoleTenantAdmin {
if requesterCompany == "" || compCode != requesterCompany { if requesterCompany == "" || !strings.EqualFold(compCode, requesterCompany) {
continue 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 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 continue
} }
} }
@@ -118,14 +126,27 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
items = append(items, summary) 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}) 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) slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err)
// Fetch from UserRepo // 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 { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to fetch users from both kratos and local db"}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to fetch users from both kratos and local db"})
} }
@@ -289,66 +310,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
// [New] Local DB Sync // Fetch the newly created identity to ensure we have all traits
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,
})
}
}
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID) identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
if err != nil { if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
@@ -357,6 +319,28 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword}) 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) response := h.mapIdentitySummary(c.Context(), *identity)
if generatedPassword != "" { if generatedPassword != "" {
response.InitialPassword = generatedPassword response.InitialPassword = generatedPassword
@@ -382,6 +366,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"}) return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "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 // [New] Check access scope
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse) requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
if requester != nil && requester.Role == domain.RoleTenantAdmin { 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) traits["name"] = strings.TrimSpace(*req.Name)
} }
if req.Phone != nil { 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 { if req.CompanyCode != nil {
code := strings.TrimSpace(*req.CompanyCode) code := strings.TrimSpace(*req.CompanyCode)
@@ -471,92 +472,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()}) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
// [New] Local DB Sync // [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller
if h.UserRepo != nil { if h.UserRepo != nil {
if localUser, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && localUser != nil { updatedLocalUser := h.mapToLocalUser(*updated)
oldRole := localUser.Role
oldTenantID := "" ctx := context.Background() // Use request context if appropriate, but sync must finish
if localUser.TenantID != nil { if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil {
oldTenantID = *localUser.TenantID slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err)
}
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)
} }
// [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 != "" { if req.Password != nil && *req.Password != "" {
@@ -654,6 +581,97 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
return summary 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 { func extractTraitString(traits map[string]interface{}, key string) string {
if traits == nil { if traits == nil {
return "" return ""

View File

@@ -3,6 +3,7 @@ package repository
import ( import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"context" "context"
"strings"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -14,7 +15,10 @@ type UserRepository interface {
FindByID(ctx context.Context, id string) (*domain.User, error) FindByID(ctx context.Context, id string) (*domain.User, error)
FindByIDs(ctx context.Context, ids []string) ([]domain.User, error) FindByIDs(ctx context.Context, ids []string) ([]domain.User, error)
ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error) ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error)
List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) List(ctx context.Context, offset, limit int, search string, 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 Delete(ctx context.Context, id string) error
} }
@@ -69,14 +73,111 @@ func (r *userRepository) ListByTenant(ctx context.Context, tenantID string) ([]d
return users, nil 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 users []domain.User
var total int64 var total int64
db := r.db.WithContext(ctx).Model(&domain.User{}) db := r.db.WithContext(ctx).Model(&domain.User{})
if companyCode != "" {
db = db.Where("company_code = ?", companyCode)
}
if search != "" { if search != "" {
searchTerm := "%" + 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 { if err := db.Count(&total).Error; err != nil {

View File

@@ -13,7 +13,7 @@ import (
) )
type TenantService interface { 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) RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error)
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error) GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
@@ -89,7 +89,7 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
return s.repo.FindByIDs(ctx, allIDs) return s.repo.FindByIDs(ctx, allIDs)
} }
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, 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 // Validate Slug
if ok, msg := utils.ValidateSlug(slug); !ok { if ok, msg := utils.ValidateSlug(slug); !ok {
return nil, errors.New(msg) return nil, errors.New(msg)
@@ -106,7 +106,7 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript
// 2. Create Tenant // 2. Create Tenant
tenant := &domain.Tenant{ tenant := &domain.Tenant{
Type: domain.TenantTypeCompany, // Default to COMPANY for manual registration Type: tenantType,
Name: name, Name: name,
Slug: slug, Slug: slug,
Description: description, Description: description,

View File

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

View File

@@ -120,6 +120,21 @@ func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, sea
return nil, 0, nil 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 TestTenantService_RegisterTenant_AutoVerify(t *testing.T) { func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc) mockRepo := new(MockTenantRepoForSvc)
mockOutbox := new(MockKetoOutboxRepositoryShared) mockOutbox := new(MockKetoOutboxRepositoryShared)
@@ -136,7 +151,7 @@ func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil) mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil)
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once() mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once()
tenant, err := svc.RegisterTenant(ctx, name, slug, "", domains, nil) tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil)
assert.NoError(t, err) assert.NoError(t, err)
assert.NotNil(t, tenant) assert.NotNil(t, tenant)
assert.Equal(t, "t1", tenant.ID) assert.Equal(t, "t1", tenant.ID)

View File

@@ -81,6 +81,20 @@ func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search
return nil, 0, nil 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)
}
type MockTenantRepository struct { type MockTenantRepository struct {
mock.Mock mock.Mock
} }

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

View File

@@ -178,7 +178,9 @@ subtitle = "Subtitle"
subtitle = "Subtitle" subtitle = "Subtitle"
[msg.admin.tenants.members] [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] [msg.admin.tenants.registry]
count = "Count" count = "Count"
@@ -836,6 +838,11 @@ select_placeholder = "Select Placeholder"
[ui.admin.tenants.members] [ui.admin.tenants.members]
title = "Tenant Members ({{count}})" 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] [ui.admin.tenants.members.table]
email = "EMAIL" email = "EMAIL"

View File

@@ -179,6 +179,8 @@ subtitle = "필수 정보만 입력해도 생성 가능합니다. Slug는 없으
[msg.admin.tenants.members] [msg.admin.tenants.members]
empty = "소속된 사용자가 없습니다." empty = "소속된 사용자가 없습니다."
desc = "조직에 소속된 사용자 목록을 확인합니다."
limit_notice = "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다."
[msg.admin.tenants.registry] [msg.admin.tenants.registry]
count = "총 {{count}}개 테넌트" count = "총 {{count}}개 테넌트"
@@ -836,6 +838,11 @@ select_placeholder = "테넌트를 선택하세요"
[ui.admin.tenants.members] [ui.admin.tenants.members]
title = "Tenant Members ({{count}})" title = "Tenant Members ({{count}})"
direct_label = "소속"
total_label = "전체"
list_title = "구성원 관리"
direct = "소속 멤버"
descendants = "하위 조직 멤버"
[ui.admin.tenants.members.table] [ui.admin.tenants.members.table]
email = "EMAIL" email = "EMAIL"