Merge branch 'main' into fix/conf-anchor

This commit is contained in:
Philipinho
2026-03-14 16:34:41 +00:00
43 changed files with 1079 additions and 277 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;
} }
@@ -24,7 +24,12 @@ function CommentActions({
</Button> </Button>
)} )}
<Button size="compact-sm" loading={isLoading} onClick={onSave}> <Button
size="compact-sm"
loading={isLoading}
onClick={onSave}
onMouseDown={(e) => e.preventDefault()}
>
{t("Save")} {t("Save")}
</Button> </Button>
</Group> </Group>
@@ -27,6 +27,9 @@ import { extractPageSlugId } from "@/lib";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { IconArrowUp, IconMessageOff } from "@tabler/icons-react"; import { IconArrowUp, IconMessageOff } from "@tabler/icons-react";
import { useAtom } from "jotai";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
function CommentListWithTabs() { function CommentListWithTabs() {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -345,6 +348,7 @@ const PageCommentInput = ({ onSave, isLoading }) => {
const [content, setContent] = useState(""); const [content, setContent] = useState("");
const { ref, focused } = useFocusWithin(); const { ref, focused } = useFocusWithin();
const commentEditorRef = useRef(null); const commentEditorRef = useRef(null);
const [currentUser] = useAtom(currentUserAtom);
const handleSave = useCallback(() => { const handleSave = useCallback(() => {
onSave(null, content); onSave(null, content);
@@ -363,19 +367,30 @@ const PageCommentInput = ({ onSave, isLoading }) => {
position: "relative", position: "relative",
}} }}
> >
<CommentEditor <Group wrap="nowrap" align="flex-start" gap="xs">
ref={commentEditorRef} <CustomAvatar
onUpdate={setContent} size="sm"
onSave={handleSave} avatarUrl={currentUser?.user?.avatarUrl}
editable={true} name={currentUser?.user?.name}
placeholder={t("Add a comment...")} style={{ flexShrink: 0, marginTop: 10 }}
/> />
<div style={{ flex: 1, minWidth: 0 }}>
<CommentEditor
ref={commentEditorRef}
onUpdate={setContent}
onSave={handleSave}
editable={true}
placeholder={t("Add a comment...")}
/>
</div>
</Group>
{focused && ( {focused && (
<ActionIcon <ActionIcon
variant="filled" variant="filled"
radius="xl" radius="xl"
size="sm" size="sm"
onClick={handleSave} onClick={handleSave}
onMouseDown={(e) => e.preventDefault()}
loading={isLoading} loading={isLoading}
style={{ position: "absolute", right: 8, bottom: 30 }} style={{ position: "absolute", right: 8, bottom: 30 }}
> >
@@ -1,6 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { useCallback, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -9,6 +9,7 @@ import {
import { import {
ActionIcon, ActionIcon,
Modal, Modal,
Text,
Tooltip, Tooltip,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
@@ -29,10 +30,12 @@ import {
DrawIoEmbed, DrawIoEmbed,
DrawIoEmbedRef, DrawIoEmbedRef,
EventExit, EventExit,
EventExport,
EventSave, EventSave,
} from "react-drawio"; } from "react-drawio";
import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils"; import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
import { modals } from "@mantine/modals";
import classes from "../common/toolbar-menu.module.css"; import classes from "../common/toolbar-menu.module.css";
export function DrawioMenu({ editor }: EditorMenuProps) { export function DrawioMenu({ editor }: EditorMenuProps) {
@@ -41,6 +44,8 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
const [initialXML, setInitialXML] = useState<string>(""); const [initialXML, setInitialXML] = useState<string>("");
const drawioRef = useRef<DrawIoEmbedRef>(null); const drawioRef = useRef<DrawIoEmbedRef>(null);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -131,33 +136,13 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
editor.commands.deleteSelection(); editor.commands.deleteSelection();
}, [editor]); }, [editor]);
const handleOpen = useCallback(async () => { const saveData = useCallback(async (svgXml: string) => {
if (!editorState?.src) return; if (isSavingRef.current) return;
isSavingRef.current = true;
try { try {
const url = getFileUrl(editorState.src); const svgString = decodeBase64ToSvgString(svgXml);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
} catch (err) {
console.error(err);
} finally {
open();
}
}, [editorState?.src, open]);
const handleSave = useCallback(
async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = "diagram.drawio.svg"; const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName); const drawioSVGFile = await svgStringToFile(svgString, fileName);
@@ -179,10 +164,85 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
attachmentId: attachment.id, attachmentId: attachment.id,
}); });
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [editor, editorState?.attachmentId]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close(); close();
}, return;
[editor, editorState?.attachmentId, close], }
);
modals.openConfirmModal({
title: t("Unsaved changes"),
children: (
<Text size="sm">
{t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
});
}, [close, t]);
const handleOpen = useCallback(async () => {
if (!editorState?.src) return;
try {
const url = getFileUrl(editorState.src);
const request = await fetch(url, {
credentials: "include",
cache: "no-store",
});
const blob = await request.blob();
const reader = new FileReader();
reader.readAsDataURL(blob);
reader.onloadend = () => {
const base64data = (reader.result || "") as string;
setInitialXML(base64data);
};
} catch (err) {
console.error(err);
} finally {
isDirtyRef.current = false;
open();
}
}, [editorState?.src, open]);
useEffect(() => {
if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current && drawioRef.current) {
drawioRef.current.exportDiagram({ format: "xmlsvg" });
}
}, 60_000);
return () => clearInterval(interval);
}, [opened]);
useEffect(() => {
if (!opened) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleClose();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [opened, handleClose]);
return ( return (
<> <>
@@ -276,7 +336,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
</div> </div>
</BaseBubbleMenu> </BaseBubbleMenu>
<Modal.Root opened={opened} onClose={close} fullScreen> <Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body> <Modal.Body>
@@ -285,6 +345,7 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
ref={drawioRef} ref={drawioRef}
xml={initialXML} xml={initialXML}
baseUrl={getDrawioUrl()} baseUrl={getDrawioUrl()}
autosave
urlParameters={{ urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark", ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true, spin: true,
@@ -296,13 +357,19 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
if (data.parentEvent !== "save") { if (data.parentEvent !== "save") {
return; return;
} }
handleSave(data); saveData(data.xml).then(() => close()).catch(() => {});
}} }}
onClose={(data: EventExit) => { onClose={(data: EventExit) => {
if (data.parentEvent) { if (data.parentEvent) {
return; return;
} }
close(); handleClose();
}}
onAutoSave={() => {
isDirtyRef.current = true;
}}
onExport={(data: EventExport) => {
saveData(data.data).catch(() => {});
}} }}
/> />
</div> </div>
@@ -6,7 +6,7 @@ import {
Text, Text,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import { getDrawioUrl } from "@/lib/config.ts"; import { getDrawioUrl } from "@/lib/config.ts";
@@ -14,6 +14,7 @@ import {
DrawIoEmbed, DrawIoEmbed,
DrawIoEmbedRef, DrawIoEmbedRef,
EventExit, EventExit,
EventExport,
EventSave, EventSave,
} from "react-drawio"; } from "react-drawio";
import { IAttachment } from "@/features/attachments/types/attachment.types"; import { IAttachment } from "@/features/attachments/types/attachment.types";
@@ -21,6 +22,7 @@ import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
import clsx from "clsx"; import clsx from "clsx";
import { IconEdit } from "@tabler/icons-react"; import { IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { modals } from "@mantine/modals";
export default function DrawioView(props: NodeViewProps) { export default function DrawioView(props: NodeViewProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -30,42 +32,108 @@ export default function DrawioView(props: NodeViewProps) {
const [initialXML, setInitialXML] = useState<string>(""); const [initialXML, setInitialXML] = useState<string>("");
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const handleOpen = async () => { const handleOpen = async () => {
if (!editor.isEditable) { if (!editor.isEditable) {
return; return;
} }
isDirtyRef.current = false;
open(); open();
}; };
const handleSave = async (data: EventSave) => { const saveData = async (svgXml: string, updateSrc = true) => {
const svgString = decodeBase64ToSvgString(data.xml); if (isSavingRef.current) return;
const fileName = "diagram.drawio.svg";
const drawioSVGFile = await svgStringToFile(svgString, fileName);
//@ts-ignore isSavingRef.current = true;
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null; try {
if (attachmentId) { const svgString = decodeBase64ToSvgString(svgXml);
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId); const fileName = "diagram.drawio.svg";
} else { const drawioSVGFile = await svgStringToFile(svgString, fileName);
attachment = await uploadFile(drawioSVGFile, pageId);
//@ts-ignore
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else {
attachment = await uploadFile(drawioSVGFile, pageId);
}
if (updateSrc) {
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
} else {
updateAttributes({
attachmentId: attachment.id,
});
}
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
};
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
} }
updateAttributes({ modals.openConfirmModal({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, title: t("Unsaved changes"),
title: attachment.fileName, children: (
size: attachment.fileSize, <Text size="sm">
attachmentId: attachment.id, {t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
}); });
}, [close, t]);
close(); useEffect(() => {
}; if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current && drawioRef.current) {
drawioRef.current.exportDiagram({ format: "xmlsvg" });
}
}, 30_000);
return () => clearInterval(interval);
}, [opened]);
useEffect(() => {
if (!opened) return;
const onKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.preventDefault();
handleClose();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [opened, handleClose]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
<Modal.Root opened={opened} onClose={close} fullScreen> <Modal.Root opened={opened} onClose={handleClose} fullScreen closeOnEscape={false}>
<Modal.Overlay /> <Modal.Overlay />
<Modal.Content style={{ overflow: "hidden" }}> <Modal.Content style={{ overflow: "hidden" }}>
<Modal.Body> <Modal.Body>
@@ -74,6 +142,7 @@ export default function DrawioView(props: NodeViewProps) {
ref={drawioRef} ref={drawioRef}
xml={initialXML} xml={initialXML}
baseUrl={getDrawioUrl()} baseUrl={getDrawioUrl()}
autosave
urlParameters={{ urlParameters={{
ui: computedColorScheme === "light" ? "kennedy" : "dark", ui: computedColorScheme === "light" ? "kennedy" : "dark",
spin: true, spin: true,
@@ -85,13 +154,19 @@ export default function DrawioView(props: NodeViewProps) {
if (data.parentEvent !== "save") { if (data.parentEvent !== "save") {
return; return;
} }
handleSave(data); saveData(data.xml, true).then(() => close()).catch(() => {});
}} }}
onClose={(data: EventExit) => { onClose={(data: EventExit) => {
if (data.parentEvent) { if (data.parentEvent) {
return; return;
} }
close(); handleClose();
}}
onAutoSave={() => {
isDirtyRef.current = true;
}}
onExport={(data: EventExport) => {
saveData(data.data, false).catch(() => {});
}} }}
/> />
</div> </div>
@@ -1,6 +1,6 @@
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
import { lazy, Suspense, useCallback, useState } from "react"; import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
import { Node as PMNode } from "@tiptap/pm/model"; import { Node as PMNode } from "@tiptap/pm/model";
import { import {
EditorMenuProps, EditorMenuProps,
@@ -10,9 +10,11 @@ import {
ActionIcon, ActionIcon,
Button, Button,
Group, Group,
Text,
Tooltip, Tooltip,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { modals } from "@mantine/modals";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
import clsx from "clsx"; import clsx from "clsx";
import { import {
@@ -52,6 +54,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
}); });
const [excalidrawData, setExcalidrawData] = useState<any>(null); const [excalidrawData, setExcalidrawData] = useState<any>(null);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const isInitialLoadRef = useRef(true);
const lastFingerprintRef = useRef("");
const editorState = useEditorState({ const editorState = useEditorState({
editor, editor,
@@ -160,57 +166,109 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
isDirtyRef.current = false;
isInitialLoadRef.current = true;
open(); open();
} }
}, [editorState?.src, open]); }, [editorState?.src, open]);
const handleSave = useCallback(async () => { const saveData = useCallback(async () => {
if (!excalidrawAPI) { if (!excalidrawAPI || isSavingRef.current) {
return; return;
} }
const { exportToSvg } = await import("@excalidraw/excalidraw"); isSavingRef.current = true;
const svg = await exportToSvg({ try {
elements: excalidrawAPI?.getSceneElements(), const { exportToSvg } = await import("@excalidraw/excalidraw");
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer(); const svg = await exportToSvg({
let svgString = serializer.serializeToString(svg); elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
svgString = svgString.replace( const serializer = new XMLSerializer();
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g, let svgString = serializer.serializeToString(svg);
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = "diagram.excalidraw.svg"; svgString = svgString.replace(
const excalidrawSvgFile = await svgStringToFile(svgString, fileName); /https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
// @ts-ignore const fileName = "diagram.excalidraw.svg";
const pageId = editor.storage?.pageId; const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
const attachmentId = editorState?.attachmentId;
let attachment: IAttachment = null; // @ts-ignore
if (attachmentId) { const pageId = editor.storage?.pageId;
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId); const attachmentId = editorState?.attachmentId;
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId); let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
editor.commands.updateAttributes("excalidraw", {
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [editor, excalidrawAPI, editorState?.attachmentId]);
const handleSaveAndExit = useCallback(async () => {
try {
await saveData();
close();
} catch {
// save failed, modal stays open
}
}, [saveData, close]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
} }
editor.commands.updateAttributes("excalidraw", { modals.openConfirmModal({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, title: t("Unsaved changes"),
title: attachment.fileName, children: (
size: attachment.fileSize, <Text size="sm">
attachmentId: attachment.id, {t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
}); });
}, [close, t]);
close(); useEffect(() => {
}, [editor, excalidrawAPI, editorState?.attachmentId, close]); if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current) {
saveData().catch(() => {});
}
}, 60_000);
return () => clearInterval(interval);
}, [opened, saveData]);
return ( return (
<> <>
@@ -317,7 +375,7 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
zIndex: 200, zIndex: 200,
}} }}
isOpen={opened} isOpen={opened}
onRequestClose={close} onRequestClose={handleClose}
disableCloseOnBgClick={true} disableCloseOnBgClick={true}
contentProps={{ contentProps={{
style: { style: {
@@ -332,10 +390,10 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
p="xs" p="xs"
> >
<Button onClick={handleSave} size={"compact-sm"}> <Button onClick={handleSaveAndExit} size={"compact-sm"}>
{t("Save & Exit")} {t("Save & Exit")}
</Button> </Button>
<Button onClick={close} color="red" size={"compact-sm"}> <Button onClick={handleClose} color="red" size={"compact-sm"}>
{t("Exit")} {t("Exit")}
</Button> </Button>
</Group> </Group>
@@ -343,6 +401,18 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
<Suspense fallback={null}> <Suspense fallback={null}>
<ExcalidrawComponent <ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)} excalidrawAPI={(api) => setExcalidrawAPI(api)}
onChange={(elements, _appState, files) => {
const fingerprint = `${elements.length}:${elements.reduce((s, e) => s + (e.version || 0), 0)}:${Object.keys(files).length}`;
if (isInitialLoadRef.current) {
lastFingerprintRef.current = fingerprint;
isInitialLoadRef.current = false;
return;
}
if (fingerprint !== lastFingerprintRef.current) {
lastFingerprintRef.current = fingerprint;
isDirtyRef.current = true;
}
}}
initialData={{ initialData={{
...excalidrawData, ...excalidrawData,
scrollToContent: true, scrollToContent: true,
@@ -7,7 +7,14 @@ import {
Text, Text,
useComputedColorScheme, useComputedColorScheme,
} from "@mantine/core"; } from "@mantine/core";
import { lazy, Suspense, useState } from "react"; import {
lazy,
Suspense,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { uploadFile } from "@/features/page/services/page-service.ts"; import { uploadFile } from "@/features/page/services/page-service.ts";
import { svgStringToFile } from "@/lib"; import { svgStringToFile } from "@/lib";
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from "@mantine/hooks";
@@ -20,6 +27,7 @@ import { IconEdit } from "@tabler/icons-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useHandleLibrary } from "@excalidraw/excalidraw"; import { useHandleLibrary } from "@excalidraw/excalidraw";
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts"; import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.ts";
import { modals } from "@mantine/modals";
const ExcalidrawComponent = lazy(() => const ExcalidrawComponent = lazy(() =>
import("@excalidraw/excalidraw").then((module) => ({ import("@excalidraw/excalidraw").then((module) => ({
@@ -42,59 +50,122 @@ export default function ExcalidrawView(props: NodeViewProps) {
const [opened, { open, close }] = useDisclosure(false); const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme(); const computedColorScheme = useComputedColorScheme();
const isDirtyRef = useRef(false);
const isSavingRef = useRef(false);
const isInitialLoadRef = useRef(true);
const lastFingerprintRef = useRef("");
const handleOpen = async () => { const handleOpen = async () => {
if (!editor.isEditable) { if (!editor.isEditable) {
return; return;
} }
isDirtyRef.current = false;
isInitialLoadRef.current = true;
open(); open();
}; };
const handleSave = async () => { const saveData = useCallback(async (updateSrc = true) => {
if (!excalidrawAPI) { if (!excalidrawAPI || isSavingRef.current) {
return; return;
} }
const { exportToSvg } = await import("@excalidraw/excalidraw"); isSavingRef.current = true;
const svg = await exportToSvg({ try {
elements: excalidrawAPI?.getSceneElements(), const { exportToSvg } = await import("@excalidraw/excalidraw");
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer(); const svg = await exportToSvg({
let svgString = serializer.serializeToString(svg); elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: false,
},
files: excalidrawAPI?.getFiles(),
});
svgString = svgString.replace( const serializer = new XMLSerializer();
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g, let svgString = serializer.serializeToString(svg);
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
const fileName = "diagram.excalidraw.svg"; svgString = svgString.replace(
const excalidrawSvgFile = await svgStringToFile(svgString, fileName); /https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
"https://unpkg.com/@excalidraw/excalidraw@latest",
);
// @ts-ignore const fileName = "diagram.excalidraw.svg";
const pageId = editor.storage?.pageId; const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
let attachment: IAttachment = null; // @ts-ignore
if (attachmentId) { const pageId = editor.storage?.pageId;
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else { let attachment: IAttachment = null;
attachment = await uploadFile(excalidrawSvgFile, pageId); if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
if (updateSrc) {
updateAttributes({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
} else {
updateAttributes({
attachmentId: attachment.id,
});
}
isDirtyRef.current = false;
} finally {
isSavingRef.current = false;
}
}, [excalidrawAPI, editor, attachmentId, updateAttributes]);
const handleSaveAndExit = useCallback(async () => {
try {
await saveData();
close();
} catch {
/* empty */
}
}, [saveData, close]);
const handleClose = useCallback(() => {
if (!isDirtyRef.current) {
close();
return;
} }
updateAttributes({ modals.openConfirmModal({
src: `/api/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`, title: t("Unsaved changes"),
title: attachment.fileName, children: (
size: attachment.fileSize, <Text size="sm">
attachmentId: attachment.id, {t("You have unsaved changes that will be lost.")}
</Text>
),
centered: true,
labels: { confirm: t("Discard"), cancel: t("Cancel") },
confirmProps: { color: "red" },
onConfirm: () => {
isDirtyRef.current = false;
close();
},
}); });
}, [close, t]);
close(); useEffect(() => {
}; if (!opened) return;
const interval = setInterval(() => {
if (isDirtyRef.current && !isSavingRef.current) {
saveData(false).catch(() => {});
}
}, 30_000);
return () => clearInterval(interval);
}, [opened, saveData]);
return ( return (
<NodeViewWrapper data-drag-handle> <NodeViewWrapper data-drag-handle>
@@ -105,7 +176,7 @@ export default function ExcalidrawView(props: NodeViewProps) {
zIndex: 200, zIndex: 200,
}} }}
isOpen={opened} isOpen={opened}
onRequestClose={close} onRequestClose={handleClose}
disableCloseOnBgClick={true} disableCloseOnBgClick={true}
contentProps={{ contentProps={{
style: { style: {
@@ -120,10 +191,10 @@ export default function ExcalidrawView(props: NodeViewProps) {
bg="var(--mantine-color-body)" bg="var(--mantine-color-body)"
p="xs" p="xs"
> >
<Button onClick={handleSave} size={"compact-sm"}> <Button onClick={handleSaveAndExit} size={"compact-sm"}>
{t("Save & Exit")} {t("Save & Exit")}
</Button> </Button>
<Button onClick={close} color="red" size={"compact-sm"}> <Button onClick={handleClose} color="red" size={"compact-sm"}>
{t("Exit")} {t("Exit")}
</Button> </Button>
</Group> </Group>
@@ -131,6 +202,18 @@ export default function ExcalidrawView(props: NodeViewProps) {
<Suspense fallback={null}> <Suspense fallback={null}>
<ExcalidrawComponent <ExcalidrawComponent
excalidrawAPI={(api) => setExcalidrawAPI(api)} excalidrawAPI={(api) => setExcalidrawAPI(api)}
onChange={(elements, _appState, files) => {
const fingerprint = `${elements.length}:${elements.reduce((s, e) => s + (e.version || 0), 0)}:${Object.keys(files).length}`;
if (isInitialLoadRef.current) {
lastFingerprintRef.current = fingerprint;
isInitialLoadRef.current = false;
return;
}
if (fingerprint !== lastFingerprintRef.current) {
lastFingerprintRef.current = fingerprint;
isDirtyRef.current = true;
}
}}
initialData={{ initialData={{
...excalidrawData, ...excalidrawData,
scrollToContent: true, scrollToContent: true,
@@ -3,6 +3,7 @@ import { ActionIcon, Anchor, Text } from "@mantine/core";
import { IconFileDescription } from "@tabler/icons-react"; import { IconFileDescription } from "@tabler/icons-react";
import { Link, useLocation, useNavigate, useParams } from "react-router-dom"; import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
import { usePageQuery } from "@/features/page/queries/page-query.ts"; import { usePageQuery } from "@/features/page/queries/page-query.ts";
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
import { import {
buildPageUrl, buildPageUrl,
buildSharedPageUrl, buildSharedPageUrl,
@@ -13,17 +14,23 @@ import classes from "./mention.module.css";
export default function MentionView(props: NodeViewProps) { export default function MentionView(props: NodeViewProps) {
const { node } = props; const { node } = props;
const { label, entityType, entityId, slugId, anchorId } = node.attrs; const { label, entityType, entityId, slugId, anchorId } = node.attrs;
const isPageMention = entityType === "page";
const { spaceSlug, pageSlug } = useParams(); const { spaceSlug, pageSlug } = useParams();
const { shareId } = useParams(); const { shareId } = useParams();
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation();
const isShareRoute = location.pathname.startsWith("/share");
const { const {
data: page, data: page,
isLoading, isLoading,
isError, isError,
} = usePageQuery({ pageId: entityType === "page" ? slugId : null }); } = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
const location = useLocation(); const { data: sharedPage } = useSharePageQuery({
const isShareRoute = location.pathname.startsWith("/share"); pageId: isPageMention && isShareRoute ? slugId : undefined,
});
const currentPageSlugId = extractPageSlugId(pageSlug); const currentPageSlugId = extractPageSlugId(pageSlug);
const isSamePage = currentPageSlugId === slugId; const isSamePage = currentPageSlugId === slugId;
@@ -39,10 +46,12 @@ export default function MentionView(props: NodeViewProps) {
} }
}; };
const sharePageTitle = sharedPage?.page?.title || label;
const shareSlugUrl = buildSharedPageUrl({ const shareSlugUrl = buildSharedPageUrl({
shareId, shareId,
pageSlugId: slugId, pageSlugId: slugId,
pageTitle: label, pageTitle: sharePageTitle,
anchorId, anchorId,
}); });
@@ -54,21 +63,59 @@ export default function MentionView(props: NodeViewProps) {
</Text> </Text>
)} )}
{entityType === "page" && isError && ( {isPageMention && isShareRoute && (
<Text component="span" c="dimmed" size="sm">
{label}
</Text>
)}
{entityType === "page" && !isError && (
<Anchor <Anchor
component={Link} component={Link}
fw={500} fw={500}
to={ to={shareSlugUrl}
isShareRoute onClick={handleClick}
? shareSlugUrl underline="never"
: buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId) className={classes.pageMentionLink}
} >
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{sharePageTitle}
</span>
</Anchor>
)}
{isPageMention && !isShareRoute && isError && (
<Anchor
component={Link}
fw={500}
to={buildPageUrl(spaceSlug, slugId, label, anchorId)}
onClick={handleClick}
underline="never"
className={classes.pageMentionLink}
>
<ActionIcon
variant="transparent"
color="gray"
component="span"
size={18}
style={{ verticalAlign: "text-bottom" }}
>
<IconFileDescription size={18} />
</ActionIcon>
<span className={classes.pageMentionText}>
{label}
</span>
</Anchor>
)}
{isPageMention && !isShareRoute && !isError && (
<Anchor
component={Link}
fw={500}
to={buildPageUrl(page?.space?.slug || spaceSlug, slugId, page?.title || label, anchorId)}
onClick={handleClick} onClick={handleClick}
underline="never" underline="never"
className={classes.pageMentionLink} className={classes.pageMentionLink}
@@ -25,9 +25,9 @@ export default function SubpagesView(props: NodeViewProps) {
// Get subpages from shared tree if we're in a shared context // Get subpages from shared tree if we're in a shared context
const sharedSubpages = useSharedPageSubpages(currentPageId); const sharedSubpages = useSharedPageSubpages(currentPageId);
const { data, isLoading, error } = useGetSidebarPagesQuery({ const { data, isLoading, error } = useGetSidebarPagesQuery(
pageId: currentPageId, shareId ? null : { pageId: currentPageId },
}); );
const subpages = useMemo(() => { const subpages = useMemo(() => {
// If we're in a shared context, use the shared subpages // If we're in a shared context, use the shared subpages
@@ -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",
+2
View File
@@ -25,6 +25,7 @@ import { CacheModule } from '@nestjs/cache-manager';
import KeyvRedis from '@keyv/redis'; import KeyvRedis from '@keyv/redis';
import { LoggerModule } from './common/logger/logger.module'; import { LoggerModule } from './common/logger/logger.module';
import { ClsModule } from 'nestjs-cls'; import { ClsModule } from 'nestjs-cls';
import { NoopAuditModule } from './integrations/audit/audit.module';
const enterpriseModules = []; const enterpriseModules = [];
try { try {
@@ -47,6 +48,7 @@ try {
middleware: { mount: true }, middleware: { mount: true },
}), }),
LoggerModule, LoggerModule,
NoopAuditModule,
CoreModule, CoreModule,
DatabaseModule, DatabaseModule,
EnvironmentModule, EnvironmentModule,
@@ -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;
-11
View File
@@ -20,10 +20,6 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd
import { ShareModule } from './share/share.module'; import { ShareModule } from './share/share.module';
import { NotificationModule } from './notification/notification.module'; import { NotificationModule } from './notification/notification.module';
import { WatcherModule } from './watcher/watcher.module'; import { WatcherModule } from './watcher/watcher.module';
import {
AUDIT_SERVICE,
NoopAuditService,
} from '../integrations/audit/audit.service';
import { ClsMiddleware } from 'nestjs-cls'; import { ClsMiddleware } from 'nestjs-cls';
@Module({ @Module({
@@ -43,13 +39,6 @@ import { ClsMiddleware } from 'nestjs-cls';
NotificationModule, NotificationModule,
WatcherModule, WatcherModule,
], ],
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
},
],
exports: [AUDIT_SERVICE],
}) })
export class CoreModule implements NestModule { export class CoreModule implements NestModule {
configure(consumer: MiddlewareConsumer) { configure(consumer: MiddlewareConsumer) {
@@ -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);
@@ -0,0 +1,14 @@
import { Global, Module } from '@nestjs/common';
import { AUDIT_SERVICE, NoopAuditService } from './audit.service';
@Global()
@Module({
providers: [
{
provide: AUDIT_SERVICE,
useClass: NoopAuditService,
},
],
exports: [AUDIT_SERVICE],
})
export class NoopAuditModule {}
@@ -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,7 +1,7 @@
import { Section, Text, Button } from '@react-email/components'; import { Section, Text } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { button, content, paragraph } from '../css/styles'; import { content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials'; import { EmailButton, MailBody } from '../partials/partials';
interface Props { interface Props {
actorName: string; actorName: string;
@@ -23,19 +23,7 @@ export const CommentCreateEmail = ({
<strong>{pageTitle}</strong>. <strong>{pageTitle}</strong>.
</Text> </Text>
</Section> </Section>
<Section <EmailButton href={pageUrl}>View</EmailButton>
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody> </MailBody>
); );
}; };
@@ -1,7 +1,7 @@
import { Section, Text, Button } from '@react-email/components'; import { Section, Text } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { button, content, paragraph } from '../css/styles'; import { content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials'; import { EmailButton, MailBody } from '../partials/partials';
interface Props { interface Props {
actorName: string; actorName: string;
@@ -23,19 +23,7 @@ export const CommentMentionEmail = ({
<strong>{pageTitle}</strong>. <strong>{pageTitle}</strong>.
</Text> </Text>
</Section> </Section>
<Section <EmailButton href={pageUrl}>View</EmailButton>
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody> </MailBody>
); );
}; };
@@ -1,7 +1,7 @@
import { Section, Text, Button } from '@react-email/components'; import { Section, Text } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { button, content, paragraph } from '../css/styles'; import { content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials'; import { EmailButton, MailBody } from '../partials/partials';
interface Props { interface Props {
actorName: string; actorName: string;
@@ -23,19 +23,7 @@ export const CommentResolvedEmail = ({
<strong>{pageTitle}</strong>. <strong>{pageTitle}</strong>.
</Text> </Text>
</Section> </Section>
<Section <EmailButton href={pageUrl}>View</EmailButton>
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody> </MailBody>
); );
}; };
@@ -1,7 +1,7 @@
import { Section, Text, Button } from '@react-email/components'; import { Section, Text } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { button, content, paragraph } from '../css/styles'; import { content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials'; import { EmailButton, MailBody } from '../partials/partials';
interface Props { interface Props {
inviteLink: string; inviteLink: string;
@@ -17,19 +17,7 @@ export const InvitationEmail = ({ inviteLink }: Props) => {
Please click the button below to accept this invitation. Please click the button below to accept this invitation.
</Text> </Text>
</Section> </Section>
<Section <EmailButton href={inviteLink}>Accept Invite</EmailButton>
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={inviteLink} style={button}>
Accept Invite
</Button>
</Section>
</MailBody> </MailBody>
); );
}; };
@@ -1,7 +1,7 @@
import { Section, Text, Button } from '@react-email/components'; import { Section, Text } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { button, content, paragraph } from '../css/styles'; import { content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials'; import { EmailButton, MailBody } from '../partials/partials';
interface Props { interface Props {
actorName: string; actorName: string;
@@ -19,19 +19,7 @@ export const PageMentionEmail = ({ actorName, pageTitle, pageUrl }: Props) => {
<strong>{pageTitle}</strong>. <strong>{pageTitle}</strong>.
</Text> </Text>
</Section> </Section>
<Section <EmailButton href={pageUrl}>View</EmailButton>
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody> </MailBody>
); );
}; };
@@ -1,7 +1,7 @@
import { Section, Text, Button } from '@react-email/components'; import { Section, Text } from '@react-email/components';
import * as React from 'react'; import * as React from 'react';
import { button, content, paragraph } from '../css/styles'; import { content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials'; import { EmailButton, MailBody } from '../partials/partials';
interface Props { interface Props {
actorName: string; actorName: string;
@@ -25,19 +25,7 @@ export const PermissionGrantedEmail = ({
<strong>{pageTitle}</strong>. <strong>{pageTitle}</strong>.
</Text> </Text>
</Section> </Section>
<Section <EmailButton href={pageUrl}>View</EmailButton>
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
paddingLeft: '15px',
paddingBottom: '15px',
}}
>
<Button href={pageUrl} style={button}>
View
</Button>
</Section>
</MailBody> </MailBody>
); );
}; };
@@ -1,4 +1,4 @@
import { container, footer, h1, logo, main } from '../css/styles'; import { button as buttonStyle, container, footer, h1, logo, main } from '../css/styles';
import { import {
Body, Body,
Container, Container,
@@ -35,6 +35,47 @@ export function MailHeader() {
); );
} }
interface EmailButtonProps {
href: string;
children: React.ReactNode;
}
export function EmailButton({ href, children }: EmailButtonProps) {
return (
<table
role="presentation"
cellPadding="0"
cellSpacing="0"
style={{ margin: '0 0 15px 15px' }}
>
<tr>
<td
style={{
backgroundColor: buttonStyle.backgroundColor,
borderRadius: buttonStyle.borderRadius,
textAlign: 'center' as const,
}}
>
<a
href={href}
target="_blank"
style={{
color: buttonStyle.color,
fontFamily: buttonStyle.fontFamily,
fontSize: buttonStyle.fontSize,
textDecoration: 'none',
display: 'inline-block',
padding: '8px 16px',
}}
>
{children}
</a>
</td>
</tr>
</table>
);
}
export function MailFooter() { export function MailFooter() {
return ( return (
<Section style={footer}> <Section style={footer}>
+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: