From a79c350831f18f8a0a2748a945495f5a694d8f41 Mon Sep 17 00:00:00 2001 From: kyy Date: Wed, 15 Apr 2026 18:23:23 +0900 Subject: [PATCH] =?UTF-8?q?devfront=20=EA=B4=80=EA=B3=84=20=ED=83=AD=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=EC=9E=90=20=EA=B2=80=EC=83=89=C2=B7=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=EC=84=A0=ED=83=9D=20UX=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/clients/ClientRelationsPage.tsx | 321 +++++++++++++++--- devfront/src/lib/devApi.ts | 24 ++ devfront/src/locales/en.toml | 60 ++++ devfront/src/locales/ko.toml | 60 ++++ devfront/src/locales/template.toml | 60 ++++ devfront/tests/devfront-relationships.spec.ts | 29 +- devfront/tests/helpers/devfront-fixtures.ts | 25 ++ 7 files changed, 519 insertions(+), 60 deletions(-) diff --git a/devfront/src/features/clients/ClientRelationsPage.tsx b/devfront/src/features/clients/ClientRelationsPage.tsx index af1120f7..e4ba79e4 100644 --- a/devfront/src/features/clients/ClientRelationsPage.tsx +++ b/devfront/src/features/clients/ClientRelationsPage.tsx @@ -1,7 +1,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { AxiosError } from "axios"; import { ArrowLeft, Link2, Plus, Trash2 } from "lucide-react"; -import { useMemo, useState } from "react"; +import { useDeferredValue, useMemo, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; @@ -24,9 +24,11 @@ import { } from "../../components/ui/table"; import { toast } from "../../components/ui/use-toast"; import { + type DevAssignableUser, addClientRelation, fetchClient, fetchClientRelations, + fetchDevUsers, removeClientRelation, } from "../../lib/devApi"; import { t } from "../../lib/i18n"; @@ -45,13 +47,37 @@ const relationOptions = [ "status_operator", ] as const; +type RelationOption = (typeof relationOptions)[number]; + +function relationLabel(relation: RelationOption) { + return t(`ui.dev.clients.relationships.option.${relation}.label`, relation); +} + +function relationDescription(relation: RelationOption) { + return t( + `ui.dev.clients.relationships.option.${relation}.description`, + relation, + ); +} + +function formatUserLabel(user: DevAssignableUser) { + const primary = user.name.trim() || user.email.trim(); + return `${primary} (${user.email.trim()})`; +} + function ClientRelationsPage() { const params = useParams(); const queryClient = useQueryClient(); const clientId = params.id ?? ""; - const [relation, setRelation] = - useState<(typeof relationOptions)[number]>("config_editor"); - const [userId, setUserId] = useState(""); + const [selectedRelations, setSelectedRelations] = useState( + [], + ); + const [userSearch, setUserSearch] = useState(""); + const deferredUserSearch = useDeferredValue(userSearch.trim()); + const [selectedUser, setSelectedUser] = useState( + null, + ); + const [isSearchOpen, setIsSearchOpen] = useState(false); const { data: clientData } = useQuery({ queryKey: ["client", clientId], @@ -69,6 +95,15 @@ function ClientRelationsPage() { enabled: clientId.length > 0, }); + const { data: userSearchData, isFetching: isUserSearchLoading } = useQuery({ + queryKey: ["dev-users", deferredUserSearch], + queryFn: () => fetchDevUsers(deferredUserSearch), + enabled: + clientId.length > 0 && + deferredUserSearch.length > 0 && + selectedUser == null, + }); + const sortedItems = useMemo(() => { return [...(relationData?.items ?? [])].sort((a, b) => { const relationCompare = a.relation.localeCompare(b.relation); @@ -79,17 +114,47 @@ function ClientRelationsPage() { }); }, [relationData?.items]); + const selectedUserExistingRelations = useMemo(() => { + if (!selectedUser) { + return new Set(); + } + + return new Set( + sortedItems + .filter((item) => item.subjectId === selectedUser.id) + .map((item) => item.relation), + ); + }, [selectedUser, sortedItems]); + const addMutation = useMutation({ - mutationFn: () => - addClientRelation(clientId, { - relation, - userId: userId.trim(), - }), + mutationFn: async () => { + if (!selectedUser) { + throw new Error( + t( + "msg.dev.clients.relationships.user_required", + "추가할 사용자를 선택하세요.", + ), + ); + } + + const pendingRelations = selectedRelations.filter( + (relation) => !selectedUserExistingRelations.has(relation), + ); + for (const relation of pendingRelations) { + await addClientRelation(clientId, { + relation, + userId: selectedUser.id, + }); + } + }, onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["client-relations", clientId], }); - setUserId(""); + setSelectedRelations([]); + setSelectedUser(null); + setUserSearch(""); + setIsSearchOpen(false); toast( t( "msg.dev.clients.relationships.added", @@ -144,19 +209,48 @@ function ClientRelationsPage() { }); const handleAdd = () => { - if (!userId.trim()) { + if (!selectedUser) { toast( t( "msg.dev.clients.relationships.user_required", - "추가할 User ID를 입력하세요.", + "추가할 사용자를 선택하세요.", ), "error", ); return; } + + const pendingRelations = selectedRelations.filter( + (relation) => !selectedUserExistingRelations.has(relation), + ); + if (pendingRelations.length === 0) { + toast( + t( + "msg.dev.clients.relationships.relation_required", + "추가할 관계를 하나 이상 선택하세요.", + ), + "error", + ); + return; + } + addMutation.mutate(); }; + const handleRelationToggle = (relation: RelationOption) => { + setSelectedRelations((current) => + current.includes(relation) + ? current.filter((item) => item !== relation) + : [...current, relation], + ); + }; + + const handleSelectUser = (user: DevAssignableUser) => { + setSelectedUser(user); + setUserSearch(formatUserLabel(user)); + setIsSearchOpen(false); + }; + const handleRemove = (targetRelation: string, subject: string) => { if ( window.confirm( @@ -243,54 +337,152 @@ function ClientRelationsPage() { {t( "msg.dev.clients.relationships.add_description", - "현재는 direct User assignment만 지원합니다. subject는 자동으로 User: 형식으로 전송됩니다.", + "사용자를 검색해 선택하고, 하나 이상의 운영 관계를 한 번에 부여할 수 있습니다.", )} - +
-
+ +
+ - handleRelationToggle(relation)} + /> +
+
+ {relationLabel(relation)} +
+
+ {relationDescription(relation)} +
+
+ {relation} +
+
+ + ); + })} +
+ + +
+
-
- - setUserId(e.target.value)} - placeholder={t( - "ui.dev.clients.relationships.user_id_placeholder", - "kratos user id", - )} - /> -
-
@@ -358,12 +550,31 @@ function ClientRelationsPage() { {sortedItems.map((item) => ( - - {item.relation} + +
+
+ {relationLabel(item.relation as RelationOption)} +
+
+ {relationDescription(item.relation as RelationOption)} +
+
-
{item.subject}
+
+ {item.userName || item.userEmail || item.subject} +
+ {(item.userEmail || item.userLoginId) && ( +
+ {[item.userEmail, item.userLoginId] + .filter(Boolean) + .join(" · ")} +
+ )} +
+ {item.subject} +
{item.subjectId && (
ID: {item.subjectId} diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index df430ecb..6446d4f0 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -104,6 +104,9 @@ export type ClientRelation = { subject: string; subjectType: string; subjectId: string; + userName?: string; + userEmail?: string; + userLoginId?: string; }; export type ClientRelationListResponse = { @@ -116,6 +119,17 @@ export type ClientRelationUpsertRequest = { userId?: string; }; +export type DevAssignableUser = { + id: string; + name: string; + email: string; + loginId?: string; +}; + +export type DevAssignableUserListResponse = { + items: DevAssignableUser[]; +}; + export type ConsentSummary = { subject: string; userName?: string; @@ -188,6 +202,16 @@ export async function fetchClientRelations(clientId: string) { return data; } +export async function fetchDevUsers(search: string, limit = 10) { + const { data } = await apiClient.get( + "/dev/users", + { + params: { search, limit }, + }, + ); + return data; +} + export async function addClientRelation( clientId: string, payload: ClientRelationUpsertRequest, diff --git a/devfront/src/locales/en.toml b/devfront/src/locales/en.toml index 811f7b26..a321c0d3 100644 --- a/devfront/src/locales/en.toml +++ b/devfront/src/locales/en.toml @@ -370,6 +370,24 @@ saved = "Saved" save_error = "Failed to save: {{error}}" status_changed = "Status changed to {{status}}." +[msg.dev.clients.relationships] +subtitle = "Review direct RP operator relations and add or remove them per user." +add_description = "Search for a user, select them, and grant one or more operator relations at once." +added = "Relationship added." +add_error = "Failed to add relationship: {{error}}" +removed = "Relationship removed." +remove_error = "Failed to remove relationship: {{error}}" +remove_confirm = "Remove this relationship?" +user_required = "Select a user to add." +relation_required = "Select at least one relationship to add." +list_description = "Lists operator relations directly assigned to this RP." +load_error = "Failed to load relationships: {{error}}" +loading = "Loading relationships..." +empty = "No direct relationships assigned." +search_loading = "Searching users..." +search_empty = "No users found." +selected_user = "Selected user: {{user}}" + [msg.dev.clients.federation] subtitle = "Manage external identity providers for this application." add_subtitle = "Connect an external OIDC provider." @@ -1453,6 +1471,48 @@ add = "Add" list_title = "Assigned Relationships" subject = "Subject" subject_type = "Type" +user_search = "User" +user_search_placeholder = "Search by name or email..." + +[ui.dev.clients.relationships.option.admins] +label = "RP Admin" +description = "Full administrator relationship for RP operations." + +[ui.dev.clients.relationships.option.creator] +label = "RP Creator" +description = "Marks the operator who created this RP." + +[ui.dev.clients.relationships.option.config_editor] +label = "RP General Settings" +description = "Edit the name, redirect URIs, and general metadata." + +[ui.dev.clients.relationships.option.secret_rotator] +label = "Secret Rotation" +description = "Rotate and reissue the client secret." + +[ui.dev.clients.relationships.option.jwks_viewer] +label = "JWKS View" +description = "View JWKS status, cache details, and key summaries." + +[ui.dev.clients.relationships.option.jwks_operator] +label = "JWKS Operations" +description = "Run operational actions such as refresh and revoke." + +[ui.dev.clients.relationships.option.consent_viewer] +label = "Consent View" +description = "View consent grants for this RP." + +[ui.dev.clients.relationships.option.consent_revoker] +label = "Consent Revoke" +description = "Revoke consent grants for this RP." + +[ui.dev.clients.relationships.option.relationship_viewer] +label = "Relationship View" +description = "View direct relations assigned to this RP." + +[ui.dev.clients.relationships.option.status_operator] +label = "Status Change" +description = "Change the active or inactive state of the RP." [ui.dev.clients.help] docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips." diff --git a/devfront/src/locales/ko.toml b/devfront/src/locales/ko.toml index 27aae064..7f5903f5 100644 --- a/devfront/src/locales/ko.toml +++ b/devfront/src/locales/ko.toml @@ -370,6 +370,24 @@ save_error = "저장 실패: {{error}}" saved = "설정이 저장되었습니다." status_changed = "상태가 {{status}}로 변경되었습니다." +[msg.dev.clients.relationships] +subtitle = "RP direct operator relation을 조회하고 사용자 단위로 추가·삭제합니다." +add_description = "사용자를 검색해 선택하고, 하나 이상의 운영 관계를 한 번에 부여할 수 있습니다." +added = "관계가 추가되었습니다." +add_error = "관계 추가 실패: {{error}}" +removed = "관계가 제거되었습니다." +remove_error = "관계 제거 실패: {{error}}" +remove_confirm = "이 관계를 제거하시겠습니까?" +user_required = "추가할 사용자를 선택하세요." +relation_required = "추가할 관계를 하나 이상 선택하세요." +list_description = "현재 RP에 직접 부여된 operator relation 목록입니다." +load_error = "관계 조회 실패: {{error}}" +loading = "관계를 불러오는 중입니다..." +empty = "직접 부여된 관계가 없습니다." +search_loading = "사용자를 찾는 중입니다..." +search_empty = "검색 결과가 없습니다." +selected_user = "선택된 사용자: {{user}}" + [msg.dev.clients.federation] add_subtitle = "외부 OIDC 제공자를 연결합니다." empty = "등록된 IdP 설정이 없습니다." @@ -1452,6 +1470,48 @@ add = "추가" list_title = "부여된 관계" subject = "주체" subject_type = "유형" +user_search = "사용자" +user_search_placeholder = "이름 또는 이메일 검색..." + +[ui.dev.clients.relationships.option.admins] +label = "RP 관리자" +description = "RP 운영 전반을 관리할 수 있는 관리자 관계입니다." + +[ui.dev.clients.relationships.option.creator] +label = "RP 생성자" +description = "이 RP를 생성한 운영 주체를 표시합니다." + +[ui.dev.clients.relationships.option.config_editor] +label = "RP 일반 설정" +description = "이름, Redirect URI, 메타데이터 같은 일반 설정을 수정합니다." + +[ui.dev.clients.relationships.option.secret_rotator] +label = "시크릿 재발급" +description = "Client secret 재발급과 회전을 수행합니다." + +[ui.dev.clients.relationships.option.jwks_viewer] +label = "JWKS 조회" +description = "JWKS 상태, 캐시 정보, 키 요약을 조회합니다." + +[ui.dev.clients.relationships.option.jwks_operator] +label = "JWKS 운영" +description = "JWKS refresh, revoke 같은 운영 작업을 수행합니다." + +[ui.dev.clients.relationships.option.consent_viewer] +label = "동의 조회" +description = "이 RP의 consent 내역을 조회합니다." + +[ui.dev.clients.relationships.option.consent_revoker] +label = "동의 회수" +description = "이 RP의 consent를 회수합니다." + +[ui.dev.clients.relationships.option.relationship_viewer] +label = "관계 조회" +description = "이 RP에 부여된 direct relation을 조회합니다." + +[ui.dev.clients.relationships.option.status_operator] +label = "상태 변경" +description = "RP 활성/비활성 상태를 변경합니다." [ui.dev.clients.help] docs_body = "Includes PKCE, client_secret_basic, redirect URI validation tips." diff --git a/devfront/src/locales/template.toml b/devfront/src/locales/template.toml index eb5792b0..a7f4d16c 100644 --- a/devfront/src/locales/template.toml +++ b/devfront/src/locales/template.toml @@ -370,6 +370,24 @@ saved = "" save_error = "" status_changed = "" +[msg.dev.clients.relationships] +subtitle = "" +add_description = "" +added = "" +add_error = "" +removed = "" +remove_error = "" +remove_confirm = "" +user_required = "" +relation_required = "" +list_description = "" +load_error = "" +loading = "" +empty = "" +search_loading = "" +search_empty = "" +selected_user = "" + [msg.dev.clients.federation] subtitle = "" add_subtitle = "" @@ -1452,6 +1470,48 @@ add = "" list_title = "" subject = "" subject_type = "" +user_search = "" +user_search_placeholder = "" + +[ui.dev.clients.relationships.option.admins] +label = "" +description = "" + +[ui.dev.clients.relationships.option.creator] +label = "" +description = "" + +[ui.dev.clients.relationships.option.config_editor] +label = "" +description = "" + +[ui.dev.clients.relationships.option.secret_rotator] +label = "" +description = "" + +[ui.dev.clients.relationships.option.jwks_viewer] +label = "" +description = "" + +[ui.dev.clients.relationships.option.jwks_operator] +label = "" +description = "" + +[ui.dev.clients.relationships.option.consent_viewer] +label = "" +description = "" + +[ui.dev.clients.relationships.option.consent_revoker] +label = "" +description = "" + +[ui.dev.clients.relationships.option.relationship_viewer] +label = "" +description = "" + +[ui.dev.clients.relationships.option.status_operator] +label = "" +description = "" [ui.dev.clients.help] docs_body = "" diff --git a/devfront/tests/devfront-relationships.spec.ts b/devfront/tests/devfront-relationships.spec.ts index e74bd33b..5ddeee3b 100644 --- a/devfront/tests/devfront-relationships.spec.ts +++ b/devfront/tests/devfront-relationships.spec.ts @@ -26,6 +26,14 @@ test.describe("DevFront relationships", () => { const state = { clients: [makeClient("client-rel", { name: "Relations app" })], consents: [] as Consent[], + users: [ + { + id: "user-2", + name: "홍길동", + email: "hong@example.com", + loginId: "hong01", + }, + ], relations: { "client-rel": [ { @@ -33,6 +41,8 @@ test.describe("DevFront relationships", () => { subject: "User:user-1", subjectType: "User", subjectId: "user-1", + userName: "기존 사용자", + userEmail: "existing@example.com", }, ] satisfies ClientRelation[], }, @@ -48,14 +58,17 @@ test.describe("DevFront relationships", () => { await expect( page.getByRole("heading", { name: "부여된 관계" }), ).toBeVisible(); + await expect(page.getByText("기존 사용자")).toBeVisible(); await expect(page.getByText("User:user-1")).toBeVisible(); - await page.getByLabel(/^관계$/).selectOption("secret_rotator"); - await page.getByLabel(/^사용자 ID$/).fill("user-2"); + await page.getByLabel(/^사용자$/).fill("홍길동"); + await page.getByRole("button", { name: /홍길동/ }).click(); + await page.getByLabel(/시크릿 재발급/).check(); + await page.getByLabel(/동의 조회/).check(); await page.getByRole("button", { name: /^추가$/ }).click(); await expect(page.getByText("User:user-2")).toBeVisible(); - await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(2); + await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(3); await page .locator("tr") @@ -63,7 +76,13 @@ test.describe("DevFront relationships", () => { .getByRole("button", { name: /Delete|삭제/i }) .click(); - await expect(page.getByText("User:user-2")).toHaveCount(0); - await expect.poll(() => state.relations["client-rel"]?.length ?? 0).toBe(1); + await expect + .poll( + () => + state.relations["client-rel"]?.filter( + (item) => item.subject === "User:user-2", + ).length ?? 0, + ) + .toBe(1); }); }); diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts index a375f4f6..e8200f9b 100644 --- a/devfront/tests/helpers/devfront-fixtures.ts +++ b/devfront/tests/helpers/devfront-fixtures.ts @@ -58,6 +58,16 @@ export type ClientRelation = { subject: string; subjectType: string; subjectId: string; + userName?: string; + userEmail?: string; + userLoginId?: string; +}; + +export type DevAssignableUser = { + id: string; + name: string; + email: string; + loginId?: string; }; export type AuditLog = { @@ -75,6 +85,7 @@ export type DevApiMockState = { clients: Client[]; consents: Consent[]; relations?: Record; + users?: DevAssignableUser[]; auditLogsByCursor?: Record< string, { items: AuditLog[]; next_cursor?: string } @@ -261,6 +272,20 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) { }); } + if (pathname === "/api/v1/dev/users" && method === "GET") { + const search = (searchParams.get("search") || "").toLowerCase(); + const limit = Number.parseInt(searchParams.get("limit") || "10", 10); + const items = (state.users ?? []) + .filter((user) => { + if (!search) return true; + return [user.name, user.email, user.loginId ?? ""].some((value) => + value.toLowerCase().includes(search), + ); + }) + .slice(0, Number.isFinite(limit) ? limit : 10); + return json(route, { items }); + } + if (pathname === "/api/v1/dev/clients" && method === "POST") { const payload = (request.postDataJSON() as { name?: string;