diff --git a/src/api/doormile/endpoints.js b/src/api/doormile/endpoints.js index c068798..9adf4ae 100644 --- a/src/api/doormile/endpoints.js +++ b/src/api/doormile/endpoints.js @@ -179,6 +179,25 @@ export const onboardClient = async (data) => { return response.data; }; +/** + * Edit an onboarded client and its login (owner login only). `authid` is the + * login's id from GET /admin/clients/onboarded. Only fields sent change; + * `password` resets the login's password when non-empty. + */ +export const updateOnboardedClient = async (authid, data) => { + const response = await doormileAxios.put(`/admin/clients/${encodeURIComponent(authid)}`, data); + return response.data; +}; + +/** + * Remove a client's console login (owner login only). The company and its + * order history are kept; the client is marked Inactive if no login remains. + */ +export const deleteOnboardedClient = async (authid) => { + const response = await doormileAxios.delete(`/admin/clients/${encodeURIComponent(authid)}`); + return response.data; +}; + /** Operating cities a new client can be placed in (applocations; owner login only). */ export const getOnboardingCities = async () => { const response = await doormileAxios.get('/admin/clients/cities'); diff --git a/src/lib/doormileHooks.js b/src/lib/doormileHooks.js index e71ebb3..0d34a3d 100644 --- a/src/lib/doormileHooks.js +++ b/src/lib/doormileHooks.js @@ -193,6 +193,20 @@ export const useOnboardedClients = (options) => export const useOnboardingCities = (options) => useQuery({ queryKey: [...KEYS.tenants, 'onboarding-cities'], queryFn: api.getOnboardingCities, staleTime: 300_000, ...options }); +export const useUpdateOnboardedClient = () => + useDoormileMutation({ + mutationFn: ({ authid, data }) => api.updateOnboardedClient(authid, data), + invalidates: [KEYS.tenants], + successMessage: 'Client updated', + }); + +export const useDeleteOnboardedClient = () => + useDoormileMutation({ + mutationFn: (authid) => api.deleteOnboardedClient(authid), + invalidates: [KEYS.tenants], + successMessage: 'Client login removed', + }); + // No toasts: the page shows its own success panel and inline errors. export const useOnboardClient = () => { const queryClient = useQueryClient(); diff --git a/src/pages/doormile/clients/ClientLoginDialogs.jsx b/src/pages/doormile/clients/ClientLoginDialogs.jsx new file mode 100644 index 0000000..d679786 --- /dev/null +++ b/src/pages/doormile/clients/ClientLoginDialogs.jsx @@ -0,0 +1,242 @@ +import React, { useEffect, useState } from 'react'; +import { KeyRound, Pencil, Trash2 } from 'lucide-react'; +import { Alert, Button, Field, Input, Modal, Switch } from '@/components/ds'; +import { inputVariants } from '@/components/ui/input'; +import { useDeleteOnboardedClient, useUpdateOnboardedClient } from '@/lib/doormileHooks'; +import { generateClientPassword } from '@/lib/clientOnboarding'; + +/** + * Edit and Remove for one onboarded client login (a row of "Clients with a + * console login"). Both call owner-only endpoints: PUT and DELETE + * /admin/clients/:authid. + */ + +const STATUSES = ['Active', 'Pending', 'Inactive']; + +/** Only the fields that actually changed, so an edit never rewrites the rest. */ +export function changedFields(row, form) { + const out = {}; + const trim = (v) => String(v ?? '').trim(); + if (trim(form.companyname) !== trim(row.tenantname)) out.companyname = trim(form.companyname); + if (trim(form.contactname) !== trim(row.contactname)) out.contactname = trim(form.contactname); + if (trim(form.email).toLowerCase() !== trim(row.loginemail).toLowerCase()) out.email = trim(form.email).toLowerCase(); + if (form.phone.replace(/\D/g, '') !== String(row.primarycontact || '').replace(/\D/g, '')) out.phone = form.phone.replace(/\D/g, ''); + if (form.status !== row.status) out.status = form.status; + if (Boolean(form.requiredeliveryotp) !== Boolean(row.requiredeliveryotp)) out.requiredeliveryotp = Boolean(form.requiredeliveryotp); + if (form.password) out.password = form.password; + return out; +} + +export function validateEdit(form, changes) { + const errors = {}; + if ('companyname' in changes && changes.companyname.length < 2) errors.companyname = 'Enter the company name'; + if ('contactname' in changes && changes.contactname.length < 2) errors.contactname = "Enter the contact person's name"; + if ('email' in changes && !/^[^\s@<>]+@[^\s@<>]+\.[^\s@<>]+$/.test(changes.email)) errors.email = 'Enter a valid email address'; + if ('phone' in changes && !/^[6-9]\d{9}$/.test(changes.phone)) errors.phone = 'Enter a 10-digit mobile number'; + if (form.password && form.password.length < 8) errors.password = 'At least 8 characters'; + if (form.password && form.password.length > 72) errors.password = 'At most 72 characters'; + return errors; +} + +function formFrom(row) { + return { + companyname: row?.tenantname || '', + contactname: row?.contactname || '', + email: row?.loginemail || '', + phone: String(row?.primarycontact || '').replace(/\D/g, ''), + status: STATUSES.includes(row?.status) ? row.status : 'Active', + requiredeliveryotp: Boolean(row?.requiredeliveryotp), + password: '', + }; +} + +export function EditClientModal({ row, onClose }) { + const update = useUpdateOnboardedClient(); + const [form, setForm] = useState(formFrom(row)); + const [errors, setErrors] = useState({}); + const [serverError, setServerError] = useState(''); + const [showPassword, setShowPassword] = useState(false); + + useEffect(() => { + setForm(formFrom(row)); + setErrors({}); + setServerError(''); + setShowPassword(false); + }, [row]); + + const set = (key) => (value) => { + setForm((f) => ({ ...f, [key]: value })); + setErrors((e) => ({ ...e, [key]: undefined })); + }; + + const changes = row ? changedFields(row, form) : {}; + const nothingChanged = Object.keys(changes).length === 0; + + const save = () => { + const nextErrors = validateEdit(form, changes); + setErrors(nextErrors); + setServerError(''); + if (Object.keys(nextErrors).length || nothingChanged) return; + update.mutate( + { authid: row.authid, data: changes }, + { + onSuccess: (res) => { + if (res?.success !== false) onClose(); + }, + onError: (err) => setServerError(err?.response?.data?.message || 'The change was not saved.'), + } + ); + }; + + return ( + !open && onClose()} + title={`Edit ${row?.tenantname || 'client'}`} + description="Changes the client and its console login together." + icon={Pencil} + busy={update.isPending} + footer={ + <> + + + + } + > + {serverError && ( + + {serverError} + + )} +
+ + set('companyname')(e.target.value)} /> + + + set('contactname')(e.target.value)} /> + + + set('phone')(e.target.value.replace(/\D/g, '').slice(0, 10))} + /> + + + set('email')(e.target.value)} /> + + + + + + set('password')(e.target.value)} + /> + +
+ + {form.password && ( + + )} +
+ + + +
+
+ ); +} + +export function RemoveClientModal({ row, onClose }) { + const remove = useDeleteOnboardedClient(); + const [typed, setTyped] = useState(''); + const [serverError, setServerError] = useState(''); + + useEffect(() => { + setTyped(''); + setServerError(''); + }, [row]); + + const confirmed = row && typed.trim().toLowerCase() === String(row.tenantname).trim().toLowerCase(); + + const submit = () => { + if (!confirmed) return; + remove.mutate(row.authid, { + onSuccess: (res) => { + if (res?.success !== false) onClose(); + }, + onError: (err) => setServerError(err?.response?.data?.message || 'The login was not removed.'), + }); + }; + + return ( + !open && onClose()} + title={`Remove ${row?.tenantname || 'client'}'s login`} + icon={Trash2} + busy={remove.isPending} + footer={ + <> + + + + } + > + {serverError && ( + + {serverError} + + )} +
+

+ {row?.loginemail} will no longer be able to sign in to the console. +

+

+ The client's orders, customers and pricing are kept. If this is its last login, the client is marked{' '} + Inactive. A session already signed in ends when its token expires. +

+ + setTyped(e.target.value)} autoComplete="off" /> + +
+
+ ); +} diff --git a/src/pages/doormile/clients/ClientOnboarding.jsx b/src/pages/doormile/clients/ClientOnboarding.jsx index 0f7076a..a96cb17 100644 --- a/src/pages/doormile/clients/ClientOnboarding.jsx +++ b/src/pages/doormile/clients/ClientOnboarding.jsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from 'react'; import { useNavigate } from 'react-router-dom'; -import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, ShieldCheck, UserPlus } from 'lucide-react'; +import { ArrowLeft, Building2, Copy, Eye, EyeOff, KeyRound, Pencil, ShieldCheck, Trash2, UserPlus } from 'lucide-react'; import { Alert, Breadcrumb, BreadcrumbItem, BreadcrumbLink, BreadcrumbList, BreadcrumbPage, BreadcrumbSeparator, Button, DataTable, EmptyState, Field, Input, PageHeader, Stack, @@ -9,6 +9,7 @@ import { import { inputVariants } from '@/components/ui/input'; import { useAuth } from '@/lib/AuthContext'; import { useOnboardClient, useOnboardedClients, useOnboardingCities } from '@/lib/doormileHooks'; +import { EditClientModal, RemoveClientModal } from './ClientLoginDialogs'; import { canOnboardClients, generateClientPassword, onboardingUnavailableMessage, validateOnboarding, } from '@/lib/clientOnboarding'; @@ -54,6 +55,12 @@ function serverFieldOf(message) { function OnboardedList() { const { data = [], isLoading } = useOnboardedClients(); + const [editing, setEditing] = useState(null); + const [removing, setRemoving] = useState(null); + // Rows without an authid come from a backend older than Edit/Delete (it has + // neither the field nor the PUT/DELETE routes). Never send /clients/undefined: + // disable the actions and say what to do instead. + const backendTooOld = data.length > 0 && data.some((r) => r.authid == null); const columns = [ { key: 'tenantname', header: 'Client', accessor: (r) => r.tenantname }, { key: 'loginemail', header: 'Console login', accessor: (r) => r.loginemail }, @@ -64,17 +71,53 @@ function OnboardedList() { header: 'Login created', accessor: (r) => (r.logincreatedat ? new Date(r.logincreatedat).toLocaleString('en-IN', { dateStyle: 'medium', timeStyle: 'short' }) : '—'), }, + { + key: 'actions', + header: 'Actions', + align: 'right', + cell: (r) => ( +
+ + +
+ ), + }, ]; return (

Clients with a console login

+ {backendTooOld && ( + + The API server this console talks to is running an older backend build without the edit and delete + endpoints. Rebuild and redeploy doormile_backend from the current code, then reload this page. + + )} `${row.tenantid}-${row.loginemail}-${i}`} + getRowId={(row, i) => row.authid ?? `${row.tenantid}-${row.loginemail}-${i}`} loading={isLoading} emptyState={} /> + setEditing(null)} /> + setRemoving(null)} />
); } diff --git a/src/pages/doormile/settings/agentStudio/AgentPlayground.jsx b/src/pages/doormile/settings/agentStudio/AgentPlayground.jsx index a64c9ff..6c0ce4b 100644 --- a/src/pages/doormile/settings/agentStudio/AgentPlayground.jsx +++ b/src/pages/doormile/settings/agentStudio/AgentPlayground.jsx @@ -13,7 +13,7 @@ import { useRunAiPlayground } from '../../../../lib/doormileHooks'; /** * Agent Studio's Test tab. Each prompt is one real run on the backend - * (POST /admin/ai/playground/run): Claude with the chosen skill's tools, where + * (POST /admin/ai/playground/run): the configured model with the chosen skill's tools, where * read tools execute with personal data redacted and write/notify tools come * back as proposals that are never executed. The trace shown is the server's, * step for step — nothing here is simulated. @@ -34,7 +34,7 @@ export function playgroundErrorMessage(err) { const status = err?.response?.status; const data = err?.response?.data || {}; if (data.code === 'PLAYGROUND_NOT_CONFIGURED') { - return 'The Test playground is not switched on for this server yet (no Claude client is configured). Nothing was run.'; + return 'The Test playground is not switched on for this server yet (no model API key is configured). Nothing was run.'; } if (status === 429) return data.message || 'Playground limit reached. Try again in a few minutes.'; if (status === 403) return 'Only an admin can run the Test playground.'; @@ -128,7 +128,7 @@ export default function AgentPlayground({

Agent Test Playground

- Run a prompt through Claude as {activeAgent?.name || 'this agent'} and see every tool call it makes. + Run a prompt through the AI model as {activeAgent?.name || 'this agent'} and see every tool call it makes.

@@ -153,8 +153,8 @@ export default function AgentPlayground({ >

- Runs are real Claude calls on live data. Read tools execute with names, phones, addresses and notes removed - before Claude sees them. Write and notify tools are never executed — they + Runs are real AI model calls on live data. Read tools execute with names, phones, addresses and notes removed + before the model sees them. Write and notify tools are never executed — they come back as proposals. Admin only, 10 runs per 10 minutes.

@@ -205,7 +205,7 @@ export default function AgentPlayground({
Playground session ready

{canRun - ? `Type a prompt${selectedSkill ? ` for “${selectedSkill.title}”` : ''} to see which tools Claude calls and what they return.` + ? `Type a prompt${selectedSkill ? ` for “${selectedSkill.title}”` : ''} to see which tools the model calls and what they return.` : 'Only an admin can run the Test playground.'}

@@ -249,7 +249,7 @@ export default function AgentPlayground({ {run.isPending && (
- Running on Claude… + Running on the model…
)}
diff --git a/src/pages/doormile/settings/agentStudio/AgentStudio.jsx b/src/pages/doormile/settings/agentStudio/AgentStudio.jsx index 73f8ee3..c6c8838 100644 --- a/src/pages/doormile/settings/agentStudio/AgentStudio.jsx +++ b/src/pages/doormile/settings/agentStudio/AgentStudio.jsx @@ -125,17 +125,17 @@ export default function AgentStudio() { {/* Said on screen: what this page changes today, and what it does not. */}
- -

- Half wired. Changes here are saved to the Doormile agent registry and - every change is audited. The Console Ops Agent's skills — the Exceptions early warnings and the + +

+ Half wired.{' '} + Changes here are saved to the Doormile agent registry and every change is audited. The Console Ops Agent's skills — the Exceptions early warnings and the chat's “what needs attention” — run on these settings now. AI_engine's Dispatch, Exception, Express Dispatch and Customer agents pick up changes within about 30 seconds once its registry-reading build is deployed; until then, and whenever the registry cannot be reached, they run on - their environment defaults. Hub, Fleet and Route Optimizer are simulations. The Test tab runs real Claude - calls (reads redacted, writes only proposed) once the server has a Claude client; until then it says so. + their environment defaults. Hub, Fleet and Route Optimizer are simulations. The Test tab runs real AI model + calls (reads redacted, writes only proposed) once the server has a model API key; until then it says so.

@@ -174,9 +174,10 @@ export default function AgentStudio() { ) : ( <> {/* 1. Top Level Mode Tabs */} -
+
{TOP_NAV_TABS.map((tab) => { const isActive = activeTab === tab.id; + const Icon = tab.icon; const badgeCount = tab.id === 'skills' ? agentSkills.length : tab.id === 'tools' ? model.tools.length : null; @@ -186,18 +187,19 @@ export default function AgentStudio() { type="button" onClick={() => setActiveTab(tab.id)} className={cn( - 'pb-3.5 -mb-px transition-all cursor-pointer flex items-center gap-2 select-none border-b-2', + 'flex items-center gap-1.5 px-3.5 py-2 rounded-xl text-xs font-semibold transition-all cursor-pointer select-none whitespace-nowrap', isActive - ? 'border-brand text-brand font-bold' - : 'border-transparent text-slate-500 hover:text-slate-800 hover:border-slate-300' + ? 'bg-white text-slate-900 shadow-sm border border-slate-200/80' + : 'text-slate-500 hover:text-slate-800 hover:bg-slate-200/50' )} > + {tab.label} {badgeCount !== null && ( {badgeCount} diff --git a/src/pages/doormile/settings/agentStudio/SkillCard.jsx b/src/pages/doormile/settings/agentStudio/SkillCard.jsx index d169da1..de5220d 100644 --- a/src/pages/doormile/settings/agentStudio/SkillCard.jsx +++ b/src/pages/doormile/settings/agentStudio/SkillCard.jsx @@ -9,67 +9,78 @@ import { Route, Check, Layers, + CircleDot, + ChevronRight, } from 'lucide-react'; import { cn } from '@/lib/utils'; +const CATEGORY_META = { + AUTHORING: { + icon: Pencil, + gradient: 'from-violet-500 to-purple-600', + bgGlow: 'bg-violet-50', + badgeBg: 'bg-violet-100 text-violet-700 border-violet-200', + accent: 'text-violet-600', + glow: 'hover:shadow-violet-100', + }, + WORKFORCE: { + icon: Users, + gradient: 'from-blue-500 to-indigo-600', + bgGlow: 'bg-blue-50', + badgeBg: 'bg-blue-100 text-blue-700 border-blue-200', + accent: 'text-blue-600', + glow: 'hover:shadow-blue-100', + }, + LOGISTICS: { + icon: Package, + gradient: 'from-emerald-500 to-teal-600', + bgGlow: 'bg-emerald-50', + badgeBg: 'bg-emerald-100 text-emerald-700 border-emerald-200', + accent: 'text-emerald-600', + glow: 'hover:shadow-emerald-100', + }, + DISPATCH: { + icon: Route, + gradient: 'from-orange-500 to-rose-500', + bgGlow: 'bg-orange-50', + badgeBg: 'bg-orange-100 text-orange-700 border-orange-200', + accent: 'text-orange-600', + glow: 'hover:shadow-orange-100', + }, +}; + +const DEFAULT_META = { + icon: Layers, + gradient: 'from-slate-500 to-slate-600', + bgGlow: 'bg-slate-50', + badgeBg: 'bg-slate-100 text-slate-600 border-slate-200', + accent: 'text-slate-600', + glow: 'hover:shadow-slate-100', +}; + export default function SkillCard({ skill, onTest, onDetails }) { const isEnabled = skill.enabled !== false; - - // Icon and color mappings based on category - const getCategoryMeta = (cat) => { - switch ((cat || '').toUpperCase()) { - case 'AUTHORING': - return { - icon: Pencil, - textColor: 'text-brand', - bgColor: 'bg-brand-tint', - borderColor: 'border-brand/20', - }; - case 'WORKFORCE': - return { - icon: Users, - textColor: 'text-slate-700', - bgColor: 'bg-slate-100', - borderColor: 'border-slate-200', - }; - case 'LOGISTICS': - return { - icon: Package, - textColor: 'text-emerald-700', - bgColor: 'bg-emerald-50', - borderColor: 'border-emerald-200', - }; - case 'DISPATCH': - return { - icon: Route, - textColor: 'text-brand', - bgColor: 'bg-brand-tint', - borderColor: 'border-brand/20', - }; - default: - return { - icon: Layers, - textColor: 'text-slate-600', - bgColor: 'bg-slate-50', - borderColor: 'border-slate-200', - }; - } - }; - - const meta = getCategoryMeta(skill.category); + const meta = CATEGORY_META[(skill.category || '').toUpperCase()] || DEFAULT_META; const CategoryIcon = meta.icon; return ( -
-
- {/* Top Header: Category Tag (Left) & Status Badge (Right) */} -
+
+ {/* Top gradient accent bar */} +
+ +
+ {/* Header row: category badge + status */} +
@@ -78,71 +89,92 @@ export default function SkillCard({ skill, onTest, onDetails }) {
{isEnabled ? ( <> - - Added + + Active ) : ( - Disabled + <> + + Disabled + )}
- {/* Skill Title */} -

- {skill.title} -

+ {/* Icon + Title */} +
+
+ +
+
+

+ {skill.title} +

+

+ {skill.description} +

+
+
- {/* Skill Description */} -

- {skill.description} -

- - {/* Prompt Trigger Pill (if present) */} + {/* Prompt pill */} {skill.prompt && ( -
- - "{skill.prompt}" +
+ + + “{skill.prompt}” +
)} - {/* Scope / Integration Tag */} + {/* Integration tag */} {skill.integration && ( -
-
- - {skill.integration} -
+
+ + + {skill.integration} +
)} -
- {/* Footer Action Bar */} -
- + {/* Footer */} +
+ - + +
); diff --git a/src/pages/doormile/settings/agentStudio/SkillCardsGrid.jsx b/src/pages/doormile/settings/agentStudio/SkillCardsGrid.jsx index aef0604..1f4d4f4 100644 --- a/src/pages/doormile/settings/agentStudio/SkillCardsGrid.jsx +++ b/src/pages/doormile/settings/agentStudio/SkillCardsGrid.jsx @@ -117,21 +117,21 @@ export default function SkillCardsGrid({
- {/* 2. Global Search Input (Sleek pill style matching reference) */} + {/* 2. Global Search Input */}
setSearchQuery(e.target.value)} - placeholder="Search skills, capabilities or pages..." - className="w-full h-10 bg-white border border-slate-200 rounded-full pl-10 pr-9 text-xs text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-1 focus:ring-brand/20 shadow-2xs transition-all" + placeholder="Search skills, capabilities or integrations..." + className="w-full h-11 bg-white border border-slate-200 rounded-2xl pl-11 pr-10 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/10 shadow-sm hover:border-slate-300 transition-all" /> {searchQuery && ( @@ -150,17 +150,17 @@ export default function SkillCardsGrid({ type="button" onClick={() => setCategoryFilter(cat)} className={cn( - 'px-3.5 py-1 rounded-full text-xs font-medium transition-all whitespace-nowrap cursor-pointer flex items-center gap-1.5', + 'px-3.5 py-1.5 rounded-xl text-xs font-semibold transition-all duration-200 whitespace-nowrap cursor-pointer flex items-center gap-1.5 border', isSelected - ? 'bg-brand text-white shadow-2xs font-semibold' - : 'bg-white border border-slate-200 text-slate-600 hover:bg-slate-50 hover:text-slate-900' + ? 'bg-slate-900 text-white border-slate-900 shadow-sm' + : 'bg-white border-slate-200 text-slate-600 hover:bg-slate-50 hover:border-slate-300 hover:text-slate-900' )} > {displayLabel} {count} @@ -170,14 +170,17 @@ export default function SkillCardsGrid({ })}
- {/* 4. Capabilities Count & "+ New skill" Action Row */} -
-
- - {filteredSkills.length} of {scopedSkills.length} capabilities - {' '} - for this scope ·{' '} - {enabledCount} enabled + {/* 4. Stats + New Skill Row */} +
+
+
+ Total + {filteredSkills.length} +
+
+ + {enabledCount} active +
@@ -220,7 +223,7 @@ export default function SkillCardsGrid({
) : ( -
+
{filteredSkills.map((skill) => ( { - const q = search.toLowerCase().trim(); - return ( - !q || - t.name.toLowerCase().includes(q) || - t.description.toLowerCase().includes(q) || - (t.parentSkill || '').toLowerCase().includes(q) || - (t.category || '').toLowerCase().includes(q) - ); - }); +function kindOf(tool) { + return KIND[(tool.kind || '').toLowerCase()] || KIND_DEFAULT; +} +/* ─── Stat chip ───────────────────────────────────────────────────────────── */ +function StatChip({ label, value, tone = 'slate' }) { + const tones = { + slate: 'bg-slate-100 text-slate-700 border-slate-200', + amber: 'bg-amber-50 text-amber-700 border-amber-200', + emerald: 'bg-emerald-50 text-emerald-700 border-emerald-200', + sky: 'bg-sky-50 text-sky-700 border-sky-200', + }; return ( -
- {/* Header & Stats Bar */} -
-
-

- - Agent Tool Registry (Schema Engine) -

-

- Every capability a skill may call, from the Doormile agent registry. Write and messaging tools need an - operator's confirmation unless their agent is autonomous. -

-
- -
-
- Total Tools: - {tools.length} -
-
- Gated Actions: - - {tools.filter((t) => t.requiresConfirmation).length} - -
-
-
- - {/* Search Input */} -
- - setSearch(e.target.value)} - placeholder="Filter tools by function name, category or description..." - className="w-full bg-white border border-slate-200/90 rounded-2xl pl-11 pr-4 py-2.5 text-xs sm:text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/15 shadow-2xs transition-all" - /> -
- - {/* Tools Table Container */} -
-
- - - - - - - - - - - - {filtered.map((tool) => { - const isExpanded = expandedTool === tool.name; - - return ( - - - setExpandedTool(isExpanded ? null : tool.name) - } - className={cn( - 'hover:bg-slate-50/80 transition-colors cursor-pointer select-none', - isExpanded && 'bg-brand-tint/30' - )} - > - - - - - - - - - - - - {/* Expandable JSONSchema Parameters View */} - {isExpanded && ( - - - - )} - - ); - })} - -
Tool FunctionKindUsed bySafety GateArguments
-
- {isExpanded ? ( - - ) : ( - - )} - - {tool.name} - -
-
- {tool.description} -
-
- - {tool.category || 'General'} - - - {tool.parentSkill || '—'} - - {/* It read "Autonomous" for every ungated tool, which - described a read-only lookup as an agent acting on - its own. Ungated tools are reads or internal events. */} - {tool.requiresConfirmation ? ( - - - Confirmation Required - - ) : ( - - - No confirmation needed - - )} - - - {tool.parameters?.length || 0} params - -
- {(tool.target || tool.implementedAt) && ( -
-
Touches
-
{tool.target || '—'}
-
Implemented at
-
{tool.implementedAt || '—'}
-
- )} - {(!tool.parameters || tool.parameters.length === 0) && ( -

- No parameters are pinned down for this tool yet — its input is open. -

- )} -
- - Tool Parameters Schema (OpenAI / Claude JSONSchema Specification) -
- -
- {tool.parameters?.map((param) => ( -
-
- - {param.name} - -
- - {param.type} - - {param.required && ( - - required - - )} -
-
-

- {param.description} -

- {param.default !== undefined && ( -
- default: {String(param.default)} -
- )} -
- ))} -
-
-
-
+
+ {value} + {label} +
+ ); +} + +/* ─── Parameter pill ──────────────────────────────────────────────────────── */ +function ParamPill({ param }) { + return ( +
+
+ {param.name} +
+ + {param.type} + + {param.required && ( + + req + + )} +
+
+ {param.description && ( +

{param.description}

+ )} + {param.default !== undefined && ( +
+ default: {String(param.default)} +
+ )} +
+ ); +} + +/* ─── Tool Card ───────────────────────────────────────────────────────────── */ +function ToolCard({ tool }) { + const [open, setOpen] = useState(false); + const k = kindOf(tool); + const KindIcon = k.icon; + const hasParams = tool.parameters && tool.parameters.length > 0; + + return ( +
+ {/* Card header — always visible */} + + + {/* Expandable detail panel */} + {open && ( +
+ {/* Meta grid */} + {(tool.target || tool.implementedAt || tool.parentSkill) && ( +
+ {tool.target && ( +
+ +
+
Touches
+
{tool.target}
+
+
+ )} + {tool.implementedAt && ( +
+ +
+
Implemented at
+
{tool.implementedAt}
+
+
+ )} +
+ )} + + {/* Parameters */} + {hasParams ? ( +
+
+ + Parameters +
+
+ {tool.parameters.map((p) => )} +
+
+ ) : ( +

No parameters — input is open-ended.

+ )} +
+ )} +
+ ); +} + +/* ─── Main view ───────────────────────────────────────────────────────────── */ +export default function ToolRegistryView({ tools = [] }) { + const [search, setSearch] = useState(''); + const [kindFilter, setKindFilter] = useState('all'); + + const counts = useMemo(() => { + const c = { all: tools.length }; + tools.forEach((t) => { + const k = (t.kind || 'read').toLowerCase(); + c[k] = (c[k] || 0) + 1; + }); + return c; + }, [tools]); + + const kindTabs = ['all', ...Object.keys(KIND).filter((k) => counts[k])]; + + const filtered = useMemo(() => { + const q = search.toLowerCase().trim(); + return tools.filter((t) => { + const matchesKind = kindFilter === 'all' || (t.kind || 'read').toLowerCase() === kindFilter; + const matchesSearch = + !q || + t.name.toLowerCase().includes(q) || + t.description.toLowerCase().includes(q) || + (t.parentSkill || '').toLowerCase().includes(q) || + (t.target || '').toLowerCase().includes(q); + return matchesKind && matchesSearch; + }); + }, [tools, kindFilter, search]); + + const gatedCount = tools.filter((t) => t.requiresConfirmation).length; + const openCount = tools.length - gatedCount; + + return ( +
+ {/* ── Stats row ─────────────────────────────────────────────────── */} +
+ + + + + + + {/* Right-side header text */} +
+

+ + Agent Tool Registry +

+

+ Every capability a skill may call. Write & notify tools need operator confirmation unless the agent is autonomous. +

+
+
+ + {/* ── Search + Kind filter ───────────────────────────────────────── */} +
+ {/* Search */} +
+ + setSearch(e.target.value)} + placeholder="Search by name, target system, or description…" + className="w-full h-10 bg-white border border-slate-200 rounded-xl pl-10 pr-9 text-sm text-slate-800 placeholder:text-slate-400 focus:outline-none focus:border-brand focus:ring-2 focus:ring-brand/10 shadow-sm transition-all" + /> + {search && ( + + )} +
+ + {/* Kind filter tabs */} +
+ {kindTabs.map((k) => { + const meta = KIND[k]; + const isActive = kindFilter === k; + return ( + + ); + })} +
+
+ + {/* ── Tool Cards ────────────────────────────────────────────────── */} + {filtered.length === 0 ? ( +
+ +

No tools match your filter

+ +
+ ) : ( +
+ {filtered.map((tool) => )} +
+ )}
); } diff --git a/tests/integration/agentStudio.test.jsx b/tests/integration/agentStudio.test.jsx index 152cd37..7763467 100644 --- a/tests/integration/agentStudio.test.jsx +++ b/tests/integration/agentStudio.test.jsx @@ -191,7 +191,7 @@ describe('Agent Studio page', () => { // The engine reads it only once its Phase 5 build is deployed; say so, not "live". expect(note).toHaveTextContent(/once its registry-reading build is deployed/); expect(note).toHaveTextContent(/environment defaults/); - expect(note).toHaveTextContent(/The Test tab runs real Claude calls/); + expect(note).toHaveTextContent(/The Test tab runs real AI model calls/); expect(note).not.toHaveTextContent(/The Test tab is a simulation/); // Since Phase 3 the console skills DO run on these settings; the note must not say otherwise. expect(note).toHaveTextContent(/run on these settings now/); diff --git a/tests/integration/clientOnboarding.test.jsx b/tests/integration/clientOnboarding.test.jsx index fcae4f1..6d372b9 100644 --- a/tests/integration/clientOnboarding.test.jsx +++ b/tests/integration/clientOnboarding.test.jsx @@ -23,6 +23,13 @@ jest.mock('@/api/doormile', () => ({ onboardClient: jest.fn(), getOnboardedClients: jest.fn(), getOnboardingCities: jest.fn(), + updateOnboardedClient: jest.fn(), + deleteOnboardedClient: jest.fn(), +})); + +jest.mock('@/api/doormile/notify', () => ({ + OpenToast: jest.fn(), + messageOf: (err, fallback = 'Something went wrong') => err?.response?.data?.message || err?.message || fallback, })); let mockAuth = { user: { email: 'admin@doormile.com', role: 'admin', tenantid: null }, isClient: false }; @@ -67,8 +74,79 @@ beforeEach(() => { jest.clearAllMocks(); api.getOnboardingCities.mockResolvedValue(CITIES); api.getOnboardedClients.mockResolvedValue([ - { tenantid: 12, tenantname: 'Older Client', primaryemail: 'a@old.example', primarycontact: '9000000000', status: 'Active', loginemail: 'a@old.example', loginrole: 'manager', logincreatedat: '2026-09-28T10:00:00Z' }, + { authid: 31, tenantid: 12, tenantname: 'Older Client', primaryemail: 'a@old.example', primarycontact: '9000000000', status: 'Active', requiredeliveryotp: false, contactname: 'Old Contact', loginemail: 'a@old.example', loginrole: 'manager', logincreatedat: '2026-09-28T10:00:00Z' }, ]); + api.updateOnboardedClient.mockResolvedValue({ success: true, data: {} }); + api.deleteOnboardedClient.mockResolvedValue({ success: true, data: {} }); +}); + +describe('Edit and Delete on a client login', () => { + it('shows Edit and Delete on each row', async () => { + renderPage(); + expect(await screen.findByRole('button', { name: 'Edit Older Client' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Delete Older Client' })).toBeInTheDocument(); + }); + + it('sends only the fields that changed', async () => { + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' })); + const dialog = await screen.findByRole('dialog'); + const save = screen.getByRole('button', { name: /Save changes/ }); + expect(save).toBeDisabled(); // nothing changed yet + fireEvent.change(dialog.querySelector('select'), { target: { value: 'Inactive' } }); + fireEvent.change(screen.getAllByLabelText(/Mobile number/)[1], { target: { value: '9123456780' } }); + fireEvent.click(save); + await waitFor(() => expect(api.updateOnboardedClient).toHaveBeenCalledWith(31, { phone: '9123456780', status: 'Inactive' })); + }); + + it('resets the password only when one is entered, and checks its length', async () => { + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' })); + await screen.findByRole('dialog'); + fireEvent.change(document.getElementById('edit-client-password'), { target: { value: 'short' } }); + fireEvent.click(screen.getByRole('button', { name: /Save changes/ })); + await waitFor(() => expect(document.getElementById('edit-client-password-error')).toHaveTextContent('At least 8 characters')); + expect(api.updateOnboardedClient).not.toHaveBeenCalled(); + fireEvent.change(document.getElementById('edit-client-password'), { target: { value: 'new-pass-123' } }); + fireEvent.click(screen.getByRole('button', { name: /Save changes/ })); + await waitFor(() => expect(api.updateOnboardedClient).toHaveBeenCalledWith(31, { password: 'new-pass-123' })); + }); + + it('shows a server conflict in the dialog', async () => { + api.updateOnboardedClient.mockRejectedValue({ response: { status: 409, data: { message: 'this email already has a console login' } } }); + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: 'Edit Older Client' })); + await screen.findByRole('dialog'); + fireEvent.change(screen.getAllByLabelText(/Login email/)[1], { target: { value: 'taken@x.example' } }); + fireEvent.click(screen.getByRole('button', { name: /Save changes/ })); + expect(await screen.findByText('this email already has a console login')).toBeInTheDocument(); + }); + + it('never calls /clients/undefined when the backend is too old to send ids', async () => { + api.getOnboardedClients.mockResolvedValue([ + { tenantid: 12, tenantname: 'Older Client', primarycontact: '9000000000', status: 'Active', loginemail: 'a@old.example', loginrole: 'manager' }, + ]); + renderPage(); + const edit = await screen.findByRole('button', { name: 'Edit Older Client' }); + expect(edit).toBeDisabled(); + expect(screen.getByRole('button', { name: 'Delete Older Client' })).toBeDisabled(); + expect(screen.getByText('Edit and Delete are not available on this server yet')).toBeInTheDocument(); + fireEvent.click(edit); + expect(api.updateOnboardedClient).not.toHaveBeenCalled(); + expect(api.deleteOnboardedClient).not.toHaveBeenCalled(); + }); + + it('deletes only after the company name is typed', async () => { + renderPage(); + fireEvent.click(await screen.findByRole('button', { name: 'Delete Older Client' })); + await screen.findByRole('dialog'); + const remove = screen.getByRole('button', { name: /Remove login/ }); + expect(remove).toBeDisabled(); + fireEvent.change(screen.getByLabelText(/to confirm/), { target: { value: 'older client' } }); + expect(remove).not.toBeDisabled(); + fireEvent.click(remove); + await waitFor(() => expect(api.deleteOnboardedClient).toHaveBeenCalledWith(31)); + }); }); describe('Client onboarding page', () => {