* 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
342 lines
8.3 KiB
TypeScript
342 lines
8.3 KiB
TypeScript
import fs from "fs";
|
|
import path from "path";
|
|
import { URL } from "url";
|
|
import util from "util";
|
|
import { Op } from "sequelize";
|
|
import {
|
|
Column,
|
|
IsLowercase,
|
|
NotIn,
|
|
Default,
|
|
Table,
|
|
Unique,
|
|
IsIn,
|
|
HasMany,
|
|
Scopes,
|
|
Is,
|
|
DataType,
|
|
IsUUID,
|
|
IsUrl,
|
|
AllowNull,
|
|
AfterUpdate,
|
|
} from "sequelize-typescript";
|
|
import {
|
|
CollectionPermission,
|
|
TeamPreference,
|
|
TeamPreferences,
|
|
} from "@shared/types";
|
|
import { getBaseDomain, RESERVED_SUBDOMAINS } from "@shared/utils/domains";
|
|
import env from "@server/env";
|
|
import DeleteAttachmentTask from "@server/queues/tasks/DeleteAttachmentTask";
|
|
import parseAttachmentIds from "@server/utils/parseAttachmentIds";
|
|
import Attachment from "./Attachment";
|
|
import AuthenticationProvider from "./AuthenticationProvider";
|
|
import Collection from "./Collection";
|
|
import Document from "./Document";
|
|
import TeamDomain from "./TeamDomain";
|
|
import User from "./User";
|
|
import ParanoidModel from "./base/ParanoidModel";
|
|
import Fix from "./decorators/Fix";
|
|
import IsFQDN from "./validators/IsFQDN";
|
|
import Length from "./validators/Length";
|
|
import NotContainsUrl from "./validators/NotContainsUrl";
|
|
|
|
const readFile = util.promisify(fs.readFile);
|
|
|
|
@Scopes(() => ({
|
|
withDomains: {
|
|
include: [{ model: TeamDomain }],
|
|
},
|
|
withAuthenticationProviders: {
|
|
include: [
|
|
{
|
|
model: AuthenticationProvider,
|
|
as: "authenticationProviders",
|
|
},
|
|
],
|
|
},
|
|
}))
|
|
@Table({ tableName: "teams", modelName: "team" })
|
|
@Fix
|
|
class Team extends ParanoidModel {
|
|
@NotContainsUrl
|
|
@Length({ min: 2, max: 255, msg: "name must be between 2 to 255 characters" })
|
|
@Column
|
|
name: string;
|
|
|
|
@IsLowercase
|
|
@Unique
|
|
@Length({
|
|
min: 2,
|
|
max: env.isCloudHosted() ? 32 : 255,
|
|
msg: `subdomain must be between 2 and ${
|
|
env.isCloudHosted() ? 32 : 255
|
|
} characters`,
|
|
})
|
|
@Is({
|
|
args: [/^[a-z\d-]+$/, "i"],
|
|
msg: "Must be only alphanumeric and dashes",
|
|
})
|
|
@NotIn({
|
|
args: [RESERVED_SUBDOMAINS],
|
|
msg: "You chose a restricted word, please try another.",
|
|
})
|
|
@Column
|
|
subdomain: string | null;
|
|
|
|
@Unique
|
|
@Length({ max: 255, msg: "domain must be 255 characters or less" })
|
|
@IsFQDN
|
|
@Column
|
|
domain: string | null;
|
|
|
|
@IsUUID(4)
|
|
@Column(DataType.UUID)
|
|
defaultCollectionId: string | null;
|
|
|
|
@AllowNull
|
|
@IsUrl
|
|
@Length({ max: 4096, msg: "avatarUrl must be 4096 characters or less" })
|
|
@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);
|
|
}
|
|
|
|
@Default(true)
|
|
@Column
|
|
sharing: boolean;
|
|
|
|
@Default(false)
|
|
@Column
|
|
inviteRequired: boolean;
|
|
|
|
@Default(true)
|
|
@Column(DataType.JSONB)
|
|
signupQueryParams: { [key: string]: string } | null;
|
|
|
|
@Default(true)
|
|
@Column
|
|
guestSignin: boolean;
|
|
|
|
@Default(true)
|
|
@Column
|
|
documentEmbeds: boolean;
|
|
|
|
@Default(true)
|
|
@Column
|
|
memberCollectionCreate: boolean;
|
|
|
|
@Default("member")
|
|
@IsIn([["viewer", "member"]])
|
|
@Column
|
|
defaultUserRole: string;
|
|
|
|
@AllowNull
|
|
@Column(DataType.JSONB)
|
|
preferences: TeamPreferences | null;
|
|
|
|
// getters
|
|
|
|
/**
|
|
* Returns whether the team has email login enabled. For self-hosted installs
|
|
* this also considers whether SMTP connection details have been configured.
|
|
*
|
|
* @return {boolean} Whether to show email login options
|
|
*/
|
|
get emailSigninEnabled(): boolean {
|
|
return (
|
|
this.guestSignin && (!!env.SMTP_HOST || env.ENVIRONMENT === "development")
|
|
);
|
|
}
|
|
|
|
get url() {
|
|
const url = new URL(env.URL);
|
|
|
|
// custom domain
|
|
if (this.domain) {
|
|
return `${url.protocol}//${this.domain}${url.port ? `:${url.port}` : ""}`;
|
|
}
|
|
|
|
if (!this.subdomain || !env.SUBDOMAINS_ENABLED) {
|
|
return env.URL;
|
|
}
|
|
|
|
url.host = `${this.subdomain}.${getBaseDomain()}`;
|
|
return url.href.replace(/\/$/, "");
|
|
}
|
|
|
|
/**
|
|
* Preferences that decide behavior for the team.
|
|
*
|
|
* @param preference The team preference to set
|
|
* @param value Sets the preference value
|
|
* @returns The current team preferences
|
|
*/
|
|
public setPreference = <T extends keyof TeamPreferences>(
|
|
preference: T,
|
|
value: TeamPreferences[T]
|
|
) => {
|
|
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: TeamPreference, fallback = false) =>
|
|
this.preferences?.[preference] ?? fallback;
|
|
|
|
provisionFirstCollection = async (userId: string) => {
|
|
await this.sequelize!.transaction(async (transaction) => {
|
|
const collection = await Collection.create(
|
|
{
|
|
name: "Welcome",
|
|
description: `This collection is a quick guide to what ${env.APP_NAME} is all about. Feel free to delete this collection once your team is up to speed with the basics!`,
|
|
teamId: this.id,
|
|
createdById: userId,
|
|
sort: Collection.DEFAULT_SORT,
|
|
permission: CollectionPermission.ReadWrite,
|
|
},
|
|
{
|
|
transaction,
|
|
}
|
|
);
|
|
|
|
// For the first collection we go ahead and create some intitial documents to get
|
|
// the team started. You can edit these in /server/onboarding/x.md
|
|
const onboardingDocs = [
|
|
"Integrations & API",
|
|
"Our Editor",
|
|
"Getting Started",
|
|
"What is Outline",
|
|
];
|
|
|
|
for (const title of onboardingDocs) {
|
|
const text = await readFile(
|
|
path.join(process.cwd(), "server", "onboarding", `${title}.md`),
|
|
"utf8"
|
|
);
|
|
const document = await Document.create(
|
|
{
|
|
version: 2,
|
|
isWelcome: true,
|
|
parentDocumentId: null,
|
|
collectionId: collection.id,
|
|
teamId: collection.teamId,
|
|
userId: collection.createdById,
|
|
lastModifiedById: collection.createdById,
|
|
createdById: collection.createdById,
|
|
title,
|
|
text,
|
|
},
|
|
{ transaction }
|
|
);
|
|
await document.publish(collection.createdById, { transaction });
|
|
}
|
|
});
|
|
};
|
|
|
|
public collectionIds = async function (this: Team, paranoid = true) {
|
|
const models = await Collection.findAll({
|
|
attributes: ["id"],
|
|
where: {
|
|
teamId: this.id,
|
|
permission: {
|
|
[Op.ne]: null,
|
|
},
|
|
},
|
|
paranoid,
|
|
});
|
|
return models.map((c) => c.id);
|
|
};
|
|
|
|
/**
|
|
* Find whether the passed domain can be used to sign-in to this team. Note
|
|
* that this method always returns true if no domain restrictions are set.
|
|
*
|
|
* @param domain The domain to check
|
|
* @returns True if the domain is allowed to sign-in to this team
|
|
*/
|
|
public isDomainAllowed = async function (
|
|
this: Team,
|
|
domain: string
|
|
): Promise<boolean> {
|
|
const allowedDomains = (await this.$get("allowedDomains")) || [];
|
|
|
|
return (
|
|
allowedDomains.length === 0 ||
|
|
allowedDomains.map((d: TeamDomain) => d.name).includes(domain)
|
|
);
|
|
};
|
|
|
|
// associations
|
|
|
|
@HasMany(() => Collection)
|
|
collections: Collection[];
|
|
|
|
@HasMany(() => Document)
|
|
documents: Document[];
|
|
|
|
@HasMany(() => User)
|
|
users: User[];
|
|
|
|
@HasMany(() => AuthenticationProvider)
|
|
authenticationProviders: AuthenticationProvider[];
|
|
|
|
@HasMany(() => TeamDomain)
|
|
allowedDomains: TeamDomain[];
|
|
|
|
// hooks
|
|
|
|
@AfterUpdate
|
|
static deletePreviousAvatar = async (model: Team) => {
|
|
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.id,
|
|
},
|
|
});
|
|
|
|
if (attachment) {
|
|
await DeleteAttachmentTask.schedule({
|
|
attachmentId: attachment.id,
|
|
teamId: model.id,
|
|
});
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
export default Team;
|