mirror of
https://github.com/outline/outline.git
synced 2026-06-13 11:25:03 +03:00
Compare commits
66 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cdd08bbf58 | |||
| bda95e4952 | |||
| 394c6e3b03 | |||
| 9113501906 | |||
| 92168c3641 | |||
| 5ea63aa1a2 | |||
| b1bf7c488b | |||
| 9811ab6aea | |||
| f0899f614b | |||
| c65b020655 | |||
| 9791ff1170 | |||
| a25f334bb1 | |||
| e1b2993bca | |||
| b3d4563730 | |||
| 7106263f88 | |||
| 3c2e9a9723 | |||
| bd01a62fc1 | |||
| 95106e695f | |||
| 39623b90bd | |||
| a3fcd71582 | |||
| a2f9962958 | |||
| 709184ae0b | |||
| bc5ffb79b2 | |||
| d703f8acf3 | |||
| 1c23cbec1b | |||
| 1caeafaeed | |||
| 969a7bb97d | |||
| 053693b9d5 | |||
| 7938ffdd7a | |||
| 9b8acf3efb | |||
| ac6b680cdb | |||
| 27c633eb8b | |||
| ca36451e42 | |||
| 0d198294eb | |||
| bc63aba1d1 | |||
| ea665b80ee | |||
| 492af6683b | |||
| f4b80d5301 | |||
| 58f0613b5f | |||
| c2edd41e87 | |||
| 62c0f15c06 | |||
| c75815b90c | |||
| 33e3680782 | |||
| b23a39bd39 | |||
| f329b56d0e | |||
| be3f28afea | |||
| 997d38a6ac | |||
| b5465376ae | |||
| 9ec6b8309d | |||
| aad2483ff9 | |||
| 0c0facc2a1 | |||
| e864684d56 | |||
| 88de417a21 | |||
| 985038525c | |||
| c808bed712 | |||
| bfddf4bb4c | |||
| 1cc10f5fff | |||
| ce3d710888 | |||
| 2aff3907c5 | |||
| 02c5c93bd8 | |||
| e9e565dc2b | |||
| 4126b94f7c | |||
| a21ddc4999 | |||
| 0d50f0d60a | |||
| c419c3ab63 | |||
| 8464d99589 |
@@ -140,6 +140,11 @@ FORCE_HTTPS=true
|
||||
# and "X-Client-IP".
|
||||
# PROXY_IP_HEADER=
|
||||
|
||||
# Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
|
||||
# X-Forwarded-Proto) set by an upstream proxy. Set to false if not
|
||||
# running behind a proxy in production.
|
||||
# PROXY_HEADERS_TRUSTED=true
|
||||
|
||||
|
||||
# ––––––––––––––––––––––––––––––––––––––
|
||||
# –––––––––– AUTHENTICATION ––––––––––
|
||||
|
||||
@@ -43,7 +43,7 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
- name: Compress Images
|
||||
id: calibre
|
||||
uses: calibreapp/image-actions@main
|
||||
uses: calibreapp/image-actions@3d5873ac3e7bf1a38b24d9778d8dc639d5706d8b # main
|
||||
with:
|
||||
githubToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
# For non-Pull Requests, run in compressOnly mode and we'll PR after.
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
if: |
|
||||
github.event_name != 'pull_request' &&
|
||||
steps.calibre.outputs.markdown != ''
|
||||
uses: peter-evans/create-pull-request@v3
|
||||
uses: peter-evans/create-pull-request@18f7dc018cc2cd597073088f7c7591b9d1c02672 # v3
|
||||
with:
|
||||
title: "chore: Auto Compress Images"
|
||||
branch-suffix: timestamp
|
||||
|
||||
@@ -27,7 +27,7 @@ jobs:
|
||||
deps: ${{ steps.filter.outputs.deps }}
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: dorny/paths-filter@v2
|
||||
- uses: dorny/paths-filter@4512585405083f25c027a35db413c2b3b9006d50 # v2
|
||||
id: filter
|
||||
with:
|
||||
filters: |
|
||||
@@ -126,7 +126,7 @@ jobs:
|
||||
run: echo "NODE_ENV=production" >> $GITHUB_ENV
|
||||
- run: yarn vite:build
|
||||
- name: Send bundle stats to RelativeCI
|
||||
uses: relative-ci/agent-action@v2
|
||||
uses: relative-ci/agent-action@38328454d6a23942175eba485fca4fbb807b1f03 # v2
|
||||
with:
|
||||
key: ${{ secrets.RELATIVE_CI_KEY }}
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
name: Docker Build Check
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- "Dockerfile.base"
|
||||
pull_request:
|
||||
paths:
|
||||
- "Dockerfile"
|
||||
- "Dockerfile.base"
|
||||
|
||||
env:
|
||||
BASE_IMAGE_NAME: outline-base
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubicloud-standard-8
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
with:
|
||||
driver: docker
|
||||
|
||||
- name: Build base image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}:latest
|
||||
push: false
|
||||
|
||||
- name: Build main image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
push: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}:latest
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
IMAGE_NAME: outlinewiki/outline
|
||||
@@ -11,13 +12,13 @@ env:
|
||||
|
||||
jobs:
|
||||
build-arm:
|
||||
runs-on: ubicloud-standard-8-arm
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404-arm
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
@@ -37,7 +38,7 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: docker/build-push-action@v7
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
@@ -45,8 +46,6 @@ jobs:
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
|
||||
- name: Docker meta
|
||||
@@ -61,7 +60,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -69,8 +68,6 @@ jobs:
|
||||
tags: ${{ env.IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/arm64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
|
||||
@@ -90,13 +87,13 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
build-amd:
|
||||
runs-on: ubicloud-standard-8
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
|
||||
- name: Docker base meta
|
||||
id: base_meta
|
||||
@@ -116,7 +113,7 @@ jobs:
|
||||
|
||||
- name: Build and push base image
|
||||
id: base_build
|
||||
uses: docker/build-push-action@v7
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.base
|
||||
@@ -124,8 +121,6 @@ jobs:
|
||||
tags: ${{ env.BASE_IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
|
||||
- name: Docker meta
|
||||
@@ -140,7 +135,7 @@ jobs:
|
||||
|
||||
- name: Build and push
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
uses: useblacksmith/build-push-action@fb9e3e6a9299c78462bfadd0d93352c316adc9b8 # v2
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
@@ -148,8 +143,6 @@ jobs:
|
||||
tags: ${{ env.IMAGE_NAME }}
|
||||
outputs: type=image,push-by-digest=true,name-canonical=true,push=true
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
pull: false
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.BASE_IMAGE_NAME }}@${{ steps.base_build.outputs.digest }}
|
||||
@@ -169,7 +162,7 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
runs-on: ubicloud-standard-8
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
needs:
|
||||
- build-amd
|
||||
- build-arm
|
||||
@@ -188,8 +181,8 @@ jobs:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
- name: Setup Blacksmith Builder
|
||||
uses: useblacksmith/setup-docker-builder@ab5c1da94f53f5cd75c1038092aa276dddfccbba # v1
|
||||
|
||||
- name: Docker meta
|
||||
id: meta
|
||||
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Create pull request
|
||||
if: steps.check.outputs.updated == 'true'
|
||||
uses: peter-evans/create-pull-request@v7
|
||||
uses: peter-evans/create-pull-request@22a9089034f40e5a961c8808d113e2c98fb63676 # v7
|
||||
with:
|
||||
commit-message: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
|
||||
title: "fix: Update Node.js to ${{ steps.check.outputs.latest }}"
|
||||
|
||||
@@ -73,6 +73,23 @@
|
||||
"eqeqeq": "error",
|
||||
"curly": "error",
|
||||
"no-console": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"paths": [
|
||||
{
|
||||
"name": "prosemirror-tables",
|
||||
"importNames": [
|
||||
"addRowBefore",
|
||||
"addRowAfter",
|
||||
"addColumnBefore",
|
||||
"addColumnAfter"
|
||||
],
|
||||
"message": "Use the wrappers from shared/editor/commands/table instead, which respect the target index and place the cursor in the inserted cell."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-unused-expressions": "error",
|
||||
"arrow-body-style": ["error", "as-needed"],
|
||||
"react/react-in-jsx-scope": "off",
|
||||
@@ -93,6 +110,7 @@
|
||||
"typescript/consistent-type-imports": "error",
|
||||
"typescript/restrict-template-expressions": "error",
|
||||
"typescript/no-floating-promises": "error",
|
||||
"typescript/no-useless-default-assignment": "error",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
|
||||
@@ -3,7 +3,7 @@ Business Source License 1.1
|
||||
Parameters
|
||||
|
||||
Licensor: General Outline, Inc.
|
||||
Licensed Work: Outline 1.8.0
|
||||
Licensed Work: Outline 1.8.1
|
||||
The Licensed Work is (c) 2026 General Outline, Inc.
|
||||
Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
you may not use the Licensed Work for a Document
|
||||
@@ -15,7 +15,7 @@ Additional Use Grant: You may make use of the Licensed Work, provided that
|
||||
Licensed Work by creating teams and documents
|
||||
controlled by such third parties.
|
||||
|
||||
Change Date: 2030-06-01
|
||||
Change Date: 2030-06-06
|
||||
|
||||
Change License: Apache License, Version 2.0
|
||||
|
||||
|
||||
@@ -240,6 +240,26 @@ function findDocumentSiblingIndex(
|
||||
return siblings?.findIndex((node) => node.id === document.id) ?? -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether the user can create a sibling of the given document.
|
||||
* A sibling shares the document's parent, so this mirrors the backend's
|
||||
* create authorization: create permission on the parent document, or on the
|
||||
* collection when the document is at the root.
|
||||
*
|
||||
* @param stores - the root stores.
|
||||
* @param document - the document to create a sibling of.
|
||||
* @returns true if the user can create a sibling.
|
||||
*/
|
||||
function canCreateSiblingDocument(
|
||||
stores: ActionContext["stores"],
|
||||
document: { collectionId?: string | null; parentDocumentId?: string }
|
||||
): boolean {
|
||||
return document.parentDocumentId
|
||||
? stores.policies.abilities(document.parentDocumentId).createChildDocument
|
||||
: !!document.collectionId &&
|
||||
stores.policies.abilities(document.collectionId).createDocument;
|
||||
}
|
||||
|
||||
export const createNestedDocument = createInternalLinkAction({
|
||||
name: ({ t }) => t("Nested document"),
|
||||
analyticsName: "New document",
|
||||
@@ -279,7 +299,7 @@ const createDocumentBefore = createInternalLinkAction({
|
||||
if (collection?.sort.field === "title") {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
return canCreateSiblingDocument(stores, document);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
@@ -321,7 +341,7 @@ const createDocumentAfter = createInternalLinkAction({
|
||||
if (collection?.sort.field === "title") {
|
||||
return false;
|
||||
}
|
||||
return stores.policies.abilities(currentTeamId).createDocument;
|
||||
return canCreateSiblingDocument(stores, document);
|
||||
},
|
||||
to: ({ activeDocumentId, stores, sidebarContext }) => {
|
||||
const document = activeDocumentId
|
||||
|
||||
@@ -13,6 +13,10 @@ ActiveCollectionSection.priority = 0.8;
|
||||
|
||||
export const DeveloperSection = ({ t }: ActionContext) => t("Debug");
|
||||
|
||||
export const DateSection = ({ t }: ActionContext) => t("Date");
|
||||
|
||||
DateSection.priority = 1;
|
||||
|
||||
export const DocumentSection = ({ t }: ActionContext) => t("Document");
|
||||
|
||||
export const SearchResultsSection = ({ t }: ActionContext) =>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { EditorAwareHTML5Backend } from "~/components/EditorAwareHTML5Backend";
|
||||
import ErrorSuspended from "~/scenes/Errors/ErrorSuspended";
|
||||
import Layout from "~/components/Layout";
|
||||
import RegisterKeyDown from "~/components/RegisterKeyDown";
|
||||
@@ -104,14 +106,16 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => {
|
||||
<DocumentContextProvider>
|
||||
<RightSidebarProvider>
|
||||
<PortalContext.Provider value={layoutRef.current}>
|
||||
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
<DndProvider backend={EditorAwareHTML5Backend}>
|
||||
<Layout title={team.name} sidebar={sidebar} ref={layoutRef}>
|
||||
<RegisterKeyDown trigger="n" handler={goToNewDocument} />
|
||||
<RegisterKeyDown trigger="t" handler={goToSearch} />
|
||||
<RegisterKeyDown trigger="/" handler={goToSearch} />
|
||||
{children}
|
||||
<CommandBar />
|
||||
<NotificationBadge />
|
||||
</Layout>
|
||||
</DndProvider>
|
||||
</PortalContext.Provider>
|
||||
</RightSidebarProvider>
|
||||
</DocumentContextProvider>
|
||||
|
||||
@@ -79,10 +79,10 @@ function Collaborators(props: Props) {
|
||||
// Memoize ids to avoid unnecessary effect executions
|
||||
const missingUserIds = useMemo(
|
||||
() =>
|
||||
uniq([...document.collaboratorIds, ...Array.from(presentIds)])
|
||||
uniq([...collaboratorIdsSet, ...presentIds])
|
||||
.filter((userId) => !users.get(userId))
|
||||
.sort(),
|
||||
[document.collaboratorIds, presentIds, users]
|
||||
[collaboratorIdsSet, presentIds, users]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -15,7 +15,7 @@ export default function DesktopEventHandler() {
|
||||
const hasDisabledUpdateMessage = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
Desktop.bridge?.redirect((path: string, replace = false) => {
|
||||
Desktop.bridge?.redirect((path: string, replace: boolean) => {
|
||||
if (replace) {
|
||||
history.replace(path);
|
||||
} else {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { mergeRefs } from "react-merge-refs";
|
||||
import { Link } from "react-router-dom";
|
||||
import { DocumentIcon } from "outline-icons";
|
||||
import styled, { css, useTheme } from "styled-components";
|
||||
@@ -27,6 +28,7 @@ import { useLocationSidebarContext } from "~/hooks/useLocationSidebarContext";
|
||||
import DocumentMenu from "~/menus/DocumentMenu";
|
||||
import { documentPath } from "~/utils/routeHelpers";
|
||||
import { determineSidebarContext } from "./Sidebar/components/SidebarContext";
|
||||
import { useDragDocument } from "./Sidebar/hooks/useDragAndDrop";
|
||||
import { ActionContextProvider } from "~/hooks/useActionContext";
|
||||
import { useDocumentMenuAction } from "~/hooks/useDocumentMenuAction";
|
||||
import { ContextMenu } from "./Menu/ContextMenu";
|
||||
@@ -98,6 +100,23 @@ function DocumentListItem(
|
||||
|
||||
const contextMenuAction = useDocumentMenuAction({ documentId: document.id });
|
||||
|
||||
const [{ isDragging }, draggableRef] = useDragDocument(
|
||||
document.asNavigationNode,
|
||||
0,
|
||||
document,
|
||||
false,
|
||||
false
|
||||
);
|
||||
|
||||
const mergedRef = React.useMemo(
|
||||
() =>
|
||||
mergeRefs<HTMLAnchorElement>([
|
||||
itemRef,
|
||||
draggableRef,
|
||||
] as React.Ref<HTMLAnchorElement>[]),
|
||||
[itemRef, draggableRef]
|
||||
);
|
||||
|
||||
return (
|
||||
<ActionContextProvider
|
||||
value={{
|
||||
@@ -114,9 +133,10 @@ function DocumentListItem(
|
||||
onClose={handleMenuClose}
|
||||
>
|
||||
<DocumentLink
|
||||
ref={itemRef}
|
||||
ref={mergedRef}
|
||||
dir={document.dir}
|
||||
$isStarred={document.isStarred}
|
||||
$isDragging={isDragging}
|
||||
$menuOpen={menuOpen}
|
||||
to={{
|
||||
pathname: documentPath(document),
|
||||
@@ -227,6 +247,7 @@ const Actions = styled(EventBoundary)`
|
||||
|
||||
const DocumentLink = styled(Link)<{
|
||||
$isStarred?: boolean;
|
||||
$isDragging?: boolean;
|
||||
$menuOpen?: boolean;
|
||||
}>`
|
||||
display: flex;
|
||||
@@ -237,6 +258,8 @@ const DocumentLink = styled(Link)<{
|
||||
max-height: 50vh;
|
||||
width: calc(100vw - 8px);
|
||||
cursor: var(--pointer);
|
||||
transition: opacity 250ms ease;
|
||||
opacity: ${(props) => (props.$isDragging ? 0.1 : 1)};
|
||||
|
||||
&:focus-visible {
|
||||
outline: none;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { BackendFactory } from "dnd-core";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
|
||||
/**
|
||||
* react-dnd's HTML5 backend installs global capture-phase listeners on `window`
|
||||
* that call `preventDefault()` on drops whose dataTransfer resembles a native
|
||||
* item – including a dragged `<img>`, which is how ProseMirror serializes an
|
||||
* image drag.
|
||||
*
|
||||
* These handlers run before ProseMirror's, and they live on `window`, so a
|
||||
* propagation-based guard can't stop react-dnd without also starving the editor
|
||||
* of the event. Instead we wrap the backend and make its top-level capture
|
||||
* handlers no-op for events that occur within the editor surface.
|
||||
*/
|
||||
const captureHandlerNames = [
|
||||
"handleTopDragStartCapture",
|
||||
"handleTopDragEnterCapture",
|
||||
"handleTopDragOverCapture",
|
||||
"handleTopDragLeaveCapture",
|
||||
"handleTopDropCapture",
|
||||
"handleTopDragEndCapture",
|
||||
] as const;
|
||||
|
||||
const isWithinEditor = (target: EventTarget | null): boolean =>
|
||||
target instanceof Element && Boolean(target.closest(".ProseMirror"));
|
||||
|
||||
/**
|
||||
* An HTML5 drag-and-drop backend that ignores drag events originating within the
|
||||
* rich text editor so that ProseMirror can handle them itself.
|
||||
*
|
||||
* @param manager The drag-and-drop manager.
|
||||
* @param context The global context.
|
||||
* @param options Backend options.
|
||||
* @returns The wrapped HTML5 backend instance.
|
||||
*/
|
||||
export const EditorAwareHTML5Backend: BackendFactory = (
|
||||
manager,
|
||||
context,
|
||||
options
|
||||
) => {
|
||||
const backend = HTML5Backend(manager, context, options);
|
||||
|
||||
// The capture handlers are private instance fields on the backend, so reach
|
||||
// for them through an index signature view of the instance.
|
||||
const handlers = backend as unknown as Record<
|
||||
string,
|
||||
(event: DragEvent) => void
|
||||
>;
|
||||
|
||||
for (const name of captureHandlerNames) {
|
||||
const original = handlers[name];
|
||||
if (typeof original === "function") {
|
||||
handlers[name] = (event: DragEvent) => {
|
||||
if (isWithinEditor(event.target)) {
|
||||
return;
|
||||
}
|
||||
original.call(backend, event);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return backend;
|
||||
};
|
||||
@@ -1,16 +1,26 @@
|
||||
import { deburr } from "es-toolkit/compat";
|
||||
import { CheckmarkIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type { FetchPageParams } from "~/stores/base/Store";
|
||||
import Button, { Inner } from "~/components/Button";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import Text from "~/components/Text";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import Input, { NativeInput, Outline } from "./Input";
|
||||
import type { PaginatedItem } from "./PaginatedList";
|
||||
import PaginatedList from "./PaginatedList";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerTrigger,
|
||||
} from "./primitives/Drawer";
|
||||
import { MenuProvider } from "./primitives/Menu/MenuContext";
|
||||
import { Menu, MenuContent, MenuTrigger, MenuButton } from "./primitives/Menu";
|
||||
import * as MenuComponents from "./primitives/components/Menu";
|
||||
import { MenuIconWrapper } from "./primitives/components/Menu";
|
||||
|
||||
interface TFilterOption extends PaginatedItem {
|
||||
@@ -34,7 +44,7 @@ type Props = {
|
||||
|
||||
const FilterOptions = ({
|
||||
options,
|
||||
selectedKeys = [],
|
||||
selectedKeys,
|
||||
className,
|
||||
onSelect,
|
||||
showFilter,
|
||||
@@ -45,6 +55,7 @@ const FilterOptions = ({
|
||||
...rest
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useMobile();
|
||||
const searchInputRef = React.useRef<HTMLInputElement>(null);
|
||||
const listRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
@@ -58,23 +69,45 @@ const FilterOptions = ({
|
||||
: "";
|
||||
|
||||
const renderItem = React.useCallback(
|
||||
(option) => (
|
||||
<MenuButton
|
||||
key={option.key}
|
||||
icon={
|
||||
option.icon && showIcons ? (
|
||||
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
|
||||
) : undefined
|
||||
}
|
||||
label={option.label}
|
||||
onClick={() => {
|
||||
onSelect(option.key);
|
||||
setOpen(false);
|
||||
}}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
/>
|
||||
),
|
||||
[onSelect, showIcons, selectedKeys]
|
||||
(option) => {
|
||||
const handleClick = () => {
|
||||
onSelect(option.key);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
const icon =
|
||||
option.icon && showIcons ? (
|
||||
<MenuIconWrapper aria-hidden>{option.icon}</MenuIconWrapper>
|
||||
) : undefined;
|
||||
|
||||
// On mobile the options render inside a Drawer (bottom sheet) rather than
|
||||
// a Radix dropdown menu, so use the raw menu components directly instead
|
||||
// of the dropdown-bound MenuButton which expects a menu root context.
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MenuComponents.MenuButton key={option.key} onClick={handleClick}>
|
||||
{icon}
|
||||
<MenuComponents.MenuLabel>{option.label}</MenuComponents.MenuLabel>
|
||||
<MenuComponents.SelectedIconWrapper aria-hidden>
|
||||
{selectedKeys.includes(option.key) ? (
|
||||
<CheckmarkIcon size={18} />
|
||||
) : null}
|
||||
</MenuComponents.SelectedIconWrapper>
|
||||
</MenuComponents.MenuButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
key={option.key}
|
||||
icon={icon}
|
||||
label={option.label}
|
||||
onClick={handleClick}
|
||||
selected={selectedKeys.includes(option.key)}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[onSelect, showIcons, selectedKeys, isMobile]
|
||||
);
|
||||
|
||||
const handleFilter = React.useCallback(
|
||||
@@ -169,39 +202,73 @@ const FilterOptions = ({
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
searchInputRef.current?.focus();
|
||||
// Avoid auto-focusing on mobile as it immediately pops the on-screen
|
||||
// keyboard over the drawer.
|
||||
if (!isMobile) {
|
||||
searchInputRef.current?.focus();
|
||||
}
|
||||
} else {
|
||||
setQuery("");
|
||||
}
|
||||
}, [open]);
|
||||
}, [open, isMobile]);
|
||||
|
||||
const showFilterInput = showFilter || options.length > 10;
|
||||
const defaultLabel = rest.defaultLabel || t("Filter options");
|
||||
|
||||
const trigger = (
|
||||
<StyledButton
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
disclosure={disclosure}
|
||||
neutral
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
);
|
||||
|
||||
const list = (
|
||||
<PaginatedList<TFilterOption>
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
fetch={fetchQuery}
|
||||
renderItem={renderItem}
|
||||
onEscape={handleEscapeFromList}
|
||||
heading={showFilterInput && !isMobile ? <Spacer /> : undefined}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
);
|
||||
|
||||
// On mobile render the options inside a Drawer (bottom sheet) to match the
|
||||
// popover style used by context menus across the app.
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent aria-label={defaultLabel} aria-describedby={undefined}>
|
||||
<DrawerTitle>{defaultLabel}</DrawerTitle>
|
||||
{showFilterInput && (
|
||||
<MobileSearchInput
|
||||
ref={searchInputRef}
|
||||
value={query}
|
||||
onChange={handleFilter}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={`${t("Filter")}…`}
|
||||
margin={0}
|
||||
/>
|
||||
)}
|
||||
<StyledScrollable hiddenScrollbars>{list}</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuProvider variant="dropdown">
|
||||
<Menu open={open} onOpenChange={setOpen}>
|
||||
<MenuTrigger>
|
||||
<StyledButton
|
||||
className={className}
|
||||
icon={selectedItems[0]?.key && selectedItems[0]?.icon}
|
||||
disclosure={disclosure}
|
||||
neutral
|
||||
>
|
||||
{selectedItems.length ? selectedLabel : defaultLabel}
|
||||
</StyledButton>
|
||||
</MenuTrigger>
|
||||
<MenuTrigger>{trigger}</MenuTrigger>
|
||||
<MenuContent aria-label={defaultLabel} align="start">
|
||||
<PaginatedList<TFilterOption>
|
||||
listRef={listRef}
|
||||
options={{ query, ...fetchQueryOptions }}
|
||||
items={filteredOptions}
|
||||
fetch={fetchQuery}
|
||||
renderItem={renderItem}
|
||||
onEscape={handleEscapeFromList}
|
||||
heading={showFilterInput ? <Spacer /> : undefined}
|
||||
empty={<Empty />}
|
||||
/>
|
||||
{list}
|
||||
{showFilterInput && (
|
||||
<SearchInput
|
||||
ref={searchInputRef}
|
||||
@@ -260,6 +327,22 @@ const SearchInput = styled(Input)`
|
||||
}
|
||||
`;
|
||||
|
||||
const MobileSearchInput = styled(Input)`
|
||||
/* "none" keeps an auto basis so the input retains its natural height; a
|
||||
flexible/0% basis would collapse it and overlap the list below. */
|
||||
flex: none;
|
||||
margin: 0 6px 6px;
|
||||
|
||||
${NativeInput} {
|
||||
/* 16px avoids iOS zooming the viewport when the input is focused. */
|
||||
font-size: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
export const StyledButton = styled(Button)`
|
||||
box-shadow: none;
|
||||
text-transform: none;
|
||||
|
||||
@@ -107,7 +107,6 @@ const IconPicker = ({
|
||||
|
||||
const handleIconChange = React.useCallback(
|
||||
(ic: string) => {
|
||||
setOpen(false);
|
||||
const icType = determineIconType(ic);
|
||||
const finalColor = icType === IconType.SVG ? chosenColor : null;
|
||||
onChange(ic, finalColor);
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import breakpoint from "styled-components-breakpoint";
|
||||
import { s } from "@shared/styles";
|
||||
import {
|
||||
isModKey,
|
||||
@@ -117,6 +118,12 @@ function InputSearchPage({
|
||||
|
||||
const InputMaxWidth = styled(Input).attrs({ round: true })`
|
||||
max-width: min(calc(30vw + 20px), 100%);
|
||||
|
||||
/* On mobile the input grows to fill the header, so add a gap before the
|
||||
* adjacent action button (e.g. "New doc"). */
|
||||
${breakpoint("mobile", "tablet")`
|
||||
margin-inline-end: 8px;
|
||||
`}
|
||||
`;
|
||||
|
||||
const Shortcut = styled.span<{ $visible: boolean }>`
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { observer } from "mobx-react";
|
||||
import { SearchIcon, HomeIcon, SidebarIcon } from "outline-icons";
|
||||
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
|
||||
import { DndProvider } from "react-dnd";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { useEffect, useState, useCallback, useRef } from "react";
|
||||
import {
|
||||
DragActiveProvider,
|
||||
SidebarScrollProvider,
|
||||
@@ -62,15 +60,6 @@ function AppSidebar() {
|
||||
}
|
||||
}, [documents, collections, user.isViewer]);
|
||||
|
||||
const [dndArea, setDndArea] = useState();
|
||||
const handleSidebarRef = useCallback((node) => setDndArea(node), []);
|
||||
const html5Options = useMemo(
|
||||
() => ({
|
||||
rootElement: dndArea,
|
||||
}),
|
||||
[dndArea]
|
||||
);
|
||||
|
||||
// Scrollable reads ref.current internally for its shadow/ResizeObserver
|
||||
// logic, so we must pass an object ref — a callback ref would leave those
|
||||
// reads undefined. We mirror the attached node into state so the
|
||||
@@ -82,83 +71,79 @@ function AppSidebar() {
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Sidebar hidden={!ui.readyToShow} ref={handleSidebarRef}>
|
||||
{dndArea && (
|
||||
<DndProvider backend={HTML5Backend} options={html5Options}>
|
||||
<DragActiveProvider>
|
||||
<DragPlaceholder />
|
||||
<Sidebar hidden={!ui.readyToShow}>
|
||||
<DragActiveProvider>
|
||||
<DragPlaceholder />
|
||||
|
||||
<TeamMenu>
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={24} alt={t("Logo")} />}
|
||||
<TeamMenu>
|
||||
<SidebarButton
|
||||
title={team.name}
|
||||
image={<TeamLogo model={team} size={24} alt={t("Logo")} />}
|
||||
>
|
||||
{isMobile ? null : (
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
>
|
||||
{isMobile ? null : (
|
||||
<Tooltip
|
||||
content={t("Toggle sidebar")}
|
||||
shortcut={`${metaDisplay}+.`}
|
||||
>
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
aria-label={
|
||||
ui.sidebarCollapsed
|
||||
? t("Expand sidebar")
|
||||
: t("Collapse sidebar")
|
||||
}
|
||||
style={{ paddingInline: 4 }}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</SidebarButton>
|
||||
</TeamMenu>
|
||||
<Overflow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={homePath()}
|
||||
icon={<HomeIcon />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
<ToggleButton
|
||||
position="bottom"
|
||||
image={<SidebarIcon />}
|
||||
aria-label={
|
||||
ui.sidebarCollapsed
|
||||
? t("Expand sidebar")
|
||||
: t("Collapse sidebar")
|
||||
}
|
||||
style={{ paddingInline: 4 }}
|
||||
onClick={() => {
|
||||
ui.toggleCollapsedSidebar();
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={searchPath()}
|
||||
icon={<SearchIcon />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
onClick={handleSearchClick}
|
||||
/>
|
||||
{can.createDocument && <DraftsLink />}
|
||||
</Tooltip>
|
||||
)}
|
||||
</SidebarButton>
|
||||
</TeamMenu>
|
||||
<Overflow>
|
||||
<Section>
|
||||
<SidebarLink
|
||||
to={homePath()}
|
||||
icon={<HomeIcon />}
|
||||
exact={false}
|
||||
label={t("Home")}
|
||||
/>
|
||||
<SidebarLink
|
||||
to={searchPath()}
|
||||
icon={<SearchIcon />}
|
||||
label={t("Search")}
|
||||
exact={false}
|
||||
onClick={handleSearchClick}
|
||||
/>
|
||||
{can.createDocument && <DraftsLink />}
|
||||
</Section>
|
||||
</Overflow>
|
||||
<Scrollable flex shadow ref={scrollRef}>
|
||||
<SidebarScrollProvider value={scrollArea}>
|
||||
<Section>
|
||||
<Starred />
|
||||
</Section>
|
||||
<Section>
|
||||
<SharedWithMe />
|
||||
</Section>
|
||||
<Section>
|
||||
<Collections />
|
||||
</Section>
|
||||
{can.createDocument && (
|
||||
<Section auto>
|
||||
<ArchiveLink />
|
||||
</Section>
|
||||
</Overflow>
|
||||
<Scrollable flex shadow ref={scrollRef}>
|
||||
<SidebarScrollProvider value={scrollArea}>
|
||||
<Section>
|
||||
<Starred />
|
||||
</Section>
|
||||
<Section>
|
||||
<SharedWithMe />
|
||||
</Section>
|
||||
<Section>
|
||||
<Collections />
|
||||
</Section>
|
||||
{can.createDocument && (
|
||||
<Section auto>
|
||||
<ArchiveLink />
|
||||
</Section>
|
||||
)}
|
||||
<Section>
|
||||
{can.createDocument && <TrashLink />}
|
||||
<SidebarAction action={inviteUser} />
|
||||
</Section>
|
||||
</SidebarScrollProvider>
|
||||
</Scrollable>
|
||||
</DragActiveProvider>
|
||||
</DndProvider>
|
||||
)}
|
||||
)}
|
||||
<Section>
|
||||
{can.createDocument && <TrashLink />}
|
||||
<SidebarAction action={inviteUser} />
|
||||
</Section>
|
||||
</SidebarScrollProvider>
|
||||
</Scrollable>
|
||||
</DragActiveProvider>
|
||||
<HistoryNavigation />
|
||||
</Sidebar>
|
||||
);
|
||||
|
||||
@@ -19,7 +19,8 @@ const layerStyles: React.CSSProperties = {
|
||||
function getItemStyles(
|
||||
initialOffset: XYCoord | null,
|
||||
currentOffset: XYCoord | null,
|
||||
sidebarWidth: number
|
||||
sidebarWidth: number,
|
||||
constrainToSidebar: boolean
|
||||
) {
|
||||
if (!initialOffset || !currentOffset) {
|
||||
return {
|
||||
@@ -27,10 +28,14 @@ function getItemStyles(
|
||||
};
|
||||
}
|
||||
const { y } = currentOffset;
|
||||
const x = Math.max(
|
||||
initialOffset.x,
|
||||
Math.min(initialOffset.x + sidebarWidth / 4, currentOffset.x)
|
||||
);
|
||||
// Sidebar drags keep the ghost tethered near its origin, but drags from
|
||||
// outside the sidebar should follow the cursor freely.
|
||||
const x = constrainToSidebar
|
||||
? Math.max(
|
||||
initialOffset.x,
|
||||
Math.min(initialOffset.x + sidebarWidth / 4, currentOffset.x)
|
||||
)
|
||||
: currentOffset.x;
|
||||
|
||||
const transform = `translate(${x}px, ${y}px)`;
|
||||
return {
|
||||
@@ -60,7 +65,14 @@ const DragPlaceholder = () => {
|
||||
|
||||
return (
|
||||
<div style={layerStyles}>
|
||||
<div style={getItemStyles(initialOffset, currentOffset, ui.sidebarWidth)}>
|
||||
<div
|
||||
style={getItemStyles(
|
||||
initialOffset,
|
||||
currentOffset,
|
||||
ui.sidebarWidth,
|
||||
item.constrainToSidebar !== false
|
||||
)}
|
||||
>
|
||||
<GhostLink
|
||||
icon={item.icon}
|
||||
label={item.title || t("Untitled")}
|
||||
|
||||
@@ -11,7 +11,7 @@ import Desktop from "~/utils/Desktop";
|
||||
import { HStack } from "~/components/primitives/HStack";
|
||||
|
||||
export type SidebarButtonProps = React.ComponentProps<typeof Button> & {
|
||||
position: "top" | "bottom";
|
||||
position?: "top" | "bottom";
|
||||
title: React.ReactNode;
|
||||
image: React.ReactNode;
|
||||
showMoreMenu?: boolean;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { observer } from "mobx-react";
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import styled, { css } from "styled-components";
|
||||
import { s } from "@shared/styles";
|
||||
import type Star from "~/models/Star";
|
||||
import DelayedMount from "~/components/DelayedMount";
|
||||
import Flex from "~/components/Flex";
|
||||
@@ -29,6 +31,7 @@ function Starred() {
|
||||
);
|
||||
const [reorderStarProps, dropToReorder] = useDropToReorderStar();
|
||||
const [createStarProps, dropToStarRef] = useDropToCreateStar();
|
||||
const [sectionStarProps, dropToSectionRef] = useDropToCreateStar();
|
||||
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@@ -42,46 +45,64 @@ function Starred() {
|
||||
|
||||
return (
|
||||
<Flex column>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!loading && !end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
</DelayedMount>
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
</Header>
|
||||
<Section
|
||||
ref={dropToSectionRef}
|
||||
$isActiveDrop={
|
||||
sectionStarProps.isDragging && sectionStarProps.isOverCursor
|
||||
}
|
||||
>
|
||||
<Header id="starred" title={t("Starred")}>
|
||||
<Relative>
|
||||
{reorderStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={reorderStarProps.isOverCursor}
|
||||
innerRef={dropToReorder}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{createStarProps.isDragging && (
|
||||
<DropCursor
|
||||
isActiveDrop={createStarProps.isOverCursor}
|
||||
innerRef={dropToStarRef}
|
||||
position="top"
|
||||
/>
|
||||
)}
|
||||
{stars.orderedData
|
||||
.slice(0, page * STARRED_PAGINATION_LIMIT)
|
||||
.map((star) => (
|
||||
<StarredLink key={star.id} star={star} />
|
||||
))}
|
||||
{!loading && !end && (
|
||||
<SidebarLink
|
||||
onClick={next}
|
||||
label={`${t("Show more")}…`}
|
||||
disabled={stars.isFetching}
|
||||
depth={0}
|
||||
/>
|
||||
)}
|
||||
{loading && (
|
||||
<Flex column>
|
||||
<DelayedMount>
|
||||
<PlaceholderCollections />
|
||||
</DelayedMount>
|
||||
</Flex>
|
||||
)}
|
||||
</Relative>
|
||||
</Header>
|
||||
</Section>
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
const Section = styled.div<{ $isActiveDrop?: boolean }>`
|
||||
border-radius: 8px;
|
||||
transition: background 100ms ease-in-out;
|
||||
|
||||
${(props) =>
|
||||
props.$isActiveDrop &&
|
||||
css`
|
||||
background: ${s("sidebarActiveBackground")};
|
||||
`}
|
||||
`;
|
||||
|
||||
export default observer(Starred);
|
||||
|
||||
@@ -22,6 +22,12 @@ import { useSidebarLabelAndIcon } from "./useSidebarLabelAndIcon";
|
||||
export type DragObject = NavigationNode & {
|
||||
depth: number;
|
||||
collectionId: string;
|
||||
/**
|
||||
* Whether the drag ghost should stay tethered to the sidebar. Defaults to
|
||||
* tethered when unset — the placeholder only lets the ghost follow the
|
||||
* cursor when this is explicitly `false` (e.g. drags from a document list).
|
||||
*/
|
||||
constrainToSidebar?: boolean;
|
||||
};
|
||||
|
||||
function useHover(
|
||||
@@ -105,6 +111,12 @@ export function useDropToCreateStar(getIndex?: () => string) {
|
||||
>({
|
||||
accept,
|
||||
drop: async (item, monitor) => {
|
||||
// A more specific drop target (e.g. a reorder cursor) has already
|
||||
// handled this drop, so avoid creating a duplicate star.
|
||||
if (monitor.didDrop()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const type = monitor.getItemType();
|
||||
let model;
|
||||
|
||||
@@ -122,7 +134,7 @@ export function useDropToCreateStar(getIndex?: () => string) {
|
||||
);
|
||||
},
|
||||
collect: (monitor) => ({
|
||||
isOverCursor: !!monitor.isOver(),
|
||||
isOverCursor: !!monitor.isOver({ shallow: true }),
|
||||
isDragging: accept.includes(String(monitor.getItemType())),
|
||||
}),
|
||||
});
|
||||
@@ -163,12 +175,16 @@ export function useDropToReorderStar(getIndex?: () => string) {
|
||||
* @param depth The depth of the node in the sidebar.
|
||||
* @param document The related Document model.
|
||||
* @param isEditing Whether the sidebar item is currently being edited.
|
||||
* @param constrainToSidebar Whether the drag ghost should stay tethered to the
|
||||
* sidebar. Defaults to true; pass false when dragging from outside the sidebar
|
||||
* (e.g. a document list) so the ghost follows the cursor.
|
||||
*/
|
||||
export function useDragDocument(
|
||||
node: NavigationNode,
|
||||
depth: number,
|
||||
document?: Document,
|
||||
isEditing?: boolean
|
||||
isEditing?: boolean,
|
||||
constrainToSidebar = true
|
||||
) {
|
||||
const icon = document?.icon || node.icon || node.emoji;
|
||||
const color = document?.color || node.color;
|
||||
@@ -188,6 +204,7 @@ export function useDragDocument(
|
||||
<Icon initial={initial} value={icon} color={color} />
|
||||
) : undefined,
|
||||
collectionId: document?.collectionId || "",
|
||||
constrainToSidebar,
|
||||
}) as DragObject,
|
||||
canDrag: () => !!document?.isActive && !isEditing,
|
||||
collect: (monitor) => ({
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import { observer } from "mobx-react";
|
||||
import * as React from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { UserRole } from "@shared/types";
|
||||
import { UserValidation } from "@shared/validations";
|
||||
import type User from "~/models/User";
|
||||
import Button from "~/components/Button";
|
||||
import ConfirmationDialog from "~/components/ConfirmationDialog";
|
||||
import Flex from "~/components/Flex";
|
||||
import Input from "~/components/Input";
|
||||
import useCurrentUser from "~/hooks/useCurrentUser";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import ImageInput from "~/scenes/Settings/components/ImageInput";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import Text from "./Text";
|
||||
|
||||
@@ -142,6 +146,48 @@ export function UserChangeNameDialog({ user, onSubmit }: Props) {
|
||||
);
|
||||
}
|
||||
|
||||
export const UserChangeAvatarDialog = observer(function UserChangeAvatarDialog({
|
||||
user,
|
||||
onSubmit,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleAvatarChange = async (avatarUrl: string | null) => {
|
||||
try {
|
||||
await user.save({ avatarUrl });
|
||||
toast.success(t("Profile picture updated"));
|
||||
} catch (err) {
|
||||
toast.error(err.message);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAvatarError = (error: string | null | undefined) => {
|
||||
toast.error(error || t("Unable to upload new profile picture"));
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex column gap={16}>
|
||||
<Flex justify="center">
|
||||
<ImageInput
|
||||
alt={t("Profile picture")}
|
||||
onSuccess={handleAvatarChange}
|
||||
onError={handleAvatarError}
|
||||
model={user}
|
||||
showRemoveOption={false}
|
||||
/>
|
||||
</Flex>
|
||||
<Flex justify="flex-end" gap={8}>
|
||||
{user.avatarUrl && (
|
||||
<Button onClick={() => handleAvatarChange(null)} neutral>
|
||||
{t("Remove")}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={onSubmit}>{t("Done")}</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
|
||||
export function UserChangeEmailDialog({ user, onSubmit }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const actor = useCurrentUser();
|
||||
|
||||
@@ -102,6 +102,7 @@ const StyledContent = styled(m.div)`
|
||||
|
||||
const StyledInnerContent = styled(Flex)`
|
||||
padding: 6px;
|
||||
padding-bottom: calc(6px + var(--sab, 0px));
|
||||
height: 100%;
|
||||
`;
|
||||
|
||||
|
||||
@@ -108,6 +108,9 @@ export const MenuExternalLink = styled.a`
|
||||
|
||||
export const MenuSubTrigger = styled.div<BaseMenuItemProps>`
|
||||
${BaseMenuItemCSS}
|
||||
// Reserve space for the absolutely-positioned disclosure arrow so long
|
||||
// labels truncate before it rather than overlapping.
|
||||
padding-inline-end: 32px;
|
||||
`;
|
||||
|
||||
export const MenuSeparator = styled.hr`
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { debounce } from "es-toolkit/compat";
|
||||
import {
|
||||
CaretDownIcon,
|
||||
CaretUpIcon,
|
||||
@@ -211,9 +212,31 @@ export default function FindAndReplace({
|
||||
});
|
||||
}, [caseSensitive, editor.commands, searchTerm]);
|
||||
|
||||
// Searching the document on every keystroke is expensive in long documents –
|
||||
// it traverses the entire doc and rebuilds highlights – so debounce.
|
||||
const debouncedFind = React.useMemo(
|
||||
() =>
|
||||
debounce(
|
||||
(attrs: {
|
||||
text: string;
|
||||
caseSensitive: boolean;
|
||||
regexEnabled: boolean;
|
||||
}) => {
|
||||
editor.commands.find(attrs);
|
||||
},
|
||||
100
|
||||
),
|
||||
[editor.commands]
|
||||
);
|
||||
|
||||
React.useEffect(() => () => debouncedFind.cancel(), [debouncedFind]);
|
||||
|
||||
const handleKeyDown = React.useCallback(
|
||||
(ev: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
function nextPrevious() {
|
||||
// Ensure any pending debounced search has run so navigation acts on the
|
||||
// results for the text currently in the input.
|
||||
debouncedFind.flush();
|
||||
if (ev.shiftKey) {
|
||||
editor.commands.prevSearchMatch();
|
||||
} else {
|
||||
@@ -243,7 +266,7 @@ export default function FindAndReplace({
|
||||
}
|
||||
}
|
||||
},
|
||||
[editor.commands, selectInputText]
|
||||
[debouncedFind, editor.commands, selectInputText]
|
||||
);
|
||||
|
||||
const handleReplace = React.useCallback(
|
||||
@@ -274,13 +297,13 @@ export default function FindAndReplace({
|
||||
ev.stopPropagation();
|
||||
setSearchTerm(ev.currentTarget.value);
|
||||
|
||||
editor.commands.find({
|
||||
debouncedFind({
|
||||
text: ev.currentTarget.value,
|
||||
caseSensitive,
|
||||
regexEnabled,
|
||||
});
|
||||
},
|
||||
[caseSensitive, editor.commands, regexEnabled]
|
||||
[caseSensitive, debouncedFind, regexEnabled]
|
||||
);
|
||||
|
||||
const handleReplaceKeyDown = React.useCallback(
|
||||
@@ -331,6 +354,9 @@ export default function FindAndReplace({
|
||||
} else {
|
||||
onClose();
|
||||
setShowReplace(false);
|
||||
// Cancel any pending debounced find so it can't reactivate highlights
|
||||
// after the search has been cleared.
|
||||
debouncedFind.cancel();
|
||||
editor.commands.clearSearch();
|
||||
}
|
||||
// oxlint-disable-next-line react-hooks/exhaustive-deps
|
||||
@@ -346,7 +372,10 @@ export default function FindAndReplace({
|
||||
>
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.prevSearchMatch()}
|
||||
onClick={() => {
|
||||
debouncedFind.flush();
|
||||
editor.commands.prevSearchMatch();
|
||||
}}
|
||||
aria-label={t("Previous match")}
|
||||
>
|
||||
<CaretUpIcon />
|
||||
@@ -355,7 +384,10 @@ export default function FindAndReplace({
|
||||
<Tooltip content={t("Next match")} shortcut="Enter" placement="bottom">
|
||||
<ButtonLarge
|
||||
disabled={disabled}
|
||||
onClick={() => editor.commands.nextSearchMatch()}
|
||||
onClick={() => {
|
||||
debouncedFind.flush();
|
||||
editor.commands.nextSearchMatch();
|
||||
}}
|
||||
aria-label={t("Next match")}
|
||||
>
|
||||
<CaretDownIcon />
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
import * as React from "react";
|
||||
import { RemoveScroll } from "react-remove-scroll";
|
||||
import styled from "styled-components";
|
||||
import EventBoundary from "@shared/components/EventBoundary";
|
||||
import { collapseSelection } from "@shared/editor/commands/collapseSelection";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Scrollable from "~/components/Scrollable";
|
||||
import { toMenuItems, toMobileMenuItems } from "~/components/Menu/transformer";
|
||||
import * as Components from "~/components/primitives/components/Menu";
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
} from "~/components/primitives/Drawer";
|
||||
import { MenuProvider } from "~/components/primitives/Menu/MenuContext";
|
||||
import type { MenuItem as TMenuItem, MenuItemWithChildren } from "~/types";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
import { mapMenuItems } from "../menus/mapMenuItems";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { useInlineMenuAnchor } from "./useInlineMenuAnchor";
|
||||
|
||||
type Props = {
|
||||
items: MenuItem[];
|
||||
/** Whether the document is right-to-left. */
|
||||
rtl: boolean;
|
||||
};
|
||||
|
||||
// The virtual anchor is an invisible zero-size element; the hook positions it
|
||||
// over the selection and Radix anchors the menu to it.
|
||||
const anchorStyle: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
width: 0,
|
||||
height: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* Renders a selection-toolbar menu inline — a vertical menu anchored to the
|
||||
* selection with no trigger button — by holding a Radix dropdown `open`
|
||||
* against a virtual anchor positioned over the selection. Radix provides the
|
||||
* positioning, collision handling, submenus, and keyboard navigation. Page
|
||||
* scroll is locked while open (via RemoveScroll, as Radix does for modal
|
||||
* menus) without enabling Radix's modal mode, which conflicts with the menu
|
||||
* being opened by an editor selection rather than a trigger.
|
||||
*/
|
||||
const InlineMenu: React.FC<Props> = ({ items, rtl }) => {
|
||||
const { t } = useTranslation();
|
||||
const { commands, view } = useEditor();
|
||||
const { state } = view;
|
||||
const isMobile = useMobile();
|
||||
const {
|
||||
ref: anchorRef,
|
||||
key: anchorKey,
|
||||
side,
|
||||
align,
|
||||
sideOffset,
|
||||
} = useInlineMenuAnchor(rtl);
|
||||
|
||||
const mapped = React.useMemo(
|
||||
() => mapMenuItems(items, commands, view, state),
|
||||
[items, commands, view, state]
|
||||
);
|
||||
|
||||
const preventFocus = React.useCallback((ev: Event) => {
|
||||
ev.preventDefault();
|
||||
}, []);
|
||||
|
||||
// Dismiss the menu by collapsing the selection so the toolbar stops matching.
|
||||
const handleDismiss = React.useCallback(() => {
|
||||
collapseSelection()(view.state, view.dispatch);
|
||||
}, [view]);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<InlineMenuDrawer
|
||||
items={mapped}
|
||||
ariaLabel={t("Options")}
|
||||
onDismiss={handleDismiss}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuProvider variant="dropdown">
|
||||
<DropdownMenuPrimitive.Root
|
||||
key={anchorKey}
|
||||
open={!!anchorKey}
|
||||
modal={false}
|
||||
>
|
||||
<DropdownMenuPrimitive.Trigger asChild>
|
||||
<div ref={anchorRef} aria-hidden style={anchorStyle} />
|
||||
</DropdownMenuPrimitive.Trigger>
|
||||
<DropdownMenuPrimitive.Portal>
|
||||
<DropdownMenuPrimitive.Content
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={sideOffset}
|
||||
collisionPadding={6}
|
||||
aria-label={t("Options")}
|
||||
onCloseAutoFocus={preventFocus}
|
||||
onInteractOutside={handleDismiss}
|
||||
onEscapeKeyDown={handleDismiss}
|
||||
asChild
|
||||
>
|
||||
<RemoveScroll as={Slot} allowPinchZoom>
|
||||
<Components.MenuContent
|
||||
maxHeightVar="--radix-dropdown-menu-content-available-height"
|
||||
transformOriginVar="--radix-dropdown-menu-content-transform-origin"
|
||||
hiddenScrollbars
|
||||
>
|
||||
<EventBoundary>{toMenuItems(mapped)}</EventBoundary>
|
||||
</Components.MenuContent>
|
||||
</RemoveScroll>
|
||||
</DropdownMenuPrimitive.Content>
|
||||
</DropdownMenuPrimitive.Portal>
|
||||
</DropdownMenuPrimitive.Root>
|
||||
</MenuProvider>
|
||||
);
|
||||
};
|
||||
|
||||
// Time for the drawer's close animation to play before the selection is
|
||||
// collapsed (which unmounts the menu).
|
||||
const DRAWER_CLOSE_MS = 500;
|
||||
|
||||
type InlineMenuDrawerProps = {
|
||||
items: TMenuItem[];
|
||||
ariaLabel: string;
|
||||
/** Collapse the selection so the toolbar stops rendering the menu. */
|
||||
onDismiss: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Mobile presentation of the inline menu: a bottom drawer with submenu drill-in,
|
||||
* matching the other menus. The menu is held open while the selection matches;
|
||||
* closing animates the drawer out before collapsing the selection.
|
||||
*/
|
||||
function InlineMenuDrawer({
|
||||
items,
|
||||
ariaLabel,
|
||||
onDismiss,
|
||||
}: InlineMenuDrawerProps) {
|
||||
const [open, setOpen] = React.useState(true);
|
||||
const [submenuName, setSubmenuName] = React.useState<string>();
|
||||
|
||||
const close = React.useCallback(() => {
|
||||
setOpen(false);
|
||||
setTimeout(() => {
|
||||
setSubmenuName(undefined);
|
||||
onDismiss();
|
||||
}, DRAWER_CLOSE_MS);
|
||||
}, [onDismiss]);
|
||||
|
||||
const handleOpenChange = React.useCallback(
|
||||
(isOpen: boolean) => {
|
||||
if (!isOpen) {
|
||||
close();
|
||||
}
|
||||
},
|
||||
[close]
|
||||
);
|
||||
|
||||
const menuItems = React.useMemo(() => {
|
||||
if (!items.length || !submenuName) {
|
||||
return items;
|
||||
}
|
||||
const submenu = items.find(
|
||||
(item) => item.type === "submenu" && item.title === submenuName
|
||||
) as MenuItemWithChildren | undefined;
|
||||
return submenu?.items ?? items;
|
||||
}, [items, submenuName]);
|
||||
|
||||
const content = toMobileMenuItems(menuItems, close, setSubmenuName);
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
<DrawerContent aria-label={ariaLabel} aria-describedby={undefined}>
|
||||
<DrawerTitle hidden>{ariaLabel}</DrawerTitle>
|
||||
<StyledScrollable hiddenScrollbars>{content}</StyledScrollable>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
|
||||
const StyledScrollable = styled(Scrollable)`
|
||||
max-height: 75vh;
|
||||
`;
|
||||
|
||||
export default InlineMenu;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { isEmail } from "class-validator";
|
||||
import { observer } from "mobx-react";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import { runInAction } from "mobx";
|
||||
import {
|
||||
DocumentIcon,
|
||||
PlusIcon,
|
||||
@@ -14,11 +15,20 @@ import { toast } from "sonner";
|
||||
import Icon from "@shared/components/Icon";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import {
|
||||
dateToReadable,
|
||||
dateToRelativeReadable,
|
||||
parseISODate,
|
||||
toISODate,
|
||||
} from "@shared/utils/date";
|
||||
import parseDocumentSlug from "@shared/utils/parseDocumentSlug";
|
||||
import { parseNaturalLanguageDate } from "@shared/utils/parseNaturalLanguageDate";
|
||||
import { Avatar, AvatarSize, GroupAvatar } from "~/components/Avatar";
|
||||
import DocumentBreadcrumb from "~/components/DocumentBreadcrumb";
|
||||
import { DynamicCalendarIcon } from "@shared/components/DynamicCalendarIcon";
|
||||
import Flex from "~/components/Flex";
|
||||
import {
|
||||
DateSection,
|
||||
DocumentsSection,
|
||||
UserSection,
|
||||
CollectionsSection,
|
||||
@@ -26,18 +36,20 @@ import {
|
||||
} from "~/actions/sections";
|
||||
import useRequest from "~/hooks/useRequest";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { client } from "~/utils/ApiClient";
|
||||
import type { Props as SuggestionsMenuProps } from "./SuggestionsMenu";
|
||||
import SuggestionsMenu from "./SuggestionsMenu";
|
||||
import SuggestionsMenuItem from "./SuggestionsMenuItem";
|
||||
import { runInAction } from "mobx";
|
||||
|
||||
interface MentionItem extends MenuItem {
|
||||
attrs: {
|
||||
id: string;
|
||||
type: MentionType;
|
||||
modelId: string;
|
||||
label: string;
|
||||
// Date mentions intentionally omit a label — their text is derived from
|
||||
// the ISO `modelId` so nothing human-readable is persisted.
|
||||
label?: string;
|
||||
actorId?: string;
|
||||
};
|
||||
}
|
||||
@@ -47,15 +59,72 @@ type Props = Omit<
|
||||
"renderMenuItem" | "items" | "embeds"
|
||||
>;
|
||||
|
||||
function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
function MentionMenu({ search = "", isActive, ...rest }: Props) {
|
||||
const [loaded, setLoaded] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
const { auth, documents, users, collections, groups } = useStores();
|
||||
const actorId = auth.currentUserId;
|
||||
const location = useLocation();
|
||||
const documentId = parseDocumentSlug(location.pathname);
|
||||
const userLocale = useUserLocale();
|
||||
const maxResultsInSection = search ? 25 : 5;
|
||||
|
||||
// Surface a date suggestion when the search query parses as a natural
|
||||
// language date (e.g. "tomorrow", "next friday", "jan 2"). Parsing is
|
||||
// asynchronous as chrono-node is loaded lazily, so the result is held in
|
||||
// state and applied once resolved.
|
||||
const [parsedISODate, setParsedISODate] = useState<string | undefined>();
|
||||
|
||||
useEffect(() => {
|
||||
if (!search) {
|
||||
setParsedISODate(undefined);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
void parseNaturalLanguageDate(search)
|
||||
.then((date) => {
|
||||
if (!cancelled) {
|
||||
setParsedISODate(date ? toISODate(date) : undefined);
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Parsing failed (e.g. the chrono chunk failed to load); drop the
|
||||
// suggestion rather than leaving a stale one.
|
||||
if (!cancelled) {
|
||||
setParsedISODate(undefined);
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [search]);
|
||||
|
||||
let dateItems: MentionItem[] = [];
|
||||
|
||||
if (actorId && parsedISODate) {
|
||||
const title = dateToRelativeReadable(parsedISODate, t, userLocale);
|
||||
const subtitle = dateToReadable(parsedISODate, userLocale);
|
||||
|
||||
dateItems = [
|
||||
{
|
||||
name: "mention",
|
||||
icon: (
|
||||
<DynamicCalendarIcon day={parseISODate(parsedISODate)?.getDate()} />
|
||||
),
|
||||
title,
|
||||
subtitle: title !== subtitle ? subtitle : undefined,
|
||||
section: DateSection,
|
||||
appendSpace: true,
|
||||
attrs: {
|
||||
id: uuidv4(),
|
||||
type: MentionType.Date,
|
||||
modelId: parsedISODate,
|
||||
actorId,
|
||||
},
|
||||
} as MentionItem,
|
||||
];
|
||||
}
|
||||
|
||||
const { loading, request } = useRequest(
|
||||
useCallback(async () => {
|
||||
const res = await client.post("/suggestions.mention", {
|
||||
@@ -87,7 +156,7 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
// Computed in the render body so MobX observer can track store access
|
||||
// (e.g. searchSuppressed). Previously this lived inside a useEffect which
|
||||
// runs outside the reactive context and triggered MobX warnings.
|
||||
const items: MentionItem[] = actorId
|
||||
const mentionItems: MentionItem[] = actorId
|
||||
? users
|
||||
.findByQuery(search, { maxResults: maxResultsInSection })
|
||||
.map(
|
||||
@@ -253,9 +322,12 @@ function MentionMenu({ search, isActive, ...rest }: Props) {
|
||||
])
|
||||
: [];
|
||||
|
||||
const items: MentionItem[] = [...dateItems, ...mentionItems];
|
||||
|
||||
const handleSelect = useCallback(
|
||||
async (item: MentionItem) => {
|
||||
if (
|
||||
item.attrs.type === MentionType.Date ||
|
||||
item.attrs.type === MentionType.Document ||
|
||||
item.attrs.type === MentionType.Collection
|
||||
) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from "@shared/editor/queries/getMarkRange";
|
||||
import { isInCode } from "@shared/editor/queries/isInCode";
|
||||
import { isInNotice } from "@shared/editor/queries/isInNotice";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MenuType, type MenuItem } from "@shared/editor/types";
|
||||
import useBoolean from "~/hooks/useBoolean";
|
||||
import useEventListener from "~/hooks/useEventListener";
|
||||
import useMobile from "~/hooks/useMobile";
|
||||
@@ -24,6 +24,7 @@ import { MediaLinkEditor } from "./MediaLinkEditor";
|
||||
import FloatingToolbar from "./FloatingToolbar";
|
||||
import LinkEditor from "./LinkEditor";
|
||||
import ToolbarMenu from "./ToolbarMenu";
|
||||
import InlineMenu from "./InlineMenu";
|
||||
import { isModKey } from "@shared/utils/keyboard";
|
||||
|
||||
type Props = {
|
||||
@@ -264,6 +265,16 @@ export function SelectionToolbar(props: Props) {
|
||||
setActiveToolbar(null);
|
||||
};
|
||||
|
||||
// Inline menus render as a vertical menu anchored to the selection rather
|
||||
// than as a horizontal toolbar with trigger buttons.
|
||||
if (
|
||||
matched?.variant === MenuType.inline &&
|
||||
activeToolbar === Toolbar.Menu &&
|
||||
items.length
|
||||
) {
|
||||
return <InlineMenu items={items} rtl={rtl} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<FloatingToolbar
|
||||
align={align}
|
||||
|
||||
@@ -35,7 +35,7 @@ import { MenuHeader } from "~/components/primitives/components/Menu";
|
||||
export type Props<T extends MenuItem = MenuItem> = {
|
||||
rtl: boolean;
|
||||
isActive: boolean;
|
||||
search: string;
|
||||
search?: string;
|
||||
trigger: string | string[];
|
||||
uploadFile?: (file: File) => Promise<string>;
|
||||
onFileUploadStart?: () => void;
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { MenuItem } from "@shared/editor/types";
|
||||
import { hideScrollbars, s } from "@shared/styles";
|
||||
import { TooltipProvider } from "~/components/TooltipContext";
|
||||
import type { MenuItem as TMenuItem } from "~/types";
|
||||
import { mapMenuItems } from "../menus/mapMenuItems";
|
||||
import { useEditor } from "./EditorContext";
|
||||
import { MediaDimension } from "./MediaDimension";
|
||||
import ToolbarButton from "./ToolbarButton";
|
||||
@@ -49,69 +50,11 @@ function ToolbarDropdown(props: ToolbarDropdownProps) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const handleClick = (menuItem: MenuItem) => () => {
|
||||
if (!menuItem.name) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (commands[menuItem.name]) {
|
||||
closeHistory(view);
|
||||
commands[menuItem.name](
|
||||
typeof menuItem.attrs === "function"
|
||||
? menuItem.attrs(state)
|
||||
: menuItem.attrs
|
||||
);
|
||||
closeHistory(view);
|
||||
} else if (menuItem.onClick) {
|
||||
menuItem.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem[] | (() => MenuItem[]) | undefined
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
const mapChildren = (children: MenuItem[]): TMenuItem[] =>
|
||||
children.map((child) => {
|
||||
if (child.name === "separator") {
|
||||
return { type: "separator", visible: child.visible };
|
||||
}
|
||||
if ("content" in child) {
|
||||
return {
|
||||
type: "custom",
|
||||
visible: child.visible,
|
||||
content: child.content,
|
||||
};
|
||||
}
|
||||
const resolvedChildren = resolveChildren(child.children);
|
||||
if (resolvedChildren) {
|
||||
const childWithPreventClose = resolvedChildren.find(
|
||||
(c) => "preventCloseCondition" in c
|
||||
);
|
||||
return {
|
||||
type: "submenu",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
visible: child.visible,
|
||||
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
|
||||
items: mapChildren(resolvedChildren),
|
||||
};
|
||||
}
|
||||
return {
|
||||
type: "button",
|
||||
title: child.label,
|
||||
icon: child.icon,
|
||||
dangerous: child.dangerous,
|
||||
visible: child.visible,
|
||||
selected:
|
||||
child.active !== undefined ? child.active(state) : undefined,
|
||||
onClick: handleClick(child),
|
||||
};
|
||||
});
|
||||
|
||||
const resolvedItemChildren = resolveChildren(item.children);
|
||||
return resolvedItemChildren ? mapChildren(resolvedItemChildren) : [];
|
||||
const resolvedItemChildren =
|
||||
typeof item.children === "function" ? item.children() : item.children;
|
||||
return resolvedItemChildren
|
||||
? mapMenuItems(resolvedItemChildren, commands, view, state)
|
||||
: [];
|
||||
}, [isOpen, commands]);
|
||||
|
||||
const handleCloseAutoFocus = useCallback((ev: Event) => {
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { selectedRect } from "prosemirror-tables";
|
||||
import * as React from "react";
|
||||
import type { EditorView } from "prosemirror-view";
|
||||
import { ColumnSelection } from "@shared/editor/selection/ColumnSelection";
|
||||
import { RowSelection } from "@shared/editor/selection/RowSelection";
|
||||
import { isTableSelected } from "@shared/editor/queries/table";
|
||||
import { useEditor } from "./EditorContext";
|
||||
|
||||
type Side = "top" | "bottom" | "left" | "right";
|
||||
type Align = "start" | "center" | "end";
|
||||
|
||||
const DEFAULT_SIDE_OFFSET = 4;
|
||||
|
||||
// Column and row menus open next to a grip handle. The grip is modelled as a
|
||||
// strip just outside the cell edge so the two distances are independent:
|
||||
// opening to the outside clears the grip (strip thickness + offset), while
|
||||
// flipping across sits only a small gap (offset) away.
|
||||
const OUTSIDE_CLEARANCE = 20;
|
||||
const FLIP_GAP = 0;
|
||||
const GRIP_INSET = OUTSIDE_CLEARANCE - FLIP_GAP;
|
||||
const GRIP_SIDE_OFFSET = FLIP_GAP;
|
||||
|
||||
type Anchor = {
|
||||
/** Viewport rect to anchor the menu to. */
|
||||
top: number;
|
||||
left: number;
|
||||
width: number;
|
||||
height: number;
|
||||
/** Which side of the anchor the menu opens towards. */
|
||||
side: Side;
|
||||
/** How the menu aligns along the anchor edge. */
|
||||
align: Align;
|
||||
/** Distance in pixels between the anchor and the menu. */
|
||||
sideOffset: number;
|
||||
/** Stable identifier for the anchored target, changes when it moves. */
|
||||
key: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Computes the rect and placement to anchor an inline selection menu to, based
|
||||
* on the current table/column/row selection. The menu opens to the "outside"
|
||||
* of the table (above a column, beside a row) to cover the least content, and
|
||||
* is centered on the anchor for minimal pointer movement. Returns null when
|
||||
* there is no supported selection.
|
||||
*
|
||||
* @param view - the editor view.
|
||||
* @param rtl - whether the document is right-to-left.
|
||||
* @returns the anchor, or null.
|
||||
*/
|
||||
function getAnchor(view: EditorView, rtl: boolean): Anchor | null {
|
||||
const { state } = view;
|
||||
const { selection } = state;
|
||||
|
||||
if (isTableSelected(state)) {
|
||||
const rect = selectedRect(state);
|
||||
const bounds = (
|
||||
view.domAtPos(rect.tableStart).node as HTMLElement
|
||||
).getBoundingClientRect();
|
||||
// A horizontal line at the table's top edge so it stays near the top
|
||||
// whether the menu opens above or flips below.
|
||||
return {
|
||||
top: bounds.top,
|
||||
left: bounds.left,
|
||||
width: bounds.width,
|
||||
height: 0,
|
||||
side: "top",
|
||||
align: "start",
|
||||
sideOffset: DEFAULT_SIDE_OFFSET,
|
||||
key: `table-${rect.tableStart}`,
|
||||
};
|
||||
}
|
||||
|
||||
if (selection instanceof ColumnSelection && selection.isColSelection()) {
|
||||
const rect = selectedRect(state);
|
||||
const cell = (
|
||||
view.domAtPos(rect.tableStart).node as HTMLElement
|
||||
).querySelector(`tr > *:nth-child(${rect.left + 1})`);
|
||||
if (cell instanceof HTMLElement) {
|
||||
const bounds = cell.getBoundingClientRect();
|
||||
// A strip just above the column's top edge (the grip), spanning the
|
||||
// column width so the menu centers on the column.
|
||||
return {
|
||||
top: bounds.top - GRIP_INSET,
|
||||
left: bounds.left,
|
||||
width: bounds.width,
|
||||
height: GRIP_INSET,
|
||||
side: "top",
|
||||
align: "center",
|
||||
sideOffset: GRIP_SIDE_OFFSET,
|
||||
key: `col-${rect.tableStart}-${rect.left}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (selection instanceof RowSelection && selection.isRowSelection()) {
|
||||
const rect = selectedRect(state);
|
||||
const cell = (
|
||||
view.domAtPos(rect.tableStart).node as HTMLElement
|
||||
).querySelector(`tr:nth-child(${rect.top + 1}) > *`);
|
||||
if (cell instanceof HTMLElement) {
|
||||
const bounds = cell.getBoundingClientRect();
|
||||
// A strip just outside the row's grip edge (left, or right in RTL),
|
||||
// spanning the row height so the menu centers on the row.
|
||||
return {
|
||||
top: bounds.top,
|
||||
left: rtl ? bounds.right : bounds.left - GRIP_INSET,
|
||||
width: GRIP_INSET,
|
||||
height: bounds.height,
|
||||
side: rtl ? "right" : "left",
|
||||
align: "center",
|
||||
sideOffset: GRIP_SIDE_OFFSET,
|
||||
key: `row-${rect.tableStart}-${rect.top}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Positions an invisible virtual anchor element over the current table, column,
|
||||
* or row selection so a Radix dropdown can anchor an inline menu to it. The
|
||||
* returned `key` changes when the anchored target changes; spread it onto the
|
||||
* menu root so Radix repositions for a new target.
|
||||
*
|
||||
* @param rtl - whether the document is right-to-left.
|
||||
* @returns the anchor ref to attach to the virtual trigger, the target key, and
|
||||
* the side/align the menu should open with.
|
||||
*/
|
||||
export function useInlineMenuAnchor(rtl: boolean) {
|
||||
const { view } = useEditor();
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const anchor = getAnchor(view, rtl);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const element = ref.current;
|
||||
if (element && anchor) {
|
||||
element.style.top = `${anchor.top}px`;
|
||||
element.style.left = `${anchor.left}px`;
|
||||
element.style.width = `${anchor.width}px`;
|
||||
element.style.height = `${anchor.height}px`;
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
ref,
|
||||
key: anchor?.key,
|
||||
side: anchor?.side ?? "top",
|
||||
align: anchor?.align ?? "start",
|
||||
sideOffset: anchor?.sideOffset ?? DEFAULT_SIDE_OFFSET,
|
||||
};
|
||||
}
|
||||
@@ -61,7 +61,7 @@ export default class ClipboardTextSerializer extends Extension {
|
||||
.map((node) => ProsemirrorHelper.toPlainText(node))
|
||||
.join("\n")
|
||||
: mdSerializer.serialize(slice.content, {
|
||||
softBreak: true,
|
||||
commonMark: true,
|
||||
});
|
||||
},
|
||||
},
|
||||
|
||||
@@ -381,6 +381,11 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
}
|
||||
});
|
||||
|
||||
// Tracks already-seen match positions so duplicate matches (possible because
|
||||
// we search the deburred text concatenated with the original) can be skipped
|
||||
// in constant time rather than rescanning the entire results array.
|
||||
const seen = new Set<string>();
|
||||
|
||||
mergedTextNodes.forEach((node) => {
|
||||
const { text = "", pos, type } = node;
|
||||
try {
|
||||
@@ -405,11 +410,13 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if already exists in results, possible due to duplicated
|
||||
// search string on L257
|
||||
if (this.results.some((r) => r.from === from && r.to === to)) {
|
||||
// Check if already exists in results, possible because we search
|
||||
// over `deburr(text) + text`
|
||||
const key = `${from}:${to}`;
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
|
||||
this.results.push({ from, to, type });
|
||||
}
|
||||
@@ -483,6 +490,7 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
}
|
||||
}
|
||||
|
||||
this.highlightRanges = allRanges;
|
||||
CSS.highlights.set("search-results", new Highlight(...allRanges));
|
||||
if (currentRanges.length) {
|
||||
CSS.highlights.set(
|
||||
@@ -495,6 +503,7 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
}
|
||||
|
||||
private clearHighlights() {
|
||||
this.highlightRanges = [];
|
||||
if (!supportsHighlightAPI) {
|
||||
return;
|
||||
}
|
||||
@@ -503,6 +512,25 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
this.currentHighlightRange = undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine whether the highlight ranges need to be rebuilt against the live
|
||||
* DOM. The CSS Custom Highlight API holds static ranges that detach when the
|
||||
* editor re-renders its DOM without changing the doc, so highlights are stale
|
||||
* when a built range's nodes have disconnected, or when some matches have not
|
||||
* yet been resolved to ranges (e.g. inside a node view that mounts later).
|
||||
*
|
||||
* @returns whether the highlights should be rebuilt.
|
||||
*/
|
||||
private highlightsStale() {
|
||||
if (this.highlightRanges.length < this.results.length) {
|
||||
return true;
|
||||
}
|
||||
return this.highlightRanges.some(
|
||||
(range) =>
|
||||
!range.startContainer.isConnected || !range.endContainer.isConnected
|
||||
);
|
||||
}
|
||||
|
||||
private handleEscape = () => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
if (params.has("q")) {
|
||||
@@ -536,6 +564,8 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
|
||||
private currentHighlightRange?: StaticRange;
|
||||
|
||||
private highlightRanges: StaticRange[] = [];
|
||||
|
||||
get allowInReadOnly() {
|
||||
return true;
|
||||
}
|
||||
@@ -604,13 +634,27 @@ export default class FindAndReplaceExtension extends Extension<FindAndReplaceOpt
|
||||
return {
|
||||
update: (view) => {
|
||||
const generation = pluginKey.getState(view.state) as number;
|
||||
// The results changed (search ran, doc changed, fold toggled), so
|
||||
// always rebuild.
|
||||
if (generation !== lastGeneration) {
|
||||
lastGeneration = generation;
|
||||
this.updateHighlights();
|
||||
return;
|
||||
}
|
||||
// Results unchanged: only rebuild when the static highlight ranges
|
||||
// have detached from a DOM re-render that didn't bump the generation.
|
||||
if (this.searchTerm && this.highlightsStale()) {
|
||||
this.updateHighlights();
|
||||
}
|
||||
},
|
||||
destroy: () => {
|
||||
this.clearHighlights();
|
||||
// The highlight registry is global and keyed by fixed names, so
|
||||
// only tear down highlights when this editor actually owns an
|
||||
// active search — otherwise an unmounting editor could wipe the
|
||||
// highlights another editor just set during a route transition.
|
||||
if (this.searchTerm) {
|
||||
this.clearHighlights();
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
import * as Y from "yjs";
|
||||
import Extension from "@shared/editor/lib/Extension";
|
||||
import { isRemoteTransaction } from "@shared/editor/lib/multiplayer";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import { Second } from "@shared/utils/time";
|
||||
|
||||
type UserAwareness = {
|
||||
@@ -107,7 +108,7 @@ export default class Multiplayer extends Extension<MultiplayerOptions> {
|
||||
|
||||
return {
|
||||
style: `background-color: ${u.color}${opacity}`,
|
||||
class: "ProseMirror-yjs-selection",
|
||||
class: EditorStyleHelper.multiplayerSelection,
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
@@ -55,15 +55,11 @@ export default class PasteHandler extends Extension {
|
||||
},
|
||||
handleDOMEvents: {
|
||||
keydown: (_, event) => {
|
||||
if (event.key === "Shift") {
|
||||
this.shiftKey = true;
|
||||
}
|
||||
this.shiftKey = event.shiftKey;
|
||||
return false;
|
||||
},
|
||||
keyup: (_, event) => {
|
||||
if (event.key === "Shift") {
|
||||
this.shiftKey = false;
|
||||
}
|
||||
this.shiftKey = event.shiftKey;
|
||||
return false;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -7,7 +7,10 @@ import Extension from "@shared/editor/lib/Extension";
|
||||
import { isInNotice } from "@shared/editor/queries/isInNotice";
|
||||
import { isMarkActive } from "@shared/editor/queries/isMarkActive";
|
||||
import { isNodeActive } from "@shared/editor/queries/isNodeActive";
|
||||
import type { SelectionToolbarMenuDescriptor } from "@shared/editor/types";
|
||||
import {
|
||||
MenuType,
|
||||
type SelectionToolbarMenuDescriptor,
|
||||
} from "@shared/editor/types";
|
||||
import { SelectionToolbar } from "../components/SelectionToolbar";
|
||||
import getAttachmentMenuItems from "../menus/attachment";
|
||||
import getCodeMenuItems from "../menus/code";
|
||||
@@ -62,16 +65,19 @@ export default class SelectionToolbarExtension extends Extension {
|
||||
},
|
||||
{
|
||||
priority: 90,
|
||||
variant: MenuType.inline,
|
||||
matches: (ctx) => ctx.isTableSelected,
|
||||
getItems: (ctx) => getTableMenuItems(ctx),
|
||||
},
|
||||
{
|
||||
priority: 85,
|
||||
variant: MenuType.inline,
|
||||
matches: (ctx) => ctx.colIndex !== undefined,
|
||||
getItems: (ctx) => getTableColMenuItems(ctx),
|
||||
},
|
||||
{
|
||||
priority: 80,
|
||||
variant: MenuType.inline,
|
||||
matches: (ctx) => ctx.rowIndex !== undefined,
|
||||
getItems: (ctx) => getTableRowMenuItems(ctx),
|
||||
},
|
||||
|
||||
@@ -46,7 +46,7 @@ export default class Suggestion<
|
||||
: `(?:${triggers.map(escapeRegExp).join("|")})`;
|
||||
|
||||
this.openRegex = new RegExp(
|
||||
`(?:^|\\s|\\(|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
|
||||
`(?:^|\\s|\\(|\\+|[\\p{Script=Han}\\p{Script=Hiragana}\\p{Script=Katakana}\\p{Script=Hangul}])${triggerPattern}(${`[\\p{L}/\\p{M}\\d${
|
||||
this.options.allowSpaces ? "\\s{1}" : ""
|
||||
}\\.\\-–_]+`})${this.options.requireSearchTerm ? "" : "?"}$`,
|
||||
"u"
|
||||
|
||||
+13
-17
@@ -17,7 +17,6 @@ import {
|
||||
WarningIcon,
|
||||
InfoIcon,
|
||||
AttachmentIcon,
|
||||
ClockIcon,
|
||||
CalendarIcon,
|
||||
MathIcon,
|
||||
DoneIcon,
|
||||
@@ -26,9 +25,12 @@ import {
|
||||
} from "outline-icons";
|
||||
import * as React from "react";
|
||||
import styled from "styled-components";
|
||||
import { v4 as uuidv4 } from "uuid";
|
||||
import type { TFunction } from "i18next";
|
||||
import Image from "@shared/editor/components/Img";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import { MentionType } from "@shared/types";
|
||||
import { toISODate } from "@shared/utils/date";
|
||||
import { metaDisplay } from "@shared/utils/keyboard";
|
||||
import Desktop from "~/utils/Desktop";
|
||||
|
||||
@@ -124,8 +126,6 @@ export default function blockMenuItems(
|
||||
keywords: "pdf upload attach",
|
||||
attrs: {
|
||||
accept: "application/pdf",
|
||||
width: 300,
|
||||
height: 424,
|
||||
preview: true,
|
||||
},
|
||||
},
|
||||
@@ -186,22 +186,18 @@ export default function blockMenuItems(
|
||||
attrs: { markup: "***" },
|
||||
},
|
||||
{
|
||||
name: "date",
|
||||
// Inserts a date mention for today. Supersedes the deprecated "Current
|
||||
// date/time" commands that inserted a static string or template token.
|
||||
name: "mention",
|
||||
title: t("Current date"),
|
||||
keywords: "clock today",
|
||||
icon: <CalendarIcon />,
|
||||
},
|
||||
{
|
||||
name: "time",
|
||||
title: t("Current time"),
|
||||
keywords: "clock now",
|
||||
icon: <ClockIcon />,
|
||||
},
|
||||
{
|
||||
name: "datetime",
|
||||
title: t("Current date and time"),
|
||||
keywords: "clock today date",
|
||||
keywords: "clock today date time now",
|
||||
icon: <CalendarIcon />,
|
||||
appendSpace: true,
|
||||
attrs: () => ({
|
||||
id: uuidv4(),
|
||||
type: MentionType.Date,
|
||||
modelId: toISODate(new Date()),
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { EditorState } from "prosemirror-state";
|
||||
import type { EditorView } from "prosemirror-view";
|
||||
import { closeHistory } from "@shared/editor/lib/closeHistory";
|
||||
import type { CommandFactory } from "@shared/editor/lib/Extension";
|
||||
import type { MenuItem } from "@shared/editor/types";
|
||||
import type { MenuItem as TMenuItem } from "~/types";
|
||||
|
||||
const resolveChildren = (
|
||||
children: MenuItem[] | (() => MenuItem[]) | undefined
|
||||
): MenuItem[] | undefined =>
|
||||
typeof children === "function" ? children() : children;
|
||||
|
||||
/**
|
||||
* Maps editor `MenuItem`s into the primitive `MenuItem`s consumed by
|
||||
* `toMenuItems`. Shared by the toolbar dropdown and the inline menu so menu
|
||||
* presentation stays consistent. Resolves nested children into submenus and
|
||||
* binds each leaf to its editor command (or `onClick`).
|
||||
*
|
||||
* @param items - the editor menu items to map.
|
||||
* @param commands - the editor command registry.
|
||||
* @param view - the editor view, used to checkpoint history around commands.
|
||||
* @param state - the editor state, used to resolve dynamic attrs and active state.
|
||||
* @returns the mapped primitive menu items.
|
||||
*/
|
||||
export function mapMenuItems(
|
||||
items: MenuItem[],
|
||||
commands: Record<string, CommandFactory>,
|
||||
view: EditorView,
|
||||
state: EditorState
|
||||
): TMenuItem[] {
|
||||
const handleClick = (item: MenuItem) => () => {
|
||||
if (!item.name) {
|
||||
return;
|
||||
}
|
||||
if (commands[item.name]) {
|
||||
closeHistory(view);
|
||||
commands[item.name](
|
||||
typeof item.attrs === "function" ? item.attrs(state) : item.attrs
|
||||
);
|
||||
closeHistory(view);
|
||||
} else if (item.onClick) {
|
||||
item.onClick();
|
||||
}
|
||||
};
|
||||
|
||||
return items.map((item) => {
|
||||
if (item.name === "separator") {
|
||||
return { type: "separator", visible: item.visible };
|
||||
}
|
||||
|
||||
if ("content" in item) {
|
||||
return { type: "custom", visible: item.visible, content: item.content };
|
||||
}
|
||||
|
||||
const resolvedChildren = resolveChildren(item.children);
|
||||
if (resolvedChildren) {
|
||||
const childWithPreventClose = resolvedChildren.find(
|
||||
(child) => "preventCloseCondition" in child
|
||||
);
|
||||
return {
|
||||
type: "submenu",
|
||||
title: item.label,
|
||||
icon: item.icon,
|
||||
visible: item.visible,
|
||||
preventCloseCondition: childWithPreventClose?.preventCloseCondition,
|
||||
items: mapMenuItems(resolvedChildren, commands, view, state),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
type: "button",
|
||||
title: item.label,
|
||||
icon: item.icon,
|
||||
dangerous: item.dangerous,
|
||||
visible: item.visible,
|
||||
selected: item.active !== undefined ? item.active(state) : undefined,
|
||||
onClick: handleClick(item),
|
||||
};
|
||||
});
|
||||
}
|
||||
+11
-14
@@ -15,9 +15,7 @@ import { TableLayout } from "@shared/editor/types";
|
||||
* @param ctx - the current selection context.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function tableMenuItems(
|
||||
ctx: SelectionContext
|
||||
): MenuItem[] {
|
||||
export default function tableMenuItems(ctx: SelectionContext): MenuItem[] {
|
||||
if (ctx.readOnly) {
|
||||
return [];
|
||||
}
|
||||
@@ -30,33 +28,32 @@ export default function tableMenuItems(
|
||||
return [
|
||||
{
|
||||
name: "setTableAttr",
|
||||
tooltip: isFullWidth ? t("Default width") : t("Full width"),
|
||||
label: isFullWidth ? t("Default width") : t("Full width"),
|
||||
icon: <AlignFullWidthIcon />,
|
||||
attrs: isFullWidth ? { layout: null } : { layout: TableLayout.fullWidth },
|
||||
active: () => isFullWidth,
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
tooltip: t("Distribute columns"),
|
||||
label: t("Distribute columns"),
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteTable",
|
||||
tooltip: t("Delete table"),
|
||||
icon: <TrashIcon />,
|
||||
name: "exportTable",
|
||||
label: t("Export as CSV"),
|
||||
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
|
||||
icon: <DownloadIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "exportTable",
|
||||
tooltip: t("Export as CSV"),
|
||||
label: "CSV",
|
||||
attrs: { format: "csv", fileName: `${window.document.title}.csv` },
|
||||
icon: <DownloadIcon />,
|
||||
name: "deleteTable",
|
||||
label: t("Delete table"),
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
+122
-118
@@ -5,7 +5,6 @@ import {
|
||||
AlignCenterIcon,
|
||||
InsertLeftIcon,
|
||||
InsertRightIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderColumnIcon,
|
||||
TableMergeCellsIcon,
|
||||
@@ -24,7 +23,11 @@ import {
|
||||
tableHasRowspan,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { t } from "i18next";
|
||||
import type { MenuItem, NodeAttrMark, SelectionContext } from "@shared/editor/types";
|
||||
import type {
|
||||
MenuItem,
|
||||
NodeAttrMark,
|
||||
SelectionContext,
|
||||
} from "@shared/editor/types";
|
||||
import { ArrowLeftIcon, ArrowRightIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
|
||||
@@ -65,9 +68,7 @@ function getColumnColors(state: EditorState, colIndex: number): Set<string> {
|
||||
* @param ctx - the current selection context.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function tableColMenuItems(
|
||||
ctx: SelectionContext
|
||||
): MenuItem[] {
|
||||
export default function tableColMenuItems(ctx: SelectionContext): MenuItem[] {
|
||||
if (ctx.readOnly) {
|
||||
return [];
|
||||
}
|
||||
@@ -94,60 +95,65 @@ export default function tableColMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: t("Align left"),
|
||||
icon: <AlignLeftIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "left",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: t("Align center"),
|
||||
label: t("Align"),
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "center" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "center",
|
||||
}),
|
||||
children: [
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: t("Align left"),
|
||||
icon: <AlignLeftIcon />,
|
||||
attrs: { index, alignment: "left" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "left",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: t("Align center"),
|
||||
icon: <AlignCenterIcon />,
|
||||
attrs: { index, alignment: "center" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "center",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
label: t("Align right"),
|
||||
icon: <AlignRightIcon />,
|
||||
attrs: { index, alignment: "right" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "right",
|
||||
}),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "setColumnAttr",
|
||||
tooltip: t("Align right"),
|
||||
icon: <AlignRightIcon />,
|
||||
attrs: { index, alignment: "right" },
|
||||
active: isNodeActive(schema.nodes.th, {
|
||||
colspan: 1,
|
||||
rowspan: 1,
|
||||
alignment: "right",
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: t("Sort ascending"),
|
||||
attrs: { index, direction: "asc" },
|
||||
label: t("Sort"),
|
||||
icon: <SortAscendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
children: [
|
||||
{
|
||||
name: "sortTable",
|
||||
label: t("Sort ascending"),
|
||||
attrs: { index, direction: "asc" },
|
||||
icon: <SortAscendingIcon />,
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
label: t("Sort descending"),
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <SortDescendingIcon />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: "sortTable",
|
||||
tooltip: t("Sort descending"),
|
||||
attrs: { index, direction: "desc" },
|
||||
icon: <SortDescendingIcon />,
|
||||
disabled: tableHasRowspan(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
tooltip: t("Background color"),
|
||||
label: t("Background"),
|
||||
icon:
|
||||
colColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
@@ -161,7 +167,7 @@ export default function tableColMenuItems(
|
||||
{
|
||||
name: "toggleColumnBackgroundAndCollapseSelection",
|
||||
label: t("None"),
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
icon: <DottedCircleIcon color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
@@ -205,71 +211,69 @@ export default function tableColMenuItems(
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "toggleHeaderColumn",
|
||||
label: t("Toggle header"),
|
||||
icon: <TableHeaderColumnIcon />,
|
||||
visible: index === 0,
|
||||
},
|
||||
{
|
||||
name: rtl ? "addColumnAfter" : "addColumnBefore",
|
||||
label: rtl ? t("Insert after") : t("Insert before"),
|
||||
icon: <InsertLeftIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: rtl ? "addColumnBefore" : "addColumnAfter",
|
||||
label: rtl ? t("Insert before") : t("Insert after"),
|
||||
icon: <InsertRightIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: t("Move left"),
|
||||
icon: <ArrowLeftIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: t("Move right"),
|
||||
icon: <ArrowRightIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.width - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: t("Merge cells"),
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: t("Split cell"),
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
visible: selectedCols.length > 1,
|
||||
label: t("Distribute columns"),
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteColumn",
|
||||
dangerous: true,
|
||||
label: t("Delete"),
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
],
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "toggleHeaderColumn",
|
||||
label: t("Toggle header"),
|
||||
icon: <TableHeaderColumnIcon />,
|
||||
visible: index === 0,
|
||||
},
|
||||
{
|
||||
name: rtl ? "addColumnAfter" : "addColumnBefore",
|
||||
label: rtl ? t("Insert after") : t("Insert before"),
|
||||
icon: <InsertLeftIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: rtl ? "addColumnBefore" : "addColumnAfter",
|
||||
label: rtl ? t("Insert before") : t("Insert after"),
|
||||
icon: <InsertRightIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: t("Move left"),
|
||||
icon: <ArrowLeftIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableColumn",
|
||||
label: t("Move right"),
|
||||
icon: <ArrowRightIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.width - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: t("Merge cells"),
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: t("Split cell"),
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "distributeColumns",
|
||||
visible: selectedCols.length > 1,
|
||||
label: t("Distribute columns"),
|
||||
icon: <TableColumnsDistributeIcon />,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteColumn",
|
||||
dangerous: true,
|
||||
label: t("Delete"),
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
TrashIcon,
|
||||
InsertAboveIcon,
|
||||
InsertBelowIcon,
|
||||
MoreIcon,
|
||||
PaletteIcon,
|
||||
TableHeaderRowIcon,
|
||||
TableSplitCellsIcon,
|
||||
@@ -16,7 +15,11 @@ import {
|
||||
isMultipleCellSelection,
|
||||
} from "@shared/editor/queries/table";
|
||||
import { t } from "i18next";
|
||||
import type { MenuItem, NodeAttrMark, SelectionContext } from "@shared/editor/types";
|
||||
import type {
|
||||
MenuItem,
|
||||
NodeAttrMark,
|
||||
SelectionContext,
|
||||
} from "@shared/editor/types";
|
||||
import { ArrowDownIcon, ArrowUpIcon } from "~/components/Icons/ArrowIcon";
|
||||
import CircleIcon from "~/components/Icons/CircleIcon";
|
||||
import CellBackgroundColorPicker from "../components/CellBackgroundColorPicker";
|
||||
@@ -56,9 +59,7 @@ function getRowColors(state: EditorState, rowIndex: number): Set<string> {
|
||||
* @param ctx - the current selection context.
|
||||
* @returns an array of menu items.
|
||||
*/
|
||||
export default function tableRowMenuItems(
|
||||
ctx: SelectionContext
|
||||
): MenuItem[] {
|
||||
export default function tableRowMenuItems(ctx: SelectionContext): MenuItem[] {
|
||||
if (ctx.readOnly) {
|
||||
return [];
|
||||
}
|
||||
@@ -83,7 +84,42 @@ export default function tableRowMenuItems(
|
||||
|
||||
return [
|
||||
{
|
||||
tooltip: t("Background color"),
|
||||
name: "toggleHeaderRow",
|
||||
label: t("Toggle header"),
|
||||
icon: <TableHeaderRowIcon />,
|
||||
visible: index === 0,
|
||||
},
|
||||
{
|
||||
name: "addRowBefore",
|
||||
label: t("Insert before"),
|
||||
icon: <InsertAboveIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "addRowAfter",
|
||||
label: t("Insert after"),
|
||||
icon: <InsertBelowIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: t("Move up"),
|
||||
icon: <ArrowUpIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: t("Move down"),
|
||||
icon: <ArrowDownIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.height - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
label: t("Background"),
|
||||
icon:
|
||||
rowColors.size > 1 ? (
|
||||
<CircleIcon color="rainbow" />
|
||||
@@ -97,7 +133,7 @@ export default function tableRowMenuItems(
|
||||
{
|
||||
name: "toggleRowBackgroundAndCollapseSelection",
|
||||
label: t("None"),
|
||||
icon: <DottedCircleIcon retainColor color="transparent" />,
|
||||
icon: <DottedCircleIcon color="transparent" />,
|
||||
active: () => (hasBackground ? false : true),
|
||||
attrs: { color: null },
|
||||
},
|
||||
@@ -141,65 +177,28 @@ export default function tableRowMenuItems(
|
||||
],
|
||||
},
|
||||
{
|
||||
icon: <MoreIcon />,
|
||||
children: [
|
||||
{
|
||||
name: "toggleHeaderRow",
|
||||
label: t("Toggle header"),
|
||||
icon: <TableHeaderRowIcon />,
|
||||
visible: index === 0,
|
||||
},
|
||||
{
|
||||
name: "addRowBefore",
|
||||
label: t("Insert before"),
|
||||
icon: <InsertAboveIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "addRowAfter",
|
||||
label: t("Insert after"),
|
||||
icon: <InsertBelowIcon />,
|
||||
attrs: { index },
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: t("Move up"),
|
||||
icon: <ArrowUpIcon />,
|
||||
attrs: { from: index, to: index - 1 },
|
||||
visible: index > 0,
|
||||
},
|
||||
{
|
||||
name: "moveTableRow",
|
||||
label: t("Move down"),
|
||||
icon: <ArrowDownIcon />,
|
||||
attrs: { from: index, to: index + 1 },
|
||||
visible: index < tableMap.map.height - 1,
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: t("Merge cells"),
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: t("Split cell"),
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteRow",
|
||||
label: t("Delete"),
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
],
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "mergeCells",
|
||||
label: t("Merge cells"),
|
||||
icon: <TableMergeCellsIcon />,
|
||||
visible: isMultipleCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "splitCell",
|
||||
label: t("Split cell"),
|
||||
icon: <TableSplitCellsIcon />,
|
||||
visible: isMergedCellSelection(state),
|
||||
},
|
||||
{
|
||||
name: "separator",
|
||||
},
|
||||
{
|
||||
name: "deleteRow",
|
||||
label: t("Delete"),
|
||||
dangerous: true,
|
||||
icon: <TrashIcon />,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
UserSuspendDialog,
|
||||
UserChangeNameDialog,
|
||||
UserChangeEmailDialog,
|
||||
UserChangeAvatarDialog,
|
||||
} from "~/components/UserDialogs";
|
||||
|
||||
/**
|
||||
@@ -33,6 +34,21 @@ export function useUserMenuActions(targetUser: User | null) {
|
||||
const { t } = useTranslation();
|
||||
const can = usePolicy(targetUser ?? ({} as User));
|
||||
|
||||
const openAvatarDialog = React.useCallback(() => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
}
|
||||
dialogs.openModal({
|
||||
title: t("Change profile picture"),
|
||||
content: (
|
||||
<UserChangeAvatarDialog
|
||||
user={targetUser}
|
||||
onSubmit={dialogs.closeAllModals}
|
||||
/>
|
||||
),
|
||||
});
|
||||
}, [dialogs, t, targetUser]);
|
||||
|
||||
const openNameDialog = React.useCallback(() => {
|
||||
if (!targetUser) {
|
||||
return;
|
||||
@@ -127,6 +143,12 @@ export function useUserMenuActions(targetUser: User | null) {
|
||||
visible: can.demote || can.promote,
|
||||
children: roleChangeActions,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change profile picture")}…`,
|
||||
section: UserSection,
|
||||
visible: can.update,
|
||||
perform: openAvatarDialog,
|
||||
}),
|
||||
createAction({
|
||||
name: `${t("Change name")}…`,
|
||||
section: UserSection,
|
||||
@@ -177,6 +199,7 @@ export function useUserMenuActions(targetUser: User | null) {
|
||||
can.update,
|
||||
can.resendInvite,
|
||||
roleChangeActions,
|
||||
openAvatarDialog,
|
||||
openNameDialog,
|
||||
openEmailDialog,
|
||||
resendInvitation,
|
||||
|
||||
@@ -29,7 +29,7 @@ export class ProsemirrorHelper {
|
||||
);
|
||||
|
||||
const markdown = serializer.serialize(doc, {
|
||||
softBreak: true,
|
||||
commonMark: true,
|
||||
});
|
||||
return markdown;
|
||||
};
|
||||
|
||||
@@ -11,6 +11,7 @@ import Route from "~/components/ProfiledRoute";
|
||||
import WebsocketProvider from "~/components/WebsocketProvider";
|
||||
import useCurrentTeam from "~/hooks/useCurrentTeam";
|
||||
import usePolicy from "~/hooks/usePolicy";
|
||||
import useQueryNotices from "~/hooks/useQueryNotices";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import {
|
||||
archivePath,
|
||||
@@ -53,6 +54,7 @@ const RedirectDocument = ({
|
||||
* the user to be logged in.
|
||||
*/
|
||||
function AuthenticatedRoutes() {
|
||||
useQueryNotices();
|
||||
const team = useCurrentTeam();
|
||||
const can = usePolicy(team);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import DelayedMount from "~/components/DelayedMount";
|
||||
import FullscreenLoading from "~/components/FullscreenLoading";
|
||||
import Route from "~/components/ProfiledRoute";
|
||||
import env from "~/env";
|
||||
import useQueryNotices from "~/hooks/useQueryNotices";
|
||||
import lazy from "~/utils/lazyWithRetry";
|
||||
import { matchDocumentSlug as documentSlug } from "~/utils/routeHelpers";
|
||||
import useAutoRefresh from "~/hooks/useAutoRefresh";
|
||||
@@ -18,7 +17,6 @@ const Logout = lazy(() => import("~/scenes/Logout"));
|
||||
const OAuthAuthorize = lazy(() => import("~/scenes/Login/OAuthAuthorize"));
|
||||
|
||||
export default function Routes() {
|
||||
useQueryNotices();
|
||||
useAutoRefresh();
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import { format as formatDate } from "date-fns";
|
||||
import { CalendarIcon } from "outline-icons";
|
||||
import * as React from "react";
|
||||
import { DayPicker } from "react-day-picker";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import styled, { useTheme } from "styled-components";
|
||||
import styled from "styled-components";
|
||||
import { Calendar } from "@shared/components/Calendar";
|
||||
import { dateLocale } from "@shared/utils/date";
|
||||
import Button from "~/components/Button";
|
||||
import {
|
||||
@@ -21,25 +20,10 @@ type Props = {
|
||||
const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const theme = useTheme();
|
||||
|
||||
const userLocale = useUserLocale();
|
||||
const locale = dateLocale(userLocale);
|
||||
|
||||
const styles = React.useMemo(
|
||||
() =>
|
||||
({
|
||||
"--rdp-caption-font-size": "16px",
|
||||
"--rdp-cell-size": "34px",
|
||||
"--rdp-selected-text": theme.accentText,
|
||||
"--rdp-accent-color": theme.accent,
|
||||
"--rdp-accent-color-dark": theme.accent,
|
||||
"--rdp-background-color": theme.listItemHoverBackground,
|
||||
"--rdp-background-color-dark": theme.listItemHoverBackground,
|
||||
}) as React.CSSProperties,
|
||||
[theme]
|
||||
);
|
||||
|
||||
const handleSelect = React.useCallback(
|
||||
(date: Date) => {
|
||||
setOpen(false);
|
||||
@@ -51,7 +35,7 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
return (
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger>
|
||||
<StyledPopoverButton icon={<Icon />} neutral>
|
||||
<StyledPopoverButton neutral>
|
||||
{selectedDate
|
||||
? formatDate(selectedDate, "MMM dd, yyyy", { locale })
|
||||
: t("Choose a date")}
|
||||
@@ -63,12 +47,12 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
side="right"
|
||||
shrink
|
||||
>
|
||||
<DayPicker
|
||||
<Calendar
|
||||
required
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={handleSelect}
|
||||
style={styles}
|
||||
locale={locale}
|
||||
disabled={{ before: new Date() }}
|
||||
/>
|
||||
</PopoverContent>
|
||||
@@ -76,23 +60,9 @@ const ExpiryDatePicker = ({ selectedDate, onSelect }: Props) => {
|
||||
);
|
||||
};
|
||||
|
||||
const Icon = () => (
|
||||
<IconWrapper>
|
||||
<CalendarIcon />
|
||||
</IconWrapper>
|
||||
);
|
||||
|
||||
const StyledPopoverButton = styled(Button)`
|
||||
margin-top: 12px;
|
||||
width: 150px;
|
||||
`;
|
||||
|
||||
const IconWrapper = styled.span`
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
`;
|
||||
|
||||
export default ExpiryDatePicker;
|
||||
|
||||
@@ -13,7 +13,6 @@ import Text from "~/components/Text";
|
||||
import useStores from "~/hooks/useStores";
|
||||
import useUserLocale from "~/hooks/useUserLocale";
|
||||
import { dateToExpiry } from "~/utils/date";
|
||||
import "react-day-picker/dist/style.css";
|
||||
import ExpiryDatePicker from "./components/ExpiryDatePicker";
|
||||
import { ExpiryType, ExpiryValues, calculateExpiryDate } from "./utils";
|
||||
|
||||
@@ -123,7 +122,7 @@ function ApiKeyNew({ onSubmit }: Props) {
|
||||
)}
|
||||
.
|
||||
</Text>
|
||||
<Flex align="center" gap={16}>
|
||||
<Flex align="center" gap={8}>
|
||||
<StyledExpirySelect
|
||||
options={expiryOptions}
|
||||
value={expiryType}
|
||||
|
||||
@@ -179,7 +179,7 @@ const CollectionScene = observer(function CollectionScene_() {
|
||||
<Notices collection={collection} />
|
||||
<Header
|
||||
collection={collection}
|
||||
isEditing={isEditRoute && !!user?.separateEditMode}
|
||||
isEditing={isEditRoute || !user?.separateEditMode}
|
||||
/>
|
||||
|
||||
<PinnedDocuments
|
||||
|
||||
@@ -8,6 +8,7 @@ import * as React from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
import { useTheme } from "styled-components";
|
||||
import { parseReactionShorthand } from "@shared/editor/lib/emoji";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { getEventFiles } from "@shared/utils/files";
|
||||
import { AttachmentValidation, CommentValidation } from "@shared/validations";
|
||||
@@ -157,6 +158,30 @@ function CommentForm({
|
||||
return;
|
||||
}
|
||||
|
||||
// "+:emoji:" shorthand: react to the comment above instead of replying.
|
||||
if (thread && !thread.isNew) {
|
||||
const emoji = parseReactionShorthand(draft);
|
||||
if (emoji) {
|
||||
const target = comments
|
||||
.inThread(thread.id)
|
||||
.filter((comment) => !comment.isNew)
|
||||
.pop();
|
||||
|
||||
if (target) {
|
||||
onSaveDraft(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
void target.addReaction({ emoji, user });
|
||||
onSubmit?.();
|
||||
|
||||
// re-focus the comment editor
|
||||
setTimeout(() => {
|
||||
editorRef.current?.focusAtStart();
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const commentDraft = draft;
|
||||
onSaveDraft(undefined);
|
||||
setForceRender((s) => ++s);
|
||||
|
||||
@@ -11,11 +11,24 @@ import type { Props as ImageUploadProps } from "./ImageUpload";
|
||||
import ImageUpload from "./ImageUpload";
|
||||
|
||||
type Props = ImageUploadProps & {
|
||||
/** The model whose avatar is displayed and updated by this input. */
|
||||
model: IAvatar;
|
||||
/** Alt text for the avatar image. */
|
||||
alt: string;
|
||||
/**
|
||||
* Whether to render the inline "Remove" button when the model has an
|
||||
* existing avatar. Defaults to true.
|
||||
*/
|
||||
showRemoveOption?: boolean;
|
||||
};
|
||||
|
||||
export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
|
||||
export default function ImageInput({
|
||||
model,
|
||||
onSuccess,
|
||||
alt,
|
||||
showRemoveOption = true,
|
||||
...rest
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
@@ -29,7 +42,7 @@ export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
|
||||
<Avatar
|
||||
model={model}
|
||||
size={AvatarSize.Upload}
|
||||
variant={AvatarVariant.Square}
|
||||
variant={AvatarVariant.Round}
|
||||
alt={alt}
|
||||
/>
|
||||
<Flex auto align="center" justify="center" className="upload">
|
||||
@@ -37,7 +50,7 @@ export default function ImageInput({ model, onSuccess, alt, ...rest }: Props) {
|
||||
</Flex>
|
||||
</ImageUpload>
|
||||
</ImageBox>
|
||||
{model.avatarUrl && (
|
||||
{model.avatarUrl && showRemoveOption && (
|
||||
<Button onClick={() => onSuccess(null)} neutral>
|
||||
{t("Remove")}
|
||||
</Button>
|
||||
@@ -55,7 +68,7 @@ const ImageBox = styled(Flex)`
|
||||
${avatarStyles};
|
||||
position: relative;
|
||||
font-size: 14px;
|
||||
border-radius: 8px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px ${s("backgroundSecondary")};
|
||||
background: ${s("background")};
|
||||
overflow: hidden;
|
||||
|
||||
@@ -49,7 +49,7 @@ const canonicalOrigin = canonicalUrl
|
||||
: window.location.origin;
|
||||
|
||||
type PathParams = {
|
||||
shareId: string;
|
||||
shareId?: string;
|
||||
collectionSlug?: string;
|
||||
documentSlug?: string;
|
||||
};
|
||||
|
||||
@@ -264,6 +264,12 @@ class UiStore {
|
||||
|
||||
@computed
|
||||
get activeCollectionId(): string | undefined {
|
||||
// Derive from the active document so it resolves even if the collection
|
||||
// loads after the document became active.
|
||||
const activeDocument = this.getPrimaryActiveModel<Document>(Document);
|
||||
if (activeDocument?.isActive && activeDocument.collectionId) {
|
||||
return activeDocument.collectionId;
|
||||
}
|
||||
return this.getPrimaryActiveModel<Collection>(Collection)?.id;
|
||||
}
|
||||
|
||||
|
||||
+9
-6
@@ -95,6 +95,7 @@
|
||||
"@radix-ui/react-one-time-password-field": "^0.1.8",
|
||||
"@radix-ui/react-popover": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.3",
|
||||
"@radix-ui/react-switch": "^1.2.6",
|
||||
"@radix-ui/react-tabs": "^1.1.13",
|
||||
"@radix-ui/react-toolbar": "^1.1.11",
|
||||
@@ -103,7 +104,7 @@
|
||||
"@sentry/node": "^7.120.4",
|
||||
"@sentry/react": "^7.120.4",
|
||||
"@simplewebauthn/browser": "^13.3.0",
|
||||
"@simplewebauthn/server": "^13.2.3",
|
||||
"@simplewebauthn/server": "^13.3.1",
|
||||
"@tanstack/react-table": "^8.21.3",
|
||||
"@tanstack/react-virtual": "^3.13.24",
|
||||
"@types/form-data": "^2.5.2",
|
||||
@@ -114,6 +115,7 @@
|
||||
"addressparser": "^1.0.1",
|
||||
"async-sema": "^3.1.1",
|
||||
"bull": "^4.16.5",
|
||||
"chrono-node": "^2.9.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"command-score": "^0.1.2",
|
||||
"compressorjs": "^1.3.0",
|
||||
@@ -213,7 +215,7 @@
|
||||
"react": "^17.0.2",
|
||||
"react-avatar-editor": "^13.0.2",
|
||||
"react-colorful": "^5.7.0",
|
||||
"react-day-picker": "^8.10.1",
|
||||
"react-day-picker": "^8.10.2",
|
||||
"react-dnd": "^16.0.1",
|
||||
"react-dnd-html5-backend": "^16.0.1",
|
||||
"react-dom": "^17.0.2",
|
||||
@@ -223,6 +225,7 @@
|
||||
"react-i18next": "^12.3.1",
|
||||
"react-merge-refs": "^2.1.1",
|
||||
"react-portal": "^4.3.0",
|
||||
"react-remove-scroll": "^2.7.2",
|
||||
"react-router-dom": "^5.3.4",
|
||||
"react-use-measure": "^2.1.7",
|
||||
"react-virtualized-auto-sizer": "^1.0.26",
|
||||
@@ -349,14 +352,14 @@
|
||||
"@types/validator": "^13.15.10",
|
||||
"@types/yauzl": "^2.10.3",
|
||||
"@types/yazl": "^2.4.6",
|
||||
"@vitest/ui": "^4.1.6",
|
||||
"@vitest/ui": "^4.1.8",
|
||||
"babel-plugin-module-resolver": "^5.0.3",
|
||||
"babel-plugin-styled-components": "^2.1.4",
|
||||
"babel-plugin-transform-inline-environment-variables": "^0.4.4",
|
||||
"babel-plugin-transform-typescript-metadata": "^0.4.0",
|
||||
"browserslist-to-esbuild": "^1.2.0",
|
||||
"concurrently": "^8.2.2",
|
||||
"discord-api-types": "^0.38.46",
|
||||
"discord-api-types": "^0.38.48",
|
||||
"husky": "^8.0.3",
|
||||
"i18next-parser": "^9.4.0",
|
||||
"ioredis-mock": "^8.13.1",
|
||||
@@ -366,7 +369,7 @@
|
||||
"oxlint": "1.66.0",
|
||||
"oxlint-tsgolint": "0.22.1",
|
||||
"postinstall-postinstall": "^2.1.0",
|
||||
"prettier": "^3.6.2",
|
||||
"prettier": "^3.8.3",
|
||||
"react-refresh": "^0.18.0",
|
||||
"rimraf": "^6.1.3",
|
||||
"rollup-plugin-webpack-stats": "2.1.11",
|
||||
@@ -391,6 +394,6 @@
|
||||
"ws@npm:~8.17.1": "^8.20.1",
|
||||
"uuid": "^11.1.1"
|
||||
},
|
||||
"version": "1.8.0",
|
||||
"version": "1.8.1",
|
||||
"packageManager": "yarn@4.11.0"
|
||||
}
|
||||
|
||||
@@ -102,6 +102,32 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
|
||||
const user =
|
||||
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
|
||||
|
||||
// The mail and userPrincipalName values come from the directory via the
|
||||
// Graph API and are owned by the organization, so an email sourced from
|
||||
// them is inherently trusted. Microsoft's mutable `email` token claim is
|
||||
// only trusted when a verification claim confirms it — xms_edov for
|
||||
// workforce tenants, or the standard email_verified claim in External ID
|
||||
// / OIDC scenarios.
|
||||
// https://learn.microsoft.com/en-us/entra/identity-platform/reference-claims-customization
|
||||
const directoryEmails = [
|
||||
profileResponse.mail,
|
||||
profileResponse.userPrincipalName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.map((value) => value.toLowerCase());
|
||||
|
||||
const verificationClaims = [
|
||||
profile.xms_edov,
|
||||
profile.email_verified,
|
||||
].filter((claim) => claim !== undefined);
|
||||
const emailVerified =
|
||||
directoryEmails.includes(email.toLowerCase()) ||
|
||||
(verificationClaims.length
|
||||
? verificationClaims.some(
|
||||
(claim) => claim === true || claim === "true"
|
||||
)
|
||||
: undefined);
|
||||
|
||||
const domain = parseEmail(email).domain;
|
||||
const subdomain = slugifyDomain(domain);
|
||||
|
||||
@@ -121,6 +147,7 @@ if (env.AZURE_CLIENT_ID && env.AZURE_CLIENT_SECRET) {
|
||||
user: {
|
||||
name: profile.name,
|
||||
email,
|
||||
emailVerified,
|
||||
avatarUrl: profile.picture,
|
||||
},
|
||||
authenticationProvider: {
|
||||
|
||||
@@ -201,6 +201,7 @@ if (env.DISCORD_CLIENT_ID && env.DISCORD_CLIENT_SECRET) {
|
||||
},
|
||||
user: {
|
||||
email,
|
||||
emailVerified: profile.verified,
|
||||
name: userName,
|
||||
language,
|
||||
avatarUrl: userAvatarUrl,
|
||||
|
||||
@@ -68,16 +68,18 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
try {
|
||||
// "domain" is the Google Workspaces domain
|
||||
const domain = profile._json.hd;
|
||||
const team = await getTeamFromContext(context);
|
||||
let team = await getTeamFromContext(context);
|
||||
const client = getClientFromOAuthState(context);
|
||||
const user =
|
||||
context.state?.auth?.user ?? (await getUserFromOAuthState(context));
|
||||
|
||||
// No profile domain means personal gmail account
|
||||
// No team implies the request came from the apex domain
|
||||
// This combination is always an error
|
||||
// No profile domain means a personal gmail account, and no team means
|
||||
// the request came from the apex domain rather than a workspace
|
||||
// subdomain. We can't infer the workspace from the domain, so resolve
|
||||
// it from the verified email's existing accounts instead.
|
||||
if (!domain && !team) {
|
||||
const userExists = await User.count({
|
||||
const existingAccounts = await User.findAll({
|
||||
attributes: ["id", "teamId"],
|
||||
where: { email: profile.email.toLowerCase() },
|
||||
include: [
|
||||
{
|
||||
@@ -86,14 +88,23 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
},
|
||||
],
|
||||
});
|
||||
const teamIds = new Set(
|
||||
existingAccounts.map((account) => account.teamId)
|
||||
);
|
||||
|
||||
// Users cannot create a team with personal gmail accounts
|
||||
if (!userExists) {
|
||||
// A personal gmail account cannot be used to create a new workspace.
|
||||
if (teamIds.size === 0) {
|
||||
throw GmailAccountCreationError();
|
||||
}
|
||||
|
||||
// To log-in with a personal account, users must specify a team subdomain
|
||||
throw TeamDomainRequiredError();
|
||||
// When the email belongs to more than one workspace it is ambiguous
|
||||
// which to sign into, so the user must start from its subdomain.
|
||||
if (teamIds.size > 1) {
|
||||
throw TeamDomainRequiredError();
|
||||
}
|
||||
|
||||
// Belongs to exactly one workspace — resolve it and sign in there.
|
||||
team = existingAccounts[0].team;
|
||||
}
|
||||
|
||||
// remove the TLD and form a subdomain from the remaining
|
||||
@@ -127,6 +138,8 @@ if (env.GOOGLE_CLIENT_ID && env.GOOGLE_CLIENT_SECRET) {
|
||||
},
|
||||
user: {
|
||||
email: profile.email,
|
||||
// Google only returns confirmed workspace email addresses.
|
||||
emailVerified: true,
|
||||
name: profile.displayName,
|
||||
language,
|
||||
avatarUrl,
|
||||
|
||||
@@ -105,6 +105,7 @@ export function createOIDCRouter(
|
||||
|
||||
return decoded as {
|
||||
email?: string;
|
||||
email_verified?: boolean | string;
|
||||
preferred_username?: string;
|
||||
sub?: string;
|
||||
};
|
||||
@@ -122,6 +123,15 @@ export function createOIDCRouter(
|
||||
);
|
||||
}
|
||||
|
||||
// The email_verified claim is part of the OIDC standard claims.
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
||||
const emailVerifiedClaim =
|
||||
profile.email_verified ?? token.email_verified;
|
||||
const emailVerified =
|
||||
emailVerifiedClaim === undefined
|
||||
? undefined
|
||||
: emailVerifiedClaim === true || emailVerifiedClaim === "true";
|
||||
|
||||
const team = await getTeamFromContext(context);
|
||||
const client = getClientFromOAuthState(context);
|
||||
const user =
|
||||
@@ -206,6 +216,7 @@ export function createOIDCRouter(
|
||||
user: {
|
||||
name,
|
||||
email,
|
||||
emailVerified,
|
||||
avatarUrl,
|
||||
},
|
||||
authenticationProvider: {
|
||||
|
||||
@@ -110,6 +110,8 @@ if (env.SLACK_CLIENT_ID && env.SLACK_CLIENT_SECRET) {
|
||||
user: {
|
||||
name: profile.user.name,
|
||||
email: profile.user.email,
|
||||
// Slack only returns confirmed workspace email addresses.
|
||||
emailVerified: true,
|
||||
avatarUrl: profile.user.image_192,
|
||||
},
|
||||
authenticationProvider: {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useTranslation, Trans } from "react-i18next";
|
||||
import styled from "styled-components";
|
||||
import { randomString } from "@shared/random";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { WebhookSubscriptionValidation } from "@shared/validations";
|
||||
import type WebhookSubscription from "~/models/WebhookSubscription";
|
||||
import Button from "~/components/Button";
|
||||
import Input from "~/components/Input";
|
||||
@@ -229,6 +230,7 @@ function WebhookSubscriptionForm({ handleSubmit, webhookSubscription }: Props) {
|
||||
required
|
||||
flex
|
||||
pattern={isCloudHosted ? "https://.*" : "https?://.*"}
|
||||
maxLength={WebhookSubscriptionValidation.maxUrlLength}
|
||||
placeholder="https://…"
|
||||
label={t("URL")}
|
||||
error={
|
||||
@@ -238,7 +240,10 @@ function WebhookSubscriptionForm({ handleSubmit, webhookSubscription }: Props) {
|
||||
)
|
||||
: undefined
|
||||
}
|
||||
{...register("url", { required: true })}
|
||||
{...register("url", {
|
||||
required: true,
|
||||
maxLength: WebhookSubscriptionValidation.maxUrlLength,
|
||||
})}
|
||||
/>
|
||||
<Input
|
||||
flex
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { z } from "zod";
|
||||
import { WebhookSubscriptionValidation } from "@shared/validations";
|
||||
import env from "@server/env";
|
||||
import { WebhookSubscription } from "@server/models";
|
||||
import { BaseSchema } from "@server/routes/api/schema";
|
||||
|
||||
const webhookUrl = z
|
||||
.url()
|
||||
.max(WebhookSubscriptionValidation.maxUrlLength, {
|
||||
error: `Webhook url must be ${WebhookSubscriptionValidation.maxUrlLength} characters or less`,
|
||||
})
|
||||
.refine((val) => !env.isCloudHosted || val.startsWith("https://"), {
|
||||
error: "Webhook url must use https",
|
||||
});
|
||||
|
||||
@@ -86,4 +86,51 @@ describe("WebhookProcessor", () => {
|
||||
subscriptionId: subscriptionTwo.id,
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldQueue", () => {
|
||||
it("returns true when a matching subscription exists", async () => {
|
||||
const subscription = await buildWebhookSubscription({
|
||||
url: "http://example.com",
|
||||
events: ["users"],
|
||||
});
|
||||
const event: UserEvent = {
|
||||
name: "users.signin",
|
||||
userId: subscription.createdById,
|
||||
teamId: subscription.teamId,
|
||||
actorId: subscription.createdById,
|
||||
ip,
|
||||
};
|
||||
|
||||
expect(await WebhookProcessor.shouldQueue(event)).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false when no subscription matches the event", async () => {
|
||||
const subscription = await buildWebhookSubscription({
|
||||
url: "http://example.com",
|
||||
events: ["documents.create"],
|
||||
});
|
||||
const event: UserEvent = {
|
||||
name: "users.signin",
|
||||
userId: subscription.createdById,
|
||||
teamId: subscription.teamId,
|
||||
actorId: subscription.createdById,
|
||||
ip,
|
||||
};
|
||||
|
||||
expect(await WebhookProcessor.shouldQueue(event)).toBe(false);
|
||||
});
|
||||
|
||||
it("returns false when the team has no subscriptions", async () => {
|
||||
const user = await buildUser();
|
||||
const event: UserEvent = {
|
||||
name: "users.signin",
|
||||
userId: user.id,
|
||||
teamId: user.teamId,
|
||||
actorId: user.id,
|
||||
ip,
|
||||
};
|
||||
|
||||
expect(await WebhookProcessor.shouldQueue(event)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,20 +6,39 @@ import DeliverWebhookTask from "../tasks/DeliverWebhookTask";
|
||||
export default class WebhookProcessor extends BaseProcessor {
|
||||
static applicableEvents: ["*"] = ["*"];
|
||||
|
||||
/**
|
||||
* Only queue an event when the team has an enabled webhook subscription that
|
||||
* matches it. The vast majority of events belong to teams with no applicable
|
||||
* subscriptions, so this avoids creating and running an empty job for them.
|
||||
*
|
||||
* @param event The event about to be queued.
|
||||
* @returns true if a matching subscription exists.
|
||||
*/
|
||||
static async shouldQueue(event: Event): Promise<boolean> {
|
||||
if (!event.teamId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const subscriptions = await WebhookSubscription.findEnabledByTeamId(
|
||||
event.teamId
|
||||
);
|
||||
|
||||
return subscriptions.some((subscription) =>
|
||||
WebhookSubscription.matchEvent(subscription.events, event.name)
|
||||
);
|
||||
}
|
||||
|
||||
async perform(event: Event) {
|
||||
if (!event.teamId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const webhookSubscriptions = await WebhookSubscription.findAll({
|
||||
where: {
|
||||
enabled: true,
|
||||
teamId: event.teamId,
|
||||
},
|
||||
});
|
||||
const subscriptions = await WebhookSubscription.findEnabledByTeamId(
|
||||
event.teamId
|
||||
);
|
||||
|
||||
const applicableSubscriptions = webhookSubscriptions.filter((webhook) =>
|
||||
webhook.validForEvent(event)
|
||||
const applicableSubscriptions = subscriptions.filter((subscription) =>
|
||||
WebhookSubscription.matchEvent(subscription.events, event.name)
|
||||
);
|
||||
|
||||
await Promise.all(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { FetchError } from "node-fetch";
|
||||
import {
|
||||
http,
|
||||
HttpResponse,
|
||||
@@ -12,7 +13,9 @@ import {
|
||||
buildWebhookSubscription,
|
||||
} from "@server/test/factories";
|
||||
import type { UserEvent } from "@server/types";
|
||||
import DeliverWebhookTask from "./DeliverWebhookTask";
|
||||
import DeliverWebhookTask, {
|
||||
isExpectedNetworkError,
|
||||
} from "./DeliverWebhookTask";
|
||||
|
||||
const ip = "127.0.0.1";
|
||||
|
||||
@@ -243,3 +246,63 @@ describe("DeliverWebhookTask", () => {
|
||||
expect(delivery.responseBody).toEqual('{"message":"Failure"}');
|
||||
});
|
||||
});
|
||||
|
||||
describe("isExpectedNetworkError", () => {
|
||||
test("treats node-fetch FetchError as expected", () => {
|
||||
expect(
|
||||
isExpectedNetworkError(
|
||||
new FetchError("request to https://example.com failed", "system")
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("treats raw socket errors as expected", () => {
|
||||
expect(isExpectedNetworkError(new Error("socket hang up"))).toBe(true);
|
||||
expect(
|
||||
isExpectedNetworkError(
|
||||
Object.assign(new Error("read ECONNRESET"), { code: "ECONNRESET" })
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("treats connection error codes as expected", () => {
|
||||
for (const code of [
|
||||
"ECONNREFUSED",
|
||||
"ETIMEDOUT",
|
||||
"EHOSTUNREACH",
|
||||
"ENOTFOUND",
|
||||
"EAI_AGAIN",
|
||||
]) {
|
||||
expect(
|
||||
isExpectedNetworkError(Object.assign(new Error("boom"), { code }))
|
||||
).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("treats invalid certificate errors as expected", () => {
|
||||
expect(
|
||||
isExpectedNetworkError(
|
||||
Object.assign(new Error("self signed certificate"), {
|
||||
code: "DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||
})
|
||||
)
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("treats the request timeout thrown by the fetch wrapper as expected", () => {
|
||||
expect(
|
||||
isExpectedNetworkError(new Error("Request timeout after 5000ms"))
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
test("does not treat unrelated errors as expected", () => {
|
||||
expect(
|
||||
isExpectedNetworkError(new TypeError("undefined is not a function"))
|
||||
).toBe(false);
|
||||
expect(
|
||||
isExpectedNetworkError(new Error("Cannot read property foo of undefined"))
|
||||
).toBe(false);
|
||||
expect(isExpectedNetworkError("socket hang up")).toBe(false);
|
||||
expect(isExpectedNetworkError(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,6 +78,56 @@ function assertUnreachable(event: never) {
|
||||
Logger.warn(`DeliverWebhookTask did not handle ${(event as Event).name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Node connection-level error codes that are expected when delivering to
|
||||
* arbitrary, user-supplied webhook URLs. These indicate a misconfigured or
|
||||
* unreachable destination rather than a bug in Outline.
|
||||
*/
|
||||
const expectedNetworkErrorCodes = new Set([
|
||||
"ECONNRESET",
|
||||
"ECONNREFUSED",
|
||||
"ECONNABORTED",
|
||||
"ETIMEDOUT",
|
||||
"EHOSTUNREACH",
|
||||
"ENETUNREACH",
|
||||
"ENOTFOUND",
|
||||
"EAI_AGAIN",
|
||||
"EPIPE",
|
||||
"EPROTO",
|
||||
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||
"SELF_SIGNED_CERT_IN_CHAIN",
|
||||
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
||||
"CERT_HAS_EXPIRED",
|
||||
"ERR_TLS_CERT_ALTNAME_INVALID",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Determine whether an error thrown while delivering a webhook is an expected
|
||||
* network failure caused by the user-supplied destination URL (connection
|
||||
* reset, timeout, unreachable host, invalid certificate, etc) rather than an
|
||||
* unexpected bug. Such failures are noisy and do not need error tracking.
|
||||
*
|
||||
* @param err The error that occurred during delivery.
|
||||
* @returns true if the error is an expected network failure.
|
||||
*/
|
||||
export function isExpectedNetworkError(err: unknown): boolean {
|
||||
if (err instanceof FetchError) {
|
||||
return true;
|
||||
}
|
||||
if (err instanceof Error) {
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code && expectedNetworkErrorCodes.has(code)) {
|
||||
return true;
|
||||
}
|
||||
// node-fetch surfaces some low-level socket failures (and our fetch wrapper
|
||||
// converts aborted requests into timeouts) without a structured code.
|
||||
return /socket hang up|request timeout|network|ECONNRESET/i.test(
|
||||
err.message
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type Props = {
|
||||
subscriptionId: string;
|
||||
event: Event;
|
||||
@@ -765,7 +815,7 @@ export default class DeliverWebhookTask extends BaseTask<Props> {
|
||||
});
|
||||
status = response.ok ? "success" : "failed";
|
||||
} catch (err) {
|
||||
if (err instanceof FetchError && env.isCloudHosted) {
|
||||
if (isExpectedNetworkError(err) && env.isCloudHosted) {
|
||||
Logger.warn(`Failed to send webhook: ${err.message}`, {
|
||||
event,
|
||||
deliveryId: delivery.id,
|
||||
|
||||
@@ -115,6 +115,7 @@ describe("accountProvisioner", () => {
|
||||
user: {
|
||||
name: userWithoutAuth.name,
|
||||
email,
|
||||
emailVerified: true,
|
||||
avatarUrl: userWithoutAuth.avatarUrl,
|
||||
},
|
||||
team: {
|
||||
@@ -138,6 +139,54 @@ describe("accountProvisioner", () => {
|
||||
expect(isNewUser).toEqual(false);
|
||||
});
|
||||
|
||||
it("should not allow authentication by email matching when email is unverified", async () => {
|
||||
const subdomain = faker.internet.domainWord();
|
||||
const existingTeam = await buildTeam({
|
||||
subdomain,
|
||||
});
|
||||
|
||||
const providers = await existingTeam.$get("authenticationProviders");
|
||||
const authenticationProvider = providers[0];
|
||||
const email = faker.internet.email();
|
||||
const userWithoutAuth = await buildUser({
|
||||
email,
|
||||
teamId: existingTeam.id,
|
||||
authentications: [],
|
||||
});
|
||||
|
||||
let error;
|
||||
try {
|
||||
await accountProvisioner(ctx, {
|
||||
user: {
|
||||
name: userWithoutAuth.name,
|
||||
email,
|
||||
emailVerified: false,
|
||||
avatarUrl: userWithoutAuth.avatarUrl,
|
||||
},
|
||||
team: {
|
||||
teamId: existingTeam.id,
|
||||
name: existingTeam.name,
|
||||
avatarUrl: existingTeam.avatarUrl,
|
||||
subdomain,
|
||||
},
|
||||
authenticationProvider: {
|
||||
name: authenticationProvider.name,
|
||||
providerId: authenticationProvider.providerId,
|
||||
},
|
||||
authentication: {
|
||||
providerId: randomUUID(),
|
||||
accessToken: "123",
|
||||
scopes: ["read"],
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
error = err;
|
||||
}
|
||||
|
||||
expect(error).toBeTruthy();
|
||||
expect(error.id).toEqual("invalid_authentication");
|
||||
});
|
||||
|
||||
it("should throw an error when authentication provider is disabled", async () => {
|
||||
const existingTeam = await buildTeam();
|
||||
const providers = await existingTeam.$get("authenticationProviders");
|
||||
@@ -250,6 +299,7 @@ describe("accountProvisioner", () => {
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email,
|
||||
emailVerified: true,
|
||||
avatarUrl: faker.image.avatar(),
|
||||
},
|
||||
team: {
|
||||
@@ -291,6 +341,7 @@ describe("accountProvisioner", () => {
|
||||
user: {
|
||||
name: "Jenny Tester",
|
||||
email,
|
||||
emailVerified: true,
|
||||
avatarUrl: faker.image.avatar(),
|
||||
},
|
||||
team: {
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
Event,
|
||||
Team,
|
||||
} from "@server/models";
|
||||
import AuthenticationHelper from "@server/models/helpers/AuthenticationHelper";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
import { PluginManager } from "@server/utils/PluginManager";
|
||||
@@ -34,6 +35,8 @@ type Props = {
|
||||
name: string;
|
||||
/** The email address of the user */
|
||||
email: string;
|
||||
/** Whether the provider has verified the user owns the email address */
|
||||
emailVerified?: boolean;
|
||||
/** The public url of an image representing the user */
|
||||
avatarUrl?: string | null;
|
||||
/** The language of the user, if known */
|
||||
@@ -178,6 +181,10 @@ async function accountProvisioner(
|
||||
result = await userProvisioner(ctx, {
|
||||
name: userParams.name,
|
||||
email: userParams.email,
|
||||
emailVerified: userParams.emailVerified,
|
||||
authenticationProviderName: AuthenticationHelper.getProviderName(
|
||||
authenticationProviderParams.name
|
||||
),
|
||||
language: userParams.language,
|
||||
role: isNewTeam ? UserRole.Admin : undefined,
|
||||
avatarUrl: userParams.avatarUrl,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Node } from "prosemirror-model";
|
||||
import { prosemirrorToYDoc } from "y-prosemirror";
|
||||
import { schema } from "@server/editor";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
import documentCollaborativeUpdater from "./documentCollaborativeUpdater";
|
||||
|
||||
describe("documentCollaborativeUpdater", () => {
|
||||
const buildYDoc = (content: object[]) => {
|
||||
const doc = Node.fromJSON(schema, { type: "doc", content });
|
||||
return prosemirrorToYDoc(doc, "default");
|
||||
};
|
||||
|
||||
it("persists canonical JSON without empty attrs on marks", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const ydoc = buildYDoc([
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "Deciders:",
|
||||
marks: [{ type: "strong" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
await documentCollaborativeUpdater({
|
||||
documentId: document.id,
|
||||
ydoc,
|
||||
sessionCollaboratorIds: [user.id],
|
||||
isLastConnection: true,
|
||||
clientVersion: null,
|
||||
});
|
||||
|
||||
await document.reload();
|
||||
|
||||
const marks = JSON.stringify(document.content).match(/"attrs":\{\}/g);
|
||||
expect(marks).toBeNull();
|
||||
|
||||
const text = document.content?.content?.[0]?.content?.[0];
|
||||
expect(text?.marks).toEqual([{ type: "strong" }]);
|
||||
});
|
||||
|
||||
it("does not persist when content is unchanged", async () => {
|
||||
const user = await buildUser();
|
||||
const content = [
|
||||
{
|
||||
type: "paragraph",
|
||||
content: [{ type: "text", text: "Hello" }],
|
||||
},
|
||||
];
|
||||
const ydoc = buildYDoc(content);
|
||||
|
||||
const document = await buildDocument({
|
||||
teamId: user.teamId,
|
||||
userId: user.id,
|
||||
content: Node.fromJSON(schema, { type: "doc", content }).toJSON(),
|
||||
});
|
||||
|
||||
const updatedAt = document.updatedAt;
|
||||
|
||||
await documentCollaborativeUpdater({
|
||||
documentId: document.id,
|
||||
ydoc,
|
||||
sessionCollaboratorIds: [user.id],
|
||||
isLastConnection: true,
|
||||
clientVersion: null,
|
||||
});
|
||||
|
||||
await document.reload();
|
||||
expect(document.updatedAt).toEqual(updatedAt);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,10 @@
|
||||
import isEqual from "fast-deep-equal";
|
||||
import { uniq } from "es-toolkit/compat";
|
||||
import { Node } from "prosemirror-model";
|
||||
import { yDocToProsemirrorJSON } from "y-prosemirror";
|
||||
import * as Y from "yjs";
|
||||
import type { ProsemirrorData } from "@shared/types";
|
||||
import { schema } from "@server/editor";
|
||||
import Logger from "@server/logging/Logger";
|
||||
import { Document, Event } from "@server/models";
|
||||
import { sequelize } from "@server/storage/database";
|
||||
@@ -50,7 +52,14 @@ export default async function documentCollaborativeUpdater({
|
||||
});
|
||||
|
||||
const state = Y.encodeStateAsUpdate(ydoc);
|
||||
const content = yDocToProsemirrorJSON(ydoc, "default") as ProsemirrorData;
|
||||
|
||||
// Round-trip through the schema so the stored JSON is canonical. The raw
|
||||
// y-prosemirror output includes empty `attrs: {}` on every mark, and outputs
|
||||
// properties in a different order - resulting in spurious "edits"
|
||||
const content = Node.fromJSON(
|
||||
schema,
|
||||
yDocToProsemirrorJSON(ydoc, "default")
|
||||
).toJSON() as ProsemirrorData;
|
||||
const isUnchanged = isEqual(document.content, content);
|
||||
const isDeleted = !!document.deletedAt;
|
||||
const lastModifiedById = isDeleted
|
||||
|
||||
@@ -24,7 +24,7 @@ async function documentMover(
|
||||
ctx: APIContext,
|
||||
{
|
||||
document,
|
||||
collectionId = null,
|
||||
collectionId,
|
||||
parentDocumentId = null,
|
||||
// convert undefined to null so parentId comparison treats them as equal
|
||||
index,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { traceFunction } from "@server/logging/tracing";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import { Collection, Revision } from "@server/models";
|
||||
import type { Document } from "@server/models";
|
||||
import { authorize } from "@server/policies";
|
||||
import type { APIContext } from "@server/types";
|
||||
import { assertPresent } from "@server/validation";
|
||||
|
||||
type Props = {
|
||||
/** The document to restore. Must be loaded with `paranoid: false`. */
|
||||
document: Document;
|
||||
/** Destination collection to restore into. Defaults to the original collection. */
|
||||
collectionId?: string | null;
|
||||
/** Revision to restore the document's content from, when not archived or deleted. */
|
||||
revisionId?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Restores a previously archived or deleted document, or restores a document's
|
||||
* content to a specific revision. Re-attaches the document to the destination
|
||||
* collection's structure when applicable and authorizes the acting user.
|
||||
*
|
||||
* @param ctx - the API context, providing the acting user and transaction.
|
||||
* @param props - the document and restore options.
|
||||
* @returns the restored document.
|
||||
* @throws ValidationError if the destination collection is not active.
|
||||
*/
|
||||
async function documentRestorer(
|
||||
ctx: APIContext,
|
||||
{ document, collectionId, revisionId }: Props
|
||||
): Promise<Document> {
|
||||
const { user } = ctx.state.auth;
|
||||
const { transaction } = ctx.state;
|
||||
|
||||
const sourceCollectionId = document.collectionId;
|
||||
const destCollectionId = collectionId ?? sourceCollectionId;
|
||||
|
||||
const srcCollection = sourceCollectionId
|
||||
? await Collection.findByPk(sourceCollectionId, {
|
||||
userId: user.id,
|
||||
includeDocumentStructure: true,
|
||||
paranoid: false,
|
||||
transaction,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const destCollection = destCollectionId
|
||||
? await Collection.findByPk(destCollectionId, {
|
||||
userId: user.id,
|
||||
includeDocumentStructure: true,
|
||||
transaction,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!destCollection?.isActive) {
|
||||
throw ValidationError(
|
||||
"Unable to restore, the collection may have been deleted or archived"
|
||||
);
|
||||
}
|
||||
|
||||
if (sourceCollectionId && sourceCollectionId !== destCollection.id) {
|
||||
authorize(user, "updateDocument", srcCollection);
|
||||
await srcCollection?.removeDocumentInStructure(document, {
|
||||
save: true,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
if (document.deletedAt) {
|
||||
authorize(user, "restore", document);
|
||||
authorize(user, "updateDocument", destCollection);
|
||||
|
||||
// restore a previously deleted document
|
||||
await document.restoreTo(ctx, { collectionId: destCollection.id });
|
||||
} else if (document.archivedAt) {
|
||||
authorize(user, "unarchive", document);
|
||||
authorize(user, "updateDocument", destCollection);
|
||||
|
||||
// restore a previously archived document
|
||||
await document.restoreTo(ctx, { collectionId: destCollection.id });
|
||||
} else if (revisionId) {
|
||||
// restore a document to a specific revision
|
||||
authorize(user, "update", document);
|
||||
const revision = await Revision.findByPk(revisionId, { transaction });
|
||||
authorize(document, "restore", revision);
|
||||
|
||||
await document.restoreFromRevision(revision);
|
||||
await document.saveWithCtx(ctx, undefined, { name: "restore" });
|
||||
} else {
|
||||
assertPresent(revisionId, "revisionId is required");
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
export default traceFunction({
|
||||
spanName: "documentRestorer",
|
||||
})(documentRestorer);
|
||||
@@ -3,6 +3,7 @@ import * as Y from "yjs";
|
||||
import { TextEditMode } from "@shared/types";
|
||||
import { APIUpdateExtension } from "@server/collaboration/APIUpdateExtension";
|
||||
import { Event } from "@server/models";
|
||||
import { parser } from "@server/editor";
|
||||
import { DocumentHelper } from "@server/models/helpers/DocumentHelper";
|
||||
import { ProsemirrorHelper } from "@server/models/helpers/ProsemirrorHelper";
|
||||
import { buildDocument, buildUser } from "@server/test/factories";
|
||||
@@ -1481,5 +1482,82 @@ describe("documentUpdater", () => {
|
||||
"Second item"
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply a link when wrapping existing table cell text", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({ teamId: user.teamId });
|
||||
|
||||
document.content = parser
|
||||
.parse("| Name | Notes |\n|------|-------|\n| Alpha | see |\n")
|
||||
.toJSON();
|
||||
await document.save();
|
||||
|
||||
const result = DocumentHelper.applyMarkdownToDocument(
|
||||
document,
|
||||
"[see](https://example.com/docs)",
|
||||
TextEditMode.Patch,
|
||||
"see"
|
||||
);
|
||||
|
||||
// The cell text is unchanged but should now carry a link mark — it must
|
||||
// not be silently dropped during the merge.
|
||||
const cellText =
|
||||
result.content!.content![0].content![1].content![1].content![0]
|
||||
.content![0];
|
||||
expect(cellText.text).toEqual("see");
|
||||
expect(cellText.marks).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "link",
|
||||
attrs: expect.objectContaining({ href: "https://example.com/docs" }),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("should preserve other table cells when adding a link to one cell", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({ teamId: user.teamId });
|
||||
|
||||
document.content = parser
|
||||
.parse(
|
||||
"| Name | Notes |\n|------|-------|\n| Alpha | see |\n| Beta | other |\n"
|
||||
)
|
||||
.toJSON();
|
||||
await document.save();
|
||||
|
||||
// Capture the untouched (Beta) row BEFORE patching so we can assert its
|
||||
// structure and attrs are preserved exactly, not just its text.
|
||||
const beforeDoc = DocumentHelper.toProsemirror(document).toJSON();
|
||||
const untouchedRow = beforeDoc.content[0].content[2];
|
||||
|
||||
const result = DocumentHelper.applyMarkdownToDocument(
|
||||
document,
|
||||
"see [docs](https://example.com/d)",
|
||||
TextEditMode.Patch,
|
||||
"see"
|
||||
);
|
||||
|
||||
// The patched cell gained the link
|
||||
expect(result.text).toContain("[docs](https://example.com/d)");
|
||||
// The untouched row node must remain identical
|
||||
expect(result.content!.content![0].content![2]).toEqual(untouchedRow);
|
||||
});
|
||||
|
||||
it("should apply a mark when wrapping existing list item text", async () => {
|
||||
const user = await buildUser();
|
||||
const document = await buildDocument({ teamId: user.teamId });
|
||||
|
||||
document.content = parser.parse("- clickme\n- other\n").toJSON();
|
||||
await document.save();
|
||||
|
||||
const result = DocumentHelper.applyMarkdownToDocument(
|
||||
document,
|
||||
"[clickme](https://example.com)",
|
||||
TextEditMode.Patch,
|
||||
"clickme"
|
||||
);
|
||||
|
||||
expect(result.text).toContain("[clickme](https://example.com)");
|
||||
expect(result.text).toContain("other");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,6 +57,7 @@ describe("userProvisioner", () => {
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: existing.name,
|
||||
email,
|
||||
emailVerified: true,
|
||||
avatarUrl: existing.avatarUrl,
|
||||
teamId: existing.teamId,
|
||||
authentication: {
|
||||
@@ -77,6 +78,34 @@ describe("userProvisioner", () => {
|
||||
expect(isNewUser).toEqual(false);
|
||||
});
|
||||
|
||||
it("should not match an existing user by email when email is unverified", async () => {
|
||||
const team = await buildTeam();
|
||||
const teamAuthProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = teamAuthProviders[0];
|
||||
|
||||
const email = "mynam@email.com";
|
||||
await buildUser({
|
||||
email,
|
||||
teamId: team.id,
|
||||
authentications: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
userProvisioner(ctx, {
|
||||
name: "Imposter",
|
||||
email,
|
||||
emailVerified: false,
|
||||
teamId: team.id,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
providerId: randomUUID(),
|
||||
accessToken: "123",
|
||||
scopes: ["read"],
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("has not been verified by");
|
||||
});
|
||||
|
||||
it("should add authentication provider to invited users", async () => {
|
||||
const team = await buildTeam({ inviteRequired: true });
|
||||
const teamAuthProviders = await team.$get("authenticationProviders");
|
||||
@@ -91,6 +120,7 @@ describe("userProvisioner", () => {
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: existing.name,
|
||||
email,
|
||||
emailVerified: true,
|
||||
avatarUrl: existing.avatarUrl,
|
||||
teamId: existing.teamId,
|
||||
authentication: {
|
||||
@@ -264,6 +294,7 @@ describe("userProvisioner", () => {
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: invite.name,
|
||||
email: "invite@ExamPle.com",
|
||||
emailVerified: true,
|
||||
teamId: invite.teamId,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
@@ -295,6 +326,7 @@ describe("userProvisioner", () => {
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: invite.name,
|
||||
email: "external@ExamPle.com", // ensure that email is case insensistive
|
||||
emailVerified: true,
|
||||
teamId: invite.teamId,
|
||||
});
|
||||
const { user, authentication, isNewUser } = result;
|
||||
@@ -340,6 +372,7 @@ describe("userProvisioner", () => {
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: faker.person.fullName(),
|
||||
email,
|
||||
emailVerified: true,
|
||||
teamId: team.id,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
@@ -357,6 +390,36 @@ describe("userProvisioner", () => {
|
||||
expect(isNewUser).toEqual(true);
|
||||
});
|
||||
|
||||
it("should reject an unverified email when the team has allowed domains", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const domain = faker.internet.domainName();
|
||||
await TeamDomain.create({
|
||||
teamId: team.id,
|
||||
name: domain,
|
||||
createdById: admin.id,
|
||||
});
|
||||
|
||||
const authenticationProviders = await team.$get("authenticationProviders");
|
||||
const authenticationProvider = authenticationProviders[0];
|
||||
const email = faker.internet.email({ provider: domain });
|
||||
|
||||
await expect(
|
||||
userProvisioner(ctx, {
|
||||
name: faker.person.fullName(),
|
||||
email,
|
||||
emailVerified: false,
|
||||
teamId: team.id,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
providerId: "fake-service-id",
|
||||
accessToken: "123",
|
||||
scopes: ["read"],
|
||||
},
|
||||
})
|
||||
).rejects.toThrow("has not been verified by");
|
||||
});
|
||||
|
||||
it("should create a user from allowed domain with emailMatchOnly", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
@@ -372,6 +435,7 @@ describe("userProvisioner", () => {
|
||||
const result = await userProvisioner(ctx, {
|
||||
name: "Test Name",
|
||||
email,
|
||||
emailVerified: true,
|
||||
teamId: team.id,
|
||||
});
|
||||
const { user, authentication, isNewUser } = result;
|
||||
@@ -408,6 +472,7 @@ describe("userProvisioner", () => {
|
||||
userProvisioner(ctx, {
|
||||
name: "Bad Domain User",
|
||||
email: faker.internet.email(),
|
||||
emailVerified: true,
|
||||
teamId: team.id,
|
||||
authentication: {
|
||||
authenticationProviderId: authenticationProvider.id,
|
||||
|
||||
@@ -25,6 +25,13 @@ type Props = {
|
||||
name: string;
|
||||
/** The email address of the user */
|
||||
email: string;
|
||||
/**
|
||||
* Whether the provider has verified the user owns the email address.
|
||||
* Matching an existing account by email only happens when explicitly true.
|
||||
*/
|
||||
emailVerified?: boolean;
|
||||
/** The display name of the authentication provider, eg "Google". */
|
||||
authenticationProviderName?: string;
|
||||
/** The language of the user, if known */
|
||||
language?: string;
|
||||
/** The role for new user, Member if none is provided */
|
||||
@@ -54,7 +61,17 @@ type Props = {
|
||||
|
||||
export default async function userProvisioner(
|
||||
ctx: APIContext,
|
||||
{ name, email, role, language, avatarUrl, teamId, authentication }: Props
|
||||
{
|
||||
name,
|
||||
email,
|
||||
emailVerified,
|
||||
authenticationProviderName,
|
||||
role,
|
||||
language,
|
||||
avatarUrl,
|
||||
teamId,
|
||||
authentication,
|
||||
}: Props
|
||||
): Promise<UserProvisionerResult> {
|
||||
const auth = authentication
|
||||
? await UserAuthentication.findOne({
|
||||
@@ -135,6 +152,14 @@ export default async function userProvisioner(
|
||||
attributes: ["defaultUserRole", "inviteRequired", "id"],
|
||||
});
|
||||
|
||||
// Unverified emails cannot match an existing account or pass allow listed domains
|
||||
if (emailVerified !== true && (existingUser || team?.allowedDomains.length)) {
|
||||
const providerName = authenticationProviderName ?? "your identity provider";
|
||||
throw InvalidAuthenticationError(
|
||||
`Your email address has not been verified by ${providerName}. Please verify your email and try signing in again.`
|
||||
);
|
||||
}
|
||||
|
||||
// We have an existing user, so we need to update it with our
|
||||
// new details and count this as a new user creation.
|
||||
if (existingUser) {
|
||||
|
||||
@@ -12,17 +12,37 @@ import { baseStyles } from "./templates/components/EmailLayout";
|
||||
const useTestEmailService = env.isDevelopment && !env.SMTP_USERNAME;
|
||||
|
||||
type SendMailOptions = {
|
||||
/** The email address being sent to. */
|
||||
to: string;
|
||||
/** The address the email is sent from. */
|
||||
from: EmailAddress;
|
||||
/** An address to set as the reply-to for the email. */
|
||||
replyTo?: string;
|
||||
/** A unique identifier for the message, used for threading. */
|
||||
messageId?: string;
|
||||
/** Message IDs this email is a reply to, used for threading. */
|
||||
references?: string[];
|
||||
/** The subject line of the email. */
|
||||
subject: string;
|
||||
/** Preview text shown in email client list views. */
|
||||
previewText?: string;
|
||||
/** The plain-text version of the email body. */
|
||||
text: string;
|
||||
/** The React element rendered to produce the HTML body. */
|
||||
component: JSX.Element;
|
||||
/** Additional CSS to inject into the head of the email. */
|
||||
headCSS?: string;
|
||||
/** The URL used to unsubscribe from these emails. */
|
||||
unsubscribeUrl?: string;
|
||||
/** Tags used for reporting, where supported by the email provider. */
|
||||
tags?: EmailTags;
|
||||
};
|
||||
|
||||
type EmailTags = {
|
||||
/** The broad category of the email, e.g. "notification". */
|
||||
category: string;
|
||||
/** The specific template name, e.g. "InviteEmail". */
|
||||
template: string;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -167,6 +187,7 @@ export class Mailer {
|
||||
references: data.references,
|
||||
inReplyTo: data.references?.at(-1),
|
||||
subject: data.subject,
|
||||
headers: this.tagHeaders(data.tags),
|
||||
html,
|
||||
text: data.text,
|
||||
list: data.unsubscribeUrl
|
||||
@@ -200,6 +221,69 @@ export class Mailer {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds the provider-specific headers used to tag a message for reporting.
|
||||
* Each supported provider expects a different header name and format; for
|
||||
* providers that do not support tagging, or when no tags are given, no
|
||||
* headers are returned.
|
||||
*
|
||||
* @param tags The tags to apply to the message.
|
||||
* @returns A map of headers to set on the message, or undefined.
|
||||
*/
|
||||
private tagHeaders(
|
||||
tags?: EmailTags
|
||||
): Record<string, string | string[]> | undefined {
|
||||
if (!tags) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Mailgun: up to three tags via repeated X-Mailgun-Tag headers.
|
||||
// https://documentation.mailgun.com/docs/mailgun/user-manual/tracking-messages/#tagging
|
||||
if (this.isMailgun) {
|
||||
return { "X-Mailgun-Tag": Object.values(tags).slice(0, 3) };
|
||||
}
|
||||
|
||||
// SES: comma-separated name=value pairs via X-SES-MESSAGE-TAGS.
|
||||
// https://docs.aws.amazon.com/ses/latest/dg/event-publishing-send-email.html
|
||||
if (this.isSES) {
|
||||
return {
|
||||
"X-SES-MESSAGE-TAGS": Object.entries(tags)
|
||||
.map(([name, value]) => `${name}=${value}`)
|
||||
.join(", "),
|
||||
};
|
||||
}
|
||||
|
||||
// Postmark: a single tag per message via X-PM-Tag.
|
||||
// https://postmarkapp.com/support/article/1117-add-link-tracking-to-a-message
|
||||
if (this.isPostmark) {
|
||||
return { "X-PM-Tag": tags.template };
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** The configured SMTP host and service name, for provider detection. */
|
||||
private get provider(): string {
|
||||
return `${env.SMTP_HOST ?? ""} ${env.SMTP_SERVICE ?? ""}`;
|
||||
}
|
||||
|
||||
/** Whether the configured SMTP provider is Mailgun. */
|
||||
private get isMailgun(): boolean {
|
||||
return /mailgun/i.test(this.provider);
|
||||
}
|
||||
|
||||
/** Whether the configured SMTP provider is Amazon SES. */
|
||||
private get isSES(): boolean {
|
||||
// Detected by the SES SMTP host (email-smtp.<region>.amazonaws.com) or a
|
||||
// well-known Nodemailer service key (SES, SES-US-EAST-1, etc.).
|
||||
return /amazonaws|(?:^|\s)ses\b/i.test(this.provider);
|
||||
}
|
||||
|
||||
/** Whether the configured SMTP provider is Postmark. */
|
||||
private get isPostmark(): boolean {
|
||||
return /postmark/i.test(this.provider);
|
||||
}
|
||||
|
||||
private getOptions(): SMTPTransport.Options {
|
||||
// nodemailer will use the service config to determine host/port
|
||||
if (env.SMTP_SERVICE) {
|
||||
|
||||
@@ -177,6 +177,7 @@ export default abstract class BaseEmail<
|
||||
text: this.renderAsText(data),
|
||||
headCSS: this.headCSS?.(data),
|
||||
unsubscribeUrl: this.unsubscribeUrl?.(data),
|
||||
tags: { category: this.category, template: templateName },
|
||||
});
|
||||
Metrics.increment("email.sent", {
|
||||
templateName,
|
||||
|
||||
@@ -372,6 +372,16 @@ export class Environment {
|
||||
@IsOptional()
|
||||
public PROXY_IP_HEADER = this.toOptionalString(environment.PROXY_IP_HEADER);
|
||||
|
||||
/**
|
||||
* Whether to trust the X-Forwarded-* headers (e.g. X-Forwarded-For,
|
||||
* X-Forwarded-Proto) set by an upstream proxy or load balancer. Defaults to
|
||||
* true for backwards compat. Set to false if not running behind a proxy in production.
|
||||
*/
|
||||
@IsBoolean()
|
||||
public PROXY_HEADERS_TRUSTED = this.toBoolean(
|
||||
environment.PROXY_HEADERS_TRUSTED ?? "true"
|
||||
);
|
||||
|
||||
/**
|
||||
* Should the installation send anonymized statistics to the maintainers.
|
||||
* Defaults to true.
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async (transaction) => {
|
||||
await queryInterface.changeColumn(
|
||||
"webhook_subscriptions",
|
||||
"url",
|
||||
{
|
||||
type: Sequelize.STRING(1024),
|
||||
allowNull: false,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await queryInterface.changeColumn(
|
||||
"oauth_clients",
|
||||
"developerUrl",
|
||||
{
|
||||
type: Sequelize.STRING(1024),
|
||||
allowNull: true,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await queryInterface.changeColumn(
|
||||
"oauth_clients",
|
||||
"avatarUrl",
|
||||
{
|
||||
type: Sequelize.STRING(1024),
|
||||
allowNull: true,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await queryInterface.changeColumn(
|
||||
"oauth_clients",
|
||||
"redirectUris",
|
||||
{
|
||||
type: Sequelize.ARRAY(Sequelize.STRING(1024)),
|
||||
allowNull: false,
|
||||
defaultValue: [],
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await queryInterface.changeColumn(
|
||||
"oauth_authorization_codes",
|
||||
"redirectUri",
|
||||
{
|
||||
type: Sequelize.STRING(1024),
|
||||
allowNull: false,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
});
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.sequelize.transaction(async (transaction) => {
|
||||
await queryInterface.changeColumn(
|
||||
"oauth_authorization_codes",
|
||||
"redirectUri",
|
||||
{
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await queryInterface.changeColumn(
|
||||
"oauth_clients",
|
||||
"redirectUris",
|
||||
{
|
||||
type: Sequelize.ARRAY(Sequelize.STRING(255)),
|
||||
allowNull: false,
|
||||
defaultValue: [],
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await queryInterface.changeColumn(
|
||||
"oauth_clients",
|
||||
"avatarUrl",
|
||||
{
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: true,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await queryInterface.changeColumn(
|
||||
"oauth_clients",
|
||||
"developerUrl",
|
||||
{
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: true,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
await queryInterface.changeColumn(
|
||||
"webhook_subscriptions",
|
||||
"url",
|
||||
{
|
||||
type: Sequelize.STRING(255),
|
||||
allowNull: false,
|
||||
},
|
||||
{ transaction }
|
||||
);
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
"use strict";
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface) {
|
||||
await queryInterface.removeColumn("teams", "collaborativeEditing");
|
||||
},
|
||||
|
||||
async down(queryInterface, Sequelize) {
|
||||
await queryInterface.addColumn("teams", "collaborativeEditing", {
|
||||
type: Sequelize.BOOLEAN,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,94 @@
|
||||
import { buildTeam, buildWebhookSubscription } from "@server/test/factories";
|
||||
import WebhookSubscription from "./WebhookSubscription";
|
||||
|
||||
describe("WebhookSubscription", () => {
|
||||
describe("matchEvent", () => {
|
||||
it("matches everything for a wildcard subscription", () => {
|
||||
expect(WebhookSubscription.matchEvent(["*"], "users.signin")).toBe(true);
|
||||
});
|
||||
|
||||
it("matches an exact event name", () => {
|
||||
expect(
|
||||
WebhookSubscription.matchEvent(["users.signin"], "users.signin")
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("matches a namespace prefix", () => {
|
||||
expect(WebhookSubscription.matchEvent(["users"], "users.signin")).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
it("does not match unrelated events", () => {
|
||||
expect(
|
||||
WebhookSubscription.matchEvent(["documents"], "users.signin")
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findEnabledByTeamId", () => {
|
||||
it("returns only enabled subscriptions for the team", async () => {
|
||||
const subscription = await buildWebhookSubscription({
|
||||
events: ["users"],
|
||||
});
|
||||
const disabled = await buildWebhookSubscription({
|
||||
teamId: subscription.teamId,
|
||||
events: ["documents"],
|
||||
});
|
||||
await disabled.disable();
|
||||
|
||||
const result = await WebhookSubscription.findEnabledByTeamId(
|
||||
subscription.teamId
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toEqual(subscription.id);
|
||||
expect(result[0].events).toEqual(["users"]);
|
||||
});
|
||||
|
||||
it("returns an empty array when the team has no subscriptions", async () => {
|
||||
const team = await buildTeam();
|
||||
|
||||
const result = await WebhookSubscription.findEnabledByTeamId(team.id);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it("reflects changes after a subscription is disabled", async () => {
|
||||
const subscription = await buildWebhookSubscription({
|
||||
events: ["users"],
|
||||
});
|
||||
|
||||
// prime the cache
|
||||
const before = await WebhookSubscription.findEnabledByTeamId(
|
||||
subscription.teamId
|
||||
);
|
||||
expect(before).toHaveLength(1);
|
||||
|
||||
await subscription.disable();
|
||||
|
||||
const after = await WebhookSubscription.findEnabledByTeamId(
|
||||
subscription.teamId
|
||||
);
|
||||
expect(after).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("reflects changes after a subscription is destroyed", async () => {
|
||||
const subscription = await buildWebhookSubscription({
|
||||
events: ["users"],
|
||||
});
|
||||
|
||||
const before = await WebhookSubscription.findEnabledByTeamId(
|
||||
subscription.teamId
|
||||
);
|
||||
expect(before).toHaveLength(1);
|
||||
|
||||
await subscription.destroy();
|
||||
|
||||
const after = await WebhookSubscription.findEnabledByTeamId(
|
||||
subscription.teamId
|
||||
);
|
||||
expect(after).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
InferAttributes,
|
||||
InferCreationAttributes,
|
||||
InstanceUpdateOptions,
|
||||
Transaction,
|
||||
} from "sequelize";
|
||||
import {
|
||||
Column,
|
||||
@@ -14,12 +15,19 @@ import {
|
||||
DataType,
|
||||
IsUrl,
|
||||
BeforeCreate,
|
||||
AfterCreate,
|
||||
AfterUpdate,
|
||||
AfterDestroy,
|
||||
AfterRestore,
|
||||
DefaultScope,
|
||||
AllowNull,
|
||||
} from "sequelize-typescript";
|
||||
import { Hour } from "@shared/utils/time";
|
||||
import { WebhookSubscriptionValidation } from "@shared/validations";
|
||||
import { ValidationError } from "@server/errors";
|
||||
import type { Event } from "@server/types";
|
||||
import { CacheHelper } from "@server/utils/CacheHelper";
|
||||
import { RedisPrefixHelper } from "@server/utils/RedisPrefixHelper";
|
||||
import Team from "./Team";
|
||||
import User from "./User";
|
||||
import ParanoidModel from "./base/ParanoidModel";
|
||||
@@ -47,6 +55,60 @@ class WebhookSubscription extends ParanoidModel<
|
||||
> {
|
||||
static eventNamespace = "webhookSubscriptions";
|
||||
|
||||
/**
|
||||
* Returns the enabled webhook subscriptions for a team, caching the
|
||||
* lightweight { id, events } projection in Redis to avoid a database query on
|
||||
* every event. The cache is invalidated by model lifecycle hooks whenever a
|
||||
* team's subscriptions change.
|
||||
*
|
||||
* @param teamId The team to load subscriptions for.
|
||||
* @returns the enabled subscriptions' ids and subscribed event names.
|
||||
*/
|
||||
public static async findEnabledByTeamId(
|
||||
teamId: string
|
||||
): Promise<Array<{ id: string; events: string[] }>> {
|
||||
return (
|
||||
(await CacheHelper.getDataOrSet<Array<{ id: string; events: string[] }>>(
|
||||
RedisPrefixHelper.getWebhookSubscriptionsKey(teamId),
|
||||
async () => {
|
||||
const subscriptions = await this.unscoped().findAll({
|
||||
attributes: ["id", "events"],
|
||||
where: { enabled: true, teamId },
|
||||
raw: true,
|
||||
});
|
||||
return subscriptions.map((subscription) => ({
|
||||
id: subscription.id,
|
||||
events: subscription.events,
|
||||
}));
|
||||
},
|
||||
Hour.seconds
|
||||
)) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines whether a subscription configured for the given event names
|
||||
* should receive an event with the given name. Pure so it can run against the
|
||||
* cached projection as well as model instances.
|
||||
*
|
||||
* @param events The event names a subscription is configured for.
|
||||
* @param eventName The name of the event being processed.
|
||||
* @returns true if the event matches the subscription configuration.
|
||||
*/
|
||||
public static matchEvent(events: string[], eventName: string): boolean {
|
||||
if (events.length === 1 && events[0] === "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const e of events) {
|
||||
if (e === eventName || eventName.startsWith(e + ".")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@NotEmpty
|
||||
@Length({
|
||||
max: WebhookSubscriptionValidation.maxNameLength,
|
||||
@@ -105,6 +167,31 @@ class WebhookSubscription extends ParanoidModel<
|
||||
}
|
||||
}
|
||||
|
||||
@AfterCreate
|
||||
@AfterUpdate
|
||||
@AfterDestroy
|
||||
@AfterRestore
|
||||
static async invalidateCache(
|
||||
model: WebhookSubscription,
|
||||
options: { transaction?: Transaction | null }
|
||||
) {
|
||||
const invalidate = () =>
|
||||
CacheHelper.removeData(
|
||||
RedisPrefixHelper.getWebhookSubscriptionsKey(model.teamId)
|
||||
);
|
||||
|
||||
// Defer invalidation until after the transaction commits so that a rollback
|
||||
// does not leave the cache out of sync, and so a stale pre-commit read is
|
||||
// not re-cached by a concurrent reader. Walk to the parent transaction when
|
||||
// nested so the callback isn't lost when a savepoint releases.
|
||||
if (options.transaction) {
|
||||
const transaction = options.transaction.parent || options.transaction;
|
||||
transaction.afterCommit(invalidate);
|
||||
} else {
|
||||
await invalidate();
|
||||
}
|
||||
}
|
||||
|
||||
// instance methods
|
||||
|
||||
/**
|
||||
@@ -130,22 +217,11 @@ class WebhookSubscription extends ParanoidModel<
|
||||
* Determines if an event should be processed for this webhook subscription
|
||||
* based on the event configuration.
|
||||
*
|
||||
* @param event Event to ceck
|
||||
* @param event Event to check
|
||||
* @returns true if event is valid
|
||||
*/
|
||||
public validForEvent = (event: Event): boolean => {
|
||||
if (this.events.length === 1 && this.events[0] === "*") {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const e of this.events) {
|
||||
if (e === event.name || event.name.startsWith(e + ".")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
public validForEvent = (event: Event): boolean =>
|
||||
WebhookSubscription.matchEvent(this.events, event.name);
|
||||
|
||||
/**
|
||||
* Calculates the signature for a webhook payload if the webhook subscription
|
||||
|
||||
@@ -17,6 +17,19 @@ export default class AuthenticationHelper {
|
||||
return PluginManager.getHooks(Hook.AuthProvider);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the human-readable display name for an authentication provider.
|
||||
*
|
||||
* @param id The authentication provider id, eg "google".
|
||||
* @returns The display name if known, otherwise the provided id.
|
||||
*/
|
||||
public static getProviderName(id: string): string {
|
||||
const provider = AuthenticationHelper.providers.find(
|
||||
(hook) => hook.value.id === id
|
||||
);
|
||||
return provider?.name ?? id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the enabled authentication provider configurations for a team,
|
||||
* if given otherwise all enabled providers are returned.
|
||||
|
||||
@@ -817,8 +817,18 @@ export class DocumentHelper {
|
||||
const textSame = oldChild.textContent === newChild.textContent;
|
||||
|
||||
if (textSame && oldChild.sameMarkup(newChild)) {
|
||||
// Fully unchanged — keep original with its rich content
|
||||
merged.push(oldChild);
|
||||
// Compare against the round-tripped baseline: when the
|
||||
// updated child is identical to a plain round-trip of the original,
|
||||
// the patch did not touch it
|
||||
if (!rtChild || newChild.eq(rtChild)) {
|
||||
merged.push(oldChild);
|
||||
} else if (!oldChild.isTextblock && !oldChild.isLeaf) {
|
||||
// Container child changed deeper down — recurse to preserve rich
|
||||
// content in the parts that did not change.
|
||||
merged.push(DocumentHelper.mergeNodes(oldChild, newChild, rtChild));
|
||||
} else {
|
||||
merged.push(newChild);
|
||||
}
|
||||
} else if (textSame) {
|
||||
// Attrs changed (e.g. checked state) but content same — merge attrs
|
||||
// so that non-markdown-representable values (colwidth, highlight
|
||||
|
||||
@@ -20,6 +20,7 @@ import Diff from "@shared/editor/extensions/Diff";
|
||||
import { EditorStyleHelper } from "@shared/editor/styles/EditorStyleHelper";
|
||||
import type { ExtendedChange } from "@shared/editor/lib/ChangesetHelper";
|
||||
import textBetween from "@shared/editor/lib/textBetween";
|
||||
import { withTrailingNode } from "@shared/editor/lib/trailingNode";
|
||||
import EditorContainer from "@shared/editor/components/Styles";
|
||||
import GlobalStyles from "@shared/styles/globals";
|
||||
import light from "@shared/styles/theme";
|
||||
@@ -106,15 +107,16 @@ export class ProsemirrorHelper extends SharedProsemirrorHelper {
|
||||
* @returns The content as a Y.Doc.
|
||||
*/
|
||||
static toYDoc(input: string | ProsemirrorData, fieldName = "default"): Y.Doc {
|
||||
if (typeof input === "object") {
|
||||
return prosemirrorToYDoc(
|
||||
ProsemirrorHelper.toProsemirror(input),
|
||||
fieldName
|
||||
);
|
||||
const node =
|
||||
typeof input === "object"
|
||||
? ProsemirrorHelper.toProsemirror(input)
|
||||
: parser.parse(input);
|
||||
if (!node) {
|
||||
return new Y.Doc();
|
||||
}
|
||||
|
||||
const node = parser.parse(input);
|
||||
return node ? prosemirrorToYDoc(node, fieldName) : new Y.Doc();
|
||||
// Normalize to the editor's trailing-node form so the document opens without
|
||||
// the editor inserting a trailing paragraph, which would be a spurious edit.
|
||||
return prosemirrorToYDoc(withTrailingNode(node), fieldName);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -109,7 +109,7 @@ async function presentDocument(
|
||||
res.parentDocumentId = document.parentDocumentId;
|
||||
res.createdBy = presentUser(document.createdBy);
|
||||
res.updatedBy = presentUser(document.updatedBy);
|
||||
res.collaboratorIds = document.collaboratorIds;
|
||||
res.collaboratorIds = document.collaboratorIds ?? [];
|
||||
res.templateId = document.templateId;
|
||||
res.insightsEnabled = document.insightsEnabled;
|
||||
res.popularityScore = document.popularityScore;
|
||||
|
||||
@@ -17,7 +17,7 @@ export function presentDCRClient(
|
||||
baseUrl: string,
|
||||
oauthClient: OAuthClient,
|
||||
{
|
||||
includeRegistrationAccessToken = false,
|
||||
includeRegistrationAccessToken,
|
||||
includeCredentials = false,
|
||||
}: {
|
||||
includeRegistrationAccessToken: boolean;
|
||||
|
||||
@@ -1,8 +1,31 @@
|
||||
import type { Event } from "@server/types";
|
||||
|
||||
export default abstract class BaseProcessor {
|
||||
/**
|
||||
* The event names this processor handles. The global event queue only creates
|
||||
* a job for the processor when an event's name is listed here, or when it
|
||||
* contains the `"*"` wildcard to match every event.
|
||||
*/
|
||||
static applicableEvents: (Event["name"] | "*")[] = [];
|
||||
|
||||
/**
|
||||
* Optional hook run in the global event queue before a job is created for this
|
||||
* processor. Implement it to cheaply opt out of events the processor will not
|
||||
* act on and avoid the cost of an empty job. When omitted, every applicable
|
||||
* event is queued.
|
||||
*
|
||||
* @param event The event about to be queued.
|
||||
* @returns true if a job should be queued for this processor.
|
||||
*/
|
||||
static shouldQueue?: (event: Event) => Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Handle an applicable event. Called once per queued job, with retries on
|
||||
* failure.
|
||||
*
|
||||
* @param event The event to process.
|
||||
* @returns A promise that resolves once the event has been processed.
|
||||
*/
|
||||
public abstract perform(event: Event): Promise<void>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -26,7 +26,7 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
|
||||
zip,
|
||||
pathInZip,
|
||||
documentId,
|
||||
format = FileOperationFormat.MarkdownZip,
|
||||
format,
|
||||
includeAttachments,
|
||||
pathMap,
|
||||
}: {
|
||||
@@ -150,26 +150,12 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
|
||||
includeAttachments = true
|
||||
) {
|
||||
const pathMap = this.createPathMap(collections, format);
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Start adding ${Object.values(pathMap).length} documents to archive`
|
||||
);
|
||||
|
||||
for (const path of pathMap) {
|
||||
const documentId = path[0].replace("/doc/", "");
|
||||
const pathInZip = path[1];
|
||||
|
||||
await this.processDocument({
|
||||
zip,
|
||||
pathInZip,
|
||||
documentId,
|
||||
includeAttachments,
|
||||
format,
|
||||
pathMap,
|
||||
});
|
||||
}
|
||||
|
||||
Logger.debug("task", "Completed adding documents to archive");
|
||||
await this.addDocumentsToArchive({
|
||||
zip,
|
||||
pathMap,
|
||||
format,
|
||||
includeAttachments,
|
||||
});
|
||||
|
||||
return await ZipHelper.toTmpFile(zip);
|
||||
}
|
||||
@@ -200,28 +186,58 @@ export default abstract class ExportDocumentTreeTask extends ExportTask {
|
||||
format
|
||||
);
|
||||
|
||||
Logger.debug(
|
||||
"task",
|
||||
`Start adding ${Object.values(pathMap).length} documents to archive`
|
||||
);
|
||||
await this.addDocumentsToArchive({
|
||||
zip,
|
||||
pathMap,
|
||||
format,
|
||||
includeAttachments: true,
|
||||
});
|
||||
|
||||
for (const entry of pathMap) {
|
||||
const documentId = entry[0].replace("/doc/", "");
|
||||
const pathInZip = entry[1];
|
||||
return await ZipHelper.toTmpFile(zip);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes each unique document in the path map and adds it to the zip.
|
||||
*
|
||||
* @param zip The yazl ZipFile to add files to
|
||||
* @param pathMap Map of document urls to their path in the zip
|
||||
* @param format The format to export in
|
||||
* @param includeAttachments Whether to include attachments in the export
|
||||
*/
|
||||
private async addDocumentsToArchive({
|
||||
zip,
|
||||
pathMap,
|
||||
format,
|
||||
includeAttachments,
|
||||
}: {
|
||||
zip: ZipFile;
|
||||
pathMap: Map<string, string>;
|
||||
format: FileOperationFormat;
|
||||
includeAttachments: boolean;
|
||||
}) {
|
||||
const processedPaths = new Set<string>();
|
||||
|
||||
Logger.debug("task", `Start adding documents to archive`);
|
||||
|
||||
for (const [url, pathInZip] of pathMap) {
|
||||
// A document may be keyed by multiple urls in the path map, only
|
||||
// process each file in the zip once.
|
||||
if (processedPaths.has(pathInZip)) {
|
||||
continue;
|
||||
}
|
||||
processedPaths.add(pathInZip);
|
||||
|
||||
await this.processDocument({
|
||||
zip,
|
||||
pathInZip,
|
||||
documentId,
|
||||
includeAttachments: true,
|
||||
documentId: url.replace("/doc/", ""),
|
||||
includeAttachments,
|
||||
format,
|
||||
pathMap,
|
||||
});
|
||||
}
|
||||
|
||||
Logger.debug("task", "Completed adding documents to archive");
|
||||
|
||||
return await ZipHelper.toTmpFile(zip);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import fs from "fs-extra";
|
||||
import ZipHelper from "@server/utils/ZipHelper";
|
||||
import {
|
||||
buildCollection,
|
||||
buildDocument,
|
||||
buildFileOperation,
|
||||
buildTeam,
|
||||
buildUser,
|
||||
} from "@server/test/factories";
|
||||
import ExportMarkdownZipTask from "./ExportMarkdownZipTask";
|
||||
|
||||
describe("ExportMarkdownZipTask", () => {
|
||||
it("should not duplicate documents in the zip file", async () => {
|
||||
const team = await buildTeam();
|
||||
const user = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
createdById: user.id,
|
||||
});
|
||||
const documents = await Promise.all([
|
||||
buildDocument({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
title: "Test1",
|
||||
}),
|
||||
buildDocument({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
collectionId: collection.id,
|
||||
title: "Test2",
|
||||
}),
|
||||
]);
|
||||
for (const document of documents) {
|
||||
await collection.addDocumentToStructure(document);
|
||||
}
|
||||
const fileOperation = await buildFileOperation({
|
||||
teamId: team.id,
|
||||
userId: user.id,
|
||||
});
|
||||
|
||||
const task = new ExportMarkdownZipTask();
|
||||
const filePath = await task.exportCollections([collection], fileOperation);
|
||||
|
||||
try {
|
||||
const fileNames: string[] = [];
|
||||
await ZipHelper.walk(filePath, (entry) => {
|
||||
if (!entry.isDirectory) {
|
||||
fileNames.push(entry.fileName);
|
||||
}
|
||||
});
|
||||
|
||||
expect(fileNames.sort()).toEqual([
|
||||
`${collection.name}/Test1.md`,
|
||||
`${collection.name}/Test2.md`,
|
||||
]);
|
||||
} finally {
|
||||
await fs.remove(filePath);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { DocumentPermission } from "@shared/types";
|
||||
import { CollectionPermission, DocumentPermission } from "@shared/types";
|
||||
import { AccessRequest, UserMembership } from "@server/models";
|
||||
import { AccessRequestStatus } from "@server/models/AccessRequest";
|
||||
import {
|
||||
@@ -313,6 +313,54 @@ describe("#accessRequests.approve", () => {
|
||||
expect(membership?.permission).toEqual(DocumentPermission.ReadWrite);
|
||||
});
|
||||
|
||||
it("should allow a document manager who is not a workspace admin to approve", async () => {
|
||||
const team = await buildTeam();
|
||||
const requester = await buildUser({ teamId: team.id });
|
||||
const manager = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: manager.id,
|
||||
permission: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: team.id,
|
||||
createdById: manager.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
await UserMembership.create({
|
||||
userId: manager.id,
|
||||
collectionId: collection.id,
|
||||
createdById: manager.id,
|
||||
permission: CollectionPermission.Admin,
|
||||
});
|
||||
|
||||
const accessRequest = await AccessRequest.create({
|
||||
documentId: document.id,
|
||||
userId: requester.id,
|
||||
teamId: team.id,
|
||||
status: AccessRequestStatus.Pending,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/accessRequests.approve", manager, {
|
||||
body: {
|
||||
id: accessRequest.id,
|
||||
permission: DocumentPermission.Read,
|
||||
},
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
|
||||
const membership = await UserMembership.findOne({
|
||||
where: {
|
||||
userId: requester.id,
|
||||
documentId: document.id,
|
||||
},
|
||||
});
|
||||
expect(membership).toBeTruthy();
|
||||
expect(membership?.permission).toEqual(DocumentPermission.Read);
|
||||
});
|
||||
|
||||
it("should not allow non-managers to approve requests", async () => {
|
||||
const team = await buildTeam();
|
||||
const requester = await buildUser({ teamId: team.id });
|
||||
@@ -461,6 +509,46 @@ describe("#accessRequests.dismiss", () => {
|
||||
expect(membership).toBeNull();
|
||||
});
|
||||
|
||||
it("should allow a document manager who is not a workspace admin to dismiss", async () => {
|
||||
const team = await buildTeam();
|
||||
const requester = await buildUser({ teamId: team.id });
|
||||
const manager = await buildUser({ teamId: team.id });
|
||||
const collection = await buildCollection({
|
||||
teamId: team.id,
|
||||
userId: manager.id,
|
||||
permission: null,
|
||||
});
|
||||
const document = await buildDocument({
|
||||
teamId: team.id,
|
||||
createdById: manager.id,
|
||||
collectionId: collection.id,
|
||||
});
|
||||
|
||||
await UserMembership.create({
|
||||
userId: manager.id,
|
||||
collectionId: collection.id,
|
||||
createdById: manager.id,
|
||||
permission: CollectionPermission.Admin,
|
||||
});
|
||||
|
||||
const accessRequest = await AccessRequest.create({
|
||||
documentId: document.id,
|
||||
userId: requester.id,
|
||||
teamId: team.id,
|
||||
});
|
||||
|
||||
const res = await server.post("/api/accessRequests.dismiss", manager, {
|
||||
body: {
|
||||
id: accessRequest.id,
|
||||
},
|
||||
});
|
||||
const body = await res.json();
|
||||
|
||||
expect(res.status).toEqual(200);
|
||||
expect(body.data.status).toEqual(AccessRequestStatus.Dismissed);
|
||||
expect(body.data.responderId).toEqual(manager.id);
|
||||
});
|
||||
|
||||
it("should not allow non-managers to dismiss requests", async () => {
|
||||
const team = await buildTeam();
|
||||
const requester = await buildUser({ teamId: team.id });
|
||||
|
||||
@@ -101,8 +101,6 @@ router.post(
|
||||
transaction,
|
||||
lock: { level: transaction.LOCK.UPDATE, of: AccessRequest },
|
||||
});
|
||||
authorize(user, "update", accessRequest);
|
||||
authorize(user, "read", accessRequest.user);
|
||||
|
||||
if (accessRequest.status !== AccessRequestStatus.Pending) {
|
||||
throw InvalidRequestError("Access request has already been responded to");
|
||||
@@ -111,8 +109,10 @@ router.post(
|
||||
const document = await Document.findByPk(accessRequest.documentId, {
|
||||
userId: user.id,
|
||||
transaction,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
authorize(user, "share", document);
|
||||
authorize(user, "manageUsers", document);
|
||||
authorize(user, "read", accessRequest.user);
|
||||
|
||||
const membership = await UserMembership.findOne({
|
||||
where: {
|
||||
@@ -157,14 +157,14 @@ router.post(
|
||||
transaction,
|
||||
lock: { level: transaction.LOCK.UPDATE, of: AccessRequest },
|
||||
});
|
||||
authorize(user, "update", accessRequest);
|
||||
authorize(user, "read", accessRequest.user);
|
||||
|
||||
const document = await Document.findByPk(accessRequest.documentId, {
|
||||
userId: user.id,
|
||||
transaction,
|
||||
rejectOnEmpty: true,
|
||||
});
|
||||
authorize(user, "share", document);
|
||||
authorize(user, "manageUsers", document);
|
||||
authorize(user, "read", accessRequest.user);
|
||||
|
||||
if (accessRequest.status === AccessRequestStatus.Pending) {
|
||||
await accessRequest.dismiss(ctx);
|
||||
|
||||
@@ -1241,6 +1241,7 @@ describe("#collections.create", () => {
|
||||
expect(body.data.name).toBe("Test");
|
||||
expect(body.data.sort.field).toBe("index");
|
||||
expect(body.data.sort.direction).toBe("asc");
|
||||
expect(body.data.permission).toBe(null);
|
||||
expect(body.policies.length).toBe(1);
|
||||
expect(body.policies[0].abilities.read).toBeTruthy();
|
||||
});
|
||||
@@ -1256,6 +1257,23 @@ describe("#collections.create", () => {
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("rejects providing both description and data", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/collections.create", user, {
|
||||
body: {
|
||||
name: "Test",
|
||||
description: "Test",
|
||||
data: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Test" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("should allow setting sharing to false", async () => {
|
||||
const user = await buildUser();
|
||||
const res = await server.post("/api/collections.create", user, {
|
||||
@@ -1447,6 +1465,50 @@ describe("#collections.update", () => {
|
||||
expect(collection.content).toBeTruthy();
|
||||
});
|
||||
|
||||
it("replaces rendered content when description is updated post-create", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
|
||||
const createRes = await server.post("/api/collections.create", admin, {
|
||||
headers: { "x-api-version": "3" },
|
||||
body: { name: "Foo", description: "Original" },
|
||||
});
|
||||
const { id } = (await createRes.json()).data;
|
||||
|
||||
const updateRes = await server.post("/api/collections.update", admin, {
|
||||
headers: { "x-api-version": "3" },
|
||||
body: { id, description: "Replaced" },
|
||||
});
|
||||
expect(updateRes.status).toEqual(200);
|
||||
|
||||
const infoRes = await server.post("/api/collections.info", admin, {
|
||||
headers: { "x-api-version": "3" },
|
||||
body: { id },
|
||||
});
|
||||
const content = JSON.stringify((await infoRes.json()).data.data);
|
||||
expect(content).toContain("Replaced");
|
||||
expect(content).not.toContain("Original");
|
||||
});
|
||||
|
||||
it("rejects providing both description and data", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
const collection = await buildCollection({ teamId: team.id });
|
||||
const res = await server.post("/api/collections.update", admin, {
|
||||
body: {
|
||||
id: collection.id,
|
||||
description: "Test",
|
||||
data: {
|
||||
type: "doc",
|
||||
content: [
|
||||
{ type: "paragraph", content: [{ type: "text", text: "Test" }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(res.status).toEqual(400);
|
||||
});
|
||||
|
||||
it("allows editing data", async () => {
|
||||
const team = await buildTeam();
|
||||
const admin = await buildAdmin({ teamId: team.id });
|
||||
|
||||
@@ -15,39 +15,50 @@ const BaseIdSchema = z.object({
|
||||
id: zodIdType(),
|
||||
});
|
||||
|
||||
/** The landing page can be set from description (markdown) or data (rich content), but not both. */
|
||||
const refineBodyContent = <T extends { description?: unknown; data?: unknown }>(
|
||||
body: T
|
||||
) => isUndefined(body.description) || isUndefined(body.data);
|
||||
|
||||
const bodyContentError = {
|
||||
error: "Only one of description or data may be provided",
|
||||
};
|
||||
|
||||
export const CollectionsCreateSchema = BaseSchema.extend({
|
||||
body: z.object({
|
||||
name: z.string(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
||||
.nullish(),
|
||||
description: z.string().nullish(),
|
||||
data: ProsemirrorSchema({ allowEmpty: true }).nullish(),
|
||||
permission: z
|
||||
.enum(CollectionPermission)
|
||||
.nullish()
|
||||
.transform((val) => (isUndefined(val) ? null : val)),
|
||||
sharing: z.boolean().prefault(true),
|
||||
icon: zodIconType().optional(),
|
||||
sort: z
|
||||
.object({
|
||||
field: z.union([z.literal("title"), z.literal("index")]),
|
||||
direction: z.union([z.literal("asc"), z.literal("desc")]),
|
||||
})
|
||||
.prefault(Collection.DEFAULT_SORT),
|
||||
index: z
|
||||
.string()
|
||||
.regex(ValidateIndex.regex, { message: ValidateIndex.message })
|
||||
.max(ValidateIndex.maxLength, {
|
||||
message: `Must be ${ValidateIndex.maxLength} or fewer characters long`,
|
||||
})
|
||||
.optional(),
|
||||
commenting: z.boolean().nullish(),
|
||||
templateManagement: z
|
||||
.enum([CollectionPermission.Admin, CollectionPermission.ReadWrite])
|
||||
.prefault(CollectionPermission.Admin),
|
||||
}),
|
||||
body: z
|
||||
.object({
|
||||
name: z.string(),
|
||||
color: z
|
||||
.string()
|
||||
.regex(ValidateColor.regex, { message: ValidateColor.message })
|
||||
.nullish(),
|
||||
description: z.string().nullish(),
|
||||
data: ProsemirrorSchema({ allowEmpty: true }).nullish(),
|
||||
permission: z
|
||||
.enum(CollectionPermission)
|
||||
.nullish()
|
||||
.transform((val) => (isUndefined(val) ? null : val)),
|
||||
sharing: z.boolean().prefault(true),
|
||||
icon: zodIconType().optional(),
|
||||
sort: z
|
||||
.object({
|
||||
field: z.union([z.literal("title"), z.literal("index")]),
|
||||
direction: z.union([z.literal("asc"), z.literal("desc")]),
|
||||
})
|
||||
.prefault(Collection.DEFAULT_SORT),
|
||||
index: z
|
||||
.string()
|
||||
.regex(ValidateIndex.regex, { message: ValidateIndex.message })
|
||||
.max(ValidateIndex.maxLength, {
|
||||
message: `Must be ${ValidateIndex.maxLength} or fewer characters long`,
|
||||
})
|
||||
.optional(),
|
||||
commenting: z.boolean().nullish(),
|
||||
templateManagement: z
|
||||
.enum([CollectionPermission.Admin, CollectionPermission.ReadWrite])
|
||||
.prefault(CollectionPermission.Admin),
|
||||
})
|
||||
.refine(refineBodyContent, bodyContentError),
|
||||
});
|
||||
|
||||
export type CollectionsCreateReq = z.infer<typeof CollectionsCreateSchema>;
|
||||
@@ -188,7 +199,7 @@ export const CollectionsUpdateSchema = BaseSchema.extend({
|
||||
templateManagement: z
|
||||
.enum([CollectionPermission.Admin, CollectionPermission.ReadWrite])
|
||||
.optional(),
|
||||
}),
|
||||
}).refine(refineBodyContent, bodyContentError),
|
||||
});
|
||||
|
||||
export type CollectionsUpdateReq = z.infer<typeof CollectionsUpdateSchema>;
|
||||
|
||||
@@ -29,7 +29,7 @@ router.post(
|
||||
auth(),
|
||||
validate(T.CreateTestUsersSchema),
|
||||
async (ctx: APIContext<T.CreateTestUsersReq>) => {
|
||||
const { count = 10 } = ctx.input.body;
|
||||
const { count } = ctx.input.body;
|
||||
const invites = Array(Math.min(count, 100))
|
||||
.fill(0)
|
||||
.map(() => {
|
||||
|
||||
@@ -29,6 +29,7 @@ import documentDuplicator from "@server/commands/documentDuplicator";
|
||||
import documentLoader from "@server/commands/documentLoader";
|
||||
import documentMover from "@server/commands/documentMover";
|
||||
import documentPermanentDeleter from "@server/commands/documentPermanentDeleter";
|
||||
import documentRestorer from "@server/commands/documentRestorer";
|
||||
import documentUpdater from "@server/commands/documentUpdater";
|
||||
import env from "@server/env";
|
||||
import {
|
||||
@@ -51,7 +52,6 @@ import {
|
||||
Document,
|
||||
DocumentInsight,
|
||||
Event,
|
||||
Revision,
|
||||
SearchQuery,
|
||||
Template,
|
||||
User,
|
||||
@@ -89,7 +89,6 @@ import { RateLimiterStrategy } from "@server/utils/RateLimiter";
|
||||
import { convertBareUrlsToEmbedMarkdown } from "@server/utils/embeds";
|
||||
import { streamZipResponse } from "@server/utils/koa";
|
||||
import { getTeamFromContext } from "@server/utils/passport";
|
||||
import { assertPresent } from "@server/validation";
|
||||
import pagination, { paginateQuery } from "../middlewares/pagination";
|
||||
import * as T from "./schema";
|
||||
import {
|
||||
@@ -968,63 +967,7 @@ router.post(
|
||||
transaction,
|
||||
});
|
||||
|
||||
const sourceCollectionId = document.collectionId;
|
||||
const destCollectionId = collectionId ?? sourceCollectionId;
|
||||
|
||||
const srcCollection = sourceCollectionId
|
||||
? await Collection.findByPk(sourceCollectionId, {
|
||||
userId: user.id,
|
||||
includeDocumentStructure: true,
|
||||
paranoid: false,
|
||||
transaction,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
const destCollection = destCollectionId
|
||||
? await Collection.findByPk(destCollectionId, {
|
||||
userId: user.id,
|
||||
includeDocumentStructure: true,
|
||||
transaction,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (!destCollection?.isActive) {
|
||||
throw ValidationError(
|
||||
"Unable to restore, the collection may have been deleted or archived"
|
||||
);
|
||||
}
|
||||
|
||||
if (sourceCollectionId && sourceCollectionId !== destCollectionId) {
|
||||
authorize(user, "updateDocument", srcCollection);
|
||||
await srcCollection?.removeDocumentInStructure(document, {
|
||||
save: true,
|
||||
transaction,
|
||||
});
|
||||
}
|
||||
|
||||
if (document.deletedAt) {
|
||||
authorize(user, "restore", document);
|
||||
authorize(user, "updateDocument", destCollection);
|
||||
|
||||
// restore a previously deleted document
|
||||
await document.restoreTo(ctx, { collectionId: destCollectionId! }); // destCollectionId is guaranteed to be defined here
|
||||
} else if (document.archivedAt) {
|
||||
authorize(user, "unarchive", document);
|
||||
authorize(user, "updateDocument", destCollection);
|
||||
|
||||
// restore a previously archived document
|
||||
await document.restoreTo(ctx, { collectionId: destCollectionId! }); // destCollectionId is guaranteed to be defined here
|
||||
} else if (revisionId) {
|
||||
// restore a document to a specific revision
|
||||
authorize(user, "update", document);
|
||||
const revision = await Revision.findByPk(revisionId, { transaction });
|
||||
authorize(document, "restore", revision);
|
||||
|
||||
await document.restoreFromRevision(revision);
|
||||
await document.saveWithCtx(ctx, undefined, { name: "restore" });
|
||||
} else {
|
||||
assertPresent(revisionId, "revisionId is required");
|
||||
}
|
||||
await documentRestorer(ctx, { document, collectionId, revisionId });
|
||||
|
||||
ctx.body = {
|
||||
data: await presentDocument(ctx, document),
|
||||
|
||||
@@ -92,6 +92,17 @@ describe("POST /mcp/", () => {
|
||||
expect(result?.serverInfo?.name).toEqual("outline");
|
||||
});
|
||||
|
||||
it("should return 202 for the notifications/initialized lifecycle message", async () => {
|
||||
const { accessToken } = await buildOAuthUser();
|
||||
|
||||
const res = await server.post("/mcp/", {
|
||||
headers: mcpHeaders(accessToken),
|
||||
body: { jsonrpc: "2.0", method: "notifications/initialized" },
|
||||
});
|
||||
|
||||
expect(res.status).toEqual(202);
|
||||
});
|
||||
|
||||
it("should set the MCP flag on the user after a successful request", async () => {
|
||||
const { user, accessToken } = await buildOAuthUser();
|
||||
expect(user.getFlag(UserFlag.MCP)).toEqual(0);
|
||||
|
||||
@@ -4,6 +4,7 @@ import Router from "koa-router";
|
||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
||||
import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js";
|
||||
import { ErrorCode } from "@modelcontextprotocol/sdk/types.js";
|
||||
import { TeamPreference } from "@shared/types";
|
||||
import { NotFoundError } from "@server/errors";
|
||||
import Logger from "@server/logging/Logger";
|
||||
@@ -18,6 +19,7 @@ import { collectionTools } from "@server/tools/collections";
|
||||
import { commentTools } from "@server/tools/comments";
|
||||
import { documentTools } from "@server/tools/documents";
|
||||
import { fetchTool } from "@server/tools/fetch";
|
||||
import { templateTools } from "@server/tools/templates";
|
||||
import { userTools } from "@server/tools/users";
|
||||
import { version } from "../../../package.json";
|
||||
|
||||
@@ -28,7 +30,9 @@ const defaultInstructions = `Document markdown content must not begin with a top
|
||||
|
||||
Document and collection markdown support @mentions using the syntax: @[Display Name](mention://user/userId). For example: @[John Doe](mention://user/c9a1b2e3-...). Use the "list_users" tool to find user IDs.
|
||||
|
||||
Read images and attachments with the "fetch" tool by setting resource to "attachment" and passing either the attachment ID or an /api/attachments.redirect?id=... URL; the tool will return a signed URL for download.`;
|
||||
Read images and attachments with the "fetch" tool by setting resource to "attachment" and passing either the attachment ID or an /api/attachments.redirect?id=... URL; the tool will return a signed URL for download.
|
||||
|
||||
When asked to create a document that follows a template, use the "list_templates" tool to find a matching template; each result already includes the template body as markdown. To use it unchanged, pass its ID as templateId to "create_document" and the new document is pre-filled from it. To adapt it first, modify the returned body and pass the result as the text parameter to "create_document". Either way no separate fetch is needed.`;
|
||||
|
||||
/**
|
||||
* Creates a fresh MCP server instance with tools filtered by the OAuth
|
||||
@@ -61,6 +65,7 @@ function createMcpServer(scopes: string[], guidance?: string): McpServer {
|
||||
commentTools(server, scopes);
|
||||
documentTools(server, scopes);
|
||||
fetchTool(server, scopes);
|
||||
templateTools(server, scopes);
|
||||
userTools(server, scopes);
|
||||
|
||||
return server;
|
||||
@@ -113,7 +118,35 @@ router.post(
|
||||
};
|
||||
|
||||
ctx.respond = false;
|
||||
await transport.handleRequest(ctx.req, ctx.res, ctx.request.body);
|
||||
|
||||
// The SDK's handleRequest answers known protocol failures itself (4xx with a
|
||||
// JSON-RPC body) via the transport. Anything that escapes here is unexpected.
|
||||
try {
|
||||
await transport.handleRequest(ctx.req, ctx.res, ctx.request.body);
|
||||
} catch (error) {
|
||||
Logger.error(
|
||||
"MCP request handling failed",
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
undefined,
|
||||
ctx.req
|
||||
);
|
||||
|
||||
if (!ctx.res.headersSent) {
|
||||
ctx.res.writeHead(500, { "Content-Type": "application/json" });
|
||||
ctx.res.end(
|
||||
JSON.stringify({
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: ErrorCode.InternalError,
|
||||
message: "Internal server error",
|
||||
},
|
||||
id: null,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
ctx.res.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+14
-7
@@ -29,6 +29,16 @@ export default function init(app: Koa = new Koa(), server?: Server) {
|
||||
void initI18n();
|
||||
|
||||
if (env.isProduction) {
|
||||
// Trust the X-Forwarded-* headers set by an upstream proxy, eg
|
||||
// X-Forwarded-For. Defaults to true, but can be disabled with
|
||||
// PROXY_HEADERS_TRUSTED when the app is reachable directly.
|
||||
if (env.PROXY_HEADERS_TRUSTED) {
|
||||
app.proxy = true;
|
||||
if (env.PROXY_IP_HEADER) {
|
||||
app.proxyIpHeader = env.PROXY_IP_HEADER;
|
||||
}
|
||||
}
|
||||
|
||||
// Force redirect to HTTPS protocol unless explicitly disabled
|
||||
if (env.FORCE_HTTPS) {
|
||||
app.use(
|
||||
@@ -37,19 +47,16 @@ export default function init(app: Koa = new Koa(), server?: Server) {
|
||||
if (httpsResolver(ctx)) {
|
||||
return true;
|
||||
}
|
||||
return xForwardedProtoResolver(ctx);
|
||||
// Only honor X-Forwarded-Proto when proxy headers are trusted
|
||||
return env.PROXY_HEADERS_TRUSTED
|
||||
? xForwardedProtoResolver(ctx)
|
||||
: false;
|
||||
},
|
||||
})
|
||||
);
|
||||
} else {
|
||||
Logger.warn("Enforced https was disabled with FORCE_HTTPS env variable");
|
||||
}
|
||||
|
||||
// trust header fields set by our proxy. eg X-Forwarded-For
|
||||
app.proxy = true;
|
||||
if (env.PROXY_IP_HEADER) {
|
||||
app.proxyIpHeader = env.PROXY_IP_HEADER;
|
||||
}
|
||||
}
|
||||
|
||||
// Make `ctx.userAgent` available
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user