forked from baron/baron-sso
RP 테넌트 제한을 orgfront picker로 전환
This commit is contained in:
@@ -5,7 +5,6 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
Plus,
|
Plus,
|
||||||
Save,
|
Save,
|
||||||
Search,
|
|
||||||
Shield,
|
Shield,
|
||||||
ShieldHalf,
|
ShieldHalf,
|
||||||
Sparkles,
|
Sparkles,
|
||||||
@@ -36,7 +35,6 @@ import type {
|
|||||||
ClientStatus,
|
ClientStatus,
|
||||||
ClientType,
|
ClientType,
|
||||||
ClientUpsertRequest,
|
ClientUpsertRequest,
|
||||||
MyTenantSummary,
|
|
||||||
TenantSummary,
|
TenantSummary,
|
||||||
} from "../../lib/devApi";
|
} from "../../lib/devApi";
|
||||||
import {
|
import {
|
||||||
@@ -45,7 +43,7 @@ import {
|
|||||||
deleteClient,
|
deleteClient,
|
||||||
fetchClient,
|
fetchClient,
|
||||||
fetchClientRelations,
|
fetchClientRelations,
|
||||||
fetchMyTenants,
|
fetchTenants,
|
||||||
refreshHeadlessJwksCache,
|
refreshHeadlessJwksCache,
|
||||||
revokeHeadlessJwksCache,
|
revokeHeadlessJwksCache,
|
||||||
updateClient,
|
updateClient,
|
||||||
@@ -58,6 +56,7 @@ import { fetchMe, type UserProfile } from "../auth/authApi";
|
|||||||
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
|
import { useDeveloperAccessGate } from "../developer-access/developerAccessGate";
|
||||||
import { ClientDetailTabs } from "./ClientDetailTabs";
|
import { ClientDetailTabs } from "./ClientDetailTabs";
|
||||||
import { AllowedTenantBadge } from "./components/AllowedTenantBadge";
|
import { AllowedTenantBadge } from "./components/AllowedTenantBadge";
|
||||||
|
import { TenantAccessPicker } from "./components/TenantAccessPicker";
|
||||||
import {
|
import {
|
||||||
claimDateTimeValueToInputString,
|
claimDateTimeValueToInputString,
|
||||||
dateTimeInputToUnixSeconds,
|
dateTimeInputToUnixSeconds,
|
||||||
@@ -597,8 +596,8 @@ function ClientGeneralPage() {
|
|||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
const { data: tenantData } = useQuery({
|
const { data: tenantData } = useQuery({
|
||||||
queryKey: ["my-tenants"],
|
queryKey: ["tenants", "all"],
|
||||||
queryFn: fetchMyTenants,
|
queryFn: () => fetchTenants(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
@@ -622,8 +621,6 @@ function ClientGeneralPage() {
|
|||||||
] = useState(false);
|
] = useState(false);
|
||||||
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
|
const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false);
|
||||||
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
const [allowedTenantIds, setAllowedTenantIds] = useState<string[]>([]);
|
||||||
const [tenantSearch, setTenantSearch] = useState("");
|
|
||||||
const [isTenantSearchOpen, setIsTenantSearchOpen] = useState(false);
|
|
||||||
const [autoLoginSupported, setAutoLoginSupported] = useState(false);
|
const [autoLoginSupported, setAutoLoginSupported] = useState(false);
|
||||||
const [autoLoginUrl, setAutoLoginUrl] = useState("");
|
const [autoLoginUrl, setAutoLoginUrl] = useState("");
|
||||||
|
|
||||||
@@ -990,10 +987,6 @@ function ClientGeneralPage() {
|
|||||||
|
|
||||||
const handleTenantAccessToggle = (enabled: boolean) => {
|
const handleTenantAccessToggle = (enabled: boolean) => {
|
||||||
setTenantAccessRestricted(enabled);
|
setTenantAccessRestricted(enabled);
|
||||||
setIsTenantSearchOpen(enabled);
|
|
||||||
if (!enabled) {
|
|
||||||
setTenantSearch("");
|
|
||||||
}
|
|
||||||
setScopes((current) => normalizeScopesForTenantAccess(current, enabled));
|
setScopes((current) => normalizeScopesForTenantAccess(current, enabled));
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1009,8 +1002,6 @@ function ClientGeneralPage() {
|
|||||||
setAllowedTenantIds((current) =>
|
setAllowedTenantIds((current) =>
|
||||||
current.includes(tenantId) ? current : [...current, tenantId],
|
current.includes(tenantId) ? current : [...current, tenantId],
|
||||||
);
|
);
|
||||||
setTenantSearch("");
|
|
||||||
setIsTenantSearchOpen(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const addScope = () => {
|
const addScope = () => {
|
||||||
@@ -1270,25 +1261,10 @@ function ClientGeneralPage() {
|
|||||||
normalizedIdTokenClaimItems,
|
normalizedIdTokenClaimItems,
|
||||||
);
|
);
|
||||||
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
|
const idTokenClaimPreviewJson = JSON.stringify(idTokenClaimPreview, null, 2);
|
||||||
const normalizedTenantSearch = tenantSearch.trim().toLowerCase();
|
const tenantOptions: TenantSummary[] = tenantData?.items ?? [];
|
||||||
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 selectedAllowedTenants = allowedTenantIds
|
const selectedAllowedTenants = allowedTenantIds
|
||||||
.map((tenantId) => tenantOptions.find((item) => item.id === tenantId))
|
.map((tenantId) => tenantOptions.find((item) => item.id === tenantId))
|
||||||
.filter(
|
.filter((tenant): tenant is TenantSummary => tenant != null);
|
||||||
(tenant): tenant is TenantSummary | MyTenantSummary => tenant != null,
|
|
||||||
);
|
|
||||||
|
|
||||||
const refreshHeadlessJwksCacheMutation = useMutation({
|
const refreshHeadlessJwksCacheMutation = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
@@ -2368,92 +2344,19 @@ function ClientGeneralPage() {
|
|||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
<div className="grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label htmlFor="tenant-search" className="text-sm font-semibold">
|
<Label className="text-sm font-semibold">
|
||||||
{t(
|
{t(
|
||||||
"ui.dev.clients.general.tenant_access.search_placeholder",
|
"ui.dev.clients.general.tenant_access.picker_label",
|
||||||
"테넌트 이름 또는 슬러그로 검색",
|
"허용 테넌트 추가",
|
||||||
)}
|
)}
|
||||||
</Label>
|
</Label>
|
||||||
<div className="relative">
|
<TenantAccessPicker
|
||||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
disabled={isGeneralSettingsReadOnly || !tenantAccessRestricted}
|
||||||
<Input
|
selectedCount={allowedTenantIds.length}
|
||||||
id="tenant-search"
|
onSelectTenant={(selection) =>
|
||||||
value={tenantSearch}
|
handleSelectAllowedTenant(selection.id)
|
||||||
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
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
{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>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user