diff --git a/.changeset/plain-dolls-dance.md b/.changeset/plain-dolls-dance.md new file mode 100644 index 00000000000..82ec784f883 --- /dev/null +++ b/.changeset/plain-dolls-dance.md @@ -0,0 +1,9 @@ +--- +"@wso2is/console": patch +"@wso2is/admin.consents.v1": patch +"@wso2is/admin.core.v1": patch +"@wso2is/common.consents.v1": patch +"@wso2is/i18n": patch +--- + +Add application assignment UI for policies diff --git a/apps/console/src/public/deployment.config.json b/apps/console/src/public/deployment.config.json index 7c2e66a693e..a1a7f44f9be 100644 --- a/apps/console/src/public/deployment.config.json +++ b/apps/console/src/public/deployment.config.json @@ -887,10 +887,16 @@ "console:consents" ], "read": [ + "internal_application_mgt_view", + "internal_claim_meta_view", "internal_consent_mgt_element_view", + "internal_consent_mgt_purpose_app_view", "internal_consent_mgt_purpose_view" ], "update": [ + "internal_branding_preference_policy_update", + "internal_consent_mgt_purpose_app_create", + "internal_consent_mgt_purpose_app_delete", "internal_consent_mgt_purpose_update" ] } diff --git a/features/admin.consents.v1/api/consent-policy-apps.ts b/features/admin.consents.v1/api/consent-policy-apps.ts new file mode 100644 index 00000000000..27c0f7606c0 --- /dev/null +++ b/features/admin.consents.v1/api/consent-policy-apps.ts @@ -0,0 +1,128 @@ +/** + * 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 { AsgardeoSPAClient, HttpClientInstance } from "@asgardeo/auth-react"; +import { store } from "@wso2is/admin.core.v1/store"; +import { HttpMethods } from "@wso2is/core/models"; +import { AxiosError, AxiosRequestConfig, AxiosResponse } from "axios"; + +const httpClient: HttpClientInstance = AsgardeoSPAClient.getInstance() + .httpRequest.bind(AsgardeoSPAClient.getInstance()); + +const getPurposeAppsUrl = (purposeId: string): string => + `${store.getState().config.endpoints.consentPolicyApps}/${purposeId}/applications`; + +const getPurposeAppUrl = (purposeId: string, applicationId: string): string => + `${getPurposeAppsUrl(purposeId)}/${applicationId}`; + +const JSON_HEADERS: Record = { + "Accept": "application/json", + "Content-Type": "application/json" +}; + +interface ApplicationObject { + id: string; +} + +/** + * Fetches the application IDs assigned to a consent purpose. + * + * @param purposeId - The purpose UUID. + * @returns Array of application IDs, or empty array if the purpose has no assignments. + */ +const getConsentPolicyApps = (purposeId: string): Promise => { + const requestConfig: AxiosRequestConfig = { + headers: JSON_HEADERS, + method: HttpMethods.GET, + url: getPurposeAppsUrl(purposeId) + }; + + return httpClient(requestConfig) + .then((response: AxiosResponse): string[] => + (response.data as ApplicationObject[]).map((app: ApplicationObject): string => app.id) + ) + .catch((error: AxiosError): string[] => { + if (error?.response?.status === 404) { + return []; + } + throw error; + }); +}; + +/** + * Saves the application assignments for a consent purpose. + * Diffs against current state: adds new apps and removes de-selected ones. + * + * @param purposeId - The purpose UUID. + * @param newIds - The full desired set of application IDs. + */ +export const saveConsentPolicyApps = (purposeId: string, newIds: string[]): Promise => { + return getConsentPolicyApps(purposeId) + .then((currentIds: string[]): Promise => { + const currentSet: Set = new Set(currentIds); + const newSet: Set = new Set(newIds); + + const toAdd: string[] = newIds.filter((id: string): boolean => !currentSet.has(id)); + const toRemove: string[] = currentIds.filter((id: string): boolean => !newSet.has(id)); + + const addRequests: Promise[] = toAdd.map((id: string): Promise => { + const config: AxiosRequestConfig = { + data: { id } as ApplicationObject, + headers: JSON_HEADERS, + method: HttpMethods.POST, + url: getPurposeAppsUrl(purposeId) + }; + + return httpClient(config).then((): void => undefined); + }); + + const removeRequests: Promise[] = toRemove.map((id: string): Promise => { + const config: AxiosRequestConfig = { + headers: JSON_HEADERS, + method: HttpMethods.DELETE, + url: getPurposeAppUrl(purposeId, id) + }; + + return httpClient(config).then((): void => undefined); + }); + + return Promise.all([ ...addRequests, ...removeRequests ]).then((): void => undefined); + }); +}; + +/** + * Removes all application assignments for a consent purpose. + * + * @param purposeId - The purpose UUID. + */ +export const deleteConsentPolicyApps = (purposeId: string): Promise => { + return getConsentPolicyApps(purposeId) + .then((currentIds: string[]): Promise => { + const removeRequests: Promise[] = currentIds.map((id: string): Promise => { + const config: AxiosRequestConfig = { + headers: JSON_HEADERS, + method: HttpMethods.DELETE, + url: getPurposeAppUrl(purposeId, id) + }; + + return httpClient(config).then((): void => undefined); + }); + + return Promise.all(removeRequests).then((): void => undefined); + }); +}; diff --git a/features/admin.consents.v1/components/consent-description-editor.tsx b/features/admin.consents.v1/components/consent-description-editor.tsx index a05fa493397..524d29f287e 100644 --- a/features/admin.consents.v1/components/consent-description-editor.tsx +++ b/features/admin.consents.v1/components/consent-description-editor.tsx @@ -401,7 +401,9 @@ const ConsentEditorToolbar: FunctionComponent void = (): void => { - if (isValidPolicyUrl) { + if (isLink) { + editor.dispatchCommand(TOGGLE_LINK_COMMAND, null); + } else if (isValidPolicyUrl) { editor.dispatchCommand(TOGGLE_LINK_COMMAND, policyUrl); } }; @@ -419,6 +421,9 @@ const ConsentEditorToolbar: FunctionComponent string = (): string => { + if (isLink) { + return t("consents:policyConsents.wizard.create.form.description.removePolicyLink"); + } if (!policyUrl) { return t("consents:policyConsents.wizard.create.form.description.insertPolicyLinkNoPolicyUrl"); } @@ -479,43 +484,42 @@ const ConsentEditorToolbar: FunctionComponent - - - { t("consents:policyConsents.wizard.create.form.description.insertCustomLinkShort") } - - { policyUrl !== undefined && ( - <> - - { /* - * Wrap in a so the Tooltip still shows on a disabled button. - * MUI/Oxygen Tooltip requires a non-disabled child to trigger. - */ } - - - - - { t("consents:policyConsents.wizard.create.form.description.insertPolicyLinkShort") } - - - - + { policyUrl === undefined ? ( + + + { t("consents:policyConsents.wizard.create.form.description.insertCustomLinkShort") } + + ) : ( + + + + + { t("consents:policyConsents.wizard.create.form.description.insertPolicyLinkShort") } + + + ) } @@ -678,7 +682,7 @@ export const ConsentDescriptionEditor: FunctionComponent - + { variant === "preference" && } = ( - Enable branding to update default policies.{ " " } + Enable branding to update default policies. - + + - - this link - - - ) } - /> + this link + + + { /* Preview column */ } diff --git a/features/admin.consents.v1/components/policy-consent-applications.tsx b/features/admin.consents.v1/components/policy-consent-applications.tsx new file mode 100644 index 00000000000..7bcb1fa760f --- /dev/null +++ b/features/admin.consents.v1/components/policy-consent-applications.tsx @@ -0,0 +1,545 @@ +/** + * 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 from "@oxygen-ui/react/Box"; +import Button from "@oxygen-ui/react/Button"; +import { useRequiredScopes } from "@wso2is/access-control"; +import { AdvancedSearchWithBasicFilters } from "@wso2is/admin.core.v1/components/advanced-search-with-basic-filters"; +import { getEmptyPlaceholderIllustrations } from "@wso2is/admin.core.v1/configs/ui"; +import { UIConstants } from "@wso2is/admin.core.v1/constants/ui-constants"; +import { AppState } from "@wso2is/admin.core.v1/store"; +import { useApplicationList } from "@wso2is/admin.applications.v1/api/application"; +import { + ApplicationListInterface, + ApplicationListItemInterface +} from "@wso2is/admin.applications.v1/models/application"; +import { AlertLevels, FeatureAccessConfigInterface, IdentifiableComponentInterface } from "@wso2is/core/models"; +import { addAlert } from "@wso2is/core/store"; +import { + deleteConsentPolicyApps, + saveConsentPolicyApps +} from "../api/consent-policy-apps"; +import useGetConsentPolicyApps from "../hooks/use-get-consent-policy-apps"; +import { + DataTable, + EmphasizedSegment, + EmptyPlaceholder, + Heading, + LinkButton, + ListLayout, + PrimaryButton, + TableActionsInterface, + TableColumnInterface, + TransferComponent, + TransferList, + TransferListItem, + UserAvatar +} from "@wso2is/react-components"; +import React, { + FormEvent, + FunctionComponent, + ReactElement, + ReactNode, + SyntheticEvent, + useCallback, + useEffect, + useMemo, + useState +} from "react"; +import { useTranslation } from "react-i18next"; +import { useDispatch, useSelector } from "react-redux"; +import { Dispatch } from "redux"; +import { Divider, Header, Icon, Modal, PaginationProps, SemanticICONS } from "semantic-ui-react"; + +interface PolicyConsentApplicationsPropsInterface extends IdentifiableComponentInterface { + purposeId?: string; +} + +/** + * Applications tab — shows which applications are linked to this consent policy. + * Admins can assign or remove applications from the scope. + * + * @param props - Props injected to the component. + * @returns Policy Consent Applications component. + */ +export const PolicyConsentApplications: FunctionComponent = ( + props: PolicyConsentApplicationsPropsInterface +): ReactElement => { + const { + ["data-componentid"]: componentId = "policy-consent-applications" + } = props; + + const { t } = useTranslation(); + const dispatch: Dispatch = useDispatch(); + + const consentsFeatureConfig: FeatureAccessConfigInterface = useSelector( + (state: AppState) => state?.config?.ui?.features?.consents + ); + const hasUpdatePermission: boolean = useRequiredScopes(consentsFeatureConfig?.scopes?.update); + + const { + data: applicationListData, + isLoading: isApplicationListLoading + } = useApplicationList( + undefined, + undefined, + undefined, + undefined, + true, + false + ); + + const allApplications: ApplicationListItemInterface[] = useMemo( + (): ApplicationListItemInterface[] => applicationListData?.applications ?? [], + [ applicationListData ] + ); + + // IDs of applications that have been assigned to this consent policy scope. + const [ assignedIds, setAssignedIds ] = useState>(new Set()); + const [ isSaving, setIsSaving ] = useState(false); + + const { + data: fetchedIds, + isLoading: isConsentAppsLoading, + error: consentAppsError + } = useGetConsentPolicyApps(props.purposeId, !!props.purposeId && !isApplicationListLoading); + + useEffect((): void => { + setAssignedIds(new Set(fetchedIds)); + }, [ fetchedIds ]); + + useEffect((): void => { + if (!consentAppsError) { + return; + } + dispatch(addAlert({ + description: t("consents:policyConsents.notifications.update.error.description"), + level: AlertLevels.ERROR, + message: t("consents:policyConsents.notifications.update.error.message") + })); + }, [ consentAppsError ]); + + // Main list state (search + pagination over assigned apps). + const [ searchQuery, setSearchQuery ] = useState(""); + const [ triggerClearQuery, setTriggerClearQuery ] = useState(false); + const [ listItemLimit, setListItemLimit ] = useState(UIConstants.DEFAULT_RESOURCE_LIST_ITEM_LIMIT); + const [ listOffset, setListOffset ] = useState(0); + const [ paginatedAssigned, setPaginatedAssigned ] = useState([]); + + // Assign modal state. + const [ showAssignModal, setShowAssignModal ] = useState(false); + const [ modalSelectedIds, setModalSelectedIds ] = useState>(new Set()); + const [ modalSearchQuery, setModalSearchQuery ] = useState(""); + const [ isSelectAll, setIsSelectAll ] = useState(false); + + const assignedApplications: ApplicationListItemInterface[] = useMemo( + (): ApplicationListItemInterface[] => + allApplications.filter((app: ApplicationListItemInterface): boolean => assignedIds.has(app.id)), + [ allApplications, assignedIds ] + ); + + const unassignedApplications: ApplicationListItemInterface[] = useMemo( + (): ApplicationListItemInterface[] => + allApplications.filter((app: ApplicationListItemInterface): boolean => !assignedIds.has(app.id)), + [ allApplications, assignedIds ] + ); + + const filteredModalApps: ApplicationListItemInterface[] = useMemo( + (): ApplicationListItemInterface[] => { + if (!modalSearchQuery) { + return unassignedApplications; + } + + return unassignedApplications.filter( + (app: ApplicationListItemInterface): boolean => + app.name.toLowerCase().includes(modalSearchQuery.toLowerCase()) + ); + }, + [ unassignedApplications, modalSearchQuery ] + ); + + const filteredAssigned: ApplicationListItemInterface[] = useMemo( + (): ApplicationListItemInterface[] => { + if (!searchQuery) { + return assignedApplications; + } + + return assignedApplications.filter( + (app: ApplicationListItemInterface): boolean => + app.name.toLowerCase().includes(searchQuery.toLowerCase()) + ); + }, + [ assignedApplications, searchQuery ] + ); + + useEffect((): void => { + setPaginatedAssigned(filteredAssigned.slice(listOffset, listOffset + listItemLimit)); + }, [ filteredAssigned, listOffset, listItemLimit ]); + + const persistAssignments = async (nextIds: Set): Promise => { + if (!props.purposeId) { + return; + } + + try { + if (nextIds.size === 0) { + await deleteConsentPolicyApps(props.purposeId); + } else { + await saveConsentPolicyApps(props.purposeId, Array.from(nextIds)); + } + } catch (error: unknown) { + dispatch(addAlert({ + description: t("consents:policyConsents.notifications.update.error.description"), + level: AlertLevels.ERROR, + message: t("consents:policyConsents.notifications.update.error.message") + })); + throw error; + } + }; + + const handleRemoveApplication: (_app: ApplicationListItemInterface) => Promise = useCallback( + async (_app: ApplicationListItemInterface): Promise => { + const prev: Set = new Set(assignedIds); + const next: Set = new Set(prev); + + next.delete(_app.id); + setAssignedIds(next); + + try { + await persistAssignments(next); + } catch { + setAssignedIds(prev); + } + }, [ assignedIds, props.purposeId ]); + + const handleSearchQueryClear = (): void => { + setSearchQuery(""); + setTriggerClearQuery((prev: boolean): boolean => !prev); + setListOffset(0); + }; + + const handlePaginationChange = (_: React.MouseEvent, data: PaginationProps): void => { + setListOffset(((data.activePage as number) - 1) * listItemLimit); + }; + + const handleItemsPerPageDropdownChange = ( + _: React.MouseEvent, + data: { value: number } + ): void => { + setListItemLimit(data.value); + setListOffset(0); + }; + + const handleOpenAssignModal = (): void => { + setModalSelectedIds(new Set()); + setModalSearchQuery(""); + setIsSelectAll(false); + setShowAssignModal(true); + }; + + const handleModalToggle = (id: string): void => { + setModalSelectedIds((prev: Set): Set => { + const next: Set = new Set(prev); + + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + + return next; + }); + }; + + const handleSelectAll = (): void => { + if (!isSelectAll) { + setModalSelectedIds(new Set(filteredModalApps.map((app: ApplicationListItemInterface) => app.id))); + } else { + setModalSelectedIds(new Set()); + } + setIsSelectAll((prev: boolean): boolean => !prev); + }; + + const handleAssignConfirm: () => Promise = async (): Promise => { + setIsSaving(true); + + const prev: Set = new Set(assignedIds); + const next: Set = new Set(prev); + + modalSelectedIds.forEach((id: string): void => { next.add(id); }); + setAssignedIds(next); + setShowAssignModal(false); + + try { + await persistAssignments(next); + } catch { + setAssignedIds(prev); + } finally { + setIsSaving(false); + } + }; + + const resolveTableColumns = (): TableColumnInterface[] => [ + { + allowToggleVisibility: false, + dataIndex: "name", + id: "name", + key: "name", + render: (app: ApplicationListItemInterface): ReactNode => ( +
+ + + { app.name } + { app.description && ( + { app.description } + ) } + +
+ ), + title: "" + }, + { + allowToggleVisibility: false, + dataIndex: "action", + id: "actions", + key: "actions", + textAlign: "right", + title: "" + } + ]; + + const resolveTableActions = (): TableActionsInterface[] => { + if (!hasUpdatePermission) { + return []; + } + + return [ + { + "data-componentid": `${componentId}-item-remove-button`, + icon: (): SemanticICONS => "trash", + onClick: (_: SyntheticEvent, app: ApplicationListItemInterface): void => { + handleRemoveApplication(app); + }, + popupText: (): string => t("common:remove"), + renderer: "semantic-icon" + } + ]; + }; + + const showPlaceholders = (): ReactElement => { + if (searchQuery) { + return ( + + { t("users:usersList.search.emptyResultPlaceholder.clearButton") } + + ) } + image={ getEmptyPlaceholderIllustrations().emptySearch } + imageSize="tiny" + title={ t("users:usersList.search.emptyResultPlaceholder.title") } + subtitle={ [ + t("users:usersList.search.emptyResultPlaceholder.subTitle.0", { query: searchQuery }), + t("users:usersList.search.emptyResultPlaceholder.subTitle.1") + ] } + /> + ); + } + + if (assignedApplications.length === 0) { + return ( + + + { t("consents:policyConsents.promptScope.assignButton") } + + ) } + image={ getEmptyPlaceholderIllustrations().emptyList } + imageSize="tiny" + title={ t("consents:policyConsents.promptScope.noApplications") } + subtitle={ [ t("consents:policyConsents.promptScope.noApplicationsSubtitle") ] } + /> + ); + } + + return null; + }; + + const advancedSearchFilter = (): ReactElement => ( + { + setSearchQuery(query?.trim() ?? ""); + setListOffset(0); + } } + disableSearchFilterDropdown + filterAttributeOptions={ [] } + placeholder={ t("consents:policyConsents.promptScope.searchPlaceholder") } + defaultSearchAttribute="" + defaultSearchOperator="" + triggerClearQuery={ triggerClearQuery } + /> + ); + + return ( + <> + + +
+ + { t("consents:policyConsents.promptScope.header") } + + + { t("consents:policyConsents.promptScope.subHeading") } + +
+ { hasUpdatePermission && assignedApplications.length > 0 && ( + + + { t("consents:policyConsents.promptScope.assignButton") } + + ) } +
+
+ + { /* Assign Applications modal */ } + + + { t("consents:policyConsents.promptScope.assignModal.header") } + + { t("consents:policyConsents.promptScope.assignModal.subHeading") } + + + + , { value }: { value: string }): void => { + setModalSearchQuery(value ?? ""); + setIsSelectAll(false); + } + } + showSelectAllCheckbox={ !isApplicationListLoading && filteredModalApps.length > 0 } + data-componentid={ `${componentId}-transfer-component` } + > + + { filteredModalApps.map((app: ApplicationListItemInterface, index: number): ReactElement => ( + handleModalToggle(app.id) } + listItem={ app.name } + listItemId={ app.id } + listItemIndex={ index } + isItemChecked={ modalSelectedIds.has(app.id) } + showSecondaryActions={ false } + showListSubItem={ !!app.description } + listSubItem={ app.description } + data-componentid={ `${componentId}-transfer-list-item-${index}` } + /> + )) } + + + + + setShowAssignModal(false) } + floated="left" + > + { t("common:cancel") } + + + + + + ); +}; diff --git a/features/admin.consents.v1/components/policy-consent-preview.tsx b/features/admin.consents.v1/components/policy-consent-preview.tsx index c645914c264..b7a6598fe96 100644 --- a/features/admin.consents.v1/components/policy-consent-preview.tsx +++ b/features/admin.consents.v1/components/policy-consent-preview.tsx @@ -227,9 +227,9 @@ export const PolicyConsentPreview: FunctionComponent diff --git a/features/admin.consents.v1/components/policy-consents-list.tsx b/features/admin.consents.v1/components/policy-consents-list.tsx index 306bdb0d185..1f712be9e6c 100644 --- a/features/admin.consents.v1/components/policy-consents-list.tsx +++ b/features/admin.consents.v1/components/policy-consents-list.tsx @@ -138,7 +138,7 @@ export const PolicyConsentsList: FunctionComponent = ( { "data-componentid": `${componentId}-item-view-button`, hidden: (item: ListItem): boolean => - !hasReadPermission || (hasUpdatePermission && (!item.isDefault || isBrandingEnabled)), + !hasReadPermission || (hasUpdatePermission && (!item.isDefault || isBrandingEnabled || !!item.id)), icon: (): SemanticICONS => "eye", onClick: (_e: SyntheticEvent, consent: ListItem): void => onEditConsentClick(consent), @@ -148,7 +148,7 @@ export const PolicyConsentsList: FunctionComponent = ( { "data-componentid": `${componentId}-item-edit-button`, hidden: (item: ListItem): boolean => - !hasUpdatePermission || (!!item.isDefault && !isBrandingEnabled), + !hasUpdatePermission || (!!item.isDefault && !isBrandingEnabled && !item.id), icon: (): SemanticICONS => "pencil alternate", onClick: (_e: SyntheticEvent, consent: ListItem): void => onEditConsentClick(consent), @@ -158,7 +158,7 @@ export const PolicyConsentsList: FunctionComponent = ( { "data-componentid": `${componentId}-item-delete-button`, hidden: (item: ListItem): boolean => - !hasDeletePermission || isCrossTenant(item) || !!item.isDefault, + !hasDeletePermission || isCrossTenant(item) || (!!item.isDefault && !item.id), icon: (): SemanticICONS => "trash alternate", onClick: (_e: SyntheticEvent, consent: ListItem): void => onDeleteConsentClick(consent), diff --git a/features/admin.consents.v1/hooks/use-get-consent-policy-apps.ts b/features/admin.consents.v1/hooks/use-get-consent-policy-apps.ts new file mode 100644 index 00000000000..6f8ffaa2a51 --- /dev/null +++ b/features/admin.consents.v1/hooks/use-get-consent-policy-apps.ts @@ -0,0 +1,88 @@ +/** + * 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 useRequest, { + RequestConfigInterface, + RequestErrorInterface +} from "@wso2is/admin.core.v1/hooks/use-request"; +import { store } from "@wso2is/admin.core.v1/store"; +import { HttpMethods } from "@wso2is/core/models"; +import { AxiosError, AxiosResponse } from "axios"; +import { useMemo } from "react"; +import { KeyedMutator } from "swr"; + +interface ApplicationInterface { + id: string; +} + +interface UseGetConsentPolicyAppsReturnInterface { + data: string[]; + error: AxiosError | undefined; + isLoading: boolean; + isValidating: boolean; + mutate: KeyedMutator>; +} + +/** + * Hook to get the application IDs assigned to a consent purpose. + * + * @param purposeId - The purpose UUID. + * @param shouldFetch - Whether to trigger the request. Defaults to `true`. + * @returns SWR response with `data` as an array of application ID strings. + */ +const useGetConsentPolicyApps = ( + purposeId: string, + shouldFetch: boolean = true +): UseGetConsentPolicyAppsReturnInterface => { + const requestConfig: RequestConfigInterface = useMemo( + (): RequestConfigInterface => ({ + headers: { + "Accept": "application/json", + "Content-Type": "application/json" + }, + method: HttpMethods.GET, + url: `${store.getState().config.endpoints.consentPolicyApps}/${purposeId}/applications` + }), + [ purposeId ] + ); + + const { data, error, isLoading, isValidating, mutate } = useRequest( + shouldFetch && purposeId ? requestConfig : null + ); + + const applicationIds: string[] = useMemo( + (): string[] => { + if (error?.response?.status === 404) { + return []; + } + + return data?.map((app: ApplicationInterface): string => app.id) ?? []; + }, + [ data, error ] + ); + + return { + data: applicationIds, + error: error?.response?.status === 404 ? undefined : error, + isLoading, + isValidating, + mutate + }; +}; + +export default useGetConsentPolicyApps; diff --git a/features/admin.consents.v1/package.json b/features/admin.consents.v1/package.json index f8394be61fa..7bbf21f5251 100644 --- a/features/admin.consents.v1/package.json +++ b/features/admin.consents.v1/package.json @@ -13,6 +13,7 @@ "@lexical/utils": "^0.21.0", "@oxygen-ui/react": "^2.4.6", "@oxygen-ui/react-icons": "^2.4.6", + "@wso2is/admin.applications.v1": "workspace:^", "@wso2is/admin.branding.v1": "workspace:^", "@wso2is/admin.claims.v1": "workspace:^", "@wso2is/admin.core.v1": "workspace:^", diff --git a/features/admin.consents.v1/pages/policy-consent-edit.tsx b/features/admin.consents.v1/pages/policy-consent-edit.tsx index 442188d77a4..64e1c95b469 100644 --- a/features/admin.consents.v1/pages/policy-consent-edit.tsx +++ b/features/admin.consents.v1/pages/policy-consent-edit.tsx @@ -31,13 +31,17 @@ import { DangerZoneGroup, PageLayout } from "@wso2is/react-components"; -import React, { FunctionComponent, ReactElement, useState } from "react"; +import Tab from "@oxygen-ui/react/Tab"; +import TabPanel from "@oxygen-ui/react/TabPanel"; +import Tabs from "@oxygen-ui/react/Tabs"; +import React, { FunctionComponent, ReactElement, SyntheticEvent, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useDispatch, useSelector } from "react-redux"; import { RouteComponentProps } from "react-router"; import { Dispatch } from "redux"; import { Label } from "semantic-ui-react"; import { EditPolicyConsent } from "../components/edit-policy-consent"; +import { PolicyConsentApplications } from "../components/policy-consent-applications"; import { DEFAULT_POLICY_PATH_MAP } from "../constants/default-policies"; /** @@ -76,6 +80,17 @@ const PolicyConsentEditPage: FunctionComponent = ( const [ showDeleteConfirmation, setShowDeleteConfirmation ] = useState(false); const [ isDeleting, setIsDeleting ] = useState(false); + const [ activeTab, setActiveTab ] = useState(0); + + const handleTabChange = (_: SyntheticEvent, newValue: number): void => { + setActiveTab(newValue); + }; + + useEffect((): void => { + if (isDefaultPolicy) { + setActiveTab(0); + } + }, [ isDefaultPolicy ]); // Only fetch from the API when the id is a real UUID, not a default-policy slug. const { data: consent, isLoading: isConsentLoading } = useGetPurpose(isDefaultPolicy ? "" : id); @@ -193,23 +208,37 @@ const PolicyConsentEditPage: FunctionComponent = ( > { (isDefaultPolicy || consent) && ( <> - - { hasDeletePermission && !isCrossTenant && !isDefaultPolicy && ( - - setShowDeleteConfirmation(true) } + + + { !isDefaultPolicy && } + + + + { hasDeletePermission && !isCrossTenant && !isDefaultPolicy && ( + + setShowDeleteConfirmation(true) } + /> + + ) } + + { !isDefaultPolicy && ( + + - + ) } setShowDeleteConfirmation(false) } diff --git a/features/admin.consents.v1/pages/policy-consents.tsx b/features/admin.consents.v1/pages/policy-consents.tsx index 9a8f2945d08..883609c9242 100644 --- a/features/admin.consents.v1/pages/policy-consents.tsx +++ b/features/admin.consents.v1/pages/policy-consents.tsx @@ -361,7 +361,7 @@ const PolicyConsentsPage: FunctionComponent = (props: P { !isBrandingEnabled && ( - Enable branding to update default policies.{ " " } + Enable branding to update default policies. history.push(AppConstants.getPaths().get("BRANDING")) } sx={ { cursor: "pointer" } } diff --git a/features/admin.core.v1/store/reducers/config.ts b/features/admin.core.v1/store/reducers/config.ts index 00b1a07e347..c0250cfe719 100644 --- a/features/admin.core.v1/store/reducers/config.ts +++ b/features/admin.core.v1/store/reducers/config.ts @@ -116,6 +116,7 @@ export const commonConfigReducerInitialState: CommonConfigReducerStateInterface< clientCertificates: "", consentMgtElements: "", consentMgtPurposes: "", + consentPolicyApps: "", copilot: "", createSecret: "", createSecretType: "", diff --git a/features/common.consents.v1/configs/endpoints.ts b/features/common.consents.v1/configs/endpoints.ts index bf9bfd1cee1..d72322f7689 100644 --- a/features/common.consents.v1/configs/endpoints.ts +++ b/features/common.consents.v1/configs/endpoints.ts @@ -27,8 +27,13 @@ import { ConsentMgtResourceEndpointsInterface } from "../models/endpoints"; export const getConsentMgtResourceEndpoints = (serverHost: string): ConsentMgtResourceEndpointsInterface => { const normalizedHost: string = serverHost?.replace(/\/+$/, "") ?? ""; + // config-mgt does not support the sub-org path (/o or /o/). + // Strip it so requests always go to the tenant-level endpoint. + const tenantHost: string = normalizedHost.replace(/\/o(\/.*)?$/, ""); + return { consentMgtElements: `${ normalizedHost }/api/identity/consent-mgt/v2.0/elements`, - consentMgtPurposes: `${ normalizedHost }/api/identity/consent-mgt/v2.0/purposes` + consentMgtPurposes: `${ normalizedHost }/api/identity/consent-mgt/v2.0/purposes`, + consentPolicyApps: `${ tenantHost }/api/server/v1/configs/consent/purposes` }; }; diff --git a/features/common.consents.v1/models/endpoints.ts b/features/common.consents.v1/models/endpoints.ts index 8c53d86ab1e..4910236b038 100644 --- a/features/common.consents.v1/models/endpoints.ts +++ b/features/common.consents.v1/models/endpoints.ts @@ -20,6 +20,10 @@ * Interface for the Consent Management resource endpoints. */ export interface ConsentMgtResourceEndpointsInterface { + /** + * Base URL for consent policy app assignments: /api/server/v1/configs/consent/purposes + */ + consentPolicyApps: string; /** * Base URL for consent management elements: /api/identity/consent-mgt/v2.0/elements */ diff --git a/modules/i18n/src/models/namespaces/consents-ns.ts b/modules/i18n/src/models/namespaces/consents-ns.ts index 8efa08b4d63..357fe01f5f5 100644 --- a/modules/i18n/src/models/namespaces/consents-ns.ts +++ b/modules/i18n/src/models/namespaces/consents-ns.ts @@ -213,6 +213,20 @@ export interface ConsentsNS { title: string; }; }; + promptScope: { + assignButton: string; + assignModal: { + header: string; + noApps: string; + subHeading: string; + }; + header: string; + noApplications: string; + noApplicationsSubtitle: string; + searchPlaceholder: string; + selectAll: string; + subHeading: string; + }; wizard: { create: { form: { @@ -246,6 +260,7 @@ export interface ConsentsNS { insertPolicyLinkNoPolicyUrl: string; insertPolicyLinkNoSelection: string; labelRoleHint: string; + removePolicyLink: string; }; }; preview: { @@ -281,7 +296,9 @@ export interface ConsentsNS { termsOfService: string; }; tabs: { + applications: { label: string }; content: { label: string }; + general: { label: string }; preview: { label: string }; }; } diff --git a/modules/i18n/src/translations/en-US/portals/consents.ts b/modules/i18n/src/translations/en-US/portals/consents.ts index 4c2224144f2..b21ce1b26c9 100644 --- a/modules/i18n/src/translations/en-US/portals/consents.ts +++ b/modules/i18n/src/translations/en-US/portals/consents.ts @@ -321,6 +321,20 @@ export const consents: ConsentsNS = { title: "Create Policy" } }, + promptScope: { + assignButton: "Assign Application", + assignModal: { + header: "Assign Applications", + noApps: "All applications are already assigned.", + subHeading: "Assign applications to this consent policy." + }, + header: "Assign Applications", + noApplications: "No applications assigned.", + noApplicationsSubtitle: "Assign applications to prompt users to review and accept this consent policy.", + searchPlaceholder: "Search by application name", + selectAll: "Select All Applications", + subHeading: "Select which applications will prompt users to review and accept this consent policy." + }, wizard: { create: { form: { @@ -365,7 +379,8 @@ export const consents: ConsentsNS = { insertPolicyLinkNoSelection: "Select the words you want to link, then click.", insertPolicyLinkShort: "Policy Link", insertPolicyLinkTooltip: "Wraps the selected text with your Policy URL as a hyperlink.", - labelRoleHint: "The checkbox label shown to users. Leave empty to use the default. Highlight text and click \"Policy Link\" to add the hyperlink." + labelRoleHint: "The checkbox label shown to users. Leave empty to use the default. Highlight text and click \"Policy Link\" to add the hyperlink.", + removePolicyLink: "Remove the policy link from the selected text." } }, preview: { @@ -401,7 +416,9 @@ export const consents: ConsentsNS = { termsOfService: "Terms of Service" }, tabs: { + applications: { label: "Applications" }, content: { label: "Content" }, + general: { label: "General" }, preview: { label: "Preview" } } }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 836544d128c..16fc99eae7a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3433,6 +3433,9 @@ importers: '@oxygen-ui/react-icons': specifier: ^2.4.6 version: 2.4.6(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.9.3) + '@wso2is/admin.applications.v1': + specifier: workspace:^ + version: link:../admin.applications.v1 '@wso2is/admin.branding.v1': specifier: workspace:^ version: link:../admin.branding.v1