mirror of
https://github.com/outline/outline.git
synced 2025-12-30 23:40:46 -06:00
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:
@@ -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]
|
||||
|
||||
@@ -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({
|
||||
|
||||
14
server/migrations/20220409222213-user-flags.js
Normal file
14
server/migrations/20220409222213-user-flags.js
Normal 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");
|
||||
}
|
||||
};
|
||||
@@ -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],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user