1
0
forked from baron/baron-sso

feat(devfront): show client creators and headless filter

This commit is contained in:
2026-06-17 22:03:15 +09:00
parent 69e1e32fd4
commit 5f3167a503
7 changed files with 311 additions and 9 deletions

View File

@@ -127,6 +127,7 @@ type clientSummary struct {
Name string `json:"name"` Name string `json:"name"`
Type string `json:"type"` Type string `json:"type"`
Status string `json:"status"` Status string `json:"status"`
CreatorID string `json:"creatorId,omitempty"`
CreatedAt *time.Time `json:"createdAt,omitempty"` CreatedAt *time.Time `json:"createdAt,omitempty"`
RedirectURIs []string `json:"redirectUris"` RedirectURIs []string `json:"redirectUris"`
Scopes []string `json:"scopes"` Scopes []string `json:"scopes"`
@@ -3224,6 +3225,7 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
} }
} }
} }
creatorID := readMetadataStringValue(client.Metadata, "user_id")
clientType := "private" clientType := "private"
if client.IsHeadlessLoginEnabled() { if client.IsHeadlessLoginEnabled() {
@@ -3270,6 +3272,7 @@ func (h *DevHandler) mapClientSummary(client domain.HydraClient) clientSummary {
Name: name, Name: name,
Type: clientType, Type: clientType,
Status: status, Status: status,
CreatorID: creatorID,
CreatedAt: createdAt, CreatedAt: createdAt,
RedirectURIs: client.RedirectURIs, RedirectURIs: client.RedirectURIs,
Scopes: scopes, Scopes: scopes,

View File

@@ -2524,6 +2524,20 @@ func TestMapClientSummary_ClassifiesHeadlessLoginAsPrivate(t *testing.T) {
assert.Equal(t, "private", summary.Type) 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) { func TestCreateClient_HeadlessLoginRejectsInlineJWKS(t *testing.T) {
var hydraCalled bool var hydraCalled bool
h := &DevHandler{ h := &DevHandler{

View File

@@ -10,6 +10,7 @@ const fetchClientsMock = vi.fn();
const fetchMeMock = vi.fn(); const fetchMeMock = vi.fn();
const fetchDeveloperRequestStatusMock = vi.fn(); const fetchDeveloperRequestStatusMock = vi.fn();
const fetchMyTenantsMock = vi.fn(); const fetchMyTenantsMock = vi.fn();
const fetchDevUserMock = vi.fn();
const requestDeveloperAccessMock = vi.fn(); const requestDeveloperAccessMock = vi.fn();
let authState = { let authState = {
@@ -46,6 +47,7 @@ vi.mock("../../lib/devApi", () => ({
fetchMe: () => fetchMeMock(), fetchMe: () => fetchMeMock(),
fetchDeveloperRequestStatus: () => fetchDeveloperRequestStatusMock(), fetchDeveloperRequestStatus: () => fetchDeveloperRequestStatusMock(),
fetchMyTenants: () => fetchMyTenantsMock(), fetchMyTenants: () => fetchMyTenantsMock(),
fetchDevUser: (userId: string) => fetchDevUserMock(userId),
requestDeveloperAccess: (...args: unknown[]) => requestDeveloperAccess: (...args: unknown[]) =>
requestDeveloperAccessMock(...args), requestDeveloperAccessMock(...args),
})); }));
@@ -113,6 +115,15 @@ beforeEach(() => {
updatedAt: "2026-05-01T00:00:00Z", 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) { function makeClients(count: number) {
@@ -138,6 +149,16 @@ async function setInputValue(input: HTMLInputElement, value: string) {
await new Promise((resolve) => setTimeout(resolve, 0)); 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() { async function renderPage() {
const container = document.createElement("div"); const container = document.createElement("div");
document.body.appendChild(container); document.body.appendChild(container);
@@ -228,6 +249,73 @@ describe("ClientsPage", () => {
expect(container.textContent).not.toContain("Tenant-scoped"); 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 () => { it("expands the list and applies search filters", async () => {
fetchClientsMock.mockResolvedValue({ fetchClientsMock.mockResolvedValue({
items: makeClients(6), items: makeClients(6),

View File

@@ -46,6 +46,7 @@ import {
type ClientSummary, type ClientSummary,
fetchClients, fetchClients,
fetchDeveloperRequestStatus, fetchDeveloperRequestStatus,
fetchDevUser,
fetchMyTenants, fetchMyTenants,
requestDeveloperAccess, requestDeveloperAccess,
} from "../../lib/devApi"; } from "../../lib/devApi";
@@ -59,6 +60,11 @@ import { ClientLogo } from "./components/ClientLogo";
type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt"; type ClientSortKey = "application" | "id" | "type" | "status" | "createdAt";
const clientListPreviewCount = 5; const clientListPreviewCount = 5;
type ClientCreatorInfo = {
name?: string;
email?: string;
};
function isClientTenantLimited(client: ClientSummary) { function isClientTenantLimited(client: ClientSummary) {
const metadata = client.metadata ?? {}; const metadata = client.metadata ?? {};
if (metadata.tenant_access_restricted === true) { 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() { function ClientsPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const auth = useAuth(); const auth = useAuth();
@@ -136,6 +155,50 @@ function ClientsPage() {
}); });
const clients = data?.items || []; 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<Record<string, ClientCreatorInfo>>(
(acc, [creatorId, user]) => {
if (user) {
acc[creatorId] = user;
}
return acc;
},
{},
);
},
enabled: hasAccessToken && creatorIds.length > 0,
});
const clientSortResolvers = useMemo< const clientSortResolvers = useMemo<
SortResolverMap<ClientSummary, ClientSortKey> SortResolverMap<ClientSummary, ClientSortKey>
@@ -144,9 +207,7 @@ function ClientsPage() {
application: (client) => client.name || client.id, application: (client) => client.name || client.id,
id: (client) => client.id, id: (client) => client.id,
type: (client) => type: (client) =>
client.metadata?.headless_login_enabled isHeadlessLoginClient(client) ? "private-headless" : client.type,
? "private-headless"
: client.type,
status: (client) => client.status, status: (client) => client.status,
createdAt: (client) => createdAt: (client) =>
client.createdAt ? new Date(client.createdAt) : null, client.createdAt ? new Date(client.createdAt) : null,
@@ -160,7 +221,11 @@ function ClientsPage() {
!searchQuery || !searchQuery ||
client.name?.toLowerCase().includes(searchQuery.toLowerCase()) || client.name?.toLowerCase().includes(searchQuery.toLowerCase()) ||
client.id.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 = const matchesStatus =
statusFilter === "all" || client.status === statusFilter; statusFilter === "all" || client.status === statusFilter;
return matchesSearch && matchesType && matchesStatus; return matchesSearch && matchesType && matchesStatus;
@@ -369,6 +434,9 @@ function ClientsPage() {
<option value="pkce"> <option value="pkce">
{t("ui.dev.clients.type.pkce", "PKCE")} {t("ui.dev.clients.type.pkce", "PKCE")}
</option> </option>
<option value="headless">
{t("ui.dev.clients.type.headless", "Headless Login")}
</option>
</select> </select>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -409,7 +477,7 @@ function ClientsPage() {
<CardContent className="flex-1 flex flex-col min-h-0 pt-0"> <CardContent className="flex-1 flex flex-col min-h-0 pt-0">
<div className={commonTableShellClass}> <div className={commonTableShellClass}>
<div className={commonTableViewportClass}> <div className={commonTableViewportClass}>
<Table className="min-w-[1180px]"> <Table className="min-w-[1280px]">
<TableHeader className={sortableTableHeaderClassName}> <TableHeader className={sortableTableHeaderClassName}>
<TableRow> <TableRow>
<SortableTableHead <SortableTableHead
@@ -439,6 +507,9 @@ function ClientsPage() {
sortConfig={sortConfig} sortConfig={sortConfig}
sortKey="status" sortKey="status"
/> />
<TableHead className={sortableTableHeadBaseClassName}>
{t("ui.dev.clients.table.creator", "생성자")}
</TableHead>
<SortableTableHead <SortableTableHead
label={t("ui.dev.clients.table.created_at", "생성일")} label={t("ui.dev.clients.table.created_at", "생성일")}
onSort={requestSort} onSort={requestSort}
@@ -456,7 +527,7 @@ function ClientsPage() {
{!hasFilterResult && ( {!hasFilterResult && (
<TableRow> <TableRow>
<TableCell <TableCell
colSpan={6} colSpan={7}
className="h-32 text-center text-muted-foreground" className="h-32 text-center text-muted-foreground"
> >
<div className="space-y-1"> <div className="space-y-1">
@@ -567,12 +638,12 @@ function ClientsPage() {
<Badge <Badge
variant={ variant={
client.type === "private" || client.type === "private" ||
client.metadata?.headless_login_enabled isHeadlessLoginClient(client)
? "success" ? "success"
: "muted" : "muted"
} }
> >
{client.metadata?.headless_login_enabled {isHeadlessLoginClient(client)
? t( ? t(
"ui.dev.clients.type.private_headless", "ui.dev.clients.type.private_headless",
"Server side App (Headless Login)", "Server side App (Headless Login)",
@@ -598,6 +669,33 @@ function ClientsPage() {
: t("ui.common.status.inactive", "Inactive")} : t("ui.common.status.inactive", "Inactive")}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell className="text-sm">
{(() => {
const creatorId = clientCreatorID(client);
const creator = creatorId
? clientCreators[creatorId]
: undefined;
const name = creator?.name?.trim();
const email = creator?.email?.trim();
if (!creatorId) {
return (
<span className="text-muted-foreground">-</span>
);
}
return (
<div className="space-y-1">
<p className="font-medium text-foreground">
{name || creatorId}
</p>
<p className="break-all text-xs text-muted-foreground">
{email || creatorId}
</p>
</div>
);
})()}
</TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{client.createdAt {client.createdAt
? new Date(client.createdAt).toLocaleDateString() ? new Date(client.createdAt).toLocaleDateString()

View File

@@ -8,6 +8,7 @@ export type ClientSummary = {
name: string; name: string;
type: ClientType; type: ClientType;
status: ClientStatus; status: ClientStatus;
creatorId?: string;
createdAt?: string; createdAt?: string;
clientSecret?: string; clientSecret?: string;
tokenEndpointAuthMethod?: string; tokenEndpointAuthMethod?: string;

View File

@@ -1,4 +1,4 @@
import { expect, test } from "@playwright/test"; import { expect, type Page, test } from "@playwright/test";
import { import {
type AuditLog, type AuditLog,
type Consent, 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<string, DevAssignableUser> = {
"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 }) => { test("clients page loads correctly", async ({ page }) => {
await seedAuth(page, "super_admin"); await seedAuth(page, "super_admin");
await installDevApiMock(page, { 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); 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 }) => { test("overview page shows recent RP changes", async ({ page }) => {
await seedAuth(page, "super_admin"); await seedAuth(page, "super_admin");
await installDevApiMock(page, { await installDevApiMock(page, {

View File

@@ -8,6 +8,7 @@ export type Client = {
name: string; name: string;
type: ClientType; type: ClientType;
status: ClientStatus; status: ClientStatus;
creatorId?: string;
redirectUris: string[]; redirectUris: string[];
scopes: string[]; scopes: string[];
createdAt: string; createdAt: string;
@@ -594,6 +595,7 @@ export async function installDevApiMock(page: Page, state: DevApiMockState) {
name: client.name, name: client.name,
type: client.type, type: client.type,
status: client.status, status: client.status,
creatorId: client.creatorId,
createdAt: client.createdAt, createdAt: client.createdAt,
redirectUris: client.redirectUris, redirectUris: client.redirectUris,
scopes: client.scopes, scopes: client.scopes,