From 97c459be670562b79869463bb7177341491ed7a5 Mon Sep 17 00:00:00 2001 From: Philip Okugbe <16838612+Philipinho@users.noreply.github.com> Date: Sat, 14 Mar 2026 13:36:30 +0000 Subject: [PATCH] feat(cloud): add find-workspace and email verification endpoints (#2020) * feat: add find-workspace and email verification endpoints * sync --- .../public/locales/en-US/translation.json | 13 +- apps/client/src/App.tsx | 2 + .../src/ee/cloud/service/cloud-service.ts | 12 ++ .../src/ee/components/cloud-login-form.tsx | 59 ++++++++ apps/client/src/ee/pages/verify-email.tsx | 107 +++++++++++++ .../auth/components/setup-workspace-form.tsx | 8 +- .../src/features/auth/hooks/use-auth.ts | 26 +++- .../features/auth/services/auth-service.ts | 3 +- .../workspace/services/workspace-service.ts | 2 +- apps/client/src/lib/app-route.ts | 1 + apps/server/package.json | 1 + .../{validator => validators}/is-iso6391.ts | 0 .../validators/no-urls.validator.spec.ts | 142 ++++++++++++++++++ .../common/validators/no-urls.validator.ts | 42 ++++++ apps/server/src/core/auth/auth.constants.ts | 1 + apps/server/src/core/auth/auth.util.ts | 32 ++++ .../core/auth/dto/create-admin-user.dto.ts | 2 + .../src/core/auth/dto/create-user.dto.ts | 2 + .../src/core/auth/services/auth.service.ts | 19 +++ .../src/core/workspace/dto/invitation.dto.ts | 2 + .../workspace/services/workspace.service.ts | 2 +- apps/server/src/ee | 2 +- .../environment/environment.validation.ts | 2 +- apps/server/src/main.ts | 1 + pnpm-lock.yaml | 9 ++ 25 files changed, 479 insertions(+), 13 deletions(-) create mode 100644 apps/client/src/ee/pages/verify-email.tsx rename apps/server/src/common/{validator => validators}/is-iso6391.ts (100%) create mode 100644 apps/server/src/common/validators/no-urls.validator.spec.ts create mode 100644 apps/server/src/common/validators/no-urls.validator.ts diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index cd2b7559..da0f0b81 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -693,5 +693,16 @@ "Failed to update trash retention": "Failed to update trash retention", "Removed page restriction": "Removed page restriction", "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." } diff --git a/apps/client/src/App.tsx b/apps/client/src/App.tsx index c290157c..b99e63b3 100644 --- a/apps/client/src/App.tsx +++ b/apps/client/src/App.tsx @@ -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 AiSettings from "@/ee/ai/pages/ai-settings.tsx"; import AuditLogs from "@/ee/audit/pages/audit-logs.tsx"; +import VerifyEmail from "@/ee/pages/verify-email.tsx"; export default function App() { const { t } = useTranslation(); @@ -63,6 +64,7 @@ export default function App() { <> } /> } /> + } /> )} diff --git a/apps/client/src/ee/cloud/service/cloud-service.ts b/apps/client/src/ee/cloud/service/cloud-service.ts index e544733e..5411b802 100644 --- a/apps/client/src/ee/cloud/service/cloud-service.ts +++ b/apps/client/src/ee/cloud/service/cloud-service.ts @@ -5,3 +5,15 @@ export async function getJoinedWorkspaces(): Promise> { const req = await api.post>("/workspace/joined"); return req.data; } + +export async function findWorkspacesByEmail(email: string): Promise { + await api.post("/workspace/find-by-email", { email }); +} + +export async function verifyEmail(data: { token: string }): Promise { + await api.post("/workspace/verify-email", data); +} + +export async function resendVerificationEmail(data: { email: string; sig: string }): Promise { + await api.post("/workspace/resend-verification", data); +} diff --git a/apps/client/src/ee/components/cloud-login-form.tsx b/apps/client/src/ee/components/cloud-login-form.tsx index 01ddd031..6ab7ebd9 100644 --- a/apps/client/src/ee/components/cloud-login-form.tsx +++ b/apps/client/src/ee/components/cloud-login-form.tsx @@ -20,14 +20,21 @@ import APP_ROUTE from "@/lib/app-route.ts"; import { useTranslation } from "react-i18next"; import JoinedWorkspaces from "@/ee/components/joined-workspaces.tsx"; import { useJoinedWorkspacesQuery } from "@/ee/cloud/query/cloud-query.ts"; +import { findWorkspacesByEmail } from "@/ee/cloud/service/cloud-service.ts"; const formSchema = z.object({ 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() { const { t } = useTranslation(); const [isLoading, setIsLoading] = useState(false); + const [isFindLoading, setIsFindLoading] = useState(false); + const [findEmailSent, setFindEmailSent] = useState(false); const { data: joinedWorkspaces } = useJoinedWorkspacesQuery(); const form = useForm({ @@ -37,6 +44,13 @@ export function CloudLoginForm() { }, }); + const findForm = useForm({ + validate: zod4Resolver(findWorkspaceSchema), + initialValues: { + email: "", + }, + }); + async function onSubmit(data: { hostname: string }) { setIsLoading(true); @@ -54,6 +68,19 @@ export function CloudLoginForm() { 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 (
@@ -83,6 +110,38 @@ export function CloudLoginForm() { {t("Continue")} + + + + {findEmailSent ? ( + + {t("We've sent you an email with your associated workspaces.")} + + ) : ( +
+ + {t("Find your workspaces")} + + + + + )}
diff --git a/apps/client/src/ee/pages/verify-email.tsx b/apps/client/src/ee/pages/verify-email.tsx new file mode 100644 index 00000000..623c3202 --- /dev/null +++ b/apps/client/src/ee/pages/verify-email.tsx @@ -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 ( + + + + {t("Verifying your email")} + + + {t("Please wait...")} + + + + ); + } + + return ( + + + + {t("Check your email")} + + + {email + ? t("We sent a verification link to {{email}}.", { email }) + : t("We sent a verification link to your email.")} + + + {t("Click the link to verify your email and access your workspace.")} + + {email && sig && !resent && ( + + )} + {resent && ( + + {t("Verification email sent. Please check your inbox.")} + + )} + + + ); +} diff --git a/apps/client/src/features/auth/components/setup-workspace-form.tsx b/apps/client/src/features/auth/components/setup-workspace-form.tsx index 261412a9..6eaf3d12 100644 --- a/apps/client/src/features/auth/components/setup-workspace-form.tsx +++ b/apps/client/src/features/auth/components/setup-workspace-form.tsx @@ -22,11 +22,11 @@ import APP_ROUTE from "@/lib/app-route.ts"; const formSchema = z.object({ 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() - .min(1, { message: "email is required" }), - password: z.string().min(8), + .email({ message: "Invalid email address" }) + .min(1, { message: "Email is required" }), + password: z.string().min(8, { message: "Password must be at least 8 characters" }), }); type FormValues = z.infer; diff --git a/apps/client/src/features/auth/hooks/use-auth.ts b/apps/client/src/features/auth/hooks/use-auth.ts index 6e1b4e34..411e04b4 100644 --- a/apps/client/src/features/auth/hooks/use-auth.ts +++ b/apps/client/src/features/auth/hooks/use-auth.ts @@ -27,7 +27,7 @@ import APP_ROUTE, { getPostLoginRedirect } from "@/lib/app-route.ts"; import { RESET } from "jotai/utils"; import { useTranslation } from "react-i18next"; import { isCloud } from "@/lib/config.ts"; -import { exchangeTokenRedirectUrl } from "@/ee/utils.ts"; +import { exchangeTokenRedirectUrl, getHostnameUrl } from "@/ee/utils.ts"; export default function useAuth() { const { t } = useTranslation(); @@ -52,9 +52,18 @@ export default function useAuth() { } } catch (err) { 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({ - message: err.response?.data.message, + message, color: "red", }); } @@ -92,6 +101,17 @@ export default function useAuth() { try { if (isCloud()) { 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 exchangeToken = res?.exchangeToken; if (hostname && exchangeToken) { diff --git a/apps/client/src/features/auth/services/auth-service.ts b/apps/client/src/features/auth/services/auth-service.ts index 20e437f3..20552d56 100644 --- a/apps/client/src/features/auth/services/auth-service.ts +++ b/apps/client/src/features/auth/services/auth-service.ts @@ -50,4 +50,5 @@ export async function verifyUserToken(data: IVerifyUserToken): Promise { export async function getCollabToken(): Promise { const req = await api.post("/auth/collab-token"); return req.data; -} \ No newline at end of file +} + diff --git a/apps/client/src/features/workspace/services/workspace-service.ts b/apps/client/src/features/workspace/services/workspace-service.ts index 20da1146..0ffd6f23 100644 --- a/apps/client/src/features/workspace/services/workspace-service.ts +++ b/apps/client/src/features/workspace/services/workspace-service.ts @@ -113,7 +113,7 @@ export async function getInvitationById(data: { export async function createWorkspace( data: ISetupWorkspace, -): Promise<{ workspace: IWorkspace } & { exchangeToken: string }> { +): Promise<{ workspace: IWorkspace; exchangeToken?: string; requiresEmailVerification?: boolean; emailSignature?: string }> { const req = await api.post("/workspace/create", data); return req.data; } diff --git a/apps/client/src/lib/app-route.ts b/apps/client/src/lib/app-route.ts index c4a13093..630dd048 100644 --- a/apps/client/src/lib/app-route.ts +++ b/apps/client/src/lib/app-route.ts @@ -12,6 +12,7 @@ const APP_ROUTE = { SELECT_WORKSPACE: "/select", MFA_CHALLENGE: "/login/mfa", MFA_SETUP_REQUIRED: "/login/mfa/setup", + VERIFY_EMAIL: "/verify-email", }, SETTINGS: { ACCOUNT: { diff --git a/apps/server/package.json b/apps/server/package.json index 5cffe556..083f8a07 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -107,6 +107,7 @@ "sanitize-filename-ts": "1.0.2", "socket.io": "^4.8.3", "stripe": "^17.5.0", + "tlds": "^1.261.0", "tmp-promise": "^3.0.3", "tseep": "^1.3.1", "typesense": "^2.1.0", diff --git a/apps/server/src/common/validator/is-iso6391.ts b/apps/server/src/common/validators/is-iso6391.ts similarity index 100% rename from apps/server/src/common/validator/is-iso6391.ts rename to apps/server/src/common/validators/is-iso6391.ts diff --git a/apps/server/src/common/validators/no-urls.validator.spec.ts b/apps/server/src/common/validators/no-urls.validator.spec.ts new file mode 100644 index 00000000..07ad5721 --- /dev/null +++ b/apps/server/src/common/validators/no-urls.validator.spec.ts @@ -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); + }, + ); + }); +}); diff --git a/apps/server/src/common/validators/no-urls.validator.ts b/apps/server/src/common/validators/no-urls.validator.ts new file mode 100644 index 00000000..c26faecc --- /dev/null +++ b/apps/server/src/common/validators/no-urls.validator.ts @@ -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; + }, + }, + }); + }; +} diff --git a/apps/server/src/core/auth/auth.constants.ts b/apps/server/src/core/auth/auth.constants.ts index 555149c4..fda2346e 100644 --- a/apps/server/src/core/auth/auth.constants.ts +++ b/apps/server/src/core/auth/auth.constants.ts @@ -1,3 +1,4 @@ export enum UserTokenType { FORGOT_PASSWORD = 'forgot-password', + EMAIL_VERIFICATION = 'email-verification', } diff --git a/apps/server/src/core/auth/auth.util.ts b/apps/server/src/core/auth/auth.util.ts index cdd46e9b..e2de9bf2 100644 --- a/apps/server/src/core/auth/auth.util.ts +++ b/apps/server/src/core/auth/auth.util.ts @@ -1,5 +1,37 @@ import { BadRequestException } from '@nestjs/common'; 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) { if (workspace.enforceSso) { diff --git a/apps/server/src/core/auth/dto/create-admin-user.dto.ts b/apps/server/src/core/auth/dto/create-admin-user.dto.ts index bdea75fe..9580f98f 100644 --- a/apps/server/src/core/auth/dto/create-admin-user.dto.ts +++ b/apps/server/src/core/auth/dto/create-admin-user.dto.ts @@ -7,11 +7,13 @@ import { } from 'class-validator'; import { CreateUserDto } from './create-user.dto'; import { Transform, TransformFnParams } from 'class-transformer'; +import { NoUrls } from '../../../common/validators/no-urls.validator'; export class CreateAdminUserDto extends CreateUserDto { @IsNotEmpty() @MinLength(1) @MaxLength(50) + @NoUrls() @Transform(({ value }: TransformFnParams) => value?.trim()) name: string; diff --git a/apps/server/src/core/auth/dto/create-user.dto.ts b/apps/server/src/core/auth/dto/create-user.dto.ts index 3362c722..bd432904 100644 --- a/apps/server/src/core/auth/dto/create-user.dto.ts +++ b/apps/server/src/core/auth/dto/create-user.dto.ts @@ -7,12 +7,14 @@ import { MinLength, } from 'class-validator'; import { Transform, TransformFnParams } from 'class-transformer'; +import { NoUrls } from '../../../common/validators/no-urls.validator'; export class CreateUserDto { @IsOptional() @MinLength(1) @MaxLength(50) @IsString() + @NoUrls() @Transform(({ value }: TransformFnParams) => value?.trim()) name: string; diff --git a/apps/server/src/core/auth/services/auth.service.ts b/apps/server/src/core/auth/services/auth.service.ts index bc907b8e..1bb2c5ee 100644 --- a/apps/server/src/core/auth/services/auth.service.ts +++ b/apps/server/src/core/auth/services/auth.service.ts @@ -17,6 +17,7 @@ import { isUserDisabled, nanoIdGen, } from '../../../common/helpers'; +import { throwIfEmailNotVerified } from '../auth.util'; import { ChangePasswordDto } from '../dto/change-password.dto'; import { MailService } from '../../../integrations/mail/mail.service'; import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email'; @@ -36,6 +37,7 @@ import { AUDIT_SERVICE, IAuditService, } from '../../../integrations/audit/audit.service'; +import { EnvironmentService } from '../../../integrations/environment/environment.service'; @Injectable() export class AuthService { @@ -46,6 +48,7 @@ export class AuthService { private userTokenRepo: UserTokenRepo, private mailService: MailService, private domainService: DomainService, + private environmentService: EnvironmentService, @InjectKysely() private readonly db: KyselyDB, @Inject(AUDIT_SERVICE) private readonly auditService: IAuditService, ) {} @@ -69,6 +72,14 @@ export class AuthService { throw new UnauthorizedException(errorMessage); } + throwIfEmailNotVerified({ + isCloud: this.environmentService.isCloud(), + emailVerifiedAt: user.emailVerifiedAt, + email: user.email, + workspaceId, + appSecret: this.environmentService.getAppSecret(), + }); + user.lastLoginAt = new Date(); await this.userRepo.updateLastLogin(user.id, workspaceId); @@ -247,6 +258,14 @@ export class AuthService { 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 const userHasMfa = user?.['mfa']?.isEnabled || false; const workspaceEnforcesMfa = workspace.enforceMfa || false; diff --git a/apps/server/src/core/workspace/dto/invitation.dto.ts b/apps/server/src/core/workspace/dto/invitation.dto.ts index 8e5cccac..187688c4 100644 --- a/apps/server/src/core/workspace/dto/invitation.dto.ts +++ b/apps/server/src/core/workspace/dto/invitation.dto.ts @@ -12,6 +12,7 @@ import { MinLength, } from 'class-validator'; import { UserRole } from '../../../common/helpers/types/permission'; +import { NoUrls } from '../../../common/validators/no-urls.validator'; export class InviteUserDto { @IsArray() @@ -44,6 +45,7 @@ export class AcceptInviteDto extends InvitationIdDto { @MinLength(2) @MaxLength(60) @IsString() + @NoUrls() name: string; @MinLength(8) diff --git a/apps/server/src/core/workspace/services/workspace.service.ts b/apps/server/src/core/workspace/services/workspace.service.ts index 2ef80590..495057a0 100644 --- a/apps/server/src/core/workspace/services/workspace.service.ts +++ b/apps/server/src/core/workspace/services/workspace.service.ts @@ -244,7 +244,7 @@ export class WorkspaceService { await this.billingQueue.add( QueueJob.WELCOME_EMAIL, { userId: user.id }, - { delay: 60 * 1000 }, // 1m + { delay: 30 * 60 * 1000 }, // 30m ); } catch (err) { this.logger.error(err); diff --git a/apps/server/src/ee b/apps/server/src/ee index 62a8a7e5..52ac3a79 160000 --- a/apps/server/src/ee +++ b/apps/server/src/ee @@ -1 +1 @@ -Subproject commit 62a8a7e548a17b8e3baf4dfdc90f2f432a691ee0 +Subproject commit 52ac3a79de56472a1f77b12ea4cd4c07fd5f5d69 diff --git a/apps/server/src/integrations/environment/environment.validation.ts b/apps/server/src/integrations/environment/environment.validation.ts index 041d0f4c..5c307da2 100644 --- a/apps/server/src/integrations/environment/environment.validation.ts +++ b/apps/server/src/integrations/environment/environment.validation.ts @@ -10,7 +10,7 @@ import { validateSync, } from 'class-validator'; import { plainToInstance } from 'class-transformer'; -import { IsISO6391 } from '../../common/validator/is-iso6391'; +import { IsISO6391 } from '../../common/validators/is-iso6391'; export class EnvironmentVariables { @IsNotEmpty() diff --git a/apps/server/src/main.ts b/apps/server/src/main.ts index fca457e5..0f2a82a1 100644 --- a/apps/server/src/main.ts +++ b/apps/server/src/main.ts @@ -67,6 +67,7 @@ async function bootstrap() { '/api/sso/google', '/api/workspace/create', '/api/workspace/joined', + '/api/workspace/find-by-email', ]; if ( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2049ad4e..30535f29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -681,6 +681,9 @@ importers: stripe: specifier: ^17.5.0 version: 17.5.0 + tlds: + specifier: ^1.261.0 + version: 1.261.0 tmp-promise: specifier: ^3.0.3 version: 3.0.3 @@ -9837,6 +9840,10 @@ packages: tiptap-extension-global-drag-handle@0.1.18: resolution: {integrity: sha512-jwFuy1K8DP3a4bFy76Hpc63w1Sil0B7uZ3mvhQomVvUFCU787Lg2FowNhn7NFzeyok761qY2VG+PZ/FDthWUdg==} + tlds@1.261.0: + resolution: {integrity: sha512-QXqwfEl9ddlGBaRFXIvNKK6OhipSiLXuRuLJX5DErz0o0Q0rYxulWLdFryTkV5PkdZct5iMInwYEGe/eR++1AA==} + hasBin: true + tldts-core@6.1.72: resolution: {integrity: sha512-FW3H9aCaGTJ8l8RVCR3EX8GxsxDbQXuwetwwgXA2chYdsX+NY1ytCBl61narjjehWmCw92tc1AxlcY3668CU8g==} @@ -21145,6 +21152,8 @@ snapshots: tiptap-extension-global-drag-handle@0.1.18: {} + tlds@1.261.0: {} + tldts-core@6.1.72: {} tldts@6.1.72: