forked from baron/baron-sso
feat(devfront): show client creators and headless filter
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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<Record<string, ClientCreatorInfo>>(
|
||||
(acc, [creatorId, user]) => {
|
||||
if (user) {
|
||||
acc[creatorId] = user;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{},
|
||||
);
|
||||
},
|
||||
enabled: hasAccessToken && creatorIds.length > 0,
|
||||
});
|
||||
|
||||
const clientSortResolvers = useMemo<
|
||||
SortResolverMap<ClientSummary, ClientSortKey>
|
||||
@@ -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() {
|
||||
<option value="pkce">
|
||||
{t("ui.dev.clients.type.pkce", "PKCE")}
|
||||
</option>
|
||||
<option value="headless">
|
||||
{t("ui.dev.clients.type.headless", "Headless Login")}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<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">
|
||||
<div className={commonTableShellClass}>
|
||||
<div className={commonTableViewportClass}>
|
||||
<Table className="min-w-[1180px]">
|
||||
<Table className="min-w-[1280px]">
|
||||
<TableHeader className={sortableTableHeaderClassName}>
|
||||
<TableRow>
|
||||
<SortableTableHead
|
||||
@@ -439,6 +507,9 @@ function ClientsPage() {
|
||||
sortConfig={sortConfig}
|
||||
sortKey="status"
|
||||
/>
|
||||
<TableHead className={sortableTableHeadBaseClassName}>
|
||||
{t("ui.dev.clients.table.creator", "생성자")}
|
||||
</TableHead>
|
||||
<SortableTableHead
|
||||
label={t("ui.dev.clients.table.created_at", "생성일")}
|
||||
onSort={requestSort}
|
||||
@@ -456,7 +527,7 @@ function ClientsPage() {
|
||||
{!hasFilterResult && (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={6}
|
||||
colSpan={7}
|
||||
className="h-32 text-center text-muted-foreground"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
@@ -567,12 +638,12 @@ function ClientsPage() {
|
||||
<Badge
|
||||
variant={
|
||||
client.type === "private" ||
|
||||
client.metadata?.headless_login_enabled
|
||||
isHeadlessLoginClient(client)
|
||||
? "success"
|
||||
: "muted"
|
||||
}
|
||||
>
|
||||
{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")}
|
||||
</Badge>
|
||||
</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">
|
||||
{client.createdAt
|
||||
? new Date(client.createdAt).toLocaleDateString()
|
||||
|
||||
@@ -8,6 +8,7 @@ export type ClientSummary = {
|
||||
name: string;
|
||||
type: ClientType;
|
||||
status: ClientStatus;
|
||||
creatorId?: string;
|
||||
createdAt?: string;
|
||||
clientSecret?: string;
|
||||
tokenEndpointAuthMethod?: string;
|
||||
|
||||
@@ -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<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 }) => {
|
||||
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, {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user