From d7605fc44d5365b97ff2fe6974643b3047672307 Mon Sep 17 00:00:00 2001 From: kyle-ssg Date: Wed, 27 May 2026 10:51:49 +0100 Subject: [PATCH 1/2] feat(environment-admin): choose environment administrators on environment creation Lets the creator assign other users and roles as environment administrators directly from the Create Environment page. Adds dedicated RTK endpoints for environment user and role permissions, and assigns selections on save with a partial-failure toast. Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/common/services/useRolePermission.ts | 13 + .../common/services/useUserPermissions.ts | 15 + frontend/common/types/requests.ts | 17 + .../pages/CreateEnvironmentPage.tsx | 447 +++++++++++++----- 4 files changed, 380 insertions(+), 112 deletions(-) diff --git a/frontend/common/services/useRolePermission.ts b/frontend/common/services/useRolePermission.ts index 22ca8ca14e83..2fdc20b24cc1 100644 --- a/frontend/common/services/useRolePermission.ts +++ b/frontend/common/services/useRolePermission.ts @@ -6,6 +6,18 @@ export const rolePermissionService = service .enhanceEndpoints({ addTagTypes: ['rolePermission'] }) .injectEndpoints({ endpoints: (builder) => ({ + createEnvironmentRolePermission: builder.mutation< + Res['rolePermission'], + Req['createEnvironmentRolePermission'] + >({ + invalidatesTags: () => [{ type: 'rolePermission' }], + query: (query: Req['createEnvironmentRolePermission']) => ({ + body: query.body, + method: 'POST', + url: `organisations/${query.organisation_id}/roles/${query.role_id}/environments-permissions/`, + }), + }), + createProjectRolePermission: builder.mutation< Res['rolePermission'], Req['createProjectRolePermission'] @@ -177,6 +189,7 @@ export async function createRolePermissions( // END OF FUNCTION_EXPORTS export const { + useCreateEnvironmentRolePermissionMutation, useCreateProjectRolePermissionMutation, useCreateRolePermissionsMutation, useGetRoleEnvironmentPermissionsQuery, diff --git a/frontend/common/services/useUserPermissions.ts b/frontend/common/services/useUserPermissions.ts index ac07cf465b16..890ee84efe90 100644 --- a/frontend/common/services/useUserPermissions.ts +++ b/frontend/common/services/useUserPermissions.ts @@ -6,6 +6,20 @@ export const userPermissionsService = service .enhanceEndpoints({ addTagTypes: ['UserPermissions'] }) .injectEndpoints({ endpoints: (builder) => ({ + createEnvironmentUserPermission: builder.mutation< + Res['userPermissions'], + Req['createEnvironmentUserPermission'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'UserPermissions' }], + query: ({ + body, + environmentId, + }: Req['createEnvironmentUserPermission']) => ({ + body, + method: 'POST', + url: `environments/${environmentId}/user-permissions/`, + }), + }), createProjectUserPermission: builder.mutation< Res['userPermissions'], Req['createProjectUserPermission'] @@ -44,6 +58,7 @@ export async function getUserPermissions( // END OF FUNCTION_EXPORTS export const { + useCreateEnvironmentUserPermissionMutation, useCreateProjectUserPermissionMutation, useGetUserPermissionsQuery, // END OF EXPORTS diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index aae324f493d2..50e0238173cd 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -357,6 +357,15 @@ export type Req = { project: number } } + createEnvironmentRolePermission: { + organisation_id: number + role_id: number + body: { + admin?: boolean + permissions: RolePermission['permissions'] + environment: number + } + } updateRolePermission: Req['createRolePermission'] & { id: number } deleteRolePermission: { organisation_id: number; role_id: number } @@ -656,6 +665,14 @@ export type Req = { user: number } } + createEnvironmentUserPermission: { + environmentId: string + body: { + admin?: boolean + permissions: string[] + user: number + } + } createGroup: { orgId: number data: Omit diff --git a/frontend/web/components/pages/CreateEnvironmentPage.tsx b/frontend/web/components/pages/CreateEnvironmentPage.tsx index 619966d8d837..d079b5b81926 100644 --- a/frontend/web/components/pages/CreateEnvironmentPage.tsx +++ b/frontend/web/components/pages/CreateEnvironmentPage.tsx @@ -1,4 +1,6 @@ -import React, { useEffect, useRef, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' +import { IonIcon } from '@ionic/react' +import { close as closeIcon } from 'ionicons/icons' import ConfigProvider from 'common/providers/ConfigProvider' import Permission from 'common/providers/Permission' import Constants from 'common/constants' @@ -16,8 +18,16 @@ import Utils from 'common/utils/utils' import { useHistory } from 'react-router-dom' import API from 'project/api' import InputGroup from 'components/base/forms/InputGroup' -import { Environment } from 'common/types/responses' +import { Environment, Role, User } from 'common/types/responses' import Button from 'components/base/forms/Button' +import UserSelect from 'components/UserSelect' +import MyRoleSelect from 'components/MyRoleSelect' +import SettingsButton from 'components/SettingsButton' +import getUserDisplayName from 'common/utils/getUserDisplayName' +import { useGetUsersQuery } from 'common/services/useUser' +import { useGetRolesQuery } from 'common/services/useRole' +import { useCreateEnvironmentUserPermissionMutation } from 'common/services/useUserPermissions' +import { useCreateEnvironmentRolePermissionMutation } from 'common/services/useRolePermission' import { useRouteContext } from 'components/providers/RouteContext' import { ProjectPermission } from 'common/types/permissions.types' @@ -28,13 +38,99 @@ const CreateEnvironmentPage: React.FC = () => { const [description, setDescription] = useState() const [selectedEnv, setSelectedEnv] = useState() const [hasMetadataRequired, setHasMetadataRequired] = useState(false) + const [adminIds, setAdminIds] = useState([]) + const [adminRoleIds, setAdminRoleIds] = useState([]) + const [showUsers, setShowUsers] = useState(false) + const [showRoles, setShowRoles] = useState(false) + const [assigningAdmins, setAssigningAdmins] = useState(false) const inputRef = useRef(null) const history = useHistory() const { projectId } = useRouteContext() - const onSave = (environment: Environment) => { + + // ProjectProvider wires onSave into a Flux listener once on mount, so any + // state we read in the saved handler is stale. Mirror selections into refs. + const adminIdsRef = useRef(adminIds) + const adminRoleIdsRef = useRef(adminRoleIds) + const projectIdRef = useRef(projectId) + adminIdsRef.current = adminIds + adminRoleIdsRef.current = adminRoleIds + projectIdRef.current = projectId + + const organisationId = AccountStore.getOrganisation()?.id as + | number + | undefined + const currentUserId = AccountStore.getUser()?.id as number | undefined + const hasRbac = Utils.getPlansPermission('RBAC') + const { data: users } = useGetUsersQuery( + { organisationId: organisationId! }, + { skip: !organisationId }, + ) + const { data: rolesData } = useGetRolesQuery( + { organisation_id: organisationId! }, + { skip: !organisationId || !hasRbac }, + ) + const roles = useMemo(() => rolesData?.results ?? [], [rolesData]) + const [createUserPermission] = useCreateEnvironmentUserPermissionMutation() + const [createRolePermission] = useCreateEnvironmentRolePermissionMutation() + + // Org administrators already have permissions on every environment, and the + // creator obviously has permissions on their own environment — exclude both. + const eligibleAdmins = useMemo( + () => + (users ?? []).filter( + (u: User) => u.role !== 'ADMIN' && u.id !== currentUserId, + ), + [users, currentUserId], + ) + + const selectedAdmins = useMemo( + () => eligibleAdmins.filter((u) => adminIds.includes(u.id)), + [eligibleAdmins, adminIds], + ) + const selectedRoles = useMemo( + () => roles.filter((r) => adminRoleIds.includes(r.id)), + [roles, adminRoleIds], + ) + + const assignEnvironmentAdmins = async (environment: Environment) => { + const userIds = adminIdsRef.current + const roleIds = adminRoleIdsRef.current + const userRequests = userIds.map((userId) => + createUserPermission({ + body: { admin: true, permissions: [], user: userId }, + environmentId: environment.api_key, + }).unwrap(), + ) + const roleRequests = roleIds.map((roleId) => + createRolePermission({ + body: { admin: true, environment: environment.id, permissions: [] }, + organisation_id: organisationId!, + role_id: roleId, + }).unwrap(), + ) + const results = await Promise.allSettled([...userRequests, ...roleRequests]) + return results.filter((r) => r.status === 'rejected').length + } + + const onSave = async (environment: Environment) => { + const hasAssignments = + adminIdsRef.current.length || adminRoleIdsRef.current.length + if (hasAssignments) { + setAssigningAdmins(true) + const failures = await assignEnvironmentAdmins(environment) + setAssigningAdmins(false) + if (failures) { + toast( + `Environment created — ${failures} admin assignment${ + failures > 1 ? 's' : '' + } failed. Retry in Environment Settings → Permissions.`, + 'danger', + ) + } + } history.push( - `/project/${projectId}/environment/${environment.api_key}/features`, + `/project/${projectIdRef.current}/environment/${environment.api_key}/features`, ) } @@ -64,9 +160,9 @@ const CreateEnvironmentPage: React.FC = () => { }, []) const handleCreateEnv = - (createEnv: CreateEnvType, isSaving: boolean) => (e: React.FormEvent) => { + (createEnv: CreateEnvType, busy: boolean) => (e: React.FormEvent) => { e.preventDefault() - if (name && !isSaving && projectId) { + if (name && !busy && projectId) { createEnv({ cloneFeatureStatesAsync: true, cloneId: selectedEnv?.api_key, @@ -118,128 +214,255 @@ const CreateEnvironmentPage: React.FC = () => { ) } + const showUserSelector = !!eligibleAdmins.length + const showRoleSelector = !!hasRbac && !!roles.length + const showAdminSelector = showUserSelector || showRoleSelector return ( - {({ createEnv, error, isSaving, project }) => ( -
-
- - - setName(Utils.safeParseEventValue(e)) - } - isValid={!!name} - type='text' - title='Name*' - placeholder='An environment name e.g. Develop' - /> - - - - setDescription(Utils.safeParseEventValue(e)) - } - isValid={!!name} - type='text' - title='Description' - placeholder='Environment Description' - /> - - - {!!project?.environments?.length && ( + {({ createEnv, error, isSaving, project }) => { + const busy = isSaving || assigningAdmins + return ( + +
+ { - setSelectedEnv( - project?.environments.find( - (v) => v.api_key === env.value, - ), - ) - }} - options={project.environments.map((env) => ({ - label: env.name, - value: env.api_key, - }))} - value={ - selectedEnv - ? { - label: selectedEnv.name, - value: selectedEnv.api_key, - } - : { label: 'Please select an environment' } - } - /> + ref={inputRef as any} + inputProps={{ + className: 'full-width', + name: 'envName', + }} + onChange={(e: InputEvent) => + setName(Utils.safeParseEventValue(e)) } + isValid={!!name} + type='text' + title='Name*' + placeholder='An environment name e.g. Develop' /> - )} - -
- {Utils.getPlansPermission('METADATA') && - envContentType?.id && ( +
+ + + setDescription(Utils.safeParseEventValue(e)) + } + isValid={!!name} + type='text' + title='Description' + placeholder='Environment Description' + /> + - + {!!project?.environments?.length && ( { + setSelectedEnv( + project?.environments.find( + (v) => v.api_key === env.value, + ), + ) + }} + options={project.environments.map((env) => ({ + label: env.name, + value: env.api_key, + }))} + value={ + selectedEnv + ? { + label: selectedEnv.name, + value: selectedEnv.api_key, + } + : { label: 'Please select an environment' } } - projectId={projectId} - entityId={selectedEnv?.api_key} - envName={name} - entityContentType={envContentType.id} - entity={envContentType.model} - isCloningEnvironment - onChange={setMetadata} - setHasMetadataRequired={setHasMetadataRequired} /> } /> - + )} + + {showAdminSelector && ( + +
+ +
+ Optionally grant other users or roles + administrator access to this environment. + Organisation administrators already have full + access to all environments. +
+ {showUserSelector && ( +
+ setShowUsers(!showUsers)} + dropdown={ + + setAdminIds([...adminIds, id]) + } + onRemove={(id: number) => + setAdminIds( + adminIds.filter((v) => v !== id), + ) + } + isOpen={showUsers} + onToggle={() => setShowUsers(!showUsers)} + /> + } + content={ + + {selectedAdmins.map((u) => ( + + setAdminIds( + adminIds.filter( + (id) => id !== u.id, + ), + ) + } + className='chip mr-2' + > + {getUserDisplayName(u)} + + + + + ))} + {!selectedAdmins.length && ( +
+ No users assigned +
+ )} +
+ } + > + Users +
+
+ )} + {showRoleSelector && ( +
+ setShowRoles(!showRoles)} + dropdown={ + + setAdminRoleIds([...adminRoleIds, id]) + } + onRemove={(id: number) => + setAdminRoleIds( + adminRoleIds.filter((v) => v !== id), + ) + } + isOpen={showRoles} + onToggle={() => setShowRoles(!showRoles)} + /> + } + content={ + + {selectedRoles.map((r) => ( + + setAdminRoleIds( + adminRoleIds.filter( + (id) => id !== r.id, + ), + ) + } + className='chip mr-2' + > + {r.name} + + + + + ))} + {!selectedRoles.length && ( +
+ No roles assigned +
+ )} +
+ } + > + Roles +
+
+ )} +
+
+ )} +
+ {Utils.getPlansPermission('METADATA') && + envContentType?.id && ( + + + + } + /> + + + )} + {error && ( + + )} - {error && ( - +
+ +
- )} - -
- -
-
-
-

