From 7abb4f9ad608e2e965f2495826526f4b2c6619df Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 31 Aug 2023 18:06:18 -0400 Subject: [PATCH] Improve validation on `api/users` endpoints (#5752) --- app/components/UserDialogs.tsx | 5 +- app/models/User.ts | 11 +- app/scenes/Invite.tsx | 33 +- app/scenes/Settings/Members.tsx | 11 +- app/stores/UsersStore.ts | 28 +- server/commands/userDemoter.test.ts | 3 +- server/commands/userDemoter.ts | 2 +- server/commands/userInviter.test.ts | 13 +- server/commands/userInviter.ts | 8 +- server/models/User.ts | 10 +- server/routes/api/teams/schema.ts | 7 +- server/routes/api/users/schema.ts | 98 +++++- server/routes/api/users/users.test.ts | 21 -- server/routes/api/users/users.ts | 448 +++++++++++++------------- shared/types.ts | 6 +- 15 files changed, 395 insertions(+), 309 deletions(-) diff --git a/app/components/UserDialogs.tsx b/app/components/UserDialogs.tsx index afc283ade..7aeb3c1e5 100644 --- a/app/components/UserDialogs.tsx +++ b/app/components/UserDialogs.tsx @@ -1,5 +1,6 @@ import * as React from "react"; import { useTranslation } from "react-i18next"; +import { UserRole } from "@shared/types"; import User from "~/models/User"; import ConfirmationDialog from "~/components/ConfirmationDialog"; import Input from "~/components/Input"; @@ -15,7 +16,7 @@ export function UserChangeToViewerDialog({ user, onSubmit }: Props) { const { users } = useStores(); const handleSubmit = async () => { - await users.demote(user, "viewer"); + await users.demote(user, UserRole.Viewer); onSubmit(); }; @@ -41,7 +42,7 @@ export function UserChangeToMemberDialog({ user, onSubmit }: Props) { const { users } = useStores(); const handleSubmit = async () => { - await users.demote(user, "member"); + await users.demote(user, UserRole.Member); onSubmit(); }; diff --git a/app/models/User.ts b/app/models/User.ts index 1cbc06711..8b7a38181 100644 --- a/app/models/User.ts +++ b/app/models/User.ts @@ -7,8 +7,9 @@ import { NotificationEventType, UserPreference, UserPreferences, + UserRole, } from "@shared/types"; -import type { Role, NotificationSettings } from "@shared/types"; +import type { NotificationSettings } from "@shared/types"; import { client } from "~/utils/ApiClient"; import ParanoidModel from "./ParanoidModel"; import Field from "./decorators/Field"; @@ -74,13 +75,13 @@ class User extends ParanoidModel { } @computed - get role(): Role { + get role(): UserRole { if (this.isAdmin) { - return "admin"; + return UserRole.Admin; } else if (this.isViewer) { - return "viewer"; + return UserRole.Viewer; } else { - return "member"; + return UserRole.Member; } } diff --git a/app/scenes/Invite.tsx b/app/scenes/Invite.tsx index 317f79675..e56efa37a 100644 --- a/app/scenes/Invite.tsx +++ b/app/scenes/Invite.tsx @@ -5,7 +5,7 @@ import { useTranslation, Trans } from "react-i18next"; import { Link } from "react-router-dom"; import styled from "styled-components"; import { s } from "@shared/styles"; -import { Role } from "@shared/types"; +import { UserRole } from "@shared/types"; import { UserValidation } from "@shared/validations"; import Button from "~/components/Button"; import CopyToClipboard from "~/components/CopyToClipboard"; @@ -28,7 +28,7 @@ type Props = { type InviteRequest = { email: string; name: string; - role: Role; + role: UserRole; }; function Invite({ onSubmit }: Props) { @@ -38,17 +38,17 @@ function Invite({ onSubmit }: Props) { { email: "", name: "", - role: "member", + role: UserRole.Member, }, { email: "", name: "", - role: "member", + role: UserRole.Member, }, { email: "", name: "", - role: "member", + role: UserRole.Member, }, ]); const { users } = useStores(); @@ -65,7 +65,7 @@ function Invite({ onSubmit }: Props) { setIsSaving(true); try { - const data = await users.invite(invites); + const data = await users.invite(invites.filter((i) => i.email)); onSubmit(); if (data.sent.length > 0) { @@ -113,7 +113,7 @@ function Invite({ onSubmit }: Props) { newInvites.push({ email: "", name: "", - role: "member", + role: UserRole.Member, }); return newInvites; }); @@ -138,13 +138,16 @@ function Invite({ onSubmit }: Props) { }); }, [showToast, t]); - const handleRoleChange = React.useCallback((role: Role, index: number) => { - setInvites((prevInvites) => { - const newInvites = [...prevInvites]; - newInvites[index]["role"] = role; - return newInvites; - }); - }, []); + const handleRoleChange = React.useCallback( + (role: UserRole, index: number) => { + setInvites((prevInvites) => { + const newInvites = [...prevInvites]; + newInvites[index]["role"] = role; + return newInvites; + }); + }, + [] + ); return (
@@ -224,7 +227,7 @@ function Invite({ onSubmit }: Props) { flex /> handleRoleChange(role, index)} + onChange={(role: UserRole) => handleRoleChange(role, index)} value={invite.role} labelHidden={index !== 0} short diff --git a/app/scenes/Settings/Members.tsx b/app/scenes/Settings/Members.tsx index 242650b33..16a343aa7 100644 --- a/app/scenes/Settings/Members.tsx +++ b/app/scenes/Settings/Members.tsx @@ -39,8 +39,8 @@ function Members() { const [totalPages, setTotalPages] = React.useState(0); const [userIds, setUserIds] = React.useState([]); const can = usePolicy(team); - const query = params.get("query") || ""; - const filter = params.get("filter") || ""; + const query = params.get("query") || undefined; + const filter = params.get("filter") || undefined; const sort = params.get("sort") || "name"; const direction = (params.get("direction") || "asc").toUpperCase() as | "ASC" @@ -176,11 +176,14 @@ function Members() { - + { @action promote = async (user: User) => { try { - this.updateCounts("admin", user.role); + this.updateCounts(UserRole.Admin, user.role); await this.actionOnUser("promote", user); } catch { - this.updateCounts(user.role, "admin"); + this.updateCounts(user.role, UserRole.Admin); } }; @action - demote = async (user: User, to: Role) => { + demote = async (user: User, to: UserRole) => { try { this.updateCounts(to, user.role); await this.actionOnUser("demote", user, to); @@ -128,7 +128,7 @@ export default class UsersStore extends BaseStore { invites: { email: string; name: string; - role: Role; + role: UserRole; }[] ) => { const res = await client.post(`/users.invite`, { @@ -206,29 +206,29 @@ export default class UsersStore extends BaseStore { } @action - updateCounts = (to: Role, from: Role) => { - if (to === "admin") { + updateCounts = (to: UserRole, from: UserRole) => { + if (to === UserRole.Admin) { this.counts.admins += 1; - if (from === "viewer") { + if (from === UserRole.Viewer) { this.counts.viewers -= 1; } } - if (to === "viewer") { + if (to === UserRole.Viewer) { this.counts.viewers += 1; - if (from === "admin") { + if (from === UserRole.Admin) { this.counts.admins -= 1; } } - if (to === "member") { - if (from === "viewer") { + if (to === UserRole.Member) { + if (from === UserRole.Viewer) { this.counts.viewers -= 1; } - if (from === "admin") { + if (from === UserRole.Admin) { this.counts.admins -= 1; } } @@ -296,7 +296,7 @@ export default class UsersStore extends BaseStore { return queriedUsers(users, query); }; - actionOnUser = async (action: string, user: User, to?: Role) => { + actionOnUser = async (action: string, user: User, to?: UserRole) => { const res = await client.post(`/users.${action}`, { id: user.id, to, diff --git a/server/commands/userDemoter.test.ts b/server/commands/userDemoter.test.ts index e984828f9..ef0a0c5ff 100644 --- a/server/commands/userDemoter.test.ts +++ b/server/commands/userDemoter.test.ts @@ -1,6 +1,5 @@ -import { CollectionPermission } from "@shared/types"; +import { CollectionPermission, UserRole } from "@shared/types"; import { CollectionUser } from "@server/models"; -import { UserRole } from "@server/models/User"; import { buildUser, buildAdmin, buildCollection } from "@server/test/factories"; import { setupTestDatabase } from "@server/test/support"; import userDemoter from "./userDemoter"; diff --git a/server/commands/userDemoter.ts b/server/commands/userDemoter.ts index 296fd825f..8ecb7b6dc 100644 --- a/server/commands/userDemoter.ts +++ b/server/commands/userDemoter.ts @@ -1,6 +1,6 @@ +import { UserRole } from "@shared/types"; import { ValidationError } from "@server/errors"; import { Event, User } from "@server/models"; -import type { UserRole } from "@server/models/User"; import CleanupDemotedUserTask from "@server/queues/tasks/CleanupDemotedUserTask"; import { sequelize } from "@server/storage/database"; diff --git a/server/commands/userInviter.test.ts b/server/commands/userInviter.test.ts index b7b9073b3..96d501522 100644 --- a/server/commands/userInviter.test.ts +++ b/server/commands/userInviter.test.ts @@ -1,3 +1,4 @@ +import { UserRole } from "@shared/types"; import { buildUser } from "@server/test/factories"; import { setupTestDatabase } from "@server/test/support"; import userInviter from "./userInviter"; @@ -12,7 +13,7 @@ describe("userInviter", () => { const response = await userInviter({ invites: [ { - role: "member", + role: UserRole.Member, email: "test@example.com", name: "Test", }, @@ -28,7 +29,7 @@ describe("userInviter", () => { const response = await userInviter({ invites: [ { - role: "member", + role: UserRole.Member, email: " ", name: "Test", }, @@ -44,7 +45,7 @@ describe("userInviter", () => { const response = await userInviter({ invites: [ { - role: "member", + role: UserRole.Member, email: "notanemail", name: "Test", }, @@ -60,12 +61,12 @@ describe("userInviter", () => { const response = await userInviter({ invites: [ { - role: "member", + role: UserRole.Member, email: "the@same.com", name: "Test", }, { - role: "member", + role: UserRole.Member, email: "the@SAME.COM", name: "Test", }, @@ -81,7 +82,7 @@ describe("userInviter", () => { const response = await userInviter({ invites: [ { - role: "member", + role: UserRole.Member, email: user.email!, name: user.name, }, diff --git a/server/commands/userInviter.ts b/server/commands/userInviter.ts index d0ed86b1c..376409919 100644 --- a/server/commands/userInviter.ts +++ b/server/commands/userInviter.ts @@ -1,5 +1,5 @@ import uniqBy from "lodash/uniqBy"; -import { Role } from "@shared/types"; +import { UserRole } from "@shared/types"; import InviteEmail from "@server/emails/templates/InviteEmail"; import env from "@server/env"; import Logger from "@server/logging/Logger"; @@ -9,7 +9,7 @@ import { UserFlag } from "@server/models/User"; export type Invite = { name: string; email: string; - role: Role; + role: UserRole; }; export default async function userInviter({ @@ -59,8 +59,8 @@ export default async function userInviter({ name: invite.name, email: invite.email, service: null, - isAdmin: invite.role === "admin", - isViewer: invite.role === "viewer", + isAdmin: invite.role === UserRole.Admin, + isViewer: invite.role === UserRole.Viewer, invitedById: user.id, flags: { [UserFlag.InviteSent]: 1, diff --git a/server/models/User.ts b/server/models/User.ts index 719cd03c3..49feeaa22 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -31,6 +31,7 @@ import { UserPreferences, NotificationEventType, NotificationEventDefaults, + UserRole, } from "@shared/types"; import { stringToColor } from "@shared/utils/color"; import env from "@server/env"; @@ -65,11 +66,6 @@ export enum UserFlag { MobileWeb = "mobileWeb", } -export enum UserRole { - Member = "member", - Viewer = "viewer", -} - @Scopes(() => ({ withAuthentications: { include: [ @@ -532,7 +528,7 @@ class User extends ParanoidModel { }); if (res.count >= 1) { - if (to === "member") { + if (to === UserRole.Member) { await this.update( { isAdmin: false, @@ -540,7 +536,7 @@ class User extends ParanoidModel { }, options ); - } else if (to === "viewer") { + } else if (to === UserRole.Viewer) { await this.update( { isAdmin: false, diff --git a/server/routes/api/teams/schema.ts b/server/routes/api/teams/schema.ts index 311885c98..e32392d2e 100644 --- a/server/routes/api/teams/schema.ts +++ b/server/routes/api/teams/schema.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import { UserRole } from "@server/models/User"; +import { UserRole } from "@shared/types"; import BaseSchema from "@server/routes/api/BaseSchema"; export const TeamsUpdateSchema = BaseSchema.extend({ @@ -21,10 +21,7 @@ export const TeamsUpdateSchema = BaseSchema.extend({ /** The default landing collection for the team */ defaultCollectionId: z.string().uuid().nullish(), /** The default user role */ - defaultUserRole: z - .string() - .refine((val) => Object.values(UserRole).includes(val as UserRole)) - .optional(), + defaultUserRole: z.nativeEnum(UserRole).optional(), /** Whether new users must be invited to join the team */ inviteRequired: z.boolean().optional(), /** Domains allowed to sign-in with SSO */ diff --git a/server/routes/api/users/schema.ts b/server/routes/api/users/schema.ts index acbe44bff..b4db432e4 100644 --- a/server/routes/api/users/schema.ts +++ b/server/routes/api/users/schema.ts @@ -1,7 +1,48 @@ import { z } from "zod"; -import { NotificationEventType, UserPreference } from "@shared/types"; +import { NotificationEventType, UserPreference, UserRole } from "@shared/types"; +import User from "@server/models/User"; import BaseSchema from "../BaseSchema"; +const BaseIdSchema = z.object({ + id: z.string().uuid(), +}); + +export const UsersListSchema = z.object({ + body: z.object({ + /** Groups sorting direction */ + direction: z + .string() + .optional() + .transform((val) => (val !== "ASC" ? "DESC" : val)), + + /** Groups sorting column */ + sort: z + .string() + .refine((val) => Object.keys(User.getAttributes()).includes(val), { + message: "Invalid sort parameter", + }) + .default("createdAt"), + + ids: z.array(z.string().uuid()).optional(), + + query: z.string().optional(), + + filter: z + .enum([ + "invited", + "viewers", + "admins", + "members", + "active", + "all", + "suspended", + ]) + .optional(), + }), +}); + +export type UsersListReq = z.infer; + export const UsersNotificationsSubscribeSchema = z.object({ body: z.object({ eventType: z.nativeEnum(NotificationEventType), @@ -42,3 +83,58 @@ export const UsersDeleteSchema = BaseSchema.extend({ }); export type UsersDeleteSchemaReq = z.infer; + +export const UsersInfoSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid().optional(), + }), +}); + +export type UsersInfoReq = z.infer; + +export const UsersActivateSchema = BaseSchema.extend({ + body: BaseIdSchema, +}); + +export type UsersActivateReq = z.infer; + +export const UsersPromoteSchema = BaseSchema.extend({ + body: BaseIdSchema, +}); + +export type UsersPromoteReq = z.infer; + +export const UsersDemoteSchema = BaseSchema.extend({ + body: z.object({ + id: z.string().uuid(), + to: z.nativeEnum(UserRole).default(UserRole.Member), + }), +}); + +export type UsersDemoteReq = z.infer; + +export const UsersSuspendSchema = BaseSchema.extend({ + body: BaseIdSchema, +}); + +export type UsersSuspendReq = z.infer; + +export const UsersResendInviteSchema = BaseSchema.extend({ + body: BaseIdSchema, +}); + +export type UsersResendInviteReq = z.infer; + +export const UsersInviteSchema = z.object({ + body: z.object({ + invites: z.array( + z.object({ + email: z.string().email(), + name: z.string(), + role: z.nativeEnum(UserRole), + }) + ), + }), +}); + +export type UsersInviteReq = z.infer; diff --git a/server/routes/api/users/users.test.ts b/server/routes/api/users/users.test.ts index 221012743..90035d81d 100644 --- a/server/routes/api/users/users.test.ts +++ b/server/routes/api/users/users.test.ts @@ -307,27 +307,6 @@ describe("#users.invite", () => { expect(body.data.users[0].isAdmin).toBeFalsy(); }); - it("should invite user as a member if role is any arbitary value", async () => { - const admin = await buildAdmin(); - const res = await server.post("/api/users.invite", { - body: { - token: admin.getJwtToken(), - invites: [ - { - email: "test@example.com", - name: "Test", - role: "arbitary", - }, - ], - }, - }); - const body = await res.json(); - expect(res.status).toEqual(200); - expect(body.data.sent.length).toEqual(1); - expect(body.data.users[0].isViewer).toBeFalsy(); - expect(body.data.users[0].isAdmin).toBeFalsy(); - }); - it("should require authentication", async () => { const res = await server.post("/api/users.invite"); expect(res.status).toEqual(401); diff --git a/server/routes/api/users/users.ts b/server/routes/api/users/users.ts index 36afe0483..89a9650da 100644 --- a/server/routes/api/users/users.ts +++ b/server/routes/api/users/users.ts @@ -17,155 +17,141 @@ import { rateLimiter } from "@server/middlewares/rateLimiter"; import { transaction } from "@server/middlewares/transaction"; import validate from "@server/middlewares/validate"; import { Event, User, Team } from "@server/models"; -import { UserFlag, UserRole } from "@server/models/User"; +import { UserFlag } from "@server/models/User"; import { can, authorize } from "@server/policies"; import { presentUser, presentPolicies } from "@server/presenters"; import { APIContext } from "@server/types"; import { RateLimiterStrategy } from "@server/utils/RateLimiter"; import { safeEqual } from "@server/utils/crypto"; -import { - assertIn, - assertSort, - assertPresent, - assertArray, -} from "@server/validation"; import pagination from "../middlewares/pagination"; import * as T from "./schema"; const router = new Router(); const emailEnabled = !!(env.SMTP_HOST || env.ENVIRONMENT === "development"); -router.post("users.list", auth(), pagination(), async (ctx: APIContext) => { - let { direction } = ctx.request.body; - const { sort = "createdAt", query, filter, ids } = ctx.request.body; - if (direction !== "ASC") { - direction = "DESC"; - } - assertSort(sort, User); +router.post( + "users.list", + auth(), + pagination(), + validate(T.UsersListSchema), + async (ctx: APIContext) => { + const { sort, direction, query, filter, ids } = ctx.input.body; - if (filter) { - assertIn( - filter, - ["invited", "viewers", "admins", "members", "active", "all", "suspended"], - "Invalid filter" - ); - } - - const actor = ctx.state.auth.user; - let where: WhereOptions = { - teamId: actor.teamId, - }; - - // Filter out suspended users if we're not an admin - if (!actor.isAdmin) { - where = { - ...where, - suspendedAt: { - [Op.eq]: null, - }, + const actor = ctx.state.auth.user; + let where: WhereOptions = { + teamId: actor.teamId, }; - } - switch (filter) { - case "invited": { - where = { ...where, lastActiveAt: null }; - break; + // Filter out suspended users if we're not an admin + if (!actor.isAdmin) { + where = { + ...where, + suspendedAt: { + [Op.eq]: null, + }, + }; } - case "viewers": { - where = { ...where, isViewer: true }; - break; - } + switch (filter) { + case "invited": { + where = { ...where, lastActiveAt: null }; + break; + } - case "admins": { - where = { ...where, isAdmin: true }; - break; - } + case "viewers": { + where = { ...where, isViewer: true }; + break; + } - case "members": { - where = { ...where, isAdmin: false, isViewer: false }; - break; - } + case "admins": { + where = { ...where, isAdmin: true }; + break; + } - case "suspended": { - if (actor.isAdmin) { + case "members": { + where = { ...where, isAdmin: false, isViewer: false }; + break; + } + + case "suspended": { + if (actor.isAdmin) { + where = { + ...where, + suspendedAt: { + [Op.ne]: null, + }, + }; + } + break; + } + + case "active": { + where = { + ...where, + lastActiveAt: { + [Op.ne]: null, + }, + suspendedAt: { + [Op.is]: null, + }, + }; + break; + } + + case "all": { + break; + } + + default: { where = { ...where, suspendedAt: { - [Op.ne]: null, + [Op.is]: null, }, }; + break; } - break; } - case "active": { + if (query) { where = { ...where, - lastActiveAt: { - [Op.ne]: null, - }, - suspendedAt: { - [Op.is]: null, + name: { + [Op.iLike]: `%${query}%`, }, }; - break; } - case "all": { - break; - } - - default: { + if (ids) { where = { ...where, - suspendedAt: { - [Op.is]: null, - }, + id: ids, }; - break; } - } - if (query) { - where = { - ...where, - name: { - [Op.iLike]: `%${query}%`, - }, + const [users, total] = await Promise.all([ + User.findAll({ + where, + order: [[sort, direction]], + offset: ctx.state.pagination.offset, + limit: ctx.state.pagination.limit, + }), + User.count({ + where, + }), + ]); + + ctx.body = { + pagination: { ...ctx.state.pagination, total }, + data: users.map((user) => + presentUser(user, { + includeDetails: can(actor, "readDetails", user), + }) + ), + policies: presentPolicies(actor, users), }; } - - if (ids) { - assertArray(ids, "ids must be an array of UUIDs"); - where = { - ...where, - id: ids, - }; - } - - const [users, total] = await Promise.all([ - User.findAll({ - where, - order: [[sort, direction]], - offset: ctx.state.pagination.offset, - limit: ctx.state.pagination.limit, - }), - User.count({ - where, - }), - ]); - - ctx.body = { - pagination: { ...ctx.state.pagination, total }, - data: users.map((user) => - presentUser(user, { - includeDetails: can(actor, "readDetails", user), - }) - ), - policies: presentPolicies(actor, users), - }; -}); +); router.post("users.count", auth(), async (ctx: APIContext) => { const { user } = ctx.state.auth; @@ -178,20 +164,25 @@ router.post("users.count", auth(), async (ctx: APIContext) => { }; }); -router.post("users.info", auth(), async (ctx: APIContext) => { - const { id } = ctx.request.body; - const actor = ctx.state.auth.user; - const user = id ? await User.findByPk(id) : actor; - authorize(actor, "read", user); - const includeDetails = can(actor, "readDetails", user); +router.post( + "users.info", + auth(), + validate(T.UsersInfoSchema), + async (ctx: APIContext) => { + const { id } = ctx.input.body; + const actor = ctx.state.auth.user; + const user = id ? await User.findByPk(id) : actor; + authorize(actor, "read", user); + const includeDetails = can(actor, "readDetails", user); - ctx.body = { - data: presentUser(user, { - includeDetails, - }), - policies: presentPolicies(actor, [user]), - }; -}); + ctx.body = { + data: presentUser(user, { + includeDetails, + }), + policies: presentPolicies(actor, [user]), + }; + } +); router.post( "users.update", @@ -245,119 +236,133 @@ router.post( ); // Admin specific -router.post("users.promote", auth(), async (ctx: APIContext) => { - const userId = ctx.request.body.id; - const actor = ctx.state.auth.user; - const teamId = actor.teamId; - assertPresent(userId, "id is required"); - const user = await User.findByPk(userId); - authorize(actor, "promote", user); +router.post( + "users.promote", + auth(), + validate(T.UsersPromoteSchema), + async (ctx: APIContext) => { + const userId = ctx.input.body.id; + const actor = ctx.state.auth.user; + const teamId = actor.teamId; + const user = await User.findByPk(userId); + authorize(actor, "promote", user); - await user.promote(); - await Event.create({ - name: "users.promote", - actorId: actor.id, - userId, - teamId, - data: { - name: user.name, - }, - ip: ctx.request.ip, - }); - const includeDetails = can(actor, "readDetails", user); + await user.promote(); + await Event.create({ + name: "users.promote", + actorId: actor.id, + userId, + teamId, + data: { + name: user.name, + }, + ip: ctx.request.ip, + }); + const includeDetails = can(actor, "readDetails", user); - ctx.body = { - data: presentUser(user, { - includeDetails, - }), - policies: presentPolicies(actor, [user]), - }; -}); + ctx.body = { + data: presentUser(user, { + includeDetails, + }), + policies: presentPolicies(actor, [user]), + }; + } +); -router.post("users.demote", auth(), async (ctx: APIContext) => { - const userId = ctx.request.body.id; - let { to } = ctx.request.body; - const actor = ctx.state.auth.user; - assertPresent(userId, "id is required"); +router.post( + "users.demote", + auth(), + validate(T.UsersDemoteSchema), + async (ctx: APIContext) => { + const userId = ctx.input.body.id; + const to = ctx.input.body.to; + const actor = ctx.state.auth.user; - to = (to === "viewer" ? "viewer" : "member") as UserRole; + const user = await User.findByPk(userId, { + rejectOnEmpty: true, + }); + authorize(actor, "demote", user); - const user = await User.findByPk(userId, { - rejectOnEmpty: true, - }); - authorize(actor, "demote", user); + await userDemoter({ + to, + user, + actorId: actor.id, + ip: ctx.request.ip, + }); + const includeDetails = can(actor, "readDetails", user); - await userDemoter({ - to, - user, - actorId: actor.id, - ip: ctx.request.ip, - }); - const includeDetails = can(actor, "readDetails", user); + ctx.body = { + data: presentUser(user, { + includeDetails, + }), + policies: presentPolicies(actor, [user]), + }; + } +); - ctx.body = { - data: presentUser(user, { - includeDetails, - }), - policies: presentPolicies(actor, [user]), - }; -}); +router.post( + "users.suspend", + auth(), + validate(T.UsersSuspendSchema), + async (ctx: APIContext) => { + const userId = ctx.input.body.id; + const actor = ctx.state.auth.user; + const user = await User.findByPk(userId, { + rejectOnEmpty: true, + }); + authorize(actor, "suspend", user); -router.post("users.suspend", auth(), async (ctx: APIContext) => { - const userId = ctx.request.body.id; - const actor = ctx.state.auth.user; - assertPresent(userId, "id is required"); - const user = await User.findByPk(userId, { - rejectOnEmpty: true, - }); - authorize(actor, "suspend", user); + await userSuspender({ + user, + actorId: actor.id, + ip: ctx.request.ip, + }); + const includeDetails = can(actor, "readDetails", user); - await userSuspender({ - user, - actorId: actor.id, - ip: ctx.request.ip, - }); - const includeDetails = can(actor, "readDetails", user); + ctx.body = { + data: presentUser(user, { + includeDetails, + }), + policies: presentPolicies(actor, [user]), + }; + } +); - ctx.body = { - data: presentUser(user, { - includeDetails, - }), - policies: presentPolicies(actor, [user]), - }; -}); +router.post( + "users.activate", + auth(), + validate(T.UsersActivateSchema), + async (ctx: APIContext) => { + const userId = ctx.input.body.id; + const actor = ctx.state.auth.user; + const user = await User.findByPk(userId, { + rejectOnEmpty: true, + }); + authorize(actor, "activate", user); -router.post("users.activate", auth(), async (ctx: APIContext) => { - const userId = ctx.request.body.id; - const actor = ctx.state.auth.user; - assertPresent(userId, "id is required"); - const user = await User.findByPk(userId, { - rejectOnEmpty: true, - }); - authorize(actor, "activate", user); + await userUnsuspender({ + user, + actorId: actor.id, + ip: ctx.request.ip, + }); + const includeDetails = can(actor, "readDetails", user); - await userUnsuspender({ - user, - actorId: actor.id, - ip: ctx.request.ip, - }); - const includeDetails = can(actor, "readDetails", user); - - ctx.body = { - data: presentUser(user, { - includeDetails, - }), - policies: presentPolicies(actor, [user]), - }; -}); + ctx.body = { + data: presentUser(user, { + includeDetails, + }), + policies: presentPolicies(actor, [user]), + }; + } +); router.post( "users.invite", rateLimiter(RateLimiterStrategy.TenPerHour), auth(), - async (ctx: APIContext) => { - const { invites } = ctx.request.body; - assertArray(invites, "invites must be an array"); + validate(T.UsersInviteSchema), + async (ctx: APIContext) => { + const { invites } = ctx.input.body; const { user } = ctx.state.auth; const team = await Team.findByPk(user.teamId); authorize(user, "inviteUser", team); @@ -380,9 +385,10 @@ router.post( router.post( "users.resendInvite", auth(), + validate(T.UsersResendInviteSchema), transaction(), - async (ctx: APIContext) => { - const { id } = ctx.request.body; + async (ctx: APIContext) => { + const { id } = ctx.input.body; const { auth, transaction } = ctx.state; const actor = auth.user; @@ -452,7 +458,7 @@ router.post( transaction(), async (ctx: APIContext) => { const { transaction } = ctx.state; - const { id, code } = ctx.request.body; + const { id, code } = ctx.input.body; const actor = ctx.state.auth.user; let user: User; diff --git a/shared/types.ts b/shared/types.ts index 6d9475717..7061a26d6 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1,4 +1,8 @@ -export type Role = "admin" | "viewer" | "member"; +export enum UserRole { + Admin = "admin", + Member = "member", + Viewer = "viewer", +} export type DateFilter = "day" | "week" | "month" | "year";