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"`
|
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,
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user