feat: add workspace agent API keys (#538)

Enforce owner/admin management, terminal expired/revoked lifecycle, explicit workspace scopes, current membership rechecks, and authenticated management UI.
This commit is contained in:
agent-kanban[bot]
2026-07-29 10:08:44 -04:00
committed by GitHub
parent 4817afecdb
commit 1b1b1db772
32 changed files with 3086 additions and 19 deletions
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -275,11 +275,13 @@ Manual API-key creation is the initial CI path:
New Agent keys never use `scope.mode = "user-workspaces"`. One key authorizes one
workspace. Expiry is required, defaults to 90 days, and cannot exceed one year.
Use one key per CI environment.
Use one key per CI environment. Personal workspace owners and team
owners/admins can manage Agent keys; team editors cannot issue credentials.
The UI lists name, workspace, permission summary, creation, expiry, last use,
and status. Revocation is immediate. Rotation creates a new key and never
reveals or mutates the old secret.
and status. Revocation is immediate. Only active keys can rotate. Rotation
creates a new key and never reveals or mutates the old secret; expired and
revoked keys are terminal, so the user creates a new key instead.
## 11. OpenAPI and Restish v2 Binding
@@ -417,7 +419,7 @@ Agent Access settings show two sections:
- delegated OAuth grants, with client, workspace, scopes, last use, and revoke;
- service API keys, with name, workspace, permissions, expiry, last use, and
revoke/replace.
revoke/rotate for active keys.
Revoking a delegated grant invalidates its refresh tokens and prevents new
access tokens. Short access-token lifetime bounds any validation-cache delay.
+4
View File
@@ -226,6 +226,7 @@ Defaults:
- exactly one workspace
- least-privilege permissions selected by the user
- explicit name, expiry, last-used time, and revocation
- personal owners and team owners/admins manage keys; editors cannot issue credentials
- separate keys for separate Agents and environments
- no admin, billing, membership, entitlement, or credential-management access
- team membership and role rechecked at authorization boundaries
@@ -234,6 +235,9 @@ Defaults:
The plaintext key is returned once and stored in a CI secret or another
non-interactive secret store.
Only active keys can be rotated. Expired and revoked keys are terminal; create
a new key when a new lifetime or credential is required.
### Scopes and Presets
The server has one canonical authorization vocabulary. OAuth grants, API keys,
+1 -1
View File
@@ -3,7 +3,7 @@ import { inArray } from 'drizzle-orm'
import { apikey } from '../../db/auth-schema'
import type { Database } from '../../platform/interface'
const WORKSPACE_TEMPLATES = [ApiKeyTemplate.IHOST, ApiKeyTemplate.REMOTE_DOWNLOAD]
const WORKSPACE_TEMPLATES = [ApiKeyTemplate.IHOST, ApiKeyTemplate.REMOTE_DOWNLOAD, ApiKeyTemplate.AGENT]
export function scopeForApiKey(configId: string, metadata: unknown): ApiKeyScope | null {
const scope = parseApiKeyScope(metadata)
+171 -6
View File
@@ -1,12 +1,31 @@
import { defaultKeyHasher } from '@better-auth/api-key'
import { API_KEY_TEMPLATES, type ApiKeyPermissions, type ApiKeyTemplate } from '@shared/api-key-templates'
import { authorizationScope, hasAuthorizationScope } from '@shared/authorization'
import { eq } from 'drizzle-orm'
import { apikey } from '../../db/auth-schema'
import {
AGENT_GRANTABLE_API_KEY_SCOPES,
API_KEY_TEMPLATES,
type ApiKeyPermissions,
ApiKeyTemplate,
type ApiKeyTemplate as ApiKeyTemplateId,
apiKeyMetadata,
} from '@shared/api-key-templates'
import {
type AuthorizationScope,
authorizationScope,
hasAuthorizationScope,
permissionScopes,
scopePermissions,
} from '@shared/authorization'
import type { AgentApiKey, AgentGrantableScope } from '@shared/schemas'
import { and, desc, eq } from 'drizzle-orm'
import { nanoid } from 'nanoid'
import { apikey, organization } from '../../db/auth-schema'
import { executeWriteTransaction } from '../../db/transaction'
import type { Database } from '../../platform/interface'
import { type ApiKeyAuth, type ApiKeyGateway, ApiKeyRateLimitError, type VerifiedApiKey } from '../../usecases/ports'
import { scopeForApiKey } from './api-key-scopes'
const AGENT_API_KEY_PREFIX = 'zpan_agent_'
const AGENT_GRANTABLE_SCOPE_SET = new Set<AuthorizationScope>(AGENT_GRANTABLE_API_KEY_SCOPES)
type VerifyApiKeyResult = {
valid: boolean
error: { message: string; code: string; details?: { tryAgainIn?: number } } | null
@@ -53,9 +72,155 @@ export function createApiKeyGateway(): ApiKeyGateway {
hasApiKeyScope(permissions: ApiKeyPermissions | null | undefined, scope) {
return hasAuthorizationScope(permissions, scope)
},
async listAgentApiKeys(db, userId, orgId, now) {
const rows = await listAgentRows(db, userId, orgId)
return rows.map((row) => toAgentApiKeyDTO(row, now))
},
async getAgentApiKey(db, userId, orgId, keyId, now) {
const row = await getAgentRow(db, userId, orgId, keyId)
return row ? toAgentApiKeyDTO(row, now) : null
},
async issueAgentApiKey(db, input) {
const now = new Date()
const id = crypto.randomUUID()
const key = `${AGENT_API_KEY_PREFIX}${nanoid(48)}`
const hashedKey = await defaultKeyHasher(key)
const insert = db.insert(apikey).values({
id,
configId: ApiKeyTemplate.AGENT,
name: input.name,
start: key.slice(0, AGENT_API_KEY_PREFIX.length + 6),
referenceId: input.userId,
prefix: AGENT_API_KEY_PREFIX,
key: hashedKey,
enabled: true,
rateLimitEnabled: true,
rateLimitTimeWindow: 60_000,
rateLimitMax: 600,
requestCount: 0,
expiresAt: input.expiresAt,
createdAt: now,
updatedAt: now,
permissions: JSON.stringify(scopePermissions(input.scopes)),
metadata: JSON.stringify(apiKeyMetadata({ mode: 'workspace', orgId: input.orgId })),
})
const revoke = input.revokeKeyId
? db.update(apikey).set({ enabled: false, updatedAt: now }).where(eq(apikey.id, input.revokeKeyId))
: null
await executeWriteTransaction(db, revoke ? [insert, revoke] : [insert])
const row = await getAgentRow(db, input.userId, input.orgId, id)
if (!row) throw new Error('agent_api_key_create_failed')
return { key, item: toAgentApiKeyDTO(row, now) }
},
async revokeAgentApiKey(db, keyId) {
await db.update(apikey).set({ enabled: false, updatedAt: new Date() }).where(eq(apikey.id, keyId))
},
}
}
type AgentApiKeyRow = {
id: string
name: string | null
permissions: string | null
metadata: string | null
enabled: boolean
createdAt: Date | number | string
expiresAt: Date | number | string | null
lastRequest: Date | number | string | null
workspaceName: string | null
}
async function listAgentRows(db: Database, userId: string, orgId: string): Promise<AgentApiKeyRow[]> {
const rows = await db
.select({
id: apikey.id,
name: apikey.name,
permissions: apikey.permissions,
metadata: apikey.metadata,
enabled: apikey.enabled,
createdAt: apikey.createdAt,
expiresAt: apikey.expiresAt,
lastRequest: apikey.lastRequest,
workspaceName: organization.name,
})
.from(apikey)
.leftJoin(organization, eq(organization.id, orgId))
.where(and(eq(apikey.configId, ApiKeyTemplate.AGENT), eq(apikey.referenceId, userId)))
.orderBy(desc(apikey.createdAt))
return rows.filter((row) => parseWorkspaceMetadata(row.metadata)?.orgId === orgId)
}
async function getAgentRow(db: Database, userId: string, orgId: string, keyId: string): Promise<AgentApiKeyRow | null> {
const rows = await db
.select({
id: apikey.id,
name: apikey.name,
permissions: apikey.permissions,
metadata: apikey.metadata,
enabled: apikey.enabled,
createdAt: apikey.createdAt,
expiresAt: apikey.expiresAt,
lastRequest: apikey.lastRequest,
workspaceName: organization.name,
})
.from(apikey)
.leftJoin(organization, eq(organization.id, orgId))
.where(and(eq(apikey.id, keyId), eq(apikey.configId, ApiKeyTemplate.AGENT), eq(apikey.referenceId, userId)))
.limit(1)
const row = rows[0]
return row && parseWorkspaceMetadata(row.metadata)?.orgId === orgId ? row : null
}
function toAgentApiKeyDTO(row: AgentApiKeyRow, now: Date): AgentApiKey {
const scope = parseWorkspaceMetadata(row.metadata)
if (!scope) throw new Error('agent_api_key_workspace_scope_missing')
const expiresAt = requireDate(row.expiresAt, 'agent_api_key_expiry_missing')
return {
id: row.id,
name: row.name ?? row.id,
orgId: scope.orgId,
workspaceName: row.workspaceName,
scopes: parseStoredScopes(row.permissions),
createdAt: toIso(row.createdAt),
expiresAt: expiresAt.toISOString(),
lastUsedAt: row.lastRequest ? toIso(row.lastRequest) : null,
status: !row.enabled ? 'revoked' : expiresAt <= now ? 'expired' : 'active',
}
}
function parseWorkspaceMetadata(value: string | null): { orgId: string } | null {
if (!value) return null
const parsed = JSON.parse(value) as { scope?: { mode?: unknown; orgId?: unknown } }
return parsed.scope?.mode === 'workspace' && typeof parsed.scope.orgId === 'string'
? { orgId: parsed.scope.orgId }
: null
}
function parseStoredScopes(value: string | null): AgentGrantableScope[] {
if (!value) return []
const permissions = JSON.parse(value) as ApiKeyPermissions
return permissionScopes(permissions).filter((scope): scope is AgentGrantableScope =>
AGENT_GRANTABLE_SCOPE_SET.has(scope),
)
}
function requireDate(value: Date | number | string | null, message: string): Date {
if (value === null) throw new Error(message)
const date = new Date(value)
if (Number.isNaN(date.getTime())) throw new Error(message)
return date
}
function toIso(value: Date | number | string): string {
const date = new Date(value)
if (Number.isNaN(date.getTime())) throw new Error('invalid_agent_api_key_date')
return date.toISOString()
}
async function normalizeVerifiedApiKey(key: NonNullable<VerifyApiKeyResult['key']>): Promise<VerifiedApiKey | null> {
const scope = scopeForApiKey(key.configId, key.metadata)
if (!scope) return null
@@ -82,9 +247,9 @@ function throwIfRateLimited(result: VerifyApiKeyResult | null) {
throw new ApiKeyRateLimitError(result.error.message, result.error.details?.tryAgainIn)
}
async function resolveApiKeyConfigId(db: Database, rawKey: string): Promise<ApiKeyTemplate | null> {
async function resolveApiKeyConfigId(db: Database, rawKey: string): Promise<ApiKeyTemplateId | null> {
const hashedKey = await defaultKeyHasher(rawKey)
const rows = await db.select({ configId: apikey.configId }).from(apikey).where(eq(apikey.key, hashedKey)).limit(1)
const configId = rows[0]?.configId
return configId && API_KEY_TEMPLATES.includes(configId as ApiKeyTemplate) ? (configId as ApiKeyTemplate) : null
return configId && API_KEY_TEMPLATES.includes(configId as ApiKeyTemplateId) ? (configId as ApiKeyTemplateId) : null
}
+16
View File
@@ -159,3 +159,19 @@ describe('isPersonalOrg', () => {
expect(result).toBe(false)
})
})
describe('canManageAgentAccess', () => {
it.each([
['owner', true],
['admin', true],
['editor', false],
['viewer', false],
])('allows Agent Access management for %s: %s', async (role, expected) => {
const { db } = await createTestApp()
const userId = await insertUser(db)
const orgId = await insertOrg(db, { metadata: '{"type":"team"}' })
await insertMember(db, orgId, userId, role)
expect(await createOrgRepo(db).canManageAgentAccess(userId, orgId)).toBe(expected)
})
})
+8 -2
View File
@@ -4,7 +4,7 @@ import { member, organization } from '../../db/auth-schema'
import type { Database } from '../../platform/interface'
import type { OrgRepo } from '../../usecases/ports'
const ROLE_LEVELS: Record<string, number> = { owner: 3, editor: 2, viewer: 1, member: 1 }
const ROLE_LEVELS: Record<string, number> = { owner: 3, admin: 3, editor: 2, viewer: 1, member: 1 }
export function createOrgRepo(db: Database): OrgRepo {
// Find the user's personal org, if they still belong to it. New personal orgs
@@ -55,5 +55,11 @@ export function createOrgRepo(db: Database): OrgRepo {
return orgId === (await findPersonalOrg(userId))
}
return { findPersonalOrg, getMemberRole, canReadOrg, canWriteToOrg, isPersonalOrg }
async function canManageAgentAccess(userId: string, orgId: string): Promise<boolean> {
const role = await getMemberRole(orgId, userId)
if (role !== null) return role === 'owner' || role === 'admin'
return orgId === (await findPersonalOrg(userId))
}
return { findPersonalOrg, getMemberRole, canReadOrg, canWriteToOrg, canManageAgentAccess, isPersonalOrg }
}
+3
View File
@@ -9,6 +9,7 @@ import { createDeps } from './composition'
import { isPotentialWebDavPublicRequest, isWebDavPublicRequest } from './domain/webdav-public-url'
import { adminOverview } from './http/admin-overview'
import { adminStats } from './http/admin-stats'
import agentApiKeys from './http/agent-api-keys'
import { serveAvatarBlob } from './http/avatar-blobs'
import backgroundJobs from './http/background-jobs'
import { configz } from './http/configz'
@@ -253,6 +254,7 @@ export function createApp(platform: Platform, auth: Auth, deps: Deps = createDep
app.route('/api/objects', objects)
app.route('/api/shares', authedShares)
app.route('/api/trash', trash)
app.route('/api/workspaces', agentApiKeys)
app.route('/api/teams', teams)
app.route('/api/teams', adminTeams)
app.route('/api/site/storages', storages)
@@ -400,3 +402,4 @@ export type AdminAuditRoute = typeof adminAudit
export type AdminOverviewRoute = typeof adminOverview
export type AdminStatsRoute = typeof adminStats
export type StorageUsageRoute = typeof storageUsage
export type AgentApiKeysRoute = typeof agentApiKeys
+16
View File
@@ -429,6 +429,9 @@ export async function createAuth(
if (!body) return
const configId = body.configId
if (typeof configId !== 'string' || !API_KEY_TEMPLATES.includes(configId as ApiKeyTemplate)) return
if (configId === ApiKeyTemplate.AGENT) {
throw new APIError('BAD_REQUEST', { message: 'Create Agent API keys from the Agent Access API' })
}
const session = await getSessionFromCtx(ctx)
const userId = session?.user.id ?? (typeof body?.userId === 'string' ? body.userId : null)
@@ -652,6 +655,19 @@ export async function createAuth(
defaultPermissions: REMOTE_DOWNLOAD_API_KEY_PERMISSIONS,
},
},
{
configId: ApiKeyTemplate.AGENT,
references: 'user',
enableMetadata: true,
rateLimit: {
enabled: true,
timeWindow: 60_000,
maxRequests: 600,
},
permissions: {
defaultPermissions: {},
},
},
]),
],
databaseHooks: {
@@ -0,0 +1,346 @@
import { defaultKeyHasher } from '@better-auth/api-key'
import { sql } from 'drizzle-orm'
import { describe, expect, it } from 'vitest'
import { authedHeaders, createTestApp } from '../test/setup.js'
type TestApp = Awaited<ReturnType<typeof createTestApp>>
function futureIso(days: number): string {
const date = new Date()
date.setDate(date.getDate() + days)
return date.toISOString()
}
async function getUserAndPersonalOrg(db: TestApp['db'], email = 'test@example.com') {
const users = await db.all<{ id: string }>(sql`SELECT id FROM user WHERE email = ${email}`)
const orgs = await db.all<{ id: string }>(sql`
SELECT o.id
FROM organization o
INNER JOIN member m ON m.organization_id = o.id
WHERE m.user_id = ${users[0]?.id} AND o.metadata LIKE '%"type":"personal"%'
LIMIT 1
`)
if (!users[0] || !orgs[0]) throw new Error('expected user and personal org')
return { userId: users[0].id, orgId: orgs[0].id }
}
async function insertStorage(db: TestApp['db']) {
const now = Date.now()
await db.run(sql`
INSERT INTO storages (
id, bucket, endpoint, region, access_key, secret_key, file_path, custom_host,
capacity, used, enabled, status, egress_credit_billing_enabled, egress_credit_unit_bytes,
egress_credit_per_unit, created_at, updated_at
)
VALUES (
'st-agent', 'test-bucket', 'https://s3.amazonaws.com', 'us-east-1',
'AKIAIOSFODNN7EXAMPLE', 'secret', '', '', 0, 0, 1, 'untested',
0, ${100 * 1024 ** 2}, 1, ${now}, ${now}
)
`)
}
async function insertFile(db: TestApp['db'], orgId: string, id: string) {
const now = Date.now()
await db.run(sql`
INSERT INTO matters (id, org_id, alias, name, type, size, dirtype, parent, object, storage_id, status, trashed_at, created_at, updated_at)
VALUES (${id}, ${orgId}, ${`${id}-alias`}, ${`${id}.txt`}, 'text/plain', 100, 0, '', 'some/key.txt', 'st-agent', 'active', NULL, ${now}, ${now})
`)
}
async function insertLandingShare(
db: TestApp['db'],
input: { token: string; orgId: string; matterId: string; userId: string },
) {
const now = Date.now()
await db.run(sql`
INSERT INTO shares (id, token, kind, matter_id, org_id, creator_id, status, private, created_at)
VALUES (${`${input.token}-id`}, ${input.token}, 'landing', ${input.matterId}, ${input.orgId}, ${input.userId}, 'active', 0, ${now})
`)
}
async function insertTeamOrg(db: TestApp['db'], orgId: string, userId: string, role = 'editor') {
const now = Date.now()
await db.run(sql`
INSERT INTO organization (id, name, slug, metadata, created_at, updated_at)
VALUES (${orgId}, ${`Team ${orgId}`}, ${orgId}, '{"type":"team"}', ${now}, ${now})
`)
await db.run(sql`
INSERT INTO member (id, organization_id, user_id, role, created_at)
VALUES (${`${orgId}-member`}, ${orgId}, ${userId}, ${role}, ${now})
`)
await db.run(sql`
INSERT INTO org_quotas (id, org_id, quota, used, traffic_quota, traffic_used, traffic_period)
VALUES (${`${orgId}-quota`}, ${orgId}, 1000000, 0, 0, 0, '1970-01')
`)
}
async function createAgentKey(app: TestApp['app'], headers: Record<string, string>, orgId: string, scopes: string[]) {
const res = await app.request(`/api/workspaces/${orgId}/agent-api-keys`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'CI', scopes, expiresAt: futureIso(90) }),
})
if (res.status !== 201) throw new Error(`create failed: ${res.status} ${await res.text()}`)
return (await res.json()) as { key: string; item: { id: string; orgId: string; scopes: string[]; status: string } }
}
async function insertLegacyAgentKey(db: TestApp['db'], userId: string): Promise<string> {
const now = Date.now()
const key = 'zpan_agent_legacy_integration_key'
const hashedKey = await defaultKeyHasher(key)
await db.run(sql`
INSERT INTO apikey (
id, config_id, name, start, reference_id, prefix, key,
enabled, rate_limit_enabled, rate_limit_time_window, rate_limit_max, request_count,
expires_at, created_at, updated_at, permissions, metadata
)
VALUES (
'legacy-agent-key', 'agent', 'Legacy Agent key', 'zpan_age', ${userId}, 'zpan_agent_', ${hashedKey},
1, 1, 60000, 600, 0,
${now + 90 * 24 * 60 * 60 * 1000}, ${now}, ${now}, '{"objects":["read"]}', NULL
)
`)
return key
}
describe('Agent API keys', () => {
it('creates, lists, rotates, and revokes a personal workspace key [spec: agent-api-keys/lifecycle]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const { orgId } = await getUserAndPersonalOrg(db)
const created = await createAgentKey(app, headers, orgId, ['objects:read'])
expect(created.key).toMatch(/^zpan_agent_/)
expect(created.item).toMatchObject({ orgId, scopes: ['objects:read'], status: 'active' })
const list = await app.request(`/api/workspaces/${orgId}/agent-api-keys`, { headers })
expect(list.status).toBe(200)
const listed = (await list.json()) as { items: Array<{ id: string; key?: string }> }
expect(listed.items.map((item) => item.id)).toContain(created.item.id)
expect(listed.items[0]?.key).toBeUndefined()
const rotated = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${created.item.id}/rotations`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(rotated.status).toBe(201)
const rotatedBody = (await rotated.json()) as { key: string; item: { id: string } }
expect(rotatedBody.key).toMatch(/^zpan_agent_/)
expect(rotatedBody.item.id).not.toBe(created.item.id)
const revoke = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${rotatedBody.item.id}`, {
method: 'DELETE',
headers,
})
expect(revoke.status).toBe(204)
})
it('creates and uses a team workspace key for allowed file operations [spec: agent-api-keys/team-file-ops]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
await insertStorage(db)
const { userId } = await getUserAndPersonalOrg(db)
await insertTeamOrg(db, 'agent-team', userId, 'owner')
await insertFile(db, 'agent-team', 'agent-readable')
const created = await createAgentKey(app, headers, 'agent-team', ['objects:read', 'objects:create'])
await db.run(sql`UPDATE member SET role = 'editor' WHERE organization_id = 'agent-team' AND user_id = ${userId}`)
const auth = { Authorization: `Bearer ${created.key}` }
const list = await app.request('/api/objects', { headers: auth })
expect(list.status).toBe(200)
const listBody = (await list.json()) as { items: Array<{ id: string }> }
expect(listBody.items.map((item) => item.id)).toContain('agent-readable')
const create = await app.request('/api/objects', {
method: 'POST',
headers: { ...auth, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'agent-folder', type: 'folder', dirtype: 1, parent: '' }),
})
expect(create.status).toBe(201)
})
it('allows team owners and admins to manage keys but denies editors [spec: agent-api-keys/management-role]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const { userId } = await getUserAndPersonalOrg(db)
await insertStorage(db)
await insertTeamOrg(db, 'agent-editor-team', userId, 'editor')
await insertTeamOrg(db, 'agent-admin-team', userId, 'admin')
await insertFile(db, 'agent-admin-team', 'agent-admin-readable')
const editorList = await app.request('/api/workspaces/agent-editor-team/agent-api-keys', { headers })
expect(editorList.status).toBe(403)
const editorCreate = await app.request('/api/workspaces/agent-editor-team/agent-api-keys', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'Denied', scopes: ['objects:read'], expiresAt: futureIso(90) }),
})
expect(editorCreate.status).toBe(403)
const adminCreated = await createAgentKey(app, headers, 'agent-admin-team', ['objects:read'])
expect(adminCreated.item.orgId).toBe('agent-admin-team')
const adminList = await app.request('/api/objects', {
headers: { Authorization: `Bearer ${adminCreated.key}` },
})
expect(adminList.status).toBe(200)
})
it('rejects disallowed scopes and raw Better Auth Agent key creation [spec: agent-api-keys/scope-boundary]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const { orgId } = await getUserAndPersonalOrg(db)
const disallowed = await app.request(`/api/workspaces/${orgId}/agent-api-keys`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ name: 'bad', scopes: ['images:upload'], expiresAt: futureIso(90) }),
})
expect(disallowed.status).toBe(400)
const raw = await app.request('/api/auth/api-key/create', {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ configId: 'agent', organizationId: orgId, permissions: { images: ['upload'] } }),
})
expect(raw.status).toBe(400)
})
it('denies missing scope, wrong workspace, revoked key, expired key, and banned owner [spec: agent-api-keys/denials]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const { orgId, userId } = await getUserAndPersonalOrg(db)
await insertStorage(db)
const created = await createAgentKey(app, headers, orgId, ['objects:create'])
const auth = { Authorization: `Bearer ${created.key}` }
const missingScope = await app.request('/api/objects', { headers: auth })
expect(missingScope.status).toBe(403)
const wrongWorkspace = await app.request('/api/objects?orgId=agent-other-workspace', { headers: auth })
expect(wrongWorkspace.status).toBe(403)
await app.request(`/api/workspaces/${orgId}/agent-api-keys/${created.item.id}`, { method: 'DELETE', headers })
const revoked = await app.request('/api/objects', { headers: auth })
expect(revoked.status).toBe(401)
const expired = await createAgentKey(app, headers, orgId, ['objects:read'])
await db.run(sql`UPDATE apikey SET expires_at = ${Date.now() - 1000} WHERE id = ${expired.item.id}`)
const expiredRes = await app.request('/api/objects', { headers: { Authorization: `Bearer ${expired.key}` } })
expect(expiredRes.status).toBe(401)
const banned = await createAgentKey(app, headers, orgId, ['objects:read'])
await db.run(sql`UPDATE user SET banned = 1 WHERE id = ${userId}`)
const bannedRes = await app.request('/api/objects', { headers: { Authorization: `Bearer ${banned.key}` } })
expect(bannedRes.status).toBe(401)
})
it('treats expired and revoked keys as terminal for rotation [spec: agent-api-keys/terminal-rotation]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const { orgId } = await getUserAndPersonalOrg(db)
const expired = await createAgentKey(app, headers, orgId, ['objects:read'])
await db.run(sql`UPDATE apikey SET expires_at = ${Date.now() - 1000} WHERE id = ${expired.item.id}`)
const expiredRotation = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${expired.item.id}/rotations`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({ expiresAt: futureIso(90) }),
})
expect(expiredRotation.status).toBe(409)
await expect(expiredRotation.json()).resolves.toMatchObject({
error: { details: [{ reason: 'AGENT_API_KEY_NOT_ACTIVE' }] },
})
const revoked = await createAgentKey(app, headers, orgId, ['objects:read'])
await app.request(`/api/workspaces/${orgId}/agent-api-keys/${revoked.item.id}`, {
method: 'DELETE',
headers,
})
const revokedRotation = await app.request(`/api/workspaces/${orgId}/agent-api-keys/${revoked.item.id}/rotations`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify({}),
})
expect(revokedRotation.status).toBe(409)
await expect(revokedRotation.json()).resolves.toMatchObject({
error: { details: [{ reason: 'AGENT_API_KEY_NOT_ACTIVE' }] },
})
})
it('rechecks team role before management and file operations [spec: agent-api-keys/role-reduction]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const { userId } = await getUserAndPersonalOrg(db)
await insertStorage(db)
await insertTeamOrg(db, 'agent-role-team', userId, 'owner')
await insertFile(db, 'agent-role-team', 'agent-role-share-file')
await insertLandingShare(db, {
token: 'agent-role-share',
orgId: 'agent-role-team',
matterId: 'agent-role-share-file',
userId,
})
const created = await createAgentKey(app, headers, 'agent-role-team', [
'objects:create',
'shares:create',
'shares:delete',
])
await db.run(
sql`UPDATE member SET role = 'viewer' WHERE organization_id = 'agent-role-team' AND user_id = ${userId}`,
)
const auth = { Authorization: `Bearer ${created.key}`, 'Content-Type': 'application/json' }
const management = await app.request('/api/workspaces/agent-role-team/agent-api-keys', { headers })
expect(management.status).toBe(403)
const create = await app.request('/api/objects', {
method: 'POST',
headers: auth,
body: JSON.stringify({ name: 'blocked', type: 'folder', dirtype: 1, parent: '' }),
})
expect(create.status).toBe(403)
const privacy = await app.request('/api/shares/agent-role-share/privacy', {
method: 'PUT',
headers: auth,
body: JSON.stringify({ private: true }),
})
expect(privacy.status).toBe(403)
const revoke = await app.request('/api/shares/agent-role-share/status', {
method: 'PUT',
headers: auth,
body: JSON.stringify({ status: 'revoked' }),
})
expect(revoke.status).toBe(403)
})
it('denies an old team workspace key after the owner membership is removed [spec: agent-api-keys/denials]', async () => {
const { app, db } = await createTestApp()
const headers = await authedHeaders(app)
const { userId } = await getUserAndPersonalOrg(db)
await insertStorage(db)
await insertTeamOrg(db, 'agent-removed-team', userId, 'owner')
await insertFile(db, 'agent-removed-team', 'agent-removed-readable')
const created = await createAgentKey(app, headers, 'agent-removed-team', ['objects:read'])
await db.run(sql`DELETE FROM member WHERE organization_id = 'agent-removed-team' AND user_id = ${userId}`)
const denied = await app.request('/api/objects?orgId=agent-removed-team', {
headers: { Authorization: `Bearer ${created.key}` },
})
expect(denied.status).toBe(403)
})
it('denies a legacy Better Auth Agent key without scoped metadata [spec: agent-api-keys/denials]', async () => {
const { app, db } = await createTestApp()
await authedHeaders(app)
const { userId } = await getUserAndPersonalOrg(db)
const key = await insertLegacyAgentKey(db, userId)
const denied = await app.request('/api/objects', { headers: { Authorization: `Bearer ${key}` } })
expect(denied.status).toBe(401)
})
})
+134
View File
@@ -0,0 +1,134 @@
import { OpenAPIHono, z } from '@hono/zod-openapi'
import {
agentApiKeyCreatedSchema,
agentApiKeyCreateSchema,
agentApiKeyListSchema,
agentApiKeyRotateSchema,
} from '@shared/schemas'
import { requireAuth } from '../middleware/auth'
import type { Env } from '../middleware/platform'
import { createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '../usecases/agent-api-keys'
import { authRoute, errorResponse, jsonBody, jsonContent } from './openapi'
const workspaceParamsSchema = z.object({ orgId: z.string().min(1) })
const keyParamsSchema = workspaceParamsSchema.extend({ keyId: z.string().min(1) })
const listQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
pageSize: z.coerce.number().int().min(1).max(100).default(50),
})
const listRoute = authRoute(
{ access: 'session' },
{
operationId: 'listWorkspaceAgentApiKeys',
summary: 'List Agent API keys for a workspace',
tags: ['Agent Access'],
method: 'get',
path: '/{orgId}/agent-api-keys',
middleware: [requireAuth] as const,
request: { params: workspaceParamsSchema, query: listQuerySchema },
responses: {
200: jsonContent(agentApiKeyListSchema, 'Agent API keys'),
403: errorResponse('Forbidden'),
},
},
)
const createRoute = authRoute(
{ access: 'session' },
{
operationId: 'createWorkspaceAgentApiKey',
summary: 'Create an Agent API key for a workspace',
tags: ['Agent Access'],
method: 'post',
path: '/{orgId}/agent-api-keys',
middleware: [requireAuth] as const,
request: { params: workspaceParamsSchema, ...jsonBody(agentApiKeyCreateSchema) },
responses: {
201: jsonContent(agentApiKeyCreatedSchema, 'Created Agent API key'),
400: errorResponse('Bad request'),
403: errorResponse('Forbidden'),
},
},
)
const rotateRoute = authRoute(
{ access: 'session' },
{
operationId: 'rotateWorkspaceAgentApiKey',
summary: 'Rotate an Agent API key for a workspace',
tags: ['Agent Access'],
method: 'post',
path: '/{orgId}/agent-api-keys/{keyId}/rotations',
middleware: [requireAuth] as const,
request: { params: keyParamsSchema, ...jsonBody(agentApiKeyRotateSchema) },
responses: {
201: jsonContent(agentApiKeyCreatedSchema, 'Rotated Agent API key'),
400: errorResponse('Bad request'),
409: errorResponse('Agent API key is not active'),
403: errorResponse('Forbidden'),
404: errorResponse('Agent API key not found'),
},
},
)
const revokeRoute = authRoute(
{ access: 'session' },
{
operationId: 'revokeWorkspaceAgentApiKey',
summary: 'Revoke an Agent API key for a workspace',
tags: ['Agent Access'],
method: 'delete',
path: '/{orgId}/agent-api-keys/{keyId}',
middleware: [requireAuth] as const,
request: { params: keyParamsSchema },
responses: {
204: { description: 'Revoked' },
403: errorResponse('Forbidden'),
404: errorResponse('Agent API key not found'),
},
},
)
const agentApiKeys = new OpenAPIHono<Env>()
.openapi(listRoute, async (c) => {
const { orgId } = c.req.valid('param')
const { page, pageSize } = c.req.valid('query')
const result = await listAgentApiKeys(c.get('deps'), c.get('platform').db, {
userId: c.get('userId')!,
orgId,
page,
pageSize,
})
return c.json(result, 200)
})
.openapi(createRoute, async (c) => {
const { orgId } = c.req.valid('param')
const result = await createAgentApiKey(c.get('deps'), c.get('platform').db, {
userId: c.get('userId')!,
orgId,
body: c.req.valid('json'),
})
return c.json(result, 201)
})
.openapi(rotateRoute, async (c) => {
const { orgId, keyId } = c.req.valid('param')
const result = await rotateAgentApiKey(c.get('deps'), c.get('platform').db, {
userId: c.get('userId')!,
orgId,
keyId,
body: c.req.valid('json'),
})
return c.json(result, 201)
})
.openapi(revokeRoute, async (c) => {
const { orgId, keyId } = c.req.valid('param')
await revokeAgentApiKey(c.get('deps'), c.get('platform').db, {
userId: c.get('userId')!,
orgId,
keyId,
})
return c.body(null, 204)
})
export default agentApiKeys
+2
View File
@@ -450,6 +450,7 @@ const revokeShareRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.SHARES_DELETE],
minTeamRole: 'editor',
},
{
operationId: 'revokeShare',
@@ -475,6 +476,7 @@ const putSharePrivacyRoute = authRoute(
{
access: 'protected',
scopes: [AuthorizationScope.SHARES_CREATE],
minTeamRole: 'editor',
},
{
operationId: 'putSharePrivacy',
+1
View File
@@ -13,6 +13,7 @@ import { anonymousAuthzContext, type Env } from './platform'
// existing org members get read access rather than being silently denied.
const ROLE_LEVELS: Record<string, number> = {
owner: 3,
admin: 3,
editor: 2,
viewer: 1,
member: 1,
+1
View File
@@ -7,6 +7,7 @@ import type { AuthzContext, Env } from './platform'
const ROLE_LEVELS: Record<string, number> = {
owner: 3,
admin: 3,
editor: 2,
viewer: 1,
member: 1,
+111
View File
@@ -0,0 +1,111 @@
import { AGENT_GRANTABLE_API_KEY_SCOPES } from '@shared/api-key-templates'
import type {
AgentApiKeyCreated,
AgentApiKeyCreateInput,
AgentApiKeyList,
AgentApiKeyRotateInput,
AgentGrantableScope,
} from '@shared/schemas'
import type { Database } from '../platform/interface'
import type { Deps } from './deps'
import { badRequest, conflict, forbidden, notFound } from './ports'
const MAX_AGENT_API_KEY_AGE_MS = 365 * 24 * 60 * 60 * 1000
const AGENT_GRANTABLE_SCOPE_SET = new Set<string>(AGENT_GRANTABLE_API_KEY_SCOPES)
export async function listAgentApiKeys(
deps: Pick<Deps, 'apiKeys' | 'org'>,
db: Database,
input: { userId: string; orgId: string; page: number; pageSize: number; now?: Date },
): Promise<AgentApiKeyList> {
await requireWorkspaceManager(deps, input.userId, input.orgId)
const items = await deps.apiKeys.listAgentApiKeys(db, input.userId, input.orgId, input.now ?? new Date())
const offset = (input.page - 1) * input.pageSize
return {
items: items.slice(offset, offset + input.pageSize),
total: items.length,
page: input.page,
pageSize: input.pageSize,
}
}
export async function createAgentApiKey(
deps: Pick<Deps, 'apiKeys' | 'org'>,
db: Database,
input: { userId: string; orgId: string; body: AgentApiKeyCreateInput; now?: Date },
): Promise<AgentApiKeyCreated> {
const now = input.now ?? new Date()
await requireWorkspaceManager(deps, input.userId, input.orgId)
return deps.apiKeys.issueAgentApiKey(db, {
name: input.body.name,
orgId: input.orgId,
userId: input.userId,
scopes: normalizeScopes(input.body.scopes),
expiresAt: parseExpiresAt(input.body.expiresAt, now),
})
}
export async function rotateAgentApiKey(
deps: Pick<Deps, 'apiKeys' | 'org'>,
db: Database,
input: { userId: string; orgId: string; keyId: string; body: AgentApiKeyRotateInput; now?: Date },
): Promise<AgentApiKeyCreated> {
const now = input.now ?? new Date()
await requireWorkspaceManager(deps, input.userId, input.orgId)
const existing = await deps.apiKeys.getAgentApiKey(db, input.userId, input.orgId, input.keyId, now)
if (!existing) throw notFound('Agent API key not found')
if (existing.status !== 'active') {
throw conflict('Only active Agent API keys can be rotated', 'AGENT_API_KEY_NOT_ACTIVE')
}
return deps.apiKeys.issueAgentApiKey(db, {
name: input.body.name?.trim() || `${existing.name} rotation`,
orgId: input.orgId,
userId: input.userId,
scopes: normalizeScopes(input.body.scopes ?? existing.scopes),
expiresAt: parseExpiresAt(input.body.expiresAt ?? existing.expiresAt, now),
revokeKeyId: existing.id,
})
}
export async function revokeAgentApiKey(
deps: Pick<Deps, 'apiKeys' | 'org'>,
db: Database,
input: { userId: string; orgId: string; keyId: string; now?: Date },
): Promise<void> {
await requireWorkspaceManager(deps, input.userId, input.orgId)
const existing = await deps.apiKeys.getAgentApiKey(
db,
input.userId,
input.orgId,
input.keyId,
input.now ?? new Date(),
)
if (!existing) throw notFound('Agent API key not found')
await deps.apiKeys.revokeAgentApiKey(db, input.keyId)
}
async function requireWorkspaceManager(deps: Pick<Deps, 'org'>, userId: string, orgId: string): Promise<void> {
if (!(await deps.org.canManageAgentAccess(userId, orgId))) {
throw forbidden('Owner or admin access to the workspace is required')
}
}
function parseExpiresAt(value: string, now: Date): Date {
const expiresAt = new Date(value)
if (Number.isNaN(expiresAt.getTime())) throw badRequest('Invalid expiry')
if (expiresAt <= now) throw badRequest('Agent API key expiry must be in the future')
if (expiresAt.getTime() - now.getTime() > MAX_AGENT_API_KEY_AGE_MS) {
throw badRequest('Agent API key expiry cannot exceed one year')
}
return expiresAt
}
function normalizeScopes(scopes: readonly string[]): AgentGrantableScope[] {
const unique = new Set(scopes)
if (unique.size !== scopes.length) throw badRequest('Duplicate Agent API key scopes are not allowed')
const normalized = [...unique] as AgentGrantableScope[]
if (normalized.some((scope) => !AGENT_GRANTABLE_SCOPE_SET.has(scope))) {
throw badRequest('Agent API key scope is not grantable')
}
return normalized
}
+1 -1
View File
@@ -81,7 +81,7 @@ function actorLogId(actor: ObjectActor): string {
return actor.kind === 'download-task-upload' ? `downloader:${actor.downloaderId}` : actor.userId
}
const ROLE_LEVELS: Record<string, number> = { owner: 3, editor: 2, viewer: 1, member: 1 }
const ROLE_LEVELS: Record<string, number> = { owner: 3, admin: 3, editor: 2, viewer: 1, member: 1 }
// Whether the user may write (editor+) in the org. Personal orgs grant full
// access to their owner even without a member row.
+15
View File
@@ -1,5 +1,6 @@
import type { ApiKeyScope } from '@shared/api-key-templates'
import type { ApiKeyPermissions, AuthorizationScope } from '@shared/authorization'
import type { AgentApiKey, AgentApiKeyCreated, AgentGrantableScope } from '@shared/schemas'
import type { Database } from '../../platform/interface'
export interface VerifiedApiKey {
@@ -40,4 +41,18 @@ export interface ApiKeyGateway {
): Promise<VerifiedApiKey | null>
hasApiKeyPermission(permissions: ApiKeyPermissions | null | undefined, resource: string, action: string): boolean
hasApiKeyScope(permissions: ApiKeyPermissions | null | undefined, scope: AuthorizationScope): boolean
listAgentApiKeys(db: Database, userId: string, orgId: string, now: Date): Promise<AgentApiKey[]>
getAgentApiKey(db: Database, userId: string, orgId: string, keyId: string, now: Date): Promise<AgentApiKey | null>
issueAgentApiKey(
db: Database,
input: {
name: string
userId: string
orgId: string
scopes: AgentGrantableScope[]
expiresAt: Date
revokeKeyId?: string
},
): Promise<AgentApiKeyCreated>
revokeAgentApiKey(db: Database, keyId: string): Promise<void>
}
+1
View File
@@ -3,5 +3,6 @@ export interface OrgRepo {
getMemberRole(orgId: string, userId: string): Promise<string | null>
canReadOrg(userId: string, orgId: string): Promise<boolean>
canWriteToOrg(userId: string, orgId: string): Promise<boolean>
canManageAgentAccess(userId: string, orgId: string): Promise<boolean>
isPersonalOrg(orgId: string): Promise<boolean>
}
+1
View File
@@ -89,6 +89,7 @@ function makeDeps(
getMemberRole: async () => null,
canReadOrg: async () => false,
canWriteToOrg: async () => false,
canManageAgentAccess: async () => false,
isPersonalOrg: async () => false,
...overrides.org,
},
+50
View File
@@ -6,6 +6,7 @@ export const ApiKeyTemplate = {
IHOST: 'ihost',
WEBDAV: 'webdav',
REMOTE_DOWNLOAD: 'remote-download',
AGENT: 'agent',
} as const
export type ApiKeyTemplate = (typeof ApiKeyTemplate)[keyof typeof ApiKeyTemplate]
@@ -59,10 +60,59 @@ export const REMOTE_DOWNLOAD_API_KEY_PERMISSIONS = {
]),
} satisfies ApiKeyPermissions
export const AGENT_GRANTABLE_API_KEY_SCOPES = [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.OBJECTS_CREATE,
AuthorizationScope.OBJECTS_UPDATE,
AuthorizationScope.OBJECTS_DELETE,
AuthorizationScope.SHARES_READ,
AuthorizationScope.SHARES_CREATE,
AuthorizationScope.SHARES_DELETE,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.STORAGE_USAGE_READ,
] as const
export const AGENT_API_KEY_PERMISSIONS = scopePermissions(AGENT_GRANTABLE_API_KEY_SCOPES)
export const AgentApiKeyShortcut = {
READER: 'reader',
FILE_MANAGER: 'file-manager',
PUBLISHER: 'publisher',
} as const
export type AgentApiKeyShortcut = (typeof AgentApiKeyShortcut)[keyof typeof AgentApiKeyShortcut]
export const AGENT_API_KEY_SHORTCUT_SCOPES = {
[AgentApiKeyShortcut.READER]: [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.SHARES_READ,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.STORAGE_USAGE_READ,
],
[AgentApiKeyShortcut.FILE_MANAGER]: [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.OBJECTS_CREATE,
AuthorizationScope.OBJECTS_UPDATE,
AuthorizationScope.OBJECTS_DELETE,
AuthorizationScope.SHARES_READ,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.STORAGE_USAGE_READ,
],
[AgentApiKeyShortcut.PUBLISHER]: [
AuthorizationScope.OBJECTS_READ,
AuthorizationScope.SHARES_READ,
AuthorizationScope.SHARES_CREATE,
AuthorizationScope.SHARES_DELETE,
AuthorizationScope.QUOTA_READ,
AuthorizationScope.STORAGE_USAGE_READ,
],
} as const satisfies Record<AgentApiKeyShortcut, readonly AuthorizationScope[]>
export const API_KEY_TEMPLATE_PERMISSIONS = {
[ApiKeyTemplate.IHOST]: IHOST_API_KEY_PERMISSIONS,
[ApiKeyTemplate.WEBDAV]: WEBDAV_API_KEY_PERMISSIONS,
[ApiKeyTemplate.REMOTE_DOWNLOAD]: REMOTE_DOWNLOAD_API_KEY_PERMISSIONS,
[ApiKeyTemplate.AGENT]: AGENT_API_KEY_PERMISSIONS,
} satisfies Record<ApiKeyTemplate, ApiKeyPermissions>
export const API_KEY_TEMPLATES = Object.values(ApiKeyTemplate)
+70
View File
@@ -0,0 +1,70 @@
import { z } from 'zod'
import {
AGENT_API_KEY_SHORTCUT_SCOPES,
AGENT_GRANTABLE_API_KEY_SCOPES,
AgentApiKeyShortcut,
} from '../api-key-templates'
import { AuthorizationScope } from '../authorization'
export const agentGrantableScopeSchema = z.enum(AGENT_GRANTABLE_API_KEY_SCOPES)
export type AgentGrantableScope = z.infer<typeof agentGrantableScopeSchema>
export const agentApiKeyShortcutSchema = z.enum(Object.values(AgentApiKeyShortcut))
export type AgentApiKeyShortcutInput = z.infer<typeof agentApiKeyShortcutSchema>
export const agentApiKeyCreateSchema = z.object({
name: z.string().trim().min(1).max(120),
scopes: z.array(agentGrantableScopeSchema).min(1),
expiresAt: z.string().datetime(),
})
export type AgentApiKeyCreateInput = z.infer<typeof agentApiKeyCreateSchema>
export const agentApiKeyRotateSchema = agentApiKeyCreateSchema.partial({ name: true, scopes: true, expiresAt: true })
export type AgentApiKeyRotateInput = z.infer<typeof agentApiKeyRotateSchema>
export const agentApiKeyStatusSchema = z.enum(['active', 'expired', 'revoked', 'inaccessible'])
export type AgentApiKeyStatus = z.infer<typeof agentApiKeyStatusSchema>
export const agentApiKeySchema = z.object({
id: z.string(),
name: z.string(),
orgId: z.string(),
workspaceName: z.string().nullable(),
scopes: z.array(agentGrantableScopeSchema),
createdAt: z.string(),
expiresAt: z.string(),
lastUsedAt: z.string().nullable(),
status: agentApiKeyStatusSchema,
})
export type AgentApiKey = z.infer<typeof agentApiKeySchema>
export const agentApiKeyListSchema = z.object({
items: z.array(agentApiKeySchema),
total: z.number().int(),
page: z.number().int(),
pageSize: z.number().int(),
})
export type AgentApiKeyList = z.infer<typeof agentApiKeyListSchema>
export const agentApiKeyCreatedSchema = z.object({
key: z.string(),
item: agentApiKeySchema,
})
export type AgentApiKeyCreated = z.infer<typeof agentApiKeyCreatedSchema>
export const agentApiKeyShortcutOptions = Object.entries(AGENT_API_KEY_SHORTCUT_SCOPES).map(([id, scopes]) => ({
id: id as AgentApiKeyShortcutInput,
scopes: [...scopes],
}))
export const agentScopeLabels = {
[AuthorizationScope.OBJECTS_READ]: 'settings.agentAccess.scope.objectsRead',
[AuthorizationScope.OBJECTS_CREATE]: 'settings.agentAccess.scope.objectsCreate',
[AuthorizationScope.OBJECTS_UPDATE]: 'settings.agentAccess.scope.objectsUpdate',
[AuthorizationScope.OBJECTS_DELETE]: 'settings.agentAccess.scope.objectsDelete',
[AuthorizationScope.SHARES_READ]: 'settings.agentAccess.scope.sharesRead',
[AuthorizationScope.SHARES_CREATE]: 'settings.agentAccess.scope.sharesCreate',
[AuthorizationScope.SHARES_DELETE]: 'settings.agentAccess.scope.sharesDelete',
[AuthorizationScope.QUOTA_READ]: 'settings.agentAccess.scope.quotaRead',
[AuthorizationScope.STORAGE_USAGE_READ]: 'settings.agentAccess.scope.storageUsageRead',
} as const satisfies Record<AgentGrantableScope, string>
+22
View File
@@ -9,6 +9,28 @@ export {
adminAnalyticsTrafficSchema,
adminOverviewSchema,
} from './admin-analytics'
export type {
AgentApiKey,
AgentApiKeyCreated,
AgentApiKeyCreateInput,
AgentApiKeyList,
AgentApiKeyRotateInput,
AgentApiKeyShortcutInput,
AgentApiKeyStatus,
AgentGrantableScope,
} from './agent-api-keys'
export {
agentApiKeyCreatedSchema,
agentApiKeyCreateSchema,
agentApiKeyListSchema,
agentApiKeyRotateSchema,
agentApiKeySchema,
agentApiKeyShortcutOptions,
agentApiKeyShortcutSchema,
agentApiKeyStatusSchema,
agentGrantableScopeSchema,
agentScopeLabels,
} from './agent-api-keys'
export type {
AnnouncementInput,
+48
View File
@@ -0,0 +1,48 @@
Feature: Agent API keys
Workspace-scoped Agent API keys provide a CI and unattended-service credential
path. Keys are owned by one authorizing user, bound to one workspace, grant only
explicit Agent scopes, expire, and are revealed only once.
@agent-api-keys/lifecycle @api
Scenario: A user manages a personal workspace Agent API key
Given an authenticated personal workspace owner
When they create, list, rotate, and revoke an Agent API key
Then the plaintext key is returned only on create or rotation
And revoked keys stop working immediately
@agent-api-keys/team-file-ops @api
Scenario: A team Agent API key performs granted file operations
Given a team workspace owner creates an Agent API key with file read and create scopes
When the owner later becomes an editor
Then the key can list files and create folders in that workspace
@agent-api-keys/management-role @api
Scenario: Team credential management is restricted to owners and admins
Given a team workspace member
When an editor tries to list or create Agent API keys
Then the API denies credential management
And an owner or admin can manage Agent API keys
@agent-api-keys/scope-boundary @api
Scenario: Agent API keys cannot request non-Agent scopes
Given an authenticated workspace editor
When they request image-hosting or raw Better Auth Agent permissions
Then the API rejects the key creation request
@agent-api-keys/denials @api
Scenario: Agent API keys fail closed
Given a workspace Agent API key
When the key is missing scope, crosses workspaces, is revoked, expires, or its owner is banned
Then protected APIs reject the request
@agent-api-keys/role-reduction @api
Scenario: Agent API keys recheck current workspace role
Given a team Agent API key created by an owner
When the owner is reduced to viewer
Then management and editor-only file operations are denied
@agent-api-keys/terminal-rotation @api
Scenario: Expired and revoked Agent API keys are terminal
Given an expired or revoked Agent API key
When an owner tries to rotate it
Then the API rejects rotation and requires a new key
+50
View File
@@ -1127,6 +1127,7 @@
"settings.tabProfile": "Profile",
"settings.tabPassword": "Password",
"settings.tabApiKeys": "API Keys",
"settings.tabAgentAccess": "Agent Access",
"settings.tabWebDav": "WebDAV",
"settings.tabImageHosting": "Image Hosting",
"settings.profile.section": "Profile",
@@ -1200,6 +1201,55 @@
"settings.apiKeys.revokeSuccess": "API key revoked",
"settings.apiKeys.orgRequired": "Select a workspace before creating this API key.",
"settings.apiKeys.manage": "Manage API Keys",
"settings.agentAccess.section": "Agent Access",
"settings.agentAccess.description": "Manage workspace-scoped Agent API keys for CI and unattended services.",
"settings.agentAccess.workspaceLabel": "Workspace",
"settings.agentAccess.workspacePlaceholder": "Select a workspace",
"settings.agentAccess.create": "Create Key",
"settings.agentAccess.createTitle": "Create Agent API Key",
"settings.agentAccess.createDescription": "Choose a workspace, expiry, and exact scopes.",
"settings.agentAccess.nameLabel": "Name",
"settings.agentAccess.namePlaceholder": "e.g. GitHub Actions deploy",
"settings.agentAccess.expiryLabel": "Expiry",
"settings.agentAccess.shortcutsLabel": "Shortcuts",
"settings.agentAccess.shortcut.reader": "Reader",
"settings.agentAccess.shortcut.file-manager": "File manager",
"settings.agentAccess.shortcut.publisher": "Publisher",
"settings.agentAccess.scope.objectsRead": "Files: read objects",
"settings.agentAccess.scope.objectsCreate": "Files: create objects",
"settings.agentAccess.scope.objectsUpdate": "Files: update objects",
"settings.agentAccess.scope.objectsDelete": "Files: delete objects",
"settings.agentAccess.scope.sharesRead": "Shares: read shares",
"settings.agentAccess.scope.sharesCreate": "Shares: create shares",
"settings.agentAccess.scope.sharesDelete": "Shares: revoke shares",
"settings.agentAccess.scope.quotaRead": "Quota: read workspace quota",
"settings.agentAccess.scope.storageUsageRead": "Storage usage: read workspace usage",
"settings.agentAccess.colName": "Name",
"settings.agentAccess.colWorkspace": "Workspace",
"settings.agentAccess.colScopes": "Scopes",
"settings.agentAccess.colCreated": "Created",
"settings.agentAccess.colExpires": "Expires",
"settings.agentAccess.colLastUsed": "Last Used",
"settings.agentAccess.colStatus": "Status",
"settings.agentAccess.colActions": "Actions",
"settings.agentAccess.status.active": "Active",
"settings.agentAccess.status.expired": "Expired",
"settings.agentAccess.status.revoked": "Revoked",
"settings.agentAccess.status.inaccessible": "Inaccessible",
"settings.agentAccess.noKeys": "No Agent API keys yet",
"settings.agentAccess.managementRequired": "Owner or admin access is required to manage Agent API keys for this workspace.",
"settings.agentAccess.never": "Never",
"settings.agentAccess.copy": "Copy",
"settings.agentAccess.copied": "Copied",
"settings.agentAccess.createSuccess": "Agent API key created",
"settings.agentAccess.rotate": "Rotate",
"settings.agentAccess.rotateSuccess": "Agent API key rotated",
"settings.agentAccess.revoke": "Revoke",
"settings.agentAccess.revokeTitle": "Revoke Agent API Key",
"settings.agentAccess.revokeConfirm": "Revoke Agent API key \"{{name}}\"? Any services using it will stop immediately.",
"settings.agentAccess.revokeSuccess": "Agent API key revoked",
"settings.agentAccess.revealedTitle": "Save Your Agent API Key",
"settings.agentAccess.revealedWarning": "This is the only time this key will be shown. Store it securely.",
"settings.appearance.theme.description": "Choose how ZPan looks. Follows your system setting by default.",
"settings.appearance.language.description": "The display language for the app.",
"settings.appearance.autoSaved": "Changes apply immediately.",
+50
View File
@@ -1127,6 +1127,7 @@
"settings.tabProfile": "基本信息",
"settings.tabPassword": "密码",
"settings.tabApiKeys": "API Key",
"settings.tabAgentAccess": "Agent Access",
"settings.tabWebDav": "WebDAV",
"settings.tabImageHosting": "图床",
"settings.profile.section": "个人资料",
@@ -1200,6 +1201,55 @@
"settings.apiKeys.revokeSuccess": "API Key 已撤销",
"settings.apiKeys.orgRequired": "创建该 API Key 前请先选择工作区。",
"settings.apiKeys.manage": "管理 API Key",
"settings.agentAccess.section": "Agent Access",
"settings.agentAccess.description": "管理用于 CI 和无人值守服务的工作空间级 Agent API Key。",
"settings.agentAccess.workspaceLabel": "工作空间",
"settings.agentAccess.workspacePlaceholder": "选择工作空间",
"settings.agentAccess.create": "创建 Key",
"settings.agentAccess.createTitle": "创建 Agent API Key",
"settings.agentAccess.createDescription": "选择工作空间、过期时间和明确权限。",
"settings.agentAccess.nameLabel": "名称",
"settings.agentAccess.namePlaceholder": "例如:GitHub Actions deploy",
"settings.agentAccess.expiryLabel": "过期时间",
"settings.agentAccess.shortcutsLabel": "快捷模板",
"settings.agentAccess.shortcut.reader": "Reader",
"settings.agentAccess.shortcut.file-manager": "File manager",
"settings.agentAccess.shortcut.publisher": "Publisher",
"settings.agentAccess.scope.objectsRead": "文件:读取对象",
"settings.agentAccess.scope.objectsCreate": "文件:创建对象",
"settings.agentAccess.scope.objectsUpdate": "文件:更新对象",
"settings.agentAccess.scope.objectsDelete": "文件:删除对象",
"settings.agentAccess.scope.sharesRead": "分享:读取分享",
"settings.agentAccess.scope.sharesCreate": "分享:创建分享",
"settings.agentAccess.scope.sharesDelete": "分享:撤销分享",
"settings.agentAccess.scope.quotaRead": "配额:读取工作空间配额",
"settings.agentAccess.scope.storageUsageRead": "存储用量:读取工作空间用量",
"settings.agentAccess.colName": "名称",
"settings.agentAccess.colWorkspace": "工作空间",
"settings.agentAccess.colScopes": "权限",
"settings.agentAccess.colCreated": "创建时间",
"settings.agentAccess.colExpires": "过期时间",
"settings.agentAccess.colLastUsed": "最近使用",
"settings.agentAccess.colStatus": "状态",
"settings.agentAccess.colActions": "操作",
"settings.agentAccess.status.active": "有效",
"settings.agentAccess.status.expired": "已过期",
"settings.agentAccess.status.revoked": "已撤销",
"settings.agentAccess.status.inaccessible": "不可访问",
"settings.agentAccess.noKeys": "暂无 Agent API Key",
"settings.agentAccess.managementRequired": "需要工作空间所有者或管理员权限才能管理 Agent API Key。",
"settings.agentAccess.never": "从未",
"settings.agentAccess.copy": "复制",
"settings.agentAccess.copied": "已复制",
"settings.agentAccess.createSuccess": "Agent API Key 已创建",
"settings.agentAccess.rotate": "轮换",
"settings.agentAccess.rotateSuccess": "Agent API Key 已轮换",
"settings.agentAccess.revoke": "撤销",
"settings.agentAccess.revokeTitle": "撤销 Agent API Key",
"settings.agentAccess.revokeConfirm": "撤销 Agent API Key「{{name}}」?使用该 Key 的服务将立即停止工作。",
"settings.agentAccess.revokeSuccess": "Agent API Key 已撤销",
"settings.agentAccess.revealedTitle": "保存你的 Agent API Key",
"settings.agentAccess.revealedWarning": "该 Key 只会显示一次,请妥善保存。",
"settings.appearance.theme.description": "选择 ZPan 的外观,默认跟随系统。",
"settings.appearance.language.description": "界面显示语言。",
"settings.appearance.autoSaved": "修改即时生效。",
+94
View File
@@ -12,6 +12,7 @@ import {
connectCloud,
continueCloudOrderPayment,
copyObject,
createAgentApiKey,
createAnnouncement,
createBackgroundJob,
createCloudBillingPortalSession,
@@ -79,6 +80,7 @@ import {
listActiveAnnouncements,
listAdminAnnouncements,
listAdminAuditLogs,
listAgentApiKeys,
listAnnouncements,
listApiKeys,
listAuthProviders,
@@ -120,6 +122,7 @@ import {
resetBrandingField,
restoreObject,
retryBackgroundJob,
revokeAgentApiKey,
revokeIhostApiKey,
revokeOrgEntitlement,
revokeRemoteDownloadApiKey,
@@ -127,6 +130,7 @@ import {
revokeSiteInvitation,
revokeUserEntitlement,
revokeWebDavAppPassword,
rotateAgentApiKey,
runDownloadTaskAction,
saveBranding,
saveEmailConfig,
@@ -3104,6 +3108,96 @@ describe('api', () => {
})
})
describe('Agent Access API keys', () => {
const sampleList = {
items: [
{
id: 'agent-key-1',
name: 'CI',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read'],
createdAt: '2026-07-29T00:00:00.000Z',
expiresAt: '2026-10-27T00:00:00.000Z',
lastUsedAt: null,
status: 'active',
},
],
total: 1,
page: 1,
pageSize: 50,
}
it('lists workspace Agent API keys through the Hono RPC route', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(sampleList))
const result = await listAgentApiKeys('org-1')
expect(result).toEqual(sampleList)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/workspaces/org-1/agent-api-keys')
expect(url).toContain('page=1')
expect(url).toContain('pageSize=50')
expect(init.method).toBe('GET')
})
it('creates a workspace Agent API key with explicit scopes and expiry', async () => {
const payload = { key: 'zpan_agent_secret', item: sampleList.items[0] }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload, true, 201))
const result = await createAgentApiKey('org-1', {
name: 'CI',
scopes: ['objects:read'],
expiresAt: '2026-10-27T00:00:00.000Z',
})
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/workspaces/org-1/agent-api-keys')
expect(init.method).toBe('POST')
expect(JSON.parse(init.body as string)).toEqual({
name: 'CI',
scopes: ['objects:read'],
expiresAt: '2026-10-27T00:00:00.000Z',
})
})
it('rotates a workspace Agent API key without sending the old secret', async () => {
const payload = { key: 'zpan_agent_rotated', item: { ...sampleList.items[0], id: 'agent-key-2' } }
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(payload, true, 201))
const result = await rotateAgentApiKey('org-1', 'agent-key-1', { name: 'CI rotated' })
expect(result).toEqual(payload)
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/workspaces/org-1/agent-api-keys/agent-key-1/rotations')
expect(init.method).toBe('POST')
expect(JSON.parse(init.body as string)).toEqual({ name: 'CI rotated' })
})
it('revokes a workspace Agent API key with DELETE', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse(null, true, 204))
await revokeAgentApiKey('org-1', 'agent-key-1')
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toContain('/api/workspaces/org-1/agent-api-keys/agent-key-1')
expect(init.method).toBe('DELETE')
})
it('throws ApiError when Agent key creation fails', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'Forbidden' }, false, 403))
await expect(
createAgentApiKey('org-1', {
name: 'CI',
scopes: ['objects:read'],
expiresAt: '2026-10-27T00:00:00.000Z',
}),
).rejects.toThrow('Forbidden')
})
})
describe('listApiKeys', () => {
const sampleKey = {
id: 'key-1',
+40
View File
@@ -1,6 +1,11 @@
import { type ApiKeyMetadata, ApiKeyTemplate } from '@shared/api-key-templates'
import type { OAuthProviderConfig } from '@shared/oauth-providers'
import type {
AgentApiKey,
AgentApiKeyCreated,
AgentApiKeyCreateInput,
AgentApiKeyList,
AgentApiKeyRotateInput,
AllowedImageMime,
AnnouncementInput,
CloudCreditBalanceResponse,
@@ -97,6 +102,7 @@ import {
adminQuotas,
adminSiteInvitations,
adminTeams,
agentApiKeysApi,
announcementsApi,
authedSharesApi,
authProviders,
@@ -1076,6 +1082,40 @@ export function deleteIhostConfig() {
})
}
// Agent Access API keys
export type { AgentApiKey, AgentApiKeyCreated, AgentApiKeyCreateInput, AgentApiKeyList, AgentApiKeyRotateInput }
export function listAgentApiKeys(orgId: string, page = 1, pageSize = 50) {
return unwrap<AgentApiKeyList>(
agentApiKeysApi[':orgId']['agent-api-keys'].$get({
param: { orgId },
query: { page: String(page), pageSize: String(pageSize) },
}),
)
}
export function createAgentApiKey(orgId: string, input: AgentApiKeyCreateInput) {
return unwrap<AgentApiKeyCreated>(
agentApiKeysApi[':orgId']['agent-api-keys'].$post({ param: { orgId }, json: input }),
)
}
export function rotateAgentApiKey(orgId: string, keyId: string, input: AgentApiKeyRotateInput = {}) {
return unwrap<AgentApiKeyCreated>(
agentApiKeysApi[':orgId']['agent-api-keys'][':keyId'].rotations.$post({
param: { orgId, keyId },
json: input,
}),
)
}
export function revokeAgentApiKey(orgId: string, keyId: string) {
return agentApiKeysApi[':orgId']['agent-api-keys'][':keyId'].$delete({ param: { orgId, keyId } }).then((res) => {
if (!res.ok) throw new ApiError(res.status, toErrorBody(res.status, { error: res.statusText }))
})
}
// Image Host API Keys (via better-auth apiKey plugin)
export interface IhostApiKey {
+2
View File
@@ -6,6 +6,7 @@ import type {
AdminSiteInvitationsRoute,
AdminStatsRoute,
AdminTeamsRoute,
AgentApiKeysRoute,
AnnouncementsRoute,
AuthedSharesRoute,
AuthProvidersRoute,
@@ -49,6 +50,7 @@ export const objects = hc<ObjectsRoute>('/api/objects', opts)
export const downloadTasksApi = hc<DownloadTasksRoute>('/api/downloads/tasks', opts)
export const downloaderSelfApi = hc<DownloaderSelfRoute>('/api/downloads/downloaders', opts)
export const trash = hc<TrashRoute>('/api/trash', opts)
export const agentApiKeysApi = hc<AgentApiKeysRoute>('/api/workspaces', opts)
export const storages = hc<StoragesRoute>('/api/site/storages', opts)
export const storageUsageApi = hc<StorageUsageRoute>('/api/storage', opts)
export const adminDownloadersApi = hc<DownloadersRoute>('/api/downloads/downloaders', opts)
+23
View File
@@ -39,6 +39,7 @@ import { Route as AuthenticatedSettingsWebdavRouteImport } from './routes/_authe
import { Route as AuthenticatedSettingsProfileRouteImport } from './routes/_authenticated/settings/profile'
import { Route as AuthenticatedSettingsPasswordRouteImport } from './routes/_authenticated/settings/password'
import { Route as AuthenticatedSettingsApiKeysRouteImport } from './routes/_authenticated/settings/api-keys'
import { Route as AuthenticatedSettingsAgentAccessRouteImport } from './routes/_authenticated/settings/agent-access'
import { Route as AuthenticatedAdminLicensingRouteImport } from './routes/_authenticated/admin/licensing'
import { Route as AuthenticatedAdminDownloadersRouteImport } from './routes/_authenticated/admin/downloaders'
import { Route as AuthenticatedAdminDashboardRouteImport } from './routes/_authenticated/admin/dashboard'
@@ -222,6 +223,12 @@ const AuthenticatedSettingsApiKeysRoute =
path: '/api-keys',
getParentRoute: () => AuthenticatedSettingsRouteRoute,
} as any)
const AuthenticatedSettingsAgentAccessRoute =
AuthenticatedSettingsAgentAccessRouteImport.update({
id: '/agent-access',
path: '/agent-access',
getParentRoute: () => AuthenticatedSettingsRouteRoute,
} as any)
const AuthenticatedAdminLicensingRoute =
AuthenticatedAdminLicensingRouteImport.update({
id: '/licensing',
@@ -376,6 +383,7 @@ export interface FileRoutesByFullPath {
'/admin/dashboard': typeof AuthenticatedAdminDashboardRoute
'/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute
'/admin/licensing': typeof AuthenticatedAdminLicensingRoute
'/settings/agent-access': typeof AuthenticatedSettingsAgentAccessRoute
'/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
'/settings/password': typeof AuthenticatedSettingsPasswordRoute
'/settings/profile': typeof AuthenticatedSettingsProfileRoute
@@ -426,6 +434,7 @@ export interface FileRoutesByTo {
'/admin/dashboard': typeof AuthenticatedAdminDashboardRoute
'/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute
'/admin/licensing': typeof AuthenticatedAdminLicensingRoute
'/settings/agent-access': typeof AuthenticatedSettingsAgentAccessRoute
'/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
'/settings/password': typeof AuthenticatedSettingsPasswordRoute
'/settings/profile': typeof AuthenticatedSettingsProfileRoute
@@ -481,6 +490,7 @@ export interface FileRoutesById {
'/_authenticated/admin/dashboard': typeof AuthenticatedAdminDashboardRoute
'/_authenticated/admin/downloaders': typeof AuthenticatedAdminDownloadersRoute
'/_authenticated/admin/licensing': typeof AuthenticatedAdminLicensingRoute
'/_authenticated/settings/agent-access': typeof AuthenticatedSettingsAgentAccessRoute
'/_authenticated/settings/api-keys': typeof AuthenticatedSettingsApiKeysRoute
'/_authenticated/settings/password': typeof AuthenticatedSettingsPasswordRoute
'/_authenticated/settings/profile': typeof AuthenticatedSettingsProfileRoute
@@ -536,6 +546,7 @@ export interface FileRouteTypes {
| '/admin/dashboard'
| '/admin/downloaders'
| '/admin/licensing'
| '/settings/agent-access'
| '/settings/api-keys'
| '/settings/password'
| '/settings/profile'
@@ -586,6 +597,7 @@ export interface FileRouteTypes {
| '/admin/dashboard'
| '/admin/downloaders'
| '/admin/licensing'
| '/settings/agent-access'
| '/settings/api-keys'
| '/settings/password'
| '/settings/profile'
@@ -640,6 +652,7 @@ export interface FileRouteTypes {
| '/_authenticated/admin/dashboard'
| '/_authenticated/admin/downloaders'
| '/_authenticated/admin/licensing'
| '/_authenticated/settings/agent-access'
| '/_authenticated/settings/api-keys'
| '/_authenticated/settings/password'
| '/_authenticated/settings/profile'
@@ -895,6 +908,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthenticatedSettingsApiKeysRouteImport
parentRoute: typeof AuthenticatedSettingsRouteRoute
}
'/_authenticated/settings/agent-access': {
id: '/_authenticated/settings/agent-access'
path: '/agent-access'
fullPath: '/settings/agent-access'
preLoaderRoute: typeof AuthenticatedSettingsAgentAccessRouteImport
parentRoute: typeof AuthenticatedSettingsRouteRoute
}
'/_authenticated/admin/licensing': {
id: '/_authenticated/admin/licensing'
path: '/licensing'
@@ -1097,6 +1117,7 @@ const AuthenticatedAdminRouteRouteWithChildren =
)
interface AuthenticatedSettingsRouteRouteChildren {
AuthenticatedSettingsAgentAccessRoute: typeof AuthenticatedSettingsAgentAccessRoute
AuthenticatedSettingsApiKeysRoute: typeof AuthenticatedSettingsApiKeysRoute
AuthenticatedSettingsPasswordRoute: typeof AuthenticatedSettingsPasswordRoute
AuthenticatedSettingsProfileRoute: typeof AuthenticatedSettingsProfileRoute
@@ -1106,6 +1127,8 @@ interface AuthenticatedSettingsRouteRouteChildren {
const AuthenticatedSettingsRouteRouteChildren: AuthenticatedSettingsRouteRouteChildren =
{
AuthenticatedSettingsAgentAccessRoute:
AuthenticatedSettingsAgentAccessRoute,
AuthenticatedSettingsApiKeysRoute: AuthenticatedSettingsApiKeysRoute,
AuthenticatedSettingsPasswordRoute: AuthenticatedSettingsPasswordRoute,
AuthenticatedSettingsProfileRoute: AuthenticatedSettingsProfileRoute,
@@ -0,0 +1,316 @@
import type { AgentApiKey } from '@shared/schemas'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { toast } from 'sonner'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '@/lib/api'
import { useListOrganizations } from '@/lib/auth-client'
import { AgentAccessSettingsPage } from './agent-access'
import { SettingsLayout } from './route'
const state = vi.hoisted(() => ({
orgs: [
{ id: 'org-1', name: 'Personal' },
{ id: 'org-2', name: 'Team Alpha' },
],
keys: [] as AgentApiKey[],
webdavEnabled: true,
}))
const translations: Record<string, string> = {
'settings.agentAccess.scope.objectsRead': 'Files: read objects',
'settings.agentAccess.scope.objectsCreate': 'Files: create objects',
'settings.agentAccess.scope.objectsUpdate': 'Files: update objects',
'settings.agentAccess.scope.objectsDelete': 'Files: delete objects',
'settings.agentAccess.scope.sharesRead': 'Shares: read shares',
'settings.agentAccess.scope.sharesCreate': 'Shares: create shares',
'settings.agentAccess.scope.sharesDelete': 'Shares: revoke shares',
'settings.agentAccess.scope.quotaRead': 'Quota: read workspace quota',
'settings.agentAccess.scope.storageUsageRead': 'Storage usage: read workspace usage',
'settings.agentAccess.managementRequired': 'Owner or admin access is required',
}
vi.mock('react-i18next', () => ({
useTranslation: () => ({ t: (key: string) => translations[key] ?? key }),
}))
vi.mock('sonner', () => ({
toast: { success: vi.fn(), error: vi.fn() },
}))
vi.mock('@tanstack/react-router', () => ({
Outlet: () => <div>outlet</div>,
createFileRoute: () => (options: unknown) => options,
}))
vi.mock('@/components/layout/page-header', () => ({
PageHeader: () => <div>page-header</div>,
}))
vi.mock('@/components/layout/page-tabs', () => ({
PageTabs: ({ items }: { items: Array<{ label: string }> }) => <div>{items.map((item) => item.label).join('|')}</div>,
}))
vi.mock('@/hooks/use-site-config', () => ({
useSiteConfig: () => ({
data: { services: { webdav: { enabled: state.webdavEnabled } } },
}),
}))
vi.mock('@/lib/auth-client', () => ({
useListOrganizations: vi.fn(),
}))
vi.mock('@/lib/api', () => ({
createAgentApiKey: vi.fn(),
listAgentApiKeys: vi.fn(),
revokeAgentApiKey: vi.fn(),
rotateAgentApiKey: vi.fn(),
}))
const queryClients: QueryClient[] = []
function renderWithQuery(ui: React.ReactNode) {
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: false },
mutations: { retry: false },
},
})
queryClient.setDefaultOptions({
queries: { retry: false, gcTime: 0 },
mutations: { retry: false, gcTime: 0 },
})
queryClients.push(queryClient)
return render(<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>)
}
beforeEach(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
},
)
vi.mocked(useListOrganizations).mockReturnValue({ data: state.orgs } as never)
vi.mocked(listAgentApiKeys).mockImplementation(async (orgId: string) => ({
items: state.keys.filter((item) => item.orgId === orgId),
total: state.keys.filter((item) => item.orgId === orgId).length,
page: 1,
pageSize: 50,
}))
})
afterEach(() => {
cleanup()
for (const queryClient of queryClients.splice(0)) queryClient.clear()
vi.clearAllMocks()
vi.unstubAllGlobals()
state.keys = []
state.webdavEnabled = true
})
describe('Agent Access settings page', () => {
it('loads the first workspace, fetches its keys, and keeps creation inside a dialog', async () => {
renderWithQuery(<AgentAccessSettingsPage />)
await waitFor(() => expect(listAgentApiKeys).toHaveBeenCalledWith('org-1'))
expect(await screen.findByText('settings.agentAccess.noKeys')).toBeTruthy()
expect(screen.queryByLabelText('settings.agentAccess.nameLabel')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.create' }))
expect(screen.getByLabelText('settings.agentAccess.nameLabel')).toBeTruthy()
expect(screen.getByLabelText('settings.agentAccess.expiryLabel')).toBeTruthy()
for (const label of [
'Files: read objects',
'Files: create objects',
'Files: update objects',
'Files: delete objects',
'Shares: read shares',
'Shares: create shares',
'Shares: revoke shares',
'Quota: read workspace quota',
'Storage usage: read workspace usage',
]) {
expect(screen.getByText(label)).toBeTruthy()
}
expect(screen.queryByText(/settings\.agentAccess\.scope\..*:/)).toBeNull()
})
it('creates a workspace Agent API key and reveals the secret once', async () => {
vi.mocked(createAgentApiKey).mockResolvedValue({
key: 'zpan_agent_secret',
item: {
id: 'agent-key-1',
name: 'CI key',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read'],
createdAt: '2026-07-29T12:00:00.000Z',
expiresAt: '2026-10-27T23:59:59.000Z',
lastUsedAt: null,
status: 'active',
},
})
renderWithQuery(<AgentAccessSettingsPage />)
await waitFor(() => expect(listAgentApiKeys).toHaveBeenCalledWith('org-1'))
await screen.findByText('settings.agentAccess.noKeys')
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.create' }))
const dialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.createTitle' })
fireEvent.change(within(dialog).getByLabelText('settings.agentAccess.nameLabel'), {
target: { value: ' CI key ' },
})
fireEvent.click(within(dialog).getByRole('button', { name: 'settings.agentAccess.create' }))
await waitFor(() =>
expect(createAgentApiKey).toHaveBeenCalledWith(
'org-1',
expect.objectContaining({
name: 'CI key',
scopes: ['objects:read', 'shares:read', 'quota:read', 'storage-usage:read'],
expiresAt: expect.stringMatching(/T23:59:59\.000Z$/),
}),
),
)
expect(screen.getByText('zpan_agent_secret')).toBeTruthy()
expect(toast.success).toHaveBeenCalledWith('settings.agentAccess.createSuccess')
})
it('rotates and revokes an existing workspace Agent API key', async () => {
state.keys = [
{
id: 'agent-key-1',
name: 'CI key',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read'],
createdAt: '2026-07-29T12:00:00.000Z',
expiresAt: '2026-10-27T23:59:59.000Z',
lastUsedAt: null,
status: 'active',
},
]
vi.mocked(rotateAgentApiKey).mockResolvedValue({
key: 'zpan_agent_rotated',
item: {
...state.keys[0],
id: 'agent-key-2',
},
})
vi.mocked(revokeAgentApiKey).mockResolvedValue(undefined)
renderWithQuery(<AgentAccessSettingsPage />)
await screen.findByText('CI key')
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.rotate' }))
await waitFor(() => expect(rotateAgentApiKey).toHaveBeenCalledWith('org-1', 'agent-key-1'))
const revealedDialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.revealedTitle' })
expect(within(revealedDialog).getByText('zpan_agent_rotated')).toBeTruthy()
expect(toast.success).toHaveBeenCalledWith('settings.agentAccess.rotateSuccess')
fireEvent.click(within(revealedDialog).getAllByRole('button', { name: 'common.close' })[1]!)
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'settings.agentAccess.revealedTitle' })).toBeNull())
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.revoke' }))
const revokeDialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.revokeTitle' })
fireEvent.click(within(revokeDialog).getByRole('button', { name: 'settings.agentAccess.revoke' }))
await waitFor(() => expect(revokeAgentApiKey).toHaveBeenCalledWith('org-1', 'agent-key-1'))
expect(toast.success).toHaveBeenCalledWith('settings.agentAccess.revokeSuccess')
})
it('surfaces rotate and revoke errors and lets the revoke dialog close from its close control', async () => {
state.keys = [
{
id: 'agent-key-1',
name: 'CI key',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read'],
createdAt: '2026-07-29T12:00:00.000Z',
expiresAt: '2026-10-27T23:59:59.000Z',
lastUsedAt: null,
status: 'active',
},
]
vi.mocked(rotateAgentApiKey).mockRejectedValue(new Error('rotate failed'))
vi.mocked(revokeAgentApiKey).mockRejectedValue(new Error('revoke failed'))
renderWithQuery(<AgentAccessSettingsPage />)
await screen.findByText('CI key')
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.rotate' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('rotate failed'))
fireEvent.click(screen.getByRole('button', { name: 'settings.agentAccess.revoke' }))
const revokeDialog = await screen.findByRole('dialog', { name: 'settings.agentAccess.revokeTitle' })
fireEvent.click(within(revokeDialog).getByRole('button', { name: 'settings.agentAccess.revoke' }))
await waitFor(() => expect(toast.error).toHaveBeenCalledWith('revoke failed'))
fireEvent.click(within(revokeDialog).getByRole('button', { name: 'common.close' }))
await waitFor(() => expect(screen.queryByRole('dialog', { name: 'settings.agentAccess.revokeTitle' })).toBeNull())
})
it('does not offer rotation for expired or revoked keys', async () => {
state.keys = [
{
id: 'expired-key',
name: 'Expired key',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read'],
createdAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-02-01T00:00:00.000Z',
lastUsedAt: null,
status: 'expired',
},
{
id: 'revoked-key',
name: 'Revoked key',
orgId: 'org-1',
workspaceName: 'Personal',
scopes: ['objects:read'],
createdAt: '2026-01-01T00:00:00.000Z',
expiresAt: '2026-12-01T00:00:00.000Z',
lastUsedAt: null,
status: 'revoked',
},
]
renderWithQuery(<AgentAccessSettingsPage />)
await screen.findByText('Expired key')
expect(screen.getByText('Revoked key')).toBeTruthy()
expect(screen.queryByRole('button', { name: 'settings.agentAccess.rotate' })).toBeNull()
})
it('disables credential creation when the workspace management check fails', async () => {
vi.mocked(listAgentApiKeys).mockRejectedValue(new Error('Forbidden'))
renderWithQuery(<AgentAccessSettingsPage />)
expect(await screen.findByText('Owner or admin access is required')).toBeTruthy()
expect(screen.getByRole('button', { name: 'settings.agentAccess.create' }).hasAttribute('disabled')).toBe(true)
})
})
describe('Settings layout tabs', () => {
it('includes the Agent Access tab alongside existing settings tabs', () => {
renderWithQuery(<SettingsLayout />)
expect(screen.getByText(/settings\.tabApiKeys\|settings\.tabAgentAccess/)).toBeTruthy()
})
it('keeps the Agent Access tab when WebDAV is disabled', () => {
state.webdavEnabled = false
renderWithQuery(<SettingsLayout />)
expect(screen.getByText(/settings\.tabApiKeys\|settings\.tabAgentAccess/)).toBeTruthy()
expect(screen.queryByText(/settings\.tabWebDav/)).toBeNull()
})
})
@@ -0,0 +1,419 @@
import { type AgentGrantableScope, agentApiKeyShortcutOptions, agentScopeLabels } from '@shared/schemas'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { createFileRoute } from '@tanstack/react-router'
import { Copy, KeyRound, Plus, RotateCw, Trash2 } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { type AgentApiKey, createAgentApiKey, listAgentApiKeys, revokeAgentApiKey, rotateAgentApiKey } from '@/lib/api'
import { useListOrganizations } from '@/lib/auth-client'
export const Route = createFileRoute('/_authenticated/settings/agent-access')({
component: AgentAccessSettingsPage,
})
interface Organization {
id: string
name: string
}
interface RevealedKey {
name: string
key: string
}
const allAgentScopes = Object.keys(agentScopeLabels) as AgentGrantableScope[]
function defaultExpiryDate(): string {
const date = new Date()
date.setDate(date.getDate() + 90)
return date.toISOString().slice(0, 10)
}
function expiryDateToIso(value: string): string {
return new Date(`${value}T23:59:59.000Z`).toISOString()
}
function formatDate(value: string | null) {
return value ? new Date(value).toLocaleString() : null
}
function CopyButton({ value }: { value: string }) {
const { t } = useTranslation()
return (
<Button
type="button"
size="icon"
variant="ghost"
aria-label={t('settings.agentAccess.copy')}
onClick={async () => {
await navigator.clipboard.writeText(value)
toast.success(t('settings.agentAccess.copied'))
}}
>
<Copy className="size-4" />
<span className="sr-only">{t('settings.agentAccess.copy')}</span>
</Button>
)
}
function CreateAgentKeyDialog({
open,
orgId,
onOpenChange,
onCreated,
}: {
open: boolean
orgId: string
onOpenChange: (open: boolean) => void
onCreated: (key: RevealedKey) => void
}) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const [name, setName] = useState('')
const [expiryDate, setExpiryDate] = useState(defaultExpiryDate)
const [scopes, setScopes] = useState<AgentGrantableScope[]>(agentApiKeyShortcutOptions[0]?.scopes ?? [])
const createMutation = useMutation({
mutationFn: () =>
createAgentApiKey(orgId, {
name: name.trim(),
scopes,
expiresAt: expiryDateToIso(expiryDate),
}),
onSuccess: (result) => {
queryClient.invalidateQueries({ queryKey: ['agent-api-keys', orgId] })
onCreated({ name: result.item.name, key: result.key })
setName('')
setExpiryDate(defaultExpiryDate())
setScopes(agentApiKeyShortcutOptions[0]?.scopes ?? [])
onOpenChange(false)
toast.success(t('settings.agentAccess.createSuccess'))
},
onError: (err) => toast.error(err.message),
})
function toggleScope(scope: AgentGrantableScope, checked: boolean) {
setScopes((current) => (checked ? [...current, scope] : current.filter((item) => item !== scope)))
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t('settings.agentAccess.createTitle')}</DialogTitle>
<DialogDescription>{t('settings.agentAccess.createDescription')}</DialogDescription>
</DialogHeader>
<div className="grid gap-4">
<div className="space-y-2">
<Label htmlFor="agent-key-name">{t('settings.agentAccess.nameLabel')}</Label>
<Input
id="agent-key-name"
value={name}
onChange={(event) => setName(event.target.value)}
placeholder={t('settings.agentAccess.namePlaceholder')}
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent-key-expiry">{t('settings.agentAccess.expiryLabel')}</Label>
<Input
id="agent-key-expiry"
type="date"
value={expiryDate}
max={oneYearDate()}
onChange={(event) => setExpiryDate(event.target.value)}
/>
</div>
<div className="space-y-2">
<Label>{t('settings.agentAccess.shortcutsLabel')}</Label>
<div className="flex flex-wrap gap-2">
{agentApiKeyShortcutOptions.map((shortcut) => (
<Button
key={shortcut.id}
type="button"
variant="outline"
size="sm"
onClick={() => setScopes([...shortcut.scopes])}
>
{t(`settings.agentAccess.shortcut.${shortcut.id}`)}
</Button>
))}
</div>
</div>
<div className="grid gap-3 sm:grid-cols-2">
{allAgentScopes.map((scope) => {
const checkboxId = `agent-key-scope-${scope}`
return (
<div key={scope} className="flex min-h-10 items-center gap-3 rounded-md border px-3 py-2 text-sm">
<Checkbox
id={checkboxId}
checked={scopes.includes(scope)}
onCheckedChange={(checked) => toggleScope(scope, !!checked)}
/>
<Label htmlFor={checkboxId} className="font-normal">
{t(agentScopeLabels[scope])}
</Label>
</div>
)
})}
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button
type="button"
disabled={!orgId || !name.trim() || scopes.length === 0 || !expiryDate || createMutation.isPending}
onClick={() => createMutation.mutate()}
>
<Plus className="size-4" />
{createMutation.isPending ? t('common.loading') : t('settings.agentAccess.create')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function oneYearDate(): string {
const date = new Date()
date.setFullYear(date.getFullYear() + 1)
return date.toISOString().slice(0, 10)
}
function RevealedKeyDialog({ revealedKey, onClose }: { revealedKey: RevealedKey | null; onClose: () => void }) {
const { t } = useTranslation()
if (!revealedKey) return null
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('settings.agentAccess.revealedTitle')}</DialogTitle>
<DialogDescription>{t('settings.agentAccess.revealedWarning')}</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label>{revealedKey.name}</Label>
<div className="flex items-center gap-2 rounded-md border bg-muted/40 p-2">
<code className="min-w-0 flex-1 break-all text-sm">{revealedKey.key}</code>
<CopyButton value={revealedKey.key} />
</div>
</div>
<DialogFooter>
<Button type="button" onClick={onClose}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
function RevokeAgentKeyDialog({ apiKey, onClose }: { apiKey: AgentApiKey | null; onClose: () => void }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
const revokeMutation = useMutation({
mutationFn: async () => {
if (!apiKey) return
await revokeAgentApiKey(apiKey.orgId, apiKey.id)
},
onSuccess: () => {
if (apiKey) queryClient.invalidateQueries({ queryKey: ['agent-api-keys', apiKey.orgId] })
toast.success(t('settings.agentAccess.revokeSuccess'))
onClose()
},
onError: (err) => toast.error(err.message),
})
if (!apiKey) return null
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('settings.agentAccess.revokeTitle')}</DialogTitle>
<DialogDescription>{t('settings.agentAccess.revokeConfirm', { name: apiKey.name })}</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose}>
{t('common.cancel')}
</Button>
<Button
type="button"
variant="destructive"
disabled={revokeMutation.isPending}
onClick={() => revokeMutation.mutate()}
>
<Trash2 className="size-4" />
{revokeMutation.isPending ? t('common.loading') : t('settings.agentAccess.revoke')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
export function AgentAccessSettingsPage() {
const { t } = useTranslation()
const queryClient = useQueryClient()
const { data: organizationData } = useListOrganizations()
const organizations = (organizationData ?? []) as Organization[]
const [orgId, setOrgId] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [revealedKey, setRevealedKey] = useState<RevealedKey | null>(null)
const [revoking, setRevoking] = useState<AgentApiKey | null>(null)
useEffect(() => {
if (!orgId && organizations[0]) setOrgId(organizations[0].id)
}, [orgId, organizations])
const keysQuery = useQuery({
queryKey: ['agent-api-keys', orgId],
queryFn: () => listAgentApiKeys(orgId),
enabled: !!orgId,
})
const rows = keysQuery.data?.items ?? []
async function rotate(apiKey: AgentApiKey) {
try {
const result = await rotateAgentApiKey(apiKey.orgId, apiKey.id)
queryClient.invalidateQueries({ queryKey: ['agent-api-keys', apiKey.orgId] })
setRevealedKey({ name: result.item.name, key: result.key })
toast.success(t('settings.agentAccess.rotateSuccess'))
} catch (err) {
toast.error(err instanceof Error ? err.message : t('common.error'))
}
}
return (
<div className="max-w-6xl">
<Card>
<CardHeader>
<CardTitle>{t('settings.agentAccess.section')}</CardTitle>
<CardDescription>{t('settings.agentAccess.description')}</CardDescription>
<CardAction>
<Button
type="button"
disabled={!orgId || keysQuery.isLoading || keysQuery.isError}
onClick={() => setCreateOpen(true)}
>
<Plus className="size-4" />
{t('settings.agentAccess.create')}
</Button>
</CardAction>
</CardHeader>
<CardContent className="space-y-4">
<div className="max-w-sm space-y-2">
<Label htmlFor="agent-access-workspace">{t('settings.agentAccess.workspaceLabel')}</Label>
<Select value={orgId} onValueChange={setOrgId}>
<SelectTrigger id="agent-access-workspace">
<SelectValue placeholder={t('settings.agentAccess.workspacePlaceholder')} />
</SelectTrigger>
<SelectContent>
{organizations.map((org) => (
<SelectItem key={org.id} value={org.id}>
{org.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{keysQuery.isLoading ? (
<p className="py-6 text-center text-sm text-muted-foreground">{t('common.loading')}</p>
) : keysQuery.isError ? (
<p className="py-6 text-center text-sm text-destructive">{t('settings.agentAccess.managementRequired')}</p>
) : rows.length === 0 ? (
<p className="py-6 text-center text-sm text-muted-foreground">{t('settings.agentAccess.noKeys')}</p>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('settings.agentAccess.colName')}</TableHead>
<TableHead>{t('settings.agentAccess.colWorkspace')}</TableHead>
<TableHead>{t('settings.agentAccess.colScopes')}</TableHead>
<TableHead>{t('settings.agentAccess.colCreated')}</TableHead>
<TableHead>{t('settings.agentAccess.colExpires')}</TableHead>
<TableHead>{t('settings.agentAccess.colLastUsed')}</TableHead>
<TableHead>{t('settings.agentAccess.colStatus')}</TableHead>
<TableHead className="w-24 text-right">{t('settings.agentAccess.colActions')}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell>
<div className="flex items-center gap-2 font-medium">
<KeyRound className="size-4 text-muted-foreground" />
{row.name}
</div>
</TableCell>
<TableCell>{row.workspaceName ?? row.orgId}</TableCell>
<TableCell>
<div className="flex max-w-md flex-wrap gap-1">
{row.scopes.map((scope) => (
<Badge key={scope} variant="secondary">
{t(agentScopeLabels[scope])}
</Badge>
))}
</div>
</TableCell>
<TableCell>{formatDate(row.createdAt)}</TableCell>
<TableCell>{formatDate(row.expiresAt)}</TableCell>
<TableCell>{formatDate(row.lastUsedAt) ?? t('settings.agentAccess.never')}</TableCell>
<TableCell>
<Badge variant={row.status === 'active' ? 'default' : 'secondary'}>
{t(`settings.agentAccess.status.${row.status}`)}
</Badge>
</TableCell>
<TableCell className="text-right">
{row.status === 'active' ? (
<Button
type="button"
size="icon"
variant="ghost"
aria-label={t('settings.agentAccess.rotate')}
onClick={() => rotate(row)}
>
<RotateCw className="size-4" />
<span className="sr-only">{t('settings.agentAccess.rotate')}</span>
</Button>
) : null}
<Button
type="button"
size="icon"
variant="ghost"
aria-label={t('settings.agentAccess.revoke')}
onClick={() => setRevoking(row)}
>
<Trash2 className="size-4" />
<span className="sr-only">{t('settings.agentAccess.revoke')}</span>
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<CreateAgentKeyDialog open={createOpen} orgId={orgId} onOpenChange={setCreateOpen} onCreated={setRevealedKey} />
<RevealedKeyDialog revealedKey={revealedKey} onClose={() => setRevealedKey(null)} />
<RevokeAgentKeyDialog apiKey={revoking} onClose={() => setRevoking(null)} />
</div>
)
}
+2 -1
View File
@@ -9,7 +9,7 @@ export const Route = createFileRoute('/_authenticated/settings')({
component: SettingsLayout,
})
function SettingsLayout() {
export function SettingsLayout() {
const { t } = useTranslation()
const { data: siteConfig } = useSiteConfig()
@@ -20,6 +20,7 @@ function SettingsLayout() {
? []
: [{ to: '/settings/webdav', label: t('settings.tabWebDav') }]),
{ to: '/settings/api-keys', label: t('settings.tabApiKeys') },
{ to: '/settings/agent-access', label: t('settings.tabAgentAccess') },
]
return (