1
0
forked from baron/baron-sso

테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거

This commit is contained in:
2026-05-13 18:05:51 +09:00
parent a4d707d4d8
commit 5e7b7b878c
85 changed files with 4808 additions and 734 deletions

View File

@@ -17,6 +17,7 @@
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.66.8",
"@tanstack/react-query-devtools": "^5.66.8",
"@tanstack/react-virtual": "^3.13.24",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -3290,6 +3291,33 @@
"react": "^18 || ^19"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.13.24",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.13.24.tgz",
"integrity": "sha512-aIJvz5OSkhNIhZIpYivrxrPTKYsjW9Uzy+sP/mx0S3sev2HyvPb7xmjbYvokzEpfgYHy/HjzJ2zFAETuUfgCpg==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.14.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.14.0",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.14.0.tgz",
"integrity": "sha512-JLANqGy/D6k4Ujmh8Tr25lGimuOXNiaVyXaCAZS0W+1390sADdGnyUdSWNIfd49gebtIxGMij4IktRVzrdr12Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",

View File

@@ -28,6 +28,7 @@
"@radix-ui/react-switch": "^1.1.2",
"@tanstack/react-query": "^5.66.8",
"@tanstack/react-query-devtools": "^5.66.8",
"@tanstack/react-virtual": "^3.13.24",
"axios": "^1.7.9",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -21,4 +21,26 @@ describe("admin routes", () => {
expect(matches?.at(-1)?.route.path).toBe("system/projections/users");
});
it("keeps protected admin pages behind an auth guard before mounting the layout", () => {
const rootRoute = adminRoutes.find((route) => route.path === "/");
const protectedShellRoute = rootRoute?.children?.[0];
expect(getRouteElementName(rootRoute?.element)).toBe("AuthGuard");
expect(getRouteElementName(protectedShellRoute?.element)).toBe("AppLayout");
expect(protectedShellRoute?.children?.at(0)?.index).toBe(true);
});
});
function getRouteElementName(element: unknown) {
if (
typeof element === "object" &&
element !== null &&
"type" in element &&
typeof element.type === "function"
) {
return element.type.name;
}
return undefined;
}

View File

@@ -5,6 +5,7 @@ import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
import AuditLogsPage from "../features/audit/AuditLogsPage";
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
import AuthGuard from "../features/auth/AuthGuard";
import AuthPage from "../features/auth/AuthPage";
import LoginPage from "../features/auth/LoginPage";
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
@@ -34,34 +35,39 @@ export const adminRoutes: RouteObject[] = [
},
{
path: "/",
element: <AppLayout />,
element: <AuthGuard />,
children: [
{ index: true, element: <GlobalOverviewPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
element: <AppLayout />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
{ index: true, element: <GlobalOverviewPage /> },
{ path: "audit-logs", element: <AuditLogsPage /> },
{ path: "auth", element: <AuthPage /> },
{ path: "users", element: <UserListPage /> },
{ path: "users/new", element: <UserCreatePage /> },
{ path: "users/:id", element: <UserDetailPage /> },
{ path: "tenants", element: <TenantListPage /> },
{ path: "tenants/new", element: <TenantCreatePage /> },
{
path: "tenants/:tenantId",
element: <TenantDetailPage />,
children: [
{ index: true, element: <TenantProfilePage /> },
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
{ path: "organization", element: <TenantUserGroupsTab /> },
{ path: "schema", element: <TenantSchemaPage /> },
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
],
},
{
path: "tenants/:tenantId/organization/:id",
element: <TenantUserGroupsTab />,
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ path: "system/projections/users", element: <UserProjectionPage /> },
],
},
{
path: "tenants/:tenantId/organization/:id",
element: <TenantUserGroupsTab />,
},
{ path: "api-keys", element: <ApiKeyListPage /> },
{ path: "api-keys/new", element: <ApiKeyCreatePage /> },
{ path: "system/projections/users", element: <UserProjectionPage /> },
],
},
];

View File

@@ -20,6 +20,7 @@ import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
type ShellTranslator,
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
@@ -53,6 +54,48 @@ const staticNavItems: NavItem[] = [
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
];
type SessionStatusProps = {
expiresAtSec?: number | null;
t: ShellTranslator;
};
function useSessionStatus({ expiresAtSec, t }: SessionStatusProps) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, []);
return buildShellSessionStatus({ expiresAtSec, nowMs, t });
}
function SessionStatusBadge(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return (
<span
className={[
shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionStatus.text}
</span>
);
}
function SessionStatusText(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return <>{sessionStatus.text}</>;
}
function AppLayout() {
const auth = useAuth();
const location = useLocation();
@@ -76,17 +119,6 @@ function AppLayout() {
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
readShellSessionExpiryEnabled,
);
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, []);
const {
data: profile,
isLoading: isProfileLoading,
@@ -396,12 +428,6 @@ function AppLayout() {
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
});
const profileRoleKey = mockRoleOverride || profile?.role || "user";
const sessionStatus = buildShellSessionStatus({
expiresAtSec: auth.user?.expires_at,
nowMs,
t,
});
const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
@@ -525,14 +551,10 @@ function AppLayout() {
: t("ui.common.theme_dark", "Dark")}
</button>
{isSessionExpiryEnabled ? (
<span
className={[
shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionStatus.text}
</span>
<SessionStatusBadge
expiresAtSec={auth.user?.expires_at}
t={t}
/>
) : null}
<div className="relative" ref={profileMenuRef}>
<button
@@ -591,12 +613,14 @@ function AppLayout() {
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled
? sessionStatus.text
: t(
"ui.dev.session.disabled",
"세션 만료 비활성화",
)}
{isSessionExpiryEnabled ? (
<SessionStatusText
expiresAtSec={auth.user?.expires_at}
t={t}
/>
) : (
t("ui.dev.session.disabled", "세션 만료 비활성화")
)}
</p>
</div>
<button

View File

@@ -28,58 +28,7 @@ import {
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
const AVAILABLE_SCOPES = [
{
id: "audit:read",
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
labelFallback: "감사 로그 조회",
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.",
},
{
id: "audit:write",
labelKey: "ui.admin.api_keys.scopes.audit_write.title",
labelFallback: "감사 로그 생성",
descKey: "msg.admin.api_keys.scopes.audit_write.desc",
descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.",
},
{
id: "user:read",
labelKey: "ui.admin.api_keys.scopes.user_read.title",
labelFallback: "사용자 조회",
descKey: "msg.admin.api_keys.scopes.user_read.desc",
descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.",
},
{
id: "user:write",
labelKey: "ui.admin.api_keys.scopes.user_write.title",
labelFallback: "사용자 관리",
descKey: "msg.admin.api_keys.scopes.user_write.desc",
descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.",
},
{
id: "tenant:read",
labelKey: "ui.admin.api_keys.scopes.tenant_read.title",
labelFallback: "테넌트 조회",
descKey: "msg.admin.api_keys.scopes.tenant_read.desc",
descFallback: "등록된 모든 조직 정보를 조회합니다.",
},
{
id: "tenant:write",
labelKey: "ui.admin.api_keys.scopes.tenant_write.title",
labelFallback: "테넌트 관리",
descKey: "msg.admin.api_keys.scopes.tenant_write.desc",
descFallback: "테넌트 정보를 직접 제어합니다.",
},
{
id: "org-context:read",
labelKey: "ui.admin.api_keys.scopes.org_context_read.title",
labelFallback: "조직 Context 조회",
descKey: "msg.admin.api_keys.scopes.org_context_read.desc",
descFallback: "외부 연동앱이 OrgFront SSOT 조직 JSON을 조회합니다.",
},
];
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
function ApiKeyCreatePage() {
const navigate = useNavigate();
@@ -305,7 +254,7 @@ function ApiKeyCreatePage() {
</h3>
</div>
<div className="grid gap-4 sm:grid-cols-2">
{AVAILABLE_SCOPES.map((scope) => {
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
const isSelected = selectedScopes.includes(scope.id);
return (
<button

View File

@@ -0,0 +1,105 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter } from "react-router-dom";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
fetchApiKeys,
rotateApiKeySecret,
updateApiKeyScopes,
} from "../../lib/adminApi";
import ApiKeyListPage from "./ApiKeyListPage";
vi.mock("../../lib/adminApi", () => ({
fetchApiKeys: vi.fn(async () => ({
items: [
{
id: "api-key-id",
name: "org-context-client",
client_id: "client-id-stable",
scopes: ["audit:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
},
],
total: 1,
})),
deleteApiKey: vi.fn(async () => undefined),
updateApiKeyScopes: vi.fn(async () => ({
id: "api-key-id",
name: "org-context-client",
client_id: "client-id-stable",
scopes: ["audit:read", "org-context:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
})),
rotateApiKeySecret: vi.fn(async () => ({
apiKey: {
id: "api-key-id",
name: "org-context-client",
client_id: "client-id-stable",
scopes: ["audit:read"],
status: "active",
createdAt: "2026-05-13T00:00:00Z",
},
clientSecret: "rotated-secret",
})),
}));
function renderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
});
return render(
<QueryClientProvider client={queryClient}>
<MemoryRouter>
<ApiKeyListPage />
</MemoryRouter>
</QueryClientProvider>,
);
}
describe("ApiKeyListPage", () => {
beforeEach(() => {
vi.clearAllMocks();
vi.spyOn(window, "confirm").mockReturnValue(true);
});
it("updates scopes without changing client_id", async () => {
const user = userEvent.setup();
renderPage();
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /권한 수정/ }));
await user.click(screen.getByRole("button", { name: /조직 Context 조회/ }));
await user.click(screen.getByRole("button", { name: /권한 저장/ }));
await waitFor(() => {
expect(updateApiKeyScopes).toHaveBeenCalledWith("api-key-id", {
scopes: expect.arrayContaining(["audit:read", "org-context:read"]),
});
});
});
it("rotates only the secret and shows the one-time secret", async () => {
const user = userEvent.setup();
renderPage();
expect(await screen.findByText("client-id-stable")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Secret 재발급/ }));
await waitFor(() => {
expect(rotateApiKeySecret).toHaveBeenCalledWith("api-key-id");
});
expect(
await screen.findByDisplayValue("rotated-secret"),
).toBeInTheDocument();
expect(fetchApiKeys).toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,16 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import type { AxiosError } from "axios";
import { Key, Plus, RefreshCw, Trash2 } from "lucide-react";
import {
Copy,
Edit3,
Key,
Plus,
RefreshCw,
RotateCcw,
Save,
Trash2,
} from "lucide-react";
import * as React from "react";
import { Link } from "react-router-dom";
import { Badge } from "../../components/ui/badge";
import { Button } from "../../components/ui/button";
@@ -11,6 +21,15 @@ import {
CardHeader,
CardTitle,
} from "../../components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "../../components/ui/dialog";
import { Input } from "../../components/ui/input";
import {
Table,
TableBody,
@@ -19,10 +38,27 @@ import {
TableHeader,
TableRow,
} from "../../components/ui/table";
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
import {
type ApiKeySummary,
deleteApiKey,
fetchApiKeys,
rotateApiKeySecret,
updateApiKeyScopes,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import { cn } from "../../lib/utils";
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
function ApiKeyListPage() {
const [editingKey, setEditingKey] = React.useState<ApiKeySummary | null>(
null,
);
const [draftScopes, setDraftScopes] = React.useState<string[]>([]);
const [rotatedSecret, setRotatedSecret] = React.useState<{
key: ApiKeySummary;
clientSecret: string;
} | null>(null);
const query = useQuery({
queryKey: ["api-keys", { limit: 50, offset: 0 }],
queryFn: () => fetchApiKeys(50, 0),
@@ -35,6 +71,27 @@ function ApiKeyListPage() {
},
});
const updateScopesMutation = useMutation({
mutationFn: ({ id, scopes }: { id: string; scopes: string[] }) =>
updateApiKeyScopes(id, { scopes }),
onSuccess: () => {
setEditingKey(null);
setDraftScopes([]);
query.refetch();
},
});
const rotateSecretMutation = useMutation({
mutationFn: (id: string) => rotateApiKeySecret(id),
onSuccess: (data) => {
setRotatedSecret({
key: data.apiKey,
clientSecret: data.clientSecret,
});
query.refetch();
},
});
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
?.data?.error;
const fallbackError =
@@ -62,6 +119,44 @@ function ApiKeyListPage() {
deleteMutation.mutate(id);
};
const openScopeEditor = (key: ApiKeySummary) => {
setEditingKey(key);
setDraftScopes(key.scopes);
};
const toggleDraftScope = (scopeId: string) => {
setDraftScopes((current) =>
current.includes(scopeId)
? current.filter((scope) => scope !== scopeId)
: [...current, scopeId],
);
};
const saveScopes = () => {
if (!editingKey || draftScopes.length === 0) return;
updateScopesMutation.mutate({ id: editingKey.id, scopes: draftScopes });
};
const handleRotateSecret = (key: ApiKeySummary) => {
if (
!window.confirm(
t(
"msg.admin.api_keys.list.rotate_confirm",
'API 키 "{{name}}"의 Secret을 재발급할까요? 기존 Secret은 더 이상 사용할 수 없습니다.',
{ name: key.name },
),
)
) {
return;
}
rotateSecretMutation.mutate(key.id);
};
const copyRotatedSecret = () => {
if (!rotatedSecret) return;
navigator.clipboard.writeText(rotatedSecret.clientSecret);
};
return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
@@ -189,15 +284,40 @@ function ApiKeyListPage() {
: t("ui.common.never", "Never")}
</TableCell>
<TableCell className="text-right">
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
<div className="flex flex-wrap justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => openScopeEditor(key)}
>
<Edit3 size={14} />
{t(
"ui.admin.api_keys.list.edit_scopes",
"권한 수정",
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleRotateSecret(key)}
disabled={rotateSecretMutation.isPending}
>
<RotateCcw size={14} />
{t(
"ui.admin.api_keys.list.rotate_secret",
"Secret 재발급",
)}
</Button>
<Button
variant="outline"
size="sm"
onClick={() => handleDelete(key.id, key.name)}
disabled={deleteMutation.isPending}
>
<Trash2 size={14} />
{t("ui.common.delete", "삭제")}
</Button>
</div>
</TableCell>
</TableRow>
))}
@@ -207,6 +327,137 @@ function ApiKeyListPage() {
</div>
</CardContent>
</Card>
<Dialog
open={editingKey !== null}
onOpenChange={() => setEditingKey(null)}
>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>
{t("ui.admin.api_keys.list.edit_scopes", "권한 수정")}
</DialogTitle>
<DialogDescription>
{editingKey
? t(
"msg.admin.api_keys.list.edit_scopes_desc",
"{{clientId}}의 CLIENT_ID는 유지하고 권한만 변경합니다.",
{ clientId: editingKey.client_id },
)
: null}
</DialogDescription>
</DialogHeader>
<div className="grid gap-3 sm:grid-cols-2">
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
const isSelected = draftScopes.includes(scope.id);
return (
<button
key={scope.id}
type="button"
onClick={() => toggleDraftScope(scope.id)}
className={cn(
"flex flex-col items-start gap-2 rounded-lg border-2 p-4 text-left transition-all",
isSelected
? "border-primary bg-primary/5"
: "border-border bg-card hover:border-muted-foreground/30",
)}
>
<span className="font-bold text-sm">
{t(scope.labelKey, scope.labelFallback)}
</span>
<span className="text-[11px] text-muted-foreground leading-snug">
{t(scope.descKey, scope.descFallback)}
</span>
<code className="text-[9px] font-mono opacity-60 uppercase tracking-tighter">
ID: {scope.id}
</code>
</button>
);
})}
</div>
{draftScopes.length === 0 && (
<p className="text-sm text-destructive">
{t(
"msg.admin.api_keys.create.scope_required",
"최소 하나 이상의 권한을 선택해야 합니다.",
)}
</p>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setEditingKey(null)}>
{t("ui.common.cancel", "취소")}
</Button>
<Button
onClick={saveScopes}
disabled={
updateScopesMutation.isPending || draftScopes.length === 0
}
>
<Save size={16} />
{t("ui.admin.api_keys.list.save_scopes", "권한 저장")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog
open={rotatedSecret !== null}
onOpenChange={() => setRotatedSecret(null)}
>
<DialogContent>
<DialogHeader>
<DialogTitle>
{t(
"ui.admin.api_keys.list.rotate_secret_done",
"Secret 재발급 완료",
)}
</DialogTitle>
<DialogDescription>
{t(
"msg.admin.api_keys.list.rotate_secret_notice",
"새 Secret은 지금 한 번만 표시됩니다. CLIENT_ID는 변경되지 않았습니다.",
)}
</DialogDescription>
</DialogHeader>
{rotatedSecret && (
<div className="space-y-4">
<div className="space-y-2">
<p className="text-xs font-bold text-muted-foreground">
CLIENT ID
</p>
<code className="block rounded-md bg-muted px-3 py-2 text-sm">
{rotatedSecret.key.client_id}
</code>
</div>
<div className="space-y-2">
<p className="text-xs font-bold text-muted-foreground">
X-Baron-Key-Secret
</p>
<div className="relative">
<Input
readOnly
value={rotatedSecret.clientSecret}
className="font-mono pr-12"
/>
<Button
variant="ghost"
size="icon"
className="absolute right-1 top-1/2 -translate-y-1/2"
onClick={copyRotatedSecret}
>
<Copy size={16} />
</Button>
</div>
</div>
</div>
)}
<DialogFooter>
<Button onClick={() => setRotatedSecret(null)}>
{t("ui.common.confirm", "확인")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,59 @@
export type ApiKeyScopeOption = {
id: string;
labelKey: string;
labelFallback: string;
descKey: string;
descFallback: string;
};
export const AVAILABLE_API_KEY_SCOPES: ApiKeyScopeOption[] = [
{
id: "audit:read",
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
labelFallback: "감사 로그 조회",
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.",
},
{
id: "audit:write",
labelKey: "ui.admin.api_keys.scopes.audit_write.title",
labelFallback: "감사 로그 생성",
descKey: "msg.admin.api_keys.scopes.audit_write.desc",
descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.",
},
{
id: "user:read",
labelKey: "ui.admin.api_keys.scopes.user_read.title",
labelFallback: "사용자 조회",
descKey: "msg.admin.api_keys.scopes.user_read.desc",
descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.",
},
{
id: "user:write",
labelKey: "ui.admin.api_keys.scopes.user_write.title",
labelFallback: "사용자 관리",
descKey: "msg.admin.api_keys.scopes.user_write.desc",
descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.",
},
{
id: "tenant:read",
labelKey: "ui.admin.api_keys.scopes.tenant_read.title",
labelFallback: "테넌트 조회",
descKey: "msg.admin.api_keys.scopes.tenant_read.desc",
descFallback: "등록된 모든 조직 정보를 조회합니다.",
},
{
id: "tenant:write",
labelKey: "ui.admin.api_keys.scopes.tenant_write.title",
labelFallback: "테넌트 관리",
descKey: "msg.admin.api_keys.scopes.tenant_write.desc",
descFallback: "테넌트 정보를 직접 제어합니다.",
},
{
id: "org-context:read",
labelKey: "ui.admin.api_keys.scopes.org_context_read.title",
labelFallback: "조직 Context 조회",
descKey: "msg.admin.api_keys.scopes.org_context_read.desc",
descFallback: "외부 연동앱이 OrgFront SSOT 조직 JSON을 조회합니다.",
},
];

View File

@@ -0,0 +1,41 @@
import { useAuth } from "react-oidc-context";
import { Navigate, Outlet, useLocation } from "react-router-dom";
export default function AuthGuard() {
const auth = useAuth();
const location = useLocation();
const isTest =
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE === true;
if (isTest) {
return <Outlet />;
}
if (auth.isLoading || auth.activeNavigator) {
return <div>Loading...</div>;
}
if (auth.error) {
return (
<div className="flex min-h-screen flex-col items-center justify-center p-4 text-center">
<div className="mb-4 text-destructive">
<h2 className="text-xl font-bold"> </h2>
<p>{auth.error.message}</p>
</div>
</div>
);
}
if (!auth.isAuthenticated) {
const returnTo = `${location.pathname}${location.search}${location.hash}`;
return (
<Navigate
to={`/login?returnTo=${encodeURIComponent(returnTo)}`}
replace
/>
);
}
return <Outlet />;
}

View File

@@ -14,7 +14,7 @@ vi.mock("../../lib/adminApi", () => ({
oidcClients: 3,
auditEvents24h: 18,
})),
fetchTenants: vi.fn(async () => ({
fetchAllTenants: vi.fn(async () => ({
items: [
{
id: "company-1",

View File

@@ -14,7 +14,7 @@ import {
type TenantSummary,
fetchAdminOverviewStats,
fetchAdminRPUsageDaily,
fetchTenants,
fetchAllTenants,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
@@ -342,7 +342,7 @@ function GlobalOverviewPage() {
});
const tenantsQuery = useQuery({
queryKey: ["admin-overview-tenant-options"],
queryFn: () => fetchTenants(1000, 0),
queryFn: () => fetchAllTenants(),
retry: false,
});
const tenantOptions = useMemo(() => {

View File

@@ -14,7 +14,7 @@ import {
import { Input } from "../../../components/ui/input";
import { Label } from "../../../components/ui/label";
import { Textarea } from "../../../components/ui/textarea";
import { createTenant, fetchTenants } from "../../../lib/adminApi";
import { createTenant, fetchAllTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { DomainTagInput } from "../components/DomainTagInput";
import { ParentTenantSelector } from "../components/ParentTenantSelector";
@@ -47,8 +47,8 @@ function TenantCreatePage() {
);
const parentQuery = useQuery({
queryKey: ["tenants", { limit: 1000 }],
queryFn: () => fetchTenants(1000, 0),
queryKey: ["tenants", "all"],
queryFn: () => fetchAllTenants(),
});
const tenants = parentQuery.data?.items ?? [];
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);

View File

@@ -1,4 +1,5 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
import { useVirtualizer } from "@tanstack/react-virtual";
import type { AxiosError } from "axios";
import {
ArrowDown,
@@ -74,6 +75,10 @@ import {
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
import {
filterNonHanmacFamilyTenants,
isHanmacFamilyUser,
} from "../../users/orgChartPicker";
import { isSeedTenant } from "../utils/protectedTenants";
import {
type TenantImportPreviewRow,
@@ -87,8 +92,14 @@ import {
const tenantCSVTemplate =
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
const tenantPageSize = 500;
const tenantVirtualizationThreshold = 250;
const tenantEstimatedRowHeight = 73;
const tenantLoadAheadPx = 360;
const tenantLoadAheadRows = 30;
type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
type TenantListRow = TenantSummary & { recursiveMemberCount: number };
const getTenantIcon = (type?: string) => {
switch (type?.toUpperCase()) {
@@ -245,6 +256,7 @@ function TenantListPage() {
Record<number, string>
>({});
const [previewOpen, setPreviewOpen] = React.useState(false);
const tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
const { data: profile } = useQuery({
queryKey: ["me"],
@@ -264,9 +276,18 @@ function TenantListPage() {
}
}, [profile, navigate]);
const query = useQuery({
queryKey: ["tenants", { limit: 1000, offset: 0 }],
queryFn: () => fetchTenants(1000, 0),
const query = useInfiniteQuery({
queryKey: ["tenants", "lazy"],
queryFn: ({ pageParam }) =>
fetchTenants(
tenantPageSize,
0,
undefined,
pageParam ? pageParam : undefined,
),
initialPageParam: "",
getNextPageParam: (lastPage) =>
lastPage.nextCursor || lastPage.next_cursor || undefined,
enabled:
profile?.role === "super_admin" ||
(profile?.role === "tenant_admin" &&
@@ -364,7 +385,28 @@ function TenantListPage() {
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
: null;
const allTenants = query.data?.items ?? [];
const tenantPages = query.data?.pages ?? [];
const rawTenants = tenantPages.flatMap((page) => page.items);
const tenantTotal = tenantPages[0]?.total ?? 0;
const hanmacFamilyTenantId = React.useMemo(() => {
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
if (typeof envTenantId === "string" && envTenantId.trim()) {
return envTenantId.trim();
}
return rawTenants.find((tenant) => tenant.slug === "hanmac-family")?.id;
}, [rawTenants]);
const allTenants = React.useMemo(() => {
if (profile?.role === "super_admin") {
return rawTenants;
}
if (
profile &&
isHanmacFamilyUser(profile, rawTenants, hanmacFamilyTenantId)
) {
return rawTenants;
}
return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId);
}, [hanmacFamilyTenantId, profile, rawTenants]);
const importParentOptionGroups =
buildTenantImportParentOptionGroups(allTenants);
const tenantSortResolvers = React.useMemo<
@@ -414,6 +456,56 @@ function TenantListPage() {
return sortItems(enriched, sortConfig, tenantSortResolvers);
}, [allTenants, search, sortConfig, tenantSortResolvers]);
const shouldVirtualizeTenants =
tenants.length >= tenantVirtualizationThreshold;
const tenantRowVirtualizer = useVirtualizer({
count: tenants.length,
getScrollElement: () => tenantTableScrollRef.current,
estimateSize: () => tenantEstimatedRowHeight,
overscan: 12,
enabled: shouldVirtualizeTenants,
});
const virtualTenantRows = shouldVirtualizeTenants
? tenantRowVirtualizer.getVirtualItems()
: [];
const lastVirtualTenantIndex =
virtualTenantRows[virtualTenantRows.length - 1]?.index ?? -1;
const fetchNextTenantPage = React.useCallback(() => {
if (query.hasNextPage && !query.isFetchingNextPage) {
void query.fetchNextPage();
}
}, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]);
const handleTenantTableScroll = React.useCallback(
(event: React.UIEvent<HTMLDivElement>) => {
const scrollElement = event.currentTarget;
const distanceToEnd =
scrollElement.scrollHeight -
scrollElement.scrollTop -
scrollElement.clientHeight;
if (distanceToEnd <= tenantLoadAheadPx) {
fetchNextTenantPage();
}
},
[fetchNextTenantPage],
);
React.useEffect(() => {
if (
!shouldVirtualizeTenants ||
lastVirtualTenantIndex < tenants.length - tenantLoadAheadRows
) {
return;
}
fetchNextTenantPage();
}, [
fetchNextTenantPage,
lastVirtualTenantIndex,
shouldVirtualizeTenants,
tenants.length,
]);
const requestSort = (key: TenantSortKey) => {
setSortConfig((current) => toggleSort(current, key));
};
@@ -600,6 +692,96 @@ function TenantListPage() {
deleteMutation.mutate(tenantId);
};
const renderTenantRow = (
tenant: TenantListRow,
options?: {
style?: React.CSSProperties;
virtualIndex?: number;
},
) => (
<TableRow
key={tenant.id}
data-index={options?.virtualIndex}
ref={
options?.virtualIndex === undefined
? undefined
: tenantRowVirtualizer.measureElement
}
style={options?.style}
>
<TableCell className="text-center">
{isSeedTenant(tenant) ? (
<span className="inline-block h-4 w-4" />
) : (
<Checkbox
checked={selectedIds.includes(tenant.id)}
onCheckedChange={(checked) => handleSelect(tenant, !!checked)}
/>
)}
</TableCell>
<TableCell
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
data-testid={`tenant-internal-id-${tenant.id}`}
>
{tenant.id}
</TableCell>
<TableCell className="font-semibold">
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/tenants/${tenant.id}`}
className="hover:underline text-primary cursor-pointer"
>
{tenant.name}
</Link>
{isSeedTenant(tenant) && (
<Badge variant="secondary" className="text-[10px]">
{t("ui.admin.tenants.seed_badge", "초기 설정")}
</Badge>
)}
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge variant="outline" className="text-[10px] font-mono">
{tenant.type}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">{tenant.slug}</TableCell>
<TableCell className="whitespace-nowrap">
<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.recursiveMemberCount}
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
: "-"}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
disabled={isSeedTenant(tenant) || deleteMutation.isPending}
onClick={() => handleDelete(tenant.id, tenant.name)}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} className="mr-2" />
{t("ui.common.delete", "삭제")}
</Button>
</TableCell>
</TableRow>
);
return (
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
@@ -728,7 +910,7 @@ function TenantListPage() {
"msg.admin.tenants.registry.count",
"총 {{count}}개 테넌트",
{
count: query.data?.total ?? 0,
count: tenantTotal,
},
)}
</CardDescription>
@@ -771,7 +953,12 @@ function TenantListPage() {
className="flex-1 flex flex-col min-h-0 m-0"
>
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
<div className="flex-1 overflow-auto relative custom-scrollbar">
<div
ref={tenantTableScrollRef}
className="flex-1 overflow-auto relative custom-scrollbar"
data-testid="tenant-table-scroll"
onScroll={handleTenantTableScroll}
>
<Table className="min-w-[1180px]">
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
<TableRow>
@@ -855,7 +1042,18 @@ function TenantListPage() {
</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableBody
className={
shouldVirtualizeTenants ? "relative block" : undefined
}
style={
shouldVirtualizeTenants
? {
height: `${tenantRowVirtualizer.getTotalSize()}px`,
}
: undefined
}
>
{query.isLoading && (
<TableRow>
<TableCell colSpan={9}>
@@ -876,102 +1074,26 @@ function TenantListPage() {
</TableCell>
</TableRow>
)}
{tenants.map((tenant) => (
<TableRow key={tenant.id}>
<TableCell className="text-center">
{isSeedTenant(tenant) ? (
<span className="inline-block h-4 w-4" />
) : (
<Checkbox
checked={selectedIds.includes(tenant.id)}
onCheckedChange={(checked) =>
handleSelect(tenant, !!checked)
}
/>
)}
</TableCell>
<TableCell
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
data-testid={`tenant-internal-id-${tenant.id}`}
>
{tenant.id}
</TableCell>
<TableCell className="font-semibold">
<div className="flex flex-wrap items-center gap-2">
<Link
to={`/tenants/${tenant.id}`}
className="hover:underline text-primary cursor-pointer"
>
{tenant.name}
</Link>
{isSeedTenant(tenant) && (
<Badge
variant="secondary"
className="text-[10px]"
>
{t(
"ui.admin.tenants.seed_badge",
"초기 설정",
)}
</Badge>
)}
</div>
</TableCell>
<TableCell className="whitespace-nowrap">
<Badge
variant="outline"
className="text-[10px] font-mono"
>
{tenant.type}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs">
{tenant.slug}
</TableCell>
<TableCell className="whitespace-nowrap">
<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.recursiveMemberCount}
</TableCell>
<TableCell className="whitespace-nowrap text-xs">
{tenant.updatedAt
? new Date(tenant.updatedAt).toLocaleString(
"ko-KR",
)
: "-"}
</TableCell>
<TableCell>
<Button
variant="ghost"
size="sm"
disabled={
isSeedTenant(tenant) || deleteMutation.isPending
}
onClick={() =>
handleDelete(tenant.id, tenant.name)
}
className="text-destructive hover:text-destructive hover:bg-destructive/10"
>
<Trash2 size={14} className="mr-2" />
{t("ui.common.delete", "삭제")}
</Button>
</TableCell>
</TableRow>
))}
{shouldVirtualizeTenants
? virtualTenantRows.map((virtualRow) => {
const tenant = tenants[virtualRow.index];
if (!tenant) {
return null;
}
return renderTenantRow(tenant, {
virtualIndex: virtualRow.index,
style: {
position: "absolute",
top: 0,
left: 0,
width: "100%",
display: "table",
tableLayout: "fixed",
transform: `translateY(${virtualRow.start}px)`,
},
});
})
: tenants.map((tenant) => renderTenantRow(tenant))}
</TableBody>
</Table>
</div>

View File

@@ -18,8 +18,8 @@ import { toast } from "../../../components/ui/use-toast";
import {
approveTenant,
deleteTenant,
fetchAllTenants,
fetchTenant,
fetchTenants,
updateTenant,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
@@ -58,7 +58,7 @@ export function TenantProfilePage() {
const parentQuery = useQuery({
queryKey: ["tenants", "list-all"],
queryFn: () => fetchTenants(1000, 0),
queryFn: () => fetchAllTenants(),
});
const [name, setName] = useState("");

View File

@@ -18,7 +18,7 @@ import {
TableHeader,
TableRow,
} from "../../../components/ui/table";
import { fetchTenants } from "../../../lib/adminApi";
import { fetchAllTenants } from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
function TenantSubTenantsPage() {
@@ -27,7 +27,7 @@ function TenantSubTenantsPage() {
const { data } = useQuery({
queryKey: ["sub-tenants", tenantId],
queryFn: () => fetchTenants(50, 0, tenantId ?? undefined),
queryFn: () => fetchAllTenants({ parentId: tenantId ?? undefined }),
enabled: !!tenantId,
});

View File

@@ -21,14 +21,14 @@ import {
} from "../../../components/ui/table";
import {
type TenantSummary,
fetchAllTenants,
fetchGroups,
fetchTenants,
} from "../../../lib/adminApi";
export default function GlobalUserGroupListPage() {
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
queryKey: ["admin-tenants"],
queryFn: () => fetchTenants(100, 0),
queryFn: () => fetchAllTenants(),
});
if (isTenantsLoading)

View File

@@ -74,7 +74,7 @@ import {
type UserSummary,
createUser,
exportTenantsCSV,
fetchTenants,
fetchAllTenants,
fetchUsers,
updateTenant,
updateUser,
@@ -449,7 +449,7 @@ function TenantUserGroupsTab() {
refetch: refetchTree,
} = useQuery({
queryKey: ["tenants-full-tree-v2"],
queryFn: () => fetchTenants(1000, 0),
queryFn: () => fetchAllTenants(),
});
const { currentBase, subTree } = useMemo(() => {

View File

@@ -42,9 +42,9 @@ import { toast } from "../../../components/ui/use-toast";
import {
addGroupMember,
assignGroupRole,
fetchAllTenants,
fetchGroup,
fetchGroupRoles,
fetchTenants,
fetchUsers,
removeGroupMember,
removeGroupRole,
@@ -91,7 +91,7 @@ export function UserGroupDetailPage() {
// Fetch all tenants for role assignment
const { data: tenantList } = useQuery({
queryKey: ["admin-tenants"],
queryFn: () => fetchTenants(100, 0),
queryFn: () => fetchAllTenants(),
enabled: isAddRoleOpen,
});

View File

@@ -42,11 +42,10 @@ import {
type UserAppointment,
type UserCreateRequest,
type UserCreateResponse,
createTenant,
createUser,
fetchAllTenants,
fetchMe,
fetchTenant,
fetchTenants,
} from "../../lib/adminApi";
import { t } from "../../lib/i18n";
import {
@@ -56,9 +55,10 @@ import {
parseOrgChartTenantSelection,
} from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields";
import { resolvePersonalTenant } from "./utils/personalTenant";
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
type UserType = "hanmac" | "external" | "personal";
type UserCategory = "hanmac" | "external" | "personal";
type PickerTarget = { kind: "appointment"; index: number };
@@ -114,8 +114,8 @@ function UserCreatePage() {
>(null);
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
const [autoPassword, setAutoPassword] = React.useState(true);
const [isHanmacFamily, setIsHanmacFamily] = React.useState(true);
const [userType, setUserType] = React.useState<UserType>("hanmac");
const [userCategory, setUserCategory] =
React.useState<UserCategory>("hanmac");
const [additionalAppointments, setAdditionalAppointments] = React.useState<
AppointmentDraft[]
>([]);
@@ -125,8 +125,8 @@ function UserCreatePage() {
const [isResolvingTenant, setIsResolvingTenant] = React.useState(false);
const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
queryKey: ["tenants", "all"],
queryFn: () => fetchAllTenants(),
});
const tenants = tenantsData?.items ?? [];
@@ -177,17 +177,11 @@ function UserCreatePage() {
const selectedTenantSlug = watch("tenantSlug");
const personalTenant = React.useMemo(
() =>
tenants.find(
(tenant) =>
tenant.slug === "personal" ||
(tenant.type === "PERSONAL" &&
tenant.name.toLowerCase() === "personal"),
),
() => resolvePersonalTenant(tenants),
[tenants],
);
const selectedTenant =
userType !== "external"
userCategory !== "external"
? undefined
: nonHanmacFamilyTenants.find((t) => t.slug === selectedTenantSlug);
@@ -231,7 +225,7 @@ function UserCreatePage() {
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL,
{
tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined,
tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined,
},
);
@@ -310,25 +304,16 @@ function UserCreatePage() {
);
};
const handleUserTypeChange = (value: string) => {
const nextType = value as UserType;
setUserType(nextType);
setIsHanmacFamily(nextType === "hanmac");
if (nextType !== "hanmac") {
const handleUserCategoryChange = (value: string) => {
const nextCategory = value as UserCategory;
setUserCategory(nextCategory);
if (nextCategory !== "hanmac") {
setAdditionalAppointments([]);
}
};
const ensurePersonalTenant = async () => {
if (personalTenant) return personalTenant;
const tenant = await createTenant({
name: "Personal",
slug: "personal",
type: "PERSONAL",
status: "active",
});
queryClient.invalidateQueries({ queryKey: ["tenants"] });
return tenant;
return personalTenant;
};
const mutation = useMutation({
@@ -355,10 +340,13 @@ function UserCreatePage() {
setGeneratedPassword(null);
setCreatedEmail(null);
const {
hanmacFamily: _hanmacFamily,
userType: _userType,
...formMetadata
} = data.metadata ?? {};
const metadata: Record<string, unknown> = {
...(data.metadata ?? {}),
hanmacFamily: userType === "hanmac" && isHanmacFamily,
userType,
...formMetadata,
};
const payload: UserCreateRequest = {
@@ -369,7 +357,7 @@ function UserCreatePage() {
metadata,
};
if (userType === "external") {
if (userCategory === "external") {
if (!data.tenantSlug) {
setError(
t(
@@ -386,7 +374,7 @@ function UserCreatePage() {
payload.jobTitle = data.jobTitle;
}
if (userType === "personal") {
if (userCategory === "personal") {
try {
const tenant = await ensurePersonalTenant();
payload.tenantSlug = tenant.slug;
@@ -405,7 +393,7 @@ function UserCreatePage() {
}
}
if (userType === "hanmac") {
if (userCategory === "hanmac") {
const appointments = additionalAppointments
.filter((appointment) => appointment.tenantId)
.map((appointment) => ({
@@ -644,7 +632,7 @@ function UserCreatePage() {
</div>
</div>
<Tabs value={userType} onValueChange={handleUserTypeChange}>
<Tabs value={userCategory} onValueChange={handleUserCategoryChange}>
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
<TabsTrigger
value="hanmac"

View File

@@ -56,12 +56,11 @@ import {
type TenantSummary,
type UserAppointment,
type UserUpdateRequest,
createTenant,
deleteUser,
fetchAllTenants,
fetchMe,
fetchPasswordPolicy,
fetchTenant,
fetchTenants,
fetchUser,
fetchUserRpHistory,
updateUser,
@@ -78,11 +77,12 @@ import {
parseOrgChartTenantSelection,
} from "./orgChartPicker";
import type { UserSchemaField } from "./userSchemaFields";
import { resolvePersonalTenant } from "./utils/personalTenant";
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
metadata: Record<string, Record<string, string | number | boolean>>;
};
type UserType = "hanmac" | "external" | "personal";
type UserCategory = "hanmac" | "external" | "personal";
type PasswordResetMode = "generated" | "manual";
type PickerTarget = { kind: "appointment"; index: number };
@@ -318,8 +318,8 @@ function UserDetailPage() {
const [passwordResetError, setPasswordResetError] = React.useState<
string | null
>(null);
const [isHanmacFamily, setIsHanmacFamily] = React.useState(false);
const [userType, setUserType] = React.useState<UserType>("external");
const [userCategory, setUserCategory] =
React.useState<UserCategory>("external");
const [additionalAppointments, setAdditionalAppointments] = React.useState<
AppointmentDraft[]
>([]);
@@ -346,8 +346,8 @@ function UserDetailPage() {
});
const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
queryKey: ["tenants", "all"],
queryFn: () => fetchAllTenants(),
});
const tenants = React.useMemo(
() => tenantsData?.items ?? [],
@@ -465,20 +465,14 @@ function UserDetailPage() {
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
}, [tenants]);
const personalTenant = React.useMemo(
() =>
tenants.find(
(tenant) =>
tenant.slug === "personal" ||
(tenant.type === "PERSONAL" &&
tenant.name.toLowerCase() === "personal"),
),
() => resolvePersonalTenant(tenants),
[tenants],
);
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
import.meta.env.ORGFRONT_URL,
{
tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined,
tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined,
},
);
@@ -566,25 +560,16 @@ function UserDetailPage() {
);
};
const handleUserTypeChange = (value: string) => {
const nextType = value as UserType;
setUserType(nextType);
setIsHanmacFamily(nextType === "hanmac");
if (nextType !== "hanmac") {
const handleUserCategoryChange = (value: string) => {
const nextCategory = value as UserCategory;
setUserCategory(nextCategory);
if (nextCategory !== "hanmac") {
setAdditionalAppointments([]);
}
};
const ensurePersonalTenant = async () => {
if (personalTenant) return personalTenant;
const tenant = await createTenant({
name: "Personal",
slug: "personal",
type: "PERSONAL",
status: "active",
});
queryClient.invalidateQueries({ queryKey: ["tenants"] });
return tenant;
return personalTenant;
};
React.useEffect(() => {
@@ -638,14 +623,18 @@ function UserDetailPage() {
tenants,
hanmacFamilyTenantId,
);
const resolvedUserType =
metadata.userType === "personal" || user.companyCode === "personal"
? "personal"
: isUserHanmacFamily
? "hanmac"
: "external";
setUserType(resolvedUserType);
setIsHanmacFamily(resolvedUserType === "hanmac");
const isPersonalUser =
user.companyCode === personalTenant.slug ||
user.tenantSlug === personalTenant.slug ||
user.tenant?.id === personalTenant.id ||
user.tenant?.slug === personalTenant.slug ||
metadata.personalTenantId === personalTenant.id;
const resolvedUserCategory = isPersonalUser
? "personal"
: isUserHanmacFamily
? "hanmac"
: "external";
setUserCategory(resolvedUserCategory);
const familyFallbackTenants = [
...(user.joinedTenants ?? []),
...(user.tenant ? [user.tenant] : []),
@@ -696,7 +685,7 @@ function UserDetailPage() {
: [],
);
}
}, [hanmacFamilyTenantId, tenants, user, reset]);
}, [hanmacFamilyTenantId, personalTenant, tenants, user, reset]);
const mutation = useMutation({
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
@@ -737,10 +726,13 @@ function UserDetailPage() {
}),
);
const {
hanmacFamily: _hanmacFamily,
userType: _userType,
...safeMetadata
} = cleanMetadata;
const metadata: Record<string, unknown> = {
...cleanMetadata,
hanmacFamily: userType === "hanmac" && isHanmacFamily,
userType,
...safeMetadata,
};
const profileData = { ...data };
@@ -750,7 +742,7 @@ function UserDetailPage() {
metadata,
};
if (userType === "personal") {
if (userCategory === "personal") {
try {
const tenant = await ensurePersonalTenant();
payload.tenantSlug = tenant.slug;
@@ -768,7 +760,7 @@ function UserDetailPage() {
}
}
if (userType === "hanmac") {
if (userCategory === "hanmac") {
const appointments = additionalAppointments
.filter((appointment) => appointment.tenantId)
.map((appointment) => ({
@@ -1071,8 +1063,8 @@ function UserDetailPage() {
</div>
<Tabs
value={userType}
onValueChange={handleUserTypeChange}
value={userCategory}
onValueChange={handleUserCategoryChange}
className="space-y-4 pt-6 border-t border-dashed"
>
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
@@ -1097,7 +1089,7 @@ function UserDetailPage() {
</TabsList>
</Tabs>
{userType === "external" && (
{userCategory === "external" && (
<div className="grid gap-8 md:grid-cols-2">
<div className="space-y-2">
<Label
@@ -1141,7 +1133,7 @@ function UserDetailPage() {
</div>
)}
{userType === "hanmac" && (
{userCategory === "hanmac" && (
<div className="space-y-4 rounded-md border p-4">
<div className="space-y-4">
<div className="space-y-3">
@@ -1314,7 +1306,7 @@ function UserDetailPage() {
</div>
)}
{userType === "personal" && (
{userCategory === "personal" && (
<div className="rounded-md border bg-muted/30 p-4 text-sm">
{personalTenant
? `Personal (${personalTenant.slug})`
@@ -1322,7 +1314,7 @@ function UserDetailPage() {
</div>
)}
{userType === "external" && (
{userCategory === "external" && (
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
<div className="space-y-2">
<Label

View File

@@ -11,6 +11,7 @@ import {
RefreshCw,
Search,
Settings2,
ShieldCheck,
Trash2,
} from "lucide-react";
import * as React from "react";
@@ -62,9 +63,9 @@ import {
bulkUpdateUsers,
deleteUser,
exportUsersCSV,
fetchAllTenants,
fetchMe,
fetchTenant,
fetchTenants,
fetchUsers,
updateUser,
} from "../../lib/adminApi";
@@ -101,8 +102,8 @@ function UserListPage() {
});
const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
queryKey: ["tenants", "all"],
queryFn: () => fetchAllTenants(),
});
const tenants = tenantsData?.items ?? [];
@@ -269,6 +270,7 @@ function UserListPage() {
const total = query.data?.total ?? 0;
const totalPages = Math.ceil(total / limit);
const canPromoteSuperAdmin = profile?.role === "super_admin";
const toggleSelectAll = () => {
if (selectedUserIds.length === items.length) {
@@ -318,6 +320,14 @@ function UserListPage() {
bulkUpdateMutation.mutate({ userIds: selectedUserIds, status });
};
const handlePromoteSuperAdmin = () => {
if (selectedUserIds.length === 0) return;
bulkUpdateMutation.mutate({
userIds: selectedUserIds,
role: "super_admin",
});
};
const handleBulkDelete = () => {
if (selectedUserIds.length === 0) return;
if (
@@ -774,6 +784,18 @@ function UserListPage() {
>
{t("ui.common.status.inactive", "비활성화")}
</Button>
{canPromoteSuperAdmin && (
<Button
variant="ghost"
size="sm"
className="text-background hover:bg-background/10 h-8 gap-1.5"
onClick={handlePromoteSuperAdmin}
data-testid="bulk-promote-super-admin-btn"
>
<ShieldCheck size={14} />
{t("ui.admin.users.bulk.promote_admin", "Admin으로 만들기")}
</Button>
)}
<div className="w-px h-4 bg-background/20 mx-1" />
<Button
variant="ghost"

View File

@@ -20,8 +20,8 @@ import {
type TenantSummary,
type UserSummary,
bulkUpdateUsers,
fetchAllTenants,
fetchGroups,
fetchTenants,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
@@ -52,8 +52,8 @@ export function UserBulkMoveGroupModal({
const queryClient = useQueryClient();
const { data: tenantsData } = useQuery({
queryKey: ["tenants", { limit: 100 }],
queryFn: () => fetchTenants(100, 0),
queryKey: ["tenants", "all"],
queryFn: () => fetchAllTenants(),
enabled: open,
});
const tenants = tenantsData?.items ?? [];

View File

@@ -24,7 +24,7 @@ import {
type BulkUserResult,
bulkCreateUsers,
createTenant,
fetchTenants,
fetchAllTenants,
fetchUsers,
} from "../../../lib/adminApi";
import { t } from "../../../lib/i18n";
@@ -139,7 +139,7 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
const tenantQuery = useQuery({
queryKey: ["tenants", "user-bulk-import"],
queryFn: () => fetchTenants(1000, 0),
queryFn: () => fetchAllTenants(),
});
const usersQuery = useQuery({

View File

@@ -169,4 +169,66 @@ describe("orgChartPicker", () => {
),
).toBe(true);
});
it("does not treat legacy hanmacFamily metadata as Hanmac family without tenant evidence", () => {
const tenants = [
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
];
expect(
isHanmacFamilyUser(
{
companyCode: "external",
tenant: tenants[1],
metadata: { hanmacFamily: true },
},
tenants,
"hanmac-family-id",
),
).toBe(false);
});
it("does not treat userType metadata as Hanmac family without tenant evidence", () => {
const tenants = [
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
type: "COMPANY_GROUP",
parentId: undefined,
},
{
id: "external-id",
slug: "external",
name: "External",
type: "COMPANY",
parentId: undefined,
},
];
expect(
isHanmacFamilyUser(
{
companyCode: "external",
tenant: tenants[1],
metadata: { userType: "hanmac" },
},
tenants,
"hanmac-family-id",
),
).toBe(false);
});
});

View File

@@ -5,10 +5,13 @@ export type OrgChartTenantSelection = {
export type TenantFilterTarget = {
id?: string;
tenantId?: string;
slug?: string;
tenantSlug?: string;
type?: string;
parentId?: string | null;
name?: string;
tenantName?: string;
};
export type HanmacFamilyUserTarget = {
@@ -120,19 +123,43 @@ export function isHanmacFamilyUser<T extends TenantFilterTarget>(
tenants: T[],
hanmacFamilyTenantId?: string,
) {
const metadata = user.metadata ?? {};
if (metadata.hanmacFamily === true || metadata.userType === "hanmac") {
return true;
}
const metadataAppointments = Array.isArray(
user.metadata?.additionalAppointments,
)
? user.metadata.additionalAppointments
.map((appointment) => appointment as TenantFilterTarget)
.filter(
(appointment) =>
typeof appointment.tenantId === "string" ||
typeof appointment.id === "string" ||
typeof appointment.tenantSlug === "string" ||
typeof appointment.slug === "string",
)
.map((appointment) => ({
id: appointment.id ?? appointment.tenantId,
slug: appointment.slug ?? appointment.tenantSlug,
parentId: appointment.parentId,
type: appointment.type,
name: appointment.name ?? appointment.tenantName,
}))
: [];
const tenantBySlug = new Map(
tenants
.filter((tenant) => tenant.slug?.trim())
.map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]),
);
const tenantById = new Map(
tenants
.filter((tenant) => tenant.id?.trim())
.map((tenant) => [tenant.id as string, tenant]),
);
const tenantCandidates = [
user.tenant,
...(user.joinedTenants ?? []),
...metadataAppointments,
...metadataAppointments.map((appointment) =>
tenantById.get(appointment.id ?? ""),
),
tenantBySlug.get(user.companyCode?.toLowerCase() ?? ""),
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
];

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import {
GLOBAL_PERSONAL_TENANT_ID,
resolvePersonalTenant,
} from "./personalTenant";
describe("resolvePersonalTenant", () => {
it("uses the fixed global Personal tenant when it is not included in the paged tenant list", () => {
expect(resolvePersonalTenant([])).toMatchObject({
id: GLOBAL_PERSONAL_TENANT_ID,
slug: "personal",
name: "Personal",
type: "PERSONAL",
status: "active",
});
});
it("prefers the tenant returned by the API when available", () => {
expect(
resolvePersonalTenant([
{
id: "api-personal-id",
slug: "personal",
name: "Personal",
type: "PERSONAL",
status: "active",
memberCount: 0,
createdAt: "2026-05-01T00:00:00Z",
updatedAt: "2026-05-01T00:00:00Z",
},
]),
).toMatchObject({
id: "api-personal-id",
slug: "personal",
});
});
});

View File

@@ -0,0 +1,34 @@
import type { TenantSummary } from "../../../lib/adminApi";
export const GLOBAL_PERSONAL_TENANT_ID =
import.meta.env.VITE_PERSONAL_TENANT_ID ||
"9607eb7b-04d2-42ab-80fe-780fe21c7e8f";
export const GLOBAL_PERSONAL_TENANT_SLUG =
import.meta.env.VITE_PERSONAL_TENANT_SLUG || "personal";
export function isPersonalTenant(
tenant: Pick<TenantSummary, "name" | "slug" | "type">,
) {
return (
tenant.slug === GLOBAL_PERSONAL_TENANT_SLUG ||
(tenant.type === "PERSONAL" && tenant.name.toLowerCase() === "personal")
);
}
export function resolvePersonalTenant(tenants: TenantSummary[]): TenantSummary {
const tenant = tenants.find(isPersonalTenant);
if (tenant) return tenant;
return {
id: GLOBAL_PERSONAL_TENANT_ID,
slug: GLOBAL_PERSONAL_TENANT_SLUG,
name: "Personal",
type: "PERSONAL",
description: "개인 사용자 기본 루트 테넌트",
status: "active",
memberCount: 0,
createdAt: "2026-05-04T06:52:59.187802Z",
updatedAt: "2026-05-04T06:52:59.191145Z",
};
}

View File

@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiClient = {
post: vi.fn(),
put: vi.fn(),
};
vi.mock("./apiClient", () => ({
default: apiClient,
}));
describe("adminApi user tenant payloads", () => {
beforeEach(() => {
apiClient.post.mockReset();
apiClient.put.mockReset();
});
it("sends tenantSlug without remapping it to companyCode when creating a user", async () => {
const { createUser } = await import("./adminApi");
apiClient.post.mockResolvedValue({ data: {} });
await createUser({
email: "user@test.com",
name: "Test User",
tenantSlug: "test-tenant",
});
expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/users",
expect.objectContaining({ tenantSlug: "test-tenant" }),
);
expect(apiClient.post.mock.calls[0][1]).not.toHaveProperty("companyCode");
});
it("sends tenantSlug without remapping it to companyCode when updating a user", async () => {
const { updateUser } = await import("./adminApi");
apiClient.put.mockResolvedValue({ data: {} });
await updateUser("user-id", { tenantSlug: "new-tenant" });
expect(apiClient.put).toHaveBeenCalledWith(
"/v1/admin/users/user-id",
expect.objectContaining({ tenantSlug: "new-tenant" }),
);
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
});
it("keeps tenantSlug payloads unchanged for bulk user APIs", async () => {
const { bulkCreateUsers, bulkUpdateUsers } = await import("./adminApi");
apiClient.post.mockResolvedValue({ data: {} });
apiClient.put.mockResolvedValue({ data: {} });
await bulkCreateUsers([
{
email: "user@test.com",
name: "Test User",
tenantSlug: "test-tenant",
metadata: {},
},
]);
await bulkUpdateUsers({
userIds: ["user-id"],
tenantSlug: "new-tenant",
});
expect(apiClient.post.mock.calls[0][1].users[0]).toMatchObject({
tenantSlug: "test-tenant",
});
expect(apiClient.post.mock.calls[0][1].users[0]).not.toHaveProperty(
"companyCode",
);
expect(apiClient.put.mock.calls[0][1]).toMatchObject({
tenantSlug: "new-tenant",
});
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
});
});

View File

@@ -1,4 +1,6 @@
import { fetchAllCursorPages } from "../../../common/core/pagination";
import apiClient from "./apiClient";
import { userManager } from "./auth";
export type AuditLog = {
event_id: string;
@@ -51,6 +53,9 @@ export type TenantListResponse = {
limit: number;
offset: number;
total: number;
cursor?: string;
nextCursor?: string;
next_cursor?: string;
};
export type TenantUpdateRequest = {
@@ -195,16 +200,73 @@ export async function fetchAdminRPUsageDaily({
return data;
}
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
export async function fetchTenants(
limit = 50,
offset = 0,
parentId?: string,
cursor?: string,
) {
const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants",
{
params: { limit, offset, parentId },
params: { limit, offset, parentId, cursor },
},
);
return data;
}
function getAdminApiBaseUrl() {
if (
(window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE
) {
return "http://playwright-mock/api";
}
return import.meta.env.VITE_ADMIN_API_BASE ?? "/api";
}
async function buildAdminRequestHeaders() {
const headers: Record<string, string> = {};
const user = await userManager.getUser();
const sessionToken =
user?.access_token || window.localStorage.getItem("admin_session");
if (sessionToken) {
headers.Authorization = `Bearer ${sessionToken}`;
}
const tenantId = window.localStorage.getItem("admin_tenant");
if (tenantId) {
headers["X-Tenant-ID"] = tenantId;
}
const isMockRoleEnabled =
window.localStorage.getItem("X-Mock-Role-Enabled") === "true";
const mockRole = window.localStorage.getItem("X-Mock-Role");
if (isMockRoleEnabled && mockRole) {
headers["X-Test-Role"] = mockRole;
}
return headers;
}
export async function fetchAllTenants({
pageSize = 100,
parentId,
}: {
pageSize?: number;
parentId?: string;
} = {}) {
return fetchAllCursorPages<TenantSummary>({
baseUrl: getAdminApiBaseUrl(),
path: "/v1/admin/tenants",
pageSize,
params: { parentId },
headers: await buildAdminRequestHeaders(),
}) as Promise<TenantListResponse>;
}
export async function fetchTenant(tenantId: string) {
const { data } = await apiClient.get<TenantSummary>(
`/v1/admin/tenants/${tenantId}`,
@@ -440,6 +502,10 @@ export type ApiKeyCreateResponse = {
clientSecret: string;
};
export type ApiKeyUpdateScopesRequest = {
scopes: string[];
};
export async function fetchApiKeys(limit = 50, offset = 0) {
const { data } = await apiClient.get<ApiKeyListResponse>(
"/v1/admin/api-keys",
@@ -458,6 +524,24 @@ export async function createApiKey(payload: ApiKeyCreateRequest) {
return data;
}
export async function updateApiKeyScopes(
apiKeyId: string,
payload: ApiKeyUpdateScopesRequest,
) {
const { data } = await apiClient.patch<ApiKeySummary>(
`/v1/admin/api-keys/${apiKeyId}`,
payload,
);
return data;
}
export async function rotateApiKeySecret(apiKeyId: string) {
const { data } = await apiClient.post<ApiKeyCreateResponse>(
`/v1/admin/api-keys/${apiKeyId}/secret/rotate`,
);
return data;
}
export async function deleteApiKey(apiKeyId: string) {
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
}
@@ -678,17 +762,9 @@ export async function fetchUser(userId: string) {
}
export async function createUser(payload: UserCreateRequest) {
// Map tenantSlug to companyCode for backend compatibility
const requestPayload: UserCreateRequest & { companyCode?: string } = {
...payload,
};
if (payload.tenantSlug !== undefined) {
requestPayload.companyCode = payload.tenantSlug;
}
const { data } = await apiClient.post<UserCreateResponse>(
"/v1/admin/users",
requestPayload,
payload,
);
return data;
}
@@ -714,16 +790,9 @@ export async function exportUsersCSV(
}
export async function bulkCreateUsers(users: BulkUserItem[]) {
const mappedUsers = users.map((u) => {
const mapped: BulkUserItem & { companyCode?: string } = { ...u };
if (u.tenantSlug !== undefined) {
mapped.companyCode = u.tenantSlug;
}
return mapped;
});
const { data } = await apiClient.post<BulkUserResponse>(
"/v1/admin/users/bulk",
{ users: mappedUsers },
{ users },
);
return data;
}
@@ -810,13 +879,7 @@ export async function bulkUpdateUsers(payload: {
grade?: string;
jobTitle?: string;
}) {
const requestPayload: typeof payload & { companyCode?: string } = {
...payload,
};
if (payload.tenantSlug !== undefined) {
requestPayload.companyCode = payload.tenantSlug;
}
const { data } = await apiClient.put("/v1/admin/users/bulk", requestPayload);
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
return data;
}
@@ -828,16 +891,9 @@ export async function bulkDeleteUsers(userIds: string[]) {
}
export async function updateUser(userId: string, payload: UserUpdateRequest) {
const requestPayload: UserUpdateRequest & { companyCode?: string } = {
...payload,
};
if (payload.tenantSlug !== undefined) {
requestPayload.companyCode = payload.tenantSlug;
}
const { data } = await apiClient.put<UserSummary>(
`/v1/admin/users/${userId}`,
requestPayload,
payload,
);
return data;
}

View File

@@ -1,6 +1,9 @@
import axios from "axios";
import { shouldStartLoginRedirect } from "../../../common/core/auth";
import { userManager } from "./auth";
let isRedirectingToLogin = false;
const apiClient = axios.create({
baseURL: (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
._IS_TEST_MODE
@@ -50,10 +53,13 @@ apiClient.interceptors.response.use(
// 이를 통해 LoginPage에서의 무한 리다이렉션 루프를 방지합니다.
await userManager.removeUser();
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
const isLoginPath = window.location.pathname === "/login";
if (!isAuthPath && !isLoginPath) {
if (
shouldStartLoginRedirect({
pathname: window.location.pathname,
isRedirecting: isRedirectingToLogin,
})
) {
isRedirectingToLogin = true;
console.info(
"[apiClient] Redirecting to /login from",
window.location.pathname,

View File

@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
fetchAllCursorPages,
fetchAllCursorPagesMainThread,
} from "../../../common/core/pagination";
describe("common cursor pagination fetch", () => {
afterEach(() => {
vi.unstubAllGlobals();
});
it("follows nextCursor until the API reports the final page", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce({
ok: true,
json: async () => ({
items: [{ id: "tenant-1" }],
nextCursor: "cursor-1",
}),
})
.mockResolvedValueOnce({
ok: true,
json: async () => ({
items: [{ id: "tenant-2" }],
}),
});
vi.stubGlobal("fetch", fetchMock);
const response = await fetchAllCursorPagesMainThread<{ id: string }>({
baseUrl: "/api",
path: "/v1/admin/tenants",
pageSize: 1,
params: { parentId: "parent-1" },
headers: { Authorization: "Bearer token" },
});
expect(response.items).toEqual([{ id: "tenant-1" }, { id: "tenant-2" }]);
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(fetchMock.mock.calls[0][0].toString()).toContain(
"/api/v1/admin/tenants?parentId=parent-1&limit=1&offset=0",
);
expect(fetchMock.mock.calls[1][0].toString()).toContain("cursor=cursor-1");
expect(fetchMock.mock.calls[0][1]).toMatchObject({
headers: { Authorization: "Bearer token" },
credentials: "same-origin",
});
});
it("uses the main thread path during browser test mode", async () => {
const fetchMock = vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
items: [{ id: "tenant-1" }],
}),
});
vi.stubGlobal("fetch", fetchMock);
Object.defineProperty(window, "_IS_TEST_MODE", {
value: true,
configurable: true,
});
const response = await fetchAllCursorPages<{ id: string }>({
baseUrl: "/api",
path: "/v1/admin/tenants",
});
expect(response.items).toEqual([{ id: "tenant-1" }]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { shouldStartLoginRedirect } from "../../../common/core/auth";
describe("shouldStartLoginRedirect", () => {
it("blocks redirects while a login redirect is already in flight", () => {
expect(
shouldStartLoginRedirect({
pathname: "/users",
isRedirecting: true,
}),
).toBe(false);
});
it("blocks redirects from auth callback and login paths", () => {
expect(
shouldStartLoginRedirect({
pathname: "/auth/callback",
isRedirecting: false,
}),
).toBe(false);
expect(
shouldStartLoginRedirect({
pathname: "/login",
isRedirecting: false,
}),
).toBe(false);
});
it("allows a redirect from protected app paths", () => {
expect(
shouldStartLoginRedirect({
pathname: "/tenants",
isRedirecting: false,
}),
).toBe(true);
});
});

View File

@@ -37,6 +37,15 @@ test.describe("Bulk Actions and Tree Search", () => {
headers,
});
}
if (
url.includes("/admin/users/bulk") &&
route.request().method() === "PUT"
) {
return route.fulfill({
json: { results: [{ id: "u-1", success: true }] },
headers,
});
}
if (url.includes("/admin/users")) {
return route.fulfill({
json: {
@@ -149,6 +158,41 @@ test.describe("Bulk Actions and Tree Search", () => {
await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
});
test("should let super admins promote selected users to super admin", async ({
page,
}) => {
let capturedPayload: unknown = null;
await page.route("**/api/v1/admin/users/bulk", async (route) => {
if (route.request().method() === "PUT") {
capturedPayload = route.request().postDataJSON();
return route.fulfill({
json: { results: [{ id: "u-1", success: true }] },
headers: { "Access-Control-Allow-Origin": "*" },
});
}
return route.fallback();
});
await page.goto("/users");
await expect(page.locator("table")).toContainText("User One", {
timeout: 20000,
});
await page.locator('table input[type="checkbox"]').nth(1).click();
const selectionBar = page.getByTestId("bulk-action-bar");
await expect(selectionBar).toBeVisible({ timeout: 15000 });
await page.getByTestId("bulk-promote-super-admin-btn").click();
await expect
.poll(() => capturedPayload)
.toEqual({
userIds: ["u-1"],
role: "super_admin",
});
await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
});
test("should filter and highlight nodes in organization tree", async ({
page,
}) => {

View File

@@ -105,6 +105,197 @@ test.describe("Tenants Management", () => {
expect(headerWhiteSpace.every((value) => value === "nowrap")).toBe(true);
});
test("should virtualize large tenant lists and load next pages automatically", async ({
page,
}) => {
await page.setViewportSize({ width: 900, height: 700 });
let requestCount = 0;
await page.route("**/api/v1/admin/tenants**", async (route) => {
if (route.request().method() !== "GET") {
return route.continue();
}
const url = new URL(route.request().url());
const cursor = url.searchParams.get("cursor");
requestCount += 1;
if (!cursor) {
return route.fulfill({
json: {
items: Array.from({ length: 500 }, (_, index) => ({
id: `tenant-${String(index + 1).padStart(3, "0")}`,
name: `Tenant ${String(index + 1).padStart(3, "0")}`,
slug: `tenant-${String(index + 1).padStart(3, "0")}`,
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
})),
total: 501,
limit: 500,
offset: 0,
nextCursor: "next-page",
},
headers: { "Access-Control-Allow-Origin": "*" },
});
}
return route.fulfill({
json: {
items: [
{
id: "tenant-501",
name: "Tenant 501",
slug: "tenant-501",
status: "active",
type: "COMPANY",
memberCount: 0,
updatedAt: new Date().toISOString(),
},
],
total: 501,
limit: 500,
offset: 0,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.goto("/tenants");
await expect(page.getByText("총 501개 테넌트")).toBeVisible();
await expect(page.getByRole("button", { name: "더 불러오기" })).toHaveCount(
0,
);
await expect
.poll(async () => page.locator("tbody tr").count())
.toBeLessThan(80);
const tableScroller = page.getByTestId("tenant-table-scroll");
await tableScroller.evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect.poll(() => requestCount).toBe(2);
await tableScroller.evaluate((element) => {
element.scrollTop = element.scrollHeight;
element.dispatchEvent(new Event("scroll", { bubbles: true }));
});
await expect(page.getByText("Tenant 501")).toBeVisible();
expect(requestCount).toBe(2);
});
test("should hide Hanmac family subtree from external tenant admins", async ({
page,
}) => {
await page.route(/.*\/api\/v1\/user\/me$/, async (route) => {
return route.fulfill({
json: {
id: "external-admin",
name: "External Admin",
role: "tenant_admin",
tenantId: "external-tenant-id",
tenantSlug: "external-tenant",
tenant: {
id: "external-tenant-id",
slug: "external-tenant",
name: "External Tenant",
type: "COMPANY",
},
manageableTenants: [
{
id: "external-tenant-id",
slug: "external-tenant",
name: "External Tenant",
type: "COMPANY",
},
{
id: "external-team-id",
slug: "external-team",
name: "External Team",
type: "USER_GROUP",
parentId: "external-tenant-id",
},
],
},
});
});
await page.route("**/api/v1/admin/tenants**", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
await route.fulfill({
json: {
items: [
{
id: "hanmac-family-id",
slug: "hanmac-family",
name: "한맥가족",
status: "active",
type: "COMPANY_GROUP",
memberCount: 0,
},
{
id: "hanmac-company-id",
slug: "hanmac-company",
name: "한맥기술",
status: "active",
type: "COMPANY",
parentId: "hanmac-family-id",
memberCount: 0,
},
{
id: "hanmac-team-id",
slug: "hanmac-team",
name: "한맥팀",
status: "active",
type: "USER_GROUP",
parentId: "hanmac-company-id",
memberCount: 0,
},
{
id: "external-tenant-id",
slug: "external-tenant",
name: "External Tenant",
status: "active",
type: "COMPANY",
memberCount: 0,
},
{
id: "external-team-id",
slug: "external-team",
name: "External Team",
status: "active",
type: "USER_GROUP",
parentId: "external-tenant-id",
memberCount: 0,
},
],
total: 5,
limit: 1000,
offset: 0,
},
headers: { "Access-Control-Allow-Origin": "*" },
});
});
await page.goto("/tenants");
await expect(page.locator("h2").last()).toContainText(
/테넌트 목록|Tenants/i,
{ timeout: 20000 },
);
await expect(page.locator("table")).toContainText("External Tenant");
await expect(page.locator("table")).toContainText("External Team");
await expect(page.locator("table")).not.toContainText("한맥가족");
await expect(page.locator("table")).not.toContainText("한맥기술");
await expect(page.locator("table")).not.toContainText("한맥팀");
});
test("should create a new tenant", async ({ page }) => {
await page.goto("/tenants/new");
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {

View File

@@ -362,8 +362,8 @@ test.describe("User Management", () => {
// Ensure the page title is loaded
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
const userTypeTabs = page.getByRole("tab");
await expect(userTypeTabs).toHaveText([
const categoryTabs = page.getByRole("tab");
await expect(categoryTabs).toHaveText([
"한맥가족 구성원",
"외부 기업 회원",
"개인 회원",
@@ -579,7 +579,6 @@ test.describe("User Management", () => {
.poll(() => createPayload)
.toMatchObject({
metadata: {
hanmacFamily: true,
additionalAppointments: [
{
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
@@ -623,7 +622,7 @@ test.describe("User Management", () => {
expect(tenantOptionValues).not.toContain("tech-planning");
});
test("should create a personal user and provision Personal tenant when missing", async ({
test("should create a personal user with the fixed global Personal tenant when it is not listed", async ({
page,
}) => {
let tenantPayload: Record<string, unknown> | undefined;
@@ -671,14 +670,14 @@ test.describe("User Management", () => {
await page.locator('input[name="email"]').fill("personal@test.com");
await page.getByRole("button", { name: /생성/i }).click();
await expect
.poll(() => tenantPayload)
.toMatchObject({ name: "Personal", slug: "personal", type: "PERSONAL" });
expect(tenantPayload).toBeUndefined();
await expect
.poll(() => createPayload)
.toMatchObject({
tenantSlug: "personal",
metadata: { userType: "personal", hanmacFamily: false },
metadata: {
personalTenantId: "9607eb7b-04d2-42ab-80fe-780fe21c7e8f",
},
});
});
@@ -699,7 +698,6 @@ test.describe("User Management", () => {
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
metadata: {
hanmacFamily: true,
additionalAppointments: [
{
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
@@ -763,7 +761,6 @@ test.describe("User Management", () => {
createdAt: "2026-04-01T00:00:00Z",
updatedAt: "2026-04-01T00:00:00Z",
metadata: {
hanmacFamily: true,
additionalAppointments: [
{
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",

View File

@@ -807,6 +807,8 @@ func main() {
// API Key Management (M2M) - Super Admin Only
admin.Get("/api-keys", requireSuperAdmin, apiKeyHandler.ListApiKeys)
admin.Post("/api-keys", requireSuperAdmin, apiKeyHandler.CreateApiKey)
admin.Patch("/api-keys/:id", requireSuperAdmin, apiKeyHandler.UpdateApiKey)
admin.Post("/api-keys/:id/secret/rotate", requireSuperAdmin, apiKeyHandler.RotateApiKeySecret)
admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey)
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)

View File

@@ -24,6 +24,8 @@ func TestOpenAPIDocumentsExternalAPIs(t *testing.T) {
"/api/v1/integrations/org-context:",
"/api/v1/admin/api-keys:",
"/api/v1/admin/api-keys/{id}:",
"/api/v1/admin/api-keys/{id}/secret/rotate:",
"ApiKeyUpdateScopesRequest:",
"BaronApiKeyId:",
"BaronApiKeySecret:",
"X-Baron-Key-ID",

View File

@@ -198,7 +198,14 @@ paths:
schema:
type: boolean
default: true
description: false이면 users와 directUserIds를 비워 반환합니다.
description: false이면 tenant members를 빈 배열로 반환합니다.
- in: query
name: includeUserIds
required: false
schema:
type: boolean
default: false
description: true이면 tenant members[].id와 members[].phone만 추가합니다.
responses:
"200":
description: OK
@@ -902,6 +909,41 @@ paths:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/admin/api-keys/{id}:
patch:
tags: [Admin]
summary: API Key 권한 수정
description: super admin 전용 API Key scope 수정입니다. client_id는 변경하지 않습니다.
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeyUpdateScopesRequest"
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeySummary"
"400":
description: Bad Request
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
"404":
description: Not Found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
delete:
tags: [Admin]
summary: API Key 삭제
@@ -922,6 +964,31 @@ paths:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/admin/api-keys/{id}/secret/rotate:
post:
tags: [Admin]
summary: API Key Secret 재발급
description: super admin 전용 Secret rotation입니다. client_id는 유지되며 clientSecret은 응답에서 한 번만 반환됩니다.
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
description: OK
content:
application/json:
schema:
$ref: "#/components/schemas/ApiKeyCreateResponse"
"404":
description: Not Found
content:
application/json:
schema:
$ref: "#/components/schemas/ErrorResponse"
/api/v1/dev/clients:
get:
tags: [Dev]
@@ -1270,43 +1337,36 @@ components:
updatedAt:
type: string
format: date-time
members:
type: array
description: 해당 tenant에 직접 소속된 사용자 목록입니다.
items:
$ref: "#/components/schemas/OrgContextMember"
OrgContextTreeNode:
allOf:
- $ref: "#/components/schemas/OrgContextTenant"
- type: object
properties:
directUserIds:
type: array
items:
type: string
children:
type: array
items:
$ref: "#/components/schemas/OrgContextTreeNode"
OrgContextUser:
OrgContextMember:
type: object
properties:
id:
type: string
description: includeUserIds=true일 때만 포함합니다.
email:
type: string
format: email
name:
type: string
role:
phone:
type: string
status:
type: string
tenantIds:
type: array
items:
type: string
tenantSlugs:
type: array
items:
type: string
description: includeUserIds=true일 때만 포함합니다.
department:
type: string
grade:
@@ -1315,15 +1375,15 @@ components:
type: string
jobTitle:
type: string
metadata:
type: object
additionalProperties: true
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
isOwner:
type: boolean
description: appointment의 isOwner 또는 isManager 기준입니다.
isLeader:
type: boolean
description: appointment의 lead 또는 isLead 기준이며, owner 계열 값이 true이면 true입니다.
isPrimary:
type: boolean
description: appointment의 representative, isPrimary 또는 primary 기준입니다.
OrgContextResponse:
type: object
@@ -1342,10 +1402,6 @@ components:
type: array
items:
$ref: "#/components/schemas/OrgContextTenant"
users:
type: array
items:
$ref: "#/components/schemas/OrgContextUser"
PasswordLoginRequest:
type: object
@@ -1790,6 +1846,17 @@ components:
type: string
example: [org-context:read]
ApiKeyUpdateScopesRequest:
type: object
required: [scopes]
properties:
scopes:
type: array
minItems: 1
items:
type: string
example: [audit:read, org-context:read]
ApiKeyCreateResponse:
type: object
properties:

View File

@@ -22,6 +22,11 @@ func Run(db *gorm.DB) error {
return fmt.Errorf("tenant seeding failed: %w", err)
}
// 3. Normalize staging seed/read-model data
if err := SanitizeLegacyUserMetadata(db); err != nil {
return fmt.Errorf("legacy user metadata sanitize failed: %w", err)
}
slog.Info("[Bootstrap] User seed skipped (Kratos is SoT)")
slog.Info("[Bootstrap] Bootstrap completed successfully.")
return nil

View File

@@ -0,0 +1,34 @@
package bootstrap
import (
"fmt"
"log/slog"
"gorm.io/gorm"
)
const sanitizeLegacyUserMetadataSQL = `
update users
set metadata = metadata - 'hanmacFamily' - 'userType',
updated_at = now()
where metadata ? 'hanmacFamily'
or metadata ? 'userType'
`
// SanitizeLegacyUserMetadata removes legacy UI classification flags from Baron user metadata.
func SanitizeLegacyUserMetadata(db *gorm.DB) error {
if db == nil {
return fmt.Errorf("database is not configured")
}
if !db.Migrator().HasTable("users") {
slog.Info("[Bootstrap] Legacy user metadata sanitize skipped because users table does not exist")
return nil
}
result := db.Exec(sanitizeLegacyUserMetadataSQL)
if result.Error != nil {
return fmt.Errorf("sanitize legacy user metadata: %w", result.Error)
}
slog.Info("[Bootstrap] Legacy user metadata sanitized", "rowsAffected", result.RowsAffected)
return nil
}

View File

@@ -0,0 +1,157 @@
package bootstrap
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/testsupport"
"context"
"log"
"os"
"path/filepath"
"testing"
"time"
"github.com/testcontainers/testcontainers-go"
postgres_module "github.com/testcontainers/testcontainers-go/modules/postgres"
"github.com/testcontainers/testcontainers-go/wait"
gorm_postgres "gorm.io/driver/postgres"
"gorm.io/gorm"
)
func TestSanitizeLegacyUserMetadataRemovesClassificationFlags(t *testing.T) {
db := openBootstrapPostgresTestDB(t)
if err := db.AutoMigrate(&domain.User{}); err != nil {
t.Fatalf("failed to migrate users table: %v", err)
}
user := domain.User{
ID: "10000000-0000-0000-0000-000000000001",
Email: "legacy@example.com",
Name: "Legacy User",
Role: domain.RoleUser,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"hanmacFamily": true,
"userType": "hanmac",
"employeeId": "E001",
"nested": map[string]any{
"userType": "must stay nested",
},
},
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
if err := SanitizeLegacyUserMetadata(db); err != nil {
t.Fatalf("SanitizeLegacyUserMetadata returned error: %v", err)
}
if err := SanitizeLegacyUserMetadata(db); err != nil {
t.Fatalf("SanitizeLegacyUserMetadata must be idempotent: %v", err)
}
var got domain.User
if err := db.First(&got, "id = ?", user.ID).Error; err != nil {
t.Fatalf("failed to load sanitized user: %v", err)
}
if _, ok := got.Metadata["hanmacFamily"]; ok {
t.Fatalf("hanmacFamily must be removed from metadata: %#v", got.Metadata)
}
if _, ok := got.Metadata["userType"]; ok {
t.Fatalf("userType must be removed from metadata: %#v", got.Metadata)
}
if got.Metadata["employeeId"] != "E001" {
t.Fatalf("employeeId = %#v, want E001", got.Metadata["employeeId"])
}
nested, ok := got.Metadata["nested"].(map[string]any)
if !ok || nested["userType"] != "must stay nested" {
t.Fatalf("nested metadata must be preserved: %#v", got.Metadata["nested"])
}
}
func TestRunSanitizesLegacyUserMetadata(t *testing.T) {
db := openBootstrapPostgresTestDB(t)
if err := db.AutoMigrate(&domain.User{}); err != nil {
t.Fatalf("failed to migrate users table: %v", err)
}
user := domain.User{
ID: "20000000-0000-0000-0000-000000000001",
Email: "run-legacy@example.com",
Name: "Run Legacy User",
Role: domain.RoleUser,
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"hanmacFamily": true,
"userType": "external",
"employeeId": "E002",
},
}
if err := db.Create(&user).Error; err != nil {
t.Fatalf("failed to create user: %v", err)
}
dir := t.TempDir()
path := filepath.Join(dir, "seed-tenant.csv")
csv := "id,name,type,parent_tenant_slug,slug,memo,email_domain\n" +
"30000000-0000-0000-0000-000000000001,Seed Root,COMPANY_GROUP,,seed-root,seed root,\n"
if err := os.WriteFile(path, []byte(csv), 0o600); err != nil {
t.Fatalf("failed to write seed csv: %v", err)
}
t.Setenv(seedTenantCSVPathEnv, path)
if err := Run(db); err != nil {
t.Fatalf("Run returned error: %v", err)
}
var got domain.User
if err := db.First(&got, "id = ?", user.ID).Error; err != nil {
t.Fatalf("failed to load sanitized user: %v", err)
}
if _, ok := got.Metadata["hanmacFamily"]; ok {
t.Fatalf("Run must remove hanmacFamily from metadata: %#v", got.Metadata)
}
if _, ok := got.Metadata["userType"]; ok {
t.Fatalf("Run must remove userType from metadata: %#v", got.Metadata)
}
if got.Metadata["employeeId"] != "E002" {
t.Fatalf("employeeId = %#v, want E002", got.Metadata["employeeId"])
}
}
func openBootstrapPostgresTestDB(t *testing.T) *gorm.DB {
t.Helper()
if !testsupport.DockerAvailable() {
t.Skip("Docker provider is unavailable in this environment")
}
ctx := context.Background()
postgresContainer, err := postgres_module.Run(ctx,
"postgres:16-alpine",
postgres_module.WithDatabase("testdb"),
postgres_module.WithUsername("user"),
postgres_module.WithPassword("password"),
testcontainers.WithWaitStrategy(
wait.ForLog("database system is ready to accept connections").
WithOccurrence(2).
WithStartupTimeout(30*time.Second),
),
)
if err != nil {
t.Fatalf("failed to start postgres container: %v", err)
}
t.Cleanup(func() {
if err := postgresContainer.Terminate(ctx); err != nil {
log.Printf("failed to terminate postgres container: %v", err)
}
})
connStr, err := postgresContainer.ConnectionString(ctx, "sslmode=disable")
if err != nil {
t.Fatalf("failed to get postgres connection string: %v", err)
}
db, err := gorm.Open(gorm_postgres.Open(connStr), &gorm.Config{})
if err != nil {
t.Fatalf("failed to open postgres connection: %v", err)
}
return db
}

View File

@@ -2,6 +2,8 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/pagination"
"errors"
"strings"
"time"
@@ -29,8 +31,55 @@ type apiKeySummary struct {
}
type apiKeyListResponse struct {
Items []apiKeySummary `json:"items"`
Total int64 `json:"total"`
Items []apiKeySummary `json:"items"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Cursor string `json:"cursor,omitempty"`
NextCursor string `json:"nextCursor,omitempty"`
}
func apiKeyToSummary(k domain.ApiKey) apiKeySummary {
lastUsed := ""
if k.LastUsedAt != nil {
lastUsed = k.LastUsedAt.Format(time.RFC3339)
}
return apiKeySummary{
ID: k.ID,
Name: k.Name,
ClientID: k.ClientID,
Scopes: strings.Fields(strings.ReplaceAll(k.Scopes, ",", " ")),
Status: k.Status,
LastUsedAt: &lastUsed,
CreatedAt: k.CreatedAt,
}
}
func apiKeyWithUpdatedScopes(k domain.ApiKey, scopes []string) domain.ApiKey {
k.Scopes = strings.Join(normalizeApiKeyScopes(scopes), " ")
return k
}
func apiKeyWithRotatedSecretHash(k domain.ApiKey, hashedSecret string) domain.ApiKey {
k.ClientSecretHash = hashedSecret
return k
}
func normalizeApiKeyScopes(scopes []string) []string {
seen := make(map[string]struct{}, len(scopes))
normalized := make([]string, 0, len(scopes))
for _, scope := range scopes {
scope = strings.TrimSpace(scope)
if scope == "" {
continue
}
if _, exists := seen[scope]; exists {
continue
}
seen[scope] = struct{}{}
normalized = append(normalized, scope)
}
return normalized
}
func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error {
@@ -40,6 +89,13 @@ func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
cursorRaw := strings.TrimSpace(c.Query("cursor"))
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
var total int64
if err := h.DB.Model(&domain.ApiKey{}).Count(&total).Error; err != nil {
@@ -47,28 +103,48 @@ func (h *ApiKeyHandler) ListApiKeys(c *fiber.Ctx) error {
}
var keys []domain.ApiKey
if err := h.DB.Order("created_at desc").Limit(limit).Offset(offset).Find(&keys).Error; err != nil {
query := h.DB.Order("created_at desc, id desc").Limit(limit + 1)
if cursorRaw != "" {
cursor, err := pagination.Decode(cursorRaw)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
query = pagination.ApplyCreatedAtIDCursor(query, cursor, "created_at", "id")
offset = 0
} else {
query = query.Offset(offset)
}
if err := query.Find(&keys).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
nextCursor := ""
hasMore := len(keys) > limit
if len(keys) > limit {
keys = keys[:limit]
}
if cursorRaw == "" && total > int64(offset+len(keys)) {
hasMore = true
}
if hasMore && len(keys) > 0 {
last := keys[len(keys)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
}
items := make([]apiKeySummary, 0, len(keys))
for _, k := range keys {
lastUsed := ""
if k.LastUsedAt != nil {
lastUsed = k.LastUsedAt.Format(time.RFC3339)
}
items = append(items, apiKeySummary{
ID: k.ID,
Name: k.Name,
ClientID: k.ClientID,
Scopes: strings.Fields(strings.ReplaceAll(k.Scopes, ",", " ")),
Status: k.Status,
LastUsedAt: &lastUsed,
CreatedAt: k.CreatedAt,
})
items = append(items, apiKeyToSummary(k))
}
return c.JSON(apiKeyListResponse{Items: items, Total: total})
return c.JSON(apiKeyListResponse{
Items: items,
Total: total,
Limit: limit,
Offset: offset,
Cursor: cursorRaw,
NextCursor: nextCursor,
})
}
func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
@@ -87,6 +163,10 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
if strings.TrimSpace(req.Name) == "" {
return errorJSON(c, fiber.StatusBadRequest, "name is required")
}
req.Scopes = normalizeApiKeyScopes(req.Scopes)
if len(req.Scopes) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "at least one scope is required")
}
// Generate Client ID (16 chars hex)
clientID := GenerateSecureToken(8)
@@ -112,21 +192,84 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
}
// Return summary + PLAIN SECRET (only this time)
lastUsed := ""
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
"apiKey": apiKeySummary{
ID: apiKey.ID,
Name: apiKey.Name,
ClientID: apiKey.ClientID,
Scopes: req.Scopes,
Status: apiKey.Status,
LastUsedAt: &lastUsed,
CreatedAt: apiKey.CreatedAt,
},
"apiKey": apiKeyToSummary(apiKey),
"clientSecret": plainSecret, // VERY IMPORTANT: user must save this now
})
}
func (h *ApiKeyHandler) UpdateApiKey(c *fiber.Ctx) error {
if h.DB == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
}
id := c.Params("id")
if id == "" {
return errorJSON(c, fiber.StatusBadRequest, "id is required")
}
var req struct {
Scopes []string `json:"scopes"`
}
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.Scopes = normalizeApiKeyScopes(req.Scopes)
if len(req.Scopes) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "at least one scope is required")
}
var apiKey domain.ApiKey
if err := h.DB.First(&apiKey, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorJSON(c, fiber.StatusNotFound, "api key not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
apiKey = apiKeyWithUpdatedScopes(apiKey, req.Scopes)
if err := h.DB.Model(&domain.ApiKey{}).Where("id = ?", id).Update("scopes", apiKey.Scopes).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.JSON(apiKeyToSummary(apiKey))
}
func (h *ApiKeyHandler) RotateApiKeySecret(c *fiber.Ctx) error {
if h.DB == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
}
id := c.Params("id")
if id == "" {
return errorJSON(c, fiber.StatusBadRequest, "id is required")
}
var apiKey domain.ApiKey
if err := h.DB.First(&apiKey, "id = ?", id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errorJSON(c, fiber.StatusNotFound, "api key not found")
}
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
plainSecret := GenerateSecureToken(8)
hashedSecret, err := bcrypt.GenerateFromPassword([]byte(plainSecret), bcrypt.DefaultCost)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to hash secret")
}
apiKey = apiKeyWithRotatedSecretHash(apiKey, string(hashedSecret))
if err := h.DB.Model(&domain.ApiKey{}).Where("id = ?", id).Update("client_secret_hash", apiKey.ClientSecretHash).Error; err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
return c.JSON(fiber.Map{
"apiKey": apiKeyToSummary(apiKey),
"clientSecret": plainSecret,
})
}
func (h *ApiKeyHandler) DeleteApiKey(c *fiber.Ctx) error {
if h.DB == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")

View File

@@ -1,6 +1,7 @@
package handler
import (
"baron-sso-backend/internal/domain"
"bytes"
"encoding/json"
"net/http"
@@ -57,3 +58,76 @@ func TestApiKeyHandler_Validation(t *testing.T) {
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
}
func TestApiKeyHandler_UpdateApiKeyScopesRequiresDatabase(t *testing.T) {
app := fiber.New()
h := &ApiKeyHandler{DB: nil}
app.Patch("/api-keys/:id", h.UpdateApiKey)
body, _ := json.Marshal(map[string]interface{}{
"scopes": []string{"org-context:read"},
})
req := httptest.NewRequest("PATCH", "/api-keys/api-key-id", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
func TestApiKeyHandler_RotateApiKeySecretRequiresDatabase(t *testing.T) {
app := fiber.New()
h := &ApiKeyHandler{DB: nil}
app.Post("/api-keys/:id/secret/rotate", h.RotateApiKeySecret)
req := httptest.NewRequest("POST", "/api-keys/api-key-id/secret/rotate", nil)
resp, _ := app.Test(req)
assert.Equal(t, http.StatusServiceUnavailable, resp.StatusCode)
}
func TestApiKeyWithUpdatedScopesPreservesClientID(t *testing.T) {
key := domain.ApiKey{
ID: "api-key-id",
Name: "M2M Test",
ClientID: "client-id-stable",
ClientSecretHash: "old-secret-hash",
Scopes: "audit:read",
Status: "active",
}
updated := apiKeyWithUpdatedScopes(key, []string{"audit:read", "org-context:read"})
assert.Equal(t, "client-id-stable", updated.ClientID)
assert.Equal(t, "old-secret-hash", updated.ClientSecretHash)
assert.Equal(t, "audit:read org-context:read", updated.Scopes)
}
func TestApiKeyWithRotatedSecretHashPreservesClientIDAndScopes(t *testing.T) {
key := domain.ApiKey{
ID: "api-key-id",
Name: "M2M Test",
ClientID: "client-id-stable",
ClientSecretHash: "old-secret-hash",
Scopes: "audit:read org-context:read",
Status: "active",
}
updated := apiKeyWithRotatedSecretHash(key, "new-secret-hash")
assert.Equal(t, "client-id-stable", updated.ClientID)
assert.Equal(t, "audit:read org-context:read", updated.Scopes)
assert.Equal(t, "new-secret-hash", updated.ClientSecretHash)
}
func TestNormalizeApiKeyScopesTrimsAndDeduplicates(t *testing.T) {
scopes := normalizeApiKeyScopes([]string{
" audit:read ",
"",
"org-context:read",
"audit:read",
})
assert.Equal(t, []string{"audit:read", "org-context:read"}, scopes)
}

View File

@@ -107,7 +107,7 @@ func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) (
return nil, nil
}
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
return nil, 0, nil
}

View File

@@ -2,6 +2,7 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/pagination"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
@@ -121,9 +122,11 @@ type clientSummary struct {
}
type clientListResponse struct {
Items []clientSummary `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Items []clientSummary `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Cursor string `json:"cursor,omitempty"`
NextCursor string `json:"nextCursor,omitempty"`
}
type clientDetailResponse struct {
@@ -186,7 +189,12 @@ type consentSummary struct {
}
type consentListResponse struct {
Items []consentSummary `json:"items"`
Items []consentSummary `json:"items"`
Total int64 `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Cursor string `json:"cursor,omitempty"`
NextCursor string `json:"nextCursor,omitempty"`
}
type clientUpsertRequest struct {
@@ -1097,6 +1105,30 @@ func (h *DevHandler) listVisibleClientSummaries(
return items, nil
}
func (h *DevHandler) listAllVisibleClientSummaries(c *fiber.Ctx, profile *domain.UserProfileResponse) ([]clientSummary, error) {
const pageSize = 500
items := make([]clientSummary, 0)
for offset := 0; ; offset += pageSize {
page, err := h.listVisibleClientSummaries(c, profile, pageSize, offset)
if err != nil {
return nil, err
}
items = append(items, page...)
if len(page) < pageSize {
break
}
}
return items, nil
}
func clientSummaryCursorKey(client clientSummary) (time.Time, string) {
timestamp := time.Unix(0, 0).UTC()
if client.CreatedAt != nil && !client.CreatedAt.IsZero() {
timestamp = client.CreatedAt.UTC()
}
return timestamp, client.ID
}
func extractAuthClaimsFromBearer(authHeader string) (string, string) {
authHeader = strings.TrimSpace(authHeader)
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
@@ -1213,6 +1245,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
h.injectTenantContextFromHeader(c)
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
cursorRaw := strings.TrimSpace(c.Query("cursor"))
if limit <= 0 {
limit = 50
}
@@ -1221,7 +1254,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
}
profile := h.getCurrentProfile(c)
items, err := h.listVisibleClientSummaries(c, profile, limit, offset)
allItems, err := h.listAllVisibleClientSummaries(c, profile)
if err != nil {
status := fiber.StatusInternalServerError
errMsg := err.Error()
@@ -1239,10 +1272,37 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
return errorJSON(c, status, errMsg)
}
var items []clientSummary
nextCursor := ""
if cursorRaw != "" {
ordered := append([]clientSummary(nil), allItems...)
pagination.SortByKeyDesc(ordered, clientSummaryCursorKey)
items, nextCursor, err = pagination.PageByCursor(ordered, limit, cursorRaw, clientSummaryCursorKey)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else {
if offset > len(allItems) {
offset = len(allItems)
}
end := offset + limit
if end > len(allItems) {
end = len(allItems)
}
items = allItems[offset:end]
if len(allItems) > end && len(items) > 0 {
lastTimestamp, lastID := clientSummaryCursorKey(items[len(items)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
}
return c.JSON(clientListResponse{
Items: items,
Limit: limit,
Offset: offset,
Items: items,
Limit: limit,
Offset: offset,
Cursor: cursorRaw,
NextCursor: nextCursor,
})
}
@@ -2126,9 +2186,13 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
subject := strings.TrimSpace(c.Query("subject"))
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
cursorRaw := strings.TrimSpace(c.Query("cursor"))
if limit <= 0 {
limit = 50
}
if offset < 0 {
offset = 0
}
// [Isolation] Get admin tenant ID from locals or header
adminTenantID := ""
@@ -2156,10 +2220,16 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
}
}
queryLimit := limit
queryOffset := offset
if cursorRaw != "" || subject != "" || (statusFilter != "" && statusFilter != "all") {
queryLimit = 10000
queryOffset = 0
}
if adminTenantID != "" {
consents, total, err = h.ConsentRepo.ListByTenant(c.Context(), clientID, adminTenantID, limit, offset)
consents, total, err = h.ConsentRepo.ListByTenant(c.Context(), clientID, adminTenantID, queryLimit, queryOffset)
} else {
consents, total, err = h.ConsentRepo.List(c.Context(), clientID, limit, offset)
consents, total, err = h.ConsentRepo.List(c.Context(), clientID, queryLimit, queryOffset)
}
if err != nil {
@@ -2216,12 +2286,47 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
})
}
return c.JSON(fiber.Map{
"items": items,
"total": total,
pagination.SortByKeyDesc(items, consentSummaryCursorKey)
nextCursor := ""
if cursorRaw != "" {
items, nextCursor, err = pagination.PageByCursor(items, limit, cursorRaw, consentSummaryCursorKey)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else if queryLimit != limit {
if offset > len(items) {
offset = len(items)
}
end := offset + limit
if end > len(items) {
end = len(items)
}
pageItems := items[offset:end]
if len(items) > end && len(pageItems) > 0 {
lastTimestamp, lastID := consentSummaryCursorKey(pageItems[len(pageItems)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
items = pageItems
} else if total > int64(offset+len(items)) && len(items) > 0 {
lastTimestamp, lastID := consentSummaryCursorKey(items[len(items)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
return c.JSON(consentListResponse{
Items: items,
Total: total,
Limit: limit,
Offset: offset,
Cursor: cursorRaw,
NextCursor: nextCursor,
})
}
func consentSummaryCursorKey(consent consentSummary) (time.Time, string) {
return consent.CreatedAt, consent.ClientID + ":" + consent.Subject
}
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
tenantID := h.injectTenantContextFromHeader(c)
subject := strings.TrimSpace(c.Query("subject"))

View File

@@ -3,6 +3,7 @@ package handler
import (
"baron-sso-backend/internal/bootstrap"
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/pagination"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
@@ -81,10 +82,22 @@ type tenantSummary struct {
}
type tenantListResponse struct {
Items []tenantSummary `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int64 `json:"total"`
Items []tenantSummary `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int64 `json:"total"`
Cursor string `json:"cursor,omitempty"`
NextCursor string `json:"nextCursor,omitempty"`
}
func pageTenantsByCursor(tenants []domain.Tenant, limit int, cursorRaw string) ([]domain.Tenant, string, error) {
ordered := append([]domain.Tenant(nil), tenants...)
pagination.SortByKeyDesc(ordered, func(tenant domain.Tenant) (time.Time, string) {
return tenant.CreatedAt, tenant.ID
})
return pagination.PageByCursor(ordered, limit, cursorRaw, func(tenant domain.Tenant) (time.Time, string) {
return tenant.CreatedAt, tenant.ID
})
}
type tenantImportResult struct {
@@ -115,43 +128,45 @@ type tenantCSVRecord struct {
}
type orgContextTenant struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Slug string `json:"slug"`
ParentID *string `json:"parentId"`
Status string `json:"status"`
Description string `json:"description"`
Domains []string `json:"domains,omitempty"`
MemberCount int64 `json:"memberCount"`
Visibility string `json:"visibility"`
OrgUnitType string `json:"orgUnitType,omitempty"`
Config domain.JSONMap `json:"config,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Slug string `json:"slug"`
ParentID *string `json:"parentId"`
Status string `json:"status"`
Description string `json:"description"`
Domains []string `json:"domains,omitempty"`
MemberCount int64 `json:"memberCount"`
Visibility string `json:"visibility"`
OrgUnitType string `json:"orgUnitType,omitempty"`
Config domain.JSONMap `json:"config,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
Members []orgContextMember `json:"members"`
}
type orgContextUser struct {
ID string `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
Role string `json:"role"`
Status string `json:"status"`
TenantIDs []string `json:"tenantIds"`
TenantSlugs []string `json:"tenantSlugs"`
Department string `json:"department,omitempty"`
Grade string `json:"grade,omitempty"`
Position string `json:"position,omitempty"`
JobTitle string `json:"jobTitle,omitempty"`
Metadata domain.JSONMap `json:"metadata,omitempty"`
CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
type orgContextMember struct {
ID string `json:"id,omitempty"`
Email string `json:"email"`
Name string `json:"name"`
Phone string `json:"phone,omitempty"`
Department string `json:"department,omitempty"`
Grade string `json:"grade,omitempty"`
Position string `json:"position,omitempty"`
JobTitle string `json:"jobTitle,omitempty"`
IsOwner bool `json:"isOwner"`
IsLeader bool `json:"isLeader"`
IsPrimary bool `json:"isPrimary"`
}
type orgContextMemberAssignment struct {
TenantID string
Member orgContextMember
}
type orgContextTreeNode struct {
orgContextTenant
DirectUserIDs []string `json:"directUserIds"`
Children []orgContextTreeNode `json:"children"`
Children []orgContextTreeNode `json:"children"`
}
type orgContextScope struct {
@@ -165,7 +180,6 @@ type orgContextResponse struct {
Scope orgContextScope `json:"scope"`
Tree *orgContextTreeNode `json:"tree"`
Tenants []orgContextTenant `json:"tenants"`
Users []orgContextUser `json:"users"`
}
func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
@@ -213,6 +227,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
limit := c.QueryInt("limit", 50)
offset := c.QueryInt("offset", 0)
parentId := c.Query("parentId")
cursorRaw := strings.TrimSpace(c.Query("cursor"))
if limit <= 0 {
limit = 50
@@ -224,6 +239,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
var tenants []domain.Tenant
var total int64
var err error
nextCursor := ""
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
role := ""
@@ -291,21 +307,48 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
}
}
tenants, err = h.filterPrivateTenantsForProfile(c.Context(), tenants, profile)
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
total = int64(len(tenants))
if offset < len(tenants) {
if cursorRaw != "" {
tenants, nextCursor, err = pageTenantsByCursor(tenants, limit, cursorRaw)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else if offset < len(tenants) {
end := offset + limit
if end > len(tenants) {
end = len(tenants)
}
tenants = tenants[offset:end]
if total > int64(end) && len(tenants) > 0 {
last := tenants[len(tenants)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
}
} else {
tenants = []domain.Tenant{}
}
} else {
// Super Admin case
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
if cursorRaw != "" && h.DB != nil {
tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, parentId, cursorRaw)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else {
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId)
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
}
if total > int64(offset+len(tenants)) && len(tenants) > 0 {
last := tenants[len(tenants)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
}
}
}
@@ -321,7 +364,52 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
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,
Cursor: cursorRaw,
NextCursor: nextCursor,
})
}
func (h *TenantHandler) listTenantsByCursor(ctx context.Context, limit int, parentID string, cursorRaw string) ([]domain.Tenant, int64, string, error) {
cursor, err := pagination.Decode(cursorRaw)
if err != nil {
return nil, 0, "", err
}
countQuery := h.DB.WithContext(ctx).Model(&domain.Tenant{})
pageQuery := h.DB.WithContext(ctx).Model(&domain.Tenant{})
if parentID != "" {
countQuery = countQuery.Where("parent_id = ?", parentID)
pageQuery = pageQuery.Where("parent_id = ?", parentID)
}
var total int64
if err := countQuery.Count(&total).Error; err != nil {
return nil, 0, "", err
}
pageQuery = pagination.ApplyCreatedAtIDCursor(pageQuery, cursor, "created_at", "id")
var tenants []domain.Tenant
if err := pageQuery.
Order("created_at desc, id desc").
Limit(limit + 1).
Preload("Domains").
Find(&tenants).Error; err != nil {
return nil, 0, "", err
}
nextCursor := ""
if len(tenants) > limit {
tenants = tenants[:limit]
last := tenants[len(tenants)-1]
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
}
return tenants, total, nextCursor, nil
}
func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
@@ -330,6 +418,11 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
allTenants, err = h.filterPrivateTenantsForProfile(c.Context(), allTenants, profile)
if err != nil {
return errorJSON(c, fiber.StatusServiceUnavailable, err.Error())
}
tenants := filterTenantCSVDescendants(allTenants, parentID)
var buf bytes.Buffer
@@ -923,6 +1016,152 @@ func filterPublicTenants(tenants []domain.Tenant) []domain.Tenant {
return filtered
}
func (h *TenantHandler) filterPrivateTenantsForProfile(ctx context.Context, tenants []domain.Tenant, profile *domain.UserProfileResponse) ([]domain.Tenant, error) {
if profile != nil && domain.NormalizeRole(profile.Role) == domain.RoleSuperAdmin {
return tenants, nil
}
privateRoots := privateTenantRootIDs(tenants)
if len(privateRoots) == 0 {
return tenants, nil
}
allowedPrivateRoots := make(map[string]bool, len(privateRoots))
for _, rootID := range privateRoots {
allowed, err := h.canViewPrivateTenant(ctx, profile, rootID, tenants)
if err != nil {
return nil, err
}
if allowed {
allowedPrivateRoots[rootID] = true
}
}
excludedIDs := make(map[string]bool)
for _, rootID := range privateRoots {
if !allowedPrivateRoots[rootID] {
excludedIDs[rootID] = true
}
}
changed := true
for changed {
changed = false
for _, tenant := range tenants {
if tenant.ParentID != nil && excludedIDs[*tenant.ParentID] && !excludedIDs[tenant.ID] {
excludedIDs[tenant.ID] = true
changed = true
}
}
}
filtered := make([]domain.Tenant, 0, len(tenants))
for _, tenant := range tenants {
if !excludedIDs[tenant.ID] {
filtered = append(filtered, tenant)
}
}
return filtered, nil
}
func privateTenantRootIDs(tenants []domain.Tenant) []string {
tenantByID := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
tenantByID[tenant.ID] = tenant
}
roots := make([]string, 0)
for _, tenant := range tenants {
if tenantVisibility(tenant.Config) != "private" {
continue
}
if tenant.ParentID != nil {
parent, ok := tenantByID[*tenant.ParentID]
if ok && tenantVisibility(parent.Config) == "private" {
continue
}
}
roots = append(roots, tenant.ID)
}
return roots
}
func (h *TenantHandler) canViewPrivateTenant(ctx context.Context, profile *domain.UserProfileResponse, privateRootID string, tenants []domain.Tenant) (bool, error) {
if profile == nil {
return false, nil
}
if profileCanManageTenantOrAncestor(profile, privateRootID, tenants) {
return true, nil
}
if h.Keto == nil || strings.TrimSpace(profile.ID) == "" {
return false, nil
}
subject := "User:" + profile.ID
for _, relation := range []string{"view_private", "view_private_descendants", "view", "manage"} {
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", privateRootID, relation)
if err != nil {
return false, fmt.Errorf("private tenant permission check failed: %w", err)
}
if allowed {
return true, nil
}
}
for _, ancestorID := range tenantAncestorIDs(privateRootID, tenants) {
allowed, err := h.Keto.CheckPermission(ctx, subject, "Tenant", ancestorID, "view_private_descendants")
if err != nil {
return false, fmt.Errorf("private tenant descendant permission check failed: %w", err)
}
if allowed {
return true, nil
}
}
return false, nil
}
func profileCanManageTenantOrAncestor(profile *domain.UserProfileResponse, tenantID string, tenants []domain.Tenant) bool {
manageableIDs := make(map[string]bool, len(profile.ManageableTenants))
for _, tenant := range profile.ManageableTenants {
if tenant.ID != "" {
manageableIDs[tenant.ID] = true
}
}
if len(manageableIDs) == 0 {
return false
}
if manageableIDs[tenantID] {
return true
}
for _, ancestorID := range tenantAncestorIDs(tenantID, tenants) {
if manageableIDs[ancestorID] {
return true
}
}
return false
}
func tenantAncestorIDs(tenantID string, tenants []domain.Tenant) []string {
tenantByID := make(map[string]domain.Tenant, len(tenants))
for _, tenant := range tenants {
tenantByID[tenant.ID] = tenant
}
ancestors := make([]string, 0)
visited := map[string]bool{}
current, ok := tenantByID[tenantID]
for ok && current.ParentID != nil && *current.ParentID != "" {
parentID := *current.ParentID
if visited[parentID] {
break
}
visited[parentID] = true
ancestors = append(ancestors, parentID)
current, ok = tenantByID[parentID]
}
return ancestors
}
func normalizeTenantUserSchema(value any) ([]any, error) {
if value == nil {
return nil, nil
@@ -1948,25 +2187,28 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
}
includeUsers := !strings.EqualFold(strings.TrimSpace(c.Query("includeUsers")), "false")
contextUsers := []orgContextUser{}
includeUserIDs := strings.EqualFold(strings.TrimSpace(c.Query("includeUserIds")), "true")
membersByTenantID := make(map[string][]orgContextMember)
if includeUsers {
if h.UserRepo == nil {
return errorJSON(c, fiber.StatusServiceUnavailable, "user repository is not configured")
}
contextUsers, err = h.loadOrgContextUsers(c.Context(), tenantIDs, tenantSlugs, tenantByID, tenantBySlug)
membersByTenantID, err = h.loadOrgContextMembers(c.Context(), tenantIDs, tenantSlugs, tenantByID, tenantBySlug, includeUserIDs)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
}
}
directUserIDsByTenantID := make(map[string][]string)
for _, user := range contextUsers {
for _, tenantID := range user.TenantIDs {
directUserIDsByTenantID[tenantID] = append(directUserIDsByTenantID[tenantID], user.ID)
for i := range contextTenants {
members := membersByTenantID[contextTenants[i].ID]
if members == nil {
members = []orgContextMember{}
}
contextTenants[i].Members = members
tenantByID[contextTenants[i].ID] = contextTenants[i]
tenantBySlug[strings.ToLower(contextTenants[i].Slug)] = contextTenants[i]
}
tree := buildOrgContextTree(root.ID, scopedTenants, tenantByID, directUserIDsByTenantID)
tree := buildOrgContextTree(root.ID, scopedTenants, tenantByID)
return c.JSON(orgContextResponse{
SchemaVersion: "baron.org-context.v1",
IssuedAt: time.Now().UTC().Format(time.RFC3339),
@@ -1976,11 +2218,10 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
},
Tree: tree,
Tenants: contextTenants,
Users: contextUsers,
})
}
func (h *TenantHandler) loadOrgContextUsers(ctx context.Context, tenantIDs, tenantSlugs []string, tenantByID, tenantBySlug map[string]orgContextTenant) ([]orgContextUser, error) {
func (h *TenantHandler) loadOrgContextMembers(ctx context.Context, tenantIDs, tenantSlugs []string, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) (map[string][]orgContextMember, error) {
usersByID, err := h.UserRepo.FindByTenantIDs(ctx, tenantIDs)
if err != nil {
return nil, err
@@ -1989,21 +2230,29 @@ func (h *TenantHandler) loadOrgContextUsers(ctx context.Context, tenantIDs, tena
if err != nil {
return nil, err
}
usersByAppointment, _, err := h.UserRepo.List(ctx, 0, 10000, "", "")
if err != nil {
return nil, err
}
seen := make(map[string]bool)
contextUsers := make([]orgContextUser, 0, len(usersByID)+len(usersBySlug))
for _, user := range append(usersByID, usersBySlug...) {
membersByTenantID := make(map[string][]orgContextMember)
users := append(usersByID, usersBySlug...)
users = append(users, usersByAppointment...)
for _, user := range users {
if seen[user.ID] || user.Status != domain.UserStatusActive {
continue
}
mapped, ok := mapOrgContextUser(user, tenantByID, tenantBySlug)
if !ok {
assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs)
if len(assignments) == 0 {
continue
}
seen[user.ID] = true
contextUsers = append(contextUsers, mapped)
for _, assignment := range assignments {
membersByTenantID[assignment.TenantID] = append(membersByTenantID[assignment.TenantID], assignment.Member)
}
}
return contextUsers, nil
return membersByTenantID, nil
}
func findOrgContextTenantBySlug(tenants []domain.Tenant, slug string) (domain.Tenant, bool) {
@@ -2089,62 +2338,119 @@ func mapOrgContextTenant(tenant domain.Tenant) orgContextTenant {
Config: tenant.Config,
CreatedAt: tenant.CreatedAt.Format(time.RFC3339),
UpdatedAt: tenant.UpdatedAt.Format(time.RFC3339),
Members: []orgContextMember{},
}
}
func mapOrgContextUser(user domain.User, tenantByID, tenantBySlug map[string]orgContextTenant) (orgContextUser, bool) {
matchedTenants := make([]orgContextTenant, 0, 2)
func mapOrgContextMemberAssignments(user domain.User, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) []orgContextMemberAssignment {
assignments := make([]orgContextMemberAssignment, 0, 2)
seenTenants := map[string]bool{}
addTenant := func(tenant orgContextTenant, ok bool) {
appointments := tenantClaimAppointmentsFromTraits(map[string]any(user.Metadata))
addTenant := func(tenant orgContextTenant, ok bool, appointment map[string]any) {
if !ok || seenTenants[tenant.ID] {
return
}
seenTenants[tenant.ID] = true
matchedTenants = append(matchedTenants, tenant)
if appointment == nil {
appointment = lookupTenantClaimAppointment(appointments, tenant.ID, &domain.Tenant{
ID: tenant.ID,
Slug: tenant.Slug,
})
}
assignments = append(assignments, orgContextMemberAssignment{
TenantID: tenant.ID,
Member: mapOrgContextMember(user, appointment, includeUserIDs),
})
}
for _, appointment := range appointments {
for _, key := range []string{"tenantId", "tenant_id"} {
if tenantID := tenantClaimString(appointment, key); tenantID != "" {
addTenant(tenantByID[tenantID], tenantByID[tenantID].ID != "", appointment)
}
}
for _, key := range []string{"tenantSlug", "tenant_slug"} {
if tenantSlug := tenantClaimString(appointment, key); tenantSlug != "" {
tenant := tenantBySlug[strings.ToLower(tenantSlug)]
addTenant(tenant, tenant.ID != "", appointment)
}
}
}
if user.TenantID != nil {
addTenant(tenantByID[*user.TenantID], tenantByID[*user.TenantID].ID != "")
addTenant(tenantByID[*user.TenantID], tenantByID[*user.TenantID].ID != "", nil)
}
if user.Tenant != nil {
addTenant(tenantByID[user.Tenant.ID], tenantByID[user.Tenant.ID].ID != "")
addTenant(tenantBySlug[strings.ToLower(user.Tenant.Slug)], tenantBySlug[strings.ToLower(user.Tenant.Slug)].ID != "")
addTenant(tenantByID[user.Tenant.ID], tenantByID[user.Tenant.ID].ID != "", nil)
tenant := tenantBySlug[strings.ToLower(user.Tenant.Slug)]
addTenant(tenant, tenant.ID != "", nil)
}
if user.CompanyCode != "" {
addTenant(tenantBySlug[strings.ToLower(strings.TrimSpace(user.CompanyCode))], tenantBySlug[strings.ToLower(strings.TrimSpace(user.CompanyCode))].ID != "")
tenant := tenantBySlug[strings.ToLower(strings.TrimSpace(user.CompanyCode))]
addTenant(tenant, tenant.ID != "", nil)
}
for _, companyCode := range user.CompanyCodes {
addTenant(tenantBySlug[strings.ToLower(strings.TrimSpace(companyCode))], tenantBySlug[strings.ToLower(strings.TrimSpace(companyCode))].ID != "")
tenant := tenantBySlug[strings.ToLower(strings.TrimSpace(companyCode))]
addTenant(tenant, tenant.ID != "", nil)
}
if len(matchedTenants) == 0 {
return orgContextUser{}, false
}
tenantIDs := make([]string, 0, len(matchedTenants))
tenantSlugs := make([]string, 0, len(matchedTenants))
for _, tenant := range matchedTenants {
tenantIDs = append(tenantIDs, tenant.ID)
tenantSlugs = append(tenantSlugs, tenant.Slug)
}
return orgContextUser{
ID: user.ID,
Email: user.Email,
Name: user.Name,
Role: user.Role,
Status: user.Status,
TenantIDs: tenantIDs,
TenantSlugs: tenantSlugs,
Department: user.Department,
Grade: user.Grade,
Position: user.Position,
JobTitle: user.JobTitle,
Metadata: user.Metadata,
CreatedAt: user.CreatedAt.Format(time.RFC3339),
UpdatedAt: user.UpdatedAt.Format(time.RFC3339),
}, true
return assignments
}
func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[string]orgContextTenant, directUserIDsByTenantID map[string][]string) *orgContextTreeNode {
func mapOrgContextMember(user domain.User, appointment map[string]any, includeUserIDs bool) orgContextMember {
grade := user.Grade
position := user.Position
jobTitle := user.JobTitle
department := user.Department
if value := tenantClaimString(appointment, "grade"); value != "" {
grade = value
}
if value := tenantClaimString(appointment, "position"); value != "" {
position = value
}
if value := tenantClaimString(appointment, "jobTitle"); value != "" {
jobTitle = value
}
if value := tenantClaimString(appointment, "job_title"); value != "" {
jobTitle = value
}
if value := tenantClaimString(appointment, "department"); value != "" {
department = value
}
isOwner := false
if value, ok := metadataBoolFromMap(appointment, "isOwner", "isManager"); ok {
isOwner = value
}
isLeader := isOwner
if value, ok := metadataBoolFromMap(appointment, "lead", "isLead"); ok {
isLeader = value
}
isPrimary := false
if value, ok := metadataBoolFromMap(appointment, "representative", "isPrimary", "primary"); ok {
isPrimary = value
}
id := ""
phone := ""
if includeUserIDs {
id = user.ID
phone = user.Phone
}
return orgContextMember{
ID: id,
Email: user.Email,
Name: user.Name,
Phone: phone,
Department: department,
Grade: grade,
Position: position,
JobTitle: jobTitle,
IsOwner: isOwner,
IsLeader: isLeader,
IsPrimary: isPrimary,
}
}
func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[string]orgContextTenant) *orgContextTreeNode {
childrenByParentID := make(map[string][]domain.Tenant)
for _, tenant := range tenants {
if tenant.ParentID == nil {
@@ -2161,7 +2467,6 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
}
node := &orgContextTreeNode{
orgContextTenant: tenant,
DirectUserIDs: directUserIDsByTenantID[tenantID],
Children: []orgContextTreeNode{},
}
for _, child := range childrenByParentID[tenantID] {

View File

@@ -130,10 +130,10 @@ func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID stri
return nil, nil
}
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
func (m *MockUserRepoForHandler) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
for _, call := range m.ExpectedCalls {
if call.Method == "List" {
args := m.Called(ctx, offset, limit, search, companyCode)
args := m.Called(ctx, offset, limit, search, tenantSlug)
return args.Get(0).([]domain.User), args.Get(1).(int64), args.Error(2)
}
}
@@ -368,6 +368,205 @@ func TestTenantHandler_ListTenants(t *testing.T) {
}
}
func TestTenantHandler_ListTenantsReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: "super_admin",
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
createdAt := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC)
tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000002", Name: "Tenant B", Slug: "slug-b", CreatedAt: createdAt},
{ID: "00000000-0000-0000-0000-000000000001", Name: "Tenant A", Slug: "slug-a", CreatedAt: createdAt.Add(-time.Minute)},
}
mockSvc.On("ListTenants", mock.Anything, 2, 0, "").Return(tenants, int64(3), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, tenants).Return(map[string]int64{}, nil).Once()
req := httptest.NewRequest("GET", "/tenants?limit=2&offset=0", nil)
resp, _ := app.Test(req)
require.Equal(t, http.StatusOK, resp.StatusCode)
var res tenantListResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
require.Len(t, res.Items, 2)
require.NotEmpty(t, res.NextCursor)
}
func TestPageTenantsByCursorUsesStableCreatedAtAndIDOrder(t *testing.T) {
createdAt := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC)
tenants := []domain.Tenant{
{ID: "00000000-0000-0000-0000-000000000001", Name: "Tenant A", Slug: "slug-a", CreatedAt: createdAt},
{ID: "00000000-0000-0000-0000-000000000003", Name: "Tenant C", Slug: "slug-c", CreatedAt: createdAt},
{ID: "00000000-0000-0000-0000-000000000002", Name: "Tenant B", Slug: "slug-b", CreatedAt: createdAt},
}
page, nextCursor, err := pageTenantsByCursor(tenants, 2, "")
require.NoError(t, err)
require.NotEmpty(t, nextCursor)
require.Equal(t, []string{
"00000000-0000-0000-0000-000000000003",
"00000000-0000-0000-0000-000000000002",
}, []string{page[0].ID, page[1].ID})
nextPage, _, err := pageTenantsByCursor(tenants, 2, nextCursor)
require.NoError(t, err)
require.Equal(t, []string{"00000000-0000-0000-0000-000000000001"}, []string{nextPage[0].ID})
}
func TestTenantHandler_ListTenantsHidesPrivateSubtreeForUnauthorizedUser(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
{ID: "public-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "공개팀", Slug: "public-team"},
{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
{ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"},
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
TenantID: parent("company"),
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "public-team")
})).Return(map[string]int64{}, nil).Once()
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var res tenantListResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
require.Equal(t, int64(3), res.Total)
require.NotContains(t, toJSONString(t, res), "private-team")
require.NotContains(t, toJSONString(t, res), "private-child")
}
func TestTenantHandler_ListTenantsShowsPrivateSubtreeForManageableTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockProjection := new(MockUserProjectionRepoForHandler)
h := &TenantHandler{
Service: mockSvc,
UserProjectionRepo: mockProjection,
}
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
{ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"},
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
TenantID: parent("company"),
ManageableTenants: []domain.Tenant{
{ID: "private-team", Slug: "private-team"},
},
})
return c.Next()
})
app.Get("/tenants", h.ListTenants)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
mockProjection.On("IsReady", mock.Anything).Return(true, nil).Once()
mockProjection.On("CountTenantMembers", mock.Anything, mock.MatchedBy(func(got []domain.Tenant) bool {
return tenantSlugsMatch(got, "hanmac-family", "hanmac", "private-team", "private-child")
})).Return(map[string]int64{}, nil).Once()
req := httptest.NewRequest(http.MethodGet, "/tenants?limit=100&offset=0", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var res tenantListResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
require.Equal(t, int64(4), res.Total)
require.Contains(t, toJSONString(t, res), "private-team")
require.Contains(t, toJSONString(t, res), "private-child")
}
func TestTenantHandler_FilterPrivateTenantsAllowsExplicitPrivatePermission(t *testing.T) {
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
{ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"},
}
mockKeto := new(devMockKetoService)
h := &TenantHandler{Keto: mockKeto}
mockKeto.On("CheckPermission", mock.Anything, "User:user-1", "Tenant", "private-team", "view_private").Return(true, nil).Once()
filtered, err := h.filterPrivateTenantsForProfile(context.Background(), tenants, &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
TenantID: parent("company"),
})
require.NoError(t, err)
require.True(t, tenantSlugsMatch(filtered, "hanmac-family", "hanmac", "private-team", "private-child"))
mockKeto.AssertExpectations(t)
}
func tenantSlugsMatch(got []domain.Tenant, want ...string) bool {
if len(got) != len(want) {
return false
}
counts := make(map[string]int, len(want))
for _, slug := range want {
counts[slug]++
}
for _, tenant := range got {
counts[tenant.Slug]--
if counts[tenant.Slug] < 0 {
return false
}
}
return true
}
func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -391,15 +590,60 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company-hanmac"), Name: "비공개", Slug: "private-team", Status: domain.TenantStatusActive, Config: domain.JSONMap{"visibility": "private"}, CreatedAt: now, UpdatedAt: now},
}
usersByTenantID := []domain.User{
{ID: "user-platform-lead", Email: "lead@example.com", Name: "플랫폼 리드", Status: domain.UserStatusActive, TenantID: parent("dept-platform"), CompanyCode: "platform", Grade: "책임", Position: "실장", CreatedAt: now, UpdatedAt: now},
{
ID: "user-platform-lead",
Email: "lead@example.com",
Name: "플랫폼 리드",
Phone: "010-1111-2222",
Status: domain.UserStatusActive,
TenantID: parent("dept-platform"),
CompanyCode: "platform",
Grade: "책임",
Position: "실장",
JobTitle: "Backend Engineer",
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantId": "dept-platform",
"isPrimary": true,
"isOwner": true,
"grade": "수석",
"position": "실장",
"jobTitle": "기술기획",
},
},
},
CreatedAt: now,
UpdatedAt: now,
},
}
usersBySlug := []domain.User{
{ID: "user-sso-member", Email: "member@example.com", Name: "SSO 구성원", Status: domain.UserStatusActive, CompanyCode: "sso", Grade: "선임", CreatedAt: now, UpdatedAt: now},
}
usersByList := []domain.User{
{
ID: "user-appointment-only",
Email: "appointment@example.com",
Name: "겸직 사용자",
Status: domain.UserStatusActive,
Metadata: domain.JSONMap{
"additionalAppointments": []any{
map[string]any{
"tenantSlug": "sso",
"lead": true,
"position": "파트장",
},
},
},
CreatedAt: now,
UpdatedAt: now,
},
}
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"group-hanmac-family", "company-hanmac", "dept-platform", "team-sso"}).Return(usersByTenantID, nil)
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac-family", "hanmac", "platform", "sso"}).Return(usersBySlug, nil)
mockUsers.On("List", mock.Anything, 0, 10000, "", "").Return(usersByList, int64(len(usersByList)), nil)
req := httptest.NewRequest(http.MethodGet, "/org-context", nil)
resp, err := app.Test(req)
@@ -421,18 +665,96 @@ func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testi
require.Equal(t, "dept-platform", tenantsPayload[2].(map[string]any)["id"])
require.Equal(t, "team-sso", tenantsPayload[3].(map[string]any)["id"])
usersPayload := got["users"].([]any)
require.Len(t, usersPayload, 2)
require.Equal(t, "user-platform-lead", usersPayload[0].(map[string]any)["id"])
require.Equal(t, []any{"dept-platform"}, usersPayload[0].(map[string]any)["tenantIds"])
require.Equal(t, "user-sso-member", usersPayload[1].(map[string]any)["id"])
require.NotContains(t, got, "users")
deptPlatform := tenantsPayload[2].(map[string]any)
platformMembers := deptPlatform["members"].([]any)
require.Len(t, platformMembers, 1)
firstUser := platformMembers[0].(map[string]any)
require.NotContains(t, firstUser, "id")
require.NotContains(t, firstUser, "phone")
require.NotContains(t, firstUser, "tenantIds")
require.NotContains(t, firstUser, "tenantSlugs")
require.NotContains(t, firstUser, "memberships")
require.NotContains(t, firstUser, "role")
require.NotContains(t, firstUser, "status")
require.NotContains(t, firstUser, "metadata")
require.NotContains(t, firstUser, "createdAt")
require.NotContains(t, firstUser, "updatedAt")
require.Equal(t, "lead@example.com", firstUser["email"])
require.Equal(t, "플랫폼 리드", firstUser["name"])
require.Equal(t, true, firstUser["isOwner"])
require.Equal(t, true, firstUser["isLeader"])
require.Equal(t, true, firstUser["isPrimary"])
require.Equal(t, "수석", firstUser["grade"])
require.Equal(t, "실장", firstUser["position"])
require.Equal(t, "기술기획", firstUser["jobTitle"])
teamSSO := tenantsPayload[3].(map[string]any)
ssoMembers := teamSSO["members"].([]any)
require.Len(t, ssoMembers, 2)
appointmentOnly := ssoMembers[1].(map[string]any)
require.Equal(t, "appointment@example.com", appointmentOnly["email"])
require.Equal(t, false, appointmentOnly["isOwner"])
require.Equal(t, true, appointmentOnly["isLeader"])
tree := got["tree"].(map[string]any)
require.Equal(t, "group-hanmac-family", tree["id"])
require.NotContains(t, tree, "directUserIds")
require.Contains(t, tree, "members")
require.NotContains(t, toJSONString(t, got), "directUserIds")
require.NotContains(t, toJSONString(t, got), "private-team")
require.NotContains(t, toJSONString(t, got), "root-other")
}
func TestTenantHandler_GetOrgContextJSONIncludesUserIDsOnlyWhenRequested(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
mockUsers := new(MockUserRepoForHandler)
h := &TenantHandler{Service: mockSvc, UserRepo: mockUsers}
app.Use(func(c *fiber.Ctx) error {
c.Locals("apiKeyName", "orgfront-ssot-client")
return c.Next()
})
app.Get("/org-context", h.GetOrgContext)
now := time.Date(2026, 5, 13, 12, 0, 0, 0, time.UTC)
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "company-hanmac", Type: domain.TenantTypeCompany, Name: "한맥기술", Slug: "hanmac", Status: domain.TenantStatusActive, CreatedAt: now, UpdatedAt: now},
}
users := []domain.User{
{ID: "user-1", Email: "user@example.com", Name: "사용자", Phone: "010-1234-5678", Status: domain.UserStatusActive, TenantID: parent("company-hanmac"), CompanyCode: "hanmac", CreatedAt: now, UpdatedAt: now},
}
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil)
mockUsers.On("FindByTenantIDs", mock.Anything, []string{"company-hanmac"}).Return(users, nil)
mockUsers.On("FindByCompanyCodes", mock.Anything, []string{"hanmac"}).Return([]domain.User{}, nil)
req := httptest.NewRequest(http.MethodGet, "/org-context?tenantSlug=hanmac&includeUserIds=true", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var got map[string]any
require.NoError(t, json.NewDecoder(resp.Body).Decode(&got))
require.NotContains(t, got, "users")
tenantsPayload := got["tenants"].([]any)
members := tenantsPayload[0].(map[string]any)["members"].([]any)
require.Len(t, members, 1)
member := members[0].(map[string]any)
require.Equal(t, "user-1", member["id"])
require.Equal(t, "010-1234-5678", member["phone"])
require.NotContains(t, member, "tenantIds")
require.NotContains(t, member, "tenantSlugs")
require.NotContains(t, member, "memberships")
tree := got["tree"].(map[string]any)
treeMembers := tree["members"].([]any)
require.Len(t, treeMembers, 1)
require.Equal(t, "user-1", treeMembers[0].(map[string]any)["id"])
require.Equal(t, "010-1234-5678", treeMembers[0].(map[string]any)["phone"])
require.NotContains(t, tree, "directUserIds")
}
func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
@@ -697,6 +1019,44 @@ func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *t
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_ExportTenantsCSV_HidesPrivateSubtreeForUnauthorizedUser(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)
h := &TenantHandler{Service: mockSvc}
parent := func(id string) *string { return &id }
tenants := []domain.Tenant{
{ID: "family", Type: domain.TenantTypeCompanyGroup, Name: "한맥가족", Slug: "hanmac-family"},
{ID: "company", Type: domain.TenantTypeCompany, ParentID: parent("family"), Name: "한맥", Slug: "hanmac"},
{ID: "public-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "공개팀", Slug: "public-team"},
{ID: "private-team", Type: domain.TenantTypeUserGroup, ParentID: parent("company"), Name: "비공개팀", Slug: "private-team", Config: domain.JSONMap{"visibility": "private"}},
{ID: "private-child", Type: domain.TenantTypeUserGroup, ParentID: parent("private-team"), Name: "비공개하위", Slug: "private-child"},
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "user-1",
Role: domain.RoleTenantAdmin,
TenantID: parent("company"),
})
return c.Next()
})
app.Get("/tenants/export", h.ExportTenantsCSV)
mockSvc.On("ListTenants", mock.Anything, 10000, 0, "").Return(tenants, int64(len(tenants)), nil).Once()
req := httptest.NewRequest("GET", "/tenants/export?includeIds=true", nil)
resp, _ := app.Test(req)
body, _ := io.ReadAll(resp.Body)
text := string(body)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Contains(t, text, "public-team")
assert.NotContains(t, text, "private-team")
assert.NotContains(t, text, "private-child")
mockSvc.AssertExpectations(t)
}
func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
app := fiber.New()
mockSvc := new(MockTenantService)

View File

@@ -2,6 +2,7 @@ package handler
import (
"baron-sso-backend/internal/domain"
"baron-sso-backend/internal/pagination"
"baron-sso-backend/internal/repository"
"baron-sso-backend/internal/service"
"baron-sso-backend/internal/utils"
@@ -80,6 +81,20 @@ func mergeUserAppointmentMetadata(metadata map[string]any, appointments []map[st
return metadata
}
func sanitizeUserMetadata(metadata map[string]any) map[string]any {
if metadata == nil {
return nil
}
sanitized := make(map[string]any, len(metadata))
for key, value := range metadata {
if key == "hanmacFamily" || key == "userType" {
continue
}
sanitized[key] = value
}
return sanitized
}
func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string {
if value := strings.TrimSpace(primaryTenantID); value != "" {
return value
@@ -206,6 +221,25 @@ func gradeFromTraits(traits map[string]interface{}) string {
return value
}
func tenantSlugFromRequest(tenantSlug string, legacyCompanyCode string) string {
if value := strings.TrimSpace(tenantSlug); value != "" {
return value
}
return strings.TrimSpace(legacyCompanyCode)
}
func tenantSlugPointerFromRequest(tenantSlug *string, legacyCompanyCode *string) *string {
if tenantSlug != nil {
value := strings.TrimSpace(*tenantSlug)
return &value
}
if legacyCompanyCode != nil {
value := strings.TrimSpace(*legacyCompanyCode)
return &value
}
return nil
}
type userSummary struct {
ID string `json:"id"`
Email string `json:"email"`
@@ -215,6 +249,7 @@ type userSummary struct {
Phone string `json:"phone"`
Role string `json:"role"`
Status string `json:"status"`
TenantSlug string `json:"tenantSlug,omitempty"`
CompanyCode string `json:"companyCode"`
Metadata domain.JSONMap `json:"metadata,omitempty"`
Tenant *domain.Tenant `json:"tenant,omitempty"`
@@ -229,10 +264,20 @@ type userSummary struct {
}
type userListResponse struct {
Items []userSummary `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int64 `json:"total"`
Items []userSummary `json:"items"`
Limit int `json:"limit"`
Offset int `json:"offset"`
Total int64 `json:"total"`
Cursor string `json:"cursor,omitempty"`
NextCursor string `json:"nextCursor,omitempty"`
}
func kratosIdentityCursorKey(identity service.KratosIdentity) (time.Time, string) {
timestamp := identity.CreatedAt
if timestamp.IsZero() {
timestamp = time.Unix(0, 0).UTC()
}
return timestamp, identity.ID
}
func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
@@ -246,6 +291,7 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
offset := c.QueryInt("offset", 0)
search := strings.TrimSpace(c.Query("search"))
tenantSlug := strings.TrimSpace(c.Query("tenantSlug"))
cursorRaw := strings.TrimSpace(c.Query("cursor"))
if limit <= 0 {
limit = 50
@@ -399,17 +445,33 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
filtered = append(filtered, identity)
}
pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
total := int64(len(filtered))
if offset > len(filtered) {
offset = len(filtered)
}
end := offset + limit
if end > len(filtered) {
end = len(filtered)
nextCursor := ""
var pageIdentities []service.KratosIdentity
if cursorRaw != "" {
pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey)
if err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid cursor")
}
offset = 0
} else {
if offset > len(filtered) {
offset = len(filtered)
}
end := offset + limit
if end > len(filtered) {
end = len(filtered)
}
pageIdentities = filtered[offset:end]
if total > int64(end) && len(pageIdentities) > 0 {
lastTimestamp, lastID := kratosIdentityCursorKey(pageIdentities[len(pageIdentities)-1])
nextCursor = pagination.Encode(lastTimestamp, lastID)
}
}
items := make([]userSummary, 0, end-offset)
for _, identity := range filtered[offset:end] {
items := make([]userSummary, 0, len(pageIdentities))
for _, identity := range pageIdentities {
summary := h.mapIdentitySummary(c.Context(), identity)
items = append(items, summary)
}
@@ -427,7 +489,14 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
}(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,
Cursor: cursorRaw,
NextCursor: nextCursor,
})
}
slog.Warn("Kratos unavailable for user list", "error", err)
@@ -490,6 +559,7 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
Name string `json:"name"`
Phone string `json:"phone"`
Role string `json:"role"`
TenantSlug string `json:"tenantSlug"`
CompanyCode string `json:"companyCode"`
Department string `json:"department"`
Grade string `json:"grade"`
@@ -504,7 +574,8 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.Metadata = mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner)
req.CompanyCode = tenantSlugFromRequest(req.TenantSlug, req.CompanyCode)
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
email := strings.TrimSpace(req.Email)
if email == "" {
@@ -724,6 +795,7 @@ type bulkUserItem struct {
Role string `json:"role"`
TenantID string `json:"tenantId"`
TenantSlug string `json:"tenantSlug"`
CompanyCode string `json:"companyCode"`
EmailDomain string `json:"emailDomain"`
Department string `json:"department"`
Grade string `json:"grade"`
@@ -881,7 +953,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
email := strings.TrimSpace(item.Email)
name := strings.TrimSpace(item.Name)
tenantID := strings.TrimSpace(item.TenantID)
tenantSlug := strings.TrimSpace(item.TenantSlug)
tenantSlug := tenantSlugFromRequest(item.TenantSlug, item.CompanyCode)
dept := strings.TrimSpace(item.Department)
if email == "" || name == "" {
@@ -1054,6 +1126,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
}
item.Metadata["additionalAppointments"] = resolvedAppointments
}
item.Metadata = sanitizeUserMetadata(item.Metadata)
password, _ := utils.GeneratePasswordWithPolicy(policy)
role := item.Role
@@ -1218,10 +1291,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
search := strings.TrimSpace(c.Query("search"))
companyCode := strings.TrimSpace(c.Query("companyCode"))
if companyCode == "" {
companyCode = strings.TrimSpace(c.Query("tenantSlug"))
}
tenantSlug := tenantSlugFromRequest(c.Query("tenantSlug"), c.Query("companyCode"))
var requesterRole string
var manageableSlugs []string
@@ -1269,8 +1339,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
}
// 1. Fetch Users using Repo for efficiency
// repo.List expects (ctx, offset, limit, search, companyCode)
users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, companyCode)
users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, tenantSlug)
if err != nil {
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export")
}
@@ -1386,6 +1455,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
UserIDs []string `json:"userIds"`
Status *string `json:"status"`
Role *string `json:"role"`
TenantSlug *string `json:"tenantSlug"`
CompanyCode *string `json:"companyCode"`
Department *string `json:"department"`
Grade *string `json:"grade"`
@@ -1395,6 +1465,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.CompanyCode = tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
if len(req.UserIDs) == 0 {
return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided")
@@ -1404,6 +1475,16 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
if requester == nil {
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
}
if req.Role != nil {
if domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
}
role, ok := domain.NormalizeRoleAlias(*req.Role)
if !ok {
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
}
*req.Role = role
}
// [New] Pre-fetch tenant cache if companyCode is being changed
type tenantCacheItem struct {
@@ -1683,6 +1764,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
Phone *string `json:"phone"`
Role *string `json:"role"`
Status *string `json:"status"`
TenantSlug *string `json:"tenantSlug"`
CompanyCode *string `json:"companyCode"`
IsAddTenant bool `json:"isAddTenant"`
IsRemoveTenant bool `json:"isRemoveTenant"`
@@ -1699,7 +1781,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if err := c.BodyParser(&req); err != nil {
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
}
req.Metadata = mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner)
req.CompanyCode = tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
req.Metadata = sanitizeUserMetadata(mergeUserAppointmentMetadata(req.Metadata, req.AdditionalAppointments, req.PrimaryTenantID, req.PrimaryTenantName, req.PrimaryTenantIsOwner))
if req.Role != nil {
if requester == nil || domain.NormalizeRole(requester.Role) != domain.RoleSuperAdmin {
return errorJSON(c, fiber.StatusForbidden, "forbidden: only super admin can change user role")
}
role, ok := domain.NormalizeRoleAlias(*req.Role)
if !ok {
return errorJSON(c, fiber.StatusBadRequest, "invalid role")
}
*req.Role = role
}
// [New] Tenant Admin restriction: Cannot change companyCode (except when adding/removing secondary membership)
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
@@ -1754,6 +1847,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
if traits == nil {
traits = map[string]interface{}{}
}
delete(traits, "hanmacFamily")
delete(traits, "userType")
// [Preserve & Merge] Multi-Tenant Info
var existingCodes []string
@@ -2191,6 +2286,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
Phone: extractTraitString(traits, "phone_number"),
Role: role,
Status: normalizeStatus(identity.State),
TenantSlug: compCode,
CompanyCode: compCode,
Department: extractTraitString(traits, "department"),
Grade: gradeFromTraits(traits),

View File

@@ -17,6 +17,7 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/stretchr/testify/require"
)
// --- Mocks ---
@@ -118,6 +119,22 @@ func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx contex
return nil
}
func TestSanitizeUserMetadataRemovesLegacyClassificationFlags(t *testing.T) {
metadata := map[string]any{
"hanmacFamily": true,
"userType": "hanmac",
"employeeId": "E001",
}
sanitized := sanitizeUserMetadata(metadata)
assert.NotContains(t, sanitized, "hanmacFamily")
assert.NotContains(t, sanitized, "userType")
assert.Equal(t, "E001", sanitized["employeeId"])
assert.Contains(t, metadata, "hanmacFamily")
assert.Contains(t, metadata, "userType")
}
type MockTenantServiceForUser struct {
mock.Mock
service.TenantService
@@ -693,6 +710,40 @@ func TestUserHandler_ListUsersReturnsServiceUnavailableWhenKratosFails(t *testin
mockKratos.AssertExpectations(t)
}
func TestUserHandler_ListUsersReturnsNextCursorWhenMoreRowsExist(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
createdAt := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC)
h := &UserHandler{KratosAdmin: mockKratos}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Get("/users", h.ListUsers)
mockKratos.On("ListIdentities", mock.Anything).Return([]service.KratosIdentity{
{ID: "u-3", State: "active", CreatedAt: createdAt, Traits: map[string]interface{}{"email": "c@example.com", "name": "C"}},
{ID: "u-2", State: "active", CreatedAt: createdAt.Add(-time.Minute), Traits: map[string]interface{}{"email": "b@example.com", "name": "B"}},
{ID: "u-1", State: "active", CreatedAt: createdAt.Add(-2 * time.Minute), Traits: map[string]interface{}{"email": "a@example.com", "name": "A"}},
}, nil).Once()
req := httptest.NewRequest("GET", "/users?limit=2", nil)
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
var res userListResponse
require.NoError(t, json.NewDecoder(resp.Body).Decode(&res))
require.Len(t, res.Items, 2)
require.NotEmpty(t, res.NextCursor)
require.Equal(t, int64(3), res.Total)
}
func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
@@ -904,6 +955,27 @@ func TestUserHandler_BulkUpdateUsers(t *testing.T) {
assert.Equal(t, "u-1", worksmobile.upserts[0].ID)
assert.Equal(t, domain.UserStatusInactive, worksmobile.upserts[0].Status)
})
t.Run("Fail - Tenant admin cannot update role", func(t *testing.T) {
app := fiber.New()
h := &UserHandler{KratosAdmin: new(MockKratosAdmin)}
app.Put("/users/bulk", func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{Role: domain.RoleTenantAdmin})
return h.BulkUpdateUsers(c)
})
role := domain.RoleSuperAdmin
payload := map[string]interface{}{
"userIds": []string{"u-1"},
"role": &role,
}
body, _ := json.Marshal(payload)
req := httptest.NewRequest("PUT", "/users/bulk", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, _ := app.Test(req)
assert.Equal(t, fiber.StatusForbidden, resp.StatusCode)
})
}
func TestUserHandler_BulkDeleteUsers(t *testing.T) {
@@ -1381,7 +1453,8 @@ func TestUserHandler_CreateUser_UsesAdditionalAppointmentAsPrimaryTenant(t *test
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
return user.Attributes["tenant_id"] == tenantID &&
user.Attributes["companyCode"] == "saman" &&
user.Attributes["additionalAppointments"] != nil
user.Attributes["additionalAppointments"] != nil &&
user.Attributes["userType"] == nil
}), mock.Anything).Return("u-appointment", nil).Once()
mockKratos.On("GetIdentity", mock.Anything, "u-appointment").Return(&service.KratosIdentity{
ID: "u-appointment",
@@ -1498,6 +1571,171 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
mockKratos.AssertExpectations(t)
}
func TestUserHandler_CreateUserAcceptsTenantSlugWithoutCompanyCode(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockOry := new(MockOryProvider)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
OryProvider: mockOry,
TenantService: mockTenant,
}
app.Post("/users", h.CreateUser)
mockOry.On("GetPasswordPolicy").Return(&domain.PasswordPolicy{MinLength: 8}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "test-tenant").Return(&domain.Tenant{
ID: "tenant-id",
Slug: "test-tenant",
}, nil).Twice()
mockTenant.On("GetTenant", mock.Anything, "tenant-id").Return(&domain.Tenant{
ID: "tenant-id",
Slug: "test-tenant",
Config: domain.JSONMap{},
}, nil).Once()
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
return user.Attributes["companyCode"] == "test-tenant" &&
user.Attributes["tenant_id"] == "tenant-id"
}), "Password1!").Return("user-id", nil).Once()
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"companyCode": "test-tenant",
"tenant_id": "tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
body := `{"email":"user@test.com","password":"Password1!","name":"Test User","tenantSlug":"test-tenant"}`
req := httptest.NewRequest(http.MethodPost, "/users", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusCreated, resp.StatusCode)
mockTenant.AssertExpectations(t)
mockOry.AssertExpectations(t)
mockKratos.AssertExpectations(t)
}
func TestUserHandler_UpdateUserAcceptsTenantSlugWithoutCompanyCode(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Put("/users/:id", h.UpdateUser)
identity := &service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"companyCode": "old-tenant",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(identity, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
}, nil).Twice()
mockTenant.On("GetTenant", mock.Anything, "new-tenant-id").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
Config: domain.JSONMap{},
}, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool {
return traits["companyCode"] == "new-tenant" &&
traits["tenant_id"] == "new-tenant-id"
}), "").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"companyCode": "new-tenant",
"tenant_id": "new-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
body := `{"tenantSlug":"new-tenant"}`
req := httptest.NewRequest(http.MethodPut, "/users/user-id", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
mockTenant.AssertExpectations(t)
mockKratos.AssertExpectations(t)
}
func TestUserHandler_BulkUpdateUsersAcceptsTenantSlugWithoutCompanyCode(t *testing.T) {
app := fiber.New()
mockKratos := new(MockKratosAdmin)
mockTenant := new(MockTenantServiceForUser)
h := &UserHandler{
KratosAdmin: mockKratos,
TenantService: mockTenant,
}
app.Use(func(c *fiber.Ctx) error {
c.Locals("user_profile", &domain.UserProfileResponse{
ID: "admin-id",
Role: domain.RoleSuperAdmin,
})
return c.Next()
})
app.Put("/users/bulk", h.BulkUpdateUsers)
mockKratos.On("GetIdentity", mock.Anything, "user-id").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"companyCode": "old-tenant",
"tenant_id": "old-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
mockTenant.On("GetTenantBySlug", mock.Anything, "new-tenant").Return(&domain.Tenant{
ID: "new-tenant-id",
Slug: "new-tenant",
}, nil).Once()
mockKratos.On("UpdateIdentity", mock.Anything, "user-id", mock.MatchedBy(func(traits map[string]interface{}) bool {
return traits["companyCode"] == "new-tenant" &&
traits["tenant_id"] == "new-tenant-id"
}), "active").Return(&service.KratosIdentity{
ID: "user-id",
State: "active",
Traits: map[string]interface{}{
"email": "user@test.com",
"name": "Test User",
"companyCode": "new-tenant",
"tenant_id": "new-tenant-id",
"role": domain.RoleUser,
},
}, nil).Once()
body := `{"userIds":["user-id"],"tenantSlug":"new-tenant"}`
req := httptest.NewRequest(http.MethodPut, "/users/bulk", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := app.Test(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, resp.StatusCode)
mockTenant.AssertExpectations(t)
mockKratos.AssertExpectations(t)
}
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
handler := &UserHandler{}
identity := service.KratosIdentity{

View File

@@ -0,0 +1,103 @@
package pagination
import (
"encoding/base64"
"encoding/json"
"errors"
"sort"
"strings"
"time"
"gorm.io/gorm"
)
type Cursor struct {
Timestamp time.Time `json:"timestamp"`
ID string `json:"id"`
}
func Encode(timestamp time.Time, id string) string {
if timestamp.IsZero() || strings.TrimSpace(id) == "" {
return ""
}
payload, err := json.Marshal(Cursor{
Timestamp: timestamp.UTC(),
ID: id,
})
if err != nil {
return ""
}
return base64.RawURLEncoding.EncodeToString(payload)
}
func Decode(raw string) (*Cursor, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, nil
}
decoded, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return nil, err
}
var cursor Cursor
if err := json.Unmarshal(decoded, &cursor); err != nil {
return nil, err
}
if cursor.Timestamp.IsZero() || strings.TrimSpace(cursor.ID) == "" {
return nil, errors.New("invalid cursor")
}
return &cursor, nil
}
func ComesAfter(timestamp time.Time, id string, cursor *Cursor) bool {
if cursor == nil {
return true
}
if timestamp.Before(cursor.Timestamp) {
return true
}
return timestamp.Equal(cursor.Timestamp) && id < cursor.ID
}
func SortByKeyDesc[T any](items []T, key func(T) (time.Time, string)) {
sort.SliceStable(items, func(i, j int) bool {
leftTime, leftID := key(items[i])
rightTime, rightID := key(items[j])
if !leftTime.Equal(rightTime) {
return leftTime.After(rightTime)
}
return leftID > rightID
})
}
func PageByCursor[T any](items []T, limit int, cursorRaw string, key func(T) (time.Time, string)) ([]T, string, error) {
cursor, err := Decode(cursorRaw)
if err != nil {
return nil, "", err
}
filtered := make([]T, 0, len(items))
for _, item := range items {
timestamp, id := key(item)
if ComesAfter(timestamp, id, cursor) {
filtered = append(filtered, item)
}
}
if len(filtered) <= limit {
return filtered, "", nil
}
page := filtered[:limit]
lastTimestamp, lastID := key(page[len(page)-1])
return page, Encode(lastTimestamp, lastID), nil
}
func ApplyCreatedAtIDCursor(db *gorm.DB, cursor *Cursor, createdAtColumn string, idColumn string) *gorm.DB {
if cursor == nil {
return db
}
return db.Where(
createdAtColumn+" < ? OR ("+createdAtColumn+" = ? AND "+idColumn+" < ?)",
cursor.Timestamp,
cursor.Timestamp,
cursor.ID,
)
}

View File

@@ -0,0 +1,55 @@
package pagination
import (
"testing"
"time"
"github.com/stretchr/testify/require"
)
type testItem struct {
id string
createdAt time.Time
}
func TestPageByCursorReturnsNextCursorAndNextPage(t *testing.T) {
now := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC)
items := []testItem{
{id: "c", createdAt: now},
{id: "b", createdAt: now.Add(-time.Minute)},
{id: "a", createdAt: now.Add(-2 * time.Minute)},
}
key := func(item testItem) (time.Time, string) {
return item.createdAt, item.id
}
firstPage, nextCursor, err := PageByCursor(items, 2, "", key)
require.NoError(t, err)
require.Len(t, firstPage, 2)
require.NotEmpty(t, nextCursor)
secondPage, nextCursor, err := PageByCursor(items, 2, nextCursor, key)
require.NoError(t, err)
require.Len(t, secondPage, 1)
require.Empty(t, nextCursor)
require.Equal(t, "a", secondPage[0].id)
}
func TestSortByKeyDescUsesIDAsTieBreaker(t *testing.T) {
now := time.Date(2026, 5, 13, 8, 0, 0, 0, time.UTC)
items := []testItem{
{id: "a", createdAt: now},
{id: "c", createdAt: now},
{id: "b", createdAt: now},
}
SortByKeyDesc(items, func(item testItem) (time.Time, string) {
return item.createdAt, item.id
})
require.Equal(t, []string{"c", "b", "a"}, []string{
items[0].id,
items[1].id,
items[2].id,
})
}

View File

@@ -4,6 +4,7 @@ import (
"baron-sso-backend/internal/domain"
"context"
"errors"
"strconv"
"strings"
"time"
@@ -33,7 +34,19 @@ func NewTenantRepository(db *gorm.DB) TenantRepository {
}
func (r *tenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error {
return r.db.WithContext(ctx).Create(tenant).Error
tenant.Slug = strings.ToLower(strings.TrimSpace(tenant.Slug))
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if tenant.Slug != "" {
suffix := "-deleted-" + strconv.FormatInt(time.Now().UTC().UnixNano(), 10)
if err := tx.Unscoped().
Model(&domain.Tenant{}).
Where("slug = ? AND deleted_at IS NOT NULL", tenant.Slug).
Update("slug", gorm.Expr("slug || ?", suffix)).Error; err != nil {
return err
}
}
return tx.Create(tenant).Error
})
}
func (r *tenantRepository) Update(ctx context.Context, tenant *domain.Tenant) error {
@@ -124,7 +137,7 @@ func (r *tenantRepository) List(ctx context.Context, limit, offset int, parentID
return nil, 0, err
}
if err := db.Order("created_at desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
if err := db.Order("created_at desc, id desc").Limit(limit).Offset(offset).Preload("Domains").Find(&tenants).Error; err != nil {
return nil, 0, err
}

View File

@@ -6,6 +6,7 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTenantRepository(t *testing.T) {
@@ -161,4 +162,31 @@ func TestTenantRepository(t *testing.T) {
err = repo.Create(ctx, tenant2)
assert.Error(t, err) // Should fail due to UNIQUE constraint
})
t.Run("Create reuses slug held by legacy soft-deleted tenant", func(t *testing.T) {
slug := "legacy-soft-delete-reuse"
require.NoError(t, testDB.Unscoped().Where("slug = ?", slug).Delete(&domain.Tenant{}).Error)
legacy := &domain.Tenant{
Name: "Legacy Deleted",
Slug: slug,
Type: domain.TenantTypeCompany,
}
require.NoError(t, repo.Create(ctx, legacy))
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", legacy.ID).Error)
_, err := repo.FindBySlug(ctx, slug)
require.Error(t, err)
replacement := &domain.Tenant{
Name: "Replacement",
Slug: slug,
Type: domain.TenantTypeCompany,
}
require.NoError(t, repo.Create(ctx, replacement))
found, err := repo.FindBySlug(ctx, slug)
require.NoError(t, err)
assert.Equal(t, replacement.ID, found.ID)
})
}

View File

@@ -0,0 +1,56 @@
package repository
import (
"context"
"gorm.io/gorm"
)
func ClearOrphanUserTenantMemberships(ctx context.Context, db *gorm.DB) (int64, error) {
result := db.WithContext(ctx).Exec(`
WITH orphan_users AS (
SELECT u.id
FROM users AS u
WHERE u.deleted_at IS NULL
AND (
(
u.tenant_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE t.id = u.tenant_id
AND t.deleted_at IS NULL
)
)
OR (
NULLIF(BTRIM(u.company_code), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE LOWER(t.slug) = LOWER(BTRIM(u.company_code))
AND t.deleted_at IS NULL
)
)
OR EXISTS (
SELECT 1
FROM UNNEST(COALESCE(u.company_codes, ARRAY[]::text[])) AS code(value)
WHERE NULLIF(BTRIM(code.value), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE LOWER(t.slug) = LOWER(BTRIM(code.value))
AND t.deleted_at IS NULL
)
)
)
)
UPDATE users AS u
SET tenant_id = NULL,
company_code = '',
company_codes = NULL,
updated_at = NOW()
FROM orphan_users AS ou
WHERE u.id = ou.id
`)
return result.RowsAffected, result.Error
}

View File

@@ -0,0 +1,63 @@
package repository
import (
"baron-sso-backend/internal/domain"
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestClearOrphanUserTenantMemberships(t *testing.T) {
ctx := context.Background()
repo := NewUserRepository(testDB)
tenantRepo := NewTenantRepository(testDB)
require.NoError(t, testDB.Exec("DELETE FROM user_login_ids").Error)
require.NoError(t, testDB.Exec("DELETE FROM users").Error)
require.NoError(t, testDB.Exec("DELETE FROM tenant_domains").Error)
require.NoError(t, testDB.Unscoped().Where("slug IN ?", []string{"orphan-active", "orphan-deleted"}).Delete(&domain.Tenant{}).Error)
activeTenant := &domain.Tenant{Name: "Active Tenant", Slug: "orphan-active", Type: domain.TenantTypeCompany}
deletedTenant := &domain.Tenant{Name: "Deleted Tenant", Slug: "orphan-deleted", Type: domain.TenantTypeCompany}
require.NoError(t, tenantRepo.Create(ctx, activeTenant))
require.NoError(t, tenantRepo.Create(ctx, deletedTenant))
require.NoError(t, testDB.Delete(&domain.Tenant{}, "id = ?", deletedTenant.ID).Error)
activeUser := &domain.User{
Email: "active-membership@example.com",
Name: "Active Membership",
Role: "user",
TenantID: &activeTenant.ID,
CompanyCode: activeTenant.Slug,
CompanyCodes: []string{activeTenant.Slug},
}
orphanUser := &domain.User{
Email: "orphan-membership@example.com",
Name: "Orphan Membership",
Role: "user",
TenantID: &deletedTenant.ID,
CompanyCode: deletedTenant.Slug,
CompanyCodes: []string{deletedTenant.Slug},
}
require.NoError(t, repo.Create(ctx, activeUser))
require.NoError(t, repo.Create(ctx, orphanUser))
affected, err := ClearOrphanUserTenantMemberships(ctx, testDB)
require.NoError(t, err)
assert.Equal(t, int64(1), affected)
foundActive, err := repo.FindByEmail(ctx, activeUser.Email)
require.NoError(t, err)
require.NotNil(t, foundActive.TenantID)
assert.Equal(t, activeTenant.ID, *foundActive.TenantID)
assert.Equal(t, activeTenant.Slug, foundActive.CompanyCode)
assert.Equal(t, []string{activeTenant.Slug}, []string(foundActive.CompanyCodes))
foundOrphan, err := repo.FindByEmail(ctx, orphanUser.Email)
require.NoError(t, err)
assert.Nil(t, foundOrphan.TenantID)
assert.Empty(t, foundOrphan.CompanyCode)
assert.Empty(t, foundOrphan.CompanyCodes)
}

View File

@@ -17,7 +17,7 @@ type UserRepository interface {
FindByID(ctx context.Context, id string) (*domain.User, error)
FindByIDs(ctx context.Context, ids []string) ([]domain.User, error)
ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error)
List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error)
List(ctx context.Context, offset, limit int, search string, tenantSlug 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)
@@ -200,15 +200,14 @@ func lowerStrings(arr []string) []string {
return res
}
func (r *userRepository) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
func (r *userRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
var users []domain.User
var total int64
db := r.db.WithContext(ctx).Model(&domain.User{})
if companyCode != "" {
// [Matrix Fix] Match users either by their primary company code OR by being in the company_codes array OR by tenant slug
if tenantSlug != "" {
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id").
Where("users.company_code = ? OR ? = ANY(users.company_codes) OR tenants.slug = ?", companyCode, companyCode, companyCode)
Where("users.company_code = ? OR ? = ANY(users.company_codes) OR tenants.slug = ?", tenantSlug, tenantSlug, tenantSlug)
}
if search != "" {

View File

@@ -134,7 +134,7 @@ func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID strin
return nil, nil
}
func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
return nil, 0, nil
}

View File

@@ -84,7 +84,7 @@ func (m *MockUserRepository) ListByTenant(ctx context.Context, tenantID string)
return nil, nil
}
func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
return nil, 0, nil
}

View File

@@ -413,7 +413,7 @@ func (f *fakeWorksmobileUserRepo) ListByTenant(ctx context.Context, tenantID str
return nil, nil
}
func (f *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, companyCode string) ([]domain.User, int64, error) {
func (f *fakeWorksmobileUserRepo) List(ctx context.Context, offset, limit int, search string, tenantSlug string) ([]domain.User, int64, error) {
return nil, 0, nil
}

View File

@@ -11,6 +11,13 @@ export type CommonOidcConfigOptions<TUserStore = unknown> = {
userStore: TUserStore;
};
export type LoginRedirectGuardParams = {
pathname: string;
isRedirecting: boolean;
loginPath?: string;
callbackPath?: string;
};
type CommonOidcRuntimeConfig<TUserStore> = {
authority: string;
client_id: string;
@@ -61,3 +68,20 @@ export function buildCommonUserManagerSettings<
redirect_uri: config.redirect_uri || "",
};
}
export function shouldStartLoginRedirect({
pathname,
isRedirecting,
loginPath = "/login",
callbackPath = DEFAULT_OIDC_REDIRECT_PATH,
}: LoginRedirectGuardParams) {
if (isRedirecting) {
return false;
}
if (pathname === loginPath || pathname.startsWith(callbackPath)) {
return false;
}
return true;
}

View File

@@ -0,0 +1,82 @@
import type { CursorFetchRequest, CursorPageResponse } from "./cursorFetchCore";
import { fetchAllCursorPagesMainThread } from "./cursorFetchCore";
type CursorWorkerResponseMessage<TItem> =
| {
id: string;
ok: true;
response: CursorPageResponse<TItem>;
}
| {
id: string;
ok: false;
error: string;
};
function createRequestId() {
if (globalThis.crypto?.randomUUID) {
return globalThis.crypto.randomUUID();
}
return `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
function shouldUseWorker(useWorker: boolean | undefined) {
if (useWorker === false || typeof Worker === "undefined") {
return false;
}
const maybeWindow = globalThis as typeof globalThis & {
window?: Window & typeof globalThis & { _IS_TEST_MODE?: boolean };
};
return maybeWindow.window?._IS_TEST_MODE !== true;
}
async function fetchAllCursorPagesInWorker<TItem>(
request: CursorFetchRequest,
): Promise<CursorPageResponse<TItem>> {
const worker = new Worker(new URL("./cursorFetch.worker.ts", import.meta.url), {
type: "module",
});
const id = createRequestId();
return new Promise((resolve, reject) => {
worker.onmessage = (
event: MessageEvent<CursorWorkerResponseMessage<TItem>>,
) => {
if (event.data.id !== id) {
return;
}
worker.terminate();
if (event.data.ok) {
resolve(event.data.response);
} else {
reject(new Error(event.data.error));
}
};
worker.onerror = (event) => {
worker.terminate();
reject(new Error(event.message || "Cursor worker failed"));
};
worker.postMessage({ id, request });
});
}
export async function fetchAllCursorPages<TItem>(
request: CursorFetchRequest & { useWorker?: boolean },
): Promise<CursorPageResponse<TItem>> {
if (shouldUseWorker(request.useWorker)) {
try {
return await fetchAllCursorPagesInWorker<TItem>(request);
} catch {
return fetchAllCursorPagesMainThread<TItem>(request);
}
}
return fetchAllCursorPagesMainThread<TItem>(request);
}
export type { CursorFetchRequest, CursorPageResponse } from "./cursorFetchCore";
export { fetchAllCursorPagesMainThread } from "./cursorFetchCore";

View File

@@ -0,0 +1,43 @@
import {
fetchAllCursorPagesMainThread,
type CursorFetchRequest,
type CursorPageResponse,
} from "./cursorFetchCore";
type CursorWorkerRequestMessage = {
id: string;
request: CursorFetchRequest;
};
type CursorWorkerResponseMessage<TItem> =
| {
id: string;
ok: true;
response: CursorPageResponse<TItem>;
}
| {
id: string;
ok: false;
error: string;
};
self.addEventListener("message", async (event: MessageEvent<CursorWorkerRequestMessage>) => {
const { id, request } = event.data;
try {
const response = await fetchAllCursorPagesMainThread(request);
self.postMessage({
id,
ok: true,
response,
} satisfies CursorWorkerResponseMessage<unknown>);
} catch (error) {
self.postMessage({
id,
ok: false,
error: error instanceof Error ? error.message : String(error),
} satisfies CursorWorkerResponseMessage<unknown>);
}
});
export {};

View File

@@ -0,0 +1,106 @@
export type CursorPageResponse<TItem> = {
items: TItem[];
limit?: number;
offset?: number;
total?: number;
cursor?: string;
nextCursor?: string;
next_cursor?: string;
};
export type CursorFetchParams = Record<
string,
string | number | boolean | null | undefined
>;
export type CursorFetchRequest = {
baseUrl: string;
path: string;
pageSize?: number;
params?: CursorFetchParams;
headers?: Record<string, string>;
credentials?: RequestCredentials;
maxPages?: number;
};
function normalizeBaseUrl(baseUrl: string) {
const value = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
return new URL(value, globalThis.location?.origin ?? "http://localhost");
}
function buildCursorFetchUrl(
request: Required<Pick<CursorFetchRequest, "baseUrl" | "path">> &
Pick<CursorFetchRequest, "params">,
pageSize: number,
cursor: string | undefined,
) {
const path = request.path.replace(/^\/+/, "");
const url = new URL(path, normalizeBaseUrl(request.baseUrl));
for (const [key, value] of Object.entries(request.params ?? {})) {
if (value !== undefined && value !== null && value !== "") {
url.searchParams.set(key, String(value));
}
}
url.searchParams.set("limit", String(pageSize));
url.searchParams.set("offset", "0");
if (cursor) {
url.searchParams.set("cursor", cursor);
} else {
url.searchParams.delete("cursor");
}
return url;
}
function readNextCursor<TItem>(page: CursorPageResponse<TItem>) {
return page.nextCursor || page.next_cursor || undefined;
}
export async function fetchAllCursorPagesMainThread<TItem>({
pageSize = 100,
credentials = "same-origin",
maxPages = 1000,
...request
}: CursorFetchRequest): Promise<CursorPageResponse<TItem>> {
const items: TItem[] = [];
let cursor: string | undefined;
for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) {
const url = buildCursorFetchUrl(request, pageSize, cursor);
const response = await fetch(url, {
headers: request.headers,
credentials,
});
if (!response.ok) {
throw new Error(`Cursor page request failed with status ${response.status}`);
}
const page = (await response.json()) as CursorPageResponse<TItem>;
items.push(...page.items);
const nextCursor = readNextCursor(page);
if (!nextCursor) {
return {
...page,
items,
limit: pageSize,
offset: 0,
total: items.length,
cursor,
nextCursor: undefined,
next_cursor: undefined,
};
}
if (nextCursor === cursor) {
throw new Error("Cursor page request returned the same next cursor");
}
cursor = nextCursor;
}
throw new Error(`Cursor page request exceeded ${maxPages} pages`);
}

View File

@@ -0,0 +1,6 @@
export {
fetchAllCursorPages,
fetchAllCursorPagesMainThread,
type CursorFetchRequest,
type CursorPageResponse,
} from "./cursorFetch";

View File

@@ -324,7 +324,7 @@ services:
- ../../adminfront:/app
- ./adminfront/vite.config.ts:/app/vite.config.ts:ro
- ./adminfront/auth.ts:/app/src/lib/auth.ts:ro
command: npm run dev -- --host 0.0.0.0
command: sh ./scripts/runtime-mode.sh
networks: [app_net]
devfront:
@@ -338,7 +338,7 @@ services:
- ../../devfront:/app
- ./devfront/vite.config.ts:/app/vite.config.ts:ro
- ./devfront/auth.ts:/app/src/lib/auth.ts:ro
command: npm run dev -- --host 0.0.0.0
command: sh ./scripts/runtime-mode.sh
networks: [app_net]
orgfront:
@@ -352,7 +352,7 @@ services:
- ../../orgfront:/app
- ./orgfront/vite.config.ts:/app/vite.config.ts:ro
- ./orgfront/auth.ts:/app/src/lib/auth.ts:ro
command: npm run dev -- --host 0.0.0.0 --port 5175
command: sh ./scripts/runtime-mode.sh
networks: [app_net]
networks:

View File

@@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import {
type ShellTranslator,
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
@@ -60,6 +61,48 @@ const navItems = [
},
];
type SessionStatusProps = {
expiresAtSec?: number | null;
t: ShellTranslator;
};
function useSessionStatus({ expiresAtSec, t }: SessionStatusProps) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, []);
return buildShellSessionStatus({ expiresAtSec, nowMs, t });
}
function SessionStatusBadge(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return (
<span
className={[
shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionStatus.text}
</span>
);
}
function SessionStatusText(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return <>{sessionStatus.text}</>;
}
function AppLayout() {
const auth = useAuth();
const location = useLocation();
@@ -73,8 +116,6 @@ function AppLayout() {
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
readShellSessionExpiryEnabled,
);
const [nowMs, setNowMs] = useState(() => Date.now());
const hasAccessToken = Boolean(auth.user?.access_token);
const { data: profile } = useQuery({
queryKey: ["userMe"],
@@ -97,15 +138,6 @@ function AppLayout() {
applyShellTheme(theme);
}, [theme]);
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
@@ -284,12 +316,6 @@ function AppLayout() {
auth.user?.profile as Record<string, unknown> | undefined,
);
const displayRoleKey = profile?.role || currentRole;
const sessionStatus = buildShellSessionStatus({
expiresAtSec: auth.user?.expires_at,
nowMs,
t,
});
const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
@@ -398,14 +424,10 @@ function AppLayout() {
: t("ui.common.theme_dark", "Dark")}
</button>
{isSessionExpiryEnabled ? (
<span
className={[
shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionStatus.text}
</span>
<SessionStatusBadge
expiresAtSec={auth.user?.expires_at}
t={t}
/>
) : null}
<div className="relative" ref={profileMenuRef}>
<button
@@ -466,12 +488,17 @@ function AppLayout() {
{t("ui.dev.session.auto_extend", "Session expiry")}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled
? sessionStatus.text
: t(
"ui.dev.session.disabled",
"Session expiry disabled",
)}
{isSessionExpiryEnabled ? (
<SessionStatusText
expiresAtSec={auth.user?.expires_at}
t={t}
/>
) : (
t(
"ui.dev.session.disabled",
"Session expiry disabled",
)
)}
</p>
</div>
<button

View File

@@ -1,6 +1,9 @@
import axios from "axios";
import { shouldStartLoginRedirect } from "../../../common/core/auth";
import { userManager } from "./auth";
let isRedirectingToLogin = false;
const apiClient = axios.create({
baseURL:
import.meta.env.VITE_DEV_API_BASE ??
@@ -32,8 +35,6 @@ apiClient.interceptors.response.use(
error.response?.data?.error?.toString().toLowerCase() ??
error.response?.data?.message?.toString().toLowerCase() ??
"";
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
const isLoginPath = window.location.pathname === "/login";
const shouldRedirectToLogin =
status === 401 ||
(status === 403 &&
@@ -41,7 +42,14 @@ apiClient.interceptors.response.use(
message.includes("invalid session") ||
message.includes("token is not active")));
if (shouldRedirectToLogin && !isAuthPath && !isLoginPath) {
if (
shouldRedirectToLogin &&
shouldStartLoginRedirect({
pathname: window.location.pathname,
isRedirecting: isRedirectingToLogin,
})
) {
isRedirectingToLogin = true;
await userManager.removeUser();
window.location.href = "/login";
}

View File

@@ -231,7 +231,7 @@ services:
condition: service_started
user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}"
environment:
- APP_ENV=${APP_ENV:-stage}
- APP_ENV=${APP_ENV:-development}
- LOG_LEVEL=debug
- OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}
- OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}
@@ -429,7 +429,7 @@ services:
env_file:
- .env
environment:
- APP_ENV=${APP_ENV:-development}
- APP_ENV=${APP_ENV:-stage}
- API_PROXY_TARGET=http://baron_backend:3000
ports:
- "${ADMINFRONT_PORT:-5173}:5173"
@@ -455,7 +455,7 @@ services:
env_file:
- .env
environment:
- APP_ENV=${APP_ENV:-development}
- APP_ENV=${APP_ENV:-stage}
- API_PROXY_TARGET=http://baron_backend:3000
ports:
- "${DEVFRONT_PORT:-5174}:5173"
@@ -481,7 +481,7 @@ services:
env_file:
- .env
environment:
- APP_ENV=${APP_ENV:-development}
- APP_ENV=${APP_ENV:-stage}
- API_PROXY_TARGET=http://baron_backend:3000
- USERFRONT_URL=${USERFRONT_URL}
ports:

View File

@@ -32,7 +32,8 @@ GET /api/v1/integrations/org-context
| 이름 | 기본값 | 설명 |
| --- | --- | --- |
| `tenantSlug` | `hanmac-family` | 조회할 subtree root tenant slug. 지정하지 않으면 `hanmac-family` 전체 subtree를 반환한다. |
| `includeUsers` | `true` | `false`이면 `users``directUserIds`를 비운다. |
| `includeUsers` | `true` | `false`이면 각 tenant의 `members`를 빈 배열로 반환한다. |
| `includeUserIds` | `false` | `true`이면 각 tenant의 `members[].id``members[].phone`만 추가한다. 사용자 UUID와 전화번호가 필요한 연동에서만 사용한다. |
상위 조직 지정은 slug만 사용한다. UUID 기반 지정은 계약에 포함하지 않는다.
@@ -67,7 +68,7 @@ curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&
"visibility": "public",
"createdAt": "2026-05-13T00:00:00Z",
"updatedAt": "2026-05-13T00:00:00Z",
"directUserIds": [],
"members": [],
"children": [
{
"id": "01970f09-2b7b-7f83-b9d6-4f6c8b33f01a",
@@ -83,8 +84,17 @@ curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&
"orgUnitType": "실",
"createdAt": "2026-05-13T00:00:00Z",
"updatedAt": "2026-05-13T00:00:00Z",
"directUserIds": [
"01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
"members": [
{
"email": "user@example.com",
"name": "홍길동",
"grade": "책임",
"position": "실장",
"jobTitle": "Backend Engineer",
"isOwner": true,
"isLeader": true,
"isPrimary": true
}
],
"children": []
}
@@ -103,27 +113,35 @@ curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&
"memberCount": 0,
"visibility": "public",
"createdAt": "2026-05-13T00:00:00Z",
"updatedAt": "2026-05-13T00:00:00Z"
}
],
"users": [
"updatedAt": "2026-05-13T00:00:00Z",
"members": []
},
{
"id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
"email": "user@example.com",
"name": "홍길동",
"role": "user",
"id": "01970f09-2b7b-7f83-b9d6-4f6c8b33f01a",
"type": "USER_GROUP",
"name": "플랫폼실",
"slug": "platform",
"parentId": "01970f08-91da-7286-bd19-882fb98d1f2c",
"status": "active",
"tenantIds": [
"01970f09-2b7b-7f83-b9d6-4f6c8b33f01a"
],
"tenantSlugs": [
"platform"
],
"grade": "책임",
"position": "실장",
"jobTitle": "Backend Engineer",
"description": "",
"domains": [],
"memberCount": 0,
"visibility": "internal",
"orgUnitType": "실",
"createdAt": "2026-05-13T00:00:00Z",
"updatedAt": "2026-05-13T00:00:00Z"
"updatedAt": "2026-05-13T00:00:00Z",
"members": [
{
"email": "user@example.com",
"name": "홍길동",
"grade": "책임",
"position": "실장",
"jobTitle": "Backend Engineer",
"isOwner": true,
"isLeader": true,
"isPrimary": true
}
]
}
]
}
@@ -136,3 +154,12 @@ curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&
- `visibility=private` tenant와 그 하위 tenant는 제외한다.
- `visibility=internal` tenant는 M2M 연동용 JSON API에는 포함한다.
- 외부 앱은 `schemaVersion`을 확인하고, 알 수 없는 version이면 별도 fallback을 적용한다.
- `tree`는 같은 tenant 집합을 계층 구조로 제공하고, `tenants`는 slug/id lookup용 flat array로 제공한다.
- 사용자 목록은 top-level `users`가 아니라 각 tenant의 `members`에 직접 소속 사용자 배열로 제공한다.
- tenant 세부 분류는 `type``orgUnitType`으로 구분한다. `orgUnitType`은 tenant `config.orgUnitType` 값이 있을 때만 포함한다.
- 기본 사용자 응답은 로그인 claim 수준의 표시 정보만 제공한다. UUID, role/status, metadata, 생성/수정 시각은 기본 응답에 포함하지 않는다.
- 사용자 UUID와 전화번호가 필요한 연동은 `includeUserIds=true`를 사용한다. 이때 각 tenant `members[].id``members[].phone`만 추가된다.
- `isOwner`는 appointment metadata의 `isOwner` 또는 `isManager` 기준이다.
- `isLeader`는 appointment metadata의 `lead` 또는 `isLead` 기준이며, `isOwner`/`isManager`가 true인 경우에도 true로 본다.
- `isPrimary`는 appointment metadata의 `representative`, `isPrimary`, `primary` 기준이다.
- appointment별 `grade`, `position`, `jobTitle`, `department`가 있으면 해당 tenant membership 값으로 우선 사용한다.

View File

@@ -0,0 +1,82 @@
# Kratos 사용자 traits 필드 인벤토리
작성일: 2026-05-13
## 확인 대상
- 설정 파일: `docker/ory/kratos/identity.schema.json`
- 로컬 Kratos DB: `ory_postgres` / `ory_kratos.identities.traits`
- 전역 Personal 테넌트: `9607eb7b-04d2-42ab-80fe-780fe21c7e8f` / `personal`
## Kratos schema에 설정된 traits 필드
| 필드 | 타입 | 용도 |
| --- | --- | --- |
| `custom_login_ids` | string array | password identifier |
| `email` | string | password/code identifier, recovery, verification, required |
| `name` | string | 사용자 이름 |
| `phone_number` | string | password/code SMS identifier |
| `department` | string | 부서 |
| `affiliationType` | string | 소속 유형 |
| `companyCode` | string | 대표 테넌트 slug |
| `role` | string | 권한 역할 |
| `tenant_id` | string | 대표 테넌트 UUID |
| `displayname` | string | 레거시 표시 이름 후보 |
| `completeForm` | boolean | 레거시 가입 폼 완료 여부 후보 |
| `team` | string | 레거시 팀 후보 |
| `taxCode` | string | 레거시 세무 코드 후보 |
| `familyCompany` | string | 레거시 가족사 후보 |
| `familyUniqueKey` | string | 레거시 가족사 고유키 후보 |
| `personal` | boolean | 레거시 Personal 여부 후보 |
| `grade` | string | 직급 |
현재 schema는 `additionalProperties: true`라서 위 목록에 없는 traits도 저장 가능합니다.
## 로컬 Kratos DB에 실제 저장된 traits 필드
| 필드 | identity 수 |
| --- | ---: |
| `affiliationType` | 3 |
| `companyCode` | 3 |
| `companyCodes` | 1 |
| `department` | 3 |
| `email` | 3 |
| `grade` | 3 |
| `name` | 3 |
| `phone_number` | 1 |
| `role` | 3 |
| `tenant_id` | 1 |
## 정리 후보
유지 후보:
- 인증 식별자: `email`, `phone_number`, `custom_login_ids`
- 사용자 기본 프로필: `name`
- 권한/대표 소속: `role`, `tenant_id`, `companyCode`
- 조직 표시/연동: `department`, `grade`
- 다중 소속이 필요한 동안 유지: `companyCodes`
schema 추가 검토 후보:
- backend projection에서 읽는 `position`, `jobTitle`
- 한맥가족 다중 소속을 metadata로 유지할 경우 `additionalAppointments`
- 대표 테넌트 표시값을 traits로 계속 줄 경우 `primaryTenantId`, `primaryTenantSlug`, `primaryTenantName`, `primaryTenantIsOwner`
제거 후보:
- `displayname`
- `completeForm`
- `team`
- `taxCode`
- `familyCompany`
- `familyUniqueKey`
- `personal`
- `hanmacFamily`는 이미 `test/kratos_identity_schema_policy_test.sh`에서 금지 필드로 검사 중입니다.
## 제안 정책
1. Personal 사용자는 사용자별 Personal 테넌트를 생성하지 않고 전역 `personal` 테넌트만 사용합니다.
2. Kratos traits는 인증/클레임에 필요한 최소 필드만 유지합니다.
3. 조직도나 연동 전용 확장 데이터는 traits 최상위에 흩뿌리지 않고 Baron DB의 user projection 또는 명시된 metadata 구조로 모읍니다.
4. `additionalProperties: true`를 바로 `false`로 바꾸면 기존 identity 갱신이 실패할 수 있으므로, 먼저 backend sanitizer와 마이그레이션으로 제거 후보를 정리한 뒤 schema를 닫습니다.

View File

@@ -0,0 +1,142 @@
# Tenant 유지보수 절차
이 문서는 관리자가 비정기적으로 수행할 수 있는 tenant 데이터 정합성 점검 및 정리 절차를 정리한다.
## Orphan 사용자 tenant 소속정보 정리
### 대상
다음 중 하나에 해당하는 활성 Baron 사용자는 orphan 소속정보 정리 대상이다.
- `users.tenant_id`가 존재하지 않거나 soft-deleted 된 `tenants.id`를 가리킨다.
- `users.company_code`가 활성 `tenants.slug`와 매칭되지 않는다.
- `users.company_codes` 배열 중 하나 이상이 활성 `tenants.slug`와 매칭되지 않는다.
정리 시 다음 필드를 비운다.
- `tenant_id`
- `company_code`
- `company_codes`
### 사전 확인
```sql
SELECT
u.email,
u.tenant_id,
u.company_code,
u.company_codes
FROM users AS u
WHERE u.deleted_at IS NULL
AND (
(
u.tenant_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE t.id = u.tenant_id
AND t.deleted_at IS NULL
)
)
OR (
NULLIF(BTRIM(u.company_code), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE LOWER(t.slug) = LOWER(BTRIM(u.company_code))
AND t.deleted_at IS NULL
)
)
OR EXISTS (
SELECT 1
FROM UNNEST(COALESCE(u.company_codes, ARRAY[]::text[])) AS code(value)
WHERE NULLIF(BTRIM(code.value), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE LOWER(t.slug) = LOWER(BTRIM(code.value))
AND t.deleted_at IS NULL
)
)
);
```
Kratos identity traits도 같은 기준으로 정리한다.
- `traits.tenant_id`
- `traits.companyCode`
- `traits.companyCodes`
### Baron users만 실행
```bash
docker exec -i baron_postgres psql -U baron -d baron_sso < scripts/clear_orphan_user_tenant_memberships.sql
```
실행 결과에는 정리된 사용자와 기존 소속정보가 출력된다.
### Baron users와 Kratos identity traits 함께 실행
로컬 Docker Compose 기준 기본 컨테이너명은 다음과 같다.
- Baron DB: `baron_postgres`
- Kratos DB: `ory_postgres`
```bash
scripts/clear_orphan_tenant_memberships.sh
```
컨테이너명이나 DB 접속 정보가 다르면 환경변수로 override 한다.
```bash
BARON_CONTAINER=baron_postgres \
BARON_DB_USER=baron \
BARON_DB_NAME=baron_sso \
KRATOS_CONTAINER=ory_postgres \
KRATOS_DB_USER=ory \
KRATOS_DB_NAME=ory_kratos \
scripts/clear_orphan_tenant_memberships.sh
```
### 사후 확인
사전 확인 쿼리를 다시 실행했을 때 결과가 0건이어야 한다.
Kratos identity traits는 다음 쿼리로 확인한다.
```sql
SELECT
id,
traits->>'email' AS email,
traits->>'tenant_id' AS tenant_id,
traits->>'companyCode' AS company_code,
traits->'companyCodes' AS company_codes
FROM identities
WHERE COALESCE(traits->>'tenant_id', '') <> ''
OR COALESCE(traits->>'companyCode', '') <> ''
OR traits ? 'companyCodes';
```
## Soft-deleted tenant slug 점검
`tenants.slug`는 DB unique index이므로 soft-deleted row도 slug를 점유한다. 현재 삭제 로직은 삭제 전에 slug에 `-deleted-...` suffix를 붙여 재사용 가능하게 만들지만, 과거 데이터나 수동 변경으로 삭제된 row가 원래 slug를 계속 점유하면 AdminFront 검색에는 보이지 않으면서 생성은 unique violation으로 실패할 수 있다.
### legacy 점유 row 확인
```sql
SELECT
id,
slug,
name,
deleted_at
FROM tenants
WHERE deleted_at IS NOT NULL
AND slug NOT LIKE '%-deleted-%'
ORDER BY deleted_at DESC;
```
### 정책
- 활성 tenant가 같은 slug를 가지고 있으면 생성 실패가 정상이다.
- soft-deleted tenant만 같은 slug를 점유하고 있으면 생성 직전에 해당 deleted row의 slug를 release 한다.
- `FindBySlug`와 검색 API는 활성 tenant만 반환하므로, 생성 제약도 활성 tenant 기준으로 체감되도록 맞춘다.

View File

@@ -0,0 +1,59 @@
# Tenant visibility exposure policy
## 목적
`hanmac-family` 하위 조직의 `config.visibility` 값이 호출 위치별로 어디까지 노출되는지 정리한다. 현재 정책에서 완전 공개 조직도는 없다고 본다. 따라서 `public`은 인터넷 전체 공개가 아니라 인증/공유 경계 안에서의 기본 노출값이다.
## Visibility 값
| 값 | 의미 | 기본값 여부 |
| --- | --- | --- |
| `public` | 인증된 사용자 화면과 통제된 공유 경계에서 기본 노출 가능한 조직. 현재 정책상 인터넷 완전 공개를 의미하지 않음 | `visibility`가 없거나 빈 값이면 `public` |
| `internal` | Baron 로그인 세션 또는 M2M API Key를 가진 내부/연동 경계에는 노출하지만, 외부 공유 경계에는 노출하지 않는 조직 | 명시 설정 필요 |
| `private` | 기본 조직도 노출 대상에서 제외하는 조직. 해당 조직의 하위 조직도 함께 제외 | 명시 설정 필요 |
`visibility``orgUnitType` 설정은 현재 `hanmac-family` descendant tenant에만 허용된다. `hanmac-family` root 자체에는 org config를 두지 않는 정책이다.
## 호출 위치별 노출 기준
| 호출 위치 | 인증/권한 경계 | `public` | `internal` | `private` |
| --- | --- | --- | --- | --- |
| OrgFront 일반 조직도 `/chart` | 로그인 세션, backend `/api/v1/admin/tenants` 기반 | 노출 | 노출 | 권한 없는 사용자는 미노출 |
| OrgFront embed picker `/embed/picker` | 로그인 세션 또는 picker 자동 로그인 경로 | 노출 | 노출 | 권한 없는 사용자는 미노출 |
| 공유 링크 `/api/v1/public/orgchart?token=...` | share token. 완전 공개가 아니라 통제된 공유 경계 | 노출 | 미노출 | 미노출 |
| M2M 조직 Context JSON API `/api/v1/integrations/org-context` | API Key + `org-context:read` | 노출 | 노출 | 미노출 |
| AdminFront 테넌트 관리 `/api/v1/admin/tenants` | 사용자 role/Keto 관리 경계 | 노출 | 노출 | `super_admin`, 해당 private subtree owner/admin/manage 가능 사용자, 또는 명시적 private 조회 권한자만 노출 |
| AdminFront CSV export | 사용자 role/Keto 관리 경계 | 노출 | 노출 | `/api/v1/admin/tenants`와 동일 |
| AdminFront CSV import | 권한 있는 관리자 작업 | 입력값 검증 대상 | 입력값 검증 대상 | 입력값 검증 대상 |
## 핵심 판단
`internal`은 현재 “특정 조직 권한자에게만 보이는 조직”이 아니다. 로그인된 OrgFront 사용자가 backend에서 해당 tenant family를 받을 수 있는 경우, OrgFront 기본 조직도와 picker에는 `internal` 조직이 기본 노출된다.
다만 `internal`은 공개 공유 링크에서는 제외된다. 외부 M2M JSON API에서는 API Key 자체가 신뢰 경계이므로 `internal`을 포함한다.
`private`은 기본적으로 일반 조직도, picker, 공유 링크, M2M JSON API에서 제외된다. 또한 `private` 조직 아래의 descendant도 함께 제외된다. AdminFront 테넌트 관리와 CSV export에서도 권한 없는 사용자에게는 제외된다.
`private` 노출이 허용되는 사용자는 다음 중 하나다.
- `super_admin`
- 해당 private 조직 또는 ancestor를 `ManageableTenants`로 가진 owner/admin/manage 가능 사용자
- Keto/ReBAC에서 해당 private 조직에 `view_private`, `view_private_descendants`, `view`, `manage` 중 하나를 가진 사용자
- ancestor tenant에 `view_private_descendants`를 가진 사용자
## 구현 근거
- backend `tenantVisibility``internal`, `private`만 별도 값으로 인정하고 나머지는 `public`으로 처리한다.
- backend `filterPublicTenants``internal`, `private`, 그리고 그 descendant를 공개 공유 링크 노출에서 제외한다.
- backend `filterOrgContextSubtree``private`과 그 descendant만 M2M 조직 Context에서 제외한다. 따라서 `internal`은 포함된다.
- backend `/api/v1/admin/tenants`와 CSV export는 사용자 profile과 Keto 권한을 기준으로 private root 및 descendant를 필터링한다.
- OrgFront `filterTenantsByVisibility(..., "internal")``private`만 제외한다. 일반 `/chart``/embed/picker`는 backend에서 이미 권한 필터링된 tenant 목록 위에서 이 기준을 사용한다.
- OrgFront `filterTenantsByVisibility(..., "public")``internal`, `private`를 제외한다. share token 기반 공개 조직도가 이 기준을 사용한다.
## 회색지대
현재 이름만 보면 `public`이 인터넷 완전 공개라는 의미로 해석될 수 있지만, 정책상 완전 공개 조직도는 없고 `public`은 기본 노출값이다.
현재 이름만 보면 `internal`이 “조직 내부 구성원 또는 특정 권한자만”이라는 의미로 해석될 수 있지만, 구현은 “공유 링크에는 숨기고, 로그인/M2M 경계에는 노출”이다.
특정 권한자에게만 보이는 조직은 `private`과 Keto/ReBAC 권한으로 표현한다. `internal`을 제한 공개 권한 모델로 확장하려면 별도 정책 변경이 필요하다.

View File

@@ -12,14 +12,8 @@ import {
import { useEffect, useRef, useState } from "react";
import { useAuth } from "react-oidc-context";
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
import { fetchMe } from "../../features/auth/authApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import {
type ShellTranslator,
applyShellTheme,
buildShellProfileSummary,
buildShellSessionStatus,
@@ -28,6 +22,13 @@ import {
shellLayoutClasses,
writeShellSessionExpiryEnabled,
} from "../../../../common/shell";
import { fetchMe } from "../../features/auth/authApi";
import { t } from "../../lib/i18n";
import { resolveProfileRole } from "../../lib/role";
import {
shouldAttemptSlidingSessionRenew,
shouldAttemptUnlimitedSessionRenew,
} from "../../lib/sessionSliding";
import LanguageSelector from "../common/LanguageSelector";
import { Toaster } from "../ui/toaster";
@@ -46,6 +47,48 @@ const navItems = [
},
];
type SessionStatusProps = {
expiresAtSec?: number | null;
t: ShellTranslator;
};
function useSessionStatus({ expiresAtSec, t }: SessionStatusProps) {
const [nowMs, setNowMs] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, []);
return buildShellSessionStatus({ expiresAtSec, nowMs, t });
}
function SessionStatusBadge(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return (
<span
className={[
shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionStatus.text}
</span>
);
}
function SessionStatusText(props: SessionStatusProps) {
const sessionStatus = useSessionStatus(props);
return <>{sessionStatus.text}</>;
}
function AppLayout() {
const auth = useAuth();
const location = useLocation();
@@ -59,8 +102,6 @@ function AppLayout() {
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
readShellSessionExpiryEnabled,
);
const [nowMs, setNowMs] = useState(() => Date.now());
const hasAccessToken = Boolean(auth.user?.access_token);
const { data: profile } = useQuery({
queryKey: ["userMe"],
@@ -79,15 +120,6 @@ function AppLayout() {
applyShellTheme(theme);
}, [theme]);
useEffect(() => {
const timer = window.setInterval(() => {
setNowMs(Date.now());
}, 1000);
return () => {
window.clearInterval(timer);
};
}, []);
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
if (
@@ -272,12 +304,6 @@ function AppLayout() {
"tenant_admin",
"rp_admin",
].includes(currentRole);
const sessionStatus = buildShellSessionStatus({
expiresAtSec: auth.user?.expires_at,
nowMs,
t,
});
const handleSessionExpiryToggle = () => {
setIsSessionExpiryEnabled((prev) => {
const next = !prev;
@@ -386,14 +412,10 @@ function AppLayout() {
: t("ui.common.theme_dark", "Dark")}
</button>
{isSessionExpiryEnabled ? (
<span
className={[
shellLayoutClasses.sessionBadge,
sessionStatus.toneClass,
].join(" ")}
>
{sessionStatus.text}
</span>
<SessionStatusBadge
expiresAtSec={auth.user?.expires_at}
t={t}
/>
) : null}
<div className="relative" ref={profileMenuRef}>
<button
@@ -451,12 +473,14 @@ function AppLayout() {
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
</p>
<p className="text-xs text-muted-foreground">
{isSessionExpiryEnabled
? sessionStatus.text
: t(
"ui.dev.session.disabled",
"세션 만료 비활성화",
)}
{isSessionExpiryEnabled ? (
<SessionStatusText
expiresAtSec={auth.user?.expires_at}
t={t}
/>
) : (
t("ui.dev.session.disabled", "세션 만료 비활성화")
)}
</p>
</div>
<button

View File

@@ -5,8 +5,8 @@ import { useLocation, useParams } from "react-router-dom";
import {
type TenantSummary,
type UserSummary,
fetchAllTenants,
fetchPublicOrgChart,
fetchTenants,
fetchUsers,
} from "../../../lib/adminApi";
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
@@ -1217,7 +1217,7 @@ export function TenantOrgChartPage() {
const tenantsQuery = useQuery({
queryKey: ["tenants-full-tree-v2"],
queryFn: () => fetchTenants(10000, 0),
queryFn: () => fetchAllTenants(),
enabled: !shareToken,
});

View File

@@ -3,7 +3,7 @@ import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
import * as React from "react";
import { useLocation } from "react-router-dom";
import { Button } from "../../../components/ui/button";
import { fetchTenants, fetchUsers } from "../../../lib/adminApi";
import { fetchAllTenants, fetchUsers } from "../../../lib/adminApi";
import { buildOrgPickerTree, flattenDescendants } from "../pickerTree";
import {
type OrgPickerEmbedOptions,
@@ -350,7 +350,7 @@ export function OrgPickerEmbedPage() {
const tenantsQuery = useQuery({
queryKey: ["org-picker-tenants"],
queryFn: () => fetchTenants(10000, 0),
queryFn: () => fetchAllTenants(),
});
const usersQuery = useQuery({
queryKey: ["org-picker-users"],

View File

@@ -0,0 +1,77 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const apiClient = {
post: vi.fn(),
put: vi.fn(),
};
vi.mock("./apiClient", () => ({
default: apiClient,
}));
describe("orgfront adminApi user tenant payloads", () => {
beforeEach(() => {
apiClient.post.mockReset();
apiClient.put.mockReset();
});
it("sends tenantSlug without remapping it to companyCode when creating a user", async () => {
const { createUser } = await import("./adminApi");
apiClient.post.mockResolvedValue({ data: {} });
await createUser({
email: "user@test.com",
name: "Test User",
tenantSlug: "test-tenant",
});
expect(apiClient.post).toHaveBeenCalledWith(
"/v1/admin/users",
expect.objectContaining({ tenantSlug: "test-tenant" }),
);
expect(apiClient.post.mock.calls[0][1]).not.toHaveProperty("companyCode");
});
it("sends tenantSlug without remapping it to companyCode when updating a user", async () => {
const { updateUser } = await import("./adminApi");
apiClient.put.mockResolvedValue({ data: {} });
await updateUser("user-id", { tenantSlug: "new-tenant" });
expect(apiClient.put).toHaveBeenCalledWith(
"/v1/admin/users/user-id",
expect.objectContaining({ tenantSlug: "new-tenant" }),
);
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
});
it("keeps tenantSlug payloads unchanged for bulk user APIs", async () => {
const { bulkCreateUsers, bulkUpdateUsers } = await import("./adminApi");
apiClient.post.mockResolvedValue({ data: {} });
apiClient.put.mockResolvedValue({ data: {} });
await bulkCreateUsers([
{
email: "user@test.com",
name: "Test User",
tenantSlug: "test-tenant",
metadata: {},
},
]);
await bulkUpdateUsers({
userIds: ["user-id"],
tenantSlug: "new-tenant",
});
expect(apiClient.post.mock.calls[0][1].users[0]).toMatchObject({
tenantSlug: "test-tenant",
});
expect(apiClient.post.mock.calls[0][1].users[0]).not.toHaveProperty(
"companyCode",
);
expect(apiClient.put.mock.calls[0][1]).toMatchObject({
tenantSlug: "new-tenant",
});
expect(apiClient.put.mock.calls[0][1]).not.toHaveProperty("companyCode");
});
});

View File

@@ -1,4 +1,6 @@
import { fetchAllCursorPages } from "../../../common/core/pagination";
import apiClient from "./apiClient";
import { userManager } from "./auth";
export type AuditLog = {
event_id: string;
@@ -50,6 +52,9 @@ export type TenantListResponse = {
limit: number;
offset: number;
total: number;
cursor?: string;
nextCursor?: string;
next_cursor?: string;
};
export type TenantUpdateRequest = {
@@ -99,16 +104,61 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
return data;
}
export async function fetchTenants(limit = 50, offset = 0, parentId?: string) {
export async function fetchTenants(
limit = 50,
offset = 0,
parentId?: string,
cursor?: string,
) {
const { data } = await apiClient.get<TenantListResponse>(
"/v1/admin/tenants",
{
params: { limit, offset, parentId },
params: { limit, offset, parentId, cursor },
},
);
return data;
}
function getOrgApiBaseUrl() {
return (
import.meta.env.VITE_DEV_API_BASE ??
import.meta.env.VITE_ADMIN_API_BASE ??
"/api"
);
}
async function buildOrgRequestHeaders() {
const headers: Record<string, string> = {};
const user = await userManager.getUser();
if (user?.access_token) {
headers.Authorization = `Bearer ${user.access_token}`;
}
const tenantId = window.localStorage.getItem("dev_tenant_id");
if (tenantId) {
headers["X-Tenant-ID"] = tenantId;
}
return headers;
}
export async function fetchAllTenants({
pageSize = 100,
parentId,
}: {
pageSize?: number;
parentId?: string;
} = {}) {
return fetchAllCursorPages<TenantSummary>({
baseUrl: getOrgApiBaseUrl(),
path: "/v1/admin/tenants",
pageSize,
params: { parentId },
headers: await buildOrgRequestHeaders(),
}) as Promise<TenantListResponse>;
}
export async function fetchTenant(tenantId: string) {
const { data } = await apiClient.get<TenantSummary>(
`/v1/admin/tenants/${tenantId}`,
@@ -481,17 +531,9 @@ export async function fetchUser(userId: string) {
}
export async function createUser(payload: UserCreateRequest) {
// Map tenantSlug to companyCode for backend compatibility
const requestPayload: UserCreateRequest & { companyCode?: string } = {
...payload,
};
if (payload.tenantSlug !== undefined) {
requestPayload.companyCode = payload.tenantSlug;
}
const { data } = await apiClient.post<UserCreateResponse>(
"/v1/admin/users",
requestPayload,
payload,
);
return data;
}
@@ -512,16 +554,9 @@ export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
}
export async function bulkCreateUsers(users: BulkUserItem[]) {
const mappedUsers = users.map((u) => {
const mapped: BulkUserItem & { companyCode?: string } = { ...u };
if (u.tenantSlug !== undefined) {
mapped.companyCode = u.tenantSlug;
}
return mapped;
});
const { data } = await apiClient.post<BulkUserResponse>(
"/v1/admin/users/bulk",
{ users: mappedUsers },
{ users },
);
return data;
}
@@ -533,13 +568,7 @@ export async function bulkUpdateUsers(payload: {
tenantSlug?: string;
department?: string;
}) {
const requestPayload: typeof payload & { companyCode?: string } = {
...payload,
};
if (payload.tenantSlug !== undefined) {
requestPayload.companyCode = payload.tenantSlug;
}
const { data } = await apiClient.put("/v1/admin/users/bulk", requestPayload);
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
return data;
}
@@ -551,16 +580,9 @@ export async function bulkDeleteUsers(userIds: string[]) {
}
export async function updateUser(userId: string, payload: UserUpdateRequest) {
const requestPayload: UserUpdateRequest & { companyCode?: string } = {
...payload,
};
if (payload.tenantSlug !== undefined) {
requestPayload.companyCode = payload.tenantSlug;
}
const { data } = await apiClient.put<UserSummary>(
`/v1/admin/users/${userId}`,
requestPayload,
payload,
);
return data;
}

View File

@@ -1,6 +1,9 @@
import axios from "axios";
import { shouldStartLoginRedirect } from "../../../common/core/auth";
import { userManager } from "./auth";
let isRedirectingToLogin = false;
const apiClient = axios.create({
baseURL:
import.meta.env.VITE_DEV_API_BASE ??
@@ -32,8 +35,6 @@ apiClient.interceptors.response.use(
error.response?.data?.error?.toString().toLowerCase() ??
error.response?.data?.message?.toString().toLowerCase() ??
"";
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
const isLoginPath = window.location.pathname === "/login";
const shouldRedirectToLogin =
status === 401 ||
(status === 403 &&
@@ -41,7 +42,14 @@ apiClient.interceptors.response.use(
message.includes("invalid session") ||
message.includes("token is not active")));
if (shouldRedirectToLogin && !isAuthPath && !isLoginPath) {
if (
shouldRedirectToLogin &&
shouldStartLoginRedirect({
pathname: window.location.pathname,
isRedirecting: isRedirectingToLogin,
})
) {
isRedirectingToLogin = true;
await userManager.removeUser();
window.location.href = "/login";
}

View File

@@ -1,8 +1,8 @@
import {
DEFAULT_SESSION_RENEW_THROTTLE_MS,
type SessionRenewDecisionParams,
shouldAttemptSlidingSessionRenew as shouldAttemptSlidingSessionRenewBase,
shouldAttemptUnlimitedSessionRenew as shouldAttemptUnlimitedSessionRenewBase,
type SessionRenewDecisionParams,
} from "../../../common/core/session";
export const SESSION_RENEW_THROTTLE_MS = DEFAULT_SESSION_RENEW_THROTTLE_MS;

View File

@@ -0,0 +1,87 @@
#!/usr/bin/env bash
set -euo pipefail
BARON_CONTAINER="${BARON_CONTAINER:-baron_postgres}"
BARON_DB_USER="${BARON_DB_USER:-baron}"
BARON_DB_NAME="${BARON_DB_NAME:-baron_sso}"
KRATOS_CONTAINER="${KRATOS_CONTAINER:-ory_postgres}"
KRATOS_DB_USER="${KRATOS_DB_USER:-ory}"
KRATOS_DB_NAME="${KRATOS_DB_NAME:-ory_kratos}"
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
docker exec -i "${BARON_CONTAINER}" \
psql -U "${BARON_DB_USER}" -d "${BARON_DB_NAME}" \
< "${script_dir}/clear_orphan_user_tenant_memberships.sql"
active_tenant_refs="$(
docker exec "${BARON_CONTAINER}" psql -U "${BARON_DB_USER}" -d "${BARON_DB_NAME}" -At -F $'\t' \
-c "SELECT id, LOWER(slug) FROM tenants WHERE deleted_at IS NULL ORDER BY id"
)"
docker exec -i "${KRATOS_CONTAINER}" psql -U "${KRATOS_DB_USER}" -d "${KRATOS_DB_NAME}" <<SQL
BEGIN;
CREATE TEMP TABLE active_tenant_refs (
id text NOT NULL,
slug text NOT NULL
) ON COMMIT DROP;
COPY active_tenant_refs (id, slug) FROM STDIN WITH (FORMAT text, DELIMITER E'\t');
${active_tenant_refs}
\.
WITH orphan_identities AS (
SELECT
i.id,
i.traits->>'email' AS email,
i.traits->>'tenant_id' AS tenant_id,
i.traits->>'companyCode' AS company_code,
i.traits->'companyCodes' AS company_codes
FROM identities AS i
WHERE (
COALESCE(i.traits->>'tenant_id', '') <> ''
AND NOT EXISTS (
SELECT 1
FROM active_tenant_refs AS refs
WHERE refs.id = i.traits->>'tenant_id'
)
)
OR (
COALESCE(i.traits->>'companyCode', '') <> ''
AND NOT EXISTS (
SELECT 1
FROM active_tenant_refs AS refs
WHERE refs.slug = LOWER(BTRIM(i.traits->>'companyCode'))
)
)
OR EXISTS (
SELECT 1
FROM JSONB_ARRAY_ELEMENTS_TEXT(COALESCE(i.traits->'companyCodes', '[]'::jsonb)) AS code(value)
WHERE NULLIF(BTRIM(code.value), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM active_tenant_refs AS refs
WHERE refs.slug = LOWER(BTRIM(code.value))
)
)
),
updated_identities AS (
UPDATE identities AS i
SET traits = i.traits - 'tenant_id' - 'companyCode' - 'companyCodes',
updated_at = NOW()
FROM orphan_identities AS oi
WHERE i.id = oi.id
RETURNING
i.id,
oi.email,
oi.tenant_id AS cleared_tenant_id,
oi.company_code AS cleared_company_code,
oi.company_codes AS cleared_company_codes
)
SELECT *
FROM updated_identities
ORDER BY email;
COMMIT;
SQL

View File

@@ -0,0 +1,67 @@
-- 삭제되었거나 존재하지 않는 tenant를 가리키는 사용자 소속정보를 정리한다.
-- 실행 예:
-- docker exec -i baron_postgres psql -U baron -d baron_sso < scripts/clear_orphan_user_tenant_memberships.sql
BEGIN;
WITH orphan_users AS (
SELECT
u.id,
u.email,
u.tenant_id,
u.company_code,
u.company_codes
FROM users AS u
WHERE u.deleted_at IS NULL
AND (
(
u.tenant_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE t.id = u.tenant_id
AND t.deleted_at IS NULL
)
)
OR (
NULLIF(BTRIM(u.company_code), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE LOWER(t.slug) = LOWER(BTRIM(u.company_code))
AND t.deleted_at IS NULL
)
)
OR EXISTS (
SELECT 1
FROM UNNEST(COALESCE(u.company_codes, ARRAY[]::text[])) AS code(value)
WHERE NULLIF(BTRIM(code.value), '') IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM tenants AS t
WHERE LOWER(t.slug) = LOWER(BTRIM(code.value))
AND t.deleted_at IS NULL
)
)
)
),
updated_users AS (
UPDATE users AS u
SET tenant_id = NULL,
company_code = '',
company_codes = NULL,
updated_at = NOW()
FROM orphan_users AS ou
WHERE u.id = ou.id
RETURNING
u.id,
u.email,
ou.tenant_id AS cleared_tenant_id,
ou.company_code AS cleared_company_code,
ou.company_codes AS cleared_company_codes
)
SELECT *
FROM updated_users
ORDER BY email;
COMMIT;

View File

@@ -0,0 +1,8 @@
-- Baron user metadata staging normalization.
-- Idempotently removes legacy classification flags that are no longer SoT.
update users
set metadata = metadata - 'hanmacFamily' - 'userType',
updated_at = now()
where metadata ? 'hanmacFamily'
or metadata ? 'userType';

View File

@@ -0,0 +1,15 @@
#!/usr/bin/env sh
set -eu
schema_file="docker/ory/kratos/identity.schema.json"
forbidden_traits="hanmacFamily userType"
for trait in $forbidden_traits; do
if grep -Fq "\"$trait\"" "$schema_file"; then
echo "forbidden Kratos trait in $schema_file: $trait" >&2
exit 1
fi
done
echo "kratos identity schema policy checks passed"

View File

@@ -21,6 +21,7 @@ assert_not_contains() {
staging_pull=".gitea/workflows/staging_code_pull.yml"
pull_compose="docker/staging_pull_compose.template.yaml"
deploy_compose="deploy/templates/docker-compose.yaml"
devfront_vite="devfront/vite.config.ts"
orgfront_vite="orgfront/vite.config.ts"
adminfront_vite="adminfront/vite.config.ts"
@@ -31,6 +32,7 @@ orgfront_runtime="orgfront/scripts/runtime-mode.sh"
for file in \
"$staging_pull" \
"$pull_compose" \
"$deploy_compose" \
"$adminfront_vite" \
"$devfront_vite" \
"$orgfront_vite" \
@@ -61,6 +63,10 @@ assert_contains "$pull_compose" "baron_devfront"
assert_contains "$pull_compose" "baron_orgfront"
assert_contains "$pull_compose" "http://127.0.0.1:5173/"
assert_contains "$pull_compose" "http://127.0.0.1:5175/"
assert_contains "$pull_compose" 'APP_ENV=${APP_ENV:-stage}'
assert_contains "$deploy_compose" "sh ./scripts/runtime-mode.sh"
assert_not_contains "$deploy_compose" "command: npm run dev"
assert_contains "$adminfront_vite" "/tmp/baron-sso-adminfront-dist"
assert_contains "$adminfront_vite" "/tmp/baron-sso-adminfront-vite-cache"