Merge pull request #88 from parcelvoy/feat/project-roles

adds project role support for ProjectAdmin and ProjectApiKey
This commit is contained in:
Chris Hills 2023-03-20 09:42:32 -05:00 committed by GitHub
commit 152fe8d430
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
32 changed files with 618 additions and 316 deletions

View file

@ -0,0 +1,27 @@
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.up = function(knex) {
return knex.schema
.alterTable('project_admins', function(table) {
table.string('role', 64).notNullable().defaultTo('support')
})
.alterTable('project_api_keys', function(table) {
table.string('role', 64).notNullable().defaultTo('support')
})
}
/**
* @param { import("knex").Knex } knex
* @returns { Promise<void> }
*/
exports.down = function(knex) {
return knex.schema
.alterTable('project_admins', function(table) {
table.dropColumn('role')
})
.alterTable('project_api_keys', function(table) {
table.dropColumn('role')
})
}

View file

@ -1,7 +1,7 @@
import Router from '@koa/router'
import { extractQueryParams } from '../utilities'
import { searchParamsSchema } from '../core/searchParams'
import { pagedAdmins } from './AdminRepository'
import { getAdmin, pagedAdmins } from './AdminRepository'
const router = new Router({
prefix: '/admins',
@ -12,4 +12,8 @@ router.get('/', async ctx => {
ctx.body = await pagedAdmins(params)
})
router.get('/:adminId', async ctx => {
ctx.body = await getAdmin(parseInt(ctx.params.adminId, 10))
})
export default router

View file

@ -2,7 +2,7 @@ import jwt from 'jsonwebtoken'
import { Context } from 'koa'
import App from '../app'
import { RequestError } from '../core/errors'
import Project from '../projects/Project'
import Project, { ProjectRole } from '../projects/Project'
import { ProjectApiKey } from '../projects/ProjectApiKey'
import { getProjectApiKey } from '../projects/ProjectService'
import AuthError from './AuthError'
@ -25,6 +25,7 @@ export interface AuthState {
export interface ProjectState extends AuthState {
project: Project
projectRole: ProjectRole
}
const parseAuth = async (ctx: Context) => {

View file

@ -5,11 +5,14 @@ import { archiveCampaign, createCampaign, deleteCampaign, duplicateCampaign, get
import { searchParamsSchema } from '../core/searchParams'
import { extractQueryParams } from '../utilities'
import { ProjectState } from '../auth/AuthMiddleware'
import { projectRoleMiddleware } from '../projects/ProjectService'
const router = new Router<ProjectState & { campaign?: Campaign }>({
prefix: '/campaigns',
})
router.use(projectRoleMiddleware('editor'))
router.get('/', async ctx => {
const params = extractQueryParams(ctx.query, searchParamsSchema)
ctx.body = await pagedCampaigns(params, ctx.state.project.id)

View file

@ -16,6 +16,7 @@ import ProfileController from '../profile/ProfileController'
import TagController from '../tags/TagController'
import { authMiddleware, scopeMiddleware } from '../auth/AuthMiddleware'
import ProjectAdminController from '../projects/ProjectAdminController'
import ProjectApiKeyController from '../projects/ProjectApiKeyController'
import AdminController from '../auth/AdminController'
const register = (parent: Router, ...routers: Router[]) => {
@ -74,6 +75,7 @@ export const projectRouter = (prefix = '/projects/:project') => {
TemplateController,
ProviderController,
ProjectAdminController,
ProjectApiKeyController,
UserController,
TagController,
)

View file

@ -164,7 +164,7 @@ export default class Model {
static async searchParams<T extends typeof Model>(
this: T,
params: SearchParams,
fields: Array<keyof InstanceType<T>>,
fields: Array<keyof InstanceType<T> | string>,
query: Query = qb => qb,
db: Database = App.main.db,
) {

View file

@ -1,4 +1,5 @@
import Router from '@koa/router'
import { projectRoleMiddleware } from '../projects/ProjectService'
import { ProjectState } from '../auth/AuthMiddleware'
import { searchParamsSchema } from '../core/searchParams'
import { JSONSchemaType, validate } from '../core/validate'
@ -13,6 +14,8 @@ const router = new Router<
prefix: '/journeys',
})
router.use(projectRoleMiddleware('editor'))
router.get('/', async ctx => {
const params = extractQueryParams(ctx.query, searchParamsSchema)
ctx.body = await pagedJourneys(params, ctx.state.project.id)

View file

@ -6,6 +6,7 @@ import { createList, getList, getListUsers, importUsersToList, pagedLists, updat
import { searchParamsSchema } from '../core/searchParams'
import { ProjectState } from '../auth/AuthMiddleware'
import parse from '../storage/FileStream'
import { projectRoleMiddleware } from '../projects/ProjectService'
const router = new Router<
ProjectState & { list?: List }
@ -13,6 +14,8 @@ const router = new Router<
prefix: '/lists',
})
router.use(projectRoleMiddleware('editor'))
router.get('/', async ctx => {
const params = extractQueryParams(ctx.query, searchParamsSchema)
ctx.body = await pagedLists(params, ctx.state.project.id)

View file

@ -10,3 +10,11 @@ export default class Project extends Model {
}
export type ProjectParams = Omit<Project, ModelParams | 'deleted_at'>
export const projectRoles = [
'support',
'editor',
'admin',
] as const
export type ProjectRole = (typeof projectRoles)[number]

View file

@ -2,8 +2,13 @@ import Router from '@koa/router'
import { extractQueryParams } from '../utilities'
import { searchParamsSchema } from '../core/searchParams'
import { ProjectState } from '../auth/AuthMiddleware'
import { addAdminToProject, getProjectAdmin, pagedProjectAdmins, removeAdminFromProject } from './ProjectAdminRepository'
import { JSONSchemaType } from 'ajv'
import { Admin } from '../auth/Admin'
import { addAdminToProject, pagedProjectAdmins, updateAdminProjectState } from './ProjectAdminRepository'
import { validate } from '../core/validate'
import { projectRoleMiddleware } from './ProjectService'
import { ProjectAdminParams } from './ProjectAdmins'
import { projectRoles } from './Project'
const router = new Router<
ProjectState & { admin?: Admin }
@ -11,18 +16,41 @@ const router = new Router<
prefix: '/admins',
})
router.use(projectRoleMiddleware('admin'))
router.get('/', async ctx => {
const params = extractQueryParams(ctx.query, searchParamsSchema)
ctx.body = await pagedProjectAdmins(params, ctx.state.project.id)
})
router.post('/', async ctx => {
await addAdminToProject(ctx.state.project.id, ctx.request.body.admin_id)
ctx.body = true
const projectAdminParamsSchema: JSONSchemaType<ProjectAdminParams> = {
$id: 'projectAdminParams',
type: 'object',
required: ['role'],
properties: {
role: {
type: 'string',
enum: projectRoles,
},
},
}
router.put('/:adminId', async ctx => {
const admin = await Admin.find(ctx.params.adminId)
if (!admin) return ctx.throw(404, 'invalid adminId')
const { role } = validate(projectAdminParamsSchema, ctx.request.body)
await addAdminToProject(ctx.state.project.id, admin.id, role)
ctx.body = await getProjectAdmin(ctx.state.project.id, admin.id)
})
router.get('/:adminId', async ctx => {
const projectAdmin = await getProjectAdmin(ctx.state.project.id, parseInt(ctx.params.adminId, 10))
if (!projectAdmin) return ctx.throw(404)
ctx.body = projectAdmin
})
router.delete('/:adminId', async ctx => {
await updateAdminProjectState(ctx.state.project.id, parseInt(ctx.params.adminId))
await removeAdminFromProject(ctx.state.project.id, parseInt(ctx.params.adminId, 10))
ctx.body = true
})

View file

@ -1,42 +1,46 @@
import { Admin } from '../auth/Admin'
import { Database } from '../config/database'
import { Database } from 'config/database'
import { SearchParams } from '../core/searchParams'
import ProjectAdmin from './ProjectAdmins'
import { ProjectRole } from './Project'
import { ProjectAdmin } from './ProjectAdmins'
const adminSelectFields = ['admins.first_name', 'admins.last_name', 'admins.email']
const projectAdminFields = [`${ProjectAdmin.tableName}.*`, ...adminSelectFields]
const baseProjectAdminQuery = (builder: Database.QueryBuilder<any>, projectId: number) => {
return builder.where('project_id', projectId)
.whereNull('project_admins.deleted_at')
.rightJoin('project_admins', 'project_admins.admin_id', 'admins.id')
.select('admins.*')
return builder
.select(projectAdminFields)
.join('admins', 'admin_id', '=', 'admins.id')
.where('project_id', projectId)
.whereNull(`${ProjectAdmin.tableName}.deleted_at`)
}
export const pagedProjectAdmins = async (params: SearchParams, projectId: number) => {
return await Admin.searchParams(
return await ProjectAdmin.searchParams(
params,
['first_name', 'last_name'],
qb => baseProjectAdminQuery(qb, projectId),
adminSelectFields,
q => baseProjectAdminQuery(q, projectId),
)
}
export const allProjectAdmins = async (projectId: number) => {
return await Admin.all(qb => baseProjectAdminQuery(qb, projectId))
export const getProjectAdmin = async (projectId: number, adminId: number) => {
return await ProjectAdmin.first(q => baseProjectAdminQuery(q.where('admin_id', adminId), projectId))
}
export const getProjectAdmin = async (id: number, projectId: number) => {
return await Admin.find(id, qb => baseProjectAdminQuery(qb, projectId))
}
export const addAdminToProject = async (projectId: number, adminId: number) => {
export const addAdminToProject = async (projectId: number, adminId: number, role: ProjectRole) => {
const admin = await getProjectAdmin(adminId, projectId)
if (admin) {
return await updateAdminProjectState(projectId, adminId, false)
return await ProjectAdmin.update(q => q.where('id', admin.id), { role })
}
return await ProjectAdmin.insert({ admin_id: adminId, project_id: projectId })
return await ProjectAdmin.insert({
admin_id: adminId,
project_id: projectId,
role,
})
}
export const updateAdminProjectState = async (projectId: number, adminId: number, isDeleted = true) => {
export const removeAdminFromProject = async (projectId: number, adminId: number) => {
return await ProjectAdmin.update(
qb => qb.where('admin_id', adminId).where('project_id', projectId),
{ deleted_at: isDeleted ? new Date() : undefined },
{ deleted_at: new Date() },
)
}

View file

@ -1,8 +1,11 @@
import Model from '../core/Model'
import { ProjectRole } from './Project'
export default class ProjectAdmin extends Model {
export class ProjectAdmin extends Model {
project_id!: number
admin_id?: number
role!: ProjectRole
deleted_at?: Date
}
export type ProjectAdminParams = Pick<ProjectAdmin, 'role'>

View file

@ -1,14 +1,14 @@
import Model from '../core/Model'
import { ProjectRole } from './Project'
export class ProjectApiKey extends Model {
project_id!: number
value!: string
name!: string
scope!: 'public' | 'secret'
role!: ProjectRole
description?: string
deleted_at?: Date
}
export type ProjectApiKeyParams = Pick<ProjectApiKey, 'scope' | 'name' | 'description'>
export type ProjectApiKeyUpdateParams = Pick<ProjectApiKey, 'name' | 'description'>
export type ProjectApiKeyParams = Pick<ProjectApiKey, 'scope' | 'name' | 'description' | 'role'>

View file

@ -0,0 +1,77 @@
import Router from '@koa/router'
import { JSONSchemaType } from 'ajv'
import { ProjectState } from '../auth/AuthMiddleware'
import { searchParamsSchema } from '../core/searchParams'
import { validate } from '../core/validate'
import { extractQueryParams } from '../utilities'
import { projectRoles } from './Project'
import { ProjectApiKey, ProjectApiKeyParams } from './ProjectApiKey'
import { createProjectApiKey, pagedApiKeys, projectRoleMiddleware, revokeProjectApiKey, updateProjectApiKey } from './ProjectService'
const router = new Router<
ProjectState & { apiKey?: ProjectApiKey }
>({
prefix: '/keys',
})
router.use(projectRoleMiddleware('admin'))
router.get('/', async ctx => {
const params = extractQueryParams(ctx.query, searchParamsSchema)
ctx.body = await pagedApiKeys(params, ctx.state.project.id)
})
const projectKeyParams: JSONSchemaType<ProjectApiKeyParams> = {
$id: 'projectKeyCreate',
type: 'object',
required: ['name'],
properties: {
scope: {
type: 'string',
enum: ['public', 'secret'],
},
name: {
type: 'string',
},
description: {
type: 'string',
nullable: true,
},
role: {
type: 'string',
enum: projectRoles,
},
},
additionalProperties: false,
}
router.post('/', async ctx => {
const payload = validate(projectKeyParams, ctx.request.body)
ctx.body = await createProjectApiKey(ctx.state.project.id, payload)
})
router.param('keyId', async (value, ctx, next) => {
const apiKey = await ProjectApiKey.first(q => q
.whereNull('deleted_at')
.where('project_id', ctx.state.project.id)
.where('id', parseInt(value, 10)),
)
if (!apiKey) {
return ctx.throw(404)
}
ctx.state.apiKey = apiKey
return next()
})
router.get('/:keyId', async ctx => {
ctx.body = ctx.state.apiKey!
})
router.patch('/:keyId', async ctx => {
ctx.body = await updateProjectApiKey(ctx.state.apiKey!.id, validate(projectKeyParams, ctx.request.body))
})
router.delete('/:keyId', async ctx => {
ctx.body = await revokeProjectApiKey(parseInt(ctx.params.keyId, 10))
})
export default router

View file

@ -3,20 +3,38 @@ import Project, { ProjectParams } from './Project'
import { JSONSchemaType, validate } from '../core/validate'
import { extractQueryParams } from '../utilities'
import { searchParamsSchema } from '../core/searchParams'
import { Context } from 'koa'
import { createProject, createProjectApiKey, getProject, pagedApiKeys, revokeProjectApiKey, updateProjectApiKey } from './ProjectService'
import { ParameterizedContext } from 'koa'
import { createProject, getProject, requireProjectRole } from './ProjectService'
import { AuthState, ProjectState } from '../auth/AuthMiddleware'
import { ProjectApiKeyParams, ProjectApiKeyUpdateParams } from './ProjectApiKey'
import { getProjectAdmin } from './ProjectAdminRepository'
import { RequestError } from '../core/errors'
import { ProjectError } from './ProjectError'
export async function projectMiddleware(ctx: Context, next: () => void) {
ctx.state.project = await getProject(
ctx.params.project ?? ctx.state.key.project_id,
ctx.state.admin?.id,
export async function projectMiddleware(ctx: ParameterizedContext<ProjectState>, next: () => void) {
const project = await getProject(
ctx.state.scope === 'admin'
? ctx.params.project
: ctx.state.key!.project_id,
)
if (!ctx.state.project) {
ctx.throw(404)
if (!project) {
throw new RequestError(ProjectError.ProjectDoesNotExist)
}
return await next()
ctx.state.project = project
if (ctx.state.scope === 'admin') {
const projectAdmin = await getProjectAdmin(project.id, ctx.state.admin!.id)
if (!projectAdmin) {
throw new RequestError(ProjectError.ProjectAccessDenied)
}
ctx.state.projectRole = projectAdmin.role ?? 'support'
} else {
ctx.state.projectRole = ctx.state.key!.role ?? 'support'
}
return next()
}
const router = new Router<AuthState>({ prefix: '/projects' })
@ -63,7 +81,10 @@ export default router
const subrouter = new Router<ProjectState>()
subrouter.get('/', async ctx => {
ctx.body = ctx.state.project
ctx.body = {
...ctx.state.project,
role: ctx.state.projectRole,
}
})
const projectUpdateParams: JSONSchemaType<Partial<ProjectParams>> = {
@ -91,60 +112,8 @@ const projectUpdateParams: JSONSchemaType<Partial<ProjectParams>> = {
}
subrouter.patch('/', async ctx => {
requireProjectRole(ctx, 'admin')
ctx.body = await Project.updateAndFetch(ctx.state.project!.id, validate(projectUpdateParams, ctx.request.body))
})
subrouter.get('/keys', async ctx => {
const params = extractQueryParams(ctx.query, searchParamsSchema)
ctx.body = await pagedApiKeys(params, ctx.state.project.id)
})
const projectKeyCreateParams: JSONSchemaType<ProjectApiKeyParams> = {
$id: 'projectKeyCreate',
type: 'object',
required: ['name'],
properties: {
scope: {
type: 'string',
enum: ['public', 'secret'],
},
name: {
type: 'string',
},
description: {
type: 'string',
nullable: true,
},
},
additionalProperties: false,
}
subrouter.post('/keys', async ctx => {
const payload = await validate(projectKeyCreateParams, ctx.request.body)
ctx.body = await createProjectApiKey(ctx.state.project.id, payload)
})
const projectKeyUpdateParams: JSONSchemaType<ProjectApiKeyUpdateParams> = {
$id: 'projectKeyUpdate',
type: 'object',
required: ['name'],
properties: {
name: {
type: 'string',
},
description: {
type: 'string',
nullable: true,
},
},
additionalProperties: false,
}
subrouter.patch('/keys/:keyId', async ctx => {
const payload = await validate(projectKeyUpdateParams, ctx.request.body)
ctx.body = updateProjectApiKey(parseInt(ctx.params.keyId, 10), payload)
})
subrouter.delete('/keys/:keyId', async ctx => {
ctx.body = revokeProjectApiKey(parseInt(ctx.params.keyId, 10))
})
export { subrouter as ProjectSubrouter }

View file

@ -0,0 +1,14 @@
import { ErrorType } from '../core/errors'
export const ProjectError = {
ProjectDoesNotExist: {
message: 'The requested campaign does not exist.',
code: 6000,
statusCode: 404,
},
ProjectAccessDenied: {
message: 'You do not have permission to access this project.',
code: 6001,
statusCode: 403,
},
} satisfies Record<string, ErrorType>

View file

@ -1,9 +1,12 @@
import { ProjectState } from 'auth/AuthMiddleware'
import { Next, ParameterizedContext } from 'koa'
import { RequestError } from '../core/errors'
import { SearchParams } from '../core/searchParams'
import { createSubscription } from '../subscriptions/SubscriptionService'
import { uuid } from '../utilities'
import Project, { ProjectParams } from './Project'
import ProjectAdmin from './ProjectAdmins'
import { ProjectApiKey, ProjectApiKeyParams, ProjectApiKeyUpdateParams } from './ProjectApiKey'
import Project, { ProjectParams, ProjectRole, projectRoles } from './Project'
import { ProjectAdmin } from './ProjectAdmins'
import { ProjectApiKey, ProjectApiKeyParams } from './ProjectApiKey'
export const adminProjectIds = async (adminId: number) => {
const records = await ProjectAdmin.all(qb => qb.where('admin_id', adminId))
@ -29,6 +32,7 @@ export const createProject = async (adminId: number, params: ProjectParams) => {
await ProjectAdmin.insert({
project_id: project.id,
admin_id: adminId,
role: 'admin',
})
// Create a single subscription for each type
@ -59,7 +63,7 @@ export const createProjectApiKey = async (projectId: number, params: ProjectApiK
})
}
export const updateProjectApiKey = async (id: number, params: ProjectApiKeyUpdateParams) => {
export const updateProjectApiKey = async (id: number, params: ProjectApiKeyParams) => {
return await ProjectApiKey.updateAndFetch(id, params)
}
@ -72,3 +76,14 @@ export const generateApiKey = (scope: 'public' | 'secret') => {
const prefix = scope === 'public' ? 'pk' : 'sk'
return `${prefix}_${key}`
}
export const requireProjectRole = (ctx: ParameterizedContext<ProjectState>, minRole: ProjectRole) => {
if (projectRoles.indexOf(minRole) > projectRoles.indexOf(ctx.state.projectRole)) {
throw new RequestError(`minimum project role ${minRole} is required`, 403)
}
}
export const projectRoleMiddleware = (minRole: ProjectRole) => async (ctx: ParameterizedContext<ProjectState>, next: Next) => {
requireProjectRole(ctx, minRole)
return next()
}

View file

@ -10,6 +10,7 @@ import { encodedLinkToParts } from '../render/LinkService'
import { ProjectState } from '../auth/AuthMiddleware'
import { extractQueryParams } from '../utilities'
import { searchParamsSchema } from '../core/searchParams'
import { projectRoleMiddleware } from '../projects/ProjectService'
/**
***
@ -117,7 +118,7 @@ export const subscriptionCreateSchema: JSONSchemaType<SubscriptionParams> = {
additionalProperties: false,
}
router.post('/', async ctx => {
router.post('/', projectRoleMiddleware('admin'), async ctx => {
const payload = validate(subscriptionCreateSchema, ctx.request.body)
ctx.body = await createSubscription(ctx.state.project.id, payload)
})

View file

@ -6,6 +6,7 @@ import { extractQueryParams } from '../utilities'
import { ProjectState } from '../auth/AuthMiddleware'
import { Tag, TagParams } from './Tag'
import { getUsedTags } from './TagService'
import { projectRoleMiddleware } from '../projects/ProjectService'
const router = new Router<
ProjectState & {
@ -44,7 +45,7 @@ const tagParams: JSONSchemaType<TagParams> = {
},
}
router.post('/', async ctx => {
router.post('/', projectRoleMiddleware('editor'), async ctx => {
ctx.body = await Tag.insertAndFetch({
project_id: ctx.state.project!.id,
...validate(tagParams, ctx.request.body),
@ -66,11 +67,11 @@ router.get('/:tagId', async ctx => {
ctx.body = ctx.state.tag!
})
router.patch('/:tagId', async ctx => {
router.patch('/:tagId', projectRoleMiddleware('editor'), async ctx => {
ctx.body = await Tag.updateAndFetch(ctx.state.tag!.id, validate(tagParams, ctx.request.body))
})
router.delete('/:tagId', async ctx => {
router.delete('/:tagId', projectRoleMiddleware('editor'), async ctx => {
await Tag.delete(b => b.where('id', ctx.state.tag!.id))
ctx.body = true
})

View file

@ -12,6 +12,7 @@ import { getUserLists } from '../lists/ListService'
import { getUserSubscriptions, toggleSubscription } from '../subscriptions/SubscriptionService'
import { SubscriptionState } from '../subscriptions/Subscription'
import { getUserEvents } from './UserEventRepository'
import { projectRoleMiddleware } from '../projects/ProjectService'
const router = new Router<
ProjectState & { user?: User }
@ -83,7 +84,7 @@ const patchUsersRequest: JSONSchemaType<UserParams[]> = {
},
minItems: 1,
}
router.patch('/', async ctx => {
router.patch('/', projectRoleMiddleware('editor'), async ctx => {
const users = validate(patchUsersRequest, ctx.request.body)
for (const user of users) {
@ -104,7 +105,7 @@ const deleteUsersRequest: JSONSchemaType<string[]> = {
},
minItems: 1,
}
router.delete('/', async ctx => {
router.delete('/', projectRoleMiddleware('editor'), async ctx => {
let userIds = ctx.request.query.user_id || []
if (!Array.isArray(userIds)) userIds = userIds.length ? [userIds] : []

View file

@ -1,6 +1,6 @@
import Axios from 'axios'
import { env } from './config/env'
import { Admin, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, JourneyStepStats, List, ListCreateParams, ListUpdateParams, Project, ProjectAdminCreateParams, ProjectApiKey, ProjectApiKeyParams, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, SearchParams, SearchResult, Subscription, SubscriptionParams, Tag, Template, TemplateCreateParams, TemplatePreviewParams, TemplateProofParams, TemplateUpdateParams, User, UserEvent, UserSubscription } from './types'
import { Admin, Campaign, CampaignCreateParams, CampaignLaunchParams, CampaignUpdateParams, CampaignUser, Image, Journey, JourneyStepMap, JourneyStepStats, List, ListCreateParams, ListUpdateParams, Project, ProjectAdmin, ProjectAdminParams, ProjectApiKey, ProjectApiKeyParams, Provider, ProviderCreateParams, ProviderMeta, ProviderUpdateParams, 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
@ -201,7 +201,20 @@ const api = {
},
},
projectAdmins: createProjectEntityPath<Admin, ProjectAdminCreateParams>('admins'),
projectAdmins: {
search: async (projectId: number, params: SearchParams) => await client
.get<SearchResult<ProjectAdmin>>(`${projectUrl(projectId)}/admins`, { params })
.then(r => r.data),
add: async (projectId: number, adminId: number, params: ProjectAdminParams) => await client
.put<ProjectAdmin>(`${projectUrl(projectId)}/admins/${adminId}`, params)
.then(r => r.data),
get: async (projectId: number, adminId: number) => await client
.get<ProjectAdmin>(`${projectUrl(projectId)}/admins/${adminId}`)
.then(r => r.data),
remove: async (projectId: number, adminId: number) => await client
.delete(`${projectUrl(projectId)}/admins/${adminId}`)
.then(r => r.data),
},
subscriptions: createProjectEntityPath<Subscription>('subscriptions'),

View file

@ -27,7 +27,7 @@ export interface FieldProps<X extends FieldValues, P extends FieldPath<X>> exten
export type FieldBindingsProps<I extends ControlledInputProps<T>, T, X extends FieldValues, P extends FieldPath<X>> = Omit<I, keyof ControlledProps<T>> & FieldProps<X, P>
export interface OptionsProps<O, V = O> {
options: O[]
options: O[] | readonly O[]
toValue?: (option: O) => V
getValueKey?: (option: V) => Key
getOptionDisplay?: (option: O) => ReactNode
@ -104,10 +104,25 @@ export interface Admin {
email: string
}
export interface ProjectAdminCreateParams {
export const projectRoles = [
'support',
'editor',
'admin',
] as const
export type ProjectRole = (typeof projectRoles)[number]
export interface ProjectAdmin extends Omit<Admin, 'id'> {
id: number
created_at: string
updated_at: string
project_id: number
admin_id: number
role: ProjectRole
}
export type ProjectAdminParams = Pick<ProjectAdmin, 'role'>
export interface Project {
id: number
name: string
@ -117,6 +132,7 @@ export interface Project {
created_at: string
updated_at: string
deleted_at?: string
role?: ProjectRole
}
export type ChannelType = 'email' | 'push' | 'text' | 'webhook'
@ -128,10 +144,11 @@ export interface ProjectApiKey {
value: string
name: string
scope: 'public' | 'secret'
role?: ProjectRole
description?: string
}
export type ProjectApiKeyParams = Pick<ProjectApiKey, 'name' | 'description' | 'scope'>
export type ProjectApiKeyParams = Pick<ProjectApiKey, 'name' | 'description' | 'scope' | 'role'>
export interface User {
id: number

View file

@ -1,4 +1,4 @@
import { PropsWithChildren } from 'react'
import { CSSProperties, PropsWithChildren } from 'react'
import './Alert.css'
interface AlertProps extends PropsWithChildren {
@ -6,11 +6,12 @@ interface AlertProps extends PropsWithChildren {
title: React.ReactNode
body?: React.ReactNode
actions?: React.ReactNode
style?: CSSProperties
}
export default function Alert(props: AlertProps) {
return (
<div className={`ui-alert ${props.variant ?? 'info'}`}>
<div className={`ui-alert ${props.variant ?? 'info'}`} style={props.style}>
<h4>{props.title}</h4>
<p className="alert-body">{props.body ?? props.children}</p>
{props.actions && <div className="alert-actions">{props.actions}</div>}

View file

@ -11,10 +11,15 @@ import { SingleSelect } from './form/SingleSelect'
import Button from './Button'
import ButtonGroup from './ButtonGroup'
import { MoonIcon, SunIcon } from './icons'
import { getRecentProjects } from '../utils'
import { checkProjectRole, getRecentProjects } from '../utils'
import { ProjectRole } from '../types'
interface SidebarProps {
links?: Array<NavLinkProps & { key: string, icon: ReactNode }>
links?: Array<NavLinkProps & {
key: string
icon: ReactNode
minRole?: ProjectRole
}>
}
export default function Sidebar({ children, links }: PropsWithChildren<SidebarProps>) {
@ -78,9 +83,11 @@ export default function Sidebar({ children, links }: PropsWithChildren<SidebarPr
/>
<nav>
{
links?.map(({ key, ...props }) => (
<NavLink {...props} key={key} />
))
links
?.filter(({ minRole }) => !minRole || checkProjectRole(minRole, project.role))
.map(({ key, minRole, ...props }) => (
<NavLink {...props} key={key} />
))
}
</nav>
{

View file

@ -28,6 +28,7 @@ const defaultOptionEnabled = () => true
export function EntityIdPicker<T extends { id: number }>({
createModalSize,
disabled,
displayValue = defaultDisplayValue,
get,
inputRef,
@ -60,6 +61,7 @@ export function EntityIdPicker<T extends { id: number }>({
as="div"
className="ui-select"
nullable
disabled={disabled}
value={entity}
onChange={next => onChange(next?.id ?? 0)}
>
@ -188,7 +190,7 @@ EntityIdPicker.Field = function EntityIdPickerField<T extends { id: number }, X
required,
...rest
}: EntityIdPickerFieldProps<T, X, P>) {
const { field } = useController({
const { field: { ref, ...field } } = useController({
control: form!.control,
name,
rules: {
@ -200,6 +202,7 @@ EntityIdPicker.Field = function EntityIdPickerField<T extends { id: number }, X
<EntityIdPicker
{...rest}
{...field}
inputRef={ref}
required={required}
disabled={disabled}
/>

View file

@ -1,6 +1,6 @@
import { parseISO, formatDuration as dateFnsFormatDuration } from 'date-fns'
import { format } from 'date-fns-tz'
import { Preferences } from './types'
import { Preferences, ProjectRole, projectRoles } from './types'
import { v4 } from 'uuid'
export function createUuid() {
@ -157,3 +157,10 @@ export function pushRecentProject(id: number | string) {
localStorageSetJson(RECENT_PROJECTS, stored)
return stored
}
/**
* @returns true if user has at least the minRole
*/
export function checkProjectRole(minRole: ProjectRole, currentRole: ProjectRole = 'support') {
return projectRoles.indexOf(minRole) <= projectRoles.indexOf(currentRole)
}

View file

@ -1,27 +1,66 @@
import { isRouteErrorResponse, useRouteError } from 'react-router-dom'
import { isRouteErrorResponse, Navigate, useNavigate, useRouteError } from 'react-router-dom'
import Alert from '../ui/Alert'
import Button from '../ui/Button'
export default function ErrorPage() {
const error = useRouteError() as any
const navigate = useNavigate()
console.error(error)
let status = 500
let message = ''
if (isRouteErrorResponse(error)) {
status = error.status
message = error.data + ''
}
if (error.response) {
status = error.response.status
message = error.response.data + ''
}
if (error.status === 401) {
return null
}
if (status === 401) {
// in case the data router didn't catch this already
return (
<div>
{error.statusText || error.statusText}
</div>
<Navigate to="/login" />
)
}
if (status === 403) {
return (
<AccessDenied />
)
}
if (status === 404) {
return (
<Alert
variant="info"
title="Page Not Found"
style={{ margin: 15 }}
actions={
<Button
onClick={() => navigate(-1)}
>
Go Back
</Button>
}
/>
)
}
return (
<div>
{'an error has occurred!'}
</div>
<Alert variant="error" title={`Error [${status.toString()}]`}>
{message}
</Alert>
)
}
export function AccessDenied() {
return (
<Alert variant="warn" title="Access Denied" style={{ margin: 15 }}>
Additional permission is required in order to access this section.
Please reach out to your administrator.
</Alert>
)
}

View file

@ -2,6 +2,7 @@ import { Context } from 'react'
import { RouteObject } from 'react-router-dom'
import { ProjectEntityPath } from '../api'
import { UseStateContext } from '../types'
import ErrorPage from './ErrorPage'
import { StatefulLoaderContextProvider } from './LoaderContextProvider'
interface StatefulRoute<T extends Record<string, any>> {
@ -24,5 +25,6 @@ export function createStatefulRoute<T extends { id: number }>({ context, path, a
)
: element,
children: children.map(({ tab, ...rest }) => rest),
errorElement: <ErrorPage />,
}
}

View file

@ -0,0 +1,24 @@
import { PropsWithChildren, useContext } from 'react'
import { ProjectContext } from '../../contexts'
import { ProjectRole, projectRoles } from '../../types'
import { AccessDenied } from '../ErrorPage'
type ProjectRoleRequiredProps = PropsWithChildren<{
minRole: ProjectRole
}>
export function ProjectRoleRequired({ children, minRole }: ProjectRoleRequiredProps) {
const [project] = useContext(ProjectContext)
if (!project.role || projectRoles.indexOf(minRole) > projectRoles.indexOf(project.role)) {
return (
<AccessDenied />
)
}
return (
<>
{children}
</>
)
}

View file

@ -38,6 +38,7 @@ import OnboardingProject from './auth/OnboardingProject'
import { CampaignsIcon, JourneysIcon, ListsIcon, SettingsIcon, UsersIcon } from '../ui/icons'
import { Projects } from './project/Projects'
import { pushRecentProject } from '../utils'
import { ProjectRoleRequired } from './project/ProjectRoleRequired'
export const useRoute = (includeProject = true) => {
const { projectId = '' } = useParams()
@ -103,12 +104,14 @@ export const router = createBrowserRouter([
to: 'campaigns',
children: 'Campaigns',
icon: <CampaignsIcon />,
minRole: 'editor',
},
{
key: 'journeys',
to: 'journeys',
children: 'Journeys',
icon: <JourneysIcon />,
minRole: 'editor',
},
{
key: 'users',
@ -121,12 +124,14 @@ export const router = createBrowserRouter([
to: 'lists',
children: 'Lists',
icon: <ListsIcon />,
minRole: 'editor',
},
{
key: 'settings',
to: 'settings',
children: 'Settings',
icon: <SettingsIcon />,
minRole: 'admin',
},
]}
>
@ -230,44 +235,46 @@ export const router = createBrowserRouter([
{
path: 'settings',
element: (
<PageContent title="Settings">
<NavigationTabs
tabs={[
{
key: 'general',
to: '',
end: true,
children: 'General',
},
{
key: 'team',
to: 'team',
children: 'Team',
},
{
key: 'api-keys',
to: 'api-keys',
children: 'API Keys',
},
{
key: 'integrations',
to: 'integrations',
children: 'Integrations',
},
{
key: 'subscriptions',
to: 'subscriptions',
children: 'Subscriptions',
},
{
key: 'tags',
to: 'tags',
children: 'Tags',
},
]}
/>
<Outlet />
</PageContent>
<ProjectRoleRequired minRole="admin">
<PageContent title="Settings">
<NavigationTabs
tabs={[
{
key: 'general',
to: '',
end: true,
children: 'General',
},
{
key: 'team',
to: 'team',
children: 'Team',
},
{
key: 'api-keys',
to: 'api-keys',
children: 'API Keys',
},
{
key: 'integrations',
to: 'integrations',
children: 'Integrations',
},
{
key: 'subscriptions',
to: 'subscriptions',
children: 'Subscriptions',
},
{
key: 'tags',
to: 'tags',
children: 'Tags',
},
]}
/>
<Outlet />
</PageContent>
</ProjectRoleRequired>
),
children: [
{

View file

@ -1,7 +1,7 @@
import { useCallback, useContext, useState } from 'react'
import api from '../../api'
import { ProjectContext } from '../../contexts'
import { ProjectApiKey } from '../../types'
import { ProjectApiKey, projectRoles } from '../../types'
import Button from '../../ui/Button'
import OptionField from '../../ui/form/OptionField'
import TextField from '../../ui/form/TextField'
@ -10,6 +10,8 @@ import Modal from '../../ui/Modal'
import { SearchTable, useSearchTableState } from '../../ui/SearchTable'
import { ArchiveIcon, PlusIcon } from '../../ui/icons'
import Menu, { MenuItem } from '../../ui/Menu'
import { SingleSelect } from '../../ui/form/SingleSelect'
import { snakeToTitle } from '../../utils'
export default function ProjectApiKeys() {
@ -32,6 +34,10 @@ export default function ProjectApiKeys() {
columns={[
{ key: 'name' },
{ key: 'scope' },
{
key: 'role',
cell: ({ item }) => item.scope === 'public' ? '--' : item.role,
},
{ key: 'value' },
{ key: 'description' },
{
@ -52,7 +58,7 @@ export default function ProjectApiKeys() {
<Button
icon={<PlusIcon />}
size="small"
onClick={() => setEditing({ scope: 'public' })}
onClick={() => setEditing({ scope: 'public', role: 'support' })}
>
Create Key
</Button>
@ -63,53 +69,68 @@ export default function ProjectApiKeys() {
open={Boolean(editing)}
onClose={() => setEditing(null)}
>
<FormWrapper<ProjectApiKey>
onSubmit={
async ({ id, name, description, scope }) => {
if (id) {
await api.apiKeys.update(project.id, id, { name, description })
} else {
await api.apiKeys.create(project.id, { name, description, scope })
{
editing && (
<FormWrapper<ProjectApiKey>
onSubmit={
async ({ id, name, description, scope }) => {
if (id) {
await api.apiKeys.update(project.id, id, { name, description })
} else {
await api.apiKeys.create(project.id, { name, description, scope })
}
await state.reload()
setEditing(null)
}
}
await state.reload()
setEditing(null)
}
}
defaultValues={editing ?? { scope: 'public' }}
submitLabel={editing ? 'Save' : 'Create'}
>
{
form => (
<>
<TextField
form={form}
name="name"
label="Name"
required
/>
<TextField
form={form}
name="description"
label="Description"
textarea
/>
{
!!(editing && !editing.id) && (
<OptionField
form={form}
name="scope"
label="Scope"
options={[
{ key: 'public', label: 'Public' },
{ key: 'secret', label: 'Secret' },
]}
/>
defaultValues={editing}
submitLabel={editing?.id ? 'Update Key' : 'Create Key'}
>
{
form => {
const scope = form.watch('scope')
return (
<>
<TextField
form={form}
name="name"
label="Name"
required
/>
<TextField
form={form}
name="description"
label="Description"
/>
<OptionField
form={form}
name="scope"
label="Scope"
options={[
{ key: 'public', label: 'Public' },
{ key: 'secret', label: 'Secret' },
]}
disabled={!!editing?.id}
/>
{
scope === 'secret' && (
<SingleSelect.Field
form={form}
name="role"
label="Role"
options={projectRoles}
getOptionDisplay={snakeToTitle}
required
/>
)
}
</>
)
}
</>
)
}
</FormWrapper>
}
</FormWrapper>
)
}
</Modal>
</>
)

View file

@ -1,124 +1,121 @@
import { Key, ReactNode, useCallback, useContext, useState } from 'react'
import { useController } from 'react-hook-form'
import { useCallback, useContext, useState } from 'react'
import api from '../../api'
import { ProjectContext } from '../../contexts'
import { Admin, ProjectAdminCreateParams } from '../../types'
import { AdminContext, ProjectContext } from '../../contexts'
import { ProjectAdmin, projectRoles } from '../../types'
import Button from '../../ui/Button'
import { DataTableCol } from '../../ui/DataTable'
import { SelectionProps } from '../../ui/form/Field'
import { EntityIdPicker } from '../../ui/form/EntityIdPicker'
import FormWrapper from '../../ui/form/FormWrapper'
import { SingleSelect } from '../../ui/form/SingleSelect'
import { ArchiveIcon, PlusIcon } from '../../ui/icons'
import Menu, { MenuItem } from '../../ui/Menu'
import Modal from '../../ui/Modal'
import { SearchTable, SearchTableQueryState, useSearchTableState } from '../../ui/SearchTable'
import { useRoute } from '../router'
import { SearchTable, useSearchTableState } from '../../ui/SearchTable'
import { snakeToTitle } from '../../utils'
interface AdminTableParams {
state: SearchTableQueryState<Admin>
title?: ReactNode
actions?: ReactNode
selectedRow?: Key
onSelectRow?: (id: number) => void
onDeleteRow?: (id: number) => void
showOptions?: boolean
}
const AdminTable = ({
state,
selectedRow,
onSelectRow,
onDeleteRow,
title,
actions,
}: AdminTableParams) => {
const route = useRoute()
function handleOnSelectRow(id: number) {
onSelectRow ? onSelectRow(id) : route(`lists/${id}`)
}
const columns: Array<DataTableCol<Admin>> = [
{ key: 'first_name' },
{ key: 'last_name' },
{ key: 'email' },
]
if (onDeleteRow) {
columns.push({
key: 'options',
cell: ({ item: { id } }) => (
<Menu size="small">
<MenuItem onClick={() => onDeleteRow?.(id)}>
<ArchiveIcon /> Remove
</MenuItem>
</Menu>
),
})
}
return <SearchTable
{...state}
title={title}
actions={actions}
columns={columns}
itemKey={({ item }) => item.id}
selectedRow={selectedRow}
onSelectRow={({ id }) => handleOnSelectRow(id)}
/>
}
const AdminSelection = ({ name, control }: SelectionProps<ProjectAdminCreateParams>) => {
const state = useSearchTableState(useCallback(async params => await api.admins.search(params), []))
const { field: { value, onChange } } = useController({ name, control, rules: { required: true } })
return <AdminTable
state={state}
selectedRow={value}
onSelectRow={(id) => onChange(id)} />
}
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 [isModalOpen, setIsModalOpen] = useState(false)
const handleDeleteProjectAdmin = async (id: number) => {
await api.projectAdmins.delete(project.id, id)
await state.reload()
}
const [editing, setEditing] = useState<null | EditFormData>(null)
const searchAdmins = useCallback(async (q: string) => await api.admins.search({ q, page: 0, itemsPerPage: 100 }), [])
return (
<>
<AdminTable
state={state}
<SearchTable
{...state}
title="Team"
onDeleteRow={handleDeleteProjectAdmin}
actions={
<Button icon={<PlusIcon />} size="small" onClick={() => setIsModalOpen(true)}>
<Button
icon={<PlusIcon />}
size="small"
onClick={() => setEditing({
admin_id: 0,
role: 'support',
})}
>
Add Team Member
</Button>
}
columns={[
{ key: 'first_name' },
{ key: 'last_name' },
{ key: 'email' },
{
key: 'role',
cell: ({ item }) => snakeToTitle(item.role),
},
{
key: 'options',
cell: ({ item }) => (
<Menu size="small">
<MenuItem
onClick={async () => {
await api.projectAdmins.remove(item.project_id, item.admin_id)
await state.reload()
}}
>
<ArchiveIcon /> Remove
</MenuItem>
</Menu>
),
},
]}
itemKey={({ item }) => item.id}
onSelectRow={setEditing}
enableSearch
/>
<Modal
title="Add Team Member"
open={isModalOpen}
onClose={setIsModalOpen}
title={editing?.id ? 'Update Team Member Permissions' : 'Add Team Member'}
open={Boolean(editing)}
onClose={() => setEditing(null)}
size="regular"
>
<FormWrapper<ProjectAdminCreateParams>
onSubmit={
async admin => {
await api.projectAdmins.create(project.id, admin)
await state.reload()
setIsModalOpen(false)
}
}
submitLabel="Add To Project"
>
{form => <>
<AdminSelection
name="admin_id"
control={form.control} />
</>}
</FormWrapper>
{
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>
</>
)