diff --git a/backend/internal/handler/dev_handler.go b/backend/internal/handler/dev_handler.go index fe42249a..34158550 100644 --- a/backend/internal/handler/dev_handler.go +++ b/backend/internal/handler/dev_handler.go @@ -127,6 +127,7 @@ type clientSummary struct { Name string `json:"name"` Type string `json:"type"` Status string `json:"status"` + CreatorID string `json:"creatorId,omitempty"` CreatedAt *time.Time `json:"createdAt,omitempty"` RedirectURIs []string `json:"redirectUris"` Scopes []string `json:"scopes"` @@ -3224,6 +3225,7 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary { } } } + creatorID := readMetadataStringValue(client.Metadata, "user_id") clientType := "private" if client.IsHeadlessLoginEnabled() { @@ -3270,6 +3272,7 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary { Name: name, Type: clientType, Status: status, + CreatorID: creatorID, CreatedAt: createdAt, RedirectURIs: client.RedirectURIs, Scopes: scopes, diff --git a/backend/internal/handler/dev_handler_test.go b/backend/internal/handler/dev_handler_test.go index 0ababf7b..fd67469a 100644 --- a/backend/internal/handler/dev_handler_test.go +++ b/backend/internal/handler/dev_handler_test.go @@ -2524,6 +2524,20 @@ func TestMapClientSummary_ClassifiesHeadlessLoginAsPrivate(t *testing.T) { assert.Equal(t, "private", summary.Type) } +func TestMapClientSummary_ExposesCreatorIDFromMetadataUserID(t *testing.T) { + h := &DevHandler{} + + summary := h.mapClientSummary(domain.HydraClient{ + ClientID: "client-created-by", + ClientName: "Creator Visible App", + Metadata: map[string]any{ + "user_id": "creator-user-id", + }, + }) + + assert.Equal(t, "creator-user-id", summary.CreatorID) +} + func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) { var hydraCalled bool h := &DevHandler{ diff --git a/devfront/src/features/clients/ClientsPage.test.tsx b/devfront/src/features/clients/ClientsPage.test.tsx index 14918c4b..39e1c570 100644 --- a/devfront/src/features/clients/ClientsPage.test.tsx +++ b/devfront/src/features/clients/ClientsPage.test.tsx @@ -10,6 +10,7 @@ const fetchClientsMock = vi.fn(); const fetchMeMock = vi.fn(); const fetchDeveloperRequestStatusMock = vi.fn(); const fetchMyTenantsMock = vi.fn(); +const fetchDevUserMock = vi.fn(); const requestDeveloperAccessMock = vi.fn(); let authState = { @@ -46,6 +47,7 @@ vi.mock("../../lib/devApi", () => ({ fetchMe: () => fetchMeMock(), fetchDeveloperRequestStatus: () => fetchDeveloperRequestStatusMock(), fetchMyTenants: () => fetchMyTenantsMock(), + fetchDevUser: (userId: string) => fetchDevUserMock(userId), requestDeveloperAccess: (...args: unknown[]) => requestDeveloperAccessMock(...args), })); @@ -113,6 +115,15 @@ beforeEach(() => { updatedAt: "2026-05-01T00:00:00Z", }, ]); + fetchDevUserMock.mockResolvedValue({ + id: "creator-1", + name: "Creator One", + email: "creator.one@example.com", + role: "user", + status: "active", + createdAt: "2026-05-01T00:00:00Z", + updatedAt: "2026-05-01T00:00:00Z", + }); }); function makeClients(count: number) { @@ -138,6 +149,16 @@ async function setInputValue(input: HTMLInputElement, value: string) { await new Promise((resolve) => setTimeout(resolve, 0)); } +async function setSelectValue(select: HTMLSelectElement, value: string) { + const descriptor = Object.getOwnPropertyDescriptor( + HTMLSelectElement.prototype, + "value", + ); + descriptor?.set?.call(select, value); + select.dispatchEvent(new Event("change", { bubbles: true })); + await new Promise((resolve) => setTimeout(resolve, 0)); +} + async function renderPage() { const container = document.createElement("div"); document.body.appendChild(container); @@ -228,6 +249,73 @@ describe("ClientsPage", () => { expect(container.textContent).not.toContain("Tenant-scoped"); }); + it("resolves and shows creator name and email by creator uuid", async () => { + fetchClientsMock.mockResolvedValue({ + items: [ + { + ...makeClients(1)[0], + name: "Creator App", + creatorId: "creator-1", + }, + ], + limit: 100, + offset: 0, + }); + + const container = await renderPage(); + await waitForTextContent(container, "Creator One"); + + expect(fetchDevUserMock).toHaveBeenCalledWith("creator-1"); + expect(container.textContent).toContain("creator.one@example.com"); + }); + + it("filters Headless Login clients from the type filter", async () => { + fetchClientsMock.mockResolvedValue({ + items: [ + { + ...makeClients(1)[0], + name: "Plain Server App", + type: "private", + metadata: {}, + }, + { + ...makeClients(1)[0], + id: "client-headless", + name: "Headless App", + type: "private", + metadata: { + headless_login_enabled: true, + }, + }, + ], + limit: 100, + offset: 0, + }); + + const container = await renderPage(); + + const advancedButton = Array.from( + container.querySelectorAll("button"), + ).find((button) => button.textContent === "Advanced Filters"); + expect(advancedButton).toBeTruthy(); + + await act(async () => { + advancedButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + + const typeSelect = Array.from(container.querySelectorAll("select")).find( + (select) => select.textContent?.includes("Headless Login"), + ) as HTMLSelectElement | undefined; + expect(typeSelect).toBeTruthy(); + + await act(async () => { + await setSelectValue(typeSelect as HTMLSelectElement, "headless"); + }); + + expect(container.textContent).toContain("Headless App"); + expect(container.textContent).not.toContain("Plain Server App"); + }); + it("expands the list and applies search filters", async () => { fetchClientsMock.mockResolvedValue({ items: makeClients(6), diff --git a/devfront/src/features/clients/ClientsPage.tsx b/devfront/src/features/clients/ClientsPage.tsx index 6552c03e..45fd2007 100644 --- a/devfront/src/features/clients/ClientsPage.tsx +++ b/devfront/src/features/clients/ClientsPage.tsx @@ -46,6 +46,7 @@ import { type ClientSummary, fetchClients, fetchDeveloperRequestStatus, + fetchDevUser, fetchMyTenants, requestDeveloperAccess, } from "../../lib/devApi"; @@ -59,6 +60,11 @@ import { ClientLogo } from "./components/ClientLogo"; type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt"; const clientListPreviewCount = 5; +type ClientCreatorInfo = { + name?: string; + email?: string; +}; + function isClientTenantLimited(client: ClientSummary) { const metadata = client.metadata ?? {}; if (metadata.tenant_access_restricted === true) { @@ -72,6 +78,19 @@ function isClientTenantLimited(client: ClientSummary) { ); } +function isHeadlessLoginClient(client: ClientSummary) { + return client.metadata?.headless_login_enabled === true; +} + +function clientCreatorID(client: ClientSummary) { + return ( + client.creatorId?.trim() || + (typeof client.metadata?.user_id === "string" + ? client.metadata.user_id.trim() + : "") + ); +} + function ClientsPage() { const navigate = useNavigate(); const auth = useAuth(); @@ -136,6 +155,50 @@ function ClientsPage() { }); const clients = data?.items || []; + const creatorIds = useMemo( + () => + Array.from( + new Set( + clients + .map((client) => clientCreatorID(client)) + .filter((creatorId) => creatorId !== ""), + ), + ).sort(), + [clients], + ); + + const { data: clientCreators = {} } = useQuery({ + queryKey: ["client-creators", creatorIds], + queryFn: async () => { + const entries = await Promise.all( + creatorIds.map(async (creatorId) => { + try { + const user = await fetchDevUser(creatorId); + return [ + creatorId, + { + name: user.name, + email: user.email, + }, + ] as const; + } catch { + return [creatorId, null] as const; + } + }), + ); + + return entries.reduce>( + (acc, [creatorId, user]) => { + if (user) { + acc[creatorId] = user; + } + return acc; + }, + {}, + ); + }, + enabled: hasAccessToken && creatorIds.length > 0, + }); const clientSortResolvers = useMemo< SortResolverMap @@ -144,9 +207,7 @@ function ClientsPage() { application: (client) => client.name || client.id, id: (client) => client.id, type: (client) => - client.metadata?.headless_login_enabled - ? "private-headless" - : client.type, + isHeadlessLoginClient(client) ? "private-headless" : client.type, status: (client) => client.status, createdAt: (client) => client.createdAt ? new Date(client.createdAt) : null, @@ -160,7 +221,11 @@ function ClientsPage() { !searchQuery || client.name?.toLowerCase().includes(searchQuery.toLowerCase()) || client.id.toLowerCase().includes(searchQuery.toLowerCase()); - const matchesType = typeFilter === "all" || client.type === typeFilter; + const matchesType = + typeFilter === "all" || + (typeFilter === "headless" + ? isHeadlessLoginClient(client) + : client.type === typeFilter && !isHeadlessLoginClient(client)); const matchesStatus = statusFilter === "all" || client.status === statusFilter; return matchesSearch && matchesType && matchesStatus; @@ -369,6 +434,9 @@ function ClientsPage() { +
@@ -409,7 +477,7 @@ function ClientsPage() {
- +
+ + {t("ui.dev.clients.table.creator", "생성자")} +
@@ -567,12 +638,12 @@ function ClientsPage() { - {client.metadata?.headless_login_enabled + {isHeadlessLoginClient(client) ? t( "ui.dev.clients.type.private_headless", "Server side App (Headless Login)", @@ -598,6 +669,33 @@ function ClientsPage() { : t("ui.common.status.inactive", "Inactive")} + + {(() => { + const creatorId = clientCreatorID(client); + const creator = creatorId + ? clientCreators[creatorId] + : undefined; + const name = creator?.name?.trim(); + const email = creator?.email?.trim(); + + if (!creatorId) { + return ( + - + ); + } + + return ( +
+

+ {name || creatorId} +

+

+ {email || creatorId} +

+
+ ); + })()} +
{client.createdAt ? new Date(client.createdAt).toLocaleDateString() diff --git a/devfront/src/lib/devApi.ts b/devfront/src/lib/devApi.ts index de7f4eb7..1cb499e6 100644 --- a/devfront/src/lib/devApi.ts +++ b/devfront/src/lib/devApi.ts @@ -8,6 +8,7 @@ export type ClientSummary = { name: string; type: ClientType; status: ClientStatus; + creatorId?: string; createdAt?: string; clientSecret?: string; tokenEndpointAuthMethod?: string; diff --git a/devfront/tests/clients.spec.ts b/devfront/tests/clients.spec.ts index abd350fb..5253e5a8 100644 --- a/devfront/tests/clients.spec.ts +++ b/devfront/tests/clients.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test"; +import { expect, type Page, test } from "@playwright/test"; import { type AuditLog, type Consent, @@ -15,6 +15,42 @@ test.afterEach(async ({ page }, testInfo) => { } }); +async function mockAdminUserLookup(page: Page) { + await page.route("**/api/v1/admin/users/*", async (route) => { + const url = new URL(route.request().url()); + const userId = url.pathname.split("/").pop(); + const users: Record = { + "creator-headless": { + id: "creator-headless", + name: "Headless Creator", + email: "headless.creator@example.com", + }, + "creator-plain": { + id: "creator-plain", + name: "Plain Creator", + email: "plain.creator@example.com", + }, + }; + const found = userId ? users[userId] : undefined; + + await route.fulfill({ + status: found ? 200 : 404, + contentType: "application/json", + body: JSON.stringify( + found + ? { + ...found, + role: "user", + status: "active", + createdAt: "2026-03-03T00:00:00.000Z", + updatedAt: "2026-03-03T00:00:00.000Z", + } + : { error: "not found" }, + ), + }); + }); +} + test("clients page loads correctly", async ({ page }) => { await seedAuth(page, "super_admin"); await installDevApiMock(page, { @@ -87,6 +123,66 @@ test("clients page shows Tenant-limited only for tenant access restricted RP", a await expect(page.getByText("Tenant-scoped")).toHaveCount(0); }); +test("clients page resolves creator and filters Headless Login clients", async ({ + page, +}) => { + await seedAuth(page, "super_admin"); + await installDevApiMock(page, { + clients: [ + makeClient("client-plain", { + name: "Plain Server App", + createdAt: "2026-05-01T00:00:00.000Z", + creatorId: "creator-plain", + }), + makeClient("client-headless", { + name: "Headless Login App", + createdAt: "2026-05-02T00:00:00.000Z", + creatorId: "creator-headless", + metadata: { + headless_login_enabled: true, + }, + }), + ], + users: [ + { + id: "creator-headless", + name: "Headless Creator", + email: "headless.creator@example.com", + }, + { + id: "creator-plain", + name: "Plain Creator", + email: "plain.creator@example.com", + }, + ] as DevAssignableUser[], + consents: [] as Consent[], + auditLogsByCursor: undefined, + }); + await mockAdminUserLookup(page); + + await page.goto("/clients"); + + const headlessRow = page.locator("tbody tr", { + hasText: "Headless Login App", + }); + await expect(headlessRow).toContainText("Headless Creator"); + await expect(headlessRow).toContainText("headless.creator@example.com"); + + await page + .getByRole("button", { name: /Advanced Filters|고급 필터/ }) + .click(); + await page + .locator('select:has(option[value="headless"])') + .selectOption("headless"); + + await expect( + page.locator("tbody tr", { hasText: "Headless Login App" }), + ).toBeVisible(); + await expect( + page.locator("tbody tr", { hasText: "Plain Server App" }), + ).toHaveCount(0); +}); + test("overview page shows recent RP changes", async ({ page }) => { await seedAuth(page, "super_admin"); await installDevApiMock(page, { diff --git a/devfront/tests/helpers/devfront-fixtures.ts b/devfront/tests/helpers/devfront-fixtures.ts index 8feab0f2..977f9508 100644 --- a/devfront/tests/helpers/devfront-fixtures.ts +++ b/devfront/tests/helpers/devfront-fixtures.ts @@ -8,6 +8,7 @@ export type Client = { name: string; type: ClientType; status: ClientStatus; + creatorId?: string; redirectUris: string[]; scopes: string[]; createdAt: string; @@ -594,6 +595,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) { name: client.name, type: client.type, status: client.status, + creatorId: client.creatorId, createdAt: client.createdAt, redirectUris: client.redirectUris, scopes: client.scopes,