From 0d50f0d60ac14b73c0a6e6e059fc7f4dd255534e Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 3 Jun 2026 23:24:32 -0400 Subject: [PATCH 01/27] fix: Do not close icon picker on choice (#12573) --- app/components/IconPicker/index.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/app/components/IconPicker/index.tsx b/app/components/IconPicker/index.tsx index 531187d786..00459e5c40 100644 --- a/app/components/IconPicker/index.tsx +++ b/app/components/IconPicker/index.tsx @@ -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); From a21ddc4999c2a110fce216b97a7f12e2f4338b42 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 3 Jun 2026 23:51:09 -0400 Subject: [PATCH 02/27] Add tagging of outgoing emails (#12570) * Add tagging of outgoing emails * Detect SES configured via well-known service key The isSES check only matched "amazonaws" in the host, so SES configured through SMTP_SERVICE (e.g. "SES" or "SES-US-EAST-1") was not detected and tagging headers were not applied. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- server/emails/mailer.tsx | 84 +++++++++++++++++++++++++++ server/emails/templates/BaseEmail.tsx | 1 + 2 files changed, 85 insertions(+) diff --git a/server/emails/mailer.tsx b/server/emails/mailer.tsx index c5a72fb1fb..0c6517a38c 100644 --- a/server/emails/mailer.tsx +++ b/server/emails/mailer.tsx @@ -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 | 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..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) { diff --git a/server/emails/templates/BaseEmail.tsx b/server/emails/templates/BaseEmail.tsx index 85801c1c23..03fa1b3a3a 100644 --- a/server/emails/templates/BaseEmail.tsx +++ b/server/emails/templates/BaseEmail.tsx @@ -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, From 4126b94f7c8ea70c883767a66663c7698d5b5351 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Wed, 3 Jun 2026 23:52:18 -0400 Subject: [PATCH 03/27] fix: Add gap between search input and New doc button on mobile (#12578) Co-authored-by: Claude --- app/components/InputSearchPage.tsx | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/components/InputSearchPage.tsx b/app/components/InputSearchPage.tsx index 6e898de5e9..d73601bb36 100644 --- a/app/components/InputSearchPage.tsx +++ b/app/components/InputSearchPage.tsx @@ -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 }>` From e9e565dc2b1817132a69c85c53b861fbb3991c3d Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 4 Jun 2026 08:24:51 -0400 Subject: [PATCH 04/27] fix: Access request logic for collection managers (#12579) * fix: Access request logic for collection managers * test: Exercise collection-manager path in access request regression tests Grant the non-workspace-admin manager a collection-level Admin membership instead of a direct document-level membership, so authorization flows through the collection-manager path being tested for #12567. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../api/accessRequests/accessRequests.test.ts | 90 ++++++++++++++++++- .../api/accessRequests/accessRequests.ts | 12 +-- 2 files changed, 95 insertions(+), 7 deletions(-) diff --git a/server/routes/api/accessRequests/accessRequests.test.ts b/server/routes/api/accessRequests/accessRequests.test.ts index 1173295911..2fed31a0ff 100644 --- a/server/routes/api/accessRequests/accessRequests.test.ts +++ b/server/routes/api/accessRequests/accessRequests.test.ts @@ -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 }); diff --git a/server/routes/api/accessRequests/accessRequests.ts b/server/routes/api/accessRequests/accessRequests.ts index ffc8af0cac..e6dd152d8a 100644 --- a/server/routes/api/accessRequests/accessRequests.ts +++ b/server/routes/api/accessRequests/accessRequests.ts @@ -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); From 02c5c93bd884e43840fd55c2bc814be6d3c8fcfa Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 4 Jun 2026 09:13:50 -0400 Subject: [PATCH 05/27] Account for screen safe area in mobile drawer bottom padding (#12577) Co-authored-by: Claude --- app/components/primitives/Drawer.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/app/components/primitives/Drawer.tsx b/app/components/primitives/Drawer.tsx index 9fdd6af6a4..d556d400d3 100644 --- a/app/components/primitives/Drawer.tsx +++ b/app/components/primitives/Drawer.tsx @@ -102,6 +102,7 @@ const StyledContent = styled(m.div)` const StyledInnerContent = styled(Flex)` padding: 6px; + padding-bottom: calc(6px + var(--sab, 0px)); height: 100%; `; From 2aff3907c587792b98b620ad3e1d32657f8ee782 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 4 Jun 2026 09:14:04 -0400 Subject: [PATCH 06/27] Make collection title and icon inline editable like documents (#12574) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collection title/icon editing was gated by `isEditRoute && separateEditMode`, which meant that in the default inline editing mode (separateEditMode off) the title and icon were never editable inline — even though the collection description was. This diverged from documents and from the collection description editor. Align the Header editing gate with documents (DataLoader) and the Overview description editor: `isEditRoute || !separateEditMode`, so title and icon are seamlessly editable inline whenever the user has update permission. Co-authored-by: Claude --- app/scenes/Collection/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/scenes/Collection/index.tsx b/app/scenes/Collection/index.tsx index 40646e22b1..39f2d72554 100644 --- a/app/scenes/Collection/index.tsx +++ b/app/scenes/Collection/index.tsx @@ -179,7 +179,7 @@ const CollectionScene = observer(function CollectionScene_() {
Date: Thu, 4 Jun 2026 17:33:06 -0400 Subject: [PATCH 07/27] chore(deps): bump hono from 4.12.18 to 4.12.23 (#12584) Bumps [hono](https://github.com/honojs/hono) from 4.12.18 to 4.12.23. - [Release notes](https://github.com/honojs/hono/releases) - [Commits](https://github.com/honojs/hono/compare/v4.12.18...v4.12.23) --- updated-dependencies: - dependency-name: hono dependency-version: 4.12.23 dependency-type: indirect ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- yarn.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/yarn.lock b/yarn.lock index b25f0ca8d6..b213a17b70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -11907,9 +11907,9 @@ __metadata: linkType: hard "hono@npm:^4.11.4": - version: 4.12.18 - resolution: "hono@npm:4.12.18" - checksum: 10c0/b0b9688fd9e41a1847b077d579dc0e92a28b67c247c6ee7d1e751c0bae269824c30c7773feff1a2874e40ea36a3d2f9d1fc5ba618a28ecdf2ca1b33ed2473864 + version: 4.12.23 + resolution: "hono@npm:4.12.23" + checksum: 10c0/58945bb3aeb16d710b4a6c2809ba02b86b7269f6a3a67e126fc81cee5e9ae8ad2d9aa553db03c4f1a104ec03e423072e33932cb23eb3bbc48774acc72fc9c346 languageName: node linkType: hard From 1cc10f5fffca7155c48e03ce406bf9fcbd87b52d Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 4 Jun 2026 23:30:55 -0400 Subject: [PATCH 08/27] fix: Increase valid user-supplied URL length to 1024 (#12585) * fix: Increase valid user-supplied URL length to 1024 * fix: Wrap URL length migration in a transaction Wrap the multi-column changeColumn operations in a transaction so a failure on any column rolls back the whole migration rather than leaving the database partially migrated. Co-Authored-By: Claude Opus 4.8 --------- Co-authored-by: Claude Opus 4.8 --- .../components/WebhookSubscriptionForm.tsx | 7 +- plugins/webhooks/server/api/schema.ts | 4 + ...60604140753-increase-url-column-lengths.js | 106 ++++++++++++++++++ shared/validations.ts | 8 +- 4 files changed, 120 insertions(+), 5 deletions(-) create mode 100644 server/migrations/20260604140753-increase-url-column-lengths.js diff --git a/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx b/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx index 61d0fc24fd..74295b2b00 100644 --- a/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx +++ b/plugins/webhooks/client/components/WebhookSubscriptionForm.tsx @@ -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, + })} /> !env.isCloudHosted || val.startsWith("https://"), { error: "Webhook url must use https", }); diff --git a/server/migrations/20260604140753-increase-url-column-lengths.js b/server/migrations/20260604140753-increase-url-column-lengths.js new file mode 100644 index 0000000000..450c6bda22 --- /dev/null +++ b/server/migrations/20260604140753-increase-url-column-lengths.js @@ -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 } + ); + }); + }, +}; diff --git a/shared/validations.ts b/shared/validations.ts index d97da1b1e7..612f6cc26f 100644 --- a/shared/validations.ts +++ b/shared/validations.ts @@ -89,13 +89,13 @@ export const OAuthClientValidation = { maxDeveloperNameLength: 100, /** The maximum length of the OAuth client developer URL */ - maxDeveloperUrlLength: 255, + maxDeveloperUrlLength: 1024, /** The maximum length of the OAuth client avatar URL */ - maxAvatarUrlLength: 255, + maxAvatarUrlLength: 1024, /** The maximum length of an OAuth client redirect URI */ - maxRedirectUriLength: 255, + maxRedirectUriLength: 1024, /** The allowed OAuth client types */ clientTypes: ["confidential", "public"] as const, @@ -170,7 +170,7 @@ export const WebhookSubscriptionValidation = { /** The maximum length of the webhook name */ maxNameLength: 255, /** The maximum length of the webhook url */ - maxUrlLength: 255, + maxUrlLength: 1024, }; export const EmojiValidation = { From bfddf4bb4c3da05e560a1b5669162f8a2862af07 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Thu, 4 Jun 2026 23:31:16 -0400 Subject: [PATCH 09/27] fix: Unhandled server error in MCP route (#12586) --- server/routes/mcp/index.test.ts | 11 +++++++++++ server/routes/mcp/index.ts | 31 ++++++++++++++++++++++++++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/server/routes/mcp/index.test.ts b/server/routes/mcp/index.test.ts index 4cdcd8231c..02c5e82481 100644 --- a/server/routes/mcp/index.test.ts +++ b/server/routes/mcp/index.test.ts @@ -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); diff --git a/server/routes/mcp/index.ts b/server/routes/mcp/index.ts index 36d81e56e3..c80aa1f58c 100644 --- a/server/routes/mcp/index.ts +++ b/server/routes/mcp/index.ts @@ -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"; @@ -113,7 +114,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(); + } + } } ); From c808bed7122d392f92248d1db0b78720eba1e17f Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 5 Jun 2026 07:42:15 -0400 Subject: [PATCH 10/27] Add mobile drawer UI for FilterOptions component (#12576) * Render filter options as drawer popover on mobile Filter options on the search page (and other FilterOptions consumers) previously rendered as a Radix dropdown on all viewports. On mobile this now renders as a bottom-sheet Drawer, matching the popover style already used by context menus. https://claude.ai/code/session_01MSjTD67PWfGbwgNA5FFoSH * Fix filter drawer search input overlapping first option on mobile The Input wrapper uses flex: 0 (a 0% basis), which collapsed the search input inside the drawer's flex column so its content painted over the first list item. Use flex: none to retain the input's natural height. --------- Co-authored-by: Claude --- app/components/FilterOptions.tsx | 161 +++++++++++++++++++++++-------- 1 file changed, 122 insertions(+), 39 deletions(-) diff --git a/app/components/FilterOptions.tsx b/app/components/FilterOptions.tsx index 049902be2e..30f1bd293c 100644 --- a/app/components/FilterOptions.tsx +++ b/app/components/FilterOptions.tsx @@ -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 { @@ -45,6 +55,7 @@ const FilterOptions = ({ ...rest }: Props) => { const { t } = useTranslation(); + const isMobile = useMobile(); const searchInputRef = React.useRef(null); const listRef = React.useRef(null); const [open, setOpen] = React.useState(false); @@ -58,23 +69,45 @@ const FilterOptions = ({ : ""; const renderItem = React.useCallback( - (option) => ( - {option.icon} - ) : 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 ? ( + {option.icon} + ) : 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 ( + + {icon} + {option.label} + + {selectedKeys.includes(option.key) ? ( + + ) : null} + + + ); + } + + return ( + + ); + }, + [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 = ( + + {selectedItems.length ? selectedLabel : defaultLabel} + + ); + + const list = ( + + listRef={listRef} + options={{ query, ...fetchQueryOptions }} + items={filteredOptions} + fetch={fetchQuery} + renderItem={renderItem} + onEscape={handleEscapeFromList} + heading={showFilterInput && !isMobile ? : undefined} + 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 ( + + {trigger} + + {defaultLabel} + {showFilterInput && ( + + )} + {list} + + + ); + } + return ( - - - {selectedItems.length ? selectedLabel : defaultLabel} - - + {trigger} - - listRef={listRef} - options={{ query, ...fetchQueryOptions }} - items={filteredOptions} - fetch={fetchQuery} - renderItem={renderItem} - onEscape={handleEscapeFromList} - heading={showFilterInput ? : undefined} - empty={} - /> + {list} {showFilterInput && ( Date: Fri, 5 Jun 2026 07:42:26 -0400 Subject: [PATCH 11/27] fix: Sticky table header styling in Safari (#12590) * fix: Sticky table header styling in Safari * feedback --- shared/editor/components/Styles.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/shared/editor/components/Styles.ts b/shared/editor/components/Styles.ts index 672f4907f5..b16353f0b9 100644 --- a/shared/editor/components/Styles.ts +++ b/shared/editor/components/Styles.ts @@ -2437,6 +2437,11 @@ table { > .${EditorStyleHelper.tableScrollable} > table > tbody > tr:first-child { position: relative; z-index: 2; + + > th { + // Safari requires the header cell to have raised z-index too + z-index: 2; + } } > .${EditorStyleHelper.tableScrollable} > table > tbody > tr:first-child > th { From 88de417a21c260e32ecc9c89d756661c54064603 Mon Sep 17 00:00:00 2001 From: Tom Moor Date: Fri, 5 Jun 2026 08:27:10 -0400 Subject: [PATCH 12/27] Refactor drag-and-drop to support dragging from document lists (#12587) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Allow dragging documents from list views into the sidebar Previously the react-dnd provider was scoped to the sidebar, so only sidebar rows could be dragged. This lifts the DndProvider up to the authenticated layout so both the main content and the sidebar share a single drag-and-drop context, and makes DocumentListItem a drag source. Now any document in search results or paginated lists (Home, Drafts, Collections, etc.) can be dragged into the sidebar to move it between collections, reparent it under another document, star it, or archive it — reusing the existing sidebar drop targets. * Make the whole Starred section a drop target to star documents Previously the only "create star" drop targets in the Starred section were the thin cursors between items, so dragging a document onto the section header or a starred row showed the drop cursor but did nothing. Wrap the section in a catch-all drop target (mirroring the Archive section) so dropping anywhere in Starred stars the document, while the precise inter-item cursors still control ordering. A didDrop guard on useDropToCreateStar prevents the catch-all from double-starring when a nested cursor already handled the drop, and the hover highlight uses a shallow isOver check so it only lights up when not over a nested target. * Let document list drag ghost follow the cursor The sidebar drag placeholder tethers the ghost near its starting x so it stays aligned with the sidebar during reordering. When a drag starts out in the main content (a document list item), that clamp pinned the ghost to a narrow band, making it look stuck in a small area. Thread a constrainToSidebar flag through the drag item (true for sidebar drags, false for document list drags) and let the placeholder follow the cursor freely when the drag originated outside the sidebar. * Clarify constrainToSidebar JSDoc to match placeholder behavior The placeholder treats an unset flag as tethered (constrainToSidebar !== false), so external drags must set it explicitly to false rather than leaving it unset. Update the comment to reflect that. * css --- app/components/AuthenticatedLayout.tsx | 20 ++- app/components/DocumentListItem.tsx | 25 ++- app/components/Sidebar/App.tsx | 155 ++++++++---------- .../Sidebar/components/DragPlaceholder.tsx | 24 ++- app/components/Sidebar/components/Starred.tsx | 97 ++++++----- .../Sidebar/hooks/useDragAndDrop.tsx | 21 ++- 6 files changed, 202 insertions(+), 140 deletions(-) diff --git a/app/components/AuthenticatedLayout.tsx b/app/components/AuthenticatedLayout.tsx index 4aa0c01ba4..d04c171c7d 100644 --- a/app/components/AuthenticatedLayout.tsx +++ b/app/components/AuthenticatedLayout.tsx @@ -1,5 +1,7 @@ import { observer } from "mobx-react"; import * as React from "react"; +import { DndProvider } from "react-dnd"; +import { HTML5Backend } from "react-dnd-html5-backend"; import { useLocation } from "react-router-dom"; import ErrorSuspended from "~/scenes/Errors/ErrorSuspended"; import Layout from "~/components/Layout"; @@ -104,14 +106,16 @@ const AuthenticatedLayout: React.FC = ({ children }: Props) => { - - - - - {children} - - - + + + + + + {children} + + + + diff --git a/app/components/DocumentListItem.tsx b/app/components/DocumentListItem.tsx index 8df3789913..2453e68750 100644 --- a/app/components/DocumentListItem.tsx +++ b/app/components/DocumentListItem.tsx @@ -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([ + itemRef, + draggableRef, + ] as React.Ref[]), + [itemRef, draggableRef] + ); + return ( ` 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; diff --git a/app/components/Sidebar/App.tsx b/app/components/Sidebar/App.tsx index eda645e509..703fcc2198 100644 --- a/app/components/Sidebar/App.tsx +++ b/app/components/Sidebar/App.tsx @@ -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 ( -