diff --git a/.changeset/omit-tenant-session-access-grants.md b/.changeset/omit-tenant-session-access-grants.md new file mode 100644 index 0000000000..055c125da6 --- /dev/null +++ b/.changeset/omit-tenant-session-access-grants.md @@ -0,0 +1,5 @@ +--- +'@truefoundry/trueforge-ui': patch +--- + +Omit tenant session grants from the session access collaborator list; tenant-wide sharing stays on `session.shared`. diff --git a/.changeset/session-share-collaborators-ui.md b/.changeset/session-share-collaborators-ui.md new file mode 100644 index 0000000000..7b41202d14 --- /dev/null +++ b/.changeset/session-share-collaborators-ui.md @@ -0,0 +1,6 @@ +--- +'@truefoundry/trueforge-ui': patch +'@truefoundry/trueforge-assistant-ui-runtime': patch +--- + +Share sessions with specific users when capabilities report `session_sharing.users`: Harness implements the session access port (invite search, grants, revoke); tenant-wide access still uses the session `shared` flag. diff --git a/packages/assistant-ui-runtime/src/server/index.ts b/packages/assistant-ui-runtime/src/server/index.ts index 43d3c4b0a3..96eb224b60 100644 --- a/packages/assistant-ui-runtime/src/server/index.ts +++ b/packages/assistant-ui-runtime/src/server/index.ts @@ -60,6 +60,7 @@ export type { CreateWebSearchRequest, CreatedBySubject, DefinedSkill, + DirectoryUser, GithubSkill, ImportGithubSkillRequest, ListPermissionsRequest, @@ -114,8 +115,13 @@ export type { SearchAgentsParams, SelectRegistrySkillRequest, Session, + SessionAccessGrant, + SessionAccessServer, + SessionAccessSubject, + SessionAccessSubjectType, SessionListEntry, SessionListMetrics, + SessionSubject, SkillBase, SkillCatalogEntry, SkillCatalogServer, diff --git a/packages/assistant-ui-runtime/src/server/types.ts b/packages/assistant-ui-runtime/src/server/types.ts index 32f5e994ec..8ae8316089 100644 --- a/packages/assistant-ui-runtime/src/server/types.ts +++ b/packages/assistant-ui-runtime/src/server/types.ts @@ -176,6 +176,11 @@ export interface AgentSpec< // Session — plain DTO, generic in TSpec so host's spec type flows through // --------------------------------------------------------------------------- +export interface SessionSubject { + id: string; + displayName: string; +} + export interface Session { id: string; title?: string | null; @@ -185,6 +190,8 @@ export interface Session { isMutable: boolean; /** When true, any subject in the tenant may read this session and its turns/events by id. */ shared?: boolean; + /** Creator of the session; the only subject allowed to manage its sharing. */ + createdBy?: SessionSubject; /** Rolled-up turns/duration/cost from the session detail API when the host provides it. */ metrics?: SessionListMetrics; createdAt: string; @@ -456,6 +463,8 @@ export interface AgentBuilderCapabilitiesResponse { skill: { enabled: boolean; reason?: string }; settings?: { enabled: boolean }; webSearch?: { enabled: boolean }; + /** Present when the host reports session sharing; `enabled` gates invite-by-user. */ + sessionSharing?: { enabled: boolean }; }; } @@ -1161,6 +1170,39 @@ export interface PermissionsServer< listPermissions(req: TRequest): Promise; } +// --------------------------------------------------------------------------- +// Session access — share a session with specific users +// --------------------------------------------------------------------------- + +export type SessionAccessSubjectType = 'user'; + +export interface SessionAccessSubject { + subjectType: SessionAccessSubjectType; + subjectId: string; +} + +export interface SessionAccessGrant extends SessionAccessSubject { + /** Null when the directory can no longer resolve the user. */ + displayName: string | null; + email: string | null; + createdAt: string; +} + +export interface DirectoryUser { + id: string; + displayName: string; + email: string | null; +} + +/** Only the session creator may call these; others are rejected with a permission error. */ +export interface SessionAccessServer { + listAccess(req: { sessionId: string }): Promise; + grantAccess(req: { sessionId: string; subjects: SessionAccessSubject[] }): Promise; + revokeAccess(req: { sessionId: string } & SessionAccessSubject): Promise; + /** Directory search for the invite input. Resolves empty when the host has no user directory. */ + searchUsers(req: { query?: string; limit?: number; pageToken?: string }): Promise>; +} + // --------------------------------------------------------------------------- // AgentMetrics — optional aggregate metrics + time-series charts // --------------------------------------------------------------------------- @@ -1260,6 +1302,7 @@ export type AgentUIServerPort< TSandboxEnvironments extends SandboxEnvironmentServer = SandboxEnvironmentServer, TMetrics extends AgentMetricsServer = AgentMetricsServer, TPermissions extends PermissionsServer = PermissionsServer, + TSessionAccess extends SessionAccessServer = SessionAccessServer, > = TChat & TBuilder & { catalog?: TCatalog; @@ -1268,6 +1311,8 @@ export type AgentUIServerPort< sandboxEnvironments?: TSandboxEnvironments; metrics?: TMetrics; permissions?: TPermissions; + /** Optional session sharing port; omit to hide the invite and collaborator UI. */ + sessionAccess?: TSessionAccess; /** Authenticated caller identity. Used for tenant-scoped share copy. */ getMe?: () => Promise<{ tenantId: string }>; }; diff --git a/packages/trueforge-ui/src/analytics/events.ts b/packages/trueforge-ui/src/analytics/events.ts index 414af8bb71..377425cd5b 100644 --- a/packages/trueforge-ui/src/analytics/events.ts +++ b/packages/trueforge-ui/src/analytics/events.ts @@ -19,6 +19,9 @@ export const AnalyticsEvents = { RENAMED: 'trueforge.agent.session_renamed', SHARE_OPENED: 'trueforge.agent.session_share_opened', SHARE_LINK_COPIED: 'trueforge.agent.session_share_link_copied', + SHARE_PERMISSION_CHANGED: 'trueforge.agent.session_share_permission_changed', + SHARE_COLLABORATORS_INVITED: 'trueforge.agent.session_share_collaborators_invited', + SHARE_COLLABORATOR_REMOVED: 'trueforge.agent.session_share_collaborator_removed', }, Tool: { APPROVAL_RESOLVED: 'trueforge.agent.tool_approval_resolved', diff --git a/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx index 855d7ba00d..361de04ab6 100644 --- a/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx +++ b/packages/trueforge-ui/src/atoms/ShareSessionDialog.tsx @@ -1,13 +1,21 @@ 'use client'; -import type { ReactNode } from 'react'; +import { useState, type ReactNode } from 'react'; import { useTrackAnalytics } from '../analytics/AnalyticsProvider.js'; import { AnalyticsEvents } from '../analytics/events.js'; -import { useShareSessionDialog, type SessionSharePermission } from '../hooks/useShareSessionDialog.js'; +import { useSessionUserSearch } from '../hooks/useSessionUserSearch.js'; +import { + SESSION_SHARE_PRIVATE, + SESSION_SHARE_TENANT, + useShareSessionDialog, + type SessionSharePermission, +} from '../hooks/useShareSessionDialog.js'; import { Icon } from '../icons/Icon.js'; +import type { DirectoryUser } from '../server/types.js'; import { auiInputClass } from './lib/inputClasses.js'; import { auiSelectOptionClass, auiSelectTriggerClass } from './lib/selectClasses.js'; +import { Avatar, AvatarFallback } from './primitives/Avatar.js'; import { Button } from './primitives/Button.js'; import { DropdownMenu } from './primitives/DropdownMenu.js'; @@ -17,11 +25,11 @@ export type ShareSessionDialogProps = { }; const SHARE_PERMISSIONS: Record = { - private: { title: 'Only you', description: 'Only you have access' }, - tenant: { title: 'Everyone within this tenant', description: 'Everyone has access' }, + [SESSION_SHARE_PRIVATE]: { title: 'Only you', description: 'Only you have access' }, + [SESSION_SHARE_TENANT]: { title: 'Everyone within this tenant', description: 'Everyone has access' }, }; -const SHARE_PERMISSION_VALUES: readonly SessionSharePermission[] = ['private', 'tenant']; +const SHARE_PERMISSION_VALUES: readonly SessionSharePermission[] = [SESSION_SHARE_PRIVATE, SESSION_SHARE_TENANT]; function permissionCopy({ permission, @@ -30,8 +38,8 @@ function permissionCopy({ permission: SessionSharePermission; tenantId: string | undefined; }): { title: string; description: string } { - if (permission === 'tenant' && tenantId != null && tenantId.length > 0) { - return { title: `Everyone within ${tenantId}`, description: SHARE_PERMISSIONS.tenant.description }; + if (permission === SESSION_SHARE_TENANT && tenantId != null && tenantId.length > 0) { + return { title: `Everyone within ${tenantId}`, description: SHARE_PERMISSIONS[SESSION_SHARE_TENANT].description }; } return SHARE_PERMISSIONS[permission]; } @@ -99,9 +107,154 @@ function AccessSelector({ ); } +function initialsOf(label: string): string { + return label.trim().slice(0, 2).toUpperCase(); +} + +function PersonRow({ + label, + role, + action, +}: { + label: string; + role: string; + action?: { label: string; onClick: () => void }; +}) { + return ( +
+ + {initialsOf(label)} + + {label} + {role} + {action == null ? null : ( + + )} +
+ ); +} + +function InviteCollaborators({ + excludedIds, + onInvite, +}: { + excludedIds: ReadonlySet; + onInvite: (users: DirectoryUser[]) => Promise; +}) { + const { query, setQuery, users, loading, hasMore, loadMore } = useSessionUserSearch({ enabled: true }); + const [selected, setSelected] = useState([]); + const [inviting, setInviting] = useState(false); + const selectedIds = new Set(selected.map(user => user.id)); + const candidates = users.filter(user => !excludedIds.has(user.id) && !selectedIds.has(user.id)); + + const submit = async () => { + if (selected.length === 0 || inviting) return; + setInviting(true); + const ok = await onInvite(selected); + setInviting(false); + if (ok) { + setSelected([]); + setQuery(''); + } + }; + + return ( +
+
+ setQuery(event.target.value)} + placeholder="Add collaborator username or email" + aria-label="Search users" + className={auiInputClass('h-8 min-w-0 flex-1 px-3.5 text-[0.8125rem]')} + /> + { + void submit(); + }} + > + + Invite + +
+ {selected.length > 0 ? ( +
+ {selected.map(user => ( + + ))} +
+ ) : null} + {query.trim().length > 0 && (candidates.length > 0 || hasMore) ? ( +
+ {candidates.map(user => ( + + ))} + {hasMore ? ( + + ) : null} +
+ ) : null} +
+ ); +} + export function ShareSessionDialog({ sessionId, trigger }: ShareSessionDialogProps) { - const { permission, canManage, loading, shareUrl, copied, tenantId, load, changePermission, copySharedSessionLink } = - useShareSessionDialog(sessionId); + const { + permission, + canChangePermission, + canInvite, + loading, + shareUrl, + copied, + tenantId, + owner, + collaborators, + load, + changePermission, + invite, + removeCollaborator, + copySharedSessionLink, + } = useShareSessionDialog(sessionId); const track = useTrackAnalytics(); return ( @@ -124,12 +277,53 @@ export function ShareSessionDialog({ sessionId, trigger }: ShareSessionDialogPro { + track(AnalyticsEvents.Session.SHARE_PERMISSION_CHANGED, { session_id: sessionId, permission: next }); void changePermission(next); }} /> - {permission === 'tenant' ? ( + {canInvite ? ( + grant.subjectId), ...(owner == null ? [] : [owner.id])])} + onInvite={async users => { + const ok = await invite(users); + if (ok) + track(AnalyticsEvents.Session.SHARE_COLLABORATORS_INVITED, { + session_id: sessionId, + count: users.length, + }); + return ok; + }} + /> + ) : null} + {collaborators.length > 0 || owner != null ? ( +
+ {collaborators.map(grant => { + const label = grant.displayName ?? grant.email ?? grant.subjectId; + return ( + { + track(AnalyticsEvents.Session.SHARE_COLLABORATOR_REMOVED, { session_id: sessionId }); + void removeCollaborator(grant.subjectId); + }, + }, + } + : {})} + /> + ); + })} + {owner == null ? null : } +
+ ) : null} + {permission === SESSION_SHARE_TENANT || collaborators.length > 0 ? ( <>
diff --git a/packages/trueforge-ui/src/hooks/useCopySharedSessionLink.ts b/packages/trueforge-ui/src/hooks/useCopySharedSessionLink.ts index a0adcacf45..5d887eb036 100644 --- a/packages/trueforge-ui/src/hooks/useCopySharedSessionLink.ts +++ b/packages/trueforge-ui/src/hooks/useCopySharedSessionLink.ts @@ -7,7 +7,8 @@ import { useOptionalResolvedRoutes } from '../routing/ResolvedRoutesContext.js'; export function useCopySharedSessionLink(sessionId: string | null | undefined): { copied: boolean; - copySharedSessionLink: () => Promise; + /** Resolves true when the link reached the clipboard. */ + copySharedSessionLink: () => Promise; } { const routes = useOptionalResolvedRoutes(); const [copied, setCopied] = useState(false); @@ -19,12 +20,14 @@ export function useCopySharedSessionLink(sessionId: string | null | undefined): }, [copied]); const copySharedSessionLink = useCallback(async () => { - if (sessionId == null) return; + if (sessionId == null) return false; try { await navigator.clipboard.writeText(buildSharedSessionHref({ sessionId, routes })); setCopied(true); + return true; } catch { // Clipboard access depends on the host browser and document permissions. + return false; } }, [routes, sessionId]); diff --git a/packages/trueforge-ui/src/hooks/useSessionUserSearch.ts b/packages/trueforge-ui/src/hooks/useSessionUserSearch.ts new file mode 100644 index 0000000000..4c1723d6da --- /dev/null +++ b/packages/trueforge-ui/src/hooks/useSessionUserSearch.ts @@ -0,0 +1,82 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { useToasterOptional } from '../containers/ToasterContainer.js'; +import { useOptionalServer } from '../server/ServerContext.js'; +import type { DirectoryUser } from '../server/types.js'; + +const SEARCH_DEBOUNCE_MS = 250; +const SEARCH_PAGE_SIZE = 25; + +/** Debounced directory search for the invite input; an empty query lists nothing. */ +export function useSessionUserSearch({ enabled }: { enabled: boolean }): { + query: string; + setQuery: (query: string) => void; + users: DirectoryUser[]; + loading: boolean; + hasMore: boolean; + loadMore: () => void; +} { + const sessionAccess = useOptionalServer()?.sessionAccess; + const toaster = useToasterOptional(); + const [query, setQuery] = useState(''); + const [users, setUsers] = useState([]); + const [nextPageToken, setNextPageToken] = useState(); + const [loading, setLoading] = useState(false); + // Guards against out-of-order responses when the query changes mid-flight. + const requestIdRef = useRef(0); + + const fetchPage = useCallback( + async ({ term, pageToken }: { term: string; pageToken?: string }) => { + if (sessionAccess == null) return; + const requestId = ++requestIdRef.current; + setLoading(true); + try { + const page = await sessionAccess.searchUsers({ + query: term, + limit: SEARCH_PAGE_SIZE, + ...(pageToken === undefined ? {} : { pageToken }), + }); + if (requestId !== requestIdRef.current) return; + setUsers(previous => { + if (pageToken === undefined) return page.data; + const seen = new Set(previous.map(user => user.id)); + return [...previous, ...page.data.filter(user => !seen.has(user.id))]; + }); + setNextPageToken(page.nextPageToken); + } catch (caught) { + if (requestId === requestIdRef.current) toaster?.showError(caught); + } finally { + if (requestId === requestIdRef.current) setLoading(false); + } + }, + [sessionAccess, toaster], + ); + + useEffect(() => { + const term = query.trim(); + // Drop stale results/tokens and invalidate in-flight responses before debounce. + requestIdRef.current++; + setUsers([]); + setNextPageToken(undefined); + + if (!enabled || sessionAccess == null || term.length === 0) { + setLoading(false); + return undefined; + } + + setLoading(true); + const timer = window.setTimeout(() => { + void fetchPage({ term }); + }, SEARCH_DEBOUNCE_MS); + return () => window.clearTimeout(timer); + }, [enabled, fetchPage, query, sessionAccess]); + + const loadMore = useCallback(() => { + if (nextPageToken == null || loading) return; + void fetchPage({ term: query.trim(), pageToken: nextPageToken }); + }, [fetchPage, loading, nextPageToken, query]); + + return { query, setQuery, users, loading, hasMore: nextPageToken != null, loadMore }; +} diff --git a/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts b/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts index 6d2856bbc1..9cb0389dac 100644 --- a/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts +++ b/packages/trueforge-ui/src/hooks/useShareSessionDialog.ts @@ -1,46 +1,98 @@ 'use client'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { useToasterOptional } from '../containers/ToasterContainer.js'; import { buildSharedSessionHref } from '../routing/paths.js'; import { useOptionalResolvedRoutes } from '../routing/ResolvedRoutesContext.js'; -import { useOptionalServer } from '../server/ServerContext.js'; +import { useOptionalServer, useServerCapabilities } from '../server/ServerContext.js'; +import type { DirectoryUser, SessionAccessGrant, SessionSubject } from '../server/types.js'; import { useCopySharedSessionLink } from './useCopySharedSessionLink.js'; import { useResourcePermissions } from './useResourcePermissions.js'; -export type SessionSharePermission = 'private' | 'tenant'; +export const SESSION_SHARE_PRIVATE = 'private' as const; +export const SESSION_SHARE_TENANT = 'tenant' as const; + +export type SessionSharePermission = typeof SESSION_SHARE_PRIVATE | typeof SESSION_SHARE_TENANT; export function useShareSessionDialog(sessionId: string | null | undefined): { permission: SessionSharePermission; canManage: boolean; + /** Tenant-wide sharing is a session `shared` write; needs MANAGE only. */ + canChangePermission: boolean; + canInvite: boolean; loading: boolean; shareUrl: string; copied: boolean; tenantId: string | undefined; + owner: SessionSubject | undefined; + collaborators: SessionAccessGrant[]; load: () => Promise; changePermission: (next: SessionSharePermission) => Promise; + invite: (users: DirectoryUser[]) => Promise; + removeCollaborator: (subjectId: string) => Promise; copySharedSessionLink: () => Promise; } { const server = useOptionalServer(); + const sessionAccess = server?.sessionAccess; + const capabilities = useServerCapabilities(); const toaster = useToasterOptional(); const routes = useOptionalResolvedRoutes(); const resourceIds = sessionId == null || sessionId.length === 0 ? [] : [sessionId]; const { allows } = useResourcePermissions({ resourceType: 'session', resourceIds }); const canManage = allows(sessionId, 'MANAGE'); - const { copied, copySharedSessionLink } = useCopySharedSessionLink(sessionId); - const [permission, setPermission] = useState('private'); + const { copied, copySharedSessionLink: copyLink } = useCopySharedSessionLink(sessionId); + const [permission, setPermission] = useState(SESSION_SHARE_PRIVATE); const [tenantId, setTenantId] = useState(); + const [owner, setOwner] = useState(); + const [collaborators, setCollaborators] = useState([]); + const collaboratorsRef = useRef(collaborators); + collaboratorsRef.current = collaborators; + // List fetches and invite/remove each bump this so an older listAccess cannot replace a newer list. + const collaboratorListEpochRef = useRef(0); + const collaboratorMutationsRef = useRef(0); const [loading, setLoading] = useState(false); - const shareUrl = sessionId == null || sessionId.length === 0 ? '' : buildSharedSessionHref({ sessionId, routes }); + const hasSession = sessionId != null && sessionId.length > 0; + const shareUrl = hasSession ? buildSharedSessionHref({ sessionId, routes }) : ''; + const canChangePermission = canManage; + // Absent until capabilities settle; hide invite so the field does not flash in. + const canInviteUsers = capabilities?.sessionSharing?.enabled === true; + const canInvite = canManage && sessionAccess != null && canInviteUsers; + + const refreshCollaborators = useCallback(async () => { + if (!hasSession || sessionAccess == null || collaboratorMutationsRef.current > 0) return; + const epoch = ++collaboratorListEpochRef.current; + const grants = await sessionAccess.listAccess({ sessionId }); + if (epoch !== collaboratorListEpochRef.current || collaboratorMutationsRef.current > 0) return; + setCollaborators(grants.filter(grant => grant.subjectType === 'user')); + }, [hasSession, sessionAccess, sessionId]); + + // Runs when invite-by-user becomes available (capabilities settle after open). + useEffect(() => { + if (!canInvite || !hasSession || sessionAccess == null) { + collaboratorListEpochRef.current++; + setCollaborators(current => (current.length === 0 ? current : [])); + return undefined; + } + let cancelled = false; + void refreshCollaborators().catch(caught => { + if (!cancelled) toaster?.showError(caught); + }); + return () => { + cancelled = true; + }; + // toaster is read for errors only; its identity must not retrigger the list. + // eslint-disable-next-line react-hooks/exhaustive-deps -- toaster + }, [canInvite, hasSession, refreshCollaborators, sessionAccess, sessionId]); const load = useCallback(async () => { - if (sessionId == null || sessionId.length === 0 || server == null) return; + if (!hasSession || server == null) return; setLoading(true); try { const session = await server.getSession({ sessionId }); - setPermission(session.shared === true ? 'tenant' : 'private'); + setOwner(session.createdBy); + setPermission(session.shared === true ? SESSION_SHARE_TENANT : SESSION_SHARE_PRIVATE); if (server.getMe != null) { try { const me = await server.getMe(); @@ -49,38 +101,106 @@ export function useShareSessionDialog(sessionId: string | null | undefined): { // Keep the generic tenant label when identity is unavailable. } } + // Re-fetch on every open so external grant/revoke changes are visible. + if (canInvite && sessionAccess != null) await refreshCollaborators(); } catch (caught) { toaster?.showError(caught); } finally { setLoading(false); } - }, [server, sessionId, toaster]); + }, [canInvite, hasSession, refreshCollaborators, server, sessionAccess, sessionId, toaster]); const changePermission = useCallback( async (next: SessionSharePermission) => { - if (!canManage || next === permission) return; + if (!canChangePermission || next === permission || !hasSession || server == null) { + return; + } const previous = permission; setPermission(next); - if (sessionId == null || sessionId.length === 0 || server == null) return; try { - await server.updateSession({ sessionId, shared: next === 'tenant' }); + await server.updateSession({ sessionId, shared: next === SESSION_SHARE_TENANT }); } catch (caught) { setPermission(previous); toaster?.showError(caught); } }, - [canManage, permission, server, sessionId, toaster], + [canChangePermission, hasSession, permission, server, sessionId, toaster], ); + const invite = useCallback( + async (users: DirectoryUser[]) => { + if (!canInvite || !hasSession || sessionAccess == null || users.length === 0) return false; + collaboratorMutationsRef.current++; + collaboratorListEpochRef.current++; + try { + const granted = await sessionAccess.grantAccess({ + sessionId, + subjects: users.map(user => ({ subjectType: 'user', subjectId: user.id })), + }); + setCollaborators(granted.filter(grant => grant.subjectType === 'user')); + return true; + } catch (caught) { + toaster?.showError(caught); + return false; + } finally { + collaboratorMutationsRef.current--; + collaboratorListEpochRef.current++; + } + }, + [canInvite, hasSession, sessionAccess, sessionId, toaster], + ); + + const removeCollaborator = useCallback( + async (subjectId: string) => { + if (!canInvite || !hasSession || sessionAccess == null) return; + collaboratorMutationsRef.current++; + collaboratorListEpochRef.current++; + // Only this grant — a failed revoke must not resurrect peers removed concurrently. + const removed = collaboratorsRef.current.find(grant => grant.subjectId === subjectId); + setCollaborators(current => current.filter(grant => grant.subjectId !== subjectId)); + try { + await sessionAccess.revokeAccess({ sessionId, subjectType: 'user', subjectId }); + } catch (caught) { + if (removed != null) { + setCollaborators(current => + current.some(item => item.subjectId === subjectId) ? current : [...current, removed], + ); + } + toaster?.showError(caught); + } finally { + collaboratorMutationsRef.current--; + collaboratorListEpochRef.current++; + } + }, + [canInvite, hasSession, sessionAccess, sessionId, toaster], + ); + + const copySharedSessionLink = useCallback(async () => { + if (!(await copyLink())) return; + toaster?.showSuccess({ + title: 'Link copied to your clipboard', + description: + permission === SESSION_SHARE_TENANT + ? 'Anyone in your workspace can open this link.' + : 'Only invited collaborators can open this link.', + }); + }, [copyLink, permission, toaster]); + return { permission, canManage, + canChangePermission, + canInvite, loading, shareUrl, copied, tenantId, + owner, + collaborators, load, changePermission, + invite, + removeCollaborator, copySharedSessionLink, }; } diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts index 6ac78d4af3..bf15c31600 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/chatServer.ts @@ -88,6 +88,7 @@ function toUiSession(session: TrueForgeApi.Session): HarnessUiSession { isMutable: session.agent.type === 'inline', isCreateAgent: readSessionIsCreateAgent(session.metadata), shared: session.shared, + createdBy: { id: session.createdBySubject.subjectId, displayName: session.createdBySubject.subjectDisplayName }, createdAt: session.createdAt, updatedAt: session.updatedAt, metrics: { diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/index.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/index.ts index 8373f698e5..e205b56dd7 100644 --- a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/index.ts +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/index.ts @@ -16,6 +16,7 @@ import { createTrueForgeClient, type CreateTrueForgeClientOptions } from './clie import { createHarnessPermissionsServer } from './permissionsServer.js'; import { createSandboxEnvironmentServer } from './sandboxEnvironments/sandboxEnvironmentServer.js'; import { createScheduleServer } from './schedules/scheduleServer.js'; +import { createHarnessSessionAccessServer } from './sessionAccessServer.js'; import type { HarnessAgentSpec } from './types.js'; export { createHarnessAgentMetricsServer, type CreateHarnessAgentMetricsServerOptions } from './agentMetricsServer.js'; @@ -77,6 +78,10 @@ export { getCapabilities, listConfiguredMcpServers, listModels, listSkills } fro export { createHarnessPermissionsServer, type CreateHarnessPermissionsServerOptions } from './permissionsServer.js'; export { createSandboxEnvironmentServer } from './sandboxEnvironments/sandboxEnvironmentServer.js'; export { createScheduleServer } from './schedules/scheduleServer.js'; +export { + createHarnessSessionAccessServer, + type CreateHarnessSessionAccessServerOptions, +} from './sessionAccessServer.js'; export type { HarnessAgentSpec, HarnessMcpServerMount, HarnessSkillMount } from './types.js'; export type CreateTrueForgeAgentUIServerOptions = CreateTrueForgeClientOptions & { @@ -111,6 +116,7 @@ export function createTrueForgeAgentUIServer(options: CreateTrueForgeAgentUIServ schedules: createScheduleServer({ client }), sandboxEnvironments: createSandboxEnvironmentServer({ client }), permissions: permissions ?? createHarnessPermissionsServer({ client }), + sessionAccess: createHarnessSessionAccessServer({ client }), getMe: async () => { const { data } = await client.auth.me(); return { tenantId: data.tenantId }; diff --git a/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/sessionAccessServer.ts b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/sessionAccessServer.ts new file mode 100644 index 0000000000..c35a8f5348 --- /dev/null +++ b/packages/trueforge-ui/src/plugins/trueforge-agent-server-adapter/sessionAccessServer.ts @@ -0,0 +1,80 @@ +import type { TrueForge, TrueForgeApi } from '@truefoundry/trueforge-sdk'; + +import type { DirectoryUser, ListResult, SessionAccessGrant, SessionAccessServer } from '../../server/types.js'; +import { toListResult } from './chatServer.js'; +import { createTrueForgeClient, type CreateTrueForgeClientOptions } from './client.js'; + +/** `GET /users?ids=` rejects more than 100 ids. */ +const USER_LOOKUP_CHUNK = 100; + +export type CreateHarnessSessionAccessServerOptions = CreateTrueForgeClientOptions & { + client?: TrueForge; +}; + +function toDirectoryUser(user: TrueForgeApi.User): DirectoryUser { + return { id: user.id, displayName: user.displayName, email: user.email }; +} + +async function usersById({ + client, + ids, +}: { + client: TrueForge; + ids: string[]; +}): Promise> { + const byId = new Map(); + for (let start = 0; start < ids.length; start += USER_LOOKUP_CHUNK) { + const page = await client.users.list({ ids: ids.slice(start, start + USER_LOOKUP_CHUNK).join(',') }); + for (const user of page.data) byId.set(user.id, user); + } + return byId; +} + +async function toAccessGrants({ + client, + grants, +}: { + client: TrueForge; + grants: TrueForgeApi.SessionGrant[]; +}): Promise { + // Tenant-wide sharing is session.shared (a tenant grant); this port is user collaborators only. + const userGrants = grants.filter(grant => grant.subject.subjectType === 'user'); + const users = await usersById({ client, ids: userGrants.map(grant => grant.subject.subjectId) }); + return userGrants.map(grant => { + const user = users.get(grant.subject.subjectId); + return { + subjectType: 'user', + subjectId: grant.subject.subjectId, + displayName: user?.displayName ?? null, + email: user?.email ?? null, + createdAt: grant.createdAt, + }; + }); +} + +export function createHarnessSessionAccessServer( + options: CreateHarnessSessionAccessServerOptions = {}, +): SessionAccessServer { + const client = options.client ?? createTrueForgeClient(options); + return { + async listAccess({ sessionId }) { + const { data } = await client.sessions.listGrants(sessionId); + return toAccessGrants({ client, grants: data }); + }, + async grantAccess({ sessionId, subjects }) { + const { data } = await client.sessions.updateGrants(sessionId, { grant: subjects }); + return toAccessGrants({ client, grants: data }); + }, + async revokeAccess({ sessionId, subjectType, subjectId }) { + await client.sessions.updateGrants(sessionId, { revoke: [{ subjectType, subjectId }] }); + }, + async searchUsers(req): Promise> { + const page = await client.users.list({ + ...(req.query === undefined || req.query.length === 0 ? {} : { query: req.query }), + ...(req.limit === undefined ? {} : { limit: req.limit }), + ...(req.pageToken === undefined || req.pageToken.length === 0 ? {} : { pageToken: req.pageToken }), + }); + return toListResult(page, toDirectoryUser); + }, + }; +} diff --git a/packages/trueforge-ui/src/server/createTrueForgeServer.ts b/packages/trueforge-ui/src/server/createTrueForgeServer.ts index 151993d03c..02f0ac01f0 100644 --- a/packages/trueforge-ui/src/server/createTrueForgeServer.ts +++ b/packages/trueforge-ui/src/server/createTrueForgeServer.ts @@ -17,6 +17,7 @@ import type { SaveAgentResult, ScheduleServer, SearchAgentsParams, + SessionAccessServer, } from './types.js'; export type CreateTrueForgeServerOptions< @@ -57,6 +58,8 @@ export type CreateTrueForgeServerOptions< sandboxEnvironments?: TSandboxEnvironments; /** Per-resource grants. Omit to leave actions enabled. */ permissions?: TPermissions; + /** Session sharing (collaborator invite + list). Optional. */ + sessionAccess?: SessionAccessServer; /** Authenticated caller identity. Used for tenant-scoped share copy. */ getMe?: () => Promise<{ tenantId: string }>; }; @@ -83,6 +86,7 @@ export type TrueForgeServer< schedules?: TSchedules; sandboxEnvironments?: TSandboxEnvironments; permissions?: TPermissions; + sessionAccess?: SessionAccessServer; getMe?: () => Promise<{ tenantId: string }>; }; @@ -177,6 +181,7 @@ export function createTrueForgeServer< ...(opts.schedules != null ? { schedules: opts.schedules } : {}), ...(opts.sandboxEnvironments != null ? { sandboxEnvironments: opts.sandboxEnvironments } : {}), ...(opts.permissions != null ? { permissions: opts.permissions } : {}), + ...(opts.sessionAccess != null ? { sessionAccess: opts.sessionAccess } : {}), ...(opts.getMe != null ? { getMe: opts.getMe } : {}), }; return server; diff --git a/packages/trueforge-ui/src/server/types.ts b/packages/trueforge-ui/src/server/types.ts index e9360a1ddb..4ad691f800 100644 --- a/packages/trueforge-ui/src/server/types.ts +++ b/packages/trueforge-ui/src/server/types.ts @@ -62,6 +62,7 @@ export type { CreateWebSearchProviderRequest, CreatedBySubject, DefinedSkill, + DirectoryUser, GithubSkill, ImportGithubSkillRequest, ListPermissionsRequest, @@ -113,9 +114,14 @@ export type { SearchAgentsParams, SelectRegistrySkillRequest, Session, + SessionAccessGrant, + SessionAccessServer, + SessionAccessSubject, + SessionAccessSubjectType, SessionEventItem, SessionListEntry, SessionListMetrics, + SessionSubject, SkillBase, SkillCatalogEntry, SkillCatalogServer, diff --git a/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx b/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx index 567d2ed623..5fcc1a71cd 100644 --- a/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx +++ b/packages/trueforge-ui/test/atoms/ShareSessionDialog.test.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ShareSessionDialog } from '@/atoms/ShareSessionDialog.js'; @@ -7,6 +7,7 @@ import { ToasterProvider } from '@/containers/ToasterContainer.js'; import { resolveRoutesConfig } from '@/routing/paths.js'; import { ResolvedRoutesProvider } from '@/routing/ResolvedRoutesContext.js'; import { ServerProvider } from '@/server/ServerContext.js'; +import type { SessionAccessGrant, SessionAccessSubject } from '@/server/types.js'; import { SlotsProvider } from '@/theme/SlotsProvider.js'; import { createMockAgentUIServer } from '../server/mockServer.js'; @@ -30,40 +31,76 @@ afterEach(() => { } }); +type SessionStub = { + id: string; + isMutable: boolean; + shared?: boolean; + createdBy?: { id: string; displayName: string }; + createdAt: string; + updatedAt: string; +}; + +const baseSession: SessionStub = { + id: 'session-1', + isMutable: true, + shared: false, + createdBy: { id: 'owner', displayName: 'Sajal Preet Singh' }, + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', +}; + +const aliceGrant: SessionAccessGrant = { + subjectType: 'user', + subjectId: 'u1', + displayName: 'Alice', + email: 'alice@example.com', + createdAt: '2026-01-01T00:00:00Z', +}; + function renderDialog({ - getSession = vi.fn(async () => ({ - id: 'session-1', - isMutable: true, - shared: false, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - })), - updateSession = vi.fn(async (req: { sessionId: string; shared?: boolean }) => ({ - id: req.sessionId, - isMutable: true, - shared: req.shared === true, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - })), - getMe = vi.fn(async () => ({ tenantId: 'acme' })), + session = baseSession, + grants = [], + sessionSharingEnabled = true, }: { - getSession?: () => Promise<{ - id: string; - isMutable: boolean; - shared?: boolean; - createdAt: string; - updatedAt: string; - }>; - updateSession?: (req: { sessionId: string; shared?: boolean }) => Promise<{ - id: string; - isMutable: boolean; - shared?: boolean; - createdAt: string; - updatedAt: string; - }>; - getMe?: () => Promise<{ tenantId: string }>; + session?: SessionStub; + grants?: SessionAccessGrant[]; + sessionSharingEnabled?: boolean; } = {}) { - const server = createMockAgentUIServer({ getSession, updateSession, getMe }); + const listAccess = vi.fn(async () => grants); + const grantAccess = vi.fn(async (req: { sessionId: string; subjects: SessionAccessSubject[] }) => [ + ...grants, + ...req.subjects.map((subject): SessionAccessGrant => ({ + ...subject, + displayName: 'Bob', + email: null, + createdAt: '2026-01-02T00:00:00Z', + })), + ]); + const revokeAccess = vi.fn(async () => undefined); + const updateSession = vi.fn(async (req: { sessionId: string; shared?: boolean }) => ({ + ...session, + shared: req.shared === true, + })); + const searchUsers = vi.fn(async () => ({ + data: [ + { id: 'u1', displayName: 'Alice', email: 'alice@example.com' }, + { id: 'u2', displayName: 'Bob', email: 'bob@example.com' }, + ], + })); + const server = createMockAgentUIServer({ + getSession: vi.fn(async () => session), + getMe: vi.fn(async () => ({ tenantId: 'acme' })), + updateSession, + sessionAccess: { listAccess, grantAccess, revokeAccess, searchUsers }, + getCapabilities: async () => ({ + data: { + sandbox: { enabled: true }, + skill: { enabled: true }, + settings: { enabled: true }, + sessionSharing: { enabled: sessionSharingEnabled }, + }, + }), + }); render( @@ -75,7 +112,7 @@ function renderDialog({ , ); - return { getSession, updateSession, getMe }; + return { listAccess, grantAccess, revokeAccess, searchUsers, updateSession }; } async function openSharePopover() { @@ -96,13 +133,7 @@ describe('ShareSessionDialog', () => { it('loads tenant sharing and copies the share URL', async () => { renderDialog({ - getSession: vi.fn(async () => ({ - id: 'session-1', - isMutable: true, - shared: true, - createdAt: '2026-01-01T00:00:00Z', - updatedAt: '2026-01-01T00:00:00Z', - })), + session: { ...baseSession, shared: true }, }); await openSharePopover(); @@ -115,14 +146,13 @@ describe('ShareSessionDialog', () => { }); expect(new URL(String(writeText.mock.calls[0]?.[0])).pathname).toBe('/sessions/share/session-1'); expect(screen.getByRole('button', { name: 'Copied' })).toBeInTheDocument(); + expect(await screen.findByText('Link copied to your clipboard')).toBeInTheDocument(); }); - it('shows the share URL after switching to tenant access and hides it again for Only you', async () => { + it('updates session.shared when switching access', async () => { const { updateSession } = renderDialog(); await openSharePopover(); - expect(screen.queryByLabelText('Share URL')).not.toBeInTheDocument(); - fireEvent.click(await screen.findByRole('button', { name: 'Session sharing' })); fireEvent.click(await screen.findByRole('option', { name: 'Everyone within acme' })); @@ -140,7 +170,7 @@ describe('ShareSessionDialog', () => { expect(screen.queryByLabelText('Share URL')).not.toBeInTheDocument(); }); - it('closes permission options on outside click without PATCHing', async () => { + it('closes permission options on outside click without changing access', async () => { const { updateSession } = renderDialog(); await openSharePopover(); @@ -153,7 +183,7 @@ describe('ShareSessionDialog', () => { expect(updateSession).not.toHaveBeenCalled(); }); - it('closes the permission menu without PATCHing when the current option is clicked', async () => { + it('closes the permission menu without changing access when the current option is clicked', async () => { const { updateSession } = renderDialog(); await openSharePopover(); @@ -164,15 +194,59 @@ describe('ShareSessionDialog', () => { expect(updateSession).not.toHaveBeenCalled(); }); - it('PATCHes shared when the tenant permission is selected', async () => { - const { updateSession } = renderDialog(); + it('lists the owner and collaborators and shows the share URL once someone is invited', async () => { + renderDialog({ grants: [aliceGrant] }); await openSharePopover(); - fireEvent.click(await screen.findByRole('button', { name: 'Session sharing' })); - fireEvent.click(await screen.findByRole('option', { name: 'Everyone within acme' })); + expect(await screen.findByText('Alice')).toBeInTheDocument(); + expect(screen.getByText('can access')).toBeInTheDocument(); + expect(screen.getByText('Sajal Preet Singh')).toBeInTheDocument(); + expect(screen.getByText('owner')).toBeInTheDocument(); + expect(screen.getByLabelText('Share URL')).toBeInTheDocument(); + }); + + it('removes a collaborator', async () => { + const { revokeAccess } = renderDialog({ grants: [aliceGrant] }); + await openSharePopover(); + + fireEvent.click(await screen.findByRole('button', { name: 'Remove Alice' })); await waitFor(() => { - expect(updateSession).toHaveBeenCalledWith({ sessionId: 'session-1', shared: true }); + expect(revokeAccess).toHaveBeenCalledWith({ sessionId: 'session-1', subjectType: 'user', subjectId: 'u1' }); }); + expect(screen.queryByText('Alice')).not.toBeInTheDocument(); + }); + + it('searches users, hides people who already have access, and invites the selection', async () => { + const { grantAccess, searchUsers } = renderDialog({ grants: [aliceGrant] }); + await openSharePopover(); + await screen.findByText('Alice'); + + fireEvent.change(screen.getByLabelText('Search users'), { target: { value: 'b' } }); + await waitFor(() => { + expect(searchUsers).toHaveBeenCalledWith({ query: 'b', limit: 25 }); + }); + + const results = await screen.findByRole('listbox', { name: 'Matching users' }); + expect(within(results).queryByText('Alice')).not.toBeInTheDocument(); + fireEvent.click(await within(results).findByRole('option', { name: /Bob/ })); + fireEvent.click(screen.getByRole('button', { name: 'Invite' })); + + await waitFor(() => { + expect(grantAccess).toHaveBeenCalledWith({ + sessionId: 'session-1', + subjects: [{ subjectType: 'user', subjectId: 'u2' }], + }); + }); + }); + + it('hides the invite search when session_sharing.enabled is false', async () => { + renderDialog({ sessionSharingEnabled: false, grants: [aliceGrant] }); + await openSharePopover(); + + expect(await screen.findByRole('button', { name: 'Session sharing' })).toBeInTheDocument(); + expect(screen.queryByLabelText('Search users')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Invite' })).not.toBeInTheDocument(); + expect(screen.queryByText('Alice')).not.toBeInTheDocument(); }); }); diff --git a/packages/trueforge-ui/test/hooks/useSessionUserSearch.test.tsx b/packages/trueforge-ui/test/hooks/useSessionUserSearch.test.tsx new file mode 100644 index 0000000000..b0cf03ad77 --- /dev/null +++ b/packages/trueforge-ui/test/hooks/useSessionUserSearch.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { DirectoryUser, ListResult, SessionAccessServer } from '@/server/types.js'; + +type ServerHookValue = { sessionAccess?: Pick }; + +const useOptionalServer = vi.hoisted(() => vi.fn<() => ServerHookValue | null>()); + +vi.mock('@/server/ServerContext.js', () => ({ useOptionalServer })); +// Stable identity, like the real provider; a fresh object per render would retrigger the search effect. +const toaster = vi.hoisted(() => ({ showError: vi.fn(), showSuccess: vi.fn() })); +vi.mock('@/containers/ToasterContainer.js', () => ({ useToasterOptional: () => toaster })); + +import { useSessionUserSearch } from '@/hooks/useSessionUserSearch.js'; + +const alice: DirectoryUser = { id: 'u1', displayName: 'Alice', email: 'alice@example.com' }; +const bob: DirectoryUser = { id: 'u2', displayName: 'Bob', email: null }; + +describe('useSessionUserSearch', () => { + const searchUsers = vi.fn(); + + beforeEach(() => { + vi.useFakeTimers(); + searchUsers.mockReset(); + toaster.showError.mockReset(); + useOptionalServer.mockReturnValue({ sessionAccess: { searchUsers } }); + }); + + afterEach(() => { + cleanup(); + vi.useRealTimers(); + }); + + it('debounces typing into a single search and skips empty queries', async () => { + searchUsers.mockResolvedValue({ data: [alice] }); + const { result } = renderHook(() => useSessionUserSearch({ enabled: true })); + + act(() => result.current.setQuery('a')); + act(() => result.current.setQuery('al')); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(searchUsers).toHaveBeenCalledTimes(1); + expect(searchUsers).toHaveBeenCalledWith({ query: 'al', limit: 25 }); + expect(result.current.users).toEqual([alice]); + + act(() => result.current.setQuery(' ')); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(searchUsers).toHaveBeenCalledTimes(1); + expect(result.current.users).toEqual([]); + }); + + it('appends the next page without duplicating users', async () => { + searchUsers + .mockResolvedValueOnce({ data: [alice], nextPageToken: 'p2' }) + .mockResolvedValueOnce({ data: [alice, bob] } satisfies ListResult); + const { result } = renderHook(() => useSessionUserSearch({ enabled: true })); + + act(() => result.current.setQuery('a')); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(result.current.hasMore).toBe(true); + + await act(async () => { + result.current.loadMore(); + await Promise.resolve(); + }); + + expect(searchUsers).toHaveBeenLastCalledWith({ query: 'a', limit: 25, pageToken: 'p2' }); + expect(result.current.users).toEqual([alice, bob]); + expect(result.current.hasMore).toBe(false); + }); + + it('ignores a stale response when the query changed mid-flight', async () => { + let resolveFirst: (page: ListResult) => void = () => undefined; + searchUsers + .mockImplementationOnce( + () => + new Promise(resolve => { + resolveFirst = resolve; + }), + ) + .mockResolvedValueOnce({ data: [bob] }); + const { result } = renderHook(() => useSessionUserSearch({ enabled: true })); + + act(() => result.current.setQuery('a')); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + act(() => result.current.setQuery('b')); + expect(result.current.users).toEqual([]); + await act(async () => { + resolveFirst({ data: [alice] }); + await Promise.resolve(); + }); + expect(result.current.users).toEqual([]); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(result.current.users).toEqual([bob]); + }); + + it('resets pagination on query change and blocks loadMore until the first page returns', async () => { + searchUsers.mockResolvedValueOnce({ data: [alice], nextPageToken: 'p2' }).mockResolvedValueOnce({ data: [bob] }); + const { result } = renderHook(() => useSessionUserSearch({ enabled: true })); + + act(() => result.current.setQuery('a')); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(result.current.hasMore).toBe(true); + + act(() => result.current.setQuery('b')); + expect(result.current.users).toEqual([]); + expect(result.current.hasMore).toBe(false); + expect(result.current.loading).toBe(true); + + act(() => { + result.current.loadMore(); + }); + expect(searchUsers).toHaveBeenCalledTimes(1); + + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + expect(searchUsers).toHaveBeenCalledTimes(2); + expect(searchUsers).toHaveBeenLastCalledWith({ query: 'b', limit: 25 }); + expect(result.current.users).toEqual([bob]); + }); + + it('reports search failures through the toaster', async () => { + const failure = new Error('boom'); + searchUsers.mockRejectedValue(failure); + const { result } = renderHook(() => useSessionUserSearch({ enabled: true })); + + act(() => result.current.setQuery('a')); + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + + expect(toaster.showError).toHaveBeenCalledWith(failure); + }); +}); diff --git a/packages/trueforge-ui/test/hooks/useShareSessionDialog.test.tsx b/packages/trueforge-ui/test/hooks/useShareSessionDialog.test.tsx new file mode 100644 index 0000000000..8129e87a26 --- /dev/null +++ b/packages/trueforge-ui/test/hooks/useShareSessionDialog.test.tsx @@ -0,0 +1,299 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { Session, SessionAccessGrant, SessionAccessServer } from '@/server/types.js'; + +type ServerHookValue = { + getSession: (req: { sessionId: string }) => Promise; + getMe: () => Promise<{ tenantId: string }>; + updateSession: (req: { sessionId: string; shared?: boolean }) => Promise; + sessionAccess?: Pick; +}; + +const useOptionalServer = vi.hoisted(() => vi.fn<() => ServerHookValue | null>()); +const useServerCapabilities = vi.hoisted(() => vi.fn<() => { sessionSharing?: { enabled: boolean } } | null>()); +const allows = vi.hoisted(() => vi.fn<() => boolean>()); +const showError = vi.hoisted(() => vi.fn()); +const showSuccess = vi.hoisted(() => vi.fn()); +const copyLink = vi.hoisted(() => vi.fn<() => Promise>()); + +vi.mock('@/server/ServerContext.js', () => ({ useOptionalServer, useServerCapabilities })); +vi.mock('@/containers/ToasterContainer.js', () => ({ + useToasterOptional: () => ({ showError, showSuccess }), +})); +vi.mock('@/hooks/useResourcePermissions.js', () => ({ useResourcePermissions: () => ({ allows }) })); +vi.mock('@/routing/ResolvedRoutesContext.js', () => ({ useOptionalResolvedRoutes: () => undefined })); +vi.mock('@/routing/paths.js', () => ({ buildSharedSessionHref: () => 'https://app.test/shared' })); +vi.mock('@/hooks/useCopySharedSessionLink.js', () => ({ + useCopySharedSessionLink: () => ({ copied: false, copySharedSessionLink: copyLink }), +})); + +import { useShareSessionDialog } from '@/hooks/useShareSessionDialog.js'; + +const session: Session = { + id: 's1', + isMutable: false, + shared: false, + createdBy: { id: 'owner', displayName: 'Owner' }, + createdAt: '2026-10-22T00:00:00Z', + updatedAt: '2026-10-22T00:00:00Z', +}; + +function grant(overrides: Partial): SessionAccessGrant { + return { + subjectType: 'user', + subjectId: 'u1', + displayName: 'Alice', + email: 'alice@example.com', + createdAt: '2026-10-22T00:00:00Z', + ...overrides, + }; +} + +describe('useShareSessionDialog', () => { + const listAccess = vi.fn(); + const grantAccess = vi.fn(); + const revokeAccess = vi.fn(); + const updateSession = vi.fn<(req: { sessionId: string; shared?: boolean }) => Promise>(); + const getSession = vi.fn<(req: { sessionId: string }) => Promise>(); + + beforeEach(() => { + vi.clearAllMocks(); + allows.mockReturnValue(true); + useServerCapabilities.mockReturnValue({ sessionSharing: { enabled: true } }); + copyLink.mockResolvedValue(true); + getSession.mockResolvedValue(session); + listAccess.mockResolvedValue([grant({})]); + grantAccess.mockResolvedValue([grant({}), grant({ subjectId: 'u2', displayName: 'Bob' })]); + revokeAccess.mockResolvedValue(undefined); + updateSession.mockImplementation(async req => ({ ...session, shared: req.shared === true })); + useOptionalServer.mockReturnValue({ + getSession, + getMe: () => Promise.resolve({ tenantId: 't1' }), + updateSession, + sessionAccess: { listAccess, grantAccess, revokeAccess }, + }); + }); + + afterEach(cleanup); + + it('loads owner, user collaborators and permission from session.shared', async () => { + getSession.mockResolvedValue({ ...session, shared: true }); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + expect(result.current.owner).toEqual({ id: 'owner', displayName: 'Owner' }); + expect(result.current.collaborators.map(item => item.subjectId)).toEqual(['u1']); + expect(result.current.permission).toBe('tenant'); + expect(result.current.tenantId).toBe('t1'); + }); + + it('refreshes collaborators on each load so external grant changes appear', async () => { + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + expect(result.current.collaborators.map(item => item.subjectId)).toEqual(['u1']); + + listAccess.mockResolvedValue([]); + await act(() => result.current.load()); + expect(result.current.collaborators).toEqual([]); + }); + + it('does not list grants for viewers who cannot manage the session', async () => { + allows.mockReturnValue(false); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + expect(listAccess).not.toHaveBeenCalled(); + expect(result.current.canInvite).toBe(false); + expect(result.current.canChangePermission).toBe(false); + }); + + it('hides invite when session_sharing.enabled is false and still allows tenant permission changes', async () => { + useServerCapabilities.mockReturnValue({ sessionSharing: { enabled: false } }); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + expect(listAccess).not.toHaveBeenCalled(); + expect(result.current.canInvite).toBe(false); + expect(result.current.canChangePermission).toBe(true); + }); + + it('hides invite when session_sharing is missing from capabilities', async () => { + useServerCapabilities.mockReturnValue({}); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + expect(listAccess).not.toHaveBeenCalled(); + expect(result.current.canInvite).toBe(false); + expect(result.current.canChangePermission).toBe(true); + }); + + it('shares with the tenant via updateSession shared and unshares the same way', async () => { + listAccess.mockResolvedValue([]); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + await act(() => result.current.changePermission('tenant')); + expect(updateSession).toHaveBeenCalledWith({ sessionId: 's1', shared: true }); + expect(result.current.permission).toBe('tenant'); + + await act(() => result.current.changePermission('private')); + expect(updateSession).toHaveBeenCalledWith({ sessionId: 's1', shared: false }); + expect(result.current.permission).toBe('private'); + }); + + it('rolls back the permission and reports the error when updateSession fails', async () => { + listAccess.mockResolvedValue([]); + const failure = new Error('forbidden'); + updateSession.mockRejectedValue(failure); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + await act(() => result.current.changePermission('tenant')); + + expect(result.current.permission).toBe('private'); + expect(showError).toHaveBeenCalledWith(failure); + }); + + it('invites users and replaces the collaborator list with the returned grants', async () => { + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + let ok = false; + await act(async () => { + ok = await result.current.invite([{ id: 'u2', displayName: 'Bob', email: null }]); + }); + + expect(ok).toBe(true); + expect(grantAccess).toHaveBeenCalledWith({ sessionId: 's1', subjects: [{ subjectType: 'user', subjectId: 'u2' }] }); + expect(result.current.collaborators.map(item => item.subjectId)).toEqual(['u1', 'u2']); + }); + + it('removes a collaborator optimistically and restores it when revoke fails', async () => { + const failure = new Error('nope'); + revokeAccess.mockRejectedValue(failure); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + await act(() => result.current.removeCollaborator('u1')); + + expect(revokeAccess).toHaveBeenCalledWith({ sessionId: 's1', subjectType: 'user', subjectId: 'u1' }); + expect(result.current.collaborators.map(item => item.subjectId)).toEqual(['u1']); + expect(showError).toHaveBeenCalledWith(failure); + }); + + it('ignores a listAccess snapshot that arrives after a collaborator was removed', async () => { + const bob = grant({ subjectId: 'u2', displayName: 'Bob', email: 'bob@example.com' }); + let releaseStale: (grants: SessionAccessGrant[]) => void = () => undefined; + const stale = new Promise(resolve => { + releaseStale = resolve; + }); + let calls = 0; + listAccess.mockImplementation(() => { + calls += 1; + return calls === 1 ? Promise.resolve([grant({}), bob]) : stale; + }); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(async () => { + await Promise.resolve(); + }); + expect(result.current.collaborators.map(item => item.subjectId)).toEqual(['u1', 'u2']); + + let loadDone: Promise = Promise.resolve(); + await act(async () => { + loadDone = result.current.load(); + for (let attempt = 0; attempt < 10 && listAccess.mock.calls.length < 2; attempt += 1) { + await Promise.resolve(); + } + }); + expect(listAccess).toHaveBeenCalledTimes(2); + + await act(() => result.current.removeCollaborator('u2')); + expect(result.current.collaborators.map(item => item.subjectId)).toEqual(['u1']); + + await act(async () => { + releaseStale([grant({}), bob]); + await loadDone; + }); + expect(result.current.collaborators.map(item => item.subjectId)).toEqual(['u1']); + }); + + it('ignores a listAccess snapshot that arrives after an invite', async () => { + let releaseStale: (grants: SessionAccessGrant[]) => void = () => undefined; + const stale = new Promise(resolve => { + releaseStale = resolve; + }); + let calls = 0; + listAccess.mockImplementation(() => { + calls += 1; + return calls === 1 ? Promise.resolve([grant({})]) : stale; + }); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(async () => { + await Promise.resolve(); + }); + + let loadDone: Promise = Promise.resolve(); + await act(async () => { + loadDone = result.current.load(); + for (let attempt = 0; attempt < 10 && listAccess.mock.calls.length < 2; attempt += 1) { + await Promise.resolve(); + } + }); + + await act(() => result.current.invite([{ id: 'u2', displayName: 'Bob', email: null }])); + expect(result.current.collaborators.map(item => item.subjectId)).toEqual(['u1', 'u2']); + + await act(async () => { + releaseStale([grant({})]); + await loadDone; + }); + expect(result.current.collaborators.map(item => item.subjectId)).toEqual(['u1', 'u2']); + }); + + it('on concurrent removals, a failed revoke restores only that collaborator', async () => { + listAccess.mockResolvedValue([grant({}), grant({ subjectId: 'u2', displayName: 'Bob', email: 'bob@example.com' })]); + let rejectU1: (reason: Error) => void = () => undefined; + const u1Failed = new Promise((_, reject) => { + rejectU1 = reject; + }); + revokeAccess.mockImplementation(async req => { + if (req.subjectId === 'u1') return u1Failed; + }); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + let removeU1: Promise = Promise.resolve(); + await act(() => { + removeU1 = result.current.removeCollaborator('u1'); + return result.current.removeCollaborator('u2'); + }); + expect(result.current.collaborators).toEqual([]); + + await act(async () => { + rejectU1(new Error('nope')); + await removeU1.catch(() => undefined); + }); + + expect(result.current.collaborators.map(item => item.subjectId)).toEqual(['u1']); + expect(showError).toHaveBeenCalledWith(expect.any(Error)); + }); + + it('toasts the audience after copying the link, and nothing when the copy fails', async () => { + getSession.mockResolvedValue({ ...session, shared: true }); + const { result } = renderHook(() => useShareSessionDialog('s1')); + await act(() => result.current.load()); + + await act(() => result.current.copySharedSessionLink()); + expect(showSuccess).toHaveBeenCalledWith({ + title: 'Link copied to your clipboard', + description: 'Anyone in your workspace can open this link.', + }); + + showSuccess.mockClear(); + copyLink.mockResolvedValue(false); + await act(() => result.current.copySharedSessionLink()); + expect(showSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts index e2e723a363..1447883c4c 100644 --- a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts +++ b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/harnessServer.test.ts @@ -19,7 +19,7 @@ const session = { }, }, title: null, - created_by: 'trueforge-default', + created_by_subject: { subject_id: 'u_owner', subject_type: 'user', subject_display_name: 'Owner' }, created_at: '2026-08-03T00:00:00.000Z', updated_at: '2026-08-03T00:00:00.000Z', metrics: { @@ -54,7 +54,11 @@ const fetchMock: typeof fetch = async (input, init) => { body !== null && typeof body === 'object' && 'title' in body && typeof body.title === 'string' ? body.title : session.title; - return Response.json({ data: { ...session, title } }); + const shared = + body !== null && typeof body === 'object' && 'shared' in body && typeof body.shared === 'boolean' + ? body.shared + : false; + return Response.json({ data: { ...session, title, shared } }); } if (url.endsWith('/api/v1/sessions/ses_1') && method === 'DELETE') { deletedSessions.push('ses_1'); @@ -297,6 +301,14 @@ describe('createHarnessChatServer', () => { assert.equal(updated.title, 'Acme onboarding'); }); + it('PATCHes session shared when updateSession receives shared', async () => { + sessionRequests.length = 0; + const server = createHarnessChatServer({ fetch: fetchMock }); + const updated = await server.updateSession({ sessionId: 'ses_1', shared: true }); + assert.deepEqual(sessionRequests.at(-1), { shared: true }); + assert.equal(updated.shared, true); + }); + it('listSessions forwards an unknown agentId to the API (empty page from the server)', async () => { let listUrl: string | undefined; const fetchNamed: typeof fetch = async input => { @@ -337,6 +349,7 @@ describe('createHarnessChatServer', () => { assert.equal(listedAgents, false); assert.equal(found.agentName, 'reviewer'); assert.equal(found.isMutable, false); + assert.deepEqual(found.createdBy, { id: 'u_owner', displayName: 'Owner' }); assert.deepEqual(found.metrics, { totalTurns: 1, totalDurationMs: 12_000, diff --git a/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/sessionAccessServer.test.ts b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/sessionAccessServer.test.ts new file mode 100644 index 0000000000..e7e3f757e3 --- /dev/null +++ b/packages/trueforge-ui/test/plugins/trueforge-agent-server-adapter/sessionAccessServer.test.ts @@ -0,0 +1,138 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createTrueForgeAgentUIServer } from '@/plugins/trueforge-agent-server-adapter/index.js'; +import { createHarnessSessionAccessServer } from '@/plugins/trueforge-agent-server-adapter/sessionAccessServer.js'; + +const GRANT = { + subject: { subject_type: 'user', subject_id: 'user-1' }, + granted_by_subject: { subject_id: 'owner', subject_type: 'user', subject_display_name: 'Owner' }, + created_at: '2026-10-28T00:00:00.000Z', +}; + +const USER = { id: 'user-1', display_name: 'Ada', email: 'ada@example.com' }; + +function jsonResponse(body: unknown): Response { + return Response.json(body); +} + +describe('createHarnessSessionAccessServer', () => { + it('lists grants and fills names from the user directory', async () => { + const fetchMock: typeof fetch = vi.fn(async input => { + const url = new URL(String(input)); + if (url.pathname.endsWith('/grants')) return jsonResponse({ data: [GRANT] }); + expect(url.pathname).toBe('/api/v1/users'); + expect(url.searchParams.get('ids')).toBe('user-1'); + return jsonResponse({ data: [USER], pagination: { limit: 25 } }); + }); + const server = createHarnessSessionAccessServer({ + baseUrl: 'https://trueforge.test', + token: 'test-token', + fetch: fetchMock, + }); + + await expect(server.listAccess({ sessionId: 'sess-1' })).resolves.toEqual([ + { + subjectType: 'user', + subjectId: 'user-1', + displayName: 'Ada', + email: 'ada@example.com', + createdAt: '2026-10-28T00:00:00.000Z', + }, + ]); + }); + + it('omits tenant grants from the collaborator list', async () => { + const fetchMock: typeof fetch = vi.fn(async input => { + const url = new URL(String(input)); + if (url.pathname.endsWith('/grants')) { + return jsonResponse({ + data: [ + GRANT, + { + subject: { subject_type: 'tenant', subject_id: 'tenant-1' }, + granted_by_subject: { + subject_id: 'owner', + subject_type: 'user', + subject_display_name: 'Owner', + }, + created_at: '2026-10-28T00:00:00.000Z', + }, + ], + }); + } + expect(url.searchParams.get('ids')).toBe('user-1'); + return jsonResponse({ data: [USER], pagination: { limit: 25 } }); + }); + const server = createHarnessSessionAccessServer({ + baseUrl: 'https://trueforge.test', + token: 'test-token', + fetch: fetchMock, + }); + + await expect(server.listAccess({ sessionId: 'sess-1' })).resolves.toEqual([ + { + subjectType: 'user', + subjectId: 'user-1', + displayName: 'Ada', + email: 'ada@example.com', + createdAt: '2026-10-28T00:00:00.000Z', + }, + ]); + }); + + it('grants and revokes through one update call', async () => { + const bodies: unknown[] = []; + const fetchMock: typeof fetch = vi.fn(async (input, init) => { + const url = new URL(String(input)); + if (url.pathname.endsWith('/grants')) { + bodies.push(JSON.parse(String(init?.body))); + return jsonResponse({ data: [GRANT] }); + } + return jsonResponse({ data: [USER], pagination: { limit: 25 } }); + }); + const server = createHarnessSessionAccessServer({ + baseUrl: 'https://trueforge.test', + token: 'test-token', + fetch: fetchMock, + }); + + await server.grantAccess({ sessionId: 'sess-1', subjects: [{ subjectType: 'user', subjectId: 'user-1' }] }); + await server.revokeAccess({ sessionId: 'sess-1', subjectType: 'user', subjectId: 'user-1' }); + + expect(bodies).toEqual([ + { grant: [{ subject_id: 'user-1', subject_type: 'user' }] }, + { revoke: [{ subject_id: 'user-1', subject_type: 'user' }] }, + ]); + }); + + it('searches users', async () => { + const fetchMock: typeof fetch = vi.fn(async input => { + const url = new URL(String(input)); + expect(url.pathname).toBe('/api/v1/users'); + expect(url.searchParams.get('query')).toBe('ada'); + expect(url.searchParams.get('limit')).toBe('25'); + return jsonResponse({ + data: [USER], + pagination: { limit: 25, next_page_token: 'p2' }, + }); + }); + const server = createHarnessSessionAccessServer({ + baseUrl: 'https://trueforge.test', + token: 'test-token', + fetch: fetchMock, + }); + + await expect(server.searchUsers({ query: 'ada', limit: 25 })).resolves.toEqual({ + data: [{ id: 'user-1', displayName: 'Ada', email: 'ada@example.com' }], + nextPageToken: 'p2', + }); + }); +}); + +describe('createTrueForgeAgentUIServer session access', () => { + it('attaches the session access port', () => { + const server = createTrueForgeAgentUIServer({ fetch: vi.fn() }); + expect(server.sessionAccess?.listAccess).toEqual(expect.any(Function)); + expect(server.sessionAccess?.searchUsers).toEqual(expect.any(Function)); + }); +});