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:
@@ -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>;
|
||||
|
||||
+7
-2
@@ -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 = ({
|
||||
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const GET_API_KEY_ROLES = gql`
|
||||
query GetApiKeyRoles {
|
||||
getApiKeyRoles {
|
||||
id
|
||||
label
|
||||
icon
|
||||
canBeAssignedToApiKeys
|
||||
}
|
||||
}
|
||||
`;
|
||||
+5
-3
@@ -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('');
|
||||
|
||||
+7
-5
@@ -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,
|
||||
|
||||
+15
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user