From 7f5bf6c6b3bfc0dd4c1e2d272ca5cdacaa73abfa Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Mon, 11 Apr 2022 19:42:50 -0700 Subject: [PATCH] feat: User flags (#3353) * feat: Add user flags concept, for tracking bits on a user * feat: Example flag usage for user invite resend abuse --- app/menus/UserMenu.tsx | 9 ++- server/commands/userInviter.ts | 4 ++ .../migrations/20220409222213-user-flags.js | 14 +++++ server/models/User.ts | 57 +++++++++++++++++++ server/routes/api/users.ts | 51 +++++++++++------ 5 files changed, 114 insertions(+), 21 deletions(-) create mode 100644 server/migrations/20220409222213-user-flags.js diff --git a/app/menus/UserMenu.tsx b/app/menus/UserMenu.tsx index 9a1d73d114..57daa23b6f 100644 --- a/app/menus/UserMenu.tsx +++ b/app/menus/UserMenu.tsx @@ -123,9 +123,12 @@ function UserMenu({ user }: Props) { await users.resendInvite(user); showToast(t(`Invite was resent to ${user.name}`), { type: "success" }); } catch (err) { - showToast(t(`An error occurred while sending the invite`), { - type: "error", - }); + showToast( + err.message ?? t(`An error occurred while sending the invite`), + { + type: "error", + } + ); } }, [users, user, t, showToast] diff --git a/server/commands/userInviter.ts b/server/commands/userInviter.ts index 007906f145..f81810bbbf 100644 --- a/server/commands/userInviter.ts +++ b/server/commands/userInviter.ts @@ -4,6 +4,7 @@ import { Role } from "@shared/types"; import InviteEmail from "@server/emails/templates/InviteEmail"; import Logger from "@server/logging/logger"; import { User, Event, Team } from "@server/models"; +import { UserFlag } from "@server/models/User"; type Invite = { name: string; @@ -61,6 +62,9 @@ export default async function userInviter({ service: null, isAdmin: invite.role === "admin", isViewer: invite.role === "viewer", + flags: { + [UserFlag.InviteSent]: 1, + }, }); users.push(newUser); await Event.create({ diff --git a/server/migrations/20220409222213-user-flags.js b/server/migrations/20220409222213-user-flags.js new file mode 100644 index 0000000000..d76f36c1d2 --- /dev/null +++ b/server/migrations/20220409222213-user-flags.js @@ -0,0 +1,14 @@ +'use strict'; + +module.exports = { + up: async (queryInterface, Sequelize) => { + return queryInterface.addColumn("users", "flags", { + type: Sequelize.JSONB, + allowNull: true, + }); + }, + + down: async (queryInterface, Sequelize) => { + return queryInterface.removeColumn("users", "flags"); + } +}; diff --git a/server/models/User.ts b/server/models/User.ts index 58e0a80a87..924de01031 100644 --- a/server/models/User.ts +++ b/server/models/User.ts @@ -40,6 +40,14 @@ import Encrypted, { } from "./decorators/Encrypted"; import Fix from "./decorators/Fix"; +/** + * Flags that are available for setting on the user. + */ +export enum UserFlag { + InviteSent = "inviteSent", + InviteReminderSent = "inviteReminderSent", +} + @Scopes(() => ({ withAuthentications: { include: [ @@ -101,6 +109,9 @@ class User extends ParanoidModel { @Column suspendedAt: Date | null; + @Column(DataType.JSONB) + flags: { [key in UserFlag]?: number } | null; + @Default(process.env.DEFAULT_LANGUAGE) @IsIn([languages]) @Column @@ -162,6 +173,52 @@ class User extends ParanoidModel { // instance methods + /** + * User flags are for storing information on a user record that is not visible + * to the user itself. + * + * @param flag The flag to set + * @param value Set the flag to true/false + * @returns The current user flags + */ + public setFlag = (flag: UserFlag, value = true) => { + if (!this.flags) { + this.flags = {}; + } + this.flags[flag] = value ? 1 : 0; + this.changed("flags", true); + + return this.flags; + }; + + /** + * Returns the content of the given user flag. + * + * @param flag The flag to retrieve + * @returns The flag value + */ + public getFlag = (flag: UserFlag) => { + return this.flags?.[flag] ?? 0; + }; + + /** + * User flags are for storing information on a user record that is not visible + * to the user itself. + * + * @param flag The flag to set + * @param value The amount to increment by, defaults to 1 + * @returns The current user flags + */ + public incrementFlag = (flag: UserFlag, value = 1) => { + if (!this.flags) { + this.flags = {}; + } + this.flags[flag] = (this.flags[flag] ?? 0) + value; + this.changed("flags", true); + + return this.flags; + }; + collectionIds = async (options = {}) => { const collectionStubs = await Collection.scope({ method: ["withMembership", this.id], diff --git a/server/routes/api/users.ts b/server/routes/api/users.ts index d692a76d14..dfa5be62c6 100644 --- a/server/routes/api/users.ts +++ b/server/routes/api/users.ts @@ -3,10 +3,13 @@ import { Op, WhereOptions } from "sequelize"; import userDestroyer from "@server/commands/userDestroyer"; import userInviter from "@server/commands/userInviter"; import userSuspender from "@server/commands/userSuspender"; +import { sequelize } from "@server/database/sequelize"; import InviteEmail from "@server/emails/templates/InviteEmail"; +import { ValidationError } from "@server/errors"; import logger from "@server/logging/logger"; import auth from "@server/middlewares/authentication"; import { Event, User, Team } from "@server/models"; +import { UserFlag } from "@server/models/User"; import { can, authorize } from "@server/policies"; import { presentUser, presentPolicies } from "@server/presenters"; import { @@ -312,27 +315,39 @@ router.post("users.resendInvite", auth(), async (ctx) => { const { id } = ctx.body; const actor = ctx.state.user; - const user = await User.findByPk(id); - authorize(actor, "resendInvite", user); + await sequelize.transaction(async (transaction) => { + const user = await User.findByPk(id, { + lock: transaction.LOCK.UPDATE, + transaction, + }); + authorize(actor, "resendInvite", user); - await InviteEmail.schedule({ - to: user.email, - name: user.name, - actorName: actor.name, - actorEmail: actor.email, - teamName: actor.team.name, - teamUrl: actor.team.url, + if (user.getFlag(UserFlag.InviteReminderSent) > 2) { + throw ValidationError("This invite has been sent too many times"); + } + + await InviteEmail.schedule({ + to: user.email, + name: user.name, + actorName: actor.name, + actorEmail: actor.email, + teamName: actor.team.name, + teamUrl: actor.team.url, + }); + + user.incrementFlag(UserFlag.InviteReminderSent); + await user.save({ transaction }); + + if (process.env.NODE_ENV === "development") { + logger.info( + "email", + `Sign in immediately: ${ + process.env.URL + }/auth/email.callback?token=${user.getEmailSigninToken()}` + ); + } }); - if (process.env.NODE_ENV === "development") { - logger.info( - "email", - `Sign in immediately: ${ - process.env.URL - }/auth/email.callback?token=${user.getEmailSigninToken()}` - ); - } - ctx.body = { success: true, };