mirror of
https://github.com/docmost/docmost.git
synced 2026-05-18 07:24:04 +08:00
feat: cloud and ee (#805)
* stripe init git submodules for enterprise modules * * Cloud billing UI - WIP * Proxy websockets in dev mode * Separate workspace login and creation for cloud * Other fixes * feat: billing (cloud) * * add domain service * prepare links from workspace hostname * WIP * Add exchange token generation * Validate JWT token type during verification * domain service * add SkipTransform decorator * * updates (server) * add new packages * new sso migration file * WIP * Fix hostname generation * WIP * WIP * Reduce input error font-size * set max password length * jwt package * license page - WIP * * License management UI * Move license key store to db * add reflector * SSO enforcement * * Add default plan * Add usePlan hook * * Fix auth container margin in mobile * Redirect login and home to select page in cloud * update .gitignore * Default to yearly * * Trial messaging * Handle ended trials * Don't set to readonly on collab disconnect (Cloud) * Refine trial (UI) * Fix bug caused by using jotai optics atom in AppHeader component * configurable database maximum pool * Close SSO form on save * wip * sync * Only show sign-in in cloud * exclude base api part from workspaceId check * close db connection beforeApplicationShutdown * Add health/live endpoint * clear cookie on hostname change * reset currentUser atom * Change text * return 401 if workspace does not match * feat: show user workspace list in cloud login page * sync * Add home path * Prefetch to speed up queries * * Add robots.txt * Disallow login and forgot password routes * wildcard user-agent * Fix space query cache * fix * fix * use space uuid for recent pages * prefetch billing plans * enhance license page * sync
This commit is contained in:
@@ -1,20 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { WorkspaceController } from './workspace.controller';
|
||||
import { WorkspaceService } from '../services/workspace.service';
|
||||
|
||||
describe('WorkspaceController', () => {
|
||||
let controller: WorkspaceController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [WorkspaceController],
|
||||
providers: [WorkspaceService],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<WorkspaceController>(WorkspaceController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
import { addDays } from 'date-fns';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { CheckHostnameDto } from '../dto/check-hostname.dto';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('workspace')
|
||||
@@ -60,7 +61,8 @@ export class WorkspaceController {
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async updateWorkspace(
|
||||
@Body() updateWorkspaceDto: UpdateWorkspaceDto,
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
@Body() dto: UpdateWorkspaceDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
) {
|
||||
@@ -71,7 +73,21 @@ export class WorkspaceController {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.workspaceService.update(workspace.id, updateWorkspaceDto);
|
||||
const updatedWorkspace = await this.workspaceService.update(
|
||||
workspace.id,
|
||||
dto,
|
||||
);
|
||||
|
||||
if (
|
||||
dto.hostname &&
|
||||
dto.hostname === updatedWorkspace.hostname &&
|
||||
workspace.hostname !== updatedWorkspace.hostname
|
||||
) {
|
||||
// log user out of old hostname
|
||||
res.clearCookie('authToken');
|
||||
}
|
||||
|
||||
return updatedWorkspace;
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -102,8 +118,6 @@ export class WorkspaceController {
|
||||
) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
|
||||
return this.workspaceService.deactivateUser();
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -172,7 +186,7 @@ export class WorkspaceController {
|
||||
|
||||
return this.workspaceInvitationService.createInvitation(
|
||||
inviteUserDto,
|
||||
workspace.id,
|
||||
workspace,
|
||||
user,
|
||||
);
|
||||
}
|
||||
@@ -193,7 +207,7 @@ export class WorkspaceController {
|
||||
|
||||
return this.workspaceInvitationService.resendInvitation(
|
||||
revokeInviteDto.invitationId,
|
||||
workspace.id,
|
||||
workspace,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -238,6 +252,13 @@ export class WorkspaceController {
|
||||
});
|
||||
}
|
||||
|
||||
@Public()
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('/check-hostname')
|
||||
async checkHostname(@Body() checkHostnameDto: CheckHostnameDto) {
|
||||
return this.workspaceService.checkHostname(checkHostnameDto.hostname);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('invites/link')
|
||||
async getInviteLink(
|
||||
@@ -258,7 +279,7 @@ export class WorkspaceController {
|
||||
const inviteLink =
|
||||
await this.workspaceInvitationService.getInvitationLinkById(
|
||||
inviteDto.invitationId,
|
||||
workspace.id,
|
||||
workspace,
|
||||
);
|
||||
|
||||
return { inviteLink };
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { MinLength } from 'class-validator';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
|
||||
export class CheckHostnameDto {
|
||||
@MinLength(1)
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
hostname: string;
|
||||
}
|
||||
@@ -1,8 +1,14 @@
|
||||
import {IsAlphanumeric, IsOptional, IsString, MaxLength, MinLength} from 'class-validator';
|
||||
import {Transform, TransformFnParams} from "class-transformer";
|
||||
import {
|
||||
IsAlphanumeric,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Transform, TransformFnParams } from 'class-transformer';
|
||||
|
||||
export class CreateWorkspaceDto {
|
||||
@MinLength(4)
|
||||
@MinLength(1)
|
||||
@MaxLength(64)
|
||||
@IsString()
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim())
|
||||
@@ -12,6 +18,7 @@ export class CreateWorkspaceDto {
|
||||
@MinLength(4)
|
||||
@MaxLength(30)
|
||||
@IsAlphanumeric()
|
||||
@Transform(({ value }: TransformFnParams) => value?.trim().toLowerCase())
|
||||
hostname?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateWorkspaceDto } from './create-workspace.dto';
|
||||
import { IsOptional, IsString } from 'class-validator';
|
||||
import { IsArray, IsBoolean, IsOptional, IsString } from 'class-validator';
|
||||
|
||||
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
logo: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
emailDomains: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enforceSso: boolean;
|
||||
}
|
||||
|
||||
@@ -12,17 +12,18 @@ import { executeTx } from '@docmost/db/utils';
|
||||
import {
|
||||
Group,
|
||||
User,
|
||||
Workspace,
|
||||
WorkspaceInvitation,
|
||||
} from '@docmost/db/types/entity.types';
|
||||
import { MailService } from '../../../integrations/mail/mail.service';
|
||||
import InvitationEmail from '@docmost/transactional/emails/invitation-email';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-accepted-email';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { TokenService } from '../../auth/services/token.service';
|
||||
import { nanoIdGen } from '../../../common/helpers';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithPagination } from '@docmost/db/pagination/pagination';
|
||||
import { DomainService } from 'src/integrations/environment/domain.service';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceInvitationService {
|
||||
@@ -31,7 +32,7 @@ export class WorkspaceInvitationService {
|
||||
private userRepo: UserRepo,
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
private mailService: MailService,
|
||||
private environmentService: EnvironmentService,
|
||||
private domainService: DomainService,
|
||||
private tokenService: TokenService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
@@ -88,7 +89,7 @@ export class WorkspaceInvitationService {
|
||||
|
||||
async createInvitation(
|
||||
inviteUserDto: InviteUserDto,
|
||||
workspaceId: string,
|
||||
workspace: Workspace,
|
||||
authUser: User,
|
||||
): Promise<void> {
|
||||
const { emails, role, groupIds } = inviteUserDto;
|
||||
@@ -102,7 +103,7 @@ export class WorkspaceInvitationService {
|
||||
.selectFrom('users')
|
||||
.select(['email'])
|
||||
.where('users.email', 'in', emails)
|
||||
.where('users.workspaceId', '=', workspaceId)
|
||||
.where('users.workspaceId', '=', workspace.id)
|
||||
.execute();
|
||||
|
||||
let existingUserEmails = [];
|
||||
@@ -121,7 +122,7 @@ export class WorkspaceInvitationService {
|
||||
.selectFrom('groups')
|
||||
.select(['id', 'name'])
|
||||
.where('groups.id', 'in', groupIds)
|
||||
.where('groups.workspaceId', '=', workspaceId)
|
||||
.where('groups.workspaceId', '=', workspace.id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
@@ -129,7 +130,7 @@ export class WorkspaceInvitationService {
|
||||
email: email,
|
||||
role: role,
|
||||
token: nanoIdGen(16),
|
||||
workspaceId: workspaceId,
|
||||
workspaceId: workspace.id,
|
||||
invitedById: authUser.id,
|
||||
groupIds: validGroups?.map((group: Partial<Group>) => group.id),
|
||||
}));
|
||||
@@ -156,6 +157,7 @@ export class WorkspaceInvitationService {
|
||||
invitation.email,
|
||||
invitation.token,
|
||||
authUser.name,
|
||||
workspace.hostname,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -269,13 +271,13 @@ export class WorkspaceInvitationService {
|
||||
|
||||
async resendInvitation(
|
||||
invitationId: string,
|
||||
workspaceId: string,
|
||||
workspace: Workspace,
|
||||
): Promise<void> {
|
||||
const invitation = await this.db
|
||||
.selectFrom('workspaceInvitations')
|
||||
.selectAll()
|
||||
.where('id', '=', invitationId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('workspaceId', '=', workspace.id)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!invitation) {
|
||||
@@ -284,7 +286,7 @@ export class WorkspaceInvitationService {
|
||||
|
||||
const invitedByUser = await this.userRepo.findById(
|
||||
invitation.invitedById,
|
||||
workspaceId,
|
||||
workspace.id,
|
||||
);
|
||||
|
||||
await this.sendInvitationMail(
|
||||
@@ -292,6 +294,7 @@ export class WorkspaceInvitationService {
|
||||
invitation.email,
|
||||
invitation.token,
|
||||
invitedByUser.name,
|
||||
workspace.hostname,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -308,17 +311,23 @@ export class WorkspaceInvitationService {
|
||||
|
||||
async getInvitationLinkById(
|
||||
invitationId: string,
|
||||
workspaceId: string,
|
||||
workspace: Workspace,
|
||||
): Promise<string> {
|
||||
const token = await this.getInvitationTokenById(invitationId, workspaceId);
|
||||
return this.buildInviteLink(invitationId, token.token);
|
||||
const token = await this.getInvitationTokenById(invitationId, workspace.id);
|
||||
return this.buildInviteLink({
|
||||
invitationId,
|
||||
inviteToken: token.token,
|
||||
hostname: workspace.hostname,
|
||||
});
|
||||
}
|
||||
|
||||
async buildInviteLink(
|
||||
invitationId: string,
|
||||
inviteToken: string,
|
||||
): Promise<string> {
|
||||
return `${this.environmentService.getAppUrl()}/invites/${invitationId}?token=${inviteToken}`;
|
||||
async buildInviteLink(opts: {
|
||||
invitationId: string;
|
||||
inviteToken: string;
|
||||
hostname?: string;
|
||||
}): Promise<string> {
|
||||
const { invitationId, inviteToken, hostname } = opts;
|
||||
return `${this.domainService.getUrl(hostname)}/invites/${invitationId}?token=${inviteToken}`;
|
||||
}
|
||||
|
||||
async sendInvitationMail(
|
||||
@@ -326,8 +335,13 @@ export class WorkspaceInvitationService {
|
||||
inviteeEmail: string,
|
||||
inviteToken: string,
|
||||
invitedByName: string,
|
||||
hostname?: string,
|
||||
): Promise<void> {
|
||||
const inviteLink = await this.buildInviteLink(invitationId, inviteToken);
|
||||
const inviteLink = await this.buildInviteLink({
|
||||
invitationId,
|
||||
inviteToken,
|
||||
hostname,
|
||||
});
|
||||
|
||||
const emailTemplate = InvitationEmail({
|
||||
inviteLink,
|
||||
|
||||
@@ -21,6 +21,11 @@ import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { PaginationResult } from '@docmost/db/pagination/pagination';
|
||||
import { UpdateWorkspaceUserRoleDto } from '../dto/update-workspace-user-role.dto';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { EnvironmentService } from '../../../integrations/environment/environment.service';
|
||||
import { DomainService } from '../../../integrations/environment/domain.service';
|
||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||
import { addDays } from 'date-fns';
|
||||
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceService {
|
||||
@@ -31,6 +36,8 @@ export class WorkspaceService {
|
||||
private groupRepo: GroupRepo,
|
||||
private groupUserRepo: GroupUserRepo,
|
||||
private userRepo: UserRepo,
|
||||
private environmentService: EnvironmentService,
|
||||
private domainService: DomainService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
) {}
|
||||
|
||||
@@ -50,14 +57,33 @@ export class WorkspaceService {
|
||||
async getWorkspacePublicData(workspaceId: string) {
|
||||
const workspace = await this.db
|
||||
.selectFrom('workspaces')
|
||||
.select(['id'])
|
||||
.select(['id', 'name', 'logo', 'hostname', 'enforceSso', 'licenseKey'])
|
||||
.select((eb) =>
|
||||
jsonArrayFrom(
|
||||
eb
|
||||
.selectFrom('authProviders')
|
||||
.select([
|
||||
'authProviders.id',
|
||||
'authProviders.name',
|
||||
'authProviders.type',
|
||||
])
|
||||
.where('authProviders.isEnabled', '=', true)
|
||||
.where('workspaceId', '=', workspaceId),
|
||||
).as('authProviders'),
|
||||
)
|
||||
.where('id', '=', workspaceId)
|
||||
.executeTakeFirst();
|
||||
|
||||
if (!workspace) {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
}
|
||||
|
||||
return workspace;
|
||||
const { licenseKey, ...rest } = workspace;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
hasLicenseKey: Boolean(licenseKey),
|
||||
};
|
||||
}
|
||||
|
||||
async create(
|
||||
@@ -68,12 +94,30 @@ export class WorkspaceService {
|
||||
return await executeTx(
|
||||
this.db,
|
||||
async (trx) => {
|
||||
let hostname = undefined;
|
||||
let trialEndAt = undefined;
|
||||
let status = undefined;
|
||||
let plan = undefined;
|
||||
|
||||
if (this.environmentService.isCloud()) {
|
||||
// generate unique hostname
|
||||
hostname = await this.generateHostname(
|
||||
createWorkspaceDto.hostname ?? createWorkspaceDto.name,
|
||||
);
|
||||
trialEndAt = addDays(new Date(), 14);
|
||||
status = WorkspaceStatus.Active;
|
||||
plan = 'standard';
|
||||
}
|
||||
|
||||
// create workspace
|
||||
const workspace = await this.workspaceRepo.insertWorkspace(
|
||||
{
|
||||
name: createWorkspaceDto.name,
|
||||
hostname: createWorkspaceDto.hostname,
|
||||
description: createWorkspaceDto.description,
|
||||
hostname,
|
||||
status,
|
||||
trialEndAt,
|
||||
plan,
|
||||
},
|
||||
trx,
|
||||
);
|
||||
@@ -91,6 +135,7 @@ export class WorkspaceService {
|
||||
workspaceId: workspace.id,
|
||||
role: UserRole.OWNER,
|
||||
})
|
||||
.where('users.id', '=', user.id)
|
||||
.execute();
|
||||
|
||||
// add user to default group created above
|
||||
@@ -182,21 +227,54 @@ export class WorkspaceService {
|
||||
}
|
||||
|
||||
async update(workspaceId: string, updateWorkspaceDto: UpdateWorkspaceDto) {
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId);
|
||||
if (!workspace) {
|
||||
throw new NotFoundException('Workspace not found');
|
||||
if (updateWorkspaceDto.enforceSso) {
|
||||
const sso = await this.db
|
||||
.selectFrom('authProviders')
|
||||
.selectAll()
|
||||
.where('isEnabled', '=', true)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
|
||||
if (sso && sso?.length === 0) {
|
||||
throw new BadRequestException(
|
||||
'There must be at least one active SSO provider to enforce SSO.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (updateWorkspaceDto.name) {
|
||||
workspace.name = updateWorkspaceDto.name;
|
||||
if (updateWorkspaceDto.emailDomains) {
|
||||
const regex =
|
||||
/(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z0-9][a-z0-9-]{0,61}[a-z0-9]/;
|
||||
|
||||
const emailDomains = updateWorkspaceDto.emailDomains || [];
|
||||
|
||||
updateWorkspaceDto.emailDomains = emailDomains
|
||||
.map((domain) => regex.exec(domain)?.[0])
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
if (updateWorkspaceDto.logo) {
|
||||
workspace.logo = updateWorkspaceDto.logo;
|
||||
if (updateWorkspaceDto.hostname) {
|
||||
const hostname = updateWorkspaceDto.hostname;
|
||||
if (DISALLOWED_HOSTNAMES.includes(hostname)) {
|
||||
throw new BadRequestException('Hostname already exists.');
|
||||
}
|
||||
if (await this.workspaceRepo.hostnameExists(hostname)) {
|
||||
throw new BadRequestException('Hostname already exists.');
|
||||
}
|
||||
}
|
||||
|
||||
await this.workspaceRepo.updateWorkspace(updateWorkspaceDto, workspaceId);
|
||||
return workspace;
|
||||
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
withMemberCount: true,
|
||||
withLicenseKey: true,
|
||||
});
|
||||
|
||||
const { licenseKey, ...rest } = workspace;
|
||||
return {
|
||||
...rest,
|
||||
hasLicenseKey: Boolean(licenseKey),
|
||||
};
|
||||
}
|
||||
|
||||
async getWorkspaceUsers(
|
||||
@@ -256,7 +334,53 @@ export class WorkspaceService {
|
||||
);
|
||||
}
|
||||
|
||||
async deactivateUser(): Promise<any> {
|
||||
return 'todo';
|
||||
async generateHostname(
|
||||
name: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<string> {
|
||||
const generateRandomSuffix = (length: number) =>
|
||||
Math.random()
|
||||
.toFixed(length)
|
||||
.substring(2, 2 + length);
|
||||
|
||||
let subdomain = name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '')
|
||||
.substring(0, 20);
|
||||
// Ensure we leave room for a random suffix.
|
||||
const maxSuffixLength = 3;
|
||||
|
||||
if (subdomain.length < 4) {
|
||||
subdomain = `${subdomain}-${generateRandomSuffix(maxSuffixLength)}`;
|
||||
}
|
||||
|
||||
if (DISALLOWED_HOSTNAMES.includes(subdomain)) {
|
||||
subdomain = `myworkspace-${generateRandomSuffix(maxSuffixLength)}`;
|
||||
}
|
||||
|
||||
let uniqueHostname = subdomain;
|
||||
|
||||
while (true) {
|
||||
const exists = await this.workspaceRepo.hostnameExists(
|
||||
uniqueHostname,
|
||||
trx,
|
||||
);
|
||||
if (!exists) {
|
||||
break;
|
||||
}
|
||||
// Append a random suffix and retry.
|
||||
const randomSuffix = generateRandomSuffix(maxSuffixLength);
|
||||
uniqueHostname = `${subdomain}-${randomSuffix}`.substring(0, 25);
|
||||
}
|
||||
|
||||
return uniqueHostname;
|
||||
}
|
||||
|
||||
async checkHostname(hostname: string) {
|
||||
const exists = await this.workspaceRepo.hostnameExists(hostname);
|
||||
if (!exists) {
|
||||
throw new NotFoundException('Hostname not found');
|
||||
}
|
||||
return { hostname: this.domainService.getUrl(hostname) };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
export enum WorkspaceStatus {
|
||||
Active = 'active',
|
||||
Suspended = 'suspended',
|
||||
}
|
||||
|
||||
export const DISALLOWED_HOSTNAMES = [
|
||||
'app',
|
||||
'help',
|
||||
'account',
|
||||
'billing',
|
||||
'docs',
|
||||
'blog',
|
||||
'status',
|
||||
'payment',
|
||||
'updates',
|
||||
'license',
|
||||
'customer',
|
||||
'customers',
|
||||
'dashboard',
|
||||
'docmost',
|
||||
'support',
|
||||
'admin',
|
||||
'about',
|
||||
'team',
|
||||
'analytics',
|
||||
'data',
|
||||
'dev',
|
||||
'development',
|
||||
'staging',
|
||||
'wiki',
|
||||
'www',
|
||||
'login',
|
||||
'signup',
|
||||
'signin',
|
||||
'register',
|
||||
'abuse',
|
||||
'general',
|
||||
'update',
|
||||
'updates',
|
||||
'upgrade',
|
||||
'upgrades',
|
||||
'api',
|
||||
'server',
|
||||
'servers',
|
||||
'service',
|
||||
'user',
|
||||
'upload',
|
||||
'uploads',
|
||||
'version',
|
||||
'translate',
|
||||
'translation',
|
||||
'translations',
|
||||
'translator',
|
||||
'setup',
|
||||
'share',
|
||||
'setting',
|
||||
'settings',
|
||||
'security',
|
||||
'shop',
|
||||
'store',
|
||||
'prod',
|
||||
'plan',
|
||||
'plans',
|
||||
'plugin',
|
||||
'plugins',
|
||||
'mail',
|
||||
'email',
|
||||
'checkout',
|
||||
'checkouts',
|
||||
'client',
|
||||
'career',
|
||||
'job',
|
||||
'jobs',
|
||||
'careers',
|
||||
'account',
|
||||
'accounts',
|
||||
'host',
|
||||
'connect',
|
||||
'contact',
|
||||
'core',
|
||||
'embed',
|
||||
'founder',
|
||||
'guide',
|
||||
'guides',
|
||||
'smtp',
|
||||
'imap',
|
||||
'lab',
|
||||
'collab',
|
||||
'collaboration',
|
||||
'ws',
|
||||
'websocket',
|
||||
'community',
|
||||
'forum',
|
||||
'forums',
|
||||
'wikis',
|
||||
'files',
|
||||
'app',
|
||||
'assets',
|
||||
'news',
|
||||
'jobs',
|
||||
'careers',
|
||||
'can',
|
||||
'demo',
|
||||
'logs',
|
||||
'dash',
|
||||
'auth',
|
||||
'organization',
|
||||
'org',
|
||||
'db',
|
||||
'database',
|
||||
'notes',
|
||||
'download',
|
||||
'workspace',
|
||||
'space',
|
||||
'group',
|
||||
'members',
|
||||
];
|
||||
Reference in New Issue
Block a user