* fix: Logic error in toast fix: Remove useless component * fix: Logout not clearing all stores * Add icons to notification settings * Add eslint rule to enforce spaced comment * Add eslint rule for arrow-body-style * Add eslint rule to enforce self-closing components * Add menu to api key settings Fix: Deleting webhook subscription does not remove from UI Split webhook subscriptions into active and inactive Styling updates
673 lines
16 KiB
TypeScript
673 lines
16 KiB
TypeScript
import crypto from "crypto";
|
|
import { addMinutes, subMinutes } from "date-fns";
|
|
import JWT from "jsonwebtoken";
|
|
import { Context } from "koa";
|
|
import { Transaction, QueryTypes, SaveOptions, Op } from "sequelize";
|
|
import {
|
|
Table,
|
|
Column,
|
|
IsIP,
|
|
IsEmail,
|
|
Default,
|
|
IsIn,
|
|
BeforeDestroy,
|
|
BeforeCreate,
|
|
BelongsTo,
|
|
ForeignKey,
|
|
DataType,
|
|
HasMany,
|
|
Scopes,
|
|
IsDate,
|
|
IsUrl,
|
|
AllowNull,
|
|
AfterUpdate,
|
|
} from "sequelize-typescript";
|
|
import { languages } from "@shared/i18n";
|
|
import type { NotificationSettings } from "@shared/types";
|
|
import {
|
|
CollectionPermission,
|
|
UserPreference,
|
|
UserPreferences,
|
|
NotificationEventType,
|
|
NotificationEventDefaults,
|
|
} from "@shared/types";
|
|
import { stringToColor } from "@shared/utils/color";
|
|
import env from "@server/env";
|
|
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
|
|
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
|
import { ValidationError } from "../errors";
|
|
import ApiKey from "./ApiKey";
|
|
import Attachment from "./Attachment";
|
|
import AuthenticationProvider from "./AuthenticationProvider";
|
|
import Collection from "./Collection";
|
|
import CollectionUser from "./CollectionUser";
|
|
import Star from "./Star";
|
|
import Team from "./Team";
|
|
import UserAuthentication from "./UserAuthentication";
|
|
import ParanoidModel from "./base/ParanoidModel";
|
|
import Encrypted, {
|
|
setEncryptedColumn,
|
|
getEncryptedColumn,
|
|
} from "./decorators/Encrypted";
|
|
import Fix from "./decorators/Fix";
|
|
import Length from "./validators/Length";
|
|
import NotContainsUrl from "./validators/NotContainsUrl";
|
|
|
|
/**
|
|
* Flags that are available for setting on the user.
|
|
*/
|
|
export enum UserFlag {
|
|
InviteSent = "inviteSent",
|
|
InviteReminderSent = "inviteReminderSent",
|
|
Desktop = "desktop",
|
|
DesktopWeb = "desktopWeb",
|
|
MobileWeb = "mobileWeb",
|
|
}
|
|
|
|
export enum UserRole {
|
|
Member = "member",
|
|
Viewer = "viewer",
|
|
}
|
|
|
|
@Scopes(() => ({
|
|
withAuthentications: {
|
|
include: [
|
|
{
|
|
separate: true,
|
|
model: UserAuthentication,
|
|
as: "authentications",
|
|
include: [
|
|
{
|
|
model: AuthenticationProvider,
|
|
as: "authenticationProvider",
|
|
where: {
|
|
enabled: true,
|
|
},
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
withTeam: {
|
|
include: [
|
|
{
|
|
model: Team,
|
|
as: "team",
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
withInvitedBy: {
|
|
include: [
|
|
{
|
|
model: User,
|
|
as: "invitedBy",
|
|
required: true,
|
|
},
|
|
],
|
|
},
|
|
invited: {
|
|
where: {
|
|
lastActiveAt: {
|
|
[Op.is]: null,
|
|
},
|
|
},
|
|
},
|
|
}))
|
|
@Table({ tableName: "users", modelName: "user" })
|
|
@Fix
|
|
class User extends ParanoidModel {
|
|
@IsEmail
|
|
@Length({ max: 255, msg: "User email must be 255 characters or less" })
|
|
@Column
|
|
email: string | null;
|
|
|
|
@NotContainsUrl
|
|
@Length({ max: 255, msg: "User name must be 255 characters or less" })
|
|
@Column
|
|
name: string;
|
|
|
|
@Default(false)
|
|
@Column
|
|
isAdmin: boolean;
|
|
|
|
@Default(false)
|
|
@Column
|
|
isViewer: boolean;
|
|
|
|
@Column(DataType.BLOB)
|
|
@Encrypted
|
|
get jwtSecret() {
|
|
return getEncryptedColumn(this, "jwtSecret");
|
|
}
|
|
|
|
set jwtSecret(value: string) {
|
|
setEncryptedColumn(this, "jwtSecret", value);
|
|
}
|
|
|
|
@IsDate
|
|
@Column
|
|
lastActiveAt: Date | null;
|
|
|
|
@IsIP
|
|
@Column
|
|
lastActiveIp: string | null;
|
|
|
|
@IsDate
|
|
@Column
|
|
lastSignedInAt: Date | null;
|
|
|
|
@IsIP
|
|
@Column
|
|
lastSignedInIp: string | null;
|
|
|
|
@IsDate
|
|
@Column
|
|
lastSigninEmailSentAt: Date | null;
|
|
|
|
@IsDate
|
|
@Column
|
|
suspendedAt: Date | null;
|
|
|
|
@Column(DataType.JSONB)
|
|
flags: { [key in UserFlag]?: number } | null;
|
|
|
|
@AllowNull
|
|
@Column(DataType.JSONB)
|
|
preferences: UserPreferences | null;
|
|
|
|
@Column(DataType.JSONB)
|
|
notificationSettings: NotificationSettings;
|
|
|
|
@Default(env.DEFAULT_LANGUAGE)
|
|
@IsIn([languages])
|
|
@Column
|
|
language: string;
|
|
|
|
@AllowNull
|
|
@IsUrl
|
|
@Length({ max: 4096, msg: "avatarUrl must be less than 4096 characters" })
|
|
@Column(DataType.STRING)
|
|
get avatarUrl() {
|
|
const original = this.getDataValue("avatarUrl");
|
|
|
|
if (original && !original.startsWith("https://tiley.herokuapp.com")) {
|
|
return original;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
set avatarUrl(value: string | null) {
|
|
this.setDataValue("avatarUrl", value);
|
|
}
|
|
|
|
// associations
|
|
@BelongsTo(() => User, "suspendedById")
|
|
suspendedBy: User | null;
|
|
|
|
@ForeignKey(() => User)
|
|
@Column(DataType.UUID)
|
|
suspendedById: string | null;
|
|
|
|
@BelongsTo(() => User, "invitedById")
|
|
invitedBy: User | null;
|
|
|
|
@ForeignKey(() => User)
|
|
@Column(DataType.UUID)
|
|
invitedById: string | null;
|
|
|
|
@BelongsTo(() => Team)
|
|
team: Team;
|
|
|
|
@ForeignKey(() => Team)
|
|
@Column(DataType.UUID)
|
|
teamId: string;
|
|
|
|
@HasMany(() => UserAuthentication)
|
|
authentications: UserAuthentication[];
|
|
|
|
// getters
|
|
|
|
get isSuspended(): boolean {
|
|
return !!this.suspendedAt;
|
|
}
|
|
|
|
get isInvited() {
|
|
return !this.lastActiveAt;
|
|
}
|
|
|
|
get color() {
|
|
return stringToColor(this.id);
|
|
}
|
|
|
|
get defaultCollectionPermission(): CollectionPermission {
|
|
return this.isViewer
|
|
? CollectionPermission.Read
|
|
: CollectionPermission.ReadWrite;
|
|
}
|
|
|
|
/**
|
|
* Returns a code that can be used to delete this user account. The code will
|
|
* be rotated when the user signs out.
|
|
*
|
|
* @returns The deletion code.
|
|
*/
|
|
get deleteConfirmationCode() {
|
|
return crypto
|
|
.createHash("md5")
|
|
.update(this.jwtSecret)
|
|
.digest("hex")
|
|
.replace(/[l1IoO0]/gi, "")
|
|
.slice(0, 8)
|
|
.toUpperCase();
|
|
}
|
|
|
|
// instance methods
|
|
|
|
/**
|
|
* Sets a preference for the users notification settings.
|
|
*
|
|
* @param type The type of notification event
|
|
* @param value Set the preference to true/false
|
|
*/
|
|
public setNotificationEventType = (
|
|
type: NotificationEventType,
|
|
value = true
|
|
) => {
|
|
this.notificationSettings[type] = value;
|
|
this.changed("notificationSettings", true);
|
|
};
|
|
|
|
/**
|
|
* Returns the current preference for the given notification event type taking
|
|
* into account the default system value.
|
|
*
|
|
* @param type The type of notification event
|
|
* @returns The current preference
|
|
*/
|
|
public subscribedToEventType = (type: NotificationEventType) =>
|
|
this.notificationSettings[type] ?? NotificationEventDefaults[type] ?? false;
|
|
|
|
/**
|
|
* 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 = {};
|
|
}
|
|
const binary = value ? 1 : 0;
|
|
if (this.flags[flag] !== binary) {
|
|
this.flags[flag] = binary;
|
|
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) => 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;
|
|
};
|
|
|
|
/**
|
|
* Preferences set by the user that decide application behavior and ui.
|
|
*
|
|
* @param preference The user preference to set
|
|
* @param value Sets the preference value
|
|
* @returns The current user preferences
|
|
*/
|
|
public setPreference = (preference: UserPreference, value: boolean) => {
|
|
if (!this.preferences) {
|
|
this.preferences = {};
|
|
}
|
|
this.preferences[preference] = value;
|
|
this.changed("preferences", true);
|
|
|
|
return this.preferences;
|
|
};
|
|
|
|
/**
|
|
* Returns the passed preference value
|
|
*
|
|
* @param preference The user preference to retrieve
|
|
* @param fallback An optional fallback value, defaults to false.
|
|
* @returns The preference value if set, else undefined
|
|
*/
|
|
public getPreference = (preference: UserPreference, fallback = false) =>
|
|
this.preferences?.[preference] ?? fallback;
|
|
|
|
collectionIds = async (options = {}) => {
|
|
const collectionStubs = await Collection.scope({
|
|
method: ["withMembership", this.id],
|
|
}).findAll({
|
|
attributes: ["id", "permission"],
|
|
where: {
|
|
teamId: this.teamId,
|
|
},
|
|
paranoid: true,
|
|
...options,
|
|
});
|
|
|
|
return collectionStubs
|
|
.filter(
|
|
(c) =>
|
|
c.permission === CollectionPermission.Read ||
|
|
c.permission === CollectionPermission.ReadWrite ||
|
|
c.memberships.length > 0 ||
|
|
c.collectionGroupMemberships.length > 0
|
|
)
|
|
.map((c) => c.id);
|
|
};
|
|
|
|
updateActiveAt = async (ctx: Context, force = false) => {
|
|
const { ip } = ctx.request;
|
|
const fiveMinutesAgo = subMinutes(new Date(), 5);
|
|
|
|
// ensure this is updated only every few minutes otherwise
|
|
// we'll be constantly writing to the DB as API requests happen
|
|
if (!this.lastActiveAt || this.lastActiveAt < fiveMinutesAgo || force) {
|
|
this.lastActiveAt = new Date();
|
|
this.lastActiveIp = ip;
|
|
}
|
|
|
|
// Track the clients each user is using
|
|
if (ctx.userAgent?.source.includes("Outline/")) {
|
|
this.setFlag(UserFlag.Desktop);
|
|
} else if (ctx.userAgent?.isDesktop) {
|
|
this.setFlag(UserFlag.DesktopWeb);
|
|
} else if (ctx.userAgent?.isMobile) {
|
|
this.setFlag(UserFlag.MobileWeb);
|
|
}
|
|
|
|
// Save only writes to the database if there are changes
|
|
return this.save({
|
|
hooks: false,
|
|
});
|
|
};
|
|
|
|
updateSignedIn = (ip: string) => {
|
|
const now = new Date();
|
|
this.lastActiveAt = now;
|
|
this.lastActiveIp = ip;
|
|
this.lastSignedInAt = now;
|
|
this.lastSignedInIp = ip;
|
|
return this.save({ hooks: false });
|
|
};
|
|
|
|
/**
|
|
* Rotate's the users JWT secret. This has the effect of invalidating ALL
|
|
* previously issued tokens.
|
|
*
|
|
* @param options Save options
|
|
* @returns Promise that resolves when database persisted
|
|
*/
|
|
rotateJwtSecret = (options: SaveOptions) => {
|
|
User.setRandomJwtSecret(this);
|
|
return this.save(options);
|
|
};
|
|
|
|
/**
|
|
* Returns a session token that is used to make API requests and is stored
|
|
* in the client browser cookies to remain logged in.
|
|
*
|
|
* @param expiresAt The time the token will expire at
|
|
* @returns The session token
|
|
*/
|
|
getJwtToken = (expiresAt?: Date) =>
|
|
JWT.sign(
|
|
{
|
|
id: this.id,
|
|
expiresAt: expiresAt ? expiresAt.toISOString() : undefined,
|
|
type: "session",
|
|
},
|
|
this.jwtSecret
|
|
);
|
|
|
|
/**
|
|
* Returns a temporary token that is only used for transferring a session
|
|
* between subdomains or domains. It has a short expiry and can only be used
|
|
* once.
|
|
*
|
|
* @returns The transfer token
|
|
*/
|
|
getTransferToken = () =>
|
|
JWT.sign(
|
|
{
|
|
id: this.id,
|
|
createdAt: new Date().toISOString(),
|
|
expiresAt: addMinutes(new Date(), 1).toISOString(),
|
|
type: "transfer",
|
|
},
|
|
this.jwtSecret
|
|
);
|
|
|
|
/**
|
|
* Returns a temporary token that is only used for logging in from an email
|
|
* It can only be used to sign in once and has a medium length expiry
|
|
*
|
|
* @returns The email signin token
|
|
*/
|
|
getEmailSigninToken = () =>
|
|
JWT.sign(
|
|
{
|
|
id: this.id,
|
|
createdAt: new Date().toISOString(),
|
|
type: "email-signin",
|
|
},
|
|
this.jwtSecret
|
|
);
|
|
|
|
/**
|
|
* Returns a list of teams that have a user matching this user's email.
|
|
*
|
|
* @returns A promise resolving to a list of teams
|
|
*/
|
|
availableTeams = async () =>
|
|
Team.findAll({
|
|
include: [
|
|
{
|
|
model: this.constructor as typeof User,
|
|
required: true,
|
|
where: { email: this.email },
|
|
},
|
|
],
|
|
});
|
|
|
|
demote = async (to: UserRole, options?: SaveOptions<User>) => {
|
|
const res = await (this.constructor as typeof User).findAndCountAll({
|
|
where: {
|
|
teamId: this.teamId,
|
|
isAdmin: true,
|
|
id: {
|
|
[Op.ne]: this.id,
|
|
},
|
|
},
|
|
limit: 1,
|
|
});
|
|
|
|
if (res.count >= 1) {
|
|
if (to === "member") {
|
|
await this.update(
|
|
{
|
|
isAdmin: false,
|
|
isViewer: false,
|
|
},
|
|
options
|
|
);
|
|
} else if (to === "viewer") {
|
|
await this.update(
|
|
{
|
|
isAdmin: false,
|
|
isViewer: true,
|
|
},
|
|
options
|
|
);
|
|
await CollectionUser.update(
|
|
{
|
|
permission: CollectionPermission.Read,
|
|
},
|
|
{
|
|
...options,
|
|
where: {
|
|
userId: this.id,
|
|
},
|
|
}
|
|
);
|
|
}
|
|
|
|
return undefined;
|
|
} else {
|
|
throw ValidationError("At least one admin is required");
|
|
}
|
|
};
|
|
|
|
promote = () =>
|
|
this.update({
|
|
isAdmin: true,
|
|
isViewer: false,
|
|
});
|
|
|
|
// hooks
|
|
|
|
@BeforeDestroy
|
|
static removeIdentifyingInfo = async (
|
|
model: User,
|
|
options: { transaction: Transaction }
|
|
) => {
|
|
await ApiKey.destroy({
|
|
where: {
|
|
userId: model.id,
|
|
},
|
|
transaction: options.transaction,
|
|
});
|
|
await Star.destroy({
|
|
where: {
|
|
userId: model.id,
|
|
},
|
|
transaction: options.transaction,
|
|
});
|
|
await UserAuthentication.destroy({
|
|
where: {
|
|
userId: model.id,
|
|
},
|
|
transaction: options.transaction,
|
|
});
|
|
model.email = null;
|
|
model.name = "Unknown";
|
|
model.avatarUrl = null;
|
|
model.lastActiveIp = null;
|
|
model.lastSignedInIp = null;
|
|
|
|
// this shouldn't be needed once this issue is resolved:
|
|
// https://github.com/sequelize/sequelize/issues/9318
|
|
await model.save({
|
|
hooks: false,
|
|
transaction: options.transaction,
|
|
});
|
|
};
|
|
|
|
@BeforeCreate
|
|
static setRandomJwtSecret = (model: User) => {
|
|
model.jwtSecret = crypto.randomBytes(64).toString("hex");
|
|
};
|
|
|
|
@AfterUpdate
|
|
static deletePreviousAvatar = async (model: User) => {
|
|
if (
|
|
model.previous("avatarUrl") &&
|
|
model.previous("avatarUrl") !== model.avatarUrl
|
|
) {
|
|
const attachmentIds = parseAttachmentIds(
|
|
model.previous("avatarUrl"),
|
|
true
|
|
);
|
|
if (!attachmentIds.length) {
|
|
return;
|
|
}
|
|
|
|
const attachment = await Attachment.findOne({
|
|
where: {
|
|
id: attachmentIds[0],
|
|
teamId: model.teamId,
|
|
userId: model.id,
|
|
},
|
|
});
|
|
|
|
if (attachment) {
|
|
await DeleteAttachmentTask.schedule({
|
|
attachmentId: attachment.id,
|
|
teamId: model.id,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
|
|
static getCounts = async function (teamId: string) {
|
|
const countSql = `
|
|
SELECT
|
|
COUNT(CASE WHEN "suspendedAt" IS NOT NULL THEN 1 END) as "suspendedCount",
|
|
COUNT(CASE WHEN "isAdmin" = true THEN 1 END) as "adminCount",
|
|
COUNT(CASE WHEN "isViewer" = true THEN 1 END) as "viewerCount",
|
|
COUNT(CASE WHEN "lastActiveAt" IS NULL THEN 1 END) as "invitedCount",
|
|
COUNT(CASE WHEN "suspendedAt" IS NULL AND "lastActiveAt" IS NOT NULL THEN 1 END) as "activeCount",
|
|
COUNT(*) as count
|
|
FROM users
|
|
WHERE "deletedAt" IS NULL
|
|
AND "teamId" = :teamId
|
|
`;
|
|
const [results] = await this.sequelize.query(countSql, {
|
|
type: QueryTypes.SELECT,
|
|
replacements: {
|
|
teamId,
|
|
},
|
|
});
|
|
|
|
const counts: {
|
|
activeCount: string;
|
|
adminCount: string;
|
|
invitedCount: string;
|
|
suspendedCount: string;
|
|
viewerCount: string;
|
|
count: string;
|
|
} = results;
|
|
|
|
return {
|
|
active: parseInt(counts.activeCount),
|
|
admins: parseInt(counts.adminCount),
|
|
viewers: parseInt(counts.viewerCount),
|
|
all: parseInt(counts.count),
|
|
invited: parseInt(counts.invitedCount),
|
|
suspended: parseInt(counts.suspendedCount),
|
|
};
|
|
};
|
|
}
|
|
|
|
export default User;
|