diff --git a/apps/platform/db/migrations/20231008171139_update_admin_invite.js b/apps/platform/db/migrations/20231008171139_update_admin_invite.js new file mode 100644 index 00000000..539f2efe --- /dev/null +++ b/apps/platform/db/migrations/20231008171139_update_admin_invite.js @@ -0,0 +1,21 @@ +exports.up = async function(knex) { + await knex.schema.alterTable('admins', function(table) { + table.string('first_name') + .nullable() + .alter() + table.string('last_name') + .nullable() + .alter() + }) +} + +exports.down = async function(knex) { + await knex.schema.alterTable('admins', function(table) { + table.string('first_name') + .notNullable() + .alter() + table.string('last_name') + .notNullable() + .alter() + }) +} diff --git a/apps/platform/src/projects/ProjectAdminController.ts b/apps/platform/src/projects/ProjectAdminController.ts index e116bedc..5d21623b 100644 --- a/apps/platform/src/projects/ProjectAdminController.ts +++ b/apps/platform/src/projects/ProjectAdminController.ts @@ -10,6 +10,7 @@ import { projectRoleMiddleware } from './ProjectService' import { ProjectAdminParams } from './ProjectAdmins' import { projectRoles } from './Project' import { RequestError } from '../core/errors' +import { createOrUpdateAdmin } from '../auth/AdminRepository' const router = new Router< ProjectState & { admin?: Admin } @@ -24,6 +25,31 @@ router.get('/', async ctx => { ctx.body = await pagedProjectAdmins(params, ctx.state.project.id) }) +const projectCreateAdminParamsSchema: JSONSchemaType = { + $id: 'projectCreateAdminParams', + type: 'object', + required: ['role', 'email'], + properties: { + email: { + type: 'string', + format: 'email', + }, + role: { + type: 'string', + enum: projectRoles, + }, + }, +} + +router.post('/', async ctx => { + const organizationId = ctx.state.project.organization_id + const { role, email } = validate(projectCreateAdminParamsSchema, ctx.request.body) + const admin = await createOrUpdateAdmin({ organization_id: organizationId, email }) + if (ctx.state.admin!.id === admin.id) throw new RequestError('You cannot add yourself to a project') + await addAdminToProject(ctx.state.project.id, admin.id, role) + ctx.body = await getProjectAdmin(ctx.state.project.id, admin.id) +}) + const projectAdminParamsSchema: JSONSchemaType = { $id: 'projectAdminParams', type: 'object', diff --git a/apps/ui/src/api.ts b/apps/ui/src/api.ts index 170d7af7..e399202d 100644 --- a/apps/ui/src/api.ts +++ b/apps/ui/src/api.ts @@ -1,6 +1,6 @@ import Axios from 'axios' import { env } from './config/env' -import { Admin, AuthMethod, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, List, ListCreateParams, ListUpdateParams, Locale, Metric, Organization, OrganizationUpdateParams, Project, ProjectAdmin, ProjectAdminParams, ProjectApiKey, ProjectApiKeyParams, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, QueueMetric, RuleSuggestions, SearchParams, SearchResult, Subscription, SubscriptionParams, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateProofParams, TemplateUpdateParams, User, UserEvent, UserSubscription } from './types' +import { Admin, AuthMethod, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, List, ListCreateParams, ListUpdateParams, Locale, Metric, Organization, OrganizationUpdateParams, Project, ProjectAdmin, ProjectAdminInviteParams, ProjectAdminParams, ProjectApiKey, ProjectApiKeyParams, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, QueueMetric, RuleSuggestions, SearchParams, SearchResult, Subscription, SubscriptionParams, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateProofParams, TemplateUpdateParams, User, UserEvent, UserSubscription } from './types' function appendValue(params: URLSearchParams, name: string, value: unknown) { if (typeof value === 'undefined' || value === null || typeof value === 'function') return @@ -220,6 +220,9 @@ const api = { add: async (projectId: number, adminId: number, params: ProjectAdminParams) => await client .put(`${projectUrl(projectId)}/admins/${adminId}`, params) .then(r => r.data), + invite: async (projectId: number, params: ProjectAdminInviteParams) => await client + .post(`${projectUrl(projectId)}/admins`, params) + .then(r => r.data), get: async (projectId: number, adminId: number) => await client .get(`${projectUrl(projectId)}/admins/${adminId}`) .then(r => r.data), diff --git a/apps/ui/src/types.ts b/apps/ui/src/types.ts index 4a800faa..287e2ca4 100644 --- a/apps/ui/src/types.ts +++ b/apps/ui/src/types.ts @@ -146,6 +146,9 @@ export interface ProjectAdmin extends Omit { } export type ProjectAdminParams = Pick +export type ProjectAdminInviteParams = ProjectAdminParams & { + email: string +} export interface Project { id: number diff --git a/apps/ui/src/ui/Button.tsx b/apps/ui/src/ui/Button.tsx index ac346667..25bd5c2f 100644 --- a/apps/ui/src/ui/Button.tsx +++ b/apps/ui/src/ui/Button.tsx @@ -22,13 +22,14 @@ type ButtonProps = { type LinkButtonProps = { to: To target?: string + onClick?: () => void } & BaseButtonProps const LinkButton = forwardRef(function LinkButton(props: LinkButtonProps, ref: Ref | undefined) { return ( + } ref={ref} onClick={props.onClick}> {props.icon && ({props.icon})} {props.children} diff --git a/apps/ui/src/views/settings/TeamInvite.tsx b/apps/ui/src/views/settings/TeamInvite.tsx new file mode 100644 index 00000000..0e7279bc --- /dev/null +++ b/apps/ui/src/views/settings/TeamInvite.tsx @@ -0,0 +1,149 @@ +import { useCallback, useContext, useState } from 'react' +import { ProjectAdmin, ProjectAdminInviteParams, projectRoles } from '../../types' +import { Button, LinkButton, Modal } from '../../ui' +import { ModalStateProps } from '../../ui/Modal' +import { EntityIdPicker } from '../../ui/form/EntityIdPicker' +import FormWrapper from '../../ui/form/FormWrapper' +import { SingleSelect } from '../../ui/form/SingleSelect' +import api from '../../api' +import { AdminContext, ProjectContext } from '../../contexts' +import { combine, snakeToTitle } from '../../utils' +import OptionField from '../../ui/form/OptionField' +import TextInput from '../../ui/form/TextInput' + +type EditMemberData = Pick & { id?: number } +type InviteMemberData = ProjectAdminInviteParams + +interface TeamInviteProps extends ModalStateProps { + member?: Partial + onMember: (member: ProjectAdmin) => void +} + +export default function TeamInvite({ member, onMember, ...props }: TeamInviteProps) { + + const [project] = useContext(ProjectContext) + const admin = useContext(AdminContext) + const searchAdmins = useCallback(async (q: string) => await api.admins.search({ q, limit: 100 }), []) + const [newOrExisting, setNewOrExisting] = useState<'new' | 'existing'>('existing') + const [invitedMember, setInvitedMember] = useState() + + const mailto = (email: string) => { + const text = `Hello!\n\nI have just added you to the project ${project.name} on Parcelvoy. To get started and setup your account, please click the link below:\n\n${window.location.origin}` + return `mailto:${email}?subject=Parcelvoy%20Project&body=${encodeURI(text)}` + } + + return <> + {member && + + { newOrExisting === 'new' + ? ( + + onSubmit={async (member) => { + const newMember = await api.projectAdmins.invite(project.id, member) + setInvitedMember(newMember) + }} + defaultValues={member} + submitLabel="Invite to Project" + > + {form => ( + <> + + + {'You cannot change your own roles.'} + + )} + options={projectRoles} + getOptionDisplay={snakeToTitle} + required + disabled={!admin || admin.id === member.admin_id} + /> + + )} + + ) + : ( + + onSubmit={async ({ role, admin_id }) => { + const member = await api.projectAdmins.add(project.id, admin_id, { role }) + onMember(member) + }} + defaultValues={member} + submitLabel={member.id ? 'Update Permissions' : 'Add to Project'} + > + {form => ( + <> + first_name + ? combine(first_name, last_name, `(${email})`) + : email + } + required + disabled={!!member.admin_id} + /> + + {'You cannot change your own roles.'} + + )} + options={projectRoles} + getOptionDisplay={snakeToTitle} + required + disabled={!admin || admin.id === member.admin_id} + /> + + )} + + ) + } + } + {invitedMember && { + setInvitedMember(undefined) + }} + title="Member Added" + size="small"> +

You have successfully added a member to this project. They will have access to this project as soon as they log in for the first time. Would you like to send them an email about their new access?

+ { + onMember(invitedMember) + setInvitedMember(undefined) + }}>Email  + +
} + +} diff --git a/apps/ui/src/views/settings/Teams.tsx b/apps/ui/src/views/settings/Teams.tsx index 7d4de171..39e8dfc0 100644 --- a/apps/ui/src/views/settings/Teams.tsx +++ b/apps/ui/src/views/settings/Teams.tsx @@ -1,25 +1,20 @@ import { useCallback, useContext, useState } from 'react' import api from '../../api' -import { AdminContext, ProjectContext } from '../../contexts' -import { ProjectAdmin, projectRoles } from '../../types' +import { ProjectContext } from '../../contexts' +import { ProjectAdmin } from '../../types' import Button from '../../ui/Button' -import { EntityIdPicker } from '../../ui/form/EntityIdPicker' -import FormWrapper from '../../ui/form/FormWrapper' -import { SingleSelect } from '../../ui/form/SingleSelect' import { ArchiveIcon, EditIcon, PlusIcon } from '../../ui/icons' import Menu, { MenuItem } from '../../ui/Menu' -import Modal from '../../ui/Modal' import { SearchTable, useSearchTableState } from '../../ui/SearchTable' import { snakeToTitle } from '../../utils' +import TeamInvite from './TeamInvite' type EditFormData = Pick & { id?: number } export default function Teams() { - const admin = useContext(AdminContext) const [project] = useContext(ProjectContext) const state = useSearchTableState(useCallback(async params => await api.projectAdmins.search(project.id, params), [project])) - const [editing, setEditing] = useState>(null) - const searchAdmins = useCallback(async (q: string) => await api.admins.search({ q, limit: 100 }), []) + const [editing, setEditing] = useState>() return ( <> @@ -69,57 +64,16 @@ export default function Teams() { onSelectRow={setEditing} enableSearch /> - { + await state.reload() + setEditing(undefined) + }} open={Boolean(editing)} - onClose={() => setEditing(null)} - size="small" - > - { - editing && ( - - onSubmit={async ({ admin_id, role }) => { - await api.projectAdmins.add(project.id, admin_id, { role }) - await state.reload() - setEditing(null) - }} - defaultValues={editing} - submitLabel={editing.id ? 'Update Permissions' : 'Add to Project'} - > - { - form => ( - <> - `${first_name} ${last_name} (${email})`} - required - disabled={!!editing.admin_id} - /> - - {'You cannot change your own roles.'} - - )} - options={projectRoles} - getOptionDisplay={snakeToTitle} - required - disabled={!admin || admin.id === editing.admin_id} - /> - - ) - } - - ) - } - + onClose={() => setEditing(undefined)} + /> ) }