feat(cloud): add find-workspace and email verification endpoints (#2020)

* feat: add find-workspace and email verification endpoints
* sync
This commit is contained in:
Philip Okugbe
2026-03-14 13:36:30 +00:00
committed by GitHub
parent d0ed6865cb
commit 97c459be67
25 changed files with 479 additions and 13 deletions
@@ -693,5 +693,16 @@
"Failed to update trash retention": "Failed to update trash retention", "Failed to update trash retention": "Failed to update trash retention",
"Removed page restriction": "Removed page restriction", "Removed page restriction": "Removed page restriction",
"Added page permission": "Added page permission", "Added page permission": "Added page permission",
"Removed page permission": "Removed page permission" "Removed page permission": "Removed page permission",
"Verifying your email": "Verifying your email",
"Please wait...": "Please wait...",
"Verification failed. The link may have expired.": "Verification failed. The link may have expired.",
"Check your email": "Check your email",
"We sent a verification link to {{email}}.": "We sent a verification link to {{email}}.",
"We sent a verification link to your email.": "We sent a verification link to your email.",
"Click the link to verify your email and access your workspace.": "Click the link to verify your email and access your workspace.",
"Resend verification email": "Resend verification email",
"Verification email sent. Please check your inbox.": "Verification email sent. Please check your inbox.",
"Failed to resend verification email. Please try again.": "Failed to resend verification email. Please try again.",
"We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces."
} }
+2
View File
@@ -38,6 +38,7 @@ import UserApiKeys from "@/ee/api-key/pages/user-api-keys";
import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys"; import WorkspaceApiKeys from "@/ee/api-key/pages/workspace-api-keys";
import AiSettings from "@/ee/ai/pages/ai-settings.tsx"; import AiSettings from "@/ee/ai/pages/ai-settings.tsx";
import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; import AuditLogs from "@/ee/audit/pages/audit-logs.tsx";
import VerifyEmail from "@/ee/pages/verify-email.tsx";
export default function App() { export default function App() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -63,6 +64,7 @@ export default function App() {
<> <>
<Route path={"/create"} element={<CreateWorkspace />} /> <Route path={"/create"} element={<CreateWorkspace />} />
<Route path={"/select"} element={<CloudLogin />} /> <Route path={"/select"} element={<CloudLogin />} />
<Route path={"/verify-email"} element={<VerifyEmail />} />
</> </>
)} )}
@@ -5,3 +5,15 @@ export async function getJoinedWorkspaces(): Promise<Partial<IWorkspace[]>> {
const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined"); const req = await api.post<Partial<IWorkspace[]>>("/workspace/joined");
return req.data; return req.data;
} }
export async function findWorkspacesByEmail(email: string): Promise<void> {
await api.post("/workspace/find-by-email", { email });
}
export async function verifyEmail(data: { token: string }): Promise<void> {
await api.post("/workspace/verify-email", data);
}
export async function resendVerificationEmail(data: { email: string; sig: string }): Promise<void> {
await api.post("/workspace/resend-verification", data);
}
@@ -20,14 +20,21 @@ import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx"; import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx";
import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts"; import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts";
import { findWorkspacesByEmail } from "@/ee/cloud/service/cloud-service.ts";
const formSchema = z.object({ const formSchema = z.object({
hostname: z.string().min(1, { message: "subdomain is required" }), hostname: z.string().min(1, { message: "subdomain is required" }),
}); });
const findWorkspaceSchema = z.object({
email: z.string().email({ message: "Please enter a valid email" }),
});
export function CloudLoginForm() { export function CloudLoginForm() {
const { t } = useTranslation(); const { t } = useTranslation();
const [isLoading, setIsLoading] = useState<boolean>(false); const [isLoading, setIsLoading] = useState<boolean>(false);
const [isFindLoading, setIsFindLoading] = useState<boolean>(false);
const [findEmailSent, setFindEmailSent] = useState<boolean>(false);
const { data: joinedWorkspaces } = useJoinedWorkspacesQuery(); const { data: joinedWorkspaces } = useJoinedWorkspacesQuery();
const form = useForm<any>({ const form = useForm<any>({
@@ -37,6 +44,13 @@ export function CloudLoginForm() {
}, },
}); });
const findForm = useForm<any>({
validate: zod4Resolver(findWorkspaceSchema),
initialValues: {
email: "",
},
});
async function onSubmit(data: { hostname: string }) { async function onSubmit(data: { hostname: string }) {
setIsLoading(true); setIsLoading(true);
@@ -54,6 +68,19 @@ export function CloudLoginForm() {
setIsLoading(false); setIsLoading(false);
} }
async function onFindSubmit(data: { email: string }) {
setIsFindLoading(true);
try {
await findWorkspacesByEmail(data.email);
setFindEmailSent(true);
} catch {
findForm.setFieldError("email", "An error occurred. Please try again.");
}
setIsFindLoading(false);
}
return ( return (
<div> <div>
<Container size={420} className={classes.container}> <Container size={420} className={classes.container}>
@@ -83,6 +110,38 @@ export function CloudLoginForm() {
{t("Continue")} {t("Continue")}
</Button> </Button>
</form> </form>
<Divider my="lg" label="or" labelPosition="center" />
{findEmailSent ? (
<Text ta="center" size="sm" c="dimmed">
{t("We've sent you an email with your associated workspaces.")}
</Text>
) : (
<form onSubmit={findForm.onSubmit(onFindSubmit)}>
<Text fw={600} mb="xs">
{t("Find your workspaces")}
</Text>
<TextInput
type="email"
placeholder="name@company.com"
description={t(
"We'll send a list of your workspaces to this email.",
)}
withErrorStyles={false}
{...findForm.getInputProps("email")}
/>
<Button
type="submit"
fullWidth
mt="md"
variant="light"
loading={isFindLoading}
>
{t("Send")}
</Button>
</form>
)}
</Box> </Box>
</Container> </Container>
+107
View File
@@ -0,0 +1,107 @@
import { useEffect, useState } from "react";
import { useSearchParams, useNavigate } from "react-router-dom";
import { Container, Title, Text, Button, Box } from "@mantine/core";
import classes from "../../features/auth/components/auth.module.css";
import {
verifyEmail,
resendVerificationEmail,
} from "@/ee/cloud/service/cloud-service.ts";
import { notifications } from "@mantine/notifications";
import APP_ROUTE from "@/lib/app-route.ts";
import { useTranslation } from "react-i18next";
export default function VerifyEmail() {
const { t } = useTranslation();
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const token = searchParams.get("token");
const rawEmail = searchParams.get("email");
const email = rawEmail && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(rawEmail) ? rawEmail : null;
const sig = searchParams.get("sig");
const [isResending, setIsResending] = useState(false);
const [resent, setResent] = useState(false);
useEffect(() => {
if (token) {
handleVerify(token);
}
}, [token]);
async function handleVerify(verifyToken: string) {
try {
await verifyEmail({ token: verifyToken });
navigate(APP_ROUTE.HOME);
} catch (err) {
notifications.show({
message: t("Verification failed. The link may have expired."),
color: "red",
});
navigate(APP_ROUTE.AUTH.LOGIN);
}
}
async function handleResend() {
if (!email || !sig) return;
setIsResending(true);
try {
await resendVerificationEmail({ email, sig });
setResent(true);
} catch {
notifications.show({
message: t("Failed to resend verification email. Please try again."),
color: "red",
});
}
setIsResending(false);
}
if (token) {
return (
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Verifying your email")}
</Title>
<Text ta="center" c="dimmed">
{t("Please wait...")}
</Text>
</Box>
</Container>
);
}
return (
<Container size={420} className={classes.container}>
<Box p="xl" className={classes.containerBox}>
<Title order={2} ta="center" fw={500} mb="md">
{t("Check your email")}
</Title>
<Text ta="center" c="dimmed" mb="md">
{email
? t("We sent a verification link to {{email}}.", { email })
: t("We sent a verification link to your email.")}
</Text>
<Text ta="center" size="sm" c="dimmed" mb="lg">
{t("Click the link to verify your email and access your workspace.")}
</Text>
{email && sig && !resent && (
<Button
fullWidth
variant="light"
onClick={handleResend}
loading={isResending}
>
{t("Resend verification email")}
</Button>
)}
{resent && (
<Text ta="center" size="sm" c="dimmed">
{t("Verification email sent. Please check your inbox.")}
</Text>
)}
</Box>
</Container>
);
}
@@ -22,11 +22,11 @@ import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({ const formSchema = z.object({
workspaceName: z.string().trim().max(50).optional(), workspaceName: z.string().trim().max(50).optional(),
name: z.string().min(1).max(50), name: z.string().min(1, { message: "Name is required" }).max(50),
email: z email: z
.email() .email({ message: "Invalid email address" })
.min(1, { message: "email is required" }), .min(1, { message: "Email is required" }),
password: z.string().min(8), password: z.string().min(8, { message: "Password must be at least 8 characters" }),
}); });
type FormValues = z.infer<typeof formSchema>; type FormValues = z.infer<typeof formSchema>;
@@ -27,7 +27,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts";
import { RESET } from "jotai/utils"; import { RESET } from "jotai/utils";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { isCloud } from "@/lib/config.ts"; import { isCloud } from "@/lib/config.ts";
import { exchangeTokenRedirectUrl } from "@/ee/utils.ts"; import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts";
export default function useAuth() { export default function useAuth() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -52,9 +52,18 @@ export default function useAuth() {
} }
} catch (err) { } catch (err) {
setIsLoading(false); setIsLoading(false);
console.log(err);
const message = err.response?.data?.message;
if (isCloud() && message?.includes("verify your email")) {
const sig = err.response?.data?.emailSignature;
navigate(
`${APP_ROUTE.AUTH.VERIFY_EMAIL}?email=${encodeURIComponent(data.email)}${sig ? `&sig=${sig}` : ""}`,
);
return;
}
notifications.show({ notifications.show({
message: err.response?.data.message, message,
color: "red", color: "red",
}); });
} }
@@ -92,6 +101,17 @@ export default function useAuth() {
try { try {
if (isCloud()) { if (isCloud()) {
const res = await createWorkspace(data); const res = await createWorkspace(data);
if (res?.requiresEmailVerification) {
const hostname = res?.workspace?.hostname;
if (hostname) {
window.location.href =
getHostnameUrl(hostname) +
`/verify-email?email=${encodeURIComponent(data.email)}&sig=${res.emailSignature}`;
}
return;
}
const hostname = res?.workspace?.hostname; const hostname = res?.workspace?.hostname;
const exchangeToken = res?.exchangeToken; const exchangeToken = res?.exchangeToken;
if (hostname && exchangeToken) { if (hostname && exchangeToken) {
@@ -51,3 +51,4 @@ export async function getCollabToken(): Promise<ICollabToken> {
const req = await api.post<ICollabToken>("/auth/collab-token"); const req = await api.post<ICollabToken>("/auth/collab-token");
return req.data; return req.data;
} }
@@ -113,7 +113,7 @@ export async function getInvitationById(data: {
export async function createWorkspace( export async function createWorkspace(
data: ISetupWorkspace, data: ISetupWorkspace,
): Promise<{ workspace: IWorkspace } & { exchangeToken: string }> { ): Promise<{ workspace: IWorkspace; exchangeToken?: string; requiresEmailVerification?: boolean; emailSignature?: string }> {
const req = await api.post("/workspace/create", data); const req = await api.post("/workspace/create", data);
return req.data; return req.data;
} }
+1
View File
@@ -12,6 +12,7 @@ const APP_ROUTE = {
SELECT_WORKSPACE: "/select", SELECT_WORKSPACE: "/select",
MFA_CHALLENGE: "/login/mfa", MFA_CHALLENGE: "/login/mfa",
MFA_SETUP_REQUIRED: "/login/mfa/setup", MFA_SETUP_REQUIRED: "/login/mfa/setup",
VERIFY_EMAIL: "/verify-email",
}, },
SETTINGS: { SETTINGS: {
ACCOUNT: { ACCOUNT: {
+1
View File
@@ -107,6 +107,7 @@
"sanitize-filename-ts": "1.0.2", "sanitize-filename-ts": "1.0.2",
"socket.io": "^4.8.3", "socket.io": "^4.8.3",
"stripe": "^17.5.0", "stripe": "^17.5.0",
"tlds": "^1.261.0",
"tmp-promise": "^3.0.3", "tmp-promise": "^3.0.3",
"tseep": "^1.3.1", "tseep": "^1.3.1",
"typesense": "^2.1.0", "typesense": "^2.1.0",
@@ -0,0 +1,142 @@
import { containsDomain } from './no-urls.validator';
// containsDomain returns true if value contains a domain-like pattern
// The full NoUrls validator also checks for https:// URLs separately
describe('containsDomain', () => {
describe('bare domains with real TLDs — should block', () => {
it.each([
'example.com',
'example.net',
'example.org',
'example.io',
'example.co',
'example.dev',
'example.app',
'example.me',
'example.info',
'example.tech',
'example.aero',
'example.cloud',
'example.museum',
'example.abc',
'example.uk',
'example.de',
'example.fr',
'example.ru',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('domains with paths — should block', () => {
it.each([
'example.com/reset',
'example.com/reset-password',
'click example.com/page',
'go to example.net/login',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('multi-part domains — should block', () => {
it.each([
'Foo.com.net',
'Foo.com.',
'Foo.mine.net',
'Foo.mine.ne',
'sub.example.com',
'login.example.co.uk',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('domain in sentence — should block', () => {
it.each([
'Reset your password at example.com',
'URGENT click example.com/reset',
'Visit example.org for details',
'go to mysite.io now',
])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('case insensitive — should block', () => {
it.each(['EXAMPLE.COM', 'Example.Com', 'example.COM'])('blocks "%s"', (value) => {
expect(containsDomain(value)).toBe(true);
});
});
describe('fake TLDs — should allow', () => {
it.each([
'Foo.mine',
'Foo.blarg',
'Foo.qqq',
'Foo.zz',
'Foo.abcd',
'Foo.abcde',
'Foo.abcdef',
'Foo.abcdefg',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('too short suffix — should allow', () => {
it.each(['Foo.a', 'Foo.c', 'A.B'])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('multi-part with fake TLD — should allow', () => {
it.each(['Foo.mine.', 'Foo.mine.n'])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('emails — should allow', () => {
it.each([
'user@example.com',
'admin@company.org',
'test@sub.domain.co.uk',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('normal names — should allow', () => {
it.each([
'John Smith',
'Dr. Smith',
'A. B. Charlie',
'John',
'Mary Jane',
"O'Brien",
'Jean-Pierre',
'José García',
])('allows "%s"', (value) => {
expect(containsDomain(value)).toBe(false);
});
});
describe('IP addresses — should allow', () => {
it.each(['192.168.1.1', '10.0.0.1', '127.0.0.1'])(
'allows "%s"',
(value) => {
expect(containsDomain(value)).toBe(false);
},
);
});
describe('edge cases — should allow', () => {
it.each(['', ' ', '.', '..', 'hello', '.com', 'a.b'])(
'allows "%s"',
(value) => {
expect(containsDomain(value)).toBe(false);
},
);
});
});
@@ -0,0 +1,42 @@
import { registerDecorator, ValidationOptions } from 'class-validator';
import * as tlds from 'tlds';
const URL_PATTERN = /https?:\/\//i;
const tldSet = new Set(tlds.map((t) => t.toLowerCase()));
export function containsDomain(value: string): boolean {
const tokens = value.split(/\s+/);
for (const token of tokens) {
if (token.includes('@')) continue;
const segments = token.split('.');
for (let i = 1; i < segments.length; i++) {
const suffix = segments[i].replace(/[^\w].*/g, '');
if (segments[i - 1] && suffix && tldSet.has(suffix.toLowerCase())) {
return true;
}
}
}
return false;
}
export function NoUrls(validationOptions?: ValidationOptions) {
return function (object: object, propertyName: string) {
registerDecorator({
name: 'noUrls',
target: object.constructor,
propertyName,
options: {
message: 'Must not contain URLs or domain names',
...validationOptions,
},
validator: {
validate(value: unknown) {
if (typeof value !== 'string') return true;
if (URL_PATTERN.test(value)) return false;
if (containsDomain(value)) return false;
return true;
},
},
});
};
}
@@ -1,3 +1,4 @@
export enum UserTokenType { export enum UserTokenType {
FORGOT_PASSWORD = 'forgot-password', FORGOT_PASSWORD = 'forgot-password',
EMAIL_VERIFICATION = 'email-verification',
} }
+32
View File
@@ -1,5 +1,37 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { Workspace } from '@docmost/db/types/entity.types'; import { Workspace } from '@docmost/db/types/entity.types';
import { createHmac } from 'node:crypto';
export function computeEmailSignature(
email: string,
workspaceId: string,
appSecret: string,
): string {
return createHmac('sha256', appSecret)
.update(`${email.toLowerCase()}:${workspaceId}`)
.digest('hex');
}
export function throwIfEmailNotVerified(opts: {
isCloud: boolean;
emailVerifiedAt: Date | null;
email: string;
workspaceId: string;
appSecret: string;
}): void {
if (!opts.isCloud || opts.emailVerifiedAt) return;
const emailSignature = computeEmailSignature(
opts.email,
opts.workspaceId,
opts.appSecret,
);
throw new BadRequestException({
message:
'Please verify your email address. Check your inbox for the verification link.',
emailSignature,
});
}
export function validateSsoEnforcement(workspace: Workspace) { export function validateSsoEnforcement(workspace: Workspace) {
if (workspace.enforceSso) { if (workspace.enforceSso) {
@@ -7,11 +7,13 @@ import {
} from 'class-validator'; } from 'class-validator';
import { CreateUserDto } from './create-user.dto'; import { CreateUserDto } from './create-user.dto';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateAdminUserDto extends CreateUserDto { export class CreateAdminUserDto extends CreateUserDto {
@IsNotEmpty() @IsNotEmpty()
@MinLength(1) @MinLength(1)
@MaxLength(50) @MaxLength(50)
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim()) @Transform(({ value }: TransformFnParams) => value?.trim())
name: string; name: string;
@@ -7,12 +7,14 @@ import {
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { Transform, TransformFnParams } from 'class-transformer'; import { Transform, TransformFnParams } from 'class-transformer';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class CreateUserDto { export class CreateUserDto {
@IsOptional() @IsOptional()
@MinLength(1) @MinLength(1)
@MaxLength(50) @MaxLength(50)
@IsString() @IsString()
@NoUrls()
@Transform(({ value }: TransformFnParams) => value?.trim()) @Transform(({ value }: TransformFnParams) => value?.trim())
name: string; name: string;
@@ -17,6 +17,7 @@ import {
isUserDisabled, isUserDisabled,
nanoIdGen, nanoIdGen,
} from '../../../common/helpers'; } from '../../../common/helpers';
import { throwIfEmailNotVerified } from '../auth.util';
import { ChangePasswordDto } from '../dto/change-password.dto'; import { ChangePasswordDto } from '../dto/change-password.dto';
import { MailService } from '../../../integrations/mail/mail.service'; import { MailService } from '../../../integrations/mail/mail.service';
import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email'; import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email';
@@ -36,6 +37,7 @@ import {
AUDIT_SERVICE, AUDIT_SERVICE,
IAuditService, IAuditService,
} from '../../../integrations/audit/audit.service'; } from '../../../integrations/audit/audit.service';
import { EnvironmentService } from '../../../integrations/environment/environment.service';
@Injectable() @Injectable()
export class AuthService { export class AuthService {
@@ -46,6 +48,7 @@ export class AuthService {
private userTokenRepo: UserTokenRepo, private userTokenRepo: UserTokenRepo,
private mailService: MailService, private mailService: MailService,
private domainService: DomainService, private domainService: DomainService,
private environmentService: EnvironmentService,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
) {} ) {}
@@ -69,6 +72,14 @@ export class AuthService {
throw new UnauthorizedException(errorMessage); throw new UnauthorizedException(errorMessage);
} }
throwIfEmailNotVerified({
isCloud: this.environmentService.isCloud(),
emailVerifiedAt: user.emailVerifiedAt,
email: user.email,
workspaceId,
appSecret: this.environmentService.getAppSecret(),
});
user.lastLoginAt = new Date(); user.lastLoginAt = new Date();
await this.userRepo.updateLastLogin(user.id, workspaceId); await this.userRepo.updateLastLogin(user.id, workspaceId);
@@ -247,6 +258,14 @@ export class AuthService {
template: emailTemplate, template: emailTemplate,
}); });
if (this.environmentService.isCloud() && !user.emailVerifiedAt) {
await this.userRepo.updateUser(
{ emailVerifiedAt: new Date() },
user.id,
workspace.id,
);
}
// Check if user has MFA enabled or workspace enforces MFA // Check if user has MFA enabled or workspace enforces MFA
const userHasMfa = user?.['mfa']?.isEnabled || false; const userHasMfa = user?.['mfa']?.isEnabled || false;
const workspaceEnforcesMfa = workspace.enforceMfa || false; const workspaceEnforcesMfa = workspace.enforceMfa || false;
@@ -12,6 +12,7 @@ import {
MinLength, MinLength,
} from 'class-validator'; } from 'class-validator';
import { UserRole } from '../../../common/helpers/types/permission'; import { UserRole } from '../../../common/helpers/types/permission';
import { NoUrls } from '../../../common/validators/no-urls.validator';
export class InviteUserDto { export class InviteUserDto {
@IsArray() @IsArray()
@@ -44,6 +45,7 @@ export class AcceptInviteDto extends InvitationIdDto {
@MinLength(2) @MinLength(2)
@MaxLength(60) @MaxLength(60)
@IsString() @IsString()
@NoUrls()
name: string; name: string;
@MinLength(8) @MinLength(8)
@@ -244,7 +244,7 @@ export class WorkspaceService {
await this.billingQueue.add( await this.billingQueue.add(
QueueJob.WELCOME_EMAIL, QueueJob.WELCOME_EMAIL,
{ userId: user.id }, { userId: user.id },
{ delay: 60 * 1000 }, // 1m { delay: 30 * 60 * 1000 }, // 30m
); );
} catch (err) { } catch (err) {
this.logger.error(err); this.logger.error(err);
@@ -10,7 +10,7 @@ import {
validateSync, validateSync,
} from 'class-validator'; } from 'class-validator';
import { plainToInstance } from 'class-transformer'; import { plainToInstance } from 'class-transformer';
import { IsISO6391 } from '../../common/validator/is-iso6391'; import { IsISO6391 } from '../../common/validators/is-iso6391';
export class EnvironmentVariables { export class EnvironmentVariables {
@IsNotEmpty() @IsNotEmpty()
+1
View File
@@ -67,6 +67,7 @@ async function bootstrap() {
'/api/sso/google', '/api/sso/google',
'/api/workspace/create', '/api/workspace/create',
'/api/workspace/joined', '/api/workspace/joined',
'/api/workspace/find-by-email',
]; ];
if ( if (
+9
View File
@@ -681,6 +681,9 @@ importers:
stripe: stripe:
specifier: ^17.5.0 specifier: ^17.5.0
version: 17.5.0 version: 17.5.0
tlds:
specifier: ^1.261.0
version: 1.261.0
tmp-promise: tmp-promise:
specifier: ^3.0.3 specifier: ^3.0.3
version: 3.0.3 version: 3.0.3
@@ -9837,6 +9840,10 @@ packages:
tiptap-extension-global-drag-handle@0.1.18: tiptap-extension-global-drag-handle@0.1.18:
resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==} resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==}
tlds@1.261.0:
resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==}
hasBin: true
tldts-core@6.1.72: tldts-core@6.1.72:
resolution: {integrity: sha512-FW3H9aCaGTJ8l8RVCR3EX8GxsxDbQXuwetwwgXA2chYdsX+NY1ytCBl61narjjehWmCw92tc1AxlcY3668CU8g==} resolution: {integrity: sha512-FW3H9aCaGTJ8l8RVCR3EX8GxsxDbQXuwetwwgXA2chYdsX+NY1ytCBl61narjjehWmCw92tc1AxlcY3668CU8g==}
@@ -21145,6 +21152,8 @@ snapshots:
tiptap-extension-global-drag-handle@0.1.18: {} tiptap-extension-global-drag-handle@0.1.18: {}
tlds@1.261.0: {}
tldts-core@6.1.72: {} tldts-core@6.1.72: {}
tldts@6.1.72: tldts@6.1.72: