diff --git a/frontend/common/services/useRolePermission.ts b/frontend/common/services/useRolePermission.ts index 2dfbfe80461d..22ca8ca14e83 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) => ({ + createProjectRolePermission: builder.mutation< + Res['rolePermission'], + Req['createProjectRolePermission'] + >({ + invalidatesTags: () => [{ type: 'rolePermission' }], + query: (query: Req['createProjectRolePermission']) => ({ + body: query.body, + method: 'POST', + url: `organisations/${query.organisation_id}/roles/${query.role_id}/projects-permissions/`, + }), + }), + createRolePermissions: builder.mutation< Res['rolePermission'], Req['createRolePermission'] @@ -165,6 +177,7 @@ export async function createRolePermissions( // END OF FUNCTION_EXPORTS export const { + useCreateProjectRolePermissionMutation, useCreateRolePermissionsMutation, useGetRoleEnvironmentPermissionsQuery, useGetRoleOrganisationPermissionsQuery, diff --git a/frontend/common/services/useUserPermissions.ts b/frontend/common/services/useUserPermissions.ts index edf9604fa72f..ac07cf465b16 100644 --- a/frontend/common/services/useUserPermissions.ts +++ b/frontend/common/services/useUserPermissions.ts @@ -6,6 +6,17 @@ export const userPermissionsService = service .enhanceEndpoints({ addTagTypes: ['UserPermissions'] }) .injectEndpoints({ endpoints: (builder) => ({ + createProjectUserPermission: builder.mutation< + Res['userPermissions'], + Req['createProjectUserPermission'] + >({ + invalidatesTags: [{ id: 'LIST', type: 'UserPermissions' }], + query: ({ body, projectId }: Req['createProjectUserPermission']) => ({ + body, + method: 'POST', + url: `projects/${projectId}/user-permissions/`, + }), + }), getUserPermissions: builder.query< Res['userPermissions'], Req['getUserPermissions'] @@ -33,6 +44,7 @@ export async function getUserPermissions( // END OF FUNCTION_EXPORTS export const { + useCreateProjectUserPermissionMutation, useGetUserPermissionsQuery, // END OF EXPORTS } = userPermissionsService diff --git a/frontend/common/types/requests.ts b/frontend/common/types/requests.ts index eabc5773ac2c..aae324f493d2 100644 --- a/frontend/common/types/requests.ts +++ b/frontend/common/types/requests.ts @@ -348,6 +348,15 @@ export type Req = { organisation_id: number role_id: number } + createProjectRolePermission: { + organisation_id: number + role_id: number + body: { + admin?: boolean + permissions: RolePermission['permissions'] + project: number + } + } updateRolePermission: Req['createRolePermission'] & { id: number } deleteRolePermission: { organisation_id: number; role_id: number } @@ -639,6 +648,14 @@ export type Req = { deleteProject: { id: number } migrateProject: { id: number } getProjectPermissions: { projectId: number } + createProjectUserPermission: { + projectId: number + body: { + admin?: boolean + permissions: string[] + user: number + } + } createGroup: { orgId: number data: Omit diff --git a/frontend/web/components/SettingsButton.tsx b/frontend/web/components/SettingsButton.tsx index 5126c0126f83..b1126cc6f30c 100644 --- a/frontend/web/components/SettingsButton.tsx +++ b/frontend/web/components/SettingsButton.tsx @@ -8,12 +8,14 @@ type SettingsButtonType = { onClick: () => void children: ReactNode content?: ReactNode + dropdown?: ReactNode feature?: PaidFeature } const SettingsButton: FC = ({ children, content, + dropdown, feature, onClick, }) => { @@ -39,6 +41,7 @@ const SettingsButton: FC = ({ {!!feature && } + {dropdown} {content} ) diff --git a/frontend/web/components/modals/CreateProject.js b/frontend/web/components/modals/CreateProject.js deleted file mode 100644 index 3da96e7b80bf..000000000000 --- a/frontend/web/components/modals/CreateProject.js +++ /dev/null @@ -1,125 +0,0 @@ -import React, { Component } from 'react' -import ErrorMessage from 'components/ErrorMessage' -import Button from 'components/base/forms/Button' -import { setInterceptClose } from './base/ModalDefault' -import PlanBasedAccess from 'components/PlanBasedAccess' - -const CreateProject = class extends Component { - static displayName = 'CreateProject' - - constructor(props, context) { - super(props, context) - this.state = {} - } - - close = (data = {}) => { - setInterceptClose(null) - closeModal() - if (data) { - const { environmentId, projectId } = data - this.props.history.push( - `/project/${projectId}/environment/${environmentId}/features?new=true`, - ) - this.props.onSave?.(data) - } - } - - componentDidMount = () => { - this.focusTimeout = setTimeout(() => { - this.input.focus() - this.focusTimeout = null - }, 500) - - setInterceptClose(() => { - return new Promise((resolve) => { - this.props.history.push(document.location.pathname) - setInterceptClose(null) - resolve(true) - }) - }) - } - - componentWillUnmount() { - if (this.focusTimeout) { - clearTimeout(this.focusTimeout) - } - } - - render() { - const { name } = this.state - return ( - - {({ createProject, error, isSaving, projects }) => { - const hasProject = !!projects && !!projects.length - const canCreate = - !hasProject || - !!Utils.getPlansPermission('CREATE_ADDITIONAL_PROJECT') - const disableCreate = !canCreate - const inner = ( -
-
{ - if (disableCreate) { - return - } - e.preventDefault() - !isSaving && name && createProject(name) - }} - > - (this.input = e)} - data-test='projectName' - disabled={disableCreate} - className='mb-0' - inputProps={{ - className: 'full-width', - name: 'projectName', - }} - onChange={(e) => - this.setState({ name: Utils.safeParseEventValue(e) }) - } - isValid={name && name.length} - type='text' - title='Project Name*' - placeholder='My Product Name' - /> - {error && } -
- -
- -
- ) - if (hasProject) { - return ( - <> - - {inner} - - ) - } - return inner - }} -
- ) - } -} - -CreateProject.propTypes = {} - -export default CreateProject diff --git a/frontend/web/components/modals/CreateProject.tsx b/frontend/web/components/modals/CreateProject.tsx new file mode 100644 index 000000000000..89368b5dc917 --- /dev/null +++ b/frontend/web/components/modals/CreateProject.tsx @@ -0,0 +1,356 @@ +import React, { + FC, + FormEvent, + ReactElement, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { IonIcon } from '@ionic/react' +import { close as closeIcon } from 'ionicons/icons' +import { RouteComponentProps } from 'react-router-dom' +import ErrorMessage from 'components/ErrorMessage' +import Button from 'components/base/forms/Button' +import InputGroup from 'components/base/forms/InputGroup' +import { setInterceptClose } from './base/ModalDefault' +import PlanBasedAccess from 'components/PlanBasedAccess' +import UserSelect from 'components/UserSelect' +import MyRoleSelect from 'components/MyRoleSelect' +import SettingsButton from 'components/SettingsButton' +import OrganisationProvider from 'common/providers/OrganisationProvider' +import { useGetUsersQuery } from 'common/services/useUser' +import { useGetRolesQuery } from 'common/services/useRole' +import { useCreateProjectUserPermissionMutation } from 'common/services/useUserPermissions' +import { useCreateProjectRolePermissionMutation } from 'common/services/useRolePermission' +import AccountStore from 'common/stores/account-store' +import getUserDisplayName from 'common/utils/getUserDisplayName' +import { Role, User } from 'common/types/responses' + +type CreateProjectSavedData = { + environmentId: number + projectId: number +} + +type CreateProjectProps = { + history: RouteComponentProps['history'] + onSave?: (data: CreateProjectSavedData) => void +} + +const CreateProject: FC = ({ history, onSave }) => { + const [name, setName] = useState('') + const [adminIds, setAdminIds] = useState([]) + const [adminRoleIds, setAdminRoleIds] = useState([]) + const [showUsers, setShowUsers] = useState(false) + const [showRoles, setShowRoles] = useState(false) + const [assigningAdmins, setAssigningAdmins] = useState(false) + // InputGroup is a class component (untyped JS) exposing a `focus()` method. + const inputRef = useRef<{ focus: () => void } | null>(null) + // OrganisationProvider wires onSave into a Flux listener once on mount, so + // any state we read in `close` is stale. Mirror selections into refs. + const adminIdsRef = useRef(adminIds) + const adminRoleIdsRef = useRef(adminRoleIds) + adminIdsRef.current = adminIds + adminRoleIdsRef.current = adminRoleIds + const onSaveRef = useRef(onSave) + onSaveRef.current = onSave + + 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] = useCreateProjectUserPermissionMutation() + const [createRolePermission] = useCreateProjectRolePermissionMutation() + + // Org administrators already have permissions on every project, and the + // creator obviously has permissions on their own project — 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], + ) + + useEffect(() => { + const focusTimeout = setTimeout(() => { + inputRef.current?.focus() + }, 500) + setInterceptClose( + () => + new Promise((resolve) => { + history.push(document.location.pathname) + setInterceptClose(null) + resolve(true) + }), + ) + return () => clearTimeout(focusTimeout) + }, [history]) + + const assignProjectAdmins = async (projectId: number) => { + const userIds = adminIdsRef.current + const roleIds = adminRoleIdsRef.current + const userRequests = userIds.map((userId) => + createUserPermission({ + body: { admin: true, permissions: [], user: userId }, + projectId, + }).unwrap(), + ) + const roleRequests = roleIds.map((roleId) => + createRolePermission({ + body: { admin: true, permissions: [], project: projectId }, + organisation_id: organisationId!, + role_id: roleId, + }).unwrap(), + ) + const results = await Promise.allSettled([...userRequests, ...roleRequests]) + return results.filter((r) => r.status === 'rejected').length + } + + const close = async (data?: CreateProjectSavedData | null) => { + setInterceptClose(null) + const { environmentId, projectId } = data || ({} as CreateProjectSavedData) + const hasAssignments = + adminIdsRef.current.length || adminRoleIdsRef.current.length + if (projectId && hasAssignments) { + setAssigningAdmins(true) + const failures = await assignProjectAdmins(projectId) + setAssigningAdmins(false) + if (failures) { + toast( + `Failed to assign ${failures} project administrator${ + failures > 1 ? 's' : '' + }. Project Settings → Members lets you retry.`, + 'danger', + ) + return + } + } + closeModal() + if (data) { + history.push( + `/project/${projectId}/environment/${environmentId}/features?new=true`, + ) + onSaveRef.current?.(data) + } + } + + return ( + + {({ createProject, error, isSaving, projects }) => { + const hasProject = !!projects && !!projects.length + const canCreate = + !hasProject || !!Utils.getPlansPermission('CREATE_ADDITIONAL_PROJECT') + const disableCreate = !canCreate + const busy = isSaving || assigningAdmins + const showUserSelector = !!eligibleAdmins.length + const showRoleSelector = !!hasRbac && !!roles.length + const showAdminSelector = showUserSelector || showRoleSelector + + const inner: ReactElement = ( +
+
) => { + if (disableCreate) { + return + } + e.preventDefault() + const trimmedName = name.trim() + if (!busy && trimmedName) { + createProject(trimmedName) + } + }} + > + void } | null) => { + inputRef.current = e + }} + data-test='projectName' + disabled={disableCreate} + className='mb-0' + inputProps={{ + className: 'full-width', + name: 'projectName', + }} + onChange={(e: React.ChangeEvent) => + setName(Utils.safeParseEventValue(e)) + } + isValid={!!name.trim()} + type='text' + title='Project Name*' + placeholder='My Product Name' + /> + + {showAdminSelector && ( +
+ +
+ Optionally grant other users or roles administrator access + to this project. Organisation administrators already have + full access to all projects. +
+ {showUserSelector && ( +
+ + !disableCreate && 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 && ( +
+ + !disableCreate && 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 +
+
+ )} +
+ )} + + {error && } +
+ +
+ +
+ ) + if (hasProject) { + return ( + <> + + {inner} + + ) + } + return inner + }} +
+ ) +} + +CreateProject.displayName = 'CreateProject' + +export default CreateProject