forked from baron/baron-sso
테넌트 목록 조회 cursor기반으로 재구성. 사용자 metadata 미사용 필드 제거
This commit is contained in:
@@ -28,58 +28,7 @@ import {
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
const AVAILABLE_SCOPES = [
|
||||
{
|
||||
id: "audit:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.audit_read.title",
|
||||
labelFallback: "감사 로그 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.audit_read.desc",
|
||||
descFallback: "시스템 내의 모든 이력을 조회할 수 있습니다.",
|
||||
},
|
||||
{
|
||||
id: "audit:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.audit_write.title",
|
||||
labelFallback: "감사 로그 생성",
|
||||
descKey: "msg.admin.api_keys.scopes.audit_write.desc",
|
||||
descFallback: "외부 앱의 로그를 Baron SSO로 전송합니다.",
|
||||
},
|
||||
{
|
||||
id: "user:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.user_read.title",
|
||||
labelFallback: "사용자 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.user_read.desc",
|
||||
descFallback: "사용자 목록 및 프로필을 읽을 수 있습니다.",
|
||||
},
|
||||
{
|
||||
id: "user:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.user_write.title",
|
||||
labelFallback: "사용자 관리",
|
||||
descKey: "msg.admin.api_keys.scopes.user_write.desc",
|
||||
descFallback: "사용자 생성, 수정, 삭제 작업을 수행합니다.",
|
||||
},
|
||||
{
|
||||
id: "tenant:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.tenant_read.title",
|
||||
labelFallback: "테넌트 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.tenant_read.desc",
|
||||
descFallback: "등록된 모든 조직 정보를 조회합니다.",
|
||||
},
|
||||
{
|
||||
id: "tenant:write",
|
||||
labelKey: "ui.admin.api_keys.scopes.tenant_write.title",
|
||||
labelFallback: "테넌트 관리",
|
||||
descKey: "msg.admin.api_keys.scopes.tenant_write.desc",
|
||||
descFallback: "테넌트 정보를 직접 제어합니다.",
|
||||
},
|
||||
{
|
||||
id: "org-context:read",
|
||||
labelKey: "ui.admin.api_keys.scopes.org_context_read.title",
|
||||
labelFallback: "조직 Context 조회",
|
||||
descKey: "msg.admin.api_keys.scopes.org_context_read.desc",
|
||||
descFallback: "외부 연동앱이 OrgFront SSOT 조직 JSON을 조회합니다.",
|
||||
},
|
||||
];
|
||||
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
|
||||
|
||||
function ApiKeyCreatePage() {
|
||||
const navigate = useNavigate();
|
||||
@@ -305,7 +254,7 @@ function ApiKeyCreatePage() {
|
||||
</h3>
|
||||
</div>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{AVAILABLE_SCOPES.map((scope) => {
|
||||
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
|
||||
const isSelected = selectedScopes.includes(scope.id);
|
||||
return (
|
||||
<button
|
||||
|
||||
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 type { AxiosError } from "axios";
|
||||
import { Key, Plus, RefreshCw, Trash2 } from "lucide-react";
|
||||
import {
|
||||
Copy,
|
||||
Edit3,
|
||||
Key,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Button } from "../../components/ui/button";
|
||||
@@ -11,6 +21,15 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../../components/ui/dialog";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -19,10 +38,27 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { deleteApiKey, fetchApiKeys } from "../../lib/adminApi";
|
||||
import {
|
||||
type ApiKeySummary,
|
||||
deleteApiKey,
|
||||
fetchApiKeys,
|
||||
rotateApiKeySecret,
|
||||
updateApiKeyScopes,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import { cn } from "../../lib/utils";
|
||||
import { AVAILABLE_API_KEY_SCOPES } from "./apiKeyScopes";
|
||||
|
||||
function ApiKeyListPage() {
|
||||
const [editingKey, setEditingKey] = React.useState<ApiKeySummary | null>(
|
||||
null,
|
||||
);
|
||||
const [draftScopes, setDraftScopes] = React.useState<string[]>([]);
|
||||
const [rotatedSecret, setRotatedSecret] = React.useState<{
|
||||
key: ApiKeySummary;
|
||||
clientSecret: string;
|
||||
} | null>(null);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["api-keys", { limit: 50, offset: 0 }],
|
||||
queryFn: () => fetchApiKeys(50, 0),
|
||||
@@ -35,6 +71,27 @@ function ApiKeyListPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const updateScopesMutation = useMutation({
|
||||
mutationFn: ({ id, scopes }: { id: string; scopes: string[] }) =>
|
||||
updateApiKeyScopes(id, { scopes }),
|
||||
onSuccess: () => {
|
||||
setEditingKey(null);
|
||||
setDraftScopes([]);
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const rotateSecretMutation = useMutation({
|
||||
mutationFn: (id: string) => rotateApiKeySecret(id),
|
||||
onSuccess: (data) => {
|
||||
setRotatedSecret({
|
||||
key: data.apiKey,
|
||||
clientSecret: data.clientSecret,
|
||||
});
|
||||
query.refetch();
|
||||
},
|
||||
});
|
||||
|
||||
const errorMsg = (query.error as AxiosError<{ error?: string }>)?.response
|
||||
?.data?.error;
|
||||
const fallbackError =
|
||||
@@ -62,6 +119,44 @@ function ApiKeyListPage() {
|
||||
deleteMutation.mutate(id);
|
||||
};
|
||||
|
||||
const openScopeEditor = (key: ApiKeySummary) => {
|
||||
setEditingKey(key);
|
||||
setDraftScopes(key.scopes);
|
||||
};
|
||||
|
||||
const toggleDraftScope = (scopeId: string) => {
|
||||
setDraftScopes((current) =>
|
||||
current.includes(scopeId)
|
||||
? current.filter((scope) => scope !== scopeId)
|
||||
: [...current, scopeId],
|
||||
);
|
||||
};
|
||||
|
||||
const saveScopes = () => {
|
||||
if (!editingKey || draftScopes.length === 0) return;
|
||||
updateScopesMutation.mutate({ id: editingKey.id, scopes: draftScopes });
|
||||
};
|
||||
|
||||
const handleRotateSecret = (key: ApiKeySummary) => {
|
||||
if (
|
||||
!window.confirm(
|
||||
t(
|
||||
"msg.admin.api_keys.list.rotate_confirm",
|
||||
'API 키 "{{name}}"의 Secret을 재발급할까요? 기존 Secret은 더 이상 사용할 수 없습니다.',
|
||||
{ name: key.name },
|
||||
),
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
rotateSecretMutation.mutate(key.id);
|
||||
};
|
||||
|
||||
const copyRotatedSecret = () => {
|
||||
if (!rotatedSecret) return;
|
||||
navigator.clipboard.writeText(rotatedSecret.clientSecret);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
@@ -189,15 +284,40 @@ function ApiKeyListPage() {
|
||||
: t("ui.common.never", "Never")}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
<div className="flex flex-wrap justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openScopeEditor(key)}
|
||||
>
|
||||
<Edit3 size={14} />
|
||||
{t(
|
||||
"ui.admin.api_keys.list.edit_scopes",
|
||||
"권한 수정",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleRotateSecret(key)}
|
||||
disabled={rotateSecretMutation.isPending}
|
||||
>
|
||||
<RotateCcw size={14} />
|
||||
{t(
|
||||
"ui.admin.api_keys.list.rotate_secret",
|
||||
"Secret 재발급",
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(key.id, key.name)}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
@@ -207,6 +327,137 @@ function ApiKeyListPage() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Dialog
|
||||
open={editingKey !== null}
|
||||
onOpenChange={() => setEditingKey(null)}
|
||||
>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("ui.admin.api_keys.list.edit_scopes", "권한 수정")}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editingKey
|
||||
? t(
|
||||
"msg.admin.api_keys.list.edit_scopes_desc",
|
||||
"{{clientId}}의 CLIENT_ID는 유지하고 권한만 변경합니다.",
|
||||
{ clientId: editingKey.client_id },
|
||||
)
|
||||
: null}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{AVAILABLE_API_KEY_SCOPES.map((scope) => {
|
||||
const isSelected = draftScopes.includes(scope.id);
|
||||
return (
|
||||
<button
|
||||
key={scope.id}
|
||||
type="button"
|
||||
onClick={() => toggleDraftScope(scope.id)}
|
||||
className={cn(
|
||||
"flex flex-col items-start gap-2 rounded-lg border-2 p-4 text-left transition-all",
|
||||
isSelected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-card hover:border-muted-foreground/30",
|
||||
)}
|
||||
>
|
||||
<span className="font-bold text-sm">
|
||||
{t(scope.labelKey, scope.labelFallback)}
|
||||
</span>
|
||||
<span className="text-[11px] text-muted-foreground leading-snug">
|
||||
{t(scope.descKey, scope.descFallback)}
|
||||
</span>
|
||||
<code className="text-[9px] font-mono opacity-60 uppercase tracking-tighter">
|
||||
ID: {scope.id}
|
||||
</code>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{draftScopes.length === 0 && (
|
||||
<p className="text-sm text-destructive">
|
||||
{t(
|
||||
"msg.admin.api_keys.create.scope_required",
|
||||
"최소 하나 이상의 권한을 선택해야 합니다.",
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditingKey(null)}>
|
||||
{t("ui.common.cancel", "취소")}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={saveScopes}
|
||||
disabled={
|
||||
updateScopesMutation.isPending || draftScopes.length === 0
|
||||
}
|
||||
>
|
||||
<Save size={16} />
|
||||
{t("ui.admin.api_keys.list.save_scopes", "권한 저장")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog
|
||||
open={rotatedSecret !== null}
|
||||
onOpenChange={() => setRotatedSecret(null)}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t(
|
||||
"ui.admin.api_keys.list.rotate_secret_done",
|
||||
"Secret 재발급 완료",
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"msg.admin.api_keys.list.rotate_secret_notice",
|
||||
"새 Secret은 지금 한 번만 표시됩니다. CLIENT_ID는 변경되지 않았습니다.",
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{rotatedSecret && (
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
CLIENT ID
|
||||
</p>
|
||||
<code className="block rounded-md bg-muted px-3 py-2 text-sm">
|
||||
{rotatedSecret.key.client_id}
|
||||
</code>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-bold text-muted-foreground">
|
||||
X-Baron-Key-Secret
|
||||
</p>
|
||||
<div className="relative">
|
||||
<Input
|
||||
readOnly
|
||||
value={rotatedSecret.clientSecret}
|
||||
className="font-mono pr-12"
|
||||
/>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2"
|
||||
onClick={copyRotatedSecret}
|
||||
>
|
||||
<Copy size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setRotatedSecret(null)}>
|
||||
{t("ui.common.confirm", "확인")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
auditEvents24h: 18,
|
||||
})),
|
||||
fetchTenants: vi.fn(async () => ({
|
||||
fetchAllTenants: vi.fn(async () => ({
|
||||
items: [
|
||||
{
|
||||
id: "company-1",
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type TenantSummary,
|
||||
fetchAdminOverviewStats,
|
||||
fetchAdminRPUsageDaily,
|
||||
fetchTenants,
|
||||
fetchAllTenants,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
|
||||
@@ -342,7 +342,7 @@ function GlobalOverviewPage() {
|
||||
});
|
||||
const tenantsQuery = useQuery({
|
||||
queryKey: ["admin-overview-tenant-options"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
retry: false,
|
||||
});
|
||||
const tenantOptions = useMemo(() => {
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { Input } from "../../../components/ui/input";
|
||||
import { Label } from "../../../components/ui/label";
|
||||
import { Textarea } from "../../../components/ui/textarea";
|
||||
import { createTenant, fetchTenants } from "../../../lib/adminApi";
|
||||
import { createTenant, fetchAllTenants } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { DomainTagInput } from "../components/DomainTagInput";
|
||||
import { ParentTenantSelector } from "../components/ParentTenantSelector";
|
||||
@@ -47,8 +47,8 @@ function TenantCreatePage() {
|
||||
);
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", { limit: 1000 }],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryKey: ["tenants", "all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
const tenants = parentQuery.data?.items ?? [];
|
||||
const selectedParentTenant = tenants.find((tenant) => tenant.id === parentId);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useInfiniteQuery, useMutation, useQuery } from "@tanstack/react-query";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
import type { AxiosError } from "axios";
|
||||
import {
|
||||
ArrowDown,
|
||||
@@ -74,6 +75,10 @@ import {
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import { type TenantNode, buildTenantFullTree } from "../../../lib/tenantTree";
|
||||
import {
|
||||
filterNonHanmacFamilyTenants,
|
||||
isHanmacFamilyUser,
|
||||
} from "../../users/orgChartPicker";
|
||||
import { isSeedTenant } from "../utils/protectedTenants";
|
||||
import {
|
||||
type TenantImportPreviewRow,
|
||||
@@ -87,8 +92,14 @@ import {
|
||||
|
||||
const tenantCSVTemplate =
|
||||
"name,type,parent_tenant_slug,slug,memo,email_domain,visibility,org_unit_type\n";
|
||||
const tenantPageSize = 500;
|
||||
const tenantVirtualizationThreshold = 250;
|
||||
const tenantEstimatedRowHeight = 73;
|
||||
const tenantLoadAheadPx = 360;
|
||||
const tenantLoadAheadRows = 30;
|
||||
|
||||
type TenantSortKey = keyof TenantSummary | "recursiveMemberCount";
|
||||
type TenantListRow = TenantSummary & { recursiveMemberCount: number };
|
||||
|
||||
const getTenantIcon = (type?: string) => {
|
||||
switch (type?.toUpperCase()) {
|
||||
@@ -245,6 +256,7 @@ function TenantListPage() {
|
||||
Record<number, string>
|
||||
>({});
|
||||
const [previewOpen, setPreviewOpen] = React.useState(false);
|
||||
const tenantTableScrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const { data: profile } = useQuery({
|
||||
queryKey: ["me"],
|
||||
@@ -264,9 +276,18 @@ function TenantListPage() {
|
||||
}
|
||||
}, [profile, navigate]);
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ["tenants", { limit: 1000, offset: 0 }],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
const query = useInfiniteQuery({
|
||||
queryKey: ["tenants", "lazy"],
|
||||
queryFn: ({ pageParam }) =>
|
||||
fetchTenants(
|
||||
tenantPageSize,
|
||||
0,
|
||||
undefined,
|
||||
pageParam ? pageParam : undefined,
|
||||
),
|
||||
initialPageParam: "",
|
||||
getNextPageParam: (lastPage) =>
|
||||
lastPage.nextCursor || lastPage.next_cursor || undefined,
|
||||
enabled:
|
||||
profile?.role === "super_admin" ||
|
||||
(profile?.role === "tenant_admin" &&
|
||||
@@ -364,7 +385,28 @@ function TenantListPage() {
|
||||
? t("msg.admin.tenants.fetch_error", "테넌트 목록 조회에 실패했습니다.")
|
||||
: null;
|
||||
|
||||
const allTenants = query.data?.items ?? [];
|
||||
const tenantPages = query.data?.pages ?? [];
|
||||
const rawTenants = tenantPages.flatMap((page) => page.items);
|
||||
const tenantTotal = tenantPages[0]?.total ?? 0;
|
||||
const hanmacFamilyTenantId = React.useMemo(() => {
|
||||
const envTenantId = import.meta.env.VITE_HANMAC_FAMILY_TENANT_ID;
|
||||
if (typeof envTenantId === "string" && envTenantId.trim()) {
|
||||
return envTenantId.trim();
|
||||
}
|
||||
return rawTenants.find((tenant) => tenant.slug === "hanmac-family")?.id;
|
||||
}, [rawTenants]);
|
||||
const allTenants = React.useMemo(() => {
|
||||
if (profile?.role === "super_admin") {
|
||||
return rawTenants;
|
||||
}
|
||||
if (
|
||||
profile &&
|
||||
isHanmacFamilyUser(profile, rawTenants, hanmacFamilyTenantId)
|
||||
) {
|
||||
return rawTenants;
|
||||
}
|
||||
return filterNonHanmacFamilyTenants(rawTenants, hanmacFamilyTenantId);
|
||||
}, [hanmacFamilyTenantId, profile, rawTenants]);
|
||||
const importParentOptionGroups =
|
||||
buildTenantImportParentOptionGroups(allTenants);
|
||||
const tenantSortResolvers = React.useMemo<
|
||||
@@ -414,6 +456,56 @@ function TenantListPage() {
|
||||
return sortItems(enriched, sortConfig, tenantSortResolvers);
|
||||
}, [allTenants, search, sortConfig, tenantSortResolvers]);
|
||||
|
||||
const shouldVirtualizeTenants =
|
||||
tenants.length >= tenantVirtualizationThreshold;
|
||||
const tenantRowVirtualizer = useVirtualizer({
|
||||
count: tenants.length,
|
||||
getScrollElement: () => tenantTableScrollRef.current,
|
||||
estimateSize: () => tenantEstimatedRowHeight,
|
||||
overscan: 12,
|
||||
enabled: shouldVirtualizeTenants,
|
||||
});
|
||||
const virtualTenantRows = shouldVirtualizeTenants
|
||||
? tenantRowVirtualizer.getVirtualItems()
|
||||
: [];
|
||||
const lastVirtualTenantIndex =
|
||||
virtualTenantRows[virtualTenantRows.length - 1]?.index ?? -1;
|
||||
|
||||
const fetchNextTenantPage = React.useCallback(() => {
|
||||
if (query.hasNextPage && !query.isFetchingNextPage) {
|
||||
void query.fetchNextPage();
|
||||
}
|
||||
}, [query.fetchNextPage, query.hasNextPage, query.isFetchingNextPage]);
|
||||
|
||||
const handleTenantTableScroll = React.useCallback(
|
||||
(event: React.UIEvent<HTMLDivElement>) => {
|
||||
const scrollElement = event.currentTarget;
|
||||
const distanceToEnd =
|
||||
scrollElement.scrollHeight -
|
||||
scrollElement.scrollTop -
|
||||
scrollElement.clientHeight;
|
||||
if (distanceToEnd <= tenantLoadAheadPx) {
|
||||
fetchNextTenantPage();
|
||||
}
|
||||
},
|
||||
[fetchNextTenantPage],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (
|
||||
!shouldVirtualizeTenants ||
|
||||
lastVirtualTenantIndex < tenants.length - tenantLoadAheadRows
|
||||
) {
|
||||
return;
|
||||
}
|
||||
fetchNextTenantPage();
|
||||
}, [
|
||||
fetchNextTenantPage,
|
||||
lastVirtualTenantIndex,
|
||||
shouldVirtualizeTenants,
|
||||
tenants.length,
|
||||
]);
|
||||
|
||||
const requestSort = (key: TenantSortKey) => {
|
||||
setSortConfig((current) => toggleSort(current, key));
|
||||
};
|
||||
@@ -600,6 +692,96 @@ function TenantListPage() {
|
||||
deleteMutation.mutate(tenantId);
|
||||
};
|
||||
|
||||
const renderTenantRow = (
|
||||
tenant: TenantListRow,
|
||||
options?: {
|
||||
style?: React.CSSProperties;
|
||||
virtualIndex?: number;
|
||||
},
|
||||
) => (
|
||||
<TableRow
|
||||
key={tenant.id}
|
||||
data-index={options?.virtualIndex}
|
||||
ref={
|
||||
options?.virtualIndex === undefined
|
||||
? undefined
|
||||
: tenantRowVirtualizer.measureElement
|
||||
}
|
||||
style={options?.style}
|
||||
>
|
||||
<TableCell className="text-center">
|
||||
{isSeedTenant(tenant) ? (
|
||||
<span className="inline-block h-4 w-4" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(tenant.id)}
|
||||
onCheckedChange={(checked) => handleSelect(tenant, !!checked)}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
|
||||
data-testid={`tenant-internal-id-${tenant.id}`}
|
||||
>
|
||||
{tenant.id}
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/tenants/${tenant.id}`}
|
||||
className="hover:underline text-primary cursor-pointer"
|
||||
>
|
||||
{tenant.name}
|
||||
</Link>
|
||||
{isSeedTenant(tenant) && (
|
||||
<Badge variant="secondary" className="text-[10px]">
|
||||
{t("ui.admin.tenants.seed_badge", "초기 설정")}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge variant="outline" className="text-[10px] font-mono">
|
||||
{tenant.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">{tenant.slug}</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active"
|
||||
? "default"
|
||||
: tenant.status === "pending"
|
||||
? "secondary"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{t(`ui.common.status.${tenant.status}`, tenant.status)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{tenant.recursiveMemberCount}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString("ko-KR")
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={isSeedTenant(tenant) || deleteMutation.isPending}
|
||||
onClick={() => handleDelete(tenant.id, tenant.name)}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6 flex flex-col h-[calc(100vh-theme(spacing.32))]">
|
||||
<header className="flex flex-wrap items-start justify-between gap-4 flex-shrink-0 sticky top-[-2.5rem] z-20 bg-background/95 backdrop-blur pt-4 pb-2 -mt-4">
|
||||
@@ -728,7 +910,7 @@ function TenantListPage() {
|
||||
"msg.admin.tenants.registry.count",
|
||||
"총 {{count}}개 테넌트",
|
||||
{
|
||||
count: query.data?.total ?? 0,
|
||||
count: tenantTotal,
|
||||
},
|
||||
)}
|
||||
</CardDescription>
|
||||
@@ -771,7 +953,12 @@ function TenantListPage() {
|
||||
className="flex-1 flex flex-col min-h-0 m-0"
|
||||
>
|
||||
<div className="flex-1 rounded-md border overflow-hidden flex flex-col">
|
||||
<div className="flex-1 overflow-auto relative custom-scrollbar">
|
||||
<div
|
||||
ref={tenantTableScrollRef}
|
||||
className="flex-1 overflow-auto relative custom-scrollbar"
|
||||
data-testid="tenant-table-scroll"
|
||||
onScroll={handleTenantTableScroll}
|
||||
>
|
||||
<Table className="min-w-[1180px]">
|
||||
<TableHeader className="sticky top-0 z-10 bg-secondary shadow-sm">
|
||||
<TableRow>
|
||||
@@ -855,7 +1042,18 @@ function TenantListPage() {
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
<TableBody
|
||||
className={
|
||||
shouldVirtualizeTenants ? "relative block" : undefined
|
||||
}
|
||||
style={
|
||||
shouldVirtualizeTenants
|
||||
? {
|
||||
height: `${tenantRowVirtualizer.getTotalSize()}px`,
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{query.isLoading && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9}>
|
||||
@@ -876,102 +1074,26 @@ function TenantListPage() {
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
{tenants.map((tenant) => (
|
||||
<TableRow key={tenant.id}>
|
||||
<TableCell className="text-center">
|
||||
{isSeedTenant(tenant) ? (
|
||||
<span className="inline-block h-4 w-4" />
|
||||
) : (
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(tenant.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleSelect(tenant, !!checked)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="max-w-[260px] break-all font-mono text-xs text-muted-foreground"
|
||||
data-testid={`tenant-internal-id-${tenant.id}`}
|
||||
>
|
||||
{tenant.id}
|
||||
</TableCell>
|
||||
<TableCell className="font-semibold">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Link
|
||||
to={`/tenants/${tenant.id}`}
|
||||
className="hover:underline text-primary cursor-pointer"
|
||||
>
|
||||
{tenant.name}
|
||||
</Link>
|
||||
{isSeedTenant(tenant) && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="text-[10px]"
|
||||
>
|
||||
{t(
|
||||
"ui.admin.tenants.seed_badge",
|
||||
"초기 설정",
|
||||
)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] font-mono"
|
||||
>
|
||||
{tenant.type}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{tenant.slug}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap">
|
||||
<Badge
|
||||
variant={
|
||||
tenant.status === "active"
|
||||
? "default"
|
||||
: tenant.status === "pending"
|
||||
? "secondary"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{t(
|
||||
`ui.common.status.${tenant.status}`,
|
||||
tenant.status,
|
||||
)}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium">
|
||||
{tenant.recursiveMemberCount}
|
||||
</TableCell>
|
||||
<TableCell className="whitespace-nowrap text-xs">
|
||||
{tenant.updatedAt
|
||||
? new Date(tenant.updatedAt).toLocaleString(
|
||||
"ko-KR",
|
||||
)
|
||||
: "-"}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={
|
||||
isSeedTenant(tenant) || deleteMutation.isPending
|
||||
}
|
||||
onClick={() =>
|
||||
handleDelete(tenant.id, tenant.name)
|
||||
}
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
>
|
||||
<Trash2 size={14} className="mr-2" />
|
||||
{t("ui.common.delete", "삭제")}
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{shouldVirtualizeTenants
|
||||
? virtualTenantRows.map((virtualRow) => {
|
||||
const tenant = tenants[virtualRow.index];
|
||||
if (!tenant) {
|
||||
return null;
|
||||
}
|
||||
return renderTenantRow(tenant, {
|
||||
virtualIndex: virtualRow.index,
|
||||
style: {
|
||||
position: "absolute",
|
||||
top: 0,
|
||||
left: 0,
|
||||
width: "100%",
|
||||
display: "table",
|
||||
tableLayout: "fixed",
|
||||
transform: `translateY(${virtualRow.start}px)`,
|
||||
},
|
||||
});
|
||||
})
|
||||
: tenants.map((tenant) => renderTenantRow(tenant))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -18,8 +18,8 @@ import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
approveTenant,
|
||||
deleteTenant,
|
||||
fetchAllTenants,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
updateTenant,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
@@ -58,7 +58,7 @@ export function TenantProfilePage() {
|
||||
|
||||
const parentQuery = useQuery({
|
||||
queryKey: ["tenants", "list-all"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
|
||||
const [name, setName] = useState("");
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../../components/ui/table";
|
||||
import { fetchTenants } from "../../../lib/adminApi";
|
||||
import { fetchAllTenants } from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
function TenantSubTenantsPage() {
|
||||
@@ -27,7 +27,7 @@ function TenantSubTenantsPage() {
|
||||
|
||||
const { data } = useQuery({
|
||||
queryKey: ["sub-tenants", tenantId],
|
||||
queryFn: () => fetchTenants(50, 0, tenantId ?? undefined),
|
||||
queryFn: () => fetchAllTenants({ parentId: tenantId ?? undefined }),
|
||||
enabled: !!tenantId,
|
||||
});
|
||||
|
||||
|
||||
@@ -21,14 +21,14 @@ import {
|
||||
} from "../../../components/ui/table";
|
||||
import {
|
||||
type TenantSummary,
|
||||
fetchAllTenants,
|
||||
fetchGroups,
|
||||
fetchTenants,
|
||||
} from "../../../lib/adminApi";
|
||||
|
||||
export default function GlobalUserGroupListPage() {
|
||||
const { data: tenantList, isLoading: isTenantsLoading } = useQuery({
|
||||
queryKey: ["admin-tenants"],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
|
||||
if (isTenantsLoading)
|
||||
|
||||
@@ -74,7 +74,7 @@ import {
|
||||
type UserSummary,
|
||||
createUser,
|
||||
exportTenantsCSV,
|
||||
fetchTenants,
|
||||
fetchAllTenants,
|
||||
fetchUsers,
|
||||
updateTenant,
|
||||
updateUser,
|
||||
@@ -449,7 +449,7 @@ function TenantUserGroupsTab() {
|
||||
refetch: refetchTree,
|
||||
} = useQuery({
|
||||
queryKey: ["tenants-full-tree-v2"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
|
||||
const { currentBase, subTree } = useMemo(() => {
|
||||
|
||||
@@ -42,9 +42,9 @@ import { toast } from "../../../components/ui/use-toast";
|
||||
import {
|
||||
addGroupMember,
|
||||
assignGroupRole,
|
||||
fetchAllTenants,
|
||||
fetchGroup,
|
||||
fetchGroupRoles,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
removeGroupMember,
|
||||
removeGroupRole,
|
||||
@@ -91,7 +91,7 @@ export function UserGroupDetailPage() {
|
||||
// Fetch all tenants for role assignment
|
||||
const { data: tenantList } = useQuery({
|
||||
queryKey: ["admin-tenants"],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
enabled: isAddRoleOpen,
|
||||
});
|
||||
|
||||
|
||||
@@ -42,11 +42,10 @@ import {
|
||||
type UserAppointment,
|
||||
type UserCreateRequest,
|
||||
type UserCreateResponse,
|
||||
createTenant,
|
||||
createUser,
|
||||
fetchAllTenants,
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
} from "../../lib/adminApi";
|
||||
import { t } from "../../lib/i18n";
|
||||
import {
|
||||
@@ -56,9 +55,10 @@ import {
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||
|
||||
type UserFormValues = UserCreateRequest & { metadata: Record<string, unknown> };
|
||||
type UserType = "hanmac" | "external" | "personal";
|
||||
type UserCategory = "hanmac" | "external" | "personal";
|
||||
|
||||
type PickerTarget = { kind: "appointment"; index: number };
|
||||
|
||||
@@ -114,8 +114,8 @@ function UserCreatePage() {
|
||||
>(null);
|
||||
const [createdEmail, setCreatedEmail] = React.useState<string | null>(null);
|
||||
const [autoPassword, setAutoPassword] = React.useState(true);
|
||||
const [isHanmacFamily, setIsHanmacFamily] = React.useState(true);
|
||||
const [userType, setUserType] = React.useState<UserType>("hanmac");
|
||||
const [userCategory, setUserCategory] =
|
||||
React.useState<UserCategory>("hanmac");
|
||||
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
||||
AppointmentDraft[]
|
||||
>([]);
|
||||
@@ -125,8 +125,8 @@ function UserCreatePage() {
|
||||
const [isResolvingTenant, setIsResolvingTenant] = React.useState(false);
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
queryKey: ["tenants", "all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
const tenants = tenantsData?.items ?? [];
|
||||
|
||||
@@ -177,17 +177,11 @@ function UserCreatePage() {
|
||||
|
||||
const selectedTenantSlug = watch("tenantSlug");
|
||||
const personalTenant = React.useMemo(
|
||||
() =>
|
||||
tenants.find(
|
||||
(tenant) =>
|
||||
tenant.slug === "personal" ||
|
||||
(tenant.type === "PERSONAL" &&
|
||||
tenant.name.toLowerCase() === "personal"),
|
||||
),
|
||||
() => resolvePersonalTenant(tenants),
|
||||
[tenants],
|
||||
);
|
||||
const selectedTenant =
|
||||
userType !== "external"
|
||||
userCategory !== "external"
|
||||
? undefined
|
||||
: nonHanmacFamilyTenants.find((t) => t.slug === selectedTenantSlug);
|
||||
|
||||
@@ -231,7 +225,7 @@ function UserCreatePage() {
|
||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
import.meta.env.ORGFRONT_URL,
|
||||
{
|
||||
tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined,
|
||||
tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -310,25 +304,16 @@ function UserCreatePage() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleUserTypeChange = (value: string) => {
|
||||
const nextType = value as UserType;
|
||||
setUserType(nextType);
|
||||
setIsHanmacFamily(nextType === "hanmac");
|
||||
if (nextType !== "hanmac") {
|
||||
const handleUserCategoryChange = (value: string) => {
|
||||
const nextCategory = value as UserCategory;
|
||||
setUserCategory(nextCategory);
|
||||
if (nextCategory !== "hanmac") {
|
||||
setAdditionalAppointments([]);
|
||||
}
|
||||
};
|
||||
|
||||
const ensurePersonalTenant = async () => {
|
||||
if (personalTenant) return personalTenant;
|
||||
const tenant = await createTenant({
|
||||
name: "Personal",
|
||||
slug: "personal",
|
||||
type: "PERSONAL",
|
||||
status: "active",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||
return tenant;
|
||||
return personalTenant;
|
||||
};
|
||||
|
||||
const mutation = useMutation({
|
||||
@@ -355,10 +340,13 @@ function UserCreatePage() {
|
||||
setGeneratedPassword(null);
|
||||
setCreatedEmail(null);
|
||||
|
||||
const {
|
||||
hanmacFamily: _hanmacFamily,
|
||||
userType: _userType,
|
||||
...formMetadata
|
||||
} = data.metadata ?? {};
|
||||
const metadata: Record<string, unknown> = {
|
||||
...(data.metadata ?? {}),
|
||||
hanmacFamily: userType === "hanmac" && isHanmacFamily,
|
||||
userType,
|
||||
...formMetadata,
|
||||
};
|
||||
|
||||
const payload: UserCreateRequest = {
|
||||
@@ -369,7 +357,7 @@ function UserCreatePage() {
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (userType === "external") {
|
||||
if (userCategory === "external") {
|
||||
if (!data.tenantSlug) {
|
||||
setError(
|
||||
t(
|
||||
@@ -386,7 +374,7 @@ function UserCreatePage() {
|
||||
payload.jobTitle = data.jobTitle;
|
||||
}
|
||||
|
||||
if (userType === "personal") {
|
||||
if (userCategory === "personal") {
|
||||
try {
|
||||
const tenant = await ensurePersonalTenant();
|
||||
payload.tenantSlug = tenant.slug;
|
||||
@@ -405,7 +393,7 @@ function UserCreatePage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (userType === "hanmac") {
|
||||
if (userCategory === "hanmac") {
|
||||
const appointments = additionalAppointments
|
||||
.filter((appointment) => appointment.tenantId)
|
||||
.map((appointment) => ({
|
||||
@@ -644,7 +632,7 @@ function UserCreatePage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={userType} onValueChange={handleUserTypeChange}>
|
||||
<Tabs value={userCategory} onValueChange={handleUserCategoryChange}>
|
||||
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
|
||||
<TabsTrigger
|
||||
value="hanmac"
|
||||
|
||||
@@ -56,12 +56,11 @@ import {
|
||||
type TenantSummary,
|
||||
type UserAppointment,
|
||||
type UserUpdateRequest,
|
||||
createTenant,
|
||||
deleteUser,
|
||||
fetchAllTenants,
|
||||
fetchMe,
|
||||
fetchPasswordPolicy,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
fetchUser,
|
||||
fetchUserRpHistory,
|
||||
updateUser,
|
||||
@@ -78,11 +77,12 @@ import {
|
||||
parseOrgChartTenantSelection,
|
||||
} from "./orgChartPicker";
|
||||
import type { UserSchemaField } from "./userSchemaFields";
|
||||
import { resolvePersonalTenant } from "./utils/personalTenant";
|
||||
|
||||
type UserFormValues = Omit<UserUpdateRequest, "metadata"> & {
|
||||
metadata: Record<string, Record<string, string | number | boolean>>;
|
||||
};
|
||||
type UserType = "hanmac" | "external" | "personal";
|
||||
type UserCategory = "hanmac" | "external" | "personal";
|
||||
|
||||
type PasswordResetMode = "generated" | "manual";
|
||||
type PickerTarget = { kind: "appointment"; index: number };
|
||||
@@ -318,8 +318,8 @@ function UserDetailPage() {
|
||||
const [passwordResetError, setPasswordResetError] = React.useState<
|
||||
string | null
|
||||
>(null);
|
||||
const [isHanmacFamily, setIsHanmacFamily] = React.useState(false);
|
||||
const [userType, setUserType] = React.useState<UserType>("external");
|
||||
const [userCategory, setUserCategory] =
|
||||
React.useState<UserCategory>("external");
|
||||
const [additionalAppointments, setAdditionalAppointments] = React.useState<
|
||||
AppointmentDraft[]
|
||||
>([]);
|
||||
@@ -346,8 +346,8 @@ function UserDetailPage() {
|
||||
});
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
queryKey: ["tenants", "all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
const tenants = React.useMemo(
|
||||
() => tenantsData?.items ?? [],
|
||||
@@ -465,20 +465,14 @@ function UserDetailPage() {
|
||||
return tenants.find((tenant) => tenant.slug === "hanmac-family")?.id ?? "";
|
||||
}, [tenants]);
|
||||
const personalTenant = React.useMemo(
|
||||
() =>
|
||||
tenants.find(
|
||||
(tenant) =>
|
||||
tenant.slug === "personal" ||
|
||||
(tenant.type === "PERSONAL" &&
|
||||
tenant.name.toLowerCase() === "personal"),
|
||||
),
|
||||
() => resolvePersonalTenant(tenants),
|
||||
[tenants],
|
||||
);
|
||||
|
||||
const pickerUrl = buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
import.meta.env.ORGFRONT_URL,
|
||||
{
|
||||
tenantId: userType === "hanmac" ? hanmacFamilyTenantId : undefined,
|
||||
tenantId: userCategory === "hanmac" ? hanmacFamilyTenantId : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -566,25 +560,16 @@ function UserDetailPage() {
|
||||
);
|
||||
};
|
||||
|
||||
const handleUserTypeChange = (value: string) => {
|
||||
const nextType = value as UserType;
|
||||
setUserType(nextType);
|
||||
setIsHanmacFamily(nextType === "hanmac");
|
||||
if (nextType !== "hanmac") {
|
||||
const handleUserCategoryChange = (value: string) => {
|
||||
const nextCategory = value as UserCategory;
|
||||
setUserCategory(nextCategory);
|
||||
if (nextCategory !== "hanmac") {
|
||||
setAdditionalAppointments([]);
|
||||
}
|
||||
};
|
||||
|
||||
const ensurePersonalTenant = async () => {
|
||||
if (personalTenant) return personalTenant;
|
||||
const tenant = await createTenant({
|
||||
name: "Personal",
|
||||
slug: "personal",
|
||||
type: "PERSONAL",
|
||||
status: "active",
|
||||
});
|
||||
queryClient.invalidateQueries({ queryKey: ["tenants"] });
|
||||
return tenant;
|
||||
return personalTenant;
|
||||
};
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -638,14 +623,18 @@ function UserDetailPage() {
|
||||
tenants,
|
||||
hanmacFamilyTenantId,
|
||||
);
|
||||
const resolvedUserType =
|
||||
metadata.userType === "personal" || user.companyCode === "personal"
|
||||
? "personal"
|
||||
: isUserHanmacFamily
|
||||
? "hanmac"
|
||||
: "external";
|
||||
setUserType(resolvedUserType);
|
||||
setIsHanmacFamily(resolvedUserType === "hanmac");
|
||||
const isPersonalUser =
|
||||
user.companyCode === personalTenant.slug ||
|
||||
user.tenantSlug === personalTenant.slug ||
|
||||
user.tenant?.id === personalTenant.id ||
|
||||
user.tenant?.slug === personalTenant.slug ||
|
||||
metadata.personalTenantId === personalTenant.id;
|
||||
const resolvedUserCategory = isPersonalUser
|
||||
? "personal"
|
||||
: isUserHanmacFamily
|
||||
? "hanmac"
|
||||
: "external";
|
||||
setUserCategory(resolvedUserCategory);
|
||||
const familyFallbackTenants = [
|
||||
...(user.joinedTenants ?? []),
|
||||
...(user.tenant ? [user.tenant] : []),
|
||||
@@ -696,7 +685,7 @@ function UserDetailPage() {
|
||||
: [],
|
||||
);
|
||||
}
|
||||
}, [hanmacFamilyTenantId, tenants, user, reset]);
|
||||
}, [hanmacFamilyTenantId, personalTenant, tenants, user, reset]);
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: UserUpdateRequest) => updateUser(userId, data),
|
||||
@@ -737,10 +726,13 @@ function UserDetailPage() {
|
||||
}),
|
||||
);
|
||||
|
||||
const {
|
||||
hanmacFamily: _hanmacFamily,
|
||||
userType: _userType,
|
||||
...safeMetadata
|
||||
} = cleanMetadata;
|
||||
const metadata: Record<string, unknown> = {
|
||||
...cleanMetadata,
|
||||
hanmacFamily: userType === "hanmac" && isHanmacFamily,
|
||||
userType,
|
||||
...safeMetadata,
|
||||
};
|
||||
|
||||
const profileData = { ...data };
|
||||
@@ -750,7 +742,7 @@ function UserDetailPage() {
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (userType === "personal") {
|
||||
if (userCategory === "personal") {
|
||||
try {
|
||||
const tenant = await ensurePersonalTenant();
|
||||
payload.tenantSlug = tenant.slug;
|
||||
@@ -768,7 +760,7 @@ function UserDetailPage() {
|
||||
}
|
||||
}
|
||||
|
||||
if (userType === "hanmac") {
|
||||
if (userCategory === "hanmac") {
|
||||
const appointments = additionalAppointments
|
||||
.filter((appointment) => appointment.tenantId)
|
||||
.map((appointment) => ({
|
||||
@@ -1071,8 +1063,8 @@ function UserDetailPage() {
|
||||
</div>
|
||||
|
||||
<Tabs
|
||||
value={userType}
|
||||
onValueChange={handleUserTypeChange}
|
||||
value={userCategory}
|
||||
onValueChange={handleUserCategoryChange}
|
||||
className="space-y-4 pt-6 border-t border-dashed"
|
||||
>
|
||||
<TabsList className="flex h-auto w-full justify-start rounded-none border-b bg-transparent p-0 text-foreground">
|
||||
@@ -1097,7 +1089,7 @@ function UserDetailPage() {
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{userType === "external" && (
|
||||
{userCategory === "external" && (
|
||||
<div className="grid gap-8 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
@@ -1141,7 +1133,7 @@ function UserDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userType === "hanmac" && (
|
||||
{userCategory === "hanmac" && (
|
||||
<div className="space-y-4 rounded-md border p-4">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-3">
|
||||
@@ -1314,7 +1306,7 @@ function UserDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userType === "personal" && (
|
||||
{userCategory === "personal" && (
|
||||
<div className="rounded-md border bg-muted/30 p-4 text-sm">
|
||||
{personalTenant
|
||||
? `Personal (${personalTenant.slug})`
|
||||
@@ -1322,7 +1314,7 @@ function UserDetailPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{userType === "external" && (
|
||||
{userCategory === "external" && (
|
||||
<div className="grid gap-6 md:grid-cols-3 pt-8 border-t">
|
||||
<div className="space-y-2">
|
||||
<Label
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings2,
|
||||
ShieldCheck,
|
||||
Trash2,
|
||||
} from "lucide-react";
|
||||
import * as React from "react";
|
||||
@@ -62,9 +63,9 @@ import {
|
||||
bulkUpdateUsers,
|
||||
deleteUser,
|
||||
exportUsersCSV,
|
||||
fetchAllTenants,
|
||||
fetchMe,
|
||||
fetchTenant,
|
||||
fetchTenants,
|
||||
fetchUsers,
|
||||
updateUser,
|
||||
} from "../../lib/adminApi";
|
||||
@@ -101,8 +102,8 @@ function UserListPage() {
|
||||
});
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
queryKey: ["tenants", "all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
const tenants = tenantsData?.items ?? [];
|
||||
|
||||
@@ -269,6 +270,7 @@ function UserListPage() {
|
||||
|
||||
const total = query.data?.total ?? 0;
|
||||
const totalPages = Math.ceil(total / limit);
|
||||
const canPromoteSuperAdmin = profile?.role === "super_admin";
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedUserIds.length === items.length) {
|
||||
@@ -318,6 +320,14 @@ function UserListPage() {
|
||||
bulkUpdateMutation.mutate({ userIds: selectedUserIds, status });
|
||||
};
|
||||
|
||||
const handlePromoteSuperAdmin = () => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
bulkUpdateMutation.mutate({
|
||||
userIds: selectedUserIds,
|
||||
role: "super_admin",
|
||||
});
|
||||
};
|
||||
|
||||
const handleBulkDelete = () => {
|
||||
if (selectedUserIds.length === 0) return;
|
||||
if (
|
||||
@@ -774,6 +784,18 @@ function UserListPage() {
|
||||
>
|
||||
{t("ui.common.status.inactive", "비활성화")}
|
||||
</Button>
|
||||
{canPromoteSuperAdmin && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-background hover:bg-background/10 h-8 gap-1.5"
|
||||
onClick={handlePromoteSuperAdmin}
|
||||
data-testid="bulk-promote-super-admin-btn"
|
||||
>
|
||||
<ShieldCheck size={14} />
|
||||
{t("ui.admin.users.bulk.promote_admin", "Admin으로 만들기")}
|
||||
</Button>
|
||||
)}
|
||||
<div className="w-px h-4 bg-background/20 mx-1" />
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -20,8 +20,8 @@ import {
|
||||
type TenantSummary,
|
||||
type UserSummary,
|
||||
bulkUpdateUsers,
|
||||
fetchAllTenants,
|
||||
fetchGroups,
|
||||
fetchTenants,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
|
||||
@@ -52,8 +52,8 @@ export function UserBulkMoveGroupModal({
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: tenantsData } = useQuery({
|
||||
queryKey: ["tenants", { limit: 100 }],
|
||||
queryFn: () => fetchTenants(100, 0),
|
||||
queryKey: ["tenants", "all"],
|
||||
queryFn: () => fetchAllTenants(),
|
||||
enabled: open,
|
||||
});
|
||||
const tenants = tenantsData?.items ?? [];
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
type BulkUserResult,
|
||||
bulkCreateUsers,
|
||||
createTenant,
|
||||
fetchTenants,
|
||||
fetchAllTenants,
|
||||
fetchUsers,
|
||||
} from "../../../lib/adminApi";
|
||||
import { t } from "../../../lib/i18n";
|
||||
@@ -139,7 +139,7 @@ export function UserBulkUploadModal({ onSuccess }: UserBulkUploadModalProps) {
|
||||
|
||||
const tenantQuery = useQuery({
|
||||
queryKey: ["tenants", "user-bulk-import"],
|
||||
queryFn: () => fetchTenants(1000, 0),
|
||||
queryFn: () => fetchAllTenants(),
|
||||
});
|
||||
|
||||
const usersQuery = useQuery({
|
||||
|
||||
@@ -169,4 +169,66 @@ describe("orgChartPicker", () => {
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("does not treat legacy hanmacFamily metadata as Hanmac family without tenant evidence", () => {
|
||||
const tenants = [
|
||||
{
|
||||
id: "hanmac-family-id",
|
||||
slug: "hanmac-family",
|
||||
name: "한맥가족",
|
||||
type: "COMPANY_GROUP",
|
||||
parentId: undefined,
|
||||
},
|
||||
{
|
||||
id: "external-id",
|
||||
slug: "external",
|
||||
name: "External",
|
||||
type: "COMPANY",
|
||||
parentId: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
isHanmacFamilyUser(
|
||||
{
|
||||
companyCode: "external",
|
||||
tenant: tenants[1],
|
||||
metadata: { hanmacFamily: true },
|
||||
},
|
||||
tenants,
|
||||
"hanmac-family-id",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("does not treat userType metadata as Hanmac family without tenant evidence", () => {
|
||||
const tenants = [
|
||||
{
|
||||
id: "hanmac-family-id",
|
||||
slug: "hanmac-family",
|
||||
name: "한맥가족",
|
||||
type: "COMPANY_GROUP",
|
||||
parentId: undefined,
|
||||
},
|
||||
{
|
||||
id: "external-id",
|
||||
slug: "external",
|
||||
name: "External",
|
||||
type: "COMPANY",
|
||||
parentId: undefined,
|
||||
},
|
||||
];
|
||||
|
||||
expect(
|
||||
isHanmacFamilyUser(
|
||||
{
|
||||
companyCode: "external",
|
||||
tenant: tenants[1],
|
||||
metadata: { userType: "hanmac" },
|
||||
},
|
||||
tenants,
|
||||
"hanmac-family-id",
|
||||
),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,10 +5,13 @@ export type OrgChartTenantSelection = {
|
||||
|
||||
export type TenantFilterTarget = {
|
||||
id?: string;
|
||||
tenantId?: string;
|
||||
slug?: string;
|
||||
tenantSlug?: string;
|
||||
type?: string;
|
||||
parentId?: string | null;
|
||||
name?: string;
|
||||
tenantName?: string;
|
||||
};
|
||||
|
||||
export type HanmacFamilyUserTarget = {
|
||||
@@ -120,19 +123,43 @@ export function isHanmacFamilyUser<T extends TenantFilterTarget>(
|
||||
tenants: T[],
|
||||
hanmacFamilyTenantId?: string,
|
||||
) {
|
||||
const metadata = user.metadata ?? {};
|
||||
if (metadata.hanmacFamily === true || metadata.userType === "hanmac") {
|
||||
return true;
|
||||
}
|
||||
|
||||
const metadataAppointments = Array.isArray(
|
||||
user.metadata?.additionalAppointments,
|
||||
)
|
||||
? user.metadata.additionalAppointments
|
||||
.map((appointment) => appointment as TenantFilterTarget)
|
||||
.filter(
|
||||
(appointment) =>
|
||||
typeof appointment.tenantId === "string" ||
|
||||
typeof appointment.id === "string" ||
|
||||
typeof appointment.tenantSlug === "string" ||
|
||||
typeof appointment.slug === "string",
|
||||
)
|
||||
.map((appointment) => ({
|
||||
id: appointment.id ?? appointment.tenantId,
|
||||
slug: appointment.slug ?? appointment.tenantSlug,
|
||||
parentId: appointment.parentId,
|
||||
type: appointment.type,
|
||||
name: appointment.name ?? appointment.tenantName,
|
||||
}))
|
||||
: [];
|
||||
const tenantBySlug = new Map(
|
||||
tenants
|
||||
.filter((tenant) => tenant.slug?.trim())
|
||||
.map((tenant) => [tenant.slug?.toLowerCase() as string, tenant]),
|
||||
);
|
||||
const tenantById = new Map(
|
||||
tenants
|
||||
.filter((tenant) => tenant.id?.trim())
|
||||
.map((tenant) => [tenant.id as string, tenant]),
|
||||
);
|
||||
const tenantCandidates = [
|
||||
user.tenant,
|
||||
...(user.joinedTenants ?? []),
|
||||
...metadataAppointments,
|
||||
...metadataAppointments.map((appointment) =>
|
||||
tenantById.get(appointment.id ?? ""),
|
||||
),
|
||||
tenantBySlug.get(user.companyCode?.toLowerCase() ?? ""),
|
||||
tenantBySlug.get(user.tenantSlug?.toLowerCase() ?? ""),
|
||||
];
|
||||
|
||||
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",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user