diff --git a/frontend/snapshots.yml b/frontend/snapshots.yml index 453f87bb4453..5b311d85a161 100644 --- a/frontend/snapshots.yml +++ b/frontend/snapshots.yml @@ -13146,6 +13146,10 @@ snapshots: hash: v1.k794b7964.a464549645ee031d1943952d051b8a9b3299c53e8ae5e37e06c29f47c22a9801.rh3jn-mkm-MBeAIvordg5eBjfB4XkBnNw1HK7WA3QBM scenes-app-settings-user--settings-user-api-keys--light: hash: v1.k794b7964.c56cd95732f9e4355cc2b62b37ae9c36394637d232ea0d75cfaa63ede57205f5.ZgEv-Uxm9273XkhrJDzf9Cj_DESxhuf6OO_1o_fTd4k + scenes-app-settings-user--settings-user-api-keys-create-modal--dark: + hash: v1.k794b7964.0c60f46bd47ba425439c87fcb40dc1b7e215a81a128d11649402e515d2675432.Z1DsSFhodE7mOsQ1EnLFhrdbCKWRYMP2lB6clnbyMC8 + scenes-app-settings-user--settings-user-api-keys-create-modal--light: + hash: v1.k794b7964.d3f6e5103966ece2e3212959e66e7ae3e35ee71cb6d2c579b0871cc01601bda8.7jToX6wggX9huxSMWqwHFRxrXQL9d8EjJLrsruKVyAs scenes-app-settings-user--settings-user-backup-codes-modal--dark: hash: v1.k794b7964.fee174652504bedf45b5050dae77b7f38ffaac07d04c14c8c77b010d565cba97.n_drnTbGpOMa04Hy05-KZlJhx74bmdscz3V2bjDSQiw scenes-app-settings-user--settings-user-backup-codes-modal--light: diff --git a/frontend/src/lib/components/ScopeAccessRow/ScopeAccessGroup.tsx b/frontend/src/lib/components/ScopeAccessRow/ScopeAccessGroup.tsx new file mode 100644 index 000000000000..4b01de9a8176 --- /dev/null +++ b/frontend/src/lib/components/ScopeAccessRow/ScopeAccessGroup.tsx @@ -0,0 +1,122 @@ +import clsx from 'clsx' +import { type ReactNode, useId, useState } from 'react' + +import { IconChevronRight } from '@posthog/icons' +import { LemonSegmentedButton, LemonTag } from '@posthog/lemon-ui' + +import type { ScopeAccessLevel } from 'lib/scopes' + +interface ScopeAccessGroupProps { + /** Group heading, such as a product area. */ + label: string + /** How many rows in the group sit at each level, for the tags in the header. */ + counts: Record + /** The level the group control shows as selected. Undefined when the rows disagree. */ + value: ScopeAccessLevel | undefined + /** Called with the level the user picks for the whole group. */ + onChange: (level: ScopeAccessLevel) => void + /** Reason the No access option should be disabled. Set to a non-empty string to disable. */ + noneDisabledReason?: string + /** Reason the Read option should be disabled. Set to a non-empty string to disable. */ + readDisabledReason?: string + /** Reason the Write option should be disabled. Set to a non-empty string to disable. */ + writeDisabledReason?: string + /** Tooltip on the selected level, for when some rows sit at another level. */ + valueTooltip?: string + /** Whether the rows show on first render. */ + defaultOpen?: boolean + /** Prefix of the `data-attr`s: `-toggle-` on the toggle, `--` on an option. */ + dataAttrPrefix: string + /** The rows of the group, rendered when the group is open. */ + children: ReactNode +} + +export function ScopeAccessGroup({ + label, + counts, + value, + onChange, + noneDisabledReason, + readDisabledReason, + writeDisabledReason, + valueTooltip, + defaultOpen = false, + dataAttrPrefix, + children, +}: ScopeAccessGroupProps): JSX.Element { + const [open, setOpen] = useState(defaultOpen) + const panelId = useId() + const total = counts.none + counts.read + counts.write + const groupSlug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-') + + return ( +
+ {/* The header stays on one line. In a narrow container, such as an OAuth popup window, + the label truncates and the counts hide, so the control keeps its place. */} +
+ +
+ + + {total} {total === 1 ? 'permission' : 'permissions'} + + + {counts.none > 0 && ( + + {counts.none} none + + )} + {counts.read > 0 && ( + + {counts.read} read + + )} + {counts.write > 0 && ( + + {counts.write} write + + )} + + +
+ onChange(level as ScopeAccessLevel)} + options={[ + { label: 'No access', value: 'none', disabledReason: noneDisabledReason }, + { label: 'Read', value: 'read', disabledReason: readDisabledReason }, + { label: 'Write', value: 'write', disabledReason: writeDisabledReason }, + ].map((option) => ({ + ...option, + // Tells a group change apart from a row change in autocapture. + 'data-attr': `${dataAttrPrefix}-${groupSlug}-${option.value}`, + tooltip: option.value === value ? valueTooltip : undefined, + }))} + /> +
+
+
+ {open && ( +
+ {children} +
+ )} +
+ ) +} diff --git a/frontend/src/lib/scopes.test.ts b/frontend/src/lib/scopes.test.ts index 1e2cdfb1c014..b332ecaecc6d 100644 --- a/frontend/src/lib/scopes.test.ts +++ b/frontend/src/lib/scopes.test.ts @@ -1,4 +1,3 @@ -import { AGENT_USE_CASE_SCOPES } from 'lib/agentScopes.generated' import { OAUTH_SCOPES_HIDDEN } from 'lib/oauthScopes.generated' import { AGENT_CLI_API_KEY_SCOPES, @@ -140,9 +139,12 @@ describe('API_KEY_SCOPE_PRESETS', () => { expect(preset.label).toBe('Read-only access') }) - it('contains :read for every entry in API_SCOPES except unprivileged-excluded scopes', () => { + it('contains :read for every readable entry in API_SCOPES except unprivileged-excluded scopes', () => { const preset = findPreset('read_only_access') - const expected = API_SCOPES.filter(({ unprivilegedExcluded }) => !unprivilegedExcluded) + const expected = API_SCOPES.filter( + ({ unprivilegedExcluded, disabledActions }) => + !unprivilegedExcluded && !disabledActions?.includes('read') + ) .map(({ key }) => `${key}:read`) .sort() expect([...preset.scopes].sort()).toEqual(expected) @@ -176,14 +178,14 @@ describe('API_KEY_SCOPE_PRESETS', () => { expect(preset.scopes).not.toContain('integration:write') expect(preset.scopes).not.toContain('user:write') }) + }) - it('only includes scopes the key creation UI can render', () => { + it.each(API_KEY_SCOPE_PRESETS.filter(({ value }) => value !== 'all_access').map(({ value }) => value))( + 'preset %s only sets levels the key creation UI can render', + (value) => { const renderableScopes = getRenderableKeyCreationScopes() - - expect(AGENT_CLI_API_KEY_SCOPES).toEqual( - (AGENT_USE_CASE_SCOPES as readonly string[]).filter((scope) => renderableScopes.has(scope)) - ) - expect(AGENT_CLI_API_KEY_SCOPES.every((scope) => renderableScopes.has(scope))).toBe(true) - }) - }) + const unrenderable = findPreset(value).scopes.filter((scope) => !renderableScopes.has(scope)) + expect(unrenderable).toEqual([]) + } + ) }) diff --git a/frontend/src/lib/scopes.tsx b/frontend/src/lib/scopes.tsx index 0c61d05be347..c33c138725df 100644 --- a/frontend/src/lib/scopes.tsx +++ b/frontend/src/lib/scopes.tsx @@ -4,6 +4,16 @@ import type { APIScopeAction, APIScopeObject } from '~/types' export const MAX_API_KEYS_PER_USER = 10 // Same as in posthog/api/personal_api_key.py +export type ScopeAccessLevel = 'none' | 'read' | 'write' + +export const countScopeRowsByLevel = (rows: { value: ScopeAccessLevel }[]): Record => { + const counts: Record = { none: 0, read: 0, write: 0 } + for (const row of rows) { + counts[row.value] += 1 + } + return counts +} + export type APIScope = { key: APIScopeObject objectName: string @@ -315,9 +325,23 @@ export const API_KEY_CREATION_DISABLED_SCOPES = new Set( API_SCOPES.flatMap(({ key, disabledActions }) => (disabledActions ?? []).map((action) => `${key}:${action}`)) ) -export const AGENT_CLI_API_KEY_SCOPES = AGENT_USE_CASE_SCOPES.filter((scope) => - API_KEY_CREATION_RENDERABLE_SCOPES.has(scope) -) +// A preset must not set a level the key picker cannot show, or the picker shows the level on a +// disabled segment. A write on a write-disabled object falls back to read, which write implies on the +// server anyway. A scope with no allowed level is dropped. +export const clampToKeyCreationScopes = (scopes: readonly string[]): string[] => [ + ...new Set( + scopes.flatMap((scope) => { + if (API_KEY_CREATION_RENDERABLE_SCOPES.has(scope)) { + return [scope] + } + const [object, action] = scope.split(':') + const read = `${object}:read` + return action === 'write' && API_KEY_CREATION_RENDERABLE_SCOPES.has(read) ? [read] : [] + }) + ), +] + +export const AGENT_CLI_API_KEY_SCOPES = clampToKeyCreationScopes(AGENT_USE_CASE_SCOPES) export const API_KEY_SCOPE_PRESETS: { value: string @@ -353,9 +377,11 @@ export const API_KEY_SCOPE_PRESETS: { value: 'mcp_server', label: 'MCP Server', // file_system is excluded because the MCP server doesn't request it, not because it's privileged. - scopes: API_SCOPES.filter( - ({ key, unprivilegedExcluded }) => !unprivilegedExcluded && key !== 'file_system' - ).map(({ key }) => `${key}:write`), + scopes: clampToKeyCreationScopes( + API_SCOPES.filter(({ key, unprivilegedExcluded }) => !unprivilegedExcluded && key !== 'file_system').map( + ({ key }) => `${key}:write` + ) + ), access_type: 'all', }, { @@ -367,7 +393,9 @@ export const API_KEY_SCOPE_PRESETS: { { value: 'read_only_access', label: 'Read-only access', - scopes: API_SCOPES.filter(({ unprivilegedExcluded }) => !unprivilegedExcluded).map(({ key }) => `${key}:read`), + scopes: clampToKeyCreationScopes( + API_SCOPES.filter(({ unprivilegedExcluded }) => !unprivilegedExcluded).map(({ key }) => `${key}:read`) + ), }, { value: 'all_access', label: 'All access', scopes: ['*'] }, ] diff --git a/frontend/src/scenes/oauth/OAuthScopeGroupControl.tsx b/frontend/src/scenes/oauth/OAuthScopeGroupControl.tsx index d5c7fa93d9cd..49f274aa0abb 100644 --- a/frontend/src/scenes/oauth/OAuthScopeGroupControl.tsx +++ b/frontend/src/scenes/oauth/OAuthScopeGroupControl.tsx @@ -1,15 +1,7 @@ -import clsx from 'clsx' -import { useId, useState } from 'react' +import { ScopeAccessGroup } from 'lib/components/ScopeAccessRow/ScopeAccessGroup' +import { countScopeRowsByLevel, type ScopeAccessLevel } from 'lib/scopes' -import { IconChevronRight } from '@posthog/icons' -import { LemonSegmentedButton, LemonTag } from '@posthog/lemon-ui' - -import { - type OAuthScopeRow, - type ScopeAccessLevel, - scopeGroupAccessLevel, - scopeGroupLevelTooltip, -} from './oauthAuthorizeLogic' +import { type OAuthScopeRow, scopeGroupAccessLevel, scopeGroupLevelTooltip } from './oauthAuthorizeLogic' import { OAuthScopeRowControl } from './OAuthScopeRowControl' interface OAuthScopeGroupControlProps { @@ -20,14 +12,6 @@ interface OAuthScopeGroupControlProps { onChangeGroup: (scopeObjects: string[], level: ScopeAccessLevel) => void } -const countByLevel = (rows: OAuthScopeRow[]): Record => { - const counts: Record = { none: 0, read: 0, write: 0 } - for (const row of rows) { - counts[row.value] += 1 - } - return counts -} - export function OAuthScopeGroupControl({ label, rows, @@ -35,92 +19,25 @@ export function OAuthScopeGroupControl({ onChangeRow, onChangeGroup, }: OAuthScopeGroupControlProps): JSX.Element { - const [open, setOpen] = useState(false) - const panelId = useId() - const counts = countByLevel(rows) const groupLevel = scopeGroupAccessLevel(rows) - const groupLevelTooltip = scopeGroupLevelTooltip(rows, groupLevel, appName) const keys = rows.map((row) => row.key) const anyWritable = rows.some((row) => row.maxLevel === 'write') const allRequired = rows.every((row) => row.minLevel !== 'none') - const groupSlug = label.toLowerCase().replace(/[^a-z0-9]+/g, '-') return ( -
- {/* The header stays on one line. In a narrow container, such as an OAuth popup window, - the label truncates and the counts hide, so the control keeps its place. */} -
- -
- - - {rows.length} {rows.length === 1 ? 'permission' : 'permissions'} - - - {/* Same order as the segmented control: no access, read, write. */} - {counts.none > 0 && ( - - {counts.none} none - - )} - {counts.read > 0 && ( - - {counts.read} read - - )} - {counts.write > 0 && ( - - {counts.write} write - - )} - - -
- onChangeGroup(keys, level as ScopeAccessLevel)} - options={[ - { - label: 'No access', - value: 'none', - disabledReason: allRequired ? `${appName} requires these permissions` : undefined, - }, - { label: 'Read', value: 'read' }, - { - label: 'Write', - value: 'write', - disabledReason: anyWritable ? undefined : `Not requested by ${appName}`, - }, - ].map((option) => - option.value === groupLevel ? { ...option, tooltip: groupLevelTooltip } : option - )} - /> -
-
-
- {open && ( -
- {rows.map((row) => ( - - ))} -
- )} -
+ onChangeGroup(keys, level)} + noneDisabledReason={allRequired ? `${appName} requires these permissions` : undefined} + writeDisabledReason={anyWritable ? undefined : `Not requested by ${appName}`} + dataAttrPrefix="oauth-scope-group" + > + {rows.map((row) => ( + + ))} + ) } diff --git a/frontend/src/scenes/oauth/OAuthScopeRowControl.tsx b/frontend/src/scenes/oauth/OAuthScopeRowControl.tsx index 5bad57815430..980493361cfa 100644 --- a/frontend/src/scenes/oauth/OAuthScopeRowControl.tsx +++ b/frontend/src/scenes/oauth/OAuthScopeRowControl.tsx @@ -1,6 +1,7 @@ import { ScopeAccessRow } from 'lib/components/ScopeAccessRow/ScopeAccessRow' +import type { ScopeAccessLevel } from 'lib/scopes' -import type { OAuthScopeRow, ScopeAccessLevel } from './oauthAuthorizeLogic' +import type { OAuthScopeRow } from './oauthAuthorizeLogic' interface OAuthScopeRowControlProps { row: OAuthScopeRow diff --git a/frontend/src/scenes/oauth/oauthAuthorizeLogic.ts b/frontend/src/scenes/oauth/oauthAuthorizeLogic.ts index e6c07219daf2..c76af4656e11 100644 --- a/frontend/src/scenes/oauth/oauthAuthorizeLogic.ts +++ b/frontend/src/scenes/oauth/oauthAuthorizeLogic.ts @@ -16,6 +16,7 @@ import { getScopeDescription, getScopeGroupLabel, OTHER_SCOPE_GROUP_LABEL, + type ScopeAccessLevel, } from 'lib/scopes' import { getAppContext } from 'lib/utils/getAppContext' import { userLogic } from 'scenes/userLogic' @@ -32,8 +33,6 @@ const IDENTITY_SCOPES = ['openid', 'profile', 'email', 'introspection'] const scopeObjectKey = (scope: string): string => (scope === '*' ? '*' : scope.split(':')[0]) -export type ScopeAccessLevel = 'none' | 'read' | 'write' - const ACCESS_LEVEL_ORDER: Record = { none: 0, read: 1, write: 2 } const clampAccessLevel = (level: ScopeAccessLevel, min: ScopeAccessLevel, max: ScopeAccessLevel): ScopeAccessLevel => { diff --git a/frontend/src/scenes/settings/stories/SettingsUser.stories.tsx b/frontend/src/scenes/settings/stories/SettingsUser.stories.tsx index c48de2267b42..d4c1b4d63edb 100644 --- a/frontend/src/scenes/settings/stories/SettingsUser.stories.tsx +++ b/frontend/src/scenes/settings/stories/SettingsUser.stories.tsx @@ -199,6 +199,18 @@ export const SettingsUserApiKeys: Story = { args: { sectionId: 'user-api-keys' }, } +export const SettingsUserApiKeysCreateModal: Story = { + args: { sectionId: 'user-api-keys' }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement) + await userEvent.click(await canvas.findByText('Create personal API key')) + // Open one group, so the snapshot shows the rows under a header too. The settings + // navigation has a "Product analytics" entry as well, so the lookup stays inside the modal. + const dialog = within(await within(document.body).findByRole('dialog')) + await userEvent.click(await dialog.findByText('Product analytics')) + }, +} + export const SettingsUserNotifications: Story = { args: { sectionId: 'user-notifications' }, } diff --git a/frontend/src/scenes/settings/user/PersonalAPIKeyScopeGroup.tsx b/frontend/src/scenes/settings/user/PersonalAPIKeyScopeGroup.tsx new file mode 100644 index 000000000000..added33cf58e --- /dev/null +++ b/frontend/src/scenes/settings/user/PersonalAPIKeyScopeGroup.tsx @@ -0,0 +1,58 @@ +import { ScopeAccessGroup } from 'lib/components/ScopeAccessRow/ScopeAccessGroup' +import { ScopeAccessRow } from 'lib/components/ScopeAccessRow/ScopeAccessRow' +import { countScopeRowsByLevel, type ScopeAccessLevel } from 'lib/scopes' + +import { + type PersonalAPIKeyScopeGroup as PersonalAPIKeyScopeGroupType, + scopeGroupAccessLevel, + scopeGroupLevelTooltip, +} from './personalAPIKeysLogic' + +interface PersonalAPIKeyScopeGroupProps { + group: PersonalAPIKeyScopeGroupType + defaultOpen: boolean + onChangeRow: (scopeObject: string, level: ScopeAccessLevel) => void + onChangeGroup: (scopeObjects: string[], level: ScopeAccessLevel) => void +} + +export function PersonalAPIKeyScopeGroup({ + group, + defaultOpen, + onChangeRow, + onChangeGroup, +}: PersonalAPIKeyScopeGroupProps): JSX.Element { + const { label, rows } = group + const groupLevel = scopeGroupAccessLevel(rows) + const keys = rows.map((row) => row.scope.key) + // A level that no row can take is disabled on the group, with the reason the rows give. + const groupDisabledReason = (reason: 'readDisabledReason' | 'writeDisabledReason'): string | undefined => + rows.every((row) => row[reason]) ? rows[0]?.[reason] : undefined + + return ( + onChangeGroup(keys, level)} + readDisabledReason={groupDisabledReason('readDisabledReason')} + writeDisabledReason={groupDisabledReason('writeDisabledReason')} + defaultOpen={defaultOpen} + dataAttrPrefix="personal-api-key-scope-group" + > + {rows.map(({ scope, value, muted, readDisabledReason, writeDisabledReason }) => ( + onChangeRow(scope.key, level as ScopeAccessLevel)} + readDisabledReason={readDisabledReason} + writeDisabledReason={writeDisabledReason} + warning={value === 'none' ? undefined : scope.warnings?.[value]} + /> + ))} + + ) +} diff --git a/frontend/src/scenes/settings/user/PersonalAPIKeys.tsx b/frontend/src/scenes/settings/user/PersonalAPIKeys.tsx index 446c9f304198..dbd10489d0ae 100644 --- a/frontend/src/scenes/settings/user/PersonalAPIKeys.tsx +++ b/frontend/src/scenes/settings/user/PersonalAPIKeys.tsx @@ -18,7 +18,6 @@ import { } from '@posthog/lemon-ui' import { CopyToClipboardInline } from 'lib/components/CopyToClipboard' -import { ScopeAccessRow } from 'lib/components/ScopeAccessRow/ScopeAccessRow' import { LemonButton } from 'lib/lemon-ui/LemonButton' import { LemonField } from 'lib/lemon-ui/LemonField' import { Link } from 'lib/lemon-ui/Link' @@ -28,6 +27,7 @@ import { preflightLogic } from 'scenes/PreflightCheck/preflightLogic' import { PersonalAPIKeyType } from '~/types' import { APIKeyTable } from '../shared/APIKeyTable' +import { PersonalAPIKeyScopeGroup } from './PersonalAPIKeyScopeGroup' import { personalAPIKeysLogic } from './personalAPIKeysLogic' import ScopeAccessSelector from './scopes/ScopeAccessSelector' @@ -40,19 +40,19 @@ export function EditKeyModal({ zIndex }: EditKeyModalProps): JSX.Element { editingKeyId, isEditingKeySubmitting, editingKeyChanged, - formScopeRadioValues, allAccessSelected, isEditingKeyLegacy, editingKey, allTeams, allOrganizations, - filteredScopes, + filteredScopeGroups, searchTerm, isDescriptionFieldVisible, } = useValues(personalAPIKeysLogic) const { setEditingKeyId, setScopeRadioValue, + setScopeGroupAccess, submitEditingKey, resetScopes, setSearchTerm, @@ -219,63 +219,22 @@ export function EditKeyModal({ zIndex }: EditKeyModalProps): JSX.Element { className="mb-2" size="small" /> -
- {filteredScopes.length === 0 ? ( -
- No scopes match "{searchTerm}" -
- ) : ( - filteredScopes.map( - ({ - key, - objectName, - disabledActions, - warnings, - disabledWhenProjectScoped, - info, - }) => { - const disabledDueToProjectScope = - disabledWhenProjectScoped && - editingKey.access_type === 'teams' - const selectedScopeAction = formScopeRadioValues[key] - const warningScopeAction = - selectedScopeAction === 'read' || - selectedScopeAction === 'write' - ? selectedScopeAction - : null - return ( - setScopeRadioValue(key, value)} - readDisabledReason={ - disabledActions?.includes('read') - ? 'Does not apply to this resource' - : disabledDueToProjectScope - ? 'Not available for project scoped keys' - : undefined - } - writeDisabledReason={ - disabledActions?.includes('write') - ? 'Does not apply to this resource' - : disabledDueToProjectScope - ? 'Not available for project scoped keys' - : undefined - } - warning={ - warningScopeAction - ? warnings?.[warningScopeAction] - : undefined - } - /> - ) - } - ) - )} -
+ {filteredScopeGroups.length === 0 ? ( +
+ No scopes match "{searchTerm}" +
+ ) : ( + filteredScopeGroups.map((group) => ( + + )) + )} )} diff --git a/frontend/src/scenes/settings/user/personalAPIKeysLogic.test.ts b/frontend/src/scenes/settings/user/personalAPIKeysLogic.test.ts index 9b46781897b8..b4f74fea1328 100644 --- a/frontend/src/scenes/settings/user/personalAPIKeysLogic.test.ts +++ b/frontend/src/scenes/settings/user/personalAPIKeysLogic.test.ts @@ -122,6 +122,29 @@ describe('personalAPIKeysLogic', () => { } ) + it('clamps a group write to what each row can take', async () => { + logic.actions.setEditingKeyId('new') + logic.actions.setEditingKeyValues({ access_type: 'teams' }) + // integration is read-only, and organization is not available for a project scoped key. + logic.actions.setScopeGroupAccess(['survey', 'integration', 'organization'], 'write') + await expectLogic(logic).toFinishAllListeners() + + expect([...logic.values.editingKey.scopes].sort()).toEqual([ + 'feature_flag:write', + 'integration:read', + 'survey:write', + ]) + }) + + it('keeps only the groups with a row that matches the search', () => { + logic.actions.setEditingKeyId('new') + logic.actions.setSearchTerm('survey') + + expect(logic.values.filteredScopeGroups.flatMap(({ rows }) => rows.map(({ scope }) => scope.key))).toEqual([ + 'survey', + ]) + }) + it('leaves the auto-selected feature_flag:write removable', async () => { logic.actions.setEditingKeyId('new') logic.actions.setScopeRadioValue('survey', 'write') diff --git a/frontend/src/scenes/settings/user/personalAPIKeysLogic.tsx b/frontend/src/scenes/settings/user/personalAPIKeysLogic.tsx index aaa265d5c5e2..7c04c8b840a6 100644 --- a/frontend/src/scenes/settings/user/personalAPIKeysLogic.tsx +++ b/frontend/src/scenes/settings/user/personalAPIKeysLogic.tsx @@ -13,8 +13,12 @@ import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' import { featureFlagLogic } from 'lib/logic/featureFlagLogic' import { APIScope, + API_SCOPE_GROUPS, API_SCOPES, + OTHER_SCOPE_GROUP_LABEL, SCOPES_IMPLYING_FEATURE_FLAG_WRITE, + type ScopeAccessLevel, + getScopeGroupLabel, scopeMatchesSearch, scopesArrayToObject, scopesObjectToArray, @@ -36,6 +40,83 @@ export type EditingKeyFormValues = Pick< access_type?: 'all' | 'organizations' | 'teams' } +export type PersonalAPIKeyScopeRow = { + scope: APIScope + value: ScopeAccessLevel + /** True when the row is dimmed because the key's access type rules it out. */ + muted: boolean + readDisabledReason?: string + writeDisabledReason?: string +} + +export type PersonalAPIKeyScopeGroup = { + label: string + rows: PersonalAPIKeyScopeRow[] +} + +// The highest level at or below `level` that the row can take. +export const clampScopeLevel = (row: PersonalAPIKeyScopeRow, level: ScopeAccessLevel): ScopeAccessLevel => { + if (level === 'write' && !row.writeDisabledReason) { + return 'write' + } + if (level !== 'none' && !row.readDisabledReason) { + return 'read' + } + return 'none' +} + +// The group control shows a level as selected when each row is at that level after the clamp. A +// level no row can take is never selected, which removes the tie between write and read in a group +// with no writable row. +export const scopeGroupAccessLevel = (rows: PersonalAPIKeyScopeRow[]): ScopeAccessLevel | undefined => { + const levels: ScopeAccessLevel[] = ['write', 'read', 'none'] + return levels.find( + (level) => + rows.some((row) => clampScopeLevel(row, level) === level) && + rows.every((row) => row.value === clampScopeLevel(row, level)) + ) +} + +// Tooltip for the selected group level when some rows sit below it after the clamp. The clamp only +// ever lowers a row, so a row that is not at the selected level is below it. +export const scopeGroupLevelTooltip = ( + rows: PersonalAPIKeyScopeRow[], + level: ScopeAccessLevel | undefined +): string | undefined => { + if (!level) { + return undefined + } + const lower = rows.filter((row) => row.value !== level).length + if (lower === 0) { + return undefined + } + return `${lower} of these permissions ${lower === 1 ? 'stays' : 'stay'} lower, because ${level} access does not apply to ${lower === 1 ? 'it' : 'them'}.` +} + +// The scopes array with each key set to its level. A write on a survey or early access feature also +// writes a feature flag (targeting / linked flag), so it implies feature_flag:write (see +// SCOPES_IMPLYING_FEATURE_FLAG_WRITE). Auto-select it so the key works out of the box. Scoped to +// these toggle actions only, not every scopes change, so it stays visible and removable if the key +// only manages plain surveys. +const withScopeLevels = (scopes: string[], levels: [string, ScopeAccessLevel][]): string[] => { + const scopesObject = scopesArrayToObject(scopes) + for (const [key, level] of levels) { + if (level === 'none') { + delete scopesObject[key] + } else { + scopesObject[key] = level + } + if ( + level === 'write' && + key in SCOPES_IMPLYING_FEATURE_FLAG_WRITE && + scopesObject['feature_flag'] !== 'write' + ) { + scopesObject['feature_flag'] = 'write' + } + } + return scopesObjectToArray(scopesObject) +} + // Generated by kea-typegen. Update if you're an agent, ignore if you're human. export interface personalAPIKeysLogicValues { featureFlags: FeatureFlagsSet // featureFlagLogic @@ -58,7 +139,7 @@ export interface personalAPIKeysLogicValues { editingKeyTouched: boolean editingKeyTouches: Record editingKeyValidationErrors: DeepPartialMap - filteredScopes: APIScope[] + filteredScopeGroups: PersonalAPIKeyScopeGroup[] formScopeRadioValues: Record getRestrictedOrganizationsForKey: (keyId: string) => OrganizationBasicType[] getRestrictedTeamsForKey: (keyId: string) => TeamBasicType[] @@ -69,6 +150,7 @@ export interface personalAPIKeysLogicValues { isPersonalApiKeyIdDisabled: (keyId: string) => boolean keys: PersonalAPIKeyType[] keysLoading: boolean + scopeRows: PersonalAPIKeyScopeRow[] searchTerm: string showEditingKeyErrors: boolean } @@ -184,6 +266,13 @@ export interface personalAPIKeysLogicActions { setEditingKeyValues: (values: DeepPartial) => { values: DeepPartial } + setScopeGroupAccess: ( + keys: string[], + level: ScopeAccessLevel + ) => { + keys: string[] + level: ScopeAccessLevel + } setScopeRadioValue: ( key: string, action: string @@ -236,7 +325,12 @@ export interface personalAPIKeysLogicMeta { hasAvailableFeature: (feature: AvailableFeature, currentUsage?: number | undefined) => boolean ) => APIScope[] allowedScopeKeys: (allowedScopes: APIScope[]) => Set - filteredScopes: (searchTerm: string, allowedScopes: APIScope[]) => APIScope[] + scopeRows: ( + allowedScopes: APIScope[], + formScopeRadioValues: Record, + editingKey: EditingKeyFormValues + ) => PersonalAPIKeyScopeRow[] + filteredScopeGroups: (searchTerm: string, scopeRows: PersonalAPIKeyScopeRow[]) => PersonalAPIKeyScopeGroup[] formScopeRadioValues: (editingKey: EditingKeyFormValues) => Record allAccessSelected: (editingKey: EditingKeyFormValues) => boolean isEditingKeyLegacy: (editingKeyId: string | null, keys: PersonalAPIKeyType[]) => boolean @@ -286,6 +380,7 @@ export const personalAPIKeysLogic = kea([ deleteKey: (id: PersonalAPIKeyType['id']) => ({ id }), rollKey: (id: PersonalAPIKeyType['id']) => ({ id }), setScopeRadioValue: (key: string, action: string) => ({ key, action }), + setScopeGroupAccess: (keys: string[], level: ScopeAccessLevel) => ({ keys, level }), resetScopes: true, loadAllTeams: true, showDescriptionField: true, @@ -440,10 +535,47 @@ export const personalAPIKeysLogic = kea([ (s) => [s.allowedScopes], (allowedScopes: APIScope[]): Set => new Set(allowedScopes.map((scope) => scope.key)), ], - filteredScopes: [ - (s) => [s.searchTerm, s.allowedScopes], - (searchTerm: string, allowedScopes: APIScope[]): APIScope[] => - allowedScopes.filter((scope) => scopeMatchesSearch(scope, searchTerm)), + scopeRows: [ + (s) => [s.allowedScopes, s.formScopeRadioValues, s.editingKey], + ( + allowedScopes: APIScope[], + formScopeRadioValues: Record, + editingKey: EditingKeyFormValues + ): PersonalAPIKeyScopeRow[] => + allowedScopes.map((scope) => { + const projectScoped = scope.disabledWhenProjectScoped && editingKey.access_type === 'teams' + const disabledReason = (action: 'read' | 'write'): string | undefined => + scope.disabledActions?.includes(action) + ? 'Does not apply to this resource' + : projectScoped + ? 'Not available for project scoped keys' + : undefined + return { + scope, + value: (formScopeRadioValues[scope.key] ?? 'none') as ScopeAccessLevel, + muted: Boolean(projectScoped), + readDisabledReason: disabledReason('read'), + writeDisabledReason: disabledReason('write'), + } + }), + ], + // The rows that match the search, grouped by product area in API_SCOPE_GROUPS order. An object + // that is in no group goes in the "Other" group at the end, so it still shows. + filteredScopeGroups: [ + (s) => [s.searchTerm, s.scopeRows], + (searchTerm: string, scopeRows: PersonalAPIKeyScopeRow[]): PersonalAPIKeyScopeGroup[] => { + const rowsByLabel = new Map() + for (const row of scopeRows) { + if (!scopeMatchesSearch(row.scope, searchTerm)) { + continue + } + const label = getScopeGroupLabel(row.scope.key) + rowsByLabel.set(label, [...(rowsByLabel.get(label) ?? []), row]) + } + return [...API_SCOPE_GROUPS.map(({ label }) => label), OTHER_SCOPE_GROUP_LABEL] + .filter((label) => rowsByLabel.has(label)) + .map((label) => ({ label, rows: rowsByLabel.get(label) ?? [] })) + }, ], formScopeRadioValues: [ (s) => [s.editingKey], @@ -699,32 +831,23 @@ export const personalAPIKeysLogic = kea([ }, setScopeRadioValue: ({ key, action }) => { - // Convert current scopes array to object for easier manipulation - const scopesObject = scopesArrayToObject(values.editingKey.scopes) - - // Update the specific scope - if (action === 'none') { - delete scopesObject[key] - } else { - scopesObject[key] = action - } - - // Survey and early access feature writes also write a feature flag (targeting / linked flag), - // so they imply feature_flag:write (see SCOPES_IMPLYING_FEATURE_FLAG_WRITE). Auto-select it so - // the key works out of the box. Scoped to this toggle action only — not every scopes change — - // so it stays visible and removable if the key only manages plain surveys. - if ( - action === 'write' && - key in SCOPES_IMPLYING_FEATURE_FLAG_WRITE && - scopesObject['feature_flag'] !== 'write' - ) { - scopesObject['feature_flag'] = 'write' - } - - // Convert back to array format - const newScopes = scopesObjectToArray(scopesObject) + actions.setEditingKeyValue( + 'scopes', + withScopeLevels(values.editingKey.scopes, [[key, action as ScopeAccessLevel]]) + ) + }, - actions.setEditingKeyValue('scopes', newScopes) + // A group action clamps each row to the levels it can take, so a group set to write holds a + // read-only row at read and a project-scoped row at none. + setScopeGroupAccess: ({ keys, level }) => { + const rowsByKey = new Map( + values.scopeRows.map((row) => [row.scope.key, row]) + ) + const levels = keys.flatMap((key): [string, ScopeAccessLevel][] => { + const row = rowsByKey.get(key) + return row ? [[key, clampScopeLevel(row, level)]] : [] + }) + actions.setEditingKeyValue('scopes', withScopeLevels(values.editingKey.scopes, levels)) }, createKeySuccess: async ({ key }) => {