23 Commits

Author SHA1 Message Date
2102595f8a Merge remote-tracking branch 'origin/main' into Merged-Downstream
# Conflicts:
#	apps/client/src/features/auth/hooks/use-auth.ts
#	apps/client/src/features/editor/extensions/extensions.ts
2024-10-01 12:43:22 +10:00
d69fff5ef7 Merge branch 'docmost:main' into main 2024-10-01 09:59:55 +10: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
8eb5eb3161 Merge branch 'docmost:main' into Merged-Downstream 2024-09-17 10:08:55 +10: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
15eb997c92 Create main.yml 2024-09-16 10:08:24 +10:00
9e8a3681d6 Merge branch 'Table-of-Contents' into Merged-Downstream 2024-09-16 09:59:40 +10:00
38f66eaab5 Merge branch 'SMTP-IgnoreTLS' into Merged-Downstream 2024-09-16 09:59:34 +10:00
6ad469a115 Minimum viable NTLM auth implementation
Added env variable "VITE_NTLM_AUTH", if true, login page will attempt NTLM auth challenge instead of showing login page.

If challenge is successful and an authenticate message is received, it will check for the existence of the user using the provided mail attribute, and create an account with a random, complex password, and then authenticate as the user.
2024-09-16 08:32:33 +10: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
9d0331d04f Create Auth/NTLM Endpoint
- Adds NTLM challenge negotiation
- Checks NTLM auth user & domain against AD / LDAP and returns info
- Adds relevant .env entries
2024-09-13 08:37:25 +10:00
0bfd3b6771 Implement nodemailer ignore tls property 2024-09-11 11:23:38 +10:00
82 changed files with 2093 additions and 441 deletions

View File

@ -7,6 +7,18 @@ APP_SECRET=REPLACE_WITH_LONG_SECRET
JWT_TOKEN_EXPIRES_IN=30d JWT_TOKEN_EXPIRES_IN=30d
# Use NTLM for user authentication, exposes to browser
VITE_NTLM_AUTH=false
# LDAP settings for NTLM authentication
LDAP_BASEDN=
LDAP_DOMAINSUFFIX=
LDAP_USERNAME=
LDAP_PASSWORD=
# User object attributes for docmost
LDAP_NAMEATTRIBUTE=
LDAP_MAILATTRIBUTE=
DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public" DATABASE_URL="postgresql://postgres:password@localhost:5432/docmost?schema=public"
REDIS_URL=redis://127.0.0.1:6379 REDIS_URL=redis://127.0.0.1:6379
@ -32,6 +44,7 @@ SMTP_PORT=587
SMTP_USERNAME= SMTP_USERNAME=
SMTP_PASSWORD= SMTP_PASSWORD=
SMTP_SECURE=false SMTP_SECURE=false
SMTP_IGNORETLS=false
# Postmark driver config # Postmark driver config
POSTMARK_TOKEN= POSTMARK_TOKEN=

47
.github/workflows/main.yml vendored Normal file
View File

@ -0,0 +1,47 @@
name: Sync Merged-Downstream with Docmost Upstream Main
on:
schedule:
- cron: '0 0 * * *' # Run daily at midnight UTC
jobs:
sync:
runs-on: ubuntu-latest
steps:
- name: Checkout repo
uses: actions/checkout@v2
with:
fetch-depth: 0 # Fetch all history to detect changes properly
- name: Set up git
run: |
git config --global user.name "Shadowfita"
git config --global user.email "www.ryan.palmer@hotmail.com"
- name: Fetch upstream
run: |
git remote add upstream https://github.com/docmost/docmost.git
git fetch upstream
- name: Check if upstream has changes
id: check_changes
run: |
UPSTREAM_DIFF=$(git diff --name-only upstream/main)
if [ -z "$UPSTREAM_DIFF" ]; then
echo "No changes in upstream main branch."
echo "::set-output name=changes::false"
else
echo "Changes detected in upstream main branch."
echo "::set-output name=changes::true"
fi
- name: Merge upstream/main into Merged-Downstream
if: steps.check_changes.outputs.changes == 'true'
run: |
git checkout Merged-Downstream
git merge upstream/main
- name: Push changes to Merged-Downstream
if: steps.check_changes.outputs.changes == 'true'
run: |
git push origin Merged-Downstream

View File

@ -41,7 +41,6 @@
"react-drawio": "^0.2.0", "react-drawio": "^0.2.0",
"react-error-boundary": "^4.0.13", "react-error-boundary": "^4.0.13",
"react-helmet-async": "^2.0.5", "react-helmet-async": "^2.0.5",
"react-moveable": "^0.56.0",
"react-router-dom": "^6.26.1", "react-router-dom": "^6.26.1",
"socket.io-client": "^4.7.5", "socket.io-client": "^4.7.5",
"tippy.js": "^6.3.7", "tippy.js": "^6.3.7",

View File

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

View File

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

View File

@ -1,19 +1,25 @@
import { Title, Text, Button, Container, Group } from "@mantine/core"; import { Title, Text, Button, Container, Group } from "@mantine/core";
import classes from "./error-404.module.css"; import classes from "./error-404.module.css";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Helmet } from "react-helmet-async";
export function Error404() { export function Error404() {
return ( return (
<Container className={classes.root}> <>
<Title className={classes.title}>404 Page Not Found</Title> <Helmet>
<Text c="dimmed" size="lg" ta="center" className={classes.description}> <title>404 page not found - Docmost</title>
Sorry, we can't find the page you are looking for. </Helmet>
</Text> <Container className={classes.root}>
<Group justify="center"> <Title className={classes.title}>404 Page Not Found</Title>
<Button component={Link} to={"/home"} variant="subtle" size="md"> <Text c="dimmed" size="lg" ta="center" className={classes.description}>
Take me back to homepage Sorry, we can't find the page you are looking for.
</Button> </Text>
</Group> <Group justify="center">
</Container> <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 * as z from "zod";
import { useForm, zodResolver } from "@mantine/form"; import { useForm, zodResolver } from "@mantine/form";
import useAuth from "@/features/auth/hooks/use-auth"; import useAuth from "@/features/auth/hooks/use-auth";
@ -10,9 +9,13 @@ import {
Button, Button,
PasswordInput, PasswordInput,
Box, Box,
Anchor,
} from "@mantine/core"; } from "@mantine/core";
import classes from "./auth.module.css"; import classes from "./auth.module.css";
import { useRedirectIfAuthenticated } from "@/features/auth/hooks/use-redirect-if-authenticated.ts"; 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({ const formSchema = z.object({
email: z email: z
@ -62,10 +65,20 @@ export function LoginForm() {
mt="md" mt="md"
{...form.getInputProps("password")} {...form.getInputProps("password")}
/> />
<Button type="submit" fullWidth mt="xl" loading={isLoading}> <Button type="submit" fullWidth mt="xl" loading={isLoading}>
Sign In Sign In
</Button> </Button>
</form> </form>
<Anchor
to={APP_ROUTE.AUTH.FORGOT_PASSWORD}
component={Link}
underline="never"
size="sm"
>
Forgot your password?
</Anchor>
</Box> </Box>
</Container> </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,23 @@
import { useState } from "react"; import { useState } from "react";
import { login, setupWorkspace } from "@/features/auth/services/auth-service"; import {
forgotPassword,
login,
ntlmLogin,
passwordReset,
setupWorkspace,
verifyUserToken,
} from "@/features/auth/services/auth-service";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import { useAtom } from "jotai"; import { useAtom } from "jotai";
import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom"; import { authTokensAtom } from "@/features/auth/atoms/auth-tokens-atom";
import { currentUserAtom } from "@/features/user/atoms/current-user-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 { notifications } from "@mantine/notifications";
import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts"; import { IAcceptInvite } from "@/features/workspace/types/workspace.types.ts";
import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts"; import { acceptInvitation } from "@/features/workspace/services/workspace-service.ts";
@ -38,6 +51,25 @@ export default function useAuth() {
} }
}; };
const handleNtlmSignIn = async () => {
setIsLoading(true);
try {
const res = await ntlmLogin();
setIsLoading(false);
setAuthToken(res.tokens);
navigate(APP_ROUTE.HOME);
} catch (err) {
console.log(err);
setIsLoading(false);
notifications.show({
message: err.response?.data.message,
color: "red",
});
}
};
const handleInvitationSignUp = async (data: IAcceptInvite) => { const handleInvitationSignUp = async (data: IAcceptInvite) => {
setIsLoading(true); setIsLoading(true);
@ -76,6 +108,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 () => { const handleIsAuthenticated = async () => {
if (!authToken) { if (!authToken) {
return false; return false;
@ -105,11 +159,51 @@ export default function useAuth() {
navigate(APP_ROUTE.AUTH.LOGIN); 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 { return {
signIn: handleSignIn, signIn: handleSignIn,
ntlmSignIn: handleNtlmSignIn,
invitationSignup: handleInvitationSignUp, invitationSignup: handleInvitationSignUp,
setupWorkspace: handleSetupWorkspace, setupWorkspace: handleSetupWorkspace,
isAuthenticated: handleIsAuthenticated, isAuthenticated: handleIsAuthenticated,
forgotPassword: handleForgotPassword,
passwordReset: handlePasswordReset,
verifyUserToken: handleVerifyUserToken,
logout: handleLogout, logout: handleLogout,
hasTokens, hasTokens,
isLoading, 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,17 +1,27 @@
import api from "@/lib/api-client"; import api from "@/lib/api-client";
import { import {
IChangePassword, IChangePassword,
IForgotPassword,
ILogin, ILogin,
IPasswordReset,
IRegister, IRegister,
ISetupWorkspace, ISetupWorkspace,
ITokenResponse, ITokenResponse,
IVerifyUserToken,
} from "@/features/auth/types/auth.types"; } from "@/features/auth/types/auth.types";
import axios from "axios";
export async function login(data: ILogin): Promise<ITokenResponse> { export async function login(data: ILogin): Promise<ITokenResponse> {
const req = await api.post<ITokenResponse>("/auth/login", data); const req = await api.post<ITokenResponse>("/auth/login", data);
return req.data; return req.data;
} }
export async function ntlmLogin(): Promise<ITokenResponse> {
// Use separate axios instance to avoid passing app auth headers to allow for NTLM authentication challenge
const req = await axios.post<ITokenResponse>("/api/auth/ntlm");
return req.data;
}
/* /*
export async function register(data: IRegister): Promise<ITokenResponse> { export async function register(data: IRegister): Promise<ITokenResponse> {
const req = await api.post<ITokenResponse>("/auth/register", data); const req = await api.post<ITokenResponse>("/auth/register", data);
@ -19,15 +29,30 @@ export async function register(data: IRegister): Promise<ITokenResponse> {
}*/ }*/
export async function changePassword( export async function changePassword(
data: IChangePassword, data: IChangePassword
): Promise<IChangePassword> { ): Promise<IChangePassword> {
const req = await api.post<IChangePassword>("/auth/change-password", data); const req = await api.post<IChangePassword>("/auth/change-password", data);
return req.data; return req.data;
} }
export async function setupWorkspace( export async function setupWorkspace(
data: ISetupWorkspace, data: ISetupWorkspace
): Promise<ITokenResponse> { ): Promise<ITokenResponse> {
const req = await api.post<ITokenResponse>("/auth/setup", data); const req = await api.post<ITokenResponse>("/auth/setup", data);
return req.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; oldPassword: string;
newPassword: string; newPassword: string;
} }
export interface IForgotPassword {
email: string;
}
export interface IPasswordReset {
token?: string;
newPassword: string;
}
export interface IVerifyUserToken {
token: string;
type: string;
}

View File

@ -49,7 +49,7 @@ export default function CodeBlockView(props: NodeViewProps) {
<Select <Select
placeholder="auto" placeholder="auto"
checkIconPosition="right" checkIconPosition="right"
data={extension.options.lowlight.listLanguages()} data={extension.options.lowlight.listLanguages().sort()}
value={languageValue} value={languageValue}
onChange={changeLanguage} onChange={changeLanguage}
searchable searchable

View File

@ -33,7 +33,7 @@
border-radius: 4px; border-radius: 4px;
transition: background-color 0.2s; transition: background-color 0.2s;
margin: 0 0.1rem; margin: 0 0.1rem;
overflow-x: scroll; overflow-x: auto;
.textInput { .textInput {
width: 400px; width: 400px;

View File

@ -17,7 +17,8 @@ import {
IconPhoto, IconPhoto,
IconTable, IconTable,
IconTypography, IconTypography,
IconMenu4 IconMenu4,
IconCalendar,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
@ -345,6 +346,26 @@ const CommandGroups: SlashMenuGroupedItemsType = {
command: ({ editor, range }: CommandProps) => command: ({ editor, range }: CommandProps) =>
editor.chain().focus().deleteRange(range).setExcalidraw().run(), 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

@ -55,10 +55,27 @@ import CodeBlockView from "@/features/editor/components/code-block/code-block-vi
import DrawioView from "../components/drawio/drawio-view"; import DrawioView from "../components/drawio/drawio-view";
import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx"; import ExcalidrawView from "@/features/editor/components/excalidraw/excalidraw-view.tsx";
import plaintext from "highlight.js/lib/languages/plaintext"; 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";
import TableOfContentsView from "../components/table-of-contents/table-of-contents-view"; import TableOfContentsView from "../components/table-of-contents/table-of-contents-view";
const lowlight = createLowlight(common); const lowlight = createLowlight(common);
lowlight.register("mermaid", plaintext); 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 = [ export const mainExtensions = [
StarterKit.configure({ StarterKit.configure({

View File

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

View File

@ -64,7 +64,7 @@ export async function exportPage(data: IExportPageParams): Promise<void> {
.split("filename=")[1] .split("filename=")[1]
.replace(/"/g, ""); .replace(/"/g, "");
saveAs(req.data, fileName); saveAs(req.data, decodeURIComponent(fileName));
} }
export async function importPage(file: File, spaceId: string) { export async function importPage(file: File, spaceId: string) {
@ -81,15 +81,18 @@ export async function importPage(file: File, spaceId: string) {
return req.data; return req.data;
} }
export async function uploadFile(file: File, pageId: string, attachmentId?: string): Promise<IAttachment> { export async function uploadFile(
file: File,
pageId: string,
attachmentId?: string,
): Promise<IAttachment> {
const formData = new FormData(); const formData = new FormData();
if(attachmentId){ if (attachmentId) {
formData.append("attachmentId", attachmentId); formData.append("attachmentId", attachmentId);
} }
formData.append("pageId", pageId); formData.append("pageId", pageId);
formData.append("file", file); formData.append("file", file);
const req = await api.post<IAttachment>("/files/upload", formData, { const req = await api.post<IAttachment>("/files/upload", formData, {
headers: { headers: {
"Content-Type": "multipart/form-data", "Content-Type": "multipart/form-data",

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({ const formSchema = z.object({
name: z.string().min(2).max(50), name: z.string().min(2).max(50),
description: z.string().max(250), 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>; type FormValues = z.infer<typeof formSchema>;
@ -23,12 +31,14 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
initialValues: { initialValues: {
name: space?.name, name: space?.name,
description: space?.description || "", description: space?.description || "",
slug: space.slug,
}, },
}); });
const handleSubmit = async (values: { const handleSubmit = async (values: {
name?: string; name?: string;
description?: string; description?: string;
slug?: string;
}) => { }) => {
const spaceData: Partial<ISpace> = { const spaceData: Partial<ISpace> = {
spaceId: space.id, spaceId: space.id,
@ -40,6 +50,10 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
spaceData.description = values.description; spaceData.description = values.description;
} }
if (form.isDirty("slug")) {
spaceData.slug = values.slug;
}
await updateSpaceMutation.mutateAsync(spaceData); await updateSpaceMutation.mutateAsync(spaceData);
form.resetDirty(); form.resetDirty();
}; };
@ -62,8 +76,8 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
id="slug" id="slug"
label="Slug" label="Slug"
variant="filled" variant="filled"
readOnly readOnly={readOnly}
value={space.slug} {...form.getInputProps("slug")}
/> />
<Textarea <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, Text,
Tooltip, Tooltip,
UnstyledButton, UnstyledButton,
} from "@mantine/core"; } from '@mantine/core';
import { spotlight } from "@mantine/spotlight"; import { spotlight } from '@mantine/spotlight';
import { import {
IconArrowDown, IconArrowDown,
IconDots, IconDots,
@ -14,27 +14,27 @@ import {
IconPlus, IconPlus,
IconSearch, IconSearch,
IconSettings, IconSettings,
} from "@tabler/icons-react"; } from '@tabler/icons-react';
import classes from "./space-sidebar.module.css"; import classes from './space-sidebar.module.css';
import React, { useMemo } from "react"; import React, { useMemo } from 'react';
import { useAtom } from "jotai"; import { useAtom } from 'jotai';
import { SearchSpotlight } from "@/features/search/search-spotlight.tsx"; import { SearchSpotlight } from '@/features/search/search-spotlight.tsx';
import { treeApiAtom } from "@/features/page/tree/atoms/tree-api-atom.ts"; import { treeApiAtom } from '@/features/page/tree/atoms/tree-api-atom.ts';
import { Link, useLocation, useParams } from "react-router-dom"; import { Link, useLocation, useParams } from 'react-router-dom';
import clsx from "clsx"; import clsx from 'clsx';
import { useDisclosure } from "@mantine/hooks"; import { useDisclosure } from '@mantine/hooks';
import SpaceSettingsModal from "@/features/space/components/settings-modal.tsx"; import SpaceSettingsModal from '@/features/space/components/settings-modal.tsx';
import { useGetSpaceBySlugQuery } from "@/features/space/queries/space-query.ts"; 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 { getSpaceUrl } from "@/lib/config.ts"; import SpaceTree from '@/features/page/tree/components/space-tree.tsx';
import SpaceTree from "@/features/page/tree/components/space-tree.tsx"; import { useSpaceAbility } from '@/features/space/permissions/use-space-ability.ts';
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
import { import {
SpaceCaslAction, SpaceCaslAction,
SpaceCaslSubject, SpaceCaslSubject,
} from "@/features/space/permissions/permissions.type.ts"; } from '@/features/space/permissions/permissions.type.ts';
import PageImportModal from "@/features/page/components/page-import-modal.tsx"; import PageImportModal from '@/features/page/components/page-import-modal.tsx';
import { SwitchSpace } from './switch-space';
export function SpaceSidebar() { export function SpaceSidebar() {
const [tree] = useAtom(treeApiAtom); const [tree] = useAtom(treeApiAtom);
@ -52,7 +52,7 @@ export function SpaceSidebar() {
} }
function handleCreatePage() { function handleCreatePage() {
tree?.create({ parentId: null, type: "internal", index: 0 }); tree?.create({ parentId: null, type: 'internal', index: 0 });
} }
return ( return (
@ -61,11 +61,12 @@ export function SpaceSidebar() {
<div <div
className={classes.section} className={classes.section}
style={{ style={{
border: "none", border: 'none',
marginBottom: "0", marginTop: 2,
marginBottom: 3,
}} }}
> >
<SpaceName spaceName={space?.name} /> <SwitchSpace spaceName={space?.name} spaceSlug={space?.slug} />
</div> </div>
<div className={classes.section}> <div className={classes.section}>
@ -77,7 +78,7 @@ export function SpaceSidebar() {
classes.menu, classes.menu,
location.pathname.toLowerCase() === getSpaceUrl(spaceSlug) location.pathname.toLowerCase() === getSpaceUrl(spaceSlug)
? classes.activeButton ? classes.activeButton
: "", : ''
)} )}
> >
<div className={classes.menuItemInner}> <div className={classes.menuItemInner}>
@ -114,7 +115,7 @@ export function SpaceSidebar() {
{spaceAbility.can( {spaceAbility.can(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Page, SpaceCaslSubject.Page
) && ( ) && (
<UnstyledButton <UnstyledButton
className={classes.menu} className={classes.menu}
@ -141,7 +142,7 @@ export function SpaceSidebar() {
{spaceAbility.can( {spaceAbility.can(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Page, SpaceCaslSubject.Page
) && ( ) && (
<Group gap="xs"> <Group gap="xs">
<SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} /> <SpaceMenu spaceId={space.id} onSpaceSettings={openSettings} />
@ -165,7 +166,7 @@ export function SpaceSidebar() {
spaceId={space.id} spaceId={space.id}
readOnly={spaceAbility.cannot( readOnly={spaceAbility.cannot(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Page, SpaceCaslSubject.Page
)} )}
/> />
</div> </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 React from 'react';
import { useSpaceQuery } from "@/features/space/queries/space-query.ts"; import { useSpaceQuery } from '@/features/space/queries/space-query.ts';
import { EditSpaceForm } from "@/features/space/components/edit-space-form.tsx"; import { EditSpaceForm } from '@/features/space/components/edit-space-form.tsx';
import { Text } from "@mantine/core"; import { Divider, Group, Text } from '@mantine/core';
import DeleteSpaceModal from './delete-space-modal';
interface SpaceDetailsProps { interface SpaceDetailsProps {
spaceId: string; spaceId: string;
@ -18,6 +19,23 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
Details Details
</Text> </Text>
<EditSpaceForm space={space} readOnly={readOnly} /> <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> </div>
)} )}
</> </>

View File

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

View File

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

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 ( return (
<> <>
<Helmet> <Helmet>
<title>Invitation signup</title> <title>Invitation Signup - Docmost</title>
</Helmet> </Helmet>
<InviteSignUpForm /> <InviteSignUpForm />
</> </>

View File

@ -1,13 +1,28 @@
import { LoginForm } from "@/features/auth/components/login-form"; import { LoginForm } from "@/features/auth/components/login-form";
import useAuth from "@/features/auth/hooks/use-auth";
import { useEffect } from "react";
import { Helmet } from "react-helmet-async"; import { Helmet } from "react-helmet-async";
const ntlmAuth = import.meta.env.VITE_NTLM_AUTH;
export default function LoginPage() { export default function LoginPage() {
const { ntlmSignIn } = useAuth();
useEffect(() => {
if (ntlmAuth)
ntlmSignIn();
}, [])
return ( return (
<> <>
<Helmet> <Helmet>
<title>Login</title> <title>Login - Docmost</title>
</Helmet> </Helmet>
<LoginForm /> {!ntlmAuth && <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 ( return (
<> <>
<Helmet> <Helmet>
<title>Setup workspace</title> <title>Setup Workspace - Docmost</title>
</Helmet> </Helmet>
<SetupWorkspaceForm /> <SetupWorkspaceForm />
</> </>

View File

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

View File

@ -59,11 +59,13 @@
"happy-dom": "^15.7.3", "happy-dom": "^15.7.3",
"kysely": "^0.27.4", "kysely": "^0.27.4",
"kysely-migration-cli": "^0.4.2", "kysely-migration-cli": "^0.4.2",
"ldapjs": "^3.0.7",
"marked": "^13.0.3", "marked": "^13.0.3",
"mime-types": "^2.1.35", "mime-types": "^2.1.35",
"nanoid": "^5.0.7", "nanoid": "^5.0.7",
"nestjs-kysely": "^1.0.0", "nestjs-kysely": "^1.0.0",
"nodemailer": "^6.9.14", "nodemailer": "^6.9.14",
"ntlm-server": "^0.1.3",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"pg": "^8.12.0", "pg": "^8.12.0",
"pg-tsquery": "^8.4.2", "pg-tsquery": "^8.4.2",
@ -85,6 +87,7 @@
"@types/debounce": "^1.2.4", "@types/debounce": "^1.2.4",
"@types/fs-extra": "^11.0.4", "@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/ldapjs": "^3.0.6",
"@types/mime-types": "^2.1.4", "@types/mime-types": "^2.1.4",
"@types/node": "^22.5.2", "@types/node": "^22.5.2",
"@types/nodemailer": "^6.4.15", "@types/nodemailer": "^6.4.15",

View File

@ -14,6 +14,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
import { HealthModule } from './integrations/health/health.module'; import { HealthModule } from './integrations/health/health.module';
import { ExportModule } from './integrations/export/export.module'; import { ExportModule } from './integrations/export/export.module';
import { ImportModule } from './integrations/import/import.module'; import { ImportModule } from './integrations/import/import.module';
import { NTLMModule } from './integrations/ntlm/ntlm.module';
@Module({ @Module({
imports: [ imports: [
@ -27,6 +28,7 @@ import { ImportModule } from './integrations/import/import.module';
HealthModule, HealthModule,
ImportModule, ImportModule,
ExportModule, ExportModule,
NTLMModule,
StorageModule.forRootAsync({ StorageModule.forRootAsync({
imports: [EnvironmentModule], imports: [EnvironmentModule],
}), }),

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 { Extensions, getSchema } from '@tiptap/core';
import { DOMParser, ParseOptions } from '@tiptap/pm/model'; 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( export function generateJSON(
html: string, html: string,
extensions: Extensions, extensions: Extensions,
@ -10,8 +12,10 @@ export function generateJSON(
const schema = getSchema(extensions); const schema = getSchema(extensions);
const window = new Window(); const window = new Window();
const dom = new HappyDomParser().parseFromString(html, 'text/html').body; const document = window.document;
document.body.innerHTML = html;
// @ts-ignore return DOMParser.fromSchema(schema)
return DOMParser.fromSchema(schema).parse(dom, options).toJSON(); .parse(document as never, options)
.toJSON();
} }

View File

@ -182,7 +182,7 @@ export class AttachmentController {
if (!inlineFileExtensions.includes(attachment.fileExt)) { if (!inlineFileExtensions.includes(attachment.fileExt)) {
res.header( res.header(
'Content-Disposition', 'Content-Disposition',
`attachment; filename="${attachment.fileName}"`, `attachment; filename="${encodeURIComponent(attachment.fileName)}"`,
); );
} }

View File

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

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

@ -256,4 +256,37 @@ export class AttachmentService {
trx, 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'; } from '@nestjs/common';
import { LoginDto } from './dto/login.dto'; import { LoginDto } from './dto/login.dto';
import { AuthService } from './services/auth.service'; import { AuthService } from './services/auth.service';
import { CreateUserDto } from './dto/create-user.dto';
import { SetupGuard } from './guards/setup.guard'; import { SetupGuard } from './guards/setup.guard';
import { EnvironmentService } from '../../integrations/environment/environment.service'; import { EnvironmentService } from '../../integrations/environment/environment.service';
import { CreateAdminUserDto } from './dto/create-admin-user.dto'; 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 { User, Workspace } from '@docmost/db/types/entity.types';
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator'; import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard'; 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') @Controller('auth')
export class AuthController { export class AuthController {
@ -61,4 +63,31 @@ export class AuthController {
) { ) {
return this.authService.changePassword(dto, user.id, workspace.id); 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

@ -10,5 +10,6 @@ import { TokenModule } from './token.module';
imports: [TokenModule, WorkspaceModule], imports: [TokenModule, WorkspaceModule],
controllers: [AuthController], controllers: [AuthController],
providers: [AuthService, SignupService, JwtStrategy], providers: [AuthService, SignupService, JwtStrategy],
exports: [AuthService]
}) })
export class AuthModule {} export class AuthModule {}

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 { SignupService } from './signup.service';
import { CreateAdminUserDto } from '../dto/create-admin-user.dto'; import { CreateAdminUserDto } from '../dto/create-admin-user.dto';
import { UserRepo } from '@docmost/db/repos/user/user.repo'; 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 { ChangePasswordDto } from '../dto/change-password.dto';
import { MailService } from '../../../integrations/mail/mail.service'; import { MailService } from '../../../integrations/mail/mail.service';
import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email'; import ChangePasswordEmail from '@docmost/transactional/emails/change-password-email';
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() @Injectable()
export class AuthService { export class AuthService {
@ -22,7 +37,10 @@ export class AuthService {
private signupService: SignupService, private signupService: SignupService,
private tokenService: TokenService, private tokenService: TokenService,
private userRepo: UserRepo, private userRepo: UserRepo,
private userTokenRepo: UserTokenRepo,
private mailService: MailService, private mailService: MailService,
private environmentService: EnvironmentService,
@InjectKysely() private readonly db: KyselyDB,
) {} ) {}
async login(loginDto: LoginDto, workspaceId: string) { async login(loginDto: LoginDto, workspaceId: string) {
@ -100,4 +118,108 @@ export class AuthService {
template: emailTemplate, 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, workspaceId,
type: JwtType.REFRESH, type: JwtType.REFRESH,
}; };
const expiresIn = '30d'; // todo: fix const expiresIn = this.environmentService.getJwtTokenExpiresIn();
return this.jwtService.sign(payload, { expiresIn }); return this.jwtService.sign(payload, { expiresIn });
} }

View File

@ -14,6 +14,9 @@ import { executeTx } from '@docmost/db/utils';
import { InjectKysely } from 'nestjs-kysely'; import { InjectKysely } from 'nestjs-kysely';
import { SpaceMemberService } from './space-member.service'; import { SpaceMemberService } from './space-member.service';
import { SpaceRole } from '../../../common/helpers/types/permission'; 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() @Injectable()
export class SpaceService { export class SpaceService {
@ -21,6 +24,7 @@ export class SpaceService {
private spaceRepo: SpaceRepo, private spaceRepo: SpaceRepo,
private spaceMemberService: SpaceMemberService, private spaceMemberService: SpaceMemberService,
@InjectKysely() private readonly db: KyselyDB, @InjectKysely() private readonly db: KyselyDB,
@InjectQueue(QueueName.ATTACHEMENT_QUEUE) private attachmentQueue: Queue,
) {} ) {}
async createSpace( async createSpace(
@ -88,10 +92,24 @@ export class SpaceService {
updateSpaceDto: UpdateSpaceDto, updateSpaceDto: UpdateSpaceDto,
workspaceId: string, workspaceId: string,
): Promise<Space> { ): 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( return await this.spaceRepo.updateSpace(
{ {
name: updateSpaceDto.name, name: updateSpaceDto.name,
description: updateSpaceDto.description, description: updateSpaceDto.description,
slug: updateSpaceDto.slug,
}, },
updateSpaceDto.spaceId, updateSpaceDto.spaceId,
workspaceId, workspaceId,
@ -120,4 +138,14 @@ export class SpaceService {
return spaces; 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) @HttpCode(HttpStatus.OK)
@Post('create') @Post('create')
createGroup( createSpace(
@Body() createSpaceDto: CreateSpaceDto, @Body() createSpaceDto: CreateSpaceDto,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@ -111,7 +111,7 @@ export class SpaceController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@Post('update') @Post('update')
async updateGroup( async updateSpace(
@Body() updateSpaceDto: UpdateSpaceDto, @Body() updateSpaceDto: UpdateSpaceDto,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@ -126,6 +126,23 @@ export class SpaceController {
return this.spaceService.updateSpace(updateSpaceDto, workspace.id); 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) @HttpCode(HttpStatus.OK)
@Post('members') @Post('members')
async getSpaceMembers( async getSpaceMembers(

View File

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

View File

@ -22,6 +22,7 @@ import { AttachmentRepo } from './repos/attachment/attachment.repo';
import { KyselyDB } from '@docmost/db/types/kysely.types'; import { KyselyDB } from '@docmost/db/types/kysely.types';
import * as process from 'node:process'; import * as process from 'node:process';
import { MigrationService } from '@docmost/db/services/migration.service'; 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 // https://github.com/brianc/node-postgres/issues/811
types.setTypeParser(types.builtins.INT8, (val) => Number(val)); types.setTypeParser(types.builtins.INT8, (val) => Number(val));
@ -66,6 +67,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
PageHistoryRepo, PageHistoryRepo,
CommentRepo, CommentRepo,
AttachmentRepo, AttachmentRepo,
UserTokenRepo,
], ],
exports: [ exports: [
WorkspaceRepo, WorkspaceRepo,
@ -78,6 +80,7 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
PageHistoryRepo, PageHistoryRepo,
CommentRepo, CommentRepo,
AttachmentRepo, AttachmentRepo,
UserTokenRepo,
], ],
}) })
export class DatabaseModule implements OnModuleDestroy, OnApplicationBootstrap { 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,6 +40,21 @@ export class AttachmentRepo {
.executeTakeFirst(); .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( async updateAttachment(
updatableAttachment: UpdatableAttachment, updatableAttachment: UpdatableAttachment,
attachmentId: string, attachmentId: string,
@ -52,7 +67,7 @@ export class AttachmentRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
async deleteAttachment(attachmentId: string): Promise<void> { async deleteAttachmentById(attachmentId: string): Promise<void> {
await this.db await this.db
.deleteFrom('attachments') .deleteFrom('attachments')
.where('id', '=', attachmentId) .where('id', '=', attachmentId)

View File

@ -64,7 +64,7 @@ export class SpaceMemberRepo {
} else if (opts.groupId) { } else if (opts.groupId) {
query = query.where('groupId', '=', opts.groupId); query = query.where('groupId', '=', opts.groupId);
} else { } else {
throw new BadRequestException('Please provider a userId or groupId'); throw new BadRequestException('Please provide a userId or groupId');
} }
return query.executeTakeFirst(); 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(), name: insertableUser.name || insertableUser.email.toLowerCase(),
email: insertableUser.email.toLowerCase(), email: insertableUser.email.toLowerCase(),
password: await hashPassword(insertableUser.password), password: await hashPassword(insertableUser.password),
locale: 'en', locale: 'en-US',
role: insertableUser?.role, role: insertableUser?.role,
lastLoginAt: new Date(), 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"; import type { ColumnType } from "kysely";
export type Generated<T> = T extends ColumnType<infer S, infer I, infer U> 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 JsonArray = JsonValue[];
export type JsonObject = { export type JsonObject = {
[K in string]?: JsonValue; [x: string]: JsonValue | undefined;
}; };
export type JsonPrimitive = boolean | number | string | null; export type JsonPrimitive = boolean | number | string | null;
@ -157,6 +162,17 @@ export interface Users {
workspaceId: string | null; 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 { export interface WorkspaceInvitations {
createdAt: Generated<Timestamp>; createdAt: Generated<Timestamp>;
email: string | null; email: string | null;
@ -195,6 +211,7 @@ export interface DB {
spaceMembers: SpaceMembers; spaceMembers: SpaceMembers;
spaces: Spaces; spaces: Spaces;
users: Users; users: Users;
userTokens: UserTokens;
workspaceInvitations: WorkspaceInvitations; workspaceInvitations: WorkspaceInvitations;
workspaces: Workspaces; workspaces: Workspaces;
} }

View File

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

@ -64,7 +64,7 @@ export class EnvironmentService {
} }
getAwsS3ForcePathStyle(): boolean { getAwsS3ForcePathStyle(): boolean {
return this.configService.get<boolean>('AWS_S3_FORCE_PATH_STYLE') return this.configService.get<boolean>('AWS_S3_FORCE_PATH_STYLE');
} }
getAwsS3Url(): string { getAwsS3Url(): string {
@ -98,6 +98,13 @@ export class EnvironmentService {
return secure === 'true'; return secure === 'true';
} }
getSmtpIgnoreTLS(): boolean {
const ignoretls = this.configService
.get<string>('SMTP_IGNORETLS', 'false')
.toLowerCase();
return ignoretls === 'true';
}
getSmtpUsername(): string { getSmtpUsername(): string {
return this.configService.get<string>('SMTP_USERNAME'); return this.configService.get<string>('SMTP_USERNAME');
} }
@ -110,6 +117,37 @@ export class EnvironmentService {
return this.configService.get<string>('POSTMARK_TOKEN'); return this.configService.get<string>('POSTMARK_TOKEN');
} }
getLdapBaseDn(): string {
return this.configService.get<string>('LDAP_BASEDN')
}
getLdapDomainSuffix(): string {
return this.configService.get<string>('LDAP_DOMAINSUFFIX');
}
getLdapUsername(): string {
return this.configService.get<string>('LDAP_USERNAME')
}
getLdapPassword(): string {
return this.configService.get<string>('LDAP_PASSWORD')
}
getLdapNameAttribute(): string {
return this.configService.get<string>('LDAP_NAMEATTRIBUTE')
}
getLdapMailAttribute(): string {
return this.configService.get<string>('LDAP_MAILATTRIBUTE')
}
usingNtlmAuth(): boolean {
const ntlmAuth = this.configService
.get<string>('VITE_NTLM_AUTH', 'false')
.toLowerCase();
return ntlmAuth === 'true';
}
isCloud(): boolean { isCloud(): boolean {
const cloudConfig = this.configService const cloudConfig = this.configService
.get<string>('CLOUD', 'false') .get<string>('CLOUD', 'false')

View File

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

View File

@ -22,6 +22,7 @@ export function turndown(html: string): string {
listParagraph, listParagraph,
mathInline, mathInline,
mathBlock, mathBlock,
iframeEmbed,
]); ]);
return turndownService.turndown(html).replaceAll('<br>', ' '); return turndownService.turndown(html).replaceAll('<br>', ' ');
} }
@ -120,3 +121,15 @@ function mathBlock(turndownService: TurndownService) {
}, },
}); });
} }
function iframeEmbed(turndownService: TurndownService) {
turndownService.addRule('iframeEmbed', {
filter: function (node: HTMLInputElement) {
return node.nodeName === 'IFRAME';
},
replacement: function (content: any, node: HTMLInputElement) {
const src = node.getAttribute('src');
return '[' + src + '](' + src + ')';
},
});
}

View File

@ -22,6 +22,7 @@ export class RedisHealthIndicator extends HealthIndicator {
}); });
await redis.ping(); await redis.ping();
redis.disconnect();
return this.getStatus(key, true); return this.getStatus(key, true);
} catch (e) { } catch (e) {
this.logger.error(e); this.logger.error(e);

View File

@ -4,6 +4,6 @@ import { ImportController } from './import.controller';
@Module({ @Module({
providers: [ImportService], providers: [ImportService],
controllers: [ImportController] controllers: [ImportController],
}) })
export class ImportModule {} export class ImportModule {}

View File

@ -32,8 +32,10 @@ export class ImportService {
): Promise<void> { ): Promise<void> {
const file = await filePromise; const file = await filePromise;
const fileBuffer = await file.toBuffer(); const fileBuffer = await file.toBuffer();
const fileName = sanitize(file.filename).slice(0, 255).split('.')[0];
const fileExtension = path.extname(file.filename).toLowerCase(); const fileExtension = path.extname(file.filename).toLowerCase();
const fileName = sanitize(
path.basename(file.filename, fileExtension).slice(0, 255),
);
const fileContent = fileBuffer.toString(); const fileContent = fileBuffer.toString();
let prosemirrorState = null; let prosemirrorState = null;

View File

@ -0,0 +1,41 @@
import { Token, marked } from 'marked';
interface CalloutToken {
type: 'callout';
calloutType: string;
text: string;
raw: string;
}
export const calloutExtension = {
name: 'callout',
level: 'block',
start(src: string) {
return src.match(/:::/)?.index ?? -1;
},
tokenizer(src: string): CalloutToken | undefined {
const rule = /^:::([a-zA-Z0-9]+)\s+([\s\S]+?):::/;
const match = rule.exec(src);
const validCalloutTypes = ['info', 'success', 'warning', 'danger'];
if (match) {
let type = match[1];
if (!validCalloutTypes.includes(type)) {
type = 'info';
}
return {
type: 'callout',
calloutType: type,
raw: match[0],
text: match[2].trim(),
};
}
},
renderer(token: Token) {
const calloutToken = token as CalloutToken;
const body = marked.parse(calloutToken.text);
return `<div data-type="callout" data-callout-type="${calloutToken.calloutType}">${body}</div>`;
},
};

View File

@ -1,4 +1,5 @@
import { marked } from 'marked'; import { marked } from 'marked';
import { calloutExtension } from './callout.marked';
marked.use({ marked.use({
renderer: { renderer: {
@ -25,6 +26,8 @@ marked.use({
}, },
}); });
marked.use({ extensions: [calloutExtension] });
export async function markdownToHtml(markdownInput: string): Promise<string> { export async function markdownToHtml(markdownInput: string): Promise<string> {
const YAML_FONT_MATTER_REGEX = /^\s*---[\s\S]*?---\s*/; const YAML_FONT_MATTER_REGEX = /^\s*---[\s\S]*?---\s*/;

View File

@ -27,11 +27,14 @@ export const mailDriverConfigProvider = {
switch (driver) { switch (driver) {
case MailOption.SMTP: case MailOption.SMTP:
let auth = undefined; let auth = undefined;
if (environmentService.getSmtpUsername() && environmentService.getSmtpPassword()) { if (
environmentService.getSmtpUsername() &&
environmentService.getSmtpPassword()
) {
auth = { auth = {
user: environmentService.getSmtpUsername(), user: environmentService.getSmtpUsername(),
pass: environmentService.getSmtpPassword(), pass: environmentService.getSmtpPassword(),
}; };
} }
return { return {
driver, driver,
@ -41,6 +44,7 @@ export const mailDriverConfigProvider = {
connectionTimeout: 30 * 1000, // 30 seconds connectionTimeout: 30 * 1000, // 30 seconds
auth, auth,
secure: environmentService.getSmtpSecure(), secure: environmentService.getSmtpSecure(),
ignoreTLS: environmentService.getSmtpIgnoreTLS()
} as SMTPTransport.Options, } as SMTPTransport.Options,
}; };

View File

@ -0,0 +1,86 @@
import {
Controller,
Get,
Req,
Res,
HttpException,
HttpStatus,
Post,
} from '@nestjs/common';
import { FastifyRequest, FastifyReply } from 'fastify';
import {
NTLMNegotiationMessage,
NTLMChallengeMessage,
NTLMAuthenticateMessage,
MessageType,
} from 'ntlm-server';
import { EnvironmentService } from '../environment/environment.service';
import { NTLMService } from './ntlm.service';
@Controller()
export class NTLMController {
constructor(
private readonly ntlmService: NTLMService,
private readonly environmentService: EnvironmentService,
) {}
@Post('auth/ntlm')
async ntlmAuth(@Req() req, @Res() res: FastifyReply) {
const authHeader = req.headers['authorization'];
if (!authHeader) {
// Step 1: Challenge the client for NTLM authentication
return res.status(401).header('WWW-Authenticate', 'NTLM').send();
}
if (authHeader.startsWith('NTLM ')) {
// Step 2: Handle NTLM negotiation message
const clientNegotiation = new NTLMNegotiationMessage(authHeader);
if (clientNegotiation.messageType === MessageType.NEGOTIATE) {
// Step 3: Send NTLM challenge message
const serverChallenge = new NTLMChallengeMessage(clientNegotiation);
const base64Challenge = serverChallenge.toBuffer().toString('base64');
return res
.status(401)
.header('WWW-Authenticate', `NTLM ${base64Challenge}`)
.send();
} else if (clientNegotiation.messageType === MessageType.AUTHENTICATE) {
// Step 4: Handle NTLM Authenticate message
const clientAuthentication = new NTLMAuthenticateMessage(authHeader);
// Here you'd perform LDAP or Active Directory authentication
const client = this.ntlmService.createClient(
clientAuthentication.domainName,
);
// Asynchronous bind to AD
await this.ntlmService.bindAsync(client);
const results = await this.ntlmService.searchAsync(client, {
scope: 'sub',
filter: `(userPrincipalName=${clientAuthentication.userName}@${clientAuthentication.domainName}*)`,
});
if (results.length == 1) {
const ntlmSignInResult = await this.ntlmService.login(
results.at(0)[this.environmentService.getLdapNameAttribute()],
results.at(0)[this.environmentService.getLdapMailAttribute()],
req.raw.workspaceId,
);
return res.status(200).send(ntlmSignInResult);
} else return res.status(403).send();
} else {
console.warn('Invalid NTLM Message received.');
return res.status(400).send('Invalid NTLM Message');
}
}
res.status(400).send('Bad NTLM request');
}
}

View File

@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { NTLMController } from './ntlm.controller';
import { NTLMService } from './ntlm.service';
import { TokenModule } from 'src/core/auth/token.module';
import { WorkspaceModule } from 'src/core/workspace/workspace.module';
import { AuthModule } from 'src/core/auth/auth.module';
@Module({
imports: [TokenModule, WorkspaceModule, AuthModule],
controllers: [NTLMController],
providers: [NTLMService],
})
export class NTLMModule {}

View File

@ -0,0 +1,110 @@
import { Inject, Injectable, UnauthorizedException } from '@nestjs/common';
import { EnvironmentService } from '../environment/environment.service';
import * as ldap from 'ldapjs';
import { UserRepo } from '@docmost/db/repos/user/user.repo';
import { TokensDto } from 'src/core/auth/dto/tokens.dto';
import { TokenService } from 'src/core/auth/services/token.service';
import { AuthService } from 'src/core/auth/services/auth.service';
@Injectable()
export class NTLMService {
constructor(
private readonly environmentService: EnvironmentService,
private authService: AuthService,
private tokenService: TokenService,
private userRepo: UserRepo,
) {}
createClient = (domain: string) =>
ldap.createClient({
url: 'ldap://' + domain + this.environmentService.getLdapDomainSuffix(),
});
// Promisified version of ldap.Client.bind
bindAsync = (client: ldap.Client): Promise<void> => {
return new Promise((resolve, reject) => {
client.bind(
this.environmentService.getLdapUsername(),
this.environmentService.getLdapPassword(),
(err) => {
if (err) {
reject(err);
} else {
resolve();
}
},
);
});
};
// Promisified version of client.search
searchAsync = (
client: ldap.Client,
options: ldap.SearchOptions,
): Promise<any[]> => {
const baseDN: string = this.environmentService.getLdapBaseDn();
return new Promise((resolve, reject) => {
const entries: any[] = [];
client.search(baseDN, options, (err, res) => {
if (err) {
reject(err);
}
res.on('searchEntry', (entry) => {
const attributes = Object.fromEntries(
entry.attributes.map(({ type, values }) => [
type,
values.length > 1 ? values : values[0],
]),
);
entries.push(attributes);
});
res.on('end', () => {
resolve(entries);
});
res.on('error', (error) => {
reject(error);
});
});
});
};
async login(name: string, email: string, workspaceId: string) {
const user = await this.userRepo.findByEmail(email, workspaceId, false);
if (!user) {
const tokensR = await this.authService.register(
{
name,
email,
password: this.generateRandomPassword(12),
},
workspaceId,
);
return tokensR;
}
user.lastLoginAt = new Date();
await this.userRepo.updateLastLogin(user.id, workspaceId);
const tokens: TokensDto = await this.tokenService.generateTokens(user);
return { tokens };
}
generateRandomPassword(length: number): string {
const characters =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()_+[]{}|;:,.<>?';
let password = '';
for (let i = 0; i < length; i++) {
const randomIndex = Math.floor(Math.random() * characters.length);
password += characters[randomIndex];
}
return password;
}
}

View File

@ -1,7 +1,10 @@
export enum QueueName { export enum QueueName {
EMAIL_QUEUE = '{email-queue}', EMAIL_QUEUE = '{email-queue}',
ATTACHEMENT_QUEUE = '{attachment-queue}',
} }
export enum QueueJob { export enum QueueJob {
SEND_EMAIL = 'send-email', SEND_EMAIL = 'send-email',
DELETE_SPACE_ATTACHMENTS = 'delete-space-attachments',
DELETE_PAGE_ATTACHMENTS = 'delete-page-attachments',
} }

View File

@ -31,6 +31,9 @@ import { QueueName } from './constants';
BullModule.registerQueue({ BullModule.registerQueue({
name: QueueName.EMAIL_QUEUE, name: QueueName.EMAIL_QUEUE,
}), }),
BullModule.registerQueue({
name: QueueName.ATTACHEMENT_QUEUE,
}),
], ],
exports: [BullModule], exports: [BullModule],
}) })

View File

@ -0,0 +1,28 @@
import { Button, Link, Section, Text } from '@react-email/components';
import * as React from 'react';
import { button, content, paragraph } from '../css/styles';
import { MailBody } from '../partials/partials';
interface Props {
username: string;
resetLink: string;
}
export const ForgotPasswordEmail = ({ username, resetLink }: Props) => {
return (
<MailBody>
<Section style={content}>
<Text style={paragraph}>Hi {username},</Text>
<Text style={paragraph}>
We received a request from you to reset your password.
</Text>
<Link href={resetLink}> Click here to set a new password</Link>
<Text style={paragraph}>
If you did not request a password reset, please ignore this email.
</Text>
</Section>
</MailBody>
);
};
export default ForgotPasswordEmail;

View File

@ -1,6 +1,6 @@
export const formatDate = (date: Date) => { export const formatDate = (date: Date) => {
new Intl.DateTimeFormat("en", { new Intl.DateTimeFormat('en', {
dateStyle: "medium", dateStyle: 'medium',
timeStyle: "medium", timeStyle: 'medium',
}).format(date); }).format(date);
}; };

View File

@ -6,10 +6,11 @@ export interface AttachmentOptions {
HTMLAttributes: Record<string, any>; HTMLAttributes: Record<string, any>;
view: any; view: any;
} }
export interface AttachmentAttributes { export interface AttachmentAttributes {
url?: string; url?: string;
name?: string; name?: string;
mime?: string; // mime type e.g. application/zip mime?: string; // e.g. application/zip
size?: number; size?: number;
attachmentId?: string; attachmentId?: string;
} }
@ -93,6 +94,15 @@ export const Attachment = Node.create<AttachmentOptions>({
this.options.HTMLAttributes, this.options.HTMLAttributes,
HTMLAttributes, HTMLAttributes,
), ),
[
"a",
{
href: HTMLAttributes["data-attachment-url"],
class: "attachment",
target: "blank",
},
`${HTMLAttributes["data-attachment-name"]}`,
],
]; ];
}, },

475
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff