mirror of
https://github.com/docmost/docmost.git
synced 2026-08-31 02:46:27 +08:00
fix: small refactor
This commit is contained in:
@@ -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.",
|
"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 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",
|
"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",
|
"Supported tools": "Supported tools",
|
||||||
"MCP server URL:": "MCP server URL:",
|
"MCP server URL:": "MCP server URL:",
|
||||||
"Learn more": "Learn more",
|
"Learn more": "Learn more",
|
||||||
@@ -1318,20 +1321,14 @@
|
|||||||
"Revoke access for {{name}}": "Revoke access for {{name}}",
|
"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.",
|
"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.",
|
"Something went wrong. Please try again.": "Something went wrong. Please try again.",
|
||||||
"Trusted applications": "Trusted applications",
|
|
||||||
"Remove {{name}}": "Remove {{name}}",
|
"Remove {{name}}": "Remove {{name}}",
|
||||||
"This origin is already trusted.": "This origin is already trusted.",
|
"I trust this application and want to continue": "I trust this application and want to continue",
|
||||||
"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",
|
|
||||||
"You will be redirected to": "You will be redirected to",
|
"You will be redirected to": "You will be redirected to",
|
||||||
"View content without making changes.": "View content without making changes.",
|
"View content without making changes.": "View content without making changes.",
|
||||||
"Create and modify content.": "Create and modify content.",
|
"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.",
|
"Make sure you trust this application before authorizing it.": "Make sure you trust this application before authorizing 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",
|
|
||||||
"Applications and AI assistants you have authorized to access your account.": "Applications and AI assistants you have authorized to access your account.",
|
"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",
|
"Authorized apps": "Authorized apps",
|
||||||
"No authorized apps yet.": "No authorized apps yet.",
|
"No authorized apps yet.": "No authorized apps yet.",
|
||||||
"Workspace knowledge only": "Workspace knowledge only",
|
"Workspace knowledge only": "Workspace knowledge only",
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
Anchor,
|
Anchor,
|
||||||
Button,
|
Badge,
|
||||||
Group,
|
Group,
|
||||||
List,
|
List,
|
||||||
Table,
|
|
||||||
Text,
|
Text,
|
||||||
Switch,
|
Switch,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -17,33 +16,14 @@ import { workspaceAtom } from "@/features/user/atoms/current-user-atom.ts";
|
|||||||
import React, { useState } from "react";
|
import React, { useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { Trans, useTranslation } from "react-i18next";
|
||||||
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
import { updateWorkspace } from "@/features/workspace/services/workspace-service.ts";
|
||||||
import { ITrustedOAuthClient } from "@/features/workspace/types/workspace.types.ts";
|
|
||||||
import { notifications } from "@mantine/notifications";
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useHasFeature } from "@/ee/hooks/use-feature";
|
import { useHasFeature } from "@/ee/hooks/use-feature";
|
||||||
import { Feature } from "@/ee/features";
|
import { Feature } from "@/ee/features";
|
||||||
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
import { useUpgradeLabel } from "@/ee/hooks/use-upgrade-label";
|
||||||
import { getAppUrl } from "@/lib/config.ts";
|
import { getAppUrl } from "@/lib/config.ts";
|
||||||
import {
|
import { IconCheck, IconCopy, IconInfoCircle } from "@tabler/icons-react";
|
||||||
IconCheck,
|
|
||||||
IconCopy,
|
|
||||||
IconInfoCircle,
|
|
||||||
IconTrash,
|
|
||||||
} from "@tabler/icons-react";
|
|
||||||
import { CopyButton } from "@/components/common/copy-button.tsx";
|
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() {
|
export default function McpSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
const [workspace, setWorkspace] = useAtom(workspaceAtom);
|
||||||
@@ -51,14 +31,7 @@ export default function McpSettings() {
|
|||||||
const hasAccess = useHasFeature(Feature.MCP);
|
const hasAccess = useHasFeature(Feature.MCP);
|
||||||
const upgradeLabel = useUpgradeLabel();
|
const upgradeLabel = useUpgradeLabel();
|
||||||
|
|
||||||
const [newClientName, setNewClientName] = useState("");
|
|
||||||
const [newClientOrigin, setNewClientOrigin] = useState("");
|
|
||||||
|
|
||||||
const mcpUrl = `${getAppUrl()}/mcp`;
|
const mcpUrl = `${getAppUrl()}/mcp`;
|
||||||
const storedTrustedClients = workspace?.trustedOauthClients;
|
|
||||||
const trustedClients = Array.isArray(storedTrustedClients)
|
|
||||||
? storedTrustedClients
|
|
||||||
: [];
|
|
||||||
|
|
||||||
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
const value = event.currentTarget.checked;
|
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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
{!hasAccess && (
|
{!hasAccess && (
|
||||||
@@ -180,11 +108,11 @@ export default function McpSettings() {
|
|||||||
</CopyButton>
|
</CopyButton>
|
||||||
</Group>
|
</Group>
|
||||||
<Text size="sm" c="dimmed" mt="xs">
|
<Text size="sm" c="dimmed" mt="xs">
|
||||||
{t(
|
{t("Connect AI assistants with your Docmost account via OAuth.")}
|
||||||
"Connect with your Docmost account via OAuth when your client supports it, or use an API key from your account settings.",
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
|
<McpOauthOnlySetting />
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<Text size="sm" fw={500} mt="md" mb={4}>
|
<Text size="sm" fw={500} mt="md" mb={4}>
|
||||||
{t("Supported tools")}
|
{t("Supported tools")}
|
||||||
@@ -222,83 +150,61 @@ export default function McpSettings() {
|
|||||||
</List.Item>
|
</List.Item>
|
||||||
</List>
|
</List>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</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 />}>
|
<Alert variant="light" color="blue" mb="md" p="sm" icon={<IconInfoCircle />}>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{t(
|
{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
|
<Anchor
|
||||||
href="https://docmost.com/docs/user-guide/mcp"
|
href="https://docmost.com/docs/user-guide/mcp"
|
||||||
|
|||||||
@@ -24,4 +24,5 @@ export const Feature = {
|
|||||||
BASES: 'bases',
|
BASES: 'bases',
|
||||||
OAUTH: 'oauth',
|
OAUTH: 'oauth',
|
||||||
AI_CONTROLS: 'ai:controls',
|
AI_CONTROLS: 'ai:controls',
|
||||||
|
MCP_CONTROLS: 'mcp:controls',
|
||||||
} as const;
|
} as const;
|
||||||
|
|||||||
@@ -311,7 +311,7 @@ function ConsentCard({ info, currentUser, params }: ConsentCardProps) {
|
|||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{t(
|
{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>
|
</Text>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
@@ -321,7 +321,7 @@ function ConsentCard({ info, currentUser, params }: ConsentCardProps) {
|
|||||||
onChange={(event) =>
|
onChange={(event) =>
|
||||||
setAcknowledged(event.currentTarget.checked)
|
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>
|
</Stack>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|||||||
@@ -27,13 +27,13 @@ export interface IWorkspace {
|
|||||||
mcpEnabled?: boolean;
|
mcpEnabled?: boolean;
|
||||||
aiChatReadOnly?: boolean;
|
aiChatReadOnly?: boolean;
|
||||||
aiChatWorkspaceKnowledgeOnly?: boolean;
|
aiChatWorkspaceKnowledgeOnly?: boolean;
|
||||||
|
mcpOauthOnly?: boolean;
|
||||||
trashRetentionDays?: number;
|
trashRetentionDays?: number;
|
||||||
restrictApiToAdmins?: boolean;
|
restrictApiToAdmins?: boolean;
|
||||||
allowMemberTemplates?: boolean;
|
allowMemberTemplates?: boolean;
|
||||||
allowPersonalSpaces?: boolean;
|
allowPersonalSpaces?: boolean;
|
||||||
defaultPageEditMode?: string;
|
defaultPageEditMode?: string;
|
||||||
isScimEnabled?: boolean;
|
isScimEnabled?: boolean;
|
||||||
trustedOauthClients?: ITrustedOAuthClient[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IWorkspaceSettings {
|
export interface IWorkspaceSettings {
|
||||||
@@ -45,11 +45,6 @@ export interface IWorkspaceSettings {
|
|||||||
defaultPageEditMode?: string;
|
defaultPageEditMode?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ITrustedOAuthClient {
|
|
||||||
origin: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IWorkspaceApiSettings {
|
export interface IWorkspaceApiSettings {
|
||||||
restrictToAdmins?: boolean;
|
restrictToAdmins?: boolean;
|
||||||
}
|
}
|
||||||
@@ -58,6 +53,7 @@ export interface IWorkspaceAiSettings {
|
|||||||
search?: boolean;
|
search?: boolean;
|
||||||
generative?: boolean;
|
generative?: boolean;
|
||||||
mcp?: boolean;
|
mcp?: boolean;
|
||||||
|
mcpOauthOnly?: boolean;
|
||||||
chat?: boolean;
|
chat?: boolean;
|
||||||
chatReadOnly?: boolean;
|
chatReadOnly?: boolean;
|
||||||
chatWorkspaceKnowledgeOnly?: boolean;
|
chatWorkspaceKnowledgeOnly?: boolean;
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ export const Feature = {
|
|||||||
BASES: 'bases',
|
BASES: 'bases',
|
||||||
OAUTH: 'oauth',
|
OAUTH: 'oauth',
|
||||||
AI_CONTROLS: 'ai:controls',
|
AI_CONTROLS: 'ai:controls',
|
||||||
|
MCP_CONTROLS: 'mcp:controls',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
export type FeatureKey = (typeof Feature)[keyof typeof Feature];
|
||||||
|
|||||||
@@ -1,32 +1,15 @@
|
|||||||
import { PartialType } from '@nestjs/mapped-types';
|
import { PartialType } from '@nestjs/mapped-types';
|
||||||
import { Type } from 'class-transformer';
|
|
||||||
import { CreateWorkspaceDto } from './create-workspace.dto';
|
import { CreateWorkspaceDto } from './create-workspace.dto';
|
||||||
import { TrustedOAuthClient } from '../workspace.util';
|
|
||||||
import {
|
import {
|
||||||
ArrayMaxSize,
|
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
IsInt,
|
IsInt,
|
||||||
IsNotEmpty,
|
|
||||||
IsOptional,
|
IsOptional,
|
||||||
IsString,
|
IsString,
|
||||||
MaxLength,
|
|
||||||
Min,
|
Min,
|
||||||
ValidateNested,
|
|
||||||
} from 'class-validator';
|
} from 'class-validator';
|
||||||
|
|
||||||
export class TrustedOAuthClientDto {
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
origin: string;
|
|
||||||
|
|
||||||
@IsString()
|
|
||||||
@IsNotEmpty()
|
|
||||||
@MaxLength(64)
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@@ -86,13 +69,6 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
|||||||
@IsIn(['read', 'edit'])
|
@IsIn(['read', 'edit'])
|
||||||
defaultPageEditMode: string;
|
defaultPageEditMode: string;
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsArray()
|
|
||||||
@ArrayMaxSize(50)
|
|
||||||
@ValidateNested({ each: true })
|
|
||||||
@Type(() => TrustedOAuthClientDto)
|
|
||||||
trustedOauthClients?: TrustedOAuthClient[];
|
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
aiChatReadOnly: boolean;
|
aiChatReadOnly: boolean;
|
||||||
@@ -100,4 +76,8 @@ export class UpdateWorkspaceDto extends PartialType(CreateWorkspaceDto) {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
aiChatWorkspaceKnowledgeOnly: boolean;
|
aiChatWorkspaceKnowledgeOnly: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
mcpOauthOnly: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,10 +30,7 @@ import { DomainService } from '../../../integrations/environment/domain.service'
|
|||||||
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||||
import { addDays } from 'date-fns';
|
import { addDays } from 'date-fns';
|
||||||
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
|
import { DISALLOWED_HOSTNAMES, WorkspaceStatus } from '../workspace.constants';
|
||||||
import {
|
import { isAdminActingOnOwner } from '../workspace.util';
|
||||||
isAdminActingOnOwner,
|
|
||||||
normalizeTrustedOAuthClients,
|
|
||||||
} from '../workspace.util';
|
|
||||||
import { v4 } from 'uuid';
|
import { v4 } from 'uuid';
|
||||||
import { InjectQueue } from '@nestjs/bullmq';
|
import { InjectQueue } from '@nestjs/bullmq';
|
||||||
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
import { QueueJob, QueueName } from '../../../integrations/queue/constants';
|
||||||
@@ -317,12 +314,6 @@ export class WorkspaceService {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeof updateWorkspaceDto.trustedOauthClients !== 'undefined') {
|
|
||||||
updateWorkspaceDto.trustedOauthClients = normalizeTrustedOAuthClients(
|
|
||||||
updateWorkspaceDto.trustedOauthClients,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (updateWorkspaceDto.hostname) {
|
if (updateWorkspaceDto.hostname) {
|
||||||
const hostname = updateWorkspaceDto.hostname;
|
const hostname = updateWorkspaceDto.hostname;
|
||||||
if (DISALLOWED_HOSTNAMES.includes(hostname)) {
|
if (DISALLOWED_HOSTNAMES.includes(hostname)) {
|
||||||
@@ -344,9 +335,9 @@ export class WorkspaceService {
|
|||||||
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
|
typeof updateWorkspaceDto.allowMemberTemplates !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
|
typeof updateWorkspaceDto.isScimEnabled !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' ||
|
typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.trustedOauthClients !== 'undefined' ||
|
|
||||||
typeof updateWorkspaceDto.aiChatReadOnly !== 'undefined' ||
|
typeof updateWorkspaceDto.aiChatReadOnly !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined'
|
typeof updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly !== 'undefined' ||
|
||||||
|
typeof updateWorkspaceDto.mcpOauthOnly !== 'undefined'
|
||||||
) {
|
) {
|
||||||
const ws = await this.db
|
const ws = await this.db
|
||||||
.selectFrom('workspaces')
|
.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 (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
|
||||||
if (
|
if (
|
||||||
!this.licenseCheckService.hasFeature(
|
!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 (
|
if (
|
||||||
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
typeof updateWorkspaceDto.disablePublicSharing !== 'undefined' ||
|
||||||
typeof updateWorkspaceDto.trashRetentionDays !== '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') {
|
if (typeof updateWorkspaceDto.allowPersonalSpaces !== 'undefined') {
|
||||||
const prev = settingsBefore?.spaces?.allowPersonal ?? false;
|
const prev = settingsBefore?.spaces?.allowPersonal ?? false;
|
||||||
if (prev !== updateWorkspaceDto.allowPersonalSpaces) {
|
if (prev !== updateWorkspaceDto.allowPersonalSpaces) {
|
||||||
@@ -622,6 +627,7 @@ export class WorkspaceService {
|
|||||||
delete updateWorkspaceDto.defaultPageEditMode;
|
delete updateWorkspaceDto.defaultPageEditMode;
|
||||||
delete updateWorkspaceDto.aiChatReadOnly;
|
delete updateWorkspaceDto.aiChatReadOnly;
|
||||||
delete updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly;
|
delete updateWorkspaceDto.aiChatWorkspaceKnowledgeOnly;
|
||||||
|
delete updateWorkspaceDto.mcpOauthOnly;
|
||||||
|
|
||||||
await this.workspaceRepo.updateWorkspace(
|
await this.workspaceRepo.updateWorkspace(
|
||||||
updateWorkspaceDto,
|
updateWorkspaceDto,
|
||||||
@@ -661,7 +667,6 @@ export class WorkspaceService {
|
|||||||
'enforceMfa',
|
'enforceMfa',
|
||||||
'emailDomains',
|
'emailDomains',
|
||||||
'isScimEnabled',
|
'isScimEnabled',
|
||||||
'trustedOauthClients',
|
|
||||||
],
|
],
|
||||||
updateWorkspaceDto,
|
updateWorkspaceDto,
|
||||||
workspaceBefore,
|
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';
|
import { UserRole } from '../../common/helpers/types/permission';
|
||||||
|
|
||||||
export function isAdminActingOnOwner(
|
export function isAdminActingOnOwner(
|
||||||
@@ -8,39 +7,6 @@ export function isAdminActingOnOwner(
|
|||||||
return authUserRole === UserRole.ADMIN && targetRole === UserRole.OWNER;
|
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 type PageEditMode = 'read' | 'edit';
|
||||||
|
|
||||||
export function getWorkspaceDefaultPageEditMode(
|
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_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_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.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> {
|
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_tokens').execute();
|
||||||
await db.schema.dropTable('oauth_grants').execute();
|
await db.schema.dropTable('oauth_grants').execute();
|
||||||
await db.schema.dropTable('oauth_authorization_codes').execute();
|
await db.schema.dropTable('oauth_authorization_codes').execute();
|
||||||
|
|||||||
@@ -20,7 +20,6 @@ export class WorkspaceRepo {
|
|||||||
'hostname',
|
'hostname',
|
||||||
'customDomain',
|
'customDomain',
|
||||||
'settings',
|
'settings',
|
||||||
'trustedOauthClients',
|
|
||||||
'defaultRole',
|
'defaultRole',
|
||||||
'emailDomains',
|
'emailDomains',
|
||||||
'defaultSpaceId',
|
'defaultSpaceId',
|
||||||
|
|||||||
-1
@@ -460,7 +460,6 @@ export interface Workspaces {
|
|||||||
status: string | null;
|
status: string | null;
|
||||||
stripeCustomerId: string | null;
|
stripeCustomerId: string | null;
|
||||||
trialEndAt: Timestamp | null;
|
trialEndAt: Timestamp | null;
|
||||||
trustedOauthClients: Generated<Json | null>;
|
|
||||||
updatedAt: Generated<Timestamp>;
|
updatedAt: Generated<Timestamp>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
Submodule apps/server/src/ee updated: 1b3f1b90e9...70fdfb9933
Reference in New Issue
Block a user