fix: small refactor

This commit is contained in:
Philipinho
2026-08-26 12:09:43 +01:00
parent 90e3437be7
commit 1a4ed15c38
15 changed files with 107 additions and 314 deletions
@@ -707,7 +707,10 @@
"Enable the MCP server to allow AI assistants and tools to interact with your workspace content.": "Enable the MCP server to allow AI assistants and tools to interact with your workspace content.",
"MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.": "MCP is only available in the Docmost enterprise edition. Contact sales@docmost.com.",
"MCP Server URL": "MCP Server URL",
"Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.": "Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.",
"Connect AI assistants with your Docmost account via OAuth.": "Connect AI assistants with your Docmost account via OAuth.",
"Require OAuth": "Require OAuth",
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.": "AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.",
"Toggle require OAuth for MCP": "Toggle require OAuth for MCP",
"Supported tools": "Supported tools",
"MCP server URL:": "MCP server URL:",
"Learn more": "Learn more",
@@ -1318,20 +1321,14 @@
"Revoke access for {{name}}": "Revoke access for {{name}}",
"Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.": "Are you sure you want to revoke access for {{name}}? The application will no longer be able to access your account.",
"Something went wrong. Please try again.": "Something went wrong. Please try again.",
"Trusted applications": "Trusted applications",
"Remove {{name}}": "Remove {{name}}",
"This origin is already trusted.": "This origin is already trusted.",
"Trusted application name": "Trusted application name",
"Trusted application origin": "Trusted application origin",
"I recognize this application and want to continue": "I recognize this application and want to continue",
"I trust this application and want to continue": "I trust this application and want to continue",
"You will be redirected to": "You will be redirected to",
"View content without making changes.": "View content without making changes.",
"Create and modify content.": "Create and modify content.",
"This application is not on your workspace's trusted list. Authorize only if you recognize it.": "This application is not on your workspace's trusted list. Authorize only if you recognize it.",
"Applications with these callback origins are trusted. Members will not see a warning when authorizing them.": "Applications with these callback origins are trusted. Members will not see a warning when authorizing them.",
"Enter the app's callback origin, e.g. https://app.yourcompany.com": "Enter the app's callback origin, e.g. https://app.yourcompany.com",
"Make sure you trust this application before authorizing it.": "Make sure you trust this application before authorizing it.",
"Applications and AI assistants you have authorized to access your account.": "Applications and AI assistants you have authorized to access your account.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account, or with an API key.": "Your workspace has MCP enabled. Connect AI assistants with your Docmost account, or with an API key.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.": "Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.",
"Authorized apps": "Authorized apps",
"No authorized apps yet.": "No authorized apps yet.",
"Workspace knowledge only": "Workspace knowledge only",
+58 -152
View File
@@ -1,9 +1,8 @@
import {
Anchor,
Button,
Badge,
Group,
List,
Table,
Text,
Switch,
TextInput,
@@ -17,33 +16,14 @@ import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
import React, { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
import { ITrustedOAuthClient } from "@/features/workspace/types/workspace.types.ts";
import { notifications } from "@mantine/notifications";
import { useHasFeature } from "@/ee/hooks/use-feature";
import { Feature } from "@/ee/features";
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
import { getAppUrl } from "@/lib/config.ts";
import {
IconCheck,
IconCopy,
IconInfoCircle,
IconTrash,
} from "@tabler/icons-react";
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
import { CopyButton } from "@/components/common/copy-button.tsx";
// Mirrors the server rule: an exact https origin, tolerating only a trailing slash.
function parseTrustedOrigin(value: string): string | null {
const input = value.trim().toLowerCase();
try {
const url = new URL(input);
if (url.protocol !== "https:") return null;
if (input !== url.origin && input !== `${url.origin}/`) return null;
return url.origin;
} catch {
return null;
}
}
export default function McpSettings() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
@@ -51,14 +31,7 @@ export default function McpSettings() {
const hasAccess = useHasFeature(Feature.MCP);
const upgradeLabel = useUpgradeLabel();
const [newClientName, setNewClientName] = useState("");
const [newClientOrigin, setNewClientOrigin] = useState("");
const mcpUrl = `${getAppUrl()}/mcp`;
const storedTrustedClients = workspace?.trustedOauthClients;
const trustedClients = Array.isArray(storedTrustedClients)
? storedTrustedClients
: [];
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
@@ -74,51 +47,6 @@ export default function McpSettings() {
}
};
const saveTrustedClients = async (next: ITrustedOAuthClient[]) => {
try {
const updatedWorkspace = await updateWorkspace({
trustedOauthClients: next,
});
setWorkspace(updatedWorkspace);
return true;
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
return false;
}
};
const handleAddTrustedClient = async () => {
const name = newClientName.trim();
const origin = parseTrustedOrigin(newClientOrigin);
if (!origin) {
notifications.show({
message: t("Enter the app's callback origin, e.g. https://app.yourcompany.com"),
color: "red",
});
return;
}
if (trustedClients.some((client) => client.origin.toLowerCase() === origin)) {
notifications.show({
message: t("This origin is already trusted."),
color: "red",
});
return;
}
if (await saveTrustedClients([...trustedClients, { origin, name }])) {
setNewClientName("");
setNewClientOrigin("");
}
};
const handleRemoveTrustedClient = (origin: string) => {
void saveTrustedClients(
trustedClients.filter((client) => client.origin !== origin),
);
};
return (
<Stack gap="lg">
{!hasAccess && (
@@ -180,11 +108,11 @@ export default function McpSettings() {
</CopyButton>
</Group>
<Text size="sm" c="dimmed" mt="xs">
{t(
"Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.",
)}
{t("Connect AI assistants with your Docmost account via OAuth.")}
</Text>
<McpOauthOnlySetting />
<div>
<Text size="sm" fw={500} mt="md" mb={4}>
{t("Supported tools")}
@@ -222,83 +150,61 @@ export default function McpSettings() {
</List.Item>
</List>
</div>
<div>
<Text size="sm" fw={500} mt="md" mb={4}>
{t("Trusted applications")}
</Text>
<Text size="sm" c="dimmed" mb="xs">
{t(
"Applications with these callback origins are trusted. Members will not see a warning when authorizing them.",
)}
</Text>
{trustedClients.length > 0 && (
<Table verticalSpacing="xs" mb="xs">
<Table.Tbody>
{trustedClients.map((client) => (
<Table.Tr key={client.origin}>
<Table.Td>
<Text size="sm" fw={500}>
{client.name}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{client.origin}
</Text>
</Table.Td>
<Table.Td w={40}>
<ActionIcon
variant="subtle"
color="red"
aria-label={t("Remove {{name}}", {
name: client.name,
})}
onClick={() =>
handleRemoveTrustedClient(client.origin)
}
>
<IconTrash size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
)}
<Group gap="xs">
<TextInput
value={newClientName}
onChange={(event) =>
setNewClientName(event.currentTarget.value)
}
placeholder={t("Name")}
aria-label={t("Trusted application name")}
maxLength={64}
style={{ flex: 1 }}
/>
<TextInput
value={newClientOrigin}
onChange={(event) =>
setNewClientOrigin(event.currentTarget.value)
}
placeholder="https://app.yourcompany.com"
aria-label={t("Trusted application origin")}
style={{ flex: 2 }}
/>
<Button
variant="default"
onClick={handleAddTrustedClient}
disabled={!newClientName.trim() || !newClientOrigin.trim()}
>
{t("Add")}
</Button>
</Group>
</div>
</div>
)}
</Stack>
);
}
function McpOauthOnlySetting() {
const { t } = useTranslation();
const [workspace, setWorkspace] = useAtom(workspaceAtom);
const [checked, setChecked] = useState(workspace?.settings?.ai?.mcpOauthOnly);
const hasAccess = useHasFeature(Feature.MCP_CONTROLS);
const upgradeLabel = useUpgradeLabel();
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.currentTarget.checked;
try {
const updatedWorkspace = await updateWorkspace({ mcpOauthOnly: value });
setChecked(value);
setWorkspace(updatedWorkspace);
} catch (err) {
notifications.show({
message: err?.response?.data?.message,
color: "red",
});
}
};
return (
<Group justify="space-between" wrap="nowrap" gap="xl" mt="md">
<div>
<Group gap="xs" align="center">
<Text size="sm" fw={500}>
{t("Require OAuth")}
</Text>
{!hasAccess && (
<Badge variant="light" size="sm" radius="sm">
{t("Enterprise")}
</Badge>
)}
</Group>
<Text size="sm" c="dimmed">
{t(
"AI assistants must connect with a Docmost account via OAuth. API keys cannot be used with the MCP server.",
)}
</Text>
</div>
<Tooltip label={upgradeLabel} disabled={hasAccess} refProp="rootRef">
<Switch
defaultChecked={checked}
onChange={handleChange}
disabled={!hasAccess}
aria-label={t("Toggle require OAuth for MCP")}
/>
</Tooltip>
</Group>
);
}
@@ -74,7 +74,7 @@ export default function UserApiKeys() {
<Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}>
<Text size="sm">
{t(
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account, or with an API key.",
"Your workspace has MCP enabled. Connect AI assistants with your Docmost account via OAuth.",
)}{" "}
<Anchor
href="https://docmost.com/docs/user-guide/mcp"
+1
View File
@@ -24,4 +24,5 @@ export const Feature = {
BASES: 'bases',
OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls',
} as const;
@@ -311,7 +311,7 @@ function ConsentCard({ info, currentUser, params }: ConsentCardProps) {
<Stack gap="xs">
<Text size="sm">
{t(
"This application is not on your workspace's trusted list. Authorize only if you recognize it.",
"Make sure you trust this application before authorizing it.",
)}
</Text>
<Checkbox
@@ -321,7 +321,7 @@ function ConsentCard({ info, currentUser, params }: ConsentCardProps) {
onChange={(event) =>
setAcknowledged(event.currentTarget.checked)
}
label={t("I recognize this application and want to continue")}
label={t("I trust this application and want to continue")}
/>
</Stack>
</Alert>
@@ -27,13 +27,13 @@ export interface IWorkspace {
mcpEnabled?: boolean;
aiChatReadOnly?: boolean;
aiChatWorkspaceKnowledgeOnly?: boolean;
mcpOauthOnly?: boolean;
trashRetentionDays?: number;
restrictApiToAdmins?: boolean;
allowMemberTemplates?: boolean;
allowPersonalSpaces?: boolean;
defaultPageEditMode?: string;
isScimEnabled?: boolean;
trustedOauthClients?: ITrustedOAuthClient[];
}
export interface IWorkspaceSettings {
@@ -45,11 +45,6 @@ export interface IWorkspaceSettings {
defaultPageEditMode?: string;
}
export interface ITrustedOAuthClient {
origin: string;
name: string;
}
export interface IWorkspaceApiSettings {
restrictToAdmins?: boolean;
}
@@ -58,6 +53,7 @@ export interface IWorkspaceAiSettings {
search?: boolean;
generative?: boolean;
mcp?: boolean;
mcpOauthOnly?: boolean;
chat?: boolean;
chatReadOnly?: boolean;
chatWorkspaceKnowledgeOnly?: boolean;
+1
View File
@@ -25,6 +25,7 @@ export const Feature = {
BASES: 'bases',
OAUTH: 'oauth',
AI_CONTROLS: 'ai:controls',
MCP_CONTROLS: 'mcp:controls',
} as const;
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
@@ -1,32 +1,15 @@
import { PartialType } from '@nestjs/mapped-types';
import { Type } from 'class-transformer';
import { CreateWorkspaceDto } from './create-workspace.dto';
import { TrustedOAuthClient } from '../workspace.util';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsInt,
IsNotEmpty,
IsOptional,
IsString,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
export class TrustedOAuthClientDto {
@IsString()
@IsNotEmpty()
origin: string;
@IsString()
@IsNotEmpty()
@MaxLength(64)
name: string;
}
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsArray()
@@ -86,13 +69,6 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsIn(['read', 'edit'])
defaultPageEditMode: string;
@IsOptional()
@IsArray()
@ArrayMaxSize(50)
@ValidateNested({ each: true })
@Type(() => TrustedOAuthClientDto)
trustedOauthClients?: TrustedOAuthClient[];
@IsOptional()
@IsBoolean()
aiChatReadOnly: boolean;
@@ -100,4 +76,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
@IsOptional()
@IsBoolean()
aiChatWorkspaceKnowledgeOnly: boolean;
@IsOptional()
@IsBoolean()
mcpOauthOnly: boolean;
}
@@ -30,10 +30,7 @@ import { DomainService } from '../../../integrations/environment/domain.service'
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { addDays } from 'date-fns';
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
import {
isAdminActingOnOwner,
normalizeTrustedOAuthClients,
} from '../workspace.util';
import { isAdminActingOnOwner } from '../workspace.util';
import { v4 } from 'uuid';
import { InjectQueue } from '@nestjs/bullmq';
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
@@ -317,12 +314,6 @@ export class WorkspaceService {
.filter(Boolean);
}
if (typeof updateWorkspaceDto.trustedOauthClients !== 'undefined') {
updateWorkspaceDto.trustedOauthClients = normalizeTrustedOAuthClients(
updateWorkspaceDto.trustedOauthClients,
);
}
if (updateWorkspaceDto.hostname) {
const hostname = updateWorkspaceDto.hostname;
if (DISALLOWED_HOSTNAMES.includes(hostname)) {
@@ -344,9 +335,9 @@ export class WorkspaceService {
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' ||
typeof updateWorkspaceDto.trustedOauthClients !== 'undefined' ||
typeof updateWorkspaceDto.aiChatReadOnly !== 'undefined' ||
typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined'
typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined' ||
typeof updateWorkspaceDto.mcpOauthOnly !== 'undefined'
) {
const ws = await this.db
.selectFrom('workspaces')
@@ -374,18 +365,6 @@ export class WorkspaceService {
}
}
if (typeof updateWorkspaceDto.trustedOauthClients !== 'undefined') {
if (
!this.licenseCheckService.hasFeature(
ws.licenseKey,
Feature.OAUTH,
ws.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
if (
!this.licenseCheckService.hasFeature(
@@ -413,6 +392,18 @@ export class WorkspaceService {
}
}
if (typeof updateWorkspaceDto.mcpOauthOnly !== 'undefined') {
if (
!this.licenseCheckService.hasFeature(
ws.licenseKey,
Feature.MCP_CONTROLS,
ws.plan,
)
) {
throw new ForbiddenException('This feature requires a valid license');
}
}
if (
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
typeof updateWorkspaceDto.trashRetentionDays !== 'undefined' ||
@@ -583,6 +574,20 @@ export class WorkspaceService {
);
}
if (typeof updateWorkspaceDto.mcpOauthOnly !== 'undefined') {
const prev = settingsBefore?.ai?.mcpOauthOnly ?? false;
if (prev !== updateWorkspaceDto.mcpOauthOnly) {
before.mcpOauthOnly = prev;
after.mcpOauthOnly = updateWorkspaceDto.mcpOauthOnly;
}
await this.workspaceRepo.updateAiSettings(
workspaceId,
'mcpOauthOnly',
updateWorkspaceDto.mcpOauthOnly,
trx,
);
}
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
const prev = settingsBefore?.spaces?.allowPersonal ?? false;
if (prev !== updateWorkspaceDto.allowPersonalSpaces) {
@@ -622,6 +627,7 @@ export class WorkspaceService {
delete updateWorkspaceDto.defaultPageEditMode;
delete updateWorkspaceDto.aiChatReadOnly;
delete updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly;
delete updateWorkspaceDto.mcpOauthOnly;
await this.workspaceRepo.updateWorkspace(
updateWorkspaceDto,
@@ -661,7 +667,6 @@ export class WorkspaceService {
'enforceMfa',
'emailDomains',
'isScimEnabled',
'trustedOauthClients',
],
updateWorkspaceDto,
workspaceBefore,
@@ -1,49 +0,0 @@
import { BadRequestException } from '@nestjs/common';
import { normalizeTrustedOAuthClients } from './workspace.util';
describe('normalizeTrustedOAuthClients', () => {
it('lowercases origins and trims names', () => {
expect(
normalizeTrustedOAuthClients([
{ origin: 'https://mcp.acme.com', name: ' Acme MCP ' },
]),
).toEqual([{ origin: 'https://mcp.acme.com', name: 'Acme MCP' }]);
});
it('dedupes origins case-insensitively with the last entry winning', () => {
expect(
normalizeTrustedOAuthClients([
{ origin: 'https://mcp.acme.com', name: 'First' },
{ origin: 'https://mcp.acme.com', name: 'Second' },
]),
).toEqual([{ origin: 'https://mcp.acme.com', name: 'Second' }]);
});
it.each([
['http origin', 'http://mcp.acme.com'],
['trailing slash', 'https://mcp.acme.com/'],
['path suffix', 'https://mcp.acme.com/oauth'],
['uppercase host', 'https://MCP.acme.com'],
['not a url', 'mcp.acme.com'],
])('rejects %s naming the origin', (_label, origin) => {
expect(() =>
normalizeTrustedOAuthClients([{ origin, name: 'Acme MCP' }]),
).toThrow(BadRequestException);
expect(() =>
normalizeTrustedOAuthClients([{ origin, name: 'Acme MCP' }]),
).toThrow(origin);
});
it.each([
['blank', ' '],
['too long', 'x'.repeat(65)],
])('rejects a %s name', (_label, name) => {
expect(() =>
normalizeTrustedOAuthClients([{ origin: 'https://mcp.acme.com', name }]),
).toThrow(BadRequestException);
});
it('returns an empty array for no entries', () => {
expect(normalizeTrustedOAuthClients([])).toEqual([]);
});
});
@@ -1,4 +1,3 @@
import { BadRequestException } from '@nestjs/common';
import { UserRole } from '../../common/helpers/types/permission';
export function isAdminActingOnOwner(
@@ -8,39 +7,6 @@ export function isAdminActingOnOwner(
return authUserRole === UserRole.ADMIN && targetRole === UserRole.OWNER;
}
export type TrustedOAuthClient = { origin: string; name: string };
// Origins must be exact https origins; duplicates collapse case-insensitively, last entry wins.
export function normalizeTrustedOAuthClients(
entries: { origin: string; name: string }[],
): TrustedOAuthClient[] {
const byOrigin = new Map<string, TrustedOAuthClient>();
for (const entry of entries) {
const name = entry.name.trim();
if (name.length < 1 || name.length > 64) {
throw new BadRequestException(
`Invalid trusted application name for origin: ${entry.origin}`,
);
}
let parsed: URL;
try {
parsed = new URL(entry.origin);
} catch {
throw new BadRequestException(
`Invalid trusted application origin: ${entry.origin}`,
);
}
if (parsed.protocol !== 'https:' || parsed.origin !== entry.origin) {
throw new BadRequestException(
`Trusted application origin must be an exact https origin: ${entry.origin}`,
);
}
const origin = entry.origin.toLowerCase();
byOrigin.set(origin, { origin, name });
}
return Array.from(byOrigin.values());
}
export type PageEditMode = 'read' | 'edit';
export function getWorkspaceDefaultPageEditMode(
@@ -78,17 +78,9 @@ export async function up(db: Kysely<any>): Promise<void> {
await db.schema.createIndex('oauth_tokens_access_expires_at_idx').on('oauth_tokens').column('access_expires_at').execute();
await db.schema.createIndex('oauth_tokens_refresh_expires_at_idx').on('oauth_tokens').column('refresh_expires_at').execute();
await db.schema.createIndex('oauth_tokens_revoked_at_idx').on('oauth_tokens').column('revoked_at').execute();
await db.schema
.alterTable('workspaces')
.addColumn('trusted_oauth_clients', 'jsonb', (col) => col.defaultTo(sql`'[]'::jsonb`))
.execute();
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE workspaces DROP COLUMN IF EXISTS trusted_oauth_clients`.execute(
db,
);
await db.schema.dropTable('oauth_tokens').execute();
await db.schema.dropTable('oauth_grants').execute();
await db.schema.dropTable('oauth_authorization_codes').execute();
@@ -20,7 +20,6 @@ export class WorkspaceRepo {
'hostname',
'customDomain',
'settings',
'trustedOauthClients',
'defaultRole',
'emailDomains',
'defaultSpaceId',
-1
View File
@@ -460,7 +460,6 @@ export interface Workspaces {
status: string | null;
stripeCustomerId: string | null;
trialEndAt: Timestamp | null;
trustedOauthClients: Generated<Json | null>;
updatedAt: Generated<Timestamp>;
}