Skip to content

Commit

Permalink
Improves project invite flow (#273)
Browse files Browse the repository at this point in the history
  • Loading branch information
pushchris authored Oct 8, 2023
1 parent edab210 commit 58f2a71
Show file tree
Hide file tree
Showing 7 changed files with 218 additions and 61 deletions.
21 changes: 21 additions & 0 deletions apps/platform/db/migrations/20231008171139_update_admin_invite.js
Original file line number Diff line number Diff line change
@@ -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()
})
}
26 changes: 26 additions & 0 deletions apps/platform/src/projects/ProjectAdminController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -24,6 +25,31 @@ router.get('/', async ctx => {
ctx.body = await pagedProjectAdmins(params, ctx.state.project.id)
})

const projectCreateAdminParamsSchema: JSONSchemaType<ProjectAdminParams & { email: string }> = {
$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<ProjectAdminParams> = {
$id: 'projectAdminParams',
type: 'object',
Expand Down
5 changes: 4 additions & 1 deletion apps/ui/src/api.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -220,6 +220,9 @@ const api = {
add: async (projectId: number, adminId: number, params: ProjectAdminParams) => await client
.put<ProjectAdmin>(`${projectUrl(projectId)}/admins/${adminId}`, params)
.then(r => r.data),
invite: async (projectId: number, params: ProjectAdminInviteParams) => await client
.post<ProjectAdmin>(`${projectUrl(projectId)}/admins`, params)
.then(r => r.data),
get: async (projectId: number, adminId: number) => await client
.get<ProjectAdmin>(`${projectUrl(projectId)}/admins/${adminId}`)
.then(r => r.data),
Expand Down
3 changes: 3 additions & 0 deletions apps/ui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,9 @@ export interface ProjectAdmin extends Omit<Admin, 'id'> {
}

export type ProjectAdminParams = Pick<ProjectAdmin, 'role'>
export type ProjectAdminInviteParams = ProjectAdminParams & {
email: string
}

export interface Project {
id: number
Expand Down
3 changes: 2 additions & 1 deletion apps/ui/src/ui/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,13 +22,14 @@ type ButtonProps = {
type LinkButtonProps = {
to: To
target?: string
onClick?: () => void
} & BaseButtonProps

const LinkButton = forwardRef(function LinkButton(props: LinkButtonProps, ref: Ref<HTMLAnchorElement> | undefined) {
return (
<Link to={props.to} target={props.target} className={
`ui-button ${props.variant ?? 'primary'} ${props.size ?? 'regular'}`
} ref={ref}>
} ref={ref} onClick={props.onClick}>
{props.icon && (<span className="button-icon">{props.icon}</span>)}
{props.children}
</Link>
Expand Down
149 changes: 149 additions & 0 deletions apps/ui/src/views/settings/TeamInvite.tsx
Original file line number Diff line number Diff line change
@@ -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<ProjectAdmin, 'admin_id' | 'role'> & { id?: number }
type InviteMemberData = ProjectAdminInviteParams

interface TeamInviteProps extends ModalStateProps {
member?: Partial<EditMemberData>
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<ProjectAdmin>()

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 && <Modal
{...props}
title={member.id ? 'Update Permissions' : 'Add Team Member'}
size="small"
description={member.id ? 'Update the permissions for this team member.' : 'Add an existing team member to this project or invite someone new.'}
>
<OptionField
value={newOrExisting}
onChange={setNewOrExisting}
name="invite_type"
options={[
{ key: 'existing', label: 'Existing Team Member' },
{ key: 'new', label: 'New Team Member' },
]}
/>
{ newOrExisting === 'new'
? (
<FormWrapper<InviteMemberData>
onSubmit={async (member) => {
const newMember = await api.projectAdmins.invite(project.id, member)
setInvitedMember(newMember)
}}
defaultValues={member}
submitLabel="Invite to Project"
>
{form => (
<>
<TextInput.Field
form={form}
name="email"
label="Email"
required
/>
<SingleSelect.Field
form={form}
name="role"
label="Role"
subtitle={admin?.id === member.admin_id && (
<span style={{ color: 'red' }}>
{'You cannot change your own roles.'}
</span>
)}
options={projectRoles}
getOptionDisplay={snakeToTitle}
required
disabled={!admin || admin.id === member.admin_id}
/>
</>
)}
</FormWrapper>
)
: (
<FormWrapper<EditMemberData>
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 => (
<>
<EntityIdPicker.Field
form={form}
name="admin_id"
label="Admin"
search={searchAdmins}
get={api.admins.get}
displayValue={({ first_name, last_name, email }) => first_name
? combine(first_name, last_name, `(${email})`)
: email
}
required
disabled={!!member.admin_id}
/>
<SingleSelect.Field
form={form}
name="role"
label="Role"
subtitle={admin?.id === member.admin_id && (
<span style={{ color: 'red' }}>
{'You cannot change your own roles.'}
</span>
)}
options={projectRoles}
getOptionDisplay={snakeToTitle}
required
disabled={!admin || admin.id === member.admin_id}
/>
</>
)}
</FormWrapper>
)
}
</Modal>}
{invitedMember && <Modal
open={invitedMember !== undefined}
onClose={() => {
setInvitedMember(undefined)
}}
title="Member Added"
size="small">
<p>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?</p>
<LinkButton to={mailto(invitedMember.email)} onClick={() => {
onMember(invitedMember)
setInvitedMember(undefined)
}}>Email</LinkButton>&nbsp;
<Button variant="secondary" onClick={() => {
onMember(invitedMember)
setInvitedMember(undefined)
}}>Done</Button>
</Modal>}
</>
}
72 changes: 13 additions & 59 deletions apps/ui/src/views/settings/Teams.tsx
Original file line number Diff line number Diff line change
@@ -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<ProjectAdmin, 'admin_id' | 'role'> & { 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 | Partial<EditFormData>>(null)
const searchAdmins = useCallback(async (q: string) => await api.admins.search({ q, limit: 100 }), [])
const [editing, setEditing] = useState<Partial<EditFormData>>()

return (
<>
Expand Down Expand Up @@ -69,57 +64,16 @@ export default function Teams() {
onSelectRow={setEditing}
enableSearch
/>
<Modal
title={editing?.id ? 'Update Permissions' : 'Add Team Member'}

<TeamInvite
member={editing}
onMember={async () => {
await state.reload()
setEditing(undefined)
}}
open={Boolean(editing)}
onClose={() => setEditing(null)}
size="small"
>
{
editing && (
<FormWrapper<EditFormData>
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 => (
<>
<EntityIdPicker.Field
form={form}
name="admin_id"
label="Admin"
search={searchAdmins}
get={api.admins.get}
displayValue={({ first_name, last_name, email }) => `${first_name} ${last_name} (${email})`}
required
disabled={!!editing.admin_id}
/>
<SingleSelect.Field
form={form}
name="role"
label="Role"
subtitle={admin?.id === editing.admin_id && (
<span style={{ color: 'red' }}>
{'You cannot change your own roles.'}
</span>
)}
options={projectRoles}
getOptionDisplay={snakeToTitle}
required
disabled={!admin || admin.id === editing.admin_id}
/>
</>
)
}
</FormWrapper>
)
}
</Modal>
onClose={() => setEditing(undefined)}
/>
</>
)
}

0 comments on commit 58f2a71

Please sign in to comment.