2 Commits

Author SHA1 Message Date
Ryan Palmer
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
Ryan Palmer
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
85 changed files with 946 additions and 1622 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,7 +44,6 @@ 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=

View File

@@ -1,47 +0,0 @@
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

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

View File

@@ -1,7 +1,7 @@
{ {
"name": "client", "name": "client",
"private": true, "private": true,
"version": "0.3.1", "version": "0.3.0",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "tsc && vite build", "build": "tsc && vite build",
@@ -41,6 +41,7 @@
"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,8 +24,6 @@ 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);
@@ -65,8 +63,6 @@ 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,15 +5,14 @@ import {
Badge, Badge,
Table, Table,
ScrollArea, ScrollArea,
ActionIcon, } from "@mantine/core";
} from '@mantine/core'; import { Link } from "react-router-dom";
import { Link } from 'react-router-dom'; import PageListSkeleton from "@/components/ui/page-list-skeleton.tsx";
import PageListSkeleton from '@/components/ui/page-list-skeleton.tsx'; import { buildPageUrl } from "@/features/page/page.utils.ts";
import { buildPageUrl } from '@/features/page/page.utils.ts'; import { formattedDate } from "@/lib/time.ts";
import { formattedDate } from '@/lib/time.ts'; import { useRecentChangesQuery } from "@/features/page/queries/page-query.ts";
import { useRecentChangesQuery } from '@/features/page/queries/page-query.ts'; import { IconFileDescription } from "@tabler/icons-react";
import { IconFileDescription } from '@tabler/icons-react'; import { getSpaceUrl } from "@/lib/config.ts";
import { getSpaceUrl } from '@/lib/config.ts';
interface Props { interface Props {
spaceId?: string; spaceId?: string;
@@ -41,14 +40,10 @@ 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 || ( {page.icon || <IconFileDescription size={18} />}
<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>
@@ -60,7 +55,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,25 +1,19 @@
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}>
<Helmet> <Title className={classes.title}>404 Page Not Found</Title>
<title>404 page not found - Docmost</title> <Text c="dimmed" size="lg" ta="center" className={classes.description}>
</Helmet> Sorry, we can't find the page you are looking for.
<Container className={classes.root}> </Text>
<Title className={classes.title}>404 Page Not Found</Title> <Group justify="center">
<Text c="dimmed" size="lg" ta="center" className={classes.description}> <Button component={Link} to={"/home"} variant="subtle" size="md">
Sorry, we can't find the page you are looking for. Take me back to homepage
</Text> </Button>
<Group justify="center"> </Group>
<Button component={Link} to={"/home"} variant="subtle" size="md"> </Container>
Take me back to homepage
</Button>
</Group>
</Container>
</>
); );
} }

View File

@@ -1,70 +0,0 @@
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,3 +1,4 @@
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";
@@ -9,13 +10,9 @@ 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
@@ -65,20 +62,10 @@ 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

