Fix: allow API key creation without Roles permission (#23102)

## 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.

<img width="1058" height="408" alt="Screenshot 2026-07-21 at 13 38 34"
src="https://github.com/user-attachments/assets/fe97ba78-e116-458d-af10-11c5969c4636"
/>

## 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.

<img width="1025" height="455" alt="Screenshot 2026-07-21 at 13 45 01"
src="https://github.com/user-attachments/assets/f1be8f97-5a30-4afc-9eee-c928f4607471"
/>
This commit is contained in:
Marie
2026-07-21 15:31:05 +02:00
committed by GitHub
parent e5fc5054cc
commit bc3112a999
10 changed files with 66 additions and 11 deletions
@@ -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!]!
@@ -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']} })
@@ -6199,6 +6199,9 @@ export default {
"apiKeys": [
2
],
"getApiKeyRoles": [
29
],
"apiKey": [
2,
{
@@ -4387,6 +4387,7 @@ export type Query = {
frontComponents: Array<FrontComponent>;
getAddressDetails: PlaceDetailsResult;
getAiSystemPromptPreview: AiSystemPromptPreview;
getApiKeyRoles: Array<Role>;
getApprovedAccessDomains: Array<ApprovedAccessDomain>;
getAutoCompleteAddress: Array<AutocompleteResult>;
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<UpdateApiKeyMutation, UpdateApiKeyMutationVariables>;
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<UpdateWebhookMutation, UpdateWebhookMutationVariables>;
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<GetApiKeyQuery, GetApiKeyQueryVariables>;
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<GetApiKeyRolesQuery, GetApiKeyRolesQueryVariables>;
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<GetApiKeysQuery, GetApiKeysQueryVariables>;
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<GetWebhookQuery, GetWebhookQueryVariables>;
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<GetWebhooksQuery, GetWebhooksQueryVariables>;
@@ -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 = ({
@@ -0,0 +1,12 @@
import gql from 'graphql-tag';
export const GET_API_KEY_ROLES = gql`
query GetApiKeyRoles {
getApiKeyRoles {
id
label
icon
canBeAssignedToApiKeys
}
}
`;
@@ -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('');
@@ -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) {
@@ -43,6 +43,13 @@ export class ApiKeyResolver {
return this.apiKeyService.findActiveByWorkspaceId(workspace.id);
}
@Query(() => [RoleDTO])
async getApiKeyRoles(
@AuthWorkspace() workspace: WorkspaceEntity,
): Promise<RoleDTO[]> {
return this.apiKeyRoleService.getApiKeyAssignableRoles(workspace.id);
}
@Query(() => ApiKeyEntity, { nullable: true })
async apiKey(
@Args('input') input: GetApiKeyInput,
@@ -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<RoleDTO[]> {
const roles = await this.roleRepository.find(workspaceId, {
where: { canBeAssignedToApiKeys: true },
order: { label: 'ASC' },
});
return fromRoleEntitiesToRoleDtos(roles);
}
public async getRolesByApiKeys({
apiKeyIds,
workspaceId,