Skip to content
Merged
95 changes: 95 additions & 0 deletions frontend/src/components/mcp/McpToolPermissions.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
<template>
<div class="grid grid-cols-5 items-center gap-y-2" :class="{ 'opacity-50': disabled }" data-el="mcp-tool-permissions">
<span class="col-span-2" />
<span
v-for="category in categories"
:key="category.value"
v-ff-tooltip="category.tooltip"
class="flex items-center justify-center gap-1 text-xs font-medium text-gray-500"
:class="{ 'cursor-help': category.tooltip }"
:data-category="category.value"
>
{{ category.label }}
<InformationCircleIcon v-if="category.tooltip" class="w-3.5 h-3.5" />
</span>
<div v-for="row in rows" :key="row.group" class="contents" :data-group="row.group">
<span class="col-span-2 text-sm whitespace-nowrap">{{ row.label }}</span>
<div v-for="category in categories" :key="category.value" class="flex justify-center">
<!-- h-4: without a label the glyph is absolutely positioned, so the checkbox has no height of its own -->
<ff-checkbox
class="h-4"
:model-value="modelValue[row.group][category.value]"
:disabled="disabled"
@update:model-value="value => onToggle(row.group, category.value, value)"
/>
</div>
</div>
</div>
</template>

<script>
import { InformationCircleIcon } from '@heroicons/vue/20/solid'

function withCategory (categories, category, value) {
const next = { ...categories, [category]: value }
if (value) {
if (category === 'write') {
next.read = true
} else if (category === 'destructive') {
next.write = true
next.read = true
}
} else {
if (category === 'read') {
next.write = false
next.destructive = false
} else if (category === 'write') {
next.destructive = false
}
}
return next
}

export default {
name: 'McpToolPermissions',
components: {
InformationCircleIcon
},
props: {
modelValue: {
type: Object,
required: true
},
disabled: {
type: Boolean,
default: false
}
},
emits: ['update:modelValue'],
data () {
return {
categories: [
{ value: 'read', label: 'Read' },
{ value: 'write', label: 'Write' },
{
value: 'destructive',
label: 'Destructive',
tooltip: "Tools that delete or overwrite data, or change what's running."
}
],
rows: [
{ group: 'platform', label: 'Platform' },
{ group: 'flow_building', label: 'Flow Building' }
]
}
},
methods: {
onToggle (group, category, value) {
this.$emit('update:modelValue', {
...this.modelValue,
[group]: withCategory(this.modelValue[group], category, value)
})
}
}
}
</script>
160 changes: 133 additions & 27 deletions frontend/src/pages/account/AccessRequestMCP.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,15 @@
</div>

<div class="w-full max-w-md space-y-4 my-4">
<!-- Access Level -->
<!-- Tool Permissions -->
<div>
<p class="text-gray-500 text-sm mb-2">
Choose whether the agent can make changes or only read data.
Choose what the agent can do in your teams. You can change this for individual teams below.
</p>
<McpToolPermissions v-model="defaultPermissions" :disabled="defaultUnused" />
<p v-if="defaultPermissionsNote" class="text-gray-500 text-sm mt-2" data-el="default-permissions-note">
{{ defaultPermissionsNote }}
</p>
<ff-radio-group
v-model="accessLevel"
label="Access Level"
orientation="vertical"
:options="accessLevelOptions"
/>
</div>

<!-- Team Scope -->
Expand All @@ -38,16 +36,55 @@
orientation="vertical"
:options="teamScopeOptions"
/>
<div v-if="teamScope === 'specific' && teams.length > 0" class="mt-2 ml-6 space-y-1">
<ff-checkbox
v-for="team in teams"
:key="team.id"
:model-value="selectedTeamIds.includes(team.id)"
:label="team.name"
@update:model-value="toggleTeam(team.id)"
/>
</div>

<!-- Team Permissions -->
<div v-if="teams.length > 0">
<label class="block text-sm font-medium mb-1">Team Permissions</label>
<p class="text-gray-500 text-sm mb-2">
Each team uses the permissions above unless you edit it.
</p>
<div class="space-y-2">
<div v-for="team in teams" :key="team.id" data-el="mcp-team-row">
<div class="flex items-center justify-between min-h-6">
<ff-checkbox
v-if="teamScope === 'specific'"
:model-value="selectedTeamIds.includes(team.id)"
:label="team.name"
@update:model-value="toggleTeam(team.id)"
/>
<span v-else class="text-sm font-medium">{{ team.name }}</span>
<div v-if="isInScope(team.id)" class="flex items-center gap-3 text-sm">
<span class="text-gray-500" data-el="team-permissions-state">{{ isCustom(team.id) ? 'Custom' : 'Default' }}</span>
<ff-button
v-if="isCustom(team.id)"
kind="tertiary"
size="small"
data-action="reset-team-default"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small one: these are hand-styled <button>s. ff-button with kind="tertiary" and size="small" would match the rest of the UI.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, both are ff-button with kind="tertiary" and size="small" now.