@@ -1,67 +0,0 @@
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,22 +1,10 @@
import { useState } from "react"; import { useState } from "react";
import { import { login, ntlmLogin, setupWorkspace } from "@/features/auth/services/auth-service";
forgotPassword,
login,
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 { import { ILogin, ISetupWorkspace } from "@/features/auth/types/auth.types";
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";
@@ -50,6 +38,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);
@@ -88,28 +95,6 @@ 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;
@@ -139,50 +124,12 @@ 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

@@ -1,14 +0,0 @@
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,20 +1,24 @@
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);
@@ -22,30 +26,15 @@ 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,17 +29,3 @@ 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().sort()} data={extension.options.lowlight.listLanguages()}
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: auto; overflow-x: scroll;
.textInput { .textInput {
width: 400px; width: 400px;

View File

@@ -16,8 +16,7 @@ import {
IconPhoto, IconPhoto,
IconTable, IconTable,
IconTypography, IconTypography,
IconMenu4, IconMenu4
IconCalendar,
} from "@tabler/icons-react"; } from "@tabler/icons-react";
import { import {
CommandProps, CommandProps,
@@ -331,26 +330,6 @@ 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

@@ -54,26 +54,9 @@ 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";
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,19 +79,17 @@ 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 = groups.items?.filter( groups.items?.filter((group: IGroup) => group.id !== variables);
(group: IGroup) => group.id !== variables queryClient.setQueryData(["groups"], groups);
);
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" });
}, },
}); });
} }
@@ -102,15 +100,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",
}); });
}, },
}); });
@@ -129,14 +127,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, decodeURIComponent(fileName)); saveAs(req.data, fileName);
} }
export async function importPage(file: File, spaceId: string) { export async function importPage(file: File, spaceId: string) {
@@ -81,18 +81,15 @@ export async function importPage(file: File, spaceId: string) {
return req.data; return req.data;
} }
export async function uploadFile( export async function uploadFile(file: File, pageId: string, attachmentId?: string): Promise<IAttachment> {
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

@@ -1,86 +0,0 @@
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,14 +8,6 @@ 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>;
@@ -31,14 +23,12 @@ 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,
@@ -50,10 +40,6 @@ 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();
}; };
@@ -76,8 +62,8 @@ export function EditSpaceForm({ space, readOnly }: EditSpaceFormProps) {
id="slug" id="slug"
label="Slug" label="Slug"
variant="filled" variant="filled"
readOnly={readOnly} readOnly
{...form.getInputProps("slug")} value={space.slug}
/> />
<Textarea <Textarea

View File

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

View File

@@ -0,0 +1,19 @@
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

@@ -1,70 +0,0 @@
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 { getSpaceUrl } from '@/lib/config.ts'; import { SpaceName } from "@/features/space/components/sidebar/space-name.tsx";
import SpaceTree from '@/features/page/tree/components/space-tree.tsx'; import { getSpaceUrl } from "@/lib/config.ts";
import { useSpaceAbility } from '@/features/space/permissions/use-space-ability.ts'; import SpaceTree from "@/features/page/tree/components/space-tree.tsx";
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,12 +61,11 @@ export function SpaceSidebar() {
<div <div
className={classes.section} className={classes.section}
style={{ style={{
border: 'none', border: "none",
marginTop: 2, marginBottom: "0",
marginBottom: 3,
}} }}
> >
<SwitchSpace spaceName={space?.name} spaceSlug={space?.slug} /> <SpaceName spaceName={space?.name} />
</div> </div>
<div className={classes.section}> <div className={classes.section}>
@@ -78,7 +77,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}>
@@ -115,7 +114,7 @@ export function SpaceSidebar() {
{spaceAbility.can( {spaceAbility.can(
SpaceCaslAction.Manage, SpaceCaslAction.Manage,
SpaceCaslSubject.Page SpaceCaslSubject.Page,
) && ( ) && (
<UnstyledButton <UnstyledButton
className={classes.menu} className={classes.menu}
@@ -142,7 +141,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} />
@@ -166,7 +165,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

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

View File

@@ -1,62 +0,0 @@
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,8 +1,7 @@
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 { Divider, Group, Text } from '@mantine/core'; import { Text } from "@mantine/core";
import DeleteSpaceModal from './delete-space-modal';
interface SpaceDetailsProps { interface SpaceDetailsProps {
spaceId: string; spaceId: string;
@@ -19,23 +18,6 @@ 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,
deleteSpace, } from "@/features/space/services/space-service.ts";
} from '@/features/space/services/space-service.ts'; import { notifications } from "@mantine/notifications";
import { notifications } from '@mantine/notifications'; import { IPagination } from "@/lib/types.ts";
import { IPagination, QueryParams } from '@/lib/types.ts';
export function useGetSpacesQuery( export function useGetSpacesQuery(): UseQueryResult<
params?: QueryParams IPagination<ISpace>,
): UseQueryResult<IPagination<ISpace>, Error> { Error
> {
return useQuery({ return useQuery({
queryKey: ['spaces', params], queryKey: ["spaces"],
queryFn: () => getSpaces(params), queryFn: () => getSpaces(),
}); });
} }
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,64 +78,34 @@ 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,
}); });
@@ -147,14 +117,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" });
}, },
}); });
} }
@@ -165,14 +135,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" });
}, },
}); });
} }
@@ -183,15 +153,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,56 +1,52 @@
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, QueryParams } from "@/lib/types.ts"; import { IPagination } 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(params?: QueryParams): Promise<IPagination<ISpace>> { export async function getSpaces(): Promise<IPagination<ISpace>> {
const req = await api.post("/spaces", params); const req = await api.post("/spaces");
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,8 +4,6 @@ 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

@@ -7,27 +7,23 @@ declare global {
export function getAppUrl(): string { export function getAppUrl(): string {
//let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL; //let appUrl = window.CONFIG?.APP_URL || process.env.APP_URL;
// if (import.meta.env.DEV) { // if (import.meta.env.DEV) {
// return appUrl || "http://localhost:3000"; // return appUrl || "http://localhost:3000";
//} //}
return `${window.location.protocol}//${window.location.host}`; return `${window.location.protocol}//${window.location.host}`;
} }
export function getBackendUrl(): string { export function getBackendUrl(): string {
return getAppUrl() + '/api'; return getAppUrl() + "/api";
} }
export function getCollaborationUrl(): string { export function getCollaborationUrl(): string {
const COLLAB_PATH = '/collab'; const COLLAB_PATH = "/collab";
const url = process.env.APP_URL || getAppUrl();
let url = getAppUrl(); const wsProtocol = url.startsWith("https") ? "wss" : "ws";
if (import.meta.env.DEV) { return `${wsProtocol}://${url.split("://")[1]}${COLLAB_PATH}`;
url = process.env.APP_URL;
}
const wsProtocol = url.startsWith('https') ? 'wss' : 'ws';
return `${wsProtocol}://${url.split('://')[1]}${COLLAB_PATH}`;
} }
export function getAvatarUrl(avatarUrl: string) { export function getAvatarUrl(avatarUrl: string) {
@@ -35,17 +31,17 @@ export function getAvatarUrl(avatarUrl: string) {
return null; return null;
} }
if (avatarUrl?.startsWith('http')) { if (avatarUrl?.startsWith("http")) {
return avatarUrl; return avatarUrl;
} }
return getBackendUrl() + '/attachments/img/avatar/' + avatarUrl; return getBackendUrl() + "/attachments/img/avatar/" + avatarUrl;
} }
export function getSpaceUrl(spaceSlug: string) { export function getSpaceUrl(spaceSlug: string) {
return '/s/' + spaceSlug; return "/s/" + spaceSlug;
} }
export function getFileUrl(src: string) { export function getFileUrl(src: string) {
return src?.startsWith('/files/') ? getBackendUrl() + src : src; return src?.startsWith("/files/") ? getBackendUrl() + src : src;
} }

View File

@@ -1,13 +0,0 @@
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 - Docmost</title> <title>Invitation signup</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 - Docmost</title> <title>Login</title>
</Helmet> </Helmet>
<LoginForm /> {!ntlmAuth && <LoginForm />}
</> </>
); );
} }

View File

@@ -1,53 +0,0 @@
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 - Docmost</title> <title>Setup workspace</title>
</Helmet> </Helmet>
<SetupWorkspaceForm /> <SetupWorkspaceForm />
</> </>

View File

@@ -1,34 +1,20 @@
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

@@ -1,6 +1,6 @@
{ {
"name": "server", "name": "server",
"version": "0.3.1", "version": "0.3.0",
"description": "", "description": "",
"author": "", "author": "",
"private": true, "private": true,
@@ -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

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

View File

@@ -1,9 +1,7 @@
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 } from 'happy-dom'; import { Window, DOMParser as HappyDomParser } 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,
@@ -12,10 +10,8 @@ export function generateJSON(
const schema = getSchema(extensions); const schema = getSchema(extensions);
const window = new Window(); const window = new Window();
const document = window.document; const dom = new HappyDomParser().parseFromString(html, 'text/html').body;
document.body.innerHTML = html;
return DOMParser.fromSchema(schema) // @ts-ignore
.parse(document as never, options) return DOMParser.fromSchema(schema).parse(dom, options).toJSON();
.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="${encodeURIComponent(attachment.fileName)}"`, `attachment; filename="${attachment.fileName}"`,
); );
} }

View File

@@ -4,11 +4,10 @@ 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, AttachmentProcessor], providers: [AttachmentService],
}) })
export class AttachmentModule {} export class AttachmentModule {}

View File

@@ -1,47 +0,0 @@
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,37 +256,4 @@ 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

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

View File

@@ -10,6 +10,7 @@ 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';
@@ -18,9 +19,6 @@ 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 {
@@ -63,31 +61,4 @@ 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

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

View File

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

View File

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

View File

@@ -11,25 +11,10 @@ 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 { import { comparePasswordHash, hashPassword } from '../../../common/helpers';
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 {
@@ -37,10 +22,7 @@ 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) {
@@ -118,108 +100,4 @@ 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 = this.environmentService.getJwtTokenExpiresIn(); const expiresIn = '30d'; // todo: fix
return this.jwtService.sign(payload, { expiresIn }); return this.jwtService.sign(payload, { expiresIn });
} }

View File

@@ -14,9 +14,6 @@ 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 {
@@ -24,7 +21,6 @@ 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(
@@ -92,24 +88,10 @@ 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,
@@ -138,14 +120,4 @@ 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')
createSpace( createGroup(
@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 updateSpace( async updateGroup(
@Body() updateSpaceDto: UpdateSpaceDto, @Body() updateSpaceDto: UpdateSpaceDto,
@AuthUser() user: User, @AuthUser() user: User,
@AuthWorkspace() workspace: Workspace, @AuthWorkspace() workspace: Workspace,
@@ -126,23 +126,6 @@ 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: invitedByUser.email, to: invitation.email,
subject: `${newUser.name} has accepted your Docmost invite`, subject: `${newUser.name} has accepted your Docmost invite`,
template: emailTemplate, template: emailTemplate,
}); });

View File

@@ -22,7 +22,6 @@ 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));
@@ -67,7 +66,6 @@ types.setTypeParser(types.builtins.INT8, (val) => Number(val));
PageHistoryRepo, PageHistoryRepo,
CommentRepo, CommentRepo,
AttachmentRepo, AttachmentRepo,
UserTokenRepo,
], ],
exports: [ exports: [
WorkspaceRepo, WorkspaceRepo,
@@ -80,7 +78,6 @@ 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

@@ -1,27 +0,0 @@
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,21 +40,6 @@ 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,
@@ -67,7 +52,7 @@ export class AttachmentRepo {
.executeTakeFirst(); .executeTakeFirst();
} }
async deleteAttachmentById(attachmentId: string): Promise<void> { async deleteAttachment(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 provide a userId or groupId'); throw new BadRequestException('Please provider a userId or groupId');
} }
return query.executeTakeFirst(); return query.executeTakeFirst();
} }

View File

@@ -1,102 +0,0 @@
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-US', locale: 'en',
role: insertableUser?.role, role: insertableUser?.role,
lastLoginAt: new Date(), lastLoginAt: new Date(),
}; };

View File

@@ -1,8 +1,3 @@
/**
* 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>
@@ -16,7 +11,7 @@ export type Json = JsonValue;
export type JsonArray = JsonValue[]; export type JsonArray = JsonValue[];
export type JsonObject = { export type JsonObject = {
[x: string]: JsonValue | undefined; [K in string]?: JsonValue;
}; };
export type JsonPrimitive = boolean | number | string | null; export type JsonPrimitive = boolean | number | string | null;
@@ -162,17 +157,6 @@ 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;
@@ -211,7 +195,6 @@ 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,7 +11,6 @@ import {
GroupUsers, GroupUsers,
SpaceMembers, SpaceMembers,
WorkspaceInvitations, WorkspaceInvitations,
UserTokens,
} from './db'; } from './db';
// Workspace // Workspace
@@ -72,8 +71,3 @@ 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,13 +98,6 @@ 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');
} }
@@ -117,6 +110,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,8 +61,7 @@ export class ImportController {
res.headers({ res.headers({
'Content-Type': getMimeType(fileExt), 'Content-Type': getMimeType(fileExt),
'Content-Disposition': 'Content-Disposition': 'attachment; filename="' + fileName + '"',
'attachment; filename="' + encodeURIComponent(fileName) + '"',
}); });
res.send(rawContent); res.send(rawContent);

View File

@@ -22,7 +22,6 @@ 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>', ' ');
} }
@@ -121,15 +120,3 @@ 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,7 +22,6 @@ 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,10 +32,8 @@ 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

@@ -1,41 +0,0 @@
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,5 +1,4 @@
import { marked } from 'marked'; import { marked } from 'marked';
import { calloutExtension } from './callout.marked';
marked.use({ marked.use({
renderer: { renderer: {
@@ -26,8 +25,6 @@ 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,14 +27,11 @@ export const mailDriverConfigProvider = {
switch (driver) { switch (driver) {
case MailOption.SMTP: case MailOption.SMTP:
let auth = undefined; let auth = undefined;
if ( if (environmentService.getSmtpUsername() && environmentService.getSmtpPassword()) {
environmentService.getSmtpUsername() &&
environmentService.getSmtpPassword()
) {
auth = { auth = {
user: environmentService.getSmtpUsername(), user: environmentService.getSmtpUsername(),
pass: environmentService.getSmtpPassword(), pass: environmentService.getSmtpPassword(),
}; };
} }
return { return {
driver, driver,
@@ -44,7 +41,6 @@ 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,10 +1,7 @@
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,9 +31,6 @@ 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

@@ -1,28 +0,0 @@
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

@@ -1,7 +1,7 @@
{ {
"name": "docmost", "name": "docmost",
"homepage": "https://docmost.com", "homepage": "https://docmost.com",
"version": "0.3.1", "version": "0.3.0",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "nx run-many -t build", "build": "nx run-many -t build",

View File

@@ -6,11 +6,10 @@ 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; // e.g. application/zip mime?: string; // mime type e.g. application/zip
size?: number; size?: number;
attachmentId?: string; attachmentId?: string;
} }
@@ -94,15 +93,6 @@ 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