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 [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 (
@@ -187,7 +82,7 @@ function TenantListPage() {
<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

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

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

@@ -98,10 +98,22 @@ func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) (
return nil, nil 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 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 { type AsyncMockRedisRepo struct {
mock.Mock mock.Mock
} }
@@ -128,7 +140,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
} }
@@ -152,6 +164,10 @@ func (m *AsyncMockTenantService) GetTenant(ctx context.Context, id string) (*dom
return nil, nil 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) { func (m *AsyncMockTenantService) ListManageableTenants(ctx context.Context, userID string) ([]domain.Tenant, error) {
return nil, nil return nil, nil
} }

View File

@@ -4,7 +4,9 @@ import (
"baron-sso-backend/internal/domain" "baron-sso-backend/internal/domain"
"baron-sso-backend/internal/repository" "baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service" "baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
"errors" "errors"
"log/slog"
"strings" "strings"
"time" "time"
@@ -15,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,
@@ -32,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"`
} }
@@ -91,12 +98,10 @@ func (h *TenantHandler) ApproveTenant(c *fiber.Ctx) error {
} }
func (h *TenantHandler) ListTenants(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) 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
} }
@@ -104,19 +109,29 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
offset = 0 offset = 0
} }
var total int64 tenants, total, err := h.Service.ListTenants(c.Context(), limit, offset, parentId)
if err := h.DB.Model(&domain.Tenant{}).Count(&total).Error; err != nil { if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
} }
var tenants []domain.Tenant // Fetch member counts for all tenants in one query using slugs (company codes)
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil { slugs := make([]string, 0, len(tenants))
return errorJSON(c, fiber.StatusInternalServerError, err.Error()) 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})
@@ -140,7 +155,15 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusInternalServerError, err.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 { func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
@@ -151,6 +174,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"`
@@ -166,9 +190,20 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
return errorJSON(c, fiber.StatusBadRequest, "name is required") 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 == "" { 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 == "" { if slug == "" {
return errorJSON(c, fiber.StatusBadRequest, "slug is required") return errorJSON(c, fiber.StatusBadRequest, "slug is required")
@@ -186,7 +221,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 errorJSON(c, fiber.StatusConflict, err.Error()) 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()) return errorJSON(c, fiber.StatusInternalServerError, 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 {
@@ -222,9 +261,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"`
} }
@@ -239,8 +280,15 @@ 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 := normalizeTenantSlug(*req.Slug) slug := utils.GenerateSlug(*req.Slug)
if slug == "" { if slug == "" {
return errorJSON(c, fiber.StatusBadRequest, "slug cannot be empty") return errorJSON(c, fiber.StatusBadRequest, "slug cannot be empty")
} }
@@ -264,6 +312,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
} }
@@ -425,6 +497,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,
@@ -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 { func normalizeTenantStatus(value string) string {
value = strings.ToLower(strings.TrimSpace(value)) value = strings.ToLower(strings.TrimSpace(value))
if value == "" { if value == "" {
@@ -458,3 +520,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)
} }
@@ -66,10 +66,58 @@ func (m *MockTenantService) GetTenant(ctx context.Context, id string) (*domain.T
return args.Get(0).(*domain.Tenant), args.Error(1) 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) { func (m *MockTenantService) SetKetoService(keto service.KetoService) {
m.Called(keto) 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) { func TestTenantHandler_CreateTenant(t *testing.T) {
app := fiber.New() app := fiber.New()
mockSvc := new(MockTenantService) mockSvc := new(MockTenantService)
@@ -85,7 +133,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))
@@ -98,6 +146,47 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
assert.Equal(t, "t1", got["id"]) 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) { func TestTenantHandler_ApproveTenant(t *testing.T) {
app := fiber.New() app := fiber.New()
mockSvc := new(MockTenantService) mockSvc := new(MockTenantService)

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 errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users from both kratos and local db") 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()) return errorJSON(c, fiber.StatusInternalServerError, 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 errorJSON(c, fiber.StatusInternalServerError, err.Error()) 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}) 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 errorJSON(c, fiber.StatusNotFound, "user not found") 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 // [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 errorJSON(c, fiber.StatusInternalServerError, err.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 h.UserRepo != nil {
if localUser, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && localUser != nil { updatedLocalUser := h.mapToLocalUser(*updated)
oldRole := localUser.Role
oldTenantID := ""
if localUser.TenantID != nil {
oldTenantID = *localUser.TenantID
}
if req.Name != nil { ctx := context.Background() // Use request context if appropriate, but sync must finish
localUser.Name = *req.Name 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)
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

@@ -16,6 +16,7 @@ type TenantRepository interface {
FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error) FindByDomain(ctx context.Context, domainName string) (*domain.Tenant, error)
FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error) FindByIDs(ctx context.Context, ids []string) ([]domain.Tenant, error)
AddDomain(ctx context.Context, tenantID string, domainName string, verified bool) 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 { 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 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 ( 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,110 @@ 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

@@ -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: "alice@test.com", Name: "Alice", Role: "user"})
_ = repo.Create(ctx, &domain.User{Email: "bob@test.com", Name: "Bob", 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.NoError(t, err)
assert.True(t, total >= 1) assert.True(t, total >= 1)
assert.Equal(t, "Alice", users[0].Name) 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.Error(t, err) // Should not be found
assert.Nil(t, 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 { 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)
GetTenant(ctx context.Context, id 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 ApproveTenant(ctx context.Context, id string) error
SetKetoService(keto KetoService) // 추가 SetKetoService(keto KetoService) // 추가
} }
@@ -89,7 +90,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 +107,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,
@@ -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) { func (s *tenantService) GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error) {
return s.repo.FindBySlug(ctx, slug) 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 // 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

@@ -59,6 +59,11 @@ func (m *MockTenantRepoForSvc) AddDomain(ctx context.Context, tenantID string, d
return m.Called(ctx, tenantID, domainName, verified).Error(0) 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 { type MockKetoSvcForTenant struct {
mock.Mock mock.Mock
} }
@@ -116,10 +121,31 @@ func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID strin
return nil, nil 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 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) { func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
mockRepo := new(MockTenantRepoForSvc) mockRepo := new(MockTenantRepoForSvc)
mockOutbox := new(MockKetoOutboxRepositoryShared) 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("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)
@@ -199,3 +225,18 @@ func TestTenantService_ApproveTenant_SyncAdmin(t *testing.T) {
mockUserRepo.AssertExpectations(t) mockUserRepo.AssertExpectations(t)
mockOutbox.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 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 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 { type MockTenantRepository struct {
mock.Mock mock.Mock
} }
@@ -121,6 +142,10 @@ func (m *MockTenantRepository) AddDomain(ctx context.Context, tenantID string, d
return nil 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) { func TestUserGroupService_Create(t *testing.T) {
mockRepo := new(MockUserGroupRepository) mockRepo := new(MockUserGroupRepository)
mockTenantRepo := new(MockTenantRepository) mockTenantRepo := new(MockTenantRepository)

View File

@@ -1,6 +1,7 @@
package utils package utils
import ( import (
"fmt"
"regexp" "regexp"
"strings" "strings"
) )
@@ -75,3 +76,46 @@ func ValidateSlug(slug string) (bool, string) {
return true, "" 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" 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"
@@ -804,8 +806,8 @@ tenant_admin = "TENANT ADMIN"
tenant_member = "TENANT MEMBER" tenant_member = "TENANT MEMBER"
[ui.admin.tenants] [ui.admin.tenants]
add = "Tenant Add" add = "Add Tenant"
title = "Tenant List" title = "Tenant Registry"
[ui.admin.tenants.admins] [ui.admin.tenants.admins]
add_button = "Add Button" add_button = "Add Button"
@@ -865,6 +867,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"
@@ -1060,6 +1067,8 @@ theme_light = "Light"
theme_toggle = "Theme Toggle" theme_toggle = "Theme Toggle"
unknown = "Unknown" unknown = "Unknown"
view = "View" view = "View"
manage = "Manage"
remove = "Remove"
[ui.common.badge] [ui.common.badge]
admin_only = "Admin only" admin_only = "Admin only"
@@ -1079,6 +1088,12 @@ ok = "Ok"
pending = "Pending" pending = "Pending"
success = "Success" success = "Success"
[test]
key = "Test"
[non.existent]
key = "Non-existent key"
[ui.dev] [ui.dev]
brand = "Brand" brand = "Brand"
console_title = "Developer Console" console_title = "Developer Console"
@@ -1525,3 +1540,25 @@ verify = "Verify"
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "Action" 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] [msg.admin.tenants.members]
empty = "소속된 사용자가 없습니다." empty = "소속된 사용자가 없습니다."
desc = "조직에 소속된 사용자 목록을 확인합니다."
limit_notice = "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다."
[msg.admin.tenants.registry] [msg.admin.tenants.registry]
count = "총 {{count}}개 테넌트" count = "총 {{count}}개 테넌트"
@@ -865,6 +867,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"
@@ -1060,6 +1067,8 @@ theme_light = "Light"
theme_toggle = "테마 전환" theme_toggle = "테마 전환"
unknown = "Unknown" unknown = "Unknown"
view = "보기" view = "보기"
manage = "관리"
remove = "제외"
[ui.common.badge] [ui.common.badge]
admin_only = "Admin only" admin_only = "Admin only"
@@ -1079,6 +1088,12 @@ ok = "정상"
pending = "준비 중" pending = "준비 중"
success = "성공" success = "성공"
[test]
key = "테스트"
[non.existent]
key = "존재하지 않는 키"
[ui.dev] [ui.dev]
brand = "Baron 로그인" brand = "Baron 로그인"
console_title = "Developer Console" console_title = "Developer Console"
@@ -1525,3 +1540,25 @@ verify = "본인인증"
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "로그인하기" 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 = "" delete_confirm = ""
empty = "" empty = ""
fetch_error = "" fetch_error = ""
not_found = ""
remove_sub_confirm = ""
subtitle = "" subtitle = ""
[msg.admin.tenants.create] [msg.admin.tenants.create]
@@ -142,7 +144,9 @@ subtitle = ""
subtitle = "" subtitle = ""
[msg.admin.tenants.members] [msg.admin.tenants.members]
desc = ""
empty = "" empty = ""
limit_notice = ""
[msg.admin.tenants.registry] [msg.admin.tenants.registry]
count = "" count = ""
@@ -163,6 +167,7 @@ subtitle = ""
[msg.admin.users.create] [msg.admin.users.create]
error = "" error = ""
password_required = "" password_required = ""
success = ""
[msg.admin.users.create.account] [msg.admin.users.create.account]
subtitle = "" subtitle = ""
@@ -754,7 +759,12 @@ title = ""
title = "" title = ""
[ui.admin.tenants.members] [ui.admin.tenants.members]
descendants = ""
direct = ""
direct_label = ""
list_title = ""
title = "" title = ""
total_label = ""
[ui.admin.tenants.members.table] [ui.admin.tenants.members.table]
email = "" email = ""
@@ -782,7 +792,12 @@ type_text = ""
[ui.admin.tenants.sub] [ui.admin.tenants.sub]
add = "" add = ""
add_dialog_desc = ""
add_dialog_title = ""
add_existing = ""
manage = "" manage = ""
no_candidates = ""
search_placeholder = ""
title = "" title = ""
[ui.admin.tenants.sub.table] [ui.admin.tenants.sub.table]
@@ -793,6 +808,7 @@ status = ""
[ui.admin.tenants.table] [ui.admin.tenants.table]
actions = "" actions = ""
members = ""
name = "" name = ""
slug = "" slug = ""
status = "" status = ""
@@ -886,9 +902,16 @@ role = ""
status = "" status = ""
tenant_dept = "" tenant_dept = ""
[ui.admin.users.table]
email = ""
name = ""
role = ""
[ui.common] [ui.common]
add = "" add = ""
admin_only = ""
assign = ""
back = "" back = ""
cancel = "" cancel = ""
close = "" close = ""
@@ -901,9 +924,11 @@ details = ""
edit = "" edit = ""
view = "" view = ""
hyphen = "" hyphen = ""
manage = ""
na = "" na = ""
never = "" never = ""
next = "" next = ""
none = ""
page_of = "" page_of = ""
prev = "" prev = ""
previous = "" previous = ""
@@ -911,10 +936,14 @@ qr = ""
reset = "" reset = ""
read_only = "" read_only = ""
refresh = "" refresh = ""
remove = ""
requesting = ""
resend = "" resend = ""
retry = "" retry = ""
save = "" save = ""
search = "" search = ""
select = ""
select_placeholder = ""
show_more = "" show_more = ""
language = "" language = ""
language_ko = "" language_ko = ""
@@ -942,6 +971,12 @@ ok = ""
pending = "" pending = ""
success = "" success = ""
[test]
key = ""
[non.existent]
key = ""
[ui.dev] [ui.dev]
brand = "" brand = ""
console_title = "" console_title = ""
@@ -1508,10 +1543,3 @@ position_placeholder = ""
[ui.admin.users.list.table] [ui.admin.users.list.table]
position_job = "" position_job = ""
[ui.common]
admin_only = ""
assign = ""
none = ""
select = ""
select_placeholder = ""

View File

@@ -336,6 +336,8 @@ theme_light = "Light"
theme_toggle = "Theme Toggle" theme_toggle = "Theme Toggle"
unknown = "Unknown" unknown = "Unknown"
view = "View" view = "View"
manage = "Manage"
remove = "Remove"
[ui.common.badge] [ui.common.badge]
admin_only = "Admin only" admin_only = "Admin only"
@@ -567,3 +569,4 @@ verify = "Verify"
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "Action" action = "Action"

View File

@@ -336,6 +336,8 @@ theme_light = "Light"
theme_toggle = "테마 전환" theme_toggle = "테마 전환"
unknown = "Unknown" unknown = "Unknown"
view = "보기" view = "보기"
manage = "관리"
remove = "제외"
[ui.common.badge] [ui.common.badge]
admin_only = "Admin only" admin_only = "Admin only"
@@ -567,3 +569,4 @@ verify = "본인인증"
[ui.userfront.signup.success] [ui.userfront.signup.success]
action = "로그인하기" action = "로그인하기"

View File

@@ -305,6 +305,7 @@ details = ""
edit = "" edit = ""
view = "" view = ""
hyphen = "" hyphen = ""
manage = ""
na = "" na = ""
never = "" never = ""
next = "" next = ""
@@ -315,6 +316,8 @@ qr = ""
reset = "" reset = ""
read_only = "" read_only = ""
refresh = "" refresh = ""
remove = ""
requesting = ""
resend = "" resend = ""
retry = "" retry = ""
save = "" save = ""
@@ -557,9 +560,3 @@ action = ""
# Auto-added missing keys # 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() { void _onNameFocusChange() {
if (!mounted) return; if (!mounted) return;
if (!_nameFocus.hasFocus && _nameTouched) { if (!_nameFocus.hasFocus && _nameTouched) {
final profile = ref.read(profileProvider).value ?? _cachedProfile; Future.microtask(() {
if (profile != null) _autoSaveIfEditing(profile, 'name'); if (!mounted) return;
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _autoSaveIfEditing(profile, 'name');
});
} else if (_nameFocus.hasFocus) { } else if (_nameFocus.hasFocus) {
_nameTouched = true; _nameTouched = true;
} }
@@ -101,8 +104,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
hasFocus: _departmentFocus.hasFocus, hasFocus: _departmentFocus.hasFocus,
); );
if (!_departmentFocus.hasFocus && _departmentTouched) { if (!_departmentFocus.hasFocus && _departmentTouched) {
final profile = ref.read(profileProvider).value ?? _cachedProfile; Future.microtask(() {
if (profile != null) _autoSaveIfEditing(profile, 'department'); if (!mounted) return;
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _autoSaveIfEditing(profile, 'department');
});
} else if (_departmentFocus.hasFocus) { } else if (_departmentFocus.hasFocus) {
_departmentTouched = true; _departmentTouched = true;
} }
@@ -111,8 +117,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
void _onPhoneFocusChange() { void _onPhoneFocusChange() {
if (!mounted) return; if (!mounted) return;
if (!_phoneFocus.hasFocus && _phoneTouched) { if (!_phoneFocus.hasFocus && _phoneTouched) {
final profile = ref.read(profileProvider).value ?? _cachedProfile; Future.microtask(() {
if (profile != null) _handlePhoneFocusChange(profile); if (!mounted) return;
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _handlePhoneFocusChange(profile);
});
} else if (_phoneFocus.hasFocus) { } else if (_phoneFocus.hasFocus) {
_phoneTouched = true; _phoneTouched = true;
} }
@@ -121,8 +130,11 @@ class _ProfilePageState extends ConsumerState<ProfilePage> {
void _onPhoneCodeFocusChange() { void _onPhoneCodeFocusChange() {
if (!mounted) return; if (!mounted) return;
if (!_phoneCodeFocus.hasFocus && _phoneCodeTouched) { if (!_phoneCodeFocus.hasFocus && _phoneCodeTouched) {
final profile = ref.read(profileProvider).value ?? _cachedProfile; Future.microtask(() {
if (profile != null) _handlePhoneFocusChange(profile); if (!mounted) return;
final profile = ref.read(profileProvider).value ?? _cachedProfile;
if (profile != null) _handlePhoneFocusChange(profile);
});
} else if (_phoneCodeFocus.hasFocus) { } else if (_phoneCodeFocus.hasFocus) {
_phoneCodeTouched = true; _phoneCodeTouched = true;
} }