forked from baron/baron-sso
Merge remote-tracking branch 'origin/feature/df-claim-tenant' into dev
This commit is contained in:
@@ -10,7 +10,7 @@ const navigateMock = vi.fn();
|
||||
const fetchClientMock = vi.fn();
|
||||
const updateClientMock = vi.fn();
|
||||
const fetchClientRelationsMock = vi.fn();
|
||||
const fetchMyTenantsMock = vi.fn();
|
||||
const fetchTenantsMock = vi.fn();
|
||||
const fetchMeMock = vi.fn();
|
||||
|
||||
let authState = {
|
||||
@@ -45,7 +45,7 @@ vi.mock("../../lib/devApi", () => ({
|
||||
fetchClient: (...args: unknown[]) => fetchClientMock(...args),
|
||||
fetchClientRelations: (...args: unknown[]) =>
|
||||
fetchClientRelationsMock(...args),
|
||||
fetchMyTenants: (...args: unknown[]) => fetchMyTenantsMock(...args),
|
||||
fetchTenants: (...args: unknown[]) => fetchTenantsMock(...args),
|
||||
refreshHeadlessJwksCache: vi.fn(),
|
||||
revokeHeadlessJwksCache: vi.fn(),
|
||||
updateClient: (...args: unknown[]) => updateClientMock(...args),
|
||||
@@ -217,7 +217,12 @@ describe("ClientGeneralPage RP claims", () => {
|
||||
fetchClientMock.mockResolvedValue(makeClientDetail("old_claim"));
|
||||
updateClientMock.mockResolvedValue(makeClientDetail("new_claim"));
|
||||
fetchClientRelationsMock.mockResolvedValue({ items: [] });
|
||||
fetchMyTenantsMock.mockResolvedValue([]);
|
||||
fetchTenantsMock.mockResolvedValue({
|
||||
items: [],
|
||||
limit: 1000,
|
||||
offset: 0,
|
||||
total: 0,
|
||||
});
|
||||
fetchMeMock.mockResolvedValue({
|
||||
id: "admin-user",
|
||||
role: "super_admin",
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Info,
|
||||
Plus,
|
||||
Save,
|
||||
Search,
|
||||
Shield,
|
||||
ShieldHalf,
|
||||
Sparkles,
|
||||
@@ -27,16 +26,24 @@ import {
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "../../components/ui/card";
|
||||
import { CopyButton } from "../../components/ui/copy-button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { Label } from "../../components/ui/label";
|
||||
import { Switch } from "../../components/ui/switch";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "../../components/ui/table";
|
||||
import { Textarea } from "../../components/ui/textarea";
|
||||
import { toast } from "../../components/ui/use-toast";
|
||||
import type {
|
||||
ClientStatus,
|
||||
ClientType,
|
||||
ClientUpsertRequest,
|
||||
MyTenantSummary,
|
||||
TenantSummary,
|
||||
} from "../../lib/devApi";
|
||||
import {
|
||||
@@ -45,7 +52,7 @@ import {
|
||||
deleteClient,
|
||||
fetchClient,
|
||||
fetchClientRelations,
|
||||
fetchMyTenants,
|
||||
fetchTenants,
|
||||
refreshHeadlessJwksCache,
|
||||
revokeHeadlessJwksCache,
|
||||
updateClient,
|
||||
@@ -57,7 +64,7 @@ import { cn } from "../../lib/utils";
|
||||
import { fetchMe, type UserProfile } from "../auth/authApi";
|
||||
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
|
||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||
import { AllowedTenantBadge } from "./components/AllowedTenantBadge";
|
||||
import { TenantAccessPicker } from "./components/TenantAccessPicker";
|
||||
import {
|
||||
claimDateTimeValueToInputString,
|
||||
dateTimeInputToUnixSeconds,
|
||||
@@ -597,8 +604,8 @@ function ClientGeneralPage() {
|
||||
retry: false,
|
||||
});
|
||||
const { data: tenantData } = useQuery({
|
||||
queryKey: ["my-tenants"],
|
||||
queryFn: fetchMyTenants,
|
||||
queryKey: ["tenants", "all"],
|
||||
queryFn: () => fetchTenants(),
|
||||
});
|
||||
|
||||
const [name, setName] = useState("");
|
||||
@@ -622,8 +629,6 @@ function ClientGeneralPage() {
|
||||
] = useState(false);
|
||||
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
|
||||
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
||||
const [tenantSearch, setTenantSearch] = useState("");
|
||||
const [isTenantSearchOpen, setIsTenantSearchOpen] = useState(false);
|
||||
const [autoLoginSupported, setAutoLoginSupported] = useState(false);
|
||||
const [autoLoginUrl, setAutoLoginUrl] = useState("");
|
||||
|
||||
@@ -990,10 +995,6 @@ function ClientGeneralPage() {
|
||||
|
||||
const handleTenantAccessToggle = (enabled: boolean) => {
|
||||
setTenantAccessRestricted(enabled);
|
||||
setIsTenantSearchOpen(enabled);
|
||||
if (!enabled) {
|
||||
setTenantSearch("");
|
||||
}
|
||||
setScopes((current) => normalizeScopesForTenantAccess(current, enabled));
|
||||
};
|
||||
|
||||
@@ -1009,8 +1010,6 @@ function ClientGeneralPage() {
|
||||
setAllowedTenantIds((current) =>
|
||||
current.includes(tenantId) ? current : [...current, tenantId],
|
||||
);
|
||||
setTenantSearch("");
|
||||
setIsTenantSearchOpen(true);
|
||||
};
|
||||
|
||||
const addScope = () => {
|
||||
@@ -1270,25 +1269,10 @@ function ClientGeneralPage() {
|
||||
normalizedIdTokenClaimItems,
|
||||
);
|
||||
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
|
||||
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
|
||||
const tenantOptions: Array<TenantSummary | MyTenantSummary> =
|
||||
tenantData ?? [];
|
||||
const filteredTenants = tenantOptions.filter((tenant) => {
|
||||
if (!normalizedTenantSearch) {
|
||||
return true;
|
||||
}
|
||||
const searchable =
|
||||
`${tenant.name} ${tenant.slug} ${tenant.description ?? ""} ${tenant.type ?? ""}`.toLowerCase();
|
||||
return searchable.includes(normalizedTenantSearch);
|
||||
});
|
||||
const tenantSuggestions = filteredTenants
|
||||
.filter((tenant) => !allowedTenantIds.includes(tenant.id))
|
||||
.slice(0, 8);
|
||||
const tenantOptions: TenantSummary[] = tenantData?.items ?? [];
|
||||
const selectedAllowedTenants = allowedTenantIds
|
||||
.map((tenantId) => tenantOptions.find((item) => item.id === tenantId))
|
||||
.filter(
|
||||
(tenant): tenant is TenantSummary | MyTenantSummary => tenant != null,
|
||||
);
|
||||
.filter((tenant): tenant is TenantSummary => tenant != null);
|
||||
|
||||
const refreshHeadlessJwksCacheMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
@@ -2322,12 +2306,20 @@ function ClientGeneralPage() {
|
||||
"테넌트 접근 제한",
|
||||
)}
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.subtitle",
|
||||
"허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.",
|
||||
)}
|
||||
</CardDescription>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
<p className="leading-relaxed">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.subtitle",
|
||||
"허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.",
|
||||
)}
|
||||
</p>
|
||||
<p className="leading-relaxed">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.hint",
|
||||
"제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 rounded-xl border border-border bg-muted/30 px-4 py-3">
|
||||
<div className="space-y-0.5 text-right">
|
||||
@@ -2358,154 +2350,168 @@ function ClientGeneralPage() {
|
||||
</div>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-5">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.hint",
|
||||
"제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.",
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||
<div className="space-y-3">
|
||||
<Label htmlFor="tenant-search" className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그로 검색",
|
||||
)}
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
id="tenant-search"
|
||||
value={tenantSearch}
|
||||
onFocus={() => {
|
||||
if (tenantAccessRestricted) {
|
||||
setIsTenantSearchOpen(true);
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
window.setTimeout(() => setIsTenantSearchOpen(false), 120);
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setTenantSearch(e.target.value);
|
||||
if (tenantAccessRestricted) {
|
||||
setIsTenantSearchOpen(true);
|
||||
}
|
||||
}}
|
||||
placeholder={t(
|
||||
"ui.dev.clients.general.tenant_access.search_placeholder",
|
||||
"테넌트 이름 또는 슬러그로 검색",
|
||||
)}
|
||||
className="pl-10"
|
||||
disabled={
|
||||
isGeneralSettingsReadOnly || !tenantAccessRestricted
|
||||
<CardContent className="space-y-3">
|
||||
{tenantAccessRestricted ? (
|
||||
<div className="grid gap-4 lg:grid-cols-[0.8fr_1.2fr]">
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.picker_label",
|
||||
"허용 테넌트 추가",
|
||||
)}{" "}
|
||||
<span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<TenantAccessPicker
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
selectedCount={allowedTenantIds.length}
|
||||
onSelectTenant={(selection) =>
|
||||
handleSelectAllowedTenant(selection.id)
|
||||
}
|
||||
/>
|
||||
{tenantAccessRestricted && isTenantSearchOpen && (
|
||||
<div className="absolute z-20 mt-2 max-h-72 w-full overflow-y-auto rounded-xl border border-border bg-background shadow-lg">
|
||||
{tenantSuggestions.length > 0 ? (
|
||||
tenantSuggestions.map((tenant) => (
|
||||
<button
|
||||
key={tenant.id}
|
||||
type="button"
|
||||
className="flex w-full items-start justify-between gap-3 border-b border-border/40 px-4 py-3 text-left transition hover:bg-muted/40 last:border-b-0"
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault();
|
||||
handleSelectAllowedTenant(tenant.id);
|
||||
}}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<div className="min-w-0 space-y-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate font-medium">
|
||||
{tenant.name}
|
||||
</span>
|
||||
<Badge variant="outline" className="text-[11px]">
|
||||
{tenant.slug}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{tenant.description || tenant.type}
|
||||
</p>
|
||||
</div>
|
||||
<Plus className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
</button>
|
||||
))
|
||||
) : (
|
||||
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.empty",
|
||||
"검색 결과가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
{tenantAccessRestricted
|
||||
? t(
|
||||
"ui.dev.clients.general.tenant_access.autocomplete_hint",
|
||||
"테넌트 이름을 입력하면 자동 완성 후보가 나타납니다. 클릭하면 허용 목록에 추가됩니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.dev.clients.general.tenant_access.disabled",
|
||||
"제한 없음",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.selected_title",
|
||||
"허용 테넌트",
|
||||
)}
|
||||
</Label>
|
||||
<div className="min-h-72 rounded-xl border border-border bg-muted/20 p-3">
|
||||
{tenantAccessRestricted && allowedTenantIds.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedAllowedTenants.map((tenant) => (
|
||||
<AllowedTenantBadge
|
||||
key={tenant.id}
|
||||
tenant={tenant}
|
||||
onRemove={() => toggleAllowedTenant(tenant.id)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
))}
|
||||
{allowedTenantIds
|
||||
.filter(
|
||||
(tenantId) =>
|
||||
!selectedAllowedTenants.some(
|
||||
(tenant) => tenant.id === tenantId,
|
||||
),
|
||||
)
|
||||
.map((tenantId) => (
|
||||
<AllowedTenantBadge
|
||||
key={tenantId}
|
||||
tenant={{ id: tenantId, name: tenantId }}
|
||||
onRemove={() => toggleAllowedTenant(tenantId)}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex h-full min-h-64 items-center justify-center text-sm text-muted-foreground">
|
||||
{tenantAccessRestricted
|
||||
? t(
|
||||
"ui.dev.clients.general.tenant_access.selected_empty",
|
||||
"아직 선택된 테넌트가 없습니다.",
|
||||
)
|
||||
: t(
|
||||
"ui.dev.clients.general.tenant_access.disabled",
|
||||
"제한 없음",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-sm font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.selected_title",
|
||||
"허용 테넌트",
|
||||
)}
|
||||
</Label>
|
||||
<div className="overflow-hidden rounded-md border border-border bg-background">
|
||||
{allowedTenantIds.length > 0 ? (
|
||||
<div className="max-h-80 overflow-auto">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 z-10 bg-muted/50">
|
||||
<TableRow>
|
||||
<TableHead className="w-[34%] px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.table.name",
|
||||
"테넌트명",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead className="w-[18%] px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.table.slug",
|
||||
"슬러그",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead className="px-4 py-3 text-left font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.table.id",
|
||||
"테넌트 ID",
|
||||
)}
|
||||
</TableHead>
|
||||
<TableHead className="w-[112px] px-4 py-3 text-right font-bold">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.table.actions",
|
||||
"작업",
|
||||
)}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{selectedAllowedTenants.map((tenant) => (
|
||||
<TableRow
|
||||
key={tenant.id}
|
||||
data-testid={`allowed-tenant-${tenant.id}`}
|
||||
>
|
||||
<TableCell className="px-4 py-3 font-medium">
|
||||
{tenant.name}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-muted-foreground">
|
||||
{tenant.slug || "-"}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||
<span className="break-all">{tenant.id}</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<CopyButton
|
||||
aria-label="테넌트 UUID 복사"
|
||||
className="h-8 w-8"
|
||||
data-testid={`allowed-tenant-copy-${tenant.id}`}
|
||||
size="icon"
|
||||
value={tenant.id}
|
||||
variant="ghost"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("ui.common.delete", "삭제")}
|
||||
onClick={() =>
|
||||
toggleAllowedTenant(tenant.id)
|
||||
}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition hover:text-destructive"
|
||||
data-testid={`allowed-tenant-remove-${tenant.id}`}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{allowedTenantIds
|
||||
.filter(
|
||||
(tenantId) =>
|
||||
!selectedAllowedTenants.some(
|
||||
(tenant) => tenant.id === tenantId,
|
||||
),
|
||||
)
|
||||
.map((tenantId) => (
|
||||
<TableRow
|
||||
key={tenantId}
|
||||
data-testid={`allowed-tenant-${tenantId}`}
|
||||
>
|
||||
<TableCell className="px-4 py-3 font-medium">
|
||||
{tenantId}
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-muted-foreground">
|
||||
-
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 font-mono text-xs text-muted-foreground">
|
||||
<span className="break-all">{tenantId}</span>
|
||||
</TableCell>
|
||||
<TableCell className="px-4 py-3 text-right">
|
||||
<div className="inline-flex items-center gap-1.5">
|
||||
<CopyButton
|
||||
aria-label="테넌트 UUID 복사"
|
||||
className="h-8 w-8"
|
||||
data-testid={`allowed-tenant-copy-${tenantId}`}
|
||||
size="icon"
|
||||
value={tenantId}
|
||||
variant="ghost"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("ui.common.delete", "삭제")}
|
||||
onClick={() =>
|
||||
toggleAllowedTenant(tenantId)
|
||||
}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition hover:text-destructive"
|
||||
data-testid={`allowed-tenant-remove-${tenantId}`}
|
||||
disabled={isGeneralSettingsReadOnly}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[99px] items-center px-4 py-3 text-sm text-muted-foreground">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.selected_empty",
|
||||
"아직 선택된 테넌트가 없습니다.",
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
|
||||
146
devfront/src/features/clients/components/TenantAccessPicker.tsx
Normal file
146
devfront/src/features/clients/components/TenantAccessPicker.tsx
Normal file
@@ -0,0 +1,146 @@
|
||||
import { Building2 } from "lucide-react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { Button } from "../../../components/ui/button";
|
||||
import { t } from "../../../lib/i18n";
|
||||
import {
|
||||
buildAuthenticatedOrgChartTenantPickerUrl,
|
||||
type OrgChartTenantSelection,
|
||||
parseOrgChartTenantSelection,
|
||||
} from "../orgChartPicker";
|
||||
|
||||
type TenantAccessPickerProps = {
|
||||
disabled?: boolean;
|
||||
selectedCount: number;
|
||||
onSelectTenant: (selection: OrgChartTenantSelection) => void;
|
||||
};
|
||||
|
||||
function resolveOrgFrontBaseUrl() {
|
||||
return (
|
||||
import.meta.env.VITE_ORGFRONT_PUBLIC_URL ||
|
||||
import.meta.env.ORGFRONT_URL ||
|
||||
"http://localhost:5175"
|
||||
);
|
||||
}
|
||||
|
||||
export function TenantAccessPicker({
|
||||
disabled = false,
|
||||
selectedCount,
|
||||
onSelectTenant,
|
||||
}: TenantAccessPickerProps) {
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const pickerUrl = useMemo(
|
||||
() => buildAuthenticatedOrgChartTenantPickerUrl(resolveOrgFrontBaseUrl()),
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickerOpen) return;
|
||||
|
||||
const onMessage = (event: MessageEvent) => {
|
||||
const selection = parseOrgChartTenantSelection(event.data);
|
||||
if (!selection) return;
|
||||
onSelectTenant(selection);
|
||||
setPickerOpen(false);
|
||||
};
|
||||
|
||||
window.addEventListener("message", onMessage);
|
||||
return () => window.removeEventListener("message", onMessage);
|
||||
}, [onSelectTenant, pickerOpen]);
|
||||
|
||||
const pickerDialog =
|
||||
pickerOpen && typeof document !== "undefined"
|
||||
? createPortal(
|
||||
<div
|
||||
className="fixed inset-0 z-[1000] flex items-start justify-center overflow-y-auto bg-black/50 p-4 md:items-center"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t(
|
||||
"ui.dev.clients.general.tenant_access.picker_title",
|
||||
"테넌트 선택",
|
||||
)}
|
||||
>
|
||||
<div className="flex h-[92vh] w-[min(96vw,1200px)] flex-col overflow-hidden rounded-2xl border border-border bg-background p-4 shadow-2xl">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-1">
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.picker_title",
|
||||
"테넌트 선택",
|
||||
)}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t(
|
||||
"msg.dev.clients.general.tenant_access.picker_description",
|
||||
"orgfront 조직도에서 허용할 테넌트를 선택하면 목록에 추가됩니다.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="shrink-0"
|
||||
onClick={() => setPickerOpen(false)}
|
||||
>
|
||||
{t("ui.common.close", "닫기")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="mt-4 min-h-0 flex-1 overflow-hidden rounded-md border">
|
||||
<iframe
|
||||
title={t(
|
||||
"ui.dev.clients.general.tenant_access.picker_title",
|
||||
"테넌트 선택",
|
||||
)}
|
||||
src={pickerUrl}
|
||||
className="h-full w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-4 flex shrink-0 justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => setPickerOpen(false)}
|
||||
>
|
||||
{t("ui.common.close", "닫기")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="h-10 gap-2"
|
||||
disabled={disabled}
|
||||
onClick={() => setPickerOpen(true)}
|
||||
>
|
||||
<Building2 className="h-4 w-4" />
|
||||
{t(
|
||||
"ui.dev.clients.general.tenant_access.open_picker",
|
||||
"테넌트 선택기 열기",
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{pickerDialog}
|
||||
|
||||
<div className="rounded-xl border border-dashed border-border bg-muted/20 px-4 py-3 text-sm text-muted-foreground">
|
||||
{selectedCount > 0
|
||||
? t(
|
||||
"msg.dev.clients.general.tenant_access.picker_hint_with_count",
|
||||
"현재 {{count}}개가 선택되어 있습니다.",
|
||||
{ count: selectedCount },
|
||||
)
|
||||
: t(
|
||||
"msg.dev.clients.general.tenant_access.picker_hint",
|
||||
"선택기를 열어 허용 테넌트를 추가하세요.",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
91
devfront/src/features/clients/orgChartPicker.ts
Normal file
91
devfront/src/features/clients/orgChartPicker.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
export type OrgChartTenantSelection = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type OrgChartPickerMessage = {
|
||||
type?: unknown;
|
||||
payload?: {
|
||||
selections?: Array<{
|
||||
type?: unknown;
|
||||
id?: unknown;
|
||||
name?: unknown;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
type OrgChartTenantPickerOptions = {
|
||||
includeInternal?: boolean;
|
||||
tenantId?: string;
|
||||
};
|
||||
|
||||
function buildAuthenticatedOrgChartUrl(
|
||||
baseUrl?: string,
|
||||
options: { includeInternal?: boolean; returnTo?: string } = {},
|
||||
) {
|
||||
const normalizedBase = (baseUrl ?? "").replace(/\/+$/, "");
|
||||
let returnTo = options.returnTo?.trim() || "/chart";
|
||||
if (options.includeInternal && returnTo.startsWith("/chart")) {
|
||||
const [path, query = ""] = returnTo.split("?", 2);
|
||||
const params = new URLSearchParams(query);
|
||||
params.set("includeInternal", "true");
|
||||
returnTo = `${path}?${params.toString()}`;
|
||||
}
|
||||
const params = new URLSearchParams({
|
||||
auto: "1",
|
||||
returnTo,
|
||||
});
|
||||
|
||||
return `${normalizedBase}/login?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function buildAuthenticatedOrgChartTenantPickerUrl(
|
||||
baseUrl?: string,
|
||||
options: OrgChartTenantPickerOptions = {},
|
||||
) {
|
||||
const normalizedBase = (baseUrl ?? "").trim() || "http://localhost:5175";
|
||||
const params = new URLSearchParams({
|
||||
mode: "single",
|
||||
select: "tenant",
|
||||
width: "400",
|
||||
height: "600",
|
||||
});
|
||||
|
||||
const tenantId = options.tenantId?.trim();
|
||||
if (tenantId) {
|
||||
params.set("tenantId", tenantId);
|
||||
}
|
||||
if (options.includeInternal) {
|
||||
params.set("includeInternal", "true");
|
||||
}
|
||||
|
||||
const pickerUrl = `/embed/picker?${params.toString()}`;
|
||||
return buildAuthenticatedOrgChartUrl(normalizedBase, {
|
||||
includeInternal: true,
|
||||
returnTo: pickerUrl,
|
||||
});
|
||||
}
|
||||
|
||||
export function parseOrgChartTenantSelection(
|
||||
message: unknown,
|
||||
): OrgChartTenantSelection | null {
|
||||
const data = message as OrgChartPickerMessage;
|
||||
if (data?.type !== "orgfront:picker:confirm") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const selection = data.payload?.selections?.[0];
|
||||
if (
|
||||
selection?.type !== "tenant" ||
|
||||
typeof selection.id !== "string" ||
|
||||
typeof selection.name !== "string" ||
|
||||
selection.id.trim() === ""
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: selection.id,
|
||||
name: selection.name,
|
||||
};
|
||||
}
|
||||
@@ -65,8 +65,14 @@ describe("devApi", () => {
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/rp-usage/daily", {
|
||||
params: { days: 30, period: "week" },
|
||||
});
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/tenants", {
|
||||
params: { limit: 25, offset: 50, parentId: "tenant-parent" },
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/admin/tenants", {
|
||||
params: {
|
||||
limit: 25,
|
||||
offset: 50,
|
||||
parentId: "tenant-parent",
|
||||
cursor: undefined,
|
||||
search: undefined,
|
||||
},
|
||||
});
|
||||
expect(apiClient.get).toHaveBeenCalledWith("/dev/clients/client-a");
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
|
||||
@@ -283,9 +283,11 @@ export async function fetchTenants(
|
||||
limit = 1000,
|
||||
offset = 0,
|
||||
parentId?: string,
|
||||
cursor?: string,
|
||||
search?: string,
|
||||
) {
|
||||
const { data } = await apiClient.get<TenantListResponse>("/tenants", {
|
||||
params: { limit, offset, parentId },
|
||||
const { data } = await apiClient.get<TenantListResponse>("/admin/tenants", {
|
||||
params: { limit, offset, parentId, cursor, search },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -65,6 +65,15 @@ test.describe("DevFront client tenant access settings", () => {
|
||||
],
|
||||
consents: [] as Consent[],
|
||||
auditLogsByCursor: undefined,
|
||||
myTenants: [
|
||||
{
|
||||
id: existingTenantId,
|
||||
name: "Alpha Tenant",
|
||||
slug: "alpha",
|
||||
description: "Existing allowed tenant",
|
||||
type: "organization",
|
||||
},
|
||||
],
|
||||
tenants: [
|
||||
{
|
||||
id: existingTenantId,
|
||||
@@ -99,10 +108,27 @@ test.describe("DevFront client tenant access settings", () => {
|
||||
)
|
||||
.toBe(existingTenantId);
|
||||
|
||||
await page
|
||||
.getByPlaceholder(/테넌트 이름 또는 슬러그로 검색|tenant name or slug/i)
|
||||
.fill("beta");
|
||||
await page.getByRole("button", { name: /Beta Tenant/i }).click();
|
||||
await page.getByRole("button", { name: /테넌트 선택기 열기/i }).click();
|
||||
await page.evaluate(
|
||||
(selection) => {
|
||||
window.postMessage(
|
||||
{
|
||||
type: "orgfront:picker:confirm",
|
||||
payload: {
|
||||
selections: [
|
||||
{
|
||||
type: "tenant",
|
||||
id: selection.id,
|
||||
name: selection.name,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
"*",
|
||||
);
|
||||
},
|
||||
{ id: addedTenantId, name: "Beta Tenant" },
|
||||
);
|
||||
await expect(
|
||||
page.getByTestId(`allowed-tenant-${addedTenantId}`),
|
||||
).toContainText(addedTenantId);
|
||||
|
||||
@@ -132,6 +132,7 @@ export type DevApiMockState = {
|
||||
relations?: Record<string, ClientRelation[]>;
|
||||
users?: DevAssignableUser[];
|
||||
tenants?: DevTenantSummary[];
|
||||
myTenants?: DevTenantSummary[];
|
||||
auditLogsByCursor?: Record<
|
||||
string,
|
||||
{ items: AuditLog[]; next_cursor?: string }
|
||||
@@ -437,6 +438,33 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/v1/admin/tenants**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
const { searchParams } = url;
|
||||
const tenants = state.tenants ?? [
|
||||
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
|
||||
];
|
||||
|
||||
return json(route, {
|
||||
items: tenants.map((tenant) => ({
|
||||
id: tenant.id,
|
||||
name: tenant.name,
|
||||
slug: tenant.slug,
|
||||
description: tenant.description ?? "",
|
||||
type: tenant.type ?? "organization",
|
||||
parentId: null,
|
||||
status: "active",
|
||||
memberCount: 0,
|
||||
createdAt: "2026-03-03T00:00:00.000Z",
|
||||
updatedAt: "2026-03-03T00:00:00.000Z",
|
||||
})),
|
||||
limit: Number.parseInt(searchParams.get("limit") || "1000", 10),
|
||||
offset: Number.parseInt(searchParams.get("offset") || "0", 10),
|
||||
total: tenants.length,
|
||||
});
|
||||
});
|
||||
|
||||
await page.route("**/api/v1/dev/**", async (route) => {
|
||||
const request = route.request();
|
||||
const url = new URL(request.url());
|
||||
@@ -534,9 +562,10 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
|
||||
if (pathname === "/api/v1/dev/my-tenants" && method === "GET") {
|
||||
return json(
|
||||
route,
|
||||
state.tenants ?? [
|
||||
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
|
||||
],
|
||||
state.myTenants ??
|
||||
state.tenants ?? [
|
||||
{ id: "tenant-a", name: "Tenant A", slug: "tenant-a" },
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user