Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Improves project invite flow #273

Merged
merged 1 commit into from
Oct 8, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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)}
/>
</>
)
}