From cb702343572b7c659e4fb6aec9e6feded02b32d2 Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Wed, 22 Jul 2026 00:32:42 +0530 Subject: [PATCH 01/19] Add purpose and element pages --- portal/frontend/src/App.tsx | 8 + .../layout/main-layout/HeaderBreadcrumbs.tsx | 37 +++ .../components/layout/sidebar/AppSidebar.tsx | 37 ++- .../features/catalog/ElementDetailsPage.tsx | 245 ++++++++++++++ .../src/features/catalog/ElementListPage.tsx | 248 ++++++++++++++ .../features/catalog/PurposeDetailsPage.tsx | 292 +++++++++++++++++ .../src/features/catalog/PurposeListPage.tsx | 254 +++++++++++++++ .../src/features/catalog/api/catalogApi.ts | 157 +++++++++ .../catalog/components/CatalogDetailCards.tsx | 115 +++++++ .../components/DeleteVersionDialog.tsx | 64 ++++ .../catalog/components/ElementFormDialog.tsx | 191 +++++++++++ .../catalog/components/PropertyEditor.tsx | 82 +++++ .../catalog/components/PurposeFormDialog.tsx | 306 ++++++++++++++++++ .../catalog/hooks/useCatalogQueries.ts | 215 ++++++++++++ .../features/catalog/utils/formProperties.ts | 24 ++ .../frontend/src/i18n/resources/en/common.ts | 101 ++++++ portal/frontend/src/types/catalog.ts | 181 +++++++++++ 17 files changed, 2555 insertions(+), 2 deletions(-) create mode 100644 portal/frontend/src/features/catalog/ElementDetailsPage.tsx create mode 100644 portal/frontend/src/features/catalog/ElementListPage.tsx create mode 100644 portal/frontend/src/features/catalog/PurposeDetailsPage.tsx create mode 100644 portal/frontend/src/features/catalog/PurposeListPage.tsx create mode 100644 portal/frontend/src/features/catalog/api/catalogApi.ts create mode 100644 portal/frontend/src/features/catalog/components/CatalogDetailCards.tsx create mode 100644 portal/frontend/src/features/catalog/components/DeleteVersionDialog.tsx create mode 100644 portal/frontend/src/features/catalog/components/ElementFormDialog.tsx create mode 100644 portal/frontend/src/features/catalog/components/PropertyEditor.tsx create mode 100644 portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx create mode 100644 portal/frontend/src/features/catalog/hooks/useCatalogQueries.ts create mode 100644 portal/frontend/src/features/catalog/utils/formProperties.ts create mode 100644 portal/frontend/src/types/catalog.ts diff --git a/portal/frontend/src/App.tsx b/portal/frontend/src/App.tsx index 4bfad7b9..08c4a23d 100644 --- a/portal/frontend/src/App.tsx +++ b/portal/frontend/src/App.tsx @@ -19,6 +19,10 @@ import { useEffect } from 'react' import { Navigate, Route, Routes } from 'react-router-dom' import MainLayout from './components/layout/main-layout/MainLayout' +import ElementDetailsPage from './features/catalog/ElementDetailsPage' +import ElementListPage from './features/catalog/ElementListPage' +import PurposeDetailsPage from './features/catalog/PurposeDetailsPage' +import PurposeListPage from './features/catalog/PurposeListPage' import ConsentDetailsPage from './features/consent-registry/ConsentDetailsPage' import ConsentRegistryPage from './features/consent-registry/ConsentRegistryPage' import DashboardPage from './features/dashboard/DashboardPage' @@ -48,6 +52,10 @@ function App(): React.JSX.Element { } /> } /> } /> + } /> + } /> + } /> + } /> } /> diff --git a/portal/frontend/src/components/layout/main-layout/HeaderBreadcrumbs.tsx b/portal/frontend/src/components/layout/main-layout/HeaderBreadcrumbs.tsx index da70d0c0..29671573 100644 --- a/portal/frontend/src/components/layout/main-layout/HeaderBreadcrumbs.tsx +++ b/portal/frontend/src/components/layout/main-layout/HeaderBreadcrumbs.tsx @@ -39,6 +39,8 @@ function buildBreadcrumbItems( pathname: string, homeLabel: string, consentsLabel: string, + purposesLabel: string, + elementsLabel: string, ): BreadcrumbItem[] { const consentDetailsMatch = pathname.match(/^\/consents\/([^/]+)$/) @@ -77,6 +79,39 @@ function buildBreadcrumbItems( ] } + const catalogDetailsMatch = pathname.match(/^\/(purposes|elements)\/([^/]+)$/) + + if (catalogDetailsMatch) { + const section = catalogDetailsMatch[1] + return [ + { label: homeLabel, path: '/dashboard', isCurrent: false }, + { + label: section === 'purposes' ? purposesLabel : elementsLabel, + path: `/${section}`, + isCurrent: false, + }, + { + label: safeDecodeURIComponent(catalogDetailsMatch[2]), + path: pathname, + isCurrent: true, + }, + ] + } + + if (pathname.startsWith('/purposes')) { + return [ + { label: homeLabel, path: '/dashboard', isCurrent: false }, + { label: purposesLabel, path: '/purposes', isCurrent: true }, + ] + } + + if (pathname.startsWith('/elements')) { + return [ + { label: homeLabel, path: '/dashboard', isCurrent: false }, + { label: elementsLabel, path: '/elements', isCurrent: true }, + ] + } + return [ { label: homeLabel, @@ -94,6 +129,8 @@ function HeaderBreadcrumbs(): React.JSX.Element { location.pathname, t('layout.home'), t('sidebar.allConsents'), + t('sidebar.purposes'), + t('sidebar.elements'), ) return ( diff --git a/portal/frontend/src/components/layout/sidebar/AppSidebar.tsx b/portal/frontend/src/components/layout/sidebar/AppSidebar.tsx index b052020c..ae54db79 100644 --- a/portal/frontend/src/components/layout/sidebar/AppSidebar.tsx +++ b/portal/frontend/src/components/layout/sidebar/AppSidebar.tsx @@ -17,7 +17,7 @@ */ import { Sidebar } from '@wso2/oxygen-ui' -import { Clock3, House, ShieldCheck } from '@wso2/oxygen-ui-icons-react' +import { Blocks, Clock3, House, ShieldCheck, Target } from '@wso2/oxygen-ui-icons-react' import { useTranslation } from 'react-i18next' import { useLocation, useNavigate } from 'react-router-dom' @@ -56,7 +56,22 @@ const CONSENT_ITEMS: SidebarItem[] = [ }, ] -const SIDEBAR_ITEMS: SidebarItem[] = [...DASHBOARD_ITEMS, ...CONSENT_ITEMS] +const CATALOG_ITEMS: SidebarItem[] = [ + { + id: 'purposes', + labelKey: 'sidebar.purposes', + path: '/purposes', + icon: , + }, + { + id: 'elements', + labelKey: 'sidebar.elements', + path: '/elements', + icon: , + }, +] + +const SIDEBAR_ITEMS: SidebarItem[] = [...DASHBOARD_ITEMS, ...CONSENT_ITEMS, ...CATALOG_ITEMS] function mapPathToMenuId(pathname: string, search: string): string { if (pathname.startsWith('/dashboard')) { @@ -73,6 +88,14 @@ function mapPathToMenuId(pathname: string, search: string): string { return 'all-consents' } + if (pathname.startsWith('/purposes')) { + return 'purposes' + } + + if (pathname.startsWith('/elements')) { + return 'elements' + } + return 'dashboard' } @@ -115,6 +138,16 @@ function AppSidebar({ collapsed }: AppSidebarProps): React.JSX.Element { ))} + + + {t('sidebar.catalog')} + {CATALOG_ITEMS.map((item) => ( + + {item.icon} + {t(item.labelKey)} + + ))} + ) diff --git a/portal/frontend/src/features/catalog/ElementDetailsPage.tsx b/portal/frontend/src/features/catalog/ElementDetailsPage.tsx new file mode 100644 index 00000000..8f489bbd --- /dev/null +++ b/portal/frontend/src/features/catalog/ElementDetailsPage.tsx @@ -0,0 +1,245 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import { + Box, + Button, + Card, + CardContent, + CardHeader, + Chip, + Divider, + IconButton, + Skeleton, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@wso2/oxygen-ui' +import { Plus, Trash2 } from '@wso2/oxygen-ui-icons-react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate, useParams } from 'react-router-dom' +import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs' +import type { ElementVersion, ElementVersionItem } from '../../types/catalog' +import { formatEpochTimestamp } from '../../utils/dateTime' +import { OverviewCard, PropertiesCard } from './components/CatalogDetailCards' +import DeleteVersionDialog from './components/DeleteVersionDialog' +import ElementFormDialog from './components/ElementFormDialog' +import { + useCreateElementVersionMutation, + useDeleteElementVersionMutation, + useElementQuery, + useElementVersionsQuery, +} from './hooks/useCatalogQueries' + +function ElementDetailsPage(): React.JSX.Element { + const { t } = useTranslation('common') + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const detailQuery = useElementQuery(id) + const versionsQuery = useElementVersionsQuery(id) + const createVersionMutation = useCreateElementVersionMutation() + const deleteMutation = useDeleteElementVersionMutation() + const [selectedVersion, setSelectedVersion] = useState() + const [versionDialogOpen, setVersionDialogOpen] = useState(false) + const [deleteVersion, setDeleteVersion] = useState() + + const detail = detailQuery.data + const selectedVersionItem = useMemo( + () => versionsQuery.data?.versions.find((version) => version.version === selectedVersion), + [selectedVersion, versionsQuery.data?.versions], + ) + const displayed: ElementVersion | undefined = detail + ? { + ...detail, + ...(selectedVersionItem ?? {}), + } + : undefined + + if (detailQuery.isLoading) { + return ( + + + + + + + + + ) + } + + if (!id || detailQuery.isError || !displayed) { + return ( + + + {t('catalog.elements.loadFailed')} + + + + ) + } + + const versions = versionsQuery.data?.versions ?? [] + + return ( + + + + + + + {displayed.displayName ?? displayed.name} + + + + + + {displayed.name}, + }, + { label: t('catalog.fields.namespace'), value: displayed.namespace }, + { + label: t('catalog.fields.type'), + value: , + }, + { label: t('catalog.fields.version'), value: displayed.version }, + { + label: t('catalog.fields.created'), + value: formatEpochTimestamp(displayed.createdTime), + }, + { label: t('catalog.fields.description'), value: displayed.description ?? '-' }, + ]} + /> + + + + {t('catalog.details.versions')}} + /> + + + + + + + {t('catalog.fields.version')} + {t('catalog.fields.displayName')} + {t('catalog.fields.description')} + {t('catalog.fields.created')} + {t('catalog.fields.actions')} + + + + {versions.map((version) => ( + setSelectedVersion(version.version)} + > + {version.version} + {version.displayName ?? '-'} + {version.description ?? '-'} + {formatEpochTimestamp(version.createdTime)} + + + { + event.stopPropagation() + setDeleteVersion(version.version) + }} + > + + + + + + ))} + +
+
+
+
+ + + { + setVersionDialogOpen(false) + createVersionMutation.reset() + }} + onCreate={() => {}} + onCreateVersion={(payload) => { + createVersionMutation.mutate( + { elementId: id, payload }, + { + onSuccess: (version) => { + setVersionDialogOpen(false) + setSelectedVersion(version.version) + }, + }, + ) + }} + /> + { + setDeleteVersion(undefined) + deleteMutation.reset() + }} + onConfirm={() => { + if (!deleteVersion) return + deleteMutation.mutate( + { id, version: deleteVersion }, + { + onSuccess: () => { + setDeleteVersion(undefined) + setSelectedVersion(undefined) + if (versions.length === 1) navigate('/elements') + }, + }, + ) + }} + /> + + ) +} + +export default ElementDetailsPage diff --git a/portal/frontend/src/features/catalog/ElementListPage.tsx b/portal/frontend/src/features/catalog/ElementListPage.tsx new file mode 100644 index 00000000..4f9ff44f --- /dev/null +++ b/portal/frontend/src/features/catalog/ElementListPage.tsx @@ -0,0 +1,248 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import { + Box, + Button, + Chip, + MenuItem, + Paper, + Skeleton, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Typography, +} from '@wso2/oxygen-ui' +import { Plus } from '@wso2/oxygen-ui-icons-react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate, useSearchParams } from 'react-router-dom' +import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs' +import type { ElementFilters, ElementType } from '../../types/catalog' +import { formatEpochTimestamp } from '../../utils/dateTime' +import ElementFormDialog from './components/ElementFormDialog' +import { useCreateElementMutation, useElementsQuery } from './hooks/useCatalogQueries' + +const ROW_OPTIONS = [10, 25, 50] + +function getPage(searchParams: URLSearchParams): number { + const page = Number(searchParams.get('page') ?? '1') + return Number.isInteger(page) && page > 0 ? page - 1 : 0 +} + +function getRowsPerPage(searchParams: URLSearchParams): number { + const rows = Number(searchParams.get('rowsPerPage') ?? '10') + return ROW_OPTIONS.includes(rows) ? rows : 10 +} + +function getFilters(searchParams: URLSearchParams): ElementFilters { + const type = searchParams.get('type') + return { + name: searchParams.get('name') ?? '', + namespace: searchParams.get('namespace') ?? '', + type: type === 'basic' || type === 'json' || type === 'xml' ? type : 'All', + version: searchParams.get('version') ?? '', + } +} + +function ElementListPage(): React.JSX.Element { + const { t } = useTranslation('common') + const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() + const [createOpen, setCreateOpen] = useState(false) + const filters = useMemo(() => getFilters(searchParams), [searchParams]) + const page = useMemo(() => getPage(searchParams), [searchParams]) + const rowsPerPage = useMemo(() => getRowsPerPage(searchParams), [searchParams]) + const query = useElementsQuery(filters, page, rowsPerPage) + const createMutation = useCreateElementMutation() + + const updateParams = ( + nextFilters: ElementFilters, + nextPage = 0, + nextRowsPerPage = rowsPerPage, + ): void => { + const params = new URLSearchParams() + if (nextFilters.name.trim()) params.set('name', nextFilters.name.trim()) + if (nextFilters.namespace.trim()) params.set('namespace', nextFilters.namespace.trim()) + if (nextFilters.type !== 'All') params.set('type', nextFilters.type) + if (nextFilters.version.trim()) params.set('version', nextFilters.version.trim()) + if (nextPage > 0) params.set('page', String(nextPage + 1)) + if (nextRowsPerPage !== 10) params.set('rowsPerPage', String(nextRowsPerPage)) + setSearchParams(params, { replace: true }) + } + + const rows = query.data?.data ?? [] + + return ( + + + + + + + {t('catalog.elements.title')} + + + + + + + + updateParams({ ...filters, name: event.target.value })} + /> + updateParams({ ...filters, namespace: event.target.value })} + /> + + updateParams({ ...filters, type: event.target.value as ElementType | 'All' }) + } + > + {t('catalog.values.all')} + basic + json + xml + + updateParams({ ...filters, version: event.target.value })} + /> + + + + + {query.isError ? ( + {t('catalog.elements.loadFailed')} + ) : null} + + + + + {t('catalog.fields.displayName')} + {t('catalog.fields.name')} + {t('catalog.fields.namespace')} + {t('catalog.fields.type')} + {t('catalog.fields.version')} + {t('catalog.fields.created')} + + + + {query.isPending + ? Array.from({ length: 5 }).map((_, index) => ( + + {Array.from({ length: 6 }).map((__, cell) => ( + + + + ))} + + )) + : rows.map((element) => ( + navigate(`/elements/${encodeURIComponent(element.elementId)}`)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + navigate(`/elements/${encodeURIComponent(element.elementId)}`) + } + }} + > + {element.displayName ?? '-'} + + {element.name} + + {element.namespace} + + + + {element.version} + {formatEpochTimestamp(element.createdTime)} + + ))} + {!query.isPending && !query.isError && rows.length === 0 ? ( + + + {t('catalog.elements.empty')} + + + ) : null} + +
+ updateParams(filters, nextPage)} + onRowsPerPageChange={(event) => updateParams(filters, 0, Number(event.target.value))} + /> +
+
+ + { + setCreateOpen(false) + createMutation.reset() + }} + onCreate={(payload) => { + createMutation.mutate(payload, { + onSuccess: (element) => { + setCreateOpen(false) + navigate(`/elements/${encodeURIComponent(element.elementId)}`) + }, + }) + }} + onCreateVersion={undefined} + /> +
+ ) +} + +export default ElementListPage diff --git a/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx b/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx new file mode 100644 index 00000000..71b763cd --- /dev/null +++ b/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx @@ -0,0 +1,292 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import { + Box, + Button, + Card, + CardContent, + CardHeader, + Chip, + Divider, + IconButton, + Link, + Skeleton, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Tooltip, + Typography, +} from '@wso2/oxygen-ui' +import { Plus, Trash2 } from '@wso2/oxygen-ui-icons-react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom' +import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs' +import type { PurposeVersion, PurposeVersionItem } from '../../types/catalog' +import { formatEpochTimestamp } from '../../utils/dateTime' +import { OverviewCard, PropertiesCard } from './components/CatalogDetailCards' +import DeleteVersionDialog from './components/DeleteVersionDialog' +import PurposeFormDialog from './components/PurposeFormDialog' +import { + useCreatePurposeVersionMutation, + useDeletePurposeVersionMutation, + usePurposeQuery, + usePurposeVersionsQuery, +} from './hooks/useCatalogQueries' + +function PurposeDetailsPage(): React.JSX.Element { + const { t } = useTranslation('common') + const { id } = useParams<{ id: string }>() + const navigate = useNavigate() + const detailQuery = usePurposeQuery(id) + const versionsQuery = usePurposeVersionsQuery(id) + const createVersionMutation = useCreatePurposeVersionMutation() + const deleteMutation = useDeletePurposeVersionMutation() + const [selectedVersion, setSelectedVersion] = useState() + const [versionDialogOpen, setVersionDialogOpen] = useState(false) + const [deleteVersion, setDeleteVersion] = useState() + + const detail = detailQuery.data + const selectedVersionItem = useMemo( + () => versionsQuery.data?.versions.find((version) => version.version === selectedVersion), + [selectedVersion, versionsQuery.data?.versions], + ) + const displayed: PurposeVersion | undefined = detail + ? { ...detail, ...(selectedVersionItem ?? {}) } + : undefined + + if (detailQuery.isLoading) { + return ( + + + + + + + + + ) + } + + if (!id || detailQuery.isError || !displayed) { + return ( + + + {t('catalog.purposes.loadFailed')} + + + + ) + } + + const versions = versionsQuery.data?.versions ?? [] + + return ( + + + + + + + {displayed.displayName ?? displayed.name} + + + + + + {displayed.name}, + }, + { label: t('catalog.fields.groupId'), value: displayed.groupId }, + { label: t('catalog.fields.version'), value: displayed.version }, + { + label: t('catalog.fields.created'), + value: formatEpochTimestamp(displayed.createdTime), + }, + { label: t('catalog.fields.description'), value: displayed.description ?? '-' }, + ]} + /> + + + + {t('catalog.details.elements')}} + /> + + + + + + + {t('catalog.fields.element')} + {t('catalog.fields.name')} + {t('catalog.fields.namespace')} + {t('catalog.fields.version')} + {t('catalog.fields.requirement')} + + + + {displayed.elements.map((element) => ( + + + + {element.displayName ?? element.name} + + + + {element.name} + + {element.namespace} + {element.version} + + + + + ))} + +
+
+
+
+ + + {t('catalog.details.versions')}} + /> + + + + + + + {t('catalog.fields.version')} + {t('catalog.fields.displayName')} + {t('catalog.fields.elements')} + {t('catalog.fields.created')} + {t('catalog.fields.actions')} + + + + {versions.map((version) => ( + setSelectedVersion(version.version)} + > + {version.version} + {version.displayName ?? '-'} + {version.elements.length} + {formatEpochTimestamp(version.createdTime)} + + + { + event.stopPropagation() + setDeleteVersion(version.version) + }} + > + + + + + + ))} + +
+
+
+
+ + + { + setVersionDialogOpen(false) + createVersionMutation.reset() + }} + onCreate={() => {}} + onCreateVersion={(payload) => { + createVersionMutation.mutate( + { purposeId: id, payload }, + { + onSuccess: (version) => { + setVersionDialogOpen(false) + setSelectedVersion(version.version) + }, + }, + ) + }} + /> + { + setDeleteVersion(undefined) + deleteMutation.reset() + }} + onConfirm={() => { + if (!deleteVersion) return + deleteMutation.mutate( + { id, version: deleteVersion }, + { + onSuccess: () => { + setDeleteVersion(undefined) + setSelectedVersion(undefined) + if (versions.length === 1) navigate('/purposes') + }, + }, + ) + }} + /> + + ) +} + +export default PurposeDetailsPage diff --git a/portal/frontend/src/features/catalog/PurposeListPage.tsx b/portal/frontend/src/features/catalog/PurposeListPage.tsx new file mode 100644 index 00000000..c1f9a23a --- /dev/null +++ b/portal/frontend/src/features/catalog/PurposeListPage.tsx @@ -0,0 +1,254 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import { + Box, + Button, + Paper, + Skeleton, + Stack, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TablePagination, + TableRow, + TextField, + Typography, +} from '@wso2/oxygen-ui' +import { Plus } from '@wso2/oxygen-ui-icons-react' +import { useMemo, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { useNavigate, useSearchParams } from 'react-router-dom' +import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs' +import type { PurposeFilters } from '../../types/catalog' +import { formatEpochTimestamp } from '../../utils/dateTime' +import PurposeFormDialog from './components/PurposeFormDialog' +import { useCreatePurposeMutation, usePurposesQuery } from './hooks/useCatalogQueries' + +const ROW_OPTIONS = [10, 25, 50] + +function getFilters(searchParams: URLSearchParams): PurposeFilters { + return { + purposeName: searchParams.get('purposeName') ?? '', + purposeVersion: searchParams.get('purposeVersion') ?? '', + elementName: searchParams.get('elementName') ?? '', + elementNamespace: searchParams.get('elementNamespace') ?? '', + elementVersion: searchParams.get('elementVersion') ?? '', + groupIds: searchParams.get('groupIds') ?? '', + } +} + +function PurposeListPage(): React.JSX.Element { + const { t } = useTranslation('common') + const navigate = useNavigate() + const [searchParams, setSearchParams] = useSearchParams() + const [createOpen, setCreateOpen] = useState(false) + const filters = useMemo(() => getFilters(searchParams), [searchParams]) + const parsedPage = Number(searchParams.get('page') ?? '1') + const page = Number.isInteger(parsedPage) && parsedPage > 0 ? parsedPage - 1 : 0 + const parsedRows = Number(searchParams.get('rowsPerPage') ?? '10') + const rowsPerPage = ROW_OPTIONS.includes(parsedRows) ? parsedRows : 10 + const query = usePurposesQuery(filters, page, rowsPerPage) + const createMutation = useCreatePurposeMutation() + + const updateParams = ( + nextFilters: PurposeFilters, + nextPage = 0, + nextRowsPerPage = rowsPerPage, + ): void => { + const params = new URLSearchParams() + Object.entries(nextFilters).forEach(([key, value]) => { + if (value.trim()) params.set(key, value.trim()) + }) + if (nextPage > 0) params.set('page', String(nextPage + 1)) + if (nextRowsPerPage !== 10) params.set('rowsPerPage', String(nextRowsPerPage)) + setSearchParams(params, { replace: true }) + } + const rows = query.data?.data ?? [] + + return ( + + + + + + + {t('catalog.purposes.title')} + + + + + + + + + updateParams({ ...filters, purposeName: event.target.value })} + /> + updateParams({ ...filters, groupIds: event.target.value })} + /> + + updateParams({ ...filters, purposeVersion: event.target.value }) + } + /> + + + updateParams({ ...filters, elementName: event.target.value })} + /> + + updateParams({ ...filters, elementNamespace: event.target.value }) + } + /> + + updateParams({ ...filters, elementVersion: event.target.value }) + } + /> + + + + + + {query.isError ? ( + {t('catalog.purposes.loadFailed')} + ) : null} + + + + + {t('catalog.fields.displayName')} + {t('catalog.fields.name')} + {t('catalog.fields.groupId')} + {t('catalog.fields.version')} + {t('catalog.fields.description')} + {t('catalog.fields.created')} + + + + {query.isPending + ? Array.from({ length: 5 }).map((_, index) => ( + + {Array.from({ length: 6 }).map((__, cell) => ( + + + + ))} + + )) + : rows.map((purpose) => ( + navigate(`/purposes/${encodeURIComponent(purpose.purposeId)}`)} + onKeyDown={(event) => { + if (event.key === 'Enter') { + navigate(`/purposes/${encodeURIComponent(purpose.purposeId)}`) + } + }} + > + {purpose.displayName ?? '-'} + + {purpose.name} + + {purpose.groupId} + {purpose.version} + {purpose.description ?? '-'} + {formatEpochTimestamp(purpose.createdTime)} + + ))} + {!query.isPending && !query.isError && rows.length === 0 ? ( + + + {t('catalog.purposes.empty')} + + + ) : null} + +
+ updateParams(filters, nextPage)} + onRowsPerPageChange={(event) => updateParams(filters, 0, Number(event.target.value))} + /> +
+
+ + { + setCreateOpen(false) + createMutation.reset() + }} + onCreate={(payload, groupId) => { + createMutation.mutate( + { payload, groupId }, + { + onSuccess: (purpose) => { + setCreateOpen(false) + navigate(`/purposes/${encodeURIComponent(purpose.purposeId)}`) + }, + }, + ) + }} + onCreateVersion={undefined} + /> +
+ ) +} + +export default PurposeListPage diff --git a/portal/frontend/src/features/catalog/api/catalogApi.ts b/portal/frontend/src/features/catalog/api/catalogApi.ts new file mode 100644 index 00000000..703912ce --- /dev/null +++ b/portal/frontend/src/features/catalog/api/catalogApi.ts @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import type { + ElementBulkCreateResponse, + ElementCreateRequest, + ElementFilters, + ElementListResponse, + ElementVersion, + ElementVersionCreateRequest, + ElementVersionList, + PurposeCreateRequest, + PurposeFilters, + PurposeListResponse, + PurposeVersion, + PurposeVersionCreateRequest, + PurposeVersionList, +} from '../../../types/catalog' +import { apiRequest, apiRequestNoContent } from '../../../utils/apiClient' + +const jsonHeaders = { 'Content-Type': 'application/json' } + +export function fetchElements( + filters: ElementFilters, + limit: number, + offset: number, +): Promise { + return apiRequest('/api/consent-elements', { + method: 'GET', + query: { + name: filters.name.trim() || undefined, + namespace: filters.namespace.trim() || undefined, + type: filters.type === 'All' ? undefined : filters.type, + version: + filters.name.trim() || filters.namespace.trim() + ? filters.version.trim() || undefined + : undefined, + limit, + offset, + }, + }) +} + +export function fetchElement(elementId: string): Promise { + return apiRequest(`/api/consent-elements/${encodeURIComponent(elementId)}`, { + method: 'GET', + }) +} + +export function fetchElementVersions(elementId: string): Promise { + return apiRequest( + `/api/consent-elements/${encodeURIComponent(elementId)}/versions`, + { method: 'GET' }, + ) +} + +export async function createElement(payload: ElementCreateRequest): Promise { + const response = await apiRequest('/api/consent-elements', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify([payload]), + }) + const result = response.results[0] + + if (!result || result.status === 'FAILED' || !result.data) { + throw new Error( + result?.error?.description ?? result?.error?.message ?? 'Element creation failed', + ) + } + + return result.data +} + +export function createElementVersion( + elementId: string, + payload: ElementVersionCreateRequest, +): Promise { + return apiRequest( + `/api/consent-elements/${encodeURIComponent(elementId)}/versions`, + { method: 'POST', headers: jsonHeaders, body: JSON.stringify(payload) }, + ) +} + +export function deleteElementVersion(elementId: string, version: string): Promise { + return apiRequestNoContent( + `/api/consent-elements/${encodeURIComponent(elementId)}/versions/${encodeURIComponent(version)}`, + { method: 'DELETE' }, + ) +} + +export function fetchPurposes( + filters: PurposeFilters, + limit: number, + offset: number, +): Promise { + return apiRequest('/api/consent-purposes', { + method: 'GET', + query: { + purposeName: filters.purposeName.trim() || undefined, + purposeVersion: filters.purposeName.trim() + ? filters.purposeVersion.trim() || undefined + : undefined, + elementName: filters.elementName.trim() || undefined, + elementNamespace: filters.elementNamespace.trim() || undefined, + elementVersion: + filters.elementName.trim() || filters.elementNamespace.trim() + ? filters.elementVersion.trim() || undefined + : undefined, + groupIds: filters.groupIds.trim() || undefined, + limit, + offset, + }, + }) +} + +export function fetchPurpose(purposeId: string): Promise { + return apiRequest(`/api/consent-purposes/${encodeURIComponent(purposeId)}`, { + method: 'GET', + }) +} + +export function fetchPurposeVersions(purposeId: string): Promise { + return apiRequest( + `/api/consent-purposes/${encodeURIComponent(purposeId)}/versions`, + { method: 'GET' }, + ) +} + +export function createPurpose( + payload: PurposeCreateRequest, + groupId?: string, +): Promise { + return apiRequest('/api/consent-purposes', { + method: 'POST', + headers: { ...jsonHeaders, ...(groupId ? { 'group-id': groupId } : {}) }, + body: JSON.stringify(payload), + }) +} + +export function createPurposeVersion( + purposeId: string, + payload: PurposeVersionCreateRequest, +): Promise { + return apiRequest( + `/api/consent-purposes/${encodeURIComponent(purposeId)}/versions`, + { method: 'POST', headers: jsonHeaders, body: JSON.stringify(payload) }, + ) +} + +export function deletePurposeVersion(purposeId: string, version: string): Promise { + return apiRequestNoContent( + `/api/consent-purposes/${encodeURIComponent(purposeId)}/versions/${encodeURIComponent(version)}`, + { method: 'DELETE' }, + ) +} diff --git a/portal/frontend/src/features/catalog/components/CatalogDetailCards.tsx b/portal/frontend/src/features/catalog/components/CatalogDetailCards.tsx new file mode 100644 index 00000000..74427c23 --- /dev/null +++ b/portal/frontend/src/features/catalog/components/CatalogDetailCards.tsx @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import { Box, Card, CardContent, CardHeader, Divider, Stack, Typography } from '@wso2/oxygen-ui' +import { useTranslation } from 'react-i18next' + +interface DetailItem { + label: string + value: React.ReactNode +} + +interface OverviewCardProps { + title: string + items: DetailItem[] +} + +export function OverviewCard({ title, items }: OverviewCardProps): React.JSX.Element { + return ( + + {title}} /> + + + + {items.map((item) => ( + + + {item.label} + + + {item.value || '-'} + + + ))} + + + + ) +} + +interface PropertiesCardProps { + properties: Record | undefined + schema: string | undefined +} + +export function PropertiesCard({ + properties, + schema, +}: PropertiesCardProps): React.JSX.Element | null { + const { t } = useTranslation('common') + const entries = Object.entries(properties ?? {}) + + if (!schema && entries.length === 0) { + return null + } + + return ( + + {t('catalog.details.definition')}} + /> + + + + {schema ? ( + + + {t('catalog.fields.schema')} + + + {schema} + + + ) : null} + {entries.length > 0 ? ( + + {entries.map(([key, value]) => ( + + + {key} + + {value} + + ))} + + ) : null} + + + + ) +} diff --git a/portal/frontend/src/features/catalog/components/DeleteVersionDialog.tsx b/portal/frontend/src/features/catalog/components/DeleteVersionDialog.tsx new file mode 100644 index 00000000..cb6579a7 --- /dev/null +++ b/portal/frontend/src/features/catalog/components/DeleteVersionDialog.tsx @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Typography, +} from '@wso2/oxygen-ui' +import { useTranslation } from 'react-i18next' + +interface DeleteVersionDialogProps { + open: boolean + version: string + loading: boolean + error: string | undefined + onClose: () => void + onConfirm: () => void +} + +function DeleteVersionDialog({ + open, + version, + loading, + error, + onClose, + onConfirm, +}: DeleteVersionDialogProps): React.JSX.Element { + const { t } = useTranslation('common') + + return ( + + {t('catalog.delete.title')} + + + {t('catalog.delete.message', { version })} + + {t('catalog.delete.warning')} + + {error ? ( + + {error} + + ) : null} + + + + + + + + ) +} + +export default DeleteVersionDialog diff --git a/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx b/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx new file mode 100644 index 00000000..c21ef2b4 --- /dev/null +++ b/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControl, + InputLabel, + MenuItem, + Select, + Stack, + TextField, + Typography, +} from '@wso2/oxygen-ui' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { + ElementCreateRequest, + ElementType, + ElementVersion, + ElementVersionCreateRequest, +} from '../../../types/catalog' +import PropertyEditor from './PropertyEditor' +import { + entriesToProperties, + propertiesToEntries, + type PropertyEntry, +} from '../utils/formProperties' + +interface ElementFormDialogProps { + open: boolean + initialValue: ElementVersion | undefined + loading: boolean + error: string | undefined + onClose: () => void + onCreate: (payload: ElementCreateRequest) => void + onCreateVersion: ((payload: ElementVersionCreateRequest) => void) | undefined +} + +function ElementFormDialog({ + open, + initialValue, + loading, + error, + onClose, + onCreate, + onCreateVersion, +}: ElementFormDialogProps): React.JSX.Element { + const { t } = useTranslation('common') + const [name, setName] = useState(initialValue?.name ?? '') + const [namespace, setNamespace] = useState(initialValue?.namespace ?? '') + const [type, setType] = useState(initialValue?.type ?? 'basic') + const [displayName, setDisplayName] = useState(initialValue?.displayName ?? '') + const [description, setDescription] = useState(initialValue?.description ?? '') + const [schema, setSchema] = useState(initialValue?.schema ?? '') + const [properties, setProperties] = useState( + propertiesToEntries(initialValue?.properties), + ) + const [validationError, setValidationError] = useState('') + const versionMode = Boolean(initialValue) + + const handleSubmit = (): void => { + if (!versionMode && !name.trim()) { + setValidationError(t('catalog.validation.nameRequired')) + return + } + if ((type === 'json' || type === 'xml') && !schema.trim()) { + setValidationError(t('catalog.validation.schemaRequired')) + return + } + + const versionPayload: ElementVersionCreateRequest = { + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + schema: schema.trim() || undefined, + properties: entriesToProperties(properties), + } + + if (versionMode && onCreateVersion) { + onCreateVersion(versionPayload) + return + } + + onCreate({ + ...versionPayload, + name: name.trim(), + namespace: namespace.trim() || undefined, + type, + }) + } + + return ( + + + {versionMode ? t('catalog.elements.newVersion') : t('catalog.elements.createTitle')} + + + + {versionMode ? ( + + {t('catalog.messages.immutableElement', { + name: initialValue?.name, + namespace: initialValue?.namespace, + type: initialValue?.type, + })} + + ) : ( + + setName(event.target.value)} + /> + setNamespace(event.target.value)} + /> + + {t('catalog.fields.type')} + + + + )} + setDisplayName(event.target.value)} + /> + setDescription(event.target.value)} + /> + {type !== 'basic' ? ( + setSchema(event.target.value)} + /> + ) : null} + + {validationError || error ? ( + + {validationError || error} + + ) : null} + + + + + + + + ) +} + +export default ElementFormDialog diff --git a/portal/frontend/src/features/catalog/components/PropertyEditor.tsx b/portal/frontend/src/features/catalog/components/PropertyEditor.tsx new file mode 100644 index 00000000..4bf70f2c --- /dev/null +++ b/portal/frontend/src/features/catalog/components/PropertyEditor.tsx @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import { Box, Button, IconButton, Stack, TextField, Typography } from '@wso2/oxygen-ui' +import { Plus, Trash2 } from '@wso2/oxygen-ui-icons-react' +import { useTranslation } from 'react-i18next' +import type { PropertyEntry } from '../utils/formProperties' + +interface PropertyEditorProps { + entries: PropertyEntry[] + onChange: (entries: PropertyEntry[]) => void +} + +function PropertyEditor({ entries, onChange }: PropertyEditorProps): React.JSX.Element { + const { t } = useTranslation('common') + + return ( + + + {t('catalog.fields.properties')} + + + {entries.length === 0 ? ( + + {t('catalog.messages.noProperties')} + + ) : null} + {entries.map((entry) => ( + + { + onChange( + entries.map((item) => + item.id === entry.id ? { ...item, key: event.target.value } : item, + ), + ) + }} + fullWidth + /> + { + onChange( + entries.map((item) => + item.id === entry.id ? { ...item, value: event.target.value } : item, + ), + ) + }} + fullWidth + /> + + { + onChange(entries.filter((item) => item.id !== entry.id)) + }} + > + + + + + ))} + + ) +} + +export default PropertyEditor diff --git a/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx b/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx new file mode 100644 index 00000000..44cabd84 --- /dev/null +++ b/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx @@ -0,0 +1,306 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import { + Button, + Checkbox, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + FormControlLabel, + IconButton, + MenuItem, + Stack, + TextField, + Typography, +} from '@wso2/oxygen-ui' +import { Plus, Trash2 } from '@wso2/oxygen-ui-icons-react' +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { + ElementSummary, + PurposeCreateRequest, + PurposeElementRequest, + PurposeVersion, + PurposeVersionCreateRequest, +} from '../../../types/catalog' +import { useElementOptionsQuery } from '../hooks/useCatalogQueries' +import PropertyEditor from './PropertyEditor' +import { + entriesToProperties, + propertiesToEntries, + type PropertyEntry, +} from '../utils/formProperties' + +interface PurposeElementFormRow extends PurposeElementRequest { + id: number + elementKey: string +} + +interface PurposeFormDialogProps { + open: boolean + initialValue: PurposeVersion | undefined + loading: boolean + error: string | undefined + onClose: () => void + onCreate: (payload: PurposeCreateRequest, groupId?: string) => void + onCreateVersion: ((payload: PurposeVersionCreateRequest) => void) | undefined +} + +function elementKey(element: Pick): string { + return `${element.namespace}::${element.name}` +} + +function PurposeFormDialog({ + open, + initialValue, + loading, + error, + onClose, + onCreate, + onCreateVersion, +}: PurposeFormDialogProps): React.JSX.Element { + const { t } = useTranslation('common') + const optionsQuery = useElementOptionsQuery(open) + const [name, setName] = useState(initialValue?.name ?? '') + const [groupId, setGroupId] = useState('') + const [displayName, setDisplayName] = useState(initialValue?.displayName ?? '') + const [description, setDescription] = useState(initialValue?.description ?? '') + const [properties, setProperties] = useState( + propertiesToEntries(initialValue?.properties), + ) + const [elements, setElements] = useState( + (initialValue?.elements ?? []).map((element, index) => ({ + id: index, + elementKey: elementKey(element), + name: element.name, + namespace: element.namespace, + version: element.version, + mandatory: element.mandatory, + })), + ) + const [validationError, setValidationError] = useState('') + const versionMode = Boolean(initialValue) + + const availableElements = optionsQuery.data?.data ?? [] + + const handleSubmit = (): void => { + if (!versionMode && !name.trim()) { + setValidationError(t('catalog.validation.nameRequired')) + return + } + if (elements.length === 0 || elements.some((element) => !element.name)) { + setValidationError(t('catalog.validation.elementRequired')) + return + } + if (new Set(elements.map((element) => element.elementKey)).size !== elements.length) { + setValidationError(t('catalog.validation.duplicateElements')) + return + } + + const purposeElements = elements.map( + ({ name: elementName, namespace, version, mandatory }) => ({ + name: elementName, + namespace: namespace || undefined, + version: version || undefined, + mandatory, + }), + ) + const versionPayload: PurposeVersionCreateRequest = { + displayName: displayName.trim() || undefined, + description: description.trim() || undefined, + properties: entriesToProperties(properties), + elements: purposeElements, + } + + if (versionMode && onCreateVersion) { + onCreateVersion(versionPayload) + return + } + + onCreate({ ...versionPayload, name: name.trim() }, groupId.trim() || undefined) + } + + return ( + + + {versionMode ? t('catalog.purposes.newVersion') : t('catalog.purposes.createTitle')} + + + + {versionMode ? ( + + {t('catalog.messages.immutablePurpose', { + name: initialValue?.name, + groupId: initialValue?.groupId, + })} + + ) : ( + + setName(event.target.value)} + /> + setGroupId(event.target.value)} + /> + + )} + setDisplayName(event.target.value)} + /> + setDescription(event.target.value)} + /> + + + + + {t('catalog.fields.elements')} + + + {optionsQuery.isLoading ? ( + {t('catalog.messages.loadingElements')} + ) : null} + {!optionsQuery.isLoading && availableElements.length === 0 ? ( + + {t('catalog.messages.createElementFirst')} + + ) : null} + {elements.map((row) => ( + + { + const selected = availableElements.find( + (element) => elementKey(element) === event.target.value, + ) + setElements( + elements.map((item) => + item.id === row.id + ? { + ...item, + elementKey: event.target.value, + name: selected?.name ?? '', + namespace: selected?.namespace ?? '', + version: selected?.version ?? '', + } + : item, + ), + ) + }} + > + {availableElements.map((element) => ( + + {element.displayName ?? element.name} ({element.namespace}) + + ))} + + { + setElements( + elements.map((item) => + item.id === row.id ? { ...item, version: event.target.value } : item, + ), + ) + }} + sx={{ width: { sm: 130 } }} + /> + { + setElements( + elements.map((item) => + item.id === row.id + ? { ...item, mandatory: event.target.checked } + : item, + ), + ) + }} + /> + } + label={t('catalog.fields.mandatory')} + /> + setElements(elements.filter((item) => item.id !== row.id))} + > + + + + ))} + + {validationError || error ? ( + + {validationError || error} + + ) : null} + + + + + + + + ) +} + +export default PurposeFormDialog diff --git a/portal/frontend/src/features/catalog/hooks/useCatalogQueries.ts b/portal/frontend/src/features/catalog/hooks/useCatalogQueries.ts new file mode 100644 index 00000000..6319c261 --- /dev/null +++ b/portal/frontend/src/features/catalog/hooks/useCatalogQueries.ts @@ -0,0 +1,215 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import { + keepPreviousData, + type UseMutationResult, + type UseQueryResult, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' +import type { + ElementCreateRequest, + ElementFilters, + ElementListResponse, + ElementVersion, + ElementVersionCreateRequest, + ElementVersionList, + PurposeCreateRequest, + PurposeFilters, + PurposeListResponse, + PurposeVersion, + PurposeVersionCreateRequest, + PurposeVersionList, +} from '../../../types/catalog' +import { + createElement, + createElementVersion, + createPurpose, + createPurposeVersion, + deleteElementVersion, + deletePurposeVersion, + fetchElement, + fetchElements, + fetchElementVersions, + fetchPurpose, + fetchPurposes, + fetchPurposeVersions, +} from '../api/catalogApi' + +interface CreatePurposeVariables { + payload: PurposeCreateRequest + groupId?: string +} + +interface CreateElementVersionVariables { + elementId: string + payload: ElementVersionCreateRequest +} + +interface CreatePurposeVersionVariables { + purposeId: string + payload: PurposeVersionCreateRequest +} + +interface DeleteVersionVariables { + id: string + version: string +} + +export function useElementsQuery( + filters: ElementFilters, + page: number, + rowsPerPage: number, +): UseQueryResult { + return useQuery({ + queryKey: ['elements', filters, page, rowsPerPage], + queryFn: () => fetchElements(filters, rowsPerPage, page * rowsPerPage), + placeholderData: keepPreviousData, + }) +} + +export function useElementOptionsQuery(enabled = true): UseQueryResult { + return useQuery({ + queryKey: ['elements', 'options'], + queryFn: () => fetchElements({ name: '', namespace: '', type: 'All', version: '' }, 100, 0), + enabled, + }) +} + +export function useElementQuery(elementId?: string): UseQueryResult { + return useQuery({ + queryKey: ['element', elementId], + queryFn: () => fetchElement(String(elementId)), + enabled: Boolean(elementId), + }) +} + +export function useElementVersionsQuery(elementId?: string): UseQueryResult { + return useQuery({ + queryKey: ['element', elementId, 'versions'], + queryFn: () => fetchElementVersions(String(elementId)), + enabled: Boolean(elementId), + }) +} + +export function useCreateElementMutation(): UseMutationResult< + ElementVersion, + Error, + ElementCreateRequest +> { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: createElement, + onSuccess: async () => queryClient.invalidateQueries({ queryKey: ['elements'] }), + }) +} + +export function useCreateElementVersionMutation(): UseMutationResult< + ElementVersion, + Error, + CreateElementVersionVariables +> { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ elementId, payload }) => createElementVersion(elementId, payload), + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ queryKey: ['elements'] }) + await queryClient.invalidateQueries({ queryKey: ['element', variables.elementId] }) + }, + }) +} + +export function useDeleteElementVersionMutation(): UseMutationResult< + void, + Error, + DeleteVersionVariables +> { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ id, version }) => deleteElementVersion(id, version), + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ queryKey: ['elements'] }) + await queryClient.invalidateQueries({ queryKey: ['element', variables.id] }) + }, + }) +} + +export function usePurposesQuery( + filters: PurposeFilters, + page: number, + rowsPerPage: number, +): UseQueryResult { + return useQuery({ + queryKey: ['purposes', filters, page, rowsPerPage], + queryFn: () => fetchPurposes(filters, rowsPerPage, page * rowsPerPage), + placeholderData: keepPreviousData, + }) +} + +export function usePurposeQuery(purposeId?: string): UseQueryResult { + return useQuery({ + queryKey: ['purpose', purposeId], + queryFn: () => fetchPurpose(String(purposeId)), + enabled: Boolean(purposeId), + }) +} + +export function usePurposeVersionsQuery(purposeId?: string): UseQueryResult { + return useQuery({ + queryKey: ['purpose', purposeId, 'versions'], + queryFn: () => fetchPurposeVersions(String(purposeId)), + enabled: Boolean(purposeId), + }) +} + +export function useCreatePurposeMutation(): UseMutationResult< + PurposeVersion, + Error, + CreatePurposeVariables +> { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ payload, groupId }) => createPurpose(payload, groupId), + onSuccess: async () => queryClient.invalidateQueries({ queryKey: ['purposes'] }), + }) +} + +export function useCreatePurposeVersionMutation(): UseMutationResult< + PurposeVersion, + Error, + CreatePurposeVersionVariables +> { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ purposeId, payload }) => createPurposeVersion(purposeId, payload), + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ queryKey: ['purposes'] }) + await queryClient.invalidateQueries({ queryKey: ['purpose', variables.purposeId] }) + }, + }) +} + +export function useDeletePurposeVersionMutation(): UseMutationResult< + void, + Error, + DeleteVersionVariables +> { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ id, version }) => deletePurposeVersion(id, version), + onSuccess: async (_data, variables) => { + await queryClient.invalidateQueries({ queryKey: ['purposes'] }) + await queryClient.invalidateQueries({ queryKey: ['purpose', variables.id] }) + }, + }) +} diff --git a/portal/frontend/src/features/catalog/utils/formProperties.ts b/portal/frontend/src/features/catalog/utils/formProperties.ts new file mode 100644 index 00000000..3a7282dc --- /dev/null +++ b/portal/frontend/src/features/catalog/utils/formProperties.ts @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +export interface PropertyEntry { + id: number + key: string + value: string +} + +export function propertiesToEntries(properties?: Record): PropertyEntry[] { + return Object.entries(properties ?? {}).map(([key, value], index) => ({ id: index, key, value })) +} + +export function entriesToProperties(entries: PropertyEntry[]): Record | undefined { + const properties = Object.fromEntries( + entries + .map((entry) => [entry.key.trim(), entry.value] as const) + .filter(([key]) => Boolean(key)), + ) + + return Object.keys(properties).length > 0 ? properties : undefined +} diff --git a/portal/frontend/src/i18n/resources/en/common.ts b/portal/frontend/src/i18n/resources/en/common.ts index 09051ca6..d34812ac 100644 --- a/portal/frontend/src/i18n/resources/en/common.ts +++ b/portal/frontend/src/i18n/resources/en/common.ts @@ -26,6 +26,9 @@ const commonEn = { consent: 'Consent', allConsents: 'All Consents', pendingConsents: 'Pending Consents', + catalog: 'Definitions', + purposes: 'Purposes', + elements: 'Elements', }, layout: { home: 'Home', @@ -185,6 +188,104 @@ const commonEn = { }, }, }, + catalog: { + actions: { + addElement: 'Add element', + addProperty: 'Add property', + cancel: 'Cancel', + clear: 'Clear', + create: 'Create', + delete: 'Delete', + deleting: 'Deleting...', + deleteVersion: 'Delete version', + removeElement: 'Remove element', + removeProperty: 'Remove property', + saving: 'Saving...', + }, + fields: { + actions: 'Actions', + created: 'Created', + description: 'Description', + displayName: 'Display name', + element: 'Element', + elementId: 'Element ID', + elementName: 'Element name', + elementNamespace: 'Element namespace', + elements: 'Elements', + elementVersion: 'Element version', + groupId: 'Group ID', + groupIds: 'Group IDs', + mandatory: 'Mandatory', + name: 'Name', + namespace: 'Namespace', + properties: 'Properties', + propertyKey: 'Key', + propertyValue: 'Value', + purposeId: 'Purpose ID', + purposeName: 'Purpose name', + purposeVersion: 'Purpose version', + requirement: 'Requirement', + schema: 'Schema', + type: 'Type', + version: 'Version', + }, + help: { + defaultNamespace: 'Uses the default namespace when empty', + organizationPurpose: 'Leave empty to create an organization-level purpose', + }, + values: { + all: 'All', + mandatory: 'Mandatory', + optional: 'Optional', + }, + elements: { + add: 'Add element', + back: 'Back to elements', + createTitle: 'Create element', + empty: 'No elements match the selected filters.', + loadFailed: 'Unable to load elements right now.', + newVersion: 'Create new version', + tableLabel: 'Consent elements', + title: 'Elements', + }, + purposes: { + add: 'Add purpose', + back: 'Back to purposes', + createTitle: 'Create purpose', + empty: 'No purposes match the selected filters.', + loadFailed: 'Unable to load purposes right now.', + newVersion: 'Create new version', + tableLabel: 'Consent purposes', + title: 'Purposes', + }, + details: { + definition: 'Definition', + elements: 'Elements', + overview: 'Overview', + versions: 'Version history', + }, + messages: { + createElementFirst: 'Create an element before defining a purpose.', + immutableElement: + '{{namespace}}:{{name}} remains a {{type}} element. Identity fields cannot change between versions.', + immutablePurpose: + '{{name}} remains owned by {{groupId}}. Identity fields cannot change between versions.', + loadingElements: 'Loading available elements...', + noProperties: 'No custom properties.', + }, + validation: { + duplicateElements: 'Each element can only be included once.', + elementRequired: 'Select at least one element.', + nameRequired: 'Name is required.', + schemaRequired: 'Schema is required for JSON and XML elements.', + }, + delete: { + title: 'Delete version', + message: 'Delete version {{version}}?', + warning: + 'This cannot be undone. Referenced versions cannot be deleted, and deleting the final version deletes the entire entry.', + }, + }, } as const export default commonEn diff --git a/portal/frontend/src/types/catalog.ts b/portal/frontend/src/types/catalog.ts new file mode 100644 index 00000000..fcbb79e1 --- /dev/null +++ b/portal/frontend/src/types/catalog.ts @@ -0,0 +1,181 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +export type ElementType = 'basic' | 'json' | 'xml' + +export interface PaginationMetadata { + total: number + offset: number + count: number + limit: number +} + +export interface ElementVersion { + elementId: string + name: string + namespace: string + version: string + type: ElementType + displayName?: string + description?: string + schema?: string + properties?: Record + createdTime: number +} + +export type ElementSummary = ElementVersion + +export interface ElementVersionItem { + version: string + displayName?: string + description?: string + schema?: string + properties?: Record + createdTime: number +} + +export interface ElementVersionList { + elementId: string + name: string + namespace: string + type: ElementType + versions: ElementVersionItem[] +} + +export interface ElementListResponse { + data: ElementSummary[] + metadata: PaginationMetadata +} + +export interface ElementFilters { + name: string + namespace: string + type: ElementType | 'All' + version: string +} + +export interface ElementCreateRequest { + name: string + namespace?: string + type: ElementType + displayName?: string + description?: string + schema?: string + properties?: Record +} + +export interface ElementVersionCreateRequest { + displayName?: string + description?: string + schema?: string + properties?: Record +} + +export interface ElementBulkCreateResult { + index: number + status: 'SUCCESS' | 'FAILED' + data?: ElementVersion + error?: { + code: string + message: string + description?: string + } +} + +export interface ElementBulkCreateResponse { + metadata: { + traceId: string + total: number + succeeded: number + failed: number + } + results: ElementBulkCreateResult[] +} + +export interface PurposeElement { + elementId: string + name: string + namespace: string + version: string + displayName?: string | null + description?: string | null + mandatory: boolean +} + +export interface PurposeVersion { + purposeId: string + name: string + groupId: string + version: string + displayName?: string + description?: string + properties?: Record + elements: PurposeElement[] + createdTime: number +} + +export interface PurposeSummary { + purposeId: string + name: string + groupId: string + version: string + displayName?: string + description?: string + createdTime: number + elements?: PurposeElement[] + properties?: Record +} + +export interface PurposeVersionItem { + version: string + displayName?: string + description?: string + properties?: Record + elements: PurposeElement[] + createdTime: number +} + +export interface PurposeVersionList { + purposeId: string + name: string + groupId: string + versions: PurposeVersionItem[] +} + +export interface PurposeListResponse { + data: PurposeSummary[] + metadata: PaginationMetadata +} + +export interface PurposeFilters { + purposeName: string + purposeVersion: string + elementName: string + elementNamespace: string + elementVersion: string + groupIds: string +} + +export interface PurposeElementRequest { + name: string + namespace?: string + version?: string + mandatory: boolean +} + +export interface PurposeCreateRequest { + name: string + displayName?: string + description?: string + properties?: Record + elements: PurposeElementRequest[] +} + +export interface PurposeVersionCreateRequest { + displayName?: string + description?: string + properties?: Record + elements: PurposeElementRequest[] +} From e9361c62d3a06b0e9f65f76c7ca0c417e17485ed Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Wed, 22 Jul 2026 02:04:35 +0530 Subject: [PATCH 02/19] Improve purpose pages --- portal/backend/.env.example | 2 +- portal/frontend/.env.example | 1 + portal/frontend/README.md | 1 + .../src/__tests__/PurposeFormDialog.test.tsx | 136 +++++ .../features/catalog/ElementDetailsPage.tsx | 4 +- .../src/features/catalog/ElementListPage.tsx | 422 +++++++++++-- .../features/catalog/PurposeDetailsPage.tsx | 361 +++++++++--- .../src/features/catalog/PurposeListPage.tsx | 557 +++++++++++++++--- .../catalog/components/ElementFormDialog.tsx | 2 +- .../catalog/components/PropertyEditor.tsx | 18 +- .../catalog/components/PurposeFormDialog.tsx | 458 +++++++++----- .../frontend/src/i18n/resources/en/common.ts | 51 ++ portal/frontend/vitest.setup.ts | 1 + 13 files changed, 1645 insertions(+), 369 deletions(-) create mode 100644 portal/frontend/src/__tests__/PurposeFormDialog.test.tsx diff --git a/portal/backend/.env.example b/portal/backend/.env.example index 41dcdb2b..c95f6104 100644 --- a/portal/backend/.env.example +++ b/portal/backend/.env.example @@ -42,7 +42,7 @@ BFF_AUTH__CLIENT_SECRET= BFF_AUTH__PORTAL_URL=http://localhost:5173/consents BFF_AUTH__REDIRECT_URI=http://localhost:8080/auth/callback BFF_AUTH__POST_LOGOUT_REDIRECT_URI=http://localhost:5173/ -BFF_AUTH__SCOPES=openid email profile portal:consents:read:self portal:consents:write:self +BFF_AUTH__SCOPES=openid email profile portal:consents:read:self portal:consents:write:self portal:elements:read portal:elements:write portal:purposes:read portal:purposes:write BFF_AUTH__RESOURCE_AUDIENCE= BFF_AUTH__ALLOWED_SIGNING_ALGORITHMS=RS256 BFF_AUTH__HTTP_TIMEOUT=5s diff --git a/portal/frontend/.env.example b/portal/frontend/.env.example index 5fa0389a..597e9f85 100644 --- a/portal/frontend/.env.example +++ b/portal/frontend/.env.example @@ -1,4 +1,5 @@ VITE_API_BASE_URL=http://localhost:8080 +VITE_ORG_ID=openfgc-perf-org VITE_AUTH_ENABLED=false VITE_AUTH_ACCESS_TOKEN_PART1_COOKIE=portal-at-p1 VITE_AUTH_REFRESH_TOKEN_PART1_COOKIE=portal-rt-p1 diff --git a/portal/frontend/README.md b/portal/frontend/README.md index 4d37b849..4ed8926d 100644 --- a/portal/frontend/README.md +++ b/portal/frontend/README.md @@ -52,6 +52,7 @@ Create a local `.env` file from `.env.example` before running or building the po | Variable | Description | Example | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------- | | `VITE_API_BASE_URL` | Required base URL for the OpenFGC Portal backend API. Vite embeds this value at build time. | `http://localhost:8080` | +| `VITE_ORG_ID` | Organization ID used to identify and filter organization-wide consent purposes. | `openfgc-perf-org` | | `VITE_AUTH_ENABLED` | Enables frontend authentication gating; set to `true` for protected deployments. | `true` | | `VITE_AUTH_ACCESS_TOKEN_PART1_COOKIE` | Cookie name for the readable access-token part. | `portal-at-p1` | | `VITE_AUTH_REFRESH_TOKEN_PART1_COOKIE` | Cookie name for the readable refresh-token part. | `portal-rt-p1` | diff --git a/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx b/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx new file mode 100644 index 00000000..79f4446c --- /dev/null +++ b/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx @@ -0,0 +1,136 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import { fireEvent, render, screen } from '@testing-library/react' +import { createElement, type ComponentProps } from 'react' +import { I18nextProvider } from 'react-i18next' +import { describe, expect, it, vi } from 'vitest' +import { OxygenTheme, OxygenUIThemeProvider } from '@wso2/oxygen-ui' +import PurposeFormDialog from '../features/catalog/components/PurposeFormDialog' +import i18n from '../i18n/i18n' + +vi.mock('../features/catalog/hooks/useCatalogQueries', () => ({ + useElementOptionsQuery: () => ({ + data: { + data: [ + { + elementId: 'element-email', + name: 'email', + namespace: 'profile', + version: 'v1', + type: 'basic', + displayName: 'Email address', + createdTime: 1, + }, + ], + metadata: { total: 1, offset: 0, count: 1, limit: 100 }, + }, + isLoading: false, + }), +})) + +function renderDialog(overrides: Partial> = {}): void { + const props: ComponentProps = { + open: true, + initialValue: undefined, + loading: false, + error: undefined, + onClose: vi.fn(), + onCreate: vi.fn(), + onCreateVersion: undefined, + ...overrides, + } + + render( + + + {createElement(PurposeFormDialog, props)} + + , + ) +} + +describe('purpose form dialog', () => { + it('uses the compact layout and creates an organization-wide purpose without a group ID', () => { + const onCreate = vi.fn() + renderDialog({ onCreate }) + + expect(screen.getByRole('dialog')).toHaveStyle({ maxWidth: '720px' }) + expect(screen.queryByRole('textbox', { name: /group id/i })).not.toBeInTheDocument() + + fireEvent.change(screen.getByRole('textbox', { name: /^name$/i }), { + target: { value: 'contact-purpose' }, + }) + fireEvent.click(screen.getByRole('button', { name: /add element/i })) + + const elementInput = screen.getByRole('combobox', { name: /element/i }) + fireEvent.change(elementInput, { target: { value: 'Email' } }) + fireEvent.click(screen.getByRole('option', { name: 'Email address (profile)' })) + fireEvent.click(screen.getByRole('button', { name: /^create$/i })) + + expect(onCreate).toHaveBeenCalledWith( + { + name: 'contact-purpose', + displayName: undefined, + description: undefined, + properties: undefined, + elements: [{ name: 'email', namespace: 'profile', version: 'v1', mandatory: false }], + }, + undefined, + ) + }) + + it('shows and requires Group ID for a group-specific purpose', () => { + renderDialog() + + fireEvent.mouseDown(screen.getByRole('combobox', { name: /purpose scope/i })) + fireEvent.click(screen.getByRole('option', { name: 'Specific group' })) + expect(screen.getByRole('textbox', { name: /group id/i })).toBeInTheDocument() + + fireEvent.change(screen.getByRole('textbox', { name: /^name$/i }), { + target: { value: 'contact-purpose' }, + }) + fireEvent.click(screen.getByRole('button', { name: /^create$/i })) + + expect(screen.getByRole('alert')).toHaveTextContent( + 'Group ID is required for a group-specific purpose.', + ) + }) + + it('keeps properties visible and rejects duplicate keys in create-version mode', () => { + const onCreateVersion = vi.fn() + renderDialog({ + initialValue: { + purposeId: 'purpose-contact', + name: 'contact-purpose', + groupId: 'test-org', + version: 'v1', + elements: [ + { + elementId: 'element-email', + name: 'email', + namespace: 'profile', + version: 'v1', + mandatory: false, + }, + ], + createdTime: 1, + }, + onCreateVersion, + }) + + expect(screen.getByRole('button', { name: /add property/i })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /add property/i })) + fireEvent.click(screen.getByRole('button', { name: /add property/i })) + + screen.getAllByRole('textbox', { name: /key/i }).forEach((input) => { + fireEvent.change(input, { target: { value: 'retention' } }) + }) + fireEvent.click(screen.getByRole('button', { name: /^create$/i })) + + expect(screen.getByRole('alert')).toHaveTextContent('Property keys must be unique.') + expect(onCreateVersion).not.toHaveBeenCalled() + }) +}) diff --git a/portal/frontend/src/features/catalog/ElementDetailsPage.tsx b/portal/frontend/src/features/catalog/ElementDetailsPage.tsx index 8f489bbd..f90e7343 100644 --- a/portal/frontend/src/features/catalog/ElementDetailsPage.tsx +++ b/portal/frontend/src/features/catalog/ElementDetailsPage.tsx @@ -165,7 +165,9 @@ function ElementDetailsPage(): React.JSX.Element { sx={{ cursor: 'pointer' }} onClick={() => setSelectedVersion(version.version)} > - {version.version} + + + {version.displayName ?? '-'} {version.description ?? '-'} {formatEpochTimestamp(version.createdTime)} diff --git a/portal/frontend/src/features/catalog/ElementListPage.tsx b/portal/frontend/src/features/catalog/ElementListPage.tsx index 4f9ff44f..d066fe2b 100644 --- a/portal/frontend/src/features/catalog/ElementListPage.tsx +++ b/portal/frontend/src/features/catalog/ElementListPage.tsx @@ -7,8 +7,11 @@ import { Box, Button, Chip, + IconButton, MenuItem, Paper, + Popover, + SearchBar, Skeleton, Stack, Table, @@ -19,9 +22,10 @@ import { TablePagination, TableRow, TextField, + Tooltip, Typography, } from '@wso2/oxygen-ui' -import { Plus } from '@wso2/oxygen-ui-icons-react' +import { Eye, ListFilter, Plus, Search } from '@wso2/oxygen-ui-icons-react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, useSearchParams } from 'react-router-dom' @@ -32,6 +36,7 @@ import ElementFormDialog from './components/ElementFormDialog' import { useCreateElementMutation, useElementsQuery } from './hooks/useCatalogQueries' const ROW_OPTIONS = [10, 25, 50] +const EMPTY_FILTERS: ElementFilters = { name: '', namespace: '', type: 'All', version: '' } function getPage(searchParams: URLSearchParams): number { const page = Number(searchParams.get('page') ?? '1') @@ -53,6 +58,210 @@ function getFilters(searchParams: URLSearchParams): ElementFilters { } } +interface ElementFiltersPanelProps { + initialFilters: ElementFilters + onApply: (filters: ElementFilters) => void + onClear: () => void +} + +function ElementFiltersPanel({ + initialFilters, + onApply, + onClear, +}: ElementFiltersPanelProps): React.JSX.Element { + const { t } = useTranslation('common') + const [draft, setDraft] = useState(initialFilters) + const [filtersAnchor, setFiltersAnchor] = useState(null) + const filtersOpen = Boolean(filtersAnchor) + const advancedFilterCount = [ + draft.namespace, + draft.type === 'All' ? '' : draft.type, + draft.version, + ].filter(Boolean).length + + const applySearch = (name: string): void => { + const nextFilters = { ...draft, name } + setDraft(nextFilters) + onApply(nextFilters) + } + const cancelAdvancedChanges = (): void => { + setDraft({ ...initialFilters, name: draft.name }) + setFiltersAnchor(null) + } + + return ( + + { + const name = event.target.value + setDraft({ + ...draft, + name, + version: name.trim() || draft.namespace.trim() ? draft.version : '', + }) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') applySearch(draft.name) + }} + sx={{ '& .MuiInputBase-root': { pr: 6 } }} + /> + + + setFiltersAnchor(event.currentTarget)} + > + + + + {advancedFilterCount > 0 ? ( + + {advancedFilterCount} + + ) : null} + + + + + + + {t('catalog.elements.filterTitle')} + + + + { + const namespace = event.target.value + setDraft({ + ...draft, + namespace, + version: draft.name.trim() || namespace.trim() ? draft.version : '', + }) + }} + /> + + setDraft({ ...draft, type: event.target.value as ElementType | 'All' }) + } + > + {t('catalog.values.all')} + basic + json + xml + + + + setDraft({ ...draft, version: event.target.value })} + /> + + + + + + + + + + + + + + ) +} + function ElementListPage(): React.JSX.Element { const { t } = useTranslation('common') const navigate = useNavigate() @@ -80,6 +289,24 @@ function ElementListPage(): React.JSX.Element { } const rows = query.data?.data ?? [] + const activeFilters = Object.entries(filters).filter(([, value]) => + Boolean(value && value !== 'All'), + ) as Array<[keyof ElementFilters, string]> + const filterLabels: Record = { + name: t('catalog.fields.name'), + namespace: t('catalog.fields.namespace'), + type: t('catalog.fields.type'), + version: t('catalog.fields.version'), + } + const removeFilter = (key: keyof ElementFilters): void => { + const nextFilters: ElementFilters = { + ...filters, + [key]: key === 'type' ? 'All' : '', + } + if (key === 'name' && !filters.namespace) nextFilters.version = '' + if (key === 'namespace' && !filters.name) nextFilters.version = '' + updateParams(nextFilters) + } return ( @@ -95,6 +322,9 @@ function ElementListPage(): React.JSX.Element { {t('catalog.elements.title')} + + {t('catalog.elements.subtitle')} + - + ) : null} {query.isError ? ( - {t('catalog.elements.loadFailed')} + + + {t('catalog.elements.loadFailed')} + + + ) : null} + - + + + {t('catalog.elements.results', { count: query.data?.metadata.total ?? 0 })} + + {query.isFetching && !query.isPending ? ( + + {t('catalog.elements.refreshing')} + + ) : null} + +
- {t('catalog.fields.displayName')} - {t('catalog.fields.name')} - {t('catalog.fields.namespace')} - {t('catalog.fields.type')} - {t('catalog.fields.version')} - {t('catalog.fields.created')} + {t('catalog.fields.element')} + {t('catalog.fields.namespace')} + {t('catalog.fields.type')} + {t('catalog.fields.version')} + {t('catalog.fields.description')} + {t('catalog.fields.created')} + + {t('catalog.fields.actions')} + {query.isPending ? Array.from({ length: 5 }).map((_, index) => ( - {Array.from({ length: 6 }).map((__, cell) => ( + {Array.from({ length: 7 }).map((__, cell) => ( @@ -188,22 +436,80 @@ function ElementListPage(): React.JSX.Element { } }} > - {element.displayName ?? '-'} - {element.name} + + + {element.displayName ?? element.name} + + + {element.name} + + + + + + {element.namespace} + - {element.namespace} - {element.version} - {formatEpochTimestamp(element.createdTime)} + + + + + + {element.description ?? t('catalog.values.noDescription')} + + + + + {formatEpochTimestamp(element.createdTime)} + + + + + { + event.stopPropagation() + navigate(`/elements/${encodeURIComponent(element.elementId)}`) + }} + > + + + + ))} {!query.isPending && !query.isError && rows.length === 0 ? ( - - {t('catalog.elements.empty')} + + + ) : null} diff --git a/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx b/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx index 71b763cd..e79528fb 100644 --- a/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx +++ b/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx @@ -4,6 +4,7 @@ */ import { + Alert, Box, Button, Card, @@ -13,6 +14,7 @@ import { Divider, IconButton, Link, + MenuItem, Skeleton, Stack, Table, @@ -21,17 +23,17 @@ import { TableContainer, TableHead, TableRow, + TextField, Tooltip, Typography, } from '@wso2/oxygen-ui' -import { Plus, Trash2 } from '@wso2/oxygen-ui-icons-react' +import { Eye, Plus, Trash2 } from '@wso2/oxygen-ui-icons-react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom' import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs' -import type { PurposeVersion, PurposeVersionItem } from '../../types/catalog' +import type { PurposeVersionItem } from '../../types/catalog' import { formatEpochTimestamp } from '../../utils/dateTime' -import { OverviewCard, PropertiesCard } from './components/CatalogDetailCards' import DeleteVersionDialog from './components/DeleteVersionDialog' import PurposeFormDialog from './components/PurposeFormDialog' import { @@ -41,6 +43,36 @@ import { usePurposeVersionsQuery, } from './hooks/useCatalogQueries' +const ORG_ID = String(import.meta.env.VITE_ORG_ID ?? '').trim() + +interface DetailField { + label: string + value: React.ReactNode +} + +function DetailGrid({ fields }: { fields: DetailField[] }): React.JSX.Element { + return ( + + {fields.map((field) => ( + + + {field.label} + + + {field.value || '-'} + + + ))} + + ) +} + function PurposeDetailsPage(): React.JSX.Element { const { t } = useTranslation('common') const { id } = useParams<{ id: string }>() @@ -54,13 +86,29 @@ function PurposeDetailsPage(): React.JSX.Element { const [deleteVersion, setDeleteVersion] = useState() const detail = detailQuery.data - const selectedVersionItem = useMemo( - () => versionsQuery.data?.versions.find((version) => version.version === selectedVersion), - [selectedVersion, versionsQuery.data?.versions], + const latestVersion = useMemo( + () => + detail + ? { + version: detail.version, + displayName: detail.displayName, + description: detail.description, + properties: detail.properties, + elements: detail.elements, + createdTime: detail.createdTime, + } + : undefined, + [detail], ) - const displayed: PurposeVersion | undefined = detail - ? { ...detail, ...(selectedVersionItem ?? {}) } - : undefined + const versions = useMemo(() => versionsQuery.data?.versions ?? [], [versionsQuery.data?.versions]) + const versionOptions = useMemo(() => { + if (!latestVersion || versions.some((version) => version.version === latestVersion.version)) { + return versions + } + return [latestVersion, ...versions] + }, [latestVersion, versions]) + const displayedVersion = + versionOptions.find((version) => version.version === selectedVersion) ?? latestVersion if (detailQuery.isLoading) { return ( @@ -68,6 +116,7 @@ function PurposeDetailsPage(): React.JSX.Element { + @@ -75,7 +124,7 @@ function PurposeDetailsPage(): React.JSX.Element { ) } - if (!id || detailQuery.isError || !displayed) { + if (!id || detailQuery.isError || !detail || !displayedVersion) { return ( @@ -88,84 +137,206 @@ function PurposeDetailsPage(): React.JSX.Element { ) } - const versions = versionsQuery.data?.versions ?? [] + const organizationWide = Boolean(ORG_ID) && detail.groupId === ORG_ID + const propertyEntries = Object.entries(displayedVersion.properties ?? {}) return ( - + - - {displayed.displayName ?? displayed.name} + + {displayedVersion.displayName ?? detail.name} - + + + {displayedVersion.version === detail.version ? ( + + ) : null} + { + const version = event.target.value + setSelectedVersion(version === detail.version ? undefined : version) + }} + sx={{ minWidth: 150 }} + > + {versionOptions.map((version) => ( + + {version.version} + {version.version === detail.version ? ` · ${t('catalog.values.latest')}` : ''} + + ))} + + + - {displayed.name}, - }, - { label: t('catalog.fields.groupId'), value: displayed.groupId }, - { label: t('catalog.fields.version'), value: displayed.version }, - { - label: t('catalog.fields.created'), - value: formatEpochTimestamp(displayed.createdTime), - }, - { label: t('catalog.fields.description'), value: displayed.description ?? '-' }, - ]} - /> - + + {t('catalog.details.identity')}} + /> + + + {detail.name}, + }, + { + label: t('catalog.fields.scope'), + value: organizationWide ? ( + + ) : ( + + + {detail.groupId} + + ), + }, + ]} + /> + + + + + {t('catalog.details.versionDetails')}} + /> + + + , + }, + { + label: t('catalog.fields.displayName'), + value: displayedVersion.displayName ?? '-', + }, + { + label: t('catalog.fields.created'), + value: formatEpochTimestamp(displayedVersion.createdTime), + }, + { + label: t('catalog.fields.description'), + value: displayedVersion.description ?? '-', + }, + ]} + /> + + + + + {t('catalog.fields.properties')}} + /> + + + {propertyEntries.length > 0 ? ( + + {propertyEntries.map(([key, value]) => ( + + + {key} + + + {value} + + + ))} + + ) : ( + + {t('catalog.messages.noProperties')} + + )} + + - + {t('catalog.details.elements')}} /> - - -
- + +
+ + + {t('catalog.fields.element')} + {t('catalog.fields.version')} + {t('catalog.fields.requirement')} + + + + {displayedVersion.elements.length === 0 ? ( - {t('catalog.fields.element')} - {t('catalog.fields.name')} - {t('catalog.fields.namespace')} - {t('catalog.fields.version')} - {t('catalog.fields.requirement')} + + + {t('catalog.messages.noElements')} + + - - - {displayed.elements.map((element) => ( - + ) : null} + {displayedVersion.elements.map((element) => { + const elementPath = `/elements/${encodeURIComponent(element.elementId)}` + + return ( + navigate(elementPath)} + > - - {element.displayName ?? element.name} - + + + {element.displayName ?? element.name} + + + {element.name} · {element.namespace} + + - {element.name} + - {element.namespace} - {element.version} - ))} - -
-
- + ) + })} + + + - + {t('catalog.details.versions')}} /> - + {versionsQuery.isLoading ? ( + + + + + + + + ) : null} + {versionsQuery.isError ? ( + + + {versionsQuery.error?.message || t('catalog.messages.versionsLoadFailed')} + + + ) : null} + {!versionsQuery.isLoading && !versionsQuery.isError ? ( @@ -204,15 +391,37 @@ function PurposeDetailsPage(): React.JSX.Element { - {versions.map((version) => ( + {versionOptions.length === 0 ? ( + + + {t('catalog.messages.noVersions')} + + + ) : null} + {versionOptions.map((version) => ( setSelectedVersion(version.version)} + onClick={() => + setSelectedVersion( + version.version === detail.version ? undefined : version.version, + ) + } > - {version.version} + + + + {displayedVersion.version === version.version ? ( + } + label={t('catalog.values.viewing')} + /> + ) : null} + + {version.displayName ?? '-'} {version.elements.length} {formatEpochTimestamp(version.createdTime)} @@ -235,7 +444,7 @@ function PurposeDetailsPage(): React.JSX.Element {
-
+ ) : null}
diff --git a/portal/frontend/src/features/catalog/PurposeListPage.tsx b/portal/frontend/src/features/catalog/PurposeListPage.tsx index c1f9a23a..f44ad87b 100644 --- a/portal/frontend/src/features/catalog/PurposeListPage.tsx +++ b/portal/frontend/src/features/catalog/PurposeListPage.tsx @@ -6,7 +6,11 @@ import { Box, Button, + Chip, + IconButton, Paper, + Popover, + SearchBar, Skeleton, Stack, Table, @@ -17,9 +21,12 @@ import { TablePagination, TableRow, TextField, + Tooltip, + ToggleButton, + ToggleButtonGroup, Typography, } from '@wso2/oxygen-ui' -import { Plus } from '@wso2/oxygen-ui-icons-react' +import { Eye, ListFilter, Plus, Search } from '@wso2/oxygen-ui-icons-react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, useSearchParams } from 'react-router-dom' @@ -30,16 +37,300 @@ import PurposeFormDialog from './components/PurposeFormDialog' import { useCreatePurposeMutation, usePurposesQuery } from './hooks/useCatalogQueries' const ROW_OPTIONS = [10, 25, 50] +const ORG_ID = String(import.meta.env.VITE_ORG_ID ?? '').trim() + +type PurposeScope = 'organization' | 'all' + +interface PurposeListFilters extends PurposeFilters { + scope: PurposeScope +} + +function emptyFilters(): PurposeListFilters { + return { + purposeName: '', + purposeVersion: '', + elementName: '', + elementNamespace: '', + elementVersion: '', + groupIds: ORG_ID, + scope: 'organization', + } +} + +function getFilters(searchParams: URLSearchParams): PurposeListFilters { + const scope: PurposeScope = searchParams.get('scope') === 'all' ? 'all' : 'organization' -function getFilters(searchParams: URLSearchParams): PurposeFilters { return { purposeName: searchParams.get('purposeName') ?? '', purposeVersion: searchParams.get('purposeVersion') ?? '', elementName: searchParams.get('elementName') ?? '', elementNamespace: searchParams.get('elementNamespace') ?? '', elementVersion: searchParams.get('elementVersion') ?? '', - groupIds: searchParams.get('groupIds') ?? '', + groupIds: scope === 'organization' ? ORG_ID : (searchParams.get('groupIds') ?? ''), + scope, + } +} + +interface PurposeFiltersPanelProps { + initialFilters: PurposeListFilters + onApply: (filters: PurposeListFilters) => void + onClear: () => void +} + +function PurposeFiltersPanel({ + initialFilters, + onApply, + onClear, +}: PurposeFiltersPanelProps): React.JSX.Element { + const { t } = useTranslation('common') + const [draft, setDraft] = useState(initialFilters) + const [filtersAnchor, setFiltersAnchor] = useState(null) + const filtersOpen = Boolean(filtersAnchor) + const advancedFilterCount = [ + draft.purposeVersion, + draft.elementName, + draft.elementNamespace, + draft.elementVersion, + draft.scope === 'all' ? draft.groupIds : '', + ].filter(Boolean).length + + const applySearch = (purposeName: string): void => { + const nextFilters = { ...draft, purposeName } + setDraft(nextFilters) + onApply(nextFilters) + } + + const cancelAdvancedChanges = (): void => { + setDraft({ ...initialFilters, purposeName: draft.purposeName }) + setFiltersAnchor(null) } + + return ( + + { + const purposeName = event.target.value + setDraft({ + ...draft, + purposeName, + purposeVersion: purposeName.trim() ? draft.purposeVersion : '', + }) + }} + onKeyDown={(event) => { + if (event.key === 'Enter') { + applySearch(draft.purposeName) + } + }} + sx={{ '& .MuiInputBase-root': { pr: 6 } }} + /> + + + setFiltersAnchor(event.currentTarget)} + > + + + + {advancedFilterCount > 0 ? ( + + {advancedFilterCount} + + ) : null} + + + + + + + {t('catalog.purposes.filterTitle')} + + + { + if (!value) return + setDraft({ + ...draft, + scope: value, + groupIds: value === 'organization' ? ORG_ID : '', + }) + }} + aria-label={t('catalog.purposes.scopeFilter')} + > + {t('catalog.purposes.orgWide')} + {t('catalog.purposes.allPurposes')} + + + {draft.scope === 'all' ? ( + + + setDraft({ ...draft, groupIds: event.target.value })} + /> + + + ) : null} + + + setDraft({ ...draft, purposeVersion: event.target.value })} + /> + + + + + { + const elementName = event.target.value + setDraft({ + ...draft, + elementName, + elementVersion: + elementName.trim() || draft.elementNamespace.trim() ? draft.elementVersion : '', + }) + }} + /> + { + const elementNamespace = event.target.value + setDraft({ + ...draft, + elementNamespace, + elementVersion: + draft.elementName.trim() || elementNamespace.trim() ? draft.elementVersion : '', + }) + }} + /> + + + setDraft({ ...draft, elementVersion: event.target.value })} + /> + + + + + + + + + + + + + + ) } function PurposeListPage(): React.JSX.Element { @@ -56,19 +347,46 @@ function PurposeListPage(): React.JSX.Element { const createMutation = useCreatePurposeMutation() const updateParams = ( - nextFilters: PurposeFilters, + nextFilters: PurposeListFilters, nextPage = 0, nextRowsPerPage = rowsPerPage, ): void => { const params = new URLSearchParams() - Object.entries(nextFilters).forEach(([key, value]) => { + const { scope, ...apiFilters } = nextFilters + Object.entries(apiFilters).forEach(([key, value]) => { + if (scope === 'organization' && key === 'groupIds') return if (value.trim()) params.set(key, value.trim()) }) + if (scope === 'all') params.set('scope', 'all') if (nextPage > 0) params.set('page', String(nextPage + 1)) if (nextRowsPerPage !== 10) params.set('rowsPerPage', String(nextRowsPerPage)) setSearchParams(params, { replace: true }) } + const rows = query.data?.data ?? [] + const activeFilters = Object.entries(filters).filter( + ([key, value]) => + key !== 'scope' && + !(filters.scope === 'organization' && key === 'groupIds') && + Boolean(value.trim()), + ) as Array<[keyof PurposeFilters, string]> + const filterLabels: Record = { + purposeName: t('catalog.fields.purposeName'), + purposeVersion: t('catalog.fields.purposeVersion'), + elementName: t('catalog.fields.elementName'), + elementNamespace: t('catalog.fields.elementNamespace'), + elementVersion: t('catalog.fields.elementVersion'), + groupIds: t('catalog.fields.groupIds'), + } + const removeFilter = (key: keyof PurposeFilters): void => { + const nextFilters: PurposeListFilters = { ...filters, [key]: '' } + + if (key === 'purposeName') nextFilters.purposeVersion = '' + if (key === 'elementName' && !filters.elementNamespace) nextFilters.elementVersion = '' + if (key === 'elementNamespace' && !filters.elementName) nextFilters.elementVersion = '' + + updateParams(nextFilters) + } return ( @@ -84,6 +402,9 @@ function PurposeListPage(): React.JSX.Element { {t('catalog.purposes.title')} + + {t('catalog.purposes.subtitle')} + - - - + updateParams(nextFilters)} + onClear={() => setSearchParams({}, { replace: true })} + /> + + + + {t('catalog.purposes.activeFilters')} + + + {activeFilters.map(([key, value]) => ( + removeFilter(key)} + /> + ))} + {activeFilters.length > 0 || filters.scope === 'all' ? ( + + ) : null} + {query.isError ? ( - {t('catalog.purposes.loadFailed')} + + + {t('catalog.purposes.loadFailed')} + + + ) : null} + - + + + {t('catalog.purposes.results', { count: query.data?.metadata.total ?? 0 })} + + {query.isFetching && !query.isPending ? ( + + {t('catalog.purposes.refreshing')} + + ) : null} + +
- {t('catalog.fields.displayName')} - {t('catalog.fields.name')} - {t('catalog.fields.groupId')} - {t('catalog.fields.version')} - {t('catalog.fields.description')} - {t('catalog.fields.created')} + {t('catalog.fields.purpose')} + {t('catalog.fields.groupId')} + {t('catalog.fields.version')} + {t('catalog.fields.description')} + {t('catalog.fields.created')} + + {t('catalog.fields.actions')} + @@ -193,20 +525,85 @@ function PurposeListPage(): React.JSX.Element { } }} > - {purpose.displayName ?? '-'} - {purpose.name} + + + {purpose.displayName ?? purpose.name} + + + {purpose.name} + + + + + {purpose.groupId === ORG_ID ? ( + + ) : ( + + {purpose.groupId} + + )} + + + + + + + {purpose.description ?? t('catalog.values.noDescription')} + + + + + {formatEpochTimestamp(purpose.createdTime)} + + + + + { + event.stopPropagation() + navigate(`/purposes/${encodeURIComponent(purpose.purposeId)}`) + }} + > + + + - {purpose.groupId} - {purpose.version} - {purpose.description ?? '-'} - {formatEpochTimestamp(purpose.createdTime)} ))} {!query.isPending && !query.isError && rows.length === 0 ? ( - - {t('catalog.purposes.empty')} + + + ) : null} diff --git a/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx b/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx index c21ef2b4..15d79f0e 100644 --- a/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx +++ b/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx @@ -168,7 +168,7 @@ function ElementFormDialog({ onChange={(event) => setSchema(event.target.value)} /> ) : null} - + {validationError || error ? ( {validationError || error} diff --git a/portal/frontend/src/features/catalog/components/PropertyEditor.tsx b/portal/frontend/src/features/catalog/components/PropertyEditor.tsx index 4bf70f2c..1b508f56 100644 --- a/portal/frontend/src/features/catalog/components/PropertyEditor.tsx +++ b/portal/frontend/src/features/catalog/components/PropertyEditor.tsx @@ -10,16 +10,24 @@ import type { PropertyEntry } from '../utils/formProperties' interface PropertyEditorProps { entries: PropertyEntry[] + embedded?: boolean onChange: (entries: PropertyEntry[]) => void } -function PropertyEditor({ entries, onChange }: PropertyEditorProps): React.JSX.Element { +function PropertyEditor({ entries, embedded, onChange }: PropertyEditorProps): React.JSX.Element { const { t } = useTranslation('common') + const isEmbedded = embedded ?? false return ( - - {t('catalog.fields.properties')} + + {!isEmbedded ? ( + {t('catalog.fields.properties')} + ) : null} - {optionsQuery.isLoading ? ( - {t('catalog.messages.loadingElements')} - ) : null} - {!optionsQuery.isLoading && availableElements.length === 0 ? ( - - {t('catalog.messages.createElementFirst')} - - ) : null} - {elements.map((row) => ( - - { - const selected = availableElements.find( - (element) => elementKey(element) === event.target.value, - ) - setElements( - elements.map((item) => - item.id === row.id - ? { - ...item, - elementKey: event.target.value, - name: selected?.name ?? '', - namespace: selected?.namespace ?? '', - version: selected?.version ?? '', - } - : item, - ), - ) + + + {optionsQuery.isLoading ? ( + {t('catalog.messages.loadingElements')} + ) : null} + {!optionsQuery.isLoading && availableElements.length === 0 ? ( + + {t('catalog.messages.createElementFirst')} + + ) : null} + {!optionsQuery.isLoading && availableElements.length > 0 && elements.length === 0 ? ( + - {availableElements.map((element) => ( - - {element.displayName ?? element.name} ({element.namespace}) - - ))} - - { - setElements( - elements.map((item) => - item.id === row.id ? { ...item, version: event.target.value } : item, - ), - ) - }} - sx={{ width: { sm: 130 } }} - /> - { - setElements( - elements.map((item) => - item.id === row.id - ? { ...item, mandatory: event.target.checked } - : item, - ), - ) + + {t('catalog.messages.noPurposeElements')} + + + ) : null} + {elements.map((row) => { + const selectedElement = + availableElements.find((element) => elementKey(element) === row.elementKey) ?? + null + + return ( + + - } - label={t('catalog.fields.mandatory')} - /> - setElements(elements.filter((item) => item.id !== row.id))} - > - - - - ))} - - {validationError || error ? ( - - {validationError || error} - - ) : null} + > + + option.elementId === value.elementId + } + getOptionDisabled={(option) => + elements.some( + (item) => item.id !== row.id && item.elementKey === elementKey(option), + ) + } + noOptionsText={t('catalog.messages.noElementsFound')} + onChange={(_, selected) => { + setElements( + elements.map((item) => + item.id === row.id + ? { + ...item, + elementKey: selected ? elementKey(selected) : '', + name: selected?.name ?? '', + namespace: selected?.namespace ?? '', + version: selected?.version ?? '', + } + : item, + ), + ) + }} + renderInput={(params) => ( + // Oxygen UI Autocomplete requires forwarding its generated input props. + // eslint-disable-next-line react/jsx-props-no-spreading + + )} + /> + { + setElements( + elements.map((item) => + item.id === row.id ? { ...item, version: event.target.value } : item, + ), + ) + }} + /> + { + setElements( + elements.map((item) => + item.id === row.id + ? { ...item, mandatory: event.target.checked } + : item, + ), + ) + }} + /> + } + label={t('catalog.fields.mandatory')} + /> + setElements(elements.filter((item) => item.id !== row.id))} + > + + + + + ) + })} + + - + diff --git a/portal/frontend/src/i18n/resources/en/common.ts b/portal/frontend/src/i18n/resources/en/common.ts index d34812ac..a0b644b3 100644 --- a/portal/frontend/src/i18n/resources/en/common.ts +++ b/portal/frontend/src/i18n/resources/en/common.ts @@ -192,15 +192,22 @@ const commonEn = { actions: { addElement: 'Add element', addProperty: 'Add property', + apply: 'Apply', cancel: 'Cancel', clear: 'Clear', + clearAll: 'Clear all', create: 'Create', delete: 'Delete', deleting: 'Deleting...', deleteVersion: 'Delete version', + filters: 'Filters', removeElement: 'Remove element', removeProperty: 'Remove property', + retry: 'Retry', saving: 'Saving...', + view: 'View', + viewElement: 'View {{name}}', + viewPurpose: 'View {{name}}', }, fields: { actions: 'Actions', @@ -221,8 +228,11 @@ const commonEn = { properties: 'Properties', propertyKey: 'Key', propertyValue: 'Value', + purpose: 'Purpose', purposeId: 'Purpose ID', purposeName: 'Purpose name', + scope: 'Scope', + purposeScope: 'Purpose scope', purposeVersion: 'Purpose version', requirement: 'Requirement', schema: 'Schema', @@ -230,38 +240,68 @@ const commonEn = { version: 'Version', }, help: { + commaSeparatedGroups: 'Separate multiple group IDs with commas', defaultNamespace: 'Uses the default namespace when empty', + elementVersionRequiresIdentity: 'Enter an element name or namespace first', organizationPurpose: 'Leave empty to create an organization-level purpose', + purposeVersionRequiresName: 'Enter a purpose name first', }, values: { all: 'All', mandatory: 'Mandatory', + matches: 'Matches', + noDescription: 'No description', optional: 'Optional', + specificGroup: 'Specific group', + itemCount: '{{count}} selected', + latest: 'Latest', + viewing: 'Viewing', }, elements: { add: 'Add element', + activeFilters: 'Active filters', back: 'Back to elements', createTitle: 'Create element', empty: 'No elements match the selected filters.', + emptyTitle: 'No elements found', + filterTitle: 'Filter elements', loadFailed: 'Unable to load elements right now.', newVersion: 'Create new version', + refreshing: 'Refreshing...', + results: '{{count}} elements', + searchPlaceholder: 'Search by element name', + subtitle: 'Manage reusable consent elements and their versioned definitions.', tableLabel: 'Consent elements', title: 'Elements', }, purposes: { add: 'Add purpose', + activeFilters: 'Active filters', + allPurposes: 'All purposes', back: 'Back to purposes', createTitle: 'Create purpose', + detailsSection: 'Purpose details', empty: 'No purposes match the selected filters.', + emptyTitle: 'No purposes found', + filterTitle: 'Filter purposes', loadFailed: 'Unable to load purposes right now.', newVersion: 'Create new version', + orgWide: 'Organization-wide', + refreshing: 'Refreshing...', + results: '{{count}} purposes', + searchLabel: 'Search purposes', + searchPlaceholder: 'Search by purpose name', + scopeFilter: 'Purpose scope', + subtitle: 'Manage reusable consent purposes and their element definitions.', tableLabel: 'Consent purposes', title: 'Purposes', }, details: { definition: 'Definition', elements: 'Elements', + identity: 'Purpose identity', overview: 'Overview', + versionDetails: 'Version details', versions: 'Version history', }, messages: { @@ -270,12 +310,23 @@ const commonEn = { '{{namespace}}:{{name}} remains a {{type}} element. Identity fields cannot change between versions.', immutablePurpose: '{{name}} remains owned by {{groupId}}. Identity fields cannot change between versions.', + immutableOrganizationPurpose: + '{{name}} is organization-wide. Its identity and scope cannot change between versions.', + immutableGroupPurpose: + '{{name}} is scoped to group {{groupId}}. Its identity and scope cannot change between versions.', loadingElements: 'Loading available elements...', + noElementsFound: 'No matching elements', + noElements: 'No elements are configured for this version.', noProperties: 'No custom properties.', + noPurposeElements: 'No elements added yet. Add at least one element to create the purpose.', + noVersions: 'No versions are available.', + versionsLoadFailed: 'Unable to load version history right now.', }, validation: { duplicateElements: 'Each element can only be included once.', + duplicatePropertyKeys: 'Property keys must be unique.', elementRequired: 'Select at least one element.', + groupIdRequired: 'Group ID is required for a group-specific purpose.', nameRequired: 'Name is required.', schemaRequired: 'Schema is required for JSON and XML elements.', }, diff --git a/portal/frontend/vitest.setup.ts b/portal/frontend/vitest.setup.ts index a9d1a118..5be4162b 100644 --- a/portal/frontend/vitest.setup.ts +++ b/portal/frontend/vitest.setup.ts @@ -21,4 +21,5 @@ import { beforeEach, vi } from 'vitest' beforeEach(() => { vi.stubEnv('VITE_API_BASE_URL', 'http://localhost:8080') + vi.stubEnv('VITE_ORG_ID', 'test-org') }) From baa6b2a7e55d0e0055b236f8bd728201085fa5a6 Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Wed, 22 Jul 2026 02:26:45 +0530 Subject: [PATCH 03/19] Improve element pages --- .../features/catalog/ElementDetailsPage.tsx | 311 ++++++++++++++---- .../src/features/catalog/ElementListPage.tsx | 3 +- .../features/catalog/PurposeDetailsPage.tsx | 4 +- .../catalog/components/ElementFormDialog.tsx | 55 +++- .../catalog/components/ElementTypeChip.tsx | 26 ++ .../frontend/src/i18n/resources/en/common.ts | 3 + 6 files changed, 333 insertions(+), 69 deletions(-) create mode 100644 portal/frontend/src/features/catalog/components/ElementTypeChip.tsx diff --git a/portal/frontend/src/features/catalog/ElementDetailsPage.tsx b/portal/frontend/src/features/catalog/ElementDetailsPage.tsx index f90e7343..cc71a7db 100644 --- a/portal/frontend/src/features/catalog/ElementDetailsPage.tsx +++ b/portal/frontend/src/features/catalog/ElementDetailsPage.tsx @@ -4,6 +4,7 @@ */ import { + Alert, Box, Button, Card, @@ -12,6 +13,7 @@ import { Chip, Divider, IconButton, + MenuItem, Skeleton, Stack, Table, @@ -20,19 +22,20 @@ import { TableContainer, TableHead, TableRow, + TextField, Tooltip, Typography, } from '@wso2/oxygen-ui' -import { Plus, Trash2 } from '@wso2/oxygen-ui-icons-react' +import { Eye, Plus, Trash2 } from '@wso2/oxygen-ui-icons-react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, useParams } from 'react-router-dom' import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs' -import type { ElementVersion, ElementVersionItem } from '../../types/catalog' +import type { ElementVersionItem } from '../../types/catalog' import { formatEpochTimestamp } from '../../utils/dateTime' -import { OverviewCard, PropertiesCard } from './components/CatalogDetailCards' import DeleteVersionDialog from './components/DeleteVersionDialog' import ElementFormDialog from './components/ElementFormDialog' +import ElementTypeChip from './components/ElementTypeChip' import { useCreateElementVersionMutation, useDeleteElementVersionMutation, @@ -40,6 +43,34 @@ import { useElementVersionsQuery, } from './hooks/useCatalogQueries' +interface DetailField { + label: string + value: React.ReactNode +} + +function DetailGrid({ fields }: { fields: DetailField[] }): React.JSX.Element { + return ( + + {fields.map((field) => ( + + + {field.label} + + + {field.value || '-'} + + + ))} + + ) +} + function ElementDetailsPage(): React.JSX.Element { const { t } = useTranslation('common') const { id } = useParams<{ id: string }>() @@ -53,16 +84,29 @@ function ElementDetailsPage(): React.JSX.Element { const [deleteVersion, setDeleteVersion] = useState() const detail = detailQuery.data - const selectedVersionItem = useMemo( - () => versionsQuery.data?.versions.find((version) => version.version === selectedVersion), - [selectedVersion, versionsQuery.data?.versions], + const latestVersion = useMemo( + () => + detail + ? { + version: detail.version, + displayName: detail.displayName, + description: detail.description, + schema: detail.schema, + properties: detail.properties, + createdTime: detail.createdTime, + } + : undefined, + [detail], ) - const displayed: ElementVersion | undefined = detail - ? { - ...detail, - ...(selectedVersionItem ?? {}), - } - : undefined + const versions = useMemo(() => versionsQuery.data?.versions ?? [], [versionsQuery.data?.versions]) + const versionOptions = useMemo(() => { + if (!latestVersion || versions.some((version) => version.version === latestVersion.version)) { + return versions + } + return [latestVersion, ...versions] + }, [latestVersion, versions]) + const displayedVersion = + versionOptions.find((version) => version.version === selectedVersion) ?? latestVersion if (detailQuery.isLoading) { return ( @@ -70,14 +114,15 @@ function ElementDetailsPage(): React.JSX.Element { - + + ) } - if (!id || detailQuery.isError || !displayed) { + if (!id || detailQuery.isError || !detail || !displayedVersion) { return ( @@ -90,61 +135,195 @@ function ElementDetailsPage(): React.JSX.Element { ) } - const versions = versionsQuery.data?.versions ?? [] + const propertyEntries = Object.entries(displayedVersion.properties ?? {}) return ( - + - - {displayed.displayName ?? displayed.name} + + {displayedVersion.displayName ?? detail.name} - + + + {displayedVersion.version === detail.version ? ( + + ) : null} + { + const version = event.target.value + setSelectedVersion(version === detail.version ? undefined : version) + }} + sx={{ minWidth: 150 }} + > + {versionOptions.map((version) => ( + + {version.version} + {version.version === detail.version ? ` · ${t('catalog.values.latest')}` : ''} + + ))} + + + - {displayed.name}, - }, - { label: t('catalog.fields.namespace'), value: displayed.namespace }, - { - label: t('catalog.fields.type'), - value: , - }, - { label: t('catalog.fields.version'), value: displayed.version }, - { - label: t('catalog.fields.created'), - value: formatEpochTimestamp(displayed.createdTime), - }, - { label: t('catalog.fields.description'), value: displayed.description ?? '-' }, - ]} - /> - + + {t('catalog.details.elementIdentity')}} + /> + + + {detail.name}, + }, + { label: t('catalog.fields.namespace'), value: detail.namespace || '-' }, + { + label: t('catalog.fields.type'), + value: , + }, + ]} + /> + + + + + {t('catalog.details.versionDetails')}} + /> + + + , + }, + { + label: t('catalog.fields.displayName'), + value: displayedVersion.displayName ?? '-', + }, + { + label: t('catalog.fields.created'), + value: formatEpochTimestamp(displayedVersion.createdTime), + }, + { + label: t('catalog.fields.description'), + value: displayedVersion.description ?? '-', + }, + ]} + /> + + + + {detail.type !== 'basic' ? ( + + {t('catalog.fields.schema')}} + /> + + + {displayedVersion.schema ? ( + + {displayedVersion.schema} + + ) : ( + + {t('catalog.messages.noSchema')} + + )} + + + ) : null} - + + {t('catalog.fields.properties')}} + /> + + + {propertyEntries.length > 0 ? ( + + {propertyEntries.map(([key, value]) => ( + + + {key} + + + {value} + + + ))} + + ) : ( + + {t('catalog.messages.noProperties')} + + )} + + + + {t('catalog.details.versions')}} /> - + {versionsQuery.isLoading ? ( + + + + + + + + ) : null} + {versionsQuery.isError ? ( + + + {versionsQuery.error?.message || t('catalog.messages.versionsLoadFailed')} + + + ) : null} + {!versionsQuery.isLoading && !versionsQuery.isError ? (
@@ -157,16 +336,36 @@ function ElementDetailsPage(): React.JSX.Element { - {versions.map((version) => ( + {versionOptions.length === 0 ? ( + + + {t('catalog.messages.noVersions')} + + + ) : null} + {versionOptions.map((version) => ( setSelectedVersion(version.version)} + onClick={() => + setSelectedVersion( + version.version === detail.version ? undefined : version.version, + ) + } > - + + + {displayedVersion.version === version.version ? ( + } + label={t('catalog.values.viewing')} + /> + ) : null} + {version.displayName ?? '-'} {version.description ?? '-'} @@ -190,7 +389,7 @@ function ElementDetailsPage(): React.JSX.Element {
- + ) : null}
diff --git a/portal/frontend/src/features/catalog/ElementListPage.tsx b/portal/frontend/src/features/catalog/ElementListPage.tsx index d066fe2b..50ac5918 100644 --- a/portal/frontend/src/features/catalog/ElementListPage.tsx +++ b/portal/frontend/src/features/catalog/ElementListPage.tsx @@ -33,6 +33,7 @@ import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcr import type { ElementFilters, ElementType } from '../../types/catalog' import { formatEpochTimestamp } from '../../utils/dateTime' import ElementFormDialog from './components/ElementFormDialog' +import ElementTypeChip from './components/ElementTypeChip' import { useCreateElementMutation, useElementsQuery } from './hooks/useCatalogQueries' const ROW_OPTIONS = [10, 25, 50] @@ -457,7 +458,7 @@ function ElementListPage(): React.JSX.Element { - + diff --git a/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx b/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx index e79528fb..7c28c8ba 100644 --- a/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx +++ b/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx @@ -61,7 +61,7 @@ function DetailGrid({ fields }: { fields: DetailField[] }): React.JSX.Element { > {fields.map((field) => ( - + {field.label} @@ -263,7 +263,7 @@ function PurposeDetailsPage(): React.JSX.Element { > {propertyEntries.map(([key, value]) => ( - + {key} diff --git a/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx b/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx index 15d79f0e..f7cf4636 100644 --- a/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx +++ b/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx @@ -4,6 +4,7 @@ */ import { + Box, Button, Dialog, DialogActions, @@ -15,8 +16,10 @@ import { Select, Stack, TextField, + Tooltip, Typography, } from '@wso2/oxygen-ui' +import { CircleHelp } from '@wso2/oxygen-ui-icons-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import type { @@ -97,19 +100,32 @@ function ElementFormDialog({ return ( - {versionMode ? t('catalog.elements.newVersion') : t('catalog.elements.createTitle')} - - - + + + {versionMode ? t('catalog.elements.newVersion') : t('catalog.elements.createTitle')} + {versionMode ? ( - - {t('catalog.messages.immutableElement', { + - ) : ( + > + + + + + ) : null} + + + + + {!versionMode ? ( setNamespace(event.target.value)} /> - {t('catalog.fields.type')} + + + {t('catalog.fields.type')} + + + + + + + + + setName(event.target.value)} + /> + setType(event.target.value as ElementType)} + aria-label={t('catalog.fields.type')} + sx={{ + flexShrink: 0, + '& .MuiToggleButton-root': { textTransform: 'none' }, + }} + onChange={(_, nextType: ElementType | null) => { + if (nextType) setType(nextType) + }} + > + {(['basic', 'json', 'xml'] as const).map((elementType) => { + const { Icon, label } = ELEMENT_TYPE_PRESENTATION[elementType] + + return ( + + + + {label} + + + ) + })} + + + + { + if (!nextMode) return + setNamespaceMode(nextMode) + if (nextMode === 'default') setNamespace('') + }} > - basic - json - xml - - + + {t('catalog.values.defaultNamespace')} + + {t('catalog.values.customNamespace')} + + {namespaceMode === 'custom' ? ( + setNamespace(event.target.value)} + sx={{ width: { xs: '100%', sm: 220 } }} + /> + ) : null} + ) : null} - - - diff --git a/portal/frontend/src/features/catalog/components/ElementTypeChip.tsx b/portal/frontend/src/features/catalog/components/ElementTypeChip.tsx index 7adb850f..13575245 100644 --- a/portal/frontend/src/features/catalog/components/ElementTypeChip.tsx +++ b/portal/frontend/src/features/catalog/components/ElementTypeChip.tsx @@ -4,23 +4,17 @@ */ import { Chip } from '@wso2/oxygen-ui' -import { Braces, CodeXml, Type as TypeIcon } from '@wso2/oxygen-ui-icons-react' import type { ElementType } from '../../../types/catalog' +import { ELEMENT_TYPE_PRESENTATION } from '../utils/elementTypePresentation' interface ElementTypeChipProps { type: ElementType } function ElementTypeChip({ type }: ElementTypeChipProps): React.JSX.Element { - if (type === 'json') { - return } label="JSON" /> - } + const { Icon, label } = ELEMENT_TYPE_PRESENTATION[type] - if (type === 'xml') { - return } label="XML" /> - } - - return } label="Basic" /> + return } label={label} /> } export default ElementTypeChip diff --git a/portal/frontend/src/features/catalog/components/ElementVersionSelect.tsx b/portal/frontend/src/features/catalog/components/ElementVersionSelect.tsx new file mode 100644 index 00000000..42eef8ef --- /dev/null +++ b/portal/frontend/src/features/catalog/components/ElementVersionSelect.tsx @@ -0,0 +1,165 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import { Box, IconButton, MenuItem, Skeleton, TextField, Tooltip } from '@wso2/oxygen-ui' +import { RefreshCw } from '@wso2/oxygen-ui-icons-react' +import { useEffect, useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useElementVersionsQuery } from '../hooks/useCatalogQueries' + +interface ElementVersionSelectProps { + elementId: string | undefined + latestVersion: string | undefined + value: string + onChange: (version: string) => void +} + +function ElementVersionSelect({ + elementId, + latestVersion, + value, + onChange, +}: ElementVersionSelectProps): React.JSX.Element { + const { t } = useTranslation('common') + const versionsQuery = useElementVersionsQuery(elementId) + const versions = useMemo(() => { + const versionValues = versionsQuery.data?.versions.map((version) => version.version) ?? [] + + if (latestVersion && !versionValues.includes(latestVersion)) { + return [latestVersion, ...versionValues] + } + + return versionValues + }, [latestVersion, versionsQuery.data?.versions]) + + useEffect(() => { + if (versionsQuery.isSuccess && versions.length > 0 && !versions.includes(value)) { + onChange(latestVersion && versions.includes(latestVersion) ? latestVersion : versions[0]) + } + }, [latestVersion, onChange, value, versions, versionsQuery.isSuccess]) + + if (!elementId) { + return ( + + + + — + + + + ) + } + + if (versionsQuery.isPending || versionsQuery.isFetching) { + return ( + + ) + } + + if (versionsQuery.isError) { + return ( + + + — + + + versionsQuery.refetch()} + sx={{ + position: 'absolute', + top: '50%', + right: 26, + zIndex: 1, + transform: 'translateY(-50%)', + bgcolor: 'background.paper', + '&:hover': { bgcolor: 'action.hover' }, + }} + > + + + + + ) + } + + if (versions.length === 0) { + return ( + + + + — + + + + ) + } + + return ( + onChange(event.target.value)} + > + {versions.map((version) => ( + + {version} + + ))} + + ) +} + +export default ElementVersionSelect diff --git a/portal/frontend/src/features/catalog/components/PropertyEditor.tsx b/portal/frontend/src/features/catalog/components/PropertyEditor.tsx index 1b508f56..9a2ed95a 100644 --- a/portal/frontend/src/features/catalog/components/PropertyEditor.tsx +++ b/portal/frontend/src/features/catalog/components/PropertyEditor.tsx @@ -26,7 +26,9 @@ function PropertyEditor({ entries, embedded, onChange }: PropertyEditorProps): R justifyContent={isEmbedded ? 'flex-end' : 'space-between'} > {!isEmbedded ? ( - {t('catalog.fields.properties')} + + {t('catalog.fields.properties')} + ) : null} - diff --git a/portal/frontend/src/features/catalog/utils/elementTypePresentation.ts b/portal/frontend/src/features/catalog/utils/elementTypePresentation.ts new file mode 100644 index 00000000..72ca7508 --- /dev/null +++ b/portal/frontend/src/features/catalog/utils/elementTypePresentation.ts @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). + * Licensed under the Apache License, Version 2.0. + */ + +import { Braces, CodeXml, Type as TypeIcon } from '@wso2/oxygen-ui-icons-react' +import type { ElementType } from '../../../types/catalog' + +export const ELEMENT_TYPE_PRESENTATION = { + basic: { + label: 'Basic', + Icon: TypeIcon, + }, + json: { + label: 'JSON', + Icon: Braces, + }, + xml: { + label: 'XML', + Icon: CodeXml, + }, +} satisfies Record diff --git a/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx b/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx index ac9efe23..422d547b 100644 --- a/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx +++ b/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx @@ -84,7 +84,7 @@ function ConsentDetailsLoading(): React.JSX.Element { } - sx={{ pb: 2 }} + sx={{ pb: 1 }} /> @@ -107,7 +107,7 @@ function ConsentDetailsLoading(): React.JSX.Element { {['purpose', 'table', 'lifecycle'].map((section) => ( - } sx={{ pb: 0 }} /> + } sx={{ pb: 1 }} /> {Array.from({ length: section === 'purpose' ? 2 : 4 }).map((_, index) => ( @@ -197,23 +197,19 @@ function ConsentDetailsPage(): React.JSX.Element { component="main" sx={{ p: { xs: 2, md: 4 }, display: 'flex', flexDirection: 'column', gap: 3 }} > - - + + - + {t('consentRegistry.details.title', 'Consent Details')} - + {canApprove ? ( - + + + setDraft({ ...draft, startDate: formatDate(value) })} + slotProps={{ + textField: { + size: 'small', + fullWidth: true, + inputProps: { + 'aria-label': t('consentRegistry.filters.startDateAriaLabel'), + }, + }, + }} + /> + setDraft({ ...draft, endDate: formatDate(value) })} + slotProps={{ + textField: { + size: 'small', + fullWidth: true, + inputProps: { + 'aria-label': t('consentRegistry.filters.endDateAriaLabel'), + }, + }, + }} + /> + + + + + + + + + + + + ) } diff --git a/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts b/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts index 650cef1a..38b3499b 100644 --- a/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts +++ b/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts @@ -76,7 +76,12 @@ function toListParams( return { consentStatuses: filters.status === 'All' ? undefined : statusFilterMap[filters.status], - consentTypes: filters.consentType.trim() || undefined, + purposeName: filters.purposeName.trim() || undefined, + groupIds: filters.groupIds.trim() || undefined, + elementName: filters.elementName.trim() || undefined, + elementVersion: filters.elementName.trim() + ? filters.elementVersion.trim() || undefined + : undefined, fromTime: toStartOfDayEpochMilliseconds(filters.startDate), toTime: toEndOfDayEpochMilliseconds(filters.endDate), limit: rowsPerPage, diff --git a/portal/frontend/src/i18n/resources/en/common.ts b/portal/frontend/src/i18n/resources/en/common.ts index 9b8a2d7d..3b9cb406 100644 --- a/portal/frontend/src/i18n/resources/en/common.ts +++ b/portal/frontend/src/i18n/resources/en/common.ts @@ -172,13 +172,19 @@ const commonEn = { }, filters: { sectionAriaLabel: 'Consent filters', + purposeSearchPlaceholder: 'Search by purpose name', status: 'Status', + groupIds: 'Group ID', + elementName: 'Element name', + elementVersion: 'Element version', + advanced: 'Advanced filters', + apply: 'Apply', + cancel: 'Cancel', startDate: 'Start date', startDateAriaLabel: 'Start date filter', endDate: 'End date', endDateAriaLabel: 'End date filter', - consentType: 'Consent type', - clear: 'Clear', + clear: 'Clear all', clearAriaLabel: 'Clear all filters', }, messages: { diff --git a/portal/frontend/src/types/consent.ts b/portal/frontend/src/types/consent.ts index 92f394fc..540d820f 100644 --- a/portal/frontend/src/types/consent.ts +++ b/portal/frontend/src/types/consent.ts @@ -40,14 +40,20 @@ export interface ConsentRecord { export interface ConsentRegistryFilters { status: 'All' | ConsentStatus + purposeName: string + groupIds: string + elementName: string + elementVersion: string startDate: string endDate: string - consentType: string } export interface ConsentListQueryParams { consentStatuses?: string - consentTypes?: string + purposeName?: string + groupIds?: string + elementName?: string + elementVersion?: string fromTime?: number toTime?: number limit: number From c1f48e8f5223e0de4a4af923506d6d88877e4ea5 Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Wed, 29 Jul 2026 19:36:04 +0530 Subject: [PATCH 09/19] Improve consent registry page - Remove type column - Add consent id to consent table - Make IDs copyable --- .../__tests__/ConsentRegistryPage.test.tsx | 7 +- .../frontend/src/components/CopyableText.tsx | 113 ++++++++++++++++++ .../features/catalog/ElementDetailsPage.tsx | 18 ++- .../features/catalog/PurposeDetailsPage.tsx | 18 ++- .../components/ConsentRegistryTable.tsx | 46 ++++--- .../details/ConsentMetadataCard.tsx | 18 ++- .../frontend/src/i18n/resources/en/common.ts | 10 ++ 7 files changed, 208 insertions(+), 22 deletions(-) create mode 100644 portal/frontend/src/components/CopyableText.tsx diff --git a/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx b/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx index dd9d2817..6b86cb62 100644 --- a/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx +++ b/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx @@ -131,7 +131,12 @@ describe('ConsentRegistryPage', () => { 'href', '/consents/CON%2F8291%3Fdraft', ) - expect(screen.queryByText('Consent ID')).not.toBeInTheDocument() + expect(screen.getByText('Consent ID')).toBeInTheDocument() + expect(screen.getByLabelText('Consent ID: CON/8291?draft')).toHaveTextContent('CON/8291…') + expect( + screen.getByRole('button', { name: 'Copy consent ID CON/8291?draft' }), + ).toBeInTheDocument() + expect(screen.queryByText('Type')).not.toBeInTheDocument() }) it('shows an error message when consent fetch fails', async () => { diff --git a/portal/frontend/src/components/CopyableText.tsx b/portal/frontend/src/components/CopyableText.tsx new file mode 100644 index 00000000..c79c979e --- /dev/null +++ b/portal/frontend/src/components/CopyableText.tsx @@ -0,0 +1,113 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Box, IconButton, Tooltip, Typography } from '@wso2/oxygen-ui' +import { Check, Copy } from '@wso2/oxygen-ui-icons-react' +import { type MouseEvent, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' + +interface CopyableTextProps { + value: string + displayValue?: string + truncateAt?: number + textAriaLabel?: string + copyAriaLabel?: string + copyTooltip?: string + monospace?: boolean +} + +function CopyableText({ + value, + displayValue, + truncateAt, + textAriaLabel, + copyAriaLabel, + copyTooltip, + monospace = false, +}: CopyableTextProps): React.JSX.Element { + const { t } = useTranslation('common') + const [copied, setCopied] = useState(false) + const resetTimer = useRef | undefined>(undefined) + const visibleValue = + displayValue ?? + (truncateAt && value.length > truncateAt ? `${value.slice(0, truncateAt)}…` : value) + + useEffect( + () => () => { + if (resetTimer.current) clearTimeout(resetTimer.current) + }, + [], + ) + + const handleCopy = (event: MouseEvent): void => { + event.stopPropagation() + const writeRequest = navigator.clipboard?.writeText(value) + + if (!writeRequest) return + + writeRequest + .then(() => { + setCopied(true) + if (resetTimer.current) clearTimeout(resetTimer.current) + resetTimer.current = setTimeout(() => setCopied(false), 2000) + }) + .catch(() => undefined) + } + + return ( + + + + {visibleValue} + + + + + {copied ? : } + + + + ) +} + +CopyableText.defaultProps = { + displayValue: undefined, + truncateAt: undefined, + textAriaLabel: undefined, + copyAriaLabel: undefined, + copyTooltip: undefined, + monospace: false, +} + +export default CopyableText diff --git a/portal/frontend/src/features/catalog/ElementDetailsPage.tsx b/portal/frontend/src/features/catalog/ElementDetailsPage.tsx index 8bd66e8a..fbcac78b 100644 --- a/portal/frontend/src/features/catalog/ElementDetailsPage.tsx +++ b/portal/frontend/src/features/catalog/ElementDetailsPage.tsx @@ -43,6 +43,7 @@ import { import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, useParams } from 'react-router-dom' +import CopyableText from '../../components/CopyableText' import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs' import type { ElementVersionItem } from '../../types/catalog' import { formatEpochTimestamp } from '../../utils/dateTime' @@ -225,8 +226,23 @@ function ElementDetailsPage(): React.JSX.Element { - {t('catalog.fields.elementId')}: {detail.elementId} + {t('catalog.fields.elementId')}: + } sx={{ pb: 1 }} diff --git a/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx b/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx index 83799af7..6926f393 100644 --- a/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx +++ b/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx @@ -43,6 +43,7 @@ import { import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Link as RouterLink, useNavigate, useParams } from 'react-router-dom' +import CopyableText from '../../components/CopyableText' import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs' import type { PurposeVersionItem } from '../../types/catalog' import { formatEpochTimestamp } from '../../utils/dateTime' @@ -227,8 +228,23 @@ function PurposeDetailsPage(): React.JSX.Element { - {t('catalog.fields.purposeId')}: {detail.purposeId} + {t('catalog.fields.purposeId')}: + } sx={{ pb: 1 }} diff --git a/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx b/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx index 75edbc49..6521825d 100644 --- a/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx +++ b/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx @@ -31,6 +31,7 @@ import { CircleCheckBig, Eye, ShieldX } from '@wso2/oxygen-ui-icons-react' import { Fragment, type MouseEvent, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Link as RouterLink, useNavigate } from 'react-router-dom' +import CopyableText from '../../../components/CopyableText' import type { ConsentRecord } from '../../../types/consent' import { formatEpochTimestamp, formatIsoDateTime } from '../../../utils/dateTime' import { CONSENT_REGISTRY_ROWS_PER_PAGE_OPTIONS } from '../constants' @@ -49,18 +50,18 @@ interface ConsentRegistryTableProps { isMutating: boolean } -type SortField = 'type' | 'status' | 'updatedAt' | 'expirationTime' +type SortField = 'id' | 'status' | 'updatedAt' | 'expirationTime' type SortDirection = 'asc' | 'desc' const PURPOSE_PREVIEW_COUNT = 2 const CONSENT_REGISTRY_COLUMN_WIDTHS = { + consentId: '13%', purposes: '34%', - type: '11%', - status: '13%', + status: '12%', updated: '16%', expiration: '16%', - actions: '10%', + actions: '8%', } as const function sortConsentRows( @@ -98,7 +99,7 @@ function sortConsentRows( return sortDirection === 'asc' ? sortedRows : sortedRows.reverse() } -function ConsentRegistryTable({ +export default function ConsentRegistryTable({ rows, totalCount, isLoading, @@ -221,14 +222,14 @@ function ConsentRegistryTable({ > + + + {t('consentRegistry.table.headers.consentId')} + + {t('consentRegistry.table.headers.purposes')} - - - {t('consentRegistry.table.headers.type')} - - {t('consentRegistry.table.headers.status')} @@ -257,14 +258,14 @@ function ConsentRegistryTable({ {isLoading ? Array.from({ length: rowsPerPage }, (_, rowIndex) => ( + + + - - - @@ -307,6 +308,20 @@ function ConsentRegistryTable({ onClick={handleRowClick} sx={{ cursor: 'pointer' }} > + + + @@ -344,9 +359,6 @@ function ConsentRegistryTable({ ) : null}
- - {row.type} - ) } - -export default ConsentRegistryTable diff --git a/portal/frontend/src/features/consent-registry/components/details/ConsentMetadataCard.tsx b/portal/frontend/src/features/consent-registry/components/details/ConsentMetadataCard.tsx index 1a4b10a5..df15386c 100644 --- a/portal/frontend/src/features/consent-registry/components/details/ConsentMetadataCard.tsx +++ b/portal/frontend/src/features/consent-registry/components/details/ConsentMetadataCard.tsx @@ -39,6 +39,7 @@ import { Users, } from '@wso2/oxygen-ui-icons-react' import { useTranslation } from 'react-i18next' +import CopyableText from '../../../../components/CopyableText' import type { ConsentDetailAPI } from '../../../../types/consent' import { formatEpochTimestamp } from '../../../../utils/dateTime' import { getConsentStatusChipColor, getConsentStatusLabelKey } from '../../utils/statusChip' @@ -150,8 +151,23 @@ function ConsentMetadataCard({ consentId, detail }: ConsentMetadataCardProps): R - {t('consentRegistry.details.consentId', 'Consent ID')}: {consentId} + {t('consentRegistry.details.consentId', 'Consent ID')}: + } action={ diff --git a/portal/frontend/src/i18n/resources/en/common.ts b/portal/frontend/src/i18n/resources/en/common.ts index 3b9cb406..b5b70d62 100644 --- a/portal/frontend/src/i18n/resources/en/common.ts +++ b/portal/frontend/src/i18n/resources/en/common.ts @@ -20,6 +20,13 @@ const commonEn = { app: { title: 'Consent Portal', }, + copyableText: { + copy: 'Copy', + copied: 'Copied', + copyLabel: 'Copy {{label}}', + copyValue: 'Copy {{label}} {{value}}', + valueAriaLabel: '{{label}}: {{value}}', + }, sidebar: { ariaLabel: 'Primary navigation', dashboard: 'Dashboard', @@ -131,6 +138,8 @@ const commonEn = { view: 'View', revoke: 'Revoke', approve: 'Approve', + copyConsentId: 'Copy consent ID', + copyConsentIdAriaLabel: 'Copy consent ID {{id}}', }, modals: { consentId: 'Consent ID', @@ -194,6 +203,7 @@ const commonEn = { }, table: { tableAriaLabel: 'Consent registry table', + consentIdAriaLabel: 'Consent ID: {{id}}', groupLabel: 'Group ID: {{groupId}}', notApplicable: 'Not applicable', purposes: { From ba1d11568ac5757469d52b00549e647ed90d6451 Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Wed, 29 Jul 2026 22:43:37 +0530 Subject: [PATCH 10/19] Add explicit consent rejection - Add /me/consents/{consentId}/reject to portal backend - Update frotnend to support explicit rejection - Improve UI for rejection and revoke buttons --- portal/backend/internal/me/handler.go | 76 +++++++++ portal/backend/internal/me/init.go | 1 + portal/backend/internal/me/service.go | 50 ++++++ .../internal/system/auth/validation_test.go | 1 + portal/backend/openapi/portal-backend.yaml | 37 +++++ .../integration/proxy_integration_test.go | 118 +++++++++++++ .../src/__tests__/ConsentApprovalApi.test.ts | 26 ++- .../src/__tests__/ConsentDetailsPage.test.tsx | 106 ++++++++++++ .../__tests__/ConsentRegistryModals.test.tsx | 37 +++++ .../src/__tests__/ConsentStatusChip.test.ts | 12 ++ .../src/__tests__/PurposeFormDialog.test.tsx | 22 ++- .../consent-registry/ConsentDetailsPage.tsx | 69 ++++++-- .../consent-registry/api/consentsApi.ts | 10 ++ .../components/ConsentRegistryTable.tsx | 4 +- .../components/ConsentRejectionDialog.tsx | 157 ++++++++++++++++++ .../components/ConsentRevocationDialog.tsx | 13 +- .../hooks/useConsentQueries.ts | 13 ++ .../consent-registry/utils/statusChip.ts | 4 + .../frontend/src/i18n/resources/en/common.ts | 9 + 19 files changed, 741 insertions(+), 24 deletions(-) create mode 100644 portal/frontend/src/__tests__/ConsentDetailsPage.test.tsx create mode 100644 portal/frontend/src/features/consent-registry/components/ConsentRejectionDialog.tsx diff --git a/portal/backend/internal/me/handler.go b/portal/backend/internal/me/handler.go index 9d74f274..9c063b57 100644 --- a/portal/backend/internal/me/handler.go +++ b/portal/backend/internal/me/handler.go @@ -33,6 +33,8 @@ import ( systemcontext "github.com/wso2/openfgc/portal/backend/internal/system/context" ) +var errInvalidConsentState = errors.New("invalid consent state") + // Handler serves /me route groups. type Handler struct { svc *Service @@ -180,6 +182,68 @@ func (h *Handler) ConsentApprove(w http.ResponseWriter, r *http.Request) { } } +// ConsentReject handles POST /me/consents/{consentId}/reject. +func (h *Handler) ConsentReject(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + writeJSONError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed") + return + } + userID, ok := h.resolveUserID(w, r) + if !ok { + return + } + consentID := r.PathValue("consentId") + if consentID == "" { + writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent id not found") + return + } + if !isValidConsentID(consentID) { + writeJSONError(w, http.StatusBadRequest, "INVALID_CONSENT_ID", "invalid consent id") + return + } + body, err := h.readBoundedBody(r) + if err != nil { + writeBodyReadError(w, err) + return + } + if err := validateEmptyObjectPayload(body); err != nil { + writeJSONError(w, http.StatusBadRequest, "INVALID_PAYLOAD", "invalid request payload") + return + } + baseResp, err := h.svc.ForwardRaw(r, http.MethodGet, "/api/v1/consents/"+url.PathEscape(consentID), func(q url.Values) { + q.Set("details", "true") + q.Del("includeStatusHistory") + }, nil) + if err != nil { + writeProxyError(w, err) + return + } + if baseResp.StatusCode != http.StatusOK { + h.svc.WriteUpstreamResponse(w, baseResp) + return + } + if _, owned, ownershipErr := OwnedConsentGroupID(baseResp.Body, userID); ownershipErr != nil { + writeProxyError(w, ownershipErr) + return + } else if !owned { + writeJSONError(w, http.StatusNotFound, "NOT_FOUND", "consent not found") + return + } + + payload, trustedGroupID, err := h.svc.BuildRejectionUpdatePayload(baseResp.Body, userID) + if err != nil { + if errors.Is(err, errInvalidConsentState) { + writeJSONError(w, http.StatusConflict, "INVALID_CONSENT_STATE", "only created consents can be rejected") + return + } + writeProxyError(w, err) + return + } + if err := h.svc.ForwardWithGroupID(w, r, http.MethodPut, "/api/v1/consents/"+url.PathEscape(consentID), nil, payload, trustedGroupID); err != nil { + writeProxyError(w, err) + } +} + // ConsentRevoke handles POST /me/consents/{consentId}/revoke. func (h *Handler) ConsentRevoke(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { @@ -262,6 +326,18 @@ func writeBodyReadError(w http.ResponseWriter, err error) { writeJSONError(w, http.StatusBadRequest, "INVALID_REQUEST_BODY", "invalid request body") } +func validateEmptyObjectPayload(body []byte) error { + if strings.TrimSpace(string(body)) == "" { + return nil + } + + var payload map[string]json.RawMessage + if err := json.Unmarshal(body, &payload); err != nil || payload == nil || len(payload) > 0 { + return errors.New("expected an empty JSON object") + } + return nil +} + func (h *Handler) resolveUserID(w http.ResponseWriter, r *http.Request) (string, bool) { principal, ok := systemcontext.PrincipalFromContext(r.Context()) if !ok { diff --git a/portal/backend/internal/me/init.go b/portal/backend/internal/me/init.go index 6fe8aaec..8b53bb85 100644 --- a/portal/backend/internal/me/init.go +++ b/portal/backend/internal/me/init.go @@ -35,6 +35,7 @@ func Initialize(mux *http.ServeMux, cfg config.Config, authManager *auth.Manager mux.Handle("GET /me/consents", authManager.Require(http.HandlerFunc(handler.Consents), auth.ScopeConsentsReadSelf)) mux.Handle("GET /me/consents/{consentId}", authManager.Require(http.HandlerFunc(handler.ConsentByID), auth.ScopeConsentsReadSelf)) mux.Handle("POST /me/consents/{consentId}/approve", authManager.Require(http.HandlerFunc(handler.ConsentApprove), auth.ScopeConsentsWriteSelf)) + mux.Handle("POST /me/consents/{consentId}/reject", authManager.Require(http.HandlerFunc(handler.ConsentReject), auth.ScopeConsentsWriteSelf)) mux.Handle("POST /me/consents/{consentId}/revoke", authManager.Require(http.HandlerFunc(handler.ConsentRevoke), auth.ScopeConsentsWriteSelf)) return nil diff --git a/portal/backend/internal/me/service.go b/portal/backend/internal/me/service.go index 4f92b809..302ce577 100644 --- a/portal/backend/internal/me/service.go +++ b/portal/backend/internal/me/service.go @@ -119,6 +119,10 @@ type consentUpdatePayload struct { Authorizations []consentAuthorizationPayload `json:"authorizations"` } +type consentAuthorizationUpdatePayload struct { + Authorizations []consentAuthorizationPayload `json:"authorizations"` +} + // NewService builds a me service from app config. func NewService(cfg config.ProxyConfig) (*Service, error) { svc, err := proxy.NewService(cfg) @@ -298,6 +302,52 @@ func (s *Service) BuildApprovalUpdatePayload(baseBody []byte, selections []conse return serializedPayload, consent.GroupID, nil } +// BuildRejectionUpdatePayload builds the minimal consent update required to reject a consent. +func (s *Service) BuildRejectionUpdatePayload(baseBody []byte, userID string) ([]byte, string, error) { + var consent consentRetrievalResponse + if err := json.Unmarshal(baseBody, &consent); err != nil { + return nil, "", proxy.ErrUpstreamUnavailable + } + + if !strings.EqualFold(strings.TrimSpace(consent.Status), "CREATED") { + return nil, "", errInvalidConsentState + } + + userID = strings.TrimSpace(userID) + if userID == "" { + return nil, "", proxy.ErrUpstreamUnavailable + } + + updatedAuthorizations := make([]consentAuthorizationPayload, len(consent.Authorizations)) + currentUserFound := false + for index, authorization := range consent.Authorizations { + status := authorization.Status + if authorization.UserID != nil && + strings.EqualFold(strings.TrimSpace(*authorization.UserID), userID) { + status = "REJECTED" + currentUserFound = true + } + updatedAuthorizations[index] = consentAuthorizationPayload{ + UserID: authorization.UserID, + Type: authorization.Type, + Status: status, + Resources: normalizeAuthorizationResources(authorization.Resources), + } + } + if !currentUserFound { + return nil, "", proxy.ErrUpstreamUnavailable + } + + payload := consentAuthorizationUpdatePayload{ + Authorizations: updatedAuthorizations, + } + serializedPayload, err := json.Marshal(payload) + if err != nil { + return nil, "", proxy.ErrUpstreamUnavailable + } + return serializedPayload, consent.GroupID, nil +} + func normalizeAuthorizationResources(resources any) any { if resources == nil { return map[string]any{} diff --git a/portal/backend/internal/system/auth/validation_test.go b/portal/backend/internal/system/auth/validation_test.go index ce5ef2c2..b6bd3ef1 100644 --- a/portal/backend/internal/system/auth/validation_test.go +++ b/portal/backend/internal/system/auth/validation_test.go @@ -359,6 +359,7 @@ func TestEveryProtectedPolicyAcceptsReconstructedTokenWithRequiredScope(t *testi {"GET", "/me/consents", ScopeConsentsReadSelf, false}, {"GET", "/me/consents/c1", ScopeConsentsReadSelf, false}, {"POST", "/me/consents/c1/approve", ScopeConsentsWriteSelf, false}, + {"POST", "/me/consents/c1/reject", ScopeConsentsWriteSelf, false}, {"POST", "/me/consents/c1/revoke", ScopeConsentsWriteSelf, false}, {"GET", "/api/consents", ScopeConsentsReadAny, true}, {"POST", "/api/consents", ScopeConsentsWriteAny, true}, diff --git a/portal/backend/openapi/portal-backend.yaml b/portal/backend/openapi/portal-backend.yaml index 092b9de5..d1a5713a 100644 --- a/portal/backend/openapi/portal-backend.yaml +++ b/portal/backend/openapi/portal-backend.yaml @@ -231,6 +231,43 @@ paths: '413': { $ref: '#/components/responses/ProxyError' } '502': { $ref: '#/components/responses/ProxyError' } '503': { $ref: '#/components/responses/ProxyError' } + /me/consents/{consentId}/reject: + post: + tags: [User] + summary: Reject a pending current-user consent + security: + - PortalOAuthDocumentation: [portal:consents:write:self] + AccessTokenPart2: [] + description: >- + Fetches the current consent with `details=true`, verifies ownership and + that its status is `CREATED`, then builds a minimal + `PUT /api/v1/consents/{consentId}` update. Existing authorizations are + preserved and the trusted user's authorization status is changed to + `REJECTED`. Purpose and element approvals are not included in the + update. The consent's immutable `groupId` is sent as the trusted + upstream `group-id` header. + parameters: + - $ref: '#/components/parameters/ConsentID' + requestBody: + required: false + description: No request properties are currently supported. + content: + application/json: + schema: + type: object + additionalProperties: false + responses: + '200': + description: Consent server update response, returned unchanged. + content: + application/json: + schema: { $ref: '#/components/schemas/ConsentDetail' } + '400': { $ref: '#/components/responses/ProxyError' } + '404': { $ref: '#/components/responses/ProxyError' } + '409': { $ref: '#/components/responses/ProxyError' } + '413': { $ref: '#/components/responses/ProxyError' } + '502': { $ref: '#/components/responses/ProxyError' } + '503': { $ref: '#/components/responses/ProxyError' } /me/consents/{consentId}/revoke: post: tags: [User] diff --git a/portal/backend/tests/integration/proxy_integration_test.go b/portal/backend/tests/integration/proxy_integration_test.go index c746dacb..80985dd8 100644 --- a/portal/backend/tests/integration/proxy_integration_test.go +++ b/portal/backend/tests/integration/proxy_integration_test.go @@ -306,6 +306,122 @@ func TestRevokeUsesPostAndInjectsActionBy(t *testing.T) { } } +func TestRejectBuildsAuthorizationOnlyUpdateAndTrustedGroupHeader(t *testing.T) { + var updateBody map[string]any + var updateGroup string + var consentDetails string + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/api/v1/consents/"+consentID: + consentDetails = r.URL.Query().Get("details") + _, _ = w.Write([]byte(`{"id":"` + consentID + `","groupId":"GROUP-BOUND","type":"accounts","status":"CREATED","attributes":{"region":"APAC"},"authorizations":[{"id":"auth-1","userId":"existing@example.com","type":"delegated","status":"APPROVED","updatedTime":1702800000000,"resources":{"accountIds":["acc-1"]}},{"id":"auth-2","userId":"user@example.com","type":"authorisation","status":"CREATED","updatedTime":1702800000000,"resources":{"accountIds":["acc-2"]}}],"purposes":[{"purposeId":"purpose-profile","name":"profile_access","version":"v2","elements":[{"elementId":"element-first","name":"first_name","namespace":"profile","version":"v1","mandatory":true,"approved":false}]}]}`)) + case r.Method == http.MethodPut && r.URL.Path == "/api/v1/consents/"+consentID: + updateGroup = r.Header.Get("group-id") + _ = json.NewDecoder(r.Body).Decode(&updateBody) + w.WriteHeader(http.StatusOK) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + defer upstream.Close() + bff := newPortalServer(t, upstream.URL, nil) + defer bff.Close() + + resp, err := http.Post(bff.URL+"/me/consents/"+consentID+"/reject", "application/json", strings.NewReader(`{}`)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusOK || updateGroup != "GROUP-BOUND" || consentDetails != "true" { + t.Fatalf("unexpected rejection result: status=%d group=%q details=%q", resp.StatusCode, updateGroup, consentDetails) + } + if len(updateBody) != 1 || updateBody["purposes"] != nil || updateBody["attributes"] != nil { + t.Fatalf("expected authorization-only update, got %v", updateBody) + } + authorizations := updateBody["authorizations"].([]any) + if len(authorizations) != 2 { + t.Fatalf("expected all authorizations to be preserved, got %v", authorizations) + } + existing := authorizations[0].(map[string]any) + if existing["userId"] != "existing@example.com" || existing["type"] != "delegated" || + existing["status"] != "APPROVED" { + t.Fatalf("unexpected existing authorization: %v", existing) + } + currentUser := authorizations[1].(map[string]any) + if currentUser["userId"] != "user@example.com" || currentUser["type"] != "authorisation" || + currentUser["status"] != "REJECTED" { + t.Fatalf("unexpected current-user authorization: %v", currentUser) + } + resources := currentUser["resources"].(map[string]any) + if resources["accountIds"].([]any)[0] != "acc-2" { + t.Fatalf("expected current-user resources to be preserved, got %v", resources) + } +} + +func TestRejectRequiresCreatedConsent(t *testing.T) { + updateCalled := false + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/consents/"+consentID { + _, _ = w.Write([]byte(`{"id":"` + consentID + `","groupId":"GROUP-001","status":"ACTIVE","authorizations":[{"userId":"user@example.com","type":"authorisation","status":"APPROVED","resources":{}}]}`)) + return + } + if r.Method == http.MethodPut { + updateCalled = true + } + w.WriteHeader(http.StatusNotFound) + })) + defer upstream.Close() + bff := newPortalServer(t, upstream.URL, nil) + defer bff.Close() + + resp, err := http.Post(bff.URL+"/me/consents/"+consentID+"/reject", "application/json", strings.NewReader(`{}`)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusConflict || updateCalled { + t.Fatalf("expected local 409 without update, got status=%d updateCalled=%v", resp.StatusCode, updateCalled) + } + var body map[string]any + _ = json.NewDecoder(resp.Body).Decode(&body) + if body["code"] != "INVALID_CONSENT_STATE" { + t.Fatalf("unexpected error response: %v", body) + } +} + +func TestRejectConcealsOwnershipFailure(t *testing.T) { + updateCalled := false + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet && r.URL.Path == "/api/v1/consents/"+consentID { + _, _ = w.Write([]byte(`{"id":"` + consentID + `","groupId":"GROUP-001","status":"CREATED","authorizations":[{"userId":"another-user@example.com","type":"authorisation","status":"CREATED","resources":{}}]}`)) + return + } + if r.Method == http.MethodPut { + updateCalled = true + } + w.WriteHeader(http.StatusNotFound) + })) + defer upstream.Close() + bff := newPortalServer(t, upstream.URL, nil) + defer bff.Close() + + resp, err := http.Post(bff.URL+"/me/consents/"+consentID+"/reject", "application/json", strings.NewReader(`{}`)) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer func() { + _ = resp.Body.Close() + }() + if resp.StatusCode != http.StatusNotFound || updateCalled { + t.Fatalf("expected concealed 404 without update, got status=%d updateCalled=%v", resp.StatusCode, updateCalled) + } +} + func TestAPIDenyByDefault(t *testing.T) { upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) })) defer upstream.Close() @@ -405,6 +521,7 @@ func TestMeEndpointsReturn503WhenPlaceholderModeDisabled(t *testing.T) { {http.MethodGet, "/me/consents", ""}, {http.MethodGet, "/me/consents/" + consentID, ""}, {http.MethodPost, "/me/consents/" + consentID + "/approve", "[]"}, + {http.MethodPost, "/me/consents/" + consentID + "/reject", "{}"}, {http.MethodPost, "/me/consents/" + consentID + "/revoke", "{}"}, } for _, tc := range tests { @@ -460,6 +577,7 @@ func TestMeEndpointsRejectInvalidConsentID(t *testing.T) { }{ {name: "get by id", method: http.MethodGet, path: "/me/consents/not-a-uuid"}, {name: "approve", method: http.MethodPost, path: "/me/consents/not-a-uuid/approve", body: "[]"}, + {name: "reject", method: http.MethodPost, path: "/me/consents/not-a-uuid/reject", body: "{}"}, {name: "revoke", method: http.MethodPost, path: "/me/consents/not-a-uuid/revoke", body: "{}"}, } diff --git a/portal/frontend/src/__tests__/ConsentApprovalApi.test.ts b/portal/frontend/src/__tests__/ConsentApprovalApi.test.ts index 84ef8af1..78b14422 100644 --- a/portal/frontend/src/__tests__/ConsentApprovalApi.test.ts +++ b/portal/frontend/src/__tests__/ConsentApprovalApi.test.ts @@ -17,7 +17,11 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { approveMyConsent, revokeMyConsent } from '../features/consent-registry/api/consentsApi' +import { + approveMyConsent, + rejectMyConsent, + revokeMyConsent, +} from '../features/consent-registry/api/consentsApi' import { apiRequest } from '../utils/apiClient' const fetchMock = vi.fn() @@ -79,6 +83,26 @@ describe('approveMyConsent', () => { body: JSON.stringify({}), }) }) + + it('rejects consent with POST', async () => { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({}), + }) + + await rejectMyConsent('consent/123?draft') + + expect(fetchMock).toHaveBeenCalledTimes(1) + const [requestUrl, requestInit] = fetchMock.mock.calls[0] ?? [] + expect(String(requestUrl)).toContain('/me/consents/consent%2F123%3Fdraft/reject') + expect(requestInit).toMatchObject({ + method: 'POST', + credentials: 'include', + body: JSON.stringify({}), + }) + }) }) describe('apiRequest', () => { diff --git a/portal/frontend/src/__tests__/ConsentDetailsPage.test.tsx b/portal/frontend/src/__tests__/ConsentDetailsPage.test.tsx new file mode 100644 index 00000000..8c01493b --- /dev/null +++ b/portal/frontend/src/__tests__/ConsentDetailsPage.test.tsx @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { cleanup, render, screen } from '@testing-library/react' +import { AcrylicOrangeTheme, CssBaseline, OxygenUIThemeProvider } from '@wso2/oxygen-ui' +import { I18nextProvider } from 'react-i18next' +import { MemoryRouter, Route, Routes } from 'react-router-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import ConsentDetailsPage from '../features/consent-registry/ConsentDetailsPage' +import i18n from '../i18n/i18n' + +const fetchMock = vi.fn() + +function renderConsentDetailsPage(status: string): void { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + id: '00000000-0000-4000-8000-000000000001', + groupId: 'GROUP-001', + type: 'accounts', + status, + createdTime: 1702800000000, + updatedTime: 1702800000000, + purposes: [], + attributes: {}, + authorizations: [], + }), + }) + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + render( + + + + + + + } /> + + + + + , + ) +} + +afterEach(() => { + cleanup() + fetchMock.mockReset() + vi.unstubAllGlobals() +}) + +describe('ConsentDetailsPage lifecycle actions', () => { + it('shows approve and reject for created consents without revoke', async () => { + renderConsentDetailsPage('CREATED') + + expect(await screen.findByRole('button', { name: 'Approve' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Reject' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument() + }) + + it('shows revoke only for active consents', async () => { + renderConsentDetailsPage('ACTIVE') + + expect(await screen.findByRole('button', { name: 'Revoke' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Approve' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Reject' })).not.toBeInTheDocument() + }) + + it.each(['REJECTED', 'REVOKED', 'EXPIRED'])( + 'shows no lifecycle action for %s consents', + async (status) => { + renderConsentDetailsPage(status) + + expect(await screen.findByRole('heading', { name: 'Consent Details' })).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Approve' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Reject' })).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Revoke' })).not.toBeInTheDocument() + }, + ) +}) diff --git a/portal/frontend/src/__tests__/ConsentRegistryModals.test.tsx b/portal/frontend/src/__tests__/ConsentRegistryModals.test.tsx index dfbaf560..184aadb7 100644 --- a/portal/frontend/src/__tests__/ConsentRegistryModals.test.tsx +++ b/portal/frontend/src/__tests__/ConsentRegistryModals.test.tsx @@ -22,6 +22,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OxygenTheme, OxygenUIThemeProvider } from '@wso2/oxygen-ui' import i18n from '../i18n/i18n' import ConsentApprovalDialog from '../features/consent-registry/components/ConsentApprovalDialog' +import ConsentRejectionDialog from '../features/consent-registry/components/ConsentRejectionDialog' import ConsentRevocationDialog from '../features/consent-registry/components/ConsentRevocationDialog' function renderWithProviders(component: React.JSX.Element): void { @@ -151,4 +152,40 @@ describe('consent registry dialogs', () => { fireEvent.click(screen.getByRole('button', { name: /cancel/i })) expect(onClose).toHaveBeenCalledTimes(1) }) + + it('calls rejection handlers from confirmation dialog', () => { + const onConfirm = vi.fn() + const onClose = vi.fn() + + renderWithProviders( + , + ) + + fireEvent.click(screen.getByRole('button', { name: /reject consent/i })) + expect(onConfirm).toHaveBeenCalledTimes(1) + + fireEvent.click(screen.getByRole('button', { name: /cancel/i })) + expect(onClose).toHaveBeenCalledTimes(1) + }) + + it('disables rejection actions while processing', () => { + renderWithProviders( + , + ) + + expect(screen.getByRole('button', { name: /processing/i })).toBeDisabled() + expect(screen.getByRole('button', { name: /cancel/i })).toBeDisabled() + }) }) diff --git a/portal/frontend/src/__tests__/ConsentStatusChip.test.ts b/portal/frontend/src/__tests__/ConsentStatusChip.test.ts index e0ea2033..c2d62840 100644 --- a/portal/frontend/src/__tests__/ConsentStatusChip.test.ts +++ b/portal/frontend/src/__tests__/ConsentStatusChip.test.ts @@ -20,6 +20,9 @@ import { describe, expect, it } from 'vitest' import { getConsentStatusChipColor, getConsentStatusLabelKey, + isConsentApprovableStatus, + isConsentRejectableStatus, + isConsentRevokableStatus, } from '../features/consent-registry/utils/statusChip' describe('getConsentStatusChipColor', () => { @@ -49,4 +52,13 @@ describe('getConsentStatusChipColor', () => { expect(getConsentStatusLabelKey('SYS_EXPIRED', 'authorization')).toBe('systemExpired') expect(getConsentStatusLabelKey('SYS_REVOKED', 'authorization')).toBe('systemRevoked') }) + + it('limits lifecycle actions to their supported statuses', () => { + expect(isConsentApprovableStatus('CREATED')).toBe(true) + expect(isConsentRejectableStatus('CREATED')).toBe(true) + expect(isConsentRevokableStatus('ACTIVE')).toBe(true) + expect(isConsentApprovableStatus('ACTIVE')).toBe(false) + expect(isConsentRejectableStatus('REJECTED')).toBe(false) + expect(isConsentRevokableStatus('CREATED')).toBe(false) + }) }) diff --git a/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx b/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx index 79f4446c..518e3394 100644 --- a/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx +++ b/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx @@ -29,6 +29,20 @@ vi.mock('../features/catalog/hooks/useCatalogQueries', () => ({ }, isLoading: false, }), + useElementVersionsQuery: () => ({ + data: { + elementId: 'element-email', + name: 'email', + namespace: 'profile', + type: 'basic', + versions: [{ version: 'v1', createdTime: 1 }], + }, + isSuccess: true, + isPending: false, + isFetching: false, + isError: false, + refetch: vi.fn(), + }), })) function renderDialog(overrides: Partial> = {}): void { @@ -87,16 +101,12 @@ describe('purpose form dialog', () => { fireEvent.mouseDown(screen.getByRole('combobox', { name: /purpose scope/i })) fireEvent.click(screen.getByRole('option', { name: 'Specific group' })) - expect(screen.getByRole('textbox', { name: /group id/i })).toBeInTheDocument() + expect(screen.getByRole('textbox', { name: /group id/i })).toBeRequired() fireEvent.change(screen.getByRole('textbox', { name: /^name$/i }), { target: { value: 'contact-purpose' }, }) - fireEvent.click(screen.getByRole('button', { name: /^create$/i })) - - expect(screen.getByRole('alert')).toHaveTextContent( - 'Group ID is required for a group-specific purpose.', - ) + expect(screen.getByRole('button', { name: /^create$/i })).toBeDisabled() }) it('keeps properties visible and rejects duplicate keys in create-version mode', () => { diff --git a/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx b/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx index 422d547b..5bd010d3 100644 --- a/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx +++ b/portal/frontend/src/features/consent-registry/ConsentDetailsPage.tsx @@ -27,11 +27,13 @@ import { Stack, Typography, } from '@wso2/oxygen-ui' +import { Ban, CircleCheckBig } from '@wso2/oxygen-ui-icons-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, useParams } from 'react-router-dom' import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs' import ConsentApprovalDialog from './components/ConsentApprovalDialog' +import ConsentRejectionDialog from './components/ConsentRejectionDialog' import ConsentRevocationDialog from './components/ConsentRevocationDialog' import ConsentAuthorizationsSection from './components/details/ConsentAuthorizationsSection' import ConsentMetadataCard from './components/details/ConsentMetadataCard' @@ -40,9 +42,14 @@ import ConsentResourcesModal from './components/details/ConsentResourcesModal' import { useApproveConsentMutation, useConsentDetailQuery, + useRejectConsentMutation, useRevokeConsentMutation, } from './hooks/useConsentQueries' -import { isConsentApprovableStatus, isConsentRevokableStatus } from './utils/statusChip' +import { + isConsentApprovableStatus, + isConsentRejectableStatus, + isConsentRevokableStatus, +} from './utils/statusChip' function formatResourcesForModal(resources: unknown): string { if (!resources) { @@ -146,8 +153,10 @@ function ConsentDetailsPage(): React.JSX.Element { const navigate = useNavigate() const consentDetailQuery = useConsentDetailQuery(id) const approveMutation = useApproveConsentMutation() + const rejectMutation = useRejectConsentMutation() const revokeMutation = useRevokeConsentMutation() const [approvalDialogOpen, setApprovalDialogOpen] = useState(false) + const [rejectionDialogOpen, setRejectionDialogOpen] = useState(false) const [revocationDialogOpen, setRevocationDialogOpen] = useState(false) const [resourcesModalOpen, setResourcesModalOpen] = useState(false) const [selectedResourcesJson, setSelectedResourcesJson] = useState('') @@ -170,6 +179,7 @@ function ConsentDetailsPage(): React.JSX.Element { const detail = consentDetailQuery.data const canApprove = detail ? isConsentApprovableStatus(detail.status) : false + const canReject = detail ? isConsentRejectableStatus(detail.status) : false const canRevoke = detail ? isConsentRevokableStatus(detail.status) : false if (consentDetailQuery.isLoading) { @@ -215,6 +225,7 @@ function ConsentDetailsPage(): React.JSX.Element { variant="contained" color="warning" size="small" + startIcon={} disabled={approveMutation.isPending} onClick={() => { setApprovalDialogOpen(true) @@ -223,17 +234,34 @@ function ConsentDetailsPage(): React.JSX.Element { {t('consentRegistry.actions.approve')} ) : null} - + {canReject ? ( + + ) : null} + {canRevoke ? ( + + ) : null} @@ -276,6 +304,23 @@ function ConsentDetailsPage(): React.JSX.Element { }} /> + { + setRejectionDialogOpen(false) + }} + onConfirm={() => { + rejectMutation.mutate(id, { + onSuccess: () => { + setRejectionDialogOpen(false) + }, + }) + }} + /> + { + return apiRequest(`/me/consents/${encodeURIComponent(consentID)}/reject`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) +} + export async function revokeMyConsent(consentID: string): Promise { return apiRequest(`/me/consents/${encodeURIComponent(consentID)}/revoke`, { method: 'POST', diff --git a/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx b/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx index 6521825d..253904ea 100644 --- a/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx +++ b/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx @@ -27,7 +27,7 @@ import { Tooltip, Typography, } from '@wso2/oxygen-ui' -import { CircleCheckBig, Eye, ShieldX } from '@wso2/oxygen-ui-icons-react' +import { Ban, CircleCheckBig, Eye } from '@wso2/oxygen-ui-icons-react' import { Fragment, type MouseEvent, useCallback, useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { Link as RouterLink, useNavigate } from 'react-router-dom' @@ -437,7 +437,7 @@ export default function ConsentRegistryTable({ data-consent-id={row.id} onClick={handleRevokeClick} > - + diff --git a/portal/frontend/src/features/consent-registry/components/ConsentRejectionDialog.tsx b/portal/frontend/src/features/consent-registry/components/ConsentRejectionDialog.tsx new file mode 100644 index 00000000..15e836af --- /dev/null +++ b/portal/frontend/src/features/consent-registry/components/ConsentRejectionDialog.tsx @@ -0,0 +1,157 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + Box, + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + Stack, + Typography, +} from '@wso2/oxygen-ui' +import { Ban } from '@wso2/oxygen-ui-icons-react' +import { useTranslation } from 'react-i18next' + +interface ConsentRejectionDialogProps { + open: boolean + consentId: string + loading: boolean + onClose: () => void + onConfirm: () => void +} + +function ConsentRejectionDialog({ + open, + consentId, + loading, + onClose, + onConfirm, +}: ConsentRejectionDialogProps): React.JSX.Element { + const { t } = useTranslation('common') + + return ( + ({ + borderRadius: 1, + ...theme.applyStyles('light', { bgcolor: theme.palette.grey[50] }), + ...theme.applyStyles('dark', { bgcolor: 'rgba(255, 255, 255, 0.06)' }), + }), + }} + > + + + + {t('consentRegistry.modals.rejection.title', 'Confirm Rejection')} + + + {t( + 'consentRegistry.modals.rejection.message', + 'Are you sure you want to reject this consent?', + )} + + + {t('consentRegistry.modals.consentId', 'Consent ID')}: {consentId} + + + + + + + + + + + + {t( + 'consentRegistry.modals.rejection.note', + 'Rejecting this consent means the requested permissions will not be granted.', + )} + + + + + + + + + + + ) +} + +export default ConsentRejectionDialog diff --git a/portal/frontend/src/features/consent-registry/components/ConsentRevocationDialog.tsx b/portal/frontend/src/features/consent-registry/components/ConsentRevocationDialog.tsx index b2a49185..6133b8a2 100644 --- a/portal/frontend/src/features/consent-registry/components/ConsentRevocationDialog.tsx +++ b/portal/frontend/src/features/consent-registry/components/ConsentRevocationDialog.tsx @@ -26,7 +26,7 @@ import { Stack, Typography, } from '@wso2/oxygen-ui' -import { AlertTriangle } from '@wso2/oxygen-ui-icons-react' +import { Ban } from '@wso2/oxygen-ui-icons-react' import { useTranslation } from 'react-i18next' interface ConsentRevocationDialogProps { @@ -111,7 +111,7 @@ function ConsentRevocationDialog({ flexShrink: 0, }} > - + {t( @@ -134,7 +134,14 @@ function ConsentRevocationDialog({ gap: 1.25, }} > - diff --git a/portal/frontend/src/features/catalog/PurposeListPage.tsx b/portal/frontend/src/features/catalog/PurposeListPage.tsx index ac8eeabf..c41d2937 100644 --- a/portal/frontend/src/features/catalog/PurposeListPage.tsx +++ b/portal/frontend/src/features/catalog/PurposeListPage.tsx @@ -4,6 +4,7 @@ */ import { + Autocomplete, Box, Button, Chip, @@ -31,10 +32,15 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, useSearchParams } from 'react-router-dom' import HeaderBreadcrumbs from '../../components/layout/main-layout/HeaderBreadcrumbs' -import type { PurposeFilters } from '../../types/catalog' +import type { ElementSummary, PurposeFilters } from '../../types/catalog' import { formatEpochTimestamp } from '../../utils/dateTime' +import ElementVersionSelect from './components/ElementVersionSelect' import PurposeFormDialog from './components/PurposeFormDialog' -import { useCreatePurposeMutation, usePurposesQuery } from './hooks/useCatalogQueries' +import { + useCreatePurposeMutation, + useElementOptionsQuery, + usePurposesQuery, +} from './hooks/useCatalogQueries' const ROW_OPTIONS = [10, 25, 50] const ORG_ID = String(import.meta.env.VITE_ORG_ID ?? '').trim() @@ -45,10 +51,13 @@ interface PurposeListFilters extends PurposeFilters { scope: PurposeScope } +function elementLabel(element: ElementSummary): string { + return `${element.displayName ?? element.name} (${element.namespace})` +} + function emptyFilters(): PurposeListFilters { return { purposeName: '', - purposeVersion: '', elementName: '', elementNamespace: '', elementVersion: '', @@ -62,7 +71,6 @@ function getFilters(searchParams: URLSearchParams): PurposeListFilters { return { purposeName: searchParams.get('purposeName') ?? '', - purposeVersion: searchParams.get('purposeVersion') ?? '', elementName: searchParams.get('elementName') ?? '', elementNamespace: searchParams.get('elementNamespace') ?? '', elementVersion: searchParams.get('elementVersion') ?? '', @@ -86,8 +94,16 @@ function PurposeFiltersPanel({ const [draft, setDraft] = useState(initialFilters) const [filtersAnchor, setFiltersAnchor] = useState(null) const filtersOpen = Boolean(filtersAnchor) + const elementOptionsQuery = useElementOptionsQuery(filtersOpen) + const elementOptions = elementOptionsQuery.data?.data ?? [] + const selectedElement = + elementOptions.find( + (element) => + element.name === draft.elementName && element.namespace === draft.elementNamespace, + ) ?? + elementOptions.find((element) => element.name === draft.elementName) ?? + null const advancedFilterCount = [ - draft.purposeVersion, draft.elementName, draft.elementNamespace, draft.elementVersion, @@ -113,12 +129,7 @@ function PurposeFiltersPanel({ value={draft.purposeName} placeholder={t('catalog.purposes.searchPlaceholder')} onChange={(event) => { - const purposeName = event.target.value - setDraft({ - ...draft, - purposeName, - purposeVersion: purposeName.trim() ? draft.purposeVersion : '', - }) + setDraft({ ...draft, purposeName: event.target.value }) }} onKeyDown={(event) => { if (event.key === 'Enter') { @@ -198,71 +209,70 @@ function PurposeFiltersPanel({ {t('catalog.purposes.filterTitle')} - { - if (!value) return - setDraft({ - ...draft, - scope: value, - groupIds: value === 'organization' ? ORG_ID : '', - }) - }} - aria-label={t('catalog.purposes.scopeFilter')} - > - {t('catalog.purposes.orgWide')} - {t('catalog.purposes.allPurposes')} - - + + { + if (!value) return + setDraft({ + ...draft, + scope: value, + groupIds: value === 'organization' ? ORG_ID : '', + }) + }} + aria-label={t('catalog.purposes.scopeFilter')} + > + {t('catalog.purposes.orgWide')} + {t('catalog.purposes.allPurposes')} + {draft.scope === 'all' ? ( - - setDraft({ ...draft, groupIds: event.target.value })} - /> - - - ) : null} - - setDraft({ ...draft, purposeVersion: event.target.value })} + label={t('catalog.fields.groupIds')} + value={draft.groupIds} + onChange={(event) => setDraft({ ...draft, groupIds: event.target.value })} + sx={{ flex: 1, minWidth: 0 }} /> - - + + ) : null} - - + { - const elementName = event.target.value + sx={{ width: '100%' }} + options={elementOptions} + value={selectedElement} + loading={elementOptionsQuery.isLoading} + getOptionLabel={elementLabel} + isOptionEqualToValue={(option, value) => option.elementId === value.elementId} + noOptionsText={t('catalog.messages.noElementsFound')} + onChange={(_, selected) => { setDraft({ ...draft, - elementName, - elementVersion: - elementName.trim() || draft.elementNamespace.trim() ? draft.elementVersion : '', + elementName: selected?.name ?? '', + elementNamespace: selected?.namespace ?? '', + elementVersion: '', }) }} + renderInput={(params) => ( + // Oxygen UI Autocomplete requires forwarding its generated input props. + // eslint-disable-next-line react/jsx-props-no-spreading + + )} /> - - - + setDraft({ ...draft, elementVersion: event.target.value })} + label={t('catalog.fields.elementVersion')} + allowAny + onChange={(elementVersion) => setDraft({ ...draft, elementVersion })} /> - - + ) : ( + + + setDraft({ ...draft, elementVersion: event.target.value })} + /> + + + )} + const filterLabels: Record = { purposeName: t('catalog.fields.purposeName'), - purposeVersion: t('catalog.fields.purposeVersion'), elementName: t('catalog.fields.elementName'), elementNamespace: t('catalog.fields.elementNamespace'), elementVersion: t('catalog.fields.elementVersion'), @@ -381,7 +401,6 @@ function PurposeListPage(): React.JSX.Element { const removeFilter = (key: keyof PurposeFilters): void => { const nextFilters: PurposeListFilters = { ...filters, [key]: '' } - if (key === 'purposeName') nextFilters.purposeVersion = '' if (key === 'elementName' && !filters.elementNamespace) nextFilters.elementVersion = '' if (key === 'elementNamespace' && !filters.elementName) nextFilters.elementVersion = '' @@ -446,7 +465,11 @@ function PurposeListPage(): React.JSX.Element { /> ))} {activeFilters.length > 0 || filters.scope === 'all' ? ( - ) : null} diff --git a/portal/frontend/src/features/catalog/api/catalogApi.ts b/portal/frontend/src/features/catalog/api/catalogApi.ts index 703912ce..0b5254a0 100644 --- a/portal/frontend/src/features/catalog/api/catalogApi.ts +++ b/portal/frontend/src/features/catalog/api/catalogApi.ts @@ -99,9 +99,6 @@ export function fetchPurposes( method: 'GET', query: { purposeName: filters.purposeName.trim() || undefined, - purposeVersion: filters.purposeName.trim() - ? filters.purposeVersion.trim() || undefined - : undefined, elementName: filters.elementName.trim() || undefined, elementNamespace: filters.elementNamespace.trim() || undefined, elementVersion: diff --git a/portal/frontend/src/features/catalog/components/ElementVersionSelect.tsx b/portal/frontend/src/features/catalog/components/ElementVersionSelect.tsx index 1c814eb8..ff0967d8 100644 --- a/portal/frontend/src/features/catalog/components/ElementVersionSelect.tsx +++ b/portal/frontend/src/features/catalog/components/ElementVersionSelect.tsx @@ -14,6 +14,7 @@ interface ElementVersionSelectProps { latestVersion: string | undefined value: string label: string + allowAny: boolean onChange: (version: string) => void } @@ -22,6 +23,7 @@ function ElementVersionSelect({ latestVersion, value, label, + allowAny, onChange, }: ElementVersionSelectProps): React.JSX.Element { const { t } = useTranslation('common') @@ -37,10 +39,15 @@ function ElementVersionSelect({ }, [latestVersion, versionsQuery.data?.versions]) useEffect(() => { - if (versionsQuery.isSuccess && versions.length > 0 && !versions.includes(value)) { + if ( + versionsQuery.isSuccess && + versions.length > 0 && + !versions.includes(value) && + !(allowAny && value === '') + ) { onChange(latestVersion && versions.includes(latestVersion) ? latestVersion : versions[0]) } - }, [latestVersion, onChange, value, versions, versionsQuery.isSuccess]) + }, [allowAny, latestVersion, onChange, value, versions, versionsQuery.isSuccess]) if (!elementId) { return ( @@ -151,7 +158,11 @@ function ElementVersionSelect({ label={label} value={value} slotProps={{ + inputLabel: { + shrink: allowAny ? true : undefined, + }, select: { + displayEmpty: allowAny, inputProps: { 'aria-label': t('catalog.fields.version'), }, @@ -159,6 +170,7 @@ function ElementVersionSelect({ }} onChange={(event) => onChange(event.target.value)} > + {allowAny ? {t('catalog.values.any')} : null} {versions.map((version) => ( {version} diff --git a/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx b/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx index 96200ed9..ba522de9 100644 --- a/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx +++ b/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx @@ -397,6 +397,7 @@ function PurposeFormDialog({ latestVersion={selectedElement?.version} value={row.version ?? ''} label={t('catalog.fields.version')} + allowAny={false} onChange={(version) => { setElements((currentElements) => currentElements.map((item) => diff --git a/portal/frontend/src/features/consent-registry/ConsentRegistryPage.tsx b/portal/frontend/src/features/consent-registry/ConsentRegistryPage.tsx index 05b8fc29..4f561c28 100644 --- a/portal/frontend/src/features/consent-registry/ConsentRegistryPage.tsx +++ b/portal/frontend/src/features/consent-registry/ConsentRegistryPage.tsx @@ -38,8 +38,6 @@ const DEFAULT_FILTERS: ConsentRegistryFiltersModel = { status: 'All', purposeName: '', groupIds: '', - elementName: '', - elementVersion: '', startDate: '', endDate: '', } @@ -67,8 +65,6 @@ function getFiltersFromSearchParams(searchParams: URLSearchParams): ConsentRegis status: statusParam && isValidFilterStatus(statusParam) ? statusParam : DEFAULT_FILTERS.status, purposeName: searchParams.get('purposeName') ?? DEFAULT_FILTERS.purposeName, groupIds: searchParams.get('groupIds') ?? DEFAULT_FILTERS.groupIds, - elementName: searchParams.get('elementName') ?? DEFAULT_FILTERS.elementName, - elementVersion: searchParams.get('elementVersion') ?? DEFAULT_FILTERS.elementVersion, startDate: searchParams.get('startDate') ?? DEFAULT_FILTERS.startDate, endDate: searchParams.get('endDate') ?? DEFAULT_FILTERS.endDate, } @@ -115,14 +111,6 @@ function toSearchParams( params.set('groupIds', filters.groupIds.trim()) } - if (filters.elementName.trim()) { - params.set('elementName', filters.elementName.trim()) - } - - if (filters.elementVersion.trim() && filters.elementName.trim()) { - params.set('elementVersion', filters.elementVersion.trim()) - } - if (filters.startDate) { params.set('startDate', filters.startDate) } diff --git a/portal/frontend/src/features/consent-registry/components/ConsentRegistryFilters.tsx b/portal/frontend/src/features/consent-registry/components/ConsentRegistryFilters.tsx index 306843a9..731da5e3 100644 --- a/portal/frontend/src/features/consent-registry/components/ConsentRegistryFilters.tsx +++ b/portal/frontend/src/features/consent-registry/components/ConsentRegistryFilters.tsx @@ -18,7 +18,6 @@ import { AdapterDateFns, - Autocomplete, Box, Button, DatePickers, @@ -37,10 +36,7 @@ import { import { ListFilter } from '@wso2/oxygen-ui-icons-react' import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' -import type { ElementSummary } from '../../../types/catalog' import type { ConsentRegistryFilters as ConsentRegistryFiltersModel } from '../../../types/consent' -import ElementVersionSelect from '../../catalog/components/ElementVersionSelect' -import { useElementOptionsQuery } from '../../catalog/hooks/useCatalogQueries' interface ConsentRegistryFiltersProps { filters: ConsentRegistryFiltersModel @@ -71,10 +67,6 @@ function formatDate(value: Date | null): string { return `${year}-${month}-${day}` } -function elementLabel(element: ElementSummary): string { - return `${element.displayName ?? element.name} (${element.namespace})` -} - function ConsentRegistryFilters({ filters, onFilterChange, @@ -84,17 +76,9 @@ function ConsentRegistryFilters({ const [draft, setDraft] = useState(filters) const [filtersAnchor, setFiltersAnchor] = useState(null) const filtersOpen = Boolean(filtersAnchor) - const elementOptionsQuery = useElementOptionsQuery(filtersOpen) - const availableElements = elementOptionsQuery.data?.data ?? [] - const selectedElement = - availableElements.find((element) => element.name === draft.elementName) ?? null - const advancedFilterCount = [ - filters.groupIds, - filters.elementName, - filters.elementVersion, - filters.startDate, - filters.endDate, - ].filter(Boolean).length + const advancedFilterCount = [filters.groupIds, filters.startDate, filters.endDate].filter( + Boolean, + ).length useEffect(() => { setDraft(filters) @@ -242,44 +226,6 @@ function ConsentRegistryFilters({ onChange={(event) => setDraft({ ...draft, groupIds: event.target.value })} /> - *': { flex: 1, minWidth: 0 } }} - > - option.elementId === value.elementId} - noOptionsText={t('catalog.messages.noElementsFound')} - onChange={(_, selected) => { - setDraft({ - ...draft, - elementName: selected?.name ?? '', - elementVersion: selected?.version ?? '', - }) - }} - renderInput={(params) => ( - // Oxygen UI Autocomplete requires forwarding its generated input props. - // eslint-disable-next-line react/jsx-props-no-spreading - - )} - sx={{ width: '100%' }} - /> - { - setDraft((currentDraft) => ({ ...currentDraft, elementVersion })) - }} - /> - - Date: Fri, 31 Jul 2026 02:08:35 +0530 Subject: [PATCH 13/19] Add api backed sorting support for portal --- portal/backend/openapi/portal-backend.yaml | 9 ++ .../__tests__/ConsentRegistryPage.test.tsx | 141 +++++++++++++++++- .../consent-registry/ConsentRegistryPage.tsx | 92 ++++++++++-- .../consent-registry/api/consentsApi.ts | 1 + .../components/ConsentRegistryTable.tsx | 72 +++------ .../hooks/useConsentQueries.ts | 30 +++- portal/frontend/src/types/consent.ts | 4 + 7 files changed, 278 insertions(+), 71 deletions(-) diff --git a/portal/backend/openapi/portal-backend.yaml b/portal/backend/openapi/portal-backend.yaml index f7f3fe1a..8cc17f39 100644 --- a/portal/backend/openapi/portal-backend.yaml +++ b/portal/backend/openapi/portal-backend.yaml @@ -145,6 +145,15 @@ paths: description: >- Element version contained by the consent. Requires elementName. schema: { type: string } + - in: query + name: sort + description: >- + Up to three comma-separated sort items in `{field}:{direction}` or + `{field}` form. Supported fields are `createdTime`, `updatedTime`, + `validityTime`, `status`, `groupId`, and `consentType`; directions + are `asc` and `desc`, with `desc` used when omitted. Defaults to + `createdTime:desc` when the parameter is omitted. + schema: { type: string } - in: query name: fromTime description: Inclusive lower bound for updated time, in Unix milliseconds. diff --git a/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx b/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx index e4aadaf9..f8dffbfa 100644 --- a/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx +++ b/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx @@ -16,11 +16,11 @@ * under the License. */ -import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { AcrylicOrangeTheme, CssBaseline, OxygenUIThemeProvider } from '@wso2/oxygen-ui' import { I18nextProvider } from 'react-i18next' -import { MemoryRouter, Route, Routes, useNavigate } from 'react-router-dom' +import { MemoryRouter, Route, Routes, useLocation, useNavigate } from 'react-router-dom' import { afterEach, describe, expect, it, vi } from 'vitest' import ConsentRegistryPage from '../features/consent-registry/ConsentRegistryPage' import i18n from '../i18n/i18n' @@ -37,6 +37,12 @@ function PendingConsentsLink(): React.JSX.Element { ) } +function CurrentLocation(): React.JSX.Element { + const location = useLocation() + + return {`${location.pathname}${location.search}`} +} + function createQueryClient(): QueryClient { return new QueryClient({ defaultOptions: { @@ -60,6 +66,7 @@ function renderConsentRegistryPage(queryClient: QueryClient, initialEntry = '/co element={ <> + } @@ -132,6 +139,7 @@ describe('ConsentRegistryPage', () => { '/consents/CON%2F8291%3Fdraft', ) expect(screen.getByText('Consent ID')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: 'Consent ID' })).not.toBeInTheDocument() expect(screen.getByLabelText('Consent ID: CON/8291?draft')).toHaveTextContent('CON/8291…') expect( screen.getByRole('button', { name: 'Copy consent ID CON/8291?draft' }), @@ -159,6 +167,46 @@ describe('ConsentRegistryPage', () => { expect(screen.queryByRole('table', { name: 'Consent registry table' })).not.toBeInTheDocument() }) + it('renders consent rows in API order without sorting the current page locally', async () => { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: [ + { + id: 'z-consent', + groupId: 'group-1', + type: 'accounts', + status: 'ACTIVE', + createdTime: 1702800000000, + updatedTime: 1702800000000, + purposes: [], + }, + { + id: 'a-consent', + groupId: 'group-1', + type: 'accounts', + status: 'ACTIVE', + createdTime: 1702900000000, + updatedTime: 1702900000000, + purposes: [], + }, + ], + metadata: { total: 2, offset: 0, count: 2, limit: 10 }, + }), + }) + + renderConsentRegistryPage(createQueryClient()) + + const consentIDs = await screen.findAllByLabelText(/^Consent ID: /) + + expect(consentIDs.map((element) => element.getAttribute('aria-label'))).toEqual([ + 'Consent ID: z-consent', + 'Consent ID: a-consent', + ]) + }) + it('shows the empty state for an empty v0.3 response', async () => { vi.stubGlobal('fetch', fetchMock) fetchMock.mockResolvedValue({ @@ -273,6 +321,95 @@ describe('ConsentRegistryPage', () => { expect(String(requestUrl)).toContain('limit=25') expect(String(requestUrl)).toContain('offset=25') + expect(String(requestUrl)).toContain('sort=updatedTime%3Adesc') + }) + + it('sends a valid URL sort to the API', async () => { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: [], + metadata: { total: 0, offset: 0, count: 0, limit: 10 }, + }), + }) + + renderConsentRegistryPage(createQueryClient(), '/consents?sort=validityTime%3Aasc') + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled() + }) + + const [requestUrl] = fetchMock.mock.calls[0] ?? [] + const url = new URL(String(requestUrl)) + + expect(url.searchParams.get('sort')).toBe('validityTime:asc') + }) + + it('falls back to updatedTime descending for an invalid URL sort', async () => { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: [], + metadata: { total: 0, offset: 0, count: 0, limit: 10 }, + }), + }) + + renderConsentRegistryPage(createQueryClient(), '/consents?sort=unsupported%3Aasc') + + await waitFor(() => { + expect(fetchMock).toHaveBeenCalled() + }) + + const [requestUrl] = fetchMock.mock.calls[0] ?? [] + const url = new URL(String(requestUrl)) + + expect(url.searchParams.get('sort')).toBe('updatedTime:desc') + }) + + it('requests a newly selected server sort from the first page', async () => { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + data: [ + { + id: 'consent-1', + groupId: 'group-1', + type: 'accounts', + status: 'ACTIVE', + createdTime: 1702800000000, + updatedTime: 1702800000000, + expirationTime: 1702900000000, + purposes: [], + }, + ], + metadata: { total: 1, offset: 10, count: 1, limit: 10 }, + }), + }) + + renderConsentRegistryPage(createQueryClient(), '/consents?page=2') + + expect(await screen.findByRole('button', { name: 'Status' })).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: 'Status' })) + + await waitFor(() => { + const requests = fetchMock.mock.calls.map(([requestUrl]) => new URL(String(requestUrl))) + + expect( + requests.some( + (url) => + url.searchParams.get('sort') === 'status:asc' && url.searchParams.get('offset') === '0', + ), + ).toBe(true) + expect(screen.getByTestId('current-location')).toHaveTextContent( + '/consents?sort=status%3Aasc', + ) + }) }) it('prefetches exactly one next page when more consents are available', async () => { diff --git a/portal/frontend/src/features/consent-registry/ConsentRegistryPage.tsx b/portal/frontend/src/features/consent-registry/ConsentRegistryPage.tsx index 4f561c28..e4b1bfdb 100644 --- a/portal/frontend/src/features/consent-registry/ConsentRegistryPage.tsx +++ b/portal/frontend/src/features/consent-registry/ConsentRegistryPage.tsx @@ -26,7 +26,11 @@ import ConsentRegistryFilters from './components/ConsentRegistryFilters' import ConsentRegistryTable from './components/ConsentRegistryTable' import ConsentRevocationDialog from './components/ConsentRevocationDialog' import { CONSENT_REGISTRY_ROWS_PER_PAGE_OPTIONS } from './constants' -import type { ConsentRegistryFilters as ConsentRegistryFiltersModel } from '../../types/consent' +import type { + ConsentRegistryFilters as ConsentRegistryFiltersModel, + ConsentRegistrySortDirection, + ConsentRegistrySortField, +} from '../../types/consent' import { useApproveConsentMutation, useConsentDetailQuery, @@ -53,6 +57,14 @@ const FILTER_STATUS_VALUES: ConsentRegistryFiltersModel['status'][] = [ const DEFAULT_PAGE = 0 const DEFAULT_ROWS_PER_PAGE = 10 +const DEFAULT_SORT_FIELD: ConsentRegistrySortField = 'updatedTime' +const DEFAULT_SORT_DIRECTION: ConsentRegistrySortDirection = 'desc' +const SORT_FIELDS: ConsentRegistrySortField[] = ['status', 'updatedTime', 'validityTime'] + +interface ConsentRegistrySort { + field: ConsentRegistrySortField + direction: ConsentRegistrySortDirection +} function isValidFilterStatus(value: string): value is ConsentRegistryFiltersModel['status'] { return FILTER_STATUS_VALUES.includes(value as ConsentRegistryFiltersModel['status']) @@ -92,10 +104,33 @@ function getRowsPerPageFromSearchParams(searchParams: URLSearchParams): number { : DEFAULT_ROWS_PER_PAGE } +function getSortFromSearchParams(searchParams: URLSearchParams): ConsentRegistrySort { + const sortParam = searchParams.get('sort') + + if (!sortParam || sortParam.includes(',')) { + return { field: DEFAULT_SORT_FIELD, direction: DEFAULT_SORT_DIRECTION } + } + + const [field, direction = DEFAULT_SORT_DIRECTION, ...extraParts] = sortParam.split(':') + const validField = SORT_FIELDS.includes(field as ConsentRegistrySortField) + const validDirection = direction === 'asc' || direction === 'desc' + + if (!validField || !validDirection || extraParts.length > 0) { + return { field: DEFAULT_SORT_FIELD, direction: DEFAULT_SORT_DIRECTION } + } + + return { + field: field as ConsentRegistrySortField, + direction, + } +} + function toSearchParams( filters: ConsentRegistryFiltersModel, page = DEFAULT_PAGE, rowsPerPage = DEFAULT_ROWS_PER_PAGE, + sortField: ConsentRegistrySortField = DEFAULT_SORT_FIELD, + sortDirection: ConsentRegistrySortDirection = DEFAULT_SORT_DIRECTION, ): URLSearchParams { const params = new URLSearchParams() @@ -127,6 +162,10 @@ function toSearchParams( params.set('rowsPerPage', String(rowsPerPage)) } + if (sortField !== DEFAULT_SORT_FIELD || sortDirection !== DEFAULT_SORT_DIRECTION) { + params.set('sort', `${sortField}:${sortDirection}`) + } + return params } @@ -142,7 +181,14 @@ function ConsentRegistryPage(): React.JSX.Element { const filters = useMemo(() => getFiltersFromSearchParams(searchParams), [searchParams]) const page = useMemo(() => getPageFromSearchParams(searchParams), [searchParams]) const rowsPerPage = useMemo(() => getRowsPerPageFromSearchParams(searchParams), [searchParams]) - const consentListQuery = useConsentListQuery(filters, page, rowsPerPage) + const sort = useMemo(() => getSortFromSearchParams(searchParams), [searchParams]) + const consentListQuery = useConsentListQuery( + filters, + page, + rowsPerPage, + sort.field, + sort.direction, + ) const selectedApprovalConsentQuery = useConsentDetailQuery(selectedApprovalConsentID ?? undefined) const approveMutation = useApproveConsentMutation() const revokeMutation = useRevokeConsentMutation() @@ -164,12 +210,24 @@ function ConsentRegistryPage(): React.JSX.Element { { - setSearchParams(toSearchParams(nextFilters, DEFAULT_PAGE, rowsPerPage), { - replace: true, - }) + setSearchParams( + toSearchParams(nextFilters, DEFAULT_PAGE, rowsPerPage, sort.field, sort.direction), + { + replace: true, + }, + ) }} onClear={() => { - setSearchParams({}, { replace: true }) + setSearchParams( + toSearchParams( + DEFAULT_FILTERS, + DEFAULT_PAGE, + rowsPerPage, + sort.field, + sort.direction, + ), + { replace: true }, + ) }} /> @@ -184,13 +242,27 @@ function ConsentRegistryPage(): React.JSX.Element { isLoading={isTableLoading} page={page} rowsPerPage={rowsPerPage} + sortField={sort.field} + sortDirection={sort.direction} onPageChange={(nextPage) => { - setSearchParams(toSearchParams(filters, nextPage, rowsPerPage), { replace: true }) + setSearchParams( + toSearchParams(filters, nextPage, rowsPerPage, sort.field, sort.direction), + { replace: true }, + ) }} onRowsPerPageChange={(nextRowsPerPage) => { - setSearchParams(toSearchParams(filters, DEFAULT_PAGE, nextRowsPerPage), { - replace: true, - }) + setSearchParams( + toSearchParams(filters, DEFAULT_PAGE, nextRowsPerPage, sort.field, sort.direction), + { + replace: true, + }, + ) + }} + onSortChange={(sortField, sortDirection) => { + setSearchParams( + toSearchParams(filters, DEFAULT_PAGE, rowsPerPage, sortField, sortDirection), + { replace: true }, + ) }} onApprove={(consentID) => { setSelectedApprovalConsentID(consentID) diff --git a/portal/frontend/src/features/consent-registry/api/consentsApi.ts b/portal/frontend/src/features/consent-registry/api/consentsApi.ts index b0929b42..c38a8166 100644 --- a/portal/frontend/src/features/consent-registry/api/consentsApi.ts +++ b/portal/frontend/src/features/consent-registry/api/consentsApi.ts @@ -35,6 +35,7 @@ export async function fetchMyConsents( groupIds: params.groupIds, elementName: params.elementName, elementVersion: params.elementVersion, + sort: params.sort, fromTime: params.fromTime, toTime: params.toTime, limit: params.limit, diff --git a/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx b/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx index 253904ea..747d500f 100644 --- a/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx +++ b/portal/frontend/src/features/consent-registry/components/ConsentRegistryTable.tsx @@ -32,7 +32,11 @@ import { Fragment, type MouseEvent, useCallback, useMemo, useState } from 'react import { useTranslation } from 'react-i18next' import { Link as RouterLink, useNavigate } from 'react-router-dom' import CopyableText from '../../../components/CopyableText' -import type { ConsentRecord } from '../../../types/consent' +import type { + ConsentRecord, + ConsentRegistrySortDirection, + ConsentRegistrySortField, +} from '../../../types/consent' import { formatEpochTimestamp, formatIsoDateTime } from '../../../utils/dateTime' import { CONSENT_REGISTRY_ROWS_PER_PAGE_OPTIONS } from '../constants' import { getConsentStatusChipColor, getConsentStatusLabelKey } from '../utils/statusChip' @@ -43,16 +47,16 @@ interface ConsentRegistryTableProps { isLoading: boolean page: number rowsPerPage: number + sortField: ConsentRegistrySortField + sortDirection: ConsentRegistrySortDirection onPageChange: (page: number) => void onRowsPerPageChange: (rowsPerPage: number) => void + onSortChange: (field: ConsentRegistrySortField, direction: ConsentRegistrySortDirection) => void onApprove: (consentID: string) => void onRevoke: (consentID: string) => void isMutating: boolean } -type SortField = 'id' | 'status' | 'updatedAt' | 'expirationTime' -type SortDirection = 'asc' | 'desc' - const PURPOSE_PREVIEW_COUNT = 2 const CONSENT_REGISTRY_COLUMN_WIDTHS = { @@ -64,69 +68,30 @@ const CONSENT_REGISTRY_COLUMN_WIDTHS = { actions: '8%', } as const -function sortConsentRows( - rows: ConsentRecord[], - sortField: SortField, - sortDirection: SortDirection, -): ConsentRecord[] { - const sortedRows = [...rows].sort((leftRow, rightRow) => { - const leftValue = leftRow[sortField] - const rightValue = rightRow[sortField] - - if (leftValue == null && rightValue == null) { - return 0 - } - - if (leftValue == null) { - return 1 - } - - if (rightValue == null) { - return -1 - } - - if (leftValue < rightValue) { - return -1 - } - - if (leftValue > rightValue) { - return 1 - } - - return 0 - }) - - return sortDirection === 'asc' ? sortedRows : sortedRows.reverse() -} - export default function ConsentRegistryTable({ rows, totalCount, isLoading, page, rowsPerPage, + sortField, + sortDirection, onPageChange, onRowsPerPageChange, + onSortChange, onApprove, onRevoke, isMutating, }: ConsentRegistryTableProps): React.JSX.Element { const { t } = useTranslation('common') const navigate = useNavigate() - const [sortField, setSortField] = useState('updatedAt') - const [sortDirection, setSortDirection] = useState('desc') const [purposesPopoverAnchor, setPurposesPopoverAnchor] = useState(null) const [selectedPurposes, setSelectedPurposes] = useState([]) - const sortedRows = useMemo( - () => sortConsentRows(rows, sortField, sortDirection), - [rows, sortDirection, sortField], - ) - const groupedRows = useMemo(() => { const groupedMap = new Map() - sortedRows.forEach((row) => { + rows.forEach((row) => { const existingRows = groupedMap.get(row.groupId) if (existingRows) { @@ -141,7 +106,7 @@ export default function ConsentRegistryTable({ groupId, groupRows, })) - }, [sortedRows]) + }, [rows]) const selectedRowIds: readonly string[] = [] const isPurposesPopoverOpen = Boolean(purposesPopoverAnchor) @@ -209,8 +174,7 @@ export default function ConsentRegistryTable({ onSelectAll={() => {}} onSelectionChange={() => {}} onSortChange={(nextField, nextDirection) => { - setSortField(nextField as SortField) - setSortDirection(nextDirection) + onSortChange(nextField as ConsentRegistrySortField, nextDirection) }} > @@ -223,9 +187,7 @@ export default function ConsentRegistryTable({ - - {t('consentRegistry.table.headers.consentId')} - + {t('consentRegistry.table.headers.consentId')} {t('consentRegistry.table.headers.purposes')} @@ -236,12 +198,12 @@ export default function ConsentRegistryTable({ - + {t('consentRegistry.table.headers.updated')} - + {t('consentRegistry.table.headers.expiration')} diff --git a/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts b/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts index 9f9d3478..3aae267b 100644 --- a/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts +++ b/portal/frontend/src/features/consent-registry/hooks/useConsentQueries.ts @@ -44,6 +44,8 @@ import type { ConsentListQueryParams, ConsentRecord, ConsentRegistryFilters, + ConsentRegistrySortDirection, + ConsentRegistrySortField, } from '../../../types/consent' import { isConsentAPIStatus } from '../../../types/consent' import { @@ -66,6 +68,8 @@ function toListParams( filters: ConsentRegistryFilters, page: number, rowsPerPage: number, + sortField: ConsentRegistrySortField, + sortDirection: ConsentRegistrySortDirection, ): ConsentListQueryParams { const statusFilterMap: Record, string> = { Active: 'ACTIVE', @@ -76,6 +80,7 @@ function toListParams( } return { + sort: `${sortField}:${sortDirection}`, consentStatuses: filters.status === 'All' ? undefined : statusFilterMap[filters.status], purposeName: filters.purposeName.trim() || undefined, groupIds: filters.groupIds.trim() || undefined, @@ -110,8 +115,10 @@ function consentListQueryOptions( filters: ConsentRegistryFilters, page: number, rowsPerPage: number, + sortField: ConsentRegistrySortField, + sortDirection: ConsentRegistrySortDirection, ) { - const params = toListParams(filters, page, rowsPerPage) + const params = toListParams(filters, page, rowsPerPage, sortField, sortDirection) return queryOptions({ queryKey: ['consents', params], @@ -130,9 +137,13 @@ export function useConsentListQuery( filters: ConsentRegistryFilters, page: number, rowsPerPage: number, + sortField: ConsentRegistrySortField, + sortDirection: ConsentRegistrySortDirection, ): UseQueryResult { const queryClient = useQueryClient() - const query = useQuery(consentListQueryOptions(filters, page, rowsPerPage)) + const query = useQuery( + consentListQueryOptions(filters, page, rowsPerPage, sortField, sortDirection), + ) useEffect(() => { const nextPage = page + 1 @@ -140,10 +151,21 @@ export function useConsentListQuery( if (!query.isPlaceholderData && hasNextPage) { queryClient - .prefetchQuery(consentListQueryOptions(filters, nextPage, rowsPerPage)) + .prefetchQuery( + consentListQueryOptions(filters, nextPage, rowsPerPage, sortField, sortDirection), + ) .catch(() => undefined) } - }, [filters, page, query.data?.total, query.isPlaceholderData, queryClient, rowsPerPage]) + }, [ + filters, + page, + query.data?.total, + query.isPlaceholderData, + queryClient, + rowsPerPage, + sortDirection, + sortField, + ]) return query } diff --git a/portal/frontend/src/types/consent.ts b/portal/frontend/src/types/consent.ts index a4c5379f..e27cb3eb 100644 --- a/portal/frontend/src/types/consent.ts +++ b/portal/frontend/src/types/consent.ts @@ -46,12 +46,16 @@ export interface ConsentRegistryFilters { endDate: string } +export type ConsentRegistrySortField = 'status' | 'updatedTime' | 'validityTime' +export type ConsentRegistrySortDirection = 'asc' | 'desc' + export interface ConsentListQueryParams { consentStatuses?: string purposeName?: string groupIds?: string elementName?: string elementVersion?: string + sort?: string fromTime?: number toTime?: number limit: number From 630abb197b8bf64cb32b569d102823dd44512786 Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Fri, 31 Jul 2026 03:33:07 +0530 Subject: [PATCH 14/19] UI Refinements - Improve no items, error display. - Add scroll to top on page navigations - Improve purpose and element create modals --- .../frontend/src/__tests__/CatalogApi.test.ts | 82 +++++++++++++ .../__tests__/ConsentRegistryPage.test.tsx | 4 +- .../src/__tests__/RouteScrollManager.test.tsx | 109 ++++++++++++++++++ .../layout/main-layout/MainLayout.tsx | 2 + .../layout/main-layout/RouteScrollManager.tsx | 59 ++++++++++ .../src/features/catalog/ElementListPage.tsx | 35 +++--- .../src/features/catalog/PurposeListPage.tsx | 35 +++--- .../src/features/catalog/api/catalogApi.ts | 15 ++- .../catalog/components/ElementFormDialog.tsx | 48 ++++++-- .../catalog/components/PurposeFormDialog.tsx | 22 +++- .../consent-registry/ConsentRegistryPage.tsx | 88 +++++++------- .../components/ConsentRegistryTable.tsx | 73 +++++++++++- .../frontend/src/i18n/resources/en/common.ts | 2 + portal/frontend/src/types/catalog.ts | 17 +-- 14 files changed, 481 insertions(+), 110 deletions(-) create mode 100644 portal/frontend/src/__tests__/CatalogApi.test.ts create mode 100644 portal/frontend/src/__tests__/RouteScrollManager.test.tsx create mode 100644 portal/frontend/src/components/layout/main-layout/RouteScrollManager.tsx diff --git a/portal/frontend/src/__tests__/CatalogApi.test.ts b/portal/frontend/src/__tests__/CatalogApi.test.ts new file mode 100644 index 00000000..d7a55577 --- /dev/null +++ b/portal/frontend/src/__tests__/CatalogApi.test.ts @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { createElement } from '../features/catalog/api/catalogApi' + +const fetchMock = vi.fn() + +afterEach(() => { + fetchMock.mockReset() + vi.unstubAllGlobals() +}) + +describe('createElement', () => { + it('returns the created element from an array response', async () => { + const element = { + elementId: '82017ca0-a120-4bf3-ac1c-ea76630d86cf', + name: 'Test 2', + namespace: 'default', + type: 'basic' as const, + version: 'v1', + createdTime: 1785447718074, + } + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => [{ status: 'SUCCESS', element }], + }) + + await expect(createElement({ name: 'Test 2', type: 'basic' })).resolves.toEqual(element) + }) + + it('preserves support for the wrapped bulk response', async () => { + const element = { + elementId: 'element-1', + name: 'Email', + namespace: 'default', + type: 'basic' as const, + version: 'v1', + createdTime: 1785447718074, + } + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => ({ + results: [{ index: 0, status: 'SUCCESS', data: element }], + }), + }) + + await expect(createElement({ name: 'Email', type: 'basic' })).resolves.toEqual(element) + }) + + it('surfaces a string failure returned for an item', async () => { + vi.stubGlobal('fetch', fetchMock) + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + json: async () => [{ status: 'FAILED', error: 'Element already exists' }], + }) + + await expect(createElement({ name: 'Email', type: 'basic' })).rejects.toThrow( + 'Element already exists', + ) + }) +}) diff --git a/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx b/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx index f8dffbfa..1ff7cb23 100644 --- a/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx +++ b/portal/frontend/src/__tests__/ConsentRegistryPage.test.tsx @@ -164,7 +164,8 @@ describe('ConsentRegistryPage', () => { expect(await screen.findByRole('heading', { name: 'All Consents' })).toBeInTheDocument() expect(await screen.findByText('Unable to load consents right now.')).toBeInTheDocument() - expect(screen.queryByRole('table', { name: 'Consent registry table' })).not.toBeInTheDocument() + expect(screen.getByRole('table', { name: 'Consent registry table' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Retry' })).toBeInTheDocument() }) it('renders consent rows in API order without sorting the current page locally', async () => { @@ -223,6 +224,7 @@ describe('ConsentRegistryPage', () => { expect( await screen.findByText('No consents found for the selected filters.'), ).toBeInTheDocument() + expect(screen.getByRole('table', { name: 'Consent registry table' })).toBeInTheDocument() }) it('shows the error state when a consent response has an unsupported status', async () => { diff --git a/portal/frontend/src/__tests__/RouteScrollManager.test.tsx b/portal/frontend/src/__tests__/RouteScrollManager.test.tsx new file mode 100644 index 00000000..710a3b0c --- /dev/null +++ b/portal/frontend/src/__tests__/RouteScrollManager.test.tsx @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, useNavigate } from 'react-router-dom' +import { afterEach, describe, expect, it, vi } from 'vitest' +import RouteScrollManager from '../components/layout/main-layout/RouteScrollManager' + +function NavigationControls(): React.JSX.Element { + const navigate = useNavigate() + + return ( + <> + + + + +
Target
+ + ) +} + +function renderManager(initialEntries = ['/first'], initialIndex = 0): void { + render( + +
+ + +
+
, + ) +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('RouteScrollManager', () => { + it('scrolls to the top for a new pathname but not a query-only update', async () => { + renderManager() + const scrollContainer = screen.getByTestId('scroll-container') + const scrollTo = vi.fn() + scrollContainer.scrollTo = scrollTo + + fireEvent.click(screen.getByRole('button', { name: 'Change query' })) + expect(scrollTo).not.toHaveBeenCalled() + + fireEvent.click(screen.getByRole('button', { name: 'Change page' })) + + await waitFor(() => { + expect(scrollTo).toHaveBeenCalledWith({ top: 0, left: 0, behavior: 'auto' }) + }) + }) + + it('scrolls hash navigation to its target instead of the page top', async () => { + const scrollIntoView = vi.fn() + const originalScrollIntoView = HTMLElement.prototype.scrollIntoView + HTMLElement.prototype.scrollIntoView = scrollIntoView + renderManager() + const scrollContainer = screen.getByTestId('scroll-container') + const scrollTo = vi.fn() + scrollContainer.scrollTo = scrollTo + + fireEvent.click(screen.getByRole('button', { name: 'Open hash' })) + + await waitFor(() => { + expect(scrollIntoView).toHaveBeenCalled() + }) + expect(scrollTo).not.toHaveBeenCalled() + HTMLElement.prototype.scrollIntoView = originalScrollIntoView + }) + + it('leaves back navigation to browser scroll restoration', async () => { + renderManager(['/first', '/second'], 1) + const scrollContainer = screen.getByTestId('scroll-container') + const scrollTo = vi.fn() + scrollContainer.scrollTo = scrollTo + + fireEvent.click(screen.getByRole('button', { name: 'Go back' })) + + await waitFor(() => { + expect(scrollTo).not.toHaveBeenCalled() + }) + }) +}) diff --git a/portal/frontend/src/components/layout/main-layout/MainLayout.tsx b/portal/frontend/src/components/layout/main-layout/MainLayout.tsx index 8a7ef46c..9c252e8a 100644 --- a/portal/frontend/src/components/layout/main-layout/MainLayout.tsx +++ b/portal/frontend/src/components/layout/main-layout/MainLayout.tsx @@ -21,6 +21,7 @@ import { useState } from 'react' import { useTranslation } from 'react-i18next' import { Outlet } from 'react-router-dom' import AppSidebar from '../sidebar/AppSidebar' +import RouteScrollManager from './RouteScrollManager' import UserProfileMenu from './UserProfileMenu' function MainLayout(): React.JSX.Element { @@ -66,6 +67,7 @@ function MainLayout(): React.JSX.Element { + (null) + + useEffect(() => { + const pathChanged = previousPathname.current !== pathname + previousPathname.current = pathname + + if (hash) { + document.getElementById(decodeHash(hash))?.scrollIntoView() + return + } + + if (pathChanged && navigationType !== 'POP') { + markerRef.current?.parentElement?.scrollTo({ + top: 0, + left: 0, + behavior: 'auto', + }) + } + }, [hash, navigationType, pathname]) + + return
) : null} - {query.isError ? ( - - - {t('catalog.elements.loadFailed')} - - - - ) : null} - ))} + {query.isError ? ( + + + + + + + ) : null} {!query.isPending && !query.isError && rows.length === 0 ? ( diff --git a/portal/frontend/src/features/catalog/PurposeListPage.tsx b/portal/frontend/src/features/catalog/PurposeListPage.tsx index c41d2937..5b943c9b 100644 --- a/portal/frontend/src/features/catalog/PurposeListPage.tsx +++ b/portal/frontend/src/features/catalog/PurposeListPage.tsx @@ -27,7 +27,7 @@ import { ToggleButtonGroup, Typography, } from '@wso2/oxygen-ui' -import { ListFilter, Plus, Search } from '@wso2/oxygen-ui-icons-react' +import { CircleSlash, ListFilter, Plus, RefreshCw, Search } from '@wso2/oxygen-ui-icons-react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, useSearchParams } from 'react-router-dom' @@ -475,21 +475,6 @@ function PurposeListPage(): React.JSX.Element { ) : null}
- {query.isError ? ( - - - {t('catalog.purposes.loadFailed')} - - - - ) : null} - ))} + {query.isError ? ( + + + + + + + ) : null} {!query.isPending && !query.isError && rows.length === 0 ? ( diff --git a/portal/frontend/src/features/catalog/api/catalogApi.ts b/portal/frontend/src/features/catalog/api/catalogApi.ts index 0b5254a0..5b82d2ff 100644 --- a/portal/frontend/src/features/catalog/api/catalogApi.ts +++ b/portal/frontend/src/features/catalog/api/catalogApi.ts @@ -57,20 +57,25 @@ export function fetchElementVersions(elementId: string): Promise { - const response = await apiRequest('/api/consent-elements', { + const response = await apiRequest< + ElementBulkCreateResponse | ElementBulkCreateResponse['results'] + >('/api/consent-elements', { method: 'POST', headers: jsonHeaders, body: JSON.stringify([payload]), }) - const result = response.results[0] + const result = (Array.isArray(response) ? response : response.results)[0] + const element = result?.element ?? result?.data + const error = result?.error - if (!result || result.status === 'FAILED' || !result.data) { + if (!result || result.status === 'FAILED' || !element) { throw new Error( - result?.error?.description ?? result?.error?.message ?? 'Element creation failed', + (typeof error === 'string' ? error : (error?.description ?? error?.message)) ?? + 'Element creation failed', ) } - return result.data + return element } export function createElementVersion( diff --git a/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx b/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx index 4b510f88..feecca56 100644 --- a/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx +++ b/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx @@ -4,6 +4,7 @@ */ import { + Alert, Box, Button, Dialog, @@ -18,7 +19,7 @@ import { Typography, } from '@wso2/oxygen-ui' import { CircleHelp } from '@wso2/oxygen-ui-icons-react' -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import type { ElementCreateRequest, @@ -69,23 +70,41 @@ function ElementFormDialog({ propertiesToEntries(initialValue?.properties), ) const [validationError, setValidationError] = useState('') + const contentRef = useRef(null) const versionMode = Boolean(initialValue) const requiredFieldsComplete = (versionMode || Boolean(name.trim())) && (versionMode || namespaceMode === 'default' || Boolean(namespace.trim())) && (type === 'basic' || Boolean(schema.trim())) + const scrollToError = (): void => { + if (contentRef.current) { + contentRef.current.scrollTop = 0 + } + } + + useEffect(() => { + if (error) { + scrollToError() + } + }, [error]) + const handleSubmit = (): void => { + setValidationError('') + if (!versionMode && !name.trim()) { setValidationError(t('catalog.validation.nameRequired')) + scrollToError() return } if (!versionMode && namespaceMode === 'custom' && !namespace.trim()) { setValidationError(t('catalog.validation.namespaceRequired')) + scrollToError() return } if ((type === 'json' || type === 'xml') && !schema.trim()) { setValidationError(t('catalog.validation.schemaRequired')) + scrollToError() return } @@ -113,10 +132,13 @@ function ElementFormDialog({ ({ + width: 'calc(100% - 32px)', + maxWidth: 720, + maxHeight: 'calc(100vh - 48px)', borderRadius: 1, ...theme.applyStyles('light', { bgcolor: theme.palette.grey[50] }), ...theme.applyStyles('dark', { bgcolor: 'rgba(255, 255, 255, 0.06)' }), @@ -154,8 +176,16 @@ function ElementFormDialog({ ) : null}
- - + + + {validationError || error ? ( + {validationError || error} + ) : null} + + + {t('catalog.elements.detailsSection')} + + {!versionMode ? ( setDisplayName(event.target.value)} /> ) : null} - {validationError || error ? ( - - {validationError || error} - - ) : null} (null) const versionMode = Boolean(initialValue) const availableElements = optionsQuery.data?.data ?? [] const requiredFieldsComplete = @@ -112,29 +113,46 @@ function PurposeFormDialog({ groupId: initialValue?.groupId, }) + const scrollToError = (): void => { + if (contentRef.current) { + contentRef.current.scrollTop = 0 + } + } + + useEffect(() => { + if (error) { + scrollToError() + } + }, [error]) + const handleSubmit = (): void => { setValidationError('') if (!versionMode && !name.trim()) { setValidationError(t('catalog.validation.nameRequired')) + scrollToError() return } if (!versionMode && ownership === 'group' && !groupId.trim()) { setValidationError(t('catalog.validation.groupIdRequired')) + scrollToError() return } if (elements.length === 0 || elements.some((element) => !element.name)) { setValidationError(t('catalog.validation.elementRequired')) + scrollToError() return } if (new Set(elements.map((element) => element.elementKey)).size !== elements.length) { setValidationError(t('catalog.validation.duplicateElements')) + scrollToError() return } const propertyKeys = properties.map((property) => property.key.trim()).filter(Boolean) if (new Set(propertyKeys).size !== propertyKeys.length) { setValidationError(t('catalog.validation.duplicatePropertyKeys')) + scrollToError() return } @@ -219,7 +237,7 @@ function PurposeFormDialog({ ) : null} - + {validationError || error ? ( {validationError || error} diff --git a/portal/frontend/src/features/consent-registry/ConsentRegistryPage.tsx b/portal/frontend/src/features/consent-registry/ConsentRegistryPage.tsx index e4b1bfdb..942ba791 100644 --- a/portal/frontend/src/features/consent-registry/ConsentRegistryPage.tsx +++ b/portal/frontend/src/features/consent-registry/ConsentRegistryPage.tsx @@ -231,54 +231,46 @@ function ConsentRegistryPage(): React.JSX.Element { }} /> - {consentListQuery.isError ? ( - {t('consentRegistry.messages.loadFailed')} - ) : null} - - {!consentListQuery.isError && (rows.length > 0 || isTableLoading) ? ( - { - setSearchParams( - toSearchParams(filters, nextPage, rowsPerPage, sort.field, sort.direction), - { replace: true }, - ) - }} - onRowsPerPageChange={(nextRowsPerPage) => { - setSearchParams( - toSearchParams(filters, DEFAULT_PAGE, nextRowsPerPage, sort.field, sort.direction), - { - replace: true, - }, - ) - }} - onSortChange={(sortField, sortDirection) => { - setSearchParams( - toSearchParams(filters, DEFAULT_PAGE, rowsPerPage, sortField, sortDirection), - { replace: true }, - ) - }} - onApprove={(consentID) => { - setSelectedApprovalConsentID(consentID) - setApprovalDialogOpen(true) - }} - onRevoke={(consentID) => { - setSelectedRevocationConsentID(consentID) - setRevocationDialogOpen(true) - }} - isMutating={approveMutation.isPending || revokeMutation.isPending} - /> - ) : null} - - {!isTableLoading && !consentListQuery.isError && rows.length === 0 ? ( - {t('consentRegistry.messages.empty')} - ) : null} + { + setSearchParams( + toSearchParams(filters, nextPage, rowsPerPage, sort.field, sort.direction), + { replace: true }, + ) + }} + onRowsPerPageChange={(nextRowsPerPage) => { + setSearchParams( + toSearchParams(filters, DEFAULT_PAGE, nextRowsPerPage, sort.field, sort.direction), + { + replace: true, + }, + ) + }} + onSortChange={(sortField, sortDirection) => { + setSearchParams( + toSearchParams(filters, DEFAULT_PAGE, rowsPerPage, sortField, sortDirection), + { replace: true }, + ) + }} + onRetry={() => consentListQuery.refetch()} + onApprove={(consentID) => { + setSelectedApprovalConsentID(consentID) + setApprovalDialogOpen(true) + }} + onRevoke={(consentID) => { + setSelectedRevocationConsentID(consentID) + setRevocationDialogOpen(true) + }} + isMutating={approveMutation.isPending || revokeMutation.isPending} + /> {selectedApprovalConsentID ? ( void onRowsPerPageChange: (rowsPerPage: number) => void onSortChange: (field: ConsentRegistrySortField, direction: ConsentRegistrySortDirection) => void + onRetry: () => void onApprove: (consentID: string) => void onRevoke: (consentID: string) => void isMutating: boolean @@ -62,9 +73,9 @@ const PURPOSE_PREVIEW_COUNT = 2 const CONSENT_REGISTRY_COLUMN_WIDTHS = { consentId: '13%', purposes: '34%', - status: '12%', - updated: '16%', - expiration: '16%', + status: '10%', + updated: '17%', + expiration: '17%', actions: '8%', } as const @@ -72,6 +83,7 @@ export default function ConsentRegistryTable({ rows, totalCount, isLoading, + isError, page, rowsPerPage, sortField, @@ -79,6 +91,7 @@ export default function ConsentRegistryTable({ onPageChange, onRowsPerPageChange, onSortChange, + onRetry, onApprove, onRevoke, isMutating, @@ -248,7 +261,54 @@ export default function ConsentRegistryTable({
)) - : groupedRows.map((group) => ( + : null} + {!isLoading && isError ? ( + + + + + + + ) : null} + {!isLoading && !isError && groupedRows.length === 0 ? ( + + + + + + + ) : null} + {!isLoading && !isError && groupedRows.length > 0 + ? groupedRows.map((group) => ( ))} - ))} + )) + : null} diff --git a/portal/frontend/src/i18n/resources/en/common.ts b/portal/frontend/src/i18n/resources/en/common.ts index b3cff53e..61d30269 100644 --- a/portal/frontend/src/i18n/resources/en/common.ts +++ b/portal/frontend/src/i18n/resources/en/common.ts @@ -213,6 +213,7 @@ const commonEn = { messages: { loading: 'Loading consents...', loadFailed: 'Unable to load consents right now.', + emptyTitle: 'No consents found', empty: 'No consents found for the selected filters.', }, table: { @@ -315,6 +316,7 @@ const commonEn = { activeFilters: 'Active filters', back: 'Back to elements', createTitle: 'Create element', + detailsSection: 'Element details', empty: 'No elements match the selected filters.', emptyTitle: 'No elements found', filterTitle: 'Filter elements', diff --git a/portal/frontend/src/types/catalog.ts b/portal/frontend/src/types/catalog.ts index 547539d0..c91a42b6 100644 --- a/portal/frontend/src/types/catalog.ts +++ b/portal/frontend/src/types/catalog.ts @@ -74,18 +74,21 @@ export interface ElementVersionCreateRequest { } export interface ElementBulkCreateResult { - index: number + index?: number status: 'SUCCESS' | 'FAILED' data?: ElementVersion - error?: { - code: string - message: string - description?: string - } + element?: ElementVersion + error?: + | string + | { + code?: string + message?: string + description?: string + } } export interface ElementBulkCreateResponse { - metadata: { + metadata?: { traceId: string total: number succeeded: number From e6ab2a539e733fadddaf31a2756e4da257e94f4a Mon Sep 17 00:00:00 2001 From: Rashmitha Chamikara Date: Fri, 31 Jul 2026 17:00:58 +0530 Subject: [PATCH 15/19] Fix: unhandled teardown errors --- portal/frontend/src/__tests__/AppSidebar.test.tsx | 8 ++++++-- .../frontend/src/__tests__/ConsentRegistryModals.test.tsx | 3 ++- portal/frontend/src/__tests__/PurposeFormDialog.test.tsx | 8 ++++++-- portal/frontend/src/__tests__/smoke.test.tsx | 8 ++++++-- 4 files changed, 20 insertions(+), 7 deletions(-) diff --git a/portal/frontend/src/__tests__/AppSidebar.test.tsx b/portal/frontend/src/__tests__/AppSidebar.test.tsx index ec0f05d6..bfb808ff 100644 --- a/portal/frontend/src/__tests__/AppSidebar.test.tsx +++ b/portal/frontend/src/__tests__/AppSidebar.test.tsx @@ -16,10 +16,10 @@ * under the License. */ -import { fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { I18nextProvider } from 'react-i18next' import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import { AcrylicOrangeTheme, CssBaseline, OxygenUIThemeProvider } from '@wso2/oxygen-ui' import AppSidebar from '../components/layout/sidebar/AppSidebar' import i18n from '../i18n/i18n' @@ -30,6 +30,10 @@ function LocationProbe(): React.JSX.Element { return

{`${location.pathname}${location.search}`}

} +afterEach(() => { + cleanup() +}) + describe('AppSidebar', () => { it('renders navigation items and navigates to dashboard and pending consents', () => { render( diff --git a/portal/frontend/src/__tests__/ConsentRegistryModals.test.tsx b/portal/frontend/src/__tests__/ConsentRegistryModals.test.tsx index 184aadb7..68661e4f 100644 --- a/portal/frontend/src/__tests__/ConsentRegistryModals.test.tsx +++ b/portal/frontend/src/__tests__/ConsentRegistryModals.test.tsx @@ -16,7 +16,7 @@ * under the License. */ -import { fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { I18nextProvider } from 'react-i18next' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { OxygenTheme, OxygenUIThemeProvider } from '@wso2/oxygen-ui' @@ -38,6 +38,7 @@ beforeEach(() => { }) afterEach(() => { + cleanup() vi.runOnlyPendingTimers() vi.useRealTimers() }) diff --git a/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx b/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx index 518e3394..a0ee995b 100644 --- a/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx +++ b/portal/frontend/src/__tests__/PurposeFormDialog.test.tsx @@ -3,10 +3,10 @@ * Licensed under the Apache License, Version 2.0. */ -import { fireEvent, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { createElement, type ComponentProps } from 'react' import { I18nextProvider } from 'react-i18next' -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { OxygenTheme, OxygenUIThemeProvider } from '@wso2/oxygen-ui' import PurposeFormDialog from '../features/catalog/components/PurposeFormDialog' import i18n from '../i18n/i18n' @@ -66,6 +66,10 @@ function renderDialog(overrides: Partial { + cleanup() +}) + describe('purpose form dialog', () => { it('uses the compact layout and creates an organization-wide purpose without a group ID', () => { const onCreate = vi.fn() diff --git a/portal/frontend/src/__tests__/smoke.test.tsx b/portal/frontend/src/__tests__/smoke.test.tsx index 6d41246e..e7630722 100644 --- a/portal/frontend/src/__tests__/smoke.test.tsx +++ b/portal/frontend/src/__tests__/smoke.test.tsx @@ -16,9 +16,9 @@ * under the License. */ -import { render, screen } from '@testing-library/react' +import { cleanup, render, screen } from '@testing-library/react' import { I18nextProvider, useTranslation } from 'react-i18next' -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' import i18n from '../i18n/i18n' function I18nPlaceholder(): React.JSX.Element { @@ -33,6 +33,10 @@ function I18nMissingKeyPlaceholder(): React.JSX.Element { return

{t('app.missingTitle')}

} +afterEach(() => { + cleanup() +}) + describe('app i18n setup', () => { it('renders translated heading text with i18n provider', () => { render( From e0dae2f43c691764a5d206e0035710b34a00de15 Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Fri, 31 Jul 2026 21:56:24 +0530 Subject: [PATCH 16/19] Apply suggested fixes - Fix license headers - Fix documented default org id to ORG-001 --- portal/frontend/.env.example | 2 +- portal/frontend/README.md | 2 +- .../src/features/catalog/ElementDetailsPage.tsx | 17 +++++++++++++++-- .../src/features/catalog/ElementListPage.tsx | 17 +++++++++++++++-- .../src/features/catalog/PurposeDetailsPage.tsx | 17 +++++++++++++++-- .../src/features/catalog/PurposeListPage.tsx | 17 +++++++++++++++-- .../src/features/catalog/api/catalogApi.ts | 17 +++++++++++++++-- .../catalog/components/CatalogDetailCards.tsx | 17 +++++++++++++++-- .../catalog/components/DeleteVersionDialog.tsx | 17 +++++++++++++++-- .../catalog/components/ElementFormDialog.tsx | 17 +++++++++++++++-- .../catalog/components/ElementVersionSelect.tsx | 17 +++++++++++++++-- .../catalog/components/PropertyEditor.tsx | 17 +++++++++++++++-- .../catalog/components/PurposeFormDialog.tsx | 17 +++++++++++++++-- .../features/catalog/hooks/useCatalogQueries.ts | 17 +++++++++++++++-- .../catalog/utils/elementTypePresentation.ts | 17 +++++++++++++++-- .../features/catalog/utils/formProperties.ts | 17 +++++++++++++++-- portal/frontend/src/types/catalog.ts | 17 +++++++++++++++-- 17 files changed, 227 insertions(+), 32 deletions(-) diff --git a/portal/frontend/.env.example b/portal/frontend/.env.example index 597e9f85..ff0529f8 100644 --- a/portal/frontend/.env.example +++ b/portal/frontend/.env.example @@ -1,5 +1,5 @@ VITE_API_BASE_URL=http://localhost:8080 -VITE_ORG_ID=openfgc-perf-org +VITE_ORG_ID=ORG-001 VITE_AUTH_ENABLED=false VITE_AUTH_ACCESS_TOKEN_PART1_COOKIE=portal-at-p1 VITE_AUTH_REFRESH_TOKEN_PART1_COOKIE=portal-rt-p1 diff --git a/portal/frontend/README.md b/portal/frontend/README.md index 4ed8926d..449d5216 100644 --- a/portal/frontend/README.md +++ b/portal/frontend/README.md @@ -52,7 +52,7 @@ Create a local `.env` file from `.env.example` before running or building the po | Variable | Description | Example | | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | ------------------------- | | `VITE_API_BASE_URL` | Required base URL for the OpenFGC Portal backend API. Vite embeds this value at build time. | `http://localhost:8080` | -| `VITE_ORG_ID` | Organization ID used to identify and filter organization-wide consent purposes. | `openfgc-perf-org` | +| `VITE_ORG_ID` | Organization ID used to identify and filter organization-wide consent purposes. | `ORG-001` | | `VITE_AUTH_ENABLED` | Enables frontend authentication gating; set to `true` for protected deployments. | `true` | | `VITE_AUTH_ACCESS_TOKEN_PART1_COOKIE` | Cookie name for the readable access-token part. | `portal-at-p1` | | `VITE_AUTH_REFRESH_TOKEN_PART1_COOKIE` | Cookie name for the readable refresh-token part. | `portal-rt-p1` | diff --git a/portal/frontend/src/features/catalog/ElementDetailsPage.tsx b/portal/frontend/src/features/catalog/ElementDetailsPage.tsx index fbcac78b..900c2caa 100644 --- a/portal/frontend/src/features/catalog/ElementDetailsPage.tsx +++ b/portal/frontend/src/features/catalog/ElementDetailsPage.tsx @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { diff --git a/portal/frontend/src/features/catalog/ElementListPage.tsx b/portal/frontend/src/features/catalog/ElementListPage.tsx index 17b6552f..fc1c5d4f 100644 --- a/portal/frontend/src/features/catalog/ElementListPage.tsx +++ b/portal/frontend/src/features/catalog/ElementListPage.tsx @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { diff --git a/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx b/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx index 6926f393..2c9fb34a 100644 --- a/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx +++ b/portal/frontend/src/features/catalog/PurposeDetailsPage.tsx @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { diff --git a/portal/frontend/src/features/catalog/PurposeListPage.tsx b/portal/frontend/src/features/catalog/PurposeListPage.tsx index 5b943c9b..3958ea84 100644 --- a/portal/frontend/src/features/catalog/PurposeListPage.tsx +++ b/portal/frontend/src/features/catalog/PurposeListPage.tsx @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { diff --git a/portal/frontend/src/features/catalog/api/catalogApi.ts b/portal/frontend/src/features/catalog/api/catalogApi.ts index 5b82d2ff..f8600772 100644 --- a/portal/frontend/src/features/catalog/api/catalogApi.ts +++ b/portal/frontend/src/features/catalog/api/catalogApi.ts @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import type { diff --git a/portal/frontend/src/features/catalog/components/CatalogDetailCards.tsx b/portal/frontend/src/features/catalog/components/CatalogDetailCards.tsx index 74427c23..55d1e432 100644 --- a/portal/frontend/src/features/catalog/components/CatalogDetailCards.tsx +++ b/portal/frontend/src/features/catalog/components/CatalogDetailCards.tsx @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { Box, Card, CardContent, CardHeader, Divider, Stack, Typography } from '@wso2/oxygen-ui' diff --git a/portal/frontend/src/features/catalog/components/DeleteVersionDialog.tsx b/portal/frontend/src/features/catalog/components/DeleteVersionDialog.tsx index 3c10be95..f20d8f60 100644 --- a/portal/frontend/src/features/catalog/components/DeleteVersionDialog.tsx +++ b/portal/frontend/src/features/catalog/components/DeleteVersionDialog.tsx @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { diff --git a/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx b/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx index feecca56..d2cf8732 100644 --- a/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx +++ b/portal/frontend/src/features/catalog/components/ElementFormDialog.tsx @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { diff --git a/portal/frontend/src/features/catalog/components/ElementVersionSelect.tsx b/portal/frontend/src/features/catalog/components/ElementVersionSelect.tsx index ff0967d8..c7f98a1d 100644 --- a/portal/frontend/src/features/catalog/components/ElementVersionSelect.tsx +++ b/portal/frontend/src/features/catalog/components/ElementVersionSelect.tsx @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { Box, IconButton, MenuItem, Skeleton, TextField, Tooltip } from '@wso2/oxygen-ui' diff --git a/portal/frontend/src/features/catalog/components/PropertyEditor.tsx b/portal/frontend/src/features/catalog/components/PropertyEditor.tsx index 9a2ed95a..2f02c96c 100644 --- a/portal/frontend/src/features/catalog/components/PropertyEditor.tsx +++ b/portal/frontend/src/features/catalog/components/PropertyEditor.tsx @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { Box, Button, IconButton, Stack, TextField, Typography } from '@wso2/oxygen-ui' diff --git a/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx b/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx index 4f04bc4c..73a146f0 100644 --- a/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx +++ b/portal/frontend/src/features/catalog/components/PurposeFormDialog.tsx @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { diff --git a/portal/frontend/src/features/catalog/hooks/useCatalogQueries.ts b/portal/frontend/src/features/catalog/hooks/useCatalogQueries.ts index 6319c261..e8e261d2 100644 --- a/portal/frontend/src/features/catalog/hooks/useCatalogQueries.ts +++ b/portal/frontend/src/features/catalog/hooks/useCatalogQueries.ts @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { diff --git a/portal/frontend/src/features/catalog/utils/elementTypePresentation.ts b/portal/frontend/src/features/catalog/utils/elementTypePresentation.ts index 72ca7508..de186067 100644 --- a/portal/frontend/src/features/catalog/utils/elementTypePresentation.ts +++ b/portal/frontend/src/features/catalog/utils/elementTypePresentation.ts @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ import { Braces, CodeXml, Type as TypeIcon } from '@wso2/oxygen-ui-icons-react' diff --git a/portal/frontend/src/features/catalog/utils/formProperties.ts b/portal/frontend/src/features/catalog/utils/formProperties.ts index 3a7282dc..0dc2ad31 100644 --- a/portal/frontend/src/features/catalog/utils/formProperties.ts +++ b/portal/frontend/src/features/catalog/utils/formProperties.ts @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ export interface PropertyEntry { diff --git a/portal/frontend/src/types/catalog.ts b/portal/frontend/src/types/catalog.ts index c91a42b6..6ef04f0d 100644 --- a/portal/frontend/src/types/catalog.ts +++ b/portal/frontend/src/types/catalog.ts @@ -1,6 +1,19 @@ /* - * Copyright (c) 2026, WSO2 LLC. (https://wso2.com). - * Licensed under the Apache License, Version 2.0. + * Copyright (c) 2026, WSO2 LLC. (https://www.wso2.com). + * + * WSO2 LLC. licenses this file to you under the Apache License, + * Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. */ export type ElementType = 'basic' | 'json' | 'xml' From f7914cb78899f5f128df34ac3469540ed6b39e15 Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Fri, 31 Jul 2026 23:56:34 +0530 Subject: [PATCH 17/19] Update portal documentation --- portal/README.md | 47 ++ portal/backend/README.md | 26 +- portal/backend/docs/README.md | 155 ++++++ portal/bff-implementation-phases.md | 67 --- portal/bff-implementation-plan.md | 736 ---------------------------- portal/frontend/README.md | 43 +- 6 files changed, 257 insertions(+), 817 deletions(-) create mode 100644 portal/README.md create mode 100644 portal/backend/docs/README.md delete mode 100644 portal/bff-implementation-phases.md delete mode 100644 portal/bff-implementation-plan.md diff --git a/portal/README.md b/portal/README.md new file mode 100644 index 00000000..52a2c3cf --- /dev/null +++ b/portal/README.md @@ -0,0 +1,47 @@ + + +# OpenFGC Portal + +OpenFGC Portal is a user interface for managing consents through the OpenFGC Consent Management API. + +## Architecture + +The React frontend calls the Go Portal Backend, which authenticates users through an OpenID Connect identity provider, enforces authorization, and forwards allowlisted requests to OpenFGC. + +```text +React frontend ──► Portal Backend ──► OpenFGC + │ + └────────► OIDC identity provider +``` + +The browser never stores the OIDC client secret or calls OpenFGC directly. + +## Quickstart + +1. [Start the OpenFGC consent server](../README.md#quick-start). +2. [Configure and start the Portal Backend](backend/README.md#quickstart). +3. [Configure and start the Portal Frontend](frontend/README.md#quickstart). +4. Open `http://localhost:5173`. + +## Components + +- [Portal Frontend development and configuration](frontend/README.md) +- [Portal Backend development and configuration](backend/README.md) + +For authentication flows, authorization, routing, security, and deployment details, see the [portal architecture and operations guide](backend/docs/README.md). diff --git a/portal/backend/README.md b/portal/backend/README.md index 7e459da7..a7091320 100644 --- a/portal/backend/README.md +++ b/portal/backend/README.md @@ -16,9 +16,29 @@ * under the License. --> -# OpenFGC Portal Backend (BFF) +# OpenFGC Portal Backend -Stateless backend-for-frontend (BFF) for OpenFGC Portal, responsible for handling portal-facing authentication flows and securely proxying API requests from `portal/frontend` to `/consent-server`. +The OpenFGC Portal Backend handles portal-facing authentication flows and securely proxies allowlisted API requests from `portal/frontend` to OpenFGC. + +## Quickstart + +Prerequisites: + +- Go 1.26.3 +- [Task](https://taskfile.dev/installation/) +- A running OpenFGC server + +From `portal/backend`: + +1. Copy `.env.example` to `.env` and set `BFF_PROXY__OPENFGC_API_URL` for your OpenFGC server. +2. To access protected routes locally without an identity provider, enable placeholder mode and set non-empty placeholder user and organization IDs in `.env`. +3. Start the Portal Backend: + + ```shell + task run:env + ``` + +The server listens on `http://localhost:8080` by default. Verify it with `GET /health`. ## Commands @@ -63,7 +83,7 @@ Portal-facing user endpoints: - `GET /me/consents` -> upstream `GET /api/v1/consents` with forced `userIds=` and `details=true` - `GET /me/consents/{consentId}` -> one upstream `GET /api/v1/consents/{consentId}?details=true`; the detailed consent snapshot is returned unchanged -- `POST /me/consents/{consentId}/approve` -> BFF fetches the current consent with `details=true`, validates selected optional elements against its embedded stable IDs and versions, automatically approves mandatory elements, preserves existing approvals, and sends a full upstream consent update with the consent's immutable `groupId` as the trusted `group-id` header +- `POST /me/consents/{consentId}/approve` -> the Portal Backend fetches the current consent with `details=true`, validates selected optional elements against its embedded stable IDs and versions, automatically approves mandatory elements, preserves existing approvals, and sends a full upstream consent update with the consent's immutable `groupId` as the trusted `group-id` header - `POST /me/consents/{consentId}/revoke` -> upstream `POST /api/v1/consents/{consentId}/revoke` Proxy hardening: diff --git a/portal/backend/docs/README.md b/portal/backend/docs/README.md new file mode 100644 index 00000000..f1db9eee --- /dev/null +++ b/portal/backend/docs/README.md @@ -0,0 +1,155 @@ + + +# Portal Architecture and Operations + +This guide describes the implemented OpenFGC Portal architecture. For commands and component-specific setup, use the [backend README](../README.md) and [frontend README](../../frontend/README.md). The complete HTTP contract is defined in the [OpenAPI specification](../openapi/portal-backend.yaml). + +## Architecture + +```text +React frontend ─────► Portal Backend ─────► OpenFGC + │ + └──── OIDC discovery and flows ────► Identity provider +``` + +### Responsibilities + +| Component | Responsibility | +| --- | --- | +| React frontend | Starts login and logout, sends the readable access-token half, refreshes expired sessions, and renders identity claims for display. It does not make authorization decisions. | +| Portal Backend | Acts as the OIDC confidential client and protected API boundary. It validates tokens, creates the trusted principal, enforces route policy, and maps allowed requests to OpenFGC. | +| Identity provider | Authenticates users, exposes OIDC discovery and JWKS metadata, and issues signed ID, access, and refresh tokens. | +| OpenFGC | Performs consent operations behind the Portal Backend. It receives identity and organization context derived by the Portal Backend. | + +The Portal Backend is stateless: login correlation is held in short-lived browser cookies, and authenticated request context is reconstructed from validated tokens. OpenFGC should not be exposed directly to browsers or the public network. + +## Authentication + +Authentication is enabled with `BFF_AUTH__ENABLED=true`. The Portal Backend performs OIDC discovery during startup and uses Authorization Code flow as a confidential client. + +### Login + +1. The frontend navigates to `GET /auth/login`. +2. The Portal Backend generates random OAuth state and a PKCE verifier, stores each in a short-lived `HttpOnly` cookie, and redirects to the identity provider with an S256 code challenge. +3. The identity provider redirects to `GET /auth/callback` with one authorization code and state value. +4. The Portal Backend consumes the transaction cookies, compares state, validates the verifier, and exchanges the code using the client credentials and PKCE verifier. +5. The returned access token is validated for the configured API audience; the ID token is validated for the OIDC client. Both use issuer discovery and JWKS verification. +6. The Portal Backend issues split-token cookies and redirects to `BFF_AUTH__PORTAL_URL`. + +Invalid, incomplete, duplicate, or replayed callback data is rejected with a generic login failure. Transaction cookies are cleared before the code exchange so they cannot be reused. + +### Split-token session + +Tokens are divided at a deterministic midpoint and constrained by configured per-part and reconstructed-token size limits. + +| Token | JavaScript-readable portion | `HttpOnly` portion | Use | +| --- | --- | --- | --- | +| Access token | Part 1 cookie | Part 2 cookie | Part 1 is sent as `Authorization: Bearer `; the browser sends part 2 with credentials. | +| Refresh token | Part 1 cookie | Part 2 cookie | Part 1 is submitted in the form body to `/auth/refresh`; the browser sends part 2 with credentials. | +| ID token | Parts 1 and 2 | None | The frontend reconstructs and decodes it for profile display only. | + +For each protected request, the Portal Backend requires exactly one partial Bearer value and exactly one access-token part-2 cookie. It reconstructs the JWT, verifies its signature, issuer, resource audience, expiry, not-before claim when present, subject, organization claim, and configured token type when enabled. The validated identity is normalized into a [`Principal`](../internal/system/context/user.go) containing `UserID`, `OrgID`, and `Scopes`, then stored in request context. Downstream authorization and proxy code use this principal rather than raw token claims. + +The split-token design prevents JavaScript from reading a complete access or refresh token, but it does not eliminate the impact of same-origin script injection. The frontend therefore emits a restrictive Content Security Policy and performs production security checks as part of its build workflow. + +### Refresh and request retry + +`POST /auth/refresh` accepts only `application/x-www-form-urlencoded` with one `refresh_token` field containing refresh-token part 1. Cookie-only refresh is rejected. The Portal Backend reconstructs the refresh token, exchanges it at the identity provider, validates the returned access token and optional ID token, and returns `204 No Content` after replacing the applicable cookies. A rotated refresh token replaces both refresh cookies; otherwise the existing refresh cookies remain in use. Identity-provider exchange or returned-token validation failures clear the token cookies. + +The frontend deduplicates concurrent refresh attempts within the current browser runtime. After a protected request returns `401`, it performs at most one refresh and retries that request once. A failed refresh starts login. A `403` is treated as an authorization failure and does not trigger refresh. + +### Logout + +`POST /auth/logout` is protected by the same split access-token authentication as other protected routes. It clears all local token cookies and returns a logout URL. When OIDC discovery provides an end-session endpoint, the Portal Backend uses it with the configured post-logout redirect and includes the reconstructed ID token only if it validates successfully. The frontend navigates only to an explicitly allowed HTTP or HTTPS origin. + +## Routes and Authorization + +### Public and authentication routes + +| Routes | Access | +| --- | --- | +| `GET /health`, `/health/liveness`, `/health/readiness` | Public | +| `GET /auth/login`, `GET /auth/callback`, `POST /auth/refresh` | Public protocol endpoints; login and refresh still require their corresponding split transaction or token material. | +| `POST /auth/logout` | Authenticated | + +### Current-user consent routes + +| Routes | Required scope | +| --- | --- | +| `GET /me/consents`, `GET /me/consents/{consentId}` | `portal:consents:read:self` | +| `POST /me/consents/{consentId}/approve` | `portal:consents:write:self` | +| `POST /me/consents/{consentId}/reject` | `portal:consents:write:self` | +| `POST /me/consents/{consentId}/revoke` | `portal:consents:write:self` | + +The Portal Backend replaces browser-supplied user filters with the validated subject. Object routes fetch the consent and verify ownership before returning details or applying a mutation. Approval and rejection payloads are built from the current consent snapshot, and trusted `actionBy` and `group-id` values are derived by the Portal Backend rather than accepted from the browser. + +### Controlled API passthrough + +`/api/*` is a deny-by-default passthrough to OpenFGC `/api/v1/*`. Unknown paths return `404`; unsupported methods on known paths return `405`. The allowlist covers consent, consent-authorization, consent-element, and consent-purpose operations documented in OpenAPI. The canonical portal scope names and method/path-to-scope policies are defined in [`scopes.go`](../internal/system/auth/scopes.go). + +| Operation family | Read scope | Write scope | +| --- | --- | --- | +| Consents and authorizations | `portal:consents:read:any` | `portal:consents:write:any` | +| Consent elements | `portal:elements:read` | `portal:elements:write` | +| Consent purposes | `portal:purposes:read` | `portal:purposes:write` | + +Authentication failures return `401` with `WWW-Authenticate: Bearer`. An authenticated principal without the required scope receives `403` without a bearer challenge. + +### Trusted upstream context + +Before forwarding, the Portal Backend removes hop-by-hop headers and browser-supplied identity headers. It sets `org-id` from the validated principal, adds a consent-bound `group-id` only for self-service mutations that require it, and propagates or generates `X-Correlation-ID`. The raw access token is not forwarded to OpenFGC. + +## Configuration and Runtime + +The Portal Backend loads configuration with the following precedence: + +```text +defaults < optional YAML file < BFF_ environment variables +``` + +Set the optional file with `BFF_CONFIG_FILE`. `BFF_AUTH__CLIENT_SECRET` is intentionally read only from the process environment and is not loaded from YAML. The complete development contract and defaults are in [`.env.example`](../.env.example). + +Configuration is grouped into: + +- `BFF_SERVER__*`: listener and server timeouts. +- `BFF_LOG__*`: log level. +- `BFF_CORS__*`: exact browser origins, methods, headers, and credential support. +- `BFF_AUTH__*`: OIDC client, token validation, claims, timeouts, cookies, and token size limits. +- `BFF_PROXY__*`: OpenFGC URL, request and response limits, timeout, passthrough methods, and local placeholder identity. + +Production startup requires authentication, HTTPS authentication URLs, and secure cookies. Placeholder identity cannot run in production and cannot be enabled together with OIDC authentication. In permitted local environments, placeholder mode supplies a configured user and organization with all portal scopes for development without an identity provider. + +### Local topology + +| Service | Typical local address | +| --- | --- | +| React development server | `http://localhost:5173` | +| Portal Backend | `http://localhost:8080` | +| OpenFGC | `http://localhost:8060` when configured through `.env.example` | +| Identity provider | Deployment-specific `BFF_AUTH__ISSUER_URL` | + +When the frontend and Portal Backend use different origins, requests include credentials and the frontend origin must be explicitly listed in `BFF_CORS__ALLOWED_ORIGINS`. Credentialed CORS does not allow a wildcard origin. Matching frontend and backend cookie-name configuration is required. + +The backend Docker Compose file builds and publishes the Portal Backend on port 8080. OpenFGC and the identity provider must be started or supplied separately. + +## Testing + +Backend unit and integration tests cover configuration guardrails, OIDC transactions, token splitting and validation, cookie handling, authorization scopes, CORS, current-user ownership, proxy mappings, header sanitization, limits, and upstream failures. Frontend tests cover the auth client, refresh retry behavior, profile rendering, API integration, and production CSP generation. + +Use the commands in the [backend README](../README.md) and [frontend README](../../frontend/README.md) to run their respective checks. Changes to routes or scopes must also update and validate the [OpenAPI specification](../openapi/portal-backend.yaml). diff --git a/portal/bff-implementation-phases.md b/portal/bff-implementation-phases.md deleted file mode 100644 index c3b4d76e..00000000 --- a/portal/bff-implementation-phases.md +++ /dev/null @@ -1,67 +0,0 @@ -## Plan: Phased BFF Implementation - -The phases below include your requirement that AI guidance files are created in Phase 1, while deployment happens only after all phases complete. - -**Phase 1: Bootstrap, standards, and tooling (blocks all later phases)** -Goal: establish a runnable project foundation with quality controls. - -1. Create BFF project skeleton and core package layout. -2. Add startup, health endpoint, config loader, logging, router wiring, and graceful shutdown. -3. Set up linting and code quality checks in local scripts and CI. -4. Add AI guidance artifacts as mandatory project assets: - - AGENTS.md - - .github/copilot-instructions.md -5. Add initial test scaffolding for unit and integration tests. -6. Add initial OpenAPI contract placeholders for BFF routes. - -Exit criteria: -1. Service starts and health endpoint returns success. -2. Lint, format, and baseline tests pass in CI. -3. AI guidance files exist and are referenced in contributor docs. - -**Phase 2: Proxy layer with config-driven identity placeholders (depends on Phase 1)** -Goal: validate proxy behavior independently from auth. - -1. Implement proxy path mapping, method/path allowlist, timeout policy, and body limits. -2. Introduce portal user endpoints in proxy mode (for example, `/me/consents`, `/me/consents/{consentId}`, `/me/consents/{consentId}/approve`, `/me/consents/{consentId}/revoke`) with explicit route mappings to upstream `/api/v1/*` contracts. -3. Add secure header handling: - - strip hop-by-hop headers - - ignore client-supplied trusted headers - - propagate/generate correlation id -4. Inject currently unavailable auth-dependent values from config for test mode. -5. Add hard guardrails so placeholder mode cannot run in production. -6. Add integration tests for rewrite logic, header safety, query preservation, error mapping, and portal user endpoint route mappings in placeholder mode. -7. Defer user ownership enforcement and principal-derived scoping for `/me/*` endpoints to Phase 3 when auth/session context is available. - -Exit criteria: -1. Proxy behavior is fully testable without auth integration. -2. Security checks on headers and route allowlisting pass. -3. Portal user endpoint mappings are implemented and validated in placeholder mode. -4. Placeholder identity mode is explicitly restricted to non-production use. - -**Phase 3: Authentication and session implementation (depends on Phase 2)** -Goal: replace placeholders with real identity from auth context. - -1. Implement login, callback, frontend ID-token profile decoding, refresh, and logout flows. Do not add `GET /auth/me`. -2. Implement the split-cookie security model; this stage uses possession of both split parts instead of a separate CSRF token. -3. Add auth/session middleware and context propagation. -4. Replace config-based identity injection with validated principal-derived values. -5. Add mock-IdP auth lifecycle tests and authenticated proxy integration tests. - -Exit criteria: -1. Full authenticated request lifecycle works end to end. -2. Refresh/logout error mappings and cookie behaviors match spec. -3. Config placeholder mode is off by default and not used in normal runtime. - -**Phase 4: Hardening and release readiness (depends on Phase 3)** -Goal: finalize operational safety and production readiness. - -1. Validate key rotation, session cookie size limits, refresh race behavior, and timeout handling. -2. Complete deployment and operations documentation. -3. Run full regression suite in local compose and staging-like setup. -4. Finalize release packaging and CI gates. - -Exit criteria: -1. All tests and quality gates are green. -2. Security and operational checks are complete. -3. Release artifact is ready. diff --git a/portal/bff-implementation-plan.md b/portal/bff-implementation-plan.md deleted file mode 100644 index 6784d430..00000000 --- a/portal/bff-implementation-plan.md +++ /dev/null @@ -1,736 +0,0 @@ -# Portal BFF Implementation Plan -**Confidential OIDC BFF · Split-Token Cookies · JWKS Validation · OpenFGC Integration** - ---- - -## 1. Purpose and Architecture - -The portal backend is both an OIDC confidential client and the protected API boundary. It performs login, authorization-code exchange, token refresh, logout, and JWT validation without a frontend IdP SDK. Access and refresh tokens use split-token transport: JavaScript sends the readable first half while the browser supplies the secure HTTP-only second half. The BFF reconstructs and validates the complete access token on every protected request before deriving identity or authorization context. - -``` -┌────────────────────────────────────────────────────┐ -│ React Portal │ -│ - No IdP SDK or client secret │ -│ - Starts login through BFF auth endpoints │ -│ - Sends Authorization: Bearer │ -└───────────────────────────┬────────────────────────┘ - │ HTTPS - ▼ -┌──────────────────────────────────────────────────────┐ -│ Portal Backend / BFF (Go, port 8080) │ -│ - OIDC confidential client and OAuth resource server │ -│ - Reconstructs tokens from request + HTTP-only cookie│ -│ - Validates ID/access tokens using IdP JWKS │ -│ - Enforces route, scope, organization, and ownership │ -│ - Maps approved requests to OpenFGC │ -└───────────────────────────┬──────────────────────────┘ - │ private network - ▼ -┌──────────────────────────────────────────────────────┐ -│ OpenFGC server (port 8060) │ -│ - Internal downstream API │ -│ - Receives only BFF-derived trusted context headers │ -└──────────────────────────────────────────────────────┘ - - ┌──────────────────────────────────────┐ - │ IdP │ - │ Authorization server / OIDC provider │ - └──────────────────────────────────────┘ -``` - -### Responsibilities - -| Component | Responsibilities | Must not do | -|---|---|---| -| React Portal | Start BFF login/logout, read token part 1, attach it as a partial Bearer value, call refresh, and decode the ID token for profile display | Use an IdP SDK, hold a client secret, reconstruct complete access/refresh tokens, or make authorization decisions from UI data | -| Portal BFF | Act as the OIDC confidential client; exchange codes; issue, reconstruct, refresh, and clear split-token cookies; validate JWTs; derive principal; authorize requests; protect OpenFGC boundary | Expose complete access/refresh tokens to JavaScript or trust token claims before JWKS validation | -| IdP | Authenticate users and issue tokens | Be bypassed by the portal or BFF | -| OpenFGC | Execute internal consent operations | Be reachable directly from the browser/public network | - -### Assumptions - -- The selected IdP issues signed JWT access tokens for the portal API audience and supports OIDC discovery/JWKS. -- The BFF is registered as an OAuth confidential client using Authorization Code flow. The client secret is available only to the BFF. -- The confidential-client flow uses OAuth `state` and S256 PKCE with one-time, short-lived HTTP-only transaction cookies. OIDC `nonce` remains deferred to a separate hardening change. -- The frontend and BFF are deployed on the same host in production, normally behind one ingress, so the host-only part-1 cookies are readable by the portal. If they use different origins on that host, CORS credentials and exact origin allowlisting are required. -- The validated token `sub` is the same identifier OpenFGC uses in `userIds`. -- The BFF and OpenFGC communicate on a private network. OpenFGC is available at `http://localhost:8060` in local development. -- OpenFGC trusts BFF-derived identity/context headers. If OpenFGC later validates user access tokens itself, that is a separate downstream-authentication design decision. - ---- - -## 2. Core Design Decisions - -| Decision | Rationale | -|---|---| -| BFF performs OIDC as a confidential client | Keeps the client secret and token endpoint interaction outside the browser and removes the frontend IdP SDK dependency. | -| Stateless login correlation and S256 PKCE | Binds each callback to one initiating browser using one-time HTTP-only state/verifier cookies without a server session, shared store, encryption key, or signing key. | -| Split-token transport | A complete access or refresh token is not JavaScript-readable; the readable half alone is unusable away from the browser session. | -| Partial Bearer header plus HTTP-only cookie | The frontend sends access-token part 1 as `Authorization: Bearer ` and the browser supplies part 2 as a secure HTTP-only cookie. | -| BFF remains a JWT resource server | Reconstructing a token is transport handling only; the BFF still validates it through issuer discovery/JWKS on every protected request. | -| Refresh token split across request body and cookie | Refresh-token part 1 is posted to `/auth/refresh`; part 2 remains HTTP-only and is never returned in a response body. | -| JavaScript-readable ID token | The frontend may decode the ID token for display/profile data, but neither the frontend nor BFF uses those browser-decoded claims for authorization. | -| JWT validation through issuer discovery/JWKS | Validates ID and access tokens from the configured IdP while supporting signing-key rotation. | -| Explicit claim-to-principal mapping | Prevents business authorization from depending on unstructured token payloads. | -| Canonical scope registry | `internal/system/auth/scopes.go` is the single runtime source of truth for portal scope names; route policies, configuration validation, and tests reference its typed constants. | -| Allowlisted BFF routes | Prevents an unrestricted proxy to OpenFGC. | -| BFF-derived upstream headers only | Browser-provided identity, organization, and client headers are never trusted. | -| OpenFGC private-only exposure | Makes the BFF the mandatory policy-enforcement point. | - ---- - -## 3. Authentication and Request Flows - -### 3.1 Frontend Authentication Contract - -The frontend uses a small portal-owned auth client, not an IdP SDK. It knows only the BFF auth routes, the readable cookie names, and the partial-token transport contract. - -``` -PortalAuthClient - - isAuthenticated() - - getUserProfile() // reconstruct and decode ID-token parts locally - - login() // navigate to /auth/login - - refresh() // POST refresh-token part 1 - - logout(returnTo?) // POST /auth/logout - - attachAccessTokenPart1(request) -``` - -Portal components must not parse token claims for authorization. The API client reads access-token part 1 from its secure, JavaScript-readable cookie and sends `Authorization: Bearer `. It must use credentials-enabled requests so the browser also sends the HTTP-only part-2 cookie. - -### 3.2 Login and Callback - -``` -1. The frontend navigates to `GET /auth/login`. -2. The BFF generates cryptographically random OAuth state and PKCE verifier values, stores them in short-lived HTTP-only cookies, and redirects to the IdP with the state and S256 code challenge. -3. The user authenticates at the IdP. -4. The IdP redirects to `GET /auth/callback?code=...&state=...`. -5. The BFF consumes the transaction cookies, validates the returned state using constant-time comparison, and exchanges the code with the matching PKCE verifier and its confidential-client authentication method. -6. The BFF validates the ID token through discovery/JWKS, including signature, allowed algorithm, - issuer, client audience, and expiry. OIDC nonce validation remains deferred. -7. The BFF validates the access token through discovery/JWKS, splits the returned tokens, sets the - token cookies, and redirects to the configured portal URL. -``` - -The BFF must reject missing or duplicate codes/state values, missing or duplicate transaction cookies, state mismatch, replayed callbacks, token-exchange failures, and invalid returned tokens. Transaction cookies are consumed before code exchange and never reused. Authorization codes, state, PKCE verifiers, and tokens must never be logged. State and PKCE provide login-CSRF and authorization-code interception protection; OIDC nonce-based ID-token replay correlation remains deferred. - -### 3.3 Token Cookie and Transport Contract - -Tokens are split at a deterministic midpoint and reconstructed only in BFF auth middleware/services. Cookie names are configurable; the logical defaults below describe the contract. - -| Token | Part 1 | Part 2 | Request transport | -|---|---|---|---| -| Access token | JavaScript-readable secure cookie `__Host-portal-at-p1` | Secure HTTP-only cookie `__Host-portal-at-p2` | Part 1 in `Authorization: Bearer `; part 2 automatically attached as a cookie | -| Refresh token | JavaScript-readable secure cookie `__Host-portal-rt-p1` | Secure HTTP-only cookie `__Host-portal-rt-p2` | Part 1 in the `POST /auth/refresh` body; part 2 automatically attached as a cookie | -| ID token | JavaScript-readable secure cookie `__Host-portal-id-p1` | JavaScript-readable secure cookie `__Host-portal-id-p2` | Frontend may reconstruct and decode it for display/profile data; the BFF may reconstruct it as an end-session hint only after appropriate validation; never accepted as API credentials | - -Login transactions use two additional configurable, secure HTTP-only cookies: OAuth state and the PKCE verifier. They contain independent cryptographically random values, expire within at most ten minutes, use `SameSite=Lax` by default, and are cleared before callback validation or token exchange. They are not encrypted because they are ephemeral correlation secrets, and tampering can only invalidate the login transaction. No server-side session or BFF-owned cryptographic key is introduced. Production `__Host-` names require `Secure`, no `Domain`, and `Path=/`; local HTTP development may use non-`__Host-` names. A new login in the same browser replaces the previous outstanding transaction, so only the most recently initiated login can complete. - -All production cookies use `Secure`, `Path=/`, no `Domain`, and an explicitly configured `SameSite` value. Access/refresh part-2 cookies use `HttpOnly`; part-1 and both ID-token cookies are JavaScript-readable. Splitting the ID token is for cookie-size handling only—the complete ID token remains available to JavaScript. Cookie expiry must not exceed the corresponding token expiry. Development cookie names may omit the `__Host-` prefix only when local HTTP operation makes secure cookies impossible. - -Each cookie must remain below a conservative browser-compatible size limit. If any token cannot fit within its defined cookie parts, authentication fails with a generic error; the BFF must not silently truncate it. Minimize unnecessary ID-token claims rather than adding unbounded cookie fragments. - -The split limits complete-token exfiltration but does not neutralize XSS: injected same-origin JavaScript can still issue requests while the browser supplies part 2. A restrictive frontend CSP and normal XSS controls remain mandatory. - -### 3.4 Protected BFF Request - -``` -React Portal - Authorization: Bearer - Cookie: __Host-portal-at-p2= - │ - ▼ -Portal BFF - 1. Parse exactly one partial Bearer Authorization header. - 2. Read exactly one configured access-token part-2 cookie. - 3. Enforce per-part and reconstructed-token size limits and concatenate part 1 + part 2. - 4. Obtain verification keys from the pinned issuer discovery/JWKS cache. - 5. Validate the reconstructed JWT signature and registered claims. - 6. Map validated claims to an authenticated principal. - 7. Enforce route-level scope, organization, and ownership policy. - 8. Strip client-supplied trusted headers and inject BFF-derived context. - 9. Map the allowlisted route to OpenFGC. - │ - ▼ -OpenFGC server :8060 -``` - -Missing either half, duplicate cookies/authorization values, malformed parts, or a failed reconstructed-token validation returns `401`. Neither partial nor reconstructed token is logged or placed in request context. - -### 3.5 Token Validation Requirements - -For every protected request, the BFF must validate the reconstructed access token: - -- JWT signature using the configured IdP JWKS and an explicitly allowed signing algorithm. -- `iss` equals `BFF_AUTH__ISSUER_URL`. -- `aud` contains the configured portal API audience. -- `exp`, `nbf` (when present), and `iat` (when required by policy), allowing only configured clock skew. -- A non-empty `sub` claim. -- Token type/purpose only when `BFF_AUTH__REQUIRE_ACCESS_TOKEN_TYPE=true`, so an ID token cannot be accepted as an API access token when the IdP provides a reliable distinguishing claim. -- Required scopes and organization claims for the target route. - -The callback separately validates the ID token against the OIDC client ID; nonce validation remains deferred. The BFF must reject complete browser-supplied tokens, query-string tokens, bearer values without the matching part-2 cookie, unsigned tokens, unsupported algorithms, unknown issuers/audiences, and expired tokens. - -### 3.6 Refresh - -`POST /auth/refresh` accepts refresh-token part 1 in an `application/x-www-form-urlencoded` body and obtains part 2 only from the configured HTTP-only cookie. It reconstructs the refresh token, authenticates the confidential client to the token endpoint, and never returns tokens in its response body. - -On success, the BFF atomically replaces both access-token cookies, both refresh-token cookies when the IdP rotates the refresh token, and both JavaScript-readable ID-token cookies when a new ID token is returned. It clears stale token cookies if refresh fails because the refresh token is invalid or expired. The frontend uses one module-level refresh promise to deduplicate concurrent `401` recovery within one browser runtime. Matching the reference server behavior, this stage adds no BFF-local or distributed refresh lock; cross-tab and cross-replica rotation races remain a documented limitation. - -`/auth/refresh` must reject cookie-only requests. Possession of refresh-token part 1 in the explicit request body, combined with the matching HTTP-only part-2 cookie, is the CSRF defense used by the reference split-cookie design. A cross-site origin cannot read part 1 through the same-origin policy and cannot reconstruct the refresh token from automatically attached cookies alone. - -### 3.7 Profile, Logout, and Expiry - -- The frontend may reconstruct and decode the JavaScript-readable ID-token parts for profile display only. Browser-decoded claims are never sent back as trusted identity or used for authorization. -- `POST /auth/logout` requires the same partial Bearer header and matching HTTP-only access-token part-2 cookie as other protected requests. It reconstructs/revokes tokens when supported, clears every token cookie using the exact issuance attributes, and initiates the IdP end-session flow when configured. -- On a protected-route `401`, the frontend may attempt one refresh and retry the original request once. If refresh fails, it starts login; it must not create retry loops. -- On `403`, the frontend shows an authorization error and must not refresh or repeatedly start login. -- Expired or invalid split cookies are cleared at the authentication boundary where safe to do so. - ---- - -## 4. API Routes and Authorization - -Every path and method currently defined in `portal/backend/openapi/portal-backend.yaml` is listed below. Routes require a successfully reconstructed and validated access token unless explicitly marked public. Authorization is evaluated from the validated principal, never from browser-supplied user or organization values. The BFF authorizes capabilities through scopes; IdP roles are used only to grant scope bundles and are not evaluated by the BFF. - -### 4.1 Public System Endpoints - -| Endpoint | Method | Description | Authentication | -|---|---|---|---| -| `/health` | GET | Service health. | Public | -| `/health/liveness` | GET | Process liveness check. | Public | -| `/health/readiness` | GET | Dependency/readiness check. | Public | - -Health endpoints expose only minimal operational status. They must not return configuration, identity, token, dependency credentials, stack traces, or other sensitive diagnostics. - -### 4.2 User Endpoints - -| Endpoint | Method | Description | Required scope | Additional authorization | -|---|---|---|---|---| -| `/me/consents` | GET | List the authenticated user's consents. | `portal:consents:read:self` | Self-scoped to `UserIdentity.UserID` | -| `/me/consents/{consentId}` | GET | Get enriched details for an owned consent. | `portal:consents:read:self` | Ownership validation | -| `/me/consents/{consentId}/approve` | POST | Approve selected optional elements for an owned consent. | `portal:consents:write:self` | Ownership validation | -| `/me/consents/{consentId}/revoke` | POST | Revoke an owned consent. | `portal:consents:write:self` | Ownership validation | - -Rules: - -- `GET /me/consents` maps to `GET /api/v1/consents?userIds=` on OpenFGC. -- Ignore or overwrite every browser-supplied `userIds` value on `/me/*` routes. -- Before reading, approving, or revoking a specific consent, verify ownership against the authenticated `sub`. -- Ignore any request body `userId` that differs from `UserIdentity.UserID`. -- Purpose data fetched internally to enrich `/me/consents` responses is covered by the parent consent scope; it does not grant independent purpose browsing or management. - -### 4.3 Controlled `/api/*` Passthrough Endpoints - -`/api/{path...}` is the existing controlled passthrough route. It is split-token authenticated and restricted by the explicit method/path allowlist; it is not an unrestricted proxy. - -| BFF route | Methods | Required scope | -|---|---|---| -| `/api/consents` | GET | `portal:consents:read:any` | -| `/api/consents` | POST | `portal:consents:write:any` | -| `/api/consents/attributes` | GET | `portal:consents:read:any` | -| `/api/consents/validate` | POST | `portal:consents:read:any` | -| `/api/consents/{consentId}` | GET | `portal:consents:read:any` | -| `/api/consents/{consentId}` | PUT | `portal:consents:write:any` | -| `/api/consents/{consentId}/history` | GET | `portal:consents:read:any` | -| `/api/consents/{consentId}/revoke` | POST | `portal:consents:write:any` | -| `/api/consents/{consentId}/authorizations` | GET, POST | `portal:consents:read:any` for GET; `portal:consents:write:any` for POST | -| `/api/consents/{consentId}/authorizations/{authorizationId}` | GET, PUT | `portal:consents:read:any` for GET; `portal:consents:write:any` for PUT | -| `/api/consent-elements` | GET, POST | `portal:elements:read` for GET; `portal:elements:write` for POST | -| `/api/consent-elements/{elementId}` | GET | `portal:elements:read` | -| `/api/consent-elements/{elementId}/versions` | GET, POST | `portal:elements:read` for GET; `portal:elements:write` for POST | -| `/api/consent-elements/{elementId}/versions/{version}` | GET, DELETE | `portal:elements:read` for GET; `portal:elements:write` for DELETE | -| `/api/consent-purposes` | GET, POST | `portal:purposes:read` for GET; `portal:purposes:write` for POST | -| `/api/consent-purposes/{purposeId}` | GET | `portal:purposes:read` | -| `/api/consent-purposes/{purposeId}/versions` | GET, POST | `portal:purposes:read` for GET; `portal:purposes:write` for POST | -| `/api/consent-purposes/{purposeId}/versions/{version}` | GET, DELETE | `portal:purposes:read` for GET; `portal:purposes:write` for DELETE | - -Rules: - -- `:any` permits cross-user consent access only within `UserIdentity.OrgID`; the BFF preserves authorized `userIds` filters and always injects `org-id`. -- `:self` is intentionally available only through `/me/*`; the BFF overwrites the target user with `UserIdentity.UserID`. -- IdP role bundles are: end user receives `:self` scopes; DPO receives consent `:any` scopes; admin receives consent `:any` scopes plus element and purpose scopes. -- Consent history is a consent read operation. Listing or retrieving element/purpose versions requires the corresponding read scope; creating or deleting a version requires the corresponding write scope. -- Deny unknown `/api/*` paths and methods before proxying. - -### 4.4 Scope Enforcement Rules - -- Define every supported portal scope once as a typed constant in `internal/system/auth/scopes.go`. Production code must not repeat scope-name string literals in routers, middleware, handlers, services, or proxy policy definitions. -- `scopes.go` also exposes the immutable known-scope set used to validate configured requested scopes at startup. Unknown configured portal scopes cause startup failure; standard OIDC scopes such as `openid` and `profile` are validated separately. -- Route policy tables reference the typed scope constants. The frontend does not duplicate or enforce the scope registry because browser checks are not an authorization boundary. -- OpenAPI cannot import Go constants directly. Generate its scope descriptions from the canonical registry or add a contract test that fails when OpenAPI scope names differ from `scopes.go`. -- The BFF reads the space-delimited `scope` claim only after JWT signature and registered-claim validation. -- Missing, invalid, expired, or wrong-audience tokens return `401`; a valid token missing a required route scope returns `403`. -- Scope values use `:` within a scope name and spaces only to separate distinct scopes, for example: `portal:consents:read:self portal:consents:write:self`. -- The BFF never accepts scopes, user IDs, or organization context from request headers, paths, query parameters, or request bodies. - ---- - -## 5. Middleware and Router Structure - -### 5.1 Middleware Order - -``` -SecurityHeaders - → CORS - → RequestID / Logger - → SplitTokenAuth - → UserIdentity - → AuthorizationPolicy - → Route Handler / OpenFGC Proxy -``` - -`SplitTokenAuth` must run before `UserIdentity` and all protected route handlers. It reconstructs and validates the access token before storing the normalized principal. `UserIdentity` is the only resolver of effective user and organization identifiers; it stores both values in request context. `AuthorizationPolicy` uses the validated principal and route metadata to enforce scopes, organization rules, and ownership requirements. - -### 5.2 Split-Token Authentication Middleware - -1. Require exactly one `Authorization` header matching `Bearer ` and reject comma-joined or duplicate authorization values. -2. Require exactly one configured HTTP-only access-token part-2 cookie and reject ambiguous duplicates. -3. Enforce conservative size and character limits on each half, concatenate part 1 followed by part 2, and retain the result only for validation. -4. Fetch provider metadata from the configured issuer discovery document at startup and use its `jwks_uri`. -5. Cache JWKS keys with bounded TTL and refresh the cache when an otherwise valid token references an unknown `kid`. -6. Validate signature, algorithm, issuer, audience, time claims, subject, and configured token-purpose claims. -7. Map claims into `Principal` only after successful validation. -8. Store `Principal` in request context; never store either token half or the reconstructed access token in context. -9. Return `401` with a generic error response and `WWW-Authenticate: Bearer` for failed authentication. - -### 5.3 User Identity Middleware - -`internal/system/middleware/identity.go` is the single, central resolver of effective request identity for `/me/*` handlers, services, and proxy transforms. - -- It writes one `UserIdentity` value into `internal/system/context`: - - ``` - UserIdentity { - UserID string // OpenFGC userIds; derived from token sub - OrgID string // OpenFGC org-id; derived from token org_id claim - } - ``` - -- In normal operation, it requires the `Principal` placed in request context by `SplitTokenAuth`, derives `UserIdentity` from the validated `Principal.Subject` and `Principal.OrgID`, and writes it through `system/context.WithUserIdentity`. -- In explicit placeholder mode, it derives the same `UserIdentity` from `PlaceholderUserID` and `PlaceholderOrgID`; handlers and services therefore use the identical contract in tests and local development. -- Placeholder mode is permitted only in local/test environments and is rejected during startup when `BFF_ENV=production`; this production safeguard already exists in `internal/system/config/config.go`. -- Placeholder mode must be an explicit configuration choice; it must never activate because authentication failed or because either token half is missing. -- Production routes always require split-token authentication; test/local route wiring may use `UserIdentity` with placeholder mode instead of `SplitTokenAuth` only when the explicit non-production configuration is enabled. - -Application handlers, services, and proxy transforms read `UserIdentityFromContext` only. `UserIDFromContext` may remain as a convenience wrapper over `UserIdentity.UserID` while existing code is migrated. They must not read JWT claims or placeholder configuration directly. - -### 5.4 Authorization Policy - -The principal model must contain only normalized, validated information: - -``` -Principal { - Subject string - Scopes []string - OrgID string -} -``` - -Claim names and mappings are configuration-driven because IdP claim conventions vary. Route policy must distinguish authentication failures (`401`) from authenticated-but-forbidden requests (`403`). - -### 5.5 CORS - -- Allow only origins in `BFF_CORS__ALLOWED_ORIGINS`; never use `*`. -- Allow `Authorization`, `Content-Type`, and `X-Correlation-ID` request headers. -- Allow only the BFF's supported HTTP methods. -- Return `Vary: Origin`. -- Set `Access-Control-Allow-Credentials: true` when the frontend and BFF are cross-origin, because protected requests require the part-2 cookie. Never combine credentials with a wildcard origin. -- Handle preflight `OPTIONS` before split-token authentication. - ---- - -## 6. OpenFGC Proxy Boundary - -### 6.1 Request Transformation - -For an authorized route, the proxy must: - -- Map only approved BFF routes to their corresponding OpenFGC `/api/v1/*` route. -- Preserve approved query parameters and request bodies while applying user-scoping rules. -- Strip hop-by-hop headers: `Connection`, `Keep-Alive`, `Proxy-Authenticate`, `Proxy-Authorization`, `TE`, `Trailer`, `Transfer-Encoding`, and `Upgrade`. -- Remove any browser-supplied `org-id`, `TPP-client-id`, user, or identity headers. -- Inject only the trusted context explicitly defined in §6.2 from the validated principal and route policy. -- Propagate a validated `X-Correlation-ID`, or generate one when absent. -- Enforce request-size limits, downstream timeouts, and a conservative retry policy appropriate for idempotency. - -### 6.2 Trusted Context Mapping - -The BFF strips any browser-supplied values for the trusted headers below, then derives them only from validated request context. - -| BFF source | OpenFGC target | Rule | -|---|---|---| -| `UserIdentity.UserID` | `userIds` query parameter | Set only for `/me/*` routes; overwrite all browser-supplied `userIds`. It is also used for user-owned authorization and action payloads. | -| `UserIdentity.OrgID` | `org-id` header | Set on every OpenFGC request. Reject the request if the required organization identity is absent. | -| Group ID read from the fetched consent resource | `group-id` header | Set only when a self-service update/revoke operation first reads the owned consent and must send that consent-bound group back upstream. Never derive a generic group ID from token claims or placeholder configuration. | -| No user/IdP-derived source | `TPP-client-id` header | Strip any browser value and do not set this header in the current design. If OpenFGC requires it, define a separate service-level trusted source before enabling that route. | -| Validated incoming correlation ID, or generated ID | `X-Correlation-ID` header | Propagate only after format/length validation; otherwise generate a new ID. | - -The BFF never forwards the browser `Authorization` header or a browser-supplied trusted-context header to OpenFGC. - -### 6.3 Downstream Authentication - -This plan uses a header-trust boundary: OpenFGC trusts the BFF's injected context, not client-provided headers. The BFF does not forward the browser access token to OpenFGC by default. - -If OpenFGC must validate access tokens in the future, explicitly add one of these designs before forwarding credentials: - -- token forwarding with OpenFGC validation of the same audience/token contract; or -- OAuth token exchange / service-to-service credentials for the OpenFGC audience. - -Do not forward a token merely because it is available; its audience and downstream authorization meaning must be correct. - ---- - -## 7. Security Requirements - -### 7.1 Browser and Token Handling - -- The frontend reads only part-1 cookies. It sends access-token part 1 only to the trusted BFF origin and sends refresh-token part 1 only to `/auth/refresh`. -- The BFF accepts access-token part 1 only in the partial Bearer header and refresh-token part 1 only in the refresh request body. Complete browser-supplied tokens are rejected. -- Access/refresh part-2 cookies are `HttpOnly`; both ID-token parts are intentionally JavaScript-readable for profile display. All production token cookies are `Secure`, host-only, and use an explicit `SameSite` policy. -- Do not put token halves, reconstructed tokens, authorization codes, or client secrets in URLs, application logs, analytics events, error messages, or telemetry attributes. -- Apply strict maximum sizes to headers, cookies, individual token parts, and reconstructed tokens. -- Use HTTPS in staging and production for both portal-to-BFF and BFF-to-IdP traffic. -- Keep access-token, refresh-token, ID-token, and cookie lifetimes aligned with IdP policy; cookies must not extend token validity. - -### 7.2 JWT/JWKS Safety - -- Pin the expected issuer and audience; do not infer either from untrusted token claims. -- Permit only explicitly configured asymmetric signing algorithms. -- Use the issuer's discovered JWKS URI; do not accept a JWKS URL from the token. -- Bound JWKS cache lifetime and apply HTTP timeouts. Retain and use the last known-good keys during transient discovery/JWKS retrieval failures, provided they still validate the token; do not discard a usable cache merely because refresh failed. -- Refresh JWKS on an unknown `kid` before rejecting a token, supporting normal IdP key rotation. -- Fail closed if discovery/JWKS integrity cannot be established and no valid cached key can validate the token. - -### 7.3 Cross-Site Request and XSS Protection - -The design does not issue a separate API CSRF cookie or token. Protected API calls and logout require a JavaScript-created partial Bearer header plus the matching HTTP-only access-token half. Refresh requires refresh-token part 1 in the explicit request body plus the matching HTTP-only part-2 cookie. Because another origin cannot read either JavaScript-readable part through the same-origin policy, automatically attached cookies alone cannot authenticate these operations. Login/callback correlation uses a separate one-time OAuth state cookie and S256 PKCE verifier cookie; OIDC nonce remains deferred. - -The split-token model reduces complete-token exfiltration but does not prevent same-origin action by injected scripts. The React portal must maintain a restrictive CSP, avoid unsafe HTML injection, review third-party scripts, and apply normal output-encoding and dependency controls. - -### 7.4 Security Headers and Caching - -Apply to BFF responses: - -``` -Content-Security-Policy: default-src 'none' -X-Content-Type-Options: nosniff -X-Frame-Options: DENY -Referrer-Policy: strict-origin-when-cross-origin -Cache-Control: no-store -Pragma: no-cache -``` - -The BFF returns JSON and redirects only; the React portal has its own CSP. - -### 7.5 Frontend XSS Hardening - -The static-compatible hardening baseline is implemented as part of the authentication boundary. The production build emits an enforcing CSP meta policy plus a `_headers` deployment artifact; the static host must apply the emitted HTTP headers so header-only directives such as `frame-ancestors` are enforced. - -The split-token design limits complete-token exfiltration but JavaScript executing in the portal origin can still read every part-1 cookie and issue authenticated requests while the browser supplies part 2. Frontend XSS prevention is therefore part of the authentication boundary, not only a UI concern. - -Serve the React application with a restrictive, deployment-specific CSP. All application JavaScript is emitted as external same-origin build assets, and inline scripts, inline event handlers, string-to-code execution, and `'unsafe-eval'` remain prohibited. Oxygen UI currently uses Emotion-generated runtime style elements, so styles retain a narrowly documented `style-src 'unsafe-inline'` exception until per-response style nonces or a build-time styling migration is implemented. A production baseline is: - -```text -Content-Security-Policy: - default-src 'self'; - script-src 'self'; - style-src 'self' 'unsafe-inline'; # temporary Oxygen UI/Emotion exception - connect-src 'self' ; - img-src 'self' data:; - font-src 'self'; - object-src 'none'; - base-uri 'none'; - frame-ancestors 'none'; - form-action 'self' ; - manifest-src 'self'; - upgrade-insecure-requests -``` - -The production HTML must not contain inline scripts, inline event handlers, inline style attributes, or inline style elements. Runtime Emotion style elements are the only current exception and are covered by the temporary style policy above; no script nonce/hash handling is introduced. Keep development-only script allowances out of production configuration. Introduce future CSP changes in report-only mode when necessary, review violations, and then enforce; reporting payloads must not include token or user data. - -Additionally: - -- Do not use `dangerouslySetInnerHTML`, direct `innerHTML`, `document.write`, string-to-code APIs, or dynamic script construction. When rendering trusted rich-text is an explicit requirement, sanitize it with an allowlist-based, centrally configured sanitizer and test bypass payloads. -- Prefer text rendering and framework escaping for all IdP claims, API values, URLs, and error messages. Treat ID-token profile claims as untrusted display input even though the token signature was validated. -- Do not copy token parts into `localStorage`, `sessionStorage`, application state snapshots, Redux/devtools persistence, analytics, or error-reporting context. -- Self-host scripts where practical. Pin third-party dependencies and use Subresource Integrity plus `crossorigin` for any explicitly approved external static script or stylesheet. -- Restrict URL/navigation sinks to validated relative paths or explicit origin allowlists. Never insert token claims or API data into executable URLs. -- Consider enforcing Trusted Types where supported after removing incompatible sinks. -- Run dependency vulnerability checks and targeted DOM-XSS tests in CI, and verify production CSP/security headers through integration tests. - ---- - -## 8. Project Structure - -``` -portal/backend/ -├── cmd/server/ -│ ├── main.go -│ └── servicemanager.go -├── internal/ -│ ├── me/ -│ │ ├── init.go # module initialization and /me route registration -│ │ ├── handler.go # /me request handlers -│ │ └── service.go # self-service consent operations -│ ├── proxy/ -│ │ ├── init.go # module initialization and /api route registration -│ │ ├── handler.go # proxy HTTP handler -│ │ ├── service.go # OpenFGC request transformation/proxying -│ │ └── service_test.go -│ └── system/ -│ ├── auth/ -│ │ ├── oidc_client.go # authorization URL and confidential-client token exchange -│ │ ├── cookies.go # split, issue, reconstruct, rotate, and clear token cookies -│ │ ├── handlers.go # login, callback, refresh, and logout endpoints -│ │ ├── validator.go # issuer discovery, JWKS cache, ID/access JWT validation -│ │ ├── principal.go # validated claim-to-Principal mapping -│ │ ├── scopes.go # canonical typed portal scope names and known-scope set -│ │ └── policy.go # scope, organization, and ownership helpers -│ ├── config/ -│ │ ├── config.go # configuration loading, defaults, validation -│ │ └── config_test.go -│ ├── context/ -│ │ └── identity.go # request-scoped Principal and UserIdentity accessors -│ ├── correlation/ -│ │ └── id.go # correlation-ID helpers -│ ├── healthcheck/ -│ │ └── handler.go -│ ├── middleware/ -│ │ ├── split_token_auth.go # reconstructs access token, validates it, stores Principal -│ │ ├── correlationid.go -│ │ ├── correlationid_test.go -│ │ ├── cors.go -│ │ ├── cors_test.go -│ │ ├── identity.go # central UserIdentity resolver: token claims or test placeholders -│ │ └── identity_test.go -│ └── log/ -│ ├── log.go -│ └── log_test.go -├── tests/ -│ └── integration/ -│ ├── health_test.go -│ ├── proxy_invalid_consent_id_test.go -│ ├── proxy_phase2_test.go -│ └── server_test.go -├── openapi/ -│ └── bff.yaml -├── README.md -├── Taskfile.yml -├── go.mod -├── Dockerfile -├── docker-compose.yml -└── go.sum -``` - -Authentication is added within the existing `internal/system` foundation. `internal/system/auth` owns confidential-client token exchange, split-token cookies, refresh/logout flows, provider discovery, JWKS/JWT validation, normalized principal creation, the canonical scope registry, and authorization middleware. It reconstructs the access token, invokes validation, discards the raw token, and stores only the validated principal (`UserID`, `OrgID`, and scopes) in `internal/system/context`. The same middleware creates that principal from explicit non-production placeholders when placeholder mode is enabled. - -Add unit tests beside the package under test and extend `tests/integration` for end-to-end OIDC, split-token authentication, and proxy scenarios. - ---- - -## 9. Configuration - -Use Koanf with environment variables as the primary runtime source and optional file overlays for local development. - -```bash -# Server -BFF_SERVER__PORT=8080 -BFF_ENV=development -BFF_LOG__LEVEL=info - -# IdP OIDC confidential client and resource-server validation -BFF_AUTH__ISSUER_URL=https://localhost:9443 -BFF_AUTH__CLIENT_ID=portal-bff -BFF_AUTH__CLIENT_SECRET=replace-with-environment-secret -BFF_AUTH__PORTAL_URL=http://localhost:3000/consents -BFF_AUTH__REDIRECT_URI=http://localhost:8080/auth/callback -BFF_AUTH__POST_LOGOUT_REDIRECT_URI=http://localhost:3000 -BFF_AUTH__SCOPES=openid profile portal:consents:read:self portal:consents:write:self -BFF_AUTH__RESOURCE_AUDIENCE=portal-api -BFF_AUTH__ALLOWED_SIGNING_ALGORITHMS=RS256 -BFF_AUTH__HTTP_TIMEOUT=5s -BFF_AUTH__CLOCK_SKEW=30s -# Disabled unless the IdP emits a reliable access-token-type claim. -BFF_AUTH__REQUIRE_ACCESS_TOKEN_TYPE=false -BFF_AUTH__ACCESS_TOKEN_TYPE_CLAIM=token_type -BFF_AUTH__ACCESS_TOKEN_TYPE_VALUE=access_token - -# Split-token cookies -BFF_AUTH__ACCESS_TOKEN_PART1_COOKIE=portal-at-p1 -BFF_AUTH__ACCESS_TOKEN_PART2_COOKIE=portal-at-p2 -BFF_AUTH__REFRESH_TOKEN_PART1_COOKIE=portal-rt-p1 -BFF_AUTH__REFRESH_TOKEN_PART2_COOKIE=portal-rt-p2 -BFF_AUTH__ID_TOKEN_PART1_COOKIE=portal-id-p1 -BFF_AUTH__ID_TOKEN_PART2_COOKIE=portal-id-p2 -BFF_AUTH__OAUTH_STATE_COOKIE=portal-oauth-state -BFF_AUTH__PKCE_VERIFIER_COOKIE=portal-pkce-verifier -BFF_AUTH__COOKIE_SECURE=false -BFF_AUTH__COOKIE_SAME_SITE=Lax -BFF_AUTH__LOGIN_TRANSACTION_MAX_AGE_SECONDS=600 -BFF_AUTH__MAX_TOKEN_PART_BYTES=3800 -BFF_AUTH__MAX_RECONSTRUCTED_TOKEN_BYTES=7600 -BFF_AUTH__REFRESH_TIMEOUT=10s - -# Claim mapping -BFF_AUTH__SCOPE_CLAIM=scope -BFF_AUTH__ORG_ID_CLAIM=org_id - -# OpenFGC server -BFF_PROXY__OPENFGC_API_URL=http://localhost:8060 -BFF_PROXY__OPENFGC_API_TIMEOUT=10s - -# CORS -BFF_CORS__ALLOWED_ORIGINS=http://localhost:3000 -BFF_CORS__ALLOWED_HEADERS=Authorization,Content-Type,X-Correlation-ID -BFF_CORS__ALLOW_CREDENTIALS=true - -# Proxy safety -BFF_PROXY__MAX_REQUEST_BYTES=1048576 - -# Test/local identity only — startup must reject this in production -BFF_PROXY__PLACEHOLDER_MODE_ENABLED=false -BFF_PROXY__PLACEHOLDER_USER_ID= -BFF_PROXY__PLACEHOLDER_ORG_ID= -``` - -When placeholder mode is enabled in an allowed non-production environment, both `BFF_PROXY__PLACEHOLDER_USER_ID` and `BFF_PROXY__PLACEHOLDER_ORG_ID` are required and must be non-empty. Placeholder mode remains invalid in production. - -The OIDC client secret must come from a secret manager or mounted secret file, not committed configuration or ordinary environment dumps. Validate redirect URIs, cookie settings, allowed origins, issuer, audience, and placeholder-mode restrictions at startup. The BFF owns no transaction-encryption or JWT-signing keys; the IdP remains the token issuer. - -The React application has no IdP client configuration or IdP SDK. Its portal auth client contains only BFF-relative auth routes, readable part-1 cookie names, split-token request transport, and the one-refresh/one-retry behavior. - ---- - -## 10. Deployment Notes - -### 10.1 Network Boundary - -- Expose the React portal and BFF through the intended ingress only. -- Do not expose OpenFGC port `8060` to browsers or public networks. -- Permit inbound OpenFGC traffic only from the BFF/private network identity. -- Strip trusted context headers at ingress so only the BFF can set them. -- Prefer mTLS or equivalent service-to-service authentication between BFF and OpenFGC. - -### 10.2 Availability and Rate Limiting - -- Rate-limit protected API routes and malformed/failed-authentication requests at the gateway or reverse proxy. -- Apply stricter limits to expensive mutation endpoints. -- Configure timeouts for IdP discovery/JWKS and OpenFGC calls. -- Monitor JWKS refresh failures, authentication failure reason codes, authorization denials, and OpenFGC latency without recording token material. - -### 10.3 Key Rotation - -The IdP owns signing-key rotation. The BFF supports it by respecting JWKS cache lifetime and refreshing JWKS when it encounters an unknown `kid`. The BFF requires an operational rotation procedure for its OIDC client secret but owns no transaction-encryption or JWT-signing keys. - -### 10.4 Local Development - -- Portal: `http://localhost:3000` -- BFF: `http://localhost:8080` -- IdP: `https://localhost:9443` -- OpenFGC: `http://localhost:8060` - -Trust the local IdP certificate through the container/host trust store. Do not use `InsecureSkipVerify` outside an explicitly isolated local-development setup. - -Local HTTP development may use non-`__Host-` cookie names with `Secure=false` through explicit development-only configuration. Startup must reject insecure authentication cookies in staging/production. - ---- - -## 11. Implementation Checklist - -### React Portal - -- [x] Implement the portal-owned auth client with login, logout, refresh, local ID-token profile decoding, and signed-in status helpers; do not add an IdP SDK. -- [x] Add an API interceptor that reads access-token part 1 and sets `Authorization: Bearer ` with credentials enabled. -- [x] Send refresh-token part 1 only in the `/auth/refresh` request body; never make cookie-only refresh requests. -- [x] Handle `401` with at most one refresh and one request retry before starting login. -- [x] Handle `403` as an authorization failure. -- [x] Reconstruct and decode the ID-token cookies only for profile display; never send browser-decoded claims back as trusted identity or use them for authorization. -- [x] Apply the static-compatible restrictive frontend CSP, prohibit unsafe script execution, and verify that frontend storage and production source sinks do not capture token parts. The temporary Emotion style exception remains tracked below. - -### BFF Authentication and Authorization - -- [x] Implement OIDC confidential-client discovery, authorization URL construction, code exchange, and refresh-token exchange. -- [x] Generate one-time OAuth state and S256 PKCE transactions, store state/verifier only in short-lived HTTP-only cookies, validate state in constant time, consume transactions before exchange, and reject callback replay without server-side session or key storage. -- [x] Implement split-token cookie issuance, reconstruction, rotation, exact-attribute clearing, and size limits. -- [x] Implement strict partial Bearer-header and part-2-cookie parsing. -- [x] Implement issuer discovery and library-managed remote JWKS caching through `go-oidc`, with bounded HTTP timeouts, cached-key reuse, and unknown-`kid` refresh for normal key rotation. -- [x] Validate ID tokens at callback, including signature, allowed algorithm, issuer, client audience, and time claims; nonce validation is deferred. -- [x] Validate reconstructed access tokens, including signature, allowed algorithm, issuer, resource audience, time claims, and subject; validate access-token type only when explicitly enabled and supported by the IdP. -- [x] Refresh JWKS on unknown `kid` and support normal key rotation. -- [x] Map validated claims to the normalized `Principal` model. -- [ ] Define all portal scope names as typed constants and an immutable known-scope set in `internal/system/auth/scopes.go`; remove duplicated production scope literals. -- [x] Validate configured requested portal scopes against the canonical scope registry during startup. -- [x] Implement `/auth/login`, `/auth/callback`, `/auth/refresh`, and `/auth/logout` with generic errors and token redaction. -- [ ] Serialize or otherwise make concurrent refresh requests safe for refresh-token rotation. -- [x] Reject cookie-only refresh/logout requests: refresh requires refresh-token part 1 in the body, while logout requires the partial Bearer header and matching access-token part-2 cookie. -- [x] Store the validated `Principal` containing user ID, organization ID, and scopes in request context, including equivalent placeholder-mode identity. -- [x] Retain explicit placeholder identity mode only for test/local use; reject it in production and never use it as an authentication fallback. -- [x] Configure and enforce the §4 scope, organization, and ownership policy for both `/me/*` and `/api/*` routes. -- [x] Return `401` with `WWW-Authenticate: Bearer` for authentication failures and `403` without a bearer challenge for authorization failures; cover both behaviors in unit and integration tests. - -### BFF Routes and Proxy - -- [x] Enforce self-scoping and object ownership on `/me/*` routes; reuse only a fetched, owned consent's group ID for the corresponding upstream mutation. -- [x] Enforce the §4 scope-to-route policy for the existing controlled `/api/*` passthrough route. -- [x] Update `openapi/portal-backend.yaml`: document auth endpoints and define the documentation-only `PortalOAuthDocumentation` scheme plus `AccessTokenPart2` as an AND security requirement. Document required route scopes explicitly, explain the non-standard browser-facing split-token contract, and contract-test documented scopes against `scopes.go` route policies. -- [x] Maintain explicit method/path mappings to OpenFGC `/api/v1/*`. -- [x] Point `BFF_PROXY__OPENFGC_API_URL` to port `8060`. -- [x] Strip client-supplied trusted headers; inject user and organization context only from `UserIdentity`. -- [x] Remove generic placeholder/token-derived `group-id` injection; preserve only consent-bound group reuse for self-service mutations. -- [x] Apply body limits, timeouts, correlation IDs, and hop-by-hop header stripping. - -### Security and Operations - -- [x] Configure exact CORS origins, permit the partial `Authorization` header, and enable credentials only for those origins. -- [x] Enforce secure host-only production cookies with explicit `SameSite`, correct expiry, HTTP-only access/refresh part-2 cookies, and intentionally JavaScript-readable ID-token parts. -- [ ] Store the OIDC client secret in the deployment secret manager and document rotation; do not introduce BFF-owned transaction or JWT-signing keys. -- [ ] Apply security and no-store cache headers. -- [x] Ensure token halves, reconstructed tokens, codes, and client secrets are redacted from application, proxy, and observability logs. -- [ ] Make OpenFGC port `8060` private-only and enforce BFF-only access. -- [ ] Add infrastructure rate limiting and monitoring for failed authentication/JWKS availability. - -### Frontend XSS Hardening - -- [x] Define a production React CSP with an exact BFF `connect-src`, prohibit inline/eval scripts, embed the enforceable meta subset, and emit the complete policy for the static host as `dist/_headers`. -- [x] Emit JavaScript and CSS build assets from the same origin and verify that production HTML contains no inline scripts, event handlers, style attributes, or style elements. -- [x] Set `object-src 'none'`, `base-uri 'none'`, `frame-ancestors 'none'`, constrained `form-action`, and `upgrade-insecure-requests` for HTTPS production builds. -- [x] Reject unsafe DOM/code sinks and web-storage use during production build verification; no trusted rich-text rendering currently requires a sanitizer. -- [x] Treat decoded ID-token claims and API content as untrusted display input, rely on React escaping, and test markup-shaped profile claims. -- [x] Verify that token parts never enter web storage or persisted frontend state. No analytics or frontend error-reporting integration is present. -- [x] Self-host frontend scripts and styles and pin direct dependencies. No external static script or stylesheet currently requires Subresource Integrity. -- [x] Add CSP policy tests, DOM-XSS rendering tests, production HTML/source-sink verification, and high-severity production dependency auditing to CI. -- [ ] Replace the temporary `style-src 'unsafe-inline'` exception required by Oxygen UI/Emotion with per-response style nonces or a reviewed build-time styling migration. Test dynamic MUI/Oxygen components before enforcing the replacement. -- [ ] Add sanitized CSP violation reporting when an approved reporting endpoint and data-retention policy are available; reports must exclude token and user data. - -### Testing - -- [x] Unit tests: token splitting/reconstruction, missing or duplicate halves, wrong half order, malformed partial Bearer header, cookie attributes, exact clearing, and token/cookie size limits. -- [x] Unit tests: callback errors and token-endpoint error mapping. -- [x] Unit tests: OAuth state generation and constant-time matching, RFC 7636 S256 challenge generation, PKCE verifier validation, transaction-cookie attributes/consumption, missing/mismatched/duplicate state, and replay rejection. -- [x] Unit tests: valid reconstructed token, invalid signature, unsupported algorithm, expired/not-yet-valid token, wrong issuer, wrong audience, missing subject, and wrong token type. -- [x] Unit tests: ID-token signature, issuer, audience, and time validation; nonce tests are deferred. -- [x] Unit tests: JWKS cache hits, unknown-`kid` refresh, rotation, and transient JWKS failure behavior. -- [x] Unit tests: canonical scope uniqueness, configured-scope validation, route-policy references, and OpenAPI scope parity. -- [x] Unit tests: claim mapping, scope policy, organization policy, and ownership policy. -- [x] Integration tests: login/callback cookie issuance and portal-style partial Bearer plus HTTP-only-cookie authentication across every protected route policy. -- [x] Integration tests: missing part 1, missing part 2, complete-token injection, duplicate cookies, and tampered reconstruction all return `401`. -- [x] Integration tests: successful refresh, rotated and retained refresh tokens, expired/invalid refresh, concurrent non-rotating refresh, and atomic cookie replacement. Refresh-token rotation race serialization remains explicitly deferred. -- [x] Integration tests: cookie-only refresh/logout rejection, logout cookie clearing, and IdP end-session and fallback behavior. -- [x] Integration tests: `401` versus `403` behavior and `WWW-Authenticate` response. -- [x] Integration tests: credentialed CORS preflight and exact `Authorization` header allowance. -- [x] Integration tests: OpenFGC path mapping, query preservation, port-8060 target, and header override prevention. -- [x] Integration tests: `/me/consents` always injects `Principal.UserID` and ignores client user filters; `/api/*` rejects missing `:any` scopes and preserves authorized filters only within `Principal.OrgID`. - -### Deferred Final Hardening - -- [ ] Add OIDC `nonce` generation, one-time cookie storage, ID-token validation, replay tests, and cleanup using the existing stateless login-transaction mechanism. - ---- - -## 12. Standards - -- HTTP stack: Go `net/http` with `ServeMux` and composable middleware. -- Configuration: centralized Koanf loading and startup validation. -- API design: contract-first route and payload definitions; deny-by-default proxy routing. -- Security model: confidential-client OIDC, split-token cross-site request protection, standards-based JWKS/JWT validation, least-privilege scopes, private downstream boundary, and externalized secrets/configuration. -- Testing model: unit tests for split cookies, reconstruction, state/PKCE correlation, JWT validation, and policy logic plus integration tests for the browser-to-BFF auth lifecycle and BFF-to-OpenFGC transformations. OIDC nonce and the remaining CSP items remain deferred hardening work. diff --git a/portal/frontend/README.md b/portal/frontend/README.md index 449d5216..31545cee 100644 --- a/portal/frontend/README.md +++ b/portal/frontend/README.md @@ -16,34 +16,55 @@ * under the License. --> -# openfgc-portal +# OpenFGC Portal Frontend -The OpenFGC Portal project is designed to create a comprehensive Consent Management Portal, leveraging the [OpenFGC Consent Management API](https://github.com/wso2/openfgc). +The OpenFGC Portal Frontend is designed to create a comprehensive Consent Management Portal User Interface, leveraging the [OpenFGC Consent Management API](https://github.com/wso2/openfgc). React 19 + TypeScript + Vite app using WSO2 Oxygen UI. ## Requirements - Node.js 20.19+ (or 22.12+) -- Corepack enabled -- pnpm + +## Quickstart + +From `portal/frontend`: + +1. Copy `.env.example` to `.env` and configure `VITE_API_BASE_URL` for the Portal Backend. +2. Ensure pnpm 10.6.5 is available. If pnpm was installed through another method, skip this command; otherwise enable it through Corepack: + + ```shell + corepack enable + ``` + +3. Install dependencies: + + ```shell + pnpm install + ``` + +4. Start the development server: + + ```shell + pnpm dev + ``` + +Open the local URL printed by Vite, typically `http://localhost:5173`. ## Package Manager -This project uses pnpm. +This project uses [pnpm](https://pnpm.io/), with the version pinned in `package.json`. + +Enable pnpm through Corepack: ```bash corepack enable -corepack prepare pnpm@10.6.5 --activate ``` -If your machine cannot install global Corepack shims due to permission restrictions, use `corepack pnpm` directly. +Note: If your machine cannot install Corepack shims, prefix pnpm commands with `corepack`, for example `corepack pnpm install`. -## Install +Alternatively, follow the other [recommended pnpm installation options](https://pnpm.io/installation). -```bash -pnpm install -``` ## Environment From a29a5e5b903a80ee564998d0717d7a95e2520414 Mon Sep 17 00:00:00 2001 From: rashmithachamikara Date: Sat, 1 Aug 2026 01:29:27 +0530 Subject: [PATCH 18/19] Add AGENTS.md files --- portal/backend/AGENTS.md | 53 + portal/frontend/.ai/oxygen-ui/AGENTS.md | 81 ++ portal/frontend/.ai/oxygen-ui/components.md | 1431 +++++++++++++++++++ portal/frontend/.ai/oxygen-ui/migration.md | 281 ++++ portal/frontend/.ai/oxygen-ui/patterns.md | 1197 ++++++++++++++++ portal/frontend/.ai/oxygen-ui/theming.md | 334 +++++ portal/frontend/AGENTS.md | 90 ++ 7 files changed, 3467 insertions(+) create mode 100644 portal/backend/AGENTS.md create mode 100644 portal/frontend/.ai/oxygen-ui/AGENTS.md create mode 100644 portal/frontend/.ai/oxygen-ui/components.md create mode 100644 portal/frontend/.ai/oxygen-ui/migration.md create mode 100644 portal/frontend/.ai/oxygen-ui/patterns.md create mode 100644 portal/frontend/.ai/oxygen-ui/theming.md create mode 100644 portal/frontend/AGENTS.md diff --git a/portal/backend/AGENTS.md b/portal/backend/AGENTS.md new file mode 100644 index 00000000..013f878f --- /dev/null +++ b/portal/backend/AGENTS.md @@ -0,0 +1,53 @@ +# OpenFGC BFF Agent Guide + +This is the shared agent policy for `portal/backend`. + +## Scope + +- Go 1.26.3 BFF code under `portal/backend/**`. +- Portal-facing authentication, self-service consent endpoints, and a hardened proxy to OpenFGC. + +## Core Rules + +- Use Go `net/http` with method-aware `ServeMux` patterns for HTTP routing. +- Prefer Go standard library where practical. +- Avoid adding third-party packages without clear justification. +- Keep handlers focused on HTTP concerns; place upstream orchestration and business logic in services. +- Keep security-sensitive logic explicit and test-covered. +- Never log tokens, secrets, or key material. +- Use structured `log/slog` logging and avoid logging sensitive user data. +- Keep middleware composable and order-aware. +- Keep code patterns consistent with `consent-server/` when the same concern exists in both services. + +## Project Structure + +- Keep process startup, HTTP server construction, and dependency wiring in `cmd/server`. +- Keep feature behavior in focused packages under `internal`. +- Keep shared infrastructure and cross-cutting concerns under `internal/system`. +- Keep unit tests beside source as `*_test.go` and service-level integration tests under `tests/integration`. +- Keep the public API contract under `openapi`. + +## Quality Gates + +- Format changed Go code with `task fmt`. +- Before merge, run `task fmt:check`, `task lint`, `task test`, and `task build` from `portal/backend`. +- Add unit or integration coverage for new behavior and regressions. +- Prefer table-driven tests and `httptest` for HTTP behavior where practical. +- Keep tests deterministic and cover success, validation, authorization, and upstream failure paths as applicable. + +## Security Baseline + +- Fail closed on security-sensitive errors. +- Apply least-privilege authentication and authorization. +- Keep proxy access deny-by-default. +- Treat client-supplied identity data as untrusted. +- Preserve OIDC validation and secure cookie protections. +- Never allow placeholder identity mode in production. +- Bound requests and sanitize forwarded headers. +- Never expose sensitive data in responses or logs. + +## Contracts and Documentation + +- Keep `openapi/portal-backend.yaml` synchronized with route, payload, response, and error-contract changes. +- Keep `README.md`, `.env.example`, configuration structs, defaults, and validation rules synchronized. +- Preserve the documented JSON error shape and use stable, explicit error codes. diff --git a/portal/frontend/.ai/oxygen-ui/AGENTS.md b/portal/frontend/.ai/oxygen-ui/AGENTS.md new file mode 100644 index 00000000..e76ab7ff --- /dev/null +++ b/portal/frontend/.ai/oxygen-ui/AGENTS.md @@ -0,0 +1,81 @@ +# Oxygen UI - AI Development Guide + +> WSO2's React component library built on Material-UI v7 + +## Quick Start + +```tsx +import { OxygenUIThemeProvider, OxygenTheme, Button } from '@wso2/oxygen-ui'; + +function App() { + return ( + + + + ); +} +``` + +## Critical Rules + +1. **Import from `@wso2/oxygen-ui`** - Never import directly from `@mui/material` +2. **Always wrap with `OxygenUIThemeProvider`** - Required at app root +3. **Icons from `@wso2/oxygen-ui-icons-react`** - Separate package for icons +4. **MUI X uses namespaces** - `DataGrid.DataGrid`, `DatePickers.DatePicker`, `TreeView.TreeView` +5. **Use theme tokens** - Use `sx` prop with theme values, not hardcoded colors/spacing + +## Import Patterns + +```tsx +// Components - import from @wso2/oxygen-ui +import { Button, TextField, Box, Typography, ListingTable } from '@wso2/oxygen-ui'; + +// Icons - import from @wso2/oxygen-ui-icons-react +import { HomeIcon, SettingsIcon, UserIcon } from '@wso2/oxygen-ui-icons-react'; + +// MUI X - use as namespaces +import { DataGrid, DatePickers, TreeView } from '@wso2/oxygen-ui'; + + +``` + +## Theme Tokens (Always Use) + +```tsx +// CORRECT - Use theme tokens + + +// WRONG - Never hardcode + +``` + +## Available Themes + +- `OxygenTheme` - Default theme (recommended) +- `AcrylicOrangeTheme` - Orange accent variant +- `AcrylicPurpleTheme` - Purple accent variant +- `ChoreoTheme` - Choreo product theme (indigo-violet gradient) +- `ClassicTheme` - Traditional styling +- `HighContrastTheme` - Accessibility-focused +- `PaleBaseTheme` - Minimal color palette +- `PaleGrayTheme` - Soft gray tones +- `PaleIndigoTheme` - Soft indigo tones + +## Custom Oxygen Components + +- **Layout**: `AppShell`, `Layout`, `Header`, `Sidebar`, `Footer`, `PageContent` +- **Data Display**: `ListingTable`, `PageTitle`, `CodeBlock`, `StatCard` +- **Forms**: `Form.CardButton`, `Form.Section`, `Form.Stack`, `Form.Wizard` +- **Inputs**: `SearchBar`, `SearchBarWithAdvancedFilter`, `ComplexSelect` +- **Feedback**: `NotificationPanel`, `NotificationBanner` +- **Theming**: `OxygenUIThemeProvider`, `ThemeSwitcher`, `ColorSchemeToggle`, `ColorSchemeImage` +- **User**: `UserMenu` +- **Animations**: `ParticleBackground` + +## Detailed Documentation + +For comprehensive API reference and examples, see: +- [Component API Reference](./components.md) +- [UI Patterns & Examples](./patterns.md) +- [Theme Customization](./theming.md) +- [Migration Guide](./migration.md) diff --git a/portal/frontend/.ai/oxygen-ui/components.md b/portal/frontend/.ai/oxygen-ui/components.md new file mode 100644 index 00000000..fe912c6f --- /dev/null +++ b/portal/frontend/.ai/oxygen-ui/components.md @@ -0,0 +1,1431 @@ +# Oxygen UI Component Reference + +Complete API reference for custom Oxygen UI components. For MUI components, refer to [Material-UI documentation](https://mui.com/material-ui/). + +## Table of Contents + +- [OxygenUIThemeProvider](#oxygenuitthemeprovider) +- [ListingTable](#listingtable) +- [AppShell](#appshell) +- [Layout](#layout) +- [Header](#header) +- [Sidebar](#sidebar) +- [Footer](#footer) +- [UserMenu](#usermenu) +- [Form](#form) +- [NotificationPanel](#notificationpanel) +- [NotificationBanner](#notificationbanner) +- [ParticleBackground](#particlebackground) +- [Other Components](#other-components) +- [MUI X Namespaces](#mui-x-namespaces) +- [Hooks](#hooks) +- [Utilities](#utilities) + +--- + +## OxygenUIThemeProvider + +Theme provider that wraps your application. Required at the root level. + +```tsx +import { OxygenUIThemeProvider, OxygenTheme } from '@wso2/oxygen-ui'; +``` + +### Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `children` | `ReactNode` | required | App content | +| `theme` | `Theme` | `OxygenTheme` | Single theme (disables switching) | +| `themes` | `ThemeOption[]` | - | Array of themes (enables switching) | +| `initialTheme` | `string` | First theme key | Initial theme key | + +### ThemeOption Type + +```tsx +interface ThemeOption { + key: string; // Unique identifier + label: string; // Display name + theme: Theme; // MUI Theme object +} +``` + +### Basic Usage + +```tsx +// Single theme (no switching) + + + +``` + +### Theme Switching + +```tsx +import { + OxygenUIThemeProvider, + OxygenTheme, + AcrylicOrangeTheme, + AcrylicPurpleTheme, +} from '@wso2/oxygen-ui'; + +const themes = [ + { key: 'default', label: 'Default', theme: OxygenTheme }, + { key: 'orange', label: 'Orange', theme: AcrylicOrangeTheme }, + { key: 'purple', label: 'Purple', theme: AcrylicPurpleTheme }, +]; + + + + +``` + +--- + +## ListingTable + +Advanced data table with compound component pattern. Supports search, sort, pagination, selection, and bulk actions. + +```tsx +import { ListingTable } from '@wso2/oxygen-ui'; +``` + +### Sub-components + +| Component | Description | +|-----------|-------------| +| `ListingTable.Provider` | Context provider for state management | +| `ListingTable.Container` | Paper wrapper with styling | +| `ListingTable.Toolbar` | Search, filters, and actions bar | +| `ListingTable.Head` | Table header wrapper | +| `ListingTable.Body` | Table body wrapper | +| `ListingTable.Footer` | Table footer (pagination) | +| `ListingTable.Row` | Table row | +| `ListingTable.Cell` | Table cell | +| `ListingTable.SortLabel` | Sortable column header | +| `ListingTable.DensityControl` | Density toggle button | +| `ListingTable.EmptyState` | Empty state display | +| `ListingTable.RowActions` | Row action buttons | +| `ListingTable.CellIcon` | Icon in cell | + +### ListingTable Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `variant` | `'table' \| 'card'` | `'table'` | Display style | +| `density` | `'compact' \| 'standard' \| 'comfortable'` | `'standard'` | Row padding | +| `striped` | `boolean` | `false` | Alternating row colors | +| `bordered` | `boolean` | `false` | Cell borders | + +### Provider Props (Context State) + +```tsx +interface ListingTableProviderProps { + // Search + searchValue?: string; + onSearchChange?: (value: string) => void; + + // Sort + sortField?: string; + sortDirection?: 'asc' | 'desc'; + onSortChange?: (field: string, direction: 'asc' | 'desc') => void; + + // Pagination + page?: number; // 0-indexed + rowsPerPage?: number; + totalCount?: number; + onPageChange?: (page: number) => void; + onRowsPerPageChange?: (rowsPerPage: number) => void; + + // Selection + selected?: readonly string[]; + onSelectionChange?: (selected: readonly string[]) => void; + onSelectAll?: () => void; + onClearSelection?: () => void; + isSelected?: (id: string) => boolean; + + // Filters + filters?: Record; + onFilterChange?: (key: string, value: unknown) => void; + onClearFilters?: () => void; + + // Bulk Actions + onBulkDelete?: (ids: readonly string[]) => void; + onBulkAction?: (actionId: string, ids: readonly string[]) => void; + + // UI State + density?: 'compact' | 'standard' | 'comfortable'; + onDensityChange?: (density: ListingTableDensity) => void; + loading?: boolean; +} +``` + +### Toolbar Props + +| Prop | Type | Default | Description | +|------|------|---------|-------------| +| `showSearch` | `boolean` | `false` | Show built-in search | +| `searchSlot` | `ReactNode` | - | Custom search component | +| `searchPlaceholder` | `string` | `'Search...'` | Search placeholder | +| `actions` | `ReactNode` | - | Right-side actions | +| `children` | `ReactNode` | - | Center content | + +### Basic Table + +```tsx + + + + + Name + Status + Actions + + + + {data.map((row) => ( + + {row.name} + {row.status} + + edit(row.id) }, + { id: 'delete', label: 'Delete', onClick: () => delete(row.id) }, + ]} + /> + + + ))} + + + +``` + +### Full-Featured Table with Context + +```tsx +const [search, setSearch] = useState(''); +const [sortField, setSortField] = useState('name'); +const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc'); +const [page, setPage] = useState(0); +const [rowsPerPage, setRowsPerPage] = useState(10); + + { + setSortField(field); + setSortDirection(dir); + }} + page={page} + rowsPerPage={rowsPerPage} + totalCount={data.length} + onPageChange={setPage} + onRowsPerPageChange={setRowsPerPage} +> + + } + /> + + + + + Name + + + Status + + + + + {paginatedData.map((row) => ( + + {row.name} + {row.status} + + ))} + + + + + +``` + +--- + +## AppShell + +Application layout wrapper using compound component pattern. + +```tsx +import { AppShell } from '@wso2/oxygen-ui'; +``` + +### Sub-components + +| Component | Description | +|-----------|-------------| +| `AppShell.Navbar` | Top navigation area (header) | +| `AppShell.Sidebar` | Left sidebar area | +| `AppShell.Main` | Main content area | +| `AppShell.Footer` | Bottom footer area | +| `AppShell.NotificationPanel` | Overlay notification panel | + +### Usage + +```tsx + + +
...
+
+ + + ... + + + + {/* React Router outlet */} + + + +