mirror of
https://github.com/docmost/docmost.git
synced 2026-05-12 01:41:12 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8768e0e6c9 | |||
| dc75fddd9c | |||
| a3559b7c33 | |||
| 803f1f0b81 |
@@ -442,6 +442,9 @@
|
||||
"Prevent members from sharing pages publicly.": "Prevent members from sharing pages publicly.",
|
||||
"Toggle public sharing": "Toggle public sharing",
|
||||
"Toggle space public sharing": "Toggle space public sharing",
|
||||
"Allow viewers to comment": "Allow viewers to comment",
|
||||
"Allow viewers to add comments on pages in this space.": "Allow viewers to add comments on pages in this space.",
|
||||
"Toggle viewer comments": "Toggle viewer comments",
|
||||
"Public sharing is disabled at the workspace level": "Public sharing is disabled at the workspace level",
|
||||
"Prevent pages in this space from being shared publicly.": "Prevent pages in this space from being shared publicly.",
|
||||
"Page permissions": "Page permissions",
|
||||
@@ -708,5 +711,20 @@
|
||||
"Resend verification email": "Resend verification email",
|
||||
"Verification email sent. Please check your inbox.": "Verification email sent. Please check your inbox.",
|
||||
"Failed to resend verification email. Please try again.": "Failed to resend verification email. Please try again.",
|
||||
"We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces."
|
||||
"We've sent you an email with your associated workspaces.": "We've sent you an email with your associated workspaces.",
|
||||
"Load more": "Load more",
|
||||
"Log out of all devices": "Log out of all devices",
|
||||
"Log out of all sessions except this device": "Log out of all sessions except this device",
|
||||
"This Device": "This Device",
|
||||
"Unknown device": "Unknown device",
|
||||
"No active sessions": "No active sessions",
|
||||
"Session revoked": "Session revoked",
|
||||
"All other sessions revoked": "All other sessions revoked",
|
||||
"Last used": "Last used",
|
||||
"Created": "Created",
|
||||
"Rename": "Rename",
|
||||
"Publish": "Publish",
|
||||
"Security": "Security",
|
||||
"Enforce SSO": "Enforce SSO",
|
||||
"Once enforced, members will not be able to login with email and password.": "Once enforced, members will not be able to login with email and password."
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@ export const Feature = {
|
||||
AUDIT_LOGS: 'audit:logs',
|
||||
RETENTION: 'retention',
|
||||
SHARING_CONTROLS: 'sharing:controls',
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
} as const;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from "zod/v4";
|
||||
import React from "react";
|
||||
import { Button, Group, Modal, Textarea } from "@mantine/core";
|
||||
import React, { useRef } from "react";
|
||||
import { Button, Divider, Group, Modal, Stack, Textarea } from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -49,6 +49,7 @@ interface ActivateLicenseFormProps {
|
||||
export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const activateLicenseMutation = useActivateMutation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
@@ -63,29 +64,68 @@ export function ActivateLicenseForm({ onClose }: ActivateLicenseFormProps) {
|
||||
onClose?.();
|
||||
}
|
||||
|
||||
function handleFileUpload(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const content = (e.target?.result as string)?.trim();
|
||||
if (content) {
|
||||
form.setFieldValue("licenseKey", content);
|
||||
handleSubmit({ licenseKey: content });
|
||||
}
|
||||
};
|
||||
reader.readAsText(file);
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = "";
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={form.onSubmit(handleSubmit)}>
|
||||
<Textarea
|
||||
label={t("License key")}
|
||||
description="Enter a valid enterprise license key. Contact sales@docmost.com to purchase one."
|
||||
placeholder={t("e.g eyJhb.....")}
|
||||
variant="filled"
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={5}
|
||||
data-autofocus
|
||||
{...form.getInputProps("licenseKey")}
|
||||
<input
|
||||
type="file"
|
||||
accept=".txt"
|
||||
ref={fileInputRef}
|
||||
onChange={handleFileUpload}
|
||||
hidden
|
||||
/>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={activateLicenseMutation.isPending}
|
||||
loading={activateLicenseMutation.isPending}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
<Stack gap="xs">
|
||||
<Textarea
|
||||
label={t("License key")}
|
||||
placeholder={t("e.g eyJhb.....")}
|
||||
variant="filled"
|
||||
autosize
|
||||
minRows={3}
|
||||
maxRows={5}
|
||||
data-autofocus
|
||||
{...form.getInputProps("licenseKey")}
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={activateLicenseMutation.isPending}
|
||||
loading={activateLicenseMutation.isPending}
|
||||
>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
<Divider label={t("Or")} labelPosition="center" />
|
||||
|
||||
<Group justify="center">
|
||||
<Button
|
||||
variant="light"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
{t("Upload license file")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,7 +68,11 @@ export default function OssDetails() {
|
||||
</List>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
Contact <a href="mailto:sales@docmost.com?subject=Enterprise%20License%20Inquiry">sales@docmost.com </a> to purchase an enterprise license.
|
||||
Get an enterprise trial key at <a href="https://customers.docmost.com/" target="_blank" rel="noopener noreferrer">customers.docmost.com</a>.
|
||||
</Text>
|
||||
|
||||
<Text size="sm" c="dimmed">
|
||||
Visit <a href="https://docmost.com/pricing" target="_blank" rel="noopener noreferrer">docmost.com/pricing</a> to purchase an enterprise license.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { Group, Text, Switch, Tooltip } from "@mantine/core";
|
||||
import React, { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import { useUpdateSpaceMutation } from "@/features/space/queries/space-query.ts";
|
||||
import { useHasFeature } from "@/ee/hooks/use-feature.ts";
|
||||
import { Feature } from "@/ee/features.ts";
|
||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label.ts";
|
||||
|
||||
type SpaceViewerCommentsToggleProps = {
|
||||
space: ISpace;
|
||||
};
|
||||
|
||||
export default function SpaceViewerCommentsToggle({
|
||||
space,
|
||||
}: SpaceViewerCommentsToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasViewerComments = useHasFeature(Feature.VIEWER_COMMENTS);
|
||||
const upgradeLabel = useUpgradeLabel();
|
||||
const isDisabled = !hasViewerComments;
|
||||
const [checked, setChecked] = useState(
|
||||
space.settings?.comments?.allowViewerComments === true,
|
||||
);
|
||||
const updateSpaceMutation = useUpdateSpaceMutation();
|
||||
|
||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.currentTarget.checked;
|
||||
try {
|
||||
await updateSpaceMutation.mutateAsync({
|
||||
spaceId: space.id,
|
||||
allowViewerComments: value,
|
||||
});
|
||||
setChecked(value);
|
||||
} catch {
|
||||
// error handled by mutation
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Group justify="space-between" wrap="nowrap" gap="xl">
|
||||
<div>
|
||||
<Text size="md">{t("Allow viewers to comment")}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("Allow viewers to add comments on pages in this space.")}
|
||||
</Text>
|
||||
</div>
|
||||
<Tooltip
|
||||
label={upgradeLabel}
|
||||
disabled={!isDisabled}
|
||||
refProp="rootRef"
|
||||
>
|
||||
<Switch
|
||||
checked={checked}
|
||||
onChange={handleChange}
|
||||
disabled={isDisabled}
|
||||
aria-label={t("Toggle viewer comments")}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -3,3 +3,15 @@ import { atom } from 'jotai';
|
||||
export const showCommentPopupAtom = atom<boolean>(false);
|
||||
export const activeCommentIdAtom = atom<string>('');
|
||||
export const draftCommentIdAtom = atom<string>('');
|
||||
|
||||
// Read-only comment state
|
||||
export const showReadOnlyCommentPopupAtom = atom<boolean>(false);
|
||||
export type YjsSelection = {
|
||||
anchor: any;
|
||||
head: any;
|
||||
};
|
||||
export type ReadOnlyCommentData = {
|
||||
yjsSelection: YjsSelection;
|
||||
selectedText: string;
|
||||
};
|
||||
export const readOnlyCommentDataAtom = atom<ReadOnlyCommentData | null>(null);
|
||||
|
||||
@@ -6,6 +6,8 @@ import {
|
||||
activeCommentIdAtom,
|
||||
draftCommentIdAtom,
|
||||
showCommentPopupAtom,
|
||||
showReadOnlyCommentPopupAtom,
|
||||
readOnlyCommentDataAtom,
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import CommentEditor from "@/features/comment/components/comment-editor";
|
||||
import CommentActions from "@/features/comment/components/comment-actions";
|
||||
@@ -19,12 +21,15 @@ import { useTranslation } from "react-i18next";
|
||||
interface CommentDialogProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
pageId: string;
|
||||
readOnly?: boolean;
|
||||
}
|
||||
|
||||
function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
function CommentDialog({ editor, pageId, readOnly }: CommentDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [comment, setComment] = useState("");
|
||||
const [, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const [, setShowReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||
const [readOnlyCommentData, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [draftCommentId, setDraftCommentId] = useAtom(draftCommentIdAtom);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
@@ -34,11 +39,17 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
handleDialogClose();
|
||||
});
|
||||
const createCommentMutation = useCreateCommentMutation();
|
||||
const { isPending } = createCommentMutation;
|
||||
const isPending = createCommentMutation.isPending;
|
||||
|
||||
const handleDialogClose = () => {
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
if (readOnly) {
|
||||
setShowReadOnlyCommentPopup(false);
|
||||
// @ts-ignore
|
||||
setReadOnlyCommentData(null);
|
||||
} else {
|
||||
setShowCommentPopup(false);
|
||||
editor.chain().focus().unsetCommentDecoration().run();
|
||||
}
|
||||
};
|
||||
|
||||
const getSelectedText = () => {
|
||||
@@ -47,6 +58,11 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
};
|
||||
|
||||
const handleAddComment = async () => {
|
||||
if (readOnly) {
|
||||
await handleAddReadOnlyComment();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const selectedText = getSelectedText();
|
||||
const commentData = {
|
||||
@@ -65,7 +81,6 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
.run();
|
||||
setActiveCommentId(createdComment.id);
|
||||
|
||||
//unselect text to close bubble menu
|
||||
editor.commands.setTextSelection({ from: editor.view.state.selection.from, to: editor.view.state.selection.from });
|
||||
|
||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||
@@ -85,6 +100,33 @@ function CommentDialog({ editor, pageId }: CommentDialogProps) {
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddReadOnlyComment = async () => {
|
||||
if (!readOnlyCommentData) return;
|
||||
|
||||
try {
|
||||
const createdComment = await createCommentMutation.mutateAsync({
|
||||
pageId,
|
||||
content: JSON.stringify(comment),
|
||||
selection: readOnlyCommentData.selectedText,
|
||||
type: "inline",
|
||||
yjsSelection: readOnlyCommentData.yjsSelection,
|
||||
});
|
||||
|
||||
setActiveCommentId(createdComment.id);
|
||||
setAsideState({ tab: "comments", isAsideOpen: true });
|
||||
|
||||
setTimeout(() => {
|
||||
const selector = `div[data-comment-id="${createdComment.id}"]`;
|
||||
const commentElement = document.querySelector(selector);
|
||||
commentElement?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
}, 400);
|
||||
} finally {
|
||||
setShowReadOnlyCommentPopup(false);
|
||||
// @ts-ignore
|
||||
setReadOnlyCommentData(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCommentEditorChange = (newContent: any) => {
|
||||
setComment(newContent);
|
||||
};
|
||||
|
||||
@@ -44,7 +44,9 @@ function CommentListWithTabs() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const canComment = page?.permissions?.canEdit ?? false;
|
||||
const canComment =
|
||||
(page?.permissions?.canEdit ?? false) ||
|
||||
(space?.settings?.comments?.allowViewerComments === true);
|
||||
|
||||
// Separate active and resolved comments
|
||||
const { activeComments, resolvedComments } = useMemo(() => {
|
||||
@@ -153,7 +155,7 @@ function CommentListWithTabs() {
|
||||
)}
|
||||
</Paper>
|
||||
),
|
||||
[comments, handleAddReply, isLoading, space?.membership?.role],
|
||||
[comments, handleAddReply, isLoading, space?.membership?.role, canComment],
|
||||
);
|
||||
|
||||
if (isCommentsLoading) {
|
||||
|
||||
@@ -75,7 +75,7 @@ function CommentMenu({
|
||||
{isResolved ? t("Re-open comment") : t("Resolve comment")}
|
||||
</Menu.Item>
|
||||
) : (
|
||||
<Tooltip label={upgradeLabel} position="left">
|
||||
<Tooltip label={upgradeLabel} position="left" withPortal={false}>
|
||||
<Menu.Item disabled leftSection={<IconCircleCheck size={14} />}>
|
||||
{t("Resolve comment")}
|
||||
</Menu.Item>
|
||||
|
||||
@@ -17,6 +17,10 @@ export interface IComment {
|
||||
deletedAt?: Date;
|
||||
creator: IUser;
|
||||
resolvedBy?: IUser;
|
||||
yjsSelection?: {
|
||||
anchor: any;
|
||||
head: any;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ICommentData {
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { TextSelection } from "@tiptap/pm/state";
|
||||
import { FC, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { IconMessage } from "@tabler/icons-react";
|
||||
import classes from "./bubble-menu.module.css";
|
||||
import { ActionIcon, Tooltip } from "@mantine/core";
|
||||
import { useAtom } from "jotai";
|
||||
import {
|
||||
showReadOnlyCommentPopupAtom,
|
||||
readOnlyCommentDataAtom,
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getRelativeSelection, ySyncPluginKey } from "@tiptap/y-tiptap";
|
||||
|
||||
type ReadonlyBubbleMenuProps = {
|
||||
editor: Editor;
|
||||
};
|
||||
|
||||
export const ReadonlyBubbleMenu: FC<ReadonlyBubbleMenuProps> = ({ editor }) => {
|
||||
const { t } = useTranslation();
|
||||
const [showReadOnlyCommentPopup, setShowReadOnlyCommentPopup] = useAtom(
|
||||
showReadOnlyCommentPopupAtom,
|
||||
);
|
||||
const [, setReadOnlyCommentData] = useAtom(readOnlyCommentDataAtom);
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const [visible, setVisible] = useState(false);
|
||||
const [position, setPosition] = useState({ top: 0, left: 0 });
|
||||
const isInteractingRef = useRef(false);
|
||||
|
||||
const updateMenuPosition = useCallback(() => {
|
||||
if (isInteractingRef.current) return;
|
||||
|
||||
const pmSelection = editor.state.selection;
|
||||
if (!(pmSelection instanceof TextSelection) || pmSelection.empty) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const selection = window.getSelection();
|
||||
if (
|
||||
!selection ||
|
||||
selection.isCollapsed ||
|
||||
selection.rangeCount === 0 ||
|
||||
showReadOnlyCommentPopup
|
||||
) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const editorDom = editor.view.dom;
|
||||
if (
|
||||
!editorDom.contains(selection.anchorNode) ||
|
||||
!editorDom.contains(selection.focusNode)
|
||||
) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const range = selection.getRangeAt(0);
|
||||
const rect = range.getBoundingClientRect();
|
||||
|
||||
if (rect.width === 0) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const editorRect = editorDom
|
||||
.closest(".editor-container")
|
||||
?.getBoundingClientRect();
|
||||
if (!editorRect) {
|
||||
setVisible(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setPosition({
|
||||
top: rect.top - editorRect.top - 44,
|
||||
left: rect.left - editorRect.left + rect.width / 2,
|
||||
});
|
||||
setVisible(true);
|
||||
}, [editor, showReadOnlyCommentPopup]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleSelectionChange = () => {
|
||||
updateMenuPosition();
|
||||
};
|
||||
|
||||
document.addEventListener("selectionchange", handleSelectionChange);
|
||||
return () => {
|
||||
document.removeEventListener("selectionchange", handleSelectionChange);
|
||||
};
|
||||
}, [updateMenuPosition]);
|
||||
|
||||
useEffect(() => {
|
||||
if (showReadOnlyCommentPopup) {
|
||||
setVisible(false);
|
||||
}
|
||||
}, [showReadOnlyCommentPopup]);
|
||||
|
||||
const handleCommentClick = () => {
|
||||
if (!editor) return;
|
||||
|
||||
const view = editor.view;
|
||||
const ystate = ySyncPluginKey.getState(view.state);
|
||||
|
||||
if (ystate?.binding) {
|
||||
const selection = getRelativeSelection(ystate.binding, view.state);
|
||||
const { from, to } = editor.state.selection;
|
||||
const selectedText = editor.state.doc.textBetween(from, to);
|
||||
|
||||
// @ts-ignore
|
||||
setReadOnlyCommentData({
|
||||
yjsSelection: {
|
||||
anchor: selection.anchor,
|
||||
head: selection.head,
|
||||
},
|
||||
selectedText,
|
||||
});
|
||||
|
||||
setShowReadOnlyCommentPopup(true);
|
||||
setVisible(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!visible) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={menuRef}
|
||||
style={{
|
||||
position: "absolute",
|
||||
top: position.top,
|
||||
left: position.left,
|
||||
transform: "translateX(-50%)",
|
||||
zIndex: 199,
|
||||
}}
|
||||
>
|
||||
<div className={classes.bubbleMenu}>
|
||||
<Tooltip label={t("Comment")} withArrow withinPortal={false}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
radius="6px"
|
||||
aria-label={t("Comment")}
|
||||
style={{ border: "none" }}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
isInteractingRef.current = true;
|
||||
handleCommentClick();
|
||||
isInteractingRef.current = false;
|
||||
}}
|
||||
>
|
||||
<IconMessage size={16} stroke={2} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export interface FullEditorProps {
|
||||
content: string;
|
||||
spaceSlug: string;
|
||||
editable: boolean;
|
||||
canComment?: boolean;
|
||||
}
|
||||
|
||||
export function FullEditor({
|
||||
@@ -25,6 +26,7 @@ export function FullEditor({
|
||||
content,
|
||||
spaceSlug,
|
||||
editable,
|
||||
canComment,
|
||||
}: FullEditorProps) {
|
||||
const [user] = useAtom(userAtom);
|
||||
const fullPageWidth = user.settings?.preferences?.fullPageWidth;
|
||||
@@ -46,6 +48,7 @@ export function FullEditor({
|
||||
pageId={pageId}
|
||||
editable={editable}
|
||||
content={content}
|
||||
canComment={canComment}
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
|
||||
@@ -37,9 +37,11 @@ import { asideStateAtom } from "@/components/layouts/global/hooks/atoms/sidebar-
|
||||
import {
|
||||
activeCommentIdAtom,
|
||||
showCommentPopupAtom,
|
||||
showReadOnlyCommentPopupAtom,
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import CommentDialog from "@/features/comment/components/comment-dialog";
|
||||
import { EditorBubbleMenu } from "@/features/editor/components/bubble-menu/bubble-menu";
|
||||
import { ReadonlyBubbleMenu } from "@/features/editor/components/bubble-menu/readonly-bubble-menu";
|
||||
import TableCellMenu from "@/features/editor/components/table/table-cell-menu.tsx";
|
||||
import TableMenu from "@/features/editor/components/table/table-menu.tsx";
|
||||
import ImageMenu from "@/features/editor/components/image/image-menu.tsx";
|
||||
@@ -73,12 +75,14 @@ interface PageEditorProps {
|
||||
pageId: string;
|
||||
editable: boolean;
|
||||
content: any;
|
||||
canComment?: boolean;
|
||||
}
|
||||
|
||||
export default function PageEditor({
|
||||
pageId,
|
||||
editable,
|
||||
content,
|
||||
canComment,
|
||||
}: PageEditorProps) {
|
||||
const collaborationURL = useCollaborationUrl();
|
||||
const isComponentMounted = useRef(false);
|
||||
@@ -93,6 +97,7 @@ export default function PageEditor({
|
||||
const [, setAsideState] = useAtom(asideStateAtom);
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
@@ -421,7 +426,13 @@ export default function PageEditor({
|
||||
<ColumnsMenu editor={editor} />
|
||||
</div>
|
||||
)}
|
||||
{editor && !editorIsEditable && (editable || canComment) && providersRef.current && (
|
||||
<ReadonlyBubbleMenu editor={editor} />
|
||||
)}
|
||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||
{showReadOnlyCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
onClick={() => editor.commands.focus("end")}
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
} from "@mantine/core";
|
||||
import { IconDevices } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
useGetSessionsQuery,
|
||||
useRevokeSessionMutation,
|
||||
useRevokeAllSessionsMutation,
|
||||
} from "@/features/session/queries/session-query";
|
||||
import { formattedDate } from "@/lib/time";
|
||||
|
||||
const PAGE_SIZE = 5;
|
||||
|
||||
export default function SessionList() {
|
||||
const { t } = useTranslation();
|
||||
const { data: sessions, isLoading } = useGetSessionsQuery();
|
||||
const revokeSessionMutation = useRevokeSessionMutation();
|
||||
const revokeAllSessionsMutation = useRevokeAllSessionsMutation();
|
||||
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
|
||||
|
||||
const otherSessions = sessions?.filter((s) => !s?.isCurrentDevice) ?? [];
|
||||
const visibleSessions = sessions?.slice(0, visibleCount) ?? [];
|
||||
const hasMore = sessions && visibleCount < sessions.length;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Table verticalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Device Name")}</Table.Th>
|
||||
<Table.Th>{t("Last Active")}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Table.Tr key={i}>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<Skeleton height={18} width={18} radius="sm" />
|
||||
<Skeleton height={14} width={140} radius="xs" />
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={14} width={120} radius="xs" />
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Skeleton height={30} width={70} radius="sm" />
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{otherSessions.length > 0 && (
|
||||
<>
|
||||
<div>
|
||||
<Text fw={500}>{t("Log out of all devices")}</Text>
|
||||
<Group justify="space-between" align="center" mt={4}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
"Log out of all sessions except this device",
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="outline"
|
||||
color="red"
|
||||
size="xs"
|
||||
loading={revokeAllSessionsMutation.isPending}
|
||||
onClick={() => revokeAllSessionsMutation.mutate()}
|
||||
>
|
||||
{t("Log out of all devices")}
|
||||
</Button>
|
||||
</Group>
|
||||
</div>
|
||||
<Divider />
|
||||
</>
|
||||
)}
|
||||
|
||||
<Table verticalSpacing="md">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t("Device Name")}</Table.Th>
|
||||
<Table.Th>{t("Last Active")}</Table.Th>
|
||||
{otherSessions.length > 0 && <Table.Th />}
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{visibleSessions.map((session) => (
|
||||
<Table.Tr key={session.id}>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<IconDevices size={18} stroke={1.5} />
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{session.deviceName || t("Unknown device")}
|
||||
</Text>
|
||||
{session?.isCurrentDevice && (
|
||||
<Text size="xs" c="blue">
|
||||
{t("This Device")}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{session?.isCurrentDevice
|
||||
? t("Now")
|
||||
: formattedDate(new Date(session.lastActiveAt))}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
{otherSessions.length > 0 && (
|
||||
<Table.Td>
|
||||
{!session?.isCurrentDevice && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="xs"
|
||||
loading={revokeSessionMutation.isPending}
|
||||
onClick={() =>
|
||||
revokeSessionMutation.mutate({
|
||||
sessionId: session.id,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("Log out")}
|
||||
</Button>
|
||||
)}
|
||||
</Table.Td>
|
||||
)}
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
|
||||
{hasMore && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
size="xs"
|
||||
onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
|
||||
>
|
||||
{t("Load more")}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{(!sessions || sessions.length === 0) && (
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
{t("No active sessions")}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
UseQueryResult,
|
||||
} from "@tanstack/react-query";
|
||||
import {
|
||||
getSessions,
|
||||
revokeSession,
|
||||
revokeAllSessions,
|
||||
} from "@/features/session/services/session-service";
|
||||
import { ISession } from "@/features/session/types/session.types";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
export function useGetSessionsQuery(): UseQueryResult<ISession[], Error> {
|
||||
return useQuery({
|
||||
queryKey: ["session-list"],
|
||||
queryFn: () => getSessions(),
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeSessionMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, { sessionId: string }>({
|
||||
mutationFn: (data) => revokeSession(data),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("Session revoked") });
|
||||
queryClient.invalidateQueries({ queryKey: ["session-list"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function useRevokeAllSessionsMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return useMutation<void, Error, void>({
|
||||
mutationFn: () => revokeAllSessions(),
|
||||
onSuccess: () => {
|
||||
notifications.show({ message: t("All other sessions revoked") });
|
||||
queryClient.invalidateQueries({ queryKey: ["session-list"] });
|
||||
},
|
||||
onError: (error) => {
|
||||
const errorMessage = error["response"]?.data?.message;
|
||||
notifications.show({ message: errorMessage, color: "red" });
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import api from "@/lib/api-client";
|
||||
import { ISession } from "@/features/session/types/session.types";
|
||||
|
||||
export async function getSessions(): Promise<ISession[]> {
|
||||
const req = await api.post<{ sessions: ISession[] }>("/sessions");
|
||||
return req.data.sessions;
|
||||
}
|
||||
|
||||
export async function revokeSession(data: {
|
||||
sessionId: string;
|
||||
}): Promise<void> {
|
||||
await api.post("/sessions/revoke", data);
|
||||
}
|
||||
|
||||
export async function revokeAllSessions(): Promise<void> {
|
||||
await api.post("/sessions/revoke-all");
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export type ISession = {
|
||||
id: string;
|
||||
deviceName: string | null;
|
||||
geoLocation: string | null;
|
||||
lastActiveAt: string;
|
||||
createdAt: string;
|
||||
isCurrentDevice?: boolean;
|
||||
};
|
||||
@@ -3,6 +3,7 @@ import SpaceMembersList from "@/features/space/components/space-members.tsx";
|
||||
import AddSpaceMembersModal from "@/features/space/components/add-space-members-modal.tsx";
|
||||
import React from "react";
|
||||
import SpaceDetails from "@/features/space/components/space-details.tsx";
|
||||
import SpaceSecuritySettings from "@/features/space/components/space-security-settings.tsx";
|
||||
import { useSpaceQuery } from "@/features/space/queries/space-query.ts";
|
||||
import { useSpaceAbility } from "@/features/space/permissions/use-space-ability.ts";
|
||||
import {
|
||||
@@ -59,6 +60,14 @@ export default function SpaceSettingsModal({
|
||||
<Tabs.Tab fw={500} value="members">
|
||||
{t("Members")}
|
||||
</Tabs.Tab>
|
||||
{spaceAbility.can(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Settings,
|
||||
) && (
|
||||
<Tabs.Tab fw={500} value="security">
|
||||
{t("Security")}
|
||||
</Tabs.Tab>
|
||||
)}
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="general">
|
||||
@@ -91,6 +100,20 @@ export default function SpaceSettingsModal({
|
||||
)}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="security">
|
||||
<ScrollArea h={580} scrollbarSize={5} pr={8}>
|
||||
<div style={{ paddingBottom: "100px" }}>
|
||||
<SpaceSecuritySettings
|
||||
space={space}
|
||||
readOnly={spaceAbility.cannot(
|
||||
SpaceCaslAction.Manage,
|
||||
SpaceCaslSubject.Settings,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</div>
|
||||
</Modal.Body>
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
ResponsiveSettingsControl,
|
||||
ResponsiveSettingsRow,
|
||||
} from "@/components/ui/responsive-settings-row.tsx";
|
||||
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
|
||||
|
||||
|
||||
interface SpaceDetailsProps {
|
||||
spaceId: string;
|
||||
@@ -27,7 +27,6 @@ interface SpaceDetailsProps {
|
||||
export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: space, isLoading, refetch } = useSpaceQuery(spaceId);
|
||||
const showSharingToggle = !readOnly;
|
||||
const [exportOpened, { open: openExportModal, close: closeExportModal }] =
|
||||
useDisclosure(false);
|
||||
const [isIconUploading, setIsIconUploading] = useState(false);
|
||||
@@ -89,13 +88,6 @@ export default function SpaceDetails({ spaceId, readOnly }: SpaceDetailsProps) {
|
||||
|
||||
<EditSpaceForm space={space} readOnly={readOnly} />
|
||||
|
||||
{showSharingToggle && (
|
||||
<>
|
||||
<Divider my="lg" />
|
||||
<SpacePublicSharingToggle space={space} />
|
||||
</>
|
||||
)}
|
||||
|
||||
{!readOnly && (
|
||||
<>
|
||||
<Divider my="lg" />
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Text, Divider } from "@mantine/core";
|
||||
import React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISpace } from "@/features/space/types/space.types.ts";
|
||||
import SpacePublicSharingToggle from "@/ee/security/components/space-public-sharing-toggle.tsx";
|
||||
import SpaceViewerCommentsToggle from "@/ee/security/components/space-viewer-comments-toggle.tsx";
|
||||
|
||||
type SpaceSecuritySettingsProps = {
|
||||
space: ISpace;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export default function SpaceSecuritySettings({
|
||||
space,
|
||||
readOnly,
|
||||
}: SpaceSecuritySettingsProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (readOnly) return null;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Text my="md" fw={600}>
|
||||
{t("Security")}
|
||||
</Text>
|
||||
|
||||
<SpacePublicSharingToggle space={space} />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<SpaceViewerCommentsToggle space={space} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,8 +9,13 @@ export interface ISpaceSharingSettings {
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceCommentsSettings {
|
||||
allowViewerComments?: boolean;
|
||||
}
|
||||
|
||||
export interface ISpaceSettings {
|
||||
sharing?: ISpaceSharingSettings;
|
||||
comments?: ISpaceCommentsSettings;
|
||||
}
|
||||
|
||||
export interface ISpace {
|
||||
@@ -29,6 +34,7 @@ export interface ISpace {
|
||||
settings?: ISpaceSettings;
|
||||
// for updates
|
||||
disablePublicSharing?: boolean;
|
||||
allowViewerComments?: boolean;
|
||||
}
|
||||
|
||||
interface IMembership {
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { useAtom } from "jotai";
|
||||
import { focusAtom } from "jotai-optics";
|
||||
import { z } from "zod/v4";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { zod4Resolver } from "mantine-form-zod-resolver";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
|
||||
import { updateUser } from "@/features/user/services/user-service.ts";
|
||||
import { IUser } from "@/features/user/types/user.types.ts";
|
||||
import { useState } from "react";
|
||||
@@ -17,18 +16,15 @@ const formSchema = z.object({
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>;
|
||||
|
||||
const userAtom = focusAtom(currentUserAtom, (optic) => optic.prop("user"));
|
||||
|
||||
export default function AccountNameForm() {
|
||||
const { t } = useTranslation();
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [currentUser] = useAtom(currentUserAtom);
|
||||
const [, setUser] = useAtom(userAtom);
|
||||
const [user, setUser] = useAtom(userAtom);
|
||||
|
||||
const form = useForm<FormValues>({
|
||||
validate: zod4Resolver(formSchema),
|
||||
initialValues: {
|
||||
name: currentUser?.user.name,
|
||||
name: user?.name,
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -53,6 +53,9 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
const { data: space } = useGetSpaceBySlugQuery(page?.space?.slug);
|
||||
|
||||
const canEdit = page?.permissions?.canEdit ?? false;
|
||||
const canComment =
|
||||
canEdit ||
|
||||
(space?.settings?.comments?.allowViewerComments === true);
|
||||
|
||||
if (isLoading) {
|
||||
return <></>;
|
||||
@@ -104,6 +107,7 @@ function PageContent({ pageSlug }: { pageSlug: string | undefined }) {
|
||||
slugId={page.slugId}
|
||||
spaceSlug={page?.space?.slug}
|
||||
editable={canEdit}
|
||||
canComment={canComment}
|
||||
/>
|
||||
<MemoizedHistoryModal pageId={page.id} />
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { getAppName } from "@/lib/config.ts";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { AccountMfaSection } from "@/features/user/components/account-mfa-section";
|
||||
import SessionList from "@/features/session/components/session-list";
|
||||
|
||||
export default function AccountSettings() {
|
||||
const { t } = useTranslation();
|
||||
@@ -36,6 +37,10 @@ export default function AccountSettings() {
|
||||
<Divider my="lg" />
|
||||
|
||||
<AccountMfaSection />
|
||||
|
||||
<Divider my="lg" />
|
||||
|
||||
<SessionList />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,6 +66,7 @@
|
||||
"ai": "^6.0.134",
|
||||
"ai-sdk-ollama": "^3.8.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"bowser": "^2.14.1",
|
||||
"bullmq": "^5.71.0",
|
||||
"cache-manager": "^7.2.8",
|
||||
"cheerio": "^1.2.0",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
prosemirrorNodeToYElement,
|
||||
tiptapExtensions,
|
||||
} from './collaboration.util';
|
||||
import { setYjsMark, updateYjsMarkAttribute, YjsSelection } from './yjs.util';
|
||||
import * as Y from 'yjs';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
|
||||
@@ -27,6 +28,53 @@ export class CollaborationHandler {
|
||||
// const fragment = doc.getXmlFragment('default');
|
||||
//});
|
||||
},
|
||||
setCommentMark: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
yjsSelection: YjsSelection;
|
||||
commentId: string;
|
||||
resolved: boolean;
|
||||
user: User;
|
||||
},
|
||||
) => {
|
||||
const { yjsSelection, commentId, resolved, user } = payload;
|
||||
await this.withYdocConnection(
|
||||
hocuspocus,
|
||||
documentName,
|
||||
{ user },
|
||||
(doc) => {
|
||||
const fragment = doc.getXmlFragment('default');
|
||||
setYjsMark(doc, fragment, yjsSelection, 'comment', {
|
||||
commentId,
|
||||
resolved,
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
resolveCommentMark: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
commentId: string;
|
||||
resolved: boolean;
|
||||
user: User;
|
||||
},
|
||||
) => {
|
||||
const { commentId, resolved, user } = payload;
|
||||
await this.withYdocConnection(
|
||||
hocuspocus,
|
||||
documentName,
|
||||
{ user },
|
||||
(doc) => {
|
||||
const fragment = doc.getXmlFragment('default');
|
||||
updateYjsMarkAttribute(
|
||||
fragment,
|
||||
'comment',
|
||||
{ name: 'commentId', value: commentId },
|
||||
{ resolved },
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
updatePageContent: async (
|
||||
documentName: string,
|
||||
payload: {
|
||||
@@ -58,8 +106,7 @@ export class CollaborationHandler {
|
||||
} else {
|
||||
const newContent = prosemirrorJson.content || [];
|
||||
const yElements = newContent.map(prosemirrorNodeToYElement);
|
||||
const position =
|
||||
operation === 'prepend' ? 0 : fragment.length;
|
||||
const position = operation === 'prepend' ? 0 : fragment.length;
|
||||
fragment.insert(position, yElements);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {
|
||||
initProseMirrorDoc,
|
||||
relativePositionToAbsolutePosition,
|
||||
} from 'y-prosemirror';
|
||||
} from '@tiptap/y-tiptap';
|
||||
import * as Y from 'yjs';
|
||||
import { Document } from '@hocuspocus/server';
|
||||
import { getSchema } from '@tiptap/core';
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
export const Feature = {
|
||||
SSO_CUSTOM: 'sso:custom',
|
||||
SSO_GOOGLE: 'sso:google',
|
||||
MFA: 'mfa',
|
||||
API_KEYS: 'api:keys',
|
||||
COMMENT_RESOLUTION: 'comment:resolution',
|
||||
PAGE_PERMISSIONS: 'page:permissions',
|
||||
AI: 'ai',
|
||||
CONFLUENCE_IMPORT: 'import:confluence',
|
||||
DOCX_IMPORT: 'import:docx',
|
||||
ATTACHMENT_INDEXING: 'attachment:indexing',
|
||||
SECURITY_SETTINGS: 'security:settings',
|
||||
MCP: 'mcp',
|
||||
SCIM: 'scim',
|
||||
PAGE_VERIFICATION: 'page:verification',
|
||||
AUDIT_LOGS: 'audit:logs',
|
||||
RETENTION: 'retention',
|
||||
SHARING_CONTROLS: 'sharing:controls',
|
||||
VIEWER_COMMENTS: 'comment:viewer',
|
||||
} as const;
|
||||
|
||||
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
||||
@@ -7,6 +7,7 @@ export interface AuditContext {
|
||||
actorId: string | null;
|
||||
actorType: 'user' | 'system' | 'api_key';
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
}
|
||||
|
||||
export const AUDIT_CONTEXT_KEY = 'auditContext';
|
||||
@@ -19,11 +20,15 @@ export class AuditContextMiddleware implements NestMiddleware {
|
||||
const workspaceId = (req as any).workspaceId ?? null;
|
||||
const ipAddress = this.extractIpAddress(req);
|
||||
|
||||
const userAgent =
|
||||
(req.headers['user-agent'] as string) ?? null;
|
||||
|
||||
const auditContext: AuditContext = {
|
||||
workspaceId,
|
||||
actorId: null,
|
||||
actorType: 'user',
|
||||
ipAddress,
|
||||
userAgent,
|
||||
};
|
||||
|
||||
this.cls.set(AUDIT_CONTEXT_KEY, auditContext);
|
||||
|
||||
@@ -5,12 +5,14 @@ import {
|
||||
HttpStatus,
|
||||
Inject,
|
||||
Post,
|
||||
Req,
|
||||
Res,
|
||||
UseGuards,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { AuthService } from './services/auth.service';
|
||||
import { SessionService } from '../session/session.service';
|
||||
import { SetupGuard } from './guards/setup.guard';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { CreateAdminUserDto } from './dto/create-admin-user.dto';
|
||||
@@ -22,7 +24,7 @@ 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';
|
||||
import { FastifyReply } from 'fastify';
|
||||
import { FastifyReply, FastifyRequest } from 'fastify';
|
||||
import { validateSsoEnforcement } from './auth.util';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
import { AuditEvent, AuditResource } from '../../common/events/audit-events';
|
||||
@@ -37,6 +39,7 @@ export class AuthController {
|
||||
|
||||
constructor(
|
||||
private authService: AuthService,
|
||||
private sessionService: SessionService,
|
||||
private environmentService: EnvironmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
@@ -115,8 +118,15 @@ export class AuthController {
|
||||
@Body() dto: ChangePasswordDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
return this.authService.changePassword(dto, user.id, workspace.id);
|
||||
const currentSessionId = (req.raw as any).sessionId;
|
||||
return this.authService.changePassword(
|
||||
dto,
|
||||
user.id,
|
||||
workspace.id,
|
||||
currentSessionId,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@@ -178,8 +188,18 @@ export class AuthController {
|
||||
@Post('logout')
|
||||
async logout(
|
||||
@AuthUser() user: User,
|
||||
@Req() req: FastifyRequest,
|
||||
@Res({ passthrough: true }) res: FastifyReply,
|
||||
) {
|
||||
const sessionId = (req.raw as any).sessionId;
|
||||
if (sessionId) {
|
||||
await this.sessionService.revokeSession(
|
||||
sessionId,
|
||||
user.id,
|
||||
user.workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
res.clearCookie('authToken');
|
||||
|
||||
this.auditService.log({
|
||||
@@ -192,6 +212,7 @@ export class AuthController {
|
||||
setAuthCookie(res: FastifyReply, token: string) {
|
||||
res.setCookie('authToken', token, {
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
path: '/',
|
||||
expires: this.environmentService.getCookieExpiresIn(),
|
||||
secure: this.environmentService.isHttps(),
|
||||
|
||||
@@ -11,6 +11,7 @@ export type JwtPayload = {
|
||||
email: string;
|
||||
workspaceId: string;
|
||||
type: 'access';
|
||||
sessionId?: string;
|
||||
};
|
||||
|
||||
export type JwtCollabPayload = {
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
import { LoginDto } from '../dto/login.dto';
|
||||
import { CreateUserDto } from '../dto/create-user.dto';
|
||||
import { TokenService } from './token.service';
|
||||
import { SessionService } from '../../session/session.service';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { SignupService } from './signup.service';
|
||||
import { CreateAdminUserDto } from '../dto/create-admin-user.dto';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
@@ -44,6 +46,8 @@ export class AuthService {
|
||||
constructor(
|
||||
private signupService: SignupService,
|
||||
private tokenService: TokenService,
|
||||
private sessionService: SessionService,
|
||||
private userSessionRepo: UserSessionRepo,
|
||||
private userRepo: UserRepo,
|
||||
private userTokenRepo: UserTokenRepo,
|
||||
private mailService: MailService,
|
||||
@@ -90,19 +94,19 @@ export class AuthService {
|
||||
metadata: { source: 'password' },
|
||||
});
|
||||
|
||||
return this.tokenService.generateAccessToken(user);
|
||||
return this.sessionService.createSessionAndToken(user);
|
||||
}
|
||||
|
||||
async register(createUserDto: CreateUserDto, workspaceId: string) {
|
||||
const user = await this.signupService.signup(createUserDto, workspaceId);
|
||||
return this.tokenService.generateAccessToken(user);
|
||||
return this.sessionService.createSessionAndToken(user);
|
||||
}
|
||||
|
||||
async setup(createAdminUserDto: CreateAdminUserDto) {
|
||||
const { workspace, user } =
|
||||
await this.signupService.initialSetup(createAdminUserDto);
|
||||
|
||||
const authToken = await this.tokenService.generateAccessToken(user);
|
||||
const authToken = await this.sessionService.createSessionAndToken(user);
|
||||
return { workspace, authToken };
|
||||
}
|
||||
|
||||
@@ -110,6 +114,7 @@ export class AuthService {
|
||||
dto: ChangePasswordDto,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
currentSessionId?: string,
|
||||
): Promise<void> {
|
||||
const user = await this.userRepo.findById(userId, workspaceId, {
|
||||
includePassword: true,
|
||||
@@ -138,6 +143,16 @@ export class AuthService {
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
if (currentSessionId) {
|
||||
await this.userSessionRepo.deleteAllExceptCurrent(
|
||||
currentSessionId,
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
} else {
|
||||
await this.userSessionRepo.deleteByUserId(userId, workspaceId);
|
||||
}
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_PASSWORD_CHANGED,
|
||||
resourceType: AuditResource.USER,
|
||||
@@ -244,6 +259,8 @@ export class AuthService {
|
||||
.execute();
|
||||
});
|
||||
|
||||
await this.userSessionRepo.deleteByUserId(user.id, workspace.id);
|
||||
|
||||
this.auditService.setActorId(user.id);
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_PASSWORD_RESET,
|
||||
@@ -276,7 +293,7 @@ export class AuthService {
|
||||
};
|
||||
}
|
||||
|
||||
const authToken = await this.tokenService.generateAccessToken(user);
|
||||
const authToken = await this.sessionService.createSessionAndToken(user);
|
||||
return { authToken };
|
||||
}
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ export class TokenService {
|
||||
private environmentService: EnvironmentService,
|
||||
) {}
|
||||
|
||||
async generateAccessToken(user: User): Promise<string> {
|
||||
async generateAccessToken(user: User, sessionId: string): Promise<string> {
|
||||
if (isUserDisabled(user)) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
@@ -35,6 +35,7 @@ export class TokenService {
|
||||
email: user.email,
|
||||
workspaceId: user.workspaceId,
|
||||
type: JwtType.ACCESS,
|
||||
sessionId,
|
||||
};
|
||||
return this.jwtService.sign(payload);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,8 @@ import { EnvironmentService } from '../../../integrations/environment/environmen
|
||||
import { JwtApiKeyPayload, JwtPayload, JwtType } from '../dto/jwt-payload';
|
||||
import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { SessionActivityService } from '../../session/session-activity.service';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
import { extractBearerTokenFromHeader, isUserDisabled } from '../../../common/helpers';
|
||||
import { ModuleRef } from '@nestjs/core';
|
||||
@@ -16,6 +18,8 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
constructor(
|
||||
private userRepo: UserRepo,
|
||||
private workspaceRepo: WorkspaceRepo,
|
||||
private userSessionRepo: UserSessionRepo,
|
||||
private sessionActivityService: SessionActivityService,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private moduleRef: ModuleRef,
|
||||
) {
|
||||
@@ -57,6 +61,16 @@ export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
|
||||
if ((payload as JwtPayload).sessionId) {
|
||||
const sessionId = (payload as JwtPayload).sessionId;
|
||||
const session = await this.userSessionRepo.findActiveById(sessionId);
|
||||
if (!session || session.userId !== payload.sub || session.workspaceId !== payload.workspaceId) {
|
||||
throw new UnauthorizedException();
|
||||
}
|
||||
req.raw.sessionId = sessionId;
|
||||
this.sessionActivityService.trackActivity(sessionId, payload.sub, payload.workspaceId);
|
||||
}
|
||||
|
||||
return { user, workspace };
|
||||
}
|
||||
|
||||
|
||||
@@ -58,13 +58,13 @@ export class CommentController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
||||
|
||||
const comment = await this.commentService.create(
|
||||
{
|
||||
userId: user.id,
|
||||
page,
|
||||
workspaceId: workspace.id,
|
||||
user,
|
||||
},
|
||||
createCommentDto,
|
||||
);
|
||||
@@ -120,7 +120,7 @@ export class CommentController {
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('update')
|
||||
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User) {
|
||||
async update(@Body() dto: UpdateCommentDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
|
||||
const comment = await this.commentRepo.findById(dto.commentId, {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
@@ -134,14 +134,14 @@ export class CommentController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
||||
|
||||
return this.commentService.update(comment, dto, user);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('delete')
|
||||
async delete(@Body() input: CommentIdDto, @AuthUser() user: User) {
|
||||
async delete(@Body() input: CommentIdDto, @AuthUser() user: User, @AuthWorkspace() workspace: Workspace) {
|
||||
const comment = await this.commentRepo.findById(input.commentId);
|
||||
if (!comment) {
|
||||
throw new NotFoundException('Comment not found');
|
||||
@@ -152,8 +152,7 @@ export class CommentController {
|
||||
throw new NotFoundException('Page not found');
|
||||
}
|
||||
|
||||
// Check page-level edit permission first
|
||||
await this.pageAccessService.validateCanEdit(page, user);
|
||||
await this.pageAccessService.validateCanComment(page, user, workspace.id);
|
||||
|
||||
// Check if user is the comment owner
|
||||
const isOwner = comment.creatorId === user.id;
|
||||
@@ -169,7 +168,7 @@ export class CommentController {
|
||||
// Space admin can delete any comment
|
||||
if (ability.cannot(SpaceCaslAction.Manage, SpaceCaslSubject.Settings)) {
|
||||
throw new ForbiddenException(
|
||||
'You can only delete your own comments or must be a space admin',
|
||||
'You can only delete your own comments',
|
||||
);
|
||||
}
|
||||
await this.commentRepo.deleteComment(comment.id);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CommentService } from './comment.service';
|
||||
import { CommentController } from './comment.controller';
|
||||
import { CollaborationModule } from '../../collaboration/collaboration.module';
|
||||
|
||||
@Module({
|
||||
imports: [CollaborationModule],
|
||||
controllers: [CommentController],
|
||||
providers: [CommentService],
|
||||
exports: [CommentService],
|
||||
|
||||
@@ -7,7 +7,8 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectQueue } from '@nestjs/bullmq';
|
||||
import { Queue } from 'bullmq';
|
||||
import { CreateCommentDto } from './dto/create-comment.dto';
|
||||
import { CreateCommentDto, yjsSelectionSchema } from './dto/create-comment.dto';
|
||||
import { CollaborationGateway } from '../../collaboration/collaboration.gateway';
|
||||
import { UpdateCommentDto } from './dto/update-comment.dto';
|
||||
import { CommentRepo } from '@docmost/db/repos/comment/comment.repo';
|
||||
import { Comment, Page, User } from '@docmost/db/types/entity.types';
|
||||
@@ -27,6 +28,7 @@ export class CommentService {
|
||||
private commentRepo: CommentRepo,
|
||||
private pageRepo: PageRepo,
|
||||
private wsService: WsService,
|
||||
private collaborationGateway: CollaborationGateway,
|
||||
@InjectQueue(QueueName.GENERAL_QUEUE)
|
||||
private generalQueue: Queue,
|
||||
@InjectQueue(QueueName.NOTIFICATION_QUEUE)
|
||||
@@ -45,10 +47,10 @@ export class CommentService {
|
||||
}
|
||||
|
||||
async create(
|
||||
opts: { userId: string; page: Page; workspaceId: string },
|
||||
opts: { page: Page; workspaceId: string; user: User },
|
||||
createCommentDto: CreateCommentDto,
|
||||
) {
|
||||
const { userId, page, workspaceId } = opts;
|
||||
const { page, workspaceId, user } = opts;
|
||||
const commentContent = JSON.parse(createCommentDto.content);
|
||||
|
||||
if (createCommentDto.parentCommentId) {
|
||||
@@ -71,11 +73,39 @@ export class CommentService {
|
||||
selection: createCommentDto?.selection?.substring(0, 250) ?? null,
|
||||
type: createCommentDto.type ?? 'page',
|
||||
parentCommentId: createCommentDto?.parentCommentId,
|
||||
creatorId: userId,
|
||||
creatorId: user.id,
|
||||
workspaceId: workspaceId,
|
||||
spaceId: page.spaceId,
|
||||
});
|
||||
|
||||
if (createCommentDto.yjsSelection) {
|
||||
const parsed = yjsSelectionSchema.safeParse(createCommentDto.yjsSelection);
|
||||
if (!parsed.success) {
|
||||
this.logger.warn(
|
||||
`Invalid yjsSelection for comment ${inserted.id}: ${parsed.error.message}`,
|
||||
);
|
||||
} else {
|
||||
const documentName = `page.${page.id}`;
|
||||
try {
|
||||
await this.collaborationGateway.handleYjsEvent(
|
||||
'setCommentMark',
|
||||
documentName,
|
||||
{
|
||||
yjsSelection: parsed.data,
|
||||
commentId: inserted.id,
|
||||
resolved: false,
|
||||
user,
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to apply comment mark for comment ${inserted.id}, comment saved without inline highlight`,
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const comment = await this.commentRepo.findById(inserted.id, {
|
||||
includeCreator: true,
|
||||
includeResolvedBy: true,
|
||||
@@ -83,7 +113,7 @@ export class CommentService {
|
||||
|
||||
this.generalQueue
|
||||
.add(QueueJob.ADD_PAGE_WATCHERS, {
|
||||
userIds: [userId],
|
||||
userIds: [user.id],
|
||||
pageId: page.id,
|
||||
spaceId: page.spaceId,
|
||||
workspaceId,
|
||||
@@ -101,7 +131,7 @@ export class CommentService {
|
||||
page.id,
|
||||
page.spaceId,
|
||||
workspaceId,
|
||||
userId,
|
||||
user.id,
|
||||
!isReply,
|
||||
createCommentDto.parentCommentId,
|
||||
);
|
||||
|
||||
@@ -1,4 +1,22 @@
|
||||
import { IsIn, IsJSON, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { IsIn, IsJSON, IsObject, IsOptional, IsString, IsUUID } from 'class-validator';
|
||||
import { z } from 'zod';
|
||||
|
||||
const yjsIdSchema = z.object({
|
||||
client: z.number().int().nonnegative(),
|
||||
clock: z.number().int().nonnegative(),
|
||||
});
|
||||
|
||||
const yjsRelativePositionSchema = z.object({
|
||||
type: yjsIdSchema,
|
||||
tname: z.string().nullable(),
|
||||
item: yjsIdSchema.nullable(),
|
||||
assoc: z.number().int(),
|
||||
});
|
||||
|
||||
export const yjsSelectionSchema = z.object({
|
||||
anchor: yjsRelativePositionSchema,
|
||||
head: yjsRelativePositionSchema,
|
||||
});
|
||||
|
||||
export class CreateCommentDto {
|
||||
@IsString()
|
||||
@@ -18,4 +36,11 @@ export class CreateCommentDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
parentCommentId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsObject()
|
||||
yjsSelection?: {
|
||||
anchor: any;
|
||||
head: any;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { AuditContextMiddleware } from '../common/middlewares/audit-context.midd
|
||||
import { ShareModule } from './share/share.module';
|
||||
import { NotificationModule } from './notification/notification.module';
|
||||
import { WatcherModule } from './watcher/watcher.module';
|
||||
import { SessionModule } from './session/session.module';
|
||||
import { ClsMiddleware } from 'nestjs-cls';
|
||||
|
||||
@Module({
|
||||
@@ -38,6 +39,7 @@ import { ClsMiddleware } from 'nestjs-cls';
|
||||
ShareModule,
|
||||
NotificationModule,
|
||||
WatcherModule,
|
||||
SessionModule,
|
||||
],
|
||||
})
|
||||
export class CoreModule implements NestModule {
|
||||
|
||||
@@ -6,12 +6,14 @@ import {
|
||||
SpaceCaslAction,
|
||||
SpaceCaslSubject,
|
||||
} from '../../casl/interfaces/space-ability.type';
|
||||
import { SpaceRepo } from '@docmost/db/repos/space/space.repo';
|
||||
|
||||
@Injectable()
|
||||
export class PageAccessService {
|
||||
constructor(
|
||||
private readonly pagePermissionRepo: PagePermissionRepo,
|
||||
private readonly spaceAbility: SpaceAbilityFactory,
|
||||
private readonly spaceRepo: SpaceRepo,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -99,4 +101,25 @@ export class PageAccessService {
|
||||
|
||||
return { hasRestriction: hasAnyRestriction };
|
||||
}
|
||||
|
||||
async validateCanComment(
|
||||
page: Page,
|
||||
user: User,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await this.validateCanEdit(page, user);
|
||||
return;
|
||||
} catch {
|
||||
// User cannot edit — check if reader commenting is enabled
|
||||
}
|
||||
|
||||
await this.validateCanView(page, user);
|
||||
|
||||
const space = await this.spaceRepo.findById(page.spaceId, workspaceId);
|
||||
const settings = space?.settings as Record<string, any> | null;
|
||||
if (!settings?.comments?.allowViewerComments) {
|
||||
throw new ForbiddenException();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||
|
||||
export class RevokeSessionDto {
|
||||
@IsUUID()
|
||||
@IsNotEmpty()
|
||||
sessionId: string;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { RedisService } from '@nestjs-labs/nestjs-ioredis';
|
||||
import type { Redis } from 'ioredis';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { UserRepo } from '@docmost/db/repos/user/user.repo';
|
||||
|
||||
const THROTTLE_SECONDS = 15 * 60; // 15 minutes
|
||||
|
||||
@Injectable()
|
||||
export class SessionActivityService {
|
||||
private readonly redis: Redis;
|
||||
|
||||
constructor(
|
||||
private readonly redisService: RedisService,
|
||||
private readonly userSessionRepo: UserSessionRepo,
|
||||
private readonly userRepo: UserRepo,
|
||||
) {
|
||||
this.redis = this.redisService.getOrThrow();
|
||||
}
|
||||
|
||||
trackActivity(sessionId: string, userId: string, workspaceId: string): void {
|
||||
const key = `session:activity:${sessionId}`;
|
||||
|
||||
this.redis
|
||||
.set(key, '1', 'EX', THROTTLE_SECONDS, 'NX')
|
||||
.then((result) => {
|
||||
if (result === null) return; // key already exists, throttled
|
||||
|
||||
this.userSessionRepo.updateLastActiveAt(sessionId).catch(() => {});
|
||||
this.userRepo
|
||||
.updateUser({ lastActiveAt: new Date() }, userId, workspaceId)
|
||||
.catch(() => {});
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
Post,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { SessionService } from './session.service';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { AuthUser } from '../../common/decorators/auth-user.decorator';
|
||||
import { AuthWorkspace } from '../../common/decorators/auth-workspace.decorator';
|
||||
import { User, Workspace } from '@docmost/db/types/entity.types';
|
||||
import { RevokeSessionDto } from './dto/revoke-session.dto';
|
||||
import { FastifyRequest } from 'fastify';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('sessions')
|
||||
export class SessionController {
|
||||
constructor(private readonly sessionService: SessionService) {}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post()
|
||||
async listSessions(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
const currentSessionId = (req.raw as any).sessionId ?? null;
|
||||
const sessions = await this.sessionService.getActiveSessions(
|
||||
user.id,
|
||||
workspace.id,
|
||||
currentSessionId,
|
||||
);
|
||||
return { sessions };
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('revoke')
|
||||
async revokeSession(
|
||||
@Body() dto: RevokeSessionDto,
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
const currentSessionId = (req.raw as any).sessionId;
|
||||
if (dto.sessionId === currentSessionId) {
|
||||
throw new BadRequestException(
|
||||
'Cannot revoke current session. Use logout instead.',
|
||||
);
|
||||
}
|
||||
await this.sessionService.revokeSession(
|
||||
dto.sessionId,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@Post('revoke-all')
|
||||
async revokeAllSessions(
|
||||
@AuthUser() user: User,
|
||||
@AuthWorkspace() workspace: Workspace,
|
||||
@Req() req: FastifyRequest,
|
||||
) {
|
||||
const currentSessionId = (req.raw as any).sessionId;
|
||||
if (!currentSessionId) {
|
||||
throw new BadRequestException(
|
||||
'Current session not found. Please log in again.',
|
||||
);
|
||||
}
|
||||
await this.sessionService.revokeAllOtherSessions(
|
||||
currentSessionId,
|
||||
user.id,
|
||||
workspace.id,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { SessionService } from './session.service';
|
||||
import { SessionActivityService } from './session-activity.service';
|
||||
import { SessionController } from './session.controller';
|
||||
import { TokenModule } from '../auth/token.module';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
imports: [TokenModule],
|
||||
controllers: [SessionController],
|
||||
providers: [SessionService, SessionActivityService],
|
||||
exports: [SessionService, SessionActivityService],
|
||||
})
|
||||
export class SessionModule {}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { TokenService } from '../auth/services/token.service';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { EnvironmentService } from '../../integrations/environment/environment.service';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { ClsService } from 'nestjs-cls';
|
||||
import {
|
||||
AuditContext,
|
||||
AUDIT_CONTEXT_KEY,
|
||||
} from '../../common/middlewares/audit-context.middleware';
|
||||
import * as Bowser from 'bowser';
|
||||
|
||||
const MAX_SESSIONS_PER_USER = 25;
|
||||
const RETENTION_DAYS = 7;
|
||||
|
||||
@Injectable()
|
||||
export class SessionService {
|
||||
private readonly logger = new Logger(SessionService.name);
|
||||
|
||||
constructor(
|
||||
private readonly tokenService: TokenService,
|
||||
private readonly userSessionRepo: UserSessionRepo,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
private readonly cls: ClsService,
|
||||
) {}
|
||||
|
||||
@Interval('session-cleanup', 24 * 60 * 60 * 1000)
|
||||
async cleanupSessions() {
|
||||
try {
|
||||
await this.userSessionRepo.deleteStale(RETENTION_DAYS);
|
||||
await this.userSessionRepo.trimExcessSessions(MAX_SESSIONS_PER_USER);
|
||||
this.logger.debug('Session cleanup completed');
|
||||
} catch (err) {
|
||||
this.logger.error('Session cleanup failed', err);
|
||||
}
|
||||
}
|
||||
|
||||
async createSessionAndToken(user: User): Promise<string> {
|
||||
const auditContext = this.cls.get<AuditContext>(AUDIT_CONTEXT_KEY);
|
||||
const ipAddress = auditContext?.ipAddress ?? null;
|
||||
const userAgent = auditContext?.userAgent ?? null;
|
||||
|
||||
const deviceName = this.parseDeviceName(userAgent);
|
||||
const expiresAt = this.environmentService.getCookieExpiresIn();
|
||||
|
||||
const session = await this.userSessionRepo.insertSession({
|
||||
userId: user.id,
|
||||
workspaceId: user.workspaceId,
|
||||
deviceName,
|
||||
ipAddress,
|
||||
expiresAt,
|
||||
});
|
||||
|
||||
return this.tokenService.generateAccessToken(user, session.id);
|
||||
}
|
||||
|
||||
async getActiveSessions(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
currentSessionId: string | null,
|
||||
) {
|
||||
const sessions = await this.userSessionRepo.findActiveByUser(
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
|
||||
const mapped = sessions.map((s) => ({
|
||||
id: s.id,
|
||||
deviceName: s.deviceName,
|
||||
geoLocation: s.geoLocation,
|
||||
lastActiveAt: s.lastActiveAt,
|
||||
createdAt: s.createdAt,
|
||||
isCurrentDevice: s.id === currentSessionId,
|
||||
}));
|
||||
|
||||
return mapped.sort((a, b) => {
|
||||
if (a.isCurrentDevice) return -1;
|
||||
if (b.isCurrentDevice) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
async revokeSession(
|
||||
sessionId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.userSessionRepo.revokeById(sessionId, userId, workspaceId);
|
||||
}
|
||||
|
||||
async revokeAllOtherSessions(
|
||||
currentSessionId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.userSessionRepo.revokeAllExceptCurrent(
|
||||
currentSessionId,
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
}
|
||||
|
||||
private parseDeviceName(userAgent: string | null): string | null {
|
||||
if (!userAgent) return null;
|
||||
|
||||
try {
|
||||
const parsed = Bowser.parse(userAgent);
|
||||
|
||||
const os = parsed.os?.name;
|
||||
const browser = parsed.browser?.name;
|
||||
const platformType = parsed.platform?.type;
|
||||
|
||||
if (platformType === 'mobile' || platformType === 'tablet') {
|
||||
return parsed.platform?.model || os || 'Mobile Device';
|
||||
}
|
||||
|
||||
if (os) {
|
||||
return browser ? `${browser} on ${os}` : os;
|
||||
}
|
||||
|
||||
return browser || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,4 +11,8 @@ export class UpdateSpaceDto extends PartialType(CreateSpaceDto) {
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
disablePublicSharing: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
allowViewerComments: boolean;
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { Space, User } from '@docmost/db/types/entity.types';
|
||||
import { UpdateSpaceDto } from '../dto/update-space.dto';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Feature } from '../../../common/features';
|
||||
import { SpaceMemberService } from './space-member.service';
|
||||
import { SpaceRole } from '../../../common/helpers/types/permission';
|
||||
import { QueueJob, QueueName } from 'src/integrations/queue/constants';
|
||||
@@ -133,17 +134,34 @@ export class SpaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updateSpaceDto.disablePublicSharing !== 'undefined') {
|
||||
if (
|
||||
typeof updateSpaceDto.disablePublicSharing !== 'undefined' ||
|
||||
typeof updateSpaceDto.allowViewerComments !== 'undefined'
|
||||
) {
|
||||
const workspace = await this.workspaceRepo.findById(workspaceId, {
|
||||
withLicenseKey: true,
|
||||
});
|
||||
|
||||
if (
|
||||
!this.licenseCheckService.hasFeature(workspace.licenseKey, 'security:settings', workspace.plan)
|
||||
typeof updateSpaceDto.disablePublicSharing !== 'undefined' &&
|
||||
!this.licenseCheckService.hasFeature(
|
||||
workspace.licenseKey,
|
||||
Feature.SECURITY_SETTINGS,
|
||||
workspace.plan,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid license',
|
||||
);
|
||||
throw new ForbiddenException('This feature requires a valid license');
|
||||
}
|
||||
|
||||
if (
|
||||
typeof updateSpaceDto.allowViewerComments !== 'undefined' &&
|
||||
!this.licenseCheckService.hasFeature(
|
||||
workspace.licenseKey,
|
||||
Feature.VIEWER_COMMENTS,
|
||||
workspace.plan,
|
||||
)
|
||||
) {
|
||||
throw new ForbiddenException('This feature requires a valid license');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +197,22 @@ export class SpaceService {
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof updateSpaceDto.allowViewerComments !== 'undefined') {
|
||||
const prev = settingsBefore?.comments?.allowViewerComments ?? false;
|
||||
if (prev !== updateSpaceDto.allowViewerComments) {
|
||||
before.allowViewerComments = prev;
|
||||
after.allowViewerComments = updateSpaceDto.allowViewerComments;
|
||||
}
|
||||
|
||||
await this.spaceRepo.updateCommentSettings(
|
||||
updateSpaceDto.spaceId,
|
||||
workspaceId,
|
||||
'allowViewerComments',
|
||||
updateSpaceDto.allowViewerComments,
|
||||
trx,
|
||||
);
|
||||
}
|
||||
|
||||
updatedSpace = await this.spaceRepo.updateSpace(
|
||||
{
|
||||
name: updateSpaceDto.name,
|
||||
|
||||
@@ -22,6 +22,7 @@ import InvitationEmail from '@docmost/transactional/emails/invitation-email';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import InvitationAcceptedEmail from '@docmost/transactional/emails/invitation-accepted-email';
|
||||
import { TokenService } from '../../auth/services/token.service';
|
||||
import { SessionService } from '../../session/session.service';
|
||||
import { nanoIdGen } from '../../../common/helpers';
|
||||
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
|
||||
import { executeWithCursorPagination } from '@docmost/db/pagination/cursor-pagination';
|
||||
@@ -49,6 +50,7 @@ export class WorkspaceInvitationService {
|
||||
private mailService: MailService,
|
||||
private domainService: DomainService,
|
||||
private tokenService: TokenService,
|
||||
private sessionService: SessionService,
|
||||
@InjectKysely() private readonly db: KyselyDB,
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
private readonly environmentService: EnvironmentService,
|
||||
@@ -350,7 +352,7 @@ export class WorkspaceInvitationService {
|
||||
};
|
||||
}
|
||||
|
||||
const authToken = await this.tokenService.generateAccessToken(newUser);
|
||||
const authToken = await this.sessionService.createSessionAndToken(newUser);
|
||||
return { authToken };
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { LicenseCheckService } from '../../../integrations/environment/license-check.service';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { CreateWorkspaceDto } from '../dto/create-workspace.dto';
|
||||
import { UpdateWorkspaceDto } from '../dto/update-workspace.dto';
|
||||
import { SpaceService } from '../../space/services/space.service';
|
||||
@@ -17,6 +18,7 @@ import { WorkspaceRepo } from '@docmost/db/repos/workspace/workspace.repo';
|
||||
import { KyselyDB, KyselyTransaction } from '@docmost/db/types/kysely.types';
|
||||
import { executeTx } from '@docmost/db/utils';
|
||||
import { InjectKysely } from 'nestjs-kysely';
|
||||
import { Feature } from '../../../common/features';
|
||||
import { User } from '@docmost/db/types/entity.types';
|
||||
import { GroupUserRepo } from '@docmost/db/repos/group/group-user.repo';
|
||||
import { GroupRepo } from '@docmost/db/repos/group/group.repo';
|
||||
@@ -67,6 +69,7 @@ export class WorkspaceService {
|
||||
@InjectQueue(QueueName.BILLING_QUEUE) private billingQueue: Queue,
|
||||
@InjectQueue(QueueName.AI_QUEUE) private aiQueue: Queue,
|
||||
@Inject(AUDIT_SERVICE) private readonly auditService: IAuditService,
|
||||
private userSessionRepo: UserSessionRepo,
|
||||
) {}
|
||||
|
||||
async findById(workspaceId: string) {
|
||||
@@ -350,7 +353,7 @@ export class WorkspaceService {
|
||||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
|
||||
typeof updateWorkspaceDto.restrictApiToAdmins !== 'undefined'
|
||||
) {
|
||||
if (!this.licenseCheckService.hasFeature(ws.licenseKey, 'security:settings', ws.plan)) {
|
||||
if (!this.licenseCheckService.hasFeature(ws.licenseKey, Feature.SECURITY_SETTINGS, ws.plan)) {
|
||||
throw new ForbiddenException(
|
||||
'This feature requires a valid license',
|
||||
);
|
||||
@@ -667,11 +670,15 @@ export class WorkspaceService {
|
||||
}
|
||||
}
|
||||
|
||||
await this.userRepo.updateUser(
|
||||
{ deactivatedAt: new Date() },
|
||||
userId,
|
||||
workspaceId,
|
||||
);
|
||||
await executeTx(this.db, async (trx) => {
|
||||
await this.userRepo.updateUser(
|
||||
{ deactivatedAt: new Date() },
|
||||
userId,
|
||||
workspaceId,
|
||||
trx,
|
||||
);
|
||||
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
event: AuditEvent.USER_DEACTIVATED,
|
||||
@@ -785,6 +792,8 @@ export class WorkspaceService {
|
||||
await this.watcherRepo.deleteByUserAndWorkspace(userId, workspaceId, {
|
||||
trx,
|
||||
});
|
||||
|
||||
await this.userSessionRepo.revokeByUserId(userId, workspaceId, trx);
|
||||
});
|
||||
|
||||
this.auditService.log({
|
||||
|
||||
@@ -17,6 +17,7 @@ import { KyselyDB } from '@docmost/db/types/kysely.types';
|
||||
import * as process from 'node:process';
|
||||
import { MigrationService } from '@docmost/db/services/migration.service';
|
||||
import { UserTokenRepo } from './repos/user-token/user-token.repo';
|
||||
import { UserSessionRepo } from '@docmost/db/repos/session/user-session.repo';
|
||||
import { BacklinkRepo } from '@docmost/db/repos/backlink/backlink.repo';
|
||||
import { ShareRepo } from '@docmost/db/repos/share/share.repo';
|
||||
import { NotificationRepo } from '@docmost/db/repos/notification/notification.repo';
|
||||
@@ -76,6 +77,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
CommentRepo,
|
||||
AttachmentRepo,
|
||||
UserTokenRepo,
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
@@ -95,6 +97,7 @@ import { normalizePostgresUrl } from '../common/helpers';
|
||||
CommentRepo,
|
||||
AttachmentRepo,
|
||||
UserTokenRepo,
|
||||
UserSessionRepo,
|
||||
BacklinkRepo,
|
||||
ShareRepo,
|
||||
NotificationRepo,
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { Kysely, sql } from 'kysely';
|
||||
|
||||
export async function up(db: Kysely<any>): Promise<void> {
|
||||
await db.schema
|
||||
.createTable('user_sessions')
|
||||
.addColumn('id', 'uuid', (col) =>
|
||||
col.primaryKey().defaultTo(sql`gen_uuid_v7()`),
|
||||
)
|
||||
.addColumn('user_id', 'uuid', (col) =>
|
||||
col.notNull().references('users.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('workspace_id', 'uuid', (col) =>
|
||||
col.notNull().references('workspaces.id').onDelete('cascade'),
|
||||
)
|
||||
.addColumn('device_name', 'varchar')
|
||||
.addColumn('user_agent', 'text')
|
||||
.addColumn('ip_address', sql`inet`)
|
||||
.addColumn('geo_location', 'varchar')
|
||||
.addColumn('last_active_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.addColumn('expires_at', 'timestamptz', (col) => col.notNull())
|
||||
.addColumn('metadata', 'jsonb')
|
||||
.addColumn('revoked_at', 'timestamptz')
|
||||
.addColumn('created_at', 'timestamptz', (col) =>
|
||||
col.notNull().defaultTo(sql`now()`),
|
||||
)
|
||||
.execute();
|
||||
|
||||
await sql`
|
||||
CREATE INDEX idx_user_sessions_active
|
||||
ON user_sessions (user_id, workspace_id, last_active_at DESC)
|
||||
WHERE revoked_at IS NULL
|
||||
`.execute(db);
|
||||
|
||||
await sql`
|
||||
CREATE INDEX idx_user_sessions_revoked
|
||||
ON user_sessions (expires_at)
|
||||
WHERE revoked_at IS NOT NULL
|
||||
`.execute(db);
|
||||
}
|
||||
|
||||
export async function down(db: Kysely<any>): Promise<void> {
|
||||
await db.schema.dropTable('user_sessions').execute();
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import {
|
||||
InsertableUserSession,
|
||||
UserSession,
|
||||
} 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';
|
||||
import { sql } from 'kysely';
|
||||
|
||||
@Injectable()
|
||||
export class UserSessionRepo {
|
||||
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
||||
|
||||
async insertSession(
|
||||
session: InsertableUserSession,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<UserSession> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.insertInto('userSessions')
|
||||
.values(session)
|
||||
.returningAll()
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
async findActiveById(id: string): Promise<UserSession | undefined> {
|
||||
return this.db
|
||||
.selectFrom('userSessions')
|
||||
.selectAll()
|
||||
.where('id', '=', id)
|
||||
.where('expiresAt', '>', new Date())
|
||||
.where('revokedAt', 'is', null)
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async findActiveByUser(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<UserSession[]> {
|
||||
return this.db
|
||||
.selectFrom('userSessions')
|
||||
.selectAll()
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('expiresAt', '>', new Date())
|
||||
.where('revokedAt', 'is', null)
|
||||
.orderBy('lastActiveAt', 'desc')
|
||||
.execute();
|
||||
}
|
||||
|
||||
async updateLastActiveAt(id: string): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('userSessions')
|
||||
.set({ lastActiveAt: new Date() })
|
||||
.where('id', '=', id)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async revokeById(
|
||||
id: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('userSessions')
|
||||
.set({ revokedAt: new Date() })
|
||||
.where('id', '=', id)
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('revokedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async revokeAllExceptCurrent(
|
||||
currentSessionId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.updateTable('userSessions')
|
||||
.set({ revokedAt: new Date() })
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('id', '!=', currentSessionId)
|
||||
.where('revokedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async revokeByUserId(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
trx?: KyselyTransaction,
|
||||
): Promise<void> {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
await db
|
||||
.updateTable('userSessions')
|
||||
.set({ revokedAt: new Date() })
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('revokedAt', 'is', null)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteByUserId(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('userSessions')
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteAllExceptCurrent(
|
||||
currentSessionId: string,
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
): Promise<void> {
|
||||
await this.db
|
||||
.deleteFrom('userSessions')
|
||||
.where('userId', '=', userId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.where('id', '!=', currentSessionId)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async deleteStale(retentionDays: number): Promise<void> {
|
||||
const cutoff = new Date(Date.now() - retentionDays * 24 * 60 * 60 * 1000);
|
||||
await this.db
|
||||
.deleteFrom('userSessions')
|
||||
.where((eb) =>
|
||||
eb.or([
|
||||
eb('revokedAt', '<', cutoff),
|
||||
eb('expiresAt', '<', cutoff),
|
||||
]),
|
||||
)
|
||||
.execute();
|
||||
}
|
||||
|
||||
async trimExcessSessions(maxPerUser: number): Promise<void> {
|
||||
const overflowed = await this.db
|
||||
.selectFrom('userSessions')
|
||||
.select(['userId', 'workspaceId'])
|
||||
.groupBy(['userId', 'workspaceId'])
|
||||
.having(sql`COUNT(*)`, '>', maxPerUser)
|
||||
.execute();
|
||||
|
||||
for (const { userId, workspaceId } of overflowed) {
|
||||
await sql`
|
||||
DELETE FROM user_sessions
|
||||
WHERE id IN (
|
||||
SELECT id FROM user_sessions
|
||||
WHERE user_id = ${userId} AND workspace_id = ${workspaceId}
|
||||
ORDER BY last_active_at DESC
|
||||
OFFSET ${maxPerUser}
|
||||
)
|
||||
`.execute(this.db);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -111,6 +111,28 @@ export class SpaceRepo {
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async updateCommentSettings(
|
||||
spaceId: string,
|
||||
workspaceId: string,
|
||||
prefKey: string,
|
||||
prefValue: string | boolean,
|
||||
trx?: KyselyTransaction,
|
||||
) {
|
||||
const db = dbOrTx(this.db, trx);
|
||||
return db
|
||||
.updateTable('spaces')
|
||||
.set({
|
||||
settings: sql`COALESCE(settings, '{}'::jsonb)
|
||||
|| jsonb_build_object('comments', COALESCE(settings->'comments', '{}'::jsonb)
|
||||
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where('id', '=', spaceId)
|
||||
.where('workspaceId', '=', workspaceId)
|
||||
.returningAll()
|
||||
.executeTakeFirst();
|
||||
}
|
||||
|
||||
async insertSpace(
|
||||
insertableSpace: InsertableSpace,
|
||||
trx?: KyselyTransaction,
|
||||
|
||||
+16
@@ -429,6 +429,21 @@ export interface PagePermissions {
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface UserSessions {
|
||||
id: Generated<string>;
|
||||
userId: string;
|
||||
workspaceId: string;
|
||||
deviceName: string | null;
|
||||
userAgent: string | null;
|
||||
ipAddress: string | null;
|
||||
geoLocation: string | null;
|
||||
metadata: Json | null;
|
||||
lastActiveAt: Generated<Timestamp>;
|
||||
expiresAt: Timestamp;
|
||||
revokedAt: Timestamp | null;
|
||||
createdAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
apiKeys: ApiKeys;
|
||||
attachments: Attachments;
|
||||
@@ -451,6 +466,7 @@ export interface DB {
|
||||
spaces: Spaces;
|
||||
userMfa: UserMfa;
|
||||
users: Users;
|
||||
userSessions: UserSessions;
|
||||
userTokens: UserTokens;
|
||||
watchers: Watchers;
|
||||
workspaceInvitations: WorkspaceInvitations;
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Shares,
|
||||
FileTasks,
|
||||
UserMfa as _UserMFA,
|
||||
UserSessions,
|
||||
ApiKeys,
|
||||
Watchers,
|
||||
Audit as _Audit,
|
||||
@@ -157,6 +158,11 @@ export type PagePermission = Selectable<_PagePermissions>;
|
||||
export type InsertablePagePermission = Insertable<_PagePermissions>;
|
||||
export type UpdatablePagePermission = Updateable<Omit<_PagePermissions, 'id'>>;
|
||||
|
||||
// User Session
|
||||
export type UserSession = Selectable<UserSessions>;
|
||||
export type InsertableUserSession = Insertable<UserSessions>;
|
||||
export type UpdatableUserSession = Updateable<Omit<UserSessions, 'id'>>;
|
||||
|
||||
// Audit
|
||||
export type Audit = Selectable<_Audit>;
|
||||
export type InsertableAudit = Insertable<_Audit>;
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: e7a6b77b7b...c70a29cb25
@@ -71,7 +71,10 @@ export class StaticModule implements OnModuleInit {
|
||||
|
||||
app.get(RENDER_PATH, (req: any, res: any) => {
|
||||
const stream = fs.createReadStream(indexFilePath);
|
||||
res.type('text/html').send(stream);
|
||||
res
|
||||
.header('Cache-Control', 'no-cache, no-store, must-revalidate')
|
||||
.type('text/html')
|
||||
.send(stream);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+7
-4
@@ -557,6 +557,9 @@ importers:
|
||||
bcrypt:
|
||||
specifier: ^6.0.0
|
||||
version: 6.0.0
|
||||
bowser:
|
||||
specifier: ^2.14.1
|
||||
version: 2.14.1
|
||||
bullmq:
|
||||
specifier: ^5.71.0
|
||||
version: 5.71.0
|
||||
@@ -5845,8 +5848,8 @@ packages:
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
bowser@2.11.0:
|
||||
resolution: {integrity: sha512-AlcaJBi/pqqJBIQ8U9Mcpc9i8Aqxn88Skv5d+xBX006BY5u8N3mGLHa5Lgppa7L/HfwgwLgZ6NYs+Ag6uUmJRA==}
|
||||
bowser@2.14.1:
|
||||
resolution: {integrity: sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==}
|
||||
|
||||
boxen@5.1.2:
|
||||
resolution: {integrity: sha512-9gYgQKXx+1nP8mP7CzFyaUARhg7D3n1dF/FnErWmu9l6JvGpNUN278h0aSb+QjoiKSWG+iZ3uHrcqk0qrY9RQQ==}
|
||||
@@ -11382,7 +11385,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@aws-sdk/types': 3.973.6
|
||||
'@smithy/types': 4.13.1
|
||||
bowser: 2.11.0
|
||||
bowser: 2.14.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/util-user-agent-node@3.973.10':
|
||||
@@ -16626,7 +16629,7 @@ snapshots:
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
bowser@2.11.0: {}
|
||||
bowser@2.14.1: {}
|
||||
|
||||
boxen@5.1.2:
|
||||
dependencies:
|
||||
|
||||
Reference in New Issue
Block a user