From f97b244a59e7bc4233971d3306ad6e1f11517c84 Mon Sep 17 00:00:00 2001 From: kyy Date: Fri, 24 Apr 2026 16:32:34 +0900 Subject: [PATCH] =?UTF-8?q?RP=20=EC=A0=95=EC=B1=85=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?UI=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/clients/ClientGeneralPage.tsx | 346 +++++++++++++++++- devfront/src/lib/devApi.ts | 37 +- devfront/src/locales/en.toml | 14 + devfront/src/locales/ko.toml | 14 + devfront/src/locales/template.toml | 14 + 5 files changed, 408 insertions(+), 17 deletions(-) diff --git a/devfront/src/features/clients/ClientGeneralPage.tsx b/devfront/src/features/clients/ClientGeneralPage.tsx index 152480e2..72f36f28 100644 --- a/devfront/src/features/clients/ClientGeneralPage.tsx +++ b/devfront/src/features/clients/ClientGeneralPage.tsx @@ -24,6 +24,7 @@ import { } from "../../components/ui/card"; import { Input } from "../../components/ui/input"; import { Label } from "../../components/ui/label"; +import { ScrollArea } from "../../components/ui/scroll-area"; import { Switch } from "../../components/ui/switch"; import { Textarea } from "../../components/ui/textarea"; import { toast } from "../../components/ui/use-toast"; @@ -31,6 +32,7 @@ import { createClient, deleteClient, fetchClient, + fetchTenants, refreshHeadlessJwksCache, revokeHeadlessJwksCache, updateClient, @@ -40,6 +42,7 @@ import type { ClientStatus, ClientType, ClientUpsertRequest, + TenantSummary, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; import { cn } from "../../lib/utils"; @@ -50,6 +53,7 @@ interface ScopeItem { name: string; description: string; mandatory: boolean; + locked?: boolean; } type SecurityProfile = "private" | "pkce"; @@ -131,6 +135,10 @@ function ClientGeneralPage() { queryFn: () => fetchClient(clientId as string), enabled: !isCreate, }); + const { data: tenantData } = useQuery({ + queryKey: ["tenants"], + queryFn: () => fetchTenants(1000, 0), + }); const [name, setName] = useState(""); const [description, setDescription] = useState(""); @@ -142,6 +150,9 @@ function ClientGeneralPage() { const [status, setStatus] = useState("active"); const [initialStatus, setInitialStatus] = useState("active"); const [redirectUris, setRedirectUris] = useState(""); + const [tenantAccessRestricted, setTenantAccessRestricted] = useState(false); + const [allowedTenantIds, setAllowedTenantIds] = useState([]); + const [tenantSearch, setTenantSearch] = useState(""); // Public Key Registration States const [tokenEndpointAuthMethod, setTokenEndpointAuthMethod] = @@ -168,6 +179,15 @@ function ClientGeneralPage() { description: t("msg.dev.clients.scopes.email", "이메일 주소 접근"), mandatory: false, }, + { + id: "4", + name: "tenant", + description: t( + "msg.dev.clients.scopes.tenant", + "소속 테넌트 정보 접근", + ), + mandatory: false, + }, ]); useEffect(() => { @@ -185,6 +205,16 @@ function ClientGeneralPage() { const headlessEnabled = !!metadata.headless_login_enabled; setHeadlessLoginEnabled(headlessEnabled); + const restrictedTenants = Array.isArray(metadata.allowed_tenants) + ? metadata.allowed_tenants + .map((value) => (typeof value === "string" ? value.trim() : "")) + .filter(Boolean) + : []; + setTenantAccessRestricted( + restrictedTenants.length > 0 || + metadata.tenant_access_restricted === true, + ); + setAllowedTenantIds(restrictedTenants); const savedAuthMethod = client.tokenEndpointAuthMethod || @@ -230,15 +260,25 @@ function ClientGeneralPage() { const savedScopes = metadata.structured_scopes as ScopeItem[] | undefined; if (savedScopes && Array.isArray(savedScopes)) { - setScopes(savedScopes); + setScopes( + normalizeScopesForTenantAccess( + savedScopes, + restrictedTenants.length > 0 || + metadata.tenant_access_restricted === true, + ), + ); } else { setScopes( - client.scopes.map((s, idx) => ({ - id: String(idx + 1), - name: s, - description: "", - mandatory: s === "openid", - })), + normalizeScopesForTenantAccess( + client.scopes.map((s, idx) => ({ + id: String(idx + 1), + name: s, + description: "", + mandatory: s === "openid", + })), + restrictedTenants.length > 0 || + metadata.tenant_access_restricted === true, + ), ); } }, [data]); @@ -279,6 +319,55 @@ function ClientGeneralPage() { } }; + const tenantScopeDescription = t( + "msg.dev.clients.scopes.tenant", + "소속 테넌트 정보 접근", + ); + + const buildTenantScope = (id: string): ScopeItem => ({ + id, + name: "tenant", + description: tenantScopeDescription, + mandatory: true, + locked: true, + }); + + function normalizeScopesForTenantAccess( + nextScopes: ScopeItem[], + restricted: boolean, + ): ScopeItem[] { + const normalized = nextScopes.map((scope) => { + if (scope.name.trim() !== "tenant") { + return scope; + } + return { + ...scope, + description: scope.description || tenantScopeDescription, + mandatory: restricted ? true : false, + locked: restricted, + }; + }); + + if (restricted && !normalized.some((scope) => scope.name.trim() === "tenant")) { + normalized.push(buildTenantScope(`tenant-${Date.now()}`)); + } + + return normalized; + } + + const handleTenantAccessToggle = (enabled: boolean) => { + setTenantAccessRestricted(enabled); + setScopes((current) => normalizeScopesForTenantAccess(current, enabled)); + }; + + const toggleAllowedTenant = (tenantId: string) => { + setAllowedTenantIds((current) => + current.includes(tenantId) + ? current.filter((id) => id !== tenantId) + : [...current, tenantId], + ); + }; + const addScope = () => { const newId = String(Date.now()); setScopes([ @@ -292,15 +381,23 @@ function ClientGeneralPage() { field: K, value: ScopeItem[K], ) => { - setScopes( - scopes.map((scope) => - scope.id === id ? { ...scope, [field]: value } : scope, - ), + setScopes((current) => + current.map((scope) => { + if (scope.id !== id) { + return scope; + } + if (scope.locked) { + return scope; + } + return { ...scope, [field]: value }; + }), ); }; const removeScope = (id: string) => { - setScopes(scopes.filter((s) => s.id !== id)); + setScopes((current) => + current.filter((scope) => scope.id !== id || scope.locked === true), + ); }; const handleStatusChange = (nextStatus: ClientStatus) => { @@ -391,7 +488,25 @@ function ClientGeneralPage() { } } + if (tenantAccessRestricted && allowedTenantIds.length === 0) { + validationErrors.push( + t( + "ui.dev.clients.general.tenant_access.validation_required", + "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다.", + ), + ); + } + const hasValidationErrors = validationErrors.length > 0; + const normalizedTenantSearch = tenantSearch.trim().toLowerCase(); + const tenantOptions: TenantSummary[] = tenantData?.items ?? []; + 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 refreshHeadlessJwksCacheMutation = useMutation({ mutationFn: async () => { @@ -467,7 +582,16 @@ function ClientGeneralPage() { ); } - const scopeNames = scopes.map((scope) => scope.name).filter(Boolean); + const normalizedScopes = normalizeScopesForTenantAccess( + scopes, + tenantAccessRestricted, + ); + const normalizedAllowedTenantIds = Array.from( + new Set(allowedTenantIds.map((id) => id.trim()).filter(Boolean)), + ); + const scopeNames = normalizedScopes + .map((scope) => scope.name.trim()) + .filter(Boolean); const effectiveTokenEndpointAuthMethod = clientType === "pkce" && headlessLoginEnabled @@ -487,7 +611,7 @@ function ClientGeneralPage() { metadata: { description, logo_url: trimmedLogoUrl, - structured_scopes: scopes, + structured_scopes: normalizedScopes, token_endpoint_auth_method: effectiveTokenEndpointAuthMethod, headless_login_enabled: headlessLoginEnabled, headless_token_endpoint_auth_method: @@ -498,6 +622,10 @@ function ClientGeneralPage() { clientType === "pkce" && headlessLoginEnabled ? trimmedJwksUri : undefined, + tenant_access_restricted: tenantAccessRestricted, + allowed_tenants: tenantAccessRestricted + ? normalizedAllowedTenantIds + : [], }, }; @@ -972,7 +1100,10 @@ function ClientGeneralPage() { {scopes.map((s) => ( @@ -998,6 +1130,7 @@ function ClientGeneralPage() { "ui.dev.clients.general.scopes.description_placeholder", "권한에 대한 설명", )} + disabled={s.locked} /> @@ -1007,6 +1140,7 @@ function ClientGeneralPage() { onCheckedChange={(checked) => updateScope(s.id, "mandatory", checked) } + disabled={s.locked} /> @@ -1016,6 +1150,7 @@ function ClientGeneralPage() { size="icon" onClick={() => removeScope(s.id)} className="h-8 w-8 text-muted-foreground hover:text-destructive" + disabled={s.locked} > @@ -1041,6 +1176,187 @@ function ClientGeneralPage() { + + +
+
+ + {t( + "ui.dev.clients.general.tenant_access.title", + "테넌트 접근 제한", + )} + + + {t( + "ui.dev.clients.general.tenant_access.subtitle", + "허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다.", + )} + +
+
+
+

+ {tenantAccessRestricted + ? t( + "ui.dev.clients.general.tenant_access.enabled", + "제한 있음", + ) + : t( + "ui.dev.clients.general.tenant_access.disabled", + "제한 없음", + )} +

+

+ {t( + "ui.dev.clients.general.tenant_access.title", + "테넌트 접근 제한", + )} +

+
+ +
+
+
+ +

+ {t( + "ui.dev.clients.general.tenant_access.hint", + "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다.", + )} +

+ +
+
+ + setTenantSearch(e.target.value)} + placeholder={t( + "ui.dev.clients.general.tenant_access.search_placeholder", + "테넌트 이름 또는 슬러그로 검색", + )} + disabled={!tenantAccessRestricted} + /> + +
+ {tenantAccessRestricted ? ( + filteredTenants.length > 0 ? ( + filteredTenants.map((tenant) => { + const checked = allowedTenantIds.includes(tenant.id); + return ( + + ); + }) + ) : ( +
+ {t( + "ui.dev.clients.general.tenant_access.empty", + "검색 결과가 없습니다.", + )} +
+ ) + ) : ( +
+ {t( + "ui.dev.clients.general.tenant_access.disabled", + "제한 없음", + )} +
+ )} +
+
+
+ +
+ +
+ {tenantAccessRestricted && allowedTenantIds.length > 0 ? ( +
+ {allowedTenantIds.map((tenantId) => { + const tenant = tenantData?.items.find( + (item) => item.id === tenantId, + ); + return ( + + + {tenant?.name || tenantId} + + + + ); + })} +
+ ) : ( +
+ {tenantAccessRestricted + ? t( + "ui.dev.clients.general.tenant_access.selected_empty", + "아직 선택된 테넌트가 없습니다.", + ) + : t( + "ui.dev.clients.general.tenant_access.disabled", + "제한 없음", + )} +
+ )} +
+
+
+
+
+ {/* 3. Security Settings */} diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index 2935c2ba..c31cba26 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -23,6 +23,28 @@ export type ClientListResponse = { offset: number; }; +export type TenantSummary = { + id: string; + type: string; + parentId?: string | null; + name: string; + slug: string; + description: string; + status: string; + domains?: string[]; + config?: Record; + memberCount: number; + createdAt: string; + updatedAt: string; +}; + +export type TenantListResponse = { + items: TenantSummary[]; + limit: number; + offset: number; + total: number; +}; + export type DevStats = { total_clients: number; active_sessions: number; @@ -188,6 +210,17 @@ export async function fetchDevStats() { return data; } +export async function fetchTenants( + limit = 1000, + offset = 0, + parentId?: string, +) { + const { data } = await apiClient.get("/tenants", { + params: { limit, offset, parentId }, + }); + return data; +} + export async function fetchClient(clientId: string) { const { data } = await apiClient.get( `/dev/clients/${clientId}`, @@ -376,14 +409,14 @@ export async function fetchDevAuditLogs( return data; } -export type TenantSummary = { +export type MyTenantSummary = { id: string; name: string; slug: string; }; export async function fetchMyTenants() { - const { data } = await apiClient.get("/dev/my-tenants"); + const { data } = await apiClient.get("/dev/my-tenants"); return data; } diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index e68489f4..c0af058a 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -419,6 +419,7 @@ help = "Enter the redirect URIs. You can modify them in the Federation tab after [msg.dev.clients.general.scopes] empty = "No scopes registered." subtitle = "Define the permission scopes this application can request." +tenant = "Tenant access claim" [msg.dev.clients.general.security] private_help = "Server side App: For apps that can safely store a client secret, such as Node.js or Java servers." @@ -1436,6 +1437,19 @@ description = "Scope Description" mandatory = "Mandatory" name = "Scope Name" delete = "Delete" +tenant = "Tenant" + +[ui.dev.clients.general.tenant_access] +title = "Tenant access restriction" +subtitle = "Limit this RP so only approved tenants can access it." +enabled = "Restricted" +disabled = "Unrestricted" +search_placeholder = "Search by tenant name or slug" +selected_title = "Allowed tenants" +selected_empty = "No tenants selected yet." +empty = "No tenants match your search." +hint = "Turning this on adds the tenant scope automatically and requires at least one allowed tenant." +validation_required = "Select at least one allowed tenant when tenant access restriction is enabled." [ui.dev.clients.general.security] private = "Server Side App" diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 6837d1ae..ede404b3 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -419,6 +419,7 @@ help = "인증 후 리다이렉트될 URI를 입력하세요. 생성 후 연동 [msg.dev.clients.general.scopes] empty = "등록된 스코프가 없습니다." subtitle = "이 앱이 요청할 수 있는 권한 범위를 정의합니다." +tenant = "소속 테넌트 정보 접근" [msg.dev.clients.general.security] pkce_help = "PKCE 앱 (SPA/모바일): 브라우저나 앱처럼 비밀키를 보관하기 어려운 경우 사용하며, PKCE가 강제됩니다." @@ -1435,6 +1436,19 @@ description = "설명" mandatory = "필수" name = "스코프 이름" delete = "삭제" +tenant = "테넌트" + +[ui.dev.clients.general.tenant_access] +title = "테넌트 접근 제한" +subtitle = "허용된 테넌트만 이 RP에 접근할 수 있도록 제한합니다." +enabled = "제한 있음" +disabled = "제한 없음" +search_placeholder = "테넌트 이름 또는 슬러그로 검색" +selected_title = "허용 테넌트" +selected_empty = "아직 선택된 테넌트가 없습니다." +empty = "검색 결과가 없습니다." +hint = "제한을 켜면 tenant 스코프가 자동으로 포함되며, 허용 테넌트를 하나 이상 선택해야 합니다." +validation_required = "테넌트 접근 제한을 사용할 경우 허용 테넌트를 하나 이상 선택해야 합니다." [ui.dev.clients.general.security] private = "Server side App" diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index a62e6746..63c4b651 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -465,6 +465,7 @@ help = "" [msg.dev.clients.general.scopes] empty = "" subtitle = "" +tenant = "" [msg.dev.clients.general.security] private_help = "" @@ -1518,6 +1519,19 @@ description = "" mandatory = "" name = "" delete = "" +tenant = "" + +[ui.dev.clients.general.tenant_access] +title = "" +subtitle = "" +enabled = "" +disabled = "" +search_placeholder = "" +selected_title = "" +selected_empty = "" +empty = "" +hint = "" +validation_required = "" [ui.dev.clients.general.security] private = ""