mirror of
https://fast.feibisi.com/https://github.com/parcelvoy/platform.git
synced 2025-08-29 11:56:04 +08:00
Merge pull request #88 from parcelvoy/feat/project-roles
adds project role support for ProjectAdmin and ProjectApiKey
This commit is contained in:
commit
152fe8d430
32 changed files with 618 additions and 316 deletions
|
@ -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')
|
||||
})
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
|
|
|
@ -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,
|
||||
) {
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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]
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
@ -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() },
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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'>
|
||||
|
|
|
@ -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'>
|
||||
|
|
77
apps/platform/src/projects/ProjectApiKeyController.ts
Normal file
77
apps/platform/src/projects/ProjectApiKeyController.ts
Normal 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
|
|
@ -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 }
|
||||
|
|
14
apps/platform/src/projects/ProjectError.ts
Normal file
14
apps/platform/src/projects/ProjectError.ts
Normal 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>
|
|
@ -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()
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
|
|
|
@ -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
|
||||
})
|
||||
|
|
|
@ -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] : []
|
||||
|
|
|
@ -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'),
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>}
|
||||
|
|
|
@ -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>
|
||||
{
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
@ -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 />,
|
||||
}
|
||||
}
|
||||
|
|
24
apps/ui/src/views/project/ProjectRoleRequired.tsx
Normal file
24
apps/ui/src/views/project/ProjectRoleRequired.tsx
Normal 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}
|
||||
</>
|
||||
)
|
||||
}
|
|
@ -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: [
|
||||
{
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
)
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue