mirror of
https://github.com/docmost/docmost.git
synced 2026-08-28 01:07:08 +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.",
|
||||
"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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -460,7 +460,6 @@ export interface Workspaces {
|
||||
status: string | null;
|
||||
stripeCustomerId: string | null;
|
||||
trialEndAt: Timestamp | null;
|
||||
trustedOauthClients: Generated<Json | null>;
|
||||
updatedAt: Generated<Timestamp>;
|
||||
}
|
||||
|
||||
|
||||
+1
-1
Submodule apps/server/src/ee updated: 1b3f1b90e9...70fdfb9933
Reference in New Issue
Block a user