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: []
diff --git a/clients/admin-ui/src/features/common/ClipboardButton.tsx b/clients/admin-ui/src/features/common/ClipboardButton.tsx
index 78238986a54..b19f8c8e386 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,13 @@ 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).then(
+ () => setTooltipText(TooltipText.COPIED),
+ () => setTooltipText(TooltipText.COPY),
+ );
};
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..c79c7e3455d
--- /dev/null
+++ b/clients/admin-ui/src/features/oauth/OAuthClientsTable.tsx
@@ -0,0 +1,141 @@
+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,
+ canUpdate,
+}: {
+ client: ClientResponse;
+ canUpdate: boolean;
+}) => {
+ 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();
+ const canUpdate = useHasPermission([ScopeRegistryEnum.CLIENT_UPDATE]);
+
+ 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..4d297d08a10
--- /dev/null
+++ b/clients/admin-ui/src/pages/api-clients/index.tsx
@@ -0,0 +1,20 @@
+import type { NextPage } from "next";
+
+import FixedLayout from "~/features/common/FixedLayout";
+import PageHeader from "~/features/common/PageHeader";
+import OAuthClientsList from "~/features/oauth/OAuthClientsTable";
+
+const ApiClientsPage: NextPage = () => {
+ return (
+
+
+
+
+ );
+};
+
+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,