first commit
This commit is contained in:
commit
efa5080897
127 changed files with 18610 additions and 0 deletions
100
frontend/src/features/agent/UrmiOtpDocsPage.tsx
Normal file
100
frontend/src/features/agent/UrmiOtpDocsPage.tsx
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import { ArrowLeft, Check, Code2, Copy, KeyRound } from 'lucide-react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { urmiOtpApi, type UrmiOtpDocumentation } from '../../lib/api'
|
||||
|
||||
export function UrmiOtpDocsPage() {
|
||||
const { projectId = '' } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const [docs, setDocs] = useState<UrmiOtpDocumentation | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
urmiOtpApi.documentation(projectId)
|
||||
.then(setDocs)
|
||||
.catch((reason) => setError(reason instanceof Error ? reason.message : 'Unable to load documentation'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [projectId])
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading API documentation…</div>
|
||||
if (!docs) return <section className="panel"><p className="auth-error">{error}</p></section>
|
||||
|
||||
const requestExample = JSON.stringify(docs.api.example, null, 2)
|
||||
|
||||
async function copyExample() {
|
||||
await navigator.clipboard.writeText(requestExample)
|
||||
setCopied(true)
|
||||
window.setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="urmiotp-docs-page">
|
||||
<button className="text-button otp-back" onClick={() => navigate('/console/agent/urmiotp')}>
|
||||
<ArrowLeft size={14} /> Back to projects
|
||||
</button>
|
||||
<div className="account-heading">
|
||||
<span className="eyebrow">UrmiOTP · {docs.project.project_name}</span>
|
||||
<h1>API documentation</h1>
|
||||
<p className="otp-message-label">
|
||||
Message label: <strong>{docs.project.message_label}</strong>
|
||||
<span> — your product identification in each message. URMI branding remains unchanged.</span>
|
||||
</p>
|
||||
<p>Unified verification templates for login, recovery, account changes, transactions, and devices.</p>
|
||||
</div>
|
||||
|
||||
<section className="panel otp-api-overview">
|
||||
<div className="panel-heading">
|
||||
<div><h2>Generate verification OTP</h2><p>{docs.api.summary}</p></div>
|
||||
<span className="explorer-method">{docs.api.method}</span>
|
||||
</div>
|
||||
<div className="explorer-field-row"><span>Endpoint</span><code>{docs.api.path}</code></div>
|
||||
<div className="otp-api-notice">
|
||||
<Code2 size={16} />
|
||||
<span>This is documentation only. The UrmiOTP API is not enabled yet.</span>
|
||||
</div>
|
||||
<div className="otp-parameter-list">
|
||||
{Object.entries(docs.api.parameters).map(([name, description]) => (
|
||||
<div key={name}><code>{name}</code><span>{description}</span></div>
|
||||
))}
|
||||
</div>
|
||||
<div className="otp-code-heading">
|
||||
<strong>Request body</strong>
|
||||
<button className="secondary-button" onClick={copyExample}><Copy size={13} /> {copied ? 'Copied' : 'Copy'}</button>
|
||||
</div>
|
||||
<pre className="explorer-code">{requestExample}</pre>
|
||||
</section>
|
||||
|
||||
<div className="otp-template-grid">
|
||||
{docs.templates.map((template, index) => (
|
||||
<section className="panel otp-template-card" key={template.type}>
|
||||
<div className="otp-template-number">{String(index + 1).padStart(2, '0')}</div>
|
||||
<div className="panel-heading">
|
||||
<div><h2>{template.name}</h2><p><code>{template.type}</code></p></div>
|
||||
<KeyRound size={17} />
|
||||
</div>
|
||||
<div className="otp-template-copy">
|
||||
<span>Approved content</span>
|
||||
<p>{template.content}</p>
|
||||
</div>
|
||||
<div className="otp-template-copy sample">
|
||||
<span>Sample</span>
|
||||
<p>{template.sample}</p>
|
||||
</div>
|
||||
<div className="otp-template-columns">
|
||||
<div>
|
||||
<strong>Use for</strong>
|
||||
<ul>{template.uses.map((use) => <li key={use}><Check size={12} /> {use}</li>)}</ul>
|
||||
</div>
|
||||
<div>
|
||||
<strong>Variables</strong>
|
||||
<ol>{template.variables.map((variable) => <li key={variable}>{variable}</li>)}</ol>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
237
frontend/src/features/agent/UrmiOtpPage.tsx
Normal file
237
frontend/src/features/agent/UrmiOtpPage.tsx
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
import { ArrowRight, CheckCircle2, ExternalLink, KeyRound, Plus, ShieldCheck, Smartphone, X } from 'lucide-react'
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { urmiOtpApi, type UrmiOtpProject } from '../../lib/api'
|
||||
|
||||
const PROJECT_NAME_RE = /^[a-z][a-z0-9-]{4,28}[a-z0-9]$/
|
||||
const MESSAGE_LABEL_RE = /^[A-Za-z0-9][A-Za-z0-9 -]{0,18}[A-Za-z0-9]$|^[A-Za-z0-9]$/
|
||||
|
||||
function ProjectModal({ onClose, onCreated }: {
|
||||
onClose: () => void
|
||||
onCreated: (project: UrmiOtpProject) => void
|
||||
}) {
|
||||
const [name, setName] = useState('')
|
||||
const [messageLabel, setMessageLabel] = useState('')
|
||||
const [appType, setAppType] = useState<'web' | 'mobile'>('web')
|
||||
const [appUrl, setAppUrl] = useState('')
|
||||
const [purpose, setPurpose] = useState('')
|
||||
const [terms, setTerms] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
async function submit(event: FormEvent) {
|
||||
event.preventDefault()
|
||||
setError('')
|
||||
if (!PROJECT_NAME_RE.test(name) || name.includes('--')) {
|
||||
setError('Use 6–30 lowercase letters, numbers, or hyphens; start with a letter and end with a letter or number')
|
||||
return
|
||||
}
|
||||
const trimmedLabel = messageLabel.trim()
|
||||
if (!trimmedLabel || trimmedLabel.length > 20 || !MESSAGE_LABEL_RE.test(trimmedLabel)) {
|
||||
setError('Message label must be 1–20 characters: letters, numbers, spaces, or hyphens; start and end with a letter or number')
|
||||
return
|
||||
}
|
||||
if (purpose.trim().length < 20) {
|
||||
setError('Describe your app and OTP use case in at least 20 characters')
|
||||
return
|
||||
}
|
||||
if (!terms) {
|
||||
setError('You must agree to the terms and conditions')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
const project = await urmiOtpApi.createProject({
|
||||
project_name: name,
|
||||
message_label: trimmedLabel,
|
||||
app_type: appType,
|
||||
app_url: appUrl.trim(),
|
||||
purpose: purpose.trim(),
|
||||
terms_accepted: terms,
|
||||
})
|
||||
onCreated(project)
|
||||
onClose()
|
||||
} catch (reason) {
|
||||
setError(reason instanceof Error ? reason.message : 'Unable to register project')
|
||||
} 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>Register an UrmiOTP project</h2>
|
||||
<button type="button" className="icon-button" onClick={onClose} aria-label="Close"><X size={18} /></button>
|
||||
</div>
|
||||
<div className="otp-project-form">
|
||||
<label>
|
||||
<span>Project name</span>
|
||||
<input
|
||||
value={name}
|
||||
onChange={(event) => setName(event.target.value.toLowerCase().replace(/[^a-z0-9-]/g, ''))}
|
||||
placeholder="acme-login"
|
||||
minLength={6}
|
||||
maxLength={30}
|
||||
required
|
||||
/>
|
||||
<small>Globally unique. Lowercase letters, numbers, and hyphens only.</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Message label</span>
|
||||
<input
|
||||
value={messageLabel}
|
||||
onChange={(event) => setMessageLabel(event.target.value.slice(0, 20))}
|
||||
placeholder="your product identification in message"
|
||||
maxLength={20}
|
||||
required
|
||||
/>
|
||||
<small>Your product name in the message body; URMI stays as the platform identifier (max 20 characters).</small>
|
||||
</label>
|
||||
<fieldset>
|
||||
<legend>Application type</legend>
|
||||
<label><input type="radio" checked={appType === 'web'} onChange={() => setAppType('web')} /> Web application</label>
|
||||
<label><input type="radio" checked={appType === 'mobile'} onChange={() => setAppType('mobile')} /> Mobile application</label>
|
||||
</fieldset>
|
||||
<label>
|
||||
<span>{appType === 'web' ? 'Website or domain URL' : 'App store URL'}</span>
|
||||
<input
|
||||
type="url"
|
||||
value={appUrl}
|
||||
onChange={(event) => setAppUrl(event.target.value)}
|
||||
placeholder={appType === 'web' ? 'https://app.example.com' : 'https://play.google.com/store/apps/details?id=...'}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>Purpose and OTP use case</span>
|
||||
<textarea
|
||||
value={purpose}
|
||||
onChange={(event) => setPurpose(event.target.value)}
|
||||
placeholder="Describe your app, its users, and how verification codes will be used."
|
||||
rows={4}
|
||||
minLength={20}
|
||||
maxLength={1000}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label className="otp-terms">
|
||||
<input type="checkbox" checked={terms} onChange={(event) => setTerms(event.target.checked)} />
|
||||
<span>I agree to the UrmiOTP terms, acceptable-use policy, and anti-abuse requirements.</span>
|
||||
</label>
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</div>
|
||||
<div className="modal-actions">
|
||||
<button className="primary-button" disabled={saving}>{saving ? 'Submitting…' : 'Submit for approval'}</button>
|
||||
<button type="button" className="secondary-button" onClick={onClose}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OtpMap() {
|
||||
return (
|
||||
<section className="panel otp-map">
|
||||
<div className="otp-map-root"><KeyRound size={20} /><strong>URMI OTP</strong></div>
|
||||
<div className="otp-map-branches">
|
||||
<div><strong>Verification</strong><span>Signup · KYC · Identity</span></div>
|
||||
<div><strong>Login</strong><span>MFA · 2FA · Passwordless</span></div>
|
||||
<div><strong>Recovery</strong><span>Password reset · Access recovery</span></div>
|
||||
<div><strong>Account changes</strong><span>Email · Phone · Security</span></div>
|
||||
<div><strong>Action authorization</strong><span>Sensitive operations</span></div>
|
||||
<div><strong>Transactions</strong><span>Payments · Transfers</span></div>
|
||||
<div><strong>Device verification</strong><span>Pairing · IoT · Activation</span></div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
export function UrmiOtpPage() {
|
||||
const navigate = useNavigate()
|
||||
const [projects, setProjects] = useState<UrmiOtpProject[]>([])
|
||||
const [modalOpen, setModalOpen] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
urmiOtpApi.listProjects()
|
||||
.then(setProjects)
|
||||
.catch(() => setError('Unable to load UrmiOTP projects'))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
if (loading) return <div className="auth-loading">Loading UrmiOTP…</div>
|
||||
|
||||
return (
|
||||
<div className="urmiotp-page">
|
||||
<div className="account-heading otp-page-heading">
|
||||
<div>
|
||||
<span className="eyebrow">Agent · Unified OTP</span>
|
||||
<h1>UrmiOTP</h1>
|
||||
<p>One verification platform and a small set of generic templates for every app and OTP journey.</p>
|
||||
</div>
|
||||
<button className="primary-button" onClick={() => setModalOpen(true)}><Plus size={15} /> Add a project</button>
|
||||
</div>
|
||||
|
||||
<OtpMap />
|
||||
|
||||
<section className="panel otp-projects-panel">
|
||||
<div className="panel-heading">
|
||||
<div><h2>Registered projects</h2><p>Each app must be approved before its API documentation becomes available.</p></div>
|
||||
<Smartphone size={19} />
|
||||
</div>
|
||||
{projects.length === 0 ? (
|
||||
<div className="otp-empty">
|
||||
<ShieldCheck size={24} />
|
||||
<strong>No projects registered</strong>
|
||||
<span>Add your web or mobile app to begin compliance review.</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="otp-project-table">
|
||||
<div className="otp-project-table-head">
|
||||
<span>Project</span><span>Application</span><span>Status</span><span />
|
||||
</div>
|
||||
{projects.map((project) => (
|
||||
<div className="otp-project-row" key={project.id}>
|
||||
<div>
|
||||
<strong>{project.project_name}</strong>
|
||||
<small>Label: {project.message_label}</small>
|
||||
<small>{project.purpose}</small>
|
||||
</div>
|
||||
<a href={project.app_url} target="_blank" rel="noreferrer">
|
||||
{project.app_type}<ExternalLink size={12} />
|
||||
</a>
|
||||
<div>
|
||||
<span className={`doc-status ${project.status}`}>{project.status}</span>
|
||||
{project.review_note && <small>{project.review_note}</small>}
|
||||
</div>
|
||||
{project.status === 'approved' ? (
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => navigate(`/console/agent/urmiotp/projects/${project.id}/docs`)}
|
||||
>
|
||||
API documentation <ArrowRight size={14} />
|
||||
</button>
|
||||
) : project.status === 'pending' ? (
|
||||
<span className="otp-awaiting"><CheckCircle2 size={13} /> Awaiting admin review</span>
|
||||
) : (
|
||||
<button className="secondary-button" onClick={() => setModalOpen(true)}>Register another</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="auth-error">{error}</p>}
|
||||
</section>
|
||||
|
||||
{modalOpen && (
|
||||
<ProjectModal
|
||||
onClose={() => setModalOpen(false)}
|
||||
onCreated={(project) => setProjects((current) => [project, ...current])}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue