1
0
forked from baron/baron-sso

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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