From 7a5f5d73e5a3ba65397dc33c69d662bd437ba84b Mon Sep 17 00:00:00 2001 From: thiva-k Date: Thu, 5 Nov 2026 20:37:20 +0530 Subject: [PATCH] Read and send an agent's owner as an object ThunderID now returns and accepts an agent's owner as an object with an id, an idType and an isLocal flag, so it can name a person managed in another identity system. Agent details showed the owner as text and failed to render once the owner became an object. Add the AgentOwner model, use it on Agent, CreateAgentRequest and UpdateAgentRequest, show the owner's ID in agent details for React and Vue, and take the owner as an object on AgentCreate. Refs thunder-id/thunderid#6435 Signed-off-by: thiva-k --- packages/javascript/src/index.ts | 2 + .../javascript/src/models/agent-resource.ts | 27 +++++++++-- .../admin/AgentCreate/AgentCreate.tsx | 3 +- .../admin/AgentDetails/BaseAgentDetails.tsx | 2 +- .../admin/__tests__/adminComponents.test.tsx | 47 +++++++++++++++++++ .../components/admin/admin-components.test.ts | 19 +++++++- .../admin/AgentCreate/AgentCreate.ts | 26 ++++++++-- .../admin/AgentDetails/BaseAgentDetails.ts | 2 +- .../nextjs/src/explorer/adminStories.tsx | 2 +- playgrounds/nuxt/lib/adminStories.ts | 2 +- .../react/src/pages/explorer/adminSamples.ts | 2 +- .../react/src/pages/explorer/adminStories.tsx | 9 +++- .../vue/src/pages/explorer/adminStories.ts | 2 +- 13 files changed, 127 insertions(+), 18 deletions(-) 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[] = [