@n8n/frontend-module-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +88 -0
- package/README.md +51 -0
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +4 -0
- package/package.json +72 -0
- package/src/SettingsMCPAgentsView.test.ts +256 -0
- package/src/SettingsMCPAgentsView.vue +223 -0
- package/src/SettingsMCPClientsView.test.ts +280 -0
- package/src/SettingsMCPClientsView.vue +168 -0
- package/src/SettingsMCPView.test.ts +832 -0
- package/src/SettingsMCPView.vue +496 -0
- package/src/SettingsMCPWorkflowsView.test.ts +274 -0
- package/src/SettingsMCPWorkflowsView.vue +245 -0
- package/src/__tests__/design-system-icons.test.ts +24 -0
- package/src/__tests__/setup.ts +4 -0
- package/src/assets/client-icons/claude.svg +1 -0
- package/src/assets/client-icons/codex.svg +1 -0
- package/src/assets/client-icons/cursor.svg +1 -0
- package/src/assets/client-icons/gemini.svg +1 -0
- package/src/assets/client-icons/mistral.svg +1 -0
- package/src/assets/client-icons/openai.svg +3 -0
- package/src/assets/client-icons/vscode.svg +1 -0
- package/src/clients.utils.test.ts +85 -0
- package/src/clients.utils.ts +124 -0
- package/src/components/ConnectionParameter.vue +213 -0
- package/src/components/MCPAgentsSelect.vue +176 -0
- package/src/components/MCPEmptyState.vue +75 -0
- package/src/components/MCPWorkflowsSelect.test.ts +384 -0
- package/src/components/MCPWorkflowsSelect.vue +159 -0
- package/src/components/McpAccessToggle.vue +77 -0
- package/src/components/McpAccessTokenTab.test.ts +220 -0
- package/src/components/McpAccessTokenTab.vue +201 -0
- package/src/components/McpAllowedCallbackUrlsDialog.test.ts +122 -0
- package/src/components/McpAllowedCallbackUrlsDialog.vue +359 -0
- package/src/components/McpClientLogoCards.vue +134 -0
- package/src/components/McpConfigSnippet.vue +92 -0
- package/src/components/McpConnectClientDialog.test.ts +115 -0
- package/src/components/McpConnectClientDialog.vue +432 -0
- package/src/components/McpConnectedClientRow.test.ts +86 -0
- package/src/components/McpConnectedClientRow.vue +152 -0
- package/src/components/McpEmptyStateCard.vue +83 -0
- package/src/components/McpStatusControl.test.ts +33 -0
- package/src/components/McpStatusControl.vue +96 -0
- package/src/components/OAuthClientDetailsModal.vue +182 -0
- package/src/components/RevokeOAuthClientConfirmModal.vue +60 -0
- package/src/components/WorkflowLocation.test.ts +329 -0
- package/src/components/WorkflowLocation.vue +198 -0
- package/src/components/tabs/AgentsTable.vue +312 -0
- package/src/components/tabs/OAuthClientOwnerCell.vue +71 -0
- package/src/components/tabs/OAuthClientsFilters.test.ts +86 -0
- package/src/components/tabs/OAuthClientsFilters.vue +201 -0
- package/src/components/tabs/OAuthClientsTable.test.ts +394 -0
- package/src/components/tabs/OAuthClientsTable.vue +473 -0
- package/src/components/tabs/WorkflowsTable.test.ts +661 -0
- package/src/components/tabs/WorkflowsTable.vue +398 -0
- package/src/composables/useMcp.test.ts +59 -0
- package/src/composables/useMcp.ts +61 -0
- package/src/composables/useOAuthClientRevoke.ts +90 -0
- package/src/index.ts +10 -0
- package/src/mcp.api.test.ts +81 -0
- package/src/mcp.api.ts +206 -0
- package/src/mcp.clients.catalog.test.ts +63 -0
- package/src/mcp.clients.catalog.ts +183 -0
- package/src/mcp.constants.ts +45 -0
- package/src/mcp.module.test.ts +106 -0
- package/src/mcp.module.ts +90 -0
- package/src/mcp.store.test.ts +628 -0
- package/src/mcp.store.ts +490 -0
- package/src/mcp.test.utils.ts +57 -0
- package/src/mcp.types.ts +20 -0
- package/src/modals/MCPConnectAgentsModal.test.ts +141 -0
- package/src/modals/MCPConnectAgentsModal.vue +122 -0
- package/src/modals/MCPConnectWorkflowsModal.test.ts +303 -0
- package/src/modals/MCPConnectWorkflowsModal.vue +101 -0
- package/src/redirect-uris.utils.ts +22 -0
- package/stylelint.config.mjs +9 -0
- package/vite.config.ts +52 -0
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { useI18n } from '@n8n/i18n';
|
|
3
|
+
import type { BaseTextKey } from '@n8n/i18n';
|
|
4
|
+
import type { OAuthClientResponseDto } from '@n8n/api-types';
|
|
5
|
+
import {
|
|
6
|
+
N8nButton,
|
|
7
|
+
N8nDataTableServer,
|
|
8
|
+
N8nIcon,
|
|
9
|
+
N8nInput,
|
|
10
|
+
N8nLoading,
|
|
11
|
+
N8nTabs,
|
|
12
|
+
N8nText,
|
|
13
|
+
N8nTimeAgo,
|
|
14
|
+
} from '@n8n/design-system';
|
|
15
|
+
import type { IUser, TabOptions, TableHeader } from '@n8n/design-system';
|
|
16
|
+
import { computed, ref } from 'vue';
|
|
17
|
+
import debounce from 'lodash/debounce';
|
|
18
|
+
import { useMCPStore } from '../../mcp.store';
|
|
19
|
+
import { useRBACStore } from '@n8n/stores/rbac.store';
|
|
20
|
+
import { useUsersStore } from '@n8n/stores/users.store';
|
|
21
|
+
import { useRootStore } from '@n8n/stores/useRootStore';
|
|
22
|
+
import { getDebounceTime } from '@n8n/composables/useDebounce';
|
|
23
|
+
import { DEBOUNCE_TIME } from '@n8n/frontend-constants/durations';
|
|
24
|
+
import { EMPTY_OAUTH_CLIENT_FILTERS, getAccessSummary, getClientBrand } from '../../clients.utils';
|
|
25
|
+
import type { OAuthClientFilters } from '../../clients.utils';
|
|
26
|
+
import McpEmptyStateCard from '../McpEmptyStateCard.vue';
|
|
27
|
+
import OAuthClientDetailsModal from '../OAuthClientDetailsModal.vue';
|
|
28
|
+
import OAuthClientOwnerCell from './OAuthClientOwnerCell.vue';
|
|
29
|
+
import OAuthClientsFilters from './OAuthClientsFilters.vue';
|
|
30
|
+
|
|
31
|
+
const i18n = useI18n();
|
|
32
|
+
const mcpStore = useMCPStore();
|
|
33
|
+
const rbacStore = useRBACStore();
|
|
34
|
+
const usersStore = useUsersStore();
|
|
35
|
+
const rootStore = useRootStore();
|
|
36
|
+
|
|
37
|
+
type Props = {
|
|
38
|
+
clients: OAuthClientResponseDto[];
|
|
39
|
+
loading: boolean;
|
|
40
|
+
scopeTools?: Record<string, string[]>;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const props = defineProps<Props>();
|
|
44
|
+
|
|
45
|
+
const emit = defineEmits<{
|
|
46
|
+
revokeClient: [client: OAuthClientResponseDto];
|
|
47
|
+
'update:ownership': [ownership: 'mine' | 'all'];
|
|
48
|
+
'update:filters': [filters: OAuthClientFilters];
|
|
49
|
+
'update:options': [options: { page: number; itemsPerPage: number }];
|
|
50
|
+
}>();
|
|
51
|
+
|
|
52
|
+
// The store is the source of truth for pagination; setters route through the
|
|
53
|
+
// parent so it can wrap the refetch in its loading state.
|
|
54
|
+
const page = computed({
|
|
55
|
+
get: () => mcpStore.oauthClientsPage,
|
|
56
|
+
set: (value: number) => emit('update:options', { page: value, itemsPerPage: itemsPerPage.value }),
|
|
57
|
+
});
|
|
58
|
+
const itemsPerPage = computed({
|
|
59
|
+
get: () => mcpStore.oauthClientsPageSize,
|
|
60
|
+
set: (value: number) => emit('update:options', { page: page.value, itemsPerPage: value }),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
const detailsClient = ref<OAuthClientResponseDto | null>(null);
|
|
64
|
+
const detailsOpen = ref(false);
|
|
65
|
+
|
|
66
|
+
const canManageAllClients = computed(() => rbacStore.hasScope('mcp:manage'));
|
|
67
|
+
const ownership = computed(() => mcpStore.oauthClientsOwnership);
|
|
68
|
+
const offeredScopes = computed(() =>
|
|
69
|
+
props.scopeTools ? Object.keys(props.scopeTools) : undefined,
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// Badges show the unfiltered totals so a search-narrowed "Mine (0)" doesn't read
|
|
73
|
+
// as "no connected clients" when there are clients that just don't match.
|
|
74
|
+
const tabOptions = computed<Array<TabOptions<'mine' | 'all'>>>(() => [
|
|
75
|
+
{
|
|
76
|
+
label: i18n.baseText('settings.mcp.oAuthClients.tabs.mine'),
|
|
77
|
+
value: 'mine' as const,
|
|
78
|
+
tag: String(mcpStore.oauthClientTotals.mine),
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
label: i18n.baseText('settings.mcp.oAuthClients.tabs.all'),
|
|
82
|
+
value: 'all' as const,
|
|
83
|
+
tag: String(mcpStore.oauthClientTotals.all ?? 0),
|
|
84
|
+
},
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
// Local UI state of the search + popover; every change is emitted so the
|
|
88
|
+
// parent can push it to the store and refetch server-side.
|
|
89
|
+
const filters = ref<OAuthClientFilters>({ ...EMPTY_OAUTH_CLIENT_FILTERS });
|
|
90
|
+
const searchQuery = ref('');
|
|
91
|
+
|
|
92
|
+
const hasActiveFilters = computed(
|
|
93
|
+
() =>
|
|
94
|
+
filters.value.search.trim() !== '' ||
|
|
95
|
+
filters.value.type !== null ||
|
|
96
|
+
filters.value.ownerId !== null ||
|
|
97
|
+
filters.value.connected !== null,
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
// Aggregate total across the tabs a manager can see (own count for members).
|
|
101
|
+
const totalClients = computed(
|
|
102
|
+
() => mcpStore.oauthClientTotals.all ?? mcpStore.oauthClientTotals.mine,
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
// Show only the empty state (hiding tabs, toolbar and table) when there are no
|
|
106
|
+
// clients anywhere — so a manager with 0 of their own but clients under "All"
|
|
107
|
+
// still gets the tabs. A search that returns nothing keeps the toolbar
|
|
108
|
+
// (hasActiveFilters) so the user can clear it.
|
|
109
|
+
const showEmptyState = computed(
|
|
110
|
+
() => props.clients.length === 0 && totalClients.value === 0 && !hasActiveFilters.value,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
function onFiltersChange(newFilters: OAuthClientFilters) {
|
|
114
|
+
filters.value = newFilters;
|
|
115
|
+
emit('update:filters', newFilters);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const applySearch = debounce((value: string) => {
|
|
119
|
+
onFiltersChange({ ...filters.value, search: value });
|
|
120
|
+
}, getDebounceTime(DEBOUNCE_TIME.INPUT.SEARCH));
|
|
121
|
+
|
|
122
|
+
function onSearchInput(value: string) {
|
|
123
|
+
searchQuery.value = value;
|
|
124
|
+
void applySearch(value);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const ownerOptions = computed<IUser[]>(() =>
|
|
128
|
+
mcpStore.oauthClientOwners.map((owner) => ({
|
|
129
|
+
id: owner.id,
|
|
130
|
+
firstName: owner.firstName,
|
|
131
|
+
lastName: owner.lastName,
|
|
132
|
+
email: owner.email,
|
|
133
|
+
fullName: [owner.firstName, owner.lastName].filter(Boolean).join(' ') || undefined,
|
|
134
|
+
})),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
function onOwnershipChange(newOwnership: 'mine' | 'all') {
|
|
138
|
+
if (newOwnership === ownership.value) return;
|
|
139
|
+
// Drop any queued search so a stale term can't re-filter after the switch.
|
|
140
|
+
applySearch.cancel();
|
|
141
|
+
filters.value = { ...EMPTY_OAUTH_CLIENT_FILTERS };
|
|
142
|
+
searchQuery.value = '';
|
|
143
|
+
emit('update:ownership', newOwnership);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// A consent row is (client × owner): the same client id can appear once per
|
|
147
|
+
// owner in the All view, so row identity must include the owner.
|
|
148
|
+
function rowId(row: OAuthClientResponseDto) {
|
|
149
|
+
return `${row.id}:${row.owner?.id ?? 'mine'}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const tableHeaders = computed<Array<TableHeader<OAuthClientResponseDto>>>(() => [
|
|
153
|
+
{
|
|
154
|
+
title: i18n.baseText('settings.mcp.oAuthClients.table.clientName'),
|
|
155
|
+
key: 'name',
|
|
156
|
+
width: 190,
|
|
157
|
+
disableSort: true,
|
|
158
|
+
value() {
|
|
159
|
+
return;
|
|
160
|
+
},
|
|
161
|
+
},
|
|
162
|
+
...(ownership.value === 'all'
|
|
163
|
+
? [
|
|
164
|
+
{
|
|
165
|
+
title: i18n.baseText('settings.mcp.oAuthClients.table.connectedBy'),
|
|
166
|
+
key: 'owner',
|
|
167
|
+
width: 200,
|
|
168
|
+
disableSort: true,
|
|
169
|
+
value() {
|
|
170
|
+
return;
|
|
171
|
+
},
|
|
172
|
+
} satisfies TableHeader<OAuthClientResponseDto>,
|
|
173
|
+
]
|
|
174
|
+
: []),
|
|
175
|
+
{
|
|
176
|
+
title: i18n.baseText('settings.mcp.oAuthClients.table.access'),
|
|
177
|
+
key: 'scopes',
|
|
178
|
+
disableSort: true,
|
|
179
|
+
value() {
|
|
180
|
+
return;
|
|
181
|
+
},
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
title: i18n.baseText('settings.mcp.oAuthClients.table.connectedAt'),
|
|
185
|
+
key: 'grantedAt',
|
|
186
|
+
width: 110,
|
|
187
|
+
disableSort: true,
|
|
188
|
+
value() {
|
|
189
|
+
return;
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
{
|
|
193
|
+
title: '',
|
|
194
|
+
key: 'actions',
|
|
195
|
+
align: 'end',
|
|
196
|
+
width: 140,
|
|
197
|
+
disableSort: true,
|
|
198
|
+
value() {
|
|
199
|
+
return;
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
]);
|
|
203
|
+
|
|
204
|
+
function accessSummary(client: OAuthClientResponseDto): string {
|
|
205
|
+
return getAccessSummary(i18n, client, offeredScopes.value);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function revokeLabel(client: OAuthClientResponseDto): string {
|
|
209
|
+
return i18n.baseText('settings.mcp.oAuthClients.table.action.revokeAccessFor', {
|
|
210
|
+
interpolate: { name: client.name },
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function clientTypeLabel(client: OAuthClientResponseDto): string | null {
|
|
215
|
+
const type = getClientBrand(client.name).type;
|
|
216
|
+
if (!type) return null;
|
|
217
|
+
return i18n.baseText(`settings.mcp.oAuthClients.clientType.${type}` as BaseTextKey);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function openDetails(item: OAuthClientResponseDto) {
|
|
221
|
+
detailsClient.value = item;
|
|
222
|
+
detailsOpen.value = true;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function onRevoke(item: OAuthClientResponseDto) {
|
|
226
|
+
emit('revokeClient', item);
|
|
227
|
+
}
|
|
228
|
+
</script>
|
|
229
|
+
|
|
230
|
+
<template>
|
|
231
|
+
<div data-test-id="oauth-clients-table">
|
|
232
|
+
<div v-if="props.loading">
|
|
233
|
+
<N8nLoading :loading="props.loading" variant="h1" class="mb-l" />
|
|
234
|
+
<N8nLoading :loading="props.loading" variant="p" :rows="5" :shrink-last="false" />
|
|
235
|
+
</div>
|
|
236
|
+
<McpEmptyStateCard
|
|
237
|
+
v-else-if="showEmptyState"
|
|
238
|
+
data-test-id="mcp-clients-empty"
|
|
239
|
+
:title="i18n.baseText('settings.mcp.connectedClients.empty.title')"
|
|
240
|
+
:description="i18n.baseText('settings.mcp.connectedClients.empty.description')"
|
|
241
|
+
/>
|
|
242
|
+
<div v-else class="mt-s mb-xl">
|
|
243
|
+
<div :class="$style.toolbar">
|
|
244
|
+
<N8nTabs
|
|
245
|
+
v-if="canManageAllClients"
|
|
246
|
+
:model-value="ownership"
|
|
247
|
+
:options="tabOptions"
|
|
248
|
+
data-test-id="mcp-clients-tabs"
|
|
249
|
+
@update:model-value="onOwnershipChange"
|
|
250
|
+
/>
|
|
251
|
+
<div v-else />
|
|
252
|
+
<div :class="$style.filters">
|
|
253
|
+
<N8nInput
|
|
254
|
+
:model-value="searchQuery"
|
|
255
|
+
:placeholder="i18n.baseText('settings.mcp.oAuthClients.search.placeholder')"
|
|
256
|
+
:class="$style.search"
|
|
257
|
+
size="medium"
|
|
258
|
+
clearable
|
|
259
|
+
data-test-id="mcp-clients-search"
|
|
260
|
+
@update:model-value="onSearchInput"
|
|
261
|
+
>
|
|
262
|
+
<template #prefix>
|
|
263
|
+
<N8nIcon icon="search" />
|
|
264
|
+
</template>
|
|
265
|
+
</N8nInput>
|
|
266
|
+
<OAuthClientsFilters
|
|
267
|
+
:model-value="filters"
|
|
268
|
+
:owners="ownerOptions"
|
|
269
|
+
:show-owner-filter="ownership === 'all'"
|
|
270
|
+
:current-user-id="usersStore.currentUser?.id"
|
|
271
|
+
@update:model-value="onFiltersChange"
|
|
272
|
+
/>
|
|
273
|
+
</div>
|
|
274
|
+
</div>
|
|
275
|
+
<N8nDataTableServer
|
|
276
|
+
v-model:page="page"
|
|
277
|
+
v-model:items-per-page="itemsPerPage"
|
|
278
|
+
:class="$style.table"
|
|
279
|
+
data-test-id="oauth-clients-data-table"
|
|
280
|
+
:headers="tableHeaders"
|
|
281
|
+
:items="props.clients"
|
|
282
|
+
:items-length="mcpStore.oauthClientsCount"
|
|
283
|
+
:item-value="rowId"
|
|
284
|
+
@click:row="(_, { item }) => openDetails(item)"
|
|
285
|
+
>
|
|
286
|
+
<template v-if="mcpStore.oauthClientsCount === 0" #cover>
|
|
287
|
+
<div :class="$style['empty-state']">
|
|
288
|
+
<N8nText data-test-id="mcp-clients-no-results" size="small" color="text-base">
|
|
289
|
+
{{ i18n.baseText('settings.mcp.oAuthClients.search.noResults') }}
|
|
290
|
+
</N8nText>
|
|
291
|
+
</div>
|
|
292
|
+
</template>
|
|
293
|
+
<template #[`item.name`]="{ item }">
|
|
294
|
+
<div :class="$style.client">
|
|
295
|
+
<span :class="$style['client-icon-chip']">
|
|
296
|
+
<component
|
|
297
|
+
:is="getClientBrand(item.name).icon"
|
|
298
|
+
v-if="getClientBrand(item.name).icon"
|
|
299
|
+
:class="$style['client-icon']"
|
|
300
|
+
/>
|
|
301
|
+
<N8nIcon v-else icon="mcp" :class="$style['client-icon']" />
|
|
302
|
+
</span>
|
|
303
|
+
<div :class="$style['client-name']">
|
|
304
|
+
<N8nText data-test-id="mcp-client-name" color="text-dark">
|
|
305
|
+
{{ item.name }}
|
|
306
|
+
</N8nText>
|
|
307
|
+
<N8nText
|
|
308
|
+
v-if="clientTypeLabel(item)"
|
|
309
|
+
data-test-id="mcp-client-type"
|
|
310
|
+
size="xsmall"
|
|
311
|
+
color="text-light"
|
|
312
|
+
>
|
|
313
|
+
{{ clientTypeLabel(item) }}
|
|
314
|
+
</N8nText>
|
|
315
|
+
</div>
|
|
316
|
+
</div>
|
|
317
|
+
</template>
|
|
318
|
+
<template #[`item.owner`]="{ item }">
|
|
319
|
+
<OAuthClientOwnerCell
|
|
320
|
+
v-if="item.owner"
|
|
321
|
+
:owner="item.owner"
|
|
322
|
+
:is-current-user="item.owner.id === usersStore.currentUser?.id"
|
|
323
|
+
/>
|
|
324
|
+
</template>
|
|
325
|
+
<template #[`item.scopes`]="{ item }">
|
|
326
|
+
<N8nText data-test-id="mcp-client-access" color="text-light" :class="$style.access">
|
|
327
|
+
{{ accessSummary(item) }}
|
|
328
|
+
</N8nText>
|
|
329
|
+
</template>
|
|
330
|
+
<template #[`item.grantedAt`]="{ item }">
|
|
331
|
+
<N8nText data-test-id="mcp-client-created-at" color="text-base">
|
|
332
|
+
<N8nTimeAgo
|
|
333
|
+
:date="new Date(item.grantedAt).toISOString()"
|
|
334
|
+
capitalize
|
|
335
|
+
:locale="rootStore.defaultLocale"
|
|
336
|
+
/>
|
|
337
|
+
</N8nText>
|
|
338
|
+
</template>
|
|
339
|
+
<template #[`item.actions`]="{ item }">
|
|
340
|
+
<div :class="$style['row-actions']">
|
|
341
|
+
<N8nButton
|
|
342
|
+
:class="$style['revoke-action']"
|
|
343
|
+
variant="outline"
|
|
344
|
+
size="small"
|
|
345
|
+
:aria-label="revokeLabel(item)"
|
|
346
|
+
data-test-id="mcp-oauth-client-revoke-button"
|
|
347
|
+
@click.stop="onRevoke(item)"
|
|
348
|
+
>
|
|
349
|
+
{{ i18n.baseText('settings.mcp.oAuthClients.table.action.revokeAccess') }}
|
|
350
|
+
</N8nButton>
|
|
351
|
+
</div>
|
|
352
|
+
</template>
|
|
353
|
+
</N8nDataTableServer>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
<OAuthClientDetailsModal
|
|
357
|
+
v-model:open="detailsOpen"
|
|
358
|
+
:client="detailsClient"
|
|
359
|
+
@revoke="onRevoke"
|
|
360
|
+
/>
|
|
361
|
+
</div>
|
|
362
|
+
</template>
|
|
363
|
+
|
|
364
|
+
<style lang="scss" module>
|
|
365
|
+
.header {
|
|
366
|
+
display: flex;
|
|
367
|
+
justify-content: space-between;
|
|
368
|
+
align-items: center;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
.toolbar {
|
|
372
|
+
display: flex;
|
|
373
|
+
justify-content: space-between;
|
|
374
|
+
align-items: center;
|
|
375
|
+
gap: var(--spacing--sm);
|
|
376
|
+
margin-bottom: var(--spacing--sm);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
.filters {
|
|
380
|
+
display: flex;
|
|
381
|
+
align-items: center;
|
|
382
|
+
gap: var(--spacing--2xs);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
.search {
|
|
386
|
+
max-width: 240px;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.client {
|
|
390
|
+
display: flex;
|
|
391
|
+
align-items: center;
|
|
392
|
+
gap: var(--spacing--2xs);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.client-icon-chip {
|
|
396
|
+
display: inline-flex;
|
|
397
|
+
align-items: center;
|
|
398
|
+
justify-content: center;
|
|
399
|
+
width: var(--spacing--lg);
|
|
400
|
+
height: var(--spacing--lg);
|
|
401
|
+
flex-shrink: 0;
|
|
402
|
+
/* fixed white tile so dark brand marks stay visible on the dark theme */
|
|
403
|
+
background-color: var(--color--neutral-white);
|
|
404
|
+
border: var(--border);
|
|
405
|
+
border-radius: var(--radius);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
.client-icon {
|
|
409
|
+
width: var(--spacing--sm);
|
|
410
|
+
height: var(--spacing--sm);
|
|
411
|
+
/* the tile is always white, so the fallback MCP glyph must stay dark in both themes */
|
|
412
|
+
color: var(--color--neutral-black);
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/* Access summary stays on one line and truncates within its column (the fixed
|
|
416
|
+
table layout below gives the cell a real width). The row opens the details modal. */
|
|
417
|
+
.access {
|
|
418
|
+
display: block;
|
|
419
|
+
overflow: hidden;
|
|
420
|
+
white-space: nowrap;
|
|
421
|
+
text-overflow: ellipsis;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
.access:hover {
|
|
425
|
+
color: var(--color--primary);
|
|
426
|
+
text-decoration: underline;
|
|
427
|
+
cursor: pointer;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
.client-name {
|
|
431
|
+
display: flex;
|
|
432
|
+
flex-direction: column;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
.empty-state {
|
|
436
|
+
display: flex;
|
|
437
|
+
flex-direction: column;
|
|
438
|
+
align-items: center;
|
|
439
|
+
justify-content: center;
|
|
440
|
+
gap: var(--spacing--sm);
|
|
441
|
+
padding: var(--spacing--lg) 0;
|
|
442
|
+
min-height: 250px;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/* The button is block-level, so the column's `align: end` (text-align) can't push it
|
|
446
|
+
to the table's trailing edge; the flex wrapper does. Same as the API keys table. */
|
|
447
|
+
.row-actions {
|
|
448
|
+
display: flex;
|
|
449
|
+
justify-content: flex-end;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/* The whole row opens the details modal, so hint it with a pointer... */
|
|
453
|
+
.table :global(tbody tr) {
|
|
454
|
+
cursor: pointer;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/* ...and keep the revoke button out of the way until the row is hovered/focused. */
|
|
458
|
+
.table :global(tbody tr) .revoke-action {
|
|
459
|
+
opacity: 0;
|
|
460
|
+
transition: opacity var(--duration--snappy) var(--easing--ease-out);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
.table :global(tbody tr:hover) .revoke-action,
|
|
464
|
+
.table :global(tbody tr:focus-within) .revoke-action {
|
|
465
|
+
opacity: 1;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
@media (prefers-reduced-motion: reduce) {
|
|
469
|
+
.table :global(tbody tr) .revoke-action {
|
|
470
|
+
transition: none;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
</style>
|