@click="resetToDefault(team.id)"
>
Reset
</ff-button>
<ff-button
kind="tertiary"
size="small"
data-action="customise-team"
:aria-expanded="isOpen(team.id)"
@click="toggleCustomise(team.id)"
>
{{ isOpen(team.id) ? 'Close' : 'Edit' }}
</ff-button>
</div>
</div>
<div v-if="isInScope(team.id) && isOpen(team.id)" class="ml-6 mt-2 mb-1">
<McpToolPermissions
:model-value="teamPermissions(team.id)"
@update:model-value="value => onTeamPermissionsChange(team.id, value)"
/>
</div>
</div>
</div>
<div v-if="teamScope === 'specific' && selectedTeamIds.length === 0" class="mt-2 ml-6 text-sm text-yellow-600">
<div v-if="teamScope === 'specific' && selectedTeamIds.length === 0" class="mt-2 text-sm text-yellow-600">
Select at least one team.
</div>
</div>
Expand Down Expand Up @@ -79,6 +116,7 @@ import { ArrowSmallLeftIcon, ArrowSmallRightIcon, CommandLineIcon, KeyIcon } fro
import { mapState } from 'pinia'

import FormRow from '../../components/FormRow.vue'
import McpToolPermissions from '../../components/mcp/McpToolPermissions.vue'

import client from '@/api/client.ts'
import teamApi from '@/api/team.ts'
Expand All @@ -92,29 +130,40 @@ function defaultExpiresAt () {
return date.toISOString().split('T')[0]
}

function defaultToolPermissions () {
return {
platform: { read: true, write: true, destructive: false },
flow_building: { read: true, write: true, destructive: true }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

#8662 asks for Read and Write pre-selected with Destructive off, and the reason for the whole epic (#8659) is that the user has to opt in to destructive tools explicitly. With this preset, clicking Allow lets the agent remove nodes, overwrite flows and deploy without anyone choosing that. Could Flow Building start on read + write like Platform?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd lean the other way here and would like your view. With Destructive off, the agent can't remove nodes, tabs or wires, or deploy, until the user re-authenticates and changes the permissions. Deploy Flows is destructive, so even after a team turns on AI Flow Deploy the user would have to re-authenticate again before the agent can deploy. Starting Flow Building on read + write + destructive keeps build-and-deploy working from one consent, and the user still sees the grid and has to click Allow. As this is the decision the epic is about, @Steve-Mcl @dimitrieh @ZJvandeWeg could you weigh in on the default? @cstns what do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept as is: Flow Building still starts with read, write and destructive on, and Platform with read and write. The team scope is now the deliberate choice (see the other thread), which was the main concern.

}
}

function permissionsEqual (a, b) {
return ['platform', 'flow_building'].every(group => (
['read', 'write', 'destructive'].every(category => !!a[group][category] === !!b[group][category])
))
}

