diff --git a/packages/javascript/src/index.ts b/packages/javascript/src/index.ts index 8e6174c2..45e42c74 100644 --- a/packages/javascript/src/index.ts +++ b/packages/javascript/src/index.ts @@ -814,6 +814,8 @@ export type { AgentInboundAuthConfig, AgentListResponse, AgentLoginConsentConfig, + AgentOwner, + AgentOwnerIdType, AgentRoleListResponse, AgentType, AgentTypeListResponse, diff --git a/packages/javascript/src/models/agent-resource.ts b/packages/javascript/src/models/agent-resource.ts index d75df750..1281f4a1 100644 --- a/packages/javascript/src/models/agent-resource.ts +++ b/packages/javascript/src/models/agent-resource.ts @@ -19,6 +19,27 @@ export interface AgentLoginConsentConfig { validityPeriod?: number; } +/** + * What an agent owner's `id` holds. + */ +export type AgentOwnerIdType = 'uuid' | 'email' | 'mobile'; + +/** + * The person accountable for an agent. A local owner is an entity of the same deployment and is + * named by its entity ID. An owner managed in another identity system is not looked up. + */ +export interface AgentOwner { + id: string; + /** + * Defaults to `uuid`. A local owner must be a `uuid`. + */ + idType?: AgentOwnerIdType; + /** + * Whether the owner is an entity of this deployment. Defaults to `true`. + */ + isLocal?: boolean; +} + /** * An agent registered in ThunderID. */ @@ -49,7 +70,7 @@ export interface Agent { logoUrl?: string; ouHandle?: string; ouId: string; - owner?: string; + owner?: AgentOwner; registrationFlowId?: string; /** * The effective access status, set by the server on a read. @@ -103,7 +124,7 @@ export interface CreateAgentRequest { inboundAuthConfig?: AgentInboundAuthConfig[]; logoUrl?: string; ouId: string; - owner?: string; + owner?: AgentOwner; type: string; } @@ -128,7 +149,7 @@ export interface UpdateAgentRequest { isRegistrationFlowEnabled?: boolean; logoUrl?: string; ouId?: string; - owner?: string; + owner?: AgentOwner; registrationFlowId?: string; type?: string; } diff --git a/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx b/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx index 279d1ea2..c281fbea 100644 --- a/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx +++ b/packages/react/src/components/admin/AgentCreate/AgentCreate.tsx @@ -3,6 +3,7 @@ import { Agent, + AgentOwner, AgentQueryKeys, AgentType, AgentTypeListResponse, @@ -32,7 +33,7 @@ export type AgentCreateProps = Omit = ({ const details: {label: string; value: ReactNode}[] = [ {label: t('admin.agents.details.type'), value: agent.type}, - {label: t('admin.agents.details.owner'), value: agent.owner ?? t('admin.agents.details.no_owner')}, + {label: t('admin.agents.details.owner'), value: agent.owner?.id ?? t('admin.agents.details.no_owner')}, {label: t('admin.common.columns.organization_unit'), value: agent.ouHandle ?? agent.ouId}, ]; diff --git a/packages/react/src/components/admin/__tests__/adminComponents.test.tsx b/packages/react/src/components/admin/__tests__/adminComponents.test.tsx index a915456c..5657eff5 100644 --- a/packages/react/src/components/admin/__tests__/adminComponents.test.tsx +++ b/packages/react/src/components/admin/__tests__/adminComponents.test.tsx @@ -218,6 +218,53 @@ describe('UserCreate', () => { }); }); +describe('AgentCreate', () => { + it('sends the owner it is given as an object', async () => { + const fetcher: FetcherMock = vi.fn().mockImplementation((url: string) => + Promise.resolve( + url.includes('/agent-types') + ? respond({ + count: 1, + startIndex: 1, + totalResults: 1, + types: [{displayName: 'Assistant', handle: 'assistant', id: 't-1', ouId: 'ou-1'}], + }) + : respond({id: 'a-9', ouId: 'ou-1', type: 'assistant'}), + ), + ); + const onCreated: Mock = vi.fn(); + const owner = {id: 'jane@example.com', idType: 'email', isLocal: false} as const; + + renderWithProviders(, {fetcher, ouId: 'ou-1'}); + + await waitFor(() => expect(screen.getByRole('option', {name: 'Assistant'})).toBeTruthy()); + fireEvent.change(document.querySelector('input[name="name"]')!, {target: {value: 'Concierge'}}); + fireEvent.click(screen.getByRole('button', {name: 'Register agent'})); + + await waitFor(() => expect(onCreated).toHaveBeenCalled()); + const [, init] = fetcher.mock.calls.find(([, request]: [string, RequestInit]) => request.method === 'POST')!; + expect(JSON.parse(init.body as string)).toMatchObject({owner}); + }); +}); + +describe('BaseAgentDetails', () => { + it("shows the owner's ID", () => { + renderWithProviders( + , + ); + + expect(screen.getByText('jane@example.com')).toBeTruthy(); + }); + + it('says when the agent has no owner', () => { + renderWithProviders(); + + expect(screen.getByText('No owner')).toBeTruthy(); + }); +}); + describe('BaseUserDetails', () => { it('hides credentials and lists the user groups', () => { renderWithProviders( diff --git a/packages/vue/src/__tests__/components/admin/admin-components.test.ts b/packages/vue/src/__tests__/components/admin/admin-components.test.ts index 2119a81a..b77fbdcd 100644 --- a/packages/vue/src/__tests__/components/admin/admin-components.test.ts +++ b/packages/vue/src/__tests__/components/admin/admin-components.test.ts @@ -530,8 +530,9 @@ describe('admin components (vue)', () => { : {...page, types: [{displayName: 'Assistant', handle: 'assistant'}]}, ), ); + const owner = {id: 'jane@example.com', idType: 'email', isLocal: false}; let renderProps: BaseAgentCreateRenderProps | undefined; - const {wrapper} = mountWith(AgentCreate, {}, context, { + const {wrapper} = mountWith(AgentCreate, {owner}, context, { default: (props: BaseAgentCreateRenderProps) => { renderProps = props; @@ -554,6 +555,7 @@ describe('admin components (vue)', () => { expect(JSON.parse(create[1].body as string)).toEqual({ attributes: {name: 'Concierge'}, ouId: 'ou-1', + owner, type: 'assistant', }); expect(invalidatedKeys(invalidate)).toEqual([[AgentQueryKeys.AGENTS]]); @@ -1061,6 +1063,21 @@ describe('admin components (vue)', () => { translate('admin.agents.details.delete.confirm', {name: 'Billing bot'}), ); }); + + it("shows the owner's ID, or that the agent has none", () => { + const {wrapper} = mountWith(BaseAgentDetails, { + agent: { + id: 'ag-2', + ouId: 'ou-1', + owner: {id: 'jane@example.com', idType: 'email', isLocal: false}, + type: 'bot', + }, + }); + expect(wrapper.text()).toContain('jane@example.com'); + + const {wrapper: unowned} = mountWith(BaseAgentDetails, {agent: {id: 'ag-3', ouId: 'ou-1', type: 'bot'}}); + expect(unowned.text()).toContain(translate('admin.agents.details.no_owner')); + }); }); describe('search and filters', () => { diff --git a/packages/vue/src/components/admin/AgentCreate/AgentCreate.ts b/packages/vue/src/components/admin/AgentCreate/AgentCreate.ts index 2df91502..bc2af7f1 100644 --- a/packages/vue/src/components/admin/AgentCreate/AgentCreate.ts +++ b/packages/vue/src/components/admin/AgentCreate/AgentCreate.ts @@ -2,8 +2,24 @@ // SPDX-License-Identifier: Apache-2.0 import {AgentQueryKeys, createAgent, getAgentTypes, toManagementErrorMessage} from '@thunderid/browser'; -import type {AgentType, AgentTypeListResponse, SelectOption, Agent, CreateAgentRequest} from '@thunderid/browser'; -import {type Component, type ComputedRef, type SetupContext, type VNode, computed, defineComponent, h} from 'vue'; +import type { + AgentOwner, + AgentType, + AgentTypeListResponse, + SelectOption, + Agent, + CreateAgentRequest, +} from '@thunderid/browser'; +import { + type Component, + type ComputedRef, + type PropType, + type SetupContext, + type VNode, + computed, + defineComponent, + h, +} from 'vue'; import BaseAgentCreate, {type AgentCreateSubmission} from './BaseAgentCreate'; import useManagementEndpoint, {type ManagementEndpoint} from '../../../composables/management/useManagementEndpoint'; import useResourceMutation, {type ResourceMutationResult} from '../../../composables/management/useResourceMutation'; @@ -15,7 +31,7 @@ import useDefaultOuId from '../shared/useDefaultOuId'; type AgentCreateProps = Omit & Readonly<{ ouId: string | undefined; - owner: string | undefined; + owner: AgentOwner | undefined; }>; /** @@ -35,8 +51,8 @@ const AgentCreate: Component = defineComponent({ onCancel: adminFormProps.onCancel, /** Organization unit the agent is created in. Defaults to the signed-in user's organization unit. */ ouId: {default: undefined, type: String}, - /** Identifier of the user who owns the agent. */ - owner: {default: undefined, type: String}, + /** The person responsible for the agent. Defaults to the server's choice, usually the creator. */ + owner: {default: undefined, type: Object as PropType}, preferences: adminFormProps.preferences, title: adminFormProps.title, }, diff --git a/packages/vue/src/components/admin/AgentDetails/BaseAgentDetails.ts b/packages/vue/src/components/admin/AgentDetails/BaseAgentDetails.ts index 6442d6f8..bbc85465 100644 --- a/packages/vue/src/components/admin/AgentDetails/BaseAgentDetails.ts +++ b/packages/vue/src/components/admin/AgentDetails/BaseAgentDetails.ts @@ -153,7 +153,7 @@ const BaseAgentDetails: Component = defineComponent({ const details: DetailListItem[] = [ {label: t('admin.agents.details.type'), value: agent.type}, - {label: t('admin.agents.details.owner'), value: agent.owner ?? t('admin.agents.details.no_owner')}, + {label: t('admin.agents.details.owner'), value: agent.owner?.id ?? t('admin.agents.details.no_owner')}, {label: t('admin.common.columns.organization_unit'), value: agent.ouHandle ?? agent.ouId}, ]; diff --git a/playgrounds/nextjs/src/explorer/adminStories.tsx b/playgrounds/nextjs/src/explorer/adminStories.tsx index f43b13e8..0e5706b4 100644 --- a/playgrounds/nextjs/src/explorer/adminStories.tsx +++ b/playgrounds/nextjs/src/explorer/adminStories.tsx @@ -619,7 +619,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ name: 'AgentDetails', renderBase: (_args: Args, action: Action) => ( action('onDelete')} diff --git a/playgrounds/nuxt/lib/adminStories.ts b/playgrounds/nuxt/lib/adminStories.ts index 02bcc43d..36963658 100644 --- a/playgrounds/nuxt/lib/adminStories.ts +++ b/playgrounds/nuxt/lib/adminStories.ts @@ -1071,7 +1071,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [ base: BaseAgentDetails, baseExtra: [':agent="agent"', ':groups="groups"', ':roles="roles"'], baseProps: { - agent: {...SAMPLE_AGENTS[0], description: 'Books travel for employees', owner: 'usr_8f2c41'}, + agent: {...SAMPLE_AGENTS[0], description: 'Books travel for employees', owner: {id: 'usr_8f2c41'}}, groups: SAMPLE_MEMBERSHIPS.slice(0, 1), roles: ['Agent operator'], }, diff --git a/playgrounds/react/src/pages/explorer/adminSamples.ts b/playgrounds/react/src/pages/explorer/adminSamples.ts index 2c5e3125..7a830764 100644 --- a/playgrounds/react/src/pages/explorer/adminSamples.ts +++ b/playgrounds/react/src/pages/explorer/adminSamples.ts @@ -82,7 +82,7 @@ export const SAMPLE_AGENTS: BasicAgent[] = [ }, ]; -export const SAMPLE_AGENT: Agent = {...SAMPLE_AGENTS[0], owner: 'usr-1'}; +export const SAMPLE_AGENT: Agent = {...SAMPLE_AGENTS[0], owner: {id: 'usr-1'}}; export const SAMPLE_APPLICATIONS: BasicApplication[] = [ {clientId: 'portal_spa', description: 'Customer portal', id: 'app-1', name: 'Customer portal', template: 'browser'}, diff --git a/playgrounds/react/src/pages/explorer/adminStories.tsx b/playgrounds/react/src/pages/explorer/adminStories.tsx index 5dd0379d..90cf8c78 100644 --- a/playgrounds/react/src/pages/explorer/adminStories.tsx +++ b/playgrounds/react/src/pages/explorer/adminStories.tsx @@ -991,7 +991,12 @@ const STORIES: ComponentDef[] = [ isLive(args) ? { imports: ['AgentCreate'], - jsx: jsx('AgentCreate', pick(args, 'title', 'ouId', 'owner'), {extra: ['onCreated={handleCreated}']}), + jsx: jsx('AgentCreate', pick(args, 'title', 'ouId'), { + extra: [ + ...(str(args, 'owner') ? [`owner={{id: '${str(args, 'owner')}'}}`] : []), + 'onCreated={handleCreated}', + ], + }), } : { imports: ['BaseAgentCreate'], @@ -1028,7 +1033,7 @@ const STORIES: ComponentDef[] = [ action('onCreated', agent.id)} onCancel={() => action('onCancel')} /> diff --git a/playgrounds/vue/src/pages/explorer/adminStories.ts b/playgrounds/vue/src/pages/explorer/adminStories.ts index 4b4bb532..73127e67 100644 --- a/playgrounds/vue/src/pages/explorer/adminStories.ts +++ b/playgrounds/vue/src/pages/explorer/adminStories.ts @@ -145,7 +145,7 @@ const SAMPLE_AGENTS: BasicAgent[] = [ const SAMPLE_AGENT: Agent = { ...SAMPLE_AGENTS[0], description: 'Books flights and hotels for employees', - owner: 'usr_8f2c41', + owner: {id: 'usr_8f2c41'}, }; const SAMPLE_APPLICATIONS: BasicApplication[] = [