diff --git a/frontend/src/agents/components/AgentUsesSection.test.tsx b/frontend/src/agents/components/AgentUsesSection.test.tsx new file mode 100644 index 00000000..aa65cf07 --- /dev/null +++ b/frontend/src/agents/components/AgentUsesSection.test.tsx @@ -0,0 +1,314 @@ +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; + +vi.mock('react-redux', () => ({ + useSelector: (selector: (s: unknown) => unknown) => + selector({ preference: { token: 'tok' } }), +})); + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => { + if (!opts) return key; + const params = Object.entries(opts) + .filter(([k]) => k !== 'defaultValue' && k !== 'interpolation') + .map(([k, v]) => `${k}=${v}`) + .join(','); + return params ? `${key}(${params})` : key; + }, + i18n: { language: 'en' }, + }), +})); + +const getAgent = vi.fn(); +const getWorkflow = vi.fn(); +vi.mock('../../api/services/userService', () => ({ + default: { + getAgent: (...a: unknown[]) => getAgent(...a), + getWorkflow: (...a: unknown[]) => getWorkflow(...a), + }, +})); + +import type { Agent, ResourceState } from '../types'; +import AgentUsesSection from './AgentUsesSection'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const K = 'settings.teams.share.uses'; + +const ok = (body: unknown) => ({ ok: true, json: async () => body }); + +const item = (over: Partial): ResourceState => ({ + key: `${over.type ?? 'tool'}:${over.id ?? 't1'}`, + type: 'tool', + id: 't1', + name: 'Item', + state: 'active', + reason: null, + ...over, +}); + +const agentWith = ( + states: ResourceState[], + over: Partial = {}, +): Agent => ({ + id: 'a1', + name: 'A', + description: '', + image: '', + source: '', + chunks: '6', + retriever: '', + prompt_id: '', + tools: [], + agent_type: 'classic', + status: 'published', + access: 'owner', + allowed_actions: ['edit', 'share', 'use'], + resource_states: states, + ...over, +}); + +const flush = async () => { + for (let i = 0; i < 6; i += 1) { + await act(async () => { + await Promise.resolve(); + }); + } +}; + +describe('AgentUsesSection', () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + getAgent.mockReset(); + getWorkflow.mockReset(); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + const render = async (agent: Agent | null, readerId = 'me') => { + getAgent.mockResolvedValue(agent ? ok(agent) : { ok: false }); + act(() => { + root.render(); + }); + await flush(); + }; + + const toggle = () => + Array.from(container.querySelectorAll('button')).find((b) => + b.textContent?.includes(`${K}.title`), + ); + + const open = async () => { + const button = toggle(); + if (button?.getAttribute('aria-expanded') === 'false') { + act(() => button.click()); + await flush(); + } + }; + + const rowOf = (name: string) => + Array.from(container.querySelectorAll('li')).find((li) => + li.textContent?.includes(name), + ); + + it('starts collapsed and lists what the agent uses when opened', async () => { + await render(agentWith([item({ name: 'Docs', type: 'source', id: 's1' })])); + expect(toggle()?.getAttribute('aria-expanded')).toBe('false'); + expect(rowOf('Docs')).toBeUndefined(); + await open(); + expect(toggle()?.getAttribute('aria-expanded')).toBe('true'); + expect(rowOf('Docs')?.textContent).toContain(`${K}.access.you`); + }); + + it('names whose access each item runs with, for the owner', async () => { + await render( + agentWith([ + item({ id: 't1', name: 'Mine' }), + item({ + id: 't2', + name: 'Bobs', + sponsor: { user_id: 'bob', label: 'bob@example.com' }, + }), + item({ + id: 't3', + name: 'Slack tool', + credential_mode: 'member', + connection: { id: 'c1', connector_key: 'slack', name: 'Slack' }, + }), + item({ + id: 't4', + name: 'Notion tool', + credential_mode: 'owner', + account: { user_id: 'me', label: 'me@example.com' }, + connection: { id: 'c2', connector_key: 'notion', name: 'Notion' }, + }), + item({ + id: 't5', + name: 'Jira tool', + credential_mode: 'owner', + account: { user_id: 'carol', label: 'carol@example.com' }, + connection: { id: 'c3', connector_key: 'jira', name: 'Jira' }, + }), + item({ type: 'prompt', id: 'p1', name: 'Tone' }), + ]), + ); + await open(); + expect(rowOf('Mine')?.textContent).toContain(`${K}.access.you`); + expect(rowOf('Bobs')?.textContent).toContain( + `${K}.access.person(person=bob@example.com)`, + ); + expect(rowOf('Slack tool')?.textContent).toContain( + `${K}.access.member(service=Slack)`, + ); + expect(rowOf('Notion tool')?.textContent).toContain( + `${K}.access.yourAccount(service=Notion)`, + ); + expect(rowOf('Jira tool')?.textContent).toContain( + `${K}.access.personAccount(person=carol@example.com,service=Jira)`, + ); + expect(rowOf('Tone')?.textContent).toContain(`${K}.access.you`); + }); + + it("says the owner's access to an editor, and theirs where they sponsor", async () => { + await render( + agentWith( + [ + item({ id: 't1', name: 'Owners' }), + item({ + id: 't2', + name: 'Editors', + sponsor: { user_id: 'me', label: 'me@example.com' }, + }), + ], + { access: 'editor', allowed_actions: ['edit', 'share', 'use'] }, + ), + ); + await open(); + expect(rowOf('Owners')?.textContent).toContain(`${K}.access.owner`); + expect(rowOf('Editors')?.textContent).toContain(`${K}.access.you`); + }); + + it('opens by itself and marks a stopped item with why it stopped', async () => { + await render( + agentWith([ + item({ id: 't1', name: 'Gone', state: 'stopped', reason: 'deleted' }), + item({ id: 't2', name: 'Fine' }), + ]), + ); + expect(toggle()?.getAttribute('aria-expanded')).toBe('true'); + const gone = rowOf('Gone'); + expect(gone?.textContent).toContain(`${K}.stopped`); + expect(gone?.textContent).toContain( + 'agents.form.resourceStates.reason.deleted(name=Gone', + ); + expect(gone?.textContent).not.toContain(`${K}.access.you`); + expect(rowOf('Fine')?.textContent).not.toContain(`${K}.stopped`); + }); + + it('flags writes outside callers cannot make until they are allowed', async () => { + await render( + agentWith( + [ + item({ + id: 't1', + name: 'Blocked', + owner_credential_writes: ['send', 'delete'], + }), + item({ + id: 't2', + name: 'Allowed', + owner_credential_writes: ['send'], + }), + item({ id: 't3', name: 'Reads', owner_credential_writes: [] }), + ], + { config: { api_write_allowlist: ['t1:send', 't2:send'] } }, + ), + ); + await open(); + expect(rowOf('Blocked')?.textContent).toContain(`${K}.writesOff`); + expect(rowOf('Allowed')?.textContent).not.toContain(`${K}.writesOff`); + expect(rowOf('Reads')?.textContent).not.toContain(`${K}.writesOff`); + const alert = container.querySelector('[data-slot="alert"]'); + expect(alert?.textContent).toContain(`${K}.writesNote`); + }); + + it('tells an editor the owner allows the writes', async () => { + await render( + agentWith( + [item({ id: 't1', name: 'Blocked', owner_credential_writes: ['x'] })], + { access: 'editor', allowed_actions: ['edit', 'share'] }, + ), + ); + await open(); + expect( + container.querySelector('[data-slot="alert"]')?.textContent, + ).toContain(`${K}.writesNoteEditor`); + }); + + it('shows no writes note when every write is allowed', async () => { + await render( + agentWith([ + item({ id: 't1', name: 'Plain', owner_credential_writes: [] }), + ]), + ); + await open(); + expect(container.querySelector('[data-slot="alert"]')).toBeNull(); + }); + + it('includes the resources of a workflow agent’s nodes', async () => { + getWorkflow.mockResolvedValue( + ok({ + success: true, + data: { + resource_states: [ + item({ id: 'n1', name: 'Node tool' }), + item({ id: 't1', name: 'Shared tool' }), + ], + }, + }), + ); + await render( + agentWith([item({ id: 't1', name: 'Shared tool' })], { + agent_type: 'workflow', + workflow: 'w1', + }), + ); + await open(); + expect(getWorkflow).toHaveBeenCalledWith('w1', 'tok'); + expect(rowOf('Node tool')).toBeDefined(); + expect( + Array.from(container.querySelectorAll('li')).filter((li) => + li.textContent?.includes('Shared tool'), + ), + ).toHaveLength(1); + }); + + it('renders nothing for someone who may not edit the agent', async () => { + await render( + agentWith([item({ name: 'Hidden' })], { + access: 'viewer', + allowed_actions: ['use'], + }), + ); + expect(container.innerHTML).toBe(''); + }); + + it('renders nothing when the agent uses nothing listed', async () => { + await render(agentWith([])); + expect(container.innerHTML).toBe(''); + }); + + it('renders nothing when the agent fails to load', async () => { + await render(null); + expect(container.innerHTML).toBe(''); + }); +}); diff --git a/frontend/src/agents/components/AgentUsesSection.tsx b/frontend/src/agents/components/AgentUsesSection.tsx new file mode 100644 index 00000000..88fb832c --- /dev/null +++ b/frontend/src/agents/components/AgentUsesSection.tsx @@ -0,0 +1,240 @@ +import { + ChevronRight, + Database, + ScrollText, + TriangleAlert, + Wrench, +} from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import { Alert, AlertDescription } from '@/components/ui/alert'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Card } from '@/components/ui/card'; +import { ListRow, ListRows } from '@/components/ui/list-row'; +import { cn } from '@/lib/utils'; + +import userService from '../../api/services/userService'; +import { selectToken } from '../../preferences/preferenceSlice'; +import { can, isOwner } from '../../utils/accessUtils'; +import type { Agent, ResourceState } from '../types'; +import { reasonKey } from './ResourceStatusNotice'; + +type AgentUsesSectionProps = { + /** The agent the share dialog is for. */ + agentId: string; + /** The reader's user id, to say "your" for what runs as them. */ + readerId?: string; +}; + +const TYPE_ICONS: Record = { + tool: Wrench, + source: Database, + prompt: ScrollText, +}; + +const K = 'settings.teams.share.uses'; +const plain = { interpolation: { escapeValue: false } }; + +/** The agent's own items, then its workflow nodes' ones it doesn't have. */ +function mergeStates( + own: ResourceState[], + nodes: ResourceState[], +): ResourceState[] { + const seen = new Set(own.map((item) => item.key.toLowerCase())); + return [...own, ...nodes.filter((item) => !seen.has(item.key.toLowerCase()))]; +} + +/** Write actions outside callers can't take: not in the API write allowlist. */ +function blockedWrites(item: ResourceState, allowlist: string[]): string[] { + const allowed = new Set(allowlist.map((entry) => entry.toLowerCase())); + return (item.owner_credential_writes ?? []).filter( + (action) => !allowed.has(`${item.id}:${action}`.toLowerCase()), + ); +} + +/** + * "What this agent uses" in the agent's share dialog: each attached tool, + * source and prompt (and a workflow agent's node tools and sources), with + * whose access it runs with for the people the agent is shared with. + * + * Built from `resource_states` on the agent read (and the workflow read), + * which only owners and editors get; the section is hidden from anyone else, + * and while it loads or when it fails. A stopped item says why. A tool whose + * writes act on stored credentials and aren't in the API write allowlist is + * marked, since API, widget and public-link users can't make those changes. + */ +export default function AgentUsesSection({ + agentId, + readerId, +}: AgentUsesSectionProps) { + const { t } = useTranslation(); + const token = useSelector(selectToken); + const [loaded, setLoaded] = useState<{ + agent: Agent; + items: ResourceState[]; + } | null>(null); + const [open, setOpen] = useState(false); + + useEffect(() => { + let cancelled = false; + setLoaded(null); + setOpen(false); + const load = async () => { + let agent: Agent; + try { + const response = await userService.getAgent(agentId, token); + if (!response.ok) return; + agent = await response.json(); + } catch { + return; + } + let items = agent.resource_states ?? []; + if (agent.agent_type === 'workflow' && agent.workflow) { + try { + const response = await userService.getWorkflow(agent.workflow, token); + if (response.ok) { + const body = await response.json(); + items = mergeStates(items, body?.data?.resource_states ?? []); + } + } catch { + // The agent's own items still show. + } + } + if (cancelled) return; + setLoaded({ agent, items }); + // Like Access settings: open when there is something to look at. + if (items.some((item) => item.state === 'stopped')) setOpen(true); + }; + void load(); + return () => { + cancelled = true; + }; + }, [agentId, token]); + + if (!loaded || !can(loaded.agent, 'edit') || loaded.items.length === 0) + return null; + + const { agent, items } = loaded; + const ownerReads = isOwner(agent); + // Without a user id (authentication off) the one local user is everyone. + const isYou = (userId: string) => + readerId ? userId === readerId : ownerReads; + const allowlist = agent.config?.api_write_allowlist ?? []; + const nameOf = (item: ResourceState) => + item.name || t('agents.form.sponsors.unknownItem'); + + const accessLabel = (item: ResourceState): string => { + const service = item.connection?.name; + if (item.credential_mode === 'member') + return service + ? t(`${K}.access.member`, { ...plain, service }) + : t(`${K}.access.memberNoService`); + if (item.credential_mode === 'owner' && item.account) { + if (isYou(item.account.user_id)) + return service + ? t(`${K}.access.yourAccount`, { ...plain, service }) + : t(`${K}.access.yourAccountNoService`); + return service + ? t(`${K}.access.personAccount`, { + ...plain, + person: item.account.label, + service, + }) + : t(`${K}.access.personAccountNoService`, { + ...plain, + person: item.account.label, + }); + } + if (item.sponsor) + return isYou(item.sponsor.user_id) + ? t(`${K}.access.you`) + : t(`${K}.access.person`, { ...plain, person: item.sponsor.label }); + return ownerReads ? t(`${K}.access.you`) : t(`${K}.access.owner`); + }; + + const stoppedText = (item: ResourceState) => + t(reasonKey(item.reason, ownerReads), { + ...plain, + name: nameOf(item), + service: + item.connection?.name || + t('agents.form.resourceStates.serviceFallback'), + person: item.sponsor?.label || item.sponsor?.user_id, + }); + + const anyBlocked = items.some( + (item) => + item.state === 'active' && blockedWrites(item, allowlist).length > 0, + ); + + return ( +
+ {/* The same inline disclosure as Access settings. */} + + {open && ( + <> +

{t(`${K}.intro`)}

+ + + {items.map((item) => { + const Icon = TYPE_ICONS[item.type] ?? Wrench; + const stopped = item.state === 'stopped'; + const description = stopped + ? stoppedText(item) + : accessLabel(item); + const writesOff = + !stopped && blockedWrites(item, allowlist).length > 0; + return ( + + + + {anyBlocked && ( + + + + {ownerReads ? t(`${K}.writesNote`) : t(`${K}.writesNoteEditor`)} + + + )} + + )} +
+ ); +} diff --git a/frontend/src/agents/components/ResourceStatusNotice.tsx b/frontend/src/agents/components/ResourceStatusNotice.tsx index 781c4a95..890318bb 100644 --- a/frontend/src/agents/components/ResourceStatusNotice.tsx +++ b/frontend/src/agents/components/ResourceStatusNotice.tsx @@ -38,7 +38,10 @@ type ResourceStatusNoticeProps = { }; /** The message key that says why an item stopped. */ -function reasonKey(reason: ResourceStateReason | null, ownerReads: boolean) { +export function reasonKey( + reason: ResourceStateReason | null, + ownerReads: boolean, +) { switch (reason) { case 'deleted': return 'agents.form.resourceStates.reason.deleted'; diff --git a/frontend/src/agents/types/index.ts b/frontend/src/agents/types/index.ts index 00086ab2..ceab5772 100644 --- a/frontend/src/agents/types/index.ts +++ b/frontend/src/agents/types/index.ts @@ -61,12 +61,18 @@ export type ResourceState = { sponsor?: ResourcePerson | null; /** Someone other than the reader who can fix it. */ contact?: ResourcePerson | null; - /** The service, for a connection reason. */ + /** The service of a connected tool, or of one a connection reason stopped. */ connection?: { id: string | null; connector_key: string | null; name: string | null; } | null; + /** A running connected tool's mode: the owner's account or each person's own. */ + credential_mode?: 'owner' | 'member' | null; + /** Whose account an owner-mode connection acts as. */ + account?: ResourcePerson | null; + /** Its write actions on credentials its owner stored (the API write allowlist's). */ + owner_credential_writes?: string[]; /** The reader may run it with their access by confirming on a save. */ can_confirm?: boolean; /** The reader owns the connection that needs signing in again. */ diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index 2f02eab3..1f161218 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -762,6 +762,25 @@ "teams": "Teams", "people": "Personen", "editors": "Bearbeiter" + }, + "uses": { + "title": "Was dieser Agent nutzt", + "intro": "Alle, die diesen Agenten nutzen, erhalten diese Elemente, jeweils mit dem angezeigten Zugriff.", + "stopped": "Gestoppt", + "writesOff": "Keine API-Änderungen", + "writesNote": "API-, Widget- und Link-Nutzer können mit Elementen, die mit „Keine API-Änderungen“ markiert sind, nichts ändern, bis du es unter Zugangsdaten erlaubst.", + "writesNoteEditor": "API-, Widget- und Link-Nutzer können mit Elementen, die mit „Keine API-Änderungen“ markiert sind, nichts ändern, bis der Eigentümer es unter Zugangsdaten erlaubt.", + "access": { + "you": "Dein Zugriff", + "person": "Zugriff von {{person}}", + "owner": "Zugriff des Eigentümers", + "member": "Das eigene {{service}}-Konto jeder Person", + "memberNoService": "Das eigene Konto jeder Person", + "yourAccount": "Dein {{service}}-Konto", + "yourAccountNoService": "Dein Konto", + "personAccount": "{{service}}-Konto von {{person}}", + "personAccountNoService": "Konto von {{person}}" + } } }, "memberCount_one": "{{formatted}} Mitglied", @@ -816,7 +835,7 @@ "viewers_can_use_in_agents": { "label": "Betrachter dürfen es in eigenen Agenten nutzen", "description": "Es läuft mit deinen Anmeldedaten.", - "descriptionMember": "Jedes Mitglied nutzt sein eigenes Konto." + "descriptionMember": "Jede Person nutzt ihr eigenes Konto." } }, "prompt": { @@ -1537,18 +1556,19 @@ "defaultKey": "Ein Admin muss ENCRYPTION_SECRET_KEY setzen, bevor Dienste verbunden werden können." }, "sharing": { - "owner": "Teammitglieder nutzen mein Konto", - "member": "Jedes Mitglied verbindet sein eigenes Konto", - "ownerShort": "Mein Konto", - "memberShort": "Jeweils eigenes" + "owner": "Alle nutzen dein Konto", + "member": "Jede Person verbindet ihr eigenes Konto", + "ownerShort": "Dein Konto", + "memberShort": "Jeweils eigenes", + "ownerShortShared": "Konto des Eigentümers" }, "share": { - "heading": "Welches Konto Teammitglieder nutzen", + "heading": "Mit wessen Konto es läuft", "ownerWarning": "Teammitglieder handeln in {{name}} als {{account}}.", "ownerWarningShared": "Teammitglieder handeln mit dem {{name}}-Konto des Eigentümers.", "confirmWrite": "Ich verstehe, dass Teammitglieder mit meinem Konto Aktionen ausführen können.", "confirmWriteShared": "Ich verstehe, dass Teammitglieder mit dem {{name}}-Konto des Eigentümers Aktionen ausführen können.", - "memberNote": "Jedes Mitglied verbindet sein eigenes {{name}}-Konto, bevor es dieses Werkzeug nutzen kann.", + "memberNote": "Jede Person nutzt ihr eigenes {{name}}-Konto und verbindet es, wenn sie dieses Werkzeug zum ersten Mal nutzt.", "forced": "Ein Admin hat das für alle Freigaben dieses Konnektors festgelegt.", "ownerChooses": "Nur der Eigentümer kann das ändern.", "saveFailed": "Die Änderung konnte nicht gespeichert werden. Versuche es erneut." diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index 57852831..1d0b15a2 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -768,6 +768,25 @@ "teams": "Teams", "people": "People", "editors": "Editors" + }, + "uses": { + "title": "What this agent uses", + "intro": "Everyone who uses this agent gets these, each with the access shown.", + "stopped": "Stopped", + "writesOff": "No API changes", + "writesNote": "API, widget and public-link users can't make changes with items marked “No API changes” until you allow them in Access Details.", + "writesNoteEditor": "API, widget and public-link users can't make changes with items marked “No API changes” until the owner allows them in Access Details.", + "access": { + "you": "Your access", + "person": "{{person}}'s access", + "owner": "The owner's access", + "member": "Each person's own {{service}} account", + "memberNoService": "Each person's own account", + "yourAccount": "Your {{service}} account", + "yourAccountNoService": "Your account", + "personAccount": "{{person}}'s {{service}} account", + "personAccountNoService": "{{person}}'s account" + } } }, "memberCount_one": "{{formatted}} member", @@ -822,7 +841,7 @@ "viewers_can_use_in_agents": { "label": "Viewers can use it in their own agents", "description": "It runs with your credentials.", - "descriptionMember": "Each member uses their own account." + "descriptionMember": "Each person uses their own account." } }, "prompt": { @@ -1543,18 +1562,19 @@ "defaultKey": "An admin must set ENCRYPTION_SECRET_KEY before services can be connected." }, "sharing": { - "owner": "Team members use my account", - "member": "Each member connects their own account", - "ownerShort": "My account", - "memberShort": "Each member's own" + "owner": "Everyone uses your account", + "member": "Each person connects their own account", + "ownerShort": "Your account", + "memberShort": "Each person's own", + "ownerShortShared": "The owner's account" }, "share": { - "heading": "Whose account team members use", + "heading": "Whose account it runs with", "ownerWarning": "Team members will act as {{account}} on {{name}}.", "ownerWarningShared": "Team members will act as the owner's {{name}} account.", "confirmWrite": "I understand teammates can take actions with my account.", "confirmWriteShared": "I understand teammates can take actions with the owner's {{name}} account.", - "memberNote": "Each member connects their own {{name}} account before they can use this tool.", + "memberNote": "Each person uses their own {{name}} account, and connects it the first time they use this tool.", "forced": "An admin chose this for every share of this connector.", "ownerChooses": "Only the owner can change this.", "saveFailed": "Could not save the change. Try again." diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 638f484f..ed93e605 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -762,6 +762,25 @@ "teams": "Equipos", "people": "Personas", "editors": "Editores" + }, + "uses": { + "title": "Qué usa este agente", + "intro": "Todos los que usan este agente reciben estos elementos, cada uno con el acceso indicado.", + "stopped": "Detenido", + "writesOff": "Sin cambios por API", + "writesNote": "Los usuarios de la API, el widget y el enlace público no pueden hacer cambios con los elementos marcados «Sin cambios por API» hasta que los permitas en Access Details.", + "writesNoteEditor": "Los usuarios de la API, el widget y el enlace público no pueden hacer cambios con los elementos marcados «Sin cambios por API» hasta que el propietario los permita en Access Details.", + "access": { + "you": "Tu acceso", + "person": "Acceso de {{person}}", + "owner": "Acceso del propietario", + "member": "La propia cuenta de {{service}} de cada persona", + "memberNoService": "La propia cuenta de cada persona", + "yourAccount": "Tu cuenta de {{service}}", + "yourAccountNoService": "Tu cuenta", + "personAccount": "Cuenta de {{service}} de {{person}}", + "personAccountNoService": "Cuenta de {{person}}" + } } }, "memberCount_one": "{{formatted}} miembro", @@ -816,7 +835,7 @@ "viewers_can_use_in_agents": { "label": "Los lectores pueden usarla en sus propios agentes", "description": "Se ejecuta con tus credenciales.", - "descriptionMember": "Cada miembro usa su propia cuenta." + "descriptionMember": "Cada persona usa su propia cuenta." } }, "prompt": { @@ -1537,18 +1556,19 @@ "defaultKey": "Un administrador debe definir ENCRYPTION_SECRET_KEY antes de poder conectar servicios." }, "sharing": { - "owner": "Los miembros usan mi cuenta", - "member": "Cada miembro conecta su propia cuenta", - "ownerShort": "Mi cuenta", - "memberShort": "La de cada miembro" + "owner": "Todos usan tu cuenta", + "member": "Cada persona conecta su propia cuenta", + "ownerShort": "Tu cuenta", + "memberShort": "La de cada persona", + "ownerShortShared": "La cuenta del propietario" }, "share": { - "heading": "Qué cuenta usan los miembros del equipo", + "heading": "Con qué cuenta se ejecuta", "ownerWarning": "Los miembros del equipo actuarán como {{account}} en {{name}}.", "ownerWarningShared": "Los miembros del equipo actuarán con la cuenta de {{name}} del propietario.", "confirmWrite": "Entiendo que los miembros del equipo pueden realizar acciones con mi cuenta.", "confirmWriteShared": "Entiendo que los miembros del equipo pueden realizar acciones con la cuenta de {{name}} del propietario.", - "memberNote": "Cada miembro conecta su propia cuenta de {{name}} antes de poder usar esta herramienta.", + "memberNote": "Cada persona usa su propia cuenta de {{name}} y la conecta la primera vez que usa esta herramienta.", "forced": "Un administrador eligió esto para todos los recursos compartidos de este conector.", "ownerChooses": "Solo el propietario puede cambiar esto.", "saveFailed": "No se pudo guardar el cambio. Inténtalo de nuevo." diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index da3ef786..b1e16803 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -761,6 +761,25 @@ "teams": "チーム", "people": "ユーザー", "editors": "編集者" + }, + "uses": { + "title": "このエージェントが使うもの", + "intro": "このエージェントを使うすべての人が、表示されたアクセスでこれらを使います。", + "stopped": "停止中", + "writesOff": "API から変更不可", + "writesNote": "API、ウィジェット、公開リンクの利用者は、「API から変更不可」の項目で変更を行えません。Access Details で許可すると行えます。", + "writesNoteEditor": "API、ウィジェット、公開リンクの利用者は、「API から変更不可」の項目で変更を行えません。オーナーが Access Details で許可すると行えます。", + "access": { + "you": "あなたのアクセス", + "person": "{{person}} のアクセス", + "owner": "オーナーのアクセス", + "member": "各自の {{service}} アカウント", + "memberNoService": "各自のアカウント", + "yourAccount": "あなたの {{service}} アカウント", + "yourAccountNoService": "あなたのアカウント", + "personAccount": "{{person}} の {{service}} アカウント", + "personAccountNoService": "{{person}} のアカウント" + } } }, "memberCount_one": "{{formatted}}人のメンバー", @@ -815,7 +834,7 @@ "viewers_can_use_in_agents": { "label": "閲覧者が自分のエージェントで使える", "description": "あなたの認証情報で実行されます。", - "descriptionMember": "各メンバーが自分のアカウントを使います。" + "descriptionMember": "各自が自分のアカウントを使います。" } }, "prompt": { @@ -1527,18 +1546,19 @@ "defaultKey": "サービスを接続する前に、管理者が ENCRYPTION_SECRET_KEY を設定する必要があります。" }, "sharing": { - "owner": "メンバーは自分のアカウントを使う", - "member": "各メンバーが自分のアカウントを接続する", - "ownerShort": "自分のアカウント", - "memberShort": "各メンバーのもの" + "owner": "全員があなたのアカウントを使う", + "member": "各自が自分のアカウントを接続する", + "ownerShort": "あなたのアカウント", + "memberShort": "各自のもの", + "ownerShortShared": "オーナーのアカウント" }, "share": { - "heading": "チームメンバーが使うアカウント", + "heading": "実行に使うアカウント", "ownerWarning": "チームメンバーは {{name}} で {{account}} として操作します。", "ownerWarningShared": "チームメンバーは所有者の {{name}} アカウントで操作します。", "confirmWrite": "チームメンバーが自分のアカウントで操作を行えることを理解しました。", "confirmWriteShared": "チームメンバーが所有者の {{name}} アカウントで操作を行えることを理解しました。", - "memberNote": "各メンバーは、このツールを使う前に自分の {{name}} アカウントを接続します。", + "memberNote": "各自が自分の {{name}} アカウントを使い、このツールを初めて使うときに接続します。", "forced": "このコネクタのすべての共有に対して管理者が設定しています。", "ownerChooses": "これを変更できるのは所有者だけです。", "saveFailed": "変更を保存できませんでした。もう一度お試しください。" diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index b145a7e7..f65dcb8b 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -806,6 +806,25 @@ "teams": "Команды", "people": "Люди", "editors": "Редакторы" + }, + "uses": { + "title": "Что использует этот агент", + "intro": "Все, кто пользуется этим агентом, получают это, каждое — с указанным доступом.", + "stopped": "Остановлено", + "writesOff": "Без изменений через API", + "writesNote": "Пользователи API, виджета и публичной ссылки не могут вносить изменения через элементы с пометкой «Без изменений через API», пока вы не разрешите это в Access Details.", + "writesNoteEditor": "Пользователи API, виджета и публичной ссылки не могут вносить изменения через элементы с пометкой «Без изменений через API», пока владелец не разрешит это в Access Details.", + "access": { + "you": "Ваш доступ", + "person": "Доступ {{person}}", + "owner": "Доступ владельца", + "member": "Собственный аккаунт {{service}} у каждого", + "memberNoService": "Собственный аккаунт у каждого", + "yourAccount": "Ваш аккаунт {{service}}", + "yourAccountNoService": "Ваш аккаунт", + "personAccount": "Аккаунт {{service}} пользователя {{person}}", + "personAccountNoService": "Аккаунт пользователя {{person}}" + } } }, "memberCount_one": "{{formatted}} участник", @@ -864,7 +883,7 @@ "viewers_can_use_in_agents": { "label": "Читатели могут использовать его в своих агентах", "description": "Он работает с вашими учётными данными.", - "descriptionMember": "Каждый участник использует свой аккаунт." + "descriptionMember": "Каждый использует свой аккаунт." } }, "prompt": { @@ -1619,18 +1638,19 @@ "defaultKey": "Администратор должен задать ENCRYPTION_SECRET_KEY, прежде чем можно будет подключать сервисы." }, "sharing": { - "owner": "Участники используют мой аккаунт", - "member": "Каждый участник подключает свой аккаунт", - "ownerShort": "Мой аккаунт", - "memberShort": "У каждого свой" + "owner": "Все используют ваш аккаунт", + "member": "Каждый подключает свой аккаунт", + "ownerShort": "Ваш аккаунт", + "memberShort": "У каждого свой", + "ownerShortShared": "Аккаунт владельца" }, "share": { - "heading": "Чей аккаунт используют участники команды", + "heading": "С чьим аккаунтом он работает", "ownerWarning": "Участники команды будут действовать в {{name}} как {{account}}.", "ownerWarningShared": "Участники команды будут действовать от имени аккаунта {{name}} владельца.", "confirmWrite": "Я понимаю, что участники команды могут выполнять действия от имени моего аккаунта.", "confirmWriteShared": "Я понимаю, что участники команды могут выполнять действия от имени аккаунта {{name}} владельца.", - "memberNote": "Каждый участник подключает свой аккаунт {{name}}, прежде чем пользоваться этим инструментом.", + "memberNote": "Каждый использует свой аккаунт {{name}} и подключает его при первом использовании этого инструмента.", "forced": "Администратор задал это для всех общих доступов к этому коннектору.", "ownerChooses": "Изменить это может только владелец.", "saveFailed": "Не удалось сохранить изменение. Попробуйте ещё раз." diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 056f62ff..0dc699a6 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -761,6 +761,25 @@ "teams": "團隊", "people": "人員", "editors": "編輯者" + }, + "uses": { + "title": "此代理使用的內容", + "intro": "使用此代理的每個人都會用到這些內容,各自依所示的存取權限執行。", + "stopped": "已停止", + "writesOff": "API 無法變更", + "writesNote": "在你於 Access Details 中允許之前,API、小工具和公開連結的使用者無法透過標示為「API 無法變更」的項目進行變更。", + "writesNoteEditor": "在擁有者於 Access Details 中允許之前,API、小工具和公開連結的使用者無法透過標示為「API 無法變更」的項目進行變更。", + "access": { + "you": "你的存取權限", + "person": "{{person}} 的存取權限", + "owner": "擁有者的存取權限", + "member": "每個人自己的 {{service}} 帳號", + "memberNoService": "每個人自己的帳號", + "yourAccount": "你的 {{service}} 帳號", + "yourAccountNoService": "你的帳號", + "personAccount": "{{person}} 的 {{service}} 帳號", + "personAccountNoService": "{{person}} 的帳號" + } } }, "memberCount_one": "{{formatted}} 位成員", @@ -815,7 +834,7 @@ "viewers_can_use_in_agents": { "label": "檢視者可以在自己的代理中使用", "description": "它會以你的憑證執行。", - "descriptionMember": "每位成員使用自己的帳號。" + "descriptionMember": "每個人使用自己的帳號。" } }, "prompt": { @@ -1527,18 +1546,19 @@ "defaultKey": "管理員必須先設定 ENCRYPTION_SECRET_KEY,才能連線服務。" }, "sharing": { - "owner": "團隊成員使用我的帳號", - "member": "每位成員連線自己的帳號", - "ownerShort": "我的帳號", - "memberShort": "各自的帳號" + "owner": "所有人使用你的帳號", + "member": "每個人連線自己的帳號", + "ownerShort": "你的帳號", + "memberShort": "各自的帳號", + "ownerShortShared": "擁有者的帳號" }, "share": { - "heading": "團隊成員使用誰的帳號", + "heading": "使用誰的帳號執行", "ownerWarning": "團隊成員將在 {{name}} 中以 {{account}} 的身分操作。", "ownerWarningShared": "團隊成員將以擁有者的 {{name}} 帳號操作。", "confirmWrite": "我了解團隊成員可以使用我的帳號執行操作。", "confirmWriteShared": "我了解團隊成員可以使用擁有者的 {{name}} 帳號執行操作。", - "memberNote": "每位成員須先連線自己的 {{name}} 帳號,才能使用此工具。", + "memberNote": "每個人使用自己的 {{name}} 帳號,並在首次使用此工具時連線。", "forced": "管理員已為此連接器的所有共用設定此項。", "ownerChooses": "只有擁有者可以變更此項。", "saveFailed": "無法儲存變更。請再試一次。" diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 4d65872d..78b626c7 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -761,6 +761,25 @@ "teams": "团队", "people": "人员", "editors": "编辑者" + }, + "uses": { + "title": "此智能体使用的内容", + "intro": "使用此智能体的每个人都会用到这些内容,各自按所示的访问权限运行。", + "stopped": "已停止", + "writesOff": "API 不可更改", + "writesNote": "在你于 Access Details 中允许之前,API、小组件和公开链接的用户无法通过标记为“API 不可更改”的项目进行更改。", + "writesNoteEditor": "在所有者于 Access Details 中允许之前,API、小组件和公开链接的用户无法通过标记为“API 不可更改”的项目进行更改。", + "access": { + "you": "你的访问权限", + "person": "{{person}} 的访问权限", + "owner": "所有者的访问权限", + "member": "每个人自己的 {{service}} 账号", + "memberNoService": "每个人自己的账号", + "yourAccount": "你的 {{service}} 账号", + "yourAccountNoService": "你的账号", + "personAccount": "{{person}} 的 {{service}} 账号", + "personAccountNoService": "{{person}} 的账号" + } } }, "memberCount_one": "{{formatted}} 名成员", @@ -815,7 +834,7 @@ "viewers_can_use_in_agents": { "label": "查看者可以在自己的代理中使用", "description": "它使用你的凭据运行。", - "descriptionMember": "每位成员使用自己的账号。" + "descriptionMember": "每个人使用自己的账号。" } }, "prompt": { @@ -1527,18 +1546,19 @@ "defaultKey": "管理员必须先设置 ENCRYPTION_SECRET_KEY,才能连接服务。" }, "sharing": { - "owner": "团队成员使用我的账号", - "member": "每位成员连接自己的账号", - "ownerShort": "我的账号", - "memberShort": "各自的账号" + "owner": "所有人使用你的账号", + "member": "每个人连接自己的账号", + "ownerShort": "你的账号", + "memberShort": "各自的账号", + "ownerShortShared": "所有者的账号" }, "share": { - "heading": "团队成员使用谁的账号", + "heading": "使用谁的账号运行", "ownerWarning": "团队成员将在 {{name}} 中以 {{account}} 的身份操作。", "ownerWarningShared": "团队成员将以所有者的 {{name}} 账号操作。", "confirmWrite": "我了解团队成员可以使用我的账号执行操作。", "confirmWriteShared": "我了解团队成员可以使用所有者的 {{name}} 账号执行操作。", - "memberNote": "每位成员需先连接自己的 {{name}} 账号才能使用此工具。", + "memberNote": "每个人使用自己的 {{name}} 账号,并在首次使用此工具时连接。", "forced": "管理员已为此连接器的所有共享设定了此项。", "ownerChooses": "只有所有者可以更改此项。", "saveFailed": "无法保存更改。请重试。" diff --git a/frontend/src/teams/ShareToTeamModal.test.tsx b/frontend/src/teams/ShareToTeamModal.test.tsx index ad0ae043..1ef86cd0 100644 --- a/frontend/src/teams/ShareToTeamModal.test.tsx +++ b/frontend/src/teams/ShareToTeamModal.test.tsx @@ -54,6 +54,13 @@ vi.mock('../api/services/connectorsService', () => ({ }, })); +// The agent section loads the agent itself; its own tests cover it. +vi.mock('../agents/components/AgentUsesSection', () => ({ + default: ({ agentId }: { agentId: string }) => ( +
{agentId}
+ ), +})); + // Mark formatted counts so a raw number in the UI shows up in a test. vi.mock('../utils/dateTimeUtils', async (importOriginal) => ({ ...(await importOriginal()), @@ -138,6 +145,13 @@ describe('ShareToTeamModal', () => { await flush(); }; + it('lists what the agent uses', async () => { + await render(); + expect( + body().querySelector('[data-testid="agent-uses"]')?.textContent, + ).toBe('a1'); + }); + describe('access settings', () => { it('shows a collapsed Access settings toggle to the owner', async () => { await render(); @@ -421,9 +435,8 @@ describe('ShareToTeamModal credentials', () => { body().querySelectorAll( '[data-slot="toggle-group-item"]', ), - ).find( - (item) => - item.textContent === `settings.connectors.sharing.${value}Short`, + ).find((item) => + item.textContent?.startsWith(`settings.connectors.sharing.${value}Short`), )!; const picker = () => body().querySelector('[role="combobox"]')!; @@ -433,8 +446,16 @@ describe('ShareToTeamModal credentials', () => { expect(text()).not.toContain('settings.connectors.share.heading'); }); + it('has no agent resource list for a tool', async () => { + await render(credentials()); + expect(body().querySelector('[data-testid="agent-uses"]')).toBeNull(); + }); + it('says whose account members use on a tool that only reads', async () => { await render(credentials()); + expect(toggle('owner').textContent).toBe( + 'settings.connectors.sharing.ownerShort', + ); expect(body().querySelector('[role="note"]')).toBeNull(); expect(body().querySelector('[data-slot="option-card"]')).toBeNull(); expect(toggle('owner').getAttribute('aria-checked')).toBe('true'); @@ -561,6 +582,10 @@ describe('ShareToTeamModal credentials', () => { it("shows whose account shares use but doesn't let them change it", async () => { await render(shared()); + // The owner's account, not "Your account". + expect(toggle('owner').textContent).toBe( + 'settings.connectors.sharing.ownerShortShared', + ); expect(toggle('owner').getAttribute('aria-checked')).toBe('true'); expect(toggle('owner').disabled).toBe(true); expect(toggle('member').disabled).toBe(true); diff --git a/frontend/src/teams/ShareToTeamModal.tsx b/frontend/src/teams/ShareToTeamModal.tsx index 04bb045a..439bd84f 100644 --- a/frontend/src/teams/ShareToTeamModal.tsx +++ b/frontend/src/teams/ShareToTeamModal.tsx @@ -20,6 +20,7 @@ import teamsService, { TeamMember, } from '../api/services/teamsService'; import connectorsService from '../api/services/connectorsService'; +import AgentUsesSection from '../agents/components/AgentUsesSection'; import SearchInput from '../components/SearchInput'; import { Alert, AlertDescription } from '../components/ui/alert'; import { Checkbox } from '../components/ui/checkbox'; @@ -898,7 +899,13 @@ export default function ShareToTeamModal({ } > {mode === 'owner' ? : } - {t(`settings.connectors.sharing.${mode}Short`)} + {/* "Your account" to the owner; an editor sees the + owner's, like the agent's "What this agent uses". */} + {t( + mode === 'owner' && credentials.readOnly + ? 'settings.connectors.sharing.ownerShortShared' + : `settings.connectors.sharing.${mode}Short`, + )} ))} @@ -1129,6 +1136,13 @@ export default function ShareToTeamModal({ )} + {/* Whose access each of the agent's tools, sources and prompt runs + with, for the people it is shared with (owners and editors only: + viewers never open this dialog). */} + {resourceType === 'agent' && ( + + )} + {accessSettings} );