mirror of
https://github.com/n8n-io/n8n.git
synced 2026-07-28 03:24:59 +02:00
feat(editor): Add Mine/All tabs, search and filters to MCP connected clients (#33857)
This commit is contained in:
parent
33f1c36112
commit
a2c19e4697
|
|
@ -240,7 +240,9 @@ export * from './evaluations';
|
|||
|
||||
export {
|
||||
OAuthClientResponseDto,
|
||||
ListOAuthClientsQueryDto,
|
||||
ListOAuthClientsResponseDto,
|
||||
DeleteOAuthClientQueryDto,
|
||||
DeleteOAuthClientResponseDto,
|
||||
InstanceMcpClientStatsResponseDto,
|
||||
} from './oauth/oauth-client.dto';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,15 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
import { MCP_CLIENT_CONNECTED_PERIODS, MCP_CLIENT_TYPE_FILTERS } from '../../schemas/mcp.schema';
|
||||
import { Z } from '../../zod-class';
|
||||
import { paginationSchema } from '../pagination/pagination.dto';
|
||||
|
||||
const oauthClientOwnerShape = z.object({
|
||||
id: z.string(),
|
||||
firstName: z.string().nullable(),
|
||||
lastName: z.string().nullable(),
|
||||
email: z.string(),
|
||||
});
|
||||
|
||||
const oauthClientShape = {
|
||||
id: z.string(),
|
||||
|
|
@ -14,6 +23,8 @@ const oauthClientShape = {
|
|||
grantedAt: z.number(),
|
||||
/** Scopes granted on the consent screen. */
|
||||
scopes: z.array(z.string()),
|
||||
/** Consent owner; present only when listing with ownership=all. */
|
||||
owner: oauthClientOwnerShape.optional(),
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -21,14 +32,44 @@ const oauthClientShape = {
|
|||
*/
|
||||
export class OAuthClientResponseDto extends Z.class(oauthClientShape) {}
|
||||
|
||||
/**
|
||||
* DTO for the OAuth clients list query
|
||||
*/
|
||||
export class ListOAuthClientsQueryDto extends Z.class({
|
||||
...paginationSchema,
|
||||
/** 'all' requires mcp:manage; defaults to 'mine'. */
|
||||
ownership: z.enum(['mine', 'all']).optional(),
|
||||
/** Case-insensitive substring match against the client's name. */
|
||||
name: z.string().trim().min(1).max(100).optional(),
|
||||
/** Narrow the `all` view to consents of this user. Ignored without mcp:manage. */
|
||||
ownerId: z.string().max(36).optional(),
|
||||
/** Client type bucket, resolved from the client name via the shared brand matchers. */
|
||||
type: z.enum(MCP_CLIENT_TYPE_FILTERS).optional(),
|
||||
/** Date bucket applied to the consent's grantedAt. */
|
||||
connected: z.enum(MCP_CLIENT_CONNECTED_PERIODS).optional(),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* DTO for listing OAuth clients response
|
||||
*/
|
||||
export class ListOAuthClientsResponseDto extends Z.class({
|
||||
data: z.array(z.object(oauthClientShape)),
|
||||
/** Total rows matching the filters (across all pages) for the current ownership. */
|
||||
count: z.number(),
|
||||
/** Tool names each grantable scope unlocks on this instance, for the client details view. */
|
||||
scopeTools: z.record(z.array(z.string())).optional(),
|
||||
/** Unfiltered per-ownership totals for the tab badges. `all` only for mcp:manage callers. */
|
||||
totals: z.object({ mine: z.number(), all: z.number().optional() }),
|
||||
/** Distinct consent owners for the "Connected by" filter; only for mcp:manage callers. */
|
||||
owners: z.array(oauthClientOwnerShape).optional(),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
* DTO for the OAuth client delete query
|
||||
*/
|
||||
export class DeleteOAuthClientQueryDto extends Z.class({
|
||||
/** Consent owner whose grant to revoke; defaults to the caller. Other users require mcp:manage. */
|
||||
userId: z.string().max(36).optional(),
|
||||
}) {}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -513,7 +513,16 @@ export {
|
|||
MCP_APPS_VARIANT_CONTROL,
|
||||
MCP_APPS_VARIANT_ENABLED,
|
||||
MCP_INSTANCE_SCOPES,
|
||||
MCP_CLIENT_BRAND_MATCHERS,
|
||||
MCP_CLIENT_TYPE_FILTERS,
|
||||
MCP_CLIENT_TYPE_FILTER_BUCKETS,
|
||||
MCP_CLIENT_CONNECTED_PERIODS,
|
||||
getMcpClientType,
|
||||
type McpScope,
|
||||
type McpClientType,
|
||||
type McpClientBrandName,
|
||||
type McpClientTypeFilter,
|
||||
type McpClientConnectedPeriod,
|
||||
} from './schemas/mcp.schema';
|
||||
|
||||
export {
|
||||
|
|
|
|||
|
|
@ -29,3 +29,51 @@ export const MCP_INSTANCE_SCOPES = [
|
|||
] as const;
|
||||
|
||||
export type McpScope = (typeof MCP_INSTANCE_SCOPES)[number];
|
||||
|
||||
export type McpClientType = 'cli' | 'ide' | 'editor' | 'assistant';
|
||||
|
||||
/** Known client brands, used by the FE to pick the logo shown next to a client. */
|
||||
export type McpClientBrandName = 'claude' | 'cursor' | 'vscode' | 'openai';
|
||||
|
||||
/**
|
||||
* Client names are free-form (self-reported at OAuth registration), so known
|
||||
* clients are recognized by name patterns. First match wins: more specific
|
||||
* patterns (e.g. "Claude Code") must come before broader ones ("Claude").
|
||||
* Shared FE/BE so the type shown in the clients table and the server-side
|
||||
* type filter cannot drift.
|
||||
*/
|
||||
export const MCP_CLIENT_BRAND_MATCHERS: ReadonlyArray<{
|
||||
pattern: RegExp;
|
||||
brand: McpClientBrandName;
|
||||
type: McpClientType;
|
||||
}> = [
|
||||
{ pattern: /claude[ -]?code/i, brand: 'claude', type: 'cli' },
|
||||
{ pattern: /claude/i, brand: 'claude', type: 'assistant' },
|
||||
{ pattern: /cursor/i, brand: 'cursor', type: 'ide' },
|
||||
{ pattern: /(visual studio code|vs ?code)/i, brand: 'vscode', type: 'editor' },
|
||||
{ pattern: /codex/i, brand: 'openai', type: 'cli' },
|
||||
{ pattern: /chatgpt|openai/i, brand: 'openai', type: 'assistant' },
|
||||
];
|
||||
|
||||
export function getMcpClientType(clientName: string): McpClientType | null {
|
||||
return MCP_CLIENT_BRAND_MATCHERS.find(({ pattern }) => pattern.test(clientName))?.type ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client-type buckets offered by the connected-clients filter (per design),
|
||||
* coarser than the derived brand types shown in the table rows.
|
||||
*/
|
||||
export const MCP_CLIENT_TYPE_FILTERS = ['ide', 'cli', 'web'] as const;
|
||||
|
||||
export type McpClientTypeFilter = (typeof MCP_CLIENT_TYPE_FILTERS)[number];
|
||||
|
||||
export const MCP_CLIENT_TYPE_FILTER_BUCKETS: Record<McpClientTypeFilter, McpClientType[]> = {
|
||||
ide: ['ide', 'editor'],
|
||||
cli: ['cli'],
|
||||
web: ['assistant'],
|
||||
};
|
||||
|
||||
/** Date buckets for the "Connected" filter, applied to the consent's grantedAt. */
|
||||
export const MCP_CLIENT_CONNECTED_PERIODS = ['last7', 'last30', 'older'] as const;
|
||||
|
||||
export type McpClientConnectedPeriod = (typeof MCP_CLIENT_CONNECTED_PERIODS)[number];
|
||||
|
|
|
|||
|
|
@ -81,6 +81,10 @@ export class TemplateConfig {
|
|||
/** Overrides default HTML template for notifying a user that their public API key was revoked by an admin (use full path) */
|
||||
@Env('N8N_UM_EMAIL_TEMPLATES_API_KEY_REVOKED')
|
||||
'api-key-revoked': string = '';
|
||||
|
||||
/** Overrides default HTML template for notifying a user that their connected MCP client was revoked by an admin (use full path) */
|
||||
@Env('N8N_UM_EMAIL_TEMPLATES_MCP_CLIENT_REVOKED')
|
||||
'mcp-client-revoked': string = '';
|
||||
}
|
||||
|
||||
const emailModeSchema = z.enum(['', 'smtp']);
|
||||
|
|
|
|||
|
|
@ -170,6 +170,7 @@ describe('GlobalConfig', () => {
|
|||
'workflow-shared': '',
|
||||
'project-shared': '',
|
||||
'api-key-revoked': '',
|
||||
'mcp-client-revoked': '',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -71,6 +71,279 @@ describe('GET /rest/mcp/oauth-clients', () => {
|
|||
scopes: ['workflow:read', 'execution:read'],
|
||||
});
|
||||
});
|
||||
|
||||
test('should include the all-consents total for managers even on the mine view', async () => {
|
||||
const client = await oauthClientRepository.save({
|
||||
id: 'totals-client',
|
||||
name: 'Totals Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
grantTypes: ['authorization_code'],
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
|
||||
await userConsentRepository.save({
|
||||
userId: owner.id,
|
||||
clientId: client.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
await userConsentRepository.save({
|
||||
userId: member.id,
|
||||
clientId: client.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
|
||||
const ownerResponse = await testServer.authAgentFor(owner).get('/mcp/oauth-clients');
|
||||
|
||||
expect(ownerResponse.statusCode).toBe(200);
|
||||
expect(ownerResponse.body.data.totals).toEqual({ mine: 1, all: 2 });
|
||||
expect(ownerResponse.body.data.data[0].owner).toBeUndefined();
|
||||
|
||||
const memberResponse = await testServer.authAgentFor(member).get('/mcp/oauth-clients');
|
||||
|
||||
expect(memberResponse.statusCode).toBe(200);
|
||||
expect(memberResponse.body.data.totals).toEqual({ mine: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /rest/mcp/oauth-clients?ownership=all', () => {
|
||||
test("should return every user's consents with owner info for a manager", async () => {
|
||||
const sharedClient = await oauthClientRepository.save({
|
||||
id: 'all-shared-client',
|
||||
name: 'Shared Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
grantTypes: ['authorization_code'],
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
const memberClient = await oauthClientRepository.save({
|
||||
id: 'all-member-client',
|
||||
name: 'Member Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
grantTypes: ['authorization_code'],
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
|
||||
await userConsentRepository.save({
|
||||
userId: owner.id,
|
||||
clientId: sharedClient.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
await userConsentRepository.save({
|
||||
userId: member.id,
|
||||
clientId: sharedClient.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
await userConsentRepository.save({
|
||||
userId: member.id,
|
||||
clientId: memberClient.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
|
||||
const response = await testServer
|
||||
.authAgentFor(owner)
|
||||
.get('/mcp/oauth-clients')
|
||||
.query({ ownership: 'all' });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data.data).toHaveLength(3);
|
||||
expect(response.body.data.totals).toEqual({ mine: 1, all: 3 });
|
||||
|
||||
// a consent row is (client × owner): the shared client appears once per user
|
||||
const sharedRows = response.body.data.data.filter(
|
||||
(row: { id: string }) => row.id === sharedClient.id,
|
||||
);
|
||||
expect(sharedRows).toHaveLength(2);
|
||||
expect(sharedRows.map((row: { owner: { id: string } }) => row.owner.id).sort()).toEqual(
|
||||
[owner.id, member.id].sort(),
|
||||
);
|
||||
expect(sharedRows[0].owner).toMatchObject({
|
||||
id: expect.any(String),
|
||||
email: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
test('should return 403 for a member', async () => {
|
||||
const response = await testServer
|
||||
.authAgentFor(member)
|
||||
.get('/mcp/oauth-clients')
|
||||
.query({ ownership: 'all' });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /rest/mcp/oauth-clients pagination and filters', () => {
|
||||
const saveClient = async (id: string, name: string) =>
|
||||
await oauthClientRepository.save({
|
||||
id,
|
||||
name,
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
grantTypes: ['authorization_code'],
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
|
||||
test('should page through results with take/skip while reporting the filtered total', async () => {
|
||||
const now = Date.now();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const client = await saveClient(`page-client-${i}`, `Page Client ${i}`);
|
||||
// distinct grantedAt so the DESC ordering is deterministic
|
||||
await userConsentRepository.save({
|
||||
userId: owner.id,
|
||||
clientId: client.id,
|
||||
grantedAt: now - i * 1000,
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
}
|
||||
|
||||
const firstPage = await testServer
|
||||
.authAgentFor(owner)
|
||||
.get('/mcp/oauth-clients')
|
||||
.query({ take: 2, skip: 0 });
|
||||
|
||||
expect(firstPage.statusCode).toBe(200);
|
||||
expect(firstPage.body.data.count).toBe(3);
|
||||
expect(firstPage.body.data.data.map((row: { id: string }) => row.id)).toEqual([
|
||||
'page-client-0',
|
||||
'page-client-1',
|
||||
]);
|
||||
|
||||
const secondPage = await testServer
|
||||
.authAgentFor(owner)
|
||||
.get('/mcp/oauth-clients')
|
||||
.query({ take: 2, skip: 2 });
|
||||
|
||||
expect(secondPage.body.data.count).toBe(3);
|
||||
expect(secondPage.body.data.data.map((row: { id: string }) => row.id)).toEqual([
|
||||
'page-client-2',
|
||||
]);
|
||||
});
|
||||
|
||||
test('should filter by client name, case-insensitively', async () => {
|
||||
const cursor = await saveClient('name-cursor', 'Cursor');
|
||||
const claude = await saveClient('name-claude', 'Claude Code');
|
||||
await userConsentRepository.save({
|
||||
userId: owner.id,
|
||||
clientId: cursor.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
await userConsentRepository.save({
|
||||
userId: owner.id,
|
||||
clientId: claude.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
|
||||
const response = await testServer
|
||||
.authAgentFor(owner)
|
||||
.get('/mcp/oauth-clients')
|
||||
.query({ name: 'CURSOR' });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.body.data.count).toBe(1);
|
||||
expect(response.body.data.data[0].id).toBe('name-cursor');
|
||||
});
|
||||
|
||||
test('should filter by client type bucket derived from the name', async () => {
|
||||
const ide = await saveClient('type-ide', 'Cursor');
|
||||
const editor = await saveClient('type-editor', 'VS Code');
|
||||
const cli = await saveClient('type-cli', 'Claude Code');
|
||||
const web = await saveClient('type-web', 'ChatGPT');
|
||||
for (const client of [ide, editor, cli, web]) {
|
||||
await userConsentRepository.save({
|
||||
userId: owner.id,
|
||||
clientId: client.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
}
|
||||
|
||||
const ideResponse = await testServer
|
||||
.authAgentFor(owner)
|
||||
.get('/mcp/oauth-clients')
|
||||
.query({ type: 'ide' });
|
||||
|
||||
// the IDE bucket covers both ide (Cursor) and editor (VS Code) brands
|
||||
expect(ideResponse.body.data.data.map((row: { id: string }) => row.id).sort()).toEqual([
|
||||
'type-editor',
|
||||
'type-ide',
|
||||
]);
|
||||
|
||||
const webResponse = await testServer
|
||||
.authAgentFor(owner)
|
||||
.get('/mcp/oauth-clients')
|
||||
.query({ type: 'web' });
|
||||
|
||||
expect(webResponse.body.data.data.map((row: { id: string }) => row.id)).toEqual(['type-web']);
|
||||
});
|
||||
|
||||
test('should filter by the connected date bucket', async () => {
|
||||
const now = Date.now();
|
||||
const day = 24 * 60 * 60 * 1000;
|
||||
const recent = await saveClient('date-recent', 'Recent Client');
|
||||
const old = await saveClient('date-old', 'Old Client');
|
||||
await userConsentRepository.save({
|
||||
userId: owner.id,
|
||||
clientId: recent.id,
|
||||
grantedAt: now - 2 * day,
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
await userConsentRepository.save({
|
||||
userId: owner.id,
|
||||
clientId: old.id,
|
||||
grantedAt: now - 60 * day,
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
|
||||
const last7 = await testServer
|
||||
.authAgentFor(owner)
|
||||
.get('/mcp/oauth-clients')
|
||||
.query({ connected: 'last7' });
|
||||
|
||||
expect(last7.body.data.data.map((row: { id: string }) => row.id)).toEqual(['date-recent']);
|
||||
|
||||
const older = await testServer
|
||||
.authAgentFor(owner)
|
||||
.get('/mcp/oauth-clients')
|
||||
.query({ connected: 'older' });
|
||||
|
||||
expect(older.body.data.data.map((row: { id: string }) => row.id)).toEqual(['date-old']);
|
||||
});
|
||||
|
||||
test('should return the distinct unfiltered owners in the all view', async () => {
|
||||
const client = await saveClient('owners-client', 'Owners Client');
|
||||
await userConsentRepository.save({
|
||||
userId: owner.id,
|
||||
clientId: client.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
await userConsentRepository.save({
|
||||
userId: member.id,
|
||||
clientId: client.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
|
||||
const response = await testServer
|
||||
.authAgentFor(owner)
|
||||
.get('/mcp/oauth-clients')
|
||||
.query({ ownership: 'all', name: 'no such client' });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
// filters narrow the rows but not the owners offered by the filter dropdown
|
||||
expect(response.body.data.count).toBe(0);
|
||||
expect(response.body.data.owners.map((o: { id: string }) => o.id).sort()).toEqual(
|
||||
[owner.id, member.id].sort(),
|
||||
);
|
||||
|
||||
const mineResponse = await testServer.authAgentFor(owner).get('/mcp/oauth-clients');
|
||||
expect(mineResponse.body.data.owners).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /rest/mcp/oauth-clients/instance-stats', () => {
|
||||
|
|
@ -220,4 +493,146 @@ describe('DELETE /rest/mcp/oauth-clients/:clientId', () => {
|
|||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
test("should allow a manager to revoke another user's grant", async () => {
|
||||
const client = await oauthClientRepository.save({
|
||||
id: 'admin-revoke-client',
|
||||
name: 'Admin Revoke Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
grantTypes: ['authorization_code'],
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
|
||||
await userConsentRepository.save({
|
||||
userId: owner.id,
|
||||
clientId: client.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
await userConsentRepository.save({
|
||||
userId: member.id,
|
||||
clientId: client.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
|
||||
const response = await testServer
|
||||
.authAgentFor(owner)
|
||||
.delete(`/mcp/oauth-clients/${client.id}`)
|
||||
.query({ userId: member.id });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
// the member's consent is gone, the owner's grant and the client survive
|
||||
expect(
|
||||
await userConsentRepository.findOneBy({ userId: member.id, clientId: client.id }),
|
||||
).toBeNull();
|
||||
expect(
|
||||
await userConsentRepository.findOneBy({ userId: owner.id, clientId: client.id }),
|
||||
).not.toBeNull();
|
||||
expect(await oauthClientRepository.findOneBy({ id: client.id })).not.toBeNull();
|
||||
});
|
||||
|
||||
test('should garbage-collect the client when a manager revokes its last grant', async () => {
|
||||
const client = await oauthClientRepository.save({
|
||||
id: 'admin-gc-client',
|
||||
name: 'Admin GC Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
grantTypes: ['authorization_code'],
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
|
||||
await userConsentRepository.save({
|
||||
userId: member.id,
|
||||
clientId: client.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
|
||||
const response = await testServer
|
||||
.authAgentFor(owner)
|
||||
.delete(`/mcp/oauth-clients/${client.id}`)
|
||||
.query({ userId: member.id });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(await oauthClientRepository.findOneBy({ id: client.id })).toBeNull();
|
||||
});
|
||||
|
||||
test("should return 403 when a member passes another user's userId", async () => {
|
||||
const client = await oauthClientRepository.save({
|
||||
id: 'member-forbidden-client',
|
||||
name: 'Member Forbidden Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
grantTypes: ['authorization_code'],
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
|
||||
await userConsentRepository.save({
|
||||
userId: owner.id,
|
||||
clientId: client.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
|
||||
const response = await testServer
|
||||
.authAgentFor(member)
|
||||
.delete(`/mcp/oauth-clients/${client.id}`)
|
||||
.query({ userId: owner.id });
|
||||
|
||||
expect(response.statusCode).toBe(403);
|
||||
expect(
|
||||
await userConsentRepository.findOneBy({ userId: owner.id, clientId: client.id }),
|
||||
).not.toBeNull();
|
||||
});
|
||||
|
||||
test('should treat a member passing their own userId like the default delete', async () => {
|
||||
const client = await oauthClientRepository.save({
|
||||
id: 'member-self-client',
|
||||
name: 'Member Self Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
grantTypes: ['authorization_code'],
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
|
||||
await userConsentRepository.save({
|
||||
userId: member.id,
|
||||
clientId: client.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
|
||||
const response = await testServer
|
||||
.authAgentFor(member)
|
||||
.delete(`/mcp/oauth-clients/${client.id}`)
|
||||
.query({ userId: member.id });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(
|
||||
await userConsentRepository.findOneBy({ userId: member.id, clientId: client.id }),
|
||||
).toBeNull();
|
||||
});
|
||||
|
||||
test('should return 404 when the target user has no grant for the client', async () => {
|
||||
const client = await oauthClientRepository.save({
|
||||
id: 'no-consent-client',
|
||||
name: 'No Consent Client',
|
||||
redirectUris: ['https://example.com/callback'],
|
||||
grantTypes: ['authorization_code'],
|
||||
tokenEndpointAuthMethod: 'none',
|
||||
});
|
||||
|
||||
await userConsentRepository.save({
|
||||
userId: owner.id,
|
||||
clientId: client.id,
|
||||
grantedAt: Date.now(),
|
||||
scope: ['workflow:read'],
|
||||
});
|
||||
|
||||
const response = await testServer
|
||||
.authAgentFor(owner)
|
||||
.delete(`/mcp/oauth-clients/${client.id}`)
|
||||
.query({ userId: member.id });
|
||||
|
||||
expect(response.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import type { McpConfig } from '@/modules/mcp/mcp.config';
|
|||
import type { McpSettingsService } from '@/modules/mcp/mcp.settings.service';
|
||||
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
|
||||
import type { UrlService } from '@/services/url.service';
|
||||
import { UserManagementMailer } from '@/user-management/email';
|
||||
|
||||
const SUPPORTED_SCOPES = ['tool:listWorkflows', 'tool:getWorkflowDetails'];
|
||||
const TEST_RESOURCE_URL = 'https://n8n.example.com/mcp-server/http';
|
||||
|
|
@ -33,6 +34,7 @@ let tokenService: Mocked<OAuthTokenService>;
|
|||
let authorizationCodeService: Mocked<OAuthAuthorizationCodeService>;
|
||||
let service: OAuthServerService;
|
||||
let userConsentRepository: Mocked<UserConsentRepository>;
|
||||
let mailer: Mocked<UserManagementMailer>;
|
||||
let getAllowedRedirectUris: Mock<() => Promise<string[]>>;
|
||||
|
||||
describe('OAuthServerService', () => {
|
||||
|
|
@ -43,6 +45,7 @@ describe('OAuthServerService', () => {
|
|||
tokenService = mockInstance(OAuthTokenService);
|
||||
authorizationCodeService = mockInstance(OAuthAuthorizationCodeService);
|
||||
userConsentRepository = mockInstance(UserConsentRepository);
|
||||
mailer = mockInstance(UserManagementMailer);
|
||||
getAllowedRedirectUris = vi.fn<(...args: []) => Promise<string[]>>().mockResolvedValue([]);
|
||||
|
||||
const resourceRegistry = new ProtectedResourceRegistry(mock<Logger>());
|
||||
|
|
@ -65,6 +68,7 @@ describe('OAuthServerService', () => {
|
|||
authorizationCodeService,
|
||||
userConsentRepository,
|
||||
resourceRegistry,
|
||||
mailer,
|
||||
);
|
||||
});
|
||||
|
||||
|
|
@ -905,9 +909,10 @@ describe('OAuthServerService', () => {
|
|||
} as OAuthClient;
|
||||
|
||||
oauthClientRepository.findOne.mockResolvedValue(client);
|
||||
userConsentRepository.findOneBy.mockResolvedValue({
|
||||
userConsentRepository.findOne.mockResolvedValue({
|
||||
userId: 'user-456',
|
||||
clientId: 'client-123',
|
||||
user: { id: 'user-456', email: 'owner@n8n.io', firstName: 'Owner' },
|
||||
} as any);
|
||||
const execute = mockGarbageCollect(0);
|
||||
|
||||
|
|
@ -932,9 +937,10 @@ describe('OAuthServerService', () => {
|
|||
} as OAuthClient;
|
||||
|
||||
oauthClientRepository.findOne.mockResolvedValue(client);
|
||||
userConsentRepository.findOneBy.mockResolvedValue({
|
||||
userConsentRepository.findOne.mockResolvedValue({
|
||||
userId: 'user-456',
|
||||
clientId: 'client-123',
|
||||
user: { id: 'user-456', email: 'owner@n8n.io', firstName: 'Owner' },
|
||||
} as any);
|
||||
const execute = mockGarbageCollect(1);
|
||||
|
||||
|
|
@ -947,6 +953,51 @@ describe('OAuthServerService', () => {
|
|||
expect(execute).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should notify the grant owner by email when someone else revokes their client', async () => {
|
||||
const client = {
|
||||
id: 'client-123',
|
||||
name: 'Test Client',
|
||||
} as OAuthClient;
|
||||
|
||||
oauthClientRepository.findOne.mockResolvedValue(client);
|
||||
userConsentRepository.findOne.mockResolvedValue({
|
||||
userId: 'user-456',
|
||||
clientId: 'client-123',
|
||||
user: { id: 'user-456', email: 'owner@n8n.io', firstName: 'Owner' },
|
||||
} as any);
|
||||
userConsentRepository.countBy.mockResolvedValue(1);
|
||||
mailer.notifyMcpClientRevoked.mockResolvedValue({ emailSent: true });
|
||||
|
||||
const revoker = { id: 'admin-1', email: 'admin@n8n.io' } as any;
|
||||
await service.deleteClient('client-123', 'user-456', revoker);
|
||||
|
||||
expect(mailer.notifyMcpClientRevoked).toHaveBeenCalledWith({
|
||||
clientName: 'Test Client',
|
||||
owner: { id: 'user-456', email: 'owner@n8n.io', firstName: 'Owner' },
|
||||
revoker,
|
||||
});
|
||||
});
|
||||
|
||||
it('should not send an email when users revoke their own client', async () => {
|
||||
const client = {
|
||||
id: 'client-123',
|
||||
name: 'Test Client',
|
||||
} as OAuthClient;
|
||||
|
||||
oauthClientRepository.findOne.mockResolvedValue(client);
|
||||
userConsentRepository.findOne.mockResolvedValue({
|
||||
userId: 'user-456',
|
||||
clientId: 'client-123',
|
||||
user: { id: 'user-456', email: 'owner@n8n.io', firstName: 'Owner' },
|
||||
} as any);
|
||||
userConsentRepository.countBy.mockResolvedValue(1);
|
||||
|
||||
const revoker = { id: 'user-456', email: 'owner@n8n.io' } as any;
|
||||
await service.deleteClient('client-123', 'user-456', revoker);
|
||||
|
||||
expect(mailer.notifyMcpClientRevoked).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should throw when client does not exist', async () => {
|
||||
oauthClientRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
|
|
@ -964,7 +1015,7 @@ describe('OAuthServerService', () => {
|
|||
} as OAuthClient;
|
||||
|
||||
oauthClientRepository.findOne.mockResolvedValue(client);
|
||||
userConsentRepository.findOneBy.mockResolvedValue(null);
|
||||
userConsentRepository.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.deleteClient('client-123', 'other-user')).rejects.toThrow(
|
||||
'OAuth client with ID client-123 not found',
|
||||
|
|
@ -1125,6 +1176,7 @@ describe('OAuthServerService', () => {
|
|||
authorizationCodeService,
|
||||
userConsentRepository,
|
||||
multiRegistry,
|
||||
mailer,
|
||||
);
|
||||
|
||||
expect(
|
||||
|
|
@ -1169,6 +1221,7 @@ describe('OAuthServerService', () => {
|
|||
authorizationCodeService,
|
||||
userConsentRepository,
|
||||
configuredRegistry,
|
||||
mailer,
|
||||
);
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,42 @@
|
|||
import type { McpClientConnectedPeriod } from '@n8n/api-types';
|
||||
import { Service } from '@n8n/di';
|
||||
import { DataSource, Repository } from '@n8n/typeorm';
|
||||
|
||||
import { UserConsent } from '../entities/oauth-user-consent.entity';
|
||||
|
||||
const DAY_IN_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
type ConsentOwner = {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string;
|
||||
};
|
||||
|
||||
export type FindConnectedClientsOptions = {
|
||||
/** Restrict to a single user's consents (the "mine" view). */
|
||||
userId?: string;
|
||||
/** Join the owner so the caller can surface it (the "all" view). */
|
||||
withOwner?: boolean;
|
||||
/** Case-insensitive substring match on the client name. */
|
||||
name?: string;
|
||||
/** Restrict to a single owner (managers filtering the "all" view). */
|
||||
ownerId?: string;
|
||||
/**
|
||||
* Restrict to these client ids. Used for the name-pattern "type" filter,
|
||||
* which is resolved to matching clients up front (bounded by the registered
|
||||
* client cap) so paging stays in SQL. Must be non-empty when provided.
|
||||
*/
|
||||
clientIds?: string[];
|
||||
/** Date bucket applied to the consent's `grantedAt`. */
|
||||
connected?: McpClientConnectedPeriod;
|
||||
/** Reference timestamp for the `connected` buckets. */
|
||||
now: number;
|
||||
skip?: number;
|
||||
/** When undefined, all matching rows are returned (no pagination). */
|
||||
take?: number;
|
||||
};
|
||||
|
||||
@Service()
|
||||
export class UserConsentRepository extends Repository<UserConsent> {
|
||||
constructor(dataSource: DataSource) {
|
||||
|
|
@ -10,13 +44,58 @@ export class UserConsentRepository extends Repository<UserConsent> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Find all consents for a user with client information
|
||||
* Connected clients (a consent joined with its client) matching the given
|
||||
* filters, newest grant first. Name/owner/date filters and pagination run
|
||||
* in SQL; the returned `total` is the full match count ignoring skip/take.
|
||||
* The `type` filter is a name-pattern match the DB can't express and is
|
||||
* applied by the caller.
|
||||
*/
|
||||
async findByUserWithClient(userId: string): Promise<UserConsent[]> {
|
||||
return await this.find({
|
||||
where: { userId },
|
||||
relations: ['client'],
|
||||
order: { grantedAt: 'DESC' },
|
||||
});
|
||||
async findConnectedClients(
|
||||
options: FindConnectedClientsOptions,
|
||||
): Promise<{ rows: UserConsent[]; total: number }> {
|
||||
const qb = this.createQueryBuilder('consent').leftJoinAndSelect('consent.client', 'client');
|
||||
|
||||
if (options.withOwner) qb.leftJoinAndSelect('consent.user', 'user');
|
||||
if (options.userId) qb.andWhere('consent.userId = :userId', { userId: options.userId });
|
||||
if (options.ownerId) qb.andWhere('consent.userId = :ownerId', { ownerId: options.ownerId });
|
||||
if (options.clientIds) {
|
||||
qb.andWhere('consent.clientId IN (:...clientIds)', { clientIds: options.clientIds });
|
||||
}
|
||||
|
||||
if (options.name?.trim()) {
|
||||
qb.andWhere('LOWER(client.name) LIKE :name', {
|
||||
name: `%${options.name.trim().toLowerCase()}%`,
|
||||
});
|
||||
}
|
||||
|
||||
if (options.connected === 'last7') {
|
||||
qb.andWhere('consent.grantedAt >= :bound', { bound: options.now - 7 * DAY_IN_MS });
|
||||
} else if (options.connected === 'last30') {
|
||||
qb.andWhere('consent.grantedAt >= :bound', { bound: options.now - 30 * DAY_IN_MS });
|
||||
} else if (options.connected === 'older') {
|
||||
qb.andWhere('consent.grantedAt < :bound', { bound: options.now - 30 * DAY_IN_MS });
|
||||
}
|
||||
|
||||
qb.orderBy('consent.grantedAt', 'DESC');
|
||||
|
||||
if (options.take !== undefined) qb.skip(options.skip ?? 0).take(options.take);
|
||||
|
||||
const [rows, total] = await qb.getManyAndCount();
|
||||
return { rows, total };
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct owners across every consent, for the "Connected by" filter. Not
|
||||
* scoped by the current filters, so the dropdown always lists all owners.
|
||||
*/
|
||||
async findConsentOwners(): Promise<ConsentOwner[]> {
|
||||
return await this.createQueryBuilder('consent')
|
||||
.innerJoin('consent.user', 'user')
|
||||
.select('user.id', 'id')
|
||||
.addSelect('user.firstName', 'firstName')
|
||||
.addSelect('user.lastName', 'lastName')
|
||||
.addSelect('user.email', 'email')
|
||||
.distinct(true)
|
||||
.getRawMany<ConsentOwner>();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,18 @@
|
|||
import {
|
||||
DeleteOAuthClientQueryDto,
|
||||
DeleteOAuthClientResponseDto,
|
||||
InstanceMcpClientStatsResponseDto,
|
||||
ListOAuthClientsQueryDto,
|
||||
ListOAuthClientsResponseDto,
|
||||
OAuthClientResponseDto,
|
||||
} from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Delete, Get, GlobalScope, Param, RestController } from '@n8n/decorators';
|
||||
import { Delete, Get, GlobalScope, Param, Query, RestController } from '@n8n/decorators';
|
||||
import { hasGlobalScope } from '@n8n/permissions';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { NotFoundError } from '@/errors/response-errors/not-found.error';
|
||||
|
||||
import { OAuthServerService } from './oauth-server.service';
|
||||
|
|
@ -21,19 +25,27 @@ export class OAuthClientsController {
|
|||
) {}
|
||||
|
||||
/**
|
||||
* Get all OAuth clients for the current user
|
||||
* Get connected OAuth clients. Defaults to the current user's consents;
|
||||
* `ownership=all` returns every user's consents and requires `mcp:manage`.
|
||||
*/
|
||||
@GlobalScope('mcp:oauth')
|
||||
@Get('/')
|
||||
async getAllClients(
|
||||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Query query: ListOAuthClientsQueryDto,
|
||||
): Promise<ListOAuthClientsResponseDto> {
|
||||
this.logger.debug('Fetching all OAuth clients for user', { userId: req.user.id });
|
||||
this.logger.debug('Fetching OAuth clients for user', {
|
||||
userId: req.user.id,
|
||||
ownership: query.ownership ?? 'mine',
|
||||
});
|
||||
|
||||
const clients = await this.oauthServerService.getAllClients(req.user.id);
|
||||
const { clients, count, totals, owners } = await this.oauthServerService.getAllClients(
|
||||
req.user,
|
||||
query,
|
||||
);
|
||||
|
||||
this.logger.debug(`Found ${clients.length} OAuth clients`);
|
||||
this.logger.debug(`Found ${count} OAuth clients`);
|
||||
|
||||
const clientDtos: OAuthClientResponseDto[] = clients.map((client) => ({
|
||||
id: client.id,
|
||||
|
|
@ -45,12 +57,15 @@ export class OAuthClientsController {
|
|||
updatedAt: client.updatedAt.toISOString(),
|
||||
grantedAt: client.grantedAt,
|
||||
scopes: client.scopes,
|
||||
owner: client.owner,
|
||||
}));
|
||||
|
||||
return {
|
||||
data: clientDtos,
|
||||
count: clients.length,
|
||||
count,
|
||||
scopeTools: this.oauthServerService.getInstanceScopeTools(),
|
||||
totals,
|
||||
owners,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -66,8 +81,10 @@ export class OAuthClientsController {
|
|||
}
|
||||
|
||||
/**
|
||||
* Delete an OAuth client by ID
|
||||
* This will cascade delete all related tokens, authorization codes, and user consents
|
||||
* Revoke a user's grant for an OAuth client (tokens, authorization codes,
|
||||
* consent). Defaults to the caller's own grant; revoking another user's
|
||||
* grant requires `mcp:manage`. The client registration is garbage-collected
|
||||
* once its last consent is gone.
|
||||
*/
|
||||
@GlobalScope('mcp:oauth')
|
||||
@Delete('/:clientId')
|
||||
|
|
@ -75,19 +92,27 @@ export class OAuthClientsController {
|
|||
req: AuthenticatedRequest,
|
||||
_res: Response,
|
||||
@Param('clientId') clientId: string,
|
||||
@Query query: DeleteOAuthClientQueryDto,
|
||||
): Promise<DeleteOAuthClientResponseDto> {
|
||||
const targetUserId = query.userId ?? req.user.id;
|
||||
if (targetUserId !== req.user.id && !hasGlobalScope(req.user, 'mcp:manage')) {
|
||||
throw new ForbiddenError('You are not allowed to revoke connected clients of other users');
|
||||
}
|
||||
|
||||
this.logger.info('Deleting OAuth client', {
|
||||
clientId,
|
||||
userId: req.user.id,
|
||||
targetUserId,
|
||||
userEmail: req.user.email,
|
||||
});
|
||||
|
||||
try {
|
||||
await this.oauthServerService.deleteClient(clientId, req.user.id);
|
||||
await this.oauthServerService.deleteClient(clientId, targetUserId, req.user);
|
||||
|
||||
this.logger.info('OAuth client deleted successfully', {
|
||||
clientId,
|
||||
userId: req.user.id,
|
||||
targetUserId,
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -13,9 +13,13 @@ import type {
|
|||
OAuthTokens,
|
||||
OAuthTokenRevocationRequest,
|
||||
} from '@modelcontextprotocol/sdk/shared/auth.js';
|
||||
import type { McpClientConnectedPeriod, McpClientTypeFilter } from '@n8n/api-types';
|
||||
import { getMcpClientType, MCP_CLIENT_TYPE_FILTER_BUCKETS } from '@n8n/api-types';
|
||||
import { Logger } from '@n8n/backend-common';
|
||||
import { GlobalConfig } from '@n8n/config';
|
||||
import type { User } from '@n8n/db';
|
||||
import { Service } from '@n8n/di';
|
||||
import { hasGlobalScope } from '@n8n/permissions';
|
||||
import type { Response } from 'express';
|
||||
|
||||
import { ProtectedResourceRegistry } from '@/services/protected-resource.registry';
|
||||
|
|
@ -27,10 +31,19 @@ import { OAuthAuthorizationCodeService } from './oauth-authorization-code.servic
|
|||
import { OAuthSessionService } from './oauth-session.service';
|
||||
import { OAuthTokenService } from './oauth-token.service';
|
||||
import { OAuthClientLimitReachedError } from './oauth.errors';
|
||||
import { ForbiddenError } from '@/errors/response-errors/forbidden.error';
|
||||
import { UserManagementMailer } from '@/user-management/email';
|
||||
|
||||
/** Maximum number of redirect URIs per client */
|
||||
const MAX_REDIRECT_URIS = 10;
|
||||
|
||||
export type ConnectedOAuthClientOwner = {
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string;
|
||||
};
|
||||
|
||||
/** A client the user has consented to, enriched with the grant details of the consent. */
|
||||
export type ConnectedOAuthClient = Omit<
|
||||
OAuthClient,
|
||||
|
|
@ -38,8 +51,38 @@ export type ConnectedOAuthClient = Omit<
|
|||
> & {
|
||||
grantedAt: number;
|
||||
scopes: string[];
|
||||
/** Consent owner; present only when listing across users (ownership=all). */
|
||||
owner?: ConnectedOAuthClientOwner;
|
||||
};
|
||||
|
||||
/** Per-ownership consent totals for the connected-clients tab badges. */
|
||||
export type ConnectedOAuthClientTotals = { mine: number; all?: number };
|
||||
|
||||
export type ListConnectedClientsOptions = {
|
||||
ownership?: 'mine' | 'all';
|
||||
skip?: number;
|
||||
take?: number;
|
||||
name?: string;
|
||||
ownerId?: string;
|
||||
type?: McpClientTypeFilter;
|
||||
connected?: McpClientConnectedPeriod;
|
||||
};
|
||||
|
||||
/** Whether a client's derived brand type falls in the requested filter bucket. */
|
||||
function matchesTypeFilter(name: string, type: McpClientTypeFilter): boolean {
|
||||
const clientType = getMcpClientType(name);
|
||||
return clientType !== null && MCP_CLIENT_TYPE_FILTER_BUCKETS[type].includes(clientType);
|
||||
}
|
||||
|
||||
/** Sort owners by display name so the "Connected by" dropdown reads naturally. */
|
||||
function sortOwners(owners: ConnectedOAuthClientOwner[]): ConnectedOAuthClientOwner[] {
|
||||
return [...owners].sort((a, b) => {
|
||||
const nameA = [a.firstName, a.lastName].filter(Boolean).join(' ') || a.email;
|
||||
const nameB = [b.firstName, b.lastName].filter(Boolean).join(' ') || b.email;
|
||||
return nameA.localeCompare(nameB);
|
||||
});
|
||||
}
|
||||
|
||||
/** Maximum length for a single redirect URI */
|
||||
const MAX_REDIRECT_URI_LENGTH = 2048;
|
||||
|
||||
|
|
@ -58,6 +101,7 @@ export class OAuthServerService implements OAuthServerProvider {
|
|||
private readonly authorizationCodeService: OAuthAuthorizationCodeService,
|
||||
private readonly userConsentRepository: UserConsentRepository,
|
||||
private readonly resourceRegistry: ProtectedResourceRegistry,
|
||||
private readonly mailer: UserManagementMailer,
|
||||
) {}
|
||||
|
||||
get clientsStore(): OAuthRegisteredClientsStore {
|
||||
|
|
@ -430,23 +474,94 @@ export class OAuthServerService implements OAuthServerProvider {
|
|||
}
|
||||
|
||||
/**
|
||||
* Get all OAuth clients the user has consented to (excluding sensitive
|
||||
* data), together with the grant details of the consent itself.
|
||||
* Get OAuth clients users have consented to (excluding sensitive data),
|
||||
* together with the grant details of each consent. `ownership: 'all'`
|
||||
* returns every user's consents with owner info and requires `mcp:manage`.
|
||||
*
|
||||
* Filters and pagination are applied in memory after loading the ownership's
|
||||
* consents: the set is small (bounded by the instance client cap) and the
|
||||
* type filter reuses the shared name-pattern matchers, which SQL can't
|
||||
* express. `count` is the filtered total, `clients` the requested page.
|
||||
*/
|
||||
async getAllClients(userId: string): Promise<ConnectedOAuthClient[]> {
|
||||
// Get all consents for the user with client information
|
||||
const userConsents = await this.userConsentRepository.findByUserWithClient(userId);
|
||||
async getAllClients(
|
||||
user: User,
|
||||
options: ListConnectedClientsOptions = {},
|
||||
): Promise<{
|
||||
clients: ConnectedOAuthClient[];
|
||||
count: number;
|
||||
totals: ConnectedOAuthClientTotals;
|
||||
owners?: ConnectedOAuthClientOwner[];
|
||||
}> {
|
||||
const canSeeAll = hasGlobalScope(user, 'mcp:manage');
|
||||
const listAll = options.ownership === 'all';
|
||||
|
||||
// Extract and sanitize the client information
|
||||
return userConsents.map((consent) => {
|
||||
if (listAll && !canSeeAll) {
|
||||
throw new ForbiddenError('You are not allowed to list connected clients of other users');
|
||||
}
|
||||
|
||||
// The `type` filter is a name-pattern match SQL can't express. Resolve it
|
||||
// to the matching client ids first — bounded by the registered client cap,
|
||||
// not the (client × user) consent set — so filtering and paging stay in SQL.
|
||||
let clientIds: string[] | undefined;
|
||||
if (options.type) {
|
||||
const registered = await this.oauthClientRepository.find({
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
clientIds = registered
|
||||
.filter((client) => matchesTypeFilter(client.name, options.type!))
|
||||
.map((client) => client.id);
|
||||
}
|
||||
|
||||
const { rows: consents, total } =
|
||||
clientIds?.length === 0
|
||||
? { rows: [], total: 0 }
|
||||
: await this.userConsentRepository.findConnectedClients({
|
||||
userId: listAll ? undefined : user.id,
|
||||
withOwner: listAll,
|
||||
name: options.name,
|
||||
ownerId: listAll ? options.ownerId : undefined,
|
||||
clientIds,
|
||||
connected: options.connected,
|
||||
now: Date.now(),
|
||||
skip: options.skip,
|
||||
take: options.take,
|
||||
});
|
||||
|
||||
const clients: ConnectedOAuthClient[] = consents.map((consent) => {
|
||||
const { clientSecret, clientSecretExpiresAt, ...sanitizedClient } = consent.client;
|
||||
return {
|
||||
...sanitizedClient,
|
||||
// bigint columns come back as strings on Postgres
|
||||
grantedAt: Number(consent.grantedAt),
|
||||
scopes: consent.scope,
|
||||
...(listAll
|
||||
? {
|
||||
owner: {
|
||||
id: consent.user.id,
|
||||
firstName: consent.user.firstName ?? null,
|
||||
lastName: consent.user.lastName ?? null,
|
||||
email: consent.user.email,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
});
|
||||
const count = total;
|
||||
|
||||
// Owners and the tab totals reflect the unfiltered set, so they come from
|
||||
// dedicated counts rather than the filtered page above.
|
||||
const [consentOwners, mineCount, allCount] = await Promise.all([
|
||||
listAll ? this.userConsentRepository.findConsentOwners() : undefined,
|
||||
this.userConsentRepository.countBy({ userId: user.id }),
|
||||
canSeeAll ? this.userConsentRepository.count() : undefined,
|
||||
]);
|
||||
const owners = consentOwners ? sortOwners(consentOwners) : undefined;
|
||||
const totals: ConnectedOAuthClientTotals = { mine: mineCount };
|
||||
if (allCount !== undefined) {
|
||||
totals.all = allCount;
|
||||
}
|
||||
|
||||
return { clients, count, totals, owners };
|
||||
}
|
||||
|
||||
/** Tool names each scope unlocks on this instance, for the clients list UI. */
|
||||
|
|
@ -455,12 +570,14 @@ export class OAuthServerService implements OAuthServerProvider {
|
|||
}
|
||||
|
||||
/**
|
||||
* Revoke the requesting user's grant for a client: their consent, tokens,
|
||||
* and authorization codes. Other users' grants for the same client are
|
||||
* Revoke a user's grant for a client: their consent, tokens, and
|
||||
* authorization codes. Other users' grants for the same client are
|
||||
* untouched. The client registration itself is garbage-collected once the
|
||||
* last consent is gone, freeing a slot under the instance client cap.
|
||||
* When a `revoker` other than the grant owner is given (admin revoke),
|
||||
* the owner is notified by email.
|
||||
*/
|
||||
async deleteClient(clientId: string, userId: string): Promise<void> {
|
||||
async deleteClient(clientId: string, userId: string, revoker?: User): Promise<void> {
|
||||
// First check if the client exists
|
||||
const client = await this.oauthClientRepository.findOne({
|
||||
where: { id: clientId },
|
||||
|
|
@ -470,8 +587,11 @@ export class OAuthServerService implements OAuthServerProvider {
|
|||
throw new Error(`OAuth client with ID ${clientId} not found`);
|
||||
}
|
||||
|
||||
// Verify the requesting user has a consent relationship with this client
|
||||
const consent = await this.userConsentRepository.findOneBy({ clientId, userId });
|
||||
// Verify the target user has a consent relationship with this client
|
||||
const consent = await this.userConsentRepository.findOne({
|
||||
where: { clientId, userId },
|
||||
relations: ['user'],
|
||||
});
|
||||
if (!consent) {
|
||||
throw new Error(`OAuth client with ID ${clientId} not found`);
|
||||
}
|
||||
|
|
@ -507,6 +627,18 @@ export class OAuthServerService implements OAuthServerProvider {
|
|||
clientName: client.name,
|
||||
});
|
||||
}
|
||||
|
||||
if (revoker && revoker.id !== userId) {
|
||||
this.mailer
|
||||
.notifyMcpClientRevoked({ clientName: client.name, owner: consent.user, revoker })
|
||||
.catch((e) => {
|
||||
this.logger.error('Failed to send MCP client revocation email', {
|
||||
clientId,
|
||||
ownerId: userId,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -218,6 +218,32 @@ describe('UserManagementMailer', () => {
|
|||
expect(callBody).toContain('jan@acme.test');
|
||||
});
|
||||
|
||||
it('should send mcp client revoked notifications', async () => {
|
||||
const revoker = mock<User>({
|
||||
firstName: 'Jan',
|
||||
lastName: 'Ostrówka',
|
||||
email: 'jan@acme.test',
|
||||
});
|
||||
|
||||
const result = await userManagementMailer.notifyMcpClientRevoked({
|
||||
clientName: 'Claude Code',
|
||||
owner: { email: 'owner@example.com', firstName: 'Maria' },
|
||||
revoker,
|
||||
});
|
||||
|
||||
expect(result.emailSent).toBe(true);
|
||||
expect(nodeMailer.sendMail).toHaveBeenCalledWith({
|
||||
emailRecipients: 'owner@example.com',
|
||||
subject: 'Your n8n MCP client access was revoked',
|
||||
body: expect.stringContaining('href="https://n8n.url/settings/mcp"'),
|
||||
});
|
||||
|
||||
const callBody = nodeMailer.sendMail.mock.calls[0][0].body as string;
|
||||
expect(callBody).toContain('Claude Code');
|
||||
expect(callBody).toContain('Jan Ostrówka');
|
||||
expect(callBody).toMatch(/\d{1,2} [A-Z][a-z]{2} \d{4}/);
|
||||
});
|
||||
|
||||
it('should send project share notifications', async () => {
|
||||
const sharer = mock<User>({ firstName: 'Sharer', email: 'sharer@user.com' });
|
||||
const newSharees = [
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
<mjml>
|
||||
<mj-include path="./_common.mjml" />
|
||||
<mj-body>
|
||||
<mj-section padding="0 20px">
|
||||
<mj-column>
|
||||
<mj-include path="./_logo.mjml" />
|
||||
<mj-text font-size="22px" font-weight="400">Your MCP client access was revoked</mj-text>
|
||||
<mj-text>
|
||||
Your connected MCP client <b>"{{clientName}}"</b> on <b>{{domain}}</b> was revoked by <b>{{revokedBy}}</b> on {{revokedAt}}.
|
||||
</mj-text>
|
||||
<mj-text>
|
||||
The client has lost access to this instance and any sessions using it have stopped working. If you still need access, you can reconnect the client.
|
||||
</mj-text>
|
||||
<mj-button href="{{mcpSettingsUrl}}">Manage MCP access</mj-button>
|
||||
<mj-include path="./_footer.mjml" />
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
|
|
@ -47,7 +47,8 @@ type TemplateName =
|
|||
| 'credentials-shared'
|
||||
| 'project-shared'
|
||||
| 'workflow-failure'
|
||||
| 'api-key-revoked';
|
||||
| 'api-key-revoked'
|
||||
| 'mcp-client-revoked';
|
||||
|
||||
@Service()
|
||||
export class UserManagementMailer {
|
||||
|
|
@ -126,6 +127,35 @@ export class UserManagementMailer {
|
|||
});
|
||||
}
|
||||
|
||||
async notifyMcpClientRevoked({
|
||||
clientName,
|
||||
owner,
|
||||
revoker,
|
||||
}: {
|
||||
clientName: string;
|
||||
owner: { email: string; firstName?: string | null };
|
||||
revoker: User;
|
||||
}): Promise<SendEmailResult> {
|
||||
if (!this.mailer) return { emailSent: false };
|
||||
|
||||
const baseUrl = this.urlService.getInstanceBaseUrl();
|
||||
const template = await this.getTemplate('mcp-client-revoked');
|
||||
|
||||
return await this.mailer.sendMail({
|
||||
emailRecipients: owner.email,
|
||||
subject: 'Your n8n MCP client access was revoked',
|
||||
body: template({
|
||||
...this.basePayload,
|
||||
email: owner.email,
|
||||
firstName: owner.firstName ?? 'there',
|
||||
clientName,
|
||||
revokedBy: formatRevokedBy(revoker),
|
||||
revokedAt: formatRevokedAt(new Date()),
|
||||
mcpSettingsUrl: `${baseUrl}/settings/mcp`,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
async workflowFailure(data: {
|
||||
email: string;
|
||||
firstName?: string;
|
||||
|
|
|
|||
|
|
@ -3176,7 +3176,7 @@
|
|||
"settings.mcp.header.toggle.disabled": "Disabled",
|
||||
"settings.mcp.actionBox.heading": "Connect AI assistants to build and run workflows",
|
||||
"settings.mcp.actionBox.button.label": "Enable MCP access",
|
||||
"settings.mcp.emptyState.docs.part1": "Read our docs to",
|
||||
"settings.mcp.emptyState.description": "Let MCP clients like Claude Code and Cursor build, run, and iterate on workflows in your instance.",
|
||||
"settings.mcp.tabs.workflows": "Workflows",
|
||||
"settings.mcp.tabs.oauth": "Connected clients",
|
||||
"settings.mcp.tabs.oauthSettings": "OAuth settings",
|
||||
|
|
@ -3238,6 +3238,27 @@
|
|||
"settings.mcp.oAuthClients.revoke.success.title": "Access revoked",
|
||||
"settings.mcp.oAuthClients.revoke.success.message": "Client {name} access has been revoked",
|
||||
"settings.mcp.oAuthClients.revoke.error": "Error revoking client access",
|
||||
"settings.mcp.oAuthClients.revoke.title": "Revoke access for \"{name}\"?",
|
||||
"settings.mcp.oAuthClients.revoke.description.own": "The client will lose access to this instance immediately and will need to reconnect. This can't be undone.",
|
||||
"settings.mcp.oAuthClients.revoke.description.other": "The client {ownerName} connected will lose access to this instance immediately and will need to reconnect. This can't be undone.",
|
||||
"settings.mcp.oAuthClients.revoke.button": "Revoke",
|
||||
"settings.mcp.oAuthClients.tabs.mine": "Mine",
|
||||
"settings.mcp.oAuthClients.tabs.all": "All",
|
||||
"settings.mcp.oAuthClients.search.placeholder": "Search clients",
|
||||
"settings.mcp.oAuthClients.search.noResults": "No clients match your search or filters",
|
||||
"settings.mcp.oAuthClients.filters.clientType": "Client type",
|
||||
"settings.mcp.oAuthClients.filters.clientType.all": "All types",
|
||||
"settings.mcp.oAuthClients.filters.clientType.ide": "IDE",
|
||||
"settings.mcp.oAuthClients.filters.clientType.cli": "CLI",
|
||||
"settings.mcp.oAuthClients.filters.clientType.web": "Web",
|
||||
"settings.mcp.oAuthClients.filters.connectedBy": "Connected by",
|
||||
"settings.mcp.oAuthClients.filters.connectedBy.all": "All users",
|
||||
"settings.mcp.oAuthClients.filters.connected": "Connected",
|
||||
"settings.mcp.oAuthClients.filters.connected.allTime": "All time",
|
||||
"settings.mcp.oAuthClients.filters.connected.lastXDays": "Last {count} days",
|
||||
"settings.mcp.oAuthClients.filters.connected.older": "Older",
|
||||
"settings.mcp.oAuthClients.table.connectedBy": "Connected by",
|
||||
"settings.mcp.oAuthClients.details.connectedBy": "Connected by",
|
||||
"settings.mcp.refresh.tooltip": "Refresh list",
|
||||
"settings.mcp.connectWorkflows": "Enable workflows",
|
||||
"settings.mcp.connectWorkflows.modalTitle": "Enable workflow MCP access",
|
||||
|
|
|
|||
|
|
@ -83,7 +83,8 @@ const createComponent = createComponentRenderer(SettingsMCPView, {
|
|||
},
|
||||
OAuthClientsTable: {
|
||||
inheritAttrs: true,
|
||||
template: '<div>OAuth Clients Table</div>',
|
||||
template:
|
||||
"<div>OAuth Clients Table<button data-test-id=\"stub-revoke-client\" @click=\"$emit('revokeClient', { id: 'client-1', name: 'Claude Code', owner: { id: 'user-2', firstName: 'Jane', lastName: 'Doe', email: 'jane@n8n.io' } })\">Revoke</button></div>",
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -349,6 +350,32 @@ describe('SettingsMCPView', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('should confirm before revoking and pass the consent owner to the store', async () => {
|
||||
const { getByTestId, container } = createComponent({ pinia });
|
||||
await nextTick();
|
||||
|
||||
await clickTab(container, 'tab-oauth');
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('mcp-oauth-clients-table')).toBeVisible();
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('stub-revoke-client'));
|
||||
|
||||
// nothing is revoked until the dialog is confirmed
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
within(document.body).getByText('Revoke access for "Claude Code"?'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(mcpStore.removeOAuthClient).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.click(within(document.body).getByRole('button', { name: 'Revoke' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mcpStore.removeOAuthClient).toHaveBeenCalledWith('client-1', 'user-2');
|
||||
});
|
||||
});
|
||||
|
||||
it('should switch back to Workflows tab and show workflows table', async () => {
|
||||
const { getByTestId, queryByTestId, container } = createComponent({ pinia });
|
||||
await nextTick();
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useI18n } from '@n8n/i18n';
|
|||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
|
||||
import { useUIStore } from '@/app/stores/ui.store';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { hasPermission } from '@/app/utils/rbac/permissions';
|
||||
import {
|
||||
LOADING_INDICATOR_TIMEOUT,
|
||||
|
|
@ -16,6 +17,7 @@ import MCPEmptyState from '@/features/ai/mcpAccess/components/MCPEmptyState.vue'
|
|||
import MCpHeaderActions from '@/features/ai/mcpAccess/components/header/MCPHeaderActions.vue';
|
||||
import WorkflowsTable from '@/features/ai/mcpAccess/components/tabs/WorkflowsTable.vue';
|
||||
import OAuthClientsTable from '@/features/ai/mcpAccess/components/tabs/OAuthClientsTable.vue';
|
||||
import RevokeOAuthClientConfirmModal from '@/features/ai/mcpAccess/components/RevokeOAuthClientConfirmModal.vue';
|
||||
import {
|
||||
N8nHeading,
|
||||
N8nNotice,
|
||||
|
|
@ -29,6 +31,7 @@ import {
|
|||
} from '@n8n/design-system';
|
||||
import type { TabOptions } from '@n8n/design-system';
|
||||
import { useMcp } from '@/features/ai/mcpAccess/composables/useMcp';
|
||||
import type { OAuthClientFilters } from '@/features/ai/mcpAccess/clients.utils';
|
||||
import type { OAuthClientResponseDto } from '@n8n/api-types';
|
||||
import { useTelemetry } from '@/app/composables/useTelemetry';
|
||||
import { WORKFLOW_DESCRIPTION_MODAL_KEY } from '@/app/constants';
|
||||
|
|
@ -45,6 +48,7 @@ const telemetry = useTelemetry();
|
|||
|
||||
const mcpStore = useMCPStore();
|
||||
const uiStore = useUIStore();
|
||||
const usersStore = useUsersStore();
|
||||
const { offerToExposeAllWorkflows } = useExposeAllWorkflowsToMcpOffer();
|
||||
|
||||
const mcpStatusLoading = ref(false);
|
||||
|
|
@ -85,7 +89,8 @@ const workflowsTableState = ref<TableOptions>({
|
|||
const workflowsTableItemsPerPage = ref(workflowsTableState.value.itemsPerPage);
|
||||
|
||||
const oAuthClientsLoading = ref(false);
|
||||
const connectedOAuthClients = ref<OAuthClientResponseDto[]>([]);
|
||||
const revokeClient = ref<OAuthClientResponseDto | null>(null);
|
||||
const revoking = ref(false);
|
||||
|
||||
const redirectUrisInput = ref('');
|
||||
const redirectUrisError = ref('');
|
||||
|
|
@ -117,7 +122,7 @@ const onTabSelected = async (tab: MCPTabs) => {
|
|||
selectedTab.value = tab;
|
||||
if (tab === 'workflows' && availableWorkflows.value.length === 0) {
|
||||
await fetchAvailableWorkflows();
|
||||
} else if (tab === 'oauth' && connectedOAuthClients.value.length === 0) {
|
||||
} else if (tab === 'oauth' && mcpStore.oauthClients.length === 0) {
|
||||
await fetchoAuthCLients();
|
||||
telemetry.track('User clicked connected clients tab');
|
||||
} else if (tab === 'settings' && mcpStore.allowedRedirectUris.length === 0) {
|
||||
|
|
@ -282,8 +287,7 @@ const onWorkflowsTableUpdate = async (options: TableOptions) => {
|
|||
const fetchoAuthCLients = async () => {
|
||||
try {
|
||||
oAuthClientsLoading.value = true;
|
||||
const clients = await mcpStore.getAllOAuthClients();
|
||||
connectedOAuthClients.value = clients ?? [];
|
||||
await mcpStore.getAllOAuthClients();
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('settings.mcp.error.fetching.oAuthClients'));
|
||||
} finally {
|
||||
|
|
@ -293,10 +297,48 @@ const fetchoAuthCLients = async () => {
|
|||
}
|
||||
};
|
||||
|
||||
const revokeClientAccess = async (client: OAuthClientResponseDto) => {
|
||||
const onOwnershipChange = async (ownership: 'mine' | 'all') => {
|
||||
try {
|
||||
await mcpStore.removeOAuthClient(client.id);
|
||||
connectedOAuthClients.value = connectedOAuthClients.value.filter((c) => c.id !== client.id);
|
||||
oAuthClientsLoading.value = true;
|
||||
await mcpStore.setOAuthClientsOwnership(ownership);
|
||||
if (ownership === 'all') {
|
||||
telemetry.track('User viewed all MCP clients');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('settings.mcp.error.fetching.oAuthClients'));
|
||||
} finally {
|
||||
setTimeout(() => {
|
||||
oAuthClientsLoading.value = false;
|
||||
}, LOADING_INDICATOR_TIMEOUT);
|
||||
}
|
||||
};
|
||||
|
||||
const withClientsErrorToast = async (fn: () => Promise<void>) => {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('settings.mcp.error.fetching.oAuthClients'));
|
||||
}
|
||||
};
|
||||
|
||||
const onClientsFiltersChange = async (filters: OAuthClientFilters) =>
|
||||
await withClientsErrorToast(() => mcpStore.setOAuthClientsFilters(filters));
|
||||
|
||||
const onClientsOptionsChange = async (options: { page: number; itemsPerPage: number }) =>
|
||||
await withClientsErrorToast(() =>
|
||||
mcpStore.setOAuthClientsPagination(options.page, options.itemsPerPage),
|
||||
);
|
||||
|
||||
const onRevokeRequest = (client: OAuthClientResponseDto) => {
|
||||
revokeClient.value = client;
|
||||
};
|
||||
|
||||
const onRevokeConfirm = async () => {
|
||||
const client = revokeClient.value;
|
||||
if (!client) return;
|
||||
try {
|
||||
revoking.value = true;
|
||||
await mcpStore.removeOAuthClient(client.id, client.owner?.id);
|
||||
toast.showMessage({
|
||||
type: 'success',
|
||||
title: i18n.baseText('settings.mcp.oAuthClients.revoke.success.title'),
|
||||
|
|
@ -306,6 +348,9 @@ const revokeClientAccess = async (client: OAuthClientResponseDto) => {
|
|||
});
|
||||
} catch (error) {
|
||||
toast.showError(error, i18n.baseText('settings.mcp.oAuthClients.revoke.error'));
|
||||
} finally {
|
||||
revoking.value = false;
|
||||
revokeClient.value = null;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -502,10 +547,13 @@ onMounted(async () => {
|
|||
<OAuthClientsTable
|
||||
v-else-if="selectedTab === 'oauth'"
|
||||
:data-test-id="'mcp-oauth-clients-table'"
|
||||
:clients="connectedOAuthClients"
|
||||
:clients="mcpStore.oauthClients"
|
||||
:scope-tools="mcpStore.oauthClientScopeTools"
|
||||
:loading="oAuthClientsLoading"
|
||||
@revoke-client="revokeClientAccess"
|
||||
@revoke-client="onRevokeRequest"
|
||||
@update:ownership="onOwnershipChange"
|
||||
@update:filters="onClientsFiltersChange"
|
||||
@update:options="onClientsOptionsChange"
|
||||
@refresh="onTableRefresh"
|
||||
/>
|
||||
<section
|
||||
|
|
@ -543,6 +591,18 @@ onMounted(async () => {
|
|||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<RevokeOAuthClientConfirmModal
|
||||
:client="revokeClient"
|
||||
:open="!!revokeClient"
|
||||
:loading="revoking"
|
||||
:revoking-for-other="
|
||||
!!revokeClient?.owner && revokeClient.owner.id !== usersStore.currentUser?.id
|
||||
"
|
||||
@confirm="onRevokeConfirm"
|
||||
@cancel="revokeClient = null"
|
||||
@update:open="revokeClient = null"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,23 @@
|
|||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { getClientBrand } from './clients.utils';
|
||||
|
||||
describe('getClientBrand', () => {
|
||||
it.each([
|
||||
['Claude Code', 'cli'],
|
||||
['Claude', 'assistant'],
|
||||
['Cursor', 'ide'],
|
||||
['Visual Studio Code', 'editor'],
|
||||
['Codex CLI', 'cli'],
|
||||
['ChatGPT', 'assistant'],
|
||||
['Some Unknown Client', null],
|
||||
])('derives the type of %s as %s', (name, type) => {
|
||||
expect(getClientBrand(name).type).toBe(type);
|
||||
});
|
||||
|
||||
it('resolves a logo for recognized brands and none for unknown clients', () => {
|
||||
expect(getClientBrand('Claude Code').icon).not.toBeNull();
|
||||
expect(getClientBrand('Cursor').icon).not.toBeNull();
|
||||
expect(getClientBrand('Some Unknown Client').icon).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,12 @@
|
|||
import type { Component } from 'vue';
|
||||
|
||||
import { MCP_INSTANCE_SCOPES } from '@n8n/api-types';
|
||||
import { MCP_CLIENT_BRAND_MATCHERS, MCP_INSTANCE_SCOPES } from '@n8n/api-types';
|
||||
import type {
|
||||
McpClientBrandName,
|
||||
McpClientConnectedPeriod,
|
||||
McpClientType,
|
||||
McpClientTypeFilter,
|
||||
} from '@n8n/api-types';
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
|
||||
import ClaudeIcon from './assets/client-icons/claude.svg?component';
|
||||
|
|
@ -8,26 +14,18 @@ import CursorIcon from './assets/client-icons/cursor.svg?component';
|
|||
import OpenAiIcon from './assets/client-icons/openai.svg?component';
|
||||
import VsCodeIcon from './assets/client-icons/vscode.svg?component';
|
||||
|
||||
export type McpClientType = 'cli' | 'ide' | 'editor' | 'assistant';
|
||||
|
||||
export interface McpClientBrand {
|
||||
icon: Component | null;
|
||||
type: McpClientType | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Client names are free-form (self-reported at OAuth registration), so known
|
||||
* clients are recognized by name patterns. First match wins: more specific
|
||||
* patterns (e.g. "Claude Code") must come before broader ones ("Claude").
|
||||
*/
|
||||
const BRAND_MATCHERS: Array<{ pattern: RegExp; brand: McpClientBrand }> = [
|
||||
{ pattern: /claude[ -]?code/i, brand: { icon: ClaudeIcon, type: 'cli' } },
|
||||
{ pattern: /claude/i, brand: { icon: ClaudeIcon, type: 'assistant' } },
|
||||
{ pattern: /cursor/i, brand: { icon: CursorIcon, type: 'ide' } },
|
||||
{ pattern: /(visual studio code|vs ?code)/i, brand: { icon: VsCodeIcon, type: 'editor' } },
|
||||
{ pattern: /codex/i, brand: { icon: OpenAiIcon, type: 'cli' } },
|
||||
{ pattern: /chatgpt|openai/i, brand: { icon: OpenAiIcon, type: 'assistant' } },
|
||||
];
|
||||
/** Logos for the brands recognized by the shared name-pattern matchers. */
|
||||
const BRAND_ICONS: Record<McpClientBrandName, Component> = {
|
||||
claude: ClaudeIcon,
|
||||
cursor: CursorIcon,
|
||||
vscode: VsCodeIcon,
|
||||
openai: OpenAiIcon,
|
||||
};
|
||||
|
||||
// Client names are bounded (a user's own registered clients), so memoizing the
|
||||
// regex scan by name is safe; the map would need eviction only if that set ever
|
||||
|
|
@ -37,10 +35,10 @@ const brandCache = new Map<string, McpClientBrand>();
|
|||
export function getClientBrand(clientName: string): McpClientBrand {
|
||||
let brand = brandCache.get(clientName);
|
||||
if (!brand) {
|
||||
brand = BRAND_MATCHERS.find(({ pattern }) => pattern.test(clientName))?.brand ?? {
|
||||
icon: null,
|
||||
type: null,
|
||||
};
|
||||
const match = MCP_CLIENT_BRAND_MATCHERS.find(({ pattern }) => pattern.test(clientName));
|
||||
brand = match
|
||||
? { icon: BRAND_ICONS[match.brand], type: match.type }
|
||||
: { icon: null, type: null };
|
||||
brandCache.set(clientName, brand);
|
||||
}
|
||||
return brand;
|
||||
|
|
@ -75,3 +73,18 @@ export function scopeLabel(
|
|||
export function isFullAccessGrant(scopes: string[]): boolean {
|
||||
return scopes.length > 0 && MCP_INSTANCE_SCOPES.every((scope) => scopes.includes(scope));
|
||||
}
|
||||
|
||||
/** UI state of the connected-clients search + filter popover; applied server-side. */
|
||||
export interface OAuthClientFilters {
|
||||
search: string;
|
||||
type: McpClientTypeFilter | null;
|
||||
ownerId: string | null;
|
||||
connected: McpClientConnectedPeriod | null;
|
||||
}
|
||||
|
||||
export const EMPTY_OAUTH_CLIENT_FILTERS: OAuthClientFilters = {
|
||||
search: '',
|
||||
type: null,
|
||||
ownerId: null,
|
||||
connected: null,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import { N8nEmptyState } from '@n8n/design-system';
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
import { computed } from 'vue';
|
||||
import { N8nButton, N8nEmptyState, N8nIcon, N8nTooltip } from '@n8n/design-system';
|
||||
import { MCP_DOCS_PAGE_URL } from '@/features/ai/mcpAccess/mcp.constants';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
|
||||
import ClaudeIcon from '../assets/client-icons/claude.svg?component';
|
||||
import CursorIcon from '../assets/client-icons/cursor.svg?component';
|
||||
import OpenAiIcon from '../assets/client-icons/openai.svg?component';
|
||||
import VsCodeIcon from '../assets/client-icons/vscode.svg?component';
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
loading?: boolean;
|
||||
|
|
@ -21,41 +26,50 @@ const emit = defineEmits<{
|
|||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const buttonDisabled = computed(() => props.disabled || props.loading);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.container" data-test-id="mcp-empty-state-container">
|
||||
<N8nEmptyState
|
||||
:icon="{ type: 'icon', value: 'mcp' }"
|
||||
:icon="{
|
||||
type: 'cards',
|
||||
center: 'mcp',
|
||||
sides: [ClaudeIcon, CursorIcon, VsCodeIcon, OpenAiIcon],
|
||||
}"
|
||||
:heading="i18n.baseText('settings.mcp.actionBox.heading')"
|
||||
:description="i18n.baseText('settings.mcp.description')"
|
||||
:button-text="i18n.baseText('settings.mcp.actionBox.button.label')"
|
||||
:button-disabled="props.disabled || props.loading"
|
||||
button-variant="solid"
|
||||
data-test-id="enable-mcp-access-button"
|
||||
@click:button="emit('turnOnMcp')"
|
||||
:description="i18n.baseText('settings.mcp.emptyState.description')"
|
||||
>
|
||||
<template #disabledButtonTooltip>
|
||||
<span v-if="props.loading">{{ i18n.baseText('generic.loading') }}...</span>
|
||||
<span v-else-if="props.managedByEnv">
|
||||
{{ i18n.baseText('settings.mcp.managedByEnv.tooltip') }}
|
||||
</span>
|
||||
<span v-else-if="props.disabled">
|
||||
{{ i18n.baseText('settings.mcp.toggle.disabled.tooltip') }}
|
||||
</span>
|
||||
</template>
|
||||
<template #additionalContent>
|
||||
<N8nText color="text-light" size="small">
|
||||
{{ i18n.baseText('settings.mcp.emptyState.docs.part1') }}
|
||||
<a
|
||||
:href="MCP_DOCS_PAGE_URL"
|
||||
:class="$style['docs-link']"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<N8nButton
|
||||
variant="ghost"
|
||||
class="mr-2xs n8n-button--highlight"
|
||||
:href="MCP_DOCS_PAGE_URL"
|
||||
target="_blank"
|
||||
data-test-id="mcp-empty-state-learn-more"
|
||||
>
|
||||
{{ i18n.baseText('generic.learnMore') }} <N8nIcon icon="arrow-up-right" />
|
||||
</N8nButton>
|
||||
<N8nTooltip :disabled="!buttonDisabled">
|
||||
<template #content>
|
||||
<span v-if="props.loading">{{ i18n.baseText('generic.loading') }}...</span>
|
||||
<span v-else-if="props.managedByEnv">
|
||||
{{ i18n.baseText('settings.mcp.managedByEnv.tooltip') }}
|
||||
</span>
|
||||
<span v-else>
|
||||
{{ i18n.baseText('settings.mcp.toggle.disabled.tooltip') }}
|
||||
</span>
|
||||
</template>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
:disabled="buttonDisabled"
|
||||
data-test-id="enable-mcp-access-button"
|
||||
@click="emit('turnOnMcp')"
|
||||
>
|
||||
{{ i18n.baseText('generic.learnMore').toLowerCase() }}
|
||||
</a>
|
||||
</N8nText>
|
||||
{{ i18n.baseText('settings.mcp.actionBox.button.label') }}
|
||||
</N8nButton>
|
||||
</N8nTooltip>
|
||||
</template>
|
||||
</N8nEmptyState>
|
||||
</div>
|
||||
|
|
@ -66,13 +80,5 @@ const i18n = useI18n();
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--lg);
|
||||
|
||||
button {
|
||||
margin-bottom: var(--spacing--2xs);
|
||||
}
|
||||
}
|
||||
|
||||
.docs-link {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -41,6 +41,13 @@ const i18n = useI18n();
|
|||
|
||||
const brand = computed(() => (props.client ? getClientBrand(props.client.name) : null));
|
||||
|
||||
const ownerLabel = computed(() => {
|
||||
const owner = props.client?.owner;
|
||||
if (!owner) return null;
|
||||
const name = [owner.firstName, owner.lastName].filter(Boolean).join(' ');
|
||||
return name ? `${name} (${owner.email})` : owner.email;
|
||||
});
|
||||
|
||||
const subtitle = computed(() => {
|
||||
const type = brand.value?.type;
|
||||
if (!type) return i18n.baseText('settings.mcp.oAuthClients.details.subtitle');
|
||||
|
|
@ -118,6 +125,15 @@ function onRevoke() {
|
|||
</N8nDialogHeader>
|
||||
|
||||
<div :class="$style.details">
|
||||
<template v-if="ownerLabel">
|
||||
<N8nText color="text-light" size="small">
|
||||
{{ i18n.baseText('settings.mcp.oAuthClients.details.connectedBy') }}
|
||||
</N8nText>
|
||||
<N8nText color="text-dark" size="small" data-test-id="mcp-client-details-connected-by">
|
||||
{{ ownerLabel }}
|
||||
</N8nText>
|
||||
</template>
|
||||
|
||||
<N8nText color="text-light" size="small">
|
||||
{{ i18n.baseText('settings.mcp.oAuthClients.details.connectedOn') }}
|
||||
</N8nText>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
<script lang="ts" setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { OAuthClientResponseDto } from '@n8n/api-types';
|
||||
import { N8nAlertDialog } from '@n8n/design-system';
|
||||
|
||||
const props = defineProps<{
|
||||
client: OAuthClientResponseDto | null;
|
||||
open: boolean;
|
||||
loading?: boolean;
|
||||
/** When the caller is not the consent owner — admin revoking another user's client. */
|
||||
revokingForOther?: boolean;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:open': [value: boolean];
|
||||
confirm: [];
|
||||
cancel: [];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const title = computed(() =>
|
||||
props.client
|
||||
? i18n.baseText('settings.mcp.oAuthClients.revoke.title', {
|
||||
interpolate: { name: props.client.name },
|
||||
})
|
||||
: '',
|
||||
);
|
||||
|
||||
const description = computed(() => {
|
||||
if (!props.client) return '';
|
||||
if (props.revokingForOther) {
|
||||
const owner = props.client.owner;
|
||||
const ownerName =
|
||||
[owner?.firstName, owner?.lastName].filter(Boolean).join(' ') || owner?.email || '';
|
||||
return i18n.baseText('settings.mcp.oAuthClients.revoke.description.other', {
|
||||
interpolate: { ownerName },
|
||||
});
|
||||
}
|
||||
return i18n.baseText('settings.mcp.oAuthClients.revoke.description.own');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nAlertDialog
|
||||
:open="open"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:action-label="i18n.baseText('settings.mcp.oAuthClients.revoke.button')"
|
||||
:cancel-label="i18n.baseText('generic.cancel')"
|
||||
action-variant="destructive"
|
||||
:loading="loading"
|
||||
size="medium"
|
||||
data-test-id="mcp-client-revoke-confirm"
|
||||
@action="emit('confirm')"
|
||||
@cancel="emit('cancel')"
|
||||
@update:open="emit('update:open', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
<script lang="ts" setup>
|
||||
import type { OAuthClientResponseDto } from '@n8n/api-types';
|
||||
import { N8nUserInfo } from '@n8n/design-system';
|
||||
|
||||
defineProps<{
|
||||
owner: NonNullable<OAuthClientResponseDto['owner']>;
|
||||
isCurrentUser?: boolean;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div data-test-id="mcp-client-owner-cell">
|
||||
<N8nUserInfo
|
||||
:first-name="owner.firstName ?? ''"
|
||||
:last-name="owner.lastName ?? ''"
|
||||
:email="owner.email"
|
||||
:is-current-user="isCurrentUser"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import userEvent from '@testing-library/user-event';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
import OAuthClientsFilters from '@/features/ai/mcpAccess/components/tabs/OAuthClientsFilters.vue';
|
||||
import { EMPTY_OAUTH_CLIENT_FILTERS } from '@/features/ai/mcpAccess/clients.utils';
|
||||
|
||||
const createComponent = createComponentRenderer(OAuthClientsFilters, {
|
||||
pinia: createTestingPinia(),
|
||||
});
|
||||
|
||||
describe('OAuthClientsFilters', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the type and connected selects but no owner select on the mine view', async () => {
|
||||
const { getByTestId, queryByTestId } = createComponent({
|
||||
props: {
|
||||
modelValue: { ...EMPTY_OAUTH_CLIENT_FILTERS },
|
||||
showOwnerFilter: false,
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('mcp-clients-filters-trigger'));
|
||||
|
||||
expect(getByTestId('mcp-clients-filters-dropdown')).toBeVisible();
|
||||
expect(getByTestId('mcp-clients-filter-type')).toBeInTheDocument();
|
||||
expect(getByTestId('mcp-clients-filter-connected')).toBeInTheDocument();
|
||||
expect(queryByTestId('mcp-clients-filter-owner')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the owner select on the all view', async () => {
|
||||
const { getByTestId } = createComponent({
|
||||
props: {
|
||||
modelValue: { ...EMPTY_OAUTH_CLIENT_FILTERS },
|
||||
showOwnerFilter: true,
|
||||
owners: [{ id: 'user-1', firstName: 'Jane', lastName: 'Doe', email: 'jane@n8n.io' }],
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('mcp-clients-filters-trigger'));
|
||||
|
||||
expect(getByTestId('mcp-clients-filter-owner')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show the active filter count on the trigger', () => {
|
||||
const { getByTestId } = createComponent({
|
||||
props: {
|
||||
modelValue: { ...EMPTY_OAUTH_CLIENT_FILTERS, type: 'cli', connected: 'last7' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('mcp-clients-filters-count')).toHaveTextContent('2');
|
||||
});
|
||||
|
||||
it('should not count the text search as an active filter', () => {
|
||||
const { queryByTestId } = createComponent({
|
||||
props: {
|
||||
modelValue: { ...EMPTY_OAUTH_CLIENT_FILTERS, search: 'claude' },
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('mcp-clients-filters-count')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should reset the popover filters but keep the search term', async () => {
|
||||
const { getByTestId, emitted } = createComponent({
|
||||
props: {
|
||||
modelValue: {
|
||||
search: 'claude',
|
||||
type: 'cli' as const,
|
||||
ownerId: 'user-1',
|
||||
connected: 'last7' as const,
|
||||
},
|
||||
showOwnerFilter: true,
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByTestId('mcp-clients-filters-trigger'));
|
||||
await userEvent.click(getByTestId('mcp-clients-filters-reset'));
|
||||
|
||||
expect(emitted('update:modelValue')).toEqual([
|
||||
[{ search: 'claude', type: null, ownerId: null, connected: null }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,201 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
import type { IUser } from '@n8n/design-system';
|
||||
import {
|
||||
N8nBadge,
|
||||
N8nButton,
|
||||
N8nInputLabel,
|
||||
N8nLink,
|
||||
N8nOption,
|
||||
N8nPopover,
|
||||
N8nSelect,
|
||||
N8nUserSelect,
|
||||
} from '@n8n/design-system';
|
||||
|
||||
import type { McpClientConnectedPeriod, McpClientTypeFilter } from '@n8n/api-types';
|
||||
import { MCP_CLIENT_CONNECTED_PERIODS, MCP_CLIENT_TYPE_FILTERS } from '@n8n/api-types';
|
||||
|
||||
import type { OAuthClientFilters } from '../../clients.utils';
|
||||
|
||||
const props = defineProps<{
|
||||
modelValue: OAuthClientFilters;
|
||||
/** Consent owners offered by the "Connected by" select (All tab only). */
|
||||
owners?: IUser[];
|
||||
showOwnerFilter?: boolean;
|
||||
currentUserId?: string;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: OAuthClientFilters];
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
|
||||
const CLIENT_TYPE_OPTIONS: readonly McpClientTypeFilter[] = MCP_CLIENT_TYPE_FILTERS;
|
||||
const CONNECTED_OPTIONS: readonly McpClientConnectedPeriod[] = MCP_CLIENT_CONNECTED_PERIODS;
|
||||
|
||||
const connectedOptionLabels: Record<McpClientConnectedPeriod, string> = {
|
||||
last7: i18n.baseText('settings.mcp.oAuthClients.filters.connected.lastXDays', {
|
||||
interpolate: { count: 7 },
|
||||
}),
|
||||
last30: i18n.baseText('settings.mcp.oAuthClients.filters.connected.lastXDays', {
|
||||
interpolate: { count: 30 },
|
||||
}),
|
||||
older: i18n.baseText('settings.mcp.oAuthClients.filters.connected.older'),
|
||||
};
|
||||
|
||||
// The search input lives outside the popover, so it doesn't count as a filter.
|
||||
const filtersLength = computed(() => {
|
||||
const { type, ownerId, connected } = props.modelValue;
|
||||
return [type, ownerId, connected].filter((value) => value !== null).length;
|
||||
});
|
||||
|
||||
const hasFilters = computed(() => filtersLength.value > 0);
|
||||
|
||||
// Selects can't carry null values, so '' stands in for "no filter".
|
||||
function setKeyValue(key: keyof OAuthClientFilters, value: string) {
|
||||
emit('update:modelValue', { ...props.modelValue, [key]: value === '' ? null : value });
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
emit('update:modelValue', { ...props.modelValue, type: null, ownerId: null, connected: null });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nPopover width="304px" :content-class="$style['popover-content']" align="end">
|
||||
<template #trigger>
|
||||
<span :class="$style['trigger-wrapper']">
|
||||
<N8nButton
|
||||
variant="outline"
|
||||
icon="funnel"
|
||||
size="medium"
|
||||
:icon-only="!hasFilters"
|
||||
:active="hasFilters"
|
||||
:aria-label="i18n.baseText('forms.resourceFiltersDropdown.filters')"
|
||||
data-test-id="mcp-clients-filters-trigger"
|
||||
>
|
||||
<N8nBadge
|
||||
v-if="hasFilters"
|
||||
:class="$style['filter-button-count']"
|
||||
data-test-id="mcp-clients-filters-count"
|
||||
theme="primary"
|
||||
>
|
||||
{{ filtersLength }}
|
||||
</N8nBadge>
|
||||
<span v-if="hasFilters">
|
||||
{{ i18n.baseText('forms.resourceFiltersDropdown.filters') }}
|
||||
</span>
|
||||
</N8nButton>
|
||||
</span>
|
||||
</template>
|
||||
<template #content>
|
||||
<div :class="$style['filters-dropdown']" data-test-id="mcp-clients-filters-dropdown">
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('settings.mcp.oAuthClients.filters.clientType')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
color="text-base"
|
||||
class="mb-3xs"
|
||||
/>
|
||||
<N8nSelect
|
||||
:model-value="modelValue.type ?? ''"
|
||||
size="medium"
|
||||
data-test-id="mcp-clients-filter-type"
|
||||
@update:model-value="setKeyValue('type', $event)"
|
||||
>
|
||||
<N8nOption
|
||||
value=""
|
||||
:label="i18n.baseText('settings.mcp.oAuthClients.filters.clientType.all')"
|
||||
/>
|
||||
<N8nOption
|
||||
v-for="type in CLIENT_TYPE_OPTIONS"
|
||||
:key="type"
|
||||
:value="type"
|
||||
:label="
|
||||
i18n.baseText(`settings.mcp.oAuthClients.filters.clientType.${type}` as BaseTextKey)
|
||||
"
|
||||
/>
|
||||
</N8nSelect>
|
||||
|
||||
<template v-if="showOwnerFilter">
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('settings.mcp.oAuthClients.filters.connectedBy')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
color="text-base"
|
||||
class="mt-s mb-3xs"
|
||||
/>
|
||||
<N8nUserSelect
|
||||
:users="owners ?? []"
|
||||
:model-value="modelValue.ownerId ?? ''"
|
||||
:current-user-id="currentUserId"
|
||||
:placeholder="i18n.baseText('settings.mcp.oAuthClients.filters.connectedBy.all')"
|
||||
size="medium"
|
||||
clearable
|
||||
data-test-id="mcp-clients-filter-owner"
|
||||
@update:model-value="setKeyValue('ownerId', $event ?? '')"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<N8nInputLabel
|
||||
:label="i18n.baseText('settings.mcp.oAuthClients.filters.connected')"
|
||||
:bold="false"
|
||||
size="small"
|
||||
color="text-base"
|
||||
class="mt-s mb-3xs"
|
||||
/>
|
||||
<N8nSelect
|
||||
:model-value="modelValue.connected ?? ''"
|
||||
size="medium"
|
||||
data-test-id="mcp-clients-filter-connected"
|
||||
@update:model-value="setKeyValue('connected', $event)"
|
||||
>
|
||||
<N8nOption
|
||||
value=""
|
||||
:label="i18n.baseText('settings.mcp.oAuthClients.filters.connected.allTime')"
|
||||
/>
|
||||
<N8nOption
|
||||
v-for="period in CONNECTED_OPTIONS"
|
||||
:key="period"
|
||||
:value="period"
|
||||
:label="connectedOptionLabels[period]"
|
||||
/>
|
||||
</N8nSelect>
|
||||
|
||||
<div v-if="hasFilters" :class="[$style['filters-dropdown-footer'], 'mt-s']">
|
||||
<N8nLink data-test-id="mcp-clients-filters-reset" @click="resetFilters">
|
||||
{{ i18n.baseText('forms.resourceFiltersDropdown.reset') }}
|
||||
</N8nLink>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</N8nPopover>
|
||||
</template>
|
||||
|
||||
<style lang="scss" module>
|
||||
.popover-content {
|
||||
padding: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.trigger-wrapper {
|
||||
display: inline-flex;
|
||||
|
||||
&[data-state='open'] button {
|
||||
background-color: var(--button--color--background-active);
|
||||
}
|
||||
}
|
||||
|
||||
.filter-button-count > span {
|
||||
font-size: var(--font-size--3xs);
|
||||
font-weight: var(--font-weight--semibold);
|
||||
}
|
||||
|
||||
.filters-dropdown-footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { within } from '@testing-library/vue';
|
||||
import { waitFor, within } from '@testing-library/vue';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { createTestingPinia } from '@pinia/testing';
|
||||
import { createComponentRenderer } from '@/__tests__/render';
|
||||
|
|
@ -13,9 +13,31 @@ vi.mock('@/app/components/TimeAgo.vue', () => ({
|
|||
},
|
||||
}));
|
||||
|
||||
vi.mock('@/features/ai/mcpAccess/mcp.store', () => ({
|
||||
useMCPStore: () => ({
|
||||
const { mockMcpStore, mockHasScope } = vi.hoisted(() => ({
|
||||
mockMcpStore: {
|
||||
openConnectPopover: vi.fn(),
|
||||
oauthClientsOwnership: 'mine' as 'mine' | 'all',
|
||||
oauthClientTotals: { mine: 0 } as { mine: number; all?: number },
|
||||
oauthClientsPage: 0,
|
||||
oauthClientsPageSize: 10,
|
||||
oauthClientsCount: 0,
|
||||
oauthClientOwners: [] as Array<{
|
||||
id: string;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
email: string;
|
||||
}>,
|
||||
},
|
||||
mockHasScope: vi.fn().mockReturnValue(false),
|
||||
}));
|
||||
|
||||
vi.mock('@/features/ai/mcpAccess/mcp.store', () => ({
|
||||
useMCPStore: () => mockMcpStore,
|
||||
}));
|
||||
|
||||
vi.mock('@n8n/stores/rbac.store', () => ({
|
||||
useRBACStore: () => ({
|
||||
hasScope: mockHasScope,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
|
@ -26,6 +48,13 @@ const createComponent = createComponentRenderer(OAuthClientsTable, {
|
|||
describe('OAuthClientsTable', () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockHasScope.mockReturnValue(false);
|
||||
mockMcpStore.oauthClientsOwnership = 'mine';
|
||||
mockMcpStore.oauthClientTotals = { mine: 0 };
|
||||
mockMcpStore.oauthClientsPage = 0;
|
||||
mockMcpStore.oauthClientsPageSize = 10;
|
||||
mockMcpStore.oauthClientsCount = 0;
|
||||
mockMcpStore.oauthClientOwners = [];
|
||||
});
|
||||
|
||||
describe('Loading state', () => {
|
||||
|
|
@ -126,6 +155,137 @@ describe('OAuthClientsTable', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('Mine/All tabs', () => {
|
||||
it('should not render tabs for users without mcp:manage', () => {
|
||||
const { queryByTestId } = createComponent({
|
||||
props: {
|
||||
clients: [createOAuthClient()],
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('mcp-clients-tabs')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render tabs with unfiltered totals for managers', () => {
|
||||
mockHasScope.mockReturnValue(true);
|
||||
mockMcpStore.oauthClientTotals = { mine: 2, all: 5 };
|
||||
|
||||
const { getByTestId } = createComponent({
|
||||
props: {
|
||||
clients: [createOAuthClient()],
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
|
||||
const tabs = getByTestId('mcp-clients-tabs');
|
||||
expect(tabs).toHaveTextContent('Mine');
|
||||
expect(tabs).toHaveTextContent('2');
|
||||
expect(tabs).toHaveTextContent('All');
|
||||
expect(tabs).toHaveTextContent('5');
|
||||
});
|
||||
|
||||
it('should emit update:ownership when switching tabs', async () => {
|
||||
mockHasScope.mockReturnValue(true);
|
||||
mockMcpStore.oauthClientTotals = { mine: 1, all: 2 };
|
||||
|
||||
const { getByText, emitted } = createComponent({
|
||||
props: {
|
||||
clients: [createOAuthClient()],
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.click(getByText('All'));
|
||||
|
||||
expect(emitted('update:ownership')).toEqual([['all']]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Search and filters', () => {
|
||||
beforeEach(() => {
|
||||
// disable the search debounce so assertions can run synchronously
|
||||
sessionStorage.setItem('N8N_DEBOUNCE_MULTIPLIER', '0');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sessionStorage.removeItem('N8N_DEBOUNCE_MULTIPLIER');
|
||||
});
|
||||
|
||||
it('should emit the debounced search term so the parent can filter server-side', async () => {
|
||||
mockMcpStore.oauthClientsCount = 2;
|
||||
|
||||
const { getByTestId, emitted } = createComponent({
|
||||
props: {
|
||||
clients: [
|
||||
createOAuthClient({ id: 'client-1', name: 'Claude Code' }),
|
||||
createOAuthClient({ id: 'client-2', name: 'Cursor' }),
|
||||
],
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.type(getByTestId('mcp-clients-search'), 'cursor');
|
||||
|
||||
await waitFor(() => {
|
||||
const emissions = emitted('update:filters') as Array<[{ search: string }]>;
|
||||
expect(emissions).toBeTruthy();
|
||||
expect(emissions[emissions.length - 1][0].search).toBe('cursor');
|
||||
});
|
||||
});
|
||||
|
||||
it('should show a no-results message when the filtered set is empty', async () => {
|
||||
// the server matched nothing for the active search
|
||||
mockMcpStore.oauthClientsCount = 0;
|
||||
|
||||
const { getByTestId, queryByTestId } = createComponent({
|
||||
props: {
|
||||
clients: [],
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
|
||||
await userEvent.type(getByTestId('mcp-clients-search'), 'nothing matches this');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(getByTestId('mcp-clients-no-results')).toBeVisible();
|
||||
});
|
||||
// the connect CTA is reserved for a genuinely empty client list
|
||||
expect(queryByTestId('mcp-oauth-create-client-button')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Connected by column', () => {
|
||||
it('should not render the owner column in the mine view', () => {
|
||||
const { queryByTestId } = createComponent({
|
||||
props: {
|
||||
clients: [createOAuthClient()],
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(queryByTestId('mcp-client-owner-cell')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the owner in the all view', () => {
|
||||
mockHasScope.mockReturnValue(true);
|
||||
mockMcpStore.oauthClientsOwnership = 'all';
|
||||
|
||||
const { getByTestId } = createComponent({
|
||||
props: {
|
||||
clients: [
|
||||
createOAuthClient({
|
||||
owner: { id: 'user-1', firstName: 'Jane', lastName: 'Doe', email: 'jane@n8n.io' },
|
||||
}),
|
||||
],
|
||||
loading: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(getByTestId('mcp-client-owner-cell')).toHaveTextContent('Jane Doe');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Actions', () => {
|
||||
it('should emit revokeClient event when the revoke button is clicked', async () => {
|
||||
const client = createOAuthClient({ name: 'Client to Revoke' });
|
||||
|
|
|
|||
|
|
@ -2,16 +2,39 @@
|
|||
import { useI18n } from '@n8n/i18n';
|
||||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
import type { OAuthClientResponseDto } from '@n8n/api-types';
|
||||
import { N8nButton, N8nDataTableServer, N8nIcon, N8nLoading, N8nText } from '@n8n/design-system';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import {
|
||||
N8nButton,
|
||||
N8nDataTableServer,
|
||||
N8nIcon,
|
||||
N8nInput,
|
||||
N8nLoading,
|
||||
N8nTabs,
|
||||
N8nText,
|
||||
} from '@n8n/design-system';
|
||||
import type { IUser, TabOptions } from '@n8n/design-system';
|
||||
import { computed, ref } from 'vue';
|
||||
import debounce from 'lodash/debounce';
|
||||
import { useMCPStore } from '@/features/ai/mcpAccess/mcp.store';
|
||||
import { useRBACStore } from '@n8n/stores/rbac.store';
|
||||
import { useUsersStore } from '@/features/settings/users/users.store';
|
||||
import { DEBOUNCE_TIME, getDebounceTime } from '@/app/constants';
|
||||
import type { TableHeader } from '@n8n/design-system/components/N8nDataTableServer';
|
||||
import TimeAgo from '@/app/components/TimeAgo.vue';
|
||||
import { getClientBrand, isFullAccessGrant, scopeLabel } from '../../clients.utils';
|
||||
import {
|
||||
EMPTY_OAUTH_CLIENT_FILTERS,
|
||||
getClientBrand,
|
||||
isFullAccessGrant,
|
||||
scopeLabel,
|
||||
} from '../../clients.utils';
|
||||
import type { OAuthClientFilters } from '../../clients.utils';
|
||||
import OAuthClientDetailsModal from '../OAuthClientDetailsModal.vue';
|
||||
import OAuthClientOwnerCell from './OAuthClientOwnerCell.vue';
|
||||
import OAuthClientsFilters from './OAuthClientsFilters.vue';
|
||||
|
||||
const i18n = useI18n();
|
||||
const mcpStore = useMCPStore();
|
||||
const rbacStore = useRBACStore();
|
||||
const usersStore = useUsersStore();
|
||||
|
||||
type Props = {
|
||||
clients: OAuthClientResponseDto[];
|
||||
|
|
@ -21,32 +44,98 @@ type Props = {
|
|||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const page = ref(0);
|
||||
const itemsPerPage = ref(10);
|
||||
const emit = defineEmits<{
|
||||
revokeClient: [client: OAuthClientResponseDto];
|
||||
'update:ownership': [ownership: 'mine' | 'all'];
|
||||
'update:filters': [filters: OAuthClientFilters];
|
||||
'update:options': [options: { page: number; itemsPerPage: number }];
|
||||
}>();
|
||||
|
||||
// The store is the source of truth for pagination; setters route through the
|
||||
// parent so it can wrap the refetch in its loading state.
|
||||
const page = computed({
|
||||
get: () => mcpStore.oauthClientsPage,
|
||||
set: (value: number) => emit('update:options', { page: value, itemsPerPage: itemsPerPage.value }),
|
||||
});
|
||||
const itemsPerPage = computed({
|
||||
get: () => mcpStore.oauthClientsPageSize,
|
||||
set: (value: number) => emit('update:options', { page: page.value, itemsPerPage: value }),
|
||||
});
|
||||
|
||||
const detailsClient = ref<OAuthClientResponseDto | null>(null);
|
||||
const detailsOpen = ref(false);
|
||||
|
||||
const visibleClients = computed(() => {
|
||||
const start = page.value * itemsPerPage.value;
|
||||
return props.clients.slice(start, start + itemsPerPage.value);
|
||||
});
|
||||
const canManageAllClients = computed(() => rbacStore.hasScope('mcp:manage'));
|
||||
const ownership = computed(() => mcpStore.oauthClientsOwnership);
|
||||
|
||||
watch(
|
||||
() => props.clients.length,
|
||||
(length) => {
|
||||
const maxPage = Math.max(0, Math.ceil(length / itemsPerPage.value) - 1);
|
||||
if (page.value > maxPage) {
|
||||
page.value = maxPage;
|
||||
}
|
||||
// Badges show the unfiltered totals so a search-narrowed "Mine (0)" doesn't read
|
||||
// as "no connected clients" when there are clients that just don't match.
|
||||
const tabOptions = computed<Array<TabOptions<'mine' | 'all'>>>(() => [
|
||||
{
|
||||
label: i18n.baseText('settings.mcp.oAuthClients.tabs.mine'),
|
||||
value: 'mine' as const,
|
||||
tag: String(mcpStore.oauthClientTotals.mine),
|
||||
},
|
||||
{
|
||||
label: i18n.baseText('settings.mcp.oAuthClients.tabs.all'),
|
||||
value: 'all' as const,
|
||||
tag: String(mcpStore.oauthClientTotals.all ?? 0),
|
||||
},
|
||||
]);
|
||||
|
||||
// Local UI state of the search + popover; every change is emitted so the
|
||||
// parent can push it to the store and refetch server-side.
|
||||
const filters = ref<OAuthClientFilters>({ ...EMPTY_OAUTH_CLIENT_FILTERS });
|
||||
const searchQuery = ref('');
|
||||
|
||||
const hasActiveFilters = computed(
|
||||
() =>
|
||||
filters.value.search.trim() !== '' ||
|
||||
filters.value.type !== null ||
|
||||
filters.value.ownerId !== null ||
|
||||
filters.value.connected !== null,
|
||||
);
|
||||
|
||||
const emit = defineEmits<{
|
||||
revokeClient: [client: OAuthClientResponseDto];
|
||||
}>();
|
||||
function onFiltersChange(newFilters: OAuthClientFilters) {
|
||||
filters.value = newFilters;
|
||||
emit('update:filters', newFilters);
|
||||
}
|
||||
|
||||
const tableHeaders = ref<Array<TableHeader<OAuthClientResponseDto>>>([
|
||||
const applySearch = debounce((value: string) => {
|
||||
onFiltersChange({ ...filters.value, search: value });
|
||||
}, getDebounceTime(DEBOUNCE_TIME.INPUT.SEARCH));
|
||||
|
||||
function onSearchInput(value: string) {
|
||||
searchQuery.value = value;
|
||||
void applySearch(value);
|
||||
}
|
||||
|
||||
const ownerOptions = computed<IUser[]>(() =>
|
||||
mcpStore.oauthClientOwners.map((owner) => ({
|
||||
id: owner.id,
|
||||
firstName: owner.firstName,
|
||||
lastName: owner.lastName,
|
||||
email: owner.email,
|
||||
fullName: [owner.firstName, owner.lastName].filter(Boolean).join(' ') || undefined,
|
||||
})),
|
||||
);
|
||||
|
||||
function onOwnershipChange(newOwnership: 'mine' | 'all') {
|
||||
if (newOwnership === ownership.value) return;
|
||||
// Drop any queued search so a stale term can't re-filter after the switch.
|
||||
applySearch.cancel();
|
||||
filters.value = { ...EMPTY_OAUTH_CLIENT_FILTERS };
|
||||
searchQuery.value = '';
|
||||
emit('update:ownership', newOwnership);
|
||||
}
|
||||
|
||||
// A consent row is (client × owner): the same client id can appear once per
|
||||
// owner in the All view, so row identity must include the owner.
|
||||
function rowId(row: OAuthClientResponseDto) {
|
||||
return `${row.id}:${row.owner?.id ?? 'mine'}`;
|
||||
}
|
||||
|
||||
const tableHeaders = computed<Array<TableHeader<OAuthClientResponseDto>>>(() => [
|
||||
{
|
||||
title: i18n.baseText('settings.mcp.oAuthClients.table.clientName'),
|
||||
key: 'name',
|
||||
|
|
@ -56,6 +145,19 @@ const tableHeaders = ref<Array<TableHeader<OAuthClientResponseDto>>>([
|
|||
return;
|
||||
},
|
||||
},
|
||||
...(ownership.value === 'all'
|
||||
? [
|
||||
{
|
||||
title: i18n.baseText('settings.mcp.oAuthClients.table.connectedBy'),
|
||||
key: 'owner',
|
||||
width: 200,
|
||||
disableSort: true,
|
||||
value() {
|
||||
return;
|
||||
},
|
||||
} satisfies TableHeader<OAuthClientResponseDto>,
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: i18n.baseText('settings.mcp.oAuthClients.table.access'),
|
||||
key: 'scopes',
|
||||
|
|
@ -124,17 +226,50 @@ function onRevoke(item: OAuthClientResponseDto) {
|
|||
<N8nLoading :loading="props.loading" variant="p" :rows="5" :shrink-last="false" />
|
||||
</div>
|
||||
<div v-else class="mt-s mb-xl">
|
||||
<div :class="$style.toolbar">
|
||||
<N8nTabs
|
||||
v-if="canManageAllClients"
|
||||
:model-value="ownership"
|
||||
:options="tabOptions"
|
||||
data-test-id="mcp-clients-tabs"
|
||||
@update:model-value="onOwnershipChange"
|
||||
/>
|
||||
<div v-else />
|
||||
<div :class="$style.filters">
|
||||
<N8nInput
|
||||
:model-value="searchQuery"
|
||||
:placeholder="i18n.baseText('settings.mcp.oAuthClients.search.placeholder')"
|
||||
:class="$style.search"
|
||||
size="medium"
|
||||
clearable
|
||||
data-test-id="mcp-clients-search"
|
||||
@update:model-value="onSearchInput"
|
||||
>
|
||||
<template #prefix>
|
||||
<N8nIcon icon="search" />
|
||||
</template>
|
||||
</N8nInput>
|
||||
<OAuthClientsFilters
|
||||
:model-value="filters"
|
||||
:owners="ownerOptions"
|
||||
:show-owner-filter="ownership === 'all'"
|
||||
:current-user-id="usersStore.currentUser?.id"
|
||||
@update:model-value="onFiltersChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<N8nDataTableServer
|
||||
v-model:page="page"
|
||||
v-model:items-per-page="itemsPerPage"
|
||||
data-test-id="oauth-clients-data-table"
|
||||
:headers="tableHeaders"
|
||||
:items="visibleClients"
|
||||
:items-length="props.clients.length"
|
||||
:items="props.clients"
|
||||
:items-length="mcpStore.oauthClientsCount"
|
||||
:item-value="rowId"
|
||||
@click:row="(_, { item }) => openDetails(item)"
|
||||
>
|
||||
<template v-if="props.clients.length === 0" #cover>
|
||||
<div :class="$style['empty-state']">
|
||||
<template v-if="mcpStore.oauthClientsCount === 0" #cover>
|
||||
<div v-if="!hasActiveFilters" :class="$style['empty-state']">
|
||||
<N8nText data-test-id="mcp-workflow-table-empty-state" size="large" color="text-base">
|
||||
{{ i18n.baseText('settings.mcp.oauth.table.empty.title') }}
|
||||
</N8nText>
|
||||
|
|
@ -153,6 +288,11 @@ function onRevoke(item: OAuthClientResponseDto) {
|
|||
{{ i18n.baseText('settings.mcp.oauth.table.empty.button') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
<div v-else :class="$style['empty-state']">
|
||||
<N8nText data-test-id="mcp-clients-no-results" size="small" color="text-base">
|
||||
{{ i18n.baseText('settings.mcp.oAuthClients.search.noResults') }}
|
||||
</N8nText>
|
||||
</div>
|
||||
</template>
|
||||
<template #[`item.name`]="{ item }">
|
||||
<div :class="$style.client">
|
||||
|
|
@ -179,6 +319,13 @@ function onRevoke(item: OAuthClientResponseDto) {
|
|||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<template #[`item.owner`]="{ item }">
|
||||
<OAuthClientOwnerCell
|
||||
v-if="item.owner"
|
||||
:owner="item.owner"
|
||||
:is-current-user="item.owner.id === usersStore.currentUser?.id"
|
||||
/>
|
||||
</template>
|
||||
<template #[`item.scopes`]="{ item }">
|
||||
<N8nText data-test-id="mcp-client-access" color="text-light" :class="$style.access">
|
||||
{{ accessSummary(item) }}
|
||||
|
|
@ -218,6 +365,24 @@ function onRevoke(item: OAuthClientResponseDto) {
|
|||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: var(--spacing--sm);
|
||||
margin-bottom: var(--spacing--sm);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.search {
|
||||
max-width: 240px;
|
||||
}
|
||||
|
||||
.client {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import type {
|
|||
InstanceMcpClientStatsResponseDto,
|
||||
ListOAuthClientsResponseDto,
|
||||
DeleteOAuthClientResponseDto,
|
||||
McpClientConnectedPeriod,
|
||||
McpClientTypeFilter,
|
||||
} from '@n8n/api-types';
|
||||
import type { WorkflowListItem } from '@/Interface';
|
||||
import type { IRestApiContext } from '@n8n/rest-api-client';
|
||||
|
|
@ -77,10 +79,29 @@ export async function toggleWorkflowsMcpAccessApi(
|
|||
});
|
||||
}
|
||||
|
||||
export type FetchOAuthClientsOptions = {
|
||||
ownership?: 'mine' | 'all';
|
||||
skip?: number;
|
||||
take?: number;
|
||||
name?: string;
|
||||
ownerId?: string;
|
||||
type?: McpClientTypeFilter;
|
||||
connected?: McpClientConnectedPeriod;
|
||||
};
|
||||
|
||||
export async function fetchOAuthClients(
|
||||
context: IRestApiContext,
|
||||
options: FetchOAuthClientsOptions = {},
|
||||
): Promise<ListOAuthClientsResponseDto> {
|
||||
return await makeRestApiRequest(context, 'GET', '/mcp/oauth-clients');
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(options).filter(([, value]) => value !== undefined),
|
||||
);
|
||||
return await makeRestApiRequest(
|
||||
context,
|
||||
'GET',
|
||||
'/mcp/oauth-clients',
|
||||
Object.keys(params).length > 0 ? params : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchInstanceMcpClientStats(
|
||||
|
|
@ -92,11 +113,13 @@ export async function fetchInstanceMcpClientStats(
|
|||
export async function deleteOAuthClient(
|
||||
context: IRestApiContext,
|
||||
clientId: string,
|
||||
userId?: string,
|
||||
): Promise<DeleteOAuthClientResponseDto> {
|
||||
return await makeRestApiRequest(
|
||||
context,
|
||||
'DELETE',
|
||||
`/mcp/oauth-clients/${encodeURIComponent(clientId)}`,
|
||||
userId ? { userId } : undefined,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { setActivePinia, createPinia } from 'pinia';
|
|||
import * as mcpApi from './mcp.api';
|
||||
import { useMCPStore } from './mcp.store';
|
||||
import { useWorkflowsListStore } from '@/app/stores/workflowsList.store';
|
||||
import { createWorkflow } from './mcp.test.utils';
|
||||
import { createOAuthClient, createWorkflow } from './mcp.test.utils';
|
||||
|
||||
const { mockWorkflowDocumentStore } = vi.hoisted(() => ({
|
||||
mockWorkflowDocumentStore: {
|
||||
|
|
@ -174,6 +174,182 @@ describe('mcp.store', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('OAuth clients ownership', () => {
|
||||
it('fetches the current page with ownership, pagination and filters', async () => {
|
||||
const client = createOAuthClient();
|
||||
const fetchSpy = vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
||||
data: [client],
|
||||
count: 1,
|
||||
totals: { mine: 1, all: 3 },
|
||||
});
|
||||
|
||||
await store.getAllOAuthClients();
|
||||
|
||||
expect(fetchSpy).toHaveBeenCalledWith({}, { ownership: 'mine', skip: 0, take: 10 });
|
||||
expect(store.oauthClients).toEqual([client]);
|
||||
expect(store.oauthClientTotals).toEqual({ mine: 1, all: 3 });
|
||||
expect(store.oauthClientsCount).toBe(1);
|
||||
});
|
||||
|
||||
it('switches ownership, resetting page and filters, and refetches', async () => {
|
||||
const fetchSpy = vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
||||
data: [],
|
||||
count: 0,
|
||||
totals: { mine: 0, all: 0 },
|
||||
});
|
||||
|
||||
store.oauthClientsFilters = {
|
||||
search: 'claude',
|
||||
type: 'cli',
|
||||
ownerId: 'user-1',
|
||||
connected: 'last7',
|
||||
};
|
||||
|
||||
await store.setOAuthClientsOwnership('all');
|
||||
|
||||
expect(store.oauthClientsOwnership).toBe('all');
|
||||
expect(store.oauthClientsFilters).toEqual({
|
||||
search: '',
|
||||
type: null,
|
||||
ownerId: null,
|
||||
connected: null,
|
||||
});
|
||||
expect(fetchSpy).toHaveBeenCalledWith({}, { ownership: 'all', skip: 0, take: 10 });
|
||||
});
|
||||
|
||||
it('sends the active filters as query params and resets the page', async () => {
|
||||
const fetchSpy = vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
||||
data: [],
|
||||
count: 0,
|
||||
totals: { mine: 0 },
|
||||
});
|
||||
|
||||
store.oauthClientsPage = 2;
|
||||
await store.setOAuthClientsFilters({
|
||||
search: ' claude ',
|
||||
type: 'cli',
|
||||
ownerId: 'user-1',
|
||||
connected: 'last30',
|
||||
});
|
||||
|
||||
expect(store.oauthClientsPage).toBe(0);
|
||||
expect(fetchSpy).toHaveBeenCalledWith(
|
||||
{},
|
||||
{
|
||||
ownership: 'mine',
|
||||
skip: 0,
|
||||
take: 10,
|
||||
name: 'claude',
|
||||
ownerId: 'user-1',
|
||||
type: 'cli',
|
||||
connected: 'last30',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it('paginates server-side and restarts from the first page on a page-size change', async () => {
|
||||
const fetchSpy = vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
||||
data: [createOAuthClient()],
|
||||
count: 40,
|
||||
totals: { mine: 40 },
|
||||
});
|
||||
|
||||
await store.setOAuthClientsPagination(2, 10);
|
||||
expect(fetchSpy).toHaveBeenLastCalledWith({}, { ownership: 'mine', skip: 20, take: 10 });
|
||||
|
||||
await store.setOAuthClientsPagination(2, 25);
|
||||
expect(store.oauthClientsPage).toBe(0);
|
||||
expect(fetchSpy).toHaveBeenLastCalledWith({}, { ownership: 'mine', skip: 0, take: 25 });
|
||||
});
|
||||
|
||||
it('clamps to the last page when the requested one shrank away', async () => {
|
||||
const fetchSpy = vi
|
||||
.spyOn(mcpApi, 'fetchOAuthClients')
|
||||
.mockResolvedValueOnce({ data: [], count: 11, totals: { mine: 11 } })
|
||||
.mockResolvedValueOnce({
|
||||
data: [createOAuthClient()],
|
||||
count: 11,
|
||||
totals: { mine: 11 },
|
||||
});
|
||||
|
||||
await store.setOAuthClientsPagination(5, 10);
|
||||
|
||||
expect(store.oauthClientsPage).toBe(1);
|
||||
expect(fetchSpy).toHaveBeenLastCalledWith({}, { ownership: 'mine', skip: 10, take: 10 });
|
||||
});
|
||||
|
||||
it('stores the distinct owners returned by the server', async () => {
|
||||
const jane = { id: 'user-1', firstName: 'Jane', lastName: 'Doe', email: 'jane@n8n.io' };
|
||||
const adam = { id: 'user-2', firstName: 'Adam', lastName: 'Ant', email: 'adam@n8n.io' };
|
||||
vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
||||
data: [createOAuthClient({ id: 'a', owner: jane })],
|
||||
count: 1,
|
||||
totals: { mine: 1, all: 3 },
|
||||
owners: [adam, jane],
|
||||
});
|
||||
|
||||
await store.getAllOAuthClients();
|
||||
|
||||
expect(store.oauthClientOwners).toEqual([adam, jane]);
|
||||
});
|
||||
|
||||
it('passes the target userId on revoke and refetches the list', async () => {
|
||||
const deleteSpy = vi.spyOn(mcpApi, 'deleteOAuthClient').mockResolvedValue({
|
||||
success: true,
|
||||
message: 'ok',
|
||||
});
|
||||
const fetchSpy = vi.spyOn(mcpApi, 'fetchOAuthClients').mockResolvedValue({
|
||||
data: [],
|
||||
count: 0,
|
||||
totals: { mine: 0, all: 0 },
|
||||
});
|
||||
|
||||
await store.removeOAuthClient('client-1', 'user-2');
|
||||
|
||||
expect(deleteSpy).toHaveBeenCalledWith({}, 'client-1', 'user-2');
|
||||
expect(fetchSpy).toHaveBeenCalled();
|
||||
expect(store.oauthClientTotals).toEqual({ mine: 0, all: 0 });
|
||||
});
|
||||
|
||||
it('keeps a successful revoke successful when the totals refetch fails', async () => {
|
||||
vi.spyOn(mcpApi, 'deleteOAuthClient').mockResolvedValue({ success: true, message: 'ok' });
|
||||
vi.spyOn(mcpApi, 'fetchOAuthClients').mockRejectedValue(new Error('refetch failed'));
|
||||
|
||||
await expect(store.removeOAuthClient('client-1')).resolves.toEqual({
|
||||
success: true,
|
||||
message: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
it('ignores a stale in-flight list response superseded by a newer request', async () => {
|
||||
let resolveStale!: (value: Awaited<ReturnType<typeof mcpApi.fetchOAuthClients>>) => void;
|
||||
const stale = new Promise<Awaited<ReturnType<typeof mcpApi.fetchOAuthClients>>>((resolve) => {
|
||||
resolveStale = resolve;
|
||||
});
|
||||
vi.spyOn(mcpApi, 'fetchOAuthClients')
|
||||
.mockReturnValueOnce(stale)
|
||||
.mockResolvedValueOnce({
|
||||
data: [createOAuthClient({ id: 'new' })],
|
||||
count: 1,
|
||||
totals: { mine: 1 },
|
||||
});
|
||||
|
||||
const staleCall = store.getAllOAuthClients(); // in flight
|
||||
await store.getAllOAuthClients(); // newer request commits 'new'
|
||||
|
||||
resolveStale({
|
||||
data: [createOAuthClient({ id: 'old' })],
|
||||
count: 1,
|
||||
totals: { mine: 99 },
|
||||
});
|
||||
await staleCall;
|
||||
|
||||
// the superseded response must not overwrite the newer selection
|
||||
expect(store.oauthClients.map((client) => client.id)).toEqual(['new']);
|
||||
expect(store.oauthClientTotals).toEqual({ mine: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('toggleWorkflowsMcpAccess (bulk)', () => {
|
||||
it('applies the new value only to workflows the backend confirmed', async () => {
|
||||
workflowsListStore.workflowsById = {
|
||||
|
|
|
|||
|
|
@ -23,6 +23,10 @@ import {
|
|||
} from '@/features/ai/mcpAccess/mcp.api';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useSettingsStore } from '@/app/stores/settings.store';
|
||||
import {
|
||||
EMPTY_OAUTH_CLIENT_FILTERS,
|
||||
type OAuthClientFilters,
|
||||
} from '@/features/ai/mcpAccess/clients.utils';
|
||||
import { isWorkflowListItem } from '@/app/utils/typeGuards';
|
||||
import type {
|
||||
ApiKey,
|
||||
|
|
@ -40,6 +44,17 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
|
|||
const currentUserMCPKey = ref<ApiKey | null>(null);
|
||||
const oauthClients = ref<OAuthClientResponseDto[]>([]);
|
||||
const oauthClientScopeTools = ref<Record<string, string[]> | undefined>(undefined);
|
||||
const oauthClientsOwnership = ref<'mine' | 'all'>('mine');
|
||||
const oauthClientTotals = ref<{ mine: number; all?: number }>({ mine: 0 });
|
||||
const oauthClientsPage = ref(0);
|
||||
const oauthClientsPageSize = ref(10);
|
||||
const oauthClientsFilters = ref<OAuthClientFilters>({ ...EMPTY_OAUTH_CLIENT_FILTERS });
|
||||
/** Total rows matching the filters (across all pages) for the current ownership. */
|
||||
const oauthClientsCount = ref(0);
|
||||
/** Distinct consent owners for the "Connected by" filter (managers only). */
|
||||
const oauthClientOwners = ref<Array<NonNullable<OAuthClientResponseDto['owner']>>>([]);
|
||||
/** Monotonic token so a slow in-flight list fetch can't overwrite a newer one. */
|
||||
let oauthClientsRequestSeq = 0;
|
||||
const allowedRedirectUris = ref<string[]>([]);
|
||||
const instanceClientStats = ref<InstanceMcpClientStatsResponseDto | null>(null);
|
||||
const connectPopoverOpen = ref(false);
|
||||
|
|
@ -169,12 +184,60 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
|
|||
}
|
||||
|
||||
async function getAllOAuthClients(): Promise<OAuthClientResponseDto[]> {
|
||||
const response = await fetchOAuthClients(rootStore.restApiContext);
|
||||
const seq = ++oauthClientsRequestSeq;
|
||||
const filters = oauthClientsFilters.value;
|
||||
const response = await fetchOAuthClients(rootStore.restApiContext, {
|
||||
ownership: oauthClientsOwnership.value,
|
||||
skip: oauthClientsPage.value * oauthClientsPageSize.value,
|
||||
take: oauthClientsPageSize.value,
|
||||
name: filters.search.trim() || undefined,
|
||||
ownerId: filters.ownerId ?? undefined,
|
||||
type: filters.type ?? undefined,
|
||||
connected: filters.connected ?? undefined,
|
||||
});
|
||||
|
||||
// A newer request (tab switch, search, pagination) superseded this one
|
||||
// while it was in flight; drop the stale response so it can't overwrite
|
||||
// the current selection.
|
||||
if (seq !== oauthClientsRequestSeq) return response.data;
|
||||
|
||||
// Clamp to the last page when the requested one shrank away (e.g. after a revoke)
|
||||
if (response.data.length === 0 && response.count > 0 && oauthClientsPage.value > 0) {
|
||||
oauthClientsPage.value = Math.max(
|
||||
0,
|
||||
Math.ceil(response.count / oauthClientsPageSize.value) - 1,
|
||||
);
|
||||
return await getAllOAuthClients();
|
||||
}
|
||||
|
||||
oauthClients.value = response.data;
|
||||
oauthClientScopeTools.value = response.scopeTools;
|
||||
oauthClientTotals.value = response.totals;
|
||||
oauthClientsCount.value = response.count;
|
||||
oauthClientOwners.value = response.owners ?? [];
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function setOAuthClientsOwnership(ownership: 'mine' | 'all'): Promise<void> {
|
||||
oauthClientsOwnership.value = ownership;
|
||||
oauthClientsPage.value = 0;
|
||||
oauthClientsFilters.value = { ...EMPTY_OAUTH_CLIENT_FILTERS };
|
||||
await getAllOAuthClients();
|
||||
}
|
||||
|
||||
async function setOAuthClientsFilters(filters: OAuthClientFilters): Promise<void> {
|
||||
oauthClientsFilters.value = filters;
|
||||
oauthClientsPage.value = 0;
|
||||
await getAllOAuthClients();
|
||||
}
|
||||
|
||||
async function setOAuthClientsPagination(page: number, pageSize: number): Promise<void> {
|
||||
// A page-size change restarts from the first page
|
||||
oauthClientsPage.value = pageSize === oauthClientsPageSize.value ? page : 0;
|
||||
oauthClientsPageSize.value = pageSize;
|
||||
await getAllOAuthClients();
|
||||
}
|
||||
|
||||
async function getInstanceClientStats(): Promise<InstanceMcpClientStatsResponseDto | null> {
|
||||
try {
|
||||
const stats = await fetchInstanceMcpClientStats(rootStore.restApiContext);
|
||||
|
|
@ -188,10 +251,19 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
|
|||
}
|
||||
}
|
||||
|
||||
async function removeOAuthClient(clientId: string): Promise<DeleteOAuthClientResponseDto> {
|
||||
const response = await deleteOAuthClient(rootStore.restApiContext, clientId);
|
||||
// Remove the client from the local store
|
||||
oauthClients.value = oauthClients.value.filter((client) => client.id !== clientId);
|
||||
async function removeOAuthClient(
|
||||
clientId: string,
|
||||
userId?: string,
|
||||
): Promise<DeleteOAuthClientResponseDto> {
|
||||
const response = await deleteOAuthClient(rootStore.restApiContext, clientId, userId);
|
||||
// Refetch instead of splicing locally so the tab totals stay accurate. The
|
||||
// revoke already succeeded, so keep the refresh best-effort: a failed
|
||||
// refetch must not turn a successful revoke into a reported error.
|
||||
try {
|
||||
await getAllOAuthClients();
|
||||
} catch {
|
||||
// Stale list/totals are acceptable; the next interaction refetches.
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
||||
|
|
@ -235,6 +307,16 @@ export const useMCPStore = defineStore(MCP_STORE, () => {
|
|||
generateNewApiKey,
|
||||
resetCurrentUserMCPKey,
|
||||
oauthClients,
|
||||
oauthClientsOwnership,
|
||||
oauthClientTotals,
|
||||
oauthClientOwners,
|
||||
oauthClientsPage,
|
||||
oauthClientsPageSize,
|
||||
oauthClientsFilters,
|
||||
oauthClientsCount,
|
||||
setOAuthClientsOwnership,
|
||||
setOAuthClientsFilters,
|
||||
setOAuthClientsPagination,
|
||||
instanceClientStats,
|
||||
getAllOAuthClients,
|
||||
oauthClientScopeTools,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user