feat: User flags (#3353)

* feat: Add user flags concept, for tracking bits on a user

* feat: Example flag usage for user invite resend abuse
This commit is contained in:
Tom Moor
2022-04-11 19:42:50 -07:00
committed by GitHub
parent 11c009bdbf
commit 7f5bf6c6b3
5 changed files with 114 additions and 21 deletions

View File

@@ -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]

View File

@@ -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({

View File

@@ -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");
}
};

View File

@@ -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],

View File

@@ -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,
};