From 02a24593efadb43f553ed38b01eb154ab9db9db9 Mon Sep 17 00:00:00 2001 From: John Sell Date: Wed, 3 Jun 2026 21:59:51 -0400 Subject: [PATCH 01/24] feat(ambient-ui): Credentials view with registry, CRUD, and binding matrix Add global Credentials page at /credentials with two tabs: - Registry: credential table grouped by provider category, create/manage sheets with provider-specific fields, token rotation, delete - Access Matrix: spreadsheet grid for credential-to-project/agent binding with inheritance, optimistic updates, bulk ops, keyboard nav, pagination Domain layer: DomainCredential, DomainRoleBinding types, provider registry Port/adapter: CredentialsPort, RoleBindingsPort with SDK implementations Query hooks: CRUD mutations with cache invalidation Sidebar: Configure group with Credentials nav item (global scope) Co-Authored-By: Claude Opus 4.6 (1M context) --- components/ambient-ui/src/adapters/index.ts | 2 + components/ambient-ui/src/adapters/mappers.ts | 33 +- .../src/adapters/sdk-credentials.ts | 97 ++ .../src/adapters/sdk-role-bindings.ts | 73 ++ .../_components/binding-matrix.tsx | 1143 +++++++++++++++++ .../_components/credential-create-sheet.tsx | 265 ++++ .../_components/credential-manage-sheet.tsx | 251 ++++ .../_components/credential-table.tsx | 260 ++++ .../src/app/(dashboard)/credentials/page.tsx | 108 ++ .../ambient-ui/src/components/app-sidebar.tsx | 31 +- .../src/domain/credential-providers.ts | 68 + components/ambient-ui/src/domain/types.ts | 52 + .../ambient-ui/src/ports/credentials.ts | 15 + components/ambient-ui/src/ports/index.ts | 2 + .../ambient-ui/src/ports/role-bindings.ts | 12 + .../ambient-ui/src/queries/query-keys.ts | 15 + .../ambient-ui/src/queries/use-credentials.ts | 79 ++ .../src/queries/use-role-bindings.ts | 53 + 18 files changed, 2549 insertions(+), 10 deletions(-) create mode 100644 components/ambient-ui/src/adapters/sdk-credentials.ts create mode 100644 components/ambient-ui/src/adapters/sdk-role-bindings.ts create mode 100644 components/ambient-ui/src/app/(dashboard)/credentials/_components/binding-matrix.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/credentials/_components/credential-create-sheet.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/credentials/_components/credential-manage-sheet.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/credentials/_components/credential-table.tsx create mode 100644 components/ambient-ui/src/app/(dashboard)/credentials/page.tsx create mode 100644 components/ambient-ui/src/domain/credential-providers.ts create mode 100644 components/ambient-ui/src/ports/credentials.ts create mode 100644 components/ambient-ui/src/ports/role-bindings.ts create mode 100644 components/ambient-ui/src/queries/use-credentials.ts create mode 100644 components/ambient-ui/src/queries/use-role-bindings.ts diff --git a/components/ambient-ui/src/adapters/index.ts b/components/ambient-ui/src/adapters/index.ts index 4a880e4df..ffbc9cece 100644 --- a/components/ambient-ui/src/adapters/index.ts +++ b/components/ambient-ui/src/adapters/index.ts @@ -2,3 +2,5 @@ export { getSessionAPI, getProjectAPI, getConfig } from './sdk-client' export { createSessionsAdapter } from './sdk-sessions' export { createProjectsAdapter } from './sdk-projects' export { createSessionMessagesAdapterWithFetch } from './session-messages' +export { createCredentialsAdapter } from './sdk-credentials' +export { createRoleBindingsAdapter } from './sdk-role-bindings' diff --git a/components/ambient-ui/src/adapters/mappers.ts b/components/ambient-ui/src/adapters/mappers.ts index 3bdba65db..13c1b7948 100644 --- a/components/ambient-ui/src/adapters/mappers.ts +++ b/components/ambient-ui/src/adapters/mappers.ts @@ -1,7 +1,8 @@ -import type { Session, Project, Agent } from 'ambient-sdk' +import type { Session, Project, Agent, Credential, RoleBinding } from 'ambient-sdk' import type { DomainSession, DomainProject, DomainSessionMessage, DomainAgent, SessionPhase, SessionEventType, DomainRepo, DomainReconciledRepo, DomainCondition, ReconciledRepoStatus, ConditionStatus, + DomainCredential, DomainRoleBinding, } from '@/domain/types' const VALID_PHASES: ReadonlySet = new Set([ @@ -219,3 +220,33 @@ export function mapSessionMessageToDomain(sdk: SdkSessionMessageShape): DomainSe createdAt: sdk.created_at ?? '', } } + +export function mapSdkCredentialToDomain(sdk: Credential): DomainCredential { + return { + id: sdk.id, + name: sdk.name, + provider: sdk.provider, + description: emptyToNull(sdk.description), + email: emptyToNull(sdk.email), + url: emptyToNull(sdk.url), + annotations: parseJsonObject(sdk.annotations), + labels: parseJsonObject(sdk.labels), + createdAt: sdk.created_at ?? '', + updatedAt: sdk.updated_at ?? '', + } +} + +export function mapSdkRoleBindingToDomain(sdk: RoleBinding): DomainRoleBinding { + return { + id: sdk.id, + roleId: sdk.role_id, + scope: sdk.scope, + userId: emptyToNull(sdk.user_id ?? ''), + projectId: emptyToNull(sdk.project_id ?? ''), + agentId: emptyToNull(sdk.agent_id ?? ''), + credentialId: emptyToNull(sdk.credential_id ?? ''), + sessionId: emptyToNull(sdk.session_id ?? ''), + createdAt: sdk.created_at ?? '', + updatedAt: sdk.updated_at ?? '', + } +} diff --git a/components/ambient-ui/src/adapters/sdk-credentials.ts b/components/ambient-ui/src/adapters/sdk-credentials.ts new file mode 100644 index 000000000..5f26b76ab --- /dev/null +++ b/components/ambient-ui/src/adapters/sdk-credentials.ts @@ -0,0 +1,97 @@ +import { CredentialAPI } from 'ambient-sdk' +import type { CredentialCreateRequest, CredentialPatchRequest } from 'ambient-sdk' +import type { CredentialsPort } from '@/ports/credentials' +import type { + DomainCredential, + DomainCredentialCreateRequest, + DomainCredentialUpdateRequest, + ListParams, + PaginatedResult, +} from '@/domain/types' +import { mapSdkCredentialToDomain } from './mappers' +import { getConfig } from './sdk-client' + +function sanitizeSearch(value: string): string { + return value.replace(/['"%;\\]/g, '') +} + +function getAPI(): CredentialAPI { + return new CredentialAPI(getConfig()) +} + +function buildSdkListOptions(params?: ListParams) { + return { + page: params?.page ?? 1, + size: params?.size ?? 20, + search: params?.search + ? `name like '%${sanitizeSearch(params.search)}%'` + : undefined, + orderBy: params?.orderBy, + } +} + +function mapDomainCreateToSdk(request: DomainCredentialCreateRequest): CredentialCreateRequest { + const sdkReq: CredentialCreateRequest = { + name: request.name, + provider: request.provider, + } + if (request.description) sdkReq.description = request.description + if (request.email) sdkReq.email = request.email + if (request.url) sdkReq.url = request.url + if (request.token) sdkReq.token = request.token + return sdkReq +} + +function mapDomainUpdateToSdk(request: DomainCredentialUpdateRequest): CredentialPatchRequest { + const sdkReq: CredentialPatchRequest = {} + if (request.name !== undefined) sdkReq.name = request.name + if (request.description !== undefined) sdkReq.description = request.description + if (request.email !== undefined) sdkReq.email = request.email + if (request.url !== undefined) sdkReq.url = request.url + if (request.token !== undefined) sdkReq.token = request.token + return sdkReq +} + +export function createCredentialsAdapter(): CredentialsPort { + return { + async list(params?: ListParams): Promise> { + const api = getAPI() + const opts = buildSdkListOptions(params) + const result = await api.list(opts) + const page = opts.page + const size = opts.size + return { + items: result.items.map(mapSdkCredentialToDomain), + total: result.total, + page, + size, + hasMore: page * size < result.total, + } + }, + + async get(id: string): Promise { + const api = getAPI() + const credential = await api.get(id) + return mapSdkCredentialToDomain(credential) + }, + + async create(request: DomainCredentialCreateRequest): Promise { + const api = getAPI() + const sdkReq = mapDomainCreateToSdk(request) + const credential = await api.create(sdkReq) + return mapSdkCredentialToDomain(credential) + }, + + async update(id: string, request: DomainCredentialUpdateRequest): Promise { + const api = getAPI() + const sdkReq = mapDomainUpdateToSdk(request) + const credential = await api.update(id, sdkReq) + return mapSdkCredentialToDomain(credential) + }, + + async delete(id: string): Promise { + const api = getAPI() + await api.delete(id) + }, + } +} diff --git a/components/ambient-ui/src/adapters/sdk-role-bindings.ts b/components/ambient-ui/src/adapters/sdk-role-bindings.ts new file mode 100644 index 000000000..2c29ea7b8 --- /dev/null +++ b/components/ambient-ui/src/adapters/sdk-role-bindings.ts @@ -0,0 +1,73 @@ +import { RoleBindingAPI } from 'ambient-sdk' +import type { RoleBindingCreateRequest } from 'ambient-sdk' +import type { RoleBindingsPort } from '@/ports/role-bindings' +import type { + DomainRoleBinding, + DomainRoleBindingCreateRequest, + ListParams, + PaginatedResult, +} from '@/domain/types' +import { mapSdkRoleBindingToDomain } from './mappers' +import { getConfig } from './sdk-client' + +function sanitizeSearch(value: string): string { + return value.replace(/['"%;\\]/g, '') +} + +function getAPI(): RoleBindingAPI { + return new RoleBindingAPI(getConfig()) +} + +function buildSdkListOptions(params?: ListParams) { + return { + page: params?.page ?? 1, + size: params?.size ?? 100, + search: params?.search + ? sanitizeSearch(params.search) + : undefined, + orderBy: params?.orderBy, + } +} + +function mapDomainCreateToSdk(request: DomainRoleBindingCreateRequest): RoleBindingCreateRequest { + const sdkReq: RoleBindingCreateRequest = { + role_id: request.roleId, + scope: request.scope, + } + if (request.userId) sdkReq.user_id = request.userId + if (request.projectId) sdkReq.project_id = request.projectId + if (request.agentId) sdkReq.agent_id = request.agentId + if (request.credentialId) sdkReq.credential_id = request.credentialId + return sdkReq +} + +export function createRoleBindingsAdapter(): RoleBindingsPort { + return { + async list(params?: ListParams): Promise> { + const api = getAPI() + const opts = buildSdkListOptions(params) + const result = await api.list(opts) + const page = opts.page + const size = opts.size + return { + items: result.items.map(mapSdkRoleBindingToDomain), + total: result.total, + page, + size, + hasMore: page * size < result.total, + } + }, + + async create(request: DomainRoleBindingCreateRequest): Promise { + const api = getAPI() + const sdkReq = mapDomainCreateToSdk(request) + const roleBinding = await api.create(sdkReq) + return mapSdkRoleBindingToDomain(roleBinding) + }, + + async delete(id: string): Promise { + const api = getAPI() + await api.delete(id) + }, + } +} diff --git a/components/ambient-ui/src/app/(dashboard)/credentials/_components/binding-matrix.tsx b/components/ambient-ui/src/app/(dashboard)/credentials/_components/binding-matrix.tsx new file mode 100644 index 000000000..7b77ec631 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/credentials/_components/binding-matrix.tsx @@ -0,0 +1,1143 @@ +'use client' + +import { + useState, + useMemo, + useCallback, + useRef, + useEffect, +} from 'react' +import { Check, ChevronDown, Loader2, Search, AlertTriangle } from 'lucide-react' +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from '@/components/ui/table' +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from '@/components/ui/tooltip' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { Input } from '@/components/ui/input' +import { Button } from '@/components/ui/button' +import { Separator } from '@/components/ui/separator' +import { useCreateRoleBinding, useDeleteRoleBinding } from '@/queries/use-role-bindings' +import { cn } from '@/lib/utils' +import type { DomainCredential, DomainRoleBinding, DomainProject, DomainAgent } from '@/domain/types' + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +type ProjectGroup = { + project: DomainProject + agents: DomainAgent[] +} + +type BulkConfirmState = { + show: boolean + title: string + message: string + count: number + onConfirm: () => void +} + +type BindingMatrixProps = { + credentials: DomainCredential[] + projects: DomainProject[] + agents: DomainAgent[] + bindings: DomainRoleBinding[] + roleId: string +} + +// --------------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------------- + +const PAGE_SIZE = 25 +const BATCH_CHUNK_SIZE = 10 + +const INITIAL_BULK_CONFIRM: BulkConfirmState = { + show: false, + title: '', + message: '', + count: 0, + onConfirm: () => undefined, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function cellKey(credentialId: string, targetId: string): string { + return `${credentialId}:${targetId}` +} + +/** + * A credential is project-bound when there is a RoleBinding with: + * credentialId === cred.id && projectId === project.id && !agentId + */ +function findProjectBinding( + bindings: DomainRoleBinding[], + credentialId: string, + projectId: string, +): DomainRoleBinding | undefined { + return bindings.find( + (b) => + b.credentialId === credentialId && + b.projectId === projectId && + !b.agentId, + ) +} + +/** + * A credential is agent-bound when there is a RoleBinding with: + * credentialId === cred.id && agentId === agent.id + */ +function findAgentBinding( + bindings: DomainRoleBinding[], + credentialId: string, + agentId: string, +): DomainRoleBinding | undefined { + return bindings.find( + (b) => b.credentialId === credentialId && b.agentId === agentId, + ) +} + +/** + * Inherited = project is bound but agent is NOT directly bound. + */ +function isInherited( + bindings: DomainRoleBinding[], + credentialId: string, + agentId: string, + projectId: string, +): boolean { + return ( + !!findProjectBinding(bindings, credentialId, projectId) && + !findAgentBinding(bindings, credentialId, agentId) + ) +} + +function globalColIndex(groups: ProjectGroup[], gIdx: number, colWithinGroup: number): number { + let idx = 0 + for (let i = 0; i < gIdx; i++) { + idx += 1 + groups[i].agents.length + } + return idx + colWithinGroup +} + +function totalColumnCount(groups: ProjectGroup[]): number { + return groups.reduce((sum, g) => sum + 1 + g.agents.length, 0) +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export function BindingMatrix({ + credentials, + projects, + agents, + bindings, + roleId, +}: BindingMatrixProps) { + // --- filter / pagination state --- + const [filterText, setFilterText] = useState('') + const [selectedProjectFilter, setSelectedProjectFilter] = useState( + projects.length > 0 ? projects[0].id : '__all__', + ) + const [currentPage, setCurrentPage] = useState(1) + const [pendingCells, setPendingCells] = useState>(() => new Set()) + const [openColumnPopovers, setOpenColumnPopovers] = useState>({}) + const [openRowPopovers, setOpenRowPopovers] = useState>({}) + const [bulkConfirm, setBulkConfirm] = useState(INITIAL_BULK_CONFIRM) + + // Optimistic binding overlay: pending additions and deletions + const [optimisticAdds, setOptimisticAdds] = useState([]) + const [optimisticDeletes, setOptimisticDeletes] = useState>(() => new Set()) + + // Merged bindings = server bindings + optimistic adds - optimistic deletes + const effectiveBindings = useMemo(() => { + const serverVisible = bindings.filter((b) => !optimisticDeletes.has(b.id)) + return [...serverVisible, ...optimisticAdds] + }, [bindings, optimisticAdds, optimisticDeletes]) + + // Refs for focus management + const focusCellRef = useRef(null) + + // Mutations + const createBinding = useCreateRoleBinding() + const deleteBinding = useDeleteRoleBinding() + + // --- Reset page when filter changes --- + useEffect(() => { + setCurrentPage(1) + }, [filterText, selectedProjectFilter]) + + // Sync selectedProjectFilter when projects change + useEffect(() => { + if (projects.length > 0 && selectedProjectFilter === '__all__') { + setSelectedProjectFilter(projects[0].id) + } + }, [projects, selectedProjectFilter]) + + // --- Build project groups --- + const allProjectGroups = useMemo(() => { + const sorted = [...projects].sort((a, b) => a.name.localeCompare(b.name)) + return sorted.map((p) => ({ + project: p, + agents: agents + .filter((a) => a.projectId === p.id) + .sort((a, b) => a.name.localeCompare(b.name)), + })) + }, [projects, agents]) + + const projectGroups = useMemo(() => { + if (selectedProjectFilter === '__all__') return allProjectGroups + return allProjectGroups.filter((g) => g.project.id === selectedProjectFilter) + }, [allProjectGroups, selectedProjectFilter]) + + const hasAnyAgents = useMemo( + () => projectGroups.some((g) => g.agents.length > 0), + [projectGroups], + ) + + const totalCols = useMemo(() => totalColumnCount(projectGroups), [projectGroups]) + + // --- Filtered & paginated credentials --- + const filteredCredentials = useMemo(() => { + const q = filterText.trim().toLowerCase() + const sorted = [...credentials].sort((a, b) => a.name.localeCompare(b.name)) + if (!q) return sorted + return sorted.filter((c) => c.name.toLowerCase().includes(q)) + }, [credentials, filterText]) + + const totalPages = Math.ceil(filteredCredentials.length / PAGE_SIZE) + const startRow = filteredCredentials.length === 0 ? 0 : (currentPage - 1) * PAGE_SIZE + 1 + const endRow = Math.min(currentPage * PAGE_SIZE, filteredCredentials.length) + + const paginatedCredentials = useMemo( + () => filteredCredentials.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE), + [filteredCredentials, currentPage], + ) + + // --- Helper to add/remove pending cell --- + const addPending = useCallback((key: string) => { + setPendingCells((prev) => { + const next = new Set(prev) + next.add(key) + return next + }) + }, []) + + const removePending = useCallback((key: string) => { + setPendingCells((prev) => { + const next = new Set(prev) + next.delete(key) + return next + }) + }, []) + + // --- Close column/row popovers --- + const closeColumnPopover = useCallback((id: string) => { + setOpenColumnPopovers((prev) => ({ ...prev, [id]: false })) + }, []) + + const closeRowPopover = useCallback((id: string) => { + setOpenRowPopovers((prev) => ({ ...prev, [id]: false })) + }, []) + + // --- Toggle a single cell --- + const toggleCell = useCallback( + async (params: { + credentialId: string + targetId: string + targetType: 'project' | 'agent' + projectId?: string + }) => { + const key = cellKey(params.credentialId, params.targetId) + if (pendingCells.has(key)) return + + const existingBinding = + params.targetType === 'project' + ? findProjectBinding(effectiveBindings, params.credentialId, params.targetId) + : findAgentBinding(effectiveBindings, params.credentialId, params.targetId) + + addPending(key) + + if (existingBinding) { + // Optimistic delete + setOptimisticDeletes((prev) => { + const next = new Set(prev) + next.add(existingBinding.id) + return next + }) + try { + await deleteBinding.mutateAsync(existingBinding.id) + } catch { + // Rollback + setOptimisticDeletes((prev) => { + const next = new Set(prev) + next.delete(existingBinding.id) + return next + }) + } finally { + // Clean up optimistic delete once server data refreshes + setOptimisticDeletes((prev) => { + const next = new Set(prev) + next.delete(existingBinding.id) + return next + }) + removePending(key) + } + } else { + // Optimistic add — create a temporary binding + const tempId = `temp-${Date.now()}-${Math.random().toString(36).slice(2, 9)}` + const tempBinding: DomainRoleBinding = { + id: tempId, + roleId, + scope: 'credential', + userId: null, + projectId: params.targetType === 'project' ? params.targetId : (params.projectId ?? null), + agentId: params.targetType === 'agent' ? params.targetId : null, + credentialId: params.credentialId, + sessionId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } + setOptimisticAdds((prev) => [...prev, tempBinding]) + try { + await createBinding.mutateAsync({ + roleId, + scope: 'credential', + credentialId: params.credentialId, + projectId: params.targetType === 'project' ? params.targetId : params.projectId, + agentId: params.targetType === 'agent' ? params.targetId : undefined, + }) + } catch { + // Rollback + } finally { + setOptimisticAdds((prev) => prev.filter((b) => b.id !== tempId)) + removePending(key) + } + } + }, + [pendingCells, effectiveBindings, addPending, removePending, roleId, createBinding, deleteBinding], + ) + + // --- Batch toggle --- + const batchToggle = useCallback( + async (calls: Array[0]>) => { + for (let i = 0; i < calls.length; i += BATCH_CHUNK_SIZE) { + const chunk = calls.slice(i, i + BATCH_CHUNK_SIZE) + await Promise.all(chunk.map((c) => toggleCell(c))) + } + }, + [toggleCell], + ) + + // --- Bulk operations --- + const bulkBindProject = useCallback( + async (projectId: string) => { + closeColumnPopover(projectId) + const unboundCreds = credentials.filter( + (c) => !findProjectBinding(effectiveBindings, c.id, projectId), + ) + await batchToggle( + unboundCreds.map((cred) => ({ + credentialId: cred.id, + targetId: projectId, + targetType: 'project' as const, + })), + ) + }, + [credentials, effectiveBindings, batchToggle, closeColumnPopover], + ) + + const bulkUnbindProject = useCallback( + (projectId: string) => { + const boundCreds = credentials.filter( + (c) => !!findProjectBinding(effectiveBindings, c.id, projectId), + ) + const project = projects.find((p) => p.id === projectId) + const projectName = project?.name ?? projectId + closeColumnPopover(projectId) + + setBulkConfirm({ + show: true, + title: 'Unbind all credentials from project', + message: `This will unbind ${boundCreds.length} credential${boundCreds.length === 1 ? '' : 's'} from ${projectName}. Agents in this project will lose access. Continue?`, + count: boundCreds.length, + onConfirm: () => { + void batchToggle( + boundCreds.map((cred) => ({ + credentialId: cred.id, + targetId: projectId, + targetType: 'project' as const, + })), + ) + }, + }) + }, + [credentials, effectiveBindings, projects, batchToggle, closeColumnPopover], + ) + + const bulkBindAgent = useCallback( + async (agentId: string, projectId: string) => { + closeColumnPopover(agentId) + const unboundCreds = credentials.filter( + (c) => !findAgentBinding(effectiveBindings, c.id, agentId), + ) + await batchToggle( + unboundCreds.map((cred) => ({ + credentialId: cred.id, + targetId: agentId, + targetType: 'agent' as const, + projectId, + })), + ) + }, + [credentials, effectiveBindings, batchToggle, closeColumnPopover], + ) + + const bulkUnbindAgent = useCallback( + (agentId: string) => { + const boundCreds = credentials.filter( + (c) => !!findAgentBinding(effectiveBindings, c.id, agentId), + ) + const agent = agents.find((a) => a.id === agentId) + const agentName = agent?.name ?? agentId + closeColumnPopover(agentId) + + setBulkConfirm({ + show: true, + title: 'Unbind all credentials from agent', + message: `This will unbind ${boundCreds.length} credential${boundCreds.length === 1 ? '' : 's'} from agent ${agentName}. Continue?`, + count: boundCreds.length, + onConfirm: () => { + void batchToggle( + boundCreds.map((cred) => ({ + credentialId: cred.id, + targetId: agentId, + targetType: 'agent' as const, + })), + ) + }, + }) + }, + [credentials, effectiveBindings, agents, batchToggle, closeColumnPopover], + ) + + const bulkBindRowProjects = useCallback( + async (cred: DomainCredential) => { + closeRowPopover(cred.id) + const unboundProjects = projects.filter( + (p) => !findProjectBinding(effectiveBindings, cred.id, p.id), + ) + await batchToggle( + unboundProjects.map((p) => ({ + credentialId: cred.id, + targetId: p.id, + targetType: 'project' as const, + })), + ) + }, + [projects, effectiveBindings, batchToggle, closeRowPopover], + ) + + const bulkUnbindRow = useCallback( + (cred: DomainCredential) => { + closeRowPopover(cred.id) + const calls: Array[0]> = [] + for (const p of projects) { + if (findProjectBinding(effectiveBindings, cred.id, p.id)) { + calls.push({ + credentialId: cred.id, + targetId: p.id, + targetType: 'project' as const, + }) + } + } + for (const a of agents) { + if (findAgentBinding(effectiveBindings, cred.id, a.id)) { + calls.push({ + credentialId: cred.id, + targetId: a.id, + targetType: 'agent' as const, + projectId: a.projectId ?? undefined, + }) + } + } + + setBulkConfirm({ + show: true, + title: 'Remove all access for credential', + message: `This will remove all access for ${cred.name}. Continue?`, + count: calls.length, + onConfirm: () => { + void batchToggle(calls) + }, + }) + }, + [projects, agents, effectiveBindings, batchToggle, closeRowPopover, toggleCell], + ) + + // --- Keyboard navigation --- + const handleCellKeydown = useCallback( + (event: React.KeyboardEvent, row: number, col: number) => { + let targetRow = row + let targetCol = col + switch (event.key) { + case 'ArrowUp': + targetRow = row - 1 + break + case 'ArrowDown': + targetRow = row + 1 + break + case 'ArrowLeft': + targetCol = col - 1 + break + case 'ArrowRight': + targetCol = col + 1 + break + default: + return + } + event.preventDefault() + const next = document.querySelector( + `[data-matrix-row="${targetRow}"][data-matrix-col="${targetCol}"]`, + ) + if (next) next.focus() + }, + [], + ) + + // --- Render helpers --- + const renderProjectHeaderPopover = useCallback( + (group: ProjectGroup) => ( + + setOpenColumnPopovers((prev) => ({ ...prev, [group.project.id]: open })) + } + > + + + + +
+

+ Project: {group.project.name} +

+ + + +
+
+
+ ), + [openColumnPopovers, bulkBindProject, bulkUnbindProject], + ) + + const renderAgentHeaderPopover = useCallback( + (agent: DomainAgent, group: ProjectGroup) => ( + + setOpenColumnPopovers((prev) => ({ ...prev, [agent.id]: open })) + } + > + + + + +
+

+ Agent: {agent.displayName ?? agent.name} +

+ + + +
+
+
+ ), + [openColumnPopovers, bulkBindAgent, bulkUnbindAgent], + ) + + return ( + +
+ {/* --- Filters: project dropdown + credential name search --- */} +
+ +
+ + setFilterText(e.target.value)} + placeholder="Filter credentials..." + className="pl-9 h-9" + /> +
+
+ + {/* --- Warning banner when showing too many columns --- */} + {selectedProjectFilter === '__all__' && totalCols > 30 && ( +
+ + Showing all {totalCols} columns. Select a specific project for easier editing. +
+ )} + + {/* --- Legend bar --- */} +
+
+ + Directly bound +
+
+ + Inherited from project +
+
+ + Not bound +
+
+ + {/* --- Matrix table --- */} +
+ + + {!hasAnyAgents ? ( + /* === SIMPLE LAYOUT: no agents anywhere === */ + + + Credential + + {projectGroups.map((group, gIdx) => ( + 0 && 'border-l border-l-border', + globalColIndex(projectGroups, gIdx, 0) % 2 === 1 && 'bg-muted/20', + )} + > + {renderProjectHeaderPopover(group)} + + ))} + + ) : ( + /* === HIERARCHICAL LAYOUT: two header rows === */ + <> + {/* Row 1: project names spanning their agent columns */} + + + Credential + + {projectGroups.map((group, gIdx) => + group.agents.length > 0 ? ( + 0 && 'border-l-2 border-l-border', + )} + > + {renderProjectHeaderPopover(group)} + + ) : ( + 0 && 'border-l border-l-border', + globalColIndex(projectGroups, gIdx, 0) % 2 === 1 && 'bg-muted/20', + )} + > + {renderProjectHeaderPopover(group)} + + ), + )} + + + {/* Row 2: "All" column + agent sub-columns */} + + {projectGroups.map((group, gIdx) => + group.agents.length > 0 ? ( + + ) : null, + )} + + + )} + + + + {filteredCredentials.length > 0 ? ( + paginatedCredentials.map((cred, rowIndex) => ( + + {/* Row header: credential name with bulk-ops popover */} + + + setOpenRowPopovers((prev) => ({ ...prev, [cred.id]: open })) + } + > + + + + +
+

+ {cred.name} +

+ + {projectGroups.length > 0 && ( + + )} + +
+
+
+
+ + {/* Cells per project group */} + {projectGroups.map((group, gIdx) => ( + + ))} +
+ )) + ) : ( + + + {filterText.trim() + ? `No credentials match "${filterText.trim()}".` + : 'No credentials to display. Create credentials to manage bindings.'} + + + )} +
+
+
+ + {/* --- Pagination controls --- */} + {filteredCredentials.length > PAGE_SIZE && ( +
+ + Showing {startRow}-{endRow} of {filteredCredentials.length} + +
+ + + Page {currentPage} of {totalPages} + + +
+
+ )} + + {/* --- Bulk unbind confirmation dialog --- */} + { + if (!open) setBulkConfirm(INITIAL_BULK_CONFIRM) + }} + > + + + {bulkConfirm.title} + {bulkConfirm.message} + + + setBulkConfirm(INITIAL_BULK_CONFIRM)}> + Cancel + + { + const { onConfirm } = bulkConfirm + setBulkConfirm(INITIAL_BULK_CONFIRM) + onConfirm() + }} + > + Unbind {bulkConfirm.count} credential{bulkConfirm.count === 1 ? '' : 's'} + + + + +
+
+ ) +} + +// --------------------------------------------------------------------------- +// Sub-components (extracted for readability, avoid JSX in map callbacks) +// --------------------------------------------------------------------------- + +function AgentSubHeaders({ + group, + gIdx, + projectGroups, + renderAgentHeaderPopover, +}: { + group: ProjectGroup + gIdx: number + projectGroups: ProjectGroup[] + renderAgentHeaderPopover: (agent: DomainAgent, group: ProjectGroup) => React.ReactNode +}) { + return ( + <> + {/* "All" column for project-level binding */} + 0 && 'border-l-2 border-l-border', + globalColIndex(projectGroups, gIdx, 0) % 2 === 1 && 'bg-muted/20', + )} + > + + + + All + + + +

Project-level binding: {group.project.name}

+
+
+
+ + {/* Agent sub-columns */} + {group.agents.map((agent, aIdx) => ( + + {renderAgentHeaderPopover(agent, group)} + + ))} + + ) +} + +function GroupCells({ + group, + gIdx, + cred, + rowIndex, + projectGroups, + hasAnyAgents, + effectiveBindings, + pendingCells, + onToggle, + onKeyDown, + focusCellRef, +}: { + group: ProjectGroup + gIdx: number + cred: DomainCredential + rowIndex: number + projectGroups: ProjectGroup[] + hasAnyAgents: boolean + effectiveBindings: DomainRoleBinding[] + pendingCells: Set + onToggle: (params: { + credentialId: string + targetId: string + targetType: 'project' | 'agent' + projectId?: string + }) => void + onKeyDown: (event: React.KeyboardEvent, row: number, col: number) => void + focusCellRef: React.MutableRefObject +}) { + const projectBound = !!findProjectBinding(effectiveBindings, cred.id, group.project.id) + const projectPending = pendingCells.has(cellKey(cred.id, group.project.id)) + const colIdx = globalColIndex(projectGroups, gIdx, 0) + + return ( + <> + {/* Project-level binding cell */} + 0 && group.agents.length > 0 && 'border-l-2 border-border', + gIdx > 0 && (!hasAnyAgents || group.agents.length === 0) && 'border-l border-border', + colIdx % 2 === 1 && 'bg-muted/20', + )} + > + + + + + +

+ {projectBound ? 'Unbind from' : 'Bind to'} project: {group.project.name} +

+
+
+
+ + {/* Agent cells */} + {group.agents.map((agent, aIdx) => { + const agentColIdx = globalColIndex(projectGroups, gIdx, aIdx + 1) + const inherited = isInherited(effectiveBindings, cred.id, agent.id, group.project.id) + const agentBound = !!findAgentBinding(effectiveBindings, cred.id, agent.id) + const agentPending = pendingCells.has(cellKey(cred.id, agent.id)) + + return ( + + {inherited ? ( + /* Inherited state: non-clickable, manage at project level */ + + + + + + + + + +

Inherited from project. Manage at project level.

+
+
+ ) : ( + /* Direct binding or unbound state */ + + + + + +

+ {agentBound ? 'Unbind from' : 'Bind to'} agent: {agent.displayName ?? agent.name} +

+
+
+ )} +
+ ) + })} + + ) +} diff --git a/components/ambient-ui/src/app/(dashboard)/credentials/_components/credential-create-sheet.tsx b/components/ambient-ui/src/app/(dashboard)/credentials/_components/credential-create-sheet.tsx new file mode 100644 index 000000000..2d72beaa2 --- /dev/null +++ b/components/ambient-ui/src/app/(dashboard)/credentials/_components/credential-create-sheet.tsx @@ -0,0 +1,265 @@ +'use client' + +import { useState, useMemo } from 'react' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetDescription, + SheetFooter, +} from '@/components/ui/sheet' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Textarea } from '@/components/ui/textarea' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { useCreateCredential } from '@/queries/use-credentials' +import type { DomainCredentialCreateRequest } from '@/domain/types' +import { + CREDENTIAL_CATEGORIES, + getProviderMeta, +} from '@/domain/credential-providers' +import type { ProviderMeta } from '@/domain/credential-providers' + +export function CredentialCreateSheet({ + open, + onOpenChange, +}: { + open: boolean + onOpenChange: (open: boolean) => void +}) { + const createCredential = useCreateCredential() + + const [category, setCategory] = useState('') + const [provider, setProvider] = useState('') + const [name, setName] = useState('') + const [token, setToken] = useState('') + const [url, setUrl] = useState('') + const [email, setEmail] = useState('') + const [description, setDescription] = useState('') + const [error, setError] = useState(null) + + const filteredProviders = useMemo(() => { + if (!category) return [] + const cat = CREDENTIAL_CATEGORIES.find((c) => c.label === category) + return cat?.providers ?? [] + }, [category]) + + const providerMeta: ProviderMeta | undefined = useMemo( + () => (provider ? getProviderMeta(provider) : undefined), + [provider], + ) + + const requiredFields = providerMeta?.fields ?? [] + + function resetForm() { + setCategory('') + setProvider('') + setName('') + setToken('') + setUrl('') + setEmail('') + setDescription('') + setError(null) + } + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + + if (!name.trim()) { + setError('Name is required.') + return + } + + if (!provider) { + setError('Provider is required.') + return + } + + const request: DomainCredentialCreateRequest = { + name: name.trim(), + provider, + } + + if (token) request.token = token + if (url.trim()) request.url = url.trim() + if (email.trim()) request.email = email.trim() + if (description.trim()) request.description = description.trim() + + try { + await createCredential.mutateAsync(request) + resetForm() + onOpenChange(false) + } catch (err) { + console.error('create credential failed', err) + setError('Failed to create credential. Please try again.') + } + } + + return ( + { + if (!v) resetForm() + onOpenChange(v) + }} + > + + + New Credential + + Add a new API key, token, or secret for use with your agents. + + + +
+
+ + +
+ +
+ + +
+ +
+ + setName(e.target.value)} + required + /> +
+ + {requiredFields.includes('token') && ( +
+ + setToken(e.target.value)} + autoComplete="off" + /> +
+ )} + + {requiredFields.includes('url') && ( +
+ + setUrl(e.target.value)} + /> +
+ )} + + {requiredFields.includes('email') && ( +
+ + setEmail(e.target.value)} + /> +
+ )} + +
+ +