From f111dccfe894317f8b7201559fc8c0695876a867 Mon Sep 17 00:00:00 2001 From: Thomas Van Dort Date: Tue, 24 Mar 2026 13:16:00 -0400 Subject: [PATCH 1/5] Add OAuth API clients list page Adds navigation, RTK Query slice, API types, table component and tests, and the /api-clients index page so users can view all OAuth clients. Co-Authored-By: Claude Sonnet 4.6 --- .../src/features/common/ClipboardButton.tsx | 11 +- .../admin-ui/src/features/common/api.slice.ts | 1 + .../src/features/common/nav/nav-config.tsx | 11 ++ .../src/features/common/nav/routes.ts | 2 + .../features/oauth/OAuthClientsTable.test.tsx | 184 ++++++++++++++++++ .../src/features/oauth/OAuthClientsTable.tsx | 134 +++++++++++++ .../src/features/oauth/oauth-clients.slice.ts | 87 +++++++++ .../admin-ui/src/pages/api-clients/index.tsx | 60 ++++++ clients/admin-ui/src/types/api/index.ts | 3 + .../src/types/api/models/ClientResponse.ts | 13 ++ .../api/models/ClientSecretRotateResponse.ts | 11 ++ .../types/api/models/Page_ClientResponse_.ts | 13 ++ clients/fidesui/src/index.ts | 2 + 13 files changed, 523 insertions(+), 9 deletions(-) create mode 100644 clients/admin-ui/src/features/oauth/OAuthClientsTable.test.tsx create mode 100644 clients/admin-ui/src/features/oauth/OAuthClientsTable.tsx create mode 100644 clients/admin-ui/src/features/oauth/oauth-clients.slice.ts create mode 100644 clients/admin-ui/src/pages/api-clients/index.tsx create mode 100644 clients/admin-ui/src/types/api/models/ClientResponse.ts create mode 100644 clients/admin-ui/src/types/api/models/ClientSecretRotateResponse.ts create mode 100644 clients/admin-ui/src/types/api/models/Page_ClientResponse_.ts diff --git a/clients/admin-ui/src/features/common/ClipboardButton.tsx b/clients/admin-ui/src/features/common/ClipboardButton.tsx index 78238986a54..384b521680d 100644 --- a/clients/admin-ui/src/features/common/ClipboardButton.tsx +++ b/clients/admin-ui/src/features/common/ClipboardButton.tsx @@ -1,10 +1,4 @@ -import { - Button, - ButtonProps, - Icons, - Tooltip, - useChakraClipboard as useClipboard, -} from "fidesui"; +import { Button, ButtonProps, Icons, Tooltip } from "fidesui"; import React, { useState } from "react"; enum TooltipText { @@ -13,12 +7,11 @@ enum TooltipText { } const useClipboardButton = (copyText: string) => { - const { onCopy } = useClipboard(copyText); const [tooltipText, setTooltipText] = useState(TooltipText.COPY); const handleClick = () => { setTooltipText(TooltipText.COPIED); - onCopy(); + navigator.clipboard.writeText(copyText); }; return { diff --git a/clients/admin-ui/src/features/common/api.slice.ts b/clients/admin-ui/src/features/common/api.slice.ts index 244735215ed..9ee489da2b5 100644 --- a/clients/admin-ui/src/features/common/api.slice.ts +++ b/clients/admin-ui/src/features/common/api.slice.ts @@ -81,6 +81,7 @@ export const baseApi = createApi({ "User", "Configuration Settings", "TCF Purpose Override", + "OAuth Client", "OpenID Provider", "Chat Provider Config", "Taxonomy", diff --git a/clients/admin-ui/src/features/common/nav/nav-config.tsx b/clients/admin-ui/src/features/common/nav/nav-config.tsx index 87c229459f7..4801bfe4b7d 100644 --- a/clients/admin-ui/src/features/common/nav/nav-config.tsx +++ b/clients/admin-ui/src/features/common/nav/nav-config.tsx @@ -293,6 +293,17 @@ export const NAV_CONFIG: NavConfigGroup[] = [ ScopeRegistryEnum.USER_READ, ], }, + { + title: "API clients", + path: routes.API_CLIENTS_ROUTE, + scopes: [ScopeRegistryEnum.CLIENT_READ], + }, + { + title: "API client detail", + path: routes.API_CLIENT_DETAIL_ROUTE, + hidden: true, + scopes: [ScopeRegistryEnum.CLIENT_READ], + }, { title: "User detail", path: routes.USER_DETAIL_ROUTE, diff --git a/clients/admin-ui/src/features/common/nav/routes.ts b/clients/admin-ui/src/features/common/nav/routes.ts index 0bf2857a749..d464e9e8a36 100644 --- a/clients/admin-ui/src/features/common/nav/routes.ts +++ b/clients/admin-ui/src/features/common/nav/routes.ts @@ -58,6 +58,8 @@ export const PROPERTIES_ROUTE = "/properties"; export const ADD_PROPERTY_ROUTE = "/properties/add-property"; export const EDIT_PROPERTY_ROUTE = "/properties/[id]"; +export const API_CLIENTS_ROUTE = "/api-clients"; +export const API_CLIENT_DETAIL_ROUTE = "/api-clients/[id]"; export const USER_MANAGEMENT_ROUTE = "/user-management"; export const USER_PROFILE_ROUTE = "/user-management/profile/[id]"; export const USER_DETAIL_ROUTE = "/user-management/profile/[id]"; diff --git a/clients/admin-ui/src/features/oauth/OAuthClientsTable.test.tsx b/clients/admin-ui/src/features/oauth/OAuthClientsTable.test.tsx new file mode 100644 index 00000000000..4a37f2f413f --- /dev/null +++ b/clients/admin-ui/src/features/oauth/OAuthClientsTable.test.tsx @@ -0,0 +1,184 @@ +import { render, screen } from "@testing-library/react"; + +import OAuthClientsList from "./OAuthClientsTable"; + +// --- Module mocks --- + +const mockUseHasPermission = jest.fn(); +jest.mock("~/features/common/Restrict", () => ({ + useHasPermission: () => mockUseHasPermission(), +})); + +const mockUseListOAuthClientsQuery = jest.fn(); +jest.mock("./oauth-clients.slice", () => ({ + useListOAuthClientsQuery: () => mockUseListOAuthClientsQuery(), +})); + +// LinkCell uses NextLink which doesn't work in jsdom (NodeList.includes bug) +jest.mock("~/features/common/table/cells/LinkCell", () => ({ + LinkCell: ({ href, children }: any) => + href ? {children} : {children}, +})); + +// Tooltip mock: real Tooltip only renders content in a portal on hover, +// so we use a lightweight stand-in that exposes the title as a DOM attribute +// for static assertions. +const MockTooltip = ({ title, children }: any) => ( + {children} +); +MockTooltip.displayName = "MockTooltip"; + +jest.mock( + "fidesui", + () => + new Proxy(jest.requireActual("fidesui"), { + get(target, prop) { + if (prop === "Tooltip") { + return MockTooltip; + } + return target[prop as keyof typeof target]; + }, + }), +); + +// --- Helpers --- + +const makeClient = (overrides = {}) => ({ + client_id: "abc123", + name: "My Client", + description: "A test client", + scopes: ["client:create", "client:read"], + ...overrides, +}); + +const renderList = () => render(); + +// --- Tests --- + +describe("OAuthClientsList", () => { + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(navigator, "clipboard", { + value: { writeText: jest.fn() }, + configurable: true, + }); + mockUseHasPermission.mockReturnValue(true); + mockUseListOAuthClientsQuery.mockReturnValue({ + data: { items: [makeClient()], total: 1, page: 1, size: 25 }, + isLoading: false, + }); + }); + + describe("list item rendering", () => { + it("renders the client name", () => { + renderList(); + expect(screen.getByText("My Client")).toBeInTheDocument(); + }); + + it("renders the client ID in monospace", () => { + renderList(); + expect(screen.getByText("abc123")).toBeInTheDocument(); + }); + + it("renders the scope count tag", () => { + renderList(); + expect(screen.getByText("2 scopes")).toBeInTheDocument(); + }); + + it("renders the description when present", () => { + renderList(); + expect(screen.getByText("A test client")).toBeInTheDocument(); + }); + + it("does not render a description section when absent", () => { + mockUseListOAuthClientsQuery.mockReturnValue({ + data: { items: [makeClient({ description: null })], total: 1 }, + isLoading: false, + }); + renderList(); + expect(screen.queryByText("A test client")).not.toBeInTheDocument(); + }); + + it("renders a copy button for the client ID", () => { + renderList(); + expect(screen.getByTestId("clipboard-btn")).toBeInTheDocument(); + }); + + it("shows 'Unnamed' when client has no name", () => { + mockUseListOAuthClientsQuery.mockReturnValue({ + data: { items: [makeClient({ name: null })], total: 1 }, + isLoading: false, + }); + renderList(); + expect(screen.getByText("Unnamed")).toBeInTheDocument(); + }); + }); + + describe("empty state", () => { + it("renders empty state text when there are no clients", () => { + mockUseListOAuthClientsQuery.mockReturnValue({ + data: { items: [], total: 0 }, + isLoading: false, + }); + renderList(); + expect(screen.getByText(/No API clients yet/)).toBeInTheDocument(); + }); + }); + + describe("loading state", () => { + it("renders without crashing while loading", () => { + mockUseListOAuthClientsQuery.mockReturnValue({ + data: undefined, + isLoading: true, + }); + expect(() => renderList()).not.toThrow(); + }); + }); + + describe("client name link — with CLIENT_UPDATE permission", () => { + it("renders the name as a link to the detail page", () => { + mockUseHasPermission.mockReturnValue(true); + renderList(); + const link = screen.getByText("My Client").closest("a"); + expect(link).toHaveAttribute("href", "/api-clients/abc123"); + }); + + it("does not show a permission tooltip", () => { + mockUseHasPermission.mockReturnValue(true); + renderList(); + const tooltip = screen.getByText("My Client").closest("[data-tooltip]"); + expect(tooltip?.getAttribute("data-tooltip")).toBeFalsy(); + }); + }); + + describe("client name — without CLIENT_UPDATE permission", () => { + beforeEach(() => mockUseHasPermission.mockReturnValue(false)); + + it("renders the name as plain text (no link)", () => { + renderList(); + expect(screen.getByText("My Client").closest("a")).toBeNull(); + }); + + it("shows a tooltip explaining the permission requirement", () => { + renderList(); + const tooltip = screen.getByText("My Client").closest("[data-tooltip]"); + expect(tooltip?.getAttribute("data-tooltip")).toMatch(/permission/i); + }); + }); + + describe("pagination", () => { + it("always renders the pagination component", () => { + renderList(); + expect(screen.getByText(/Total 1 items/)).toBeInTheDocument(); + }); + + it("shows the total item count", () => { + mockUseListOAuthClientsQuery.mockReturnValue({ + data: { items: [makeClient()], total: 42 }, + isLoading: false, + }); + renderList(); + expect(screen.getByText(/Total 42 items/)).toBeInTheDocument(); + }); + }); +}); diff --git a/clients/admin-ui/src/features/oauth/OAuthClientsTable.tsx b/clients/admin-ui/src/features/oauth/OAuthClientsTable.tsx new file mode 100644 index 00000000000..266d4aeb380 --- /dev/null +++ b/clients/admin-ui/src/features/oauth/OAuthClientsTable.tsx @@ -0,0 +1,134 @@ +import { Flex, List, Pagination, Tag, Tooltip, Typography } from "fidesui"; +import { useState } from "react"; + +import ClipboardButton from "~/features/common/ClipboardButton"; +import { API_CLIENTS_ROUTE } from "~/features/common/nav/routes"; +import { useHasPermission } from "~/features/common/Restrict"; +import { LinkCell } from "~/features/common/table/cells/LinkCell"; +import { + DEFAULT_PAGE_SIZE, + DEFAULT_PAGE_SIZES, +} from "~/features/common/table/constants"; +import { ClientResponse, ScopeRegistryEnum } from "~/types/api"; + +import { useListOAuthClientsQuery } from "./oauth-clients.slice"; + +const { Text } = Typography; + +const ClientListItem = ({ client }: { client: ClientResponse }) => { + const canUpdate = useHasPermission([ScopeRegistryEnum.CLIENT_UPDATE]); + + return ( + + + + + + {client.name ?? "Unnamed"} + + + + {client.scopes.length} scopes + + } + description={ + + + + {client.client_id} + + + + {client.description && ( + + {client.description} + + )} + + } + /> + + ); +}; + +const useOAuthClientsList = () => { + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE); + + const { data, isLoading, error } = useListOAuthClientsQuery({ + page, + size: pageSize, + }); + + return { + data: data?.items ?? [], + total: data?.total ?? 0, + isLoading, + error, + page, + pageSize, + setPage, + setPageSize, + }; +}; + +const OAuthClientsList = () => { + const { data, total, isLoading, page, pageSize, setPage, setPageSize } = + useOAuthClientsList(); + + return ( +
+ client.client_id} + locale={{ + emptyText: ( +
+ + No API clients yet. Click "Create API client" to get + started. + +
+ ), + }} + renderItem={(client) => } + /> + + { + if (newPageSize !== pageSize) { + setPageSize(newPageSize); + setPage(1); + } else { + setPage(newPage); + } + }} + showSizeChanger + pageSizeOptions={DEFAULT_PAGE_SIZES} + showTotal={(totalItems) => `Total ${totalItems} items`} + /> + +
+ ); +}; + +export default OAuthClientsList; diff --git a/clients/admin-ui/src/features/oauth/oauth-clients.slice.ts b/clients/admin-ui/src/features/oauth/oauth-clients.slice.ts new file mode 100644 index 00000000000..b2cd4ef90c6 --- /dev/null +++ b/clients/admin-ui/src/features/oauth/oauth-clients.slice.ts @@ -0,0 +1,87 @@ +import { baseApi } from "~/features/common/api.slice"; +import { + ClientCreatedResponse, + ClientResponse, + ClientSecretRotateResponse, + Page_ClientResponse_, +} from "~/types/api"; + +export interface ClientCreateParams { + name?: string; + description?: string; + scopes?: string[]; +} + +export interface ClientUpdateParams { + client_id: string; + name?: string; + description?: string; + scopes?: string[]; +} + +const oauthClientsApi = baseApi.injectEndpoints({ + endpoints: (build) => ({ + listOAuthClients: build.query< + Page_ClientResponse_, + { page?: number; size?: number } + >({ + query: ({ page = 1, size = 25 } = {}) => ({ + url: `oauth/client`, + params: { page, size }, + }), + providesTags: ["OAuth Client"], + }), + getOAuthClient: build.query({ + query: (clientId) => ({ url: `oauth/client/${clientId}` }), + providesTags: (_result, _error, clientId) => [ + { type: "OAuth Client", id: clientId }, + ], + }), + createOAuthClient: build.mutation< + ClientCreatedResponse, + ClientCreateParams + >({ + query: (body) => ({ + url: `oauth/client`, + method: "POST", + body, + }), + invalidatesTags: ["OAuth Client"], + }), + updateOAuthClient: build.mutation({ + query: ({ client_id, ...body }) => ({ + url: `oauth/client/${client_id}`, + method: "PUT", + body, + }), + invalidatesTags: (_result, _error, { client_id }) => [ + "OAuth Client", + { type: "OAuth Client", id: client_id }, + ], + }), + deleteOAuthClient: build.mutation({ + query: (clientId) => ({ + url: `oauth/client/${clientId}`, + method: "DELETE", + }), + invalidatesTags: ["OAuth Client"], + }), + rotateOAuthClientSecret: build.mutation( + { + query: (clientId) => ({ + url: `oauth/client/${clientId}/secret`, + method: "POST", + }), + }, + ), + }), +}); + +export const { + useListOAuthClientsQuery, + useGetOAuthClientQuery, + useCreateOAuthClientMutation, + useUpdateOAuthClientMutation, + useDeleteOAuthClientMutation, + useRotateOAuthClientSecretMutation, +} = oauthClientsApi; diff --git a/clients/admin-ui/src/pages/api-clients/index.tsx b/clients/admin-ui/src/pages/api-clients/index.tsx new file mode 100644 index 00000000000..5cea1e01b3d --- /dev/null +++ b/clients/admin-ui/src/pages/api-clients/index.tsx @@ -0,0 +1,60 @@ +import { Button, Flex } from "fidesui"; +import type { NextPage } from "next"; +import { useState } from "react"; + +import FixedLayout from "~/features/common/FixedLayout"; +import PageHeader from "~/features/common/PageHeader"; +import Restrict from "~/features/common/Restrict"; +import ClientSecretModal from "~/features/oauth/ClientSecretModal"; +import CreateOAuthClientModal from "~/features/oauth/CreateOAuthClientModal"; +import OAuthClientsList from "~/features/oauth/OAuthClientsTable"; +import { ScopeRegistryEnum } from "~/types/api"; + +const ApiClientsPage: NextPage = () => { + const [createModalOpen, setCreateModalOpen] = useState(false); + const [secretModalOpen, setSecretModalOpen] = useState(false); + const [newClientId, setNewClientId] = useState(""); + const [newClientSecret, setNewClientSecret] = useState(""); + + const handleCreated = (clientId: string, secret: string) => { + setNewClientId(clientId); + setNewClientSecret(secret); + setSecretModalOpen(true); + }; + + return ( + + + + + + + + + setCreateModalOpen(false)} + onCreated={handleCreated} + /> + setSecretModalOpen(false)} + /> + + ); +}; + +export default ApiClientsPage; diff --git a/clients/admin-ui/src/types/api/index.ts b/clients/admin-ui/src/types/api/index.ts index 1da841285f4..5d66805c5de 100644 --- a/clients/admin-ui/src/types/api/index.ts +++ b/clients/admin-ui/src/types/api/index.ts @@ -90,6 +90,9 @@ export type { ClassifyStatusUpdatePayload } from "./models/ClassifyStatusUpdateP export type { ClassifySystem } from "./models/ClassifySystem"; export type { ClientConfig } from "./models/ClientConfig"; export type { ClientCreatedResponse } from "./models/ClientCreatedResponse"; +export type { ClientResponse } from "./models/ClientResponse"; +export type { ClientSecretRotateResponse } from "./models/ClientSecretRotateResponse"; +export type { Page_ClientResponse_ } from "./models/Page_ClientResponse_"; export type { CloudConfig } from "./models/CloudConfig"; export type { CollectionAddressResponse } from "./models/CollectionAddressResponse"; export type { CollectionMeta } from "./models/CollectionMeta"; diff --git a/clients/admin-ui/src/types/api/models/ClientResponse.ts b/clients/admin-ui/src/types/api/models/ClientResponse.ts new file mode 100644 index 00000000000..edcbf36509f --- /dev/null +++ b/clients/admin-ui/src/types/api/models/ClientResponse.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Response schema for an OAuth client. Never includes the client secret. + */ +export type ClientResponse = { + client_id: string; + name?: string | null; + description?: string | null; + scopes: Array; +}; diff --git a/clients/admin-ui/src/types/api/models/ClientSecretRotateResponse.ts b/clients/admin-ui/src/types/api/models/ClientSecretRotateResponse.ts new file mode 100644 index 00000000000..1f2be87522b --- /dev/null +++ b/clients/admin-ui/src/types/api/models/ClientSecretRotateResponse.ts @@ -0,0 +1,11 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +/** + * Response schema for secret rotation. Secret is shown exactly once. + */ +export type ClientSecretRotateResponse = { + client_id: string; + client_secret: string; +}; diff --git a/clients/admin-ui/src/types/api/models/Page_ClientResponse_.ts b/clients/admin-ui/src/types/api/models/Page_ClientResponse_.ts new file mode 100644 index 00000000000..1e0cb1ee1a4 --- /dev/null +++ b/clients/admin-ui/src/types/api/models/Page_ClientResponse_.ts @@ -0,0 +1,13 @@ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ + +import type { ClientResponse } from "./ClientResponse"; + +export type Page_ClientResponse_ = { + items: Array; + total: number | null; + page: number | null; + size: number | null; + pages?: number | null; +}; diff --git a/clients/fidesui/src/index.ts b/clients/fidesui/src/index.ts index bc2b446125e..9178fd847e8 100644 --- a/clients/fidesui/src/index.ts +++ b/clients/fidesui/src/index.ts @@ -228,6 +228,7 @@ export type { TableProps, TabsProps, TooltipProps, + TransferProps, TreeDataNode, TreeProps, UploadFile, @@ -270,6 +271,7 @@ export { Switch, Tabs, TimePicker, + Transfer, Tree, TreeSelect, Upload, From 32e463de303b81fc465a12cdf05ae6506e30409f Mon Sep 17 00:00:00 2001 From: Thomas Van Dort Date: Tue, 24 Mar 2026 13:37:01 -0400 Subject: [PATCH 2/5] Remove create flow from list page (added in follow-up PR) --- .../admin-ui/src/pages/api-clients/index.tsx | 40 ------------------- 1 file changed, 40 deletions(-) diff --git a/clients/admin-ui/src/pages/api-clients/index.tsx b/clients/admin-ui/src/pages/api-clients/index.tsx index 5cea1e01b3d..4d297d08a10 100644 --- a/clients/admin-ui/src/pages/api-clients/index.tsx +++ b/clients/admin-ui/src/pages/api-clients/index.tsx @@ -1,27 +1,10 @@ -import { Button, Flex } from "fidesui"; import type { NextPage } from "next"; -import { useState } from "react"; import FixedLayout from "~/features/common/FixedLayout"; import PageHeader from "~/features/common/PageHeader"; -import Restrict from "~/features/common/Restrict"; -import ClientSecretModal from "~/features/oauth/ClientSecretModal"; -import CreateOAuthClientModal from "~/features/oauth/CreateOAuthClientModal"; import OAuthClientsList from "~/features/oauth/OAuthClientsTable"; -import { ScopeRegistryEnum } from "~/types/api"; const ApiClientsPage: NextPage = () => { - const [createModalOpen, setCreateModalOpen] = useState(false); - const [secretModalOpen, setSecretModalOpen] = useState(false); - const [newClientId, setNewClientId] = useState(""); - const [newClientSecret, setNewClientSecret] = useState(""); - - const handleCreated = (clientId: string, secret: string) => { - setNewClientId(clientId); - setNewClientSecret(secret); - setSecretModalOpen(true); - }; - return ( { breadcrumbItems={[{ title: "All API clients" }]} isSticky={false} /> - - - - - - setCreateModalOpen(false)} - onCreated={handleCreated} - /> - setSecretModalOpen(false)} - /> ); }; From 3c0d92ef86b74f0dae8a36a84a1b5ea265faf83a Mon Sep 17 00:00:00 2001 From: Thomas Van Dort Date: Tue, 24 Mar 2026 14:43:31 -0400 Subject: [PATCH 3/5] Handle clipboard write failure in ClipboardButton --- clients/admin-ui/src/features/common/ClipboardButton.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/clients/admin-ui/src/features/common/ClipboardButton.tsx b/clients/admin-ui/src/features/common/ClipboardButton.tsx index 384b521680d..b19f8c8e386 100644 --- a/clients/admin-ui/src/features/common/ClipboardButton.tsx +++ b/clients/admin-ui/src/features/common/ClipboardButton.tsx @@ -10,8 +10,10 @@ const useClipboardButton = (copyText: string) => { const [tooltipText, setTooltipText] = useState(TooltipText.COPY); const handleClick = () => { - setTooltipText(TooltipText.COPIED); - navigator.clipboard.writeText(copyText); + navigator.clipboard.writeText(copyText).then( + () => setTooltipText(TooltipText.COPIED), + () => setTooltipText(TooltipText.COPY), + ); }; return { From 62895ceb99277b30572b4c75428d33bffd10b044 Mon Sep 17 00:00:00 2001 From: Thomas Van Dort Date: Tue, 24 Mar 2026 16:23:31 -0400 Subject: [PATCH 4/5] Move useHasPermission out of ClientListItem into parent --- .../src/features/oauth/OAuthClientsTable.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/clients/admin-ui/src/features/oauth/OAuthClientsTable.tsx b/clients/admin-ui/src/features/oauth/OAuthClientsTable.tsx index 266d4aeb380..c79c7e3455d 100644 --- a/clients/admin-ui/src/features/oauth/OAuthClientsTable.tsx +++ b/clients/admin-ui/src/features/oauth/OAuthClientsTable.tsx @@ -15,9 +15,13 @@ import { useListOAuthClientsQuery } from "./oauth-clients.slice"; const { Text } = Typography; -const ClientListItem = ({ client }: { client: ClientResponse }) => { - const canUpdate = useHasPermission([ScopeRegistryEnum.CLIENT_UPDATE]); - +const ClientListItem = ({ + client, + canUpdate, +}: { + client: ClientResponse; + canUpdate: boolean; +}) => { return ( { const OAuthClientsList = () => { const { data, total, isLoading, page, pageSize, setPage, setPageSize } = useOAuthClientsList(); + const canUpdate = useHasPermission([ScopeRegistryEnum.CLIENT_UPDATE]); return (
@@ -107,7 +112,9 @@ const OAuthClientsList = () => {
), }} - renderItem={(client) => } + renderItem={(client) => ( + + )} /> Date: Wed, 25 Mar 2026 17:03:15 -0400 Subject: [PATCH 5/5] add changelog for PR #7747 --- changelog/7747-oauth-clients-list-page.yaml | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 changelog/7747-oauth-clients-list-page.yaml diff --git a/changelog/7747-oauth-clients-list-page.yaml b/changelog/7747-oauth-clients-list-page.yaml new file mode 100644 index 00000000000..51772b7af3d --- /dev/null +++ b/changelog/7747-oauth-clients-list-page.yaml @@ -0,0 +1,4 @@ +type: Added +description: Added OAuth API clients list page with paginated table and nav entry +pr: 7747 +labels: []