export default {
name: 'AccessRequestMCP',
components: {
CommandLineIcon,
KeyIcon,
ArrowSmallRightIcon,
ArrowSmallLeftIcon,
FormRow
FormRow,
McpToolPermissions
},
data () {
return {
// No defaults for access level/team scope: the user must make an explicit choice before Allow enables
accessLevel: null,
teamScope: null,
expiresAt: defaultExpiresAt(),
selectedTeamIds: [],
teams: [],
defaultPermissions: defaultToolPermissions(),
teamOverrides: {},
openTeamIds: [],
submitting: false,
error: null,
accessLevelOptions: [
{ label: 'Full access', value: 'full', description: 'Read and write operations' },
{ label: 'Read-only', value: 'readonly', description: 'Read operations only' }
],
teamScopeOptions: [
{ label: 'All teams', value: 'all', description: 'Access all teams you belong to' },
{ label: 'Specific teams', value: 'specific', description: 'Choose which teams to grant access to' }
Expand All @@ -132,13 +181,37 @@ export default {
if (Number.isNaN(ts)) return false
return ts > Date.now() && ts <= Date.now() + ONE_YEAR
},
// With specific teams all customised, no team the token reaches uses the defaults. With all
// teams they still apply to teams the user joins later.
defaultUnused () {
return this.teamScope === 'specific' && this.selectedTeamIds.length > 0 && this.selectedTeamIds.every(teamId => this.isCustom(teamId))
},
defaultPermissionsNote () {
if (this.teamScope === 'all') {
return this.teams.length > 0 && this.teams.every(team => this.isCustom(team.id))
? 'Every team you belong to has custom permissions, so these only apply to teams you join later.'
: 'These apply to every team without custom permissions, including teams you join later.'
}
if (this.teamScope === 'specific') {
return this.defaultUnused
? 'Not used, every selected team has custom permissions.'
: 'These apply to the selected teams without custom permissions.'
}
return null
},
disableAllow () {
if (this.submitting) return true
if (!this.accessLevel || !this.teamScope || !this.expiryValid) return true
if (!this.teamScope || !this.expiryValid) return true
if (this.teamScope === 'specific' && this.selectedTeamIds.length === 0) return true
return false
}
},
watch: {
teamScope () {
this.teamOverrides = {}
this.openTeamIds = []
}
},
async mounted () {
try {
const data = await teamApi.getTeams()
Expand All @@ -148,22 +221,55 @@ export default {
}
},
methods: {
isInScope (teamId) {
return this.teamScope === 'all' || (this.teamScope === 'specific' && this.selectedTeamIds.includes(teamId))
},
isCustom (teamId) {
return Object.prototype.hasOwnProperty.call(this.teamOverrides, teamId)
},
isOpen (teamId) {
return this.openTeamIds.includes(teamId)
},
teamPermissions (teamId) {
return this.teamOverrides[teamId] || this.defaultPermissions
},
toggleTeam (teamId) {
const idx = this.selectedTeamIds.indexOf(teamId)
if (idx === -1) {
this.selectedTeamIds.push(teamId)
} else {
this.selectedTeamIds.splice(idx, 1)
this.resetToDefault(teamId)
this.openTeamIds = this.openTeamIds.filter(id => id !== teamId)
}
},
toggleCustomise (teamId) {
this.openTeamIds = this.openTeamIds.includes(teamId)
? this.openTeamIds.filter(id => id !== teamId)
: [...this.openTeamIds, teamId]
},
onTeamPermissionsChange (teamId, value) {
if (permissionsEqual(value, this.defaultPermissions)) {
const { [teamId]: _removed, ...rest } = this.teamOverrides
this.teamOverrides = rest
} else {
this.teamOverrides = { ...this.teamOverrides, [teamId]: value }
}
},
resetToDefault (teamId) {
if (this.isCustom(teamId)) {
const { [teamId]: _removed, ...rest } = this.teamOverrides
this.teamOverrides = rest
}
},
async allowAccess () {
this.submitting = true
this.error = null
try {
await client.put(`/account/authorize/${this.requestId}/consent`, {
readOnly: this.accessLevel === 'readonly',
teamIds: this.teamScope === 'all' ? [] : this.selectedTeamIds,
expiresAt: Date.parse(this.expiresAt)
expiresAt: Date.parse(this.expiresAt),
toolPermissions: { default: this.defaultPermissions, teams: this.teamOverrides }
})
window.location.href = `/account/complete/${this.requestId}`
} catch (err) {
Expand Down
46 changes: 44 additions & 2 deletions frontend/src/pages/account/Security/Tokens.vue
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,21 @@ import TokenDialog from './dialogs/TokenDialog.vue'
import { pluralize } from '@/composables/strings/String.js'
import { useAccountAuthStore } from '@/stores/account-auth.js'

const GROUPS = [
{ key: 'platform', label: 'Platform' },
{ key: 'flow_building', label: 'Flow Building' }
]

function levelOf (categories) {
if (categories?.destructive) {
return 'read + write + destructive'
}
if (categories?.write) {
return 'read + write'
}
return categories?.read ? 'read only' : 'none'
}

export default {
name: 'PersonalAccessTokens',
components: {
Expand Down Expand Up @@ -99,8 +114,35 @@ export default {
component: {
is: markRaw({
name: 'ReadOnlyCell',
props: ['readOnly'],
template: '<span v-if="readOnly" class="ff-badge ff-badge--info">Read Only</span><span v-else></span>'
props: ['readOnly', 'toolPermissions'],
template: `
<span v-if="toolPermissions" class="ff-badge ff-badge--info" v-ff-tooltip="tooltip" style="cursor:help">{{ summary }}</span>
<span v-else-if="readOnly" class="ff-badge ff-badge--info">Read Only</span>
<span v-else></span>
`,
computed: {
permissionSets () {
return [this.toolPermissions.default, ...Object.values(this.toolPermissions.teams || {})]
},
summary () {
const levels = new Set(this.permissionSets.flatMap(set => GROUPS.map(({ key }) => levelOf(set[key]))))
if (levels.size === 1 && levels.has('read only')) {
return 'Read only'
}
if (levels.size === 1 && levels.has('read + write')) {
return 'Read + write'
}
return 'Custom'
},
tooltip () {
const defaults = GROUPS.map(({ key, label }) => `${label}: ${levelOf(this.toolPermissions.default[key])}`).join(', ')
const overrides = Object.keys(this.toolPermissions.teams || {}).length
if (overrides === 0) {
return defaults
}
return `${defaults}. ${overrides} ${pluralize('team', overrides)} with custom permissions`
}
}
})
}
},
Expand Down
Loading
Loading