From bc3112a999c81b48f238d00bdaa558b40b75a33e Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:31:05 +0200 Subject: [PATCH] Fix: allow API key creation without Roles permission (#23102) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Problem A user with the **API keys & webhooks** permission but **without** the **Roles** setting permission cannot create an API key through the UI. The role selector relies on the `getRoles` query, which is guarded by the `ROLES` permission, so the roles list comes back empty, `SettingsDevelopersRoleSelector` early-returns, and no role can be selected — leaving the form unsavable. Screenshot 2026-07-21 at 13 38 34 ## Fix Expose the assignable roles through the API-key permission scope so users can **pick** a role to assign to an API key without being able to **edit** roles. - **Backend**: add `getApiKeyRoles` query on `ApiKeyResolver` (already guarded by `API_KEYS_AND_WEBHOOKS`), backed by `ApiKeyRoleService.getApiKeyAssignableRoles` which returns roles where `canBeAssignedToApiKeys = true`. - **Frontend**: add a `GetApiKeyRoles` query and use it in the API key create and detail pages instead of `getRoles`. The role selector prop type is narrowed to the fields it actually uses. Screenshot 2026-07-21 at 13 45 01 --- .../src/metadata/generated/schema.graphql | 1 + .../src/metadata/generated/schema.ts | 2 ++ .../src/metadata/generated/types.ts | 3 +++ .../src/generated-metadata/graphql.ts | 7 +++++++ .../SettingsDevelopersRoleSelector.tsx | 9 +++++++-- .../developers/graphql/queries/getApiKeyRoles.ts | 12 ++++++++++++ .../api-keys/SettingsDevelopersApiKeyDetail.tsx | 8 +++++--- .../api-keys/SettingsDevelopersApiKeysNew.tsx | 12 +++++++----- .../core-modules/api-key/api-key.resolver.ts | 7 +++++++ .../api-key/services/api-key-role.service.ts | 16 +++++++++++++++- 10 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/developers/graphql/queries/getApiKeyRoles.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index f0b9964a63..4134d0e67e 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -3062,6 +3062,7 @@ type Query { getViewFieldGroups(viewId: String!): [ViewFieldGroup!]! getViewFieldGroup(id: String!): ViewFieldGroup apiKeys: [ApiKey!]! + getApiKeyRoles: [Role!]! apiKey(input: GetApiKeyInput!): ApiKey getInviteSuggestions: [InviteSuggestion!]! applicationConnectionProviders(applicationId: UUID!): [ApplicationConnectionProvider!]! diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index b6cbda603a..56728c9221 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -2720,6 +2720,7 @@ export interface Query { getViewFieldGroups: ViewFieldGroup[] getViewFieldGroup?: ViewFieldGroup apiKeys: ApiKey[] + getApiKeyRoles: Role[] apiKey?: ApiKey getInviteSuggestions: InviteSuggestion[] applicationConnectionProviders: ApplicationConnectionProvider[] @@ -5913,6 +5914,7 @@ export interface QueryGenqlSelection{ getViewFieldGroups?: (ViewFieldGroupGenqlSelection & { __args: {viewId: Scalars['String']} }) getViewFieldGroup?: (ViewFieldGroupGenqlSelection & { __args: {id: Scalars['String']} }) apiKeys?: ApiKeyGenqlSelection + getApiKeyRoles?: RoleGenqlSelection apiKey?: (ApiKeyGenqlSelection & { __args: {input: GetApiKeyInput} }) getInviteSuggestions?: InviteSuggestionGenqlSelection applicationConnectionProviders?: (ApplicationConnectionProviderGenqlSelection & { __args: {applicationId: Scalars['UUID']} }) diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index f01348b7e0..3f5ed57ebd 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -6199,6 +6199,9 @@ export default { "apiKeys": [ 2 ], + "getApiKeyRoles": [ + 29 + ], "apiKey": [ 2, { diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 2ad93994ec..5ea53486e8 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -4387,6 +4387,7 @@ export type Query = { frontComponents: Array; getAddressDetails: PlaceDetailsResult; getAiSystemPromptPreview: AiSystemPromptPreview; + getApiKeyRoles: Array; getApprovedAccessDomains: Array; getAutoCompleteAddress: Array; getAvailablePackages: Scalars['JSON']['output']; @@ -8070,6 +8071,11 @@ export type GetApiKeyQueryVariables = Exact<{ export type GetApiKeyQuery = { __typename?: 'Query', apiKey?: { __typename?: 'ApiKey', createdAt: string, id: string, name: string, expiresAt: string, revokedAt?: string | null, role: { __typename?: 'Role', id: string, label: string, icon?: string | null } } | null }; +export type GetApiKeyRolesQueryVariables = Exact<{ [key: string]: never; }>; + + +export type GetApiKeyRolesQuery = { __typename?: 'Query', getApiKeyRoles: Array<{ __typename?: 'Role', id: string, label: string, icon?: string | null, canBeAssignedToApiKeys: boolean }> }; + export type GetApiKeysQueryVariables = Exact<{ [key: string]: never; }>; @@ -9073,6 +9079,7 @@ export const RevokeApiKeyDocument = {"kind":"Document","definitions":[{"kind":"O export const UpdateApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateApiKey"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKeyFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"revokedAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}}]}}]}}]} as unknown as DocumentNode; export const UpdateWebhookDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateWebhook"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateWebhookInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateWebhook"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WebhookFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WebhookFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Webhook"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"targetUrl"}},{"kind":"Field","name":{"kind":"Name","value":"operations"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"secret"}}]}}]} as unknown as DocumentNode; export const GetApiKeyDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetApiKey"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"GetApiKeyInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKey"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKeyFragment"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"revokedAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}}]}}]}}]} as unknown as DocumentNode; +export const GetApiKeyRolesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetApiKeyRoles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getApiKeyRoles"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"canBeAssignedToApiKeys"}}]}}]}}]} as unknown as DocumentNode; export const GetApiKeysDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetApiKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"apiKeys"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ApiKeyFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ApiKeyFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ApiKey"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"expiresAt"}},{"kind":"Field","name":{"kind":"Name","value":"revokedAt"}},{"kind":"Field","name":{"kind":"Name","value":"role"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}}]}}]}}]} as unknown as DocumentNode; export const GetWebhookDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWebhook"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"webhook"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WebhookFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WebhookFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Webhook"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"targetUrl"}},{"kind":"Field","name":{"kind":"Name","value":"operations"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"secret"}}]}}]} as unknown as DocumentNode; export const GetWebhooksDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetWebhooks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"webhooks"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"WebhookFragment"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"WebhookFragment"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"Webhook"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"targetUrl"}},{"kind":"Field","name":{"kind":"Name","value":"operations"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"secret"}}]}}]} as unknown as DocumentNode; diff --git a/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersRoleSelector.tsx b/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersRoleSelector.tsx index acddde4749..d87cccda66 100644 --- a/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersRoleSelector.tsx +++ b/packages/twenty-front/src/modules/settings/developers/components/SettingsDevelopersRoleSelector.tsx @@ -1,13 +1,18 @@ -import { type RoleWithPartialMembers } from '@/settings/roles/types/RoleWithPartialMembers'; import { Select } from '@/ui/input/components/Select'; +import { type Role } from '~/generated-metadata/graphql'; import { type IconComponent, useIcons } from 'twenty-ui/icon'; +type ApiKeyAssignableRole = Pick< + Role, + 'id' | 'label' | 'icon' | 'canBeAssignedToApiKeys' +>; + type SettingsDevelopersRoleSelectorProps = { value?: string | null | undefined; onChange: (roleId: string) => void; label?: string; description?: string; - roles: RoleWithPartialMembers[]; + roles: ApiKeyAssignableRole[]; }; export const SettingsDevelopersRoleSelector = ({ diff --git a/packages/twenty-front/src/modules/settings/developers/graphql/queries/getApiKeyRoles.ts b/packages/twenty-front/src/modules/settings/developers/graphql/queries/getApiKeyRoles.ts new file mode 100644 index 0000000000..4c1758ad5f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/developers/graphql/queries/getApiKeyRoles.ts @@ -0,0 +1,12 @@ +import gql from 'graphql-tag'; + +export const GET_API_KEY_ROLES = gql` + query GetApiKeyRoles { + getApiKeyRoles { + id + label + icon + canBeAssignedToApiKeys + } + } +`; diff --git a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx index 778c0f5f8f..1e1d006ef6 100644 --- a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx +++ b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeyDetail.tsx @@ -32,7 +32,7 @@ import { CreateApiKeyDocument, GenerateApiKeyTokenDocument, GetApiKeyDocument, - GetRolesDocument, + GetApiKeyRolesDocument, RevokeApiKeyDocument, } from '~/generated-metadata/graphql'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; @@ -100,9 +100,11 @@ export const SettingsDevelopersApiKeyDetail = () => { } }, [apiKeyData]); - const { data: rolesData, loading: rolesLoading } = useQuery(GetRolesDocument); + const { data: rolesData, loading: rolesLoading } = useQuery( + GetApiKeyRolesDocument, + ); - const roles = rolesData?.getRoles ?? []; + const roles = rolesData?.getApiKeyRoles ?? []; const apiKey = apiKeyData?.apiKey; const [apiKeyName, setApiKeyName] = useState(''); diff --git a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx index 9496890b14..86e7c37c6a 100644 --- a/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx +++ b/packages/twenty-front/src/pages/settings/developers/api-keys/SettingsDevelopersApiKeysNew.tsx @@ -21,8 +21,8 @@ import { Section } from 'twenty-ui/layout'; import { CreateApiKeyDocument, GenerateApiKeyTokenDocument, + GetApiKeyRolesDocument, GetApiKeysDocument, - GetRolesDocument, } from '~/generated-metadata/graphql'; import { useNavigateSettings } from '~/hooks/useNavigateSettings'; import { SETTINGS_API_WEBHOOKS_TABS } from '~/pages/settings/api-webhooks/constants/SettingsApiWebhooksTabs'; @@ -31,8 +31,10 @@ export const SettingsDevelopersApiKeysNew = () => { const { t } = useLingui(); const [generateOneApiKeyToken] = useMutation(GenerateApiKeyTokenDocument); const navigateSettings = useNavigateSettings(); - const { data: rolesData, loading: rolesLoading } = useQuery(GetRolesDocument); - const roles = rolesData?.getRoles ?? []; + const { data: rolesData, loading: rolesLoading } = useQuery( + GetApiKeyRolesDocument, + ); + const roles = rolesData?.getApiKeyRoles ?? []; const [formValues, setFormValues] = useState<{ name: string; @@ -45,8 +47,8 @@ export const SettingsDevelopersApiKeysNew = () => { }); useEffect(() => { - if (isDefined(rolesData?.getRoles)) { - const apiKeyAssignableRoles = rolesData.getRoles.filter( + if (isDefined(rolesData?.getApiKeyRoles)) { + const apiKeyAssignableRoles = rolesData.getApiKeyRoles.filter( (role) => role.canBeAssignedToApiKeys, ); if (apiKeyAssignableRoles.length > 0) { diff --git a/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts b/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts index e838b33c79..bd13f69b2b 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/api-key.resolver.ts @@ -43,6 +43,13 @@ export class ApiKeyResolver { return this.apiKeyService.findActiveByWorkspaceId(workspace.id); } + @Query(() => [RoleDTO]) + async getApiKeyRoles( + @AuthWorkspace() workspace: WorkspaceEntity, + ): Promise { + return this.apiKeyRoleService.getApiKeyAssignableRoles(workspace.id); + } + @Query(() => ApiKeyEntity, { nullable: true }) async apiKey( @Args('input') input: GetApiKeyInput, diff --git a/packages/twenty-server/src/engine/core-modules/api-key/services/api-key-role.service.ts b/packages/twenty-server/src/engine/core-modules/api-key/services/api-key-role.service.ts index 314a7bddae..cf271f69d8 100644 --- a/packages/twenty-server/src/engine/core-modules/api-key/services/api-key-role.service.ts +++ b/packages/twenty-server/src/engine/core-modules/api-key/services/api-key-role.service.ts @@ -14,7 +14,10 @@ import { RoleTargetService } from 'src/engine/metadata-modules/role-target/servi import { type RoleDTO } from 'src/engine/metadata-modules/role/dtos/role.dto'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { fromFlatRoleToRoleDto } from 'src/engine/metadata-modules/role/utils/fromFlatRoleToRoleDto.util'; -import { fromRoleEntityToRoleDto } from 'src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util'; +import { + fromRoleEntitiesToRoleDtos, + fromRoleEntityToRoleDto, +} from 'src/engine/metadata-modules/role/utils/fromRoleEntityToRoleDto.util'; import { InjectWorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/inject-workspace-scoped-repository.decorator'; import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; @@ -173,6 +176,17 @@ export class ApiKeyRoleService { }; } + public async getApiKeyAssignableRoles( + workspaceId: string, + ): Promise { + const roles = await this.roleRepository.find(workspaceId, { + where: { canBeAssignedToApiKeys: true }, + order: { label: 'ASC' }, + }); + + return fromRoleEntitiesToRoleDtos(roles); + } + public async getRolesByApiKeys({ apiKeyIds, workspaceId,