Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/javascript/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,6 +814,8 @@ export type {
AgentInboundAuthConfig,
AgentListResponse,
AgentLoginConsentConfig,
AgentOwner,
AgentOwnerIdType,
AgentRoleListResponse,
AgentType,
AgentTypeListResponse,
Expand Down
27 changes: 24 additions & 3 deletions packages/javascript/src/models/agent-resource.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -103,7 +124,7 @@ export interface CreateAgentRequest {
inboundAuthConfig?: AgentInboundAuthConfig[];
logoUrl?: string;
ouId: string;
owner?: string;
owner?: AgentOwner;
type: string;
}

Expand All @@ -128,7 +149,7 @@ export interface UpdateAgentRequest {
isRegistrationFlowEnabled?: boolean;
logoUrl?: string;
ouId?: string;
owner?: string;
owner?: AgentOwner;
registrationFlowId?: string;
type?: string;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import {
Agent,
AgentOwner,
AgentQueryKeys,
AgentType,
AgentTypeListResponse,
Expand Down Expand Up @@ -32,7 +33,7 @@ export type AgentCreateProps = Omit<BaseAgentCreateProps, 'agentTypes' | 'error'
/**
* The person responsible for the agent. Defaults to the server's choice, usually the creator.
*/
owner?: string;
owner?: AgentOwner;
};

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ const BaseAgentDetails: FC<BaseAgentDetailsProps> = ({

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},
];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,53 @@ describe('UserCreate', () => {
});
});

describe('AgentCreate', () => {
it('sends the owner it is given as an object', async () => {
const fetcher: FetcherMock = vi.fn<ApiFetcher>().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(<AgentCreate onCreated={onCreated} owner={owner} />, {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(
<BaseAgentDetails
agent={{id: 'a-1', ouId: 'ou-1', owner: {id: 'jane@example.com', idType: 'email', isLocal: false}, type: 'x'}}
/>,
);

expect(screen.getByText('jane@example.com')).toBeTruthy();
});

it('says when the agent has no owner', () => {
renderWithProviders(<BaseAgentDetails agent={{id: 'a-1', ouId: 'ou-1', type: 'x'}} />);

expect(screen.getByText('No owner')).toBeTruthy();
});
});

describe('BaseUserDetails', () => {
it('hides credentials and lists the user groups', () => {
renderWithProviders(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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]]);
Expand Down Expand Up @@ -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', () => {
Expand Down
26 changes: 21 additions & 5 deletions packages/vue/src/components/admin/AgentCreate/AgentCreate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -15,7 +31,7 @@ import useDefaultOuId from '../shared/useDefaultOuId';
type AgentCreateProps = Omit<AdminFormProps, 'error' | 'isSubmitting'> &
Readonly<{
ouId: string | undefined;
owner: string | undefined;
owner: AgentOwner | undefined;
}>;

/**
Expand All @@ -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<AgentOwner>},
preferences: adminFormProps.preferences,
title: adminFormProps.title,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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},
];

Expand Down
2 changes: 1 addition & 1 deletion playgrounds/nextjs/src/explorer/adminStories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -619,7 +619,7 @@ export const ADMIN_COMPONENTS: ComponentDef[] = [
name: 'AgentDetails',
renderBase: (_args: Args, action: Action) => (
<BaseAgentDetails
agent={{...SAMPLE_AGENTS[0], clientId: 'triage-bot', owner: 'usr_8f2c41'}}
agent={{...SAMPLE_AGENTS[0], clientId: 'triage-bot', owner: {id: 'usr_8f2c41'}}}
groups={SAMPLE_MEMBERSHIPS.slice(1)}
roles={['Viewer']}
onDelete={() => action('onDelete')}
Expand Down
2 changes: 1 addition & 1 deletion playgrounds/nuxt/lib/adminStories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
},
Expand Down
2 changes: 1 addition & 1 deletion playgrounds/react/src/pages/explorer/adminSamples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'},
Expand Down
9 changes: 7 additions & 2 deletions playgrounds/react/src/pages/explorer/adminStories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
Expand Down Expand Up @@ -1028,7 +1033,7 @@ const STORIES: ComponentDef[] = [
<AgentCreate
title={str(args, 'title') || undefined}
ouId={str(args, 'ouId') || undefined}
owner={str(args, 'owner') || undefined}
owner={str(args, 'owner') ? {id: str(args, 'owner')} : undefined}
onCreated={(agent: Agent) => action('onCreated', agent.id)}
onCancel={() => action('onCancel')}
/>
Expand Down
2 changes: 1 addition & 1 deletion playgrounds/vue/src/pages/explorer/adminStories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down
Loading