reports
This commit is contained in:
parent
d272ae0bd3
commit
ff01efe4bb
17 changed files with 1378 additions and 21 deletions
|
|
@ -23,6 +23,8 @@ import { UrmiOtpPage } from './features/agent/UrmiOtpPage'
|
|||
import { UrmiOtpDocsPage } from './features/agent/UrmiOtpDocsPage'
|
||||
import { AgentTemplatesPage } from './features/agent/AgentTemplatesPage'
|
||||
import { AgentTemplateDocsPage } from './features/agent/AgentTemplateDocsPage'
|
||||
import { UrmiportPage } from './features/agent/UrmiportPage'
|
||||
import { PublicReportPage } from './features/reports/PublicReportPage'
|
||||
import { KycPage } from './features/compliance/KycPage'
|
||||
import { DltPage } from './features/compliance/DltPage'
|
||||
import { ComplianceGate } from './features/compliance/ComplianceProvider'
|
||||
|
|
@ -45,6 +47,7 @@ export default function App() {
|
|||
<Routes>
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/privacy" element={<PrivacyPolicyPage />} />
|
||||
<Route path="/reports/:group/:reportId" element={<PublicReportPage />} />
|
||||
<Route path="/login" element={<AuthPage initialMode="login" />} />
|
||||
<Route path="/register" element={<AuthPage initialMode="register" />} />
|
||||
<Route path="/admin/*" element={<AdminApp />} />
|
||||
|
|
@ -74,6 +77,7 @@ export default function App() {
|
|||
<Route path="agent/urmiai/templates/:slug/docs" element={<AgentTemplateDocsPage />} />
|
||||
<Route path="agent/urmiotp" element={<UrmiOtpPage />} />
|
||||
<Route path="agent/urmiotp/projects/:projectId/docs" element={<UrmiOtpDocsPage />} />
|
||||
<Route path="agent/urmiport" element={<UrmiportPage />} />
|
||||
<Route path=":section/*" element={<Placeholder />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useState } from 'react'
|
||||
import {
|
||||
Activity, Bell, BookOpen, Bot, Boxes, ChevronDown, CircleDollarSign,
|
||||
Code2, CreditCard, Cpu, FileCheck2, Gauge, KeyRound, LayoutDashboard,
|
||||
Code2, CreditCard, Cpu, FileCheck2, FileText, Gauge, KeyRound, LayoutDashboard,
|
||||
LogOut, Menu, MessageSquareText, Network, Phone, Search, Settings, ShieldCheck,
|
||||
SlidersHorizontal, TestTube2, Users, Webhook, X, Zap,
|
||||
MessageCircle,
|
||||
|
|
@ -26,6 +26,7 @@ const groups = [
|
|||
{ label: 'UrmiAI', path: '/console/agent/urmiai', icon: Bot },
|
||||
{ label: 'UrmIOT', path: '/console/agent/urmiot', icon: Cpu },
|
||||
{ label: 'UrmiOTP', path: '/console/agent/urmiotp', icon: KeyRound },
|
||||
{ label: 'Urmiport', path: '/console/agent/urmiport', icon: FileText },
|
||||
]},
|
||||
{ label: 'Communicate', links: [
|
||||
{ label: 'Messaging', path: '/console/messaging', icon: MessageSquareText },
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ function TestModal({
|
|||
}) {
|
||||
const [to, setTo] = useState('')
|
||||
const [customerName, setCustomerName] = useState('')
|
||||
const [reportGroup, setReportGroup] = useState('')
|
||||
const [reportId, setReportId] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [sending, setSending] = useState(false)
|
||||
|
|
@ -26,6 +27,7 @@ function TestModal({
|
|||
await agentTemplatesApi.test(template.slug, {
|
||||
to: to.trim(),
|
||||
customer_name: customerName.trim(),
|
||||
report_group: reportGroup.trim(),
|
||||
report_id: reportId.trim() || undefined,
|
||||
})
|
||||
onSent()
|
||||
|
|
@ -56,10 +58,15 @@ function TestModal({
|
|||
<span>Customer name</span>
|
||||
<input value={customerName} onChange={(e) => setCustomerName(e.target.value)} placeholder="Suyash" required />
|
||||
</label>
|
||||
<label>
|
||||
<span>Report group</span>
|
||||
<input value={reportGroup} onChange={(e) => setReportGroup(e.target.value)} placeholder="acme-agent-runs" required />
|
||||
<small>Urmiport group slug used in urmiai.com/reports/{'{group}'}/{'{id}'}.</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Report ID (optional)</span>
|
||||
<input value={reportId} onChange={(e) => setReportId(e.target.value)} placeholder="run-abc123" />
|
||||
<small>Used for the dynamic View Report button URL suffix.</small>
|
||||
<small>Report postfix; auto-generated when omitted.</small>
|
||||
</label>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</div>
|
||||
|
|
|
|||
284
frontend/src/features/agent/UrmiportPage.tsx
Normal file
284
frontend/src/features/agent/UrmiportPage.tsx
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
import { ArrowRight, Code2, ExternalLink, FileText, Globe, Lock, Plus, X } from 'lucide-react'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { urmiportApi, type UrmiportApiDoc, type UrmiportGroup, type UrmiportReport } from '../../lib/api'
|
||||
|
||||
const GROUP_SLUG_RE = /^[a-z][a-z0-9-]{1,48}[a-z0-9]$/
|
||||
|
||||
function GroupModal({
|
||||
onClose,
|
||||
onCreated,
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated: (group: UrmiportGroup) => void
|
||||
}) {
|
||||
const [reportGroup, setReportGroup] = useState('')
|
||||
const [displayName, setDisplayName] = useState('')
|
||||
const [defaultAccess, setDefaultAccess] = useState<'public' | 'password'>('public')
|
||||
const [password, setPassword] = useState('')
|
||||
const [idMode, setIdMode] = useState<'auto' | 'manual'>('auto')
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
const slug = reportGroup.trim().toLowerCase()
|
||||
if (!GROUP_SLUG_RE.test(slug) || slug.includes('--')) {
|
||||
setError('Report group must be 3–50 chars: lowercase letters, numbers, hyphens')
|
||||
return
|
||||
}
|
||||
if (defaultAccess === 'password' && !password.trim()) {
|
||||
setError('Password is required for password-protected groups')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const group = await urmiportApi.createGroup({
|
||||
report_group: slug,
|
||||
display_name: displayName.trim() || undefined,
|
||||
default_access: defaultAccess,
|
||||
password: defaultAccess === 'password' ? password.trim() : undefined,
|
||||
id_mode: idMode,
|
||||
})
|
||||
onCreated(group)
|
||||
onClose()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to create report group')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
<form className="modal-card otp-project-modal" onSubmit={submit} onClick={(event) => event.stopPropagation()}>
|
||||
<div className="modal-header">
|
||||
<h2>Create report group</h2>
|
||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="otp-project-form">
|
||||
<label>
|
||||
<span>Report group</span>
|
||||
<input
|
||||
value={reportGroup}
|
||||
onChange={(event) => setReportGroup(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
placeholder="acme-agent-runs"
|
||||
required
|
||||
/>
|
||||
<small>Used in URLs: urmiai.com/reports/{'{report_group}'}/{'{report_id}'}</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Display name (optional)</span>
|
||||
<input value={displayName} onChange={(event) => setDisplayName(event.target.value)} placeholder="Acme agent runs" />
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend>Default access</legend>
|
||||
<label><input type="radio" checked={defaultAccess === 'public'} onChange={() => setDefaultAccess('public')} /> Public</label>
|
||||
<label><input type="radio" checked={defaultAccess === 'password'} onChange={() => setDefaultAccess('password')} /> Password</label>
|
||||
</fieldset>
|
||||
{defaultAccess === 'password' && (
|
||||
<label>
|
||||
<span>Default password</span>
|
||||
<input type="password" value={password} onChange={(event) => setPassword(event.target.value)} required />
|
||||
</label>
|
||||
)}
|
||||
<fieldset>
|
||||
<legend>Report ID mode</legend>
|
||||
<label><input type="radio" checked={idMode === 'auto'} onChange={() => setIdMode('auto')} /> Auto-generate postfix</label>
|
||||
<label><input type="radio" checked={idMode === 'manual'} onChange={() => setIdMode('manual')} /> Require report_id in API</label>
|
||||
</fieldset>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="primary-button" disabled={saving}>{saving ? 'Creating…' : 'Create group'}</button>
|
||||
<button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function UrmiportPage() {
|
||||
const [groups, setGroups] = useState<UrmiportGroup[]>([])
|
||||
const [selectedGroup, setSelectedGroup] = useState<string | null>(null)
|
||||
const [reports, setReports] = useState<UrmiportReport[]>([])
|
||||
const [docs, setDocs] = useState<UrmiportApiDoc | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [reportsLoading, setReportsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [showModal, setShowModal] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
Promise.all([urmiportApi.listGroups(), urmiportApi.docs()])
|
||||
.then(([groupList, apiDocs]) => {
|
||||
setGroups(groupList)
|
||||
setDocs(apiDocs)
|
||||
if (groupList[0]) setSelectedGroup(groupList[0].report_group)
|
||||
})
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load Urmiport'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedGroup) {
|
||||
setReports([])
|
||||
return
|
||||
}
|
||||
setReportsLoading(true)
|
||||
urmiportApi.listReports(selectedGroup)
|
||||
.then(setReports)
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load reports'))
|
||||
.finally(() => setReportsLoading(false))
|
||||
}, [selectedGroup])
|
||||
|
||||
return (
|
||||
<div className="urmiport-page">
|
||||
<header className="page-header">
|
||||
<div>
|
||||
<span className="eyebrow">Agent</span>
|
||||
<h1><FileText size={24} /> Urmiport</h1>
|
||||
<p>Host HTML reports at urmiai.com/reports/{'{report_group}'}/{'{report_id}'} — no Urmi branding on the public page.</p>
|
||||
</div>
|
||||
<button className="primary-button" onClick={() => setShowModal(true)}>
|
||||
<Plus size={16} /> New report group
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
|
||||
<section className="panel urmiport-groups-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Report groups</h2>
|
||||
<p>Each group is a namespace for hosted reports. Publish HTML via the API below.</p>
|
||||
</div>
|
||||
</div>
|
||||
{loading ? (
|
||||
<p className="panel-empty">Loading…</p>
|
||||
) : !groups.length ? (
|
||||
<p className="panel-empty">Create a report group to start hosting reports.</p>
|
||||
) : (
|
||||
<div className="urmiport-group-table">
|
||||
<div className="urmiport-group-table-head">
|
||||
<span>Group</span>
|
||||
<span>Access</span>
|
||||
<span>ID mode</span>
|
||||
<span>Reports</span>
|
||||
<span>URL pattern</span>
|
||||
</div>
|
||||
{groups.map((group) => (
|
||||
<button
|
||||
type="button"
|
||||
key={group.id}
|
||||
className={`urmiport-group-row ${selectedGroup === group.report_group ? 'is-selected' : ''}`}
|
||||
onClick={() => setSelectedGroup(group.report_group)}
|
||||
>
|
||||
<div>
|
||||
<strong>{group.display_name || group.report_group}</strong>
|
||||
<small>{group.report_group}</small>
|
||||
</div>
|
||||
<span>{group.default_access === 'password' ? <><Lock size={13} /> Password</> : <><Globe size={13} /> Public</>}</span>
|
||||
<span>{group.id_mode === 'auto' ? 'Auto' : 'Manual'}</span>
|
||||
<span>{group.report_count}</span>
|
||||
<code>{group.url_pattern}</code>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{selectedGroup && (
|
||||
<section className="panel urmiport-reports-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2>Reports in {selectedGroup}</h2>
|
||||
<p>Published via API. Same report_group + report_id updates the HTML body.</p>
|
||||
</div>
|
||||
</div>
|
||||
{reportsLoading ? (
|
||||
<p className="panel-empty">Loading reports…</p>
|
||||
) : !reports.length ? (
|
||||
<p className="panel-empty">No reports yet. Publish one using the API docs below.</p>
|
||||
) : (
|
||||
<div className="urmiport-report-table">
|
||||
<div className="urmiport-report-table-head">
|
||||
<span>Report ID</span>
|
||||
<span>Access</span>
|
||||
<span>Updated</span>
|
||||
<span>URL</span>
|
||||
</div>
|
||||
{reports.map((report) => (
|
||||
<div className="urmiport-report-row" key={report.id}>
|
||||
<strong>{report.report_id}</strong>
|
||||
<span>{report.access === 'password' ? 'Password' : 'Public'}</span>
|
||||
<span>{new Date(report.updated_at).toLocaleString()}</span>
|
||||
<a href={report.url} target="_blank" rel="noreferrer">
|
||||
<ExternalLink size={13} /> Open
|
||||
</a>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{docs && (
|
||||
<section className="panel urmiport-docs-panel">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h2><Code2 size={18} /> API documentation</h2>
|
||||
<p>{docs.summary}</p>
|
||||
</div>
|
||||
<span className="explorer-method">{docs.method}</span>
|
||||
</div>
|
||||
<div className="urmiport-docs-grid">
|
||||
<div>
|
||||
<h3>Endpoint</h3>
|
||||
<code>POST /api/v1/urmiport/reports</code>
|
||||
<h3>Authentication</h3>
|
||||
<p><code>{docs.authentication.header}: {docs.authentication.format}</code></p>
|
||||
<h3>Request body</h3>
|
||||
<ul className="agent-template-notes">
|
||||
{Object.entries(docs.request_body).map(([key, field]) => (
|
||||
<li key={key}>
|
||||
<strong>{key}</strong>{field.required ? ' (required)' : ' (optional)'} — {field.description}
|
||||
{field.example ? <> Example: <code>{field.example}</code></> : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<h3>Notes</h3>
|
||||
<ul className="agent-template-notes">
|
||||
{docs.notes?.map((note) => <li key={note}>{note}</li>)}
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h3>Example request</h3>
|
||||
<pre className="explorer-code">{JSON.stringify({
|
||||
report_group: 'acme-agent-runs',
|
||||
report_id: 'run-abc123',
|
||||
body: '<html><body><h1>Agent run complete</h1></body></html>',
|
||||
access: 'public',
|
||||
}, null, 2)}</pre>
|
||||
<h3>Example response</h3>
|
||||
<pre className="explorer-code">{JSON.stringify(docs.responses['200']?.example ?? {}, null, 2)}</pre>
|
||||
<Link to="/console/api-explorer" className="text-button">
|
||||
Try in API Explorer <ArrowRight size={14} />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<GroupModal
|
||||
onClose={() => setShowModal(false)}
|
||||
onCreated={(group) => {
|
||||
setGroups((current) => [group, ...current])
|
||||
setSelectedGroup(group.report_group)
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import {
|
|||
integrationsApi,
|
||||
messagesApi,
|
||||
numbersApi,
|
||||
urmiportApi,
|
||||
voiceApi,
|
||||
type AgentMessageApiDoc,
|
||||
type AgentMessageSendResponse,
|
||||
|
|
@ -13,6 +14,8 @@ import {
|
|||
type MessageApiDoc,
|
||||
type PhoneNumber,
|
||||
type SendMessageResponse,
|
||||
type UrmiportApiDoc,
|
||||
type UrmiportPublishResponse,
|
||||
type VoiceApiDocs,
|
||||
type VoiceCall,
|
||||
} from '../../lib/api'
|
||||
|
|
@ -137,34 +140,51 @@ export function ApiExplorerPage() {
|
|||
const [agentTemplate, setAgentTemplate] = useState('')
|
||||
const [agentTo, setAgentTo] = useState('')
|
||||
const [agentCustomerName, setAgentCustomerName] = useState('')
|
||||
const [agentReportGroup, setAgentReportGroup] = useState('')
|
||||
const [agentReportId, setAgentReportId] = useState('')
|
||||
const [agentResponse, setAgentResponse] = useState<AgentMessageSendResponse | null>(null)
|
||||
const [agentError, setAgentError] = useState('')
|
||||
const [agentRunning, setAgentRunning] = useState(false)
|
||||
|
||||
const [urmiportDoc, setUrmiportDoc] = useState<UrmiportApiDoc | null>(null)
|
||||
const [urmiportGroup, setUrmiportGroup] = useState('')
|
||||
const [urmiportReportId, setUrmiportReportId] = useState('')
|
||||
const [urmiportBody, setUrmiportBody] = useState('<html><body><h1>Agent run complete</h1><p>All checks passed.</p></body></html>')
|
||||
const [urmiportAccess, setUrmiportAccess] = useState('public')
|
||||
const [urmiportPassword, setUrmiportPassword] = useState('')
|
||||
const [urmiportResponse, setUrmiportResponse] = useState<UrmiportPublishResponse | null>(null)
|
||||
const [urmiportError, setUrmiportError] = useState('')
|
||||
const [urmiportRunning, setUrmiportRunning] = useState(false)
|
||||
|
||||
const [fromDid, setFromDid] = useState('')
|
||||
const [callTo, setCallTo] = useState('')
|
||||
const [voiceResponse, setVoiceResponse] = useState<VoiceCall | null>(null)
|
||||
const [voiceError, setVoiceError] = useState('')
|
||||
const [voiceRunning, setVoiceRunning] = useState(false)
|
||||
|
||||
const [copied, setCopied] = useState<'sms' | 'voice' | 'agent' | null>(null)
|
||||
const [copied, setCopied] = useState<'sms' | 'voice' | 'agent' | 'urmiport' | null>(null)
|
||||
const [loadError, setLoadError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
Promise.allSettled([
|
||||
messagesApi.docs(),
|
||||
agentMessagesApi.docs(),
|
||||
urmiportApi.docs(),
|
||||
voiceApi.docs(),
|
||||
integrationsApi.docs(),
|
||||
numbersApi.mine(),
|
||||
])
|
||||
.then((results) => {
|
||||
const [smsResult, agentResult, voiceResult, integrationResult, numbersResult] = results
|
||||
const [smsResult, agentResult, urmiportResult, voiceResult, integrationResult, numbersResult] = results
|
||||
if (smsResult.status === 'fulfilled') setSmsDoc(smsResult.value)
|
||||
if (agentResult.status === 'fulfilled') {
|
||||
setAgentDoc(agentResult.value)
|
||||
setAgentTemplate(agentResult.value.request_body.template?.example ?? 'agent_run_completion')
|
||||
setAgentReportGroup(agentResult.value.request_body.report_group?.example ?? 'acme-agent-runs')
|
||||
}
|
||||
if (urmiportResult.status === 'fulfilled') {
|
||||
setUrmiportDoc(urmiportResult.value)
|
||||
setUrmiportGroup(urmiportResult.value.request_body.report_group?.example ?? 'acme-agent-runs')
|
||||
}
|
||||
if (voiceResult.status === 'fulfilled') setVoiceDoc(normalizeVoiceDocs(voiceResult.value))
|
||||
if (integrationResult.status === 'fulfilled') {
|
||||
|
|
@ -206,7 +226,8 @@ export function ApiExplorerPage() {
|
|||
template: agentTemplate || agentDoc.request_body.template.example,
|
||||
to: agentTo || agentDoc.request_body.to.example,
|
||||
customer_name: agentCustomerName || agentDoc.request_body.customer_name.example,
|
||||
report_id: agentReportId || agentDoc.request_body.report_id.example,
|
||||
report_group: agentReportGroup || agentDoc.request_body.report_group?.example || 'acme-agent-runs',
|
||||
report_id: agentReportId || agentDoc.request_body.report_id?.example,
|
||||
}, null, 2)
|
||||
return [
|
||||
`curl -X POST '${API_BASE_URL}${agentDoc.path}' \\`,
|
||||
|
|
@ -214,7 +235,24 @@ export function ApiExplorerPage() {
|
|||
` -H 'Content-Type: application/json' \\`,
|
||||
` -d '${body.replace(/'/g, `'\\''`)}'`,
|
||||
].join('\n')
|
||||
}, [agentCustomerName, agentDoc, agentReportId, agentTemplate, agentTo, apiKey])
|
||||
}, [agentCustomerName, agentDoc, agentReportGroup, agentReportId, agentTemplate, agentTo, apiKey])
|
||||
|
||||
const urmiportCurl = useMemo(() => {
|
||||
if (!urmiportDoc) return ''
|
||||
const body = JSON.stringify({
|
||||
report_group: urmiportGroup || urmiportDoc.request_body.report_group.example,
|
||||
report_id: urmiportReportId || urmiportDoc.request_body.report_id?.example,
|
||||
body: urmiportBody,
|
||||
access: urmiportAccess,
|
||||
password: urmiportAccess === 'password' ? urmiportPassword || urmiportDoc.request_body.password?.example : undefined,
|
||||
}, null, 2)
|
||||
return [
|
||||
`curl -X POST '${API_BASE_URL}${urmiportDoc.path}' \\`,
|
||||
` -H 'Authorization: Bearer ${apiKey || 'urmi_live_<your_api_key>'}' \\`,
|
||||
` -H 'Content-Type: application/json' \\`,
|
||||
` -d '${body.replace(/'/g, `'\\''`)}'`,
|
||||
].join('\n')
|
||||
}, [apiKey, urmiportAccess, urmiportBody, urmiportDoc, urmiportGroup, urmiportPassword, urmiportReportId])
|
||||
|
||||
const voiceCurl = useMemo(() => {
|
||||
if (!voiceDoc) return ''
|
||||
|
|
@ -259,6 +297,7 @@ export function ApiExplorerPage() {
|
|||
template: agentTemplate.trim(),
|
||||
to: agentTo.trim(),
|
||||
customer_name: agentCustomerName.trim(),
|
||||
report_group: agentReportGroup.trim(),
|
||||
report_id: agentReportId.trim() || undefined,
|
||||
})
|
||||
setAgentResponse(result)
|
||||
|
|
@ -287,8 +326,28 @@ export function ApiExplorerPage() {
|
|||
}
|
||||
}
|
||||
|
||||
async function copyCurl(kind: 'sms' | 'voice' | 'agent') {
|
||||
const text = kind === 'sms' ? smsCurl : kind === 'agent' ? agentCurl : voiceCurl
|
||||
async function runUrmiport() {
|
||||
setUrmiportError('')
|
||||
setUrmiportResponse(null)
|
||||
setUrmiportRunning(true)
|
||||
try {
|
||||
const result = await urmiportApi.publish(apiKey.trim(), {
|
||||
report_group: urmiportGroup.trim(),
|
||||
report_id: urmiportReportId.trim() || undefined,
|
||||
body: urmiportBody,
|
||||
access: urmiportAccess as 'public' | 'password',
|
||||
password: urmiportAccess === 'password' ? urmiportPassword.trim() || undefined : undefined,
|
||||
})
|
||||
setUrmiportResponse(result)
|
||||
} catch (reason) {
|
||||
setUrmiportError(reason instanceof Error ? reason.message : 'Request failed')
|
||||
} finally {
|
||||
setUrmiportRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyCurl(kind: 'sms' | 'voice' | 'agent' | 'urmiport') {
|
||||
const text = kind === 'sms' ? smsCurl : kind === 'agent' ? agentCurl : kind === 'urmiport' ? urmiportCurl : voiceCurl
|
||||
await navigator.clipboard.writeText(text)
|
||||
setCopied(kind)
|
||||
window.setTimeout(() => setCopied(null), 2000)
|
||||
|
|
@ -425,12 +484,14 @@ export function ApiExplorerPage() {
|
|||
key === 'template' ? agentTemplate
|
||||
: key === 'to' ? agentTo
|
||||
: key === 'customer_name' ? agentCustomerName
|
||||
: agentReportId
|
||||
: key === 'report_group' ? agentReportGroup
|
||||
: agentReportId
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (key === 'template') setAgentTemplate(e.target.value)
|
||||
if (key === 'to') setAgentTo(e.target.value)
|
||||
if (key === 'customer_name') setAgentCustomerName(e.target.value)
|
||||
if (key === 'report_group') setAgentReportGroup(e.target.value)
|
||||
if (key === 'report_id') setAgentReportId(e.target.value)
|
||||
}}
|
||||
placeholder={field.example}
|
||||
|
|
@ -475,6 +536,100 @@ export function ApiExplorerPage() {
|
|||
</ExplorerGroup>
|
||||
)}
|
||||
|
||||
{urmiportDoc && (
|
||||
<ExplorerGroup title="Urmiport">
|
||||
<section className="panel explorer-endpoint-card">
|
||||
<div className="panel-heading">
|
||||
<div>
|
||||
<h3>{urmiportDoc.title}</h3>
|
||||
<p>{urmiportDoc.summary}</p>
|
||||
</div>
|
||||
<span className="explorer-method">{urmiportDoc.method}</span>
|
||||
</div>
|
||||
|
||||
<FieldRow label="Endpoint" value={`${API_BASE_URL}${urmiportDoc.path}`} />
|
||||
<FieldRow label="Auth" value={`${urmiportDoc.authentication.header}: ${urmiportDoc.authentication.format}`} />
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Request body</h4>
|
||||
<div className="explorer-request-grid">
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>report_group</strong>
|
||||
<span>{urmiportDoc.request_body.report_group.description}</span>
|
||||
<input value={urmiportGroup} onChange={(e) => setUrmiportGroup(e.target.value)} placeholder={urmiportDoc.request_body.report_group.example} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>report_id</strong>
|
||||
<span>{urmiportDoc.request_body.report_id.description}</span>
|
||||
<input value={urmiportReportId} onChange={(e) => setUrmiportReportId(e.target.value)} placeholder={urmiportDoc.request_body.report_id.example} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="explorer-input explorer-input-wide">
|
||||
<label>
|
||||
<strong>body</strong>
|
||||
<span>{urmiportDoc.request_body.body.description}</span>
|
||||
<textarea value={urmiportBody} onChange={(e) => setUrmiportBody(e.target.value)} rows={6} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>access</strong>
|
||||
<span>{urmiportDoc.request_body.access.description}</span>
|
||||
<select value={urmiportAccess} onChange={(e) => setUrmiportAccess(e.target.value)}>
|
||||
<option value="public">public</option>
|
||||
<option value="password">password</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
{urmiportAccess === 'password' && (
|
||||
<div className="explorer-input">
|
||||
<label>
|
||||
<strong>password</strong>
|
||||
<span>{urmiportDoc.request_body.password.description}</span>
|
||||
<input type="password" value={urmiportPassword} onChange={(e) => setUrmiportPassword(e.target.value)} placeholder={urmiportDoc.request_body.password.example} />
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>API key</h4>
|
||||
<label className="explorer-input">
|
||||
<strong>Bearer token</strong>
|
||||
<span>Create the report group in Agent → Urmiport before publishing.</span>
|
||||
<input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} placeholder="urmi_live_..." />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="explorer-actions">
|
||||
<button className="primary-button" onClick={runUrmiport} disabled={urmiportRunning || !apiKey.trim()}>
|
||||
<Play size={15} /> {urmiportRunning ? 'Publishing…' : 'Try request'}
|
||||
</button>
|
||||
<button className="secondary-button" onClick={() => copyCurl('urmiport')}>
|
||||
<Copy size={15} /> {copied === 'urmiport' ? 'Copied cURL' : 'Copy cURL'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{urmiportError && <p className="auth-error">{urmiportError}</p>}
|
||||
{urmiportResponse && (
|
||||
<div className="explorer-response success">
|
||||
<strong><CheckCircle2 size={15} /> 200 OK</strong>
|
||||
<pre>{JSON.stringify(urmiportResponse, null, 2)}</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="explorer-section">
|
||||
<h4>Example cURL</h4>
|
||||
<pre className="explorer-code">{urmiportCurl}</pre>
|
||||
</div>
|
||||
</section>
|
||||
</ExplorerGroup>
|
||||
)}
|
||||
|
||||
{voiceDoc && integrationDocs && (
|
||||
<ExplorerGroup title="Numbers">
|
||||
<RelayCallout principle={voiceDoc.relay.principle} latency={voiceDoc.relay.latency} />
|
||||
|
|
|
|||
80
frontend/src/features/reports/PublicReportPage.tsx
Normal file
80
frontend/src/features/reports/PublicReportPage.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { urmiportApi } from '../../lib/api'
|
||||
|
||||
export function PublicReportPage() {
|
||||
const { group, reportId } = useParams<{ group: string; reportId: string }>()
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [requiresPassword, setRequiresPassword] = useState(false)
|
||||
const [html, setHtml] = useState<string | null>(null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!group || !reportId) return
|
||||
setLoading(true)
|
||||
setError('')
|
||||
urmiportApi.viewReport(group, reportId)
|
||||
.then((payload) => {
|
||||
setRequiresPassword(payload.requires_password)
|
||||
setHtml(payload.html)
|
||||
})
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Report not found'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [group, reportId])
|
||||
|
||||
async function unlock(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
if (!group || !reportId) return
|
||||
setError('')
|
||||
try {
|
||||
const payload = await urmiportApi.unlockReport(group, reportId, password)
|
||||
setHtml(payload.html)
|
||||
setRequiresPassword(false)
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Invalid password')
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="public-report-loading">Loading report…</div>
|
||||
}
|
||||
|
||||
if (error && !requiresPassword) {
|
||||
return <div className="public-report-error">{error}</div>
|
||||
}
|
||||
|
||||
if (requiresPassword && !html) {
|
||||
return (
|
||||
<div className="public-report-gate">
|
||||
<form onSubmit={unlock}>
|
||||
<h1>Password required</h1>
|
||||
<p>This report is protected. Enter the password to continue.</p>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder="Password"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
{error && <p className="public-report-error-text">{error}</p>}
|
||||
<button type="submit">View report</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!html) {
|
||||
return <div className="public-report-error">Report not found</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className="public-report-frame"
|
||||
title="Report"
|
||||
srcDoc={html}
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
@ -1277,6 +1277,112 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
|
|||
.otp-project-form fieldset { display: flex; gap: 20px; padding: 0; border: 0; }
|
||||
.otp-project-form fieldset legend { margin-bottom: 8px; }
|
||||
.otp-project-form fieldset label { display: flex; align-items: center; gap: 7px; color: #607089; font-size: 10px; }
|
||||
.urmiport-page { max-width: 1180px; }
|
||||
.urmiport-groups-panel, .urmiport-reports-panel, .urmiport-docs-panel { margin-bottom: 20px; }
|
||||
.urmiport-group-table, .urmiport-report-table { margin-top: 18px; }
|
||||
.urmiport-group-table-head,
|
||||
.urmiport-group-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1.2fr) minmax(100px, .7fr) minmax(90px, .6fr) minmax(70px, .5fr) minmax(220px, 1.4fr);
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
text-align: left;
|
||||
}
|
||||
.urmiport-report-table-head,
|
||||
.urmiport-report-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(160px, 1fr) minmax(100px, .7fr) minmax(160px, .9fr) auto;
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
}
|
||||
.urmiport-group-table-head, .urmiport-report-table-head {
|
||||
padding: 0 10px 9px;
|
||||
color: #8a95a7;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.urmiport-group-row {
|
||||
width: 100%;
|
||||
padding: 14px 10px;
|
||||
border: 0;
|
||||
border-top: 1px solid #edf0f4;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
}
|
||||
.urmiport-group-row.is-selected { background: #f5f9ff; }
|
||||
.urmiport-group-row strong, .urmiport-group-row small, .urmiport-report-row strong { display: block; }
|
||||
.urmiport-group-row strong, .urmiport-report-row strong { color: #35435a; font-size: 12px; }
|
||||
.urmiport-group-row small { margin-top: 4px; color: #8a95a7; font-size: 9px; }
|
||||
.urmiport-group-row span, .urmiport-report-row span { display: inline-flex; align-items: center; gap: 5px; color: #607089; font-size: 10px; }
|
||||
.urmiport-group-row code { color: #1475ff; font-size: 9px; word-break: break-all; }
|
||||
.urmiport-report-row { padding: 14px 10px; border-top: 1px solid #edf0f4; }
|
||||
.urmiport-report-row a { display: inline-flex; align-items: center; gap: 5px; color: #1475ff; font-size: 10px; }
|
||||
.urmiport-docs-grid { display: grid; grid-template-columns: 1.1fr 1fr; gap: 24px; margin-top: 18px; }
|
||||
.urmiport-docs-grid h3 { margin: 18px 0 8px; color: #35435a; font-size: 11px; }
|
||||
.urmiport-docs-grid h3:first-child { margin-top: 0; }
|
||||
.explorer-input-wide { grid-column: 1 / -1; }
|
||||
.explorer-input textarea {
|
||||
width: 100%;
|
||||
min-height: 120px;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dfe5ed;
|
||||
border-radius: 5px;
|
||||
color: #26364d;
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
resize: vertical;
|
||||
}
|
||||
.public-report-frame {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
background: #fff;
|
||||
}
|
||||
.public-report-loading,
|
||||
.public-report-error {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
color: #46546a;
|
||||
font-size: 14px;
|
||||
}
|
||||
.public-report-gate {
|
||||
min-height: 100vh;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
padding: 24px;
|
||||
background: #f7f9fc;
|
||||
}
|
||||
.public-report-gate form {
|
||||
width: min(100%, 360px);
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 24px;
|
||||
border: 1px solid #dfe5ed;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
.public-report-gate h1 { margin: 0; font-size: 18px; color: #26364d; }
|
||||
.public-report-gate p { margin: 0; color: #607089; font-size: 12px; line-height: 1.5; }
|
||||
.public-report-gate input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid #dfe5ed;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.public-report-gate button {
|
||||
padding: 10px 14px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: #1475ff;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
.public-report-error-text { margin: 0; color: #d14343; font-size: 11px; }
|
||||
.otp-terms { grid-template-columns: auto 1fr; align-items: start; }
|
||||
.otp-terms input { margin-top: 2px; accent-color: #1475ff; }
|
||||
.otp-terms span { line-height: 1.45; }
|
||||
|
|
@ -1345,6 +1451,9 @@ main { padding: 30px clamp(20px, 3vw, 44px) 50px; }
|
|||
.agent-template-actions { flex-wrap: wrap; }
|
||||
.otp-project-table-head { display: none; }
|
||||
.otp-project-row { grid-template-columns: 1fr; align-items: start; }
|
||||
.urmiport-group-table-head, .urmiport-report-table-head { display: none; }
|
||||
.urmiport-group-row, .urmiport-report-row { grid-template-columns: 1fr; align-items: start; }
|
||||
.urmiport-docs-grid { grid-template-columns: 1fr; }
|
||||
.templates-table-head { display: none; }
|
||||
.templates-table-row { grid-template-columns: 1fr; align-items: start; gap: 8px; }
|
||||
.templates-send-button { justify-self: start; }
|
||||
|
|
|
|||
|
|
@ -493,6 +493,7 @@ export type AgentMessageSendResponse = {
|
|||
template: string
|
||||
to: string
|
||||
customer_name: string
|
||||
report_group: string
|
||||
report_id: string
|
||||
report_url: string | null
|
||||
wa_message_id: string | null
|
||||
|
|
@ -511,7 +512,7 @@ export const agentTemplatesApi = {
|
|||
jsonRequest<{ template: string; allowed: boolean; retry_after?: string | null; last_tested_at?: string | null }>(
|
||||
`/api/v1/agent-templates/${slug}/test-status`,
|
||||
),
|
||||
test: (slug: string, payload: { to: string; customer_name: string; report_id?: string }) =>
|
||||
test: (slug: string, payload: { to: string; customer_name: string; report_group: string; report_id?: string }) =>
|
||||
jsonRequest<AgentMessageSendResponse>(`/api/v1/agent-templates/${slug}/test`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
|
|
@ -522,7 +523,7 @@ export const agentMessagesApi = {
|
|||
docs: () => jsonRequest<AgentMessageApiDoc>('/api/v1/agent/messages/docs/send'),
|
||||
send: (
|
||||
apiKey: string,
|
||||
payload: { template: string; to: string; customer_name: string; report_id?: string },
|
||||
payload: { template: string; to: string; customer_name: string; report_group: string; report_id?: string },
|
||||
) =>
|
||||
fetch(`${API_BASE_URL}/api/v1/agent/messages/send`, {
|
||||
method: 'POST',
|
||||
|
|
@ -540,6 +541,119 @@ export const agentMessagesApi = {
|
|||
}),
|
||||
}
|
||||
|
||||
export type UrmiportGroup = {
|
||||
id: string
|
||||
report_group: string
|
||||
display_name: string | null
|
||||
default_access: 'public' | 'password'
|
||||
password_protected: boolean
|
||||
id_mode: 'auto' | 'manual'
|
||||
report_count: number
|
||||
url_pattern: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type UrmiportReport = {
|
||||
id: string
|
||||
report_group: string
|
||||
report_id: string
|
||||
access: 'public' | 'password'
|
||||
password_protected: boolean
|
||||
url: string
|
||||
updated_at: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export type UrmiportApiDoc = {
|
||||
id: string
|
||||
method: string
|
||||
path: string
|
||||
title: string
|
||||
summary: string
|
||||
authentication: {
|
||||
type: string
|
||||
header: string
|
||||
format: string
|
||||
alternate_header?: string
|
||||
}
|
||||
request_body: Record<string, {
|
||||
type: string
|
||||
required: boolean
|
||||
description: string
|
||||
example?: string
|
||||
}>
|
||||
responses: Record<string, { description: string; example?: Record<string, unknown> }>
|
||||
notes?: string[]
|
||||
}
|
||||
|
||||
export type UrmiportPublishResponse = UrmiportReport & { success: boolean }
|
||||
|
||||
export const urmiportApi = {
|
||||
docs: () => jsonRequest<UrmiportApiDoc>('/api/v1/urmiport/docs'),
|
||||
listGroups: () => jsonRequest<UrmiportGroup[]>('/api/v1/urmiport/groups'),
|
||||
createGroup: (payload: {
|
||||
report_group: string
|
||||
display_name?: string
|
||||
default_access: 'public' | 'password'
|
||||
password?: string
|
||||
id_mode: 'auto' | 'manual'
|
||||
}) => jsonRequest<UrmiportGroup>('/api/v1/urmiport/groups', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(payload),
|
||||
}),
|
||||
listReports: (slug: string) => jsonRequest<UrmiportReport[]>(`/api/v1/urmiport/groups/${slug}/reports`),
|
||||
viewReport: (group: string, reportId: string) =>
|
||||
fetch(`${API_BASE_URL}/api/v1/urmiport/view/${group}/${reportId}`).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null)
|
||||
throw new Error(typeof body?.detail === 'string' ? body.detail : 'Report not found')
|
||||
}
|
||||
return response.json() as Promise<{
|
||||
report_group: string
|
||||
report_id: string
|
||||
access: string
|
||||
requires_password: boolean
|
||||
html: string | null
|
||||
}>
|
||||
}),
|
||||
unlockReport: (group: string, reportId: string, password: string) =>
|
||||
fetch(`${API_BASE_URL}/api/v1/urmiport/view/${group}/${reportId}/unlock`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ password }),
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null)
|
||||
throw new Error(typeof body?.detail === 'string' ? body.detail : 'Unable to unlock report')
|
||||
}
|
||||
return response.json() as Promise<{ html: string }>
|
||||
}),
|
||||
publish: (
|
||||
apiKey: string,
|
||||
payload: {
|
||||
report_group: string
|
||||
report_id?: string
|
||||
body: string
|
||||
access?: 'public' | 'password'
|
||||
password?: string
|
||||
},
|
||||
) =>
|
||||
fetch(`${API_BASE_URL}/api/v1/urmiport/reports`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
}).then(async (response) => {
|
||||
if (!response.ok) {
|
||||
const body = await response.json().catch(() => null)
|
||||
throw new Error(typeof body?.detail === 'string' ? body.detail : 'Request failed')
|
||||
}
|
||||
return response.json() as Promise<UrmiportPublishResponse>
|
||||
}),
|
||||
}
|
||||
|
||||
export const urmiOtpApi = {
|
||||
listProjects: () => jsonRequest<UrmiOtpProject[]>('/api/v1/urmiotp/projects'),
|
||||
createProject: (payload: {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue