diff --git a/frontend/src/lib/api.mock.ts b/frontend/src/lib/api.mock.ts index 0dacdcfa74554..e6dac16290e92 100644 --- a/frontend/src/lib/api.mock.ts +++ b/frontend/src/lib/api.mock.ts @@ -84,6 +84,7 @@ export const MOCK_DEFAULT_TEAM: TeamType = { autocapture_web_vitals_opt_in: false, autocapture_exceptions_errors_to_ignore: [], effective_membership_level: OrganizationMembershipLevel.Admin, + user_access_level: 'admin', access_control: true, has_group_types: true, primary_dashboard: 1, diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 7be5df3d764d6..81587232b3b07 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -921,6 +921,10 @@ class ApiRequest { return await api.update(this.assembleFullUrl(), options?.data, options) } + public async put(options?: ApiMethodOptions & { data: any }): Promise { + return await api.put(this.assembleFullUrl(), options?.data, options) + } + public async create(options?: ApiMethodOptions & { data: any }): Promise { return await api.create(this.assembleFullUrl(), options?.data, options) } @@ -2554,14 +2558,19 @@ const api = { }) }, - async update(url: string, data: any, options?: ApiMethodOptions): Promise { + async _update( + method: 'PATCH' | 'PUT', + url: string, + data: P, + options?: ApiMethodOptions + ): Promise { url = prepareUrl(url) ensureProjectIdNotInvalid(url) const isFormData = data instanceof FormData - const response = await handleFetch(url, 'PATCH', async () => { + const response = await handleFetch(url, method, async () => { return await fetch(url, { - method: 'PATCH', + method: method, headers: { ...objectClean(options?.headers ?? {}), ...(isFormData ? {} : { 'Content-Type': 'application/json' }), @@ -2576,7 +2585,15 @@ const api = { return await getJSONOrNull(response) }, - async create(url: string, data?: any, options?: ApiMethodOptions): Promise { + async update(url: string, data: P, options?: ApiMethodOptions): Promise { + return api._update('PATCH', url, data, options) + }, + + async put(url: string, data: P, options?: ApiMethodOptions): Promise { + return api._update('PUT', url, data, options) + }, + + async create(url: string, data?: P, options?: ApiMethodOptions): Promise { const res = await api.createResponse(url, data, options) return await getJSONOrNull(res) }, diff --git a/frontend/src/models/dashboardsModel.tsx b/frontend/src/models/dashboardsModel.tsx index 99c4cdf01ea96..2c23d08675c2a 100644 --- a/frontend/src/models/dashboardsModel.tsx +++ b/frontend/src/models/dashboardsModel.tsx @@ -114,10 +114,10 @@ export const dashboardsModel = kea([ const beforeChange = { ...values.rawDashboards[id] } - const response = (await api.update( + const response = await api.update( `api/environments/${teamLogic.values.currentTeamId}/dashboards/${id}`, payload - )) as DashboardType + ) const updatedAttribute = Object.keys(payload)[0] if (updatedAttribute === 'name' || updatedAttribute === 'description' || updatedAttribute === 'tags') { eventUsageLogic.actions.reportDashboardFrontEndUpdate( @@ -134,10 +134,10 @@ export const dashboardsModel = kea([ button: { label: 'Undo', action: async () => { - const reverted = (await api.update( + const reverted = await api.update( `api/environments/${teamLogic.values.currentTeamId}/dashboards/${id}`, beforeChange - )) as DashboardType + ) actions.updateDashboardSuccess(getQueryBasedDashboard(reverted)) lemonToast.success('Dashboard change reverted') }, @@ -160,31 +160,34 @@ export const dashboardsModel = kea([ }) ) as DashboardType, pinDashboard: async ({ id, source }) => { - const response = (await api.update( + const response = await api.update( `api/environments/${teamLogic.values.currentTeamId}/dashboards/${id}`, { pinned: true, } - )) as DashboardType + ) eventUsageLogic.actions.reportDashboardPinToggled(true, source) return getQueryBasedDashboard(response)! }, unpinDashboard: async ({ id, source }) => { - const response = (await api.update( + const response = await api.update( `api/environments/${teamLogic.values.currentTeamId}/dashboards/${id}`, { pinned: false, } - )) as DashboardType + ) eventUsageLogic.actions.reportDashboardPinToggled(false, source) return getQueryBasedDashboard(response)! }, duplicateDashboard: async ({ id, name, show, duplicateTiles }) => { - const result = (await api.create(`api/environments/${teamLogic.values.currentTeamId}/dashboards/`, { - use_dashboard: id, - name: `${name} (Copy)`, - duplicate_tiles: duplicateTiles, - })) as DashboardType + const result = await api.create( + `api/environments/${teamLogic.values.currentTeamId}/dashboards/`, + { + use_dashboard: id, + name: `${name} (Copy)`, + duplicate_tiles: duplicateTiles, + } + ) if (show) { router.actions.push(urls.dashboard(result.id)) } diff --git a/frontend/src/scenes/authentication/login2FALogic.ts b/frontend/src/scenes/authentication/login2FALogic.ts index 6232f6225312a..6b6e63cde3f5f 100644 --- a/frontend/src/scenes/authentication/login2FALogic.ts +++ b/frontend/src/scenes/authentication/login2FALogic.ts @@ -54,7 +54,7 @@ export const login2FALogic = kea([ submit: async ({ token }, breakpoint) => { breakpoint() try { - return await api.create('api/login/token', { token }) + return await api.create('api/login/token', { token }) } catch (e) { const { code, detail } = e as Record actions.setGeneralError(code, detail) diff --git a/frontend/src/scenes/authentication/loginLogic.ts b/frontend/src/scenes/authentication/loginLogic.ts index 19d73edc00158..6ace53926171f 100644 --- a/frontend/src/scenes/authentication/loginLogic.ts +++ b/frontend/src/scenes/authentication/loginLogic.ts @@ -86,7 +86,7 @@ export const loginLogic = kea([ } breakpoint() - const response = await api.create('api/login/precheck', { email }) + const response = await api.create('api/login/precheck', { email }) return { status: 'completed', ...response } }, }, @@ -102,7 +102,7 @@ export const loginLogic = kea([ submit: async ({ email, password }, breakpoint) => { breakpoint() try { - return await api.create('api/login', { email, password }) + return await api.create('api/login', { email, password }) } catch (e) { const { code } = e as Record let { detail } = e as Record diff --git a/frontend/src/scenes/authentication/setup2FALogic.ts b/frontend/src/scenes/authentication/setup2FALogic.ts index 7ea0f4a498510..03b7a4957a4f4 100644 --- a/frontend/src/scenes/authentication/setup2FALogic.ts +++ b/frontend/src/scenes/authentication/setup2FALogic.ts @@ -86,7 +86,7 @@ export const setup2FALogic = kea([ null as { backup_codes: string[] } | null, { generateBackupCodes: async () => { - return await api.create('api/users/@me/two_factor_backup_codes/') + return await api.create('api/users/@me/two_factor_backup_codes/') }, }, ], @@ -95,7 +95,7 @@ export const setup2FALogic = kea([ { disable2FA: async () => { try { - await api.create('api/users/@me/two_factor_disable/') + await api.create('api/users/@me/two_factor_disable/') return true } catch (e) { const { code, detail } = e as Record @@ -114,7 +114,7 @@ export const setup2FALogic = kea([ submit: async ({ token }, breakpoint) => { breakpoint() try { - return await api.create('api/users/@me/validate_2fa/', { token }) + return await api.create('api/users/@me/validate_2fa/', { token }) } catch (e) { const { code, detail } = e as Record actions.setGeneralError(code, detail) diff --git a/frontend/src/scenes/feature-flags/FeatureFlagCodeInstructions.stories.tsx b/frontend/src/scenes/feature-flags/FeatureFlagCodeInstructions.stories.tsx index 0c5bc5df8edff..2878e8887e4c8 100644 --- a/frontend/src/scenes/feature-flags/FeatureFlagCodeInstructions.stories.tsx +++ b/frontend/src/scenes/feature-flags/FeatureFlagCodeInstructions.stories.tsx @@ -29,6 +29,7 @@ const REGULAR_FEATURE_FLAG: FeatureFlagType = { rollback_conditions: [], performed_rollback: false, can_edit: true, + user_access_level: 'editor', tags: [], surveys: [], } diff --git a/frontend/src/scenes/feature-flags/activityDescriptions.tsx b/frontend/src/scenes/feature-flags/activityDescriptions.tsx index 93fec0692b0c3..a85f73cde21ad 100644 --- a/frontend/src/scenes/feature-flags/activityDescriptions.tsx +++ b/frontend/src/scenes/feature-flags/activityDescriptions.tsx @@ -252,6 +252,7 @@ const featureFlagActionsMapping: Record< analytics_dashboards: () => null, has_enriched_analytics: () => null, surveys: () => null, + user_access_level: () => null, } export function flagActivityDescriber(logItem: ActivityLogItem, asNotification?: boolean): HumanizedChange { diff --git a/frontend/src/scenes/feature-flags/featureFlagLogic.ts b/frontend/src/scenes/feature-flags/featureFlagLogic.ts index 48889df0f3d63..3f73931970c85 100644 --- a/frontend/src/scenes/feature-flags/featureFlagLogic.ts +++ b/frontend/src/scenes/feature-flags/featureFlagLogic.ts @@ -96,6 +96,7 @@ const NEW_FLAG: FeatureFlagType = { surveys: null, performed_rollback: false, can_edit: true, + user_access_level: 'editor', tags: [], } const NEW_VARIANT = { diff --git a/frontend/src/scenes/instance/SystemStatus/staffUsersLogic.ts b/frontend/src/scenes/instance/SystemStatus/staffUsersLogic.ts index 51054ab2fa8ac..3ac34a80e733b 100644 --- a/frontend/src/scenes/instance/SystemStatus/staffUsersLogic.ts +++ b/frontend/src/scenes/instance/SystemStatus/staffUsersLogic.ts @@ -33,8 +33,7 @@ export const staffUsersLogic = kea([ actions.setStaffUsersToBeAdded([]) const newStaffUsers = await Promise.all( staffUsersToBeAdded.map( - async (userUuid) => - (await api.update(`api/users/${userUuid}`, { is_staff: true })) as UserType + async (userUuid) => await api.update(`api/users/${userUuid}`, { is_staff: true }) ) ) const updatedAllUsers: UserType[] = [ @@ -45,7 +44,7 @@ export const staffUsersLogic = kea([ return updatedAllUsers }, deleteStaffUser: async ({ userUuid }) => { - await api.update(`api/users/${userUuid}`, { is_staff: false }) + await api.update(`api/users/${userUuid}`, { is_staff: false }) if (values.user?.uuid === userUuid) { actions.loadUser() // Loads the main user object to properly reflect staff user changes router.actions.push(urls.projectHomepage()) diff --git a/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-template-for-snapshot.ts b/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-template-for-snapshot.ts index 175647c05a7a6..e1cdf46446ef8 100644 --- a/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-template-for-snapshot.ts +++ b/frontend/src/scenes/notebooks/Notebook/__mocks__/notebook-template-for-snapshot.ts @@ -14,6 +14,7 @@ export const notebookTestTemplate = ( last_modified_at: '2023-06-02T00:00:00Z', created_by: MOCK_DEFAULT_BASIC_USER, last_modified_by: MOCK_DEFAULT_BASIC_USER, + user_access_level: 'editor' as const, version: 1, content: { type: 'doc', diff --git a/frontend/src/scenes/notebooks/Notebook/migrations/migrate.test.ts b/frontend/src/scenes/notebooks/Notebook/migrations/migrate.test.ts index d1409e1716f6d..2dd7b6aa1915d 100644 --- a/frontend/src/scenes/notebooks/Notebook/migrations/migrate.test.ts +++ b/frontend/src/scenes/notebooks/Notebook/migrations/migrate.test.ts @@ -933,10 +933,12 @@ describe('migrate()', () => { it.each(contentToExpected)('migrates %s', (_name, prevContent, nextContent) => { const prevNotebook: NotebookType = { ...mockNotebook, + user_access_level: 'editor' as const, content: { type: 'doc', content: prevContent }, } const nextNotebook: NotebookType = { ...mockNotebook, + user_access_level: 'editor' as const, content: { type: 'doc', content: nextContent }, } diff --git a/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts b/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts index 5b9396ecdc94b..bc0593c22bff3 100644 --- a/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts +++ b/frontend/src/scenes/notebooks/Notebook/notebookLogic.ts @@ -232,6 +232,7 @@ export const notebookLogic = kea([ content: null, text_content: null, version: 0, + user_access_level: 'editor', } } else if (props.shortId.startsWith('template-')) { response = diff --git a/frontend/src/scenes/notebooks/NotebookTemplates/notebookTemplates.ts b/frontend/src/scenes/notebooks/NotebookTemplates/notebookTemplates.ts index f1c63fe133674..7206f48eaf4a4 100644 --- a/frontend/src/scenes/notebooks/NotebookTemplates/notebookTemplates.ts +++ b/frontend/src/scenes/notebooks/NotebookTemplates/notebookTemplates.ts @@ -19,6 +19,7 @@ export const LOCAL_NOTEBOOK_TEMPLATES: NotebookType[] = [ last_modified_at: '2023-06-02T00:00:00Z', created_by: TEMPLATE_USERS.posthog, last_modified_by: TEMPLATE_USERS.posthog, + user_access_level: 'viewer' as const, version: 1, content: { type: 'doc', diff --git a/frontend/src/scenes/projectLogic.ts b/frontend/src/scenes/projectLogic.ts index d2b71d5f5e35e..b9d29ff625136 100644 --- a/frontend/src/scenes/projectLogic.ts +++ b/frontend/src/scenes/projectLogic.ts @@ -52,10 +52,10 @@ export const projectLogic = kea([ throw new Error('Current project has not been loaded yet, so it cannot be updated!') } - const patchedProject = (await api.update( + const patchedProject = await api.update( `api/projects/${values.currentProject.id}`, payload - )) as ProjectType + ) breakpoint() // We need to reload current org (which lists its projects) in organizationLogic AND in userLogic diff --git a/frontend/src/scenes/settings/organization/VerifiedDomains/__snapshots__/verifiedDomainsLogic.test.ts.snap b/frontend/src/scenes/settings/organization/VerifiedDomains/__snapshots__/verifiedDomainsLogic.test.ts.snap index 01691f9675451..47c205486a7e8 100644 --- a/frontend/src/scenes/settings/organization/VerifiedDomains/__snapshots__/verifiedDomainsLogic.test.ts.snap +++ b/frontend/src/scenes/settings/organization/VerifiedDomains/__snapshots__/verifiedDomainsLogic.test.ts.snap @@ -115,6 +115,7 @@ exports[`verifiedDomainsLogic values has proper defaults 1`] = ` "test_account_filters_default_checked": false, "timezone": "UTC", "updated_at": "2022-03-17T16:09:21.566253Z", + "user_access_level": "admin", "uuid": "TEAM_UUID", }, ], diff --git a/frontend/src/scenes/settings/organization/VerifiedDomains/verifiedDomainsLogic.ts b/frontend/src/scenes/settings/organization/VerifiedDomains/verifiedDomainsLogic.ts index de997f5d7b1cc..90ad7dc2d0435 100644 --- a/frontend/src/scenes/settings/organization/VerifiedDomains/verifiedDomainsLogic.ts +++ b/frontend/src/scenes/settings/organization/VerifiedDomains/verifiedDomainsLogic.ts @@ -78,9 +78,12 @@ export const verifiedDomainsLogic = kea([ (await api.get(`api/organizations/${values.currentOrganization?.id}/domains`)) .results as OrganizationDomainType[], addVerifiedDomain: async (domain: string) => { - const response = await api.create(`api/organizations/${values.currentOrganization?.id}/domains`, { - domain, - }) + const response = await api.create( + `api/organizations/${values.currentOrganization?.id}/domains`, + { + domain, + } + ) return [response, ...values.verifiedDomains] }, deleteVerifiedDomain: async (id: string) => { @@ -93,18 +96,18 @@ export const verifiedDomainsLogic = kea([ false, { updateDomain: async (payload: OrganizationDomainUpdatePayload) => { - const response = await api.update( + const response = await api.update( `api/organizations/${values.currentOrganization?.id}/domains/${payload.id}`, { ...payload, id: undefined } ) lemonToast.success('Domain updated successfully! Changes will take immediately.') - actions.replaceDomain(response as OrganizationDomainType) + actions.replaceDomain(response) return false }, verifyDomain: async () => { - const response = (await api.create( + const response = await api.create( `api/organizations/${values.currentOrganization?.id}/domains/${values.verifyModal}/verify` - )) as OrganizationDomainType + ) if (response.is_verified) { lemonToast.success('Domain verified successfully.') } else { @@ -158,12 +161,12 @@ export const verifiedDomainsLogic = kea([ if (!id) { return } - const response = (await api.update( + const response = await api.update( `api/organizations/${values.currentOrganization?.id}/domains/${payload.id}`, { ...updateParams, } - )) as OrganizationDomainType + ) breakpoint() actions.replaceDomain(response) actions.setConfigureSAMLModalId(null) diff --git a/frontend/src/scenes/settings/organization/inviteLogic.ts b/frontend/src/scenes/settings/organization/inviteLogic.ts index 878961f7332bf..3492c95ab2be1 100644 --- a/frontend/src/scenes/settings/organization/inviteLogic.ts +++ b/frontend/src/scenes/settings/organization/inviteLogic.ts @@ -1,7 +1,7 @@ import { actions, connect, events, kea, listeners, path, reducers, selectors } from 'kea' import { loaders } from 'kea-loaders' import { router, urlToAction } from 'kea-router' -import api from 'lib/api' +import api, { PaginatedResponse } from 'lib/api' import { OrganizationMembershipLevel } from 'lib/constants' import { lemonToast } from 'lib/lemon-ui/LemonToast/LemonToast' import { organizationLogic } from 'scenes/organizationLogic' @@ -49,7 +49,7 @@ export const inviteLogic = kea([ { inviteTeamMembers: async () => { if (!values.canSubmit) { - return { invites: [] } + return [] } const payload: Pick[] = @@ -57,7 +57,10 @@ export const inviteLogic = kea([ if (values.message) { payload.forEach((payload) => (payload.message = values.message)) } - return await api.create('api/organizations/@current/invites/bulk/', payload) + return await api.create( + 'api/organizations/@current/invites/bulk/', + payload + ) }, }, ], @@ -66,7 +69,11 @@ export const inviteLogic = kea([ { loadInvites: async () => { return organizationLogic.values.currentOrganization - ? (await api.get('api/organizations/@current/invites/')).results + ? ( + await api.get>( + 'api/organizations/@current/invites/' + ) + ).results : [] }, deleteInvite: async (invite: OrganizationInviteType) => { diff --git a/frontend/src/scenes/teamActivityDescriber.tsx b/frontend/src/scenes/teamActivityDescriber.tsx index bc08596af9a23..4bde2cf4d8e50 100644 --- a/frontend/src/scenes/teamActivityDescriber.tsx +++ b/frontend/src/scenes/teamActivityDescriber.tsx @@ -362,6 +362,7 @@ const teamActionsMapping: Record< id: () => null, updated_at: () => null, uuid: () => null, + user_access_level: () => null, live_events_token: () => null, product_intents: () => null, } diff --git a/frontend/src/scenes/userLogic.ts b/frontend/src/scenes/userLogic.ts index 9db1e96fa8806..a3adad1c6f50e 100644 --- a/frontend/src/scenes/userLogic.ts +++ b/frontend/src/scenes/userLogic.ts @@ -55,7 +55,7 @@ export const userLogic = kea([ { loadUser: async () => { try { - return await api.get('api/users/@me/') + return await api.get('api/users/@me/') } catch (error: any) { console.error(error) actions.loadUserFailure(error.message) @@ -67,12 +67,13 @@ export const userLogic = kea([ throw new Error('Current user has not been loaded yet, so it cannot be updated!') } try { - const response = await api.update('api/users/@me/', user) + const response = await api.update('api/users/@me/', user) successCallback && successCallback() return response } catch (error: any) { console.error(error) actions.updateUserFailure(error.message) + return values.user } }, setUserScenePersonalisation: async ({ scene, dashboard }) => { @@ -80,13 +81,14 @@ export const userLogic = kea([ throw new Error('Current user has not been loaded yet, so it cannot be updated!') } try { - return await api.create('api/users/@me/scene_personalisation', { + return await api.create('api/users/@me/scene_personalisation', { scene, dashboard, }) } catch (error: any) { console.error(error) actions.updateUserFailure(error.message) + return values.user } }, }, diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 8ad57c8964ec9..6c9910e548139 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -233,6 +233,10 @@ export interface ColumnConfig { active: ColumnChoice } +export type WithAccessControl = { + user_access_level: 'none' | 'member' | 'admin' | 'viewer' | 'editor' +} + interface UserBaseType { uuid: string distinct_id: string @@ -456,7 +460,7 @@ export interface ProjectBasicType { name: string } -export interface TeamBasicType { +export interface TeamBasicType extends WithAccessControl { id: number uuid: string organization: string // Organization ID @@ -494,6 +498,7 @@ export interface ProjectType extends ProjectBasicType { export interface TeamSurveyConfigType { appearance?: SurveyAppearance } + export interface TeamType extends TeamBasicType { created_at: string updated_at: string @@ -2940,7 +2945,7 @@ export interface FeatureFlagBasicType { ensure_experience_continuity: boolean | null } -export interface FeatureFlagType extends Omit { +export interface FeatureFlagType extends Omit, WithAccessControl { /** Null means that the flag has never been saved yet (it's new). */ id: number | null created_by: UserBasicType | null @@ -3831,7 +3836,6 @@ export interface RoleType { name: string feature_flags_access_level: AccessLevel members: RoleMemberType[] - associated_flags: { id: number; key: string }[] created_at: string created_by: UserBasicType | null } @@ -3840,14 +3844,6 @@ export interface RolesListParams { feature_flags_access_level?: AccessLevel } -export interface FeatureFlagAssociatedRoleType { - id: string - feature_flag: FeatureFlagType | null - role: RoleType - updated_at: string - added_at: string -} - export interface RoleMemberType { id: string user: UserBaseType @@ -3888,6 +3884,50 @@ export type APIScopeObject = | 'user' | 'webhook' +export interface AccessControlTypeBase { + created_by: UserBasicType | null + created_at: string + updated_at: string + resource: APIScopeObject + access_level: string | null // TODO: Change to enum + organization_member?: OrganizationMemberType['id'] | null + role?: RoleType['id'] | null +} + +export interface AccessControlTypeProject extends AccessControlTypeBase {} + +export interface AccessControlTypeMember extends AccessControlTypeBase { + organization_member: OrganizationMemberType['id'] +} + +export interface AccessControlTypeRole extends AccessControlTypeBase { + role: RoleType['id'] +} + +export type AccessControlType = AccessControlTypeProject | AccessControlTypeMember | AccessControlTypeRole + +export type AccessControlUpdateType = Pick & { + resource?: AccessControlType['resource'] +} + +export type AccessControlResponseType = { + access_controls: AccessControlType[] + available_access_levels: string[] // TODO: Change to enum + user_access_level: string + default_access_level: string + user_can_edit_access_levels: boolean +} + +// TODO: To be deprecated +export interface FeatureFlagAssociatedRoleType { + id: string + feature_flag: FeatureFlagType | null + role: RoleType + updated_at: string + added_at: string +} +// TODO: To be deprecated + export interface OrganizationResourcePermissionType { id: string resource: Resource @@ -3973,12 +4013,13 @@ export type NotebookListItemType = { last_modified_by?: UserBasicType | null } -export type NotebookType = NotebookListItemType & { - content: JSONContent | null - version: number - // used to power text-based search - text_content?: string | null -} +export type NotebookType = NotebookListItemType & + WithAccessControl & { + content: JSONContent | null + version: number + // used to power text-based search + text_content?: string | null + } export enum NotebookNodeType { Mention = 'ph-mention', @@ -4426,6 +4467,7 @@ export enum SidePanelTab { Discussion = 'discussion', Status = 'status', Exports = 'exports', + // AccessControl = 'access-control', } export interface SourceFieldOauthConfig {