Compare commits

..

46 Commits

Author SHA1 Message Date
46d92fbabc v0.4.1 2024-10-11 11:39:12 +01:00
e17b975aaa Revert "Add alignment styles for task list items (#378)" (#390)
This reverts commit 2721ab6a29.
2024-10-11 11:37:39 +01:00
038d21b438 v0.4.0 2024-10-10 22:03:16 +01:00
078361b367 add local editor-ext to client package.json
* update vite
2024-10-10 21:57:36 +01:00
384f11f2b7 make file upload size limit configurable (#386) 2024-10-10 21:28:28 +01:00
e333eee08b fix: base64 encoded drawio image decoded to Latin-1 instead of UTF-8 (#369) 2024-10-10 15:39:04 +01:00
7ec6a36515 fix: removed font overwrite for KaTeX elements (#377) 2024-10-10 15:35:20 +01:00
2721ab6a29 Add alignment styles for task list items (#378) 2024-10-09 18:52:20 +01:00
a2bc374f47 fix: horizontal scrollbar always shown on math block (#353) 2024-09-30 02:39:57 +01:00
eaa80a5546 fix: disconnect Redis health checker (#351) 2024-09-29 10:00:24 +01:00
e9e668bd39 fix: use environment service for refresh token's expiration (#337) 2024-09-21 10:41:26 +01:00
9390b39e35 Implement nodemailer ignore tls property (#299) 2024-09-20 17:57:50 +01:00
2ae3816324 fix: send "invitation accepted" email to inviter (#331)
The email says "${invitedUserName} has accepted your invitation ...", so it makes more sense to send it to the inviter instead of the invitee.
2024-09-19 22:19:04 +01:00
e96330afbf fix: text casing 2024-09-19 15:59:56 +01:00
e56f7933f4 fix: refactor forgot password system (#329)
* refactor forgot password system

* ready
2024-09-19 15:51:51 +01:00
b152c858b4 fix: add user tokens repo to database module 2024-09-18 20:28:39 +01:00
e43ea66442 add forgot-password ui (#273) 2024-09-17 15:53:05 +01:00
f34812653e feat(backend): forgot password (#250)
* feat(backend): forgot password

* feat: apply feedback from code review

* chore(auth): validate the minimum length of 'newPassword'

* chore(auth): make token has an expiry of 1 hour

* chore: rename all occurrences of 'code' to 'token'

* chore(backend): provide value on nanoIdGen method
2024-09-17 15:52:47 +01:00
6a3a7721be features and bug fixes (#322)
* fix page import title bug

* fix youtube embed in markdown export

* add link to rendered file html

* fix: markdown callout import

* update local generateJSON

* feat: switch spaces from sidebar

* remove unused package

* feat: editor date menu command

* fix date description

* update default locale code

* feat: add more code highlight languages
2024-09-17 15:40:49 +01:00
fb27282886 feat: delete space and edit space slug (#307)
* feat: make space slug editable

* feat: delete space

* client
2024-09-16 17:43:40 +01:00
dea9f4c063 remove unnecessary log 2024-09-13 22:37:38 +01:00
0b6730c06f fix page export failure when title contains non-ASCII characters (#309) 2024-09-13 17:40:24 +01:00
be0d97661a update README 2024-09-04 18:56:14 +01:00
4e2b23c97e v0.3.1 2024-09-03 10:49:38 +01:00
dc3ce27762 fix collaboration websocket 2024-09-03 10:48:47 +01:00
8af2d4e8cf file content-disposition 2024-09-02 16:39:07 +01:00
73ddec4ca7 v0.3.0 2024-09-02 15:56:24 +01:00
2b9765fb35 lazy load (#237) 2024-09-02 15:51:28 +01:00
7fdd355cc3 Reduce version text size 2024-09-02 13:08:01 +01:00
6c6b47599a update dependencies 2024-09-02 12:43:33 +01:00
7e6a71fa2d add HR divider to slash menu 2024-09-02 01:28:15 +01:00
1141796f24 Show version
* Add default mermaid content
2024-09-01 17:30:34 +01:00
11dbc079be Add home link to logo 2024-09-01 16:24:20 +01:00
87b99f8646 feat: draw.io (diagrams.net) integration (#215)
* draw.io init

* updates
2024-09-01 12:26:20 +01:00
38e9eef2dc feat: excalidraw integration (#214)
* update tiptap version

* excalidraw init

* cleanup

* better file handling and other fixes

* use different modal to fix excalidraw cursor position issue
* see https://github.com/excalidraw/excalidraw/issues/7312
* fix websocket in vite dev mode

* WIP

* add align attribute

* fix table

* menu icons

* Render image in excalidraw html
* add size to custom SVG components

* rewrite undefined font urls
2024-08-31 19:11:07 +01:00
77b541ec71 Fix mime attribute 2024-08-26 17:12:59 +01:00
7dc37b933f feat: editor file attachments (#194)
* fix current slider value

* WIP

* changes to extension attributes

* update command title
2024-08-26 12:38:47 +01:00
7e80797e3f feat: mermaid diagram integration (#202) 2024-08-24 18:30:07 +01:00
17475bf123 feat: code block language selection (#198)
* code block language selection

* cleanup

* Add copy button
2024-08-24 18:12:19 +01:00
4433d5174d Add Source Label to Dockerfile (#157) 2024-08-20 13:09:36 +01:00
c810d0b314 fix: added env variable for support for forcepathstyle on s3 (#181) 2024-08-20 13:05:59 +01:00
463480ae67 v0.2.10 (fix) 2024-08-07 23:03:00 +02:00
2449d69fab v2.9.10 2024-08-07 22:22:49 +02:00
e0d74fcb0e Fix: editor formatting (#137)
* reduce space between list items

* reduce spacing

* Make inline code readable in dark mode
* Disable spellcheck in code

* fix numbered list in toggle block
2024-08-04 10:06:22 +02:00
4967849e3a add SMTP_SECURE 2024-08-02 11:19:12 +02:00
0a447e91bb fix markdown import 2024-07-22 18:39:44 +01:00
125 changed files with 6824 additions and 4368 deletions

View File

@ -19,6 +19,10 @@ AWS_S3_SECRET_ACCESS_KEY=
AWS_S3_REGION=
AWS_S3_BUCKET=
AWS_S3_ENDPOINT=
AWS_S3_FORCE_PATH_STYLE=
# default: 50mb
FILE_UPLOAD_SIZE_LIMIT=
# options: smtp | postmark
MAIL_DRIVER=smtp
@ -30,6 +34,8 @@ SMTP_HOST=127.0.0.1
SMTP_PORT=587
SMTP_USERNAME=
SMTP_PASSWORD=
SMTP_SECURE=false
SMTP_IGNORETLS=false
# Postmark driver config
POSTMARK_TOKEN=

View File

@ -1,4 +1,5 @@
FROM node:21-alpine AS base
LABEL org.opencontainers.image.source="https://github.com/docmost/docmost"
FROM base AS builder

View File

@ -17,6 +17,7 @@ To get started with Docmost, please refer to our [documentation](https://docmost
## Features
- Real-time collaboration
- Diagrams (Draw.io, Excalidraw and Mermaid)
- Spaces
- Permissions management
- Groups
@ -32,4 +33,4 @@ To get started with Docmost, please refer to our [documentation](https://docmost
</p>
### Contributing
See the [development doc](https://docmost.com/docs/self-hosting/development)
See the [development documentation](https://docmost.com/docs/self-hosting/development)

View File

@ -1,7 +1,7 @@
{
"name": "client",
"private": true,
"version": "0.2.8",
"version": "0.4.1",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
@ -9,62 +9,65 @@
"preview": "vite preview"
},
"dependencies": {
"@docmost/editor-ext": "workspace:*",
"@casl/ability": "^6.7.1",
"@casl/react": "^4.0.0",
"@emoji-mart/data": "^1.2.1",
"@emoji-mart/react": "^1.1.1",
"@mantine/core": "^7.11.0",
"@mantine/form": "^7.11.0",
"@mantine/hooks": "^7.11.0",
"@mantine/modals": "^7.11.0",
"@mantine/notifications": "^7.11.0",
"@mantine/spotlight": "^7.11.0",
"@tabler/icons-react": "^3.7.0",
"@tanstack/react-query": "^5.48.0",
"@tiptap/extension-code-block-lowlight": "^2.4.0",
"axios": "^1.7.2",
"@excalidraw/excalidraw": "^0.17.6",
"@mantine/core": "^7.12.2",
"@mantine/form": "^7.12.2",
"@mantine/hooks": "^7.12.2",
"@mantine/modals": "^7.12.2",
"@mantine/notifications": "^7.12.2",
"@mantine/spotlight": "^7.12.2",
"@tabler/icons-react": "^3.14.0",
"@tanstack/react-query": "^5.53.2",
"axios": "^1.7.7",
"clsx": "^2.1.1",
"date-fns": "^3.6.0",
"emoji-mart": "^5.6.0",
"file-saver": "^2.0.5",
"jotai": "^2.8.3",
"jotai": "^2.9.3",
"jotai-optics": "^0.4.0",
"js-cookie": "^3.0.5",
"jwt-decode": "^4.0.0",
"katex": "^0.16.10",
"katex": "^0.16.11",
"lowlight": "^3.1.0",
"mermaid": "^11.0.2",
"react": "^18.3.1",
"react-arborist": "^3.4.0",
"react-clear-modal": "^2.0.9",
"react-dom": "^18.3.1",
"react-drawio": "^0.2.0",
"react-error-boundary": "^4.0.13",
"react-helmet-async": "^2.0.5",
"react-moveable": "^0.56.0",
"react-router-dom": "^6.24.0",
"react-router-dom": "^6.26.1",
"socket.io-client": "^4.7.5",
"tippy.js": "^6.3.7",
"tiptap-extension-global-drag-handle": "^0.1.10",
"tiptap-extension-global-drag-handle": "^0.1.12",
"zod": "^3.23.8"
},
"devDependencies": {
"@tanstack/eslint-plugin-query": "^5.47.0",
"@tanstack/eslint-plugin-query": "^5.53.0",
"@types/file-saver": "^2.0.7",
"@types/js-cookie": "^3.0.6",
"@types/katex": "^0.16.7",
"@types/node": "20.14.9",
"@types/react": "^18.3.3",
"@types/node": "22.5.2",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.14.1",
"@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0",
"@vitejs/plugin-react": "^4.3.1",
"eslint": "^9.5.0",
"eslint": "^9.9.1",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.7",
"eslint-plugin-react-refresh": "^0.4.11",
"optics-ts": "^2.4.1",
"postcss": "^8.4.38",
"postcss-preset-mantine": "^1.15.0",
"postcss": "^8.4.43",
"postcss-preset-mantine": "^1.17.0",
"postcss-simple-vars": "^7.0.1",
"prettier": "^3.3.2",
"typescript": "^5.5.2",
"vite": "^5.3.1"
"prettier": "^3.3.3",
"typescript": "^5.5.4",
"vite": "^5.4.8"
}
}

View File

@ -24,6 +24,8 @@ import PageRedirect from "@/pages/page/page-redirect.tsx";
import Layout from "@/components/layouts/global/layout.tsx";
import { ErrorBoundary } from "react-error-boundary";
import InviteSignup from "@/pages/auth/invite-signup.tsx";
import ForgotPassword from "@/pages/auth/forgot-password.tsx";
import PasswordReset from "./pages/auth/password-reset";
export default function App() {
const [, setSocket] = useAtom(socketAtom);
@ -63,6 +65,8 @@ export default function App() {
<Route path={"/login"} element={<LoginPage />} />
<Route path={"/invites/:invitationId"} element={<InviteSignup />} />
<Route path={"/setup/register"} element={<SetupWorkspace />} />
<Route path={"/forgot-password"} element={<ForgotPassword />} />
<Route path={"/password-reset"} element={<PasswordReset />} />
<Route path={"/p/:pageSlug"} element={<PageRedirect />} />

View File

@ -5,14 +5,15 @@ import {
Badge,
Table,
ScrollArea,
} from "@mantine/core";
import { Link } from "react-router-dom";
import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import { buildPageUrl } from "@/features/page/page.utils.ts";
import { formattedDate } from "@/lib/time.ts";
import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { IconFileDescription } from "@tabler/icons-react";
import { getSpaceUrl } from "@/lib/config.ts";
ActionIcon,
} from '@mantine/core';
import { Link } from 'react-router-dom';
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx';
import { buildPageUrl } from '@/features/page/page.utils.ts';
import { formattedDate } from '@/lib/time.ts';
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts';
import { IconFileDescription } from '@tabler/icons-react';
import { getSpaceUrl } from '@/lib/config.ts';
interface Props {
spaceId?: string;
@ -40,10 +41,14 @@ export default function RecentChanges({ spaceId }: Props) {
to={buildPageUrl(page?.space.slug, page.slugId, page.title)}
>
<Group wrap="nowrap">
{page.icon || <IconFileDescription size={18} />}
{page.icon || (
<ActionIcon variant='transparent' color='gray' size={18}>
<IconFileDescription size={18} />
</ActionIcon>
)}
<Text fw={500} size="md" lineClamp={1}>
{page.title || "Untitled"}
{page.title || 'Untitled'}
</Text>
</Group>
</UnstyledButton>
@ -55,7 +60,7 @@ export default function RecentChanges({ spaceId }: Props) {
variant="light"
component={Link}
to={getSpaceUrl(page?.space.slug)}
style={{ cursor: "pointer" }}
style={{ cursor: 'pointer' }}
>
{page?.space.name}
</Badge>

View File

@ -0,0 +1,20 @@
import { rem } from '@mantine/core';
interface Props {
size?: number | string;
}
function IconDrawio({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="#F08705"
style={{ width: rem(size), height: rem(size) }}
>
<path d="M19.69 13.419h-2.527l-2.667-4.555a1.292 1.292 0 001.035-1.28V4.16c0-.725-.576-1.312-1.302-1.312H9.771c-.726 0-1.312.576-1.312 1.301v3.435c0 .619.426 1.152 1.034 1.28l-2.666 4.555H4.309c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312h4.458c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312h-.437l2.645-4.523h2.059l2.656 4.523h-.438c-.725 0-1.312.576-1.312 1.301v3.435c0 .725.576 1.312 1.302 1.312H19.7c.726 0 1.312-.576 1.312-1.302v-3.434c0-.726-.576-1.312-1.301-1.312zM24 22.976c0 .565-.459 1.024-1.013 1.024H1.024A1.022 1.022 0 010 22.987V1.024C0 .459.459 0 1.013 0h21.963C23.541 0 24 .459 24 1.013z"></path>
</svg>
);
}
export default IconDrawio;

View File

@ -0,0 +1,20 @@
import { rem } from "@mantine/core";
interface Props {
size?: number | string;
}
function IconExcalidraw({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="#6965DB"
viewBox="0 0 24 24"
style={{ width: rem(size), height: rem(size) }}
>
<path d="M23.943 19.806a.196.196 0 00-.168-.034c-1.26-1.855-2.873-3.61-4.419-5.315l-.252-.284c-.001-.073-.067-.12-.134-.15l-.084-.084c-.05-.1-.169-.167-.286-.1-.47.234-.907.585-1.327.919-.554.434-1.109.87-1.63 1.354a5.058 5.058 0 00-.588.618c-.084.117-.017.217.084.267-.37.368-.74.736-1.109 1.12a.19.19 0 00-.05.134c0 .05.033.1.067.117l.655.502v.016c.924.92 2.554 2.173 4.285 3.527.251.201.52.402.773.602.117.134.234.285.335.418.05.066.169.084.236.033.033.034.084.067.118.1a.24.24 0 00.1.034.153.153 0 00.135-.066.237.237 0 00.033-.1c.017 0 .017.016.034.016a.192.192 0 00.134-.05l3.058-3.327c.12-.116.014-.267 0-.267zm-7.628-.134l-1.546-1.17-.15-.1c-.035-.017-.068-.05-.102-.067l-.117-.1c.66-.66 1.33-1.308 2-1.956-.488.484-1.463 1.906-1.261 2.373.002 0 .018.042.067.084l1.11.936zm4.1 3.126l-1.277-.97a26.906 26.906 0 00-1.58-1.504c.69.518 1.277.97 1.361 1.053.673.585.638.485 1.093.87l.554.4c-.074.103-.151.148-.151.151zm.336.25l-.034-.016a.913.913 0 00.152-.117zM.587 3.476c.034.217.085.435.118.636.201 1.103.403 2.106.772 2.858l.152.568c.05.217.134.485.219.552a66.769 66.769 0 003.578 2.942.177.177 0 00.219 0s0 .016.016.016a.153.153 0 00.118.05.191.191 0 00.134-.05c1.798-1.989 3.142-3.627 4.1-4.998.068-.066.084-.167.084-.25.067-.067.118-.151.185-.201.067-.067.067-.184 0-.235l-.017-.016c0-.033-.017-.084-.05-.1-.42-.401-.722-.685-1.042-.986a93.555 93.555 0 01-2.352-2.273c-.017-.017-.034-.034-.067-.034-.336-.117-1.025-.234-1.882-.385-1.277-.216-3.008-.517-4.57-.986 0 0-.101 0-.118.017l-.05.05C.05.714.022.707 0 .718c.017.1.017.167.05.284 0 .033.068.301.068.334zm7.191 4.78l-.033.034a.036.036 0 01.033-.034zM6.553 2.238c.101.1.521.502.622.585-.437-.2-1.529-.702-2.034-.869.505.1 1.194.201 1.412.284zM.79 1.403c.252.434.454 1.939.655 3.41-.118-.469-.201-.936-.302-1.372C.992 2.673.84 1.988.638 1.386c.124 0 .152.021.152.017zm-.286-.369c0-.016 0-.033-.017-.033.085 0 .135.017.202.05 0 .006-.145-.017-.185-.017zm23.17-.217c.017-.066-.336-.367-.219-.384.253-.017.253-.401 0-.401-.335.017-.688.1-1.008.15-.587.117-1.192.234-1.78.367a79.696 79.696 0 00-3.949.937c-.403.117-.857.2-1.243.401-.135.067-.118.2-.05.284-.034.017-.051.017-.085.034-.117.017-.218.034-.335.05-.102.017-.152.1-.135.2 0 .017.017.05.017.067-.706.936-1.496 1.923-2.353 2.976-.84.969-1.73 1.989-2.62 3.042-2.84 3.31-6.05 7.07-9.594 10.38a.161.161 0 000 .234c.016.016.033.033.05.033-.05.05-.101.085-.152.134-.033.034-.05.067-.05.1a.364.364 0 00-.067.084c-.067.067-.067.184.017.234.067.066.185.066.235-.017.017-.017.017-.033.033-.033a.265.265 0 01.37 0c.202.217.404.435.588.618l-.42-.35c-.067-.067-.184-.05-.234.016-.068.066-.051.184.016.234l4.469 3.727c.034.034.067.034.118.034a.15.15 0 00.117-.05l.101-.1c.017.016.05.016.067.016.05 0 .084-.016.118-.05 6.049-6.05 10.922-10.614 16.5-14.693.05-.033.067-.1.067-.15.067 0 .118-.05.15-.117 1.026-3.125 1.228-5.9 1.295-7.27 0-.059.016-.038.016-.068.017-.033.017-.05.017-.05a.978.978 0 00-.067-.619zm-10.82 4.915c.268-.301.537-.619.806-.903-1.73 2.273-4.603 5.766-8.67 9.929 2.773-3.059 5.562-6.218 7.864-9.026zM5.14 23.466c-.016-.017-.016-.017 0-.017zm2.504-2.156c.135-.15.27-.284.42-.434 0 0 0 .016.017.016-.224.198-.433.418-.437.418zm.69-.668c.099-.1.14-.173.284-.318.992-1.02 2.017-2.04 3.059-3.076l.016-.016c.252-.2.555-.418.824-.619a228.063 228.063 0 00-4.184 4.029zM14.852 3.91c-.554.719-1.176 1.671-1.697 2.423-1.646 2.374-6.94 8.174-7.057 8.274a1189.647 1189.647 0 01-4.839 4.597l-.1.1c-.085-.1-.085-.25.016-.334C8.652 11.966 13.19 6.133 15.021 3.576c-.05.116-.084.216-.168.334zm2.906 3.427c-.671-.386-.99-.987-.806-1.572l.05-.2a.775.775 0 01.085-.167 1.9 1.9 0 01.756-.703c.016 0 .033 0 .05-.016-.017-.034-.017-.084-.017-.134.017-.1.085-.167.202-.167.202 0 .824.184 1.059.384.067.05.134.117.202.184.084.1.218.268.285.401.034.017.067.184.118.268.033.134.067.284.05.418-.017.016 0 .116-.017.116a1.605 1.605 0 01-.218.619c-.03.03.006.012-.05.067a1.22 1.22 0 01-.32.334 1.49 1.49 0 01-1.26.234 2.191 2.191 0 00-.169-.066zm4.37 1.403c0 .017-.017.05 0 .067-.034 0-.05.017-.085.034a109.886 109.886 0 00-3.915 3.025c1.11-.986 2.218-1.989 3.378-2.975.336-.301.571-.686.638-1.12l.168-1.003v-.033c.085-.201.404-.118.353.1-.004-.001-.173.795-.537 1.905z"></path>
</svg>
);
}
export default IconExcalidraw;

View File

@ -0,0 +1,20 @@
import { rem } from "@mantine/core";
interface Props {
size?: number | string;
}
function IconMermaid({ size }: Props) {
return (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="#FF3670"
viewBox="0 0 24 24"
style={{ width: rem(size), height: rem(size) }}
>
<path d="M23.99 2.115A12.223 12.223 0 0012 10.149 12.223 12.223 0 00.01 2.115a12.23 12.23 0 005.32 10.604 6.562 6.562 0 012.845 5.423v3.754h7.65v-3.754a6.561 6.561 0 012.844-5.423 12.223 12.223 0 005.32-10.604z"></path>
</svg>
);
}
export default IconMermaid;

View File

@ -57,6 +57,8 @@ export function AppHeader() {
size="lg"
fw={600}
style={{ cursor: "pointer", userSelect: "none" }}
component={Link}
to="/home"
>
Docmost
</Text>

View File

@ -93,6 +93,18 @@ export default function SettingsSidebar() {
</Group>
<ScrollArea w="100%">{menuItems}</ScrollArea>
<div className={classes.version}>
<Text
className={classes.version}
size="sm"
c="dimmed"
component="a"
href="https://github.com/docmost/docmost/releases"
target="_blank"
>
v{APP_VERSION}
</Text>
</div>
</div>
);
}

View File

@ -57,3 +57,8 @@
display: flex;
align-items: center;
}
.version {
padding-left: var(--mantine-spacing-xs) ;
padding-top: 10px;
}

View File

@ -1,13 +1,14 @@
import React, { ReactNode } from "react";
import data from "@emoji-mart/data";
import Picker from "@emoji-mart/react";
import React, { ReactNode } from 'react';
import {
ActionIcon,
Popover,
Button,
useMantineColorScheme,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
} from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Suspense } from 'react';
const Picker = React.lazy(() => import('@emoji-mart/react'));
export interface EmojiPickerInterface {
onEmojiSelect: (emoji: any) => void;
@ -48,23 +49,25 @@ function EmojiPicker({
{icon}
</ActionIcon>
</Popover.Target>
<Popover.Dropdown bg="000" style={{ border: "none" }}>
<Picker
data={data}
onEmojiSelect={handleEmojiSelect}
perLine={8}
skinTonePosition="search"
theme={colorScheme}
/>
<Popover.Dropdown bg="000" style={{ border: 'none' }}>
<Suspense fallback={null}>
<Picker
data={async () => (await import('@emoji-mart/data')).default}
onEmojiSelect={handleEmojiSelect}
perLine={8}
skinTonePosition="search"
theme={colorScheme}
/>
</Suspense>
<Button
variant="default"
c="gray"
size="xs"
style={{
position: "absolute",
position: 'absolute',
zIndex: 2,
bottom: "1rem",
right: "1rem",
bottom: '1rem',
right: '1rem',
}}
onClick={handleRemoveEmoji}
>

View File

@ -1,19 +1,25 @@
import { Title, Text, Button, Container, Group } from "@mantine/core";
import classes from "./error-404.module.css";
import { Link } from "react-router-dom";
import { Helmet } from "react-helmet-async";
export function Error404() {
return (
<Container className={classes.root}>
<Title className={classes.title}>404 Page Not Found</Title>
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
Sorry, we can't find the page you are looking for.
</Text>
<Group justify="center">
<Button component={Link} to={"/home"} variant="subtle" size="md">
Take me back to homepage
</Button>
</Group>
</Container>
<>
<Helmet>
<title>404 page not found - Docmost</title>
</Helmet>
<Container className={classes.root}>
<Title className={classes.title}>404 Page Not Found</Title>
<Text c="dimmed" size="lg" ta="center" className={classes.description}>
Sorry, we can't find the page you are looking for.
</Text>
<Group justify="center">
<Button component={Link} to={"/home"} variant="subtle" size="md">
Take me back to homepage
</Button>
</Group>
</Container>
</>
);
}

View File

@ -0,0 +1,70 @@
import { useState } from "react";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import useAuth from "@/features/auth/hooks/use-auth";
import { IForgotPassword } from "@/features/auth/types/auth.types";
import { Box, Button, Container, Text, TextInput, Title } from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
const formSchema = z.object({
email: z
.string()
.min(1, { message: "Email is required" })
.email({ message: "Invalid email address" }),
});
export function ForgotPasswordForm() {
const { forgotPassword, isLoading } = useAuth();
const [isTokenSent, setIsTokenSent] = useState<boolean>(false);
useRedirectIfAuthenticated();
const form = useForm<IForgotPassword>({
validate: zodResolver(formSchema),
initialValues: {
email: "",
},
});
async function onSubmit(data: IForgotPassword) {
if (await forgotPassword(data)) {
setIsTokenSent(true);
}
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Forgot password
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
{!isTokenSent && (
<TextInput
id="email"
type="email"
label="Email"
placeholder="email@example.com"
variant="filled"
{...form.getInputProps("email")}
/>
)}
{isTokenSent && (
<Text>
A password reset link has been sent to your email. Please check
your inbox.
</Text>
)}
{!isTokenSent && (
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Send reset link
</Button>
)}
</form>
</Box>
</Container>
);
}

View File

@ -1,4 +1,3 @@
import * as React from "react";
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import useAuth from "@/features/auth/hooks/use-auth";
@ -10,9 +9,13 @@ import {
Button,
PasswordInput,
Box,
Anchor,
} from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
import { Link, useNavigate } from "react-router-dom";
import APP_ROUTE from "@/lib/app-route.ts";
const formSchema = z.object({
email: z
@ -62,10 +65,20 @@ export function LoginForm() {
mt="md"
{...form.getInputProps("password")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Sign In
</Button>
</form>
<Anchor
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
component={Link}
underline="never"
size="sm"
>
Forgot your password?
</Anchor>
</Box>
</Container>
);

View File

@ -0,0 +1,67 @@
import * as z from "zod";
import { useForm, zodResolver } from "@mantine/form";
import useAuth from "@/features/auth/hooks/use-auth";
import { IPasswordReset } from "@/features/auth/types/auth.types";
import {
Box,
Button,
Container,
PasswordInput,
Text,
Title,
} from "@mantine/core";
import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts";
const formSchema = z.object({
newPassword: z
.string()
.min(8, { message: "Password must contain at least 8 characters" }),
});
interface PasswordResetFormProps {
resetToken?: string;
}
export function PasswordResetForm({ resetToken }: PasswordResetFormProps) {
const { passwordReset, isLoading } = useAuth();
useRedirectIfAuthenticated();
const form = useForm<IPasswordReset>({
validate: zodResolver(formSchema),
initialValues: {
newPassword: "",
},
});
async function onSubmit(data: IPasswordReset) {
await passwordReset({
token: resetToken,
newPassword: data.newPassword
})
}
return (
<Container size={420} my={40} className={classes.container}>
<Box p="xl" mt={200}>
<Title order={2} ta="center" fw={500} mb="md">
Password reset
</Title>
<form onSubmit={form.onSubmit(onSubmit)}>
<PasswordInput
label="New password"
placeholder="Your new password"
variant="filled"
mt="md"
{...form.getInputProps("newPassword")}
/>
<Button type="submit" fullWidth mt="xl" loading={isLoading}>
Set password
</Button>
</form>
</Box>
</Container>
);
}

View File

@ -1,10 +1,22 @@
import { useState } from "react";
import { login, setupWorkspace } from "@/features/auth/services/auth-service";
import {
forgotPassword,
login,
passwordReset,
setupWorkspace,
verifyUserToken,
} from "@/features/auth/services/auth-service";
import { useNavigate } from "react-router-dom";
import { useAtom } from "jotai";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
import { ILogin, ISetupWorkspace } from "@/features/auth/types/auth.types";
import {
IForgotPassword,
ILogin,
IPasswordReset,
ISetupWorkspace,
IVerifyUserToken,
} from "@/features/auth/types/auth.types";
import { notifications } from "@mantine/notifications";
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
@ -76,6 +88,28 @@ export default function useAuth() {
}
};
const handlePasswordReset = async (data: IPasswordReset) => {
setIsLoading(true);
try {
const res = await passwordReset(data);
setIsLoading(false);
setAuthToken(res.tokens);
navigate(APP_ROUTE.HOME);
notifications.show({
message: "Password reset was successful",
});
} catch (err) {
setIsLoading(false);
notifications.show({
message: err.response?.data.message,
color: "red",
});
}
};
const handleIsAuthenticated = async () => {
if (!authToken) {
return false;
@ -105,11 +139,50 @@ export default function useAuth() {
navigate(APP_ROUTE.AUTH.LOGIN);
};
const handleForgotPassword = async (data: IForgotPassword) => {
setIsLoading(true);
try {
await forgotPassword(data);
setIsLoading(false);
return true;
} catch (err) {
console.log(err);
setIsLoading(false);
notifications.show({
message: err.response?.data.message,
color: "red",
});
return false;
}
};
const handleVerifyUserToken = async (data: IVerifyUserToken) => {
setIsLoading(true);
try {
await verifyUserToken(data);
setIsLoading(false);
} catch (err) {
console.log(err);
setIsLoading(false);
notifications.show({
message: err.response?.data.message,
color: "red",
});
}
};
return {
signIn: handleSignIn,
invitationSignup: handleInvitationSignUp,
setupWorkspace: handleSetupWorkspace,
isAuthenticated: handleIsAuthenticated,
forgotPassword: handleForgotPassword,
passwordReset: handlePasswordReset,
verifyUserToken: handleVerifyUserToken,
logout: handleLogout,
hasTokens,
isLoading,

View File

@ -0,0 +1,14 @@
import { useQuery, UseQueryResult } from "@tanstack/react-query";
import { verifyUserToken } from "../services/auth-service";
import { IVerifyUserToken } from "../types/auth.types";
export function useVerifyUserTokenQuery(
verify: IVerifyUserToken,
): UseQueryResult<any, Error> {
return useQuery({
queryKey: ["verify-token", verify],
queryFn: () => verifyUserToken(verify),
enabled: !!verify.token,
staleTime: 0,
});
}

View File

@ -1,10 +1,13 @@
import api from "@/lib/api-client";
import {
IChangePassword,
IForgotPassword,
ILogin,
IPasswordReset,
IRegister,
ISetupWorkspace,
ITokenResponse,
IVerifyUserToken,
} from "@/features/auth/types/auth.types";
export async function login(data: ILogin): Promise<ITokenResponse> {
@ -19,15 +22,30 @@ export async function register(data: IRegister): Promise<ITokenResponse> {
}*/
export async function changePassword(
data: IChangePassword,
data: IChangePassword
): Promise<IChangePassword> {
const req = await api.post<IChangePassword>("/auth/change-password", data);
return req.data;
}
export async function setupWorkspace(
data: ISetupWorkspace,
data: ISetupWorkspace
): Promise<ITokenResponse> {
const req = await api.post<ITokenResponse>("/auth/setup", data);
return req.data;
}
export async function forgotPassword(data: IForgotPassword): Promise<void> {
await api.post<any>("/auth/forgot-password", data);
}
export async function passwordReset(
data: IPasswordReset
): Promise<ITokenResponse> {
const req = await api.post<any>("/auth/password-reset", data);
return req.data;
}
export async function verifyUserToken(data: IVerifyUserToken): Promise<any> {
return api.post<any>("/auth/verify-token", data);
}

View File

@ -29,3 +29,17 @@ export interface IChangePassword {
oldPassword: string;
newPassword: string;
}
export interface IForgotPassword {
email: string;
}
export interface IPasswordReset {
token?: string;
newPassword: string;
}
export interface IVerifyUserToken {
token: string;
type: string;
}

View File

@ -0,0 +1,48 @@
import { NodeViewProps, NodeViewWrapper } from "@tiptap/react";
import { Group, Text, Paper, ActionIcon } from "@mantine/core";
import { getFileUrl } from "@/lib/config.ts";
import { IconDownload, IconPaperclip } from "@tabler/icons-react";
import { useHover } from "@mantine/hooks";
import { formatBytes } from "@/lib";
export default function AttachmentView(props: NodeViewProps) {
const { node, selected } = props;
const { url, name, size } = node.attrs;
const { hovered, ref } = useHover();
return (
<NodeViewWrapper>
<Paper withBorder p="4px" ref={ref} data-drag-handle>
<Group
justify="space-between"
gap="xl"
style={{ cursor: "pointer" }}
wrap="nowrap"
h={25}
>
<Group justify="space-between" wrap="nowrap">
<IconPaperclip size={20} />
<Text component="span" size="md" truncate="end">
{name}
</Text>
<Text component="span" size="sm" c="dimmed" inline>
{formatBytes(size)}
</Text>
</Group>
{selected || hovered ? (
<a href={getFileUrl(url)} target="_blank">
<ActionIcon variant="default" aria-label="download file">
<IconDownload size={18} />
</ActionIcon>
</a>
) : (
""
)}
</Group>
</Paper>
</NodeViewWrapper>
);
}

View File

@ -0,0 +1,33 @@
import { handleAttachmentUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import {getFileUploadSizeLimit} from "@/lib/config.ts";
import {formatBytes} from "@/lib";
export const uploadAttachmentAction = handleAttachmentUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
try {
return await uploadFile(file, pageId);
} catch (err) {
notifications.show({
color: "red",
message: err?.response.data.message,
});
throw err;
}
},
validateFn: (file) => {
if (file.type.includes("image/") || file.type.includes("video/")) {
return false;
}
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
color: "red",
message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
});
return false;
}
return true;
},
});

View File

@ -0,0 +1,98 @@
import { NodeViewContent, NodeViewProps, NodeViewWrapper } from '@tiptap/react';
import { ActionIcon, CopyButton, Group, Select, Tooltip } from '@mantine/core';
import { useEffect, useState } from 'react';
import { IconCheck, IconCopy } from '@tabler/icons-react';
//import MermaidView from "@/features/editor/components/code-block/mermaid-view.tsx";
import classes from './code-block.module.css';
import React from 'react';
import { Suspense } from 'react';
const MermaidView = React.lazy(
() => import('@/features/editor/components/code-block/mermaid-view.tsx')
);
export default function CodeBlockView(props: NodeViewProps) {
const { node, updateAttributes, extension, editor, getPos } = props;
const { language } = node.attrs;
const [languageValue, setLanguageValue] = useState<string | null>(
language || null
);
const [isSelected, setIsSelected] = useState(false);
useEffect(() => {
const updateSelection = () => {
const { state } = editor;
const { from, to } = state.selection;
// Check if the selection intersects with the node's range
const isNodeSelected =
(from >= getPos() && from < getPos() + node.nodeSize) ||
(to > getPos() && to <= getPos() + node.nodeSize);
setIsSelected(isNodeSelected);
};
editor.on('selectionUpdate', updateSelection);
return () => {
editor.off('selectionUpdate', updateSelection);
};
}, [editor, getPos(), node.nodeSize]);
function changeLanguage(language: string) {
setLanguageValue(language);
updateAttributes({
language: language,
});
}
return (
<NodeViewWrapper className="codeBlock">
<Group justify="flex-end" contentEditable={false}>
<Select
placeholder="auto"
checkIconPosition="right"
data={extension.options.lowlight.listLanguages().sort()}
value={languageValue}
onChange={changeLanguage}
searchable
style={{ maxWidth: '130px' }}
classNames={{ input: classes.selectInput }}
disabled={!editor.isEditable}
/>
<CopyButton value={node?.textContent} timeout={2000}>
{({ copied, copy }) => (
<Tooltip
label={copied ? 'Copied' : 'Copy'}
withArrow
position="right"
>
<ActionIcon
color={copied ? 'teal' : 'gray'}
variant="subtle"
onClick={copy}
>
{copied ? <IconCheck size={16} /> : <IconCopy size={16} />}
</ActionIcon>
</Tooltip>
)}
</CopyButton>
</Group>
<pre
spellCheck="false"
hidden={
((language === 'mermaid' && !editor.isEditable) ||
(language === 'mermaid' && !isSelected)) &&
node.textContent.length > 0
}
>
<NodeViewContent as="code" className={`language-${language}`} />
</pre>
{language === 'mermaid' && (
<Suspense fallback={null}>
<MermaidView props={props} />
</Suspense>
)}
</NodeViewWrapper>
);
}

View File

@ -0,0 +1,18 @@
.selectInput {
height: 25px;
min-height: 25px;
}
.error {
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7));
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
display: flex;
align-items: center;
justify-content: center;
}
.mermaid {
display: flex;
align-items: center;
justify-content: center;
}

View File

@ -0,0 +1,50 @@
import { NodeViewProps } from "@tiptap/react";
import { useEffect, useState } from "react";
import mermaid from "mermaid";
import { v4 as uuidv4 } from "uuid";
import classes from "./code-block.module.css";
mermaid.initialize({
startOnLoad: false,
suppressErrorRendering: true,
});
interface MermaidViewProps {
props: NodeViewProps;
}
export default function MermaidView({ props }: MermaidViewProps) {
const { node } = props;
const [preview, setPreview] = useState<string>("");
useEffect(() => {
const id = `mermaid-${uuidv4()}`;
if (node.textContent.length > 0) {
mermaid
.render(id, node.textContent)
.then((item) => {
setPreview(item.svg);
})
.catch((err) => {
if (props.editor.isEditable) {
setPreview(
`<div class="${classes.error}">Mermaid diagram error: ${err}</div>`,
);
} else {
setPreview(
`<div class="${classes.error}">Invalid Mermaid Diagram</div>`,
);
}
});
}
}, [node.textContent]);
return (
<div
className={classes.mermaid}
contentEditable={false}
dangerouslySetInnerHTML={{ __html: preview }}
></div>
);
}

View File

@ -1,6 +1,7 @@
import type { EditorView } from "@tiptap/pm/view";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
export const handleFilePaste = (
view: EditorView,
@ -15,6 +16,7 @@ export const handleFilePaste = (
if (file) {
uploadImageAction(file, view, pos, pageId);
uploadVideoAction(file, view, pos, pageId);
uploadAttachmentAction(file, view, pos, pageId);
}
return true;
}
@ -38,6 +40,7 @@ export const handleFileDrop = (
if (file) {
uploadImageAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadVideoAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
uploadAttachmentAction(file, view, coordinates?.pos ?? 0 - 1, pageId);
}
return true;
}

View File

@ -1,28 +1,34 @@
import React, { memo, useCallback, useState } from "react";
import { Slider } from "@mantine/core";
import { memo, useCallback, useEffect, useState } from 'react';
import { Slider } from '@mantine/core';
export type ImageWidthProps = {
onChange: (value: number) => void;
value: number;
width?: string;
};
export const NodeWidthResize = memo(({ onChange, value }: ImageWidthProps) => {
export const NodeWidthResize = memo(({ onChange, value, width }: ImageWidthProps) => {
const [currentValue, setCurrentValue] = useState(value);
useEffect(() => {
setCurrentValue(value);
}, [value]);
const handleChange = useCallback(
(newValue: number) => {
onChange(newValue);
},
[onChange],
[onChange]
);
return (
<Slider
p={"sm"}
p={'sm'}
min={10}
value={currentValue}
onChange={setCurrentValue}
onChangeEnd={handleChange}
w={width || 100}
label={(value) => `${value}%`}
/>
);

View File

@ -0,0 +1,82 @@
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
} from '@tiptap/react';
import { useCallback } from 'react';
import { sticky } from 'tippy.js';
import { Node as PMNode } from 'prosemirror-model';
import {
EditorMenuProps,
ShouldShowProps,
} from '@/features/editor/components/table/types/types.ts';
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
export function DrawioMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive('drawio') && editor.getAttributes('drawio')?.src;
},
[editor]
);
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === 'drawio';
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
}
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const onWidthChange = useCallback(
(value: number) => {
editor.commands.updateAttributes('drawio', { width: `${value}%` });
},
[editor]
);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`drawio-menu}`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: 'flip', enabled: false }],
},
plugins: [sticky],
sticky: 'popper',
}}
shouldShow={shouldShow}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
{editor.getAttributes('drawio')?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes('drawio').width)}
/>
)}
</div>
</BaseBubbleMenu>
);
}
export default DrawioMenu;

View File

@ -0,0 +1,173 @@
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
import { ActionIcon, Card, Image, Modal, Text } from '@mantine/core';
import { useRef, useState } from 'react';
import { uploadFile } from '@/features/page/services/page-service.ts';
import { useDisclosure } from '@mantine/hooks';
import { getFileUrl } from '@/lib/config.ts';
import {
DrawIoEmbed,
DrawIoEmbedRef,
EventExit,
EventSave,
} from 'react-drawio';
import { IAttachment } from '@/lib/types';
import { decodeBase64ToSvgString, svgStringToFile } from '@/lib/utils';
import clsx from 'clsx';
import { IconEdit } from '@tabler/icons-react';
export default function DrawioView(props: NodeViewProps) {
const { node, updateAttributes, editor, selected } = props;
const { src, title, width, attachmentId } = node.attrs;
const drawioRef = useRef<DrawIoEmbedRef>(null);
const [initialXML, setInitialXML] = useState<string>('');
const [opened, { open, close }] = useDisclosure(false);
const handleOpen = async () => {
if (!editor.isEditable) {
return;
}
try {
if (src) {
const url = getFileUrl(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 = () => {
let base64data = (reader.result || '') as string;
setInitialXML(base64data);
};
}
} catch (err) {
console.error(err);
} finally {
open();
}
};
const handleSave = async (data: EventSave) => {
const svgString = decodeBase64ToSvgString(data.xml);
const fileName = 'diagram.drawio.svg';
const drawioSVGFile = await svgStringToFile(svgString, fileName);
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(drawioSVGFile, pageId, attachmentId);
} else {
attachment = await uploadFile(drawioSVGFile, pageId);
}
updateAttributes({
src: `/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
close();
};
return (
<NodeViewWrapper>
<Modal.Root opened={opened} onClose={close} fullScreen>
<Modal.Overlay />
<Modal.Content style={{ overflow: 'hidden' }}>
<Modal.Body>
<div style={{ height: '100vh' }}>
<DrawIoEmbed
ref={drawioRef}
xml={initialXML}
urlParameters={{
ui: 'kennedy',
spin: true,
libraries: true,
saveAndExit: true,
noSaveBtn: true,
}}
onSave={(data: EventSave) => {
// If the save is triggered by another event, then do nothing
if (data.parentEvent !== 'save') {
return;
}
handleSave(data);
}}
onClose={(data: EventExit) => {
// If the exit is triggered by another event, then do nothing
if (data.parentEvent) {
return;
}
close();
}}
/>
</div>
</Modal.Body>
</Modal.Content>
</Modal.Root>
{src ? (
<div style={{ position: 'relative' }}>
<Image
onClick={(e) => e.detail === 2 && handleOpen()}
radius="md"
fit="contain"
w={width}
src={getFileUrl(src)}
alt={title}
className={clsx(
selected ? 'ProseMirror-selectednode' : '',
'alignCenter'
)}
/>
{selected && (
<ActionIcon
onClick={handleOpen}
variant="default"
color="gray"
mx="xs"
style={{
position: 'absolute',
top: 8,
right: 8,
}}
>
<IconEdit size={18} />
</ActionIcon>
)}
</div>
) : (
<Card
radius="md"
onClick={(e) => e.detail === 2 && handleOpen()}
p="xs"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
withBorder
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
Double-click to edit drawio diagram
</Text>
</div>
</Card>
)}
</NodeViewWrapper>
);
}

View File

@ -0,0 +1,82 @@
import {
BubbleMenu as BaseBubbleMenu,
findParentNode,
posToDOMRect,
} from '@tiptap/react';
import { useCallback } from 'react';
import { sticky } from 'tippy.js';
import { Node as PMNode } from 'prosemirror-model';
import {
EditorMenuProps,
ShouldShowProps,
} from '@/features/editor/components/table/types/types.ts';
import { NodeWidthResize } from '@/features/editor/components/common/node-width-resize.tsx';
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
const shouldShow = useCallback(
({ state }: ShouldShowProps) => {
if (!state) {
return false;
}
return editor.isActive('excalidraw') && editor.getAttributes('excalidraw')?.src;
},
[editor]
);
const getReferenceClientRect = useCallback(() => {
const { selection } = editor.state;
const predicate = (node: PMNode) => node.type.name === 'excalidraw';
const parent = findParentNode(predicate)(selection);
if (parent) {
const dom = editor.view.nodeDOM(parent?.pos) as HTMLElement;
return dom.getBoundingClientRect();
}
return posToDOMRect(editor.view, selection.from, selection.to);
}, [editor]);
const onWidthChange = useCallback(
(value: number) => {
editor.commands.updateAttributes('excalidraw', { width: `${value}%` });
},
[editor]
);
return (
<BaseBubbleMenu
editor={editor}
pluginKey={`excalidraw-menu}`}
updateDelay={0}
tippyOptions={{
getReferenceClientRect,
offset: [0, 8],
zIndex: 99,
popperOptions: {
modifiers: [{ name: 'flip', enabled: false }],
},
plugins: [sticky],
sticky: 'popper',
}}
shouldShow={shouldShow}
>
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
{editor.getAttributes('excalidraw')?.width && (
<NodeWidthResize
onChange={onWidthChange}
value={parseInt(editor.getAttributes('excalidraw').width)}
/>
)}
</div>
</BaseBubbleMenu>
);
}
export default ExcalidrawMenu;

View File

@ -0,0 +1,212 @@
import { NodeViewProps, NodeViewWrapper } from '@tiptap/react';
import {
ActionIcon,
Button,
Card,
Group,
Image,
Text,
useComputedColorScheme,
} from '@mantine/core';
import { useState } from 'react';
import { uploadFile } from '@/features/page/services/page-service.ts';
import { svgStringToFile } from '@/lib';
import { useDisclosure } from '@mantine/hooks';
import { getFileUrl } from '@/lib/config.ts';
import { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types';
import { IAttachment } from '@/lib/types';
import ReactClearModal from 'react-clear-modal';
import clsx from 'clsx';
import { IconEdit } from '@tabler/icons-react';
import { lazy } from 'react';
import { Suspense } from 'react';
const Excalidraw = lazy(() =>
import('@excalidraw/excalidraw').then((module) => ({
default: module.Excalidraw,
}))
);
export default function ExcalidrawView(props: NodeViewProps) {
const { node, updateAttributes, editor, selected } = props;
const { src, title, width, attachmentId } = node.attrs;
const [excalidrawAPI, setExcalidrawAPI] =
useState<ExcalidrawImperativeAPI>(null);
const [excalidrawData, setExcalidrawData] = useState<any>(null);
const [opened, { open, close }] = useDisclosure(false);
const computedColorScheme = useComputedColorScheme();
const handleOpen = async () => {
if (!editor.isEditable) {
return;
}
try {
if (src) {
const url = getFileUrl(src);
const request = await fetch(url, {
credentials: 'include',
cache: 'no-store',
});
const { loadFromBlob } = await import('@excalidraw/excalidraw');
const data = await loadFromBlob(await request.blob(), null, null);
setExcalidrawData(data);
}
} catch (err) {
console.error(err);
} finally {
open();
}
};
const handleSave = async () => {
if (!excalidrawAPI) {
return;
}
const { exportToSvg } = await import('@excalidraw/excalidraw');
const svg = await exportToSvg({
elements: excalidrawAPI?.getSceneElements(),
appState: {
exportEmbedScene: true,
exportWithDarkMode: computedColorScheme == 'light' ? false : true,
},
files: excalidrawAPI?.getFiles(),
});
const serializer = new XMLSerializer();
let svgString = serializer.serializeToString(svg);
svgString = svgString.replace(
/https:\/\/unpkg\.com\/@excalidraw\/excalidraw@undefined/g,
'https://unpkg.com/@excalidraw/excalidraw@latest'
);
const fileName = 'diagram.excalidraw.svg';
const excalidrawSvgFile = await svgStringToFile(svgString, fileName);
const pageId = editor.storage?.pageId;
let attachment: IAttachment = null;
if (attachmentId) {
attachment = await uploadFile(excalidrawSvgFile, pageId, attachmentId);
} else {
attachment = await uploadFile(excalidrawSvgFile, pageId);
}
updateAttributes({
src: `/files/${attachment.id}/${attachment.fileName}?t=${new Date(attachment.updatedAt).getTime()}`,
title: attachment.fileName,
size: attachment.fileSize,
attachmentId: attachment.id,
});
close();
};
return (
<NodeViewWrapper>
<ReactClearModal
style={{
backgroundColor: 'rgba(0, 0, 0, 0.5)',
padding: 0,
zIndex: 200,
}}
isOpen={opened}
onRequestClose={close}
disableCloseOnBgClick={true}
contentProps={{
style: {
padding: 0,
width: '90vw',
},
}}
>
<Group
justify="flex-end"
wrap="nowrap"
bg="var(--mantine-color-body)"
p="xs"
>
<Button onClick={handleSave} size={'compact-sm'}>
Save & Exit
</Button>
<Button onClick={close} color="red" size={'compact-sm'}>
Exit
</Button>
</Group>
<div style={{ height: '90vh' }}>
<Suspense fallback={null}>
<Excalidraw
excalidrawAPI={(api) => setExcalidrawAPI(api)}
initialData={{
...excalidrawData,
scrollToContent: true,
}}
/>
</Suspense>
</div>
</ReactClearModal>
{src ? (
<div style={{ position: 'relative' }}>
<Image
onClick={(e) => e.detail === 2 && handleOpen()}
radius="md"
fit="contain"
w={width}
src={getFileUrl(src)}
alt={title}
className={clsx(
selected ? 'ProseMirror-selectednode' : '',
'alignCenter'
)}
/>
{selected && (
<ActionIcon
onClick={handleOpen}
variant="default"
color="gray"
mx="xs"
style={{
position: 'absolute',
top: 8,
right: 8,
}}
>
<IconEdit size={18} />
</ActionIcon>
)}
</div>
) : (
<Card
radius="md"
onClick={(e) => e.detail === 2 && handleOpen()}
p="xs"
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
}}
withBorder
className={clsx(selected ? 'ProseMirror-selectednode' : '')}
>
<div style={{ display: 'flex', alignItems: 'center' }}>
<ActionIcon variant="transparent" color="gray">
<IconEdit size={18} />
</ActionIcon>
<Text component="span" size="lg" c="dimmed">
Double-click to edit excalidraw diagram
</Text>
</div>
</Card>
)}
</NodeViewWrapper>
);
}

View File

@ -1,12 +1,18 @@
import { handleImageUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import {getFileUploadSizeLimit} from "@/lib/config.ts";
import { formatBytes } from "@/lib";
export const uploadImageAction = handleImageUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
try {
return await uploadFile(file, pageId);
} catch (err) {
console.error("failed to upload image", err);
notifications.show({
color: "red",
message: err?.response.data.message,
});
throw err;
}
},
@ -14,8 +20,11 @@ export const uploadImageAction = handleImageUpload({
if (!file.type.includes("image/")) {
return false;
}
if (file.size / 1024 / 1024 > 20) {
//error("File size too big (max 20MB).");
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
color: "red",
message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
});
return false;
}
return true;

View File

@ -17,10 +17,6 @@
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7));
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
}
&:not(.error, .empty) * {
font-family: KaTeX_Main, Times New Roman, serif;
}
}
.mathBlock {
@ -33,7 +29,7 @@
border-radius: 4px;
transition: background-color 0.2s;
margin: 0 0.1rem;
overflow-x: scroll;
overflow-x: auto;
.textInput {
width: 400px;
@ -52,10 +48,4 @@
color: light-dark(var(--mantine-color-red-8), var(--mantine-color-red-7));
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-gray-8));
}
&:not(.error, .empty) * {
font-family: KaTeX_Main, Times New Roman, serif;
}
}

View File

@ -12,9 +12,12 @@ import {
IconMath,
IconMathFunction,
IconMovie,
IconPaperclip,
IconPhoto,
IconTable,
IconTypography,
IconMenu4,
IconCalendar,
} from "@tabler/icons-react";
import {
CommandProps,
@ -22,6 +25,10 @@ import {
} from "@/features/editor/components/slash-menu/types";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import { uploadAttachmentAction } from "@/features/editor/components/attachment/upload-attachment-action.tsx";
import IconExcalidraw from "@/components/icons/icon-excalidraw";
import IconMermaid from "@/components/icons/icon-mermaid";
import IconDrawio from "@/components/icons/icon-drawio";
const CommandGroups: SlashMenuGroupedItemsType = {
basic: [
@ -118,15 +125,23 @@ const CommandGroups: SlashMenuGroupedItemsType = {
},
{
title: "Code",
description: "Capture a code snippet.",
description: "Insert code snippet.",
searchTerms: ["codeblock"],
icon: IconCode,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).toggleCodeBlock().run(),
},
{
title: "Divider",
description: "Insert horizontal rule divider",
searchTerms: ["horizontal rule", "hr"],
icon: IconMenu4,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setHorizontalRule().run(),
},
{
title: "Image",
description: "Upload an image from your computer.",
description: "Upload any image from your device.",
searchTerms: ["photo", "picture", "media"],
icon: IconPhoto,
command: ({ editor, range }) => {
@ -139,11 +154,13 @@ const CommandGroups: SlashMenuGroupedItemsType = {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.multiple = true;
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
uploadImageAction(file, editor.view, pos, pageId);
for (const file of input.files) {
const pos = editor.view.state.selection.from;
uploadImageAction(file, editor.view, pos, pageId);
}
}
};
input.click();
@ -151,7 +168,7 @@ const CommandGroups: SlashMenuGroupedItemsType = {
},
{
title: "Video",
description: "Upload an video from your computer.",
description: "Upload any video from your device.",
searchTerms: ["video", "mp4", "media"],
icon: IconMovie,
command: ({ editor, range }) => {
@ -174,6 +191,37 @@ const CommandGroups: SlashMenuGroupedItemsType = {
input.click();
},
},
{
title: "File attachment",
description: "Upload any file from your device.",
searchTerms: ["file", "attachment", "upload", "pdf", "csv", "zip"],
icon: IconPaperclip,
command: ({ editor, range }) => {
editor.chain().focus().deleteRange(range).run();
const pageId = editor.storage?.pageId;
if (!pageId) return;
// upload file
const input = document.createElement("input");
input.type = "file";
input.accept = "";
input.onchange = async () => {
if (input.files?.length) {
const file = input.files[0];
const pos = editor.view.state.selection.from;
if (file.type.includes("image/*")) {
uploadImageAction(file, editor.view, pos, pageId);
} else if (file.type.includes("video/*")) {
uploadVideoAction(file, editor.view, pos, pageId);
} else {
uploadAttachmentAction(file, editor.view, pos, pageId);
}
}
};
input.click();
},
},
{
title: "Table",
description: "Insert a table.",
@ -253,6 +301,56 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setMathBlock().run(),
},
{
title: "Mermaid diagram",
description: "Insert mermaid diagram",
searchTerms: ["mermaid", "diagrams", "chart", "uml"],
icon: IconMermaid,
command: ({ editor, range }: CommandProps) =>
editor
.chain()
.focus()
.deleteRange(range)
.setCodeBlock({ language: "mermaid" })
.insertContent("flowchart LR\n" + " A --> B")
.run(),
},
{
title: "Draw.io (diagrams.net) ",
description: "Insert and design Drawio diagrams",
searchTerms: ["drawio", "diagrams", "charts", "uml", "whiteboard"],
icon: IconDrawio,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setDrawio().run(),
},
{
title: "Excalidraw diagram",
description: "Draw and sketch excalidraw diagrams",
searchTerms: ["diagrams", "draw", "sketch", "whiteboard"],
icon: IconExcalidraw,
command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setExcalidraw().run(),
},
{
title: "Date",
description: "Insert current date",
searchTerms: ["date", "today"],
icon: IconCalendar,
command: ({ editor, range }: CommandProps) => {
const currentDate = new Date().toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric",
});
return editor
.chain()
.focus()
.deleteRange(range)
.insertContent(currentDate)
.run();
},
},
],
};

View File

@ -1,12 +1,18 @@
import { handleVideoUpload } from "@docmost/editor-ext";
import { uploadFile } from "@/features/page/services/page-service.ts";
import { notifications } from "@mantine/notifications";
import {getFileUploadSizeLimit} from "@/lib/config.ts";
import {formatBytes} from "@/lib";
export const uploadVideoAction = handleVideoUpload({
onUpload: async (file: File, pageId: string): Promise<any> => {
try {
return await uploadFile(file, pageId);
} catch (err) {
console.error("failed to upload image", err);
notifications.show({
color: "red",
message: err?.response.data.message,
});
throw err;
}
},
@ -15,9 +21,14 @@ export const uploadVideoAction = handleVideoUpload({
return false;
}
if (file.size / 1024 / 1024 > 20) {
if (file.size > getFileUploadSizeLimit()) {
notifications.show({
color: "red",
message: `File exceeds the ${formatBytes(getFileUploadSizeLimit())} attachment limit`,
});
return false;
}
return true;
},
});

View File

@ -12,7 +12,6 @@ import { TextStyle } from "@tiptap/extension-text-style";
import { Color } from "@tiptap/extension-color";
import Table from "@tiptap/extension-table";
import TableHeader from "@tiptap/extension-table-header";
import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight";
import SlashCommand from "@/features/editor/extensions/slash-command";
import { Collaboration } from "@tiptap/extension-collaboration";
import { CollaborationCursor } from "@tiptap/extension-collaboration-cursor";
@ -32,6 +31,10 @@ import {
TiptapVideo,
LinkExtension,
Selection,
Attachment,
CustomCodeBlock,
Drawio,
Excalidraw,
} from "@docmost/editor-ext";
import {
randomElement,
@ -46,7 +49,31 @@ import ImageView from "@/features/editor/components/image/image-view.tsx";
import CalloutView from "@/features/editor/components/callout/callout-view.tsx";
import { common, createLowlight } from "lowlight";
import VideoView from "@/features/editor/components/video/video-view.tsx";
import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx";
import CodeBlockView from "@/features/editor/components/code-block/code-block-view.tsx";
import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import plaintext from "highlight.js/lib/languages/plaintext";
import powershell from "highlight.js/lib/languages/powershell";
import elixir from "highlight.js/lib/languages/elixir";
import erlang from "highlight.js/lib/languages/erlang";
import dockerfile from "highlight.js/lib/languages/dockerfile";
import clojure from "highlight.js/lib/languages/clojure";
import fortran from "highlight.js/lib/languages/fortran";
import haskell from "highlight.js/lib/languages/haskell";
import scala from "highlight.js/lib/languages/scala";
const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext);
lowlight.register("powershell", powershell);
lowlight.register("powershell", powershell);
lowlight.register("erlang", erlang);
lowlight.register("elixir", elixir);
lowlight.register("dockerfile", dockerfile);
lowlight.register("clojure", clojure);
lowlight.register("fortran", fortran);
lowlight.register("haskell", haskell);
lowlight.register("scala", scala);
export const mainExtensions = [
StarterKit.configure({
@ -56,6 +83,11 @@ export const mainExtensions = [
color: "#70CFF8",
},
codeBlock: false,
code: {
HTMLAttributes: {
spellcheck: false,
},
},
}),
Placeholder.configure({
placeholder: ({ node }) => {
@ -70,6 +102,7 @@ export const mainExtensions = [
}
},
includeChildren: true,
showOnlyWhenEditable: true,
}),
TextAlign.configure({ types: ["heading", "paragraph"] }),
TaskList,
@ -129,10 +162,23 @@ export const mainExtensions = [
Callout.configure({
view: CalloutView,
}),
CodeBlockLowlight.configure({
CustomCodeBlock.configure({
view: CodeBlockView,
lowlight,
HTMLAttributes: {
spellcheck: false,
},
}),
Selection,
Attachment.configure({
view: AttachmentView,
}),
Drawio.configure({
view: DrawioView,
}),
Excalidraw.configure({
view: ExcalidrawView,
}),
] as any;
type CollabExtensions = (provider: HocuspocusProvider, user: IUser) => any[];

View File

@ -31,14 +31,14 @@ import TableCellMenu from "@/features/editor/components/table/table-cell-menu.ts
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
import {
handleFileDrop,
handleFilePaste,
} from "@/features/editor/components/common/file-upload-handler.tsx";
import LinkMenu from "@/features/editor/components/link/link-menu.tsx";
import ExcalidrawMenu from "./components/excalidraw/excalidraw-menu";
import DrawioMenu from "./components/drawio/drawio-menu";
interface PageEditorProps {
pageId: string;
@ -173,6 +173,8 @@ export default function PageEditor({ pageId, editable }: PageEditorProps) {
<ImageMenu editor={editor} />
<VideoMenu editor={editor} />
<CalloutMenu editor={editor} />
<ExcalidrawMenu editor={editor} />
<DrawioMenu editor={editor} />
<LinkMenu editor={editor} appendTo={menuContainerRef} />
</div>
)}

View File

@ -1,6 +1,13 @@
.ProseMirror {
.codeBlock {
padding: 4px;
border-radius: var(--mantine-radius-default);
background-color: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-8));
}
pre {
padding: var(--mantine-spacing-sm) var(--mantine-spacing-md);
padding: var(--mantine-spacing-xs) var(--mantine-spacing-md);
margin: 4px;
font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
border-radius: var(--mantine-radius-default);
@ -86,4 +93,22 @@
font-weight: 700;
}
}
:not(pre) > code {
font-family: "JetBrainsMono", var(--mantine-font-family-monospace);
line-height: var(--mantine-line-height);
padding: 2px calc(var(--mantine-spacing-xs) / 2);
border-radius: var(--mantine-radius-sm);
margin: 0;
@mixin where-light {
background-color: var(--code-bg, var(--mantine-color-gray-1));
color: var(--mantine-color-black);
}
@mixin where-dark {
background-color: var(--mantine-color-dark-8);
color: var(--mantine-color-gray-4);
}
}
}

View File

@ -20,6 +20,11 @@
outline: none;
}
p {
margin-top: 0.65em;
margin-bottom: 0.65em;
}
ul,
ol {
padding: 0 1rem;
@ -27,6 +32,12 @@
margin-bottom: 0.25rem;
}
ul p,
ol p {
margin-top: 0;
margin-bottom: 0;
}
h1,
h2,
h3,
@ -80,15 +91,9 @@
outline: 2px solid #70cff8;
}
.node-mathInline {
.katex-display {
margin: 0;
}
}
& > .react-renderer {
margin-top: var(--mantine-spacing-xl);
margin-bottom: var(--mantine-spacing-xl);
margin-top: var(--mantine-spacing-sm);
margin-bottom: var(--mantine-spacing-sm);
&:first-child {
margin-top: 0;

View File

@ -50,8 +50,7 @@
[data-type="detailsContainer"] {
flex: 1;
margin-left: 0.2em;
overflow-x: hidden;
padding: 4px;
word-break: break-word;
overflow-wrap: break-word;

View File

@ -9,6 +9,3 @@
@import "./media.css";
@import "./code.css";
@import "./print.css";

View File

@ -4,10 +4,34 @@
height: auto;
}
.node-image, .node-video {
.node-image, .node-video, .node-excalidraw, .node-drawio {
&.ProseMirror-selectednode {
outline: none;
}
}
.attachment-placeholder {
display: flex;
align-items: center;
justify-content: center;
background-color: var(--mantine-color-body);
border-radius: var(--mantine-radius-default);
cursor: pointer;
padding: 15px;
height: 25px;
@mixin light {
border: 1px solid var(--mantine-color-gray-3);
}
@mixin dark {
border: 1px solid var(--mantine-color-dark-4);
}
}
.uploading-text {
font-size: var(--mantine-font-size-md);
line-height: var(--mantine-line-height-md);
}
}

View File

@ -24,7 +24,7 @@
.ProseMirror table .is-editor-empty:first-child::before,
.ProseMirror table .is-empty::before {
content: '';
display: none;
@media print {
display: none;

View File

@ -3,8 +3,8 @@ import {
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
import { IGroup } from "@/features/group/types/group.types";
} from '@tanstack/react-query';
import { IGroup } from '@/features/group/types/group.types';
import {
addGroupMember,
createGroup,
@ -14,22 +14,22 @@ import {
getGroups,
removeGroupMember,
updateGroup,
} from "@/features/group/services/group-service";
import { notifications } from "@mantine/notifications";
import { QueryParams } from "@/lib/types.ts";
} from '@/features/group/services/group-service';
import { notifications } from '@mantine/notifications';
import { QueryParams } from '@/lib/types.ts';
export function useGetGroupsQuery(
params?: QueryParams,
params?: QueryParams
): UseQueryResult<any, Error> {
return useQuery({
queryKey: ["groups", params],
queryKey: ['groups', params],
queryFn: () => getGroups(params),
});
}
export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
return useQuery({
queryKey: ["groups", groupId],
queryKey: ['groups', groupId],
queryFn: () => getGroupById(groupId),
enabled: !!groupId,
});
@ -37,7 +37,7 @@ export function useGroupQuery(groupId: string): UseQueryResult<IGroup, Error> {
export function useGroupMembersQuery(groupId: string) {
return useQuery({
queryKey: ["groupMembers", groupId],
queryKey: ['groupMembers', groupId],
queryFn: () => getGroupMembers(groupId),
enabled: !!groupId,
});
@ -47,10 +47,10 @@ export function useCreateGroupMutation() {
return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => createGroup(data),
onSuccess: () => {
notifications.show({ message: "Group created successfully" });
notifications.show({ message: 'Group created successfully' });
},
onError: () => {
notifications.show({ message: "Failed to create group", color: "red" });
notifications.show({ message: 'Failed to create group', color: 'red' });
},
});
}
@ -61,14 +61,14 @@ export function useUpdateGroupMutation() {
return useMutation<IGroup, Error, Partial<IGroup>>({
mutationFn: (data) => updateGroup(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Group updated successfully" });
notifications.show({ message: 'Group updated successfully' });
queryClient.invalidateQueries({
queryKey: ["group", variables.groupId],
queryKey: ['group', variables.groupId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: 'red' });
},
});
}
@ -79,17 +79,19 @@ export function useDeleteGroupMutation() {
return useMutation({
mutationFn: (groupId: string) => deleteGroup({ groupId }),
onSuccess: (data, variables) => {
notifications.show({ message: "Group deleted successfully" });
notifications.show({ message: 'Group deleted successfully' });
const groups = queryClient.getQueryData(["groups"]) as any;
const groups = queryClient.getQueryData(['groups']) as any;
if (groups) {
groups.items?.filter((group: IGroup) => group.id !== variables);
queryClient.setQueryData(["groups"], groups);
groups.items = groups.items?.filter(
(group: IGroup) => group.id !== variables
);
queryClient.setQueryData(['groups'], groups);
}
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: 'red' });
},
});
}
@ -100,15 +102,15 @@ export function useAddGroupMemberMutation() {
return useMutation<void, Error, { groupId: string; userIds: string[] }>({
mutationFn: (data) => addGroupMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Added successfully" });
notifications.show({ message: 'Added successfully' });
queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId],
queryKey: ['groupMembers', variables.groupId],
});
},
onError: () => {
notifications.show({
message: "Failed to add group members",
color: "red",
message: 'Failed to add group members',
color: 'red',
});
},
});
@ -127,14 +129,14 @@ export function useRemoveGroupMemberMutation() {
>({
mutationFn: (data) => removeGroupMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Removed successfully" });
notifications.show({ message: 'Removed successfully' });
queryClient.invalidateQueries({
queryKey: ["groupMembers", variables.groupId],
queryKey: ['groupMembers', variables.groupId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: 'red' });
},
});
}

View File

@ -64,7 +64,7 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
.split("filename=")[1]
.replace(/"/g, "");
saveAs(req.data, fileName);
saveAs(req.data, decodeURIComponent(fileName));
}
export async function importPage(file: File, spaceId: string) {
@ -81,8 +81,15 @@ export async function importPage(file: File, spaceId: string) {
return req.data;
}
export async function uploadFile(file: File, pageId: string) {
export async function uploadFile(
file: File,
pageId: string,
attachmentId?: string,
): Promise<IAttachment> {
const formData = new FormData();
if (attachmentId) {
formData.append("attachmentId", attachmentId);
}
formData.append("pageId", pageId);
formData.append("file", file);
@ -92,5 +99,5 @@ export async function uploadFile(file: File, pageId: string) {
},
});
return req;
return req as unknown as IAttachment;
}

View File

@ -0,0 +1,86 @@
import { Button, Divider, Group, Modal, Text, TextInput } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { useDeleteSpaceMutation } from '../queries/space-query';
import { useField } from '@mantine/form';
import { ISpace } from '../types/space.types';
import { useNavigate } from 'react-router-dom';
import APP_ROUTE from '@/lib/app-route';
interface DeleteSpaceModalProps {
space: ISpace;
}
export default function DeleteSpaceModal({ space }: DeleteSpaceModalProps) {
const [opened, { open, close }] = useDisclosure(false);
const deleteSpaceMutation = useDeleteSpaceMutation();
const navigate = useNavigate();
const confirmNameField = useField({
initialValue: '',
validateOnChange: true,
validate: (value) =>
value.trim().toLowerCase() === space.name.trim().toLocaleLowerCase()
? null
: 'Names do not match',
});
const handleDelete = async () => {
if (
confirmNameField.getValue().trim().toLowerCase() !==
space.name.trim().toLowerCase()
) {
confirmNameField.validate();
return;
}
try {
// pass slug too so we can clear the local cache
await deleteSpaceMutation.mutateAsync({ id: space.id, slug: space.slug });
navigate(APP_ROUTE.HOME);
} catch (error) {
console.error('Failed to delete space', error);
}
};
return (
<>
<Button onClick={open} variant="light" color="red">
Delete
</Button>
<Modal
opened={opened}
onClose={close}
title="Are you sure you want to delete this space?"
>
<Divider size="xs" mb="xs" />
<Text>
All pages, comments, attachments and permissions in this space will be
deleted irreversibly.
</Text>
<Text mt="sm">
Type the space name{' '}
<Text span fw={500}>
'{space.name}'
</Text>{' '}
to confirm your action.
</Text>
<TextInput
{...confirmNameField.getInputProps()}
variant="filled"
placeholder="Confirm space name"
py="sm"
data-autofocus
/>
<Group justify="flex-end" mt="md">
<Button onClick={close} variant="default">
Cancel
</Button>
<Button onClick={handleDelete} color="red">
Confirm
</Button>
</Group>
</Modal>
</>
);
}

View File

@ -8,6 +8,14 @@ import { ISpace } from "@/features/space/types/space.types.ts";
const formSchema = z.object({
name: z.string().min(2).max(50),
description: z.string().max(250),
slug: z
.string()
.min(2)
.max(50)
.regex(
/^[a-zA-Z0-9]+$/,
"Space slug must be alphanumeric. No special characters",
),
});
type FormValues = z.infer<typeof formSchema>;
@ -23,12 +31,14 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
initialValues: {
name: space?.name,
description: space?.description || "",
slug: space.slug,
},
});
const handleSubmit = async (values: {
name?: string;
description?: string;
slug?: string;
}) => {
const spaceData: Partial<ISpace> = {
spaceId: space.id,
@ -40,6 +50,10 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
spaceData.description = values.description;
}
if (form.isDirty("slug")) {
spaceData.slug = values.slug;
}
await updateSpaceMutation.mutateAsync(spaceData);
form.resetDirty();
};
@ -62,8 +76,8 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
id="slug"
label="Slug"
variant="filled"
readOnly
value={space.slug}
readOnly={readOnly}
{...form.getInputProps("slug")}
/>
<Textarea

View File

@ -1,6 +0,0 @@
.spaceName {
display: block;
width: 100%;
padding: var(--mantine-spacing-sm);
color: light-dark(var(--mantine-color-black), var(--mantine-color-dark-0));
}

View File

@ -1,19 +0,0 @@
import { UnstyledButton, Group, Text } from "@mantine/core";
import classes from "./space-name.module.css";
interface SpaceNameProps {
spaceName: string;
}
export function SpaceName({ spaceName }: SpaceNameProps) {
return (
<UnstyledButton className={classes.spaceName}>
<Group>
<div style={{ flex: 1 }}>
<Text size="md" fw={500} lineClamp={1}>
{spaceName}
</Text>
</div>
</Group>
</UnstyledButton>
);
}

View File

@ -0,0 +1,70 @@
import { useEffect, useState } from 'react';
import { useDebouncedValue } from '@mantine/hooks';
import { Avatar, Group, Select, SelectProps, Text } from '@mantine/core';
import { useGetSpacesQuery } from '@/features/space/queries/space-query.ts';
import { ISpace } from '../../types/space.types';
interface SpaceSelectProps {
onChange: (value: string) => void;
value?: string;
label?: string;
}
const renderSelectOption: SelectProps['renderOption'] = ({ option }) => (
<Group gap="sm">
<Avatar color="initials" variant="filled" name={option.label} size={20} />
<div>
<Text size="sm">{option.label}</Text>
</div>
</Group>
);
export function SpaceSelect({ onChange, label, value }: SpaceSelectProps) {
const [searchValue, setSearchValue] = useState('');
const [debouncedQuery] = useDebouncedValue(searchValue, 500);
const { data: spaces, isLoading } = useGetSpacesQuery({
query: debouncedQuery,
limit: 50,
});
const [data, setData] = useState([]);
useEffect(() => {
if (spaces) {
const spaceData = spaces?.items
.filter((space: ISpace) => space.slug !== value)
.map((space: ISpace) => {
return {
label: space.name,
value: space.slug,
};
});
const filteredSpaceData = spaceData.filter(
(user) =>
!data.find((existingUser) => existingUser.value === user.value)
);
setData((prevData) => [...prevData, ...filteredSpaceData]);
}
}, [spaces]);
return (
<Select
data={data}
renderOption={renderSelectOption}
maxDropdownHeight={300}
//label={label || 'Select space'}
placeholder="Search for spaces"
searchable
searchValue={searchValue}
onSearchChange={setSearchValue}
clearable
variant="filled"
onChange={onChange}
nothingFoundMessage="No space found"
limit={50}
checkIconPosition="right"
comboboxProps={{ width: 300, withinPortal: false }}
dropdownOpened
/>
);
}

View File

@ -5,8 +5,8 @@ import {
Text,
Tooltip,
UnstyledButton,
} from "@mantine/core";
import { spotlight } from "@mantine/spotlight";
} from '@mantine/core';
import { spotlight } from '@mantine/spotlight';
import {
IconArrowDown,
IconDots,
@ -14,27 +14,27 @@ import {
IconPlus,
IconSearch,
IconSettings,
} from "@tabler/icons-react";
} from '@tabler/icons-react';
import classes from "./space-sidebar.module.css";
import React, { useMemo } from "react";
import { useAtom } from "jotai";
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx";
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts";
import { Link, useLocation, useParams } from "react-router-dom";
import clsx from "clsx";
import { useDisclosure } from "@mantine/hooks";
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx";
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts";
import { SpaceName } from "@/features/space/components/sidebar/space-name.tsx";
import { getSpaceUrl } from "@/lib/config.ts";
import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import classes from './space-sidebar.module.css';
import React, { useMemo } from 'react';
import { useAtom } from 'jotai';
import { SearchSpotlight } from '@/features/search/search-spotlight.tsx';
import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom.ts';
import { Link, useLocation, useParams } from 'react-router-dom';
import clsx from 'clsx';
import { useDisclosure } from '@mantine/hooks';
import SpaceSettingsModal from '@/features/space/components/settings-modal.tsx';
import { useGetSpaceBySlugQuery } from '@/features/space/queries/space-query.ts';
import { getSpaceUrl } from '@/lib/config.ts';
import SpaceTree from '@/features/page/tree/components/space-tree.tsx';
import { useSpaceAbility } from '@/features/space/permissions/use-space-ability.ts';
import {
SpaceCaslAction,
SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts";
import PageImportModal from "@/features/page/components/page-import-modal.tsx";
} from '@/features/space/permissions/permissions.type.ts';
import PageImportModal from '@/features/page/components/page-import-modal.tsx';
import { SwitchSpace } from './switch-space';
export function SpaceSidebar() {
const [tree] = useAtom(treeApiAtom);
@ -52,7 +52,7 @@ export function SpaceSidebar() {
}
function handleCreatePage() {
tree?.create({ parentId: null, type: "internal", index: 0 });
tree?.create({ parentId: null, type: 'internal', index: 0 });
}
return (
@ -61,11 +61,12 @@ export function SpaceSidebar() {
<div
className={classes.section}
style={{
border: "none",
marginBottom: "0",
border: 'none',
marginTop: 2,
marginBottom: 3,
}}
>
<SpaceName spaceName={space?.name} />
<SwitchSpace spaceName={space?.name} spaceSlug={space?.slug} />
</div>
<div className={classes.section}>
@ -77,7 +78,7 @@ export function SpaceSidebar() {
classes.menu,
location.pathname.toLowerCase() === getSpaceUrl(spaceSlug)
? classes.activeButton
: "",
: ''
)}
>
<div className={classes.menuItemInner}>
@ -114,7 +115,7 @@ export function SpaceSidebar() {
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
SpaceCaslSubject.Page
) && (
<UnstyledButton
className={classes.menu}
@ -141,7 +142,7 @@ export function SpaceSidebar() {
{spaceAbility.can(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
SpaceCaslSubject.Page
) && (
<Group gap="xs">
<SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} />
@ -165,7 +166,7 @@ export function SpaceSidebar() {
spaceId={space.id}
readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage,
SpaceCaslSubject.Page,
SpaceCaslSubject.Page
)}
/>
</div>

View File

@ -0,0 +1,5 @@
.spaceName {
width: 100%;
padding: var(--mantine-spacing-sm);
color: light-dark(var(--mantine-color-dark-4), var(--mantine-color-dark-0));
}

View File

@ -0,0 +1,62 @@
import classes from './switch-space.module.css';
import { useNavigate } from 'react-router-dom';
import { SpaceSelect } from './space-select';
import { getSpaceUrl } from '@/lib/config';
import { Avatar, Button, Popover, Text } from '@mantine/core';
import { IconChevronDown } from '@tabler/icons-react';
import { useDisclosure } from '@mantine/hooks';
interface SwitchSpaceProps {
spaceName: string;
spaceSlug: string;
}
export function SwitchSpace({ spaceName, spaceSlug }: SwitchSpaceProps) {
const [opened, { close, open, toggle }] = useDisclosure(false);
const navigate = useNavigate();
const handleSelect = (value: string) => {
if (value) {
navigate(getSpaceUrl(value));
close();
}
};
return (
<Popover
width={300}
position="bottom"
withArrow
shadow="md"
opened={opened}
>
<Popover.Target>
<Button
variant="subtle"
fullWidth
justify="space-between"
rightSection={<IconChevronDown size={18} />}
color="gray"
onClick={toggle}
>
<Avatar
size={20}
color="initials"
variant="filled"
name={spaceName}
/>
<Text className={classes.spaceName} size="md" fw={500} lineClamp={1}>
{spaceName}
</Text>
</Button>
</Popover.Target>
<Popover.Dropdown>
<SpaceSelect
label={spaceName}
value={spaceSlug}
onChange={handleSelect}
/>
</Popover.Dropdown>
</Popover>
);
}

View File

@ -1,7 +1,8 @@
import React from "react";
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
import { EditSpaceForm } from "@/features/space/components/edit-space-form.tsx";
import { Text } from "@mantine/core";
import React from 'react';
import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
import { Divider, Group, Text } from '@mantine/core';
import DeleteSpaceModal from './delete-space-modal';
interface SpaceDetailsProps {
spaceId: string;
@ -18,6 +19,23 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
Details
</Text>
<EditSpaceForm space={space} readOnly={readOnly} />
{!readOnly && (
<>
<Divider my="lg" />
<Group justify="space-between" wrap="nowrap" gap="xl">
<div>
<Text size="md">Delete space</Text>
<Text size="sm" c="dimmed">
Delete this space with all its pages and data.
</Text>
</div>
<DeleteSpaceModal space={space} />
</Group>
</>
)}
</div>
)}
</>

View File

@ -3,14 +3,14 @@ import {
useQuery,
useQueryClient,
UseQueryResult,
} from "@tanstack/react-query";
} from '@tanstack/react-query';
import {
IAddSpaceMember,
IChangeSpaceMemberRole,
IRemoveSpaceMember,
ISpace,
ISpaceMember,
} from "@/features/space/types/space.types";
} from '@/features/space/types/space.types';
import {
addSpaceMember,
changeMemberRole,
@ -20,23 +20,23 @@ import {
removeSpaceMember,
createSpace,
updateSpace,
} from "@/features/space/services/space-service.ts";
import { notifications } from "@mantine/notifications";
import { IPagination } from "@/lib/types.ts";
deleteSpace,
} from '@/features/space/services/space-service.ts';
import { notifications } from '@mantine/notifications';
import { IPagination, QueryParams } from '@/lib/types.ts';
export function useGetSpacesQuery(): UseQueryResult<
IPagination<ISpace>,
Error
> {
export function useGetSpacesQuery(
params?: QueryParams
): UseQueryResult<IPagination<ISpace>, Error> {
return useQuery({
queryKey: ["spaces"],
queryFn: () => getSpaces(),
queryKey: ['spaces', params],
queryFn: () => getSpaces(params),
});
}
export function useSpaceQuery(spaceId: string): UseQueryResult<ISpace, Error> {
return useQuery({
queryKey: ["spaces", spaceId],
queryKey: ['spaces', spaceId],
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
staleTime: 5 * 60 * 1000,
@ -50,22 +50,22 @@ export function useCreateSpaceMutation() {
mutationFn: (data) => createSpace(data),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["spaces"],
queryKey: ['spaces'],
});
notifications.show({ message: "Space created successfully" });
notifications.show({ message: 'Space created successfully' });
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: 'red' });
},
});
}
export function useGetSpaceBySlugQuery(
spaceId: string,
spaceId: string
): UseQueryResult<ISpace, Error> {
return useQuery({
queryKey: ["spaces", spaceId],
queryKey: ['spaces', spaceId],
queryFn: () => getSpaceById(spaceId),
enabled: !!spaceId,
staleTime: 5 * 60 * 1000,
@ -78,34 +78,64 @@ export function useUpdateSpaceMutation() {
return useMutation<ISpace, Error, Partial<ISpace>>({
mutationFn: (data) => updateSpace(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Space updated successfully" });
notifications.show({ message: 'Space updated successfully' });
const space = queryClient.getQueryData([
"space",
'space',
variables.spaceId,
]) as ISpace;
if (space) {
const updatedSpace = { ...space, ...data };
queryClient.setQueryData(["space", variables.spaceId], updatedSpace);
queryClient.setQueryData(["space", data.slug], updatedSpace);
queryClient.setQueryData(['space', variables.spaceId], updatedSpace);
queryClient.setQueryData(['space', data.slug], updatedSpace);
}
queryClient.invalidateQueries({
queryKey: ["spaces"],
queryKey: ['spaces'],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: 'red' });
},
});
}
export function useDeleteSpaceMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (data: Partial<ISpace>) => deleteSpace(data.id),
onSuccess: (data, variables) => {
notifications.show({ message: 'Space deleted successfully' });
if (variables.slug) {
queryClient.removeQueries({
queryKey: ['spaces', variables.slug],
exact: true,
});
}
const spaces = queryClient.getQueryData(['spaces']) as any;
if (spaces) {
spaces.items = spaces.items?.filter(
(space: ISpace) => space.id !== variables.id
);
queryClient.setQueryData(['spaces'], spaces);
}
},
onError: (error) => {
const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: 'red' });
},
});
}
export function useSpaceMembersQuery(
spaceId: string,
spaceId: string
): UseQueryResult<IPagination<ISpaceMember>, Error> {
return useQuery({
queryKey: ["spaceMembers", spaceId],
queryKey: ['spaceMembers', spaceId],
queryFn: () => getSpaceMembers(spaceId),
enabled: !!spaceId,
});
@ -117,14 +147,14 @@ export function useAddSpaceMemberMutation() {
return useMutation<void, Error, IAddSpaceMember>({
mutationFn: (data) => addSpaceMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Members added successfully" });
notifications.show({ message: 'Members added successfully' });
queryClient.invalidateQueries({
queryKey: ["spaceMembers", variables.spaceId],
queryKey: ['spaceMembers', variables.spaceId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: 'red' });
},
});
}
@ -135,14 +165,14 @@ export function useRemoveSpaceMemberMutation() {
return useMutation<void, Error, IRemoveSpaceMember>({
mutationFn: (data) => removeSpaceMember(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Removed successfully" });
notifications.show({ message: 'Removed successfully' });
queryClient.refetchQueries({
queryKey: ["spaceMembers", variables.spaceId],
queryKey: ['spaceMembers', variables.spaceId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: 'red' });
},
});
}
@ -153,15 +183,15 @@ export function useChangeSpaceMemberRoleMutation() {
return useMutation<void, Error, IChangeSpaceMemberRole>({
mutationFn: (data) => changeMemberRole(data),
onSuccess: (data, variables) => {
notifications.show({ message: "Member role updated successfully" });
notifications.show({ message: 'Member role updated successfully' });
// due to pagination levels, change in cache instead
queryClient.refetchQueries({
queryKey: ["spaceMembers", variables.spaceId],
queryKey: ['spaceMembers', variables.spaceId],
});
},
onError: (error) => {
const errorMessage = error["response"]?.data?.message;
notifications.show({ message: errorMessage, color: "red" });
const errorMessage = error['response']?.data?.message;
notifications.show({ message: errorMessage, color: 'red' });
},
});
}

View File

@ -1,52 +1,56 @@
import api from "@/lib/api-client";
import api from '@/lib/api-client';
import {
IAddSpaceMember,
IChangeSpaceMemberRole,
IRemoveSpaceMember,
ISpace,
} from "@/features/space/types/space.types";
import { IPagination } from "@/lib/types.ts";
import { IPagination, QueryParams } from "@/lib/types.ts";
import { IUser } from "@/features/user/types/user.types.ts";
export async function getSpaces(): Promise<IPagination<ISpace>> {
const req = await api.post("/spaces");
export async function getSpaces(params?: QueryParams): Promise<IPagination<ISpace>> {
const req = await api.post("/spaces", params);
return req.data;
}
export async function getSpaceById(spaceId: string): Promise<ISpace> {
const req = await api.post<ISpace>("/spaces/info", { spaceId });
const req = await api.post<ISpace>('/spaces/info', { spaceId });
return req.data;
}
export async function createSpace(data: Partial<ISpace>): Promise<ISpace> {
const req = await api.post<ISpace>("/spaces/create", data);
const req = await api.post<ISpace>('/spaces/create', data);
return req.data;
}
export async function updateSpace(data: Partial<ISpace>): Promise<ISpace> {
const req = await api.post<ISpace>("/spaces/update", data);
const req = await api.post<ISpace>('/spaces/update', data);
return req.data;
}
export async function deleteSpace(spaceId: string): Promise<void> {
await api.post<void>('/spaces/delete', { spaceId });
}
export async function getSpaceMembers(
spaceId: string,
spaceId: string
): Promise<IPagination<IUser>> {
const req = await api.post<any>("/spaces/members", { spaceId });
const req = await api.post<any>('/spaces/members', { spaceId });
return req.data;
}
export async function addSpaceMember(data: IAddSpaceMember): Promise<void> {
await api.post("/spaces/members/add", data);
await api.post('/spaces/members/add', data);
}
export async function removeSpaceMember(
data: IRemoveSpaceMember,
data: IRemoveSpaceMember
): Promise<void> {
await api.post("/spaces/members/remove", data);
await api.post('/spaces/members/remove', data);
}
export async function changeMemberRole(
data: IChangeSpaceMemberRole,
data: IChangeSpaceMemberRole
): Promise<void> {
await api.post("/spaces/members/change-role", data);
await api.post('/spaces/members/change-role', data);
}

View File

@ -1,3 +1,4 @@
export const SOCKET_URL = import.meta.env.DEV
? "http://localhost:3000"
? process.env.APP_URL
: undefined;

View File

@ -4,6 +4,8 @@ const APP_ROUTE = {
LOGIN: "/login",
SIGNUP: "/signup",
SETUP: "/setup/register",
FORGOT_PASSWORD: "/forgot-password",
PASSWORD_RESET: "/password-reset",
},
SETTINGS: {
ACCOUNT: {

View File

@ -1,46 +1,58 @@
import bytes from "bytes";
declare global {
interface Window {
CONFIG?: Record<string, string>;
}
interface Window {
CONFIG?: Record<string, string>;
}
}
export function getAppUrl(): string {
let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL;
//let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL;
if (import.meta.env.DEV) {
return appUrl || "http://localhost:3000";
}
// if (import.meta.env.DEV) {
// return appUrl || "http://localhost:3000";
//}
return `${window.location.protocol}//${window.location.host}`;
return `${window.location.protocol}//${window.location.host}`;
}
export function getBackendUrl(): string {
return getAppUrl() + "/api";
return getAppUrl() + '/api';
}
export function getCollaborationUrl(): string {
const COLLAB_PATH = "/collab";
const COLLAB_PATH = '/collab';
const wsProtocol = getAppUrl().startsWith("https") ? "wss" : "ws";
return `${wsProtocol}://${getAppUrl().split("://")[1]}${COLLAB_PATH}`;
let url = getAppUrl();
if (import.meta.env.DEV) {
url = process.env.APP_URL;
}
const wsProtocol = url.startsWith('https') ? 'wss' : 'ws';
return `${wsProtocol}://${url.split('://')[1]}${COLLAB_PATH}`;
}
export function getAvatarUrl(avatarUrl: string) {
if (!avatarUrl) {
return null;
}
if (!avatarUrl) {
return null;
}
if (avatarUrl.startsWith("http")) {
return avatarUrl;
}
if (avatarUrl?.startsWith('http')) {
return avatarUrl;
}
return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl;
return getBackendUrl() + '/attachments/img/avatar/' + avatarUrl;
}
export function getSpaceUrl(spaceSlug: string) {
return "/s/" + spaceSlug;
return '/s/' + spaceSlug;
}
export function getFileUrl(src: string) {
return src.startsWith("/files/") ? getBackendUrl() + src : src;
return src?.startsWith('/files/') ? getBackendUrl() + src : src;
}
export function getFileUploadSizeLimit() {
const limit = window.CONFIG?.FILE_UPLOAD_SIZE_LIMIT || process?.env.FILE_UPLOAD_SIZE_LIMIT || '50mb';
return bytes(limit);
}

View File

@ -25,3 +25,49 @@ export const computeSpaceSlug = (name: string) => {
return alphanumericName.toLowerCase();
}
};
export const formatBytes = (
bytes: number,
decimalPlaces: number = 2,
): string => {
if (bytes === 0) return "0.0 KB";
const unitSize = 1024;
const precision = decimalPlaces < 0 ? 0 : decimalPlaces;
const units = ["KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
const kilobytes = bytes / unitSize;
const unitIndex = Math.floor(Math.log(kilobytes) / Math.log(unitSize));
const adjustedUnitIndex = Math.max(unitIndex, 0);
const adjustedSize = kilobytes / Math.pow(unitSize, adjustedUnitIndex);
return `${adjustedSize.toFixed(precision)} ${units[adjustedUnitIndex]}`;
};
export async function svgStringToFile(
svgString: string,
fileName: string,
): Promise<File> {
const blob = new Blob([svgString], { type: "image/svg+xml" });
return new File([blob], fileName, { type: "image/svg+xml" });
}
// Convert a string holding Base64 encoded UTF-8 data into a proper UTF-8 encoded string
// as a replacement for `atob`.
// based on: https://developer.mozilla.org/en-US/docs/Glossary/Base64
function decodeBase64(base64: string): string {
// convert string to bytes
const bytes = Uint8Array.from(atob(base64), (m) => m.codePointAt(0));
// properly decode bytes to UTF-8 encoded string
return new TextDecoder().decode(bytes);
}
export function decodeBase64ToSvgString(base64Data: string): string {
const base64Prefix = 'data:image/svg+xml;base64,';
if (base64Data.startsWith(base64Prefix)) {
base64Data = base64Data.replace(base64Prefix, '');
}
return decodeBase64(base64Data);
}

View File

@ -22,6 +22,7 @@ export const queryClient = new QueryClient({
},
});
const root = ReactDOM.createRoot(
document.getElementById("root") as HTMLElement,
);

View File

@ -0,0 +1,13 @@
import { ForgotPasswordForm } from "@/features/auth/components/forgot-password-form";
import { Helmet } from "react-helmet-async";
export default function ForgotPassword() {
return (
<>
<Helmet>
<title>Forgot Password - Docmost</title>
</Helmet>
<ForgotPasswordForm />
</>
);
}

View File

@ -5,7 +5,7 @@ export default function InviteSignup() {
return (
<>
<Helmet>
<title>Invitation signup</title>
<title>Invitation Signup - Docmost</title>
</Helmet>
<InviteSignUpForm />
</>

View File

@ -5,7 +5,7 @@ export default function LoginPage() {
return (
<>
<Helmet>
<title>Login</title>
<title>Login - Docmost</title>
</Helmet>
<LoginForm />
</>

View File

@ -0,0 +1,53 @@
import { Helmet } from "react-helmet-async";
import { PasswordResetForm } from "@/features/auth/components/password-reset-form";
import { Link, useSearchParams } from "react-router-dom";
import { useVerifyUserTokenQuery } from "@/features/auth/queries/auth-query";
import { Button, Container, Group, Text } from "@mantine/core";
import APP_ROUTE from "@/lib/app-route";
export default function PasswordReset() {
const [searchParams] = useSearchParams();
const { data, isLoading, isError } = useVerifyUserTokenQuery({
token: searchParams.get("token"),
type: "forgot-password",
});
const resetToken = searchParams.get("token");
if (isLoading) {
return <div></div>;
}
if (isError || !resetToken) {
return (
<>
<Helmet>
<title>Password Reset - Docmost</title>
</Helmet>
<Container my={40}>
<Text size="lg" ta="center">
Invalid or expired password reset link
</Text>
<Group justify="center">
<Button
component={Link}
to={APP_ROUTE.AUTH.LOGIN}
variant="subtle"
size="md"
>
Goto login page
</Button>
</Group>
</Container>
</>
);
}
return (
<>
<Helmet>
<title>Password Reset - Docmost</title>
</Helmet>
<PasswordResetForm resetToken={resetToken} />
</>
);
}

View File

@ -32,7 +32,7 @@ export default function SetupWorkspace() {
return (
<>
<Helmet>
<title>Setup workspace</title>
<title>Setup Workspace - Docmost</title>
</Helmet>
<SetupWorkspaceForm />
</>

View File

@ -1,20 +1,34 @@
import { createTheme, MantineColorsTuple } from "@mantine/core";
import { createTheme, MantineColorsTuple } from '@mantine/core';
const blue: MantineColorsTuple = [
"#e7f3ff",
"#d0e4ff",
"#a1c6fa",
"#6ea6f6",
"#458bf2",
"#2b7af1",
"#0b60d8", //
"#1b72f2",
"#0056c1",
"#004aac",
'#e7f3ff',
'#d0e4ff',
'#a1c6fa',
'#6ea6f6',
'#458bf2',
'#2b7af1',
'#0b60d8',
'#1b72f2',
'#0056c1',
'#004aac',
];
const red: MantineColorsTuple = [
'#ffebeb',
'#fad7d7',
'#eeadad',
'#e3807f',
'#da5a59',
'#d54241',
'#d43535',
'#bc2727',
'#a82022',
'#93151b',
];
export const theme = createTheme({
colors: {
blue,
red,
},
});

View File

@ -1 +1,2 @@
/// <reference types="vite/client" />
declare const APP_VERSION: string

View File

@ -5,13 +5,15 @@ import * as path from "path";
export const envPath = path.resolve(process.cwd(), "..", "..");
export default defineConfig(({ mode }) => {
const { APP_URL } = loadEnv(mode, envPath, "");
const { APP_URL, FILE_UPLOAD_SIZE_LIMIT } = loadEnv(mode, envPath, "");
return {
define: {
"process.env": {
APP_URL,
FILE_UPLOAD_SIZE_LIMIT
},
'APP_VERSION': JSON.stringify(process.env.npm_package_version),
},
plugins: [react()],
resolve: {

View File

@ -1,6 +1,6 @@
{
"name": "server",
"version": "0.2.8",
"version": "0.4.1",
"description": "",
"author": "",
"private": true,
@ -28,40 +28,37 @@
"test:e2e": "jest --config test/jest-e2e.json"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.600.0",
"@aws-sdk/s3-request-presigner": "^3.600.0",
"@aws-sdk/client-s3": "^3.637.0",
"@aws-sdk/s3-request-presigner": "^3.637.0",
"@casl/ability": "^6.7.1",
"@fastify/cookie": "^9.3.1",
"@fastify/cookie": "^9.4.0",
"@fastify/multipart": "^8.3.0",
"@fastify/static": "^7.0.4",
"@nestjs/bullmq": "^10.1.1",
"@nestjs/common": "^10.3.9",
"@nestjs/config": "^3.2.2",
"@nestjs/core": "^10.3.9",
"@nestjs/bullmq": "^10.2.1",
"@nestjs/common": "^10.4.1",
"@nestjs/config": "^3.2.3",
"@nestjs/core": "^10.4.1",
"@nestjs/event-emitter": "^2.0.4",
"@nestjs/jwt": "^10.2.0",
"@nestjs/mapped-types": "^2.0.5",
"@nestjs/passport": "^10.0.3",
"@nestjs/platform-fastify": "^10.3.9",
"@nestjs/platform-socket.io": "^10.3.9",
"@nestjs/platform-fastify": "^10.4.1",
"@nestjs/platform-socket.io": "^10.4.1",
"@nestjs/terminus": "^10.2.3",
"@nestjs/websockets": "^10.3.9",
"@react-email/components": "0.0.19",
"@react-email/render": "^0.0.15",
"@nestjs/websockets": "^10.4.1",
"@react-email/components": "0.0.24",
"@react-email/render": "^1.0.1",
"@socket.io/redis-adapter": "^8.3.0",
"@types/pg": "^8.11.6",
"bcrypt": "^5.1.1",
"bullmq": "^5.8.2",
"bytes": "^3.1.2",
"bullmq": "^5.12.12",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"fastify": "^4.28.0",
"fix-esm": "^1.0.1",
"fs-extra": "^11.2.0",
"happy-dom": "^14.12.3",
"kysely": "^0.27.3",
"happy-dom": "^15.7.3",
"kysely": "^0.27.4",
"kysely-migration-cli": "^0.4.2",
"marked": "^13.0.2",
"marked": "^13.0.3",
"mime-types": "^2.1.35",
"nanoid": "^5.0.7",
"nestjs-kysely": "^1.0.0",
@ -69,46 +66,46 @@
"passport-jwt": "^4.0.1",
"pg": "^8.12.0",
"pg-tsquery": "^8.4.2",
"postmark": "^4.0.4",
"postmark": "^4.0.5",
"react": "^18.3.1",
"redis": "^4.6.14",
"redis": "^4.7.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sanitize-filename-ts": "^1.0.2",
"socket.io": "^4.7.5",
"ws": "^8.17.1"
"ws": "^8.18.0"
},
"devDependencies": {
"@nestjs/cli": "^10.3.2",
"@nestjs/schematics": "^10.1.1",
"@nestjs/testing": "^10.3.9",
"@nestjs/cli": "^10.4.5",
"@nestjs/schematics": "^10.1.4",
"@nestjs/testing": "^10.4.1",
"@types/bcrypt": "^5.0.2",
"@types/bytes": "^3.1.4",
"@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/mime-types": "^2.1.4",
"@types/node": "^20.14.9",
"@types/node": "^22.5.2",
"@types/nodemailer": "^6.4.15",
"@types/passport-jwt": "^4.0.1",
"@types/pg": "^8.11.8",
"@types/supertest": "^6.0.2",
"@types/ws": "^8.5.10",
"@typescript-eslint/eslint-plugin": "^7.14.1",
"@typescript-eslint/parser": "^7.14.1",
"eslint": "^9.5.0",
"@types/ws": "^8.5.12",
"@typescript-eslint/eslint-plugin": "^8.3.0",
"@typescript-eslint/parser": "^8.3.0",
"eslint": "^9.9.1",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"eslint-plugin-prettier": "^5.2.1",
"jest": "^29.7.0",
"kysely-codegen": "^0.15.0",
"prettier": "^3.3.2",
"react-email": "^2.1.4",
"kysely-codegen": "^0.16.3",
"prettier": "^3.3.3",
"react-email": "^3.0.1",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.1.5",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.5.2"
"typescript": "^5.5.4"
},
"jest": {
"moduleFileExtensions": [

View File

@ -15,6 +15,7 @@ import TableHeader from '@tiptap/extension-table-header';
import {
Callout,
Comment,
CustomCodeBlock,
Details,
DetailsContent,
DetailsSummary,
@ -26,6 +27,9 @@ import {
TiptapImage,
TiptapVideo,
TrailingNode,
Attachment,
Drawio,
Excalidraw,
} from '@docmost/editor-ext';
import { generateText, JSONContent } from '@tiptap/core';
import { generateHTML } from '../common/helpers/prosemirror/html';
@ -35,7 +39,9 @@ import { generateHTML } from '../common/helpers/prosemirror/html';
import { generateJSON } from '@tiptap/html';
export const tiptapExtensions = [
StarterKit,
StarterKit.configure({
codeBlock: false,
}),
Comment,
TextAlign,
TaskList,
@ -62,6 +68,10 @@ export const tiptapExtensions = [
TiptapImage,
TiptapVideo,
Callout,
Attachment,
CustomCodeBlock,
Drawio,
Excalidraw,
] as any;
export function jsonToHtml(tiptapJson: any) {

View File

@ -0,0 +1,3 @@
export enum EventName {
COLLAB_PAGE_UPDATED = 'collab.page.updated',
}

View File

@ -1,7 +1,9 @@
import { Extensions, getSchema } from '@tiptap/core';
import { DOMParser, ParseOptions } from '@tiptap/pm/model';
import { Window, DOMParser as HappyDomParser } from 'happy-dom';
import { Window } from 'happy-dom';
// this function does not work as intended
// it has issues with closing tags
export function generateJSON(
html: string,
extensions: Extensions,
@ -10,11 +12,10 @@ export function generateJSON(
const schema = getSchema(extensions);
const window = new Window();
const dom = new HappyDomParser(window).parseFromString(
html,
'text/html',
).body;
const document = window.document;
document.body.innerHTML = html;
// @ts-ignore
return DOMParser.fromSchema(schema).parse(dom, options).toJSON();
return DOMParser.fromSchema(schema)
.parse(document as never, options)
.toJSON();
}

View File

@ -5,10 +5,10 @@ export enum AttachmentType {
File = 'file',
}
export const validImageExtensions = ['.jpg', '.png', '.jpeg', 'gif'];
export const validImageExtensions = ['.jpg', '.png', '.jpeg'];
export const MAX_AVATAR_SIZE = '5MB';
export const InlineFileExtensions = [
export const inlineFileExtensions = [
'.jpg',
'.png',
'.jpeg',
@ -16,4 +16,3 @@ export const InlineFileExtensions = [
'.mp4',
'.mov',
];
export const MAX_FILE_SIZE = '20MB';

View File

@ -1,288 +1,310 @@
import {
BadRequestException,
Controller,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Param,
Post,
Req,
Res,
UseGuards,
UseInterceptors,
BadRequestException,
Controller,
ForbiddenException,
Get,
HttpCode,
HttpStatus,
Logger,
NotFoundException,
Param,
Post,
Req,
Res,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { AttachmentService } from './services/attachment.service';
import { FastifyReply } from 'fastify';
import { FileInterceptor } from '../../common/interceptors/file.interceptor';
import {AttachmentService} from './services/attachment.service';
import {FastifyReply} from 'fastify';
import {FileInterceptor} from '../../common/interceptors/file.interceptor';
import * as bytes from 'bytes';
import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { StorageService } from '../../integrations/storage/storage.service';
import {AuthUser} from '../../common/decorators/auth-user.decorator';
import {AuthWorkspace} from '../../common/decorators/auth-workspace.decorator';
import {JwtAuthGuard} from '../../common/guards/jwt-auth.guard';
import {User, Workspace} from '@docmost/db/types/entity.types';
import {StorageService} from '../../integrations/storage/storage.service';
import {
getAttachmentFolderPath,
validAttachmentTypes,
getAttachmentFolderPath,
validAttachmentTypes,
} from './attachment.utils';
import { getMimeType } from '../../common/helpers';
import {getMimeType} from '../../common/helpers';
import {
AttachmentType,
MAX_AVATAR_SIZE,
MAX_FILE_SIZE,
AttachmentType,
inlineFileExtensions,
MAX_AVATAR_SIZE,
} from './attachment.constants';
import {
SpaceCaslAction,
SpaceCaslSubject,
SpaceCaslAction,
SpaceCaslSubject,
} from '../casl/interfaces/space-ability.type';
import SpaceAbilityFactory from '../casl/abilities/space-ability.factory';
import {
WorkspaceCaslAction,
WorkspaceCaslSubject,
WorkspaceCaslAction,
WorkspaceCaslSubject,
} from '../casl/interfaces/workspace-ability.type';
import WorkspaceAbilityFactory from '../casl/abilities/workspace-ability.factory';
import { PageRepo } from '@docmost/db/repos/page/page.repo';
import { AttachmentRepo } from '@docmost/db/repos/attachment/attachment.repo';
import { validate as isValidUUID } from 'uuid';
import {PageRepo} from '@docmost/db/repos/page/page.repo';
import {AttachmentRepo} from '@docmost/db/repos/attachment/attachment.repo';
import {validate as isValidUUID} from 'uuid';
import {EnvironmentService} from "../../integrations/environment/environment.service";
@Controller()
export class AttachmentController {
private readonly logger = new Logger(AttachmentController.name);
private readonly logger = new Logger(AttachmentController.name);
constructor(
private readonly attachmentService: AttachmentService,
private readonly storageService: StorageService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageRepo: PageRepo,
private readonly attachmentRepo: AttachmentRepo,
) {}
constructor(
private readonly attachmentService: AttachmentService,
private readonly storageService: StorageService,
private readonly workspaceAbility: WorkspaceAbilityFactory,
private readonly spaceAbility: SpaceAbilityFactory,
private readonly pageRepo: PageRepo,
private readonly attachmentRepo: AttachmentRepo,
private readonly environmentService: EnvironmentService,
) {
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('files/upload')
@UseInterceptors(FileInterceptor)
async uploadFile(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(MAX_FILE_SIZE);
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('files/upload')
@UseInterceptors(FileInterceptor)
async uploadFile(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(this.environmentService.getFileUploadSizeLimit());
let file = null;
try {
file = await req.file({
limits: { fileSize: maxFileSize, fields: 2, files: 1 },
});
} catch (err: any) {
this.logger.error(err.message);
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${MAX_FILE_SIZE} limit`,
let file = null;
try {
file = await req.file({
limits: {fileSize: maxFileSize, fields: 3, files: 1},
});
} catch (err: any) {
this.logger.error(err.message);
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Failed to upload file');
}
const pageId = file.fields?.pageId?.value;
if (!pageId) {
throw new BadRequestException('PageId is required');
}
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
page.spaceId,
);
}
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const spaceId = page.spaceId;
const attachmentId = file.fields?.attachmentId?.value;
if (attachmentId && !isValidUUID(attachmentId)) {
throw new BadRequestException('Invalid attachment id');
}
try {
const fileResponse = await this.attachmentService.uploadFile({
filePromise: file,
pageId: pageId,
spaceId: spaceId,
userId: user.id,
workspaceId: workspace.id,
attachmentId: attachmentId,
});
return res.send(fileResponse);
} catch (err: any) {
if (err?.statusCode === 413) {
const errMessage = `File too large. Exceeds the ${this.environmentService.getFileUploadSizeLimit()} limit`;
this.logger.error(errMessage);
throw new BadRequestException(errMessage);
}
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
if (!file) {
throw new BadRequestException('Failed to upload file');
}
const pageId = file.fields?.pageId?.value;
if (!pageId) {
throw new BadRequestException('PageId is required');
}
const page = await this.pageRepo.findById(pageId);
if (!page) {
throw new NotFoundException('Page not found');
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
page.spaceId,
);
if (spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
const spaceId = page.spaceId;
try {
const fileResponse = await this.attachmentService.uploadFile({
filePromise: file,
pageId: pageId,
spaceId: spaceId,
userId: user.id,
workspaceId: workspace.id,
});
return res.send(fileResponse);
} catch (err: any) {
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
async getFile(
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@Param('fileName') fileName?: string,
) {
if (!isValidUUID(fileId)) {
throw new NotFoundException('Invalid file id');
}
const attachment = await this.attachmentRepo.findById(fileId);
if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId
@UseGuards(JwtAuthGuard)
@Get('/files/:fileId/:fileName')
async getFile(
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@Param('fileId') fileId: string,
@Param('fileName') fileName?: string,
) {
throw new NotFoundException();
}
if (!isValidUUID(fileId)) {
throw new NotFoundException('Invalid file id');
}
const spaceAbility = await this.spaceAbility.createForUser(
user,
attachment.spaceId,
);
const attachment = await this.attachmentRepo.findById(fileId);
if (
!attachment ||
attachment.workspaceId !== workspace.id ||
!attachment.pageId ||
!attachment.spaceId
) {
throw new NotFoundException();
}
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
try {
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
'Content-Type': getMimeType(attachment.filePath),
'Cache-Control': 'public, max-age=3600',
});
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
}
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/upload-image')
@UseInterceptors(FileInterceptor)
async uploadAvatarOrLogo(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const maxFileSize = bytes(MAX_AVATAR_SIZE);
let file = null;
try {
file = await req.file({
limits: { fileSize: maxFileSize, fields: 3, files: 1 },
});
} catch (err: any) {
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`,
const spaceAbility = await this.spaceAbility.createForUser(
user,
attachment.spaceId,
);
}
if (spaceAbility.cannot(SpaceCaslAction.Read, SpaceCaslSubject.Page)) {
throw new ForbiddenException();
}
try {
const fileStream = await this.storageService.read(attachment.filePath);
res.headers({
'Content-Type': attachment.mimeType,
'Cache-Control': 'public, max-age=3600',
});
if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
);
}
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
}
if (!file) {
throw new BadRequestException('Invalid file upload');
}
const attachmentType = file.fields?.type?.value;
const spaceId = file.fields?.spaceId?.value;
if (!attachmentType) {
throw new BadRequestException('attachment type is required');
}
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
@UseGuards(JwtAuthGuard)
@HttpCode(HttpStatus.OK)
@Post('attachments/upload-image')
@UseInterceptors(FileInterceptor)
async uploadAvatarOrLogo(
@Req() req: any,
@Res() res: FastifyReply,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
throw new BadRequestException('Invalid image attachment type');
const maxFileSize = bytes(MAX_AVATAR_SIZE);
let file = null;
try {
file = await req.file({
limits: {fileSize: maxFileSize, fields: 3, files: 1},
});
} catch (err: any) {
if (err?.statusCode === 413) {
throw new BadRequestException(
`File too large. Exceeds the ${MAX_AVATAR_SIZE} limit`,
);
}
}
if (!file) {
throw new BadRequestException('Invalid file upload');
}
const attachmentType = file.fields?.type?.value;
const spaceId = file.fields?.spaceId?.value;
if (!attachmentType) {
throw new BadRequestException('attachment type is required');
}
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
if (attachmentType === AttachmentType.WorkspaceLogo) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
}
if (attachmentType === AttachmentType.SpaceLogo) {
if (!spaceId) {
throw new BadRequestException('spaceId is required');
}
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
if (
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
try {
const fileResponse = await this.attachmentService.uploadImage(
file,
attachmentType,
user.id,
workspace.id,
spaceId,
);
return res.send(fileResponse);
} catch (err: any) {
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
if (attachmentType === AttachmentType.WorkspaceLogo) {
const ability = this.workspaceAbility.createForUser(user, workspace);
if (
ability.cannot(
WorkspaceCaslAction.Manage,
WorkspaceCaslSubject.Settings,
)
) {
throw new ForbiddenException();
}
}
if (attachmentType === AttachmentType.SpaceLogo) {
if (!spaceId) {
throw new BadRequestException('spaceId is required');
}
const spaceAbility = await this.spaceAbility.createForUser(user, spaceId);
if (
spaceAbility.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)
) {
throw new ForbiddenException();
}
}
try {
const fileResponse = await this.attachmentService.uploadImage(
file,
attachmentType,
user.id,
workspace.id,
spaceId,
);
return res.send(fileResponse);
} catch (err: any) {
this.logger.error(err);
throw new BadRequestException('Error processing file upload.');
}
}
@Get('attachments/img/:attachmentType/:fileName')
async getLogoOrAvatar(
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('attachmentType') attachmentType: AttachmentType,
@Param('fileName') fileName?: string,
) {
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
@Get('attachments/img/:attachmentType/:fileName')
async getLogoOrAvatar(
@Res() res: FastifyReply,
@AuthWorkspace() workspace: Workspace,
@Param('attachmentType') attachmentType: AttachmentType,
@Param('fileName') fileName?: string,
) {
throw new BadRequestException('Invalid image attachment type');
}
if (
!validAttachmentTypes.includes(attachmentType) ||
attachmentType === AttachmentType.File
) {
throw new BadRequestException('Invalid image attachment type');
}
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
const filePath = `${getAttachmentFolderPath(attachmentType, workspace.id)}/${fileName}`;
try {
const fileStream = await this.storageService.read(filePath);
res.headers({
'Content-Type': getMimeType(filePath),
'Cache-Control': 'public, max-age=86400',
});
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
try {
const fileStream = await this.storageService.read(filePath);
res.headers({
'Content-Type': getMimeType(filePath),
'Cache-Control': 'public, max-age=86400',
});
return res.send(fileStream);
} catch (err) {
this.logger.error(err);
throw new NotFoundException('File not found');
}
}
}
}

View File

@ -4,10 +4,11 @@ import { AttachmentController } from './attachment.controller';
import { StorageModule } from '../../integrations/storage/storage.module';
import { UserModule } from '../user/user.module';
import { WorkspaceModule } from '../workspace/workspace.module';
import { AttachmentProcessor } from './processors/attachment.processor';
@Module({
imports: [StorageModule, UserModule, WorkspaceModule],
controllers: [AttachmentController],
providers: [AttachmentService],
providers: [AttachmentService, AttachmentProcessor],
})
export class AttachmentModule {}

View File

@ -38,7 +38,6 @@ export async function prepareFile(
mimeType: file.mimetype,
};
} catch (error) {
console.error('Error in file preparation:', error);
throw error;
}
}

View File

@ -0,0 +1,47 @@
import { Logger, OnModuleDestroy } from '@nestjs/common';
import { OnWorkerEvent, Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { AttachmentService } from '../services/attachment.service';
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { Space } from '@docmost/db/types/entity.types';
@Processor(QueueName.ATTACHEMENT_QUEUE)
export class AttachmentProcessor extends WorkerHost implements OnModuleDestroy {
private readonly logger = new Logger(AttachmentProcessor.name);
constructor(private readonly attachmentService: AttachmentService) {
super();
}
async process(job: Job<Space, void>): Promise<void> {
try {
if (job.name === QueueJob.DELETE_SPACE_ATTACHMENTS) {
await this.attachmentService.handleDeleteSpaceAttachments(job.data.id);
}
} catch (err) {
throw err;
}
}
@OnWorkerEvent('active')
onActive(job: Job) {
this.logger.debug(`Processing ${job.name} job`);
}
@OnWorkerEvent('failed')
onError(job: Job) {
this.logger.error(
`Error processing ${job.name} job. Reason: ${job.failedReason}`,
);
}
@OnWorkerEvent('completed')
onCompleted(job: Job) {
this.logger.debug(`Completed ${job.name} job`);
}
async onModuleDestroy(): Promise<void> {
if (this.worker) {
await this.worker.close();
}
}
}

View File

@ -1,4 +1,9 @@
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { StorageService } from '../../../integrations/storage/storage.service';
import { MultipartFile } from '@fastify/multipart';
import {
@ -36,27 +41,64 @@ export class AttachmentService {
userId: string;
spaceId: string;
workspaceId: string;
attachmentId?: string;
}) {
const { filePromise, pageId, spaceId, userId, workspaceId } = opts;
const preparedFile: PreparedFile = await prepareFile(filePromise);
const attachmentId = uuid7();
let isUpdate = false;
let attachmentId = null;
// passing attachmentId to allow for updating diagrams
// instead of creating new files for each save
if (opts?.attachmentId) {
let existingAttachment = await this.attachmentRepo.findById(
opts.attachmentId,
);
if (!existingAttachment) {
throw new NotFoundException(
'Existing attachment to overwrite not found',
);
}
if (
existingAttachment.pageId !== pageId &&
existingAttachment.fileExt !== preparedFile.fileExtension &&
existingAttachment.workspaceId !== workspaceId
) {
throw new BadRequestException('File attachment does not match');
}
attachmentId = opts.attachmentId;
isUpdate = true;
} else {
attachmentId = uuid7();
}
const filePath = `${getAttachmentFolderPath(AttachmentType.File, workspaceId)}/${attachmentId}/${preparedFile.fileName}`;
await this.uploadToDrive(filePath, preparedFile.buffer);
let attachment: Attachment = null;
try {
attachment = await this.saveAttachment({
attachmentId,
preparedFile,
filePath,
type: AttachmentType.File,
userId,
spaceId,
workspaceId,
pageId,
});
if (isUpdate) {
attachment = await this.attachmentRepo.updateAttachment(
{
updatedAt: new Date(),
},
attachmentId,
);
} else {
attachment = await this.saveAttachment({
attachmentId,
preparedFile,
filePath,
type: AttachmentType.File,
userId,
spaceId,
workspaceId,
pageId,
});
}
} catch (err) {
// delete uploaded file on error
this.logger.error(err);
@ -214,4 +256,37 @@ export class AttachmentService {
trx,
);
}
async handleDeleteSpaceAttachments(spaceId: string) {
try {
const attachments = await this.attachmentRepo.findBySpaceId(spaceId);
if (!attachments || attachments.length === 0) {
return;
}
const failedDeletions = [];
await Promise.all(
attachments.map(async (attachment) => {
try {
await this.storageService.delete(attachment.filePath);
await this.attachmentRepo.deleteAttachmentById(attachment.id);
} catch (err) {
failedDeletions.push(attachment.id);
this.logger.log(
`DeleteSpaceAttachments: failed to delete attachment ${attachment.id}:`,
err,
);
}
}),
);
if(failedDeletions.length === attachments.length){
throw new Error(`Failed to delete any attachments for spaceId: ${spaceId}`);
}
} catch (err) {
throw err;
}
}
}

View File

@ -0,0 +1,3 @@
export enum UserTokenType {
FORGOT_PASSWORD = 'forgot-password',
}

View File

@ -10,7 +10,6 @@ import {
} from '@nestjs/common';
import { LoginDto } from './dto/login.dto';
import { AuthService } from './services/auth.service';
import { CreateUserDto } from './dto/create-user.dto';
import { SetupGuard } from './guards/setup.guard';
import { EnvironmentService } from '../../integrations/environment/environment.service';
import { CreateAdminUserDto } from './dto/create-admin-user.dto';
@ -19,6 +18,9 @@ import { AuthUser } from '../../common/decorators/auth-user.decorator';
import { User, Workspace } from '@docmost/db/types/entity.types';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { ForgotPasswordDto } from './dto/forgot-password.dto';
import { PasswordResetDto } from './dto/password-reset.dto';
import { VerifyUserTokenDto } from './dto/verify-user-token.dto';
@Controller('auth')
export class AuthController {
@ -61,4 +63,31 @@ export class AuthController {
) {
return this.authService.changePassword(dto, user.id, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('forgot-password')
async forgotPassword(
@Body() forgotPasswordDto: ForgotPasswordDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.authService.forgotPassword(forgotPasswordDto, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('password-reset')
async passwordReset(
@Body() passwordResetDto: PasswordResetDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.authService.passwordReset(passwordResetDto, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('verify-token')
async verifyResetToken(
@Body() verifyUserTokenDto: VerifyUserTokenDto,
@AuthWorkspace() workspace: Workspace,
) {
return this.authService.verifyUserToken(verifyUserTokenDto, workspace.id);
}
}

View File

@ -0,0 +1,7 @@
import { IsEmail, IsNotEmpty } from 'class-validator';
export class ForgotPasswordDto {
@IsNotEmpty()
@IsEmail()
email: string;
}

View File

@ -0,0 +1,10 @@
import { IsString, MinLength } from 'class-validator';
export class PasswordResetDto {
@IsString()
token: string;
@IsString()
@MinLength(8)
newPassword: string;
}

View File

@ -0,0 +1,9 @@
import { IsString, MinLength } from 'class-validator';
export class VerifyUserTokenDto {
@IsString()
token: string;
@IsString()
type: string;
}

View File

@ -11,10 +11,25 @@ import { TokensDto } from '../dto/tokens.dto';
import { SignupService } from './signup.service';
import { CreateAdminUserDto } from '../dto/create-admin-user.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { comparePasswordHash, hashPassword } from '../../../common/helpers';
import {
comparePasswordHash,
hashPassword,
nanoIdGen,
} from '../../../common/helpers';
import { ChangePasswordDto } from '../dto/change-password.dto';
import { MailService } from '../../../integrations/mail/mail.service';
import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email';
import { ForgotPasswordDto } from '../dto/forgot-password.dto';
import ForgotPasswordEmail from '@docmost/transactional/emails/forgot-password-email';
import { UserTokenRepo } from '@docmost/db/repos/user-token/user-token.repo';
import { PasswordResetDto } from '../dto/password-reset.dto';
import { UserToken } from '@docmost/db/types/entity.types';
import { UserTokenType } from '../auth.constants';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import { InjectKysely } from 'nestjs-kysely';
import { executeTx } from '@docmost/db/utils';
import { VerifyUserTokenDto } from '../dto/verify-user-token.dto';
import { EnvironmentService } from 'src/integrations/environment/environment.service';
@Injectable()
export class AuthService {
@ -22,7 +37,10 @@ export class AuthService {
private signupService: SignupService,
private tokenService: TokenService,
private userRepo: UserRepo,
private userTokenRepo: UserTokenRepo,
private mailService: MailService,
private environmentService: EnvironmentService,
@InjectKysely() private readonly db: KyselyDB,
) {}
async login(loginDto: LoginDto, workspaceId: string) {
@ -100,4 +118,108 @@ export class AuthService {
template: emailTemplate,
});
}
async forgotPassword(
forgotPasswordDto: ForgotPasswordDto,
workspaceId: string,
): Promise<void> {
const user = await this.userRepo.findByEmail(
forgotPasswordDto.email,
workspaceId,
);
if (!user) {
return;
}
const token = nanoIdGen(16);
const resetLink = `${this.environmentService.getAppUrl()}/password-reset?token=${token}`;
await this.userTokenRepo.insertUserToken({
token: token,
userId: user.id,
workspaceId: user.workspaceId,
expiresAt: new Date(new Date().getTime() + 60 * 60 * 1000), // 1 hour
type: UserTokenType.FORGOT_PASSWORD,
});
const emailTemplate = ForgotPasswordEmail({
username: user.name,
resetLink: resetLink,
});
await this.mailService.sendToQueue({
to: user.email,
subject: 'Reset your password',
template: emailTemplate,
});
}
async passwordReset(passwordResetDto: PasswordResetDto, workspaceId: string) {
const userToken = await this.userTokenRepo.findById(
passwordResetDto.token,
workspaceId,
);
if (
!userToken ||
userToken.type !== UserTokenType.FORGOT_PASSWORD ||
userToken.expiresAt < new Date()
) {
throw new BadRequestException('Invalid or expired token');
}
const user = await this.userRepo.findById(userToken.userId, workspaceId);
if (!user) {
throw new NotFoundException('User not found');
}
const newPasswordHash = await hashPassword(passwordResetDto.newPassword);
await executeTx(this.db, async (trx) => {
await this.userRepo.updateUser(
{
password: newPasswordHash,
},
user.id,
workspaceId,
trx,
);
trx
.deleteFrom('userTokens')
.where('userId', '=', user.id)
.where('type', '=', UserTokenType.FORGOT_PASSWORD)
.execute();
});
const emailTemplate = ChangePasswordEmail({ username: user.name });
await this.mailService.sendToQueue({
to: user.email,
subject: 'Your password has been changed',
template: emailTemplate,
});
const tokens: TokensDto = await this.tokenService.generateTokens(user);
return { tokens };
}
async verifyUserToken(
userTokenDto: VerifyUserTokenDto,
workspaceId: string,
): Promise<void> {
const userToken = await this.userTokenRepo.findById(
userTokenDto.token,
workspaceId,
);
if (
!userToken ||
userToken.type !== userTokenDto.type ||
userToken.expiresAt < new Date()
) {
throw new BadRequestException('Invalid or expired token');
}
}
}

View File

@ -31,7 +31,7 @@ export class TokenService {
workspaceId,
type: JwtType.REFRESH,
};
const expiresIn = '30d'; // todo: fix
const expiresIn = this.environmentService.getJwtTokenExpiresIn();
return this.jwtService.sign(payload, { expiresIn });
}

View File

@ -14,6 +14,9 @@ import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely';
import { SpaceMemberService } from './space-member.service';
import { SpaceRole } from '../../../common/helpers/types/permission';
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
import { Queue } from 'bullmq';
import { InjectQueue } from '@nestjs/bullmq';
@Injectable()
export class SpaceService {
@ -21,6 +24,7 @@ export class SpaceService {
private spaceRepo: SpaceRepo,
private spaceMemberService: SpaceMemberService,
@InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHEMENT_QUEUE) private attachmentQueue: Queue,
) {}
async createSpace(
@ -88,10 +92,24 @@ export class SpaceService {
updateSpaceDto: UpdateSpaceDto,
workspaceId: string,
): Promise<Space> {
if (updateSpaceDto?.slug) {
const slugExists = await this.spaceRepo.slugExists(
updateSpaceDto.slug,
workspaceId,
);
if (slugExists) {
throw new BadRequestException(
'Space slug exists. Please use a unique space slug',
);
}
}
return await this.spaceRepo.updateSpace(
{
name: updateSpaceDto.name,
description: updateSpaceDto.description,
slug: updateSpaceDto.slug,
},
updateSpaceDto.spaceId,
workspaceId,
@ -120,4 +138,14 @@ export class SpaceService {
return spaces;
}
async deleteSpace(spaceId: string, workspaceId: string): Promise<void> {
const space = await this.spaceRepo.findById(spaceId, workspaceId);
if (!space) {
throw new NotFoundException('Space not found');
}
await this.spaceRepo.deleteSpace(spaceId, workspaceId);
await this.attachmentQueue.add(QueueJob.DELETE_SPACE_ATTACHMENTS, space);
}
}

View File

@ -95,7 +95,7 @@ export class SpaceController {
@HttpCode(HttpStatus.OK)
@Post('create')
createGroup(
createSpace(
@Body() createSpaceDto: CreateSpaceDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@ -111,7 +111,7 @@ export class SpaceController {
@HttpCode(HttpStatus.OK)
@Post('update')
async updateGroup(
async updateSpace(
@Body() updateSpaceDto: UpdateSpaceDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
@ -126,6 +126,23 @@ export class SpaceController {
return this.spaceService.updateSpace(updateSpaceDto, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('delete')
async deleteSpace(
@Body() spaceIdDto: SpaceIdDto,
@AuthUser() user: User,
@AuthWorkspace() workspace: Workspace,
) {
const ability = await this.spaceAbility.createForUser(
user,
spaceIdDto.spaceId,
);
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
throw new ForbiddenException();
}
return this.spaceService.deleteSpace(spaceIdDto.spaceId, workspace.id);
}
@HttpCode(HttpStatus.OK)
@Post('members')
async getSpaceMembers(

View File

@ -248,7 +248,7 @@ export class WorkspaceInvitationService {
});
await this.mailService.sendToQueue({
to: invitation.email,
to: invitedByUser.email,
subject: `${newUser.name} has accepted your Docmost invite`,
template: emailTemplate,
});

View File

@ -22,6 +22,7 @@ import { AttachmentRepo } from './repos/attachment/attachment.repo';
import { KyselyDB } from '@docmost/db/types/kysely.types';
import * as process from 'node:process';
import { MigrationService } from '@docmost/db/services/migration.service';
import { UserTokenRepo } from './repos/user-token/user-token.repo';
// https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@ -66,6 +67,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
PageHistoryRepo,
CommentRepo,
AttachmentRepo,
UserTokenRepo,
],
exports: [
WorkspaceRepo,
@ -78,6 +80,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
PageHistoryRepo,
CommentRepo,
AttachmentRepo,
UserTokenRepo,
],
})
export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap {

View File

@ -0,0 +1,27 @@
import { sql, Kysely } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('user_tokens')
.addColumn('id', 'uuid', (col) =>
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
)
.addColumn('token', 'varchar', (col) => col.notNull())
.addColumn('type', 'varchar', (col) => col.notNull())
.addColumn('user_id', 'uuid', (col) =>
col.notNull().references('users.id').onDelete('cascade'),
)
.addColumn('workspace_id', 'uuid', (col) =>
col.references('workspaces.id').onDelete('cascade'),
)
.addColumn('expires_at', 'timestamptz')
.addColumn('used_at', 'timestamptz', (col) => col)
.addColumn('created_at', 'timestamptz', (col) =>
col.notNull().defaultTo(sql`now()`),
)
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('user_tokens').execute();
}

View File

@ -40,11 +40,26 @@ export class AttachmentRepo {
.executeTakeFirst();
}
async findBySpaceId(
spaceId: string,
opts?: {
trx?: KyselyTransaction;
},
): Promise<Attachment[]> {
const db = dbOrTx(this.db, opts?.trx);
return db
.selectFrom('attachments')
.selectAll()
.where('spaceId', '=', spaceId)
.execute();
}
async updateAttachment(
updatableAttachment: UpdatableAttachment,
attachmentId: string,
): Promise<void> {
await this.db
): Promise<Attachment> {
return await this.db
.updateTable('attachments')
.set(updatableAttachment)
.where('id', '=', attachmentId)
@ -52,7 +67,7 @@ export class AttachmentRepo {
.executeTakeFirst();
}
async deleteAttachment(attachmentId: string): Promise<void> {
async deleteAttachmentById(attachmentId: string): Promise<void> {
await this.db
.deleteFrom('attachments')
.where('id', '=', attachmentId)

View File

@ -64,7 +64,7 @@ export class SpaceMemberRepo {
} else if (opts.groupId) {
query = query.where('groupId', '=', opts.groupId);
} else {
throw new BadRequestException('Please provider a userId or groupId');
throw new BadRequestException('Please provide a userId or groupId');
}
return query.executeTakeFirst();
}

View File

@ -0,0 +1,102 @@
import {
InsertableUserToken,
UpdatableUserToken,
UserToken,
} from '@docmost/db/types/entity.types';
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
import { dbOrTx } from '@docmost/db/utils';
import { Injectable } from '@nestjs/common';
import { InjectKysely } from 'nestjs-kysely';
@Injectable()
export class UserTokenRepo {
constructor(@InjectKysely() private readonly db: KyselyDB) {}
async findById(
token: string,
workspaceId: string,
trx?: KyselyTransaction,
): Promise<UserToken> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('userTokens')
.select([
'id',
'token',
'userId',
'workspaceId',
'type',
'expiresAt',
'usedAt',
'createdAt',
])
.where('token', '=', token)
.where('workspaceId', '=', workspaceId)
.executeTakeFirst();
}
async insertUserToken(
insertableUserToken: InsertableUserToken,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.insertInto('userTokens')
.values(insertableUserToken)
.returningAll()
.executeTakeFirst();
}
async findByUserId(
userId: string,
workspaceId: string,
tokenType: string,
trx?: KyselyTransaction,
): Promise<UserToken[]> {
const db = dbOrTx(this.db, trx);
return db
.selectFrom('userTokens')
.select([
'id',
'token',
'userId',
'workspaceId',
'type',
'expiresAt',
'usedAt',
'createdAt',
])
.where('userId', '=', userId)
.where('workspaceId', '=', workspaceId)
.where('type', '=', tokenType)
.orderBy('expiresAt desc')
.execute();
}
async updateUserToken(
updatableUserToken: UpdatableUserToken,
userTokenId: string,
trx?: KyselyTransaction,
) {
const db = dbOrTx(this.db, trx);
return db
.updateTable('userTokens')
.set(updatableUserToken)
.where('id', '=', userTokenId)
.execute();
}
async deleteToken(token: string, trx?: KyselyTransaction): Promise<void> {
const db = dbOrTx(this.db, trx);
await db.deleteFrom('userTokens').where('token', '=', token).execute();
}
async deleteExpiredUserTokens(trx?: KyselyTransaction): Promise<void> {
const db = dbOrTx(this.db, trx);
await db
.deleteFrom('userTokens')
.where('expiresAt', '<', new Date())
.execute();
}
}

View File

@ -102,7 +102,7 @@ export class UserRepo {
name: insertableUser.name || insertableUser.email.toLowerCase(),
email: insertableUser.email.toLowerCase(),
password: await hashPassword(insertableUser.password),
locale: 'en',
locale: 'en-US',
role: insertableUser?.role,
lastLoginAt: new Date(),
};

View File

@ -1,3 +1,8 @@
/**
* This file was generated by kysely-codegen.
* Please do not edit it manually.
*/
import type { ColumnType } from "kysely";
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U>
@ -11,7 +16,7 @@ export type Json = JsonValue;
export type JsonArray = JsonValue[];
export type JsonObject = {
[K in string]?: JsonValue;
[x: string]: JsonValue | undefined;
};
export type JsonPrimitive = boolean | number | string | null;
@ -157,6 +162,17 @@ export interface Users {
workspaceId: string | null;
}
export interface UserTokens {
createdAt: Generated<Timestamp>;
expiresAt: Timestamp | null;
id: Generated<string>;
token: string;
type: string;
usedAt: Timestamp | null;
userId: string;
workspaceId: string | null;
}
export interface WorkspaceInvitations {
createdAt: Generated<Timestamp>;
email: string | null;
@ -195,6 +211,7 @@ export interface DB {
spaceMembers: SpaceMembers;
spaces: Spaces;
users: Users;
userTokens: UserTokens;
workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces;
}

View File

@ -11,6 +11,7 @@ import {
GroupUsers,
SpaceMembers,
WorkspaceInvitations,
UserTokens,
} from './db';
// Workspace
@ -71,3 +72,8 @@ export type UpdatableComment = Updateable<Omit<Comments, 'id'>>;
export type Attachment = Selectable<Attachments>;
export type InsertableAttachment = Insertable<Attachments>;
export type UpdatableAttachment = Updateable<Omit<Attachments, 'id'>>;
// User Token
export type UserToken = Selectable<UserTokens>;
export type InsertableUserToken = Insertable<UserTokens>;
export type UpdatableUserToken = Updateable<Omit<UserTokens, 'id'>>;

View File

@ -43,6 +43,11 @@ export class EnvironmentService {
return this.configService.get<string>('STORAGE_DRIVER', 'local');
}
getFileUploadSizeLimit(): string {
return this.configService.get<string>('FILE_UPLOAD_SIZE_LIMIT', '50mb');
}
getAwsS3AccessKeyId(): string {
return this.configService.get<string>('AWS_S3_ACCESS_KEY_ID');
}
@ -63,6 +68,10 @@ export class EnvironmentService {
return this.configService.get<string>('AWS_S3_ENDPOINT');
}
getAwsS3ForcePathStyle(): boolean {
return this.configService.get<boolean>('AWS_S3_FORCE_PATH_STYLE');
}
getAwsS3Url(): string {
return this.configService.get<string>('AWS_S3_URL');
}
@ -94,6 +103,13 @@ export class EnvironmentService {
return secure === 'true';
}
getSmtpIgnoreTLS(): boolean {
const ignoretls = this.configService
.get<string>('SMTP_IGNORETLS', 'false')
.toLowerCase();
return ignoretls === 'true';
}
getSmtpUsername(): string {
return this.configService.get<string>('SMTP_USERNAME');
}

View File

@ -61,7 +61,8 @@ export class ImportController {
res.headers({
'Content-Type': getMimeType(fileExt),
'Content-Disposition': 'attachment; filename="' + fileName + '"',
'Content-Disposition':
'attachment; filename="' + encodeURIComponent(fileName) + '"',
});
res.send(rawContent);

Some files were not shown because too many files have changed in this diff Show More