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); await users.resendInvite(user);
showToast(t(`Invite was resent to ${user.name}`), { type: "success" }); showToast(t(`Invite was resent to ${user.name}`), { type: "success" });
} catch (err) { } catch (err) {
showToast(t(`An error occurred while sending the invite`), { showToast(
type: "error", err.message ?? t(`An error occurred while sending the invite`),
}); {
type: "error",
}
);
} }
}, },
[users, user, t, showToast] [users, user, t, showToast]

View File

@@ -4,6 +4,7 @@ import { Role } from "@shared/types";
import InviteEmail from "@server/emails/templates/InviteEmail"; import InviteEmail from "@server/emails/templates/InviteEmail";
import Logger from "@server/logging/logger"; import Logger from "@server/logging/logger";
import { User, Event, Team } from "@server/models"; import { User, Event, Team } from "@server/models";
import { UserFlag } from "@server/models/User";
type Invite = { type Invite = {
name: string; name: string;
@@ -61,6 +62,9 @@ export default async function userInviter({
service: null, service: null,
isAdmin: invite.role === "admin", isAdmin: invite.role === "admin",
isViewer: invite.role === "viewer", isViewer: invite.role === "viewer",
flags: {
[UserFlag.InviteSent]: 1,
},
}); });
users.push(newUser); users.push(newUser);
await Event.create({ 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"; } from "./decorators/Encrypted";
import Fix from "./decorators/Fix"; import Fix from "./decorators/Fix";
/**
* Flags that are available for setting on the user.
*/
export enum UserFlag {
InviteSent = "inviteSent",
InviteReminderSent = "inviteReminderSent",
}
@Scopes(() => ({ @Scopes(() => ({
withAuthentications: { withAuthentications: {
include: [ include: [
@@ -101,6 +109,9 @@ class User extends ParanoidModel {
@Column @Column
suspendedAt: Date | null; suspendedAt: Date | null;
@Column(DataType.JSONB)
flags: { [key in UserFlag]?: number } | null;
@Default(process.env.DEFAULT_LANGUAGE) @Default(process.env.DEFAULT_LANGUAGE)
@IsIn([languages]) @IsIn([languages])
@Column @Column
@@ -162,6 +173,52 @@ class User extends ParanoidModel {
// instance methods // 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 = {}) => { collectionIds = async (options = {}) => {
const collectionStubs = await Collection.scope({ const collectionStubs = await Collection.scope({
method: ["withMembership", this.id], method: ["withMembership", this.id],

View File

@@ -3,10 +3,13 @@ import { Op, WhereOptions } from "sequelize";
import userDestroyer from "@server/commands/userDestroyer"; import userDestroyer from "@server/commands/userDestroyer";
import userInviter from "@server/commands/userInviter"; import userInviter from "@server/commands/userInviter";
import userSuspender from "@server/commands/userSuspender"; import userSuspender from "@server/commands/userSuspender";
import { sequelize } from "@server/database/sequelize";
import InviteEmail from "@server/emails/templates/InviteEmail"; import InviteEmail from "@server/emails/templates/InviteEmail";
import { ValidationError } from "@server/errors";
import logger from "@server/logging/logger"; import logger from "@server/logging/logger";
import auth from "@server/middlewares/authentication"; import auth from "@server/middlewares/authentication";
import { Event, User, Team } from "@server/models"; import { Event, User, Team } from "@server/models";
import { UserFlag } from "@server/models/User";
import { can, authorize } from "@server/policies"; import { can, authorize } from "@server/policies";
import { presentUser, presentPolicies } from "@server/presenters"; import { presentUser, presentPolicies } from "@server/presenters";
import { import {
@@ -312,27 +315,39 @@ router.post("users.resendInvite", auth(), async (ctx) => {
const { id } = ctx.body; const { id } = ctx.body;
const actor = ctx.state.user; const actor = ctx.state.user;
const user = await User.findByPk(id); await sequelize.transaction(async (transaction) => {
authorize(actor, "resendInvite", user); const user = await User.findByPk(id, {
lock: transaction.LOCK.UPDATE,
transaction,
});
authorize(actor, "resendInvite", user);
await InviteEmail.schedule({ if (user.getFlag(UserFlag.InviteReminderSent) > 2) {
to: user.email, throw ValidationError("This invite has been sent too many times");
name: user.name, }
actorName: actor.name,
actorEmail: actor.email, await InviteEmail.schedule({
teamName: actor.team.name, to: user.email,
teamUrl: actor.team.url, 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 = { ctx.body = {
success: true, success: true,
}; };