mirror of
https://github.com/docmost/docmost.git
synced 2026-05-07 06:23:06 +08:00
feat(cloud): add find-workspace and email verification endpoints (#2020)
* feat: add find-workspace and email verification endpoints * sync
This commit is contained in:
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 62a8a7e548...52ac3a79de
@@ -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()
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
Generated
+9
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user