From b1476b28ac486b66556c274e1ec77f1c3e7dadbd Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Sat, 18 Jul 2026 16:03:10 +0100 Subject: [PATCH 1/5] feat(trust-relationships): Add API Access tab with trust relationships UI The organisation settings API Keys tab becomes API Access and gains a Trust Relationships section behind the trust_relationships feature flag: list, create, edit and delete, with a claim-rules editor and the same admin toggle and RBAC role assignment flow as master API keys, attached via the backing key. Also fixes two latent request types in the master API key role services. beep boop --- .../useMasterAPIKeyWithMasterAPIKeyRole.ts | 2 +- .../common/services/useTrustRelationship.ts | 87 +++++ frontend/common/types/requests.ts | 30 +- frontend/common/types/responses.ts | 20 + frontend/web/components/AdminAPIKeys.js | 38 +- .../OrganisationSettingsPage.tsx | 24 +- .../organisation-settings/tabs/APIKeysTab.tsx | 11 +- .../GithubTrustRelationshipForm.tsx | 345 ++++++++++++++++++ .../NewTrustRelationshipModal.tsx | 58 +++ .../TrustRelationshipModal.tsx | 280 ++++++++++++++ .../TrustRelationshipPermissionsFields.tsx | 84 +++++ .../WorkflowSetupSnippet.tsx | 85 +++++ .../tabs/trust-relationships/errors.ts | 18 + .../tabs/trust-relationships/index.tsx | 194 ++++++++++ .../tabs/trust-relationships/providers.tsx | 24 ++ 15 files changed, 1277 insertions(+), 23 deletions(-) create mode 100644 frontend/common/services/useTrustRelationship.ts create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm.tsx create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal.tsx create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal.tsx create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields.tsx create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet.tsx create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/errors.ts create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/index.tsx create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/providers.tsx diff --git a/frontend/common/services/useMasterAPIKeyWithMasterAPIKeyRole.ts b/frontend/common/services/useMasterAPIKeyWithMasterAPIKeyRole.ts index 0f948f2c1f4b..fb85d2204758 100644 --- a/frontend/common/services/useMasterAPIKeyWithMasterAPIKeyRole.ts +++ b/frontend/common/services/useMasterAPIKeyWithMasterAPIKeyRole.ts @@ -87,7 +87,7 @@ export async function getRolesMasterAPIKeyWithMasterAPIKeyRoles( export async function deleteMasterAPIKeyWithMasterAPIKeyRoles( store: any, - data: Req['getMasterAPIKeyWithMasterAPIKeyRoles'], + data: Req['deleteMasterAPIKeyWithMasterAPIKeyRoles'], options?: Parameters< typeof masterAPIKeyWithMasterAPIKeyRoleService.endpoints.deleteMasterAPIKeyWithMasterAPIKeyRoles.initiate >[1], diff --git a/frontend/common/services/useTrustRelationship.ts b/frontend/common/services/useTrustRelationship.ts new file mode 100644 index 000000000000..367727b98662 --- /dev/null +++ b/frontend/common/services/useTrustRelationship.ts @@ -0,0 +1,87 @@ +import { Res } from 'common/types/responses' +import { Req } from 'common/types/requests' +import { service } from 'common/service' + +export const trustRelationshipService = service + .enhanceEndpoints({ + addTagTypes: ['TrustRelationship'], + }) + .injectEndpoints({ + endpoints: (builder) => ({ + createTrustRelationship: builder.mutation< + Res['trustRelationship'], + Req['createTrustRelationship'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'TrustRelationship' }], + query: (query: Req['createTrustRelationship']) => ({ + body: query.body, + method: 'POST', + url: `organisations/${query.organisation_id}/trust-relationships/`, + }), + }), + deleteTrustRelationship: builder.mutation< + void, + Req['deleteTrustRelationship'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'TrustRelationship' }], + query: (query: Req['deleteTrustRelationship']) => ({ + method: 'DELETE', + url: `organisations/${query.organisation_id}/trust-relationships/${query.id}/`, + }), + }), + getTrustRelationships: builder.query< + Res['trustRelationships'], + Req['getTrustRelationships'] + >({ + providesTags: [{ id: 'LIST', type: 'TrustRelationship' }], + query: (query: Req['getTrustRelationships']) => ({ + url: `organisations/${query.organisation_id}/trust-relationships/`, + }), + }), + updateTrustRelationship: builder.mutation< + Res['trustRelationship'], + Req['updateTrustRelationship'] + >({ + invalidatesTags: (res) => [ + { id: 'LIST', type: 'TrustRelationship' }, + { id: res?.id, type: 'TrustRelationship' }, + ], + query: (query: Req['updateTrustRelationship']) => ({ + body: query.body, + method: 'PUT', + url: `organisations/${query.organisation_id}/trust-relationships/${query.id}/`, + }), + }), + // END OF ENDPOINTS + }), + }) + +export async function getTrustRelationships( + store: any, + data: Req['getTrustRelationships'], + options?: Parameters< + typeof trustRelationshipService.endpoints.getTrustRelationships.initiate + >[1], +) { + return store.dispatch( + trustRelationshipService.endpoints.getTrustRelationships.initiate( + data, + options, + ), + ) +} +// END OF FUNCTION_EXPORTS + +export const { + useCreateTrustRelationshipMutation, + useDeleteTrustRelationshipMutation, + useGetTrustRelationshipsQuery, + useUpdateTrustRelationshipMutation, + // END OF EXPORTS +} = trustRelationshipService + +/* Usage examples: +const { data, isLoading } = useGetTrustRelationshipsQuery({ organisation_id: 2 }) //get hook +const [createTrustRelationship, { isLoading, data, isSuccess }] = useCreateTrustRelationshipMutation() //create hook +trustRelationshipService.endpoints.getTrustRelationships.select({organisation_id: 2})(store.getState()) //access data from any function +*/ diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index fe7053e9cfbb..d6a25d9cd1ff 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -33,6 +33,7 @@ import { TagStrategy, FeatureType, LifecycleStage, + TrustRelationshipClaimRule, } from './responses' import { UtmsType } from './utms' @@ -494,7 +495,11 @@ export type Req = { getRoleMasterApiKey: { org_id: number; role_id: number; id: string } updateRoleMasterApiKey: { org_id: number; role_id: number; id: string } deleteRoleMasterApiKey: { org_id: number; role_id: number; id: string } - createRoleMasterApiKey: { org_id: number; role_id: number } + createRoleMasterApiKey: { + org_id: number + role_id: number + body: { master_api_key: string } + } getMasterAPIKeyWithMasterAPIKeyRoles: { org_id: number; prefix: string } deleteMasterAPIKeyWithMasterAPIKeyRoles: { org_id: number @@ -502,6 +507,29 @@ export type Req = { role_id: number } getRolesMasterAPIKeyWithMasterAPIKeyRoles: { org_id: number; prefix: string } + getTrustRelationships: { organisation_id: number } + createTrustRelationship: { + organisation_id: number + body: { + name: string + issuer: string + audience: string + claim_rules: TrustRelationshipClaimRule[] + is_admin: boolean + } + } + updateTrustRelationship: { + organisation_id: number + id: number + body: { + name: string + issuer: string + audience: string + claim_rules: TrustRelationshipClaimRule[] + is_admin: boolean + } + } + deleteTrustRelationship: { organisation_id: number; id: number } createLaunchDarklyProjectImport: { project_id: number body: { diff --git a/frontend/common/types/responses.ts b/frontend/common/types/responses.ts index fa51e33faee2..4f74607db5e2 100644 --- a/frontend/common/types/responses.ts +++ b/frontend/common/types/responses.ts @@ -1293,6 +1293,24 @@ export type WarehouseConnection = { unique_events_count: number | null } +export type TrustRelationshipClaimRule = { + claim: string + values: string[] +} + +export type TrustRelationship = { + id: number + name: string + issuer: string + audience: string + claim_rules: TrustRelationshipClaimRule[] + is_admin: boolean + master_api_key_id: string + master_api_key_prefix: string + created_at: string + created_by: number | null +} + export type Res = { segments: PagedResponse segment: Segment @@ -1383,6 +1401,8 @@ export type Res = { launchDarklyProjectImport: LaunchDarklyProjectImport launchDarklyProjectsImport: LaunchDarklyProjectImport[] roleMasterApiKey: { id: number; master_api_key: string; role: number } + trustRelationship: TrustRelationship + trustRelationships: PagedResponse masterAPIKeyWithMasterAPIKeyRoles: { id: string prefix: string diff --git a/frontend/web/components/AdminAPIKeys.js b/frontend/web/components/AdminAPIKeys.js index 45c9f13d4a0f..d5d43748d3de 100644 --- a/frontend/web/components/AdminAPIKeys.js +++ b/frontend/web/components/AdminAPIKeys.js @@ -7,6 +7,8 @@ import OrganisationStore from 'common/stores/organisation-store' import getUserDisplayName from 'common/utils/getUserDisplayName' import Token from './Token' import JSONReference from './JSONReference' +import PageTitle from './PageTitle' +import PlanBasedBanner from './PlanBasedAccess' import Button from './base/forms/Button' import DateSelect from './DateSelect' import Icon from './icons/Icon' @@ -201,8 +203,8 @@ export class CreateAPIKey extends PureComponent { /> <> - - + + { this.setState({ @@ -212,7 +214,13 @@ export class CreateAPIKey extends PureComponent { checked={is_admin} disabled={!Utils.getPlansPermission('RBAC') && is_admin} /> + + {!is_admin && ( <> @@ -439,12 +447,19 @@ export default class AdminAPIKeys extends PureComponent { title={'API Keys'} json={apiKeys} /> - -
{`${'Manage'} API Keys`}
-

- {`API keys are used to authenticate with the Admin API.`} -

-
+
+ + {`Create API Key`} + + } + > + {`API keys are used to authenticate with the Admin API. `} -
- - + +
{(this.state.isLoading || !OrganisationStore.model?.users) && (
diff --git a/frontend/web/components/pages/organisation-settings/OrganisationSettingsPage.tsx b/frontend/web/components/pages/organisation-settings/OrganisationSettingsPage.tsx index 519e0ba6177e..2190e3d528c4 100644 --- a/frontend/web/components/pages/organisation-settings/OrganisationSettingsPage.tsx +++ b/frontend/web/components/pages/organisation-settings/OrganisationSettingsPage.tsx @@ -40,15 +40,20 @@ const OrganisationSettingsPage: FC = () => { API.trackPage(Constants.pages.ORGANISATION_SETTINGS) }, []) - // Back-compat: the SAML tab was renamed to SSO. Existing bookmarks and - // links pointing at ?tab=saml redirect to ?tab=sso so users land in the - // right place. + // Back-compat: renamed tabs redirect so existing bookmarks and links land + // in the right place (SAML became SSO; the API Keys tab became API Access + // with the `keys` slug). const history = useHistory() const location = useLocation() useEffect(() => { + const legacyTabs: Record = { + 'api-keys': 'keys', + 'saml': 'sso', + } const params = new URLSearchParams(location.search) - if (params.get('tab') === 'saml') { - params.set('tab', 'sso') + const redirectTo = legacyTabs[params.get('tab') || ''] + if (redirectTo) { + params.set('tab', redirectTo) history.replace(`${location.pathname}?${params.toString()}`) } }, [history, location]) @@ -116,7 +121,7 @@ const OrganisationSettingsPage: FC = () => { component: , isVisible: true, key: 'keys', - label: 'API Keys', + label: 'API Access', }, { component: , @@ -137,7 +142,12 @@ const OrganisationSettingsPage: FC = () => { {tabs.map(({ component, key, label }) => ( - + {component} ))} diff --git a/frontend/web/components/pages/organisation-settings/tabs/APIKeysTab.tsx b/frontend/web/components/pages/organisation-settings/tabs/APIKeysTab.tsx index 7db70e515a2d..be4b4b0e2cc3 100644 --- a/frontend/web/components/pages/organisation-settings/tabs/APIKeysTab.tsx +++ b/frontend/web/components/pages/organisation-settings/tabs/APIKeysTab.tsx @@ -1,10 +1,19 @@ import React from 'react' import AdminAPIKeys from 'components/AdminAPIKeys' +import Utils from 'common/utils/utils' +import TrustRelationships from './trust-relationships' type APIKeysTabProps = { organisationId: number } export const APIKeysTab = ({ organisationId }: APIKeysTabProps) => { - return + return ( + <> + + {Utils.getFlagsmithHasFeature('trust_relationships') && ( + + )} + + ) } diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm.tsx new file mode 100644 index 000000000000..153c68aefa3f --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm.tsx @@ -0,0 +1,345 @@ +import React, { FC, useEffect, useMemo, useState } from 'react' +import Button from 'components/base/forms/Button' +import ErrorMessage from 'components/ErrorMessage' +import Input from 'components/base/forms/Input' +import InputGroup from 'components/base/forms/InputGroup' +import Utils from 'common/utils/utils' +import { getStore } from 'common/store' +import { + Repository, + TrustRelationship, + TrustRelationshipClaimRule, +} from 'common/types/responses' +import { useGetGithubIntegrationQuery } from 'common/services/useGithubIntegration' +import { useGetGithubReposQuery } from 'common/services/useGithub' +import { + useCreateTrustRelationshipMutation, + useUpdateTrustRelationshipMutation, +} from 'common/services/useTrustRelationship' +import { createRoleMasterApiKey } from 'common/services/useRoleMasterApiKey' +import { + deleteMasterAPIKeyWithMasterAPIKeyRoles, + getRolesMasterAPIKeyWithMasterAPIKeyRoles, +} from 'common/services/useMasterAPIKeyWithMasterAPIKeyRole' +import TrustRelationshipPermissionsFields, { + SelectedRole, +} from './TrustRelationshipPermissionsFields' +import WorkflowSetupSnippet, { GITHUB_ISSUER } from './WorkflowSetupSnippet' + +type GithubTrustRelationshipFormProps = { + organisationId: number + existingAudiences: string[] + trustRelationship?: TrustRelationship +} + +const ruleValue = ( + trustRelationship: TrustRelationship | undefined, + claim: string, +): string | undefined => + trustRelationship?.claim_rules.find((rule) => rule.claim === claim)?.values[0] + +const GithubTrustRelationshipForm: FC = ({ + existingAudiences, + organisationId, + trustRelationship, +}) => { + const isEdit = !!trustRelationship + const { data: githubIntegrations } = useGetGithubIntegrationQuery({ + organisation_id: organisationId, + }) + const installationId = githubIntegrations?.results?.[0]?.installation_id + const { data: repos } = useGetGithubReposQuery( + { installation_id: installationId || '', organisation_id: organisationId }, + { skip: !installationId }, + ) + + const initialRepoFullName = ruleValue(trustRelationship, 'repository') || '' + const pinnedRepoId = ruleValue(trustRelationship, 'repository_id') + + const [selectedRepo, setSelectedRepo] = useState(null) + const [manualOwner, setManualOwner] = useState( + initialRepoFullName.split('/')[0] || '', + ) + const [manualRepo, setManualRepo] = useState( + initialRepoFullName.split('/')[1] || '', + ) + const [environment, setEnvironment] = useState( + ruleValue(trustRelationship, 'environment') || '', + ) + const [isAdmin, setIsAdmin] = useState(trustRelationship?.is_admin ?? true) + const [roles, setRoles] = useState([]) + + // A repository pinned by ID resolves through the installation's repo list. + const pinnedRepo = useMemo( + () => + pinnedRepoId + ? repos?.results?.find((repo) => `${repo.id}` === pinnedRepoId) + : undefined, + [pinnedRepoId, repos], + ) + useEffect(() => { + if (pinnedRepo && !selectedRepo) { + setSelectedRepo(pinnedRepo) + } + }, [pinnedRepo, selectedRepo]) + + useEffect(() => { + if (trustRelationship) { + getRolesMasterAPIKeyWithMasterAPIKeyRoles(getStore(), { + org_id: organisationId, + prefix: trustRelationship.master_api_key_prefix, + }).then((res: { data?: { results: SelectedRole[] } }) => { + setRoles(res.data?.results || []) + }) + } + }, [organisationId, trustRelationship]) + + const [ + createTrustRelationship, + { error: createError, isLoading: isCreating }, + ] = useCreateTrustRelationshipMutation() + const [ + updateTrustRelationship, + { error: updateError, isLoading: isUpdating }, + ] = useUpdateTrustRelationshipMutation() + + const isUnresolvedPin = !!pinnedRepoId && !pinnedRepo + const owner = selectedRepo?.owner?.login || manualOwner.trim() + const manualFullName = + manualOwner.trim() && manualRepo.trim() + ? `${manualOwner.trim()}/${manualRepo.trim()}` + : '' + const repoFullName = selectedRepo ? selectedRepo.full_name : manualFullName + const repoChanged = + !isEdit || (!isUnresolvedPin && repoFullName !== initialRepoFullName) + + // GitHub's default `aud` is the repository owner's URL, so the first trust + // relationship per owner needs no audience configuration in the workflow. + const audience = useMemo(() => { + if (isEdit && !repoChanged) return trustRelationship.audience + if (!owner) return '' + const otherAudiences = existingAudiences.filter( + (existing) => existing !== trustRelationship?.audience, + ) + const ownerAudience = `https://github.com/${owner}` + return otherAudiences.includes(ownerAudience) + ? `https://github.com/${repoFullName}` + : ownerAudience + }, [ + isEdit, + repoChanged, + trustRelationship, + owner, + repoFullName, + existingAudiences, + ]) + + const addRole = (role: SelectedRole) => { + if (isEdit) { + createRoleMasterApiKey(getStore(), { + body: { master_api_key: trustRelationship.master_api_key_id }, + org_id: organisationId, + role_id: role.id, + }).then(() => toast('Role assigned')) + } + setRoles((selected) => [...selected, { id: role.id, name: role.name }]) + } + + const removeRole = (roleId: number) => { + if (isEdit) { + deleteMasterAPIKeyWithMasterAPIKeyRoles(getStore(), { + org_id: organisationId, + prefix: trustRelationship.master_api_key_prefix, + role_id: roleId, + }).then(() => toast('Role removed')) + } + setRoles((selected) => selected.filter((role) => role.id !== roleId)) + } + + const save = () => { + const claimRules: TrustRelationshipClaimRule[] = [] + if (isUnresolvedPin) { + claimRules.push({ claim: 'repository_id', values: [pinnedRepoId] }) + } else if (selectedRepo) { + // The numeric repository id survives renames; names are reclaimable. + claimRules.push({ + claim: 'repository_id', + values: [`${selectedRepo.id}`], + }) + } else { + claimRules.push({ claim: 'repository', values: [repoFullName] }) + } + if (environment.trim()) { + claimRules.push({ claim: 'environment', values: [environment.trim()] }) + } + const body = { + audience, + claim_rules: claimRules, + is_admin: isAdmin, + issuer: GITHUB_ISSUER, + name: + isUnresolvedPin && trustRelationship + ? trustRelationship.name + : `GitHub Actions (${repoFullName})`, + } + if (isEdit) { + updateTrustRelationship({ + body, + id: trustRelationship.id, + organisation_id: organisationId, + }) + .unwrap() + .then(() => { + toast('Trust relationship updated') + closeModal() + }) + .catch(() => null) + } else { + createTrustRelationship({ body, organisation_id: organisationId }) + .unwrap() + .then((created) => + Promise.all( + roles.map((role) => + createRoleMasterApiKey(getStore(), { + body: { master_api_key: created.master_api_key_id }, + org_id: organisationId, + role_id: role.id, + }), + ), + ), + ) + .then(() => { + toast('Trust relationship created') + closeModal() + }) + .catch(() => null) + } + } + + let repositoryFields = ( + <> + + setManualOwner(Utils.safeParseEventValue(e)) + } + placeholder='e.g. YourOrg' + /> + + setManualRepo(Utils.safeParseEventValue(e)) + } + placeholder='e.g. your-repo' + /> +
+ Install the{' '} + {' '} + to pick repositories from a list. +
+ + ) + if (isUnresolvedPin) { + repositoryFields = ( + + } + /> + ) + } else if (installationId) { + repositoryFields = ( + ({ + label: repo.full_name, + repo, + value: repo.full_name, + }))} + onChange={(option: { repo: Repository }) => + setSelectedRepo(option.repo) + } + /> + } + /> + ) + } + + return ( +
+ {repositoryFields} + + setEnvironment(Utils.safeParseEventValue(e)) + } + placeholder='e.g. production' + /> + {!!audience && ( + <> + + + + + )} + { + if (!isAdmin) { + setRoles([]) + } + setIsAdmin(!isAdmin) + }} + roles={roles} + onAddRole={addRole} + onRemoveRole={removeRole} + /> + +
+ +
+
+ ) +} + +export default GithubTrustRelationshipForm diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal.tsx new file mode 100644 index 000000000000..15088484a0e1 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal.tsx @@ -0,0 +1,58 @@ +import React, { FC, useState } from 'react' +import GithubTrustRelationshipForm from './GithubTrustRelationshipForm' +import TrustRelationshipModal from './TrustRelationshipModal' + +type Provider = 'github' | 'other' + +type NewTrustRelationshipModalProps = { + organisationId: number + existingAudiences: string[] +} + +const NewTrustRelationshipModal: FC = ({ + existingAudiences, + organisationId, +}) => { + const [provider, setProvider] = useState(null) + + if (provider === 'github') { + return ( + + ) + } + if (provider === 'other') { + return + } + + return ( +
+
setProvider('github')} + > +
GitHub Actions
+
+ Let workflows in a GitHub repository authenticate with their OIDC job + token. Recommended if your CI runs on GitHub Actions. +
+
+
setProvider('other')} + > +
Other OIDC provider
+
+ Configure a custom issuer, audience and claim matching rules for any + OIDC identity provider, such as GitLab CI or Kubernetes. +
+
+
+ ) +} + +export default NewTrustRelationshipModal diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal.tsx new file mode 100644 index 000000000000..914c512baa80 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal.tsx @@ -0,0 +1,280 @@ +import React, { FC, useEffect, useMemo, useState } from 'react' +import { IonIcon } from '@ionic/react' +import { close as closeIcon } from 'ionicons/icons' +import Button from 'components/base/forms/Button' +import ErrorMessage from 'components/ErrorMessage' +import Input from 'components/base/forms/Input' +import InputGroup from 'components/base/forms/InputGroup' +import Utils from 'common/utils/utils' +import { getStore } from 'common/store' +import { TrustRelationship } from 'common/types/responses' +import { + useCreateTrustRelationshipMutation, + useUpdateTrustRelationshipMutation, +} from 'common/services/useTrustRelationship' +import { createRoleMasterApiKey } from 'common/services/useRoleMasterApiKey' +import { + deleteMasterAPIKeyWithMasterAPIKeyRoles, + getRolesMasterAPIKeyWithMasterAPIKeyRoles, +} from 'common/services/useMasterAPIKeyWithMasterAPIKeyRole' +import TrustRelationshipPermissionsFields, { + SelectedRole, +} from './TrustRelationshipPermissionsFields' +import WorkflowSetupSnippet, { GITHUB_ISSUER } from './WorkflowSetupSnippet' + +type ClaimRuleRow = { claim: string; values: string } + +type TrustRelationshipModalProps = { + organisationId: number + trustRelationship?: TrustRelationship +} + +const TrustRelationshipModal: FC = ({ + organisationId, + trustRelationship, +}) => { + const isEdit = !!trustRelationship + const [name, setName] = useState(trustRelationship?.name || '') + const [issuer, setIssuer] = useState(trustRelationship?.issuer || '') + const [audience, setAudience] = useState(trustRelationship?.audience || '') + const [claimRules, setClaimRules] = useState( + trustRelationship?.claim_rules.map((rule) => ({ + claim: rule.claim, + values: rule.values.join(', '), + })) || [], + ) + const [isAdmin, setIsAdmin] = useState(trustRelationship?.is_admin ?? true) + const [roles, setRoles] = useState([]) + + const [ + createTrustRelationship, + { error: createError, isLoading: isCreating }, + ] = useCreateTrustRelationshipMutation() + const [ + updateTrustRelationship, + { error: updateError, isLoading: isUpdating }, + ] = useUpdateTrustRelationshipMutation() + const fieldErrors = ((createError || updateError) as { data?: any })?.data + // Field-keyed errors render inline on their inputs; the alert carries + // whatever has no field to attach to. + const alertError = useMemo(() => { + const error = (createError || updateError) as { data?: any } | undefined + const data = error?.data + if (data && typeof data === 'object' && !Array.isArray(data)) { + const rest = Object.fromEntries( + Object.entries(data).filter( + ([key]) => !['audience', 'issuer', 'name'].includes(key), + ), + ) + return Object.keys(rest).length ? { ...error, data: rest } : null + } + return error + }, [createError, updateError]) + + useEffect(() => { + if (trustRelationship) { + getRolesMasterAPIKeyWithMasterAPIKeyRoles(getStore(), { + org_id: organisationId, + prefix: trustRelationship.master_api_key_prefix, + }).then((res: { data?: { results: SelectedRole[] } }) => { + setRoles(res.data?.results || []) + }) + } + }, [organisationId, trustRelationship]) + + const addRole = (role: SelectedRole) => { + if (isEdit) { + createRoleMasterApiKey(getStore(), { + body: { master_api_key: trustRelationship.master_api_key_id }, + org_id: organisationId, + role_id: role.id, + }).then(() => toast('Role assigned')) + } + setRoles((selected) => [...selected, { id: role.id, name: role.name }]) + } + + const removeRole = (roleId: number) => { + if (isEdit) { + deleteMasterAPIKeyWithMasterAPIKeyRoles(getStore(), { + org_id: organisationId, + prefix: trustRelationship.master_api_key_prefix, + role_id: roleId, + }).then(() => toast('Role removed')) + } + setRoles((selected) => selected.filter((role) => role.id !== roleId)) + } + + const onIsAdminChange = () => { + // Turning admin on detaches any assigned roles server-side. + if (!isAdmin) { + setRoles([]) + } + setIsAdmin(!isAdmin) + } + + const body = { + audience, + claim_rules: claimRules + .filter((rule) => rule.claim) + .map((rule) => ({ + claim: rule.claim.trim(), + values: rule.values + .split(',') + .map((value) => value.trim()) + .filter(Boolean), + })), + is_admin: isAdmin, + issuer, + name, + } + + const save = () => { + if (isEdit) { + updateTrustRelationship({ + body, + id: trustRelationship.id, + organisation_id: organisationId, + }) + .unwrap() + .then(() => { + toast('Trust relationship updated') + closeModal() + }) + .catch(() => null) + } else { + createTrustRelationship({ + body, + organisation_id: organisationId, + }) + .unwrap() + .then((created) => + Promise.all( + roles.map((role) => + createRoleMasterApiKey(getStore(), { + body: { master_api_key: created.master_api_key_id }, + org_id: organisationId, + role_id: role.id, + }), + ), + ), + ) + .then(() => { + toast('Trust relationship created') + closeModal() + }) + .catch(() => null) + } + } + + return ( +
+ setName(Utils.safeParseEventValue(e))} + placeholder='e.g. GitHub Actions' + /> + setIssuer(Utils.safeParseEventValue(e))} + placeholder='e.g. https://token.actions.githubusercontent.com' + /> + setAudience(Utils.safeParseEventValue(e))} + placeholder='e.g. https://github.com/YourOrg' + /> + + {claimRules.map((rule, index) => ( + + + + setClaimRules((rules) => + rules.map((r, i) => + i === index + ? { ...r, claim: Utils.safeParseEventValue(e) } + : r, + ), + ) + } + placeholder='Claim, e.g. repository' + /> + + + + setClaimRules((rules) => + rules.map((r, i) => + i === index + ? { ...r, values: Utils.safeParseEventValue(e) } + : r, + ), + ) + } + placeholder='Values, e.g. YourOrg/your-repo' + /> + + + + ))} +
+ Values support * wildcards; separate alternatives with commas. +
+ + {issuer.trim().replace(/\/+$/, '') === GITHUB_ISSUER && !!audience && ( + rule.claim === 'environment') + ?.values.split(',')[0] + ?.trim() || undefined + } + /> + )} + + +
+ +
+
+ ) +} + +export default TrustRelationshipModal diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields.tsx new file mode 100644 index 000000000000..8f13ef019935 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields.tsx @@ -0,0 +1,84 @@ +import React, { FC, useState } from 'react' +import { IonIcon } from '@ionic/react' +import { close as closeIcon } from 'ionicons/icons' +import Button from 'components/base/forms/Button' +import PlanBasedBanner from 'components/PlanBasedAccess' +import Switch from 'components/Switch' +import MyRoleSelect from 'components/MyRoleSelect' +import Utils from 'common/utils/utils' + +export type SelectedRole = { id: number; name: string } + +type TrustRelationshipPermissionsFieldsProps = { + organisationId: number + isAdmin: boolean + onIsAdminChange: () => void + roles: SelectedRole[] + onAddRole: (role: SelectedRole) => void + onRemoveRole: (roleId: number) => void +} + +const TrustRelationshipPermissionsFields: FC< + TrustRelationshipPermissionsFieldsProps +> = ({ + isAdmin, + onAddRole, + onIsAdminChange, + onRemoveRole, + organisationId, + roles, +}) => { + const [showRoles, setShowRoles] = useState(false) + + return ( + <> + + + + + + + {!isAdmin && ( + <> + + + {roles.map((role) => ( + onRemoveRole(role.id)} + className='chip' + > + {role.name} + + + + + ))} + + + +
+ role.id)} + onAdd={(role) => onAddRole(role as unknown as SelectedRole)} + onRemove={onRemoveRole} + isOpen={showRoles} + onToggle={() => setShowRoles(!showRoles)} + /> +
+
+ + )} + + ) +} + +export default TrustRelationshipPermissionsFields diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet.tsx new file mode 100644 index 000000000000..add9df960618 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet.tsx @@ -0,0 +1,85 @@ +import React, { FC, useMemo } from 'react' +import CodeCard from 'components/pages/onboarding/OnboardingConnectPanel/CodeCard' +import Icon from 'components/icons/Icon' +import { TrustRelationship } from 'common/types/responses' + +export const GITHUB_ISSUER = 'https://token.actions.githubusercontent.com' + +// Claims the GitHub form can round-trip; anything else edits as freeform. +const GITHUB_FORM_CLAIMS = ['repository', 'repository_id', 'environment'] + +export const isGithubFormEditable = ( + trustRelationship: TrustRelationship, +): boolean => + trustRelationship.issuer === GITHUB_ISSUER && + trustRelationship.claim_rules.every( + (rule) => + GITHUB_FORM_CLAIMS.includes(rule.claim) && rule.values.length === 1, + ) && + trustRelationship.claim_rules.some( + (rule) => rule.claim === 'repository' || rule.claim === 'repository_id', + ) + +const GITHUB_OWNER_AUDIENCE_REGEX = /^https:\/\/github\.com\/[^/]+$/ + +export const isDefaultGithubAudience = (audience: string): boolean => + GITHUB_OWNER_AUDIENCE_REGEX.test(audience) + +type WorkflowSetupSnippetProps = { + audience: string + environment?: string +} + +const WorkflowSetupSnippet: FC = ({ + audience, + environment, +}) => { + const isDefaultAudience = isDefaultGithubAudience(audience) + const code = useMemo(() => { + const lines = ['jobs:', ' flagsmith:', ' runs-on: ubuntu-latest'] + if (environment) { + lines.push(` environment: ${environment}`) + } + lines.push( + ' permissions:', + ' id-token: write', + ' steps:', + ' - uses: Flagsmith/setup-cli@v1', + ) + if (!isDefaultAudience) { + lines.push(' with:', ` audience: ${audience}`) + } + return lines.join('\n') + }, [environment, isDefaultAudience, audience]) + + const header = Workflow setup + return ( +
+ + {header} + + + } + > + { + 'The default audience for this owner is already in use, so the workflow must request this audience explicitly.' + } + + ) + } + /> +
+ ) +} + +export default WorkflowSetupSnippet diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/errors.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/errors.ts new file mode 100644 index 000000000000..6e88715ee6da --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/errors.ts @@ -0,0 +1,18 @@ +// Extracts the first human-readable message from a DRF error body, which +// arrives as {detail: "..."}, {field: ["..."]}, or {non_field_errors: ["..."]}. +export const trustRelationshipErrorMessage = ( + error: unknown, + fallback: string, +): string => { + const data = (error as { data?: unknown })?.data + if (data && typeof data === 'object') { + const first = Object.values(data as Record)[0] + if (typeof first === 'string') { + return first + } + if (Array.isArray(first) && typeof first[0] === 'string') { + return first[0] + } + } + return fallback +} diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/index.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/index.tsx new file mode 100644 index 000000000000..8901ab7c4bf8 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/index.tsx @@ -0,0 +1,194 @@ +import React, { FC } from 'react' +import moment from 'moment' +import Button from 'components/base/forms/Button' +import EmptyState from 'components/EmptyState' +import Icon from 'components/icons/Icon' +import PageTitle from 'components/PageTitle' +import PanelSearch from 'components/PanelSearch' +import Switch from 'components/Switch' +import GithubTrustRelationshipForm from './GithubTrustRelationshipForm' +import { trustRelationshipErrorMessage } from './errors' +import NewTrustRelationshipModal from './NewTrustRelationshipModal' +import TrustRelationshipModal from './TrustRelationshipModal' +import { isGithubFormEditable } from './WorkflowSetupSnippet' +import { providerForIssuer } from './providers' +import { TrustRelationship } from 'common/types/responses' +import { + useDeleteTrustRelationshipMutation, + useGetTrustRelationshipsQuery, +} from 'common/services/useTrustRelationship' + +const IssuerCell: FC<{ issuer: string }> = ({ issuer }) => { + const provider = providerForIssuer(issuer) + if (!provider) { + return {issuer} + } + return ( + {provider.icon}}> + {`${issuer}`} + + ) +} + +type TrustRelationshipsProps = { + organisationId: number +} + +const TrustRelationships: FC = ({ + organisationId, +}) => { + const { data, isLoading } = useGetTrustRelationshipsQuery( + { organisation_id: organisationId }, + { skip: !organisationId }, + ) + const [deleteTrustRelationship] = useDeleteTrustRelationshipMutation() + + const editTrustRelationship = (trustRelationship: TrustRelationship) => + openModal( + `${trustRelationship.name} trust relationship`, + isGithubFormEditable(trustRelationship) ? ( + existing.audience, + )} + /> + ) : ( + + ), + 'p-0 side-modal', + ) + + const addTrustRelationship = () => + openModal( + 'New trust relationship', + trustRelationship.audience, + )} + />, + 'p-0 side-modal', + ) + + const remove = (trustRelationship: TrustRelationship) => { + openConfirm({ + body: ( +
+ Deleting the {trustRelationship.name} trust + relationship immediately stops token exchange for it, and any + outstanding access tokens stop working. +
+ ), + destructive: true, + onYes: () => { + deleteTrustRelationship({ + id: trustRelationship.id, + organisation_id: organisationId, + }) + .unwrap() + .then(() => toast('Trust relationship deleted')) + .catch((error) => + toast( + trustRelationshipErrorMessage( + error, + 'Could not delete trust relationship', + ), + 'danger', + ), + ) + }, + title: 'Delete trust relationship', + yesText: 'Delete', + }) + } + + return ( +
+ Add trust relationship + } + > + Let a trusted OIDC identity provider exchange its tokens for short-lived + Flagsmith access tokens. + + {!isLoading && !data?.results?.length && ( + + )} + {!!data?.results?.length && ( + + Name + Issuer + Audience +
+ Is admin +
+
+ Remove +
+ + } + renderRow={(trustRelationship: TrustRelationship) => ( + editTrustRelationship(trustRelationship)} + data-test={`trust-relationship-${trustRelationship.id}`} + > + +
+ {trustRelationship.name} +
+
+ Created{' '} + {moment(trustRelationship.created_at).format('Do MMM YYYY')} +
+
+ + + + + {trustRelationship.audience} + +
e.stopPropagation()} + > + +
+
+ +
+
+ )} + /> + )} +
+ ) +} + +export default TrustRelationships diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/providers.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/providers.tsx new file mode 100644 index 000000000000..2019884a6698 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/providers.tsx @@ -0,0 +1,24 @@ +import React, { ReactNode } from 'react' +import { GithubIcon } from 'components/icons/GithubIcon' +import { GITHUB_ISSUER } from './WorkflowSetupSnippet' + +// Known OIDC providers, keyed by issuer. Add an entry here to give a future +// preset (GitLab CI, Kubernetes, ...) its own badge in the list view. +export type TrustRelationshipProvider = { + issuer: string + label: string + icon: ReactNode +} + +export const TRUST_RELATIONSHIP_PROVIDERS: TrustRelationshipProvider[] = [ + { + icon: , + issuer: GITHUB_ISSUER, + label: 'GitHub Actions', + }, +] + +export const providerForIssuer = ( + issuer: string, +): TrustRelationshipProvider | undefined => + TRUST_RELATIONSHIP_PROVIDERS.find((provider) => provider.issuer === issuer) From ff2e4b529586c637be3eca13e055fdcf2396c9cf Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 20 Jul 2026 11:57:30 +0100 Subject: [PATCH 2/5] fix(OIDC): Provider selection cards are not keyboard accessible The GitHub/Other provider cards were div+onClick only, so keyboard and screen-reader users could not start the create flow. Add role="button", tabIndex and an Enter/Space onKeyDown handler. beep boop --- .../trust-relationships/NewTrustRelationshipModal.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal.tsx index 15088484a0e1..454e02afc262 100644 --- a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal.tsx +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal.tsx @@ -32,7 +32,12 @@ const NewTrustRelationshipModal: FC = ({
setProvider('github')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') setProvider('github') + }} >
GitHub Actions
@@ -43,7 +48,12 @@ const NewTrustRelationshipModal: FC = ({
setProvider('other')} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') setProvider('other') + }} >
Other OIDC provider
From b703c21b483aa4bfed39bd974df2b9a207e6106b Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 20 Jul 2026 11:57:30 +0100 Subject: [PATCH 3/5] fix(OIDC): Trust relationship role changes ignore API failures addRole/removeRole mutated local role state unconditionally and never inspected the request result, so a failed assignment left the UI showing a role the backend never stored, with no error surfaced. Update local state only on success and toast on failure; also flag when role assignment fails during creation instead of silently swallowing it. beep boop --- .../GithubTrustRelationshipForm.tsx | 56 +++++++++++++------ 1 file changed, 40 insertions(+), 16 deletions(-) diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm.tsx index 153c68aefa3f..43978d0d6f78 100644 --- a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm.tsx +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm.tsx @@ -135,25 +135,42 @@ const GithubTrustRelationshipForm: FC = ({ ]) const addRole = (role: SelectedRole) => { - if (isEdit) { - createRoleMasterApiKey(getStore(), { - body: { master_api_key: trustRelationship.master_api_key_id }, - org_id: organisationId, - role_id: role.id, - }).then(() => toast('Role assigned')) + if (!isEdit) { + // Roles are assigned after the trust relationship is created (see save). + setRoles((selected) => [...selected, { id: role.id, name: role.name }]) + return } - setRoles((selected) => [...selected, { id: role.id, name: role.name }]) + createRoleMasterApiKey(getStore(), { + body: { master_api_key: trustRelationship.master_api_key_id }, + org_id: organisationId, + role_id: role.id, + }).then((res) => { + if (res.error) { + toast('Could not assign role', 'danger') + return + } + setRoles((selected) => [...selected, { id: role.id, name: role.name }]) + toast('Role assigned') + }) } const removeRole = (roleId: number) => { - if (isEdit) { - deleteMasterAPIKeyWithMasterAPIKeyRoles(getStore(), { - org_id: organisationId, - prefix: trustRelationship.master_api_key_prefix, - role_id: roleId, - }).then(() => toast('Role removed')) + if (!isEdit) { + setRoles((selected) => selected.filter((role) => role.id !== roleId)) + return } - setRoles((selected) => selected.filter((role) => role.id !== roleId)) + deleteMasterAPIKeyWithMasterAPIKeyRoles(getStore(), { + org_id: organisationId, + prefix: trustRelationship.master_api_key_prefix, + role_id: roleId, + }).then((res) => { + if (res.error) { + toast('Could not remove role', 'danger') + return + } + setRoles((selected) => selected.filter((role) => role.id !== roleId)) + toast('Role removed') + }) } const save = () => { @@ -208,8 +225,15 @@ const GithubTrustRelationshipForm: FC = ({ ), ), ) - .then(() => { - toast('Trust relationship created') + .then((results) => { + if (results.some((res) => res.error)) { + toast( + 'Trust relationship created, but some roles could not be assigned', + 'danger', + ) + } else { + toast('Trust relationship created') + } closeModal() }) .catch(() => null) From 5a3a2d808b1c9014c08946721974a6b258ba6957 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Mon, 20 Jul 2026 12:46:37 +0100 Subject: [PATCH 4/5] refactor(OIDC): Give each trust relationship component its own folder Adopt the component-folder layout the frontend guideline expects: every new component now lives in its own directory with an index.ts barrel (ComponentName/ComponentName.tsx + index.ts). providers.ts and errors.ts stay as flat modules, being exempt constants/util. Cross-component imports use the components/ alias, as the import-alias lint rule requires for parent-traversal paths; APIKeysTab keeps importing the tab via the unchanged ./trust-relationships path. beep boop --- .../GithubTrustRelationshipForm.tsx | 6 ++++-- .../GithubTrustRelationshipForm/index.ts | 1 + .../NewTrustRelationshipModal.tsx | 4 ++-- .../NewTrustRelationshipModal/index.ts | 1 + .../TrustRelationshipModal.tsx | 6 ++++-- .../TrustRelationshipModal/index.ts | 1 + .../TrustRelationshipPermissionsFields.tsx | 0 .../TrustRelationshipPermissionsFields/index.ts | 2 ++ .../TrustRelationships.tsx} | 12 ++++++------ .../trust-relationships/TrustRelationships/index.ts | 1 + .../WorkflowSetupSnippet.tsx | 0 .../WorkflowSetupSnippet/index.ts | 6 ++++++ .../tabs/trust-relationships/index.ts | 1 + 13 files changed, 29 insertions(+), 12 deletions(-) rename frontend/web/components/pages/organisation-settings/tabs/trust-relationships/{ => GithubTrustRelationshipForm}/GithubTrustRelationshipForm.tsx (97%) create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/index.ts rename frontend/web/components/pages/organisation-settings/tabs/trust-relationships/{ => NewTrustRelationshipModal}/NewTrustRelationshipModal.tsx (88%) create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal/index.ts rename frontend/web/components/pages/organisation-settings/tabs/trust-relationships/{ => TrustRelationshipModal}/TrustRelationshipModal.tsx (97%) create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/index.ts rename frontend/web/components/pages/organisation-settings/tabs/trust-relationships/{ => TrustRelationshipPermissionsFields}/TrustRelationshipPermissionsFields.tsx (100%) create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields/index.ts rename frontend/web/components/pages/organisation-settings/tabs/trust-relationships/{index.tsx => TrustRelationships/TrustRelationships.tsx} (89%) create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationships/index.ts rename frontend/web/components/pages/organisation-settings/tabs/trust-relationships/{ => WorkflowSetupSnippet}/WorkflowSetupSnippet.tsx (100%) create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet/index.ts create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/index.ts diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/GithubTrustRelationshipForm.tsx similarity index 97% rename from frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm.tsx rename to frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/GithubTrustRelationshipForm.tsx index 43978d0d6f78..9a2b51bd4c6a 100644 --- a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm.tsx +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/GithubTrustRelationshipForm.tsx @@ -23,8 +23,10 @@ import { } from 'common/services/useMasterAPIKeyWithMasterAPIKeyRole' import TrustRelationshipPermissionsFields, { SelectedRole, -} from './TrustRelationshipPermissionsFields' -import WorkflowSetupSnippet, { GITHUB_ISSUER } from './WorkflowSetupSnippet' +} from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields' +import WorkflowSetupSnippet, { + GITHUB_ISSUER, +} from 'components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet' type GithubTrustRelationshipFormProps = { organisationId: number diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/index.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/index.ts new file mode 100644 index 000000000000..23cd2e2b81a3 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/index.ts @@ -0,0 +1 @@ +export { default } from './GithubTrustRelationshipForm' diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal/NewTrustRelationshipModal.tsx similarity index 88% rename from frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal.tsx rename to frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal/NewTrustRelationshipModal.tsx index 454e02afc262..fa474a2cc5f3 100644 --- a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal.tsx +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal/NewTrustRelationshipModal.tsx @@ -1,6 +1,6 @@ import React, { FC, useState } from 'react' -import GithubTrustRelationshipForm from './GithubTrustRelationshipForm' -import TrustRelationshipModal from './TrustRelationshipModal' +import GithubTrustRelationshipForm from 'components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm' +import TrustRelationshipModal from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal' type Provider = 'github' | 'other' diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal/index.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal/index.ts new file mode 100644 index 000000000000..13ce30e55aa7 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal/index.ts @@ -0,0 +1 @@ +export { default } from './NewTrustRelationshipModal' diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/TrustRelationshipModal.tsx similarity index 97% rename from frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal.tsx rename to frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/TrustRelationshipModal.tsx index 914c512baa80..f969744f0738 100644 --- a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal.tsx +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/TrustRelationshipModal.tsx @@ -19,8 +19,10 @@ import { } from 'common/services/useMasterAPIKeyWithMasterAPIKeyRole' import TrustRelationshipPermissionsFields, { SelectedRole, -} from './TrustRelationshipPermissionsFields' -import WorkflowSetupSnippet, { GITHUB_ISSUER } from './WorkflowSetupSnippet' +} from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields' +import WorkflowSetupSnippet, { + GITHUB_ISSUER, +} from 'components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet' type ClaimRuleRow = { claim: string; values: string } diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/index.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/index.ts new file mode 100644 index 000000000000..b7345ad9e43f --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/index.ts @@ -0,0 +1 @@ +export { default } from './TrustRelationshipModal' diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields/TrustRelationshipPermissionsFields.tsx similarity index 100% rename from frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields.tsx rename to frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields/TrustRelationshipPermissionsFields.tsx diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields/index.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields/index.ts new file mode 100644 index 000000000000..97e0d19bc420 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields/index.ts @@ -0,0 +1,2 @@ +export { default } from './TrustRelationshipPermissionsFields' +export type { SelectedRole } from './TrustRelationshipPermissionsFields' diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/index.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationships/TrustRelationships.tsx similarity index 89% rename from frontend/web/components/pages/organisation-settings/tabs/trust-relationships/index.tsx rename to frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationships/TrustRelationships.tsx index 8901ab7c4bf8..18dfb7674d96 100644 --- a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/index.tsx +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationships/TrustRelationships.tsx @@ -6,12 +6,12 @@ import Icon from 'components/icons/Icon' import PageTitle from 'components/PageTitle' import PanelSearch from 'components/PanelSearch' import Switch from 'components/Switch' -import GithubTrustRelationshipForm from './GithubTrustRelationshipForm' -import { trustRelationshipErrorMessage } from './errors' -import NewTrustRelationshipModal from './NewTrustRelationshipModal' -import TrustRelationshipModal from './TrustRelationshipModal' -import { isGithubFormEditable } from './WorkflowSetupSnippet' -import { providerForIssuer } from './providers' +import GithubTrustRelationshipForm from 'components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm' +import { trustRelationshipErrorMessage } from 'components/pages/organisation-settings/tabs/trust-relationships/errors' +import NewTrustRelationshipModal from 'components/pages/organisation-settings/tabs/trust-relationships/NewTrustRelationshipModal' +import TrustRelationshipModal from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal' +import { isGithubFormEditable } from 'components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet' +import { providerForIssuer } from 'components/pages/organisation-settings/tabs/trust-relationships/providers' import { TrustRelationship } from 'common/types/responses' import { useDeleteTrustRelationshipMutation, diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationships/index.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationships/index.ts new file mode 100644 index 000000000000..7469d6036418 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationships/index.ts @@ -0,0 +1 @@ +export { default } from './TrustRelationships' diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet/WorkflowSetupSnippet.tsx similarity index 100% rename from frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet.tsx rename to frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet/WorkflowSetupSnippet.tsx diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet/index.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet/index.ts new file mode 100644 index 000000000000..a3a4266cf307 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet/index.ts @@ -0,0 +1,6 @@ +export { + default, + GITHUB_ISSUER, + isGithubFormEditable, + isDefaultGithubAudience, +} from './WorkflowSetupSnippet' diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/index.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/index.ts new file mode 100644 index 000000000000..7469d6036418 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/index.ts @@ -0,0 +1 @@ +export { default } from './TrustRelationships' From 90be5fa77fbc34dfb44aea8951cea00c148a8808 Mon Sep 17 00:00:00 2001 From: Kim Gustyr Date: Wed, 22 Jul 2026 13:20:12 +0100 Subject: [PATCH 5/5] fix(OIDC): Freeform trust relationship modal ignores role API failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The role add/remove fix from review only reached the GitHub preset form, leaving the freeform modal with optimistic state updates and success toasts on failed API calls. Role behaviour now lives in a single useTrustRelationshipRoles hook used by both forms — including partial failure reporting when assigning roles after creation — with its core extracted for unit tests, so the two forms cannot drift again. beep boop --- .../GithubTrustRelationshipForm.tsx | 86 +-------- .../TrustRelationshipModal.tsx | 74 ++------ .../useTrustRelationshipRoles.test.ts | 177 ++++++++++++++++++ .../hooks/useTrustRelationshipRoles.ts | 115 ++++++++++++ 4 files changed, 318 insertions(+), 134 deletions(-) create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/hooks/__tests__/useTrustRelationshipRoles.test.ts create mode 100644 frontend/web/components/pages/organisation-settings/tabs/trust-relationships/hooks/useTrustRelationshipRoles.ts diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/GithubTrustRelationshipForm.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/GithubTrustRelationshipForm.tsx index 9a2b51bd4c6a..6de342b8a022 100644 --- a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/GithubTrustRelationshipForm.tsx +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/GithubTrustRelationshipForm/GithubTrustRelationshipForm.tsx @@ -4,7 +4,6 @@ import ErrorMessage from 'components/ErrorMessage' import Input from 'components/base/forms/Input' import InputGroup from 'components/base/forms/InputGroup' import Utils from 'common/utils/utils' -import { getStore } from 'common/store' import { Repository, TrustRelationship, @@ -16,14 +15,8 @@ import { useCreateTrustRelationshipMutation, useUpdateTrustRelationshipMutation, } from 'common/services/useTrustRelationship' -import { createRoleMasterApiKey } from 'common/services/useRoleMasterApiKey' -import { - deleteMasterAPIKeyWithMasterAPIKeyRoles, - getRolesMasterAPIKeyWithMasterAPIKeyRoles, -} from 'common/services/useMasterAPIKeyWithMasterAPIKeyRole' -import TrustRelationshipPermissionsFields, { - SelectedRole, -} from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields' +import useTrustRelationshipRoles from 'components/pages/organisation-settings/tabs/trust-relationships/hooks/useTrustRelationshipRoles' +import TrustRelationshipPermissionsFields from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields' import WorkflowSetupSnippet, { GITHUB_ISSUER, } from 'components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet' @@ -69,7 +62,8 @@ const GithubTrustRelationshipForm: FC = ({ ruleValue(trustRelationship, 'environment') || '', ) const [isAdmin, setIsAdmin] = useState(trustRelationship?.is_admin ?? true) - const [roles, setRoles] = useState([]) + const { addRole, assignRoles, clearRoles, removeRole, roles } = + useTrustRelationshipRoles(organisationId, trustRelationship) // A repository pinned by ID resolves through the installation's repo list. const pinnedRepo = useMemo( @@ -85,17 +79,6 @@ const GithubTrustRelationshipForm: FC = ({ } }, [pinnedRepo, selectedRepo]) - useEffect(() => { - if (trustRelationship) { - getRolesMasterAPIKeyWithMasterAPIKeyRoles(getStore(), { - org_id: organisationId, - prefix: trustRelationship.master_api_key_prefix, - }).then((res: { data?: { results: SelectedRole[] } }) => { - setRoles(res.data?.results || []) - }) - } - }, [organisationId, trustRelationship]) - const [ createTrustRelationship, { error: createError, isLoading: isCreating }, @@ -136,45 +119,6 @@ const GithubTrustRelationshipForm: FC = ({ existingAudiences, ]) - const addRole = (role: SelectedRole) => { - if (!isEdit) { - // Roles are assigned after the trust relationship is created (see save). - setRoles((selected) => [...selected, { id: role.id, name: role.name }]) - return - } - createRoleMasterApiKey(getStore(), { - body: { master_api_key: trustRelationship.master_api_key_id }, - org_id: organisationId, - role_id: role.id, - }).then((res) => { - if (res.error) { - toast('Could not assign role', 'danger') - return - } - setRoles((selected) => [...selected, { id: role.id, name: role.name }]) - toast('Role assigned') - }) - } - - const removeRole = (roleId: number) => { - if (!isEdit) { - setRoles((selected) => selected.filter((role) => role.id !== roleId)) - return - } - deleteMasterAPIKeyWithMasterAPIKeyRoles(getStore(), { - org_id: organisationId, - prefix: trustRelationship.master_api_key_prefix, - role_id: roleId, - }).then((res) => { - if (res.error) { - toast('Could not remove role', 'danger') - return - } - setRoles((selected) => selected.filter((role) => role.id !== roleId)) - toast('Role removed') - }) - } - const save = () => { const claimRules: TrustRelationshipClaimRule[] = [] if (isUnresolvedPin) { @@ -216,25 +160,15 @@ const GithubTrustRelationshipForm: FC = ({ } else { createTrustRelationship({ body, organisation_id: organisationId }) .unwrap() - .then((created) => - Promise.all( - roles.map((role) => - createRoleMasterApiKey(getStore(), { - body: { master_api_key: created.master_api_key_id }, - org_id: organisationId, - role_id: role.id, - }), - ), - ), - ) - .then((results) => { - if (results.some((res) => res.error)) { + .then((created) => assignRoles(created.master_api_key_id)) + .then((allAssigned) => { + if (allAssigned) { + toast('Trust relationship created') + } else { toast( 'Trust relationship created, but some roles could not be assigned', 'danger', ) - } else { - toast('Trust relationship created') } closeModal() }) @@ -345,7 +279,7 @@ const GithubTrustRelationshipForm: FC = ({ isAdmin={isAdmin} onIsAdminChange={() => { if (!isAdmin) { - setRoles([]) + clearRoles() } setIsAdmin(!isAdmin) }} diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/TrustRelationshipModal.tsx b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/TrustRelationshipModal.tsx index f969744f0738..13fe663b711a 100644 --- a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/TrustRelationshipModal.tsx +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipModal/TrustRelationshipModal.tsx @@ -1,4 +1,4 @@ -import React, { FC, useEffect, useMemo, useState } from 'react' +import React, { FC, useMemo, useState } from 'react' import { IonIcon } from '@ionic/react' import { close as closeIcon } from 'ionicons/icons' import Button from 'components/base/forms/Button' @@ -6,20 +6,13 @@ import ErrorMessage from 'components/ErrorMessage' import Input from 'components/base/forms/Input' import InputGroup from 'components/base/forms/InputGroup' import Utils from 'common/utils/utils' -import { getStore } from 'common/store' import { TrustRelationship } from 'common/types/responses' import { useCreateTrustRelationshipMutation, useUpdateTrustRelationshipMutation, } from 'common/services/useTrustRelationship' -import { createRoleMasterApiKey } from 'common/services/useRoleMasterApiKey' -import { - deleteMasterAPIKeyWithMasterAPIKeyRoles, - getRolesMasterAPIKeyWithMasterAPIKeyRoles, -} from 'common/services/useMasterAPIKeyWithMasterAPIKeyRole' -import TrustRelationshipPermissionsFields, { - SelectedRole, -} from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields' +import useTrustRelationshipRoles from 'components/pages/organisation-settings/tabs/trust-relationships/hooks/useTrustRelationshipRoles' +import TrustRelationshipPermissionsFields from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields' import WorkflowSetupSnippet, { GITHUB_ISSUER, } from 'components/pages/organisation-settings/tabs/trust-relationships/WorkflowSetupSnippet' @@ -46,7 +39,8 @@ const TrustRelationshipModal: FC = ({ })) || [], ) const [isAdmin, setIsAdmin] = useState(trustRelationship?.is_admin ?? true) - const [roles, setRoles] = useState([]) + const { addRole, assignRoles, clearRoles, removeRole, roles } = + useTrustRelationshipRoles(organisationId, trustRelationship) const [ createTrustRelationship, @@ -73,43 +67,10 @@ const TrustRelationshipModal: FC = ({ return error }, [createError, updateError]) - useEffect(() => { - if (trustRelationship) { - getRolesMasterAPIKeyWithMasterAPIKeyRoles(getStore(), { - org_id: organisationId, - prefix: trustRelationship.master_api_key_prefix, - }).then((res: { data?: { results: SelectedRole[] } }) => { - setRoles(res.data?.results || []) - }) - } - }, [organisationId, trustRelationship]) - - const addRole = (role: SelectedRole) => { - if (isEdit) { - createRoleMasterApiKey(getStore(), { - body: { master_api_key: trustRelationship.master_api_key_id }, - org_id: organisationId, - role_id: role.id, - }).then(() => toast('Role assigned')) - } - setRoles((selected) => [...selected, { id: role.id, name: role.name }]) - } - - const removeRole = (roleId: number) => { - if (isEdit) { - deleteMasterAPIKeyWithMasterAPIKeyRoles(getStore(), { - org_id: organisationId, - prefix: trustRelationship.master_api_key_prefix, - role_id: roleId, - }).then(() => toast('Role removed')) - } - setRoles((selected) => selected.filter((role) => role.id !== roleId)) - } - const onIsAdminChange = () => { // Turning admin on detaches any assigned roles server-side. if (!isAdmin) { - setRoles([]) + clearRoles() } setIsAdmin(!isAdmin) } @@ -149,19 +110,16 @@ const TrustRelationshipModal: FC = ({ organisation_id: organisationId, }) .unwrap() - .then((created) => - Promise.all( - roles.map((role) => - createRoleMasterApiKey(getStore(), { - body: { master_api_key: created.master_api_key_id }, - org_id: organisationId, - role_id: role.id, - }), - ), - ), - ) - .then(() => { - toast('Trust relationship created') + .then((created) => assignRoles(created.master_api_key_id)) + .then((allAssigned) => { + if (allAssigned) { + toast('Trust relationship created') + } else { + toast( + 'Trust relationship created, but some roles could not be assigned', + 'danger', + ) + } closeModal() }) .catch(() => null) diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/hooks/__tests__/useTrustRelationshipRoles.test.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/hooks/__tests__/useTrustRelationshipRoles.test.ts new file mode 100644 index 000000000000..eba3316e0722 --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/hooks/__tests__/useTrustRelationshipRoles.test.ts @@ -0,0 +1,177 @@ +import { createRoleHandlers } from 'components/pages/organisation-settings/tabs/trust-relationships/hooks/useTrustRelationshipRoles' +import { SelectedRole } from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields' +import { TrustRelationship } from 'common/types/responses' +import { createRoleMasterApiKey } from 'common/services/useRoleMasterApiKey' +import { deleteMasterAPIKeyWithMasterAPIKeyRoles } from 'common/services/useMasterAPIKeyWithMasterAPIKeyRole' + +jest.mock('common/store', () => ({ getStore: () => ({}) })) +jest.mock('common/services/useRoleMasterApiKey', () => ({ + createRoleMasterApiKey: jest.fn(), +})) +jest.mock('common/services/useMasterAPIKeyWithMasterAPIKeyRole', () => ({ + deleteMasterAPIKeyWithMasterAPIKeyRoles: jest.fn(), + getRolesMasterAPIKeyWithMasterAPIKeyRoles: jest.fn(), +})) + +const mockCreate = createRoleMasterApiKey as jest.Mock +const mockDelete = deleteMasterAPIKeyWithMasterAPIKeyRoles as jest.Mock + +const trustRelationship = { + audience: 'https://github.com/Flagsmith', + claim_rules: [], + created_at: '', + created_by: null, + id: 1, + is_admin: false, + issuer: 'https://token.actions.githubusercontent.com', + master_api_key_id: 'key-id', + master_api_key_prefix: 'prefix', + name: 'GitHub Actions', +} as TrustRelationship + +const role: SelectedRole = { id: 7, name: 'Deployer' } + +const trackRoles = () => { + let state: SelectedRole[] = [role] + const setRoles = jest.fn( + ( + update: SelectedRole[] | ((previous: SelectedRole[]) => SelectedRole[]), + ) => { + state = typeof update === 'function' ? update(state) : update + }, + ) + return { getState: () => state, setRoles } +} + +beforeEach(() => { + ;(global as { toast?: unknown }).toast = jest.fn() +}) + +describe('addRole', () => { + it('does not update local state when the API call fails', async () => { + // Given + mockCreate.mockResolvedValue({ error: { status: 403 } }) + const { getState, setRoles } = trackRoles() + const handlers = createRoleHandlers({ + organisationId: 1, + setRoles, + trustRelationship, + }) + + // When + handlers.addRole({ id: 8, name: 'Reader' }) + await mockCreate.mock.results[0].value + + // Then + expect(setRoles).not.toHaveBeenCalled() + expect(getState()).toEqual([role]) + expect(toast).toHaveBeenCalledWith('Could not assign role', 'danger') + }) + + it('updates local state when the API call succeeds', async () => { + // Given + mockCreate.mockResolvedValue({ data: {} }) + const { getState, setRoles } = trackRoles() + const handlers = createRoleHandlers({ + organisationId: 1, + setRoles, + trustRelationship, + }) + + // When + handlers.addRole({ id: 8, name: 'Reader' }) + await mockCreate.mock.results[0].value + + // Then + expect(getState()).toEqual([role, { id: 8, name: 'Reader' }]) + expect(toast).toHaveBeenCalledWith('Role assigned') + }) + + it('updates local state without an API call in create mode', () => { + // Given + const { getState, setRoles } = trackRoles() + const handlers = createRoleHandlers({ organisationId: 1, setRoles }) + + // When + handlers.addRole({ id: 8, name: 'Reader' }) + + // Then + expect(mockCreate).not.toHaveBeenCalled() + expect(getState()).toEqual([role, { id: 8, name: 'Reader' }]) + }) +}) + +describe('removeRole', () => { + it('does not update local state when the API call fails', async () => { + // Given + mockDelete.mockResolvedValue({ error: { status: 403 } }) + const { getState, setRoles } = trackRoles() + const handlers = createRoleHandlers({ + organisationId: 1, + setRoles, + trustRelationship, + }) + + // When + handlers.removeRole(role.id) + await mockDelete.mock.results[0].value + + // Then + expect(setRoles).not.toHaveBeenCalled() + expect(getState()).toEqual([role]) + expect(toast).toHaveBeenCalledWith('Could not remove role', 'danger') + }) + + it('updates local state when the API call succeeds', async () => { + // Given + mockDelete.mockResolvedValue({ data: {} }) + const { getState, setRoles } = trackRoles() + const handlers = createRoleHandlers({ + organisationId: 1, + setRoles, + trustRelationship, + }) + + // When + handlers.removeRole(role.id) + await mockDelete.mock.results[0].value + + // Then + expect(getState()).toEqual([]) + expect(toast).toHaveBeenCalledWith('Role removed') + }) +}) + +describe('assignRoles', () => { + it('resolves false when any assignment fails', async () => { + // Given + mockCreate + .mockResolvedValueOnce({ data: {} }) + .mockResolvedValueOnce({ error: { status: 404 } }) + const { setRoles } = trackRoles() + const handlers = createRoleHandlers({ organisationId: 1, setRoles }) + + // When + const allAssigned = await handlers.assignRoles('key-id', [ + role, + { id: 8, name: 'Reader' }, + ]) + + // Then + expect(allAssigned).toBe(false) + expect(mockCreate).toHaveBeenCalledTimes(2) + }) + + it('resolves true when every assignment succeeds', async () => { + // Given + mockCreate.mockResolvedValue({ data: {} }) + const { setRoles } = trackRoles() + const handlers = createRoleHandlers({ organisationId: 1, setRoles }) + + // When + const allAssigned = await handlers.assignRoles('key-id', [role]) + + // Then + expect(allAssigned).toBe(true) + }) +}) diff --git a/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/hooks/useTrustRelationshipRoles.ts b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/hooks/useTrustRelationshipRoles.ts new file mode 100644 index 000000000000..44409f179acd --- /dev/null +++ b/frontend/web/components/pages/organisation-settings/tabs/trust-relationships/hooks/useTrustRelationshipRoles.ts @@ -0,0 +1,115 @@ +import { Dispatch, SetStateAction, useEffect, useMemo, useState } from 'react' +import { getStore } from 'common/store' +import { TrustRelationship } from 'common/types/responses' +import { createRoleMasterApiKey } from 'common/services/useRoleMasterApiKey' +import { + deleteMasterAPIKeyWithMasterAPIKeyRoles, + getRolesMasterAPIKeyWithMasterAPIKeyRoles, +} from 'common/services/useMasterAPIKeyWithMasterAPIKeyRole' +import { SelectedRole } from 'components/pages/organisation-settings/tabs/trust-relationships/TrustRelationshipPermissionsFields' + +type RoleHandlerContext = { + organisationId: number + trustRelationship?: TrustRelationship + setRoles: Dispatch> +} + +// The behaviour shared by both trust relationship forms, kept free of React +// so it can be unit tested. In edit mode, changes apply to the backing key +// immediately and local state only follows confirmed API results; in create +// mode, roles accumulate locally and are assigned after creation via +// `assignRoles`. +export const createRoleHandlers = ({ + organisationId, + setRoles, + trustRelationship, +}: RoleHandlerContext) => ({ + addRole: (role: SelectedRole): void => { + if (!trustRelationship) { + // Roles are assigned after the trust relationship is created. + setRoles((selected) => [...selected, { id: role.id, name: role.name }]) + return + } + createRoleMasterApiKey(getStore(), { + body: { master_api_key: trustRelationship.master_api_key_id }, + org_id: organisationId, + role_id: role.id, + }).then((res: { error?: unknown }) => { + if (res.error) { + toast('Could not assign role', 'danger') + return + } + setRoles((selected) => [...selected, { id: role.id, name: role.name }]) + toast('Role assigned') + }) + }, + assignRoles: async ( + masterApiKeyId: string, + roles: SelectedRole[], + ): Promise => { + // Create mode: assign the accumulated roles to the freshly created + // relationship's backing key. Resolves whether every assignment + // succeeded. + const results: { error?: unknown }[] = await Promise.all( + roles.map((role) => + createRoleMasterApiKey(getStore(), { + body: { master_api_key: masterApiKeyId }, + org_id: organisationId, + role_id: role.id, + }), + ), + ) + return results.every((res) => !res.error) + }, + removeRole: (roleId: number): void => { + if (!trustRelationship) { + setRoles((selected) => selected.filter((role) => role.id !== roleId)) + return + } + deleteMasterAPIKeyWithMasterAPIKeyRoles(getStore(), { + org_id: organisationId, + prefix: trustRelationship.master_api_key_prefix, + role_id: roleId, + }).then((res: { error?: unknown }) => { + if (res.error) { + toast('Could not remove role', 'danger') + return + } + setRoles((selected) => selected.filter((role) => role.id !== roleId)) + toast('Role removed') + }) + }, +}) + +export default function useTrustRelationshipRoles( + organisationId: number, + trustRelationship?: TrustRelationship, +) { + const [roles, setRoles] = useState([]) + + useEffect(() => { + if (trustRelationship) { + getRolesMasterAPIKeyWithMasterAPIKeyRoles(getStore(), { + org_id: organisationId, + prefix: trustRelationship.master_api_key_prefix, + }).then((res: { data?: { results: SelectedRole[] } }) => { + setRoles(res.data?.results || []) + }) + } + }, [organisationId, trustRelationship]) + + const handlers = useMemo( + () => createRoleHandlers({ organisationId, setRoles, trustRelationship }), + [organisationId, trustRelationship], + ) + + return { + addRole: handlers.addRole, + assignRoles: (masterApiKeyId: string) => + handlers.assignRoles(masterApiKeyId, roles), + // Turning admin on detaches roles server-side; mirror that locally. + clearRoles: () => setRoles([]), + removeRole: handlers.removeRole, + roles, + } +}