diff --git a/.fides/db_dataset.yml b/.fides/db_dataset.yml index 5659af9d491..73b59a1da1f 100644 --- a/.fides/db_dataset.yml +++ b/.fides/db_dataset.yml @@ -1246,6 +1246,23 @@ dataset: - name: replaceable description: 'Fides Generated Description for Column: replaceable' data_categories: [system.operations] + - name: dashboard_snapshot + data_categories: [system.operations] + fields: + - name: created_at + data_categories: [system.operations] + - name: id + data_categories: [system.operations] + - name: metadata + data_categories: [system.operations] + - name: metric_key + data_categories: [system.operations] + - name: snapshot_date + data_categories: [system.operations] + - name: updated_at + data_categories: [system.operations] + - name: value + data_categories: [system.operations] - name: datasetconfig data_categories: [] fields: diff --git a/changelog/7700-pbac-ui-management.yaml b/changelog/7700-pbac-ui-management.yaml new file mode 100644 index 00000000000..f928a866674 --- /dev/null +++ b/changelog/7700-pbac-ui-management.yaml @@ -0,0 +1,4 @@ +type: Added +description: Add PBAC management UI for data purposes, consumers, and query log configuration +pr: 7700 +labels: [] diff --git a/clients/admin-ui/src/features/common/api.slice.ts b/clients/admin-ui/src/features/common/api.slice.ts index b7d4b45f67b..540950da039 100644 --- a/clients/admin-ui/src/features/common/api.slice.ts +++ b/clients/admin-ui/src/features/common/api.slice.ts @@ -71,6 +71,9 @@ export const baseApi = createApi({ "Property", "Property-Specific Messaging Templates", "Purpose", + "DataPurpose", + "DataConsumer", + "QueryLogConfig", "Shared Monitor Configs", "System", "System Assets", 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 7d54f9a08be..5fcd091c070 100644 --- a/clients/admin-ui/src/features/common/nav/nav-config.tsx +++ b/clients/admin-ui/src/features/common/nav/nav-config.tsx @@ -254,6 +254,20 @@ export const NAV_CONFIG: NavConfigGroup[] = [ ScopeRegistryEnum.CONFIG_UPDATE, ], }, + { + title: "Data purposes", + path: routes.DATA_PURPOSES_ROUTE, + requiresPlus: true, + requiresFlag: "alphaPurposeBasedAccessControl", + scopes: [ScopeRegistryEnum.DATA_PURPOSE_READ], + }, + { + title: "Data consumers", + path: routes.DATA_CONSUMERS_ROUTE, + requiresPlus: true, + requiresFlag: "alphaPurposeBasedAccessControl", + scopes: [ScopeRegistryEnum.DATA_CONSUMER_READ], + }, { title: "Access policies", path: routes.ACCESS_POLICIES_ROUTE, @@ -392,6 +406,12 @@ if (process.env.NEXT_PUBLIC_APP_ENV === "development") { scopes: [ScopeRegistryEnum.DEVELOPER_READ], requiresPlus: true, }, + { + title: "Seed Data", + path: routes.SEED_DATA_ROUTE, + scopes: [ScopeRegistryEnum.DEVELOPER_READ], + requiresPlus: true, + }, { title: "Test monitors", path: routes.TEST_MONITORS_ROUTE, diff --git a/clients/admin-ui/src/features/common/nav/routes.ts b/clients/admin-ui/src/features/common/nav/routes.ts index 50a35ab4d48..a7bedce7067 100644 --- a/clients/admin-ui/src/features/common/nav/routes.ts +++ b/clients/admin-ui/src/features/common/nav/routes.ts @@ -105,6 +105,7 @@ export const ERRORS_POC_ROUTE = "/poc/error"; export const TABLE_MIGRATION_POC_ROUTE = "/poc/table-migration"; export const FIDES_JS_DOCS = "/fides-js-docs"; export const PROMPT_EXPLORER_ROUTE = "/poc/prompt-explorer"; +export const SEED_DATA_ROUTE = "/poc/seed-data"; export const TEST_MONITORS_ROUTE = "/poc/test-monitors"; // RBAC routes @@ -120,6 +121,16 @@ export const SANDBOX_PRIVACY_NOTICES_ROUTE = "/sandbox/privacy-notices"; export const PRIVACY_ASSESSMENTS_ROUTE = "/privacy-assessments"; export const PRIVACY_ASSESSMENTS_DETAIL_ROUTE = "/privacy-assessments/[id]"; +// Data Purposes (Core Configuration) +export const DATA_PURPOSES_ROUTE = "/data-purposes"; +export const DATA_PURPOSES_NEW_ROUTE = "/data-purposes/new"; +export const DATA_PURPOSES_EDIT_ROUTE = "/data-purposes/[fidesKey]"; + +// Data Consumers (Core Configuration) +export const DATA_CONSUMERS_ROUTE = "/data-consumers"; +export const DATA_CONSUMERS_NEW_ROUTE = "/data-consumers/new"; +export const DATA_CONSUMERS_EDIT_ROUTE = "/data-consumers/[id]"; + // Access Policies (Core Configuration) export const ACCESS_POLICIES_ROUTE = "/access-policies"; export const ACCESS_POLICIES_ONBOARDING_ROUTE = "/access-policies/onboarding"; diff --git a/clients/admin-ui/src/features/dashboard/constants.ts b/clients/admin-ui/src/features/dashboard/constants.ts index 12f8f91b3e3..cdae5f20750 100644 --- a/clients/admin-ui/src/features/dashboard/constants.ts +++ b/clients/admin-ui/src/features/dashboard/constants.ts @@ -74,8 +74,15 @@ export const ACTION_CTA: Record< }, [ActionType.DSR_ACTION]: { label: "View request", - route: (d) => - d.request_id ? `/privacy-requests/${d.request_id}` : "/privacy-requests", + route: (d) => { + if (d.request_id) { + return `/privacy-requests/${d.request_id}`; + } + if (d.is_overdue) { + return "/privacy-requests?is_overdue=true"; + } + return "/privacy-requests"; + }, }, [ActionType.SYSTEM_REVIEW]: { label: "Review system", diff --git a/clients/admin-ui/src/features/data-consumers/DataConsumerActionsCell.tsx b/clients/admin-ui/src/features/data-consumers/DataConsumerActionsCell.tsx new file mode 100644 index 00000000000..4e1de1678cf --- /dev/null +++ b/clients/admin-ui/src/features/data-consumers/DataConsumerActionsCell.tsx @@ -0,0 +1,40 @@ +import { Button, Flex, Icons } from "fidesui"; +import { useRouter } from "next/router"; + +import { DATA_CONSUMERS_ROUTE } from "~/features/common/nav/routes"; +import Restrict from "~/features/common/Restrict"; +import { ScopeRegistryEnum } from "~/types/api"; + +import { DataConsumer } from "./data-consumer.slice"; +import DeleteDataConsumerModal from "./DeleteDataConsumerModal"; + +interface Props { + consumer: DataConsumer; +} + +const DataConsumerActionsCell = ({ consumer }: Props) => { + const router = useRouter(); + + const handleEdit = () => { + router.push(`${DATA_CONSUMERS_ROUTE}/${consumer.id}`); + }; + + return ( + + + + + + + ); +}; + +export default DataConsumerForm; diff --git a/clients/admin-ui/src/features/data-consumers/DataConsumersTable.tsx b/clients/admin-ui/src/features/data-consumers/DataConsumersTable.tsx new file mode 100644 index 00000000000..5e515915540 --- /dev/null +++ b/clients/admin-ui/src/features/data-consumers/DataConsumersTable.tsx @@ -0,0 +1,43 @@ +import { Button, Flex, Table } from "fidesui"; +import { useRouter } from "next/router"; + +import { DebouncedSearchInput } from "~/features/common/DebouncedSearchInput"; +import { DATA_CONSUMERS_NEW_ROUTE } from "~/features/common/nav/routes"; +import Restrict from "~/features/common/Restrict"; +import { ScopeRegistryEnum } from "~/types/api"; + +import useDataConsumersTable from "./useDataConsumersTable"; + +const DataConsumersTable = () => { + const router = useRouter(); + const { tableProps, columns, searchQuery, updateSearch } = + useDataConsumersTable(); + + return ( + + + + + + + + + + ); +}; + +export default DataConsumersTable; diff --git a/clients/admin-ui/src/features/data-consumers/DeleteDataConsumerModal.tsx b/clients/admin-ui/src/features/data-consumers/DeleteDataConsumerModal.tsx new file mode 100644 index 00000000000..178f50786ee --- /dev/null +++ b/clients/admin-ui/src/features/data-consumers/DeleteDataConsumerModal.tsx @@ -0,0 +1,56 @@ +import { Button, Icons, useMessage, useModal } from "fidesui"; +import { useRouter } from "next/router"; + +import { getErrorMessage } from "~/features/common/helpers"; +import { DATA_CONSUMERS_ROUTE } from "~/features/common/nav/routes"; +import { RTKErrorResult } from "~/types/errors/api"; + +import { + DataConsumer, + useDeleteDataConsumerMutation, +} from "./data-consumer.slice"; + +interface Props { + consumer: DataConsumer; +} + +const DeleteDataConsumerModal = ({ consumer }: Props) => { + const message = useMessage(); + const modal = useModal(); + const router = useRouter(); + const [deleteDataConsumer] = useDeleteDataConsumerMutation(); + + const handleDelete = (e: React.MouseEvent) => { + e.stopPropagation(); + modal.confirm({ + title: `Delete ${consumer.name}`, + content: `You are about to delete data consumer "${consumer.name}". This action is not reversible. Are you sure you want to proceed?`, + okText: "Delete", + okType: "danger", + centered: true, + onOk: async () => { + try { + await deleteDataConsumer(consumer.id).unwrap(); + message.success( + `Data consumer "${consumer.name}" deleted successfully`, + ); + router.push(DATA_CONSUMERS_ROUTE); + } catch (error) { + message.error(getErrorMessage(error as RTKErrorResult["error"])); + } + }, + }); + }; + + return ( + + + + } + data-testid="no-results-notice" + /> + ), + }, + }, + }), + [totalRows, isLoading, isFetching, items, router], + ); + + const { tableProps } = useAntTable(tableState, antTableConfig); + + const columns: ColumnsType = useMemo( + () => [ + { + title: "Name", + dataIndex: "name", + key: "name", + render: (_, { name, id }) => ( + {name} + ), + }, + { + title: "Type", + dataIndex: "type", + key: "type", + render: (_, { type }) => ( + {CONSUMER_TYPE_LABELS[type as DataConsumerType] ?? type} + ), + }, + { + title: "Contact", + dataIndex: "contact_email", + key: "contact_email", + render: (_, { contact_email }) => contact_email || "N/A", + }, + { + title: "Purposes", + dataIndex: "purpose_fides_keys", + key: "purpose_fides_keys", + render: (_, { purpose_fides_keys }) => + purpose_fides_keys && purpose_fides_keys.length > 0 ? ( + + {purpose_fides_keys.map((key) => ( + {key} + ))} + + ) : ( + "N/A" + ), + }, + { + title: "Tags", + dataIndex: "tags", + key: "tags", + render: (_, { tags }) => + tags && tags.length > 0 ? ( + + {tags.map((tag) => ( + {tag} + ))} + + ) : ( + "N/A" + ), + }, + { + title: "Actions", + dataIndex: "actions", + key: "actions", + render: (_, consumer) => ( + + ), + }, + ], + [], + ); + + return { tableProps, columns, error, searchQuery, updateSearch }; +}; + +export default useDataConsumersTable; diff --git a/clients/admin-ui/src/features/data-purposes/DataPurposeActionsCell.tsx b/clients/admin-ui/src/features/data-purposes/DataPurposeActionsCell.tsx new file mode 100644 index 00000000000..39813661e7b --- /dev/null +++ b/clients/admin-ui/src/features/data-purposes/DataPurposeActionsCell.tsx @@ -0,0 +1,47 @@ +import { Button, Flex, Icons } from "fidesui"; +import { useRouter } from "next/router"; + +import { DATA_PURPOSES_ROUTE } from "~/features/common/nav/routes"; +import Restrict from "~/features/common/Restrict"; +import { DataPurpose } from "~/features/data-purposes/data-purpose.slice"; +import DeleteDataPurposeModal from "~/features/data-purposes/DeleteDataPurposeModal"; +import { ScopeRegistryEnum } from "~/types/api"; + +interface Props { + purpose: DataPurpose; +} + +const DataPurposeActionsCell = ({ purpose }: Props) => { + const router = useRouter(); + + const handleEdit = () => { + router.push(`${DATA_PURPOSES_ROUTE}/${purpose.fides_key}`); + }; + + return ( + + + + + + + ); +}; + +export default DataPurposeForm; diff --git a/clients/admin-ui/src/features/data-purposes/DataPurposesTable.tsx b/clients/admin-ui/src/features/data-purposes/DataPurposesTable.tsx new file mode 100644 index 00000000000..85e5b26bf8c --- /dev/null +++ b/clients/admin-ui/src/features/data-purposes/DataPurposesTable.tsx @@ -0,0 +1,42 @@ +import { Button, Flex, Table } from "fidesui"; +import { useRouter } from "next/router"; + +import { DebouncedSearchInput } from "~/features/common/DebouncedSearchInput"; +import { DATA_PURPOSES_NEW_ROUTE } from "~/features/common/nav/routes"; +import Restrict from "~/features/common/Restrict"; +import useDataPurposesTable from "~/features/data-purposes/useDataPurposesTable"; +import { ScopeRegistryEnum } from "~/types/api"; + +const DataPurposesTable = () => { + const router = useRouter(); + const { tableProps, columns, searchQuery, updateSearch } = + useDataPurposesTable(); + + return ( + + + + + + + +
+ + ); +}; + +export default DataPurposesTable; diff --git a/clients/admin-ui/src/features/data-purposes/DeleteDataPurposeModal.tsx b/clients/admin-ui/src/features/data-purposes/DeleteDataPurposeModal.tsx new file mode 100644 index 00000000000..696e3bce4d2 --- /dev/null +++ b/clients/admin-ui/src/features/data-purposes/DeleteDataPurposeModal.tsx @@ -0,0 +1,62 @@ +import { useMessage, useModal } from "fidesui"; +import React from "react"; + +import { getErrorMessage } from "~/features/common/helpers"; +import Restrict from "~/features/common/Restrict"; +import { + DataPurpose, + useDeleteDataPurposeMutation, +} from "~/features/data-purposes/data-purpose.slice"; +import { ScopeRegistryEnum } from "~/types/api"; +import { RTKErrorResult } from "~/types/errors/api"; + +interface Props { + purpose: DataPurpose; + triggerComponent: React.ReactElement; +} + +const DeleteDataPurposeModal = ({ purpose, triggerComponent }: Props) => { + const message = useMessage(); + const modal = useModal(); + const [deleteDataPurpose] = useDeleteDataPurposeMutation(); + + const handleModalOpen = (e: React.MouseEvent) => { + e.stopPropagation(); + modal.confirm({ + title: `Delete ${purpose.name}`, + content: ( + + You are about to delete data purpose "{purpose.name}". This + action is not reversible. Are you sure you want to proceed? + + ), + okText: "Delete", + centered: true, + onOk: async () => { + try { + await deleteDataPurpose({ + fidesKey: purpose.fides_key, + force: true, + }).unwrap(); + message.success( + `Data purpose "${purpose.name}" deleted successfully`, + ); + } catch (error) { + message.error(getErrorMessage(error as RTKErrorResult["error"])); + } + }, + }); + }; + + return ( + + + {React.cloneElement(triggerComponent, { + onClick: handleModalOpen, + })} + + + ); +}; + +export default DeleteDataPurposeModal; diff --git a/clients/admin-ui/src/features/data-purposes/constants.ts b/clients/admin-ui/src/features/data-purposes/constants.ts new file mode 100644 index 00000000000..a6afc1bc69b --- /dev/null +++ b/clients/admin-ui/src/features/data-purposes/constants.ts @@ -0,0 +1 @@ +export const DATA_PURPOSE_FORM_ID = "data-purpose-form"; diff --git a/clients/admin-ui/src/features/data-purposes/data-purpose.slice.ts b/clients/admin-ui/src/features/data-purposes/data-purpose.slice.ts new file mode 100644 index 00000000000..1c235a18d99 --- /dev/null +++ b/clients/admin-ui/src/features/data-purposes/data-purpose.slice.ts @@ -0,0 +1,90 @@ +import { baseApi } from "~/features/common/api.slice"; + +interface DataPurposeParams { + page?: number; + size?: number; + search?: string; + data_use?: string; +} + +export interface DataPurpose { + id?: string; + fides_key: string; + name: string; + description?: string; + data_use: string; + data_subject?: string; + data_categories?: string[]; + legal_basis_for_processing?: string; + flexible_legal_basis_for_processing?: boolean; + special_category_legal_basis?: string; + impact_assessment_location?: string; + retention_period?: string; + features?: string[]; + created_at?: string; + updated_at?: string; +} + +export interface DataPurposePage { + items: DataPurpose[]; + total: number; + page: number; + size: number; + pages: number; +} + +export const dataPurposesApi = baseApi.injectEndpoints({ + endpoints: (builder) => ({ + getAllDataPurposes: builder.query({ + query: (params) => ({ + url: `data-purpose`, + params, + }), + providesTags: ["DataPurpose"], + }), + getDataPurposeByKey: builder.query({ + query: (fidesKey) => ({ + url: `data-purpose/${fidesKey}`, + }), + providesTags: ["DataPurpose"], + }), + createDataPurpose: builder.mutation>({ + query: (body) => ({ + url: `data-purpose`, + method: "POST", + body, + }), + invalidatesTags: ["DataPurpose"], + }), + updateDataPurpose: builder.mutation< + DataPurpose, + { fidesKey: string } & Partial + >({ + query: ({ fidesKey, ...body }) => ({ + url: `data-purpose/${fidesKey}`, + method: "PUT", + body, + }), + invalidatesTags: ["DataPurpose"], + }), + deleteDataPurpose: builder.mutation< + void, + { fidesKey: string; force?: boolean } + >({ + query: ({ fidesKey, force }) => ({ + url: `data-purpose/${fidesKey}`, + method: "DELETE", + params: force ? { force: true } : undefined, + }), + invalidatesTags: ["DataPurpose", "DataConsumer"], + }), + }), +}); + +export const { + useGetAllDataPurposesQuery, + useGetDataPurposeByKeyQuery, + useCreateDataPurposeMutation, + useUpdateDataPurposeMutation, + useDeleteDataPurposeMutation, +} = dataPurposesApi; diff --git a/clients/admin-ui/src/features/data-purposes/index.ts b/clients/admin-ui/src/features/data-purposes/index.ts new file mode 100644 index 00000000000..c82ff9818d8 --- /dev/null +++ b/clients/admin-ui/src/features/data-purposes/index.ts @@ -0,0 +1 @@ +export * from "./data-purpose.slice"; diff --git a/clients/admin-ui/src/features/data-purposes/useDataPurposesTable.tsx b/clients/admin-ui/src/features/data-purposes/useDataPurposesTable.tsx new file mode 100644 index 00000000000..15b713560af --- /dev/null +++ b/clients/admin-ui/src/features/data-purposes/useDataPurposesTable.tsx @@ -0,0 +1,133 @@ +import { + Button, + ColumnsType, + Empty, + Flex, + Space, + Tag, + Typography, +} from "fidesui"; +import { useRouter } from "next/router"; +import { useMemo } from "react"; + +import { DATA_PURPOSES_NEW_ROUTE } from "~/features/common/nav/routes"; +import { LinkCell } from "~/features/common/table/cells/LinkCell"; +import { useAntTable, useTableState } from "~/features/common/table/hooks"; +import { + DataPurpose, + useGetAllDataPurposesQuery, +} from "~/features/data-purposes/data-purpose.slice"; +import DataPurposeActionsCell from "~/features/data-purposes/DataPurposeActionsCell"; + +const useDataPurposesTable = () => { + const router = useRouter(); + + const tableState = useTableState({ + pagination: { + defaultPageSize: 25, + pageSizeOptions: [25, 50, 100], + }, + search: { + defaultSearchQuery: "", + }, + }); + + const { pageIndex, pageSize, searchQuery, updateSearch } = tableState; + + const { data, error, isLoading, isFetching } = useGetAllDataPurposesQuery({ + page: pageIndex, + size: pageSize, + search: searchQuery, + }); + + const items = useMemo(() => data?.items ?? [], [data?.items]); + const totalRows = data?.total ?? 0; + + const antTableConfig = useMemo( + () => ({ + dataSource: items, + totalRows, + isLoading, + isFetching, + getRowKey: (record: DataPurpose) => record.fides_key, + customTableProps: { + locale: { + emptyText: ( + + + No data purposes found. + + + + + + } + data-testid="no-results-notice" + /> + ), + }, + }, + }), + [totalRows, isLoading, isFetching, items, router], + ); + + const { tableProps } = useAntTable(tableState, antTableConfig); + + const columns: ColumnsType = useMemo( + () => [ + { + title: "Name", + dataIndex: "name", + key: "name", + render: (_, { name, fides_key }) => ( + {name} + ), + }, + { + title: "Key", + dataIndex: "fides_key", + key: "fides_key", + }, + { + title: "Data use", + dataIndex: "data_use", + key: "data_use", + render: (_, { data_use }) => {data_use}, + }, + { + title: "Categories", + dataIndex: "data_categories", + key: "data_categories", + render: (_, { data_categories }) => + data_categories && data_categories.length > 0 ? ( + + {data_categories.map((cat) => ( + {cat} + ))} + + ) : ( + "N/A" + ), + }, + { + title: "Actions", + dataIndex: "actions", + key: "actions", + render: (_, purpose) => , + }, + ], + [], + ); + + return { tableProps, columns, error, searchQuery, updateSearch }; +}; + +export default useDataPurposesTable; diff --git a/clients/admin-ui/src/features/integrations/add-integration/allIntegrationTypes.tsx b/clients/admin-ui/src/features/integrations/add-integration/allIntegrationTypes.tsx index d6ebeef4d31..617082f23a9 100644 --- a/clients/admin-ui/src/features/integrations/add-integration/allIntegrationTypes.tsx +++ b/clients/admin-ui/src/features/integrations/add-integration/allIntegrationTypes.tsx @@ -58,6 +58,18 @@ const INTEGRATION_TYPE_MAP: { [K in ConnectionType]?: IntegrationTypeInfo } = { [ConnectionType.WEBSITE]: WEBSITE_INTEGRATION_TYPE_INFO, [ConnectionType.POSTGRES]: POSTGRES_TYPE_INFO, [ConnectionType.MANUAL_TASK]: MANUAL_TYPE_INFO, + [ConnectionType.TEST_DATASTORE]: { + placeholder: { + name: "Mock query log", + key: "test_datastore_placeholder", + connection_type: ConnectionType.TEST_DATASTORE, + access: AccessLevel.READ, + created_at: "", + }, + category: ConnectionCategory.DATA_WAREHOUSE, + tags: ["Query Logging", "Mock"], + enabledFeatures: [IntegrationFeature.QUERY_LOGGING], + }, }; export const INTEGRATION_TYPE_LIST: IntegrationTypeInfo[] = [ diff --git a/clients/admin-ui/src/features/integrations/configure-query-log/QueryLogConfigTab.tsx b/clients/admin-ui/src/features/integrations/configure-query-log/QueryLogConfigTab.tsx new file mode 100644 index 00000000000..b293fe8137e --- /dev/null +++ b/clients/admin-ui/src/features/integrations/configure-query-log/QueryLogConfigTab.tsx @@ -0,0 +1,225 @@ +import { + Button, + Flex, + Form, + Select, + Skeleton, + Switch, + Typography, + useMessage, +} from "fidesui"; +import { useCallback, useMemo } from "react"; + +import { getErrorMessage } from "~/features/common/helpers"; +import { RTKErrorResult } from "~/types/errors/api"; + +import { POLL_INTERVAL_OPTIONS, PollInterval } from "./constants"; +import { + useCreateQueryLogConfigMutation, + useGetQueryLogConfigsQuery, + useTestQueryLogConnectionMutation, + useTriggerQueryLogPollMutation, + useUpdateQueryLogConfigMutation, +} from "./query-log-config.slice"; + +interface FormValues { + enabled: boolean; + poll_interval_seconds: number; +} + +const DEFAULT_VALUES: FormValues = { + enabled: false, + poll_interval_seconds: PollInterval.FIVE_MINUTES, +}; + +interface QueryLogConfigTabProps { + integration: { key: string; name?: string }; +} + +const QueryLogConfigTab = ({ integration }: QueryLogConfigTabProps) => { + const [form] = Form.useForm(); + const message = useMessage(); + + const { data: configsResponse, isLoading: isLoadingConfigs } = + useGetQueryLogConfigsQuery({ + connection_config_key: integration.key, + page: 1, + size: 1, + }); + + const existingConfig = useMemo( + () => configsResponse?.items?.[0], + [configsResponse], + ); + + const [createConfig, { isLoading: isCreating }] = + useCreateQueryLogConfigMutation(); + const [updateConfig, { isLoading: isUpdating }] = + useUpdateQueryLogConfigMutation(); + const [testConnection, { isLoading: isTesting }] = + useTestQueryLogConnectionMutation(); + const [triggerPoll, { isLoading: isPolling }] = + useTriggerQueryLogPollMutation(); + + const isSaving = isCreating || isUpdating; + + const enabled = Form.useWatch("enabled", form); + + const initialValues: FormValues = useMemo( + () => ({ + enabled: existingConfig?.enabled ?? DEFAULT_VALUES.enabled, + poll_interval_seconds: + existingConfig?.poll_interval_seconds ?? + DEFAULT_VALUES.poll_interval_seconds, + }), + [existingConfig], + ); + + const handleSubmit = useCallback( + async (values: FormValues) => { + try { + if (values.enabled && !existingConfig) { + // Create new config + await createConfig({ + name: `${integration.name ?? integration.key} query log`, + connection_config_key: integration.key, + enabled: true, + poll_interval_seconds: values.poll_interval_seconds, + }).unwrap(); + message.success("Query logging enabled"); + } else if (values.enabled && existingConfig) { + // Update existing config + await updateConfig({ + configKey: existingConfig.key, + enabled: true, + poll_interval_seconds: values.poll_interval_seconds, + }).unwrap(); + message.success("Query logging settings updated"); + } else if (!values.enabled && existingConfig) { + // Disable — keep the config but stop polling + await updateConfig({ + configKey: existingConfig.key, + enabled: false, + }).unwrap(); + message.success("Query logging disabled"); + } + } catch (err) { + message.error(getErrorMessage(err as RTKErrorResult["error"])); + } + }, + [existingConfig, integration, createConfig, updateConfig, message], + ); + + const handleTest = useCallback(async () => { + if (!existingConfig) { + return; + } + try { + const result = await testConnection(existingConfig.key).unwrap(); + if (result.success) { + message.success(result.message); + } else { + message.error(result.message); + } + } catch (err) { + message.error( + getErrorMessage( + err as RTKErrorResult["error"], + "Connection test failed", + ), + ); + } + }, [existingConfig, testConnection, message]); + + const handlePoll = useCallback(async () => { + if (!existingConfig) { + return; + } + try { + const result = await triggerPoll(existingConfig.key).unwrap(); + message.success(`${result.entries_processed} entries processed`); + } catch (err) { + message.error( + getErrorMessage(err as RTKErrorResult["error"], "Poll failed"), + ); + } + }, [existingConfig, triggerPoll, message]); + + return ( +
+ + Enable query logging to track and audit database queries for this + integration. + + + {isLoadingConfigs ? ( + + ) : ( +
+ + + + + {enabled && ( + +
` with search, pagination, filters +4. **Add page** — Formik form with validation +5. **Edit page** — Same form, pre-populated, with delete button +6. **Nav registration** — Under "Core configuration" with feature flag + +### File Structure + +Per Talos guidance (`features/my-feature/` template): + +``` +clients/admin-ui/src/ +├── features/ +│ ├── data-purposes/ +│ │ ├── types.d.ts # interfaces and type overrides +│ │ ├── constants.ts # enums, label maps, display config +│ │ ├── data-purpose.slice.ts # RTK Query endpoints +│ │ ├── DataPurposesTable.tsx # root feature component (Ant Table) +│ │ ├── useDataPurposesTable.tsx # table hook (state, columns, data) +│ │ ├── DataPurposeForm.tsx # Formik add/edit form +│ │ ├── DeleteDataPurposeModal.tsx # confirm delete modal +│ │ ├── DataPurposeActionsCell.tsx # edit/delete cell in table +│ │ └── index.ts # barrel export of public API +│ │ +│ └── data-consumers/ +│ ├── types.d.ts +│ ├── constants.ts +│ ├── data-consumer.slice.ts +│ ├── DataConsumersTable.tsx +│ ├── useDataConsumersTable.tsx +│ ├── DataConsumerForm.tsx # includes purpose assignment +│ ├── DeleteDataConsumerModal.tsx +│ ├── DataConsumerActionsCell.tsx +│ └── index.ts +│ +├── pages/ # thin routing shells only +│ ├── data-purposes/ +│ │ ├── index.tsx # list page +│ │ ├── new.tsx # add page +│ │ └── [fidesKey].tsx # edit page +│ │ +│ └── data-consumers/ +│ ├── index.tsx +│ ├── new.tsx +│ └── [id].tsx +``` + +Notes on Talos conventions: +- **`types.d.ts`** — interfaces and type aliases (including `Omit`-based overrides of generated types) +- **`constants.ts`** — TypeScript enums, label/color maps, static option arrays +- **`index.ts`** — required barrel file in every feature directory +- **Pages** are thin shells — fetch data, handle loading/error, compose root feature component; no business logic + +### Detailed Component Design + +#### 1. Types & Constants + +**`features/data-consumers/types.d.ts`**: +```ts +import { DataConsumerType } from "./constants"; + +// Override too-generic generated types +export interface DataConsumerResponse extends Omit { + type: DataConsumerType; +} +``` + +**`features/data-consumers/constants.ts`**: +```ts +// Enums and label maps per Talos convention + +export enum DataConsumerType { + SERVICE = "service", + APPLICATION = "application", + GROUP = "group", + USER = "user", +} + +export const CONSUMER_TYPE_LABELS: Record = { + [DataConsumerType.SERVICE]: "Service", + [DataConsumerType.APPLICATION]: "Application", + [DataConsumerType.GROUP]: "Group", + [DataConsumerType.USER]: "User", +}; + +export const CONSUMER_TYPE_OPTIONS = Object.entries(CONSUMER_TYPE_LABELS).map( + ([value, label]) => ({ value, label }) +); +``` + +#### 2. RTK Query API Slices + +**`data-purpose.slice.ts`**: +```ts +const dataPurposeApi = baseApi.injectEndpoints({ + endpoints: (builder) => ({ + getAllDataPurposes: builder.query({ + query: (params) => ({ url: "plus/data-purpose", params }), + providesTags: ["DataPurpose"], + }), + getDataPurposeByKey: builder.query({ + query: (fidesKey) => ({ url: `plus/data-purpose/${fidesKey}` }), + providesTags: ["DataPurpose"], + }), + createDataPurpose: builder.mutation({ + query: (body) => ({ url: "plus/data-purpose", method: "POST", body }), + invalidatesTags: ["DataPurpose"], + }), + updateDataPurpose: builder.mutation< + DataPurposeResponse, + { fidesKey: string } & DataPurposeUpdate + >({ + query: ({ fidesKey, ...body }) => ({ + url: `plus/data-purpose/${fidesKey}`, + method: "PUT", + body, + }), + invalidatesTags: ["DataPurpose"], + }), + deleteDataPurpose: builder.mutation({ + query: ({ fidesKey, force }) => ({ + url: `plus/data-purpose/${fidesKey}`, + method: "DELETE", + params: force ? { force: true } : undefined, + }), + invalidatesTags: ["DataPurpose", "DataConsumer"], + }), + }), +}); + +export const { + useGetAllDataPurposesQuery, + useGetDataPurposeByKeyQuery, + useCreateDataPurposeMutation, + useUpdateDataPurposeMutation, + useDeleteDataPurposeMutation, +} = dataPurposeApi; +``` + +**`data-consumer.slice.ts`** — same pattern plus: +```ts +assignConsumerPurposes: builder.mutation< + DataConsumerResponse, + { id: string; purposeFidesKeys: string[] } +>({ + query: ({ id, purposeFidesKeys }) => ({ + url: `plus/data-consumer/${id}/purpose`, + method: "PUT", + body: { purpose_fides_keys: purposeFidesKeys }, + }), + invalidatesTags: ["DataConsumer"], +}), +``` + +#### 3. Page Components (Thin Shells) + +**`pages/data-purposes/index.tsx`** (list): +```tsx +const DataPurposesPage: NextPage = () => { + return ( + + + + + ); +}; +``` + +**`pages/data-purposes/new.tsx`** (add): +```tsx +const AddDataPurposePage: NextPage = () => { + const [createDataPurpose] = useCreateDataPurposeMutation(); + const router = useRouter(); + + const handleSubmit = async (values: DataPurposeCreate) => { + try { + await createDataPurpose(values).unwrap(); + message.success(`Data purpose "${values.name}" created`); + router.push(DATA_PURPOSES_ROUTE); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + + return ( + + + + + ); +}; +``` + +**`pages/data-purposes/[fidesKey].tsx`** (edit): +```tsx +const EditDataPurposePage: NextPage = () => { + const router = useRouter(); + const { fidesKey } = router.query; + const { data: purpose, isLoading, error } = useGetDataPurposeByKeyQuery( + fidesKey as string, + { skip: !fidesKey } + ); + const [updateDataPurpose] = useUpdateDataPurposeMutation(); + + if (error) { + return ; + } + + const handleSubmit = async (values: DataPurposeUpdate) => { + try { + await updateDataPurpose({ fidesKey: fidesKey as string, ...values }).unwrap(); + message.success(`Data purpose "${values.name}" updated`); + router.push(DATA_PURPOSES_ROUTE); + } catch (error) { + message.error(getErrorMessage(error)); + } + }; + + return ( + + + {isLoading ? : } + + ); +}; +``` + +#### 4. Table Hook + +**`useDataPurposesTable.tsx`**: +```tsx +const useDataPurposesTable = () => { + const tableState = useTableState({ + pagination: { defaultPageSize: 25, pageSizeOptions: [25, 50, 100] }, + search: { defaultSearchQuery: "" }, + }); + + const { pageIndex, pageSize, searchQuery, updateSearch } = tableState; + const { data, error, isLoading, isFetching } = useGetAllDataPurposesQuery({ + page: pageIndex, + size: pageSize, + search: searchQuery, + }); + + const columns: ColumnsType = useMemo( + () => [ + { + title: "Name", + dataIndex: "name", + render: (_, record) => ( + + {record.name} + + ), + }, + { title: "Key", dataIndex: "fides_key" }, + { + title: "Data use", + dataIndex: "data_use", + render: (value: string) => {value}, + }, + { + title: "Categories", + dataIndex: "data_categories", + render: (categories: string[]) => + categories.length > 0 ? ( + {categories.map((c) => {c})} + ) : ( + "N/A" + ), + }, + { + title: "Actions", + render: (_, record) => , + }, + ], + [] + ); + + const tableProps = useAntTable({ tableState, data }); + + return { tableProps, columns, error, isLoading, isFetching, searchQuery, updateSearch }; +}; +``` + +#### 5. Delete Modal + +**`DeleteDataPurposeModal.tsx`**: +```tsx +interface DeleteDataPurposeModalProps { + purpose: DataPurposeResponse; +} + +const DeleteDataPurposeModal = ({ purpose }: DeleteDataPurposeModalProps) => { + const [modal, contextHolder] = Modal.useModal(); + const [deleteDataPurpose] = useDeleteDataPurposeMutation(); + const router = useRouter(); + + const handleDelete = () => { + modal.confirm({ + title: "Delete data purpose?", + content: `This will permanently delete "${purpose.name}". This action cannot be undone.`, + okText: "Delete", + okButtonProps: { danger: true }, + centered: true, + onOk: async () => { + try { + await deleteDataPurpose({ fidesKey: purpose.fides_key, force: true }).unwrap(); + message.success(`Data purpose "${purpose.name}" deleted`); + router.push(DATA_PURPOSES_ROUTE); + } catch (error) { + message.error(getErrorMessage(error)); + } + }, + }); + }; + + return ( + <> + {contextHolder} + + + ); +}; +``` + +#### 6. Data Purpose Form + +Fields: +- **Name** (required, Ant ``) +- **Key** (required, auto-generated from name if blank, disabled on edit, Ant ``) +- **Data use** (required, Ant ``) +- **Data categories** (Ant ``) +- **Retention period** (optional, Ant ``) + +#### 7. Data Consumer Form + +Fields: +- **Name** (required, Ant ``) +- **Type** (required, Ant ``) +- **Description** (optional, Ant ``) +- **Tags** (Ant `` — populated from `useGetAllDataPurposesQuery`; on save calls `assignConsumerPurposes` mutation with full list) +- **External ID** (optional, Ant ``, advanced section) +- **Contact Slack channel** (optional, Ant ``, advanced section) + +On the edit form, if `purpose_fides_keys` changed from the loaded value, the submit handler calls `assignConsumerPurposes` after `updateDataConsumer`. + +#### 8. Data Consumers Table + +| Column | Source | Notes | +|--------|--------|-------| +| Name | `name` | `` → `/data-consumers/{id}` | +| Type | `type` | `` with label from `CONSUMER_TYPE_LABELS` | +| Contact | `contact_email` | Text, show "N/A" if empty | +| Purposes | `purposes` | `` of `` showing purpose names | +| Tags | `tags` | `` of `` | +| Actions | — | Edit / Delete | + +- Search filters by name +- Optional `` filter by assigned purpose (fetches purposes for options) + +### 9. Navigation & Routing + +**Route constants** (`routes.ts`): +```ts +export const DATA_PURPOSES_ROUTE = "/data-purposes"; +export const DATA_PURPOSES_NEW_ROUTE = "/data-purposes/new"; +export const DATA_PURPOSES_EDIT_ROUTE = "/data-purposes/[fidesKey]"; + +export const DATA_CONSUMERS_ROUTE = "/data-consumers"; +export const DATA_CONSUMERS_NEW_ROUTE = "/data-consumers/new"; +export const DATA_CONSUMERS_EDIT_ROUTE = "/data-consumers/[id]"; +``` + +**Nav config** — add to "Core configuration" group: +```tsx +{ + title: "Data purposes", + path: routes.DATA_PURPOSES_ROUTE, + requiresPlus: true, + requiresFlag: "alphaPurposeBasedAccessControl", + scopes: [ScopeRegistryEnum.DATA_PURPOSE_READ], +}, +{ + title: "Data consumers", + path: routes.DATA_CONSUMERS_ROUTE, + requiresPlus: true, + requiresFlag: "alphaPurposeBasedAccessControl", + scopes: [ScopeRegistryEnum.DATA_CONSUMER_READ], +}, +``` + +### 10. Query Log Config Tab (Integration Detail Page) + +Query log configs sit on top of an existing `ConnectionConfig`, following the same two-step pattern as discovery monitors. The UI adds a **"Query logging" tab** to the integration detail page. + +#### Reference pattern + +The "Data discovery" tab (`MonitorConfigTab`) is the direct reference: +- Tab rendered conditionally via `useFeatureBasedTabs` when `IntegrationFeature.DATA_DISCOVERY` is enabled +- Tab lists existing configs in a table, with an "Add" button opening a modal +- Modal contains a form for create/edit; mutations invalidate cache tags to refresh the table + +#### Backend integration feature + +Add `QUERY_LOGGING = "QUERY_LOGGING"` to the `IntegrationFeature` enum (backend, in fides). BigQuery and `test_datastore` connection types include this feature. The frontend enum in `types/api/models/IntegrationFeature.ts` is auto-generated. + +#### Tab registration + +In `useFeatureBasedTabs.tsx`, add after the DATA_DISCOVERY block: + +```tsx +if (enabledFeatures?.includes(IntegrationFeature.QUERY_LOGGING)) { + tabItems.push({ + label: "Query logging", + key: "query-logging", + children: ( + + ), + }); +} +``` + +#### File structure + +``` +features/integrations/configure-query-log/ + types.d.ts # QueryLogConfig interfaces + constants.ts # poll interval options, status labels + query-log-config.slice.ts # RTK Query endpoints + QueryLogConfigTab.tsx # tab root: description + add button + table + modal + useQueryLogConfigTable.tsx # table hook (columns, data, pagination) + ConfigureQueryLogModal.tsx # create/edit modal with form + QueryLogConfigActionsCell.tsx # edit / delete / test / poll actions +``` + +#### RTK Query slice (`query-log-config.slice.ts`) + +```ts +const queryLogConfigApi = baseApi.injectEndpoints({ + endpoints: (builder) => ({ + getQueryLogConfigs: builder.query({ + query: (params) => ({ url: "plus/query-log-config", params }), + providesTags: ["QueryLogConfig"], + }), + getQueryLogConfigByKey: builder.query({ + query: (configKey) => ({ url: `plus/query-log-config/${configKey}` }), + providesTags: ["QueryLogConfig"], + }), + createQueryLogConfig: builder.mutation({ + query: (body) => ({ url: "plus/query-log-config", method: "POST", body }), + invalidatesTags: ["QueryLogConfig"], + }), + updateQueryLogConfig: builder.mutation< + QueryLogConfigResponse, + { configKey: string } & QueryLogConfigUpdate + >({ + query: ({ configKey, ...body }) => ({ + url: `plus/query-log-config/${configKey}`, + method: "PUT", + body, + }), + invalidatesTags: ["QueryLogConfig"], + }), + deleteQueryLogConfig: builder.mutation({ + query: (configKey) => ({ + url: `plus/query-log-config/${configKey}`, + method: "DELETE", + }), + invalidatesTags: ["QueryLogConfig"], + }), + testQueryLogConnection: builder.mutation({ + query: (configKey) => ({ + url: `plus/query-log-config/${configKey}/test`, + method: "POST", + }), + }), + triggerQueryLogPoll: builder.mutation({ + query: (configKey) => ({ + url: `plus/query-log-config/${configKey}/poll`, + method: "POST", + }), + }), + }), +}); +``` + +#### Table columns + +| Column | Source | Notes | +|--------|--------|-------| +| Name | `name` | Text | +| Key | `key` | Monospace | +| Status | `enabled` | Ant `` toggle (calls update mutation) | +| Poll interval | `poll_interval_seconds` | Human-readable from `POLL_INTERVAL_LABELS` | +| Last poll | `watermark` | Formatted timestamp or "Never" | +| Actions | — | Test / Poll / Edit / Delete | + +#### Modal form fields + +- **Name** (required, Ant ``) +- **Key** (auto-generated from name, disabled on edit, Ant ``) +- **Enabled** (Ant ``, default true) +- **Poll interval** (Ant `