forked from baron/baron-sso
테넌트 목록 및 조직 계층 구조 개선
This commit is contained in:
@@ -19,7 +19,7 @@ describe("Label Component", () => {
|
|||||||
<>
|
<>
|
||||||
<Label htmlFor="test-input">Label Text</Label>
|
<Label htmlFor="test-input">Label Text</Label>
|
||||||
<input id="test-input" />
|
<input id="test-input" />
|
||||||
</>
|
</>,
|
||||||
);
|
);
|
||||||
const label = screen.getByText("Label Text");
|
const label = screen.getByText("Label Text");
|
||||||
expect(label).toHaveAttribute("for", "test-input");
|
expect(label).toHaveAttribute("for", "test-input");
|
||||||
|
|||||||
87
adminfront/src/components/ui/tabs.tsx
Normal file
87
adminfront/src/components/ui/tabs.tsx
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
|
||||||
|
const TabsContext = React.createContext<{
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
}>({});
|
||||||
|
|
||||||
|
const Tabs = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & {
|
||||||
|
value?: string;
|
||||||
|
onValueChange?: (value: string) => void;
|
||||||
|
}
|
||||||
|
>(({ className, value, onValueChange, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<TabsContext.Provider value={{ value, onValueChange }}>
|
||||||
|
<div ref={ref} className={cn("w-full", className)} {...props} />
|
||||||
|
</TabsContext.Provider>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
Tabs.displayName = "Tabs";
|
||||||
|
|
||||||
|
const TabsList = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
TabsList.displayName = "TabsList";
|
||||||
|
|
||||||
|
const TabsTrigger = React.forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
React.ButtonHTMLAttributes<HTMLButtonElement> & { value: string }
|
||||||
|
>(({ className, value, ...props }, ref) => {
|
||||||
|
const { value: activeValue, onValueChange } = React.useContext(TabsContext);
|
||||||
|
const isSelected = activeValue === value;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
ref={ref}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
className={cn(
|
||||||
|
"inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
data-state={isSelected ? "active" : "inactive"}
|
||||||
|
onClick={() => onValueChange?.(value)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
TabsTrigger.displayName = "TabsTrigger";
|
||||||
|
|
||||||
|
const TabsContent = React.forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
React.HTMLAttributes<HTMLDivElement> & { value: string }
|
||||||
|
>(({ className, value, ...props }, ref) => {
|
||||||
|
const { value: activeValue } = React.useContext(TabsContext);
|
||||||
|
const isSelected = activeValue === value;
|
||||||
|
|
||||||
|
if (!isSelected) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
role="tabpanel"
|
||||||
|
className={cn(
|
||||||
|
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
TabsContent.displayName = "TabsContent";
|
||||||
|
|
||||||
|
export { Tabs, TabsList, TabsTrigger, TabsContent };
|
||||||
@@ -29,8 +29,8 @@ function TenantCreatePage() {
|
|||||||
const [domains, setDomains] = useState("");
|
const [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({
|
||||||
|
|||||||
@@ -27,118 +27,13 @@ import {
|
|||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
type TenantNode = TenantSummary & { children: TenantNode[] };
|
|
||||||
|
|
||||||
function buildTenantTree(tenants: TenantSummary[]): TenantNode[] {
|
|
||||||
const tenantMap = new Map<string, TenantNode>();
|
|
||||||
const rootTenants: TenantNode[] = [];
|
|
||||||
|
|
||||||
for (const tenant of tenants) {
|
|
||||||
tenantMap.set(tenant.id, { ...tenant, children: [] });
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const tenant of tenants) {
|
|
||||||
const node = tenantMap.get(tenant.id);
|
|
||||||
if (!node) continue;
|
|
||||||
|
|
||||||
if (tenant.parentId) {
|
|
||||||
const parent = tenantMap.get(tenant.parentId);
|
|
||||||
if (parent) {
|
|
||||||
parent.children.push(node);
|
|
||||||
} else {
|
|
||||||
rootTenants.push(node); // Orphaned
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
rootTenants.push(node);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rootTenants;
|
|
||||||
}
|
|
||||||
|
|
||||||
const TenantRow: React.FC<{
|
|
||||||
tenant: TenantNode;
|
|
||||||
level: number;
|
|
||||||
onDelete: (id: string, name: string) => void;
|
|
||||||
isDeleting: boolean;
|
|
||||||
}> = ({ tenant, level, onDelete, isDeleting }) => {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<TableRow key={tenant.id}>
|
|
||||||
<TableCell style={{ paddingLeft: `${1 + level * 1.5}rem` }}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{level > 0 && (
|
|
||||||
<CornerDownRight size={14} className="text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<span className="font-semibold">{tenant.name}</span>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge variant="outline" className="text-[10px] font-mono">
|
|
||||||
{tenant.type || "PERSONAL"}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>{tenant.slug}</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
<Badge
|
|
||||||
variant={
|
|
||||||
tenant.status === "active"
|
|
||||||
? "default"
|
|
||||||
: tenant.status === "pending"
|
|
||||||
? "secondary"
|
|
||||||
: "muted"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
|
||||||
</Badge>
|
|
||||||
</TableCell>
|
|
||||||
<TableCell>
|
|
||||||
{tenant.updatedAt
|
|
||||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
|
||||||
: "-"}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell className="text-right">
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
|
||||||
>
|
|
||||||
<Pencil size={14} />
|
|
||||||
{t("ui.common.edit", "편집")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => onDelete(tenant.id, tenant.name)}
|
|
||||||
disabled={isDeleting}
|
|
||||||
>
|
|
||||||
<Trash2 size={14} />
|
|
||||||
{t("ui.common.delete", "삭제")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
{tenant.children.map((child) => (
|
|
||||||
<TenantRow
|
|
||||||
key={child.id}
|
|
||||||
tenant={child}
|
|
||||||
level={level + 1}
|
|
||||||
onDelete={onDelete}
|
|
||||||
isDeleting={isDeleting}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function TenantListPage() {
|
function TenantListPage() {
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ["tenants", { limit: 1000, offset: 0 }], // Fetch all to build tree
|
queryKey: ["tenants", { limit: 1000, offset: 0 }],
|
||||||
queryFn: () => fetchTenants(1000, 0),
|
queryFn: () => fetchTenants(1000, 0),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const navigate = useNavigate();
|
||||||
const deleteMutation = useMutation({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: (tenantId: string) => deleteTenant(tenantId),
|
mutationFn: (tenantId: string) => deleteTenant(tenantId),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -153,7 +48,7 @@ function TenantListPage() {
|
|||||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
const tenantTree = query.data?.items ? buildTenantTree(query.data.items) : [];
|
const tenants = query.data?.items ?? [];
|
||||||
|
|
||||||
const handleDelete = (tenantId: string, tenantName: string) => {
|
const handleDelete = (tenantId: string, tenantName: string) => {
|
||||||
if (
|
if (
|
||||||
@@ -182,12 +77,12 @@ function TenantListPage() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<h2 className="text-3xl font-semibold">
|
<h2 className="text-3xl font-semibold">
|
||||||
{t("ui.admin.tenants.title", "테넌트 목록")}
|
{t("ui.admin.tenants.title", "테넌트 레지스트리")}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-sm text-[var(--color-muted)]">
|
<p className="text-sm text-[var(--color-muted)]">
|
||||||
{t(
|
{t(
|
||||||
"msg.admin.tenants.subtitle",
|
"msg.admin.tenants.subtitle",
|
||||||
"현재 등록된 테넌트를 확인하고 상태를 관리합니다.",
|
"시스템에 등록된 모든 테넌트를 평면 목록으로 확인하고 관리합니다.",
|
||||||
)}
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +108,7 @@ function TenantListPage() {
|
|||||||
<CardHeader className="flex flex-row items-center justify-between">
|
<CardHeader className="flex flex-row items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>
|
<CardTitle>
|
||||||
{t("ui.admin.tenants.registry.title", "Tenant registry")}
|
{t("ui.admin.tenants.registry.title", "Tenant Registry")}
|
||||||
</CardTitle>
|
</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
|
{t("msg.admin.tenants.registry.count", "총 {{count}}개 테넌트", {
|
||||||
@@ -247,6 +142,9 @@ function TenantListPage() {
|
|||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.tenants.table.status", "STATUS")}
|
{t("ui.admin.tenants.table.status", "STATUS")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
|
<TableHead>
|
||||||
|
{t("ui.admin.tenants.table.members", "MEMBERS")}
|
||||||
|
</TableHead>
|
||||||
<TableHead>
|
<TableHead>
|
||||||
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
{t("ui.admin.tenants.table.updated", "UPDATED")}
|
||||||
</TableHead>
|
</TableHead>
|
||||||
@@ -258,15 +156,15 @@ function TenantListPage() {
|
|||||||
<TableBody>
|
<TableBody>
|
||||||
{query.isLoading && (
|
{query.isLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={6}>
|
<TableCell colSpan={7}>
|
||||||
{t("msg.common.loading", "로딩 중...")}
|
{t("msg.common.loading", "로딩 중...")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{!query.isLoading && tenantTree.length === 0 && (
|
{!query.isLoading && tenants.length === 0 && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell
|
<TableCell
|
||||||
colSpan={6}
|
colSpan={7}
|
||||||
className="text-center py-8 text-muted-foreground"
|
className="text-center py-8 text-muted-foreground"
|
||||||
>
|
>
|
||||||
{t(
|
{t(
|
||||||
@@ -276,14 +174,63 @@ function TenantListPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{tenantTree.map((tenant) => (
|
{tenants.map((tenant) => (
|
||||||
<TenantRow
|
<TableRow key={tenant.id}>
|
||||||
key={tenant.id}
|
<TableCell className="font-semibold">{tenant.name}</TableCell>
|
||||||
tenant={tenant}
|
<TableCell>
|
||||||
level={0}
|
<Badge variant="outline" className="text-[10px] font-mono">
|
||||||
onDelete={handleDelete}
|
{t(
|
||||||
isDeleting={deleteMutation.isPending}
|
`domain.tenant_type.${tenant.type?.toLowerCase()}`,
|
||||||
/>
|
tenant.type,
|
||||||
|
)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-mono text-xs">
|
||||||
|
{tenant.slug}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge
|
||||||
|
variant={
|
||||||
|
tenant.status === "active"
|
||||||
|
? "default"
|
||||||
|
: tenant.status === "pending"
|
||||||
|
? "secondary"
|
||||||
|
: "muted"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
{tenant.memberCount}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-xs">
|
||||||
|
{tenant.updatedAt
|
||||||
|
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||||
|
: "-"}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => navigate(`/tenants/${tenant.id}`)}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
{t("ui.common.edit", "편집")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleDelete(tenant.id, tenant.name)}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
{t("ui.common.delete", "삭제")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ describe("i18n utility", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("replaces variables in template", () => {
|
it("replaces variables in template", () => {
|
||||||
expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe("Hello World");
|
expect(t("test.key", "Hello {{ name }}", { name: "World" })).toBe(
|
||||||
|
"Hello World",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("respects locale in localStorage", () => {
|
it("respects locale in localStorage", () => {
|
||||||
@@ -27,7 +29,7 @@ describe("i18n utility", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("defaults to ko if no locale set and browser language is ko", () => {
|
it("defaults to ko if no locale set and browser language is ko", () => {
|
||||||
vi.spyOn(window.navigator, 'language', 'get').mockReturnValue('ko-KR');
|
vi.spyOn(window.navigator, "language", "get").mockReturnValue("ko-KR");
|
||||||
expect(t("ui.common.save", "저장")).toBe("저장");
|
expect(t("ui.common.save", "저장")).toBe("저장");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -1,79 +1,84 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test.describe('Authentication', () => {
|
test.describe("Authentication", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
// Mock OIDC configuration
|
// Mock OIDC configuration
|
||||||
await page.route('**/oidc/.well-known/openid-configuration', async route => {
|
await page.route(
|
||||||
await route.fulfill({
|
"**/oidc/.well-known/openid-configuration",
|
||||||
json: {
|
async (route) => {
|
||||||
issuer: "http://localhost:5000/oidc",
|
await route.fulfill({
|
||||||
authorization_endpoint: "http://localhost:5000/oidc/auth",
|
json: {
|
||||||
token_endpoint: "http://localhost:5000/oidc/token",
|
issuer: "http://localhost:5000/oidc",
|
||||||
jwks_uri: "http://localhost:5000/oidc/jwks",
|
authorization_endpoint: "http://localhost:5000/oidc/auth",
|
||||||
response_types_supported: ["code"],
|
token_endpoint: "http://localhost:5000/oidc/token",
|
||||||
subject_types_supported: ["public"],
|
jwks_uri: "http://localhost:5000/oidc/jwks",
|
||||||
id_token_signing_alg_values_supported: ["RS256"]
|
response_types_supported: ["code"],
|
||||||
}
|
subject_types_supported: ["public"],
|
||||||
});
|
id_token_signing_alg_values_supported: ["RS256"],
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should redirect unauthorized users to login page', async ({ page }) => {
|
test("should redirect unauthorized users to login page", async ({ page }) => {
|
||||||
await page.goto('/');
|
await page.goto("/");
|
||||||
// Should be redirected to /login
|
// Should be redirected to /login
|
||||||
await expect(page).toHaveURL(/\/login/);
|
await expect(page).toHaveURL(/\/login/);
|
||||||
await expect(page.locator('h1')).toContainText('Baron SSO');
|
await expect(page.locator("h1")).toContainText("Baron SSO");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should allow access to dashboard when authenticated', async ({ page }) => {
|
test("should allow access to dashboard when authenticated", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const authority = "http://localhost:5000/oidc";
|
const authority = "http://localhost:5000/oidc";
|
||||||
const client_id = "adminfront";
|
const client_id = "adminfront";
|
||||||
const key = `oidc.user:${authority}:${client_id}`;
|
const key = `oidc.user:${authority}:${client_id}`;
|
||||||
const authData = {
|
const authData = {
|
||||||
access_token: 'fake-token',
|
access_token: "fake-token",
|
||||||
token_type: 'Bearer',
|
token_type: "Bearer",
|
||||||
profile: {
|
profile: {
|
||||||
sub: 'admin-user',
|
sub: "admin-user",
|
||||||
name: 'Admin User',
|
name: "Admin User",
|
||||||
email: 'admin@example.com'
|
email: "admin@example.com",
|
||||||
},
|
},
|
||||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
};
|
};
|
||||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto("/");
|
||||||
|
|
||||||
// Wait for the auth loading to finish
|
// Wait for the auth loading to finish
|
||||||
await expect(page.locator('.animate-spin')).not.toBeVisible();
|
await expect(page.locator(".animate-spin")).not.toBeVisible();
|
||||||
|
|
||||||
// Should be on the dashboard/overview
|
// Should be on the dashboard/overview
|
||||||
await expect(page.locator('aside')).toBeVisible();
|
await expect(page.locator("aside")).toBeVisible();
|
||||||
await expect(page.locator('h1')).toContainText('Admin Control');
|
await expect(page.locator("h1")).toContainText("Admin Control");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should logout and redirect to login page', async ({ page }) => {
|
test("should logout and redirect to login page", async ({ page }) => {
|
||||||
// Start authenticated
|
// Start authenticated
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
const authority = "http://localhost:5000/oidc";
|
const authority = "http://localhost:5000/oidc";
|
||||||
const client_id = "adminfront";
|
const client_id = "adminfront";
|
||||||
const key = `oidc.user:${authority}:${client_id}`;
|
const key = `oidc.user:${authority}:${client_id}`;
|
||||||
const authData = {
|
const authData = {
|
||||||
access_token: 'fake-token',
|
access_token: "fake-token",
|
||||||
token_type: 'Bearer',
|
token_type: "Bearer",
|
||||||
profile: { sub: 'admin-user', name: 'Admin' },
|
profile: { sub: "admin-user", name: "Admin" },
|
||||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
};
|
};
|
||||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/');
|
await page.goto("/");
|
||||||
await expect(page.locator('aside')).toBeVisible();
|
await expect(page.locator("aside")).toBeVisible();
|
||||||
|
|
||||||
// Mock window.confirm
|
// Mock window.confirm
|
||||||
page.on('dialog', dialog => dialog.accept());
|
page.on("dialog", (dialog) => dialog.accept());
|
||||||
|
|
||||||
// Click logout button (label: ui.admin.nav.logout)
|
// Click logout button (label: ui.admin.nav.logout)
|
||||||
await page.click('button:has-text("Logout"), button:has-text("로그아웃")');
|
await page.click('button:has-text("Logout"), button:has-text("로그아웃")');
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { expect, test } from "@playwright/test";
|
||||||
|
|
||||||
test.describe('Tenants Management', () => {
|
test.describe("Tenants Management", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
// Authenticate
|
// Authenticate
|
||||||
await page.addInitScript(() => {
|
await page.addInitScript(() => {
|
||||||
@@ -8,68 +8,90 @@ test.describe('Tenants Management', () => {
|
|||||||
const client_id = "adminfront";
|
const client_id = "adminfront";
|
||||||
const key = `oidc.user:${authority}:${client_id}`;
|
const key = `oidc.user:${authority}:${client_id}`;
|
||||||
const authData = {
|
const authData = {
|
||||||
access_token: 'fake-token',
|
access_token: "fake-token",
|
||||||
token_type: 'Bearer',
|
token_type: "Bearer",
|
||||||
profile: { sub: 'admin-user', name: 'Admin User', email: 'admin@example.com' },
|
profile: {
|
||||||
|
sub: "admin-user",
|
||||||
|
name: "Admin User",
|
||||||
|
email: "admin@example.com",
|
||||||
|
},
|
||||||
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
expires_at: Math.floor(Date.now() / 1000) + 3600,
|
||||||
};
|
};
|
||||||
window.localStorage.setItem(key, JSON.stringify(authData));
|
window.localStorage.setItem(key, JSON.stringify(authData));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Mock OIDC config to avoid redirects
|
// Mock OIDC config to avoid redirects
|
||||||
await page.route('**/oidc/.well-known/openid-configuration', async route => {
|
await page.route(
|
||||||
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
"**/oidc/.well-known/openid-configuration",
|
||||||
});
|
async (route) => {
|
||||||
|
await route.fulfill({ json: { issuer: "http://localhost:5000/oidc" } });
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should list tenants', async ({ page }) => {
|
test("should list tenants", async ({ page }) => {
|
||||||
await page.route('**/api/v1/admin/tenants*', async route => {
|
await page.route("**/api/v1/admin/tenants*", async (route) => {
|
||||||
await route.fulfill({
|
await route.fulfill({
|
||||||
json: {
|
json: {
|
||||||
items: [
|
items: [
|
||||||
{ id: '1', name: 'Tenant A', slug: 'tenant-a', status: 'active', type: 'COMPANY', updatedAt: new Date().toISOString() },
|
{
|
||||||
|
id: "1",
|
||||||
|
name: "Tenant A",
|
||||||
|
slug: "tenant-a",
|
||||||
|
status: "active",
|
||||||
|
type: "COMPANY",
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
total: 1,
|
total: 1,
|
||||||
limit: 1000,
|
limit: 1000,
|
||||||
offset: 0
|
offset: 0,
|
||||||
}
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/tenants');
|
await page.goto("/tenants");
|
||||||
await expect(page.locator('h2')).toContainText('테넌트 목록');
|
await expect(page.locator("h2")).toContainText("테넌트 목록");
|
||||||
await expect(page.locator('table')).toContainText('Tenant A');
|
await expect(page.locator("table")).toContainText("Tenant A");
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should create a new tenant', async ({ page }) => {
|
test("should create a new tenant", async ({ page }) => {
|
||||||
// Mock GET for list (empty) and for parents
|
// Mock GET for list (empty) and for parents
|
||||||
await page.route('**/api/v1/admin/tenants*', async route => {
|
await page.route("**/api/v1/admin/tenants*", async (route) => {
|
||||||
if (route.request().method() === 'GET') {
|
if (route.request().method() === "GET") {
|
||||||
await route.fulfill({ json: { items: [], total: 0, limit: 100, offset: 0 } });
|
await route.fulfill({
|
||||||
} else if (route.request().method() === 'POST') {
|
json: { items: [], total: 0, limit: 100, offset: 0 },
|
||||||
await route.fulfill({
|
});
|
||||||
json: { id: '2', name: 'New Tenant', slug: 'new-tenant', status: 'active', type: 'COMPANY' }
|
} else if (route.request().method() === "POST") {
|
||||||
|
await route.fulfill({
|
||||||
|
json: {
|
||||||
|
id: "2",
|
||||||
|
name: "New Tenant",
|
||||||
|
slug: "new-tenant",
|
||||||
|
status: "active",
|
||||||
|
type: "COMPANY",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await page.goto('/tenants/new');
|
await page.goto("/tenants/new");
|
||||||
|
|
||||||
await page.fill('input >> nth=0', 'New Tenant');
|
await page.fill("input >> nth=0", "New Tenant");
|
||||||
await page.fill('input >> nth=1', 'new-tenant');
|
await page.fill("input >> nth=1", "new-tenant");
|
||||||
await page.fill('textarea', 'Description');
|
await page.fill("textarea", "Description");
|
||||||
|
|
||||||
await page.click('button:has-text("생성")');
|
await page.click('button:has-text("생성")');
|
||||||
|
|
||||||
await expect(page).toHaveURL(/\/tenants$/);
|
await expect(page).toHaveURL(/\/tenants$/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should show validation error on empty name', async ({ page }) => {
|
test("should show validation error on empty name", async ({ page }) => {
|
||||||
await page.goto('/tenants/new');
|
await page.goto("/tenants/new");
|
||||||
const submitBtn = page.locator('button:has-text("생성")');
|
const submitBtn = page.locator('button:has-text("생성")');
|
||||||
await expect(submitBtn).toBeDisabled();
|
await expect(submitBtn).toBeDisabled();
|
||||||
|
|
||||||
await page.fill('input >> nth=0', 'Valid Name');
|
await page.fill("input >> nth=0", "Valid Name");
|
||||||
await expect(submitBtn).not.toBeDisabled();
|
await expect(submitBtn).not.toBeDisabled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -102,6 +102,15 @@ func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search
|
|||||||
return nil, 0, nil
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *AsyncMockUserRepo) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *AsyncMockUserRepo) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
type AsyncMockRedisRepo struct {
|
type AsyncMockRedisRepo struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
@@ -128,7 +137,7 @@ type AsyncMockTenantService struct {
|
|||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
func (m *AsyncMockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"baron-sso-backend/internal/utils"
|
"baron-sso-backend/internal/utils"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -16,15 +17,17 @@ import (
|
|||||||
type TenantHandler struct {
|
type TenantHandler struct {
|
||||||
DB *gorm.DB
|
DB *gorm.DB
|
||||||
Service service.TenantService
|
Service service.TenantService
|
||||||
|
UserRepo repository.UserRepository
|
||||||
Keto service.KetoService
|
Keto service.KetoService
|
||||||
KetoOutbox repository.KetoOutboxRepository
|
KetoOutbox repository.KetoOutboxRepository
|
||||||
KratosAdmin service.KratosAdminService
|
KratosAdmin service.KratosAdminService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService) *TenantHandler {
|
func NewTenantHandler(db *gorm.DB, svc service.TenantService, userRepo repository.UserRepository, keto service.KetoService, outbox repository.KetoOutboxRepository, kratos service.KratosAdminService) *TenantHandler {
|
||||||
return &TenantHandler{
|
return &TenantHandler{
|
||||||
DB: db,
|
DB: db,
|
||||||
Service: svc,
|
Service: svc,
|
||||||
|
UserRepo: userRepo,
|
||||||
Keto: keto,
|
Keto: keto,
|
||||||
KetoOutbox: outbox,
|
KetoOutbox: outbox,
|
||||||
KratosAdmin: kratos,
|
KratosAdmin: kratos,
|
||||||
@@ -33,12 +36,15 @@ func NewTenantHandler(db *gorm.DB, svc service.TenantService, keto service.KetoS
|
|||||||
|
|
||||||
type tenantSummary struct {
|
type tenantSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
ParentID *string `json:"parentId"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Domains []string `json:"domains,omitempty"`
|
Domains []string `json:"domains,omitempty"`
|
||||||
Config domain.JSONMap `json:"config,omitempty"`
|
Config domain.JSONMap `json:"config,omitempty"`
|
||||||
|
MemberCount int64 `json:"memberCount"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
}
|
}
|
||||||
@@ -98,6 +104,8 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
limit := c.QueryInt("limit", 50)
|
limit := c.QueryInt("limit", 50)
|
||||||
offset := c.QueryInt("offset", 0)
|
offset := c.QueryInt("offset", 0)
|
||||||
|
parentId := c.Query("parentId")
|
||||||
|
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
@@ -105,19 +113,45 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
|||||||
offset = 0
|
offset = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Use separate queries for count and find to avoid GORM statement contamination
|
||||||
|
countQuery := h.DB.Model(&domain.Tenant{})
|
||||||
|
if parentId != "" {
|
||||||
|
countQuery = countQuery.Where("parent_id = ?", parentId)
|
||||||
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := h.DB.Model(&domain.Tenant{}).Count(&total).Error; err != nil {
|
if err := countQuery.Count(&total).Error; err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
findQuery := h.DB.Model(&domain.Tenant{})
|
||||||
|
if parentId != "" {
|
||||||
|
findQuery = findQuery.Where("parent_id = ?", parentId)
|
||||||
|
}
|
||||||
|
|
||||||
var tenants []domain.Tenant
|
var tenants []domain.Tenant
|
||||||
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
|
if err := findQuery.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Fetch member counts for all tenants in one query using slugs (company codes)
|
||||||
|
slugs := make([]string, 0, len(tenants))
|
||||||
|
for _, t := range tenants {
|
||||||
|
slugs = append(slugs, t.Slug)
|
||||||
|
}
|
||||||
|
memberCounts, err := h.UserRepo.CountByCompanyCodes(c.Context(), slugs)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("failed to count members for tenants", "error", err)
|
||||||
|
memberCounts = make(map[string]int64)
|
||||||
|
}
|
||||||
|
|
||||||
items := make([]tenantSummary, 0, len(tenants))
|
items := make([]tenantSummary, 0, len(tenants))
|
||||||
for _, t := range tenants {
|
for _, t := range tenants {
|
||||||
items = append(items, mapTenantSummary(t))
|
summary := mapTenantSummary(t)
|
||||||
|
// Ensure robust matching by trimming and lowercasing the slug key
|
||||||
|
key := strings.ToLower(strings.TrimSpace(t.Slug))
|
||||||
|
summary.MemberCount = memberCounts[key]
|
||||||
|
items = append(items, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(tenantListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
|
return c.JSON(tenantListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
|
||||||
@@ -141,7 +175,15 @@ func (h *TenantHandler) GetTenant(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(mapTenantSummary(tenant))
|
memberCounts, err := h.UserRepo.CountByCompanyCodes(c.Context(), []string{tenant.Slug})
|
||||||
|
count := int64(0)
|
||||||
|
if err == nil {
|
||||||
|
count = memberCounts[strings.ToLower(tenant.Slug)]
|
||||||
|
}
|
||||||
|
summary := mapTenantSummary(tenant)
|
||||||
|
summary.MemberCount = count
|
||||||
|
|
||||||
|
return c.JSON(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
||||||
@@ -152,6 +194,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
|||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
|
Type string `json:"type"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Domains []string `json:"domains"`
|
Domains []string `json:"domains"`
|
||||||
@@ -167,6 +210,11 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "name is required"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tenantType := normalizeTenantType(req.Type)
|
||||||
|
if tenantType == "" {
|
||||||
|
tenantType = domain.TenantTypeCompany // Default to COMPANY
|
||||||
|
}
|
||||||
|
|
||||||
slug := req.Slug
|
slug := req.Slug
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
slug = utils.GenerateUniqueSlug(name, func(s string) bool {
|
slug = utils.GenerateUniqueSlug(name, func(s string) bool {
|
||||||
@@ -193,7 +241,7 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
|||||||
parentID = &pid
|
parentID = &pid
|
||||||
}
|
}
|
||||||
|
|
||||||
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, req.Description, req.Domains, parentID)
|
tenant, err := h.Service.RegisterTenant(c.Context(), name, slug, tenantType, req.Description, req.Domains, parentID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if strings.Contains(err.Error(), "already exists") {
|
if strings.Contains(err.Error(), "already exists") {
|
||||||
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusConflict).JSON(fiber.Map{"error": err.Error()})
|
||||||
@@ -201,12 +249,16 @@ func (h *TenantHandler) CreateTenant(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
summary := mapTenantSummary(*tenant)
|
||||||
|
summary.MemberCount = 0
|
||||||
|
|
||||||
if req.Config != nil {
|
if req.Config != nil {
|
||||||
tenant.Config = req.Config
|
tenant.Config = req.Config
|
||||||
h.DB.Save(tenant)
|
h.DB.Save(tenant)
|
||||||
|
summary.Config = tenant.Config
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(mapTenantSummary(*tenant))
|
return c.Status(fiber.StatusCreated).JSON(summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
||||||
@@ -229,9 +281,11 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Name *string `json:"name"`
|
Name *string `json:"name"`
|
||||||
|
Type *string `json:"type"`
|
||||||
Slug *string `json:"slug"`
|
Slug *string `json:"slug"`
|
||||||
Description *string `json:"description"`
|
Description *string `json:"description"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
|
ParentID *string `json:"parentId"`
|
||||||
Domains []string `json:"domains"`
|
Domains []string `json:"domains"`
|
||||||
Config map[string]any `json:"config"`
|
Config map[string]any `json:"config"`
|
||||||
}
|
}
|
||||||
@@ -246,6 +300,13 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
tenant.Name = name
|
tenant.Name = name
|
||||||
}
|
}
|
||||||
|
if req.Type != nil {
|
||||||
|
tenantType := normalizeTenantType(*req.Type)
|
||||||
|
if tenantType == "" {
|
||||||
|
return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{"error": "invalid tenant type"})
|
||||||
|
}
|
||||||
|
tenant.Type = tenantType
|
||||||
|
}
|
||||||
if req.Slug != nil {
|
if req.Slug != nil {
|
||||||
slug := utils.GenerateSlug(*req.Slug)
|
slug := utils.GenerateSlug(*req.Slug)
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
@@ -271,6 +332,30 @@ func (h *TenantHandler) UpdateTenant(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
tenant.Status = status
|
tenant.Status = status
|
||||||
}
|
}
|
||||||
|
if req.ParentID != nil {
|
||||||
|
pid := strings.TrimSpace(*req.ParentID)
|
||||||
|
if pid == "" {
|
||||||
|
tenant.ParentID = nil
|
||||||
|
} else {
|
||||||
|
tenant.ParentID = &pid
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Keto] Sync hierarchy via Outbox
|
||||||
|
if h.KetoOutbox != nil {
|
||||||
|
if tenant.ParentID != nil {
|
||||||
|
_ = h.KetoOutbox.Create(c.Context(), &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: tenant.ID,
|
||||||
|
Relation: "parents",
|
||||||
|
Subject: "Tenant:" + *tenant.ParentID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// We don't have enough info here to delete specific parent if we don't know the old one,
|
||||||
|
// but for now we focus on adding.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if req.Config != nil {
|
if req.Config != nil {
|
||||||
tenant.Config = req.Config
|
tenant.Config = req.Config
|
||||||
}
|
}
|
||||||
@@ -432,6 +517,8 @@ func mapTenantSummary(t domain.Tenant) tenantSummary {
|
|||||||
|
|
||||||
return tenantSummary{
|
return tenantSummary{
|
||||||
ID: t.ID,
|
ID: t.ID,
|
||||||
|
Type: t.Type,
|
||||||
|
ParentID: t.ParentID,
|
||||||
Name: t.Name,
|
Name: t.Name,
|
||||||
Slug: t.Slug,
|
Slug: t.Slug,
|
||||||
Description: t.Description,
|
Description: t.Description,
|
||||||
@@ -453,3 +540,13 @@ func normalizeTenantStatus(value string) string {
|
|||||||
}
|
}
|
||||||
return value
|
return value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeTenantType(value string) string {
|
||||||
|
value = strings.ToUpper(strings.TrimSpace(value))
|
||||||
|
switch value {
|
||||||
|
case domain.TenantTypePersonal, domain.TenantTypeCompany, domain.TenantTypeCompanyGroup, domain.TenantTypeUserGroup:
|
||||||
|
return value
|
||||||
|
default:
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ type MockTenantService struct {
|
|||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
func (m *MockTenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
||||||
args := m.Called(ctx, name, slug, description, domains, parentID)
|
args := m.Called(ctx, name, slug, tenantType, description, domains, parentID)
|
||||||
if args.Get(0) == nil {
|
if args.Get(0) == nil {
|
||||||
return nil, args.Error(1)
|
return nil, args.Error(1)
|
||||||
}
|
}
|
||||||
@@ -85,7 +85,7 @@ func TestTenantHandler_CreateTenant(t *testing.T) {
|
|||||||
}
|
}
|
||||||
body, _ := json.Marshal(input)
|
body, _ := json.Marshal(input)
|
||||||
|
|
||||||
mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", "", []string{"test.com"}, (*string)(nil)).
|
mockSvc.On("RegisterTenant", mock.Anything, "Test Tenant", "test-tenant", domain.TenantTypeCompany, "", []string{"test.com"}, (*string)(nil)).
|
||||||
Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil)
|
Return(&domain.Tenant{ID: "t1", Name: "Test Tenant", Slug: "test-tenant"}, nil)
|
||||||
|
|
||||||
req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))
|
req := httptest.NewRequest("POST", "/tenants", bytes.NewReader(body))
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
limit := c.QueryInt("limit", 50)
|
limit := c.QueryInt("limit", 50)
|
||||||
offset := c.QueryInt("offset", 0)
|
offset := c.QueryInt("offset", 0)
|
||||||
search := strings.TrimSpace(c.Query("search"))
|
search := strings.TrimSpace(c.Query("search"))
|
||||||
|
companyCode := strings.TrimSpace(c.Query("companyCode"))
|
||||||
|
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
@@ -89,14 +90,21 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
// Tenant Admin filtering
|
// Tenant Admin filtering
|
||||||
if requesterRole == domain.RoleTenantAdmin {
|
if requesterRole == domain.RoleTenantAdmin {
|
||||||
if requesterCompany == "" || compCode != requesterCompany {
|
if requesterCompany == "" || !strings.EqualFold(compCode, requesterCompany) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search filtering
|
// Dedicated companyCode filter
|
||||||
|
if companyCode != "" && !strings.EqualFold(compCode, companyCode) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search filtering (Keyword search in email, name, or companyCode)
|
||||||
if search != "" {
|
if search != "" {
|
||||||
if !strings.Contains(email, searchLower) && !strings.Contains(name, searchLower) {
|
if !strings.Contains(email, searchLower) &&
|
||||||
|
!strings.Contains(name, searchLower) &&
|
||||||
|
!strings.Contains(strings.ToLower(compCode), searchLower) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -118,14 +126,27 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
items = append(items, summary)
|
items = append(items, summary)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Lazy Sync] Asynchronously update local DB with fresh data from Kratos
|
||||||
|
// This ensures that member counts (which use local DB) eventually match reality
|
||||||
|
if h.UserRepo != nil {
|
||||||
|
go func(ids []service.KratosIdentity) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
for _, identity := range ids {
|
||||||
|
localUser := h.mapToLocalUser(identity)
|
||||||
|
_ = h.UserRepo.Update(ctx, localUser)
|
||||||
|
}
|
||||||
|
}(filtered)
|
||||||
|
}
|
||||||
|
|
||||||
return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
|
return c.JSON(userListResponse{Items: items, Limit: limit, Offset: offset, Total: total})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Fallback to Local DB if Kratos is down (Development only recommended)
|
// 2. Fallback to Local DB if Kratos is down
|
||||||
slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err)
|
slog.Warn("Kratos unavailable, falling back to local DB for user list", "error", err)
|
||||||
|
|
||||||
// Fetch from UserRepo
|
// Fetch from UserRepo
|
||||||
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search)
|
users, total, err := h.UserRepo.List(c.Context(), offset, limit, search, companyCode)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to fetch users from both kratos and local db"})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": "failed to fetch users from both kratos and local db"})
|
||||||
}
|
}
|
||||||
@@ -289,66 +310,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
// [New] Local DB Sync
|
// Fetch the newly created identity to ensure we have all traits
|
||||||
localUser := &domain.User{
|
|
||||||
ID: identityID,
|
|
||||||
Email: email,
|
|
||||||
Name: name,
|
|
||||||
Phone: normalizePhoneNumber(req.Phone),
|
|
||||||
AffiliationType: "internal",
|
|
||||||
CompanyCode: req.CompanyCode,
|
|
||||||
Department: req.Department,
|
|
||||||
Role: role,
|
|
||||||
Status: "active",
|
|
||||||
Metadata: req.Metadata,
|
|
||||||
}
|
|
||||||
if tenantID != "" {
|
|
||||||
localUser.TenantID = &tenantID
|
|
||||||
}
|
|
||||||
|
|
||||||
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
|
|
||||||
if h.UserRepo != nil {
|
|
||||||
go func(u *domain.User) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := h.UserRepo.Create(ctx, u); err != nil {
|
|
||||||
slog.Error("[UserHandler] Failed to sync user to local DB", "email", u.Email, "error", err)
|
|
||||||
}
|
|
||||||
}(localUser)
|
|
||||||
}
|
|
||||||
|
|
||||||
// [Keto] Sync relations via Outbox
|
|
||||||
if h.KetoOutboxRepo != nil {
|
|
||||||
// 1. Tenant Membership
|
|
||||||
if localUser.TenantID != nil {
|
|
||||||
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
|
||||||
Namespace: "Tenant",
|
|
||||||
Object: *localUser.TenantID,
|
|
||||||
Relation: "members",
|
|
||||||
Subject: "User:" + identityID,
|
|
||||||
Action: domain.KetoOutboxActionCreate,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
// 2. Role Specifics
|
|
||||||
if role == domain.RoleSuperAdmin {
|
|
||||||
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
|
||||||
Namespace: "System",
|
|
||||||
Object: "global",
|
|
||||||
Relation: "super_admins",
|
|
||||||
Subject: "User:" + identityID,
|
|
||||||
Action: domain.KetoOutboxActionCreate,
|
|
||||||
})
|
|
||||||
} else if role == domain.RoleTenantAdmin && localUser.TenantID != nil {
|
|
||||||
_ = h.KetoOutboxRepo.Create(c.Context(), &domain.KetoOutbox{
|
|
||||||
Namespace: "Tenant",
|
|
||||||
Object: *localUser.TenantID,
|
|
||||||
Relation: "admins",
|
|
||||||
Subject: "User:" + identityID,
|
|
||||||
Action: domain.KetoOutboxActionCreate,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
identity, err := h.KratosAdmin.GetIdentity(c.Context(), identityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
@@ -357,6 +319,28 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{"id": identityID, "initialPassword": generatedPassword})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [New] Local DB Sync - Ensure user exists in read-model
|
||||||
|
if h.UserRepo != nil {
|
||||||
|
localUser := h.mapToLocalUser(*identity)
|
||||||
|
|
||||||
|
// Sync to local DB
|
||||||
|
go func(u *domain.User, role string, tID *string) {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Use Update (upsert) instead of Create for robustness
|
||||||
|
if err := h.UserRepo.Update(ctx, u); err != nil {
|
||||||
|
slog.Error("[UserHandler] Failed to sync new user to local DB", "email", u.Email, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// [Keto] Sync relations via Outbox
|
||||||
|
if h.KetoOutboxRepo != nil {
|
||||||
|
h.syncKetoRole(ctx, u.ID, role, "", "", tID)
|
||||||
|
}
|
||||||
|
}(localUser, role, localUser.TenantID)
|
||||||
|
}
|
||||||
|
|
||||||
response := h.mapIdentitySummary(c.Context(), *identity)
|
response := h.mapIdentitySummary(c.Context(), *identity)
|
||||||
if generatedPassword != "" {
|
if generatedPassword != "" {
|
||||||
response.InitialPassword = generatedPassword
|
response.InitialPassword = generatedPassword
|
||||||
@@ -382,6 +366,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
return c.Status(fiber.StatusNotFound).JSON(fiber.Map{"error": "user not found"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Capture current local state for transition comparison
|
||||||
|
var oldRole string
|
||||||
|
var oldTenantID string
|
||||||
|
if h.UserRepo != nil {
|
||||||
|
if local, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && local != nil {
|
||||||
|
oldRole = local.Role
|
||||||
|
if local.TenantID != nil {
|
||||||
|
oldTenantID = *local.TenantID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// [New] Check access scope
|
// [New] Check access scope
|
||||||
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
requester, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
if requester != nil && requester.Role == domain.RoleTenantAdmin {
|
||||||
@@ -420,7 +416,12 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
traits["name"] = strings.TrimSpace(*req.Name)
|
traits["name"] = strings.TrimSpace(*req.Name)
|
||||||
}
|
}
|
||||||
if req.Phone != nil {
|
if req.Phone != nil {
|
||||||
traits["phone_number"] = normalizePhoneNumber(strings.TrimSpace(*req.Phone))
|
phone := normalizePhoneNumber(strings.TrimSpace(*req.Phone))
|
||||||
|
if phone == "" {
|
||||||
|
delete(traits, "phone_number")
|
||||||
|
} else {
|
||||||
|
traits["phone_number"] = phone
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if req.CompanyCode != nil {
|
if req.CompanyCode != nil {
|
||||||
code := strings.TrimSpace(*req.CompanyCode)
|
code := strings.TrimSpace(*req.CompanyCode)
|
||||||
@@ -471,92 +472,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
||||||
}
|
}
|
||||||
|
|
||||||
// [New] Local DB Sync
|
// [New] Local DB Sync - Sync synchronously to ensure immediate consistency for the caller
|
||||||
if h.UserRepo != nil {
|
if h.UserRepo != nil {
|
||||||
if localUser, err := h.UserRepo.FindByID(c.Context(), userID); err == nil && localUser != nil {
|
updatedLocalUser := h.mapToLocalUser(*updated)
|
||||||
oldRole := localUser.Role
|
|
||||||
oldTenantID := ""
|
ctx := context.Background() // Use request context if appropriate, but sync must finish
|
||||||
if localUser.TenantID != nil {
|
if err := h.UserRepo.Update(ctx, updatedLocalUser); err != nil {
|
||||||
oldTenantID = *localUser.TenantID
|
slog.Error("[UserHandler] Failed to sync updated user to local DB", "userID", updatedLocalUser.ID, "error", err)
|
||||||
}
|
|
||||||
|
|
||||||
if req.Name != nil {
|
|
||||||
localUser.Name = *req.Name
|
|
||||||
}
|
|
||||||
if req.Phone != nil {
|
|
||||||
localUser.Phone = normalizePhoneNumber(*req.Phone)
|
|
||||||
}
|
|
||||||
if req.CompanyCode != nil {
|
|
||||||
localUser.CompanyCode = *req.CompanyCode
|
|
||||||
if tenant, err := h.TenantService.GetTenantBySlug(c.Context(), *req.CompanyCode); err == nil && tenant != nil {
|
|
||||||
localUser.TenantID = &tenant.ID
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if req.Department != nil {
|
|
||||||
localUser.Department = *req.Department
|
|
||||||
}
|
|
||||||
if req.Role != nil {
|
|
||||||
localUser.Role = *req.Role
|
|
||||||
}
|
|
||||||
if req.Status != nil {
|
|
||||||
localUser.Status = *req.Status
|
|
||||||
}
|
|
||||||
if req.Metadata != nil {
|
|
||||||
localUser.Metadata = req.Metadata
|
|
||||||
}
|
|
||||||
|
|
||||||
// [SoT Policy] Kratos가 SoT이므로 로컬 DB 저장은 비동기 Read-Model 동기화로 처리합니다.
|
|
||||||
// [ReBAC Policy] 로컬 DB와 Keto 간의 정합성을 위해 Outbox를 함께 기록합니다.
|
|
||||||
go func(u *domain.User, rRole *string, oRole string, oTenantID string) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
if err := h.UserRepo.Update(ctx, u); err == nil {
|
|
||||||
// [Keto Sync on Role Change] via Outbox
|
|
||||||
if h.KetoOutboxRepo != nil && rRole != nil && *rRole != oRole {
|
|
||||||
uID := u.ID
|
|
||||||
newR := *rRole
|
|
||||||
if oRole == domain.RoleSuperAdmin {
|
|
||||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
||||||
Namespace: "System",
|
|
||||||
Object: "global",
|
|
||||||
Relation: "super_admins",
|
|
||||||
Subject: "User:" + uID,
|
|
||||||
Action: domain.KetoOutboxActionDelete,
|
|
||||||
})
|
|
||||||
} else if oRole == domain.RoleTenantAdmin && oTenantID != "" {
|
|
||||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
||||||
Namespace: "Tenant",
|
|
||||||
Object: oTenantID,
|
|
||||||
Relation: "admins",
|
|
||||||
Subject: "User:" + uID,
|
|
||||||
Action: domain.KetoOutboxActionDelete,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
if newR == domain.RoleSuperAdmin {
|
|
||||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
||||||
Namespace: "System",
|
|
||||||
Object: "global",
|
|
||||||
Relation: "super_admins",
|
|
||||||
Subject: "User:" + uID,
|
|
||||||
Action: domain.KetoOutboxActionCreate,
|
|
||||||
})
|
|
||||||
} else if newR == domain.RoleTenantAdmin && u.TenantID != nil {
|
|
||||||
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
|
||||||
Namespace: "Tenant",
|
|
||||||
Object: *u.TenantID,
|
|
||||||
Relation: "admins",
|
|
||||||
Subject: "User:" + uID,
|
|
||||||
Action: domain.KetoOutboxActionCreate,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
slog.Error("[UserHandler] Failed to sync user update to local DB", "userID", u.ID, "error", err)
|
|
||||||
}
|
|
||||||
}(localUser, req.Role, oldRole, oldTenantID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// [Keto Sync] asynchronously as it's less critical for immediate UI count
|
||||||
|
go h.syncKetoRole(context.Background(), updatedLocalUser.ID,
|
||||||
|
extractTraitString(updated.Traits, "grade"), oldRole, oldTenantID, updatedLocalUser.TenantID)
|
||||||
}
|
}
|
||||||
|
|
||||||
if req.Password != nil && *req.Password != "" {
|
if req.Password != nil && *req.Password != "" {
|
||||||
@@ -654,6 +581,97 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
|||||||
return summary
|
return summary
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *UserHandler) normalizePhoneNumber(phone string) string {
|
||||||
|
return normalizePhoneNumber(phone)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UserHandler) mapToLocalUser(identity service.KratosIdentity) *domain.User {
|
||||||
|
traits := identity.Traits
|
||||||
|
role := extractTraitString(traits, "grade")
|
||||||
|
if role == "" {
|
||||||
|
role = "user"
|
||||||
|
}
|
||||||
|
compCode := extractTraitString(traits, "companyCode")
|
||||||
|
|
||||||
|
user := &domain.User{
|
||||||
|
ID: identity.ID,
|
||||||
|
Email: extractTraitString(traits, "email"),
|
||||||
|
Name: extractTraitString(traits, "name"),
|
||||||
|
Phone: extractTraitString(traits, "phone_number"),
|
||||||
|
Role: role,
|
||||||
|
Status: normalizeStatus(identity.State),
|
||||||
|
CompanyCode: compCode,
|
||||||
|
Department: extractTraitString(traits, "department"),
|
||||||
|
AffiliationType: extractTraitString(traits, "affiliationType"),
|
||||||
|
CreatedAt: identity.CreatedAt,
|
||||||
|
UpdatedAt: identity.UpdatedAt,
|
||||||
|
}
|
||||||
|
|
||||||
|
if compCode != "" && h.TenantService != nil {
|
||||||
|
// Use a background context or a timeout-limited context for tenant lookup
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if tenant, err := h.TenantService.GetTenantBySlug(ctx, compCode); err == nil && tenant != nil {
|
||||||
|
user.TenantID = &tenant.ID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata
|
||||||
|
user.Metadata = make(domain.JSONMap)
|
||||||
|
coreTraits := map[string]bool{
|
||||||
|
"email": true, "name": true, "phone_number": true,
|
||||||
|
"grade": true, "companyCode": true, "department": true,
|
||||||
|
"affiliationType": true, "role": true, "tenant_id": true,
|
||||||
|
}
|
||||||
|
for k, v := range traits {
|
||||||
|
if !coreTraits[k] {
|
||||||
|
user.Metadata[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return user
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *UserHandler) syncKetoRole(ctx context.Context, userID, newRole, oldRole, oldTenantID string, newTenantID *string) {
|
||||||
|
// Remove old roles
|
||||||
|
if oldRole == domain.RoleSuperAdmin {
|
||||||
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: "System",
|
||||||
|
Object: "global",
|
||||||
|
Relation: "super_admins",
|
||||||
|
Subject: "User:" + userID,
|
||||||
|
Action: domain.KetoOutboxActionDelete,
|
||||||
|
})
|
||||||
|
} else if oldRole == domain.RoleTenantAdmin && oldTenantID != "" {
|
||||||
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: oldTenantID,
|
||||||
|
Relation: "admins",
|
||||||
|
Subject: "User:" + userID,
|
||||||
|
Action: domain.KetoOutboxActionDelete,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add new roles
|
||||||
|
if newRole == domain.RoleSuperAdmin {
|
||||||
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: "System",
|
||||||
|
Object: "global",
|
||||||
|
Relation: "super_admins",
|
||||||
|
Subject: "User:" + userID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
|
} else if newRole == domain.RoleTenantAdmin && newTenantID != nil {
|
||||||
|
_ = h.KetoOutboxRepo.Create(ctx, &domain.KetoOutbox{
|
||||||
|
Namespace: "Tenant",
|
||||||
|
Object: *newTenantID,
|
||||||
|
Relation: "admins",
|
||||||
|
Subject: "User:" + userID,
|
||||||
|
Action: domain.KetoOutboxActionCreate,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func extractTraitString(traits map[string]interface{}, key string) string {
|
func extractTraitString(traits map[string]interface{}, key string) string {
|
||||||
if traits == nil {
|
if traits == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package repository
|
|||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
@@ -14,7 +15,10 @@ type UserRepository interface {
|
|||||||
FindByID(ctx context.Context, id string) (*domain.User, error)
|
FindByID(ctx context.Context, id string) (*domain.User, error)
|
||||||
FindByIDs(ctx context.Context, ids []string) ([]domain.User, error)
|
FindByIDs(ctx context.Context, ids []string) ([]domain.User, error)
|
||||||
ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error)
|
ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error)
|
||||||
List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error)
|
List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error)
|
||||||
|
CountByTenant(ctx context.Context, tenantID string) (int64, error)
|
||||||
|
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
|
||||||
|
CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error)
|
||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,14 +73,111 @@ func (r *userRepository) ListByTenant(ctx context.Context, tenantID string) ([]d
|
|||||||
return users, nil
|
return users, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r *userRepository) List(ctx context.Context, offset, limit int, search string) ([]domain.User, int64, error) {
|
func (r *userRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||||
|
var count int64
|
||||||
|
err := r.db.WithContext(ctx).Model(&domain.User{}).Where("tenant_id = ?", tenantID).Count(&count).Error
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||||
|
type result struct {
|
||||||
|
TenantID string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
var results []result
|
||||||
|
if len(tenantIDs) == 0 {
|
||||||
|
return make(map[string]int64), nil
|
||||||
|
}
|
||||||
|
if err := r.db.WithContext(ctx).Model(&domain.User{}).
|
||||||
|
Select("tenant_id, count(*) as count").
|
||||||
|
Where("tenant_id IN ?", tenantIDs).
|
||||||
|
Group("tenant_id").
|
||||||
|
Find(&results).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := make(map[string]int64)
|
||||||
|
for _, res := range results {
|
||||||
|
if res.TenantID != "" {
|
||||||
|
counts[res.TenantID] = res.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ensure all requested tenant IDs are in the map, even if count is 0
|
||||||
|
for _, id := range tenantIDs {
|
||||||
|
if _, ok := counts[id]; !ok {
|
||||||
|
counts[id] = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) CountByCompanyCodes(ctx context.Context, codes []string) (map[string]int64, error) {
|
||||||
|
if len(codes) == 0 {
|
||||||
|
return make(map[string]int64), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Resolve IDs for these codes to support dual counting (slug or ID)
|
||||||
|
var tenants []domain.Tenant
|
||||||
|
_ = r.db.WithContext(ctx).Where("slug IN ?", codes).Find(&tenants).Error
|
||||||
|
|
||||||
|
idToSlug := make(map[string]string)
|
||||||
|
slugToNormalized := make(map[string]string)
|
||||||
|
|
||||||
|
for _, code := range codes {
|
||||||
|
slugToNormalized[strings.ToLower(strings.TrimSpace(code))] = code
|
||||||
|
}
|
||||||
|
for _, t := range tenants {
|
||||||
|
idToSlug[t.ID] = t.Slug
|
||||||
|
}
|
||||||
|
|
||||||
|
type result struct {
|
||||||
|
CompanyCode string
|
||||||
|
TenantID string
|
||||||
|
Count int64
|
||||||
|
}
|
||||||
|
var results []result
|
||||||
|
|
||||||
|
// Use a more comprehensive aggregation
|
||||||
|
err := r.db.WithContext(ctx).Model(&domain.User{}).
|
||||||
|
Select("company_code, tenant_id, count(*) as count").
|
||||||
|
Where("company_code IN ? OR tenant_id IN (SELECT id FROM tenants WHERE slug IN ?)", codes, codes).
|
||||||
|
Group("company_code, tenant_id").
|
||||||
|
Scan(&results).Error
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
counts := make(map[string]int64)
|
||||||
|
for _, res := range results {
|
||||||
|
var slug string
|
||||||
|
if res.CompanyCode != "" {
|
||||||
|
slug = res.CompanyCode
|
||||||
|
} else if res.TenantID != "" {
|
||||||
|
slug = idToSlug[res.TenantID]
|
||||||
|
}
|
||||||
|
|
||||||
|
if slug != "" {
|
||||||
|
normalizedSlug := strings.ToLower(strings.TrimSpace(slug))
|
||||||
|
counts[normalizedSlug] += res.Count
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return counts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *userRepository) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
|
||||||
var users []domain.User
|
var users []domain.User
|
||||||
var total int64
|
var total int64
|
||||||
db := r.db.WithContext(ctx).Model(&domain.User{})
|
db := r.db.WithContext(ctx).Model(&domain.User{})
|
||||||
|
|
||||||
|
if companyCode != "" {
|
||||||
|
db = db.Where("company_code = ?", companyCode)
|
||||||
|
}
|
||||||
|
|
||||||
if search != "" {
|
if search != "" {
|
||||||
searchTerm := "%" + search + "%"
|
searchTerm := "%" + search + "%"
|
||||||
db = db.Where("email LIKE ? OR name LIKE ?", searchTerm, searchTerm)
|
db = db.Where("(email LIKE ? OR name LIKE ? OR company_code LIKE ?)", searchTerm, searchTerm, searchTerm)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.Count(&total).Error; err != nil {
|
if err := db.Count(&total).Error; err != nil {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type TenantService interface {
|
type TenantService interface {
|
||||||
RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error)
|
RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error)
|
||||||
RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error)
|
RequestRegistration(ctx context.Context, name, slug, description string, domainName string, adminEmail string) (*domain.Tenant, error)
|
||||||
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
GetTenantByDomain(ctx context.Context, emailDomain string) (*domain.Tenant, error)
|
||||||
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
GetTenantBySlug(ctx context.Context, slug string) (*domain.Tenant, error)
|
||||||
@@ -89,7 +89,7 @@ func (s *tenantService) ListManageableTenants(ctx context.Context, userID string
|
|||||||
return s.repo.FindByIDs(ctx, allIDs)
|
return s.repo.FindByIDs(ctx, allIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, tenantType, description string, domains []string, parentID *string) (*domain.Tenant, error) {
|
||||||
// Validate Slug
|
// Validate Slug
|
||||||
if ok, msg := utils.ValidateSlug(slug); !ok {
|
if ok, msg := utils.ValidateSlug(slug); !ok {
|
||||||
return nil, errors.New(msg)
|
return nil, errors.New(msg)
|
||||||
@@ -106,7 +106,7 @@ func (s *tenantService) RegisterTenant(ctx context.Context, name, slug, descript
|
|||||||
|
|
||||||
// 2. Create Tenant
|
// 2. Create Tenant
|
||||||
tenant := &domain.Tenant{
|
tenant := &domain.Tenant{
|
||||||
Type: domain.TenantTypeCompany, // Default to COMPANY for manual registration
|
Type: tenantType,
|
||||||
Name: name,
|
Name: name,
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
Description: description,
|
Description: description,
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,21 @@ func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, sea
|
|||||||
return nil, 0, nil
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepoForTenant) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||||
|
args := m.Called(tenantID)
|
||||||
|
return int64(args.Int(0)), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepoForTenant) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||||
|
args := m.Called(tenantIDs)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(map[string]int64), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
||||||
mockRepo := new(MockTenantRepoForSvc)
|
mockRepo := new(MockTenantRepoForSvc)
|
||||||
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
mockOutbox := new(MockKetoOutboxRepositoryShared)
|
||||||
@@ -136,7 +151,7 @@ func TestTenantService_RegisterTenant_AutoVerify(t *testing.T) {
|
|||||||
mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil)
|
mockRepo.On("AddDomain", ctx, mock.Anything, "example.com", true).Return(nil)
|
||||||
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once()
|
mockRepo.On("FindBySlug", ctx, slug).Return(&domain.Tenant{ID: "t1", Slug: slug}, nil).Once()
|
||||||
|
|
||||||
tenant, err := svc.RegisterTenant(ctx, name, slug, "", domains, nil)
|
tenant, err := svc.RegisterTenant(ctx, name, slug, domain.TenantTypeCompany, "", domains, nil)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, tenant)
|
assert.NotNil(t, tenant)
|
||||||
assert.Equal(t, "t1", tenant.ID)
|
assert.Equal(t, "t1", tenant.ID)
|
||||||
|
|||||||
@@ -81,6 +81,20 @@ func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search
|
|||||||
return nil, 0, nil
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepository) CountByTenant(ctx context.Context, tenantID string) (int64, error) {
|
||||||
|
args := m.Called(tenantID)
|
||||||
|
return int64(args.Int(0)), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MockUserRepository) CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error) {
|
||||||
|
args := m.Called(tenantIDs)
|
||||||
|
if args.Get(0) == nil {
|
||||||
|
return nil, args.Error(1)
|
||||||
|
}
|
||||||
|
return args.Get(0).(map[string]int64), args.Error(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
type MockTenantRepository struct {
|
type MockTenantRepository struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
}
|
}
|
||||||
|
|||||||
118
docs/UI_DESIGN_POLICY.md
Normal file
118
docs/UI_DESIGN_POLICY.md
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
# UI 버튼 위치 및 정렬 정책 (UI Button Placement Policy)
|
||||||
|
|
||||||
|
본 문서는 Baron SSO 프로젝트 내 모든 프론트엔드 애플리케이션(`userfront`, `devfront`, `adminfront`)에서 일관된 사용자 경험(UX)을 제공하기 위한 UI 버튼 배치 및 정렬 가이드라인을 정의합니다. (관련 이슈: [#308](https://gitea.hmac.kr/baron/baron-sso/issues/308))
|
||||||
|
|
||||||
|
## 1. 버튼 종류별 위치 (Button Placement by Type)
|
||||||
|
|
||||||
|
버튼의 성격에 따라 다음과 같이 배치합니다.
|
||||||
|
|
||||||
|
* **Primary Action (주요 동작)**
|
||||||
|
* **예시**: 저장, 확인, 제출, 생성 등
|
||||||
|
* **위치**: 우측 하단 (Bottom Right) 또는 모달/다이얼로그의 우측 끝에 배치합니다. 사용자의 시선 흐름(좌에서 우, 위에서 아래)에 따라 최종 액션을 우측 하단에서 마무리하도록 유도합니다.
|
||||||
|
* **Secondary Action (보조 동작)**
|
||||||
|
* **예시**: 취소, 닫기, 이전으로 등
|
||||||
|
* **위치**: Primary 버튼의 바로 **좌측**에 배치합니다.
|
||||||
|
* **Destructive Action (파괴적 동작)**
|
||||||
|
* **예시**: 삭제, 초기화, 권한 해제 등
|
||||||
|
* **위치 및 스타일**: 붉은색(Red/Destructive) 스타일을 적용하여 시각적으로 명확히 구분합니다. Primary/Secondary 그룹과 물리적으로 분리하거나 (예: 좌측 끝 배치), Secondary 액션 위치에 두되 색상으로 강력한 경고를 줍니다.
|
||||||
|
|
||||||
|
## 2. 정렬 기준 (Alignment Rules)
|
||||||
|
|
||||||
|
* **폼(Form) 하단 버튼 그룹**
|
||||||
|
* **기본 정렬**: 우측 정렬 (Right-aligned). "취소"는 왼쪽, "저장"은 오른쪽에 위치합니다. `[ 취소 ] [ 저장 ]`
|
||||||
|
* **리스트 아이템 내부 액션 버튼**
|
||||||
|
* **기본 정렬**: 리스트/테이블의 각 행(Row) 우측 끝에 배치합니다.
|
||||||
|
* 버튼 개수가 많을 경우 (3개 이상), 툴팁이나 Dropdown 메뉴(예: 햄버거 버튼 또는 "더보기" 아이콘)로 숨겨 UI 복잡도를 낮춥니다.
|
||||||
|
|
||||||
|
## 3. 반응형 고려 (Responsive Design)
|
||||||
|
|
||||||
|
* **모바일 환경 (Mobile / Small Screens)**
|
||||||
|
* 화면 너비가 좁은 모바일 기기(예: `userfront` 앱 환경, `devfront`/`adminfront`의 모바일 뷰)에서는 버튼 그룹을 **Full Width (화면 가득 채움)**로 변경하여 터치 영역을 확보합니다.
|
||||||
|
* 여러 개의 버튼이 있는 경우 세로로 스택(Stack)하며, **Primary Action을 맨 위**에, Secondary Action을 그 아래에 배치합니다.
|
||||||
|
* *데스크탑*: `[ 취소 ] [ 확인 ]`
|
||||||
|
* *모바일*:
|
||||||
|
```
|
||||||
|
[ 확인 ]
|
||||||
|
[ 취소 ]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. 로딩 및 피드백 (Loading & Feedback)
|
||||||
|
|
||||||
|
* **중복 제출 방지**: 폼 전송이나 API 호출을 발생시키는 버튼을 클릭하면 즉각적으로 버튼을 비활성화(Disabled) 상태로 변경하여 다중 클릭을 방지합니다.
|
||||||
|
* **로딩 스피너**: 버튼 내부에 로딩 스피너(Spinner)를 표시하여 사용자에게 진행 상황을 시각적으로 알립니다.
|
||||||
|
* **스켈레톤 로딩(Skeleton Loading)**: 화면 진입 시 전체 데이터를 로딩해야 하는 경우, 무의미한 빈 화면(빈 공간) 대신 스켈레톤 UI를 사용하여 로딩 중임을 직관적으로 알리고 체감 대기 시간을 줄입니다.
|
||||||
|
* **작업 결과 안내**: 성공, 실패 등의 결과는 Toast 메시지 (혹은 스낵바)를 통해 화면 하단/상단에 일시적으로 노출하여 사용자가 흐름을 끊지 않고도 인지할 수 있게 돕습니다.
|
||||||
|
|
||||||
|
## 5. 빈 상태 처리 (Empty State)
|
||||||
|
|
||||||
|
* **빈 목록 안내**: 테이블이나 리스트에 표시할 항목이 없는 경우 단순히 빈 화면으로 두지 않고 중앙 정렬된 아이콘이나 일러스트와 함께 "데이터가 없습니다." 등의 명확한 문구를 표시합니다.
|
||||||
|
* **콜 투 액션(Call to Action)**: 데이터가 비어 있는 경우 생성 버튼(Primary Action)을 빈 상태 안내 영역 아래에 배치하여 사용자가 즉시 데이터를 추가할 수 있도록 유도합니다.
|
||||||
|
|
||||||
|
## 6. 오류 표시 (Error Handling)
|
||||||
|
|
||||||
|
* **인라인(Inline) 오류**: 폼(Form)의 유효성 검사에서 실패한 경우, 각 입력 필드 바로 아래에 붉은색 텍스트로 실패 원인을 명확하게 표시합니다.
|
||||||
|
* **포커스 이동**: 제출 버튼 클릭 시 오류가 있는 첫 번째 입력 필드로 자동 스크롤 하거나 포커스(Focus)를 이동시켜 수정이 용이하게 합니다.
|
||||||
|
|
||||||
|
## 7. 접근성 (Accessibility - a11y)
|
||||||
|
|
||||||
|
* **포커스 링(Focus Ring)**: 키보드를 통해 탐색(Tab)하는 사용자를 위해 버튼, 텍스트 입력창 등에 포커스가 갈 경우 외곽선을 명확히 렌더링(예: 파란색 테두리 등)해야 합니다. `outline: none`을 무분별하게 사용하지 않습니다.
|
||||||
|
* **대체 텍스트**: 텍스트 없이 아이콘만 존재하는 버튼(예: X 형태의 닫기 버튼)의 경우 반드시 `aria-label` 속성(또는 Flutter의 `Semantics`)을 사용하여 스크린 리더 사용자가 해당 버튼의 역할을 알 수 있게 해야 합니다.
|
||||||
|
|
||||||
|
## 8. 프론트엔드 환경별 구현 가이드 (Implementation Guide)
|
||||||
|
|
||||||
|
현재 운영 중인 프론트엔드 환경에 맞춘 구현 가이드라인입니다.
|
||||||
|
|
||||||
|
### 8.1. React 환경 (`devfront`, `adminfront`)
|
||||||
|
Tailwind CSS 기반의 컴포넌트를 사용하여 아래와 같이 구현합니다.
|
||||||
|
|
||||||
|
* **버튼 그룹 우측 정렬 (데스크탑)**: `flex justify-end gap-2`
|
||||||
|
* **반응형 (모바일 세로 배치, 데스크탑 가로 배치)**: `flex flex-col-reverse sm:flex-row sm:justify-end gap-2`
|
||||||
|
*(참고: `flex-col-reverse`를 사용하면 코드 상 먼저 작성된 취소 버튼이 모바일에서는 아래로, 나중에 작성된 확인 버튼이 위로 올라가게 배치할 수 있습니다.)*
|
||||||
|
* **코드 예시**:
|
||||||
|
```tsx
|
||||||
|
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-2 mt-4">
|
||||||
|
<Button variant="outline" onClick={onCancel} disabled={isLoading}>취소</Button>
|
||||||
|
<Button variant="default" onClick={onSave} disabled={isLoading}>
|
||||||
|
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
저장
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 8.2. Flutter 환경 (`userfront`)
|
||||||
|
Flutter 프레임워크를 사용하는 환경에서는 화면 너비에 따라 위젯 구성을 동적으로 처리해야 합니다.
|
||||||
|
|
||||||
|
* **폼 하단 정렬**: `Row` 위젯과 `MainAxisAlignment.end` 사용.
|
||||||
|
* **반응형 대응**: 화면 너비(MediaQuery)에 따라 `Row`를 전체 너비를 채우는 `Column`으로 스위칭하거나, `OverflowBar` 위젯 등을 활용할 수 있습니다.
|
||||||
|
* **코드 예시**:
|
||||||
|
```dart
|
||||||
|
// 데스크탑/태블릿용 (우측 정렬)
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
TextButton(onPressed: isLoading ? null : onCancel, child: const Text('취소')),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: isLoading ? null : onSave,
|
||||||
|
child: isLoading
|
||||||
|
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Text('확인')
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
// 모바일용 (전체 너비 세로 배치)
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: isLoading ? null : onSave,
|
||||||
|
child: isLoading
|
||||||
|
? const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2))
|
||||||
|
: const Text('확인')
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextButton(onPressed: isLoading ? null : onCancel, child: const Text('취소')),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
```
|
||||||
87
docs/keto-rebac-namespaces-diagram.md
Normal file
87
docs/keto-rebac-namespaces-diagram.md
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# Ory Keto (ReBAC) 네임스페이스 및 권한 상속 다이어그램
|
||||||
|
|
||||||
|
이 문서는 `docker/ory/keto/namespaces.ts`에 정의된 Baron SSO 프로젝트의 Ory Keto(ReBAC) 네임스페이스와 각 네임스페이스 간의 권한 상속(Permits) 및 관계(Relations)를 나타내는 Mermaid 다이어그램입니다.
|
||||||
|
|
||||||
|
## 네임스페이스 설계 구조
|
||||||
|
|
||||||
|
Ory Keto는 다음과 같은 4개의 주요 네임스페이스로 구성되어 있습니다:
|
||||||
|
|
||||||
|
1. **`User`**: 권한의 주체가 되는 기본 사용자.
|
||||||
|
2. **`System`**: 시스템 전역 권한 (최고 관리자 및 인증된 사용자).
|
||||||
|
3. **`Tenant`**: 조직/회사/부서 등 모든 형태의 격리 공간. 상위-하위(`parents`) 계층 구조를 가짐.
|
||||||
|
4. **`RelyingParty`**: OIDC 클라이언트(앱/리소스). 특정 `Tenant`에 종속될 수 있음.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Mermaid 다이어그램
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
classDiagram
|
||||||
|
class User {
|
||||||
|
<<Namespace>>
|
||||||
|
}
|
||||||
|
|
||||||
|
class System {
|
||||||
|
<<Namespace>>
|
||||||
|
-- Relations --
|
||||||
|
super_admins: User[]
|
||||||
|
authenticated_users: User[]
|
||||||
|
-- Permits --
|
||||||
|
manage_all: super_admins
|
||||||
|
}
|
||||||
|
|
||||||
|
class Tenant {
|
||||||
|
<<Namespace>>
|
||||||
|
-- Relations --
|
||||||
|
owners: User[]
|
||||||
|
admins: User[] | SubjectSet~Tenant, owners~
|
||||||
|
members: User[]
|
||||||
|
parents: Tenant[]
|
||||||
|
-- Permits --
|
||||||
|
view: members OR admins OR parents.view
|
||||||
|
manage: admins OR parents.manage
|
||||||
|
create_subtenant: manage
|
||||||
|
}
|
||||||
|
|
||||||
|
class RelyingParty {
|
||||||
|
<<Namespace>>
|
||||||
|
-- Relations --
|
||||||
|
admins: User[]
|
||||||
|
parents: Tenant[]
|
||||||
|
access: User[] | SubjectSet~Tenant, members~ | SubjectSet~System, authenticated_users~
|
||||||
|
-- Permits --
|
||||||
|
view: admins OR parents.view
|
||||||
|
manage: admins OR parents.manage
|
||||||
|
access: access OR manage
|
||||||
|
}
|
||||||
|
|
||||||
|
%% Relationship lines indicating references (SubjectSets or Direct inclusion)
|
||||||
|
User ..> System : super_admins, authenticated_users
|
||||||
|
User ..> Tenant : owners, admins, members
|
||||||
|
User ..> RelyingParty : admins, access
|
||||||
|
|
||||||
|
Tenant "1" --> "*" Tenant : parents (상위 조직 상속)
|
||||||
|
Tenant ..> RelyingParty : parents (소유권 상속)
|
||||||
|
Tenant ..> RelyingParty : access (members 접근 권한)
|
||||||
|
|
||||||
|
System ..> RelyingParty : access (authenticated_users)
|
||||||
|
|
||||||
|
%% Styling
|
||||||
|
style User fill:#e1f5fe,stroke:#333,stroke-width:2px
|
||||||
|
style System fill:#ffe0b2,stroke:#333,stroke-width:2px
|
||||||
|
style Tenant fill:#fff9c4,stroke:#333,stroke-width:2px
|
||||||
|
style RelyingParty fill:#e1bee7,stroke:#333,stroke-width:2px
|
||||||
|
```
|
||||||
|
|
||||||
|
### 권한 평가(Permit) 상세 로직 설명
|
||||||
|
|
||||||
|
- **Tenant (테넌트/조직):**
|
||||||
|
- `view` (조회): 테넌트의 일반 멤버(`members`), 관리자(`admins`), 그리고 **상위 테넌트(parents)에서 조회 권한을 가진 자**가 조회할 수 있습니다.
|
||||||
|
- `manage` (관리): 테넌트의 관리자(`admins`), 그리고 **상위 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다.
|
||||||
|
- _참고:_ 조직장(`owners`)은 자동으로 `admins` 집합(SubjectSet)에 포함됩니다.
|
||||||
|
|
||||||
|
- **RelyingParty (OIDC 앱):**
|
||||||
|
- `view` (조회): 앱의 직접 관리자(`admins`) 또는 **이 앱을 소유한 테넌트(parents)에서 조회 권한을 가진 자**가 조회할 수 있습니다.
|
||||||
|
- `manage` (관리): 앱의 직접 관리자(`admins`) 또는 **이 앱을 소유한 테넌트(parents)에서 관리 권한을 가진 자**가 관리할 수 있습니다.
|
||||||
|
- `access` (접근/로그인 가능 여부): 이 앱에 직접 접근 권한을 부여받은 유저/그룹(`access`), 또는 앱을 관리할 수 있는 권한(`manage`)을 가진 사람이 접근할 수 있습니다.
|
||||||
|
- _접근 대상(access)은 특정 유저, 특정 테넌트의 전 멤버, 또는 전역 인증된 유저(System:authenticated_users)가 될 수 있습니다._
|
||||||
@@ -178,7 +178,9 @@ subtitle = "Subtitle"
|
|||||||
subtitle = "Subtitle"
|
subtitle = "Subtitle"
|
||||||
|
|
||||||
[msg.admin.tenants.members]
|
[msg.admin.tenants.members]
|
||||||
empty = "Empty"
|
empty = "No members found."
|
||||||
|
desc = "View the list of users belonging to this organization."
|
||||||
|
limit_notice = "Showing members from the first 10 descendant organizations due to size limits."
|
||||||
|
|
||||||
[msg.admin.tenants.registry]
|
[msg.admin.tenants.registry]
|
||||||
count = "Count"
|
count = "Count"
|
||||||
@@ -836,6 +838,11 @@ select_placeholder = "Select Placeholder"
|
|||||||
|
|
||||||
[ui.admin.tenants.members]
|
[ui.admin.tenants.members]
|
||||||
title = "Tenant Members ({{count}})"
|
title = "Tenant Members ({{count}})"
|
||||||
|
direct_label = "Direct"
|
||||||
|
total_label = "Total"
|
||||||
|
list_title = "Member Management"
|
||||||
|
direct = "Direct Members"
|
||||||
|
descendants = "Descendant Members"
|
||||||
|
|
||||||
[ui.admin.tenants.members.table]
|
[ui.admin.tenants.members.table]
|
||||||
email = "EMAIL"
|
email = "EMAIL"
|
||||||
|
|||||||
@@ -179,6 +179,8 @@ subtitle = "필수 정보만 입력해도 생성 가능합니다. Slug는 없으
|
|||||||
|
|
||||||
[msg.admin.tenants.members]
|
[msg.admin.tenants.members]
|
||||||
empty = "소속된 사용자가 없습니다."
|
empty = "소속된 사용자가 없습니다."
|
||||||
|
desc = "조직에 소속된 사용자 목록을 확인합니다."
|
||||||
|
limit_notice = "하위 조직이 많아 상위 10개 조직의 멤버만 표시됩니다."
|
||||||
|
|
||||||
[msg.admin.tenants.registry]
|
[msg.admin.tenants.registry]
|
||||||
count = "총 {{count}}개 테넌트"
|
count = "총 {{count}}개 테넌트"
|
||||||
@@ -836,6 +838,11 @@ select_placeholder = "테넌트를 선택하세요"
|
|||||||
|
|
||||||
[ui.admin.tenants.members]
|
[ui.admin.tenants.members]
|
||||||
title = "Tenant Members ({{count}})"
|
title = "Tenant Members ({{count}})"
|
||||||
|
direct_label = "소속"
|
||||||
|
total_label = "전체"
|
||||||
|
list_title = "구성원 관리"
|
||||||
|
direct = "소속 멤버"
|
||||||
|
descendants = "하위 조직 멤버"
|
||||||
|
|
||||||
[ui.admin.tenants.members.table]
|
[ui.admin.tenants.members.table]
|
||||||
email = "EMAIL"
|
email = "EMAIL"
|
||||||
|
|||||||
Reference in New Issue
Block a user