diff --git a/src/lib/auth.ts b/src/lib/auth.ts index 6ec76abe..371e619a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,13 +1,13 @@ import { Report } from '@prisma/client'; -import debug from 'debug'; import redis from '@umami/redis-client'; -import { PERMISSIONS, ROLE_PERMISSIONS, ROLES, SHARE_TOKEN_HEADER } from 'lib/constants'; +import debug from 'debug'; +import { PERMISSIONS, ROLE_PERMISSIONS, SHARE_TOKEN_HEADER } from 'lib/constants'; import { secret } from 'lib/crypto'; +import { NextApiRequest } from 'next'; import { createSecureToken, ensureArray, getRandomChars, parseToken } from 'next-basics'; -import { getTeamUser, getWebsiteById } from 'queries'; +import { getTeamUser } from 'queries'; import { loadWebsite } from './load'; import { Auth } from './types'; -import { NextApiRequest } from 'next'; const log = debug('umami:auth'); const cloudMode = process.env.CLOUD_MODE; @@ -52,9 +52,17 @@ export async function canViewWebsite({ user, shareToken }: Auth, websiteId: stri const website = await loadWebsite(websiteId); - if (user.id === website?.userId) { - return true; + if (website.userId) { + return user.id === website.userId; } + + if (website.teamId) { + const teamUser = await getTeamUser(website.teamId, user.id); + + return !!teamUser; + } + + return false; } export async function canViewAllWebsites({ user }: Auth) { @@ -80,7 +88,17 @@ export async function canUpdateWebsite({ user }: Auth, websiteId: string) { const website = await loadWebsite(websiteId); - return user.id === website?.userId; + if (website.userId) { + return user.id === website.userId; + } + + if (website.teamId) { + const teamUser = await getTeamUser(website.teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteUpdate); + } + + return false; } export async function canDeleteWebsite({ user }: Auth, websiteId: string) { @@ -90,7 +108,17 @@ export async function canDeleteWebsite({ user }: Auth, websiteId: string) { const website = await loadWebsite(websiteId); - return user.id === website?.userId; + if (website.userId) { + return user.id === website.userId; + } + + if (website.teamId) { + const teamUser = await getTeamUser(website.teamId, user.id); + + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteDelete); + } + + return false; } export async function canViewReport(auth: Auth, report: Report) { @@ -137,7 +165,11 @@ export async function canViewTeam({ user }: Auth, teamId: string) { return getTeamUser(teamId, user.id); } -export async function canUpdateTeam({ user }: Auth, teamId: string) { +export async function canUpdateTeam({ user, grant }: Auth, teamId: string) { + if (cloudMode) { + return !!grant?.find(a => a === PERMISSIONS.teamUpdate); + } + if (user.isAdmin) { return true; } @@ -147,6 +179,14 @@ export async function canUpdateTeam({ user }: Auth, teamId: string) { return teamUser && hasPermission(teamUser.role, PERMISSIONS.teamUpdate); } +export async function canAddUserToTeam({ user, grant }: Auth) { + if (cloudMode) { + return !!grant?.find(a => a === PERMISSIONS.teamUpdate); + } + + return user.isAdmin; +} + export async function canDeleteTeam({ user }: Auth, teamId: string) { if (user.isAdmin) { return true; @@ -171,20 +211,14 @@ export async function canDeleteTeamUser({ user }: Auth, teamId: string, removeUs return teamUser && hasPermission(teamUser.role, PERMISSIONS.teamUpdate); } -export async function canDeleteTeamWebsite({ user }: Auth, teamId: string, websiteId: string) { +export async function canCreateTeamWebsite({ user }: Auth, teamId: string) { if (user.isAdmin) { return true; } - const teamWebsite = await getWebsiteById(websiteId); + const teamUser = await getTeamUser(teamId, user.id); - if (teamWebsite && teamWebsite.teamId === teamId) { - const teamUser = await getTeamUser(teamId, user.id); - - return teamUser.role === ROLES.teamOwner || teamUser.role === ROLES.teamMember; - } - - return false; + return teamUser && hasPermission(teamUser.role, PERMISSIONS.websiteCreate); } export async function canCreateUser({ user }: Auth) { diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 7f7a8ea9..1dc1c0d5 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -147,8 +147,19 @@ export const ROLE_PERMISSIONS = { PERMISSIONS.teamCreate, ], [ROLES.viewOnly]: [], - [ROLES.teamOwner]: [PERMISSIONS.teamUpdate, PERMISSIONS.teamDelete], - [ROLES.teamMember]: [], + [ROLES.teamOwner]: [ + PERMISSIONS.teamUpdate, + PERMISSIONS.teamDelete, + PERMISSIONS.websiteCreate, + PERMISSIONS.websiteUpdate, + PERMISSIONS.websiteDelete, + ], + [ROLES.teamMember]: [ + PERMISSIONS.websiteCreate, + PERMISSIONS.websiteUpdate, + PERMISSIONS.websiteDelete, + ], + [ROLES.teamGuest]: [], } as const; export const THEME_COLORS = { diff --git a/src/pages/api/teams/[id]/users/[userId].ts b/src/pages/api/teams/[id]/users/[userId].ts deleted file mode 100644 index 3b16ac05..00000000 --- a/src/pages/api/teams/[id]/users/[userId].ts +++ /dev/null @@ -1,38 +0,0 @@ -import { canDeleteTeamUser } from 'lib/auth'; -import { useAuth, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { deleteTeamUser } from 'queries'; -import * as yup from 'yup'; - -export interface TeamUserRequestQuery { - id: string; - userId: string; -} - -const schema = { - DELETE: yup.object().shape({ - id: yup.string().uuid().required(), - userId: yup.string().uuid().required(), - }), -}; - -export default async (req: NextApiRequestQueryBody, res: NextApiResponse) => { - await useAuth(req, res); - await useValidate(schema, req, res); - - if (req.method === 'DELETE') { - const { id: teamId, userId } = req.query; - - if (!(await canDeleteTeamUser(req.auth, teamId, userId))) { - return unauthorized(res, 'You must be the owner of this team.'); - } - - await deleteTeamUser(teamId, userId); - - return ok(res); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/teams/[id]/users/index.ts b/src/pages/api/teams/[id]/users/index.ts index 4915ad7f..0440cd74 100644 --- a/src/pages/api/teams/[id]/users/index.ts +++ b/src/pages/api/teams/[id]/users/index.ts @@ -1,21 +1,33 @@ -import * as yup from 'yup'; -import { canViewTeam } from 'lib/auth'; +import { canAddUserToTeam, canViewTeam } from 'lib/auth'; import { useAuth, useValidate } from 'lib/middleware'; +import { pageInfo } from 'lib/schema'; import { NextApiRequestQueryBody, SearchFilter } from 'lib/types'; import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getUsersByTeamId } from 'queries'; -import { pageInfo } from 'lib/schema'; +import { badRequest, methodNotAllowed, ok, unauthorized } from 'next-basics'; +import { createTeamUser, getTeamUser, getUsersByTeamId } from 'queries'; +import * as yup from 'yup'; export interface TeamUserRequestQuery extends SearchFilter { id: string; } +export interface TeamUserRequestBody { + userId: string; + role: string; +} + const schema = { GET: yup.object().shape({ id: yup.string().uuid().required(), ...pageInfo, }), + POST: yup.object().shape({ + userId: yup.string().uuid().required(), + role: yup + .string() + .matches(/team-member|team-guest/i) + .required(), + }), }; export default async ( @@ -43,5 +55,24 @@ export default async ( return ok(res, users); } + // admin function only + if (req.method === 'POST') { + if (!(await canAddUserToTeam(req.auth))) { + return unauthorized(res); + } + + const { userId, role } = req.body; + + const teamUser = await getTeamUser(teamId, userId); + + if (teamUser) { + return badRequest(res, 'User is already a member of the Team.'); + } + + const users = await createTeamUser(userId, teamId, role); + + return ok(res, users); + } + return methodNotAllowed(res); }; diff --git a/src/pages/api/teams/[id]/websites/[websiteId].ts b/src/pages/api/teams/[id]/websites/[websiteId].ts deleted file mode 100644 index 531649e2..00000000 --- a/src/pages/api/teams/[id]/websites/[websiteId].ts +++ /dev/null @@ -1,41 +0,0 @@ -import { canDeleteTeamWebsite } from 'lib/auth'; -import { useAuth, useValidate } from 'lib/middleware'; -import { NextApiRequestQueryBody } from 'lib/types'; -import { NextApiResponse } from 'next'; -import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import * as yup from 'yup'; -import { deleteWebsite } from 'queries/admin/website'; - -export interface TeamWebsitesRequestQuery { - id: string; - websiteId: string; -} - -const schema = { - DELETE: yup.object().shape({ - id: yup.string().uuid().required(), - websiteId: yup.string().uuid().required(), - }), -}; - -export default async ( - req: NextApiRequestQueryBody, - res: NextApiResponse, -) => { - await useAuth(req, res); - await useValidate(schema, req, res); - - const { id: teamId, websiteId } = req.query; - - if (req.method === 'DELETE') { - if (!(await canDeleteTeamWebsite(req.auth, teamId, websiteId))) { - return unauthorized(res); - } - - await deleteWebsite(websiteId); - - return ok(res); - } - - return methodNotAllowed(res); -}; diff --git a/src/pages/api/teams/[id]/websites/index.ts b/src/pages/api/teams/[id]/websites/index.ts index 4fe11fb7..33bb4207 100644 --- a/src/pages/api/teams/[id]/websites/index.ts +++ b/src/pages/api/teams/[id]/websites/index.ts @@ -1,18 +1,21 @@ import * as yup from 'yup'; -import { canViewTeam } from 'lib/auth'; +import { canCreateTeamWebsite, canViewTeam } from 'lib/auth'; import { useAuth, useValidate } from 'lib/middleware'; import { NextApiRequestQueryBody, SearchFilter } from 'lib/types'; import { pageInfo } from 'lib/schema'; import { NextApiResponse } from 'next'; import { methodNotAllowed, ok, unauthorized } from 'next-basics'; -import { getWebsitesByTeamId } from 'queries'; +import { createWebsite, getWebsitesByTeamId } from 'queries'; +import { uuid } from 'lib/crypto'; export interface TeamWebsiteRequestQuery extends SearchFilter { id: string; } export interface TeamWebsiteRequestBody { - websiteIds?: string[]; + name: string; + domain: string; + shareId: string; } const schema = { @@ -21,8 +24,9 @@ const schema = { ...pageInfo, }), POST: yup.object().shape({ - id: yup.string().uuid().required(), - websiteIds: yup.array().of(yup.string()).min(1).required(), + name: yup.string().max(100).required(), + domain: yup.string().max(500).required(), + shareId: yup.string().max(50).nullable(), }), }; @@ -51,5 +55,17 @@ export default async ( return ok(res, websites); } + if (req.method === 'POST') { + if (!(await canCreateTeamWebsite(req.auth, teamId))) { + return unauthorized(res); + } + + const { name, domain, shareId } = req.body; + + const website = await createWebsite({ id: uuid(), name, domain, shareId }); + + return ok(res, website); + } + return methodNotAllowed(res); };