- Not seeing an environment? Check that your project - administrator has invited you to it. -

-
- )} +
+

+ Not seeing an environment? Check that your project + administrator has invited you to it. +

+ + ) + }}
) }} From e047a29404c38a47c533514dab643893d0385423 Mon Sep 17 00:00:00 2001 From: Kyle Johnson Date: Wed, 27 May 2026 12:36:16 +0100 Subject: [PATCH 2/2] Update frontend/web/components/pages/CreateEnvironmentPage.tsx Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- frontend/web/components/pages/CreateEnvironmentPage.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/frontend/web/components/pages/CreateEnvironmentPage.tsx b/frontend/web/components/pages/CreateEnvironmentPage.tsx index d079b5b81926..9034248fa89d 100644 --- a/frontend/web/components/pages/CreateEnvironmentPage.tsx +++ b/frontend/web/components/pages/CreateEnvironmentPage.tsx @@ -96,6 +96,7 @@ const CreateEnvironmentPage: React.FC = () => { const assignEnvironmentAdmins = async (environment: Environment) => { const userIds = adminIdsRef.current const roleIds = adminRoleIdsRef.current + const orgId = AccountStore.getOrganisation()?.id const userRequests = userIds.map((userId) => createUserPermission({ body: { admin: true, permissions: [], user: userId }, @@ -105,7 +106,7 @@ const CreateEnvironmentPage: React.FC = () => { const roleRequests = roleIds.map((roleId) => createRolePermission({ body: { admin: true, environment: environment.id, permissions: [] }, - organisation_id: organisationId!, + organisation_id: orgId!, role_id: roleId, }).unwrap(), )