forked from baron/baron-sso
테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거
This commit is contained in:
28
adminfront/package-lock.json
generated
28
adminfront/package-lock.json
generated
@@ -17,6 +17,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@tanstack/react-query": "^5.66.8",
|
"@tanstack/react-query": "^5.66.8",
|
||||||
"@tanstack/react-query-devtools": "^5.66.8",
|
"@tanstack/react-query-devtools": "^5.66.8",
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
@@ -3290,6 +3291,33 @@
|
|||||||
"react": "^18 || ^19"
|
"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": {
|
"node_modules/@testing-library/dom": {
|
||||||
"version": "10.4.1",
|
"version": "10.4.1",
|
||||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
"@tanstack/react-query": "^5.66.8",
|
"@tanstack/react-query": "^5.66.8",
|
||||||
"@tanstack/react-query-devtools": "^5.66.8",
|
"@tanstack/react-query-devtools": "^5.66.8",
|
||||||
|
"@tanstack/react-virtual": "^3.13.24",
|
||||||
"axios": "^1.7.9",
|
"axios": "^1.7.9",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
@@ -21,4 +21,26 @@ describe("admin routes", () => {
|
|||||||
|
|
||||||
expect(matches?.at(-1)?.route.path).toBe("system/projections/users");
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import ApiKeyCreatePage from "../features/api-keys/ApiKeyCreatePage";
|
|||||||
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
|
import ApiKeyListPage from "../features/api-keys/ApiKeyListPage";
|
||||||
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
import AuditLogsPage from "../features/audit/AuditLogsPage";
|
||||||
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
import AuthCallbackPage from "../features/auth/AuthCallbackPage";
|
||||||
|
import AuthGuard from "../features/auth/AuthGuard";
|
||||||
import AuthPage from "../features/auth/AuthPage";
|
import AuthPage from "../features/auth/AuthPage";
|
||||||
import LoginPage from "../features/auth/LoginPage";
|
import LoginPage from "../features/auth/LoginPage";
|
||||||
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
import GlobalOverviewPage from "../features/overview/GlobalOverviewPage";
|
||||||
@@ -34,34 +35,39 @@ export const adminRoutes: RouteObject[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: "/",
|
path: "/",
|
||||||
element: <AppLayout />,
|
element: <AuthGuard />,
|
||||||
children: [
|
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: <AppLayout />,
|
||||||
element: <TenantDetailPage />,
|
|
||||||
children: [
|
children: [
|
||||||
{ index: true, element: <TenantProfilePage /> },
|
{ index: true, element: <GlobalOverviewPage /> },
|
||||||
{ path: "permissions", element: <TenantAdminsAndOwnersTab /> },
|
{ path: "audit-logs", element: <AuditLogsPage /> },
|
||||||
{ path: "organization", element: <TenantUserGroupsTab /> },
|
{ path: "auth", element: <AuthPage /> },
|
||||||
{ path: "schema", element: <TenantSchemaPage /> },
|
{ path: "users", element: <UserListPage /> },
|
||||||
{ path: "worksmobile", element: <TenantWorksmobilePage /> },
|
{ 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 /> },
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
type ShellTranslator,
|
||||||
applyShellTheme,
|
applyShellTheme,
|
||||||
buildShellProfileSummary,
|
buildShellProfileSummary,
|
||||||
buildShellSessionStatus,
|
buildShellSessionStatus,
|
||||||
@@ -53,6 +54,48 @@ const staticNavItems: NavItem[] = [
|
|||||||
{ label: "ui.admin.nav.auth_guard", to: "/auth", icon: KeyRound },
|
{ 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() {
|
function AppLayout() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -76,17 +119,6 @@ function AppLayout() {
|
|||||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
|
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
|
||||||
readShellSessionExpiryEnabled,
|
readShellSessionExpiryEnabled,
|
||||||
);
|
);
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
setNowMs(Date.now());
|
|
||||||
}, 1000);
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: profile,
|
data: profile,
|
||||||
isLoading: isProfileLoading,
|
isLoading: isProfileLoading,
|
||||||
@@ -396,12 +428,6 @@ function AppLayout() {
|
|||||||
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
|
fallbackEmail: t("ui.dev.profile.unknown_email", "unknown@example.com"),
|
||||||
});
|
});
|
||||||
const profileRoleKey = mockRoleOverride || profile?.role || "user";
|
const profileRoleKey = mockRoleOverride || profile?.role || "user";
|
||||||
const sessionStatus = buildShellSessionStatus({
|
|
||||||
expiresAtSec: auth.user?.expires_at,
|
|
||||||
nowMs,
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSessionExpiryToggle = () => {
|
const handleSessionExpiryToggle = () => {
|
||||||
setIsSessionExpiryEnabled((prev) => {
|
setIsSessionExpiryEnabled((prev) => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
@@ -525,14 +551,10 @@ function AppLayout() {
|
|||||||
: t("ui.common.theme_dark", "Dark")}
|
: t("ui.common.theme_dark", "Dark")}
|
||||||
</button>
|
</button>
|
||||||
{isSessionExpiryEnabled ? (
|
{isSessionExpiryEnabled ? (
|
||||||
<span
|
<SessionStatusBadge
|
||||||
className={[
|
expiresAtSec={auth.user?.expires_at}
|
||||||
shellLayoutClasses.sessionBadge,
|
t={t}
|
||||||
sessionStatus.toneClass,
|
/>
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{sessionStatus.text}
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative" ref={profileMenuRef}>
|
<div className="relative" ref={profileMenuRef}>
|
||||||
<button
|
<button
|
||||||
@@ -591,12 +613,14 @@ function AppLayout() {
|
|||||||
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
|
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{isSessionExpiryEnabled
|
{isSessionExpiryEnabled ? (
|
||||||
? sessionStatus.text
|
<SessionStatusText
|
||||||
: t(
|
expiresAtSec={auth.user?.expires_at}
|
||||||
"ui.dev.session.disabled",
|
t={t}
|
||||||
"세션 만료 비활성화",
|
/>
|
||||||
)}
|
) : (
|
||||||
|
t("ui.dev.session.disabled", "세션 만료 비활성화")
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -28,58 +28,7 @@ import {
|
|||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import { cn } from "../../lib/utils";
|
import { cn } from "../../lib/utils";
|
||||||
|
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
|
||||||
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을 조회합니다.",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function ApiKeyCreatePage() {
|
function ApiKeyCreatePage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -305,7 +254,7 @@ function ApiKeyCreatePage() {
|
|||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid gap-4 sm:grid-cols-2">
|
<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);
|
const isSelected = selectedScopes.includes(scope.id);
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
|||||||
105
adminfront/src/features/api-keys/ApiKeyListPage.test.tsx
Normal file
105
adminfront/src/features/api-keys/ApiKeyListPage.test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,6 +1,16 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||||
import type { AxiosError } from "axios";
|
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 { Link } from "react-router-dom";
|
||||||
import { Badge } from "../../components/ui/badge";
|
import { Badge } from "../../components/ui/badge";
|
||||||
import { Button } from "../../components/ui/button";
|
import { Button } from "../../components/ui/button";
|
||||||
@@ -11,6 +21,15 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from "../../components/ui/card";
|
} from "../../components/ui/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "../../components/ui/dialog";
|
||||||
|
import { Input } from "../../components/ui/input";
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -19,10 +38,27 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../components/ui/table";
|
} 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 { t } from "../../lib/i18n";
|
||||||
|
import { cn } from "../../lib/utils";
|
||||||
|
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
|
||||||
|
|
||||||
function ApiKeyListPage() {
|
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({
|
const query = useQuery({
|
||||||
queryKey: ["api-keys", { limit: 50, offset: 0 }],
|
queryKey: ["api-keys", { limit: 50, offset: 0 }],
|
||||||
queryFn: () => fetchApiKeys(50, 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
|
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||||
?.data?.error;
|
?.data?.error;
|
||||||
const fallbackError =
|
const fallbackError =
|
||||||
@@ -62,6 +119,44 @@ function ApiKeyListPage() {
|
|||||||
deleteMutation.mutate(id);
|
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 (
|
return (
|
||||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
<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">
|
<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")}
|
: t("ui.common.never", "Never")}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<Button
|
<div className="flex flex-wrap justify-end gap-2">
|
||||||
variant="outline"
|
<Button
|
||||||
size="sm"
|
variant="outline"
|
||||||
onClick={() => handleDelete(key.id, key.name)}
|
size="sm"
|
||||||
disabled={deleteMutation.isPending}
|
onClick={() => openScopeEditor(key)}
|
||||||
>
|
>
|
||||||
<Trash2 size={14} />
|
<Edit3 size={14} />
|
||||||
{t("ui.common.delete", "삭제")}
|
{t(
|
||||||
</Button>
|
"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>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
@@ -207,6 +327,137 @@ function ApiKeyListPage() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
59
adminfront/src/features/api-keys/apiKeyScopes.ts
Normal file
59
adminfront/src/features/api-keys/apiKeyScopes.ts
Normal 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을 조회합니다.",
|
||||||
|
},
|
||||||
|
];
|
||||||
41
adminfront/src/features/auth/AuthGuard.tsx
Normal file
41
adminfront/src/features/auth/AuthGuard.tsx
Normal 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 />;
|
||||||
|
}
|
||||||
@@ -14,7 +14,7 @@ vi.mock("../../lib/adminApi", () => ({
|
|||||||
oidcClients: 3,
|
oidcClients: 3,
|
||||||
auditEvents24h: 18,
|
auditEvents24h: 18,
|
||||||
})),
|
})),
|
||||||
fetchTenants: vi.fn(async () => ({
|
fetchAllTenants: vi.fn(async () => ({
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
id: "company-1",
|
id: "company-1",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
fetchAdminOverviewStats,
|
fetchAdminOverviewStats,
|
||||||
fetchAdminRPUsageDaily,
|
fetchAdminRPUsageDaily,
|
||||||
fetchTenants,
|
fetchAllTenants,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
|
|
||||||
@@ -342,7 +342,7 @@ function GlobalOverviewPage() {
|
|||||||
});
|
});
|
||||||
const tenantsQuery = useQuery({
|
const tenantsQuery = useQuery({
|
||||||
queryKey: ["admin-overview-tenant-options"],
|
queryKey: ["admin-overview-tenant-options"],
|
||||||
queryFn: () => fetchTenants(1000, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const tenantOptions = useMemo(() => {
|
const tenantOptions = useMemo(() => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
import { Input } from "../../../components/ui/input";
|
import { Input } from "../../../components/ui/input";
|
||||||
import { Label } from "../../../components/ui/label";
|
import { Label } from "../../../components/ui/label";
|
||||||
import { Textarea } from "../../../components/ui/textarea";
|
import { Textarea } from "../../../components/ui/textarea";
|
||||||
import { createTenant, fetchTenants } from "../../../lib/adminApi";
|
import { createTenant, fetchAllTenants } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { DomainTagInput } from "../components/DomainTagInput";
|
import { DomainTagInput } from "../components/DomainTagInput";
|
||||||
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||||
@@ -47,8 +47,8 @@ function TenantCreatePage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const parentQuery = useQuery({
|
const parentQuery = useQuery({
|
||||||
queryKey: ["tenants", { limit: 1000 }],
|
queryKey: ["tenants", "all"],
|
||||||
queryFn: () => fetchTenants(1000, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
const tenants = parentQuery.data?.items ?? [];
|
const tenants = parentQuery.data?.items ?? [];
|
||||||
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
|
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
|
||||||
|
|||||||
@@ -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 type { AxiosError } from "axios";
|
||||||
import {
|
import {
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
@@ -74,6 +75,10 @@ import {
|
|||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||||
|
import {
|
||||||
|
filterNonHanmacFamilyTenants,
|
||||||
|
isHanmacFamilyUser,
|
||||||
|
} from "../../users/orgChartPicker";
|
||||||
import { isSeedTenant } from "../utils/protectedTenants";
|
import { isSeedTenant } from "../utils/protectedTenants";
|
||||||
import {
|
import {
|
||||||
type TenantImportPreviewRow,
|
type TenantImportPreviewRow,
|
||||||
@@ -87,8 +92,14 @@ import {
|
|||||||
|
|
||||||
const tenantCSVTemplate =
|
const tenantCSVTemplate =
|
||||||
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
|
"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 TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
|
||||||
|
type TenantListRow = TenantSummary & { recursiveMemberCount: number };
|
||||||
|
|
||||||
const getTenantIcon = (type?: string) => {
|
const getTenantIcon = (type?: string) => {
|
||||||
switch (type?.toUpperCase()) {
|
switch (type?.toUpperCase()) {
|
||||||
@@ -245,6 +256,7 @@ function TenantListPage() {
|
|||||||
Record<number, string>
|
Record<number, string>
|
||||||
>({});
|
>({});
|
||||||
const [previewOpen, setPreviewOpen] = React.useState(false);
|
const [previewOpen, setPreviewOpen] = React.useState(false);
|
||||||
|
const tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
const { data: profile } = useQuery({
|
const { data: profile } = useQuery({
|
||||||
queryKey: ["me"],
|
queryKey: ["me"],
|
||||||
@@ -264,9 +276,18 @@ function TenantListPage() {
|
|||||||
}
|
}
|
||||||
}, [profile, navigate]);
|
}, [profile, navigate]);
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useInfiniteQuery({
|
||||||
queryKey: ["tenants", { limit: 1000, offset: 0 }],
|
queryKey: ["tenants", "lazy"],
|
||||||
queryFn: () => fetchTenants(1000, 0),
|
queryFn: ({ pageParam }) =>
|
||||||
|
fetchTenants(
|
||||||
|
tenantPageSize,
|
||||||
|
0,
|
||||||
|
undefined,
|
||||||
|
pageParam ? pageParam : undefined,
|
||||||
|
),
|
||||||
|
initialPageParam: "",
|
||||||
|
getNextPageParam: (lastPage) =>
|
||||||
|
lastPage.nextCursor || lastPage.next_cursor || undefined,
|
||||||
enabled:
|
enabled:
|
||||||
profile?.role === "super_admin" ||
|
profile?.role === "super_admin" ||
|
||||||
(profile?.role === "tenant_admin" &&
|
(profile?.role === "tenant_admin" &&
|
||||||
@@ -364,7 +385,28 @@ function TenantListPage() {
|
|||||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||||
: null;
|
: 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 =
|
const importParentOptionGroups =
|
||||||
buildTenantImportParentOptionGroups(allTenants);
|
buildTenantImportParentOptionGroups(allTenants);
|
||||||
const tenantSortResolvers = React.useMemo<
|
const tenantSortResolvers = React.useMemo<
|
||||||
@@ -414,6 +456,56 @@ function TenantListPage() {
|
|||||||
return sortItems(enriched, sortConfig, tenantSortResolvers);
|
return sortItems(enriched, sortConfig, tenantSortResolvers);
|
||||||
}, [allTenants, search, 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) => {
|
const requestSort = (key: TenantSortKey) => {
|
||||||
setSortConfig((current) => toggleSort(current, key));
|
setSortConfig((current) => toggleSort(current, key));
|
||||||
};
|
};
|
||||||
@@ -600,6 +692,96 @@ function TenantListPage() {
|
|||||||
deleteMutation.mutate(tenantId);
|
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 (
|
return (
|
||||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
<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">
|
<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",
|
"msg.admin.tenants.registry.count",
|
||||||
"총 {{count}}개 테넌트",
|
"총 {{count}}개 테넌트",
|
||||||
{
|
{
|
||||||
count: query.data?.total ?? 0,
|
count: tenantTotal,
|
||||||
},
|
},
|
||||||
)}
|
)}
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
@@ -771,7 +953,12 @@ function TenantListPage() {
|
|||||||
className="flex-1 flex flex-col min-h-0 m-0"
|
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 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]">
|
<Table className="min-w-[1180px]">
|
||||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||||
<TableRow>
|
<TableRow>
|
||||||
@@ -855,7 +1042,18 @@ function TenantListPage() {
|
|||||||
</TableHead>
|
</TableHead>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody
|
||||||
|
className={
|
||||||
|
shouldVirtualizeTenants ? "relative block" : undefined
|
||||||
|
}
|
||||||
|
style={
|
||||||
|
shouldVirtualizeTenants
|
||||||
|
? {
|
||||||
|
height: `${tenantRowVirtualizer.getTotalSize()}px`,
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
{query.isLoading && (
|
{query.isLoading && (
|
||||||
<TableRow>
|
<TableRow>
|
||||||
<TableCell colSpan={9}>
|
<TableCell colSpan={9}>
|
||||||
@@ -876,102 +1074,26 @@ function TenantListPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
)}
|
)}
|
||||||
{tenants.map((tenant) => (
|
{shouldVirtualizeTenants
|
||||||
<TableRow key={tenant.id}>
|
? virtualTenantRows.map((virtualRow) => {
|
||||||
<TableCell className="text-center">
|
const tenant = tenants[virtualRow.index];
|
||||||
{isSeedTenant(tenant) ? (
|
if (!tenant) {
|
||||||
<span className="inline-block h-4 w-4" />
|
return null;
|
||||||
) : (
|
}
|
||||||
<Checkbox
|
return renderTenantRow(tenant, {
|
||||||
checked={selectedIds.includes(tenant.id)}
|
virtualIndex: virtualRow.index,
|
||||||
onCheckedChange={(checked) =>
|
style: {
|
||||||
handleSelect(tenant, !!checked)
|
position: "absolute",
|
||||||
}
|
top: 0,
|
||||||
/>
|
left: 0,
|
||||||
)}
|
width: "100%",
|
||||||
</TableCell>
|
display: "table",
|
||||||
<TableCell
|
tableLayout: "fixed",
|
||||||
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
data-testid={`tenant-internal-id-${tenant.id}`}
|
},
|
||||||
>
|
});
|
||||||
{tenant.id}
|
})
|
||||||
</TableCell>
|
: tenants.map((tenant) => renderTenantRow(tenant))}
|
||||||
<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>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ import { toast } from "../../../components/ui/use-toast";
|
|||||||
import {
|
import {
|
||||||
approveTenant,
|
approveTenant,
|
||||||
deleteTenant,
|
deleteTenant,
|
||||||
|
fetchAllTenants,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
fetchTenants,
|
|
||||||
updateTenant,
|
updateTenant,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
@@ -58,7 +58,7 @@ export function TenantProfilePage() {
|
|||||||
|
|
||||||
const parentQuery = useQuery({
|
const parentQuery = useQuery({
|
||||||
queryKey: ["tenants", "list-all"],
|
queryKey: ["tenants", "list-all"],
|
||||||
queryFn: () => fetchTenants(1000, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
import { fetchTenants } from "../../../lib/adminApi";
|
import { fetchAllTenants } from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
function TenantSubTenantsPage() {
|
function TenantSubTenantsPage() {
|
||||||
@@ -27,7 +27,7 @@ function TenantSubTenantsPage() {
|
|||||||
|
|
||||||
const { data } = useQuery({
|
const { data } = useQuery({
|
||||||
queryKey: ["sub-tenants", tenantId],
|
queryKey: ["sub-tenants", tenantId],
|
||||||
queryFn: () => fetchTenants(50, 0, tenantId ?? undefined),
|
queryFn: () => fetchAllTenants({ parentId: tenantId ?? undefined }),
|
||||||
enabled: !!tenantId,
|
enabled: !!tenantId,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -21,14 +21,14 @@ import {
|
|||||||
} from "../../../components/ui/table";
|
} from "../../../components/ui/table";
|
||||||
import {
|
import {
|
||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
|
fetchAllTenants,
|
||||||
fetchGroups,
|
fetchGroups,
|
||||||
fetchTenants,
|
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
|
|
||||||
export default function GlobalUserGroupListPage() {
|
export default function GlobalUserGroupListPage() {
|
||||||
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
|
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
|
||||||
queryKey: ["admin-tenants"],
|
queryKey: ["admin-tenants"],
|
||||||
queryFn: () => fetchTenants(100, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (isTenantsLoading)
|
if (isTenantsLoading)
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ import {
|
|||||||
type UserSummary,
|
type UserSummary,
|
||||||
createUser,
|
createUser,
|
||||||
exportTenantsCSV,
|
exportTenantsCSV,
|
||||||
fetchTenants,
|
fetchAllTenants,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
updateTenant,
|
updateTenant,
|
||||||
updateUser,
|
updateUser,
|
||||||
@@ -449,7 +449,7 @@ function TenantUserGroupsTab() {
|
|||||||
refetch: refetchTree,
|
refetch: refetchTree,
|
||||||
} = useQuery({
|
} = useQuery({
|
||||||
queryKey: ["tenants-full-tree-v2"],
|
queryKey: ["tenants-full-tree-v2"],
|
||||||
queryFn: () => fetchTenants(1000, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { currentBase, subTree } = useMemo(() => {
|
const { currentBase, subTree } = useMemo(() => {
|
||||||
|
|||||||
@@ -42,9 +42,9 @@ import { toast } from "../../../components/ui/use-toast";
|
|||||||
import {
|
import {
|
||||||
addGroupMember,
|
addGroupMember,
|
||||||
assignGroupRole,
|
assignGroupRole,
|
||||||
|
fetchAllTenants,
|
||||||
fetchGroup,
|
fetchGroup,
|
||||||
fetchGroupRoles,
|
fetchGroupRoles,
|
||||||
fetchTenants,
|
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
removeGroupMember,
|
removeGroupMember,
|
||||||
removeGroupRole,
|
removeGroupRole,
|
||||||
@@ -91,7 +91,7 @@ export function UserGroupDetailPage() {
|
|||||||
// Fetch all tenants for role assignment
|
// Fetch all tenants for role assignment
|
||||||
const { data: tenantList } = useQuery({
|
const { data: tenantList } = useQuery({
|
||||||
queryKey: ["admin-tenants"],
|
queryKey: ["admin-tenants"],
|
||||||
queryFn: () => fetchTenants(100, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
enabled: isAddRoleOpen,
|
enabled: isAddRoleOpen,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -42,11 +42,10 @@ import {
|
|||||||
type UserAppointment,
|
type UserAppointment,
|
||||||
type UserCreateRequest,
|
type UserCreateRequest,
|
||||||
type UserCreateResponse,
|
type UserCreateResponse,
|
||||||
createTenant,
|
|
||||||
createUser,
|
createUser,
|
||||||
|
fetchAllTenants,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
fetchTenants,
|
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
import { t } from "../../lib/i18n";
|
import { t } from "../../lib/i18n";
|
||||||
import {
|
import {
|
||||||
@@ -56,9 +55,10 @@ import {
|
|||||||
parseOrgChartTenantSelection,
|
parseOrgChartTenantSelection,
|
||||||
} from "./orgChartPicker";
|
} from "./orgChartPicker";
|
||||||
import type { UserSchemaField } from "./userSchemaFields";
|
import type { UserSchemaField } from "./userSchemaFields";
|
||||||
|
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||||
|
|
||||||
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
||||||
type UserType = "hanmac" | "external" | "personal";
|
type UserCategory = "hanmac" | "external" | "personal";
|
||||||
|
|
||||||
type PickerTarget = { kind: "appointment"; index: number };
|
type PickerTarget = { kind: "appointment"; index: number };
|
||||||
|
|
||||||
@@ -114,8 +114,8 @@ function UserCreatePage() {
|
|||||||
>(null);
|
>(null);
|
||||||
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
||||||
const [autoPassword, setAutoPassword] = React.useState(true);
|
const [autoPassword, setAutoPassword] = React.useState(true);
|
||||||
const [isHanmacFamily, setIsHanmacFamily] = React.useState(true);
|
const [userCategory, setUserCategory] =
|
||||||
const [userType, setUserType] = React.useState<UserType>("hanmac");
|
React.useState<UserCategory>("hanmac");
|
||||||
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
||||||
AppointmentDraft[]
|
AppointmentDraft[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -125,8 +125,8 @@ function UserCreatePage() {
|
|||||||
const [isResolvingTenant, setIsResolvingTenant] = React.useState(false);
|
const [isResolvingTenant, setIsResolvingTenant] = React.useState(false);
|
||||||
|
|
||||||
const { data: tenantsData } = useQuery({
|
const { data: tenantsData } = useQuery({
|
||||||
queryKey: ["tenants", { limit: 100 }],
|
queryKey: ["tenants", "all"],
|
||||||
queryFn: () => fetchTenants(100, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
const tenants = tenantsData?.items ?? [];
|
const tenants = tenantsData?.items ?? [];
|
||||||
|
|
||||||
@@ -177,17 +177,11 @@ function UserCreatePage() {
|
|||||||
|
|
||||||
const selectedTenantSlug = watch("tenantSlug");
|
const selectedTenantSlug = watch("tenantSlug");
|
||||||
const personalTenant = React.useMemo(
|
const personalTenant = React.useMemo(
|
||||||
() =>
|
() => resolvePersonalTenant(tenants),
|
||||||
tenants.find(
|
|
||||||
(tenant) =>
|
|
||||||
tenant.slug === "personal" ||
|
|
||||||
(tenant.type === "PERSONAL" &&
|
|
||||||
tenant.name.toLowerCase() === "personal"),
|
|
||||||
),
|
|
||||||
[tenants],
|
[tenants],
|
||||||
);
|
);
|
||||||
const selectedTenant =
|
const selectedTenant =
|
||||||
userType !== "external"
|
userCategory !== "external"
|
||||||
? undefined
|
? undefined
|
||||||
: nonHanmacFamilyTenants.find((t) => t.slug === selectedTenantSlug);
|
: nonHanmacFamilyTenants.find((t) => t.slug === selectedTenantSlug);
|
||||||
|
|
||||||
@@ -231,7 +225,7 @@ function UserCreatePage() {
|
|||||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||||
import.meta.env.ORGFRONT_URL,
|
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 handleUserCategoryChange = (value: string) => {
|
||||||
const nextType = value as UserType;
|
const nextCategory = value as UserCategory;
|
||||||
setUserType(nextType);
|
setUserCategory(nextCategory);
|
||||||
setIsHanmacFamily(nextType === "hanmac");
|
if (nextCategory !== "hanmac") {
|
||||||
if (nextType !== "hanmac") {
|
|
||||||
setAdditionalAppointments([]);
|
setAdditionalAppointments([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensurePersonalTenant = async () => {
|
const ensurePersonalTenant = async () => {
|
||||||
if (personalTenant) return personalTenant;
|
return personalTenant;
|
||||||
const tenant = await createTenant({
|
|
||||||
name: "Personal",
|
|
||||||
slug: "personal",
|
|
||||||
type: "PERSONAL",
|
|
||||||
status: "active",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
|
||||||
return tenant;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
@@ -355,10 +340,13 @@ function UserCreatePage() {
|
|||||||
setGeneratedPassword(null);
|
setGeneratedPassword(null);
|
||||||
setCreatedEmail(null);
|
setCreatedEmail(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
hanmacFamily: _hanmacFamily,
|
||||||
|
userType: _userType,
|
||||||
|
...formMetadata
|
||||||
|
} = data.metadata ?? {};
|
||||||
const metadata: Record<string, unknown> = {
|
const metadata: Record<string, unknown> = {
|
||||||
...(data.metadata ?? {}),
|
...formMetadata,
|
||||||
hanmacFamily: userType === "hanmac" && isHanmacFamily,
|
|
||||||
userType,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const payload: UserCreateRequest = {
|
const payload: UserCreateRequest = {
|
||||||
@@ -369,7 +357,7 @@ function UserCreatePage() {
|
|||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (userType === "external") {
|
if (userCategory === "external") {
|
||||||
if (!data.tenantSlug) {
|
if (!data.tenantSlug) {
|
||||||
setError(
|
setError(
|
||||||
t(
|
t(
|
||||||
@@ -386,7 +374,7 @@ function UserCreatePage() {
|
|||||||
payload.jobTitle = data.jobTitle;
|
payload.jobTitle = data.jobTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userType === "personal") {
|
if (userCategory === "personal") {
|
||||||
try {
|
try {
|
||||||
const tenant = await ensurePersonalTenant();
|
const tenant = await ensurePersonalTenant();
|
||||||
payload.tenantSlug = tenant.slug;
|
payload.tenantSlug = tenant.slug;
|
||||||
@@ -405,7 +393,7 @@ function UserCreatePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userType === "hanmac") {
|
if (userCategory === "hanmac") {
|
||||||
const appointments = additionalAppointments
|
const appointments = additionalAppointments
|
||||||
.filter((appointment) => appointment.tenantId)
|
.filter((appointment) => appointment.tenantId)
|
||||||
.map((appointment) => ({
|
.map((appointment) => ({
|
||||||
@@ -644,7 +632,7 @@ function UserCreatePage() {
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
|
||||||
<TabsTrigger
|
<TabsTrigger
|
||||||
value="hanmac"
|
value="hanmac"
|
||||||
|
|||||||
@@ -56,12 +56,11 @@ import {
|
|||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
type UserAppointment,
|
type UserAppointment,
|
||||||
type UserUpdateRequest,
|
type UserUpdateRequest,
|
||||||
createTenant,
|
|
||||||
deleteUser,
|
deleteUser,
|
||||||
|
fetchAllTenants,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchPasswordPolicy,
|
fetchPasswordPolicy,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
fetchTenants,
|
|
||||||
fetchUser,
|
fetchUser,
|
||||||
fetchUserRpHistory,
|
fetchUserRpHistory,
|
||||||
updateUser,
|
updateUser,
|
||||||
@@ -78,11 +77,12 @@ import {
|
|||||||
parseOrgChartTenantSelection,
|
parseOrgChartTenantSelection,
|
||||||
} from "./orgChartPicker";
|
} from "./orgChartPicker";
|
||||||
import type { UserSchemaField } from "./userSchemaFields";
|
import type { UserSchemaField } from "./userSchemaFields";
|
||||||
|
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||||
|
|
||||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||||
metadata: Record<string, Record<string, string | number | boolean>>;
|
metadata: Record<string, Record<string, string | number | boolean>>;
|
||||||
};
|
};
|
||||||
type UserType = "hanmac" | "external" | "personal";
|
type UserCategory = "hanmac" | "external" | "personal";
|
||||||
|
|
||||||
type PasswordResetMode = "generated" | "manual";
|
type PasswordResetMode = "generated" | "manual";
|
||||||
type PickerTarget = { kind: "appointment"; index: number };
|
type PickerTarget = { kind: "appointment"; index: number };
|
||||||
@@ -318,8 +318,8 @@ function UserDetailPage() {
|
|||||||
const [passwordResetError, setPasswordResetError] = React.useState<
|
const [passwordResetError, setPasswordResetError] = React.useState<
|
||||||
string | null
|
string | null
|
||||||
>(null);
|
>(null);
|
||||||
const [isHanmacFamily, setIsHanmacFamily] = React.useState(false);
|
const [userCategory, setUserCategory] =
|
||||||
const [userType, setUserType] = React.useState<UserType>("external");
|
React.useState<UserCategory>("external");
|
||||||
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
||||||
AppointmentDraft[]
|
AppointmentDraft[]
|
||||||
>([]);
|
>([]);
|
||||||
@@ -346,8 +346,8 @@ function UserDetailPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: tenantsData } = useQuery({
|
const { data: tenantsData } = useQuery({
|
||||||
queryKey: ["tenants", { limit: 100 }],
|
queryKey: ["tenants", "all"],
|
||||||
queryFn: () => fetchTenants(100, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
const tenants = React.useMemo(
|
const tenants = React.useMemo(
|
||||||
() => tenantsData?.items ?? [],
|
() => tenantsData?.items ?? [],
|
||||||
@@ -465,20 +465,14 @@ function UserDetailPage() {
|
|||||||
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
|
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
|
||||||
}, [tenants]);
|
}, [tenants]);
|
||||||
const personalTenant = React.useMemo(
|
const personalTenant = React.useMemo(
|
||||||
() =>
|
() => resolvePersonalTenant(tenants),
|
||||||
tenants.find(
|
|
||||||
(tenant) =>
|
|
||||||
tenant.slug === "personal" ||
|
|
||||||
(tenant.type === "PERSONAL" &&
|
|
||||||
tenant.name.toLowerCase() === "personal"),
|
|
||||||
),
|
|
||||||
[tenants],
|
[tenants],
|
||||||
);
|
);
|
||||||
|
|
||||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||||
import.meta.env.ORGFRONT_URL,
|
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 handleUserCategoryChange = (value: string) => {
|
||||||
const nextType = value as UserType;
|
const nextCategory = value as UserCategory;
|
||||||
setUserType(nextType);
|
setUserCategory(nextCategory);
|
||||||
setIsHanmacFamily(nextType === "hanmac");
|
if (nextCategory !== "hanmac") {
|
||||||
if (nextType !== "hanmac") {
|
|
||||||
setAdditionalAppointments([]);
|
setAdditionalAppointments([]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const ensurePersonalTenant = async () => {
|
const ensurePersonalTenant = async () => {
|
||||||
if (personalTenant) return personalTenant;
|
return personalTenant;
|
||||||
const tenant = await createTenant({
|
|
||||||
name: "Personal",
|
|
||||||
slug: "personal",
|
|
||||||
type: "PERSONAL",
|
|
||||||
status: "active",
|
|
||||||
});
|
|
||||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
|
||||||
return tenant;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -638,14 +623,18 @@ function UserDetailPage() {
|
|||||||
tenants,
|
tenants,
|
||||||
hanmacFamilyTenantId,
|
hanmacFamilyTenantId,
|
||||||
);
|
);
|
||||||
const resolvedUserType =
|
const isPersonalUser =
|
||||||
metadata.userType === "personal" || user.companyCode === "personal"
|
user.companyCode === personalTenant.slug ||
|
||||||
? "personal"
|
user.tenantSlug === personalTenant.slug ||
|
||||||
: isUserHanmacFamily
|
user.tenant?.id === personalTenant.id ||
|
||||||
? "hanmac"
|
user.tenant?.slug === personalTenant.slug ||
|
||||||
: "external";
|
metadata.personalTenantId === personalTenant.id;
|
||||||
setUserType(resolvedUserType);
|
const resolvedUserCategory = isPersonalUser
|
||||||
setIsHanmacFamily(resolvedUserType === "hanmac");
|
? "personal"
|
||||||
|
: isUserHanmacFamily
|
||||||
|
? "hanmac"
|
||||||
|
: "external";
|
||||||
|
setUserCategory(resolvedUserCategory);
|
||||||
const familyFallbackTenants = [
|
const familyFallbackTenants = [
|
||||||
...(user.joinedTenants ?? []),
|
...(user.joinedTenants ?? []),
|
||||||
...(user.tenant ? [user.tenant] : []),
|
...(user.tenant ? [user.tenant] : []),
|
||||||
@@ -696,7 +685,7 @@ function UserDetailPage() {
|
|||||||
: [],
|
: [],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}, [hanmacFamilyTenantId, tenants, user, reset]);
|
}, [hanmacFamilyTenantId, personalTenant, tenants, user, reset]);
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
|
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
|
||||||
@@ -737,10 +726,13 @@ function UserDetailPage() {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const {
|
||||||
|
hanmacFamily: _hanmacFamily,
|
||||||
|
userType: _userType,
|
||||||
|
...safeMetadata
|
||||||
|
} = cleanMetadata;
|
||||||
const metadata: Record<string, unknown> = {
|
const metadata: Record<string, unknown> = {
|
||||||
...cleanMetadata,
|
...safeMetadata,
|
||||||
hanmacFamily: userType === "hanmac" && isHanmacFamily,
|
|
||||||
userType,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const profileData = { ...data };
|
const profileData = { ...data };
|
||||||
@@ -750,7 +742,7 @@ function UserDetailPage() {
|
|||||||
metadata,
|
metadata,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (userType === "personal") {
|
if (userCategory === "personal") {
|
||||||
try {
|
try {
|
||||||
const tenant = await ensurePersonalTenant();
|
const tenant = await ensurePersonalTenant();
|
||||||
payload.tenantSlug = tenant.slug;
|
payload.tenantSlug = tenant.slug;
|
||||||
@@ -768,7 +760,7 @@ function UserDetailPage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (userType === "hanmac") {
|
if (userCategory === "hanmac") {
|
||||||
const appointments = additionalAppointments
|
const appointments = additionalAppointments
|
||||||
.filter((appointment) => appointment.tenantId)
|
.filter((appointment) => appointment.tenantId)
|
||||||
.map((appointment) => ({
|
.map((appointment) => ({
|
||||||
@@ -1071,8 +1063,8 @@ function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<Tabs
|
||||||
value={userType}
|
value={userCategory}
|
||||||
onValueChange={handleUserTypeChange}
|
onValueChange={handleUserCategoryChange}
|
||||||
className="space-y-4 pt-6 border-t border-dashed"
|
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">
|
<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>
|
</TabsList>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{userType === "external" && (
|
{userCategory === "external" && (
|
||||||
<div className="grid gap-8 md:grid-cols-2">
|
<div className="grid gap-8 md:grid-cols-2">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
@@ -1141,7 +1133,7 @@ function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{userType === "hanmac" && (
|
{userCategory === "hanmac" && (
|
||||||
<div className="space-y-4 rounded-md border p-4">
|
<div className="space-y-4 rounded-md border p-4">
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
@@ -1314,7 +1306,7 @@ function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{userType === "personal" && (
|
{userCategory === "personal" && (
|
||||||
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
||||||
{personalTenant
|
{personalTenant
|
||||||
? `Personal (${personalTenant.slug})`
|
? `Personal (${personalTenant.slug})`
|
||||||
@@ -1322,7 +1314,7 @@ function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{userType === "external" && (
|
{userCategory === "external" && (
|
||||||
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
|
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label
|
<Label
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
Search,
|
||||||
Settings2,
|
Settings2,
|
||||||
|
ShieldCheck,
|
||||||
Trash2,
|
Trash2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
@@ -62,9 +63,9 @@ import {
|
|||||||
bulkUpdateUsers,
|
bulkUpdateUsers,
|
||||||
deleteUser,
|
deleteUser,
|
||||||
exportUsersCSV,
|
exportUsersCSV,
|
||||||
|
fetchAllTenants,
|
||||||
fetchMe,
|
fetchMe,
|
||||||
fetchTenant,
|
fetchTenant,
|
||||||
fetchTenants,
|
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
updateUser,
|
updateUser,
|
||||||
} from "../../lib/adminApi";
|
} from "../../lib/adminApi";
|
||||||
@@ -101,8 +102,8 @@ function UserListPage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const { data: tenantsData } = useQuery({
|
const { data: tenantsData } = useQuery({
|
||||||
queryKey: ["tenants", { limit: 100 }],
|
queryKey: ["tenants", "all"],
|
||||||
queryFn: () => fetchTenants(100, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
const tenants = tenantsData?.items ?? [];
|
const tenants = tenantsData?.items ?? [];
|
||||||
|
|
||||||
@@ -269,6 +270,7 @@ function UserListPage() {
|
|||||||
|
|
||||||
const total = query.data?.total ?? 0;
|
const total = query.data?.total ?? 0;
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
const canPromoteSuperAdmin = profile?.role === "super_admin";
|
||||||
|
|
||||||
const toggleSelectAll = () => {
|
const toggleSelectAll = () => {
|
||||||
if (selectedUserIds.length === items.length) {
|
if (selectedUserIds.length === items.length) {
|
||||||
@@ -318,6 +320,14 @@ function UserListPage() {
|
|||||||
bulkUpdateMutation.mutate({ userIds: selectedUserIds, status });
|
bulkUpdateMutation.mutate({ userIds: selectedUserIds, status });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handlePromoteSuperAdmin = () => {
|
||||||
|
if (selectedUserIds.length === 0) return;
|
||||||
|
bulkUpdateMutation.mutate({
|
||||||
|
userIds: selectedUserIds,
|
||||||
|
role: "super_admin",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleBulkDelete = () => {
|
const handleBulkDelete = () => {
|
||||||
if (selectedUserIds.length === 0) return;
|
if (selectedUserIds.length === 0) return;
|
||||||
if (
|
if (
|
||||||
@@ -774,6 +784,18 @@ function UserListPage() {
|
|||||||
>
|
>
|
||||||
{t("ui.common.status.inactive", "비활성화")}
|
{t("ui.common.status.inactive", "비활성화")}
|
||||||
</Button>
|
</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" />
|
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
|
|||||||
@@ -20,8 +20,8 @@ import {
|
|||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
bulkUpdateUsers,
|
bulkUpdateUsers,
|
||||||
|
fetchAllTenants,
|
||||||
fetchGroups,
|
fetchGroups,
|
||||||
fetchTenants,
|
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
|
|
||||||
@@ -52,8 +52,8 @@ export function UserBulkMoveGroupModal({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
const { data: tenantsData } = useQuery({
|
const { data: tenantsData } = useQuery({
|
||||||
queryKey: ["tenants", { limit: 100 }],
|
queryKey: ["tenants", "all"],
|
||||||
queryFn: () => fetchTenants(100, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
enabled: open,
|
enabled: open,
|
||||||
});
|
});
|
||||||
const tenants = tenantsData?.items ?? [];
|
const tenants = tenantsData?.items ?? [];
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
type BulkUserResult,
|
type BulkUserResult,
|
||||||
bulkCreateUsers,
|
bulkCreateUsers,
|
||||||
createTenant,
|
createTenant,
|
||||||
fetchTenants,
|
fetchAllTenants,
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { t } from "../../../lib/i18n";
|
import { t } from "../../../lib/i18n";
|
||||||
@@ -139,7 +139,7 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
|||||||
|
|
||||||
const tenantQuery = useQuery({
|
const tenantQuery = useQuery({
|
||||||
queryKey: ["tenants", "user-bulk-import"],
|
queryKey: ["tenants", "user-bulk-import"],
|
||||||
queryFn: () => fetchTenants(1000, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const usersQuery = useQuery({
|
const usersQuery = useQuery({
|
||||||
|
|||||||
@@ -169,4 +169,66 @@ describe("orgChartPicker", () => {
|
|||||||
),
|
),
|
||||||
).toBe(true);
|
).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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ export type OrgChartTenantSelection = {
|
|||||||
|
|
||||||
export type TenantFilterTarget = {
|
export type TenantFilterTarget = {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
tenantId?: string;
|
||||||
slug?: string;
|
slug?: string;
|
||||||
|
tenantSlug?: string;
|
||||||
type?: string;
|
type?: string;
|
||||||
parentId?: string | null;
|
parentId?: string | null;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
tenantName?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type HanmacFamilyUserTarget = {
|
export type HanmacFamilyUserTarget = {
|
||||||
@@ -120,19 +123,43 @@ export function isHanmacFamilyUser<T extends TenantFilterTarget>(
|
|||||||
tenants: T[],
|
tenants: T[],
|
||||||
hanmacFamilyTenantId?: string,
|
hanmacFamilyTenantId?: string,
|
||||||
) {
|
) {
|
||||||
const metadata = user.metadata ?? {};
|
const metadataAppointments = Array.isArray(
|
||||||
if (metadata.hanmacFamily === true || metadata.userType === "hanmac") {
|
user.metadata?.additionalAppointments,
|
||||||
return true;
|
)
|
||||||
}
|
? 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(
|
const tenantBySlug = new Map(
|
||||||
tenants
|
tenants
|
||||||
.filter((tenant) => tenant.slug?.trim())
|
.filter((tenant) => tenant.slug?.trim())
|
||||||
.map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]),
|
.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 = [
|
const tenantCandidates = [
|
||||||
user.tenant,
|
user.tenant,
|
||||||
...(user.joinedTenants ?? []),
|
...(user.joinedTenants ?? []),
|
||||||
|
...metadataAppointments,
|
||||||
|
...metadataAppointments.map((appointment) =>
|
||||||
|
tenantById.get(appointment.id ?? ""),
|
||||||
|
),
|
||||||
tenantBySlug.get(user.companyCode?.toLowerCase() ?? ""),
|
tenantBySlug.get(user.companyCode?.toLowerCase() ?? ""),
|
||||||
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
|
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
|
||||||
];
|
];
|
||||||
|
|||||||
37
adminfront/src/features/users/utils/personalTenant.test.ts
Normal file
37
adminfront/src/features/users/utils/personalTenant.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
34
adminfront/src/features/users/utils/personalTenant.ts
Normal file
34
adminfront/src/features/users/utils/personalTenant.ts
Normal 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",
|
||||||
|
};
|
||||||
|
}
|
||||||
77
adminfront/src/lib/adminApi.test.ts
Normal file
77
adminfront/src/lib/adminApi.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { fetchAllCursorPages } from "../../../common/core/pagination";
|
||||||
import apiClient from "./apiClient";
|
import apiClient from "./apiClient";
|
||||||
|
import { userManager } from "./auth";
|
||||||
|
|
||||||
export type AuditLog = {
|
export type AuditLog = {
|
||||||
event_id: string;
|
event_id: string;
|
||||||
@@ -51,6 +53,9 @@ export type TenantListResponse = {
|
|||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
cursor?: string;
|
||||||
|
nextCursor?: string;
|
||||||
|
next_cursor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TenantUpdateRequest = {
|
export type TenantUpdateRequest = {
|
||||||
@@ -195,16 +200,73 @@ export async function fetchAdminRPUsageDaily({
|
|||||||
return data;
|
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>(
|
const { data } = await apiClient.get<TenantListResponse>(
|
||||||
"/v1/admin/tenants",
|
"/v1/admin/tenants",
|
||||||
{
|
{
|
||||||
params: { limit, offset, parentId },
|
params: { limit, offset, parentId, cursor },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return data;
|
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) {
|
export async function fetchTenant(tenantId: string) {
|
||||||
const { data } = await apiClient.get<TenantSummary>(
|
const { data } = await apiClient.get<TenantSummary>(
|
||||||
`/v1/admin/tenants/${tenantId}`,
|
`/v1/admin/tenants/${tenantId}`,
|
||||||
@@ -440,6 +502,10 @@ export type ApiKeyCreateResponse = {
|
|||||||
clientSecret: string;
|
clientSecret: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type ApiKeyUpdateScopesRequest = {
|
||||||
|
scopes: string[];
|
||||||
|
};
|
||||||
|
|
||||||
export async function fetchApiKeys(limit = 50, offset = 0) {
|
export async function fetchApiKeys(limit = 50, offset = 0) {
|
||||||
const { data } = await apiClient.get<ApiKeyListResponse>(
|
const { data } = await apiClient.get<ApiKeyListResponse>(
|
||||||
"/v1/admin/api-keys",
|
"/v1/admin/api-keys",
|
||||||
@@ -458,6 +524,24 @@ export async function createApiKey(payload: ApiKeyCreateRequest) {
|
|||||||
return data;
|
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) {
|
export async function deleteApiKey(apiKeyId: string) {
|
||||||
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
|
await apiClient.delete(`/v1/admin/api-keys/${apiKeyId}`);
|
||||||
}
|
}
|
||||||
@@ -678,17 +762,9 @@ export async function fetchUser(userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createUser(payload: UserCreateRequest) {
|
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>(
|
const { data } = await apiClient.post<UserCreateResponse>(
|
||||||
"/v1/admin/users",
|
"/v1/admin/users",
|
||||||
requestPayload,
|
payload,
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -714,16 +790,9 @@ export async function exportUsersCSV(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkCreateUsers(users: BulkUserItem[]) {
|
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>(
|
const { data } = await apiClient.post<BulkUserResponse>(
|
||||||
"/v1/admin/users/bulk",
|
"/v1/admin/users/bulk",
|
||||||
{ users: mappedUsers },
|
{ users },
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -810,13 +879,7 @@ export async function bulkUpdateUsers(payload: {
|
|||||||
grade?: string;
|
grade?: string;
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
}) {
|
}) {
|
||||||
const requestPayload: typeof payload & { companyCode?: string } = {
|
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
|
||||||
...payload,
|
|
||||||
};
|
|
||||||
if (payload.tenantSlug !== undefined) {
|
|
||||||
requestPayload.companyCode = payload.tenantSlug;
|
|
||||||
}
|
|
||||||
const { data } = await apiClient.put("/v1/admin/users/bulk", requestPayload);
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -828,16 +891,9 @@ export async function bulkDeleteUsers(userIds: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
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>(
|
const { data } = await apiClient.put<UserSummary>(
|
||||||
`/v1/admin/users/${userId}`,
|
`/v1/admin/users/${userId}`,
|
||||||
requestPayload,
|
payload,
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { shouldStartLoginRedirect } from "../../../common/core/auth";
|
||||||
import { userManager } from "./auth";
|
import { userManager } from "./auth";
|
||||||
|
|
||||||
|
let isRedirectingToLogin = false;
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL: (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
baseURL: (window as Window & typeof globalThis & { _IS_TEST_MODE?: boolean })
|
||||||
._IS_TEST_MODE
|
._IS_TEST_MODE
|
||||||
@@ -50,10 +53,13 @@ apiClient.interceptors.response.use(
|
|||||||
// 이를 통해 LoginPage에서의 무한 리다이렉션 루프를 방지합니다.
|
// 이를 통해 LoginPage에서의 무한 리다이렉션 루프를 방지합니다.
|
||||||
await userManager.removeUser();
|
await userManager.removeUser();
|
||||||
|
|
||||||
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
|
if (
|
||||||
const isLoginPath = window.location.pathname === "/login";
|
shouldStartLoginRedirect({
|
||||||
|
pathname: window.location.pathname,
|
||||||
if (!isAuthPath && !isLoginPath) {
|
isRedirecting: isRedirectingToLogin,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
isRedirectingToLogin = true;
|
||||||
console.info(
|
console.info(
|
||||||
"[apiClient] Redirecting to /login from",
|
"[apiClient] Redirecting to /login from",
|
||||||
window.location.pathname,
|
window.location.pathname,
|
||||||
|
|||||||
71
adminfront/src/lib/cursorFetch.test.ts
Normal file
71
adminfront/src/lib/cursorFetch.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
37
adminfront/src/lib/loginRedirectGuard.test.ts
Normal file
37
adminfront/src/lib/loginRedirectGuard.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,15 @@ test.describe("Bulk Actions and Tree Search", () => {
|
|||||||
headers,
|
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")) {
|
if (url.includes("/admin/users")) {
|
||||||
return route.fulfill({
|
return route.fulfill({
|
||||||
json: {
|
json: {
|
||||||
@@ -149,6 +158,41 @@ test.describe("Bulk Actions and Tree Search", () => {
|
|||||||
await expect(selectionBar).not.toBeVisible({ timeout: 10000 });
|
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 ({
|
test("should filter and highlight nodes in organization tree", async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
|
|||||||
@@ -105,6 +105,197 @@ test.describe("Tenants Management", () => {
|
|||||||
expect(headerWhiteSpace.every((value) => value === "nowrap")).toBe(true);
|
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 }) => {
|
test("should create a new tenant", async ({ page }) => {
|
||||||
await page.goto("/tenants/new");
|
await page.goto("/tenants/new");
|
||||||
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
|
await expect(page.locator("h2").last()).toContainText(/추가|Create/i, {
|
||||||
|
|||||||
@@ -362,8 +362,8 @@ test.describe("User Management", () => {
|
|||||||
|
|
||||||
// Ensure the page title is loaded
|
// Ensure the page title is loaded
|
||||||
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
|
await expect(page.getByText(/사용자 추가/i).first()).toBeVisible();
|
||||||
const userTypeTabs = page.getByRole("tab");
|
const categoryTabs = page.getByRole("tab");
|
||||||
await expect(userTypeTabs).toHaveText([
|
await expect(categoryTabs).toHaveText([
|
||||||
"한맥가족 구성원",
|
"한맥가족 구성원",
|
||||||
"외부 기업 회원",
|
"외부 기업 회원",
|
||||||
"개인 회원",
|
"개인 회원",
|
||||||
@@ -579,7 +579,6 @@ test.describe("User Management", () => {
|
|||||||
.poll(() => createPayload)
|
.poll(() => createPayload)
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
metadata: {
|
metadata: {
|
||||||
hanmacFamily: true,
|
|
||||||
additionalAppointments: [
|
additionalAppointments: [
|
||||||
{
|
{
|
||||||
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
||||||
@@ -623,7 +622,7 @@ test.describe("User Management", () => {
|
|||||||
expect(tenantOptionValues).not.toContain("tech-planning");
|
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,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
let tenantPayload: Record<string, unknown> | undefined;
|
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.locator('input[name="email"]').fill("personal@test.com");
|
||||||
await page.getByRole("button", { name: /생성/i }).click();
|
await page.getByRole("button", { name: /생성/i }).click();
|
||||||
|
|
||||||
await expect
|
expect(tenantPayload).toBeUndefined();
|
||||||
.poll(() => tenantPayload)
|
|
||||||
.toMatchObject({ name: "Personal", slug: "personal", type: "PERSONAL" });
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() => createPayload)
|
.poll(() => createPayload)
|
||||||
.toMatchObject({
|
.toMatchObject({
|
||||||
tenantSlug: "personal",
|
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",
|
createdAt: "2026-04-01T00:00:00Z",
|
||||||
updatedAt: "2026-04-01T00:00:00Z",
|
updatedAt: "2026-04-01T00:00:00Z",
|
||||||
metadata: {
|
metadata: {
|
||||||
hanmacFamily: true,
|
|
||||||
additionalAppointments: [
|
additionalAppointments: [
|
||||||
{
|
{
|
||||||
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
||||||
@@ -763,7 +761,6 @@ test.describe("User Management", () => {
|
|||||||
createdAt: "2026-04-01T00:00:00Z",
|
createdAt: "2026-04-01T00:00:00Z",
|
||||||
updatedAt: "2026-04-01T00:00:00Z",
|
updatedAt: "2026-04-01T00:00:00Z",
|
||||||
metadata: {
|
metadata: {
|
||||||
hanmacFamily: true,
|
|
||||||
additionalAppointments: [
|
additionalAppointments: [
|
||||||
{
|
{
|
||||||
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
tenantId: "03dbe16b-e47b-4f72-927b-782807d67a35",
|
||||||
|
|||||||
@@ -807,6 +807,8 @@ func main() {
|
|||||||
// API Key Management (M2M) - Super Admin Only
|
// API Key Management (M2M) - Super Admin Only
|
||||||
admin.Get("/api-keys", requireSuperAdmin, apiKeyHandler.ListApiKeys)
|
admin.Get("/api-keys", requireSuperAdmin, apiKeyHandler.ListApiKeys)
|
||||||
admin.Post("/api-keys", requireSuperAdmin, apiKeyHandler.CreateApiKey)
|
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)
|
admin.Delete("/api-keys/:id", requireSuperAdmin, apiKeyHandler.DeleteApiKey)
|
||||||
|
|
||||||
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
// 개발자 포털 라우트 (RP/Consent 관리 및 IdP 설정)
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ func TestOpenAPIDocumentsExternalAPIs(t *testing.T) {
|
|||||||
"/api/v1/integrations/org-context:",
|
"/api/v1/integrations/org-context:",
|
||||||
"/api/v1/admin/api-keys:",
|
"/api/v1/admin/api-keys:",
|
||||||
"/api/v1/admin/api-keys/{id}:",
|
"/api/v1/admin/api-keys/{id}:",
|
||||||
|
"/api/v1/admin/api-keys/{id}/secret/rotate:",
|
||||||
|
"ApiKeyUpdateScopesRequest:",
|
||||||
"BaronApiKeyId:",
|
"BaronApiKeyId:",
|
||||||
"BaronApiKeySecret:",
|
"BaronApiKeySecret:",
|
||||||
"X-Baron-Key-ID",
|
"X-Baron-Key-ID",
|
||||||
|
|||||||
@@ -198,7 +198,14 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
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:
|
responses:
|
||||||
"200":
|
"200":
|
||||||
description: OK
|
description: OK
|
||||||
@@ -902,6 +909,41 @@ paths:
|
|||||||
$ref: "#/components/schemas/ErrorResponse"
|
$ref: "#/components/schemas/ErrorResponse"
|
||||||
|
|
||||||
/api/v1/admin/api-keys/{id}:
|
/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:
|
delete:
|
||||||
tags: [Admin]
|
tags: [Admin]
|
||||||
summary: API Key 삭제
|
summary: API Key 삭제
|
||||||
@@ -922,6 +964,31 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: "#/components/schemas/ErrorResponse"
|
$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:
|
/api/v1/dev/clients:
|
||||||
get:
|
get:
|
||||||
tags: [Dev]
|
tags: [Dev]
|
||||||
@@ -1270,43 +1337,36 @@ components:
|
|||||||
updatedAt:
|
updatedAt:
|
||||||
type: string
|
type: string
|
||||||
format: date-time
|
format: date-time
|
||||||
|
members:
|
||||||
|
type: array
|
||||||
|
description: 해당 tenant에 직접 소속된 사용자 목록입니다.
|
||||||
|
items:
|
||||||
|
$ref: "#/components/schemas/OrgContextMember"
|
||||||
|
|
||||||
OrgContextTreeNode:
|
OrgContextTreeNode:
|
||||||
allOf:
|
allOf:
|
||||||
- $ref: "#/components/schemas/OrgContextTenant"
|
- $ref: "#/components/schemas/OrgContextTenant"
|
||||||
- type: object
|
- type: object
|
||||||
properties:
|
properties:
|
||||||
directUserIds:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
children:
|
children:
|
||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/OrgContextTreeNode"
|
$ref: "#/components/schemas/OrgContextTreeNode"
|
||||||
|
|
||||||
OrgContextUser:
|
OrgContextMember:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
id:
|
id:
|
||||||
type: string
|
type: string
|
||||||
|
description: includeUserIds=true일 때만 포함합니다.
|
||||||
email:
|
email:
|
||||||
type: string
|
type: string
|
||||||
format: email
|
format: email
|
||||||
name:
|
name:
|
||||||
type: string
|
type: string
|
||||||
role:
|
phone:
|
||||||
type: string
|
type: string
|
||||||
status:
|
description: includeUserIds=true일 때만 포함합니다.
|
||||||
type: string
|
|
||||||
tenantIds:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
tenantSlugs:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
department:
|
department:
|
||||||
type: string
|
type: string
|
||||||
grade:
|
grade:
|
||||||
@@ -1315,15 +1375,15 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
jobTitle:
|
jobTitle:
|
||||||
type: string
|
type: string
|
||||||
metadata:
|
isOwner:
|
||||||
type: object
|
type: boolean
|
||||||
additionalProperties: true
|
description: appointment의 isOwner 또는 isManager 기준입니다.
|
||||||
createdAt:
|
isLeader:
|
||||||
type: string
|
type: boolean
|
||||||
format: date-time
|
description: appointment의 lead 또는 isLead 기준이며, owner 계열 값이 true이면 true입니다.
|
||||||
updatedAt:
|
isPrimary:
|
||||||
type: string
|
type: boolean
|
||||||
format: date-time
|
description: appointment의 representative, isPrimary 또는 primary 기준입니다.
|
||||||
|
|
||||||
OrgContextResponse:
|
OrgContextResponse:
|
||||||
type: object
|
type: object
|
||||||
@@ -1342,10 +1402,6 @@ components:
|
|||||||
type: array
|
type: array
|
||||||
items:
|
items:
|
||||||
$ref: "#/components/schemas/OrgContextTenant"
|
$ref: "#/components/schemas/OrgContextTenant"
|
||||||
users:
|
|
||||||
type: array
|
|
||||||
items:
|
|
||||||
$ref: "#/components/schemas/OrgContextUser"
|
|
||||||
|
|
||||||
PasswordLoginRequest:
|
PasswordLoginRequest:
|
||||||
type: object
|
type: object
|
||||||
@@ -1790,6 +1846,17 @@ components:
|
|||||||
type: string
|
type: string
|
||||||
example: [org-context:read]
|
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:
|
ApiKeyCreateResponse:
|
||||||
type: object
|
type: object
|
||||||
properties:
|
properties:
|
||||||
|
|||||||
@@ -22,6 +22,11 @@ func Run(db *gorm.DB) error {
|
|||||||
return fmt.Errorf("tenant seeding failed: %w", err)
|
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] User seed skipped (Kratos is SoT)")
|
||||||
slog.Info("[Bootstrap] Bootstrap completed successfully.")
|
slog.Info("[Bootstrap] Bootstrap completed successfully.")
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
34
backend/internal/bootstrap/user_metadata_sanitize.go
Normal file
34
backend/internal/bootstrap/user_metadata_sanitize.go
Normal 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
|
||||||
|
}
|
||||||
157
backend/internal/bootstrap/user_metadata_sanitize_test.go
Normal file
157
backend/internal/bootstrap/user_metadata_sanitize_test.go
Normal 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
|
||||||
|
}
|
||||||
@@ -2,6 +2,8 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/pagination"
|
||||||
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -29,8 +31,55 @@ type apiKeySummary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type apiKeyListResponse struct {
|
type apiKeyListResponse struct {
|
||||||
Items []apiKeySummary `json:"items"`
|
Items []apiKeySummary `json:"items"`
|
||||||
Total int64 `json:"total"`
|
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 {
|
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)
|
limit := c.QueryInt("limit", 50)
|
||||||
offset := c.QueryInt("offset", 0)
|
offset := c.QueryInt("offset", 0)
|
||||||
|
cursorRaw := strings.TrimSpace(c.Query("cursor"))
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
if err := h.DB.Model(&domain.ApiKey{}).Count(&total).Error; err != nil {
|
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
|
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())
|
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))
|
items := make([]apiKeySummary, 0, len(keys))
|
||||||
for _, k := range keys {
|
for _, k := range keys {
|
||||||
lastUsed := ""
|
items = append(items, apiKeyToSummary(k))
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
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) == "" {
|
if strings.TrimSpace(req.Name) == "" {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "name is required")
|
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)
|
// Generate Client ID (16 chars hex)
|
||||||
clientID := GenerateSecureToken(8)
|
clientID := GenerateSecureToken(8)
|
||||||
@@ -112,21 +192,84 @@ func (h *ApiKeyHandler) CreateApiKey(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return summary + PLAIN SECRET (only this time)
|
// Return summary + PLAIN SECRET (only this time)
|
||||||
lastUsed := ""
|
|
||||||
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
return c.Status(fiber.StatusCreated).JSON(fiber.Map{
|
||||||
"apiKey": apiKeySummary{
|
"apiKey": apiKeyToSummary(apiKey),
|
||||||
ID: apiKey.ID,
|
|
||||||
Name: apiKey.Name,
|
|
||||||
ClientID: apiKey.ClientID,
|
|
||||||
Scopes: req.Scopes,
|
|
||||||
Status: apiKey.Status,
|
|
||||||
LastUsedAt: &lastUsed,
|
|
||||||
CreatedAt: apiKey.CreatedAt,
|
|
||||||
},
|
|
||||||
"clientSecret": plainSecret, // VERY IMPORTANT: user must save this now
|
"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 {
|
func (h *ApiKeyHandler) DeleteApiKey(c *fiber.Ctx) error {
|
||||||
if h.DB == nil {
|
if h.DB == nil {
|
||||||
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
return errorJSON(c, fiber.StatusServiceUnavailable, "database not available")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package handler
|
package handler
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"baron-sso-backend/internal/domain"
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -57,3 +58,76 @@ func TestApiKeyHandler_Validation(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, http.StatusBadRequest, resp.StatusCode)
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -107,7 +107,7 @@ func (m *AsyncMockUserRepo) ListByTenant(ctx context.Context, tenantID string) (
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *AsyncMockUserRepo) List(ctx context.Context, offset, limit int, search string, 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
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/pagination"
|
||||||
"baron-sso-backend/internal/repository"
|
"baron-sso-backend/internal/repository"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"baron-sso-backend/internal/utils"
|
"baron-sso-backend/internal/utils"
|
||||||
@@ -121,9 +122,11 @@ type clientSummary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type clientListResponse struct {
|
type clientListResponse struct {
|
||||||
Items []clientSummary `json:"items"`
|
Items []clientSummary `json:"items"`
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
Offset int `json:"offset"`
|
Offset int `json:"offset"`
|
||||||
|
Cursor string `json:"cursor,omitempty"`
|
||||||
|
NextCursor string `json:"nextCursor,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type clientDetailResponse struct {
|
type clientDetailResponse struct {
|
||||||
@@ -186,7 +189,12 @@ type consentSummary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type consentListResponse 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 {
|
type clientUpsertRequest struct {
|
||||||
@@ -1097,6 +1105,30 @@ func (h *DevHandler) listVisibleClientSummaries(
|
|||||||
return items, nil
|
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) {
|
func extractAuthClaimsFromBearer(authHeader string) (string, string) {
|
||||||
authHeader = strings.TrimSpace(authHeader)
|
authHeader = strings.TrimSpace(authHeader)
|
||||||
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
if !strings.HasPrefix(strings.ToLower(authHeader), "bearer ") {
|
||||||
@@ -1213,6 +1245,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
h.injectTenantContextFromHeader(c)
|
h.injectTenantContextFromHeader(c)
|
||||||
limit := c.QueryInt("limit", 50)
|
limit := c.QueryInt("limit", 50)
|
||||||
offset := c.QueryInt("offset", 0)
|
offset := c.QueryInt("offset", 0)
|
||||||
|
cursorRaw := strings.TrimSpace(c.Query("cursor"))
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
@@ -1221,7 +1254,7 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
profile := h.getCurrentProfile(c)
|
profile := h.getCurrentProfile(c)
|
||||||
items, err := h.listVisibleClientSummaries(c, profile, limit, offset)
|
allItems, err := h.listAllVisibleClientSummaries(c, profile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
status := fiber.StatusInternalServerError
|
status := fiber.StatusInternalServerError
|
||||||
errMsg := err.Error()
|
errMsg := err.Error()
|
||||||
@@ -1239,10 +1272,37 @@ func (h *DevHandler) ListClients(c *fiber.Ctx) error {
|
|||||||
return errorJSON(c, status, errMsg)
|
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{
|
return c.JSON(clientListResponse{
|
||||||
Items: items,
|
Items: items,
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
Offset: offset,
|
Offset: offset,
|
||||||
|
Cursor: cursorRaw,
|
||||||
|
NextCursor: nextCursor,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2126,9 +2186,13 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
|||||||
subject := strings.TrimSpace(c.Query("subject"))
|
subject := strings.TrimSpace(c.Query("subject"))
|
||||||
limit := c.QueryInt("limit", 50)
|
limit := c.QueryInt("limit", 50)
|
||||||
offset := c.QueryInt("offset", 0)
|
offset := c.QueryInt("offset", 0)
|
||||||
|
cursorRaw := strings.TrimSpace(c.Query("cursor"))
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
|
if offset < 0 {
|
||||||
|
offset = 0
|
||||||
|
}
|
||||||
|
|
||||||
// [Isolation] Get admin tenant ID from locals or header
|
// [Isolation] Get admin tenant ID from locals or header
|
||||||
adminTenantID := ""
|
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 != "" {
|
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 {
|
} 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 {
|
if err != nil {
|
||||||
@@ -2216,12 +2286,47 @@ func (h *DevHandler) ListConsents(c *fiber.Ctx) error {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return c.JSON(fiber.Map{
|
pagination.SortByKeyDesc(items, consentSummaryCursorKey)
|
||||||
"items": items,
|
nextCursor := ""
|
||||||
"total": total,
|
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 {
|
func (h *DevHandler) RevokeConsents(c *fiber.Ctx) error {
|
||||||
tenantID := h.injectTenantContextFromHeader(c)
|
tenantID := h.injectTenantContextFromHeader(c)
|
||||||
subject := strings.TrimSpace(c.Query("subject"))
|
subject := strings.TrimSpace(c.Query("subject"))
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handler
|
|||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/bootstrap"
|
"baron-sso-backend/internal/bootstrap"
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/pagination"
|
||||||
"baron-sso-backend/internal/repository"
|
"baron-sso-backend/internal/repository"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"baron-sso-backend/internal/utils"
|
"baron-sso-backend/internal/utils"
|
||||||
@@ -81,10 +82,22 @@ type tenantSummary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type tenantListResponse struct {
|
type tenantListResponse struct {
|
||||||
Items []tenantSummary `json:"items"`
|
Items []tenantSummary `json:"items"`
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
Offset int `json:"offset"`
|
Offset int `json:"offset"`
|
||||||
Total int64 `json:"total"`
|
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 {
|
type tenantImportResult struct {
|
||||||
@@ -115,43 +128,45 @@ type tenantCSVRecord struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type orgContextTenant struct {
|
type orgContextTenant struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
ParentID *string `json:"parentId"`
|
ParentID *string `json:"parentId"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Domains []string `json:"domains,omitempty"`
|
Domains []string `json:"domains,omitempty"`
|
||||||
MemberCount int64 `json:"memberCount"`
|
MemberCount int64 `json:"memberCount"`
|
||||||
Visibility string `json:"visibility"`
|
Visibility string `json:"visibility"`
|
||||||
OrgUnitType string `json:"orgUnitType,omitempty"`
|
OrgUnitType string `json:"orgUnitType,omitempty"`
|
||||||
Config domain.JSONMap `json:"config,omitempty"`
|
Config domain.JSONMap `json:"config,omitempty"`
|
||||||
CreatedAt string `json:"createdAt"`
|
CreatedAt string `json:"createdAt"`
|
||||||
UpdatedAt string `json:"updatedAt"`
|
UpdatedAt string `json:"updatedAt"`
|
||||||
|
Members []orgContextMember `json:"members"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type orgContextUser struct {
|
type orgContextMember struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id,omitempty"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Role string `json:"role"`
|
Phone string `json:"phone,omitempty"`
|
||||||
Status string `json:"status"`
|
Department string `json:"department,omitempty"`
|
||||||
TenantIDs []string `json:"tenantIds"`
|
Grade string `json:"grade,omitempty"`
|
||||||
TenantSlugs []string `json:"tenantSlugs"`
|
Position string `json:"position,omitempty"`
|
||||||
Department string `json:"department,omitempty"`
|
JobTitle string `json:"jobTitle,omitempty"`
|
||||||
Grade string `json:"grade,omitempty"`
|
IsOwner bool `json:"isOwner"`
|
||||||
Position string `json:"position,omitempty"`
|
IsLeader bool `json:"isLeader"`
|
||||||
JobTitle string `json:"jobTitle,omitempty"`
|
IsPrimary bool `json:"isPrimary"`
|
||||||
Metadata domain.JSONMap `json:"metadata,omitempty"`
|
}
|
||||||
CreatedAt string `json:"createdAt"`
|
|
||||||
UpdatedAt string `json:"updatedAt"`
|
type orgContextMemberAssignment struct {
|
||||||
|
TenantID string
|
||||||
|
Member orgContextMember
|
||||||
}
|
}
|
||||||
|
|
||||||
type orgContextTreeNode struct {
|
type orgContextTreeNode struct {
|
||||||
orgContextTenant
|
orgContextTenant
|
||||||
DirectUserIDs []string `json:"directUserIds"`
|
Children []orgContextTreeNode `json:"children"`
|
||||||
Children []orgContextTreeNode `json:"children"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type orgContextScope struct {
|
type orgContextScope struct {
|
||||||
@@ -165,7 +180,6 @@ type orgContextResponse struct {
|
|||||||
Scope orgContextScope `json:"scope"`
|
Scope orgContextScope `json:"scope"`
|
||||||
Tree *orgContextTreeNode `json:"tree"`
|
Tree *orgContextTreeNode `json:"tree"`
|
||||||
Tenants []orgContextTenant `json:"tenants"`
|
Tenants []orgContextTenant `json:"tenants"`
|
||||||
Users []orgContextUser `json:"users"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *TenantHandler) RegisterTenantPublic(c *fiber.Ctx) error {
|
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)
|
limit := c.QueryInt("limit", 50)
|
||||||
offset := c.QueryInt("offset", 0)
|
offset := c.QueryInt("offset", 0)
|
||||||
parentId := c.Query("parentId")
|
parentId := c.Query("parentId")
|
||||||
|
cursorRaw := strings.TrimSpace(c.Query("cursor"))
|
||||||
|
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
@@ -224,6 +239,7 @@ func (h *TenantHandler) ListTenants(c *fiber.Ctx) error {
|
|||||||
var tenants []domain.Tenant
|
var tenants []domain.Tenant
|
||||||
var total int64
|
var total int64
|
||||||
var err error
|
var err error
|
||||||
|
nextCursor := ""
|
||||||
|
|
||||||
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
profile, _ := c.Locals("user_profile").(*domain.UserProfileResponse)
|
||||||
role := ""
|
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))
|
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
|
end := offset + limit
|
||||||
if end > len(tenants) {
|
if end > len(tenants) {
|
||||||
end = len(tenants)
|
end = len(tenants)
|
||||||
}
|
}
|
||||||
tenants = tenants[offset:end]
|
tenants = tenants[offset:end]
|
||||||
|
if total > int64(end) && len(tenants) > 0 {
|
||||||
|
last := tenants[len(tenants)-1]
|
||||||
|
nextCursor = pagination.Encode(last.CreatedAt, last.ID)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
tenants = []domain.Tenant{}
|
tenants = []domain.Tenant{}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Super Admin case
|
// Super Admin case
|
||||||
tenants, total, err = h.Service.ListTenants(c.Context(), limit, offset, parentId)
|
if cursorRaw != "" && h.DB != nil {
|
||||||
if err != nil {
|
tenants, total, nextCursor, err = h.listTenantsByCursor(c.Context(), limit, parentId, cursorRaw)
|
||||||
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{"error": err.Error()})
|
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)
|
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 {
|
func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
||||||
@@ -330,6 +418,11 @@ func (h *TenantHandler) ExportTenantsCSV(c *fiber.Ctx) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
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)
|
tenants := filterTenantCSVDescendants(allTenants, parentID)
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
@@ -923,6 +1016,152 @@ func filterPublicTenants(tenants []domain.Tenant) []domain.Tenant {
|
|||||||
return filtered
|
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) {
|
func normalizeTenantUserSchema(value any) ([]any, error) {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil, 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")
|
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 includeUsers {
|
||||||
if h.UserRepo == nil {
|
if h.UserRepo == nil {
|
||||||
return errorJSON(c, fiber.StatusServiceUnavailable, "user repository is not configured")
|
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 {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
return errorJSON(c, fiber.StatusInternalServerError, err.Error())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
for i := range contextTenants {
|
||||||
directUserIDsByTenantID := make(map[string][]string)
|
members := membersByTenantID[contextTenants[i].ID]
|
||||||
for _, user := range contextUsers {
|
if members == nil {
|
||||||
for _, tenantID := range user.TenantIDs {
|
members = []orgContextMember{}
|
||||||
directUserIDsByTenantID[tenantID] = append(directUserIDsByTenantID[tenantID], user.ID)
|
|
||||||
}
|
}
|
||||||
|
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{
|
return c.JSON(orgContextResponse{
|
||||||
SchemaVersion: "baron.org-context.v1",
|
SchemaVersion: "baron.org-context.v1",
|
||||||
IssuedAt: time.Now().UTC().Format(time.RFC3339),
|
IssuedAt: time.Now().UTC().Format(time.RFC3339),
|
||||||
@@ -1976,11 +2218,10 @@ func (h *TenantHandler) GetOrgContext(c *fiber.Ctx) error {
|
|||||||
},
|
},
|
||||||
Tree: tree,
|
Tree: tree,
|
||||||
Tenants: contextTenants,
|
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)
|
usersByID, err := h.UserRepo.FindByTenantIDs(ctx, tenantIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -1989,21 +2230,29 @@ func (h *TenantHandler) loadOrgContextUsers(ctx context.Context, tenantIDs, tena
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
usersByAppointment, _, err := h.UserRepo.List(ctx, 0, 10000, "", "")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
seen := make(map[string]bool)
|
seen := make(map[string]bool)
|
||||||
contextUsers := make([]orgContextUser, 0, len(usersByID)+len(usersBySlug))
|
membersByTenantID := make(map[string][]orgContextMember)
|
||||||
for _, user := range append(usersByID, usersBySlug...) {
|
users := append(usersByID, usersBySlug...)
|
||||||
|
users = append(users, usersByAppointment...)
|
||||||
|
for _, user := range users {
|
||||||
if seen[user.ID] || user.Status != domain.UserStatusActive {
|
if seen[user.ID] || user.Status != domain.UserStatusActive {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
mapped, ok := mapOrgContextUser(user, tenantByID, tenantBySlug)
|
assignments := mapOrgContextMemberAssignments(user, tenantByID, tenantBySlug, includeUserIDs)
|
||||||
if !ok {
|
if len(assignments) == 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
seen[user.ID] = true
|
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) {
|
func findOrgContextTenantBySlug(tenants []domain.Tenant, slug string) (domain.Tenant, bool) {
|
||||||
@@ -2089,62 +2338,119 @@ func mapOrgContextTenant(tenant domain.Tenant) orgContextTenant {
|
|||||||
Config: tenant.Config,
|
Config: tenant.Config,
|
||||||
CreatedAt: tenant.CreatedAt.Format(time.RFC3339),
|
CreatedAt: tenant.CreatedAt.Format(time.RFC3339),
|
||||||
UpdatedAt: tenant.UpdatedAt.Format(time.RFC3339),
|
UpdatedAt: tenant.UpdatedAt.Format(time.RFC3339),
|
||||||
|
Members: []orgContextMember{},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func mapOrgContextUser(user domain.User, tenantByID, tenantBySlug map[string]orgContextTenant) (orgContextUser, bool) {
|
func mapOrgContextMemberAssignments(user domain.User, tenantByID, tenantBySlug map[string]orgContextTenant, includeUserIDs bool) []orgContextMemberAssignment {
|
||||||
matchedTenants := make([]orgContextTenant, 0, 2)
|
assignments := make([]orgContextMemberAssignment, 0, 2)
|
||||||
seenTenants := map[string]bool{}
|
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] {
|
if !ok || seenTenants[tenant.ID] {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
seenTenants[tenant.ID] = true
|
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 {
|
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 {
|
if user.Tenant != nil {
|
||||||
addTenant(tenantByID[user.Tenant.ID], tenantByID[user.Tenant.ID].ID != "")
|
addTenant(tenantByID[user.Tenant.ID], tenantByID[user.Tenant.ID].ID != "", nil)
|
||||||
addTenant(tenantBySlug[strings.ToLower(user.Tenant.Slug)], tenantBySlug[strings.ToLower(user.Tenant.Slug)].ID != "")
|
tenant := tenantBySlug[strings.ToLower(user.Tenant.Slug)]
|
||||||
|
addTenant(tenant, tenant.ID != "", nil)
|
||||||
}
|
}
|
||||||
if user.CompanyCode != "" {
|
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 {
|
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 assignments
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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)
|
childrenByParentID := make(map[string][]domain.Tenant)
|
||||||
for _, tenant := range tenants {
|
for _, tenant := range tenants {
|
||||||
if tenant.ParentID == nil {
|
if tenant.ParentID == nil {
|
||||||
@@ -2161,7 +2467,6 @@ func buildOrgContextTree(rootID string, tenants []domain.Tenant, tenantByID map[
|
|||||||
}
|
}
|
||||||
node := &orgContextTreeNode{
|
node := &orgContextTreeNode{
|
||||||
orgContextTenant: tenant,
|
orgContextTenant: tenant,
|
||||||
DirectUserIDs: directUserIDsByTenantID[tenantID],
|
|
||||||
Children: []orgContextTreeNode{},
|
Children: []orgContextTreeNode{},
|
||||||
}
|
}
|
||||||
for _, child := range childrenByParentID[tenantID] {
|
for _, child := range childrenByParentID[tenantID] {
|
||||||
|
|||||||
@@ -130,10 +130,10 @@ func (m *MockUserRepoForHandler) ListByTenant(ctx context.Context, tenantID stri
|
|||||||
return nil, nil
|
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 {
|
for _, call := range m.ExpectedCalls {
|
||||||
if call.Method == "List" {
|
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)
|
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) {
|
func TestTenantHandler_GetOrgContextJSONDefaultsToHanmacFamilyForApiKey(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
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},
|
{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{
|
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{
|
usersBySlug := []domain.User{
|
||||||
{ID: "user-sso-member", Email: "member@example.com", Name: "SSO 구성원", Status: domain.UserStatusActive, CompanyCode: "sso", Grade: "선임", CreatedAt: now, UpdatedAt: now},
|
{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)
|
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("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("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)
|
req := httptest.NewRequest(http.MethodGet, "/org-context", nil)
|
||||||
resp, err := app.Test(req)
|
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, "dept-platform", tenantsPayload[2].(map[string]any)["id"])
|
||||||
require.Equal(t, "team-sso", tenantsPayload[3].(map[string]any)["id"])
|
require.Equal(t, "team-sso", tenantsPayload[3].(map[string]any)["id"])
|
||||||
|
|
||||||
usersPayload := got["users"].([]any)
|
require.NotContains(t, got, "users")
|
||||||
require.Len(t, usersPayload, 2)
|
deptPlatform := tenantsPayload[2].(map[string]any)
|
||||||
require.Equal(t, "user-platform-lead", usersPayload[0].(map[string]any)["id"])
|
platformMembers := deptPlatform["members"].([]any)
|
||||||
require.Equal(t, []any{"dept-platform"}, usersPayload[0].(map[string]any)["tenantIds"])
|
require.Len(t, platformMembers, 1)
|
||||||
require.Equal(t, "user-sso-member", usersPayload[1].(map[string]any)["id"])
|
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)
|
tree := got["tree"].(map[string]any)
|
||||||
require.Equal(t, "group-hanmac-family", tree["id"])
|
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), "private-team")
|
||||||
require.NotContains(t, toJSONString(t, got), "root-other")
|
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) {
|
func TestTenantHandler_GetOrgContextJSONScopesByTenantSlug(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
@@ -697,6 +1019,44 @@ func TestTenantHandler_ExportTenantsCSV_FiltersDescendantsByParentIDWithIDs(t *t
|
|||||||
mockSvc.AssertExpectations(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) {
|
func TestTenantHandler_ImportTenantsCSVCreatesTenant(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockSvc := new(MockTenantService)
|
mockSvc := new(MockTenantService)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package handler
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
|
"baron-sso-backend/internal/pagination"
|
||||||
"baron-sso-backend/internal/repository"
|
"baron-sso-backend/internal/repository"
|
||||||
"baron-sso-backend/internal/service"
|
"baron-sso-backend/internal/service"
|
||||||
"baron-sso-backend/internal/utils"
|
"baron-sso-backend/internal/utils"
|
||||||
@@ -80,6 +81,20 @@ func mergeUserAppointmentMetadata(metadata map[string]any, appointments []map[st
|
|||||||
return metadata
|
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 {
|
func primaryTenantIDFromRequest(primaryTenantID string, metadata map[string]any, appointments []map[string]any) string {
|
||||||
if value := strings.TrimSpace(primaryTenantID); value != "" {
|
if value := strings.TrimSpace(primaryTenantID); value != "" {
|
||||||
return value
|
return value
|
||||||
@@ -206,6 +221,25 @@ func gradeFromTraits(traits map[string]interface{}) string {
|
|||||||
return value
|
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 {
|
type userSummary struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
@@ -215,6 +249,7 @@ type userSummary struct {
|
|||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
|
TenantSlug string `json:"tenantSlug,omitempty"`
|
||||||
CompanyCode string `json:"companyCode"`
|
CompanyCode string `json:"companyCode"`
|
||||||
Metadata domain.JSONMap `json:"metadata,omitempty"`
|
Metadata domain.JSONMap `json:"metadata,omitempty"`
|
||||||
Tenant *domain.Tenant `json:"tenant,omitempty"`
|
Tenant *domain.Tenant `json:"tenant,omitempty"`
|
||||||
@@ -229,10 +264,20 @@ type userSummary struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type userListResponse struct {
|
type userListResponse struct {
|
||||||
Items []userSummary `json:"items"`
|
Items []userSummary `json:"items"`
|
||||||
Limit int `json:"limit"`
|
Limit int `json:"limit"`
|
||||||
Offset int `json:"offset"`
|
Offset int `json:"offset"`
|
||||||
Total int64 `json:"total"`
|
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 {
|
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)
|
offset := c.QueryInt("offset", 0)
|
||||||
search := strings.TrimSpace(c.Query("search"))
|
search := strings.TrimSpace(c.Query("search"))
|
||||||
tenantSlug := strings.TrimSpace(c.Query("tenantSlug"))
|
tenantSlug := strings.TrimSpace(c.Query("tenantSlug"))
|
||||||
|
cursorRaw := strings.TrimSpace(c.Query("cursor"))
|
||||||
|
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
@@ -399,17 +445,33 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
filtered = append(filtered, identity)
|
filtered = append(filtered, identity)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pagination.SortByKeyDesc(filtered, kratosIdentityCursorKey)
|
||||||
total := int64(len(filtered))
|
total := int64(len(filtered))
|
||||||
if offset > len(filtered) {
|
nextCursor := ""
|
||||||
offset = len(filtered)
|
var pageIdentities []service.KratosIdentity
|
||||||
}
|
if cursorRaw != "" {
|
||||||
end := offset + limit
|
pageIdentities, nextCursor, err = pagination.PageByCursor(filtered, limit, cursorRaw, kratosIdentityCursorKey)
|
||||||
if end > len(filtered) {
|
if err != nil {
|
||||||
end = len(filtered)
|
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)
|
items := make([]userSummary, 0, len(pageIdentities))
|
||||||
for _, identity := range filtered[offset:end] {
|
for _, identity := range pageIdentities {
|
||||||
summary := h.mapIdentitySummary(c.Context(), identity)
|
summary := h.mapIdentitySummary(c.Context(), identity)
|
||||||
items = append(items, summary)
|
items = append(items, summary)
|
||||||
}
|
}
|
||||||
@@ -427,7 +489,14 @@ func (h *UserHandler) ListUsers(c *fiber.Ctx) error {
|
|||||||
}(filtered)
|
}(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)
|
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"`
|
Name string `json:"name"`
|
||||||
Phone string `json:"phone"`
|
Phone string `json:"phone"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
|
TenantSlug string `json:"tenantSlug"`
|
||||||
CompanyCode string `json:"companyCode"`
|
CompanyCode string `json:"companyCode"`
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
Grade string `json:"grade"`
|
Grade string `json:"grade"`
|
||||||
@@ -504,7 +574,8 @@ func (h *UserHandler) CreateUser(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
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)
|
email := strings.TrimSpace(req.Email)
|
||||||
if email == "" {
|
if email == "" {
|
||||||
@@ -724,6 +795,7 @@ type bulkUserItem struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
TenantID string `json:"tenantId"`
|
TenantID string `json:"tenantId"`
|
||||||
TenantSlug string `json:"tenantSlug"`
|
TenantSlug string `json:"tenantSlug"`
|
||||||
|
CompanyCode string `json:"companyCode"`
|
||||||
EmailDomain string `json:"emailDomain"`
|
EmailDomain string `json:"emailDomain"`
|
||||||
Department string `json:"department"`
|
Department string `json:"department"`
|
||||||
Grade string `json:"grade"`
|
Grade string `json:"grade"`
|
||||||
@@ -881,7 +953,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
email := strings.TrimSpace(item.Email)
|
email := strings.TrimSpace(item.Email)
|
||||||
name := strings.TrimSpace(item.Name)
|
name := strings.TrimSpace(item.Name)
|
||||||
tenantID := strings.TrimSpace(item.TenantID)
|
tenantID := strings.TrimSpace(item.TenantID)
|
||||||
tenantSlug := strings.TrimSpace(item.TenantSlug)
|
tenantSlug := tenantSlugFromRequest(item.TenantSlug, item.CompanyCode)
|
||||||
dept := strings.TrimSpace(item.Department)
|
dept := strings.TrimSpace(item.Department)
|
||||||
|
|
||||||
if email == "" || name == "" {
|
if email == "" || name == "" {
|
||||||
@@ -1054,6 +1126,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
item.Metadata["additionalAppointments"] = resolvedAppointments
|
item.Metadata["additionalAppointments"] = resolvedAppointments
|
||||||
}
|
}
|
||||||
|
item.Metadata = sanitizeUserMetadata(item.Metadata)
|
||||||
|
|
||||||
password, _ := utils.GeneratePasswordWithPolicy(policy)
|
password, _ := utils.GeneratePasswordWithPolicy(policy)
|
||||||
role := item.Role
|
role := item.Role
|
||||||
@@ -1218,10 +1291,7 @@ func (h *UserHandler) BulkCreateUsers(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
||||||
search := strings.TrimSpace(c.Query("search"))
|
search := strings.TrimSpace(c.Query("search"))
|
||||||
companyCode := strings.TrimSpace(c.Query("companyCode"))
|
tenantSlug := tenantSlugFromRequest(c.Query("tenantSlug"), c.Query("companyCode"))
|
||||||
if companyCode == "" {
|
|
||||||
companyCode = strings.TrimSpace(c.Query("tenantSlug"))
|
|
||||||
}
|
|
||||||
|
|
||||||
var requesterRole string
|
var requesterRole string
|
||||||
var manageableSlugs []string
|
var manageableSlugs []string
|
||||||
@@ -1269,8 +1339,7 @@ func (h *UserHandler) ExportUsersCSV(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 1. Fetch Users using Repo for efficiency
|
// 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, tenantSlug)
|
||||||
users, _, err := h.UserRepo.List(c.Context(), 0, 10000, search, companyCode)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return errorJSON(c, fiber.StatusInternalServerError, "failed to fetch users for export")
|
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"`
|
UserIDs []string `json:"userIds"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
Role *string `json:"role"`
|
Role *string `json:"role"`
|
||||||
|
TenantSlug *string `json:"tenantSlug"`
|
||||||
CompanyCode *string `json:"companyCode"`
|
CompanyCode *string `json:"companyCode"`
|
||||||
Department *string `json:"department"`
|
Department *string `json:"department"`
|
||||||
Grade *string `json:"grade"`
|
Grade *string `json:"grade"`
|
||||||
@@ -1395,6 +1465,7 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
||||||
}
|
}
|
||||||
|
req.CompanyCode = tenantSlugPointerFromRequest(req.TenantSlug, req.CompanyCode)
|
||||||
|
|
||||||
if len(req.UserIDs) == 0 {
|
if len(req.UserIDs) == 0 {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided")
|
return errorJSON(c, fiber.StatusBadRequest, "no user IDs provided")
|
||||||
@@ -1404,6 +1475,16 @@ func (h *UserHandler) BulkUpdateUsers(c *fiber.Ctx) error {
|
|||||||
if requester == nil {
|
if requester == nil {
|
||||||
return errorJSON(c, fiber.StatusUnauthorized, "unauthorized")
|
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
|
// [New] Pre-fetch tenant cache if companyCode is being changed
|
||||||
type tenantCacheItem struct {
|
type tenantCacheItem struct {
|
||||||
@@ -1683,6 +1764,7 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
Phone *string `json:"phone"`
|
Phone *string `json:"phone"`
|
||||||
Role *string `json:"role"`
|
Role *string `json:"role"`
|
||||||
Status *string `json:"status"`
|
Status *string `json:"status"`
|
||||||
|
TenantSlug *string `json:"tenantSlug"`
|
||||||
CompanyCode *string `json:"companyCode"`
|
CompanyCode *string `json:"companyCode"`
|
||||||
IsAddTenant bool `json:"isAddTenant"`
|
IsAddTenant bool `json:"isAddTenant"`
|
||||||
IsRemoveTenant bool `json:"isRemoveTenant"`
|
IsRemoveTenant bool `json:"isRemoveTenant"`
|
||||||
@@ -1699,7 +1781,18 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return errorJSON(c, fiber.StatusBadRequest, "invalid request body")
|
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)
|
// [New] Tenant Admin restriction: Cannot change companyCode (except when adding/removing secondary membership)
|
||||||
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
if requester != nil && domain.NormalizeRole(requester.Role) == domain.RoleTenantAdmin {
|
||||||
@@ -1754,6 +1847,8 @@ func (h *UserHandler) UpdateUser(c *fiber.Ctx) error {
|
|||||||
if traits == nil {
|
if traits == nil {
|
||||||
traits = map[string]interface{}{}
|
traits = map[string]interface{}{}
|
||||||
}
|
}
|
||||||
|
delete(traits, "hanmacFamily")
|
||||||
|
delete(traits, "userType")
|
||||||
|
|
||||||
// [Preserve & Merge] Multi-Tenant Info
|
// [Preserve & Merge] Multi-Tenant Info
|
||||||
var existingCodes []string
|
var existingCodes []string
|
||||||
@@ -2191,6 +2286,7 @@ func (h *UserHandler) mapIdentitySummary(ctx context.Context, identity service.K
|
|||||||
Phone: extractTraitString(traits, "phone_number"),
|
Phone: extractTraitString(traits, "phone_number"),
|
||||||
Role: role,
|
Role: role,
|
||||||
Status: normalizeStatus(identity.State),
|
Status: normalizeStatus(identity.State),
|
||||||
|
TenantSlug: compCode,
|
||||||
CompanyCode: compCode,
|
CompanyCode: compCode,
|
||||||
Department: extractTraitString(traits, "department"),
|
Department: extractTraitString(traits, "department"),
|
||||||
Grade: gradeFromTraits(traits),
|
Grade: gradeFromTraits(traits),
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
// --- Mocks ---
|
// --- Mocks ---
|
||||||
@@ -118,6 +119,22 @@ func (f *fakeUserHandlerWorksmobileSyncer) EnqueueUserDeleteIfInScope(ctx contex
|
|||||||
return nil
|
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 {
|
type MockTenantServiceForUser struct {
|
||||||
mock.Mock
|
mock.Mock
|
||||||
service.TenantService
|
service.TenantService
|
||||||
@@ -693,6 +710,40 @@ func TestUserHandler_ListUsersReturnsServiceUnavailableWhenKratosFails(t *testin
|
|||||||
mockKratos.AssertExpectations(t)
|
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) {
|
func TestUserHandler_BulkCreateUsers_HanmacEmailPolicy(t *testing.T) {
|
||||||
app := fiber.New()
|
app := fiber.New()
|
||||||
mockKratos := new(MockKratosAdmin)
|
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, "u-1", worksmobile.upserts[0].ID)
|
||||||
assert.Equal(t, domain.UserStatusInactive, worksmobile.upserts[0].Status)
|
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) {
|
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 {
|
mockOry.On("CreateUser", mock.MatchedBy(func(user *domain.BrokerUser) bool {
|
||||||
return user.Attributes["tenant_id"] == tenantID &&
|
return user.Attributes["tenant_id"] == tenantID &&
|
||||||
user.Attributes["companyCode"] == "saman" &&
|
user.Attributes["companyCode"] == "saman" &&
|
||||||
user.Attributes["additionalAppointments"] != nil
|
user.Attributes["additionalAppointments"] != nil &&
|
||||||
|
user.Attributes["userType"] == nil
|
||||||
}), mock.Anything).Return("u-appointment", nil).Once()
|
}), mock.Anything).Return("u-appointment", nil).Once()
|
||||||
mockKratos.On("GetIdentity", mock.Anything, "u-appointment").Return(&service.KratosIdentity{
|
mockKratos.On("GetIdentity", mock.Anything, "u-appointment").Return(&service.KratosIdentity{
|
||||||
ID: "u-appointment",
|
ID: "u-appointment",
|
||||||
@@ -1498,6 +1571,171 @@ func TestUserHandler_CreateUser_AutoCreatesPersonalTenantWhenAssignmentMissing(t
|
|||||||
mockKratos.AssertExpectations(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) {
|
func TestUserHandler_MapToLocalUserKeepsRoleAndGradeSeparate(t *testing.T) {
|
||||||
handler := &UserHandler{}
|
handler := &UserHandler{}
|
||||||
identity := service.KratosIdentity{
|
identity := service.KratosIdentity{
|
||||||
|
|||||||
103
backend/internal/pagination/cursor.go
Normal file
103
backend/internal/pagination/cursor.go
Normal 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,
|
||||||
|
)
|
||||||
|
}
|
||||||
55
backend/internal/pagination/cursor_test.go
Normal file
55
backend/internal/pagination/cursor_test.go
Normal 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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"baron-sso-backend/internal/domain"
|
"baron-sso-backend/internal/domain"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,7 +34,19 @@ func NewTenantRepository(db *gorm.DB) TenantRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (r *tenantRepository) Create(ctx context.Context, tenant *domain.Tenant) error {
|
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 {
|
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
|
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
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestTenantRepository(t *testing.T) {
|
func TestTenantRepository(t *testing.T) {
|
||||||
@@ -161,4 +162,31 @@ func TestTenantRepository(t *testing.T) {
|
|||||||
err = repo.Create(ctx, tenant2)
|
err = repo.Create(ctx, tenant2)
|
||||||
assert.Error(t, err) // Should fail due to UNIQUE constraint
|
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)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
56
backend/internal/repository/user_membership_maintenance.go
Normal file
56
backend/internal/repository/user_membership_maintenance.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ type UserRepository interface {
|
|||||||
FindByID(ctx context.Context, id string) (*domain.User, error)
|
FindByID(ctx context.Context, id string) (*domain.User, error)
|
||||||
FindByIDs(ctx context.Context, ids []string) ([]domain.User, error)
|
FindByIDs(ctx context.Context, ids []string) ([]domain.User, error)
|
||||||
ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error)
|
ListByTenant(ctx context.Context, tenantID string) ([]domain.User, error)
|
||||||
List(ctx context.Context, offset, limit int, search string, 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)
|
CountByTenant(ctx context.Context, tenantID string) (int64, error)
|
||||||
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
|
CountByTenantIDs(ctx context.Context, tenantIDs []string) (map[string]int64, error)
|
||||||
CountByCompanyCodes(ctx context.Context, codes []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
|
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 users []domain.User
|
||||||
var total int64
|
var total int64
|
||||||
db := r.db.WithContext(ctx).Model(&domain.User{})
|
db := r.db.WithContext(ctx).Model(&domain.User{})
|
||||||
|
|
||||||
if companyCode != "" {
|
if tenantSlug != "" {
|
||||||
// [Matrix Fix] Match users either by their primary company code OR by being in the company_codes array OR by tenant slug
|
|
||||||
db = db.Joins("LEFT JOIN tenants ON users.tenant_id = tenants.id").
|
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 != "" {
|
if search != "" {
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ func (m *MockUserRepoForTenant) ListByTenant(ctx context.Context, tenantID strin
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockUserRepoForTenant) List(ctx context.Context, offset, limit int, search string, 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
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -84,7 +84,7 @@ func (m *MockUserRepository) ListByTenant(ctx context.Context, tenantID string)
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *MockUserRepository) List(ctx context.Context, offset, limit int, search string, 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
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -413,7 +413,7 @@ func (f *fakeWorksmobileUserRepo) ListByTenant(ctx context.Context, tenantID str
|
|||||||
return nil, nil
|
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
|
return nil, 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,13 @@ export type CommonOidcConfigOptions<TUserStore = unknown> = {
|
|||||||
userStore: TUserStore;
|
userStore: TUserStore;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type LoginRedirectGuardParams = {
|
||||||
|
pathname: string;
|
||||||
|
isRedirecting: boolean;
|
||||||
|
loginPath?: string;
|
||||||
|
callbackPath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
type CommonOidcRuntimeConfig<TUserStore> = {
|
type CommonOidcRuntimeConfig<TUserStore> = {
|
||||||
authority: string;
|
authority: string;
|
||||||
client_id: string;
|
client_id: string;
|
||||||
@@ -61,3 +68,20 @@ export function buildCommonUserManagerSettings<
|
|||||||
redirect_uri: config.redirect_uri || "",
|
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;
|
||||||
|
}
|
||||||
|
|||||||
82
common/core/pagination/cursorFetch.ts
Normal file
82
common/core/pagination/cursorFetch.ts
Normal 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";
|
||||||
43
common/core/pagination/cursorFetch.worker.ts
Normal file
43
common/core/pagination/cursorFetch.worker.ts
Normal 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 {};
|
||||||
106
common/core/pagination/cursorFetchCore.ts
Normal file
106
common/core/pagination/cursorFetchCore.ts
Normal 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`);
|
||||||
|
}
|
||||||
6
common/core/pagination/index.ts
Normal file
6
common/core/pagination/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export {
|
||||||
|
fetchAllCursorPages,
|
||||||
|
fetchAllCursorPagesMainThread,
|
||||||
|
type CursorFetchRequest,
|
||||||
|
type CursorPageResponse,
|
||||||
|
} from "./cursorFetch";
|
||||||
@@ -324,7 +324,7 @@ services:
|
|||||||
- ../../adminfront:/app
|
- ../../adminfront:/app
|
||||||
- ./adminfront/vite.config.ts:/app/vite.config.ts:ro
|
- ./adminfront/vite.config.ts:/app/vite.config.ts:ro
|
||||||
- ./adminfront/auth.ts:/app/src/lib/auth.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]
|
networks: [app_net]
|
||||||
|
|
||||||
devfront:
|
devfront:
|
||||||
@@ -338,7 +338,7 @@ services:
|
|||||||
- ../../devfront:/app
|
- ../../devfront:/app
|
||||||
- ./devfront/vite.config.ts:/app/vite.config.ts:ro
|
- ./devfront/vite.config.ts:/app/vite.config.ts:ro
|
||||||
- ./devfront/auth.ts:/app/src/lib/auth.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]
|
networks: [app_net]
|
||||||
|
|
||||||
orgfront:
|
orgfront:
|
||||||
@@ -352,7 +352,7 @@ services:
|
|||||||
- ../../orgfront:/app
|
- ../../orgfront:/app
|
||||||
- ./orgfront/vite.config.ts:/app/vite.config.ts:ro
|
- ./orgfront/vite.config.ts:/app/vite.config.ts:ro
|
||||||
- ./orgfront/auth.ts:/app/src/lib/auth.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: [app_net]
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { useEffect, useRef, useState } from "react";
|
|||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
type ShellTranslator,
|
||||||
applyShellTheme,
|
applyShellTheme,
|
||||||
buildShellProfileSummary,
|
buildShellProfileSummary,
|
||||||
buildShellSessionStatus,
|
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() {
|
function AppLayout() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -73,8 +116,6 @@ function AppLayout() {
|
|||||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
|
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
|
||||||
readShellSessionExpiryEnabled,
|
readShellSessionExpiryEnabled,
|
||||||
);
|
);
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
||||||
|
|
||||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
const { data: profile } = useQuery({
|
const { data: profile } = useQuery({
|
||||||
queryKey: ["userMe"],
|
queryKey: ["userMe"],
|
||||||
@@ -97,15 +138,6 @@ function AppLayout() {
|
|||||||
applyShellTheme(theme);
|
applyShellTheme(theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
setNowMs(Date.now());
|
|
||||||
}, 1000);
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
@@ -284,12 +316,6 @@ function AppLayout() {
|
|||||||
auth.user?.profile as Record<string, unknown> | undefined,
|
auth.user?.profile as Record<string, unknown> | undefined,
|
||||||
);
|
);
|
||||||
const displayRoleKey = profile?.role || currentRole;
|
const displayRoleKey = profile?.role || currentRole;
|
||||||
const sessionStatus = buildShellSessionStatus({
|
|
||||||
expiresAtSec: auth.user?.expires_at,
|
|
||||||
nowMs,
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSessionExpiryToggle = () => {
|
const handleSessionExpiryToggle = () => {
|
||||||
setIsSessionExpiryEnabled((prev) => {
|
setIsSessionExpiryEnabled((prev) => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
@@ -398,14 +424,10 @@ function AppLayout() {
|
|||||||
: t("ui.common.theme_dark", "Dark")}
|
: t("ui.common.theme_dark", "Dark")}
|
||||||
</button>
|
</button>
|
||||||
{isSessionExpiryEnabled ? (
|
{isSessionExpiryEnabled ? (
|
||||||
<span
|
<SessionStatusBadge
|
||||||
className={[
|
expiresAtSec={auth.user?.expires_at}
|
||||||
shellLayoutClasses.sessionBadge,
|
t={t}
|
||||||
sessionStatus.toneClass,
|
/>
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{sessionStatus.text}
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative" ref={profileMenuRef}>
|
<div className="relative" ref={profileMenuRef}>
|
||||||
<button
|
<button
|
||||||
@@ -466,12 +488,17 @@ function AppLayout() {
|
|||||||
{t("ui.dev.session.auto_extend", "Session expiry")}
|
{t("ui.dev.session.auto_extend", "Session expiry")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{isSessionExpiryEnabled
|
{isSessionExpiryEnabled ? (
|
||||||
? sessionStatus.text
|
<SessionStatusText
|
||||||
: t(
|
expiresAtSec={auth.user?.expires_at}
|
||||||
"ui.dev.session.disabled",
|
t={t}
|
||||||
"Session expiry disabled",
|
/>
|
||||||
)}
|
) : (
|
||||||
|
t(
|
||||||
|
"ui.dev.session.disabled",
|
||||||
|
"Session expiry disabled",
|
||||||
|
)
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { shouldStartLoginRedirect } from "../../../common/core/auth";
|
||||||
import { userManager } from "./auth";
|
import { userManager } from "./auth";
|
||||||
|
|
||||||
|
let isRedirectingToLogin = false;
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL:
|
baseURL:
|
||||||
import.meta.env.VITE_DEV_API_BASE ??
|
import.meta.env.VITE_DEV_API_BASE ??
|
||||||
@@ -32,8 +35,6 @@ apiClient.interceptors.response.use(
|
|||||||
error.response?.data?.error?.toString().toLowerCase() ??
|
error.response?.data?.error?.toString().toLowerCase() ??
|
||||||
error.response?.data?.message?.toString().toLowerCase() ??
|
error.response?.data?.message?.toString().toLowerCase() ??
|
||||||
"";
|
"";
|
||||||
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
|
|
||||||
const isLoginPath = window.location.pathname === "/login";
|
|
||||||
const shouldRedirectToLogin =
|
const shouldRedirectToLogin =
|
||||||
status === 401 ||
|
status === 401 ||
|
||||||
(status === 403 &&
|
(status === 403 &&
|
||||||
@@ -41,7 +42,14 @@ apiClient.interceptors.response.use(
|
|||||||
message.includes("invalid session") ||
|
message.includes("invalid session") ||
|
||||||
message.includes("token is not active")));
|
message.includes("token is not active")));
|
||||||
|
|
||||||
if (shouldRedirectToLogin && !isAuthPath && !isLoginPath) {
|
if (
|
||||||
|
shouldRedirectToLogin &&
|
||||||
|
shouldStartLoginRedirect({
|
||||||
|
pathname: window.location.pathname,
|
||||||
|
isRedirecting: isRedirectingToLogin,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
isRedirectingToLogin = true;
|
||||||
await userManager.removeUser();
|
await userManager.removeUser();
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -231,7 +231,7 @@ services:
|
|||||||
condition: service_started
|
condition: service_started
|
||||||
user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}"
|
user: "${OATHKEEPER_UID:-1001}:${OATHKEEPER_GID:-1001}"
|
||||||
environment:
|
environment:
|
||||||
- APP_ENV=${APP_ENV:-stage}
|
- APP_ENV=${APP_ENV:-development}
|
||||||
- LOG_LEVEL=debug
|
- LOG_LEVEL=debug
|
||||||
- OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}
|
- OATHKEEPER_INTROSPECT_CLIENT_ID=${OATHKEEPER_INTROSPECT_CLIENT_ID:-oathkeeper-introspect}
|
||||||
- OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}
|
- OATHKEEPER_INTROSPECT_CLIENT_SECRET=${OATHKEEPER_INTROSPECT_CLIENT_SECRET:-oathkeeper-secret}
|
||||||
@@ -429,7 +429,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- APP_ENV=${APP_ENV:-development}
|
- APP_ENV=${APP_ENV:-stage}
|
||||||
- API_PROXY_TARGET=http://baron_backend:3000
|
- API_PROXY_TARGET=http://baron_backend:3000
|
||||||
ports:
|
ports:
|
||||||
- "${ADMINFRONT_PORT:-5173}:5173"
|
- "${ADMINFRONT_PORT:-5173}:5173"
|
||||||
@@ -455,7 +455,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- APP_ENV=${APP_ENV:-development}
|
- APP_ENV=${APP_ENV:-stage}
|
||||||
- API_PROXY_TARGET=http://baron_backend:3000
|
- API_PROXY_TARGET=http://baron_backend:3000
|
||||||
ports:
|
ports:
|
||||||
- "${DEVFRONT_PORT:-5174}:5173"
|
- "${DEVFRONT_PORT:-5174}:5173"
|
||||||
@@ -481,7 +481,7 @@ services:
|
|||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
environment:
|
environment:
|
||||||
- APP_ENV=${APP_ENV:-development}
|
- APP_ENV=${APP_ENV:-stage}
|
||||||
- API_PROXY_TARGET=http://baron_backend:3000
|
- API_PROXY_TARGET=http://baron_backend:3000
|
||||||
- USERFRONT_URL=${USERFRONT_URL}
|
- USERFRONT_URL=${USERFRONT_URL}
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ GET /api/v1/integrations/org-context
|
|||||||
| 이름 | 기본값 | 설명 |
|
| 이름 | 기본값 | 설명 |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `tenantSlug` | `hanmac-family` | 조회할 subtree root tenant slug. 지정하지 않으면 `hanmac-family` 전체 subtree를 반환한다. |
|
| `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 기반 지정은 계약에 포함하지 않는다.
|
상위 조직 지정은 slug만 사용한다. UUID 기반 지정은 계약에 포함하지 않는다.
|
||||||
|
|
||||||
@@ -67,7 +68,7 @@ curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&
|
|||||||
"visibility": "public",
|
"visibility": "public",
|
||||||
"createdAt": "2026-05-13T00:00:00Z",
|
"createdAt": "2026-05-13T00:00:00Z",
|
||||||
"updatedAt": "2026-05-13T00:00:00Z",
|
"updatedAt": "2026-05-13T00:00:00Z",
|
||||||
"directUserIds": [],
|
"members": [],
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
"id": "01970f09-2b7b-7f83-b9d6-4f6c8b33f01a",
|
"id": "01970f09-2b7b-7f83-b9d6-4f6c8b33f01a",
|
||||||
@@ -83,8 +84,17 @@ curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&
|
|||||||
"orgUnitType": "실",
|
"orgUnitType": "실",
|
||||||
"createdAt": "2026-05-13T00:00:00Z",
|
"createdAt": "2026-05-13T00:00:00Z",
|
||||||
"updatedAt": "2026-05-13T00:00:00Z",
|
"updatedAt": "2026-05-13T00:00:00Z",
|
||||||
"directUserIds": [
|
"members": [
|
||||||
"01970f0a-5c28-74d8-a73a-f6e9e9a7b210"
|
{
|
||||||
|
"email": "user@example.com",
|
||||||
|
"name": "홍길동",
|
||||||
|
"grade": "책임",
|
||||||
|
"position": "실장",
|
||||||
|
"jobTitle": "Backend Engineer",
|
||||||
|
"isOwner": true,
|
||||||
|
"isLeader": true,
|
||||||
|
"isPrimary": true
|
||||||
|
}
|
||||||
],
|
],
|
||||||
"children": []
|
"children": []
|
||||||
}
|
}
|
||||||
@@ -103,27 +113,35 @@ curl 'https://sso.example.com/api/v1/integrations/org-context?tenantSlug=hanmac&
|
|||||||
"memberCount": 0,
|
"memberCount": 0,
|
||||||
"visibility": "public",
|
"visibility": "public",
|
||||||
"createdAt": "2026-05-13T00:00:00Z",
|
"createdAt": "2026-05-13T00:00:00Z",
|
||||||
"updatedAt": "2026-05-13T00:00:00Z"
|
"updatedAt": "2026-05-13T00:00:00Z",
|
||||||
}
|
"members": []
|
||||||
],
|
},
|
||||||
"users": [
|
|
||||||
{
|
{
|
||||||
"id": "01970f0a-5c28-74d8-a73a-f6e9e9a7b210",
|
"id": "01970f09-2b7b-7f83-b9d6-4f6c8b33f01a",
|
||||||
"email": "user@example.com",
|
"type": "USER_GROUP",
|
||||||
"name": "홍길동",
|
"name": "플랫폼실",
|
||||||
"role": "user",
|
"slug": "platform",
|
||||||
|
"parentId": "01970f08-91da-7286-bd19-882fb98d1f2c",
|
||||||
"status": "active",
|
"status": "active",
|
||||||
"tenantIds": [
|
"description": "",
|
||||||
"01970f09-2b7b-7f83-b9d6-4f6c8b33f01a"
|
"domains": [],
|
||||||
],
|
"memberCount": 0,
|
||||||
"tenantSlugs": [
|
"visibility": "internal",
|
||||||
"platform"
|
"orgUnitType": "실",
|
||||||
],
|
|
||||||
"grade": "책임",
|
|
||||||
"position": "실장",
|
|
||||||
"jobTitle": "Backend Engineer",
|
|
||||||
"createdAt": "2026-05-13T00:00:00Z",
|
"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=private` tenant와 그 하위 tenant는 제외한다.
|
||||||
- `visibility=internal` tenant는 M2M 연동용 JSON API에는 포함한다.
|
- `visibility=internal` tenant는 M2M 연동용 JSON API에는 포함한다.
|
||||||
- 외부 앱은 `schemaVersion`을 확인하고, 알 수 없는 version이면 별도 fallback을 적용한다.
|
- 외부 앱은 `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 값으로 우선 사용한다.
|
||||||
|
|||||||
82
docs/kratos-user-traits-field-inventory.md
Normal file
82
docs/kratos-user-traits-field-inventory.md
Normal 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를 닫습니다.
|
||||||
142
docs/tenant-maintenance-procedures.md
Normal file
142
docs/tenant-maintenance-procedures.md
Normal 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 기준으로 체감되도록 맞춘다.
|
||||||
59
docs/tenant-visibility-exposure-policy.md
Normal file
59
docs/tenant-visibility-exposure-policy.md
Normal 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`을 제한 공개 권한 모델로 확장하려면 별도 정책 변경이 필요하다.
|
||||||
@@ -12,14 +12,8 @@ import {
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useAuth } from "react-oidc-context";
|
import { useAuth } from "react-oidc-context";
|
||||||
import { NavLink, Outlet, useLocation, useNavigate } from "react-router-dom";
|
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 {
|
import {
|
||||||
|
type ShellTranslator,
|
||||||
applyShellTheme,
|
applyShellTheme,
|
||||||
buildShellProfileSummary,
|
buildShellProfileSummary,
|
||||||
buildShellSessionStatus,
|
buildShellSessionStatus,
|
||||||
@@ -28,6 +22,13 @@ import {
|
|||||||
shellLayoutClasses,
|
shellLayoutClasses,
|
||||||
writeShellSessionExpiryEnabled,
|
writeShellSessionExpiryEnabled,
|
||||||
} from "../../../../common/shell";
|
} 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 LanguageSelector from "../common/LanguageSelector";
|
||||||
import { Toaster } from "../ui/toaster";
|
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() {
|
function AppLayout() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@@ -59,8 +102,6 @@ function AppLayout() {
|
|||||||
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
|
const [isSessionExpiryEnabled, setIsSessionExpiryEnabled] = useState(
|
||||||
readShellSessionExpiryEnabled,
|
readShellSessionExpiryEnabled,
|
||||||
);
|
);
|
||||||
const [nowMs, setNowMs] = useState(() => Date.now());
|
|
||||||
|
|
||||||
const hasAccessToken = Boolean(auth.user?.access_token);
|
const hasAccessToken = Boolean(auth.user?.access_token);
|
||||||
const { data: profile } = useQuery({
|
const { data: profile } = useQuery({
|
||||||
queryKey: ["userMe"],
|
queryKey: ["userMe"],
|
||||||
@@ -79,15 +120,6 @@ function AppLayout() {
|
|||||||
applyShellTheme(theme);
|
applyShellTheme(theme);
|
||||||
}, [theme]);
|
}, [theme]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const timer = window.setInterval(() => {
|
|
||||||
setNowMs(Date.now());
|
|
||||||
}, 1000);
|
|
||||||
return () => {
|
|
||||||
window.clearInterval(timer);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleClickOutside = (event: MouseEvent) => {
|
const handleClickOutside = (event: MouseEvent) => {
|
||||||
if (
|
if (
|
||||||
@@ -272,12 +304,6 @@ function AppLayout() {
|
|||||||
"tenant_admin",
|
"tenant_admin",
|
||||||
"rp_admin",
|
"rp_admin",
|
||||||
].includes(currentRole);
|
].includes(currentRole);
|
||||||
const sessionStatus = buildShellSessionStatus({
|
|
||||||
expiresAtSec: auth.user?.expires_at,
|
|
||||||
nowMs,
|
|
||||||
t,
|
|
||||||
});
|
|
||||||
|
|
||||||
const handleSessionExpiryToggle = () => {
|
const handleSessionExpiryToggle = () => {
|
||||||
setIsSessionExpiryEnabled((prev) => {
|
setIsSessionExpiryEnabled((prev) => {
|
||||||
const next = !prev;
|
const next = !prev;
|
||||||
@@ -386,14 +412,10 @@ function AppLayout() {
|
|||||||
: t("ui.common.theme_dark", "Dark")}
|
: t("ui.common.theme_dark", "Dark")}
|
||||||
</button>
|
</button>
|
||||||
{isSessionExpiryEnabled ? (
|
{isSessionExpiryEnabled ? (
|
||||||
<span
|
<SessionStatusBadge
|
||||||
className={[
|
expiresAtSec={auth.user?.expires_at}
|
||||||
shellLayoutClasses.sessionBadge,
|
t={t}
|
||||||
sessionStatus.toneClass,
|
/>
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
{sessionStatus.text}
|
|
||||||
</span>
|
|
||||||
) : null}
|
) : null}
|
||||||
<div className="relative" ref={profileMenuRef}>
|
<div className="relative" ref={profileMenuRef}>
|
||||||
<button
|
<button
|
||||||
@@ -451,12 +473,14 @@ function AppLayout() {
|
|||||||
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
|
{t("ui.dev.session.auto_extend", "세션 만료 관리")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{isSessionExpiryEnabled
|
{isSessionExpiryEnabled ? (
|
||||||
? sessionStatus.text
|
<SessionStatusText
|
||||||
: t(
|
expiresAtSec={auth.user?.expires_at}
|
||||||
"ui.dev.session.disabled",
|
t={t}
|
||||||
"세션 만료 비활성화",
|
/>
|
||||||
)}
|
) : (
|
||||||
|
t("ui.dev.session.disabled", "세션 만료 비활성화")
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { useLocation, useParams } from "react-router-dom";
|
|||||||
import {
|
import {
|
||||||
type TenantSummary,
|
type TenantSummary,
|
||||||
type UserSummary,
|
type UserSummary,
|
||||||
|
fetchAllTenants,
|
||||||
fetchPublicOrgChart,
|
fetchPublicOrgChart,
|
||||||
fetchTenants,
|
|
||||||
fetchUsers,
|
fetchUsers,
|
||||||
} from "../../../lib/adminApi";
|
} from "../../../lib/adminApi";
|
||||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||||
@@ -1217,7 +1217,7 @@ export function TenantOrgChartPage() {
|
|||||||
|
|
||||||
const tenantsQuery = useQuery({
|
const tenantsQuery = useQuery({
|
||||||
queryKey: ["tenants-full-tree-v2"],
|
queryKey: ["tenants-full-tree-v2"],
|
||||||
queryFn: () => fetchTenants(10000, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
enabled: !shareToken,
|
enabled: !shareToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { Check, ChevronDown, ChevronRight, Search, X } from "lucide-react";
|
|||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
import { useLocation } from "react-router-dom";
|
import { useLocation } from "react-router-dom";
|
||||||
import { Button } from "../../../components/ui/button";
|
import { Button } from "../../../components/ui/button";
|
||||||
import { fetchTenants, fetchUsers } from "../../../lib/adminApi";
|
import { fetchAllTenants, fetchUsers } from "../../../lib/adminApi";
|
||||||
import { buildOrgPickerTree, flattenDescendants } from "../pickerTree";
|
import { buildOrgPickerTree, flattenDescendants } from "../pickerTree";
|
||||||
import {
|
import {
|
||||||
type OrgPickerEmbedOptions,
|
type OrgPickerEmbedOptions,
|
||||||
@@ -350,7 +350,7 @@ export function OrgPickerEmbedPage() {
|
|||||||
|
|
||||||
const tenantsQuery = useQuery({
|
const tenantsQuery = useQuery({
|
||||||
queryKey: ["org-picker-tenants"],
|
queryKey: ["org-picker-tenants"],
|
||||||
queryFn: () => fetchTenants(10000, 0),
|
queryFn: () => fetchAllTenants(),
|
||||||
});
|
});
|
||||||
const usersQuery = useQuery({
|
const usersQuery = useQuery({
|
||||||
queryKey: ["org-picker-users"],
|
queryKey: ["org-picker-users"],
|
||||||
|
|||||||
77
orgfront/src/lib/adminApi.test.ts
Normal file
77
orgfront/src/lib/adminApi.test.ts
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
|
import { fetchAllCursorPages } from "../../../common/core/pagination";
|
||||||
import apiClient from "./apiClient";
|
import apiClient from "./apiClient";
|
||||||
|
import { userManager } from "./auth";
|
||||||
|
|
||||||
export type AuditLog = {
|
export type AuditLog = {
|
||||||
event_id: string;
|
event_id: string;
|
||||||
@@ -50,6 +52,9 @@ export type TenantListResponse = {
|
|||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
total: number;
|
total: number;
|
||||||
|
cursor?: string;
|
||||||
|
nextCursor?: string;
|
||||||
|
next_cursor?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TenantUpdateRequest = {
|
export type TenantUpdateRequest = {
|
||||||
@@ -99,16 +104,61 @@ export async function fetchAuditLogs(limit = 50, cursor?: string) {
|
|||||||
return data;
|
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>(
|
const { data } = await apiClient.get<TenantListResponse>(
|
||||||
"/v1/admin/tenants",
|
"/v1/admin/tenants",
|
||||||
{
|
{
|
||||||
params: { limit, offset, parentId },
|
params: { limit, offset, parentId, cursor },
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
return data;
|
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) {
|
export async function fetchTenant(tenantId: string) {
|
||||||
const { data } = await apiClient.get<TenantSummary>(
|
const { data } = await apiClient.get<TenantSummary>(
|
||||||
`/v1/admin/tenants/${tenantId}`,
|
`/v1/admin/tenants/${tenantId}`,
|
||||||
@@ -481,17 +531,9 @@ export async function fetchUser(userId: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createUser(payload: UserCreateRequest) {
|
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>(
|
const { data } = await apiClient.post<UserCreateResponse>(
|
||||||
"/v1/admin/users",
|
"/v1/admin/users",
|
||||||
requestPayload,
|
payload,
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -512,16 +554,9 @@ export function exportUsersCSVUrl(search?: string, tenantSlug?: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function bulkCreateUsers(users: BulkUserItem[]) {
|
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>(
|
const { data } = await apiClient.post<BulkUserResponse>(
|
||||||
"/v1/admin/users/bulk",
|
"/v1/admin/users/bulk",
|
||||||
{ users: mappedUsers },
|
{ users },
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
@@ -533,13 +568,7 @@ export async function bulkUpdateUsers(payload: {
|
|||||||
tenantSlug?: string;
|
tenantSlug?: string;
|
||||||
department?: string;
|
department?: string;
|
||||||
}) {
|
}) {
|
||||||
const requestPayload: typeof payload & { companyCode?: string } = {
|
const { data } = await apiClient.put("/v1/admin/users/bulk", payload);
|
||||||
...payload,
|
|
||||||
};
|
|
||||||
if (payload.tenantSlug !== undefined) {
|
|
||||||
requestPayload.companyCode = payload.tenantSlug;
|
|
||||||
}
|
|
||||||
const { data } = await apiClient.put("/v1/admin/users/bulk", requestPayload);
|
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -551,16 +580,9 @@ export async function bulkDeleteUsers(userIds: string[]) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function updateUser(userId: string, payload: UserUpdateRequest) {
|
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>(
|
const { data } = await apiClient.put<UserSummary>(
|
||||||
`/v1/admin/users/${userId}`,
|
`/v1/admin/users/${userId}`,
|
||||||
requestPayload,
|
payload,
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
import { shouldStartLoginRedirect } from "../../../common/core/auth";
|
||||||
import { userManager } from "./auth";
|
import { userManager } from "./auth";
|
||||||
|
|
||||||
|
let isRedirectingToLogin = false;
|
||||||
|
|
||||||
const apiClient = axios.create({
|
const apiClient = axios.create({
|
||||||
baseURL:
|
baseURL:
|
||||||
import.meta.env.VITE_DEV_API_BASE ??
|
import.meta.env.VITE_DEV_API_BASE ??
|
||||||
@@ -32,8 +35,6 @@ apiClient.interceptors.response.use(
|
|||||||
error.response?.data?.error?.toString().toLowerCase() ??
|
error.response?.data?.error?.toString().toLowerCase() ??
|
||||||
error.response?.data?.message?.toString().toLowerCase() ??
|
error.response?.data?.message?.toString().toLowerCase() ??
|
||||||
"";
|
"";
|
||||||
const isAuthPath = window.location.pathname.startsWith("/auth/callback");
|
|
||||||
const isLoginPath = window.location.pathname === "/login";
|
|
||||||
const shouldRedirectToLogin =
|
const shouldRedirectToLogin =
|
||||||
status === 401 ||
|
status === 401 ||
|
||||||
(status === 403 &&
|
(status === 403 &&
|
||||||
@@ -41,7 +42,14 @@ apiClient.interceptors.response.use(
|
|||||||
message.includes("invalid session") ||
|
message.includes("invalid session") ||
|
||||||
message.includes("token is not active")));
|
message.includes("token is not active")));
|
||||||
|
|
||||||
if (shouldRedirectToLogin && !isAuthPath && !isLoginPath) {
|
if (
|
||||||
|
shouldRedirectToLogin &&
|
||||||
|
shouldStartLoginRedirect({
|
||||||
|
pathname: window.location.pathname,
|
||||||
|
isRedirecting: isRedirectingToLogin,
|
||||||
|
})
|
||||||
|
) {
|
||||||
|
isRedirectingToLogin = true;
|
||||||
await userManager.removeUser();
|
await userManager.removeUser();
|
||||||
window.location.href = "/login";
|
window.location.href = "/login";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
DEFAULT_SESSION_RENEW_THROTTLE_MS,
|
DEFAULT_SESSION_RENEW_THROTTLE_MS,
|
||||||
|
type SessionRenewDecisionParams,
|
||||||
shouldAttemptSlidingSessionRenew as shouldAttemptSlidingSessionRenewBase,
|
shouldAttemptSlidingSessionRenew as shouldAttemptSlidingSessionRenewBase,
|
||||||
shouldAttemptUnlimitedSessionRenew as shouldAttemptUnlimitedSessionRenewBase,
|
shouldAttemptUnlimitedSessionRenew as shouldAttemptUnlimitedSessionRenewBase,
|
||||||
type SessionRenewDecisionParams,
|
|
||||||
} from "../../../common/core/session";
|
} from "../../../common/core/session";
|
||||||
|
|
||||||
export const SESSION_RENEW_THROTTLE_MS = DEFAULT_SESSION_RENEW_THROTTLE_MS;
|
export const SESSION_RENEW_THROTTLE_MS = DEFAULT_SESSION_RENEW_THROTTLE_MS;
|
||||||
|
|||||||
87
scripts/clear_orphan_tenant_memberships.sh
Executable file
87
scripts/clear_orphan_tenant_memberships.sh
Executable 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
|
||||||
67
scripts/clear_orphan_user_tenant_memberships.sql
Normal file
67
scripts/clear_orphan_user_tenant_memberships.sql
Normal 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;
|
||||||
8
scripts/sanitize_baron_user_metadata.sql
Normal file
8
scripts/sanitize_baron_user_metadata.sql
Normal 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';
|
||||||
15
test/kratos_identity_schema_policy_test.sh
Normal file
15
test/kratos_identity_schema_policy_test.sh
Normal 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"
|
||||||
@@ -21,6 +21,7 @@ assert_not_contains() {
|
|||||||
|
|
||||||
staging_pull=".gitea/workflows/staging_code_pull.yml"
|
staging_pull=".gitea/workflows/staging_code_pull.yml"
|
||||||
pull_compose="docker/staging_pull_compose.template.yaml"
|
pull_compose="docker/staging_pull_compose.template.yaml"
|
||||||
|
deploy_compose="deploy/templates/docker-compose.yaml"
|
||||||
devfront_vite="devfront/vite.config.ts"
|
devfront_vite="devfront/vite.config.ts"
|
||||||
orgfront_vite="orgfront/vite.config.ts"
|
orgfront_vite="orgfront/vite.config.ts"
|
||||||
adminfront_vite="adminfront/vite.config.ts"
|
adminfront_vite="adminfront/vite.config.ts"
|
||||||
@@ -31,6 +32,7 @@ orgfront_runtime="orgfront/scripts/runtime-mode.sh"
|
|||||||
for file in \
|
for file in \
|
||||||
"$staging_pull" \
|
"$staging_pull" \
|
||||||
"$pull_compose" \
|
"$pull_compose" \
|
||||||
|
"$deploy_compose" \
|
||||||
"$adminfront_vite" \
|
"$adminfront_vite" \
|
||||||
"$devfront_vite" \
|
"$devfront_vite" \
|
||||||
"$orgfront_vite" \
|
"$orgfront_vite" \
|
||||||
@@ -61,6 +63,10 @@ assert_contains "$pull_compose" "baron_devfront"
|
|||||||
assert_contains "$pull_compose" "baron_orgfront"
|
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:5173/"
|
||||||
assert_contains "$pull_compose" "http://127.0.0.1:5175/"
|
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-dist"
|
||||||
assert_contains "$adminfront_vite" "/tmp/baron-sso-adminfront-vite-cache"
|
assert_contains "$adminfront_vite" "/tmp/baron-sso-adminfront-vite-cache"
|
||||||
|
|||||||
Reference in New Issue
Block a user