CI deploy 895c0ed
Browse files- build_info.json +1 -1
- web/.gitignore +1 -0
- web/e2e/agentic-ux.spec.ts +203 -0
- web/e2e/biometrics.spec.ts +4 -3
- web/package.json +3 -2
- web/playwright.ux.config.ts +26 -0
- web/src/App.tsx +50 -10
- web/src/activity/store.ts +50 -0
- web/src/api/ai.ts +78 -0
- web/src/components/AgentTrace.test.tsx +47 -0
- web/src/components/AgentTrace.tsx +64 -0
- web/src/components/ShadowBadge.tsx +25 -0
- web/src/i18n/ar.ts +137 -0
- web/src/i18n/en.ts +137 -0
- web/src/pages/ActivityPage.test.tsx +39 -0
- web/src/pages/ActivityPage.tsx +79 -0
- web/src/pages/AgenticDemoPage.disabled.test.tsx +21 -0
- web/src/pages/AgenticDemoPage.test.tsx +85 -0
- web/src/pages/AgenticDemoPage.tsx +166 -0
- web/src/pages/BiometricsLabPage.test.tsx +22 -0
- web/src/pages/BiometricsLabPage.tsx +29 -0
- web/src/pages/HomePage.test.tsx +38 -0
- web/src/pages/HomePage.tsx +58 -0
- web/src/pages/PayPage.test.tsx +63 -58
- web/src/pages/PayPage.tsx +171 -135
- web/src/pages/SecurityPage.test.tsx +42 -0
- web/src/pages/SecurityPage.tsx +68 -0
- web/src/styles.css +64 -0
- web/src/utils/format.test.ts +52 -0
- web/src/utils/format.ts +34 -0
build_info.json
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
{"commit":"
|
|
|
|
| 1 |
+
{"commit":"895c0ed","build_time":"2026-07-13T14:35:49Z","frontend":"1.0.0"}
|
web/.gitignore
CHANGED
|
@@ -3,3 +3,4 @@ dist
|
|
| 3 |
test-results
|
| 4 |
playwright-report
|
| 5 |
.vite
|
|
|
|
|
|
| 3 |
test-results
|
| 4 |
playwright-report
|
| 5 |
.vite
|
| 6 |
+
e2e/screenshots
|
web/e2e/agentic-ux.spec.ts
ADDED
|
@@ -0,0 +1,203 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { test, expect, type Page } from '@playwright/test'
|
| 2 |
+
|
| 3 |
+
const json = (body: unknown) => ({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
|
| 4 |
+
|
| 5 |
+
function orchestrateResult(req: Record<string, unknown>) {
|
| 6 |
+
const amount = Number(req.amount_minor ?? 0)
|
| 7 |
+
const country = String(req.country ?? 'SA')
|
| 8 |
+
const known = (req.known_payees as string[] | undefined) ?? []
|
| 9 |
+
const history = Number(req.history_payments ?? 20)
|
| 10 |
+
const base = {
|
| 11 |
+
reason_codes: ['risk_frictionless'], required_auth: ['webauthn', 'oob'],
|
| 12 |
+
auth_recommendation: { recommended_method: 'webauthn', meets_pdp_minimum: true },
|
| 13 |
+
rails: { ranked: ['sarie'], eligible_only: true },
|
| 14 |
+
audit: { verified: true, length: 6, protection_level: 'signed-checkpoint (demo, in-memory)' },
|
| 15 |
+
labels: { note: 'DEMO ONLY · Mock provider · No real money' }, fallback: null,
|
| 16 |
+
}
|
| 17 |
+
const risk = (band: string, codes: string[]) => ({ band, recommend_step_up: band !== 'low', reason_codes: codes, confidence: 'ok', model_version: 'v1', feature_version: 'features-v1', shadow: true })
|
| 18 |
+
const explain = (msgs: string[], codes: string[]) => ({ locale: req.locale ?? 'en', messages: msgs, reason_codes: codes })
|
| 19 |
+
if (amount >= 99_999_999) return { ...base, decision: 'deny', agent_state: 'policy_evaluated', risk: risk('high', ['POLICY_LIMIT_EXCEEDED']), explanation: explain(['This payment exceeds an allowed limit.'], ['POLICY_LIMIT_EXCEEDED']), payment: null }
|
| 20 |
+
if (country === 'AE') return { ...base, decision: 'deny', agent_state: 'policy_evaluated', risk: risk('model_unavailable', ['MODEL_UNAVAILABLE']), explanation: explain([], []), payment: null }
|
| 21 |
+
if (history === 0) return { ...base, decision: 'step_up', agent_state: 'policy_evaluated', risk: risk('model_unavailable', ['INSUFFICIENT_HISTORY']), explanation: explain(['There is not enough history to personalize this check.'], ['INSUFFICIENT_HISTORY']), payment: null }
|
| 22 |
+
if (known.length === 0) return { ...base, decision: 'allow', agent_state: 'initiated', risk: risk('elevated', ['NEW_PAYEE']), explanation: explain(['This payment is to a payee you have not paid before.'], ['NEW_PAYEE']), payment: { id: 'pay_demo1', status: 'processing' } }
|
| 23 |
+
return { ...base, decision: 'allow', agent_state: 'initiated', risk: risk('low', []), explanation: explain([], []), payment: { id: 'pay_demo2', status: 'processing' } }
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
async function mockApi(page: Page) {
|
| 27 |
+
let settled = false
|
| 28 |
+
// settled uses a REALISTIC deterministic timestamp (2023-11-14) so normal date rendering is tested.
|
| 29 |
+
const pay = (over: Record<string, unknown> = {}) => ({ id: 'pay_e2e', user_id: 'demo', status: settled ? 'succeeded' : 'requires_customer_action', amount_minor: 15050, currency: 'SAR', country: 'SA', merchant_id: 'Blue Bottle', provider: 'mock', provider_ref: 'mp', correlation_id: 'c', consent_id: 'c', refunded_minor: 0, requires_action_url: null, locked: true, created_at: 1_699_990_000, updated_at: settled ? 1_700_000_000 : 1_699_990_000, ...over })
|
| 30 |
+
await page.route('**/version', (r) => r.fulfill(json({ app_version: '1.0.0', commit: 'test', build_time: null, frontend: '1.0.0', backend: '1.0.0', ui_mode: 'react', provider_mode: 'mock', non_custodial: true })))
|
| 31 |
+
await page.route('**/payments/providers', (r) => r.fulfill(json({ default: { country: 'SA', currency: 'SAR' }, non_custodial: true, routing: { SA: { provider: 'mock', capabilities: { name: 'mock', supports_cancel: true, supports_refund: true, supports_partial_refund: true, countries: ['*'], currencies: ['SAR'], payment_initiation: true, sandbox: true } } } })))
|
| 32 |
+
await page.route('**/demo/seed', (r) => r.fulfill(json({ success: true, user_id: 'demo', modalities: ['face'], token: 'tok', samples: { face_image: 'Zg==' } })))
|
| 33 |
+
await page.route('**/payments', (r) => r.request().method() === 'POST' ? r.fulfill(json(pay())) : r.continue())
|
| 34 |
+
await page.route('**/payments/pay_e2e/mock-advance', (r) => { settled = true; return r.fulfill(json(pay())) })
|
| 35 |
+
await page.route('**/payments/pay_e2e', (r) => r.fulfill(json(pay())))
|
| 36 |
+
await page.route('**/ai/v1/models/status', (r) => r.fulfill(json({ behavioural_model: 'logreg', model_version: 'v1', feature_version: 'features-v1', reason_codes_version: 'reason_codes-v1', shadow_only: true, affects_payment: false, label: 'DEMO ONLY · Mock provider · No real-money payment' })))
|
| 37 |
+
await page.route('**/ai/v1/demo/payee', (r) => r.fulfill(json({ payee_ref: 'payee_demo', display: 'SA03 **** 7519' })))
|
| 38 |
+
await page.route('**/ai/v1/demo/orchestrate', (r) => r.fulfill(json(orchestrateResult(r.request().postDataJSON() ?? {}))))
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
test.beforeEach(async ({ page }) => { await mockApi(page) })
|
| 42 |
+
|
| 43 |
+
const shot = (page: Page, name: string, project: string) =>
|
| 44 |
+
page.screenshot({ path: `e2e/screenshots/${name}-${project}.png`, fullPage: true })
|
| 45 |
+
|
| 46 |
+
// Change route WITHOUT a document reload (in-memory session must survive).
|
| 47 |
+
const goHash = (page: Page, h: string) => page.evaluate((x) => { window.location.hash = x }, h)
|
| 48 |
+
|
| 49 |
+
async function signIn(page: Page) {
|
| 50 |
+
await page.goto('/#/enroll')
|
| 51 |
+
await page.getByRole('button', { name: 'Load demo identity' }).click()
|
| 52 |
+
await expect(page.getByText(/Signed in as/i)).toBeVisible() // enroll success (header says "Signed in:")
|
| 53 |
+
}
|
| 54 |
+
async function walkToReview(page: Page) {
|
| 55 |
+
await goHash(page, '#/pay')
|
| 56 |
+
await page.getByTestId('stage-payee').waitFor()
|
| 57 |
+
await page.getByRole('button', { name: 'Next' }).click() // -> amount
|
| 58 |
+
await page.getByRole('button', { name: 'Next' }).click() // -> rail
|
| 59 |
+
await expect(page.getByRole('button', { name: 'Next' })).toBeEnabled()
|
| 60 |
+
await page.getByRole('button', { name: 'Next' }).click() // -> review
|
| 61 |
+
await expect(page.getByTestId('stage-review')).toBeVisible()
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
test('1-3. default Home + working desktop/mobile navigation', async ({ page }, info) => {
|
| 65 |
+
await page.goto('/')
|
| 66 |
+
await expect(page.getByRole('heading', { name: 'Welcome to AmanPay' })).toBeVisible()
|
| 67 |
+
await shot(page, 'home', info.project.name)
|
| 68 |
+
const navSel = info.project.name === 'mobile' ? '.bottom-nav' : '.desktop-nav'
|
| 69 |
+
await expect(page.locator(navSel)).toBeVisible()
|
| 70 |
+
await page.locator(`${navSel} button`, { hasText: 'Security' }).click()
|
| 71 |
+
await expect(page.getByRole('heading', { name: 'Security' })).toBeVisible()
|
| 72 |
+
})
|
| 73 |
+
|
| 74 |
+
test('4. Security page explains device authentication', async ({ page }, info) => {
|
| 75 |
+
await page.goto('/#/security')
|
| 76 |
+
await expect(page.getByText(/stays on your device/i)).toBeVisible()
|
| 77 |
+
await expect(page.getByText('Shadow mode').first()).toBeVisible()
|
| 78 |
+
await shot(page, 'security', info.project.name)
|
| 79 |
+
})
|
| 80 |
+
|
| 81 |
+
test('5-6. no fingerprint upload in customer flow; Lab has the disclaimer', async ({ page }, info) => {
|
| 82 |
+
await page.goto('/#/pay')
|
| 83 |
+
await expect(page.locator('input[type="file"]')).toHaveCount(0)
|
| 84 |
+
await page.goto('/#/biometrics/lab')
|
| 85 |
+
await expect(page.getByText(/A website cannot retrieve the fingerprint stored in your phone or computer sensor/i)).toBeVisible()
|
| 86 |
+
await shot(page, 'biometrics-lab', info.project.name)
|
| 87 |
+
})
|
| 88 |
+
|
| 89 |
+
test('7-11. payment journey completes stages -> review -> processing -> receipt', async ({ page }, info) => {
|
| 90 |
+
await signIn(page)
|
| 91 |
+
await goHash(page, '#/pay')
|
| 92 |
+
await expect(page.getByTestId('stage-payee')).toBeVisible()
|
| 93 |
+
await shot(page, 'payment-payee', info.project.name)
|
| 94 |
+
await walkToReview(page)
|
| 95 |
+
await expect(page.getByText(/Confirm with device biometrics/i)).toBeVisible()
|
| 96 |
+
await expect(page.getByText(/SA03 \*\*\*\* \*\*\*\* 7519/)).toBeVisible() // masked, not full IBAN
|
| 97 |
+
await shot(page, 'payment-review', info.project.name)
|
| 98 |
+
await page.getByRole('button', { name: 'Next' }).click() // review -> auth
|
| 99 |
+
await page.getByRole('button', { name: 'Confirm with device biometrics' }).click()
|
| 100 |
+
await expect(page.getByText(/Awaiting your bank|Processing/i).first()).toBeVisible()
|
| 101 |
+
await shot(page, 'payment-processing', info.project.name)
|
| 102 |
+
await page.getByRole('button', { name: /Simulate bank approval/i }).click()
|
| 103 |
+
await expect(page.getByTestId('receipt')).toBeVisible()
|
| 104 |
+
await expect(page.getByTestId('success')).toBeVisible()
|
| 105 |
+
// receipt date must be real — never 1970 or Invalid Date
|
| 106 |
+
const receiptText = (await page.getByTestId('receipt').textContent()) ?? ''
|
| 107 |
+
expect(receiptText).not.toMatch(/1970|Invalid Date/)
|
| 108 |
+
expect(receiptText).toMatch(/2023/)
|
| 109 |
+
await shot(page, 'payment-receipt', info.project.name)
|
| 110 |
+
})
|
| 111 |
+
|
| 112 |
+
test('11b. Arabic receipt renders a real date (no 1970 / Invalid Date)', async ({ page }, info) => {
|
| 113 |
+
await signIn(page)
|
| 114 |
+
await page.getByRole('button', { name: 'العربية' }).click()
|
| 115 |
+
await goHash(page, '#/pay')
|
| 116 |
+
await page.getByRole('button', { name: 'التالي' }).click()
|
| 117 |
+
await page.getByRole('button', { name: 'التالي' }).click()
|
| 118 |
+
await expect(page.getByRole('button', { name: 'التالي' })).toBeEnabled()
|
| 119 |
+
await page.getByRole('button', { name: 'التالي' }).click() // review
|
| 120 |
+
await page.getByRole('button', { name: 'التالي' }).click() // auth
|
| 121 |
+
await page.getByRole('button', { name: 'التأكيد بالقياسات الحيوية للجهاز' }).click()
|
| 122 |
+
await page.getByRole('button', { name: /موافقة البنك|Simulate bank approval/i }).click()
|
| 123 |
+
await expect(page.getByTestId('receipt')).toBeVisible()
|
| 124 |
+
const ar = (await page.getByTestId('receipt').textContent()) ?? ''
|
| 125 |
+
expect(ar).not.toMatch(/1970|Invalid Date/)
|
| 126 |
+
await shot(page, 'payment-receipt-arabic', info.project.name)
|
| 127 |
+
})
|
| 128 |
+
|
| 129 |
+
test('9. editing after review returns to the edit stage', async ({ page }) => {
|
| 130 |
+
await signIn(page)
|
| 131 |
+
await walkToReview(page)
|
| 132 |
+
await page.getByRole('button', { name: /Edit · Amount/i }).click()
|
| 133 |
+
await expect(page.getByTestId('stage-amount')).toBeVisible()
|
| 134 |
+
})
|
| 135 |
+
|
| 136 |
+
test('12. Activity lists the session payment event', async ({ page }, info) => {
|
| 137 |
+
await signIn(page)
|
| 138 |
+
await walkToReview(page)
|
| 139 |
+
await page.getByRole('button', { name: 'Next' }).click()
|
| 140 |
+
await page.getByRole('button', { name: 'Confirm with device biometrics' }).click()
|
| 141 |
+
await page.getByRole('button', { name: /Simulate bank approval/i }).click()
|
| 142 |
+
await expect(page.getByTestId('receipt')).toBeVisible()
|
| 143 |
+
await page.goto('/#/activity')
|
| 144 |
+
await expect(page.getByText('Activity from this session')).toBeVisible()
|
| 145 |
+
await expect(page.getByTestId('activity-item').first()).toBeVisible()
|
| 146 |
+
await shot(page, 'activity', info.project.name)
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
test('13-14. agentic demo normal + new-payee scenarios', async ({ page }, info) => {
|
| 150 |
+
await page.goto('/#/demo/agentic-security')
|
| 151 |
+
await expect(page.getByText('Shadow mode').first()).toBeVisible()
|
| 152 |
+
await page.getByTestId('scenario-normal').click()
|
| 153 |
+
await expect(page.getByTestId('agentic-result')).toBeVisible()
|
| 154 |
+
await shot(page, 'agentic-demo', info.project.name)
|
| 155 |
+
await page.getByTestId('scenario-newPayee').click()
|
| 156 |
+
await expect(page.getByText(/have not paid before/i)).toBeVisible()
|
| 157 |
+
await expect(page.getByTestId('risk-band')).toHaveText('elevated')
|
| 158 |
+
})
|
| 159 |
+
|
| 160 |
+
test('15-16. provider-unavailable -> deterministic deny (shadow cannot override)', async ({ page }, info) => {
|
| 161 |
+
await page.goto('/#/demo/agentic-security')
|
| 162 |
+
await page.getByTestId('scenario-providerUnavailable').click()
|
| 163 |
+
await expect(page.getByTestId('policy-decision')).toHaveText('deny')
|
| 164 |
+
await shot(page, 'agentic-deny', info.project.name)
|
| 165 |
+
})
|
| 166 |
+
|
| 167 |
+
test('17-18. dev panel hidden by default; no secret/capability/IBAN text', async ({ page }) => {
|
| 168 |
+
await page.goto('/#/demo/agentic-security')
|
| 169 |
+
await page.getByTestId('scenario-normal').click()
|
| 170 |
+
await expect(page.getByTestId('agentic-result')).toBeVisible()
|
| 171 |
+
await expect(page.locator('details.dev-panel')).toHaveJSProperty('open', false)
|
| 172 |
+
const body = (await page.locator('body').textContent()) ?? ''
|
| 173 |
+
expect(body).not.toMatch(/CapabilityGrant|ExecutionCapability|BEGIN [A-Z ]*PRIVATE KEY|608010167519/)
|
| 174 |
+
})
|
| 175 |
+
|
| 176 |
+
test('19-20. Arabic RTL for Home + Security', async ({ page }, info) => {
|
| 177 |
+
await page.goto('/#/home')
|
| 178 |
+
await page.getByRole('button', { name: 'العربية' }).click()
|
| 179 |
+
await expect(page.locator('html')).toHaveAttribute('dir', 'rtl')
|
| 180 |
+
await expect(page.getByText('مرحبًا بك في أمانباي')).toBeVisible()
|
| 181 |
+
await shot(page, 'arabic-home', info.project.name)
|
| 182 |
+
await page.goto('/#/security')
|
| 183 |
+
await expect(page.getByText('وضع الظل')).toBeVisible()
|
| 184 |
+
await shot(page, 'arabic-security', info.project.name)
|
| 185 |
+
})
|
| 186 |
+
|
| 187 |
+
test('21. unknown route shows a safe not-found screen', async ({ page }) => {
|
| 188 |
+
await page.goto('/#/does-not-exist')
|
| 189 |
+
await expect(page.getByText(/Page not found/i)).toBeVisible()
|
| 190 |
+
})
|
| 191 |
+
|
| 192 |
+
test('22. Arabic payment review renders', async ({ page }, info) => {
|
| 193 |
+
await signIn(page)
|
| 194 |
+
await page.goto('/#/home')
|
| 195 |
+
await page.getByRole('button', { name: 'العربية' }).click()
|
| 196 |
+
await goHash(page, '#/pay')
|
| 197 |
+
await page.getByRole('button', { name: 'التالي' }).click()
|
| 198 |
+
await page.getByRole('button', { name: 'التالي' }).click()
|
| 199 |
+
await expect(page.getByRole('button', { name: 'التالي' })).toBeEnabled()
|
| 200 |
+
await page.getByRole('button', { name: 'التالي' }).click()
|
| 201 |
+
await expect(page.getByTestId('stage-review')).toBeVisible()
|
| 202 |
+
await shot(page, 'arabic-payment-review', info.project.name)
|
| 203 |
+
})
|
web/e2e/biometrics.spec.ts
CHANGED
|
@@ -33,9 +33,10 @@ async function mockApi(page: Page) {
|
|
| 33 |
|
| 34 |
test.beforeEach(async ({ page }) => { await mockApi(page) })
|
| 35 |
|
| 36 |
-
test('biometrics dashboard
|
| 37 |
-
|
| 38 |
-
|
|
|
|
| 39 |
await expect(page.getByRole('heading', { name: 'Biometric demo' })).toBeVisible()
|
| 40 |
await expect(page.getByText('Available').first()).toBeVisible()
|
| 41 |
})
|
|
|
|
| 33 |
|
| 34 |
test.beforeEach(async ({ page }) => { await mockApi(page) })
|
| 35 |
|
| 36 |
+
test('biometrics dashboard route lists modalities', async ({ page }) => {
|
| 37 |
+
// PR C2.5 IA: the customer nav no longer has a top-level "Biometrics" tab (research demos
|
| 38 |
+
// live under "Biometrics lab" / the mobile "More" menu). The dashboard route still exists.
|
| 39 |
+
await page.goto('/#/biometrics')
|
| 40 |
await expect(page.getByRole('heading', { name: 'Biometric demo' })).toBeVisible()
|
| 41 |
await expect(page.getByText('Available').first()).toBeVisible()
|
| 42 |
})
|
web/package.json
CHANGED
|
@@ -12,7 +12,8 @@
|
|
| 12 |
"test": "vitest run",
|
| 13 |
"test:watch": "vitest",
|
| 14 |
"e2e": "playwright test",
|
| 15 |
-
"e2e:bio": "playwright test --config playwright.bio.config.ts"
|
|
|
|
| 16 |
},
|
| 17 |
"dependencies": {
|
| 18 |
"react": "^18.3.1",
|
|
@@ -36,4 +37,4 @@
|
|
| 36 |
"vitest": "^4.1.10",
|
| 37 |
"@playwright/test": "^1.47.0"
|
| 38 |
}
|
| 39 |
-
}
|
|
|
|
| 12 |
"test": "vitest run",
|
| 13 |
"test:watch": "vitest",
|
| 14 |
"e2e": "playwright test",
|
| 15 |
+
"e2e:bio": "playwright test --config playwright.bio.config.ts",
|
| 16 |
+
"e2e:ux": "playwright test --config playwright.ux.config.ts"
|
| 17 |
},
|
| 18 |
"dependencies": {
|
| 19 |
"react": "^18.3.1",
|
|
|
|
| 37 |
"vitest": "^4.1.10",
|
| 38 |
"@playwright/test": "^1.47.0"
|
| 39 |
}
|
| 40 |
+
}
|
web/playwright.ux.config.ts
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig, devices } from '@playwright/test'
|
| 2 |
+
|
| 3 |
+
// Agentic Security UX E2E: serves the built app via `vite preview` and mocks all backend
|
| 4 |
+
// calls in-spec (no real backend). Desktop + mobile (Pixel 5).
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
testDir: './e2e',
|
| 7 |
+
testMatch: /agentic-ux\.spec\.ts/,
|
| 8 |
+
timeout: 30_000,
|
| 9 |
+
reporter: [['list'], ['html', { outputFolder: 'playwright-report', open: 'never' }]],
|
| 10 |
+
use: {
|
| 11 |
+
baseURL: process.env.E2E_BASE_URL ?? 'http://localhost:4174',
|
| 12 |
+
trace: 'retain-on-failure',
|
| 13 |
+
screenshot: 'only-on-failure',
|
| 14 |
+
video: 'retain-on-failure',
|
| 15 |
+
},
|
| 16 |
+
webServer: process.env.E2E_BASE_URL ? undefined : {
|
| 17 |
+
command: 'npm run preview -- --port 4174 --strictPort',
|
| 18 |
+
url: 'http://localhost:4174',
|
| 19 |
+
reuseExistingServer: !process.env.CI,
|
| 20 |
+
timeout: 60_000,
|
| 21 |
+
},
|
| 22 |
+
projects: [
|
| 23 |
+
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
| 24 |
+
{ name: 'mobile', use: { ...devices['Pixel 5'] } },
|
| 25 |
+
],
|
| 26 |
+
})
|
web/src/App.tsx
CHANGED
|
@@ -2,6 +2,11 @@ import { useEffect, useState } from 'react'
|
|
| 2 |
import { EnrollPage } from './pages/EnrollPage'
|
| 3 |
import { PayPage } from './pages/PayPage'
|
| 4 |
import { NotFoundPage } from './pages/NotFoundPage'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
import { BiometricsDashboard } from './pages/BiometricsDashboard'
|
| 6 |
import { BiometricEnrollPage } from './pages/BiometricEnrollPage'
|
| 7 |
import { BiometricVerifyPage } from './pages/BiometricVerifyPage'
|
|
@@ -13,16 +18,18 @@ import { AppFooter } from './components/AppFooter'
|
|
| 13 |
import { useSession } from './hooks/useSession'
|
| 14 |
import { useI18n } from './i18n'
|
| 15 |
import { Button } from './components/ui'
|
|
|
|
| 16 |
|
| 17 |
const ROUTES = [
|
| 18 |
-
'pay', '
|
|
|
|
| 19 |
'biometrics/liveness', 'biometrics/reportcard', 'biometrics/oob', 'results', 'notfound',
|
| 20 |
] as const
|
| 21 |
type Route = (typeof ROUTES)[number]
|
| 22 |
|
| 23 |
function parse(): Route {
|
| 24 |
const raw = window.location.hash.replace(/^#\/?/, '')
|
| 25 |
-
if (raw === '' || raw === '
|
| 26 |
return (ROUTES as readonly string[]).includes(raw) ? (raw as Route) : 'notfound'
|
| 27 |
}
|
| 28 |
|
|
@@ -37,12 +44,22 @@ function useHashRoute(): [Route, (r: string) => void] {
|
|
| 37 |
return [route, nav]
|
| 38 |
}
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
export function App() {
|
| 41 |
const { t, locale, setLocale } = useI18n()
|
| 42 |
const { activeUser } = useSession()
|
| 43 |
const [route, nav] = useHashRoute()
|
|
|
|
|
|
|
| 44 |
const tab = (r: string, label: string) => (
|
| 45 |
-
<button className={`tab ${
|
| 46 |
)
|
| 47 |
return (
|
| 48 |
<div className="app">
|
|
@@ -56,11 +73,10 @@ export function App() {
|
|
| 56 |
</svg>
|
| 57 |
<span>{t('app.title')}</span>
|
| 58 |
</div>
|
| 59 |
-
<nav aria-label="Primary">
|
| 60 |
-
{tab(
|
| 61 |
-
{tab('
|
| 62 |
-
{tab('biometrics', t('nav.
|
| 63 |
-
{tab('results', t('nav.results'))}
|
| 64 |
</nav>
|
| 65 |
<div className="topbar-right">
|
| 66 |
<span className="active-user">{activeUser ? t('header.activeUser', { user: activeUser.userId }) : t('header.noUser')}</span>
|
|
@@ -69,8 +85,12 @@ export function App() {
|
|
| 69 |
</header>
|
| 70 |
<p className="demo-banner" role="note">{t('demo.banner')}</p>
|
| 71 |
<main className="content">
|
| 72 |
-
<
|
| 73 |
-
{route === 'pay' && <PayPage onNavEnroll={() => nav('enroll')} />}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
{route === 'enroll' && <EnrollPage />}
|
| 75 |
{route === 'biometrics' && <BiometricsDashboard onNav={(r) => nav(r)} />}
|
| 76 |
{route === 'biometrics/enroll' && <BiometricEnrollPage />}
|
|
@@ -81,6 +101,26 @@ export function App() {
|
|
| 81 |
{route === 'results' && <ResultsPage />}
|
| 82 |
{route === 'notfound' && <NotFoundPage onNav={(r) => nav(r)} />}
|
| 83 |
</main>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
<AppFooter />
|
| 85 |
</div>
|
| 86 |
)
|
|
|
|
| 2 |
import { EnrollPage } from './pages/EnrollPage'
|
| 3 |
import { PayPage } from './pages/PayPage'
|
| 4 |
import { NotFoundPage } from './pages/NotFoundPage'
|
| 5 |
+
import { HomePage } from './pages/HomePage'
|
| 6 |
+
import { SecurityPage } from './pages/SecurityPage'
|
| 7 |
+
import { BiometricsLabPage } from './pages/BiometricsLabPage'
|
| 8 |
+
import { AgenticDemoPage } from './pages/AgenticDemoPage'
|
| 9 |
+
import { ActivityPage } from './pages/ActivityPage'
|
| 10 |
import { BiometricsDashboard } from './pages/BiometricsDashboard'
|
| 11 |
import { BiometricEnrollPage } from './pages/BiometricEnrollPage'
|
| 12 |
import { BiometricVerifyPage } from './pages/BiometricVerifyPage'
|
|
|
|
| 18 |
import { useSession } from './hooks/useSession'
|
| 19 |
import { useI18n } from './i18n'
|
| 20 |
import { Button } from './components/ui'
|
| 21 |
+
import { AGENTIC_DEMO_ENABLED } from './api/ai'
|
| 22 |
|
| 23 |
const ROUTES = [
|
| 24 |
+
'home', 'pay', 'security', 'activity', 'demo/agentic-security', 'biometrics/lab',
|
| 25 |
+
'enroll', 'biometrics', 'biometrics/enroll', 'biometrics/verify',
|
| 26 |
'biometrics/liveness', 'biometrics/reportcard', 'biometrics/oob', 'results', 'notfound',
|
| 27 |
] as const
|
| 28 |
type Route = (typeof ROUTES)[number]
|
| 29 |
|
| 30 |
function parse(): Route {
|
| 31 |
const raw = window.location.hash.replace(/^#\/?/, '')
|
| 32 |
+
if (raw === '' || raw === 'home') return 'home'
|
| 33 |
return (ROUTES as readonly string[]).includes(raw) ? (raw as Route) : 'notfound'
|
| 34 |
}
|
| 35 |
|
|
|
|
| 44 |
return [route, nav]
|
| 45 |
}
|
| 46 |
|
| 47 |
+
// Primary customer destinations (desktop nav + mobile bottom bar).
|
| 48 |
+
const PRIMARY: { route: string; key: string }[] = [
|
| 49 |
+
{ route: 'home', key: 'nav.home' },
|
| 50 |
+
{ route: 'pay', key: 'nav.pay' },
|
| 51 |
+
{ route: 'security', key: 'nav.security' },
|
| 52 |
+
{ route: 'activity', key: 'nav.activity' },
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
export function App() {
|
| 56 |
const { t, locale, setLocale } = useI18n()
|
| 57 |
const { activeUser } = useSession()
|
| 58 |
const [route, nav] = useHashRoute()
|
| 59 |
+
const [moreOpen, setMoreOpen] = useState(false)
|
| 60 |
+
const matches = (r: string) => route === r || (r === 'home' && route === 'home')
|
| 61 |
const tab = (r: string, label: string) => (
|
| 62 |
+
<button className={`tab ${matches(r) ? 'active' : ''}`} aria-current={matches(r)} onClick={() => nav(r)}>{label}</button>
|
| 63 |
)
|
| 64 |
return (
|
| 65 |
<div className="app">
|
|
|
|
| 73 |
</svg>
|
| 74 |
<span>{t('app.title')}</span>
|
| 75 |
</div>
|
| 76 |
+
<nav aria-label="Primary" className="desktop-nav">
|
| 77 |
+
{PRIMARY.map((p) => <span key={p.route}>{tab(p.route, t(p.key as never))}</span>)}
|
| 78 |
+
{AGENTIC_DEMO_ENABLED && tab('demo/agentic-security', t('nav.demo'))}
|
| 79 |
+
{tab('biometrics/lab', t('nav.lab'))}
|
|
|
|
| 80 |
</nav>
|
| 81 |
<div className="topbar-right">
|
| 82 |
<span className="active-user">{activeUser ? t('header.activeUser', { user: activeUser.userId }) : t('header.noUser')}</span>
|
|
|
|
| 85 |
</header>
|
| 86 |
<p className="demo-banner" role="note">{t('demo.banner')}</p>
|
| 87 |
<main className="content">
|
| 88 |
+
{route === 'home' && <HomePage onNav={(r) => nav(r)} />}
|
| 89 |
+
{route === 'pay' && <PayPage onNavEnroll={() => nav('enroll')} onNav={(r) => nav(r)} />}
|
| 90 |
+
{route === 'security' && <SecurityPage onNav={(r) => nav(r)} />}
|
| 91 |
+
{route === 'demo/agentic-security' && <AgenticDemoPage />}
|
| 92 |
+
{route === 'biometrics/lab' && <BiometricsLabPage onNav={(r) => nav(r)} />}
|
| 93 |
+
{route === 'activity' && <ActivityPage />}
|
| 94 |
{route === 'enroll' && <EnrollPage />}
|
| 95 |
{route === 'biometrics' && <BiometricsDashboard onNav={(r) => nav(r)} />}
|
| 96 |
{route === 'biometrics/enroll' && <BiometricEnrollPage />}
|
|
|
|
| 101 |
{route === 'results' && <ResultsPage />}
|
| 102 |
{route === 'notfound' && <NotFoundPage onNav={(r) => nav(r)} />}
|
| 103 |
</main>
|
| 104 |
+
{moreOpen && (
|
| 105 |
+
<div className="more-sheet" role="menu" aria-label={t('nav.more')}>
|
| 106 |
+
{AGENTIC_DEMO_ENABLED && (
|
| 107 |
+
<button role="menuitem" className="more-item" onClick={() => { setMoreOpen(false); nav('demo/agentic-security') }}>{t('nav.demo')}</button>
|
| 108 |
+
)}
|
| 109 |
+
<button role="menuitem" className="more-item" onClick={() => { setMoreOpen(false); nav('biometrics/lab') }}>{t('nav.lab')}</button>
|
| 110 |
+
</div>
|
| 111 |
+
)}
|
| 112 |
+
<nav className="bottom-nav" aria-label="Primary mobile">
|
| 113 |
+
{PRIMARY.map((p) => (
|
| 114 |
+
<button key={p.route} className={`bnav ${matches(p.route) ? 'active' : ''}`}
|
| 115 |
+
aria-current={matches(p.route)} onClick={() => { setMoreOpen(false); nav(p.route) }}>
|
| 116 |
+
<span className="bnav-label">{t(p.key as never)}</span>
|
| 117 |
+
</button>
|
| 118 |
+
))}
|
| 119 |
+
<button className={`bnav ${moreOpen ? 'active' : ''}`} aria-expanded={moreOpen}
|
| 120 |
+
aria-haspopup="menu" onClick={() => setMoreOpen((o) => !o)}>
|
| 121 |
+
<span className="bnav-label">{t('nav.more')}</span>
|
| 122 |
+
</button>
|
| 123 |
+
</nav>
|
| 124 |
<AppFooter />
|
| 125 |
</div>
|
| 126 |
)
|
web/src/activity/store.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Session-only activity store (PR C2.5). Honest: this is NOT persistent account history —
|
| 2 |
+
// it holds events created during the current browser session and is labelled as such in the UI.
|
| 3 |
+
// It never stores raw biometrics, full IBANs, capabilities, signatures or secrets.
|
| 4 |
+
|
| 5 |
+
export type ActivityKind = 'payment' | 'confirmation' | 'biometrics' | 'security-demo'
|
| 6 |
+
|
| 7 |
+
export interface ActivityEvent {
|
| 8 |
+
id: string
|
| 9 |
+
kind: ActivityKind
|
| 10 |
+
title: string
|
| 11 |
+
status: string
|
| 12 |
+
ts: number
|
| 13 |
+
amount?: string
|
| 14 |
+
reference?: string
|
| 15 |
+
reasonCodes?: string[]
|
| 16 |
+
auditVerified?: boolean
|
| 17 |
+
modelVersion?: string
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
let events: ActivityEvent[] = []
|
| 21 |
+
const subs = new Set<() => void>()
|
| 22 |
+
|
| 23 |
+
function rid(): string {
|
| 24 |
+
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
| 25 |
+
? crypto.randomUUID()
|
| 26 |
+
: `ev-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export function addActivity(e: Omit<ActivityEvent, 'id' | 'ts'> & { ts?: number }): ActivityEvent {
|
| 30 |
+
const ev: ActivityEvent = { id: rid(), ts: e.ts ?? Date.now(), ...e }
|
| 31 |
+
events = [ev, ...events].slice(0, 100)
|
| 32 |
+
subs.forEach((f) => f())
|
| 33 |
+
return ev
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
export function listActivity(kind?: ActivityKind): ActivityEvent[] {
|
| 37 |
+
const all = [...events].sort((a, b) => b.ts - a.ts)
|
| 38 |
+
return kind ? all.filter((e) => e.kind === kind) : all
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
export function subscribeActivity(fn: () => void): () => void {
|
| 42 |
+
subs.add(fn)
|
| 43 |
+
return () => subs.delete(fn)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
/** Test helper — reset the session store. */
|
| 47 |
+
export function _resetActivity(): void {
|
| 48 |
+
events = []
|
| 49 |
+
subs.forEach((f) => f())
|
| 50 |
+
}
|
web/src/api/ai.ts
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Typed client for the SAFE /ai/v1 surfaces (PR C2.5).
|
| 2 |
+
// Never accepts or exposes raw features, thresholds, capabilities, signatures, keys or full IBANs.
|
| 3 |
+
// The agentic demo is gated by a frontend flag AND authoritative backend gating.
|
| 4 |
+
import { apiRequest } from './client'
|
| 5 |
+
|
| 6 |
+
export const AGENTIC_DEMO_ENABLED =
|
| 7 |
+
(import.meta.env.VITE_AGENTIC_DEMO_ENABLED as string | undefined) !== 'false'
|
| 8 |
+
|
| 9 |
+
export interface ModelStatus {
|
| 10 |
+
behavioural_model: string
|
| 11 |
+
model_version: string
|
| 12 |
+
feature_version: string
|
| 13 |
+
reason_codes_version: string
|
| 14 |
+
shadow_only: boolean
|
| 15 |
+
affects_payment: boolean
|
| 16 |
+
label: string
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
export interface RiskView {
|
| 20 |
+
band: 'low' | 'uncertain' | 'elevated' | 'high' | 'model_unavailable'
|
| 21 |
+
recommend_step_up: boolean
|
| 22 |
+
reason_codes: string[]
|
| 23 |
+
confidence: string
|
| 24 |
+
model_version: string
|
| 25 |
+
feature_version: string
|
| 26 |
+
shadow: boolean
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export interface AuthRecommendation {
|
| 30 |
+
recommended_method: string
|
| 31 |
+
meets_pdp_minimum: boolean
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
export interface AuditSummary {
|
| 35 |
+
verified: boolean
|
| 36 |
+
length: number
|
| 37 |
+
protection_level: string
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// Deliberately omits capability/signature/key/approval fields — the UI types cannot carry them.
|
| 41 |
+
export interface OrchestrateResult {
|
| 42 |
+
decision: 'allow' | 'deny' | 'step_up'
|
| 43 |
+
reason_codes: string[]
|
| 44 |
+
required_auth: string[]
|
| 45 |
+
agent_state: string
|
| 46 |
+
risk: RiskView | null
|
| 47 |
+
auth_recommendation: AuthRecommendation | null
|
| 48 |
+
rails: { ranked: string[]; eligible_only: boolean } | null
|
| 49 |
+
explanation: { locale: string; messages: string[]; reason_codes: string[] } | null
|
| 50 |
+
payment: { id: string; status: string } | null
|
| 51 |
+
audit: AuditSummary | null
|
| 52 |
+
fallback: string | null
|
| 53 |
+
labels: Record<string, string>
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
export interface ScenarioInput {
|
| 57 |
+
payee_ref: string
|
| 58 |
+
amount_minor: number
|
| 59 |
+
known_payees?: string[]
|
| 60 |
+
history_payments?: number
|
| 61 |
+
recent_24h?: number
|
| 62 |
+
consent?: boolean
|
| 63 |
+
approve?: boolean
|
| 64 |
+
locale?: string
|
| 65 |
+
now?: number
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
export function modelStatus(signal?: AbortSignal): Promise<ModelStatus> {
|
| 69 |
+
return apiRequest<ModelStatus>('/ai/v1/models/status', { signal })
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
export function demoPayee(iban: string, signal?: AbortSignal): Promise<{ payee_ref: string; display: string }> {
|
| 73 |
+
return apiRequest('/ai/v1/demo/payee', { body: { iban }, signal })
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
export function orchestrate(input: ScenarioInput, signal?: AbortSignal): Promise<OrchestrateResult> {
|
| 77 |
+
return apiRequest<OrchestrateResult>('/ai/v1/demo/orchestrate', { body: input, signal })
|
| 78 |
+
}
|
web/src/components/AgentTrace.test.tsx
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'vitest'
|
| 2 |
+
import { render, screen } from '@testing-library/react'
|
| 3 |
+
import { I18nProvider } from '../i18n'
|
| 4 |
+
import { AgentTrace } from './AgentTrace'
|
| 5 |
+
import type { OrchestrateResult } from '../api/ai'
|
| 6 |
+
|
| 7 |
+
function make(over: Partial<OrchestrateResult>): OrchestrateResult {
|
| 8 |
+
return {
|
| 9 |
+
decision: 'step_up', reason_codes: [], required_auth: [], agent_state: 'policy_evaluated',
|
| 10 |
+
risk: { band: 'low', recommend_step_up: false, reason_codes: [], confidence: 'ok',
|
| 11 |
+
model_version: 'v1', feature_version: 'v1', shadow: true },
|
| 12 |
+
auth_recommendation: { recommended_method: 'webauthn', meets_pdp_minimum: true },
|
| 13 |
+
rails: { ranked: ['sarie'], eligible_only: true }, explanation: null, payment: null,
|
| 14 |
+
audit: { verified: true, length: 3, protection_level: 'x' }, fallback: null, labels: {},
|
| 15 |
+
...over,
|
| 16 |
+
}
|
| 17 |
+
}
|
| 18 |
+
|
| 19 |
+
const r = (res: OrchestrateResult) =>
|
| 20 |
+
render(<I18nProvider initial="en"><AgentTrace result={res} /></I18nProvider>)
|
| 21 |
+
|
| 22 |
+
describe('AgentTrace', () => {
|
| 23 |
+
it('marks deterministic policy authoritative and risk advisory', () => {
|
| 24 |
+
r(make({}))
|
| 25 |
+
expect(screen.getByText('Deterministic policy')).toBeInTheDocument()
|
| 26 |
+
expect(screen.getByText('Authoritative')).toBeInTheDocument()
|
| 27 |
+
expect(screen.getAllByText('Advisory only').length).toBeGreaterThan(0)
|
| 28 |
+
})
|
| 29 |
+
|
| 30 |
+
it('shows Payment Core NOT executed unless initiated', () => {
|
| 31 |
+
r(make({ agent_state: 'policy_evaluated' }))
|
| 32 |
+
expect(screen.getAllByText('Not executed').length).toBeGreaterThan(0)
|
| 33 |
+
})
|
| 34 |
+
|
| 35 |
+
it('shows Payment Core executed only when initiated', () => {
|
| 36 |
+
r(make({ agent_state: 'initiated', payment: { id: 'pay_1', status: 'processing' } }))
|
| 37 |
+
expect(screen.getByText('pay_1')).toBeInTheDocument()
|
| 38 |
+
expect(screen.getByText('Executed')).toBeInTheDocument()
|
| 39 |
+
})
|
| 40 |
+
|
| 41 |
+
it('never labels a reasoning agent as a payment executor', () => {
|
| 42 |
+
const { container } = r(make({}))
|
| 43 |
+
// the executor row is Payment Core; risk/context/auth/rail carry Advisory, never Executed
|
| 44 |
+
const advisorySteps = container.querySelectorAll('.role-advisory')
|
| 45 |
+
expect(advisorySteps.length).toBeGreaterThan(0)
|
| 46 |
+
})
|
| 47 |
+
})
|
web/src/components/AgentTrace.tsx
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useI18n } from '../i18n'
|
| 2 |
+
import type { OrchestrateResult } from '../api/ai'
|
| 3 |
+
|
| 4 |
+
type StepRole = 'advisory' | 'authoritative' | 'user' | 'notExecuted' | 'executed'
|
| 5 |
+
|
| 6 |
+
interface Step {
|
| 7 |
+
key: string
|
| 8 |
+
labelKey:
|
| 9 |
+
| 'trace.context' | 'trace.risk' | 'trace.policy' | 'trace.auth'
|
| 10 |
+
| 'trace.rail' | 'trace.userAction' | 'trace.paymentCore'
|
| 11 |
+
role: StepRole
|
| 12 |
+
summary: string
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
function roleBadge(role: StepRole): { key: string; tone: string } {
|
| 16 |
+
switch (role) {
|
| 17 |
+
case 'advisory': return { key: 'shadow.advisory', tone: 'tone-info' }
|
| 18 |
+
case 'authoritative': return { key: 'authoritative.badge', tone: 'tone-ok' }
|
| 19 |
+
case 'user': return { key: 'trace.userRequired', tone: 'tone-warn' }
|
| 20 |
+
case 'executed': return { key: 'trace.executed', tone: 'tone-ok' }
|
| 21 |
+
default: return { key: 'trace.notExecuted', tone: 'tone-bad' }
|
| 22 |
+
}
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
/** Safe, read-only sequence view. Never renders capabilities, signatures, keys or envelopes. */
|
| 26 |
+
export function AgentTrace({ result }: { result: OrchestrateResult }) {
|
| 27 |
+
const { t } = useI18n()
|
| 28 |
+
const executed = result.agent_state === 'initiated'
|
| 29 |
+
const steps: Step[] = [
|
| 30 |
+
{ key: 'context', labelKey: 'trace.context', role: 'advisory',
|
| 31 |
+
summary: result.risk ? '' : t('shadow.noChange') },
|
| 32 |
+
{ key: 'risk', labelKey: 'trace.risk', role: 'advisory',
|
| 33 |
+
summary: result.risk ? result.risk.band : 'model_unavailable' },
|
| 34 |
+
{ key: 'policy', labelKey: 'trace.policy', role: 'authoritative', summary: result.decision },
|
| 35 |
+
{ key: 'auth', labelKey: 'trace.auth', role: 'advisory',
|
| 36 |
+
summary: result.auth_recommendation?.recommended_method ?? '' },
|
| 37 |
+
{ key: 'rail', labelKey: 'trace.rail', role: 'advisory',
|
| 38 |
+
summary: (result.rails?.ranked ?? []).join(', ') },
|
| 39 |
+
{ key: 'user', labelKey: 'trace.userAction', role: 'user',
|
| 40 |
+
summary: result.decision === 'step_up' ? t('trace.userRequired') : '' },
|
| 41 |
+
{ key: 'core', labelKey: 'trace.paymentCore',
|
| 42 |
+
role: executed ? 'executed' : 'notExecuted',
|
| 43 |
+
summary: result.payment?.id ?? t('trace.notExecuted') },
|
| 44 |
+
]
|
| 45 |
+
return (
|
| 46 |
+
<div className="agent-trace">
|
| 47 |
+
<h3>{t('trace.title')}</h3>
|
| 48 |
+
<ol className="trace-list">
|
| 49 |
+
{steps.map((s) => {
|
| 50 |
+
const b = roleBadge(s.role)
|
| 51 |
+
return (
|
| 52 |
+
<li key={s.key} className={`trace-step role-${s.role}`}>
|
| 53 |
+
<div className="trace-head">
|
| 54 |
+
<span className="trace-label">{t(s.labelKey)}</span>
|
| 55 |
+
<span className={`badge ${b.tone}`}>{t(b.key as never)}</span>
|
| 56 |
+
</div>
|
| 57 |
+
{s.summary && <span className="trace-summary">{s.summary}</span>}
|
| 58 |
+
</li>
|
| 59 |
+
)
|
| 60 |
+
})}
|
| 61 |
+
</ol>
|
| 62 |
+
</div>
|
| 63 |
+
)
|
| 64 |
+
}
|
web/src/components/ShadowBadge.tsx
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useI18n } from '../i18n'
|
| 2 |
+
|
| 3 |
+
/** Shadow-mode honesty badges — shown wherever a C1/C2 result appears. */
|
| 4 |
+
export function ShadowBadges() {
|
| 5 |
+
const { t } = useI18n()
|
| 6 |
+
return (
|
| 7 |
+
<div className="shadow-badges" role="note">
|
| 8 |
+
<span className="badge tone-warn">{t('shadow.badge')}</span>
|
| 9 |
+
<span className="badge tone-info">{t('shadow.advisory')}</span>
|
| 10 |
+
<span className="badge tone-info">{t('shadow.noChange')}</span>
|
| 11 |
+
<span className="badge tone-info">{t('shadow.synthetic')}</span>
|
| 12 |
+
</div>
|
| 13 |
+
)
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
/** A single "Authoritative" marker for the deterministic policy result. */
|
| 17 |
+
export function AuthoritativeBadge() {
|
| 18 |
+
const { t } = useI18n()
|
| 19 |
+
return <span className="badge tone-ok">{t('authoritative.badge')}</span>
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export function ShadowFootnote() {
|
| 23 |
+
const { t } = useI18n()
|
| 24 |
+
return <p className="muted small">{t('shadow.notEvidence')}</p>
|
| 25 |
+
}
|
web/src/i18n/ar.ts
CHANGED
|
@@ -258,4 +258,141 @@ export const ar: Record<MessageKey, string> = {
|
|
| 258 |
'oob.status.rejected': 'مرفوض.',
|
| 259 |
'oob.status.expired': 'منتهي الصلاحية.',
|
| 260 |
'oob.alreadyFinalized': 'تم إنهاء هذا التأكيد مسبقًا — يتم عرض نتيجته الحالية.',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
}
|
|
|
|
| 258 |
'oob.status.rejected': 'مرفوض.',
|
| 259 |
'oob.status.expired': 'منتهي الصلاحية.',
|
| 260 |
'oob.alreadyFinalized': 'تم إنهاء هذا التأكيد مسبقًا — يتم عرض نتيجته الحالية.',
|
| 261 |
+
'nav.home': 'الرئيسية',
|
| 262 |
+
'nav.security': 'الأمان',
|
| 263 |
+
'nav.demo': 'تجربة الذكاء',
|
| 264 |
+
'nav.activity': 'النشاط',
|
| 265 |
+
'nav.lab': 'مختبر القياسات',
|
| 266 |
+
'home.title': 'الرئيسية',
|
| 267 |
+
'home.greeting': 'مرحبًا بك في أمانباي',
|
| 268 |
+
'home.security.protected': 'حسابك محمي',
|
| 269 |
+
'home.security.setup': 'قم بإعداد مصادقة الجهاز',
|
| 270 |
+
'home.security.pending': 'هناك تحقق إضافي معلّق',
|
| 271 |
+
'home.security.unavailable': 'فحص الأمان غير متاح — تبقى الضمانات القياسية فعّالة',
|
| 272 |
+
'home.security.passkey': 'مفتاح مرور الجهاز',
|
| 273 |
+
'home.security.oob': 'التأكيد خارج القناة',
|
| 274 |
+
'home.nextAction': 'الخطوة التالية الموصى بها',
|
| 275 |
+
'home.quick.title': 'إجراءات سريعة',
|
| 276 |
+
'home.quick.pay': 'ادفع',
|
| 277 |
+
'home.quick.activity': 'عرض النشاط',
|
| 278 |
+
'home.quick.security': 'إعدادات الأمان',
|
| 279 |
+
'home.quick.confirm': 'تأكيد الطلب المعلّق',
|
| 280 |
+
'home.recent.title': 'النشاط الأخير',
|
| 281 |
+
'home.recent.empty': 'لا يوجد نشاط حديث بعد.',
|
| 282 |
+
'home.demoBadge': 'تجريبي · مزود وهمي · لا مدفوعات حقيقية',
|
| 283 |
+
'security.title': 'الأمان',
|
| 284 |
+
'security.device.title': 'مصادقة الجهاز',
|
| 285 |
+
'security.device.setup': 'إعداد مصادقة الجهاز',
|
| 286 |
+
'security.device.signin': 'تسجيل الدخول بمفتاح المرور',
|
| 287 |
+
'security.device.explain': 'تبقى بصمتك أو وجهك أو رقم جهازك على جهازك. يستلم أمانباي تأكيدًا آمنًا فقط.',
|
| 288 |
+
'security.device.registered': 'تم إعداد مصادقة الجهاز على هذا الجهاز.',
|
| 289 |
+
'security.behaviour.title': 'الحماية السلوكية',
|
| 290 |
+
'security.behaviour.status': 'تقييم الظل مُفعّل',
|
| 291 |
+
'security.behaviour.noEffect': 'هذا النموذج التجريبي لا يوافق على المدفوعات أو يرفضها أو يعدّلها.',
|
| 292 |
+
'security.oob.title': 'التأكيد خارج القناة',
|
| 293 |
+
'security.oob.explain': 'قد تتطلب المدفوعات عالية الخطورة تأكيدًا منفصلًا عبر قناتك المفضلة.',
|
| 294 |
+
'security.activity.title': 'نشاط الأمان',
|
| 295 |
+
'shadow.badge': 'وضع الظل',
|
| 296 |
+
'shadow.advisory': 'استشاري فقط',
|
| 297 |
+
'shadow.noChange': 'لا يغيّر تفويض الدفع',
|
| 298 |
+
'shadow.synthetic': 'نموذج ببيانات اصطناعية',
|
| 299 |
+
'shadow.notEvidence': 'ليس دليلًا على أداء كشف الاحتيال الواقعي',
|
| 300 |
+
'authoritative.badge': 'مرجعي',
|
| 301 |
+
'bio.confirmDevice': 'التأكيد بالقياسات الحيوية للجهاز',
|
| 302 |
+
'lab.title': 'مختبر القياسات الحيوية',
|
| 303 |
+
'lab.subtitle': 'تجارب بحثية — ليست جزءًا من مسار الدفع العادي.',
|
| 304 |
+
'lab.fingerprint.title': 'تجربة صورة البصمة',
|
| 305 |
+
'lab.fingerprint.disclaimer': 'تقبل هذه التجربة البحثية صورة بصمة ضوئية مرفوعة أو عينة تجريبية. لا يمكن لأي موقع إلكتروني قراءة البصمة المخزّنة في مستشعر هاتفك أو حاسوبك.',
|
| 306 |
+
'agentic.title': 'تجربة الأمان الوكيلي',
|
| 307 |
+
'agentic.subtitle': 'يشغّل مسار الوكلاء الآمن على بيانات اصطناعية. السياسة الحتمية هي المرجع؛ نموذج الذكاء استشاري وفي وضع الظل.',
|
| 308 |
+
'agentic.disabled': 'تجربة الوكلاء معطّلة في هذه البيئة.',
|
| 309 |
+
'agentic.scenario.title': 'اختر سيناريو',
|
| 310 |
+
'agentic.scenario.normal': 'دفعة عادية',
|
| 311 |
+
'agentic.scenario.newPayee': 'مستفيد جديد',
|
| 312 |
+
'agentic.scenario.unusualAmount': 'مبلغ غير معتاد',
|
| 313 |
+
'agentic.scenario.highVelocity': 'وتيرة مرتفعة',
|
| 314 |
+
'agentic.scenario.providerUnavailable': 'المزود غير متاح',
|
| 315 |
+
'agentic.scenario.insufficientHistory': 'سجل غير كافٍ',
|
| 316 |
+
'agentic.run': 'شغّل التنسيق الآمن',
|
| 317 |
+
'agentic.running': 'جارٍ التشغيل…',
|
| 318 |
+
'agentic.result.title': 'النتيجة',
|
| 319 |
+
'agentic.result.policy': 'قرار السياسة الحتمية',
|
| 320 |
+
'agentic.result.band': 'نطاق خطورة الظل',
|
| 321 |
+
'agentic.result.reasons': 'الأسباب',
|
| 322 |
+
'agentic.result.auth': 'توصية المصادقة',
|
| 323 |
+
'agentic.result.rails': 'مسارات الدفع المؤهلة',
|
| 324 |
+
'agentic.result.explanation': 'التفسير',
|
| 325 |
+
'agentic.result.audit': 'سلسلة التدقيق',
|
| 326 |
+
'agentic.result.auditVerified': 'مُتحقّق',
|
| 327 |
+
'agentic.result.auditFailed': 'غير ��ُتحقّق',
|
| 328 |
+
'agentic.dev.show': 'عرض تفاصيل المطوّر',
|
| 329 |
+
'agentic.dev.hide': 'إخفاء تفاصيل المطوّر',
|
| 330 |
+
'agentic.dev.label': 'تجربة المطوّر — محاكاة ووضع ظل فقط',
|
| 331 |
+
'trace.title': 'تسلسل الوكلاء',
|
| 332 |
+
'trace.context': 'سياق المعاملة',
|
| 333 |
+
'trace.risk': 'خطورة الظل',
|
| 334 |
+
'trace.policy': 'السياسة الحتمية',
|
| 335 |
+
'trace.auth': 'توصية المصادقة',
|
| 336 |
+
'trace.rail': 'ترتيب المسارات',
|
| 337 |
+
'trace.userAction': 'تأكيد المستخدم',
|
| 338 |
+
'trace.paymentCore': 'نواة الدفع',
|
| 339 |
+
'trace.userRequired': 'مطلوب إجراء المستخدم',
|
| 340 |
+
'trace.notExecuted': 'لم يُنفَّذ',
|
| 341 |
+
'trace.executed': 'نُفِّذ',
|
| 342 |
+
'pay.verifyRequired': 'مطلوب تحقق إضافي.',
|
| 343 |
+
'pay.deviceConfirm': 'سيؤكد جهازك هويتك دون مشاركة بصمتك.',
|
| 344 |
+
'common.on': 'مفعّل',
|
| 345 |
+
'common.off': 'معطّل',
|
| 346 |
+
'common.notSet': 'غير مُعد',
|
| 347 |
+
'common.loading': 'جارٍ التحميل…',
|
| 348 |
+
'common.error': 'حدث خطأ ما.',
|
| 349 |
+
'common.back': 'رجوع',
|
| 350 |
+
'pay.stepOf': 'خطوة {n} من {total}',
|
| 351 |
+
'pay.next': 'التالي',
|
| 352 |
+
'pay.back': 'رجوع',
|
| 353 |
+
'pay.edit': 'تعديل',
|
| 354 |
+
'pay.confirm': 'تأكيد',
|
| 355 |
+
'pay.stage.payee': 'المستفيد',
|
| 356 |
+
'pay.stage.amount': 'المبلغ',
|
| 357 |
+
'pay.stage.rail': 'مسار الدفع',
|
| 358 |
+
'pay.stage.review': 'المراجعة',
|
| 359 |
+
'pay.stage.auth': 'المصادقة',
|
| 360 |
+
'pay.stage.processing': 'المعالجة',
|
| 361 |
+
'pay.stage.receipt': 'الإيصال',
|
| 362 |
+
'pay.newPayeeNote': 'هذا مستفيد جديد.',
|
| 363 |
+
'pay.destination': 'الوجهة',
|
| 364 |
+
'pay.rail.title': 'اختر مسار دفع مؤهلًا',
|
| 365 |
+
'pay.rail.available': 'متاح',
|
| 366 |
+
'pay.rail.unavailable': 'غير متاح',
|
| 367 |
+
'pay.rail.feeUnavailable': 'معلومات الرسوم غير متوفرة',
|
| 368 |
+
'pay.rail.speed': 'السرعة المتوقعة',
|
| 369 |
+
'pay.rail.instant': 'شبه فوري',
|
| 370 |
+
'pay.rail.none': 'لا يوجد مسار مؤهل متاح لهذه الدفعة.',
|
| 371 |
+
'pay.review.fee': 'الرسوم',
|
| 372 |
+
'pay.review.rail': 'المسار',
|
| 373 |
+
'pay.review.reference': 'المرجع',
|
| 374 |
+
'pay.review.authReq': 'المصادقة المطلوبة',
|
| 375 |
+
'pay.review.reason': 'السبب',
|
| 376 |
+
'pay.review.editNote': 'يتطلب تغيير أي حقل مراجعة جديدة.',
|
| 377 |
+
'pay.authTitle': 'أكّد هذه الدفعة',
|
| 378 |
+
'pay.processingTitle': 'جارٍ معالجة دفعتك',
|
| 379 |
+
'pay.receiptTitle': 'إيصال الدفع',
|
| 380 |
+
'pay.dateTime': 'التاريخ والوقت',
|
| 381 |
+
'pay.viewActivity': 'عرض في النشاط',
|
| 382 |
+
'pay.status.label': 'الحالة',
|
| 383 |
+
'activity.title': 'النشاط',
|
| 384 |
+
'activity.sessionOnly': 'النشاط من هذه الجلسة فقط',
|
| 385 |
+
'activity.sessionNote': 'هذه القائمة ليست سجلًا دائمًا للحساب.',
|
| 386 |
+
'activity.empty': 'لا يوجد نشاط في هذه الجلسة بعد.',
|
| 387 |
+
'activity.tab.all': 'الكل',
|
| 388 |
+
'activity.tab.payments': 'المدفوعات',
|
| 389 |
+
'activity.tab.confirmations': 'التأكيدات',
|
| 390 |
+
'activity.tab.biometrics': 'مختبر القياسات',
|
| 391 |
+
'activity.tab.security': 'تجارب الأمان',
|
| 392 |
+
'activity.kind.payment': 'دفعة',
|
| 393 |
+
'activity.kind.confirmation': 'تأكيد',
|
| 394 |
+
'activity.kind.biometrics': 'تجربة قياسات',
|
| 395 |
+
'activity.kind.securityDemo': 'تجربة أمان',
|
| 396 |
+
'nav.more': 'المزيد',
|
| 397 |
+
'date.unavailable': 'التاريخ غير متوفر',
|
| 398 |
}
|
web/src/i18n/en.ts
CHANGED
|
@@ -256,6 +256,143 @@ export const en = {
|
|
| 256 |
'oob.status.rejected': 'Rejected.',
|
| 257 |
'oob.status.expired': 'Expired.',
|
| 258 |
'oob.alreadyFinalized': 'This confirmation was already finalized — showing its current result.',
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 259 |
} as const
|
| 260 |
|
| 261 |
export type MessageKey = keyof typeof en
|
|
|
|
| 256 |
'oob.status.rejected': 'Rejected.',
|
| 257 |
'oob.status.expired': 'Expired.',
|
| 258 |
'oob.alreadyFinalized': 'This confirmation was already finalized — showing its current result.',
|
| 259 |
+
'nav.home': 'Home',
|
| 260 |
+
'nav.security': 'Security',
|
| 261 |
+
'nav.demo': 'AI demo',
|
| 262 |
+
'nav.activity': 'Activity',
|
| 263 |
+
'nav.lab': 'Biometrics lab',
|
| 264 |
+
'home.title': 'Home',
|
| 265 |
+
'home.greeting': 'Welcome to AmanPay',
|
| 266 |
+
'home.security.protected': 'Your account is protected',
|
| 267 |
+
'home.security.setup': 'Set up device authentication',
|
| 268 |
+
'home.security.pending': 'Additional verification is pending',
|
| 269 |
+
'home.security.unavailable': 'Security check unavailable — standard safeguards remain active',
|
| 270 |
+
'home.security.passkey': 'Device passkey',
|
| 271 |
+
'home.security.oob': 'Out-of-band confirmation',
|
| 272 |
+
'home.nextAction': 'Recommended next step',
|
| 273 |
+
'home.quick.title': 'Quick actions',
|
| 274 |
+
'home.quick.pay': 'Pay',
|
| 275 |
+
'home.quick.activity': 'View activity',
|
| 276 |
+
'home.quick.security': 'Security settings',
|
| 277 |
+
'home.quick.confirm': 'Confirm pending request',
|
| 278 |
+
'home.recent.title': 'Recent activity',
|
| 279 |
+
'home.recent.empty': 'No recent activity yet.',
|
| 280 |
+
'home.demoBadge': 'Demo · Mock provider · No real-money payment',
|
| 281 |
+
'security.title': 'Security',
|
| 282 |
+
'security.device.title': 'Device authentication',
|
| 283 |
+
'security.device.setup': 'Set up device authentication',
|
| 284 |
+
'security.device.signin': 'Sign in with passkey',
|
| 285 |
+
'security.device.explain': 'Your fingerprint, face or device PIN stays on your device. AmanPay receives only a secure confirmation.',
|
| 286 |
+
'security.device.registered': 'Device authentication is set up on this device.',
|
| 287 |
+
'security.behaviour.title': 'Behavioural protection',
|
| 288 |
+
'security.behaviour.status': 'Shadow evaluation active',
|
| 289 |
+
'security.behaviour.noEffect': 'This experimental model does not approve, deny or modify payments.',
|
| 290 |
+
'security.oob.title': 'Out-of-band confirmation',
|
| 291 |
+
'security.oob.explain': 'High-risk payments can require a separate confirmation on your preferred channel.',
|
| 292 |
+
'security.activity.title': 'Security activity',
|
| 293 |
+
'shadow.badge': 'Shadow mode',
|
| 294 |
+
'shadow.advisory': 'Advisory only',
|
| 295 |
+
'shadow.noChange': 'Does not change payment authorization',
|
| 296 |
+
'shadow.synthetic': 'Synthetic-data prototype',
|
| 297 |
+
'shadow.notEvidence': 'Not evidence of real-world fraud-detection performance',
|
| 298 |
+
'authoritative.badge': 'Authoritative',
|
| 299 |
+
'bio.confirmDevice': 'Confirm with device biometrics',
|
| 300 |
+
'lab.title': 'Biometrics lab',
|
| 301 |
+
'lab.subtitle': 'Research demos — not part of the normal payment flow.',
|
| 302 |
+
'lab.fingerprint.title': 'Fingerprint image demo',
|
| 303 |
+
'lab.fingerprint.disclaimer': 'This research demo accepts an uploaded optical fingerprint image or a demo sample. A website cannot retrieve the fingerprint stored in your phone or computer sensor.',
|
| 304 |
+
'agentic.title': 'Agentic security demo',
|
| 305 |
+
'agentic.subtitle': 'Runs the secured agent workflow on synthetic data. Deterministic policy is authoritative; the AI model is advisory and in shadow mode.',
|
| 306 |
+
'agentic.disabled': 'The agentic demo is disabled in this environment.',
|
| 307 |
+
'agentic.scenario.title': 'Choose a scenario',
|
| 308 |
+
'agentic.scenario.normal': 'Normal payment',
|
| 309 |
+
'agentic.scenario.newPayee': 'New payee',
|
| 310 |
+
'agentic.scenario.unusualAmount': 'Unusual amount',
|
| 311 |
+
'agentic.scenario.highVelocity': 'High velocity',
|
| 312 |
+
'agentic.scenario.providerUnavailable': 'Provider unavailable',
|
| 313 |
+
'agentic.scenario.insufficientHistory': 'Insufficient history',
|
| 314 |
+
'agentic.run': 'Run secure orchestration',
|
| 315 |
+
'agentic.running': 'Running…',
|
| 316 |
+
'agentic.result.title': 'Result',
|
| 317 |
+
'agentic.result.policy': 'Deterministic policy decision',
|
| 318 |
+
'agentic.result.band': 'Shadow risk band',
|
| 319 |
+
'agentic.result.reasons': 'Reasons',
|
| 320 |
+
'agentic.result.auth': 'Authentication recommendation',
|
| 321 |
+
'agentic.result.rails': 'Eligible payment rails',
|
| 322 |
+
'agentic.result.explanation': 'Explanation',
|
| 323 |
+
'agentic.result.audit': 'Audit chain',
|
| 324 |
+
'agentic.result.auditVerified': 'Verified',
|
| 325 |
+
'agentic.result.auditFailed': 'Not verified',
|
| 326 |
+
'agentic.dev.show': 'Show developer details',
|
| 327 |
+
'agentic.dev.hide': 'Hide developer details',
|
| 328 |
+
'agentic.dev.label': 'Developer demo — simulated and shadow-only',
|
| 329 |
+
'trace.title': 'Agent sequence',
|
| 330 |
+
'trace.context': 'Transaction context',
|
| 331 |
+
'trace.risk': 'Shadow risk',
|
| 332 |
+
'trace.policy': 'Deterministic policy',
|
| 333 |
+
'trace.auth': 'Authentication recommendation',
|
| 334 |
+
'trace.rail': 'Rail ranking',
|
| 335 |
+
'trace.userAction': 'User confirmation',
|
| 336 |
+
'trace.paymentCore': 'Payment Core',
|
| 337 |
+
'trace.userRequired': 'User action required',
|
| 338 |
+
'trace.notExecuted': 'Not executed',
|
| 339 |
+
'trace.executed': 'Executed',
|
| 340 |
+
'pay.verifyRequired': 'Additional verification is required.',
|
| 341 |
+
'pay.deviceConfirm': 'Your device will confirm your identity without sharing your fingerprint.',
|
| 342 |
+
'common.on': 'On',
|
| 343 |
+
'common.off': 'Off',
|
| 344 |
+
'common.notSet': 'Not set up',
|
| 345 |
+
'common.loading': 'Loading…',
|
| 346 |
+
'common.error': 'Something went wrong.',
|
| 347 |
+
'common.back': 'Back',
|
| 348 |
+
'pay.stepOf': 'Step {n} of {total}',
|
| 349 |
+
'pay.next': 'Next',
|
| 350 |
+
'pay.back': 'Back',
|
| 351 |
+
'pay.edit': 'Edit',
|
| 352 |
+
'pay.confirm': 'Confirm',
|
| 353 |
+
'pay.stage.payee': 'Payee',
|
| 354 |
+
'pay.stage.amount': 'Amount',
|
| 355 |
+
'pay.stage.rail': 'Payment rail',
|
| 356 |
+
'pay.stage.review': 'Review',
|
| 357 |
+
'pay.stage.auth': 'Authentication',
|
| 358 |
+
'pay.stage.processing': 'Processing',
|
| 359 |
+
'pay.stage.receipt': 'Receipt',
|
| 360 |
+
'pay.newPayeeNote': 'This is a new payee.',
|
| 361 |
+
'pay.destination': 'Destination',
|
| 362 |
+
'pay.rail.title': 'Choose an eligible payment rail',
|
| 363 |
+
'pay.rail.available': 'Available',
|
| 364 |
+
'pay.rail.unavailable': 'Unavailable',
|
| 365 |
+
'pay.rail.feeUnavailable': 'Fee information unavailable',
|
| 366 |
+
'pay.rail.speed': 'Expected speed',
|
| 367 |
+
'pay.rail.instant': 'Near-instant',
|
| 368 |
+
'pay.rail.none': 'No eligible rail is available for this payment.',
|
| 369 |
+
'pay.review.fee': 'Fee',
|
| 370 |
+
'pay.review.rail': 'Rail',
|
| 371 |
+
'pay.review.reference': 'Reference',
|
| 372 |
+
'pay.review.authReq': 'Required authentication',
|
| 373 |
+
'pay.review.reason': 'Why',
|
| 374 |
+
'pay.review.editNote': 'Changing any field requires a new review.',
|
| 375 |
+
'pay.authTitle': 'Confirm this payment',
|
| 376 |
+
'pay.processingTitle': 'Processing your payment',
|
| 377 |
+
'pay.receiptTitle': 'Payment receipt',
|
| 378 |
+
'pay.dateTime': 'Date & time',
|
| 379 |
+
'pay.viewActivity': 'View in activity',
|
| 380 |
+
'pay.status.label': 'Status',
|
| 381 |
+
'activity.title': 'Activity',
|
| 382 |
+
'activity.sessionOnly': 'Activity from this session',
|
| 383 |
+
'activity.sessionNote': 'This list is not permanent account history.',
|
| 384 |
+
'activity.empty': 'No activity in this session yet.',
|
| 385 |
+
'activity.tab.all': 'All',
|
| 386 |
+
'activity.tab.payments': 'Payments',
|
| 387 |
+
'activity.tab.confirmations': 'Confirmations',
|
| 388 |
+
'activity.tab.biometrics': 'Biometrics lab',
|
| 389 |
+
'activity.tab.security': 'Security demos',
|
| 390 |
+
'activity.kind.payment': 'Payment',
|
| 391 |
+
'activity.kind.confirmation': 'Confirmation',
|
| 392 |
+
'activity.kind.biometrics': 'Biometrics demo',
|
| 393 |
+
'activity.kind.securityDemo': 'Security demo',
|
| 394 |
+
'nav.more': 'More',
|
| 395 |
+
'date.unavailable': 'Date unavailable',
|
| 396 |
} as const
|
| 397 |
|
| 398 |
export type MessageKey = keyof typeof en
|
web/src/pages/ActivityPage.test.tsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, beforeEach } from 'vitest'
|
| 2 |
+
import { render, screen, fireEvent } from '@testing-library/react'
|
| 3 |
+
import { I18nProvider } from '../i18n'
|
| 4 |
+
import { ActivityPage } from './ActivityPage'
|
| 5 |
+
import { _resetActivity, addActivity } from '../activity/store'
|
| 6 |
+
|
| 7 |
+
const renderAct = (locale: 'en' | 'ar' = 'en') =>
|
| 8 |
+
render(<I18nProvider initial={locale}><ActivityPage /></I18nProvider>)
|
| 9 |
+
|
| 10 |
+
describe('ActivityPage', () => {
|
| 11 |
+
beforeEach(() => _resetActivity())
|
| 12 |
+
|
| 13 |
+
it('labels the list as session-only (honest, not permanent history)', () => {
|
| 14 |
+
renderAct()
|
| 15 |
+
expect(screen.getByText('Activity from this session')).toBeInTheDocument()
|
| 16 |
+
expect(screen.getByText(/not permanent account history/i)).toBeInTheDocument()
|
| 17 |
+
})
|
| 18 |
+
|
| 19 |
+
it('shows an empty state when there is no session activity', () => {
|
| 20 |
+
renderAct()
|
| 21 |
+
expect(screen.getByTestId('activity-empty')).toBeInTheDocument()
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
it('lists a recorded event and filters by tab', () => {
|
| 25 |
+
addActivity({ kind: 'payment', title: 'Blue Bottle', status: 'succeeded', amount: '150.50 SAR' })
|
| 26 |
+
addActivity({ kind: 'security-demo', title: 'New payee', status: 'deny', reasonCodes: ['NEW_PAYEE'] })
|
| 27 |
+
renderAct()
|
| 28 |
+
expect(screen.getAllByTestId('activity-item').length).toBe(2)
|
| 29 |
+
fireEvent.click(screen.getByRole('tab', { name: 'Payments' }))
|
| 30 |
+
expect(screen.getAllByTestId('activity-item').length).toBe(1)
|
| 31 |
+
expect(screen.getByText('Blue Bottle')).toBeInTheDocument()
|
| 32 |
+
})
|
| 33 |
+
|
| 34 |
+
it('never renders full IBANs or capability text', () => {
|
| 35 |
+
addActivity({ kind: 'payment', title: 'Blue Bottle', status: 'succeeded', reference: 'p1' })
|
| 36 |
+
const { container } = renderAct()
|
| 37 |
+
expect(container.textContent).not.toMatch(/SA\d{22}|CapabilityGrant|signature/i)
|
| 38 |
+
})
|
| 39 |
+
})
|
web/src/pages/ActivityPage.tsx
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { useI18n } from '../i18n'
|
| 3 |
+
import { Callout } from '../components/ui'
|
| 4 |
+
import { listActivity, subscribeActivity, type ActivityEvent, type ActivityKind } from '../activity/store'
|
| 5 |
+
import { formatDateTime } from '../utils/format'
|
| 6 |
+
|
| 7 |
+
const TABS: { key: string; kind?: ActivityKind; label: string }[] = [
|
| 8 |
+
{ key: 'all', label: 'activity.tab.all' },
|
| 9 |
+
{ key: 'payments', kind: 'payment', label: 'activity.tab.payments' },
|
| 10 |
+
{ key: 'confirmations', kind: 'confirmation', label: 'activity.tab.confirmations' },
|
| 11 |
+
{ key: 'biometrics', kind: 'biometrics', label: 'activity.tab.biometrics' },
|
| 12 |
+
{ key: 'security', kind: 'security-demo', label: 'activity.tab.security' },
|
| 13 |
+
]
|
| 14 |
+
|
| 15 |
+
const KIND_LABEL: Record<ActivityKind, string> = {
|
| 16 |
+
payment: 'activity.kind.payment', confirmation: 'activity.kind.confirmation',
|
| 17 |
+
biometrics: 'activity.kind.biometrics', 'security-demo': 'activity.kind.securityDemo',
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
/** Session-only activity. Explicitly labelled as NOT permanent account history. Never renders
|
| 21 |
+
* raw biometrics, full IBANs, capabilities, signatures or thresholds. */
|
| 22 |
+
export function ActivityPage() {
|
| 23 |
+
const { t, locale } = useI18n()
|
| 24 |
+
const [tab, setTab] = useState('all')
|
| 25 |
+
const [, force] = useState(0)
|
| 26 |
+
|
| 27 |
+
useEffect(() => subscribeActivity(() => force((n) => n + 1)), [])
|
| 28 |
+
|
| 29 |
+
const kind = TABS.find((x) => x.key === tab)?.kind
|
| 30 |
+
const events: ActivityEvent[] = listActivity(kind)
|
| 31 |
+
|
| 32 |
+
return (
|
| 33 |
+
<section className="page activity" aria-labelledby="act-h">
|
| 34 |
+
<div className="row-between">
|
| 35 |
+
<h2 id="act-h">{t('activity.title')}</h2>
|
| 36 |
+
<span className="badge tone-info">{t('activity.sessionOnly')}</span>
|
| 37 |
+
</div>
|
| 38 |
+
<p className="muted small">{t('activity.sessionNote')}</p>
|
| 39 |
+
|
| 40 |
+
<div className="tabs" role="tablist" aria-label={t('activity.title')}>
|
| 41 |
+
{TABS.map((x) => (
|
| 42 |
+
<button key={x.key} role="tab" aria-selected={tab === x.key}
|
| 43 |
+
className={`tab ${tab === x.key ? 'active' : ''}`} onClick={() => setTab(x.key)}>
|
| 44 |
+
{t(x.label as never)}
|
| 45 |
+
</button>
|
| 46 |
+
))}
|
| 47 |
+
</div>
|
| 48 |
+
|
| 49 |
+
{events.length === 0 ? (
|
| 50 |
+
<Callout tone="info"><span data-testid="activity-empty">{t('activity.empty')}</span></Callout>
|
| 51 |
+
) : (
|
| 52 |
+
<ul className="activity-list">
|
| 53 |
+
{events.map((e) => (
|
| 54 |
+
<li key={e.id} className="activity-item" data-testid="activity-item">
|
| 55 |
+
<div className="row-between">
|
| 56 |
+
<span className="badge tone-info">{t(KIND_LABEL[e.kind] as never)}</span>
|
| 57 |
+
<span className="muted small">{formatDateTime(e.ts / 1000, locale)}</span>
|
| 58 |
+
</div>
|
| 59 |
+
<div className="row-between">
|
| 60 |
+
<strong>{e.title}</strong>
|
| 61 |
+
<span className="badge tone-info">{e.status}</span>
|
| 62 |
+
</div>
|
| 63 |
+
{e.amount && <div className="muted small">{e.amount}</div>}
|
| 64 |
+
{e.reference && <div className="muted small mono">{e.reference}</div>}
|
| 65 |
+
{e.reasonCodes && e.reasonCodes.length > 0 && (
|
| 66 |
+
<div className="muted small">{e.reasonCodes.join(', ')}</div>
|
| 67 |
+
)}
|
| 68 |
+
{typeof e.auditVerified === 'boolean' && (
|
| 69 |
+
<span className={`badge ${e.auditVerified ? 'tone-ok' : 'tone-bad'}`}>
|
| 70 |
+
{e.auditVerified ? t('agentic.result.auditVerified') : t('agentic.result.auditFailed')}
|
| 71 |
+
</span>
|
| 72 |
+
)}
|
| 73 |
+
</li>
|
| 74 |
+
))}
|
| 75 |
+
</ul>
|
| 76 |
+
)}
|
| 77 |
+
</section>
|
| 78 |
+
)
|
| 79 |
+
}
|
web/src/pages/AgenticDemoPage.disabled.test.tsx
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi } from 'vitest'
|
| 2 |
+
import { render, screen } from '@testing-library/react'
|
| 3 |
+
import { I18nProvider } from '../i18n'
|
| 4 |
+
|
| 5 |
+
vi.mock('../api/ai', () => ({
|
| 6 |
+
AGENTIC_DEMO_ENABLED: false,
|
| 7 |
+
modelStatus: vi.fn(),
|
| 8 |
+
demoPayee: vi.fn(),
|
| 9 |
+
orchestrate: vi.fn(),
|
| 10 |
+
}))
|
| 11 |
+
import { AgenticDemoPage } from './AgenticDemoPage'
|
| 12 |
+
import * as ai from '../api/ai'
|
| 13 |
+
|
| 14 |
+
describe('AgenticDemoPage (feature flag disabled)', () => {
|
| 15 |
+
it('shows the disabled notice and calls no backend', () => {
|
| 16 |
+
render(<I18nProvider initial="en"><AgenticDemoPage /></I18nProvider>)
|
| 17 |
+
expect(screen.getByText(/agentic demo is disabled/i)).toBeInTheDocument()
|
| 18 |
+
expect(ai.demoPayee).not.toHaveBeenCalled()
|
| 19 |
+
expect(ai.orchestrate).not.toHaveBeenCalled()
|
| 20 |
+
})
|
| 21 |
+
})
|
web/src/pages/AgenticDemoPage.test.tsx
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
| 2 |
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
| 3 |
+
import { I18nProvider } from '../i18n'
|
| 4 |
+
import { AgenticDemoPage } from './AgenticDemoPage'
|
| 5 |
+
import type { OrchestrateResult } from '../api/ai'
|
| 6 |
+
|
| 7 |
+
vi.mock('../api/ai', () => ({
|
| 8 |
+
AGENTIC_DEMO_ENABLED: true,
|
| 9 |
+
modelStatus: vi.fn(),
|
| 10 |
+
demoPayee: vi.fn(),
|
| 11 |
+
orchestrate: vi.fn(),
|
| 12 |
+
}))
|
| 13 |
+
import * as ai from '../api/ai'
|
| 14 |
+
|
| 15 |
+
const RESULT: OrchestrateResult = {
|
| 16 |
+
decision: 'step_up', reason_codes: ['NEW_PAYEE'], required_auth: ['webauthn', 'oob'],
|
| 17 |
+
agent_state: 'policy_evaluated',
|
| 18 |
+
risk: { band: 'elevated', recommend_step_up: true, reason_codes: ['NEW_PAYEE'],
|
| 19 |
+
confidence: 'ok', model_version: 'v1', feature_version: 'features-v1', shadow: true },
|
| 20 |
+
auth_recommendation: { recommended_method: 'webauthn+oob', meets_pdp_minimum: true },
|
| 21 |
+
rails: { ranked: ['sarie'], eligible_only: true },
|
| 22 |
+
explanation: { locale: 'en', messages: ['This payment is to a payee you have not paid before.'],
|
| 23 |
+
reason_codes: ['NEW_PAYEE'] },
|
| 24 |
+
payment: null,
|
| 25 |
+
audit: { verified: true, length: 5, protection_level: 'signed-checkpoint (demo, in-memory)' },
|
| 26 |
+
fallback: null, labels: { note: 'DEMO ONLY · Mock provider · No real money' },
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
const renderPage = (locale: 'en' | 'ar' = 'en') =>
|
| 30 |
+
render(<I18nProvider initial={locale}><AgenticDemoPage /></I18nProvider>)
|
| 31 |
+
|
| 32 |
+
describe('AgenticDemoPage (enabled)', () => {
|
| 33 |
+
beforeEach(() => {
|
| 34 |
+
vi.mocked(ai.modelStatus).mockResolvedValue({
|
| 35 |
+
behavioural_model: 'logreg', model_version: 'v1', feature_version: 'features-v1',
|
| 36 |
+
reason_codes_version: 'reason_codes-v1', shadow_only: true, affects_payment: false, label: 'DEMO',
|
| 37 |
+
})
|
| 38 |
+
vi.mocked(ai.demoPayee).mockResolvedValue({ payee_ref: 'payee_x', display: 'SA03 **** 7519' })
|
| 39 |
+
vi.mocked(ai.orchestrate).mockResolvedValue(RESULT)
|
| 40 |
+
})
|
| 41 |
+
|
| 42 |
+
it('shows shadow-mode disclosures up front', () => {
|
| 43 |
+
renderPage()
|
| 44 |
+
expect(screen.getByText('Shadow mode')).toBeInTheDocument()
|
| 45 |
+
expect(screen.getByText('Advisory only')).toBeInTheDocument()
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
it('runs a scenario and shows the authoritative decision + advisory risk band', async () => {
|
| 49 |
+
renderPage()
|
| 50 |
+
await waitFor(() => expect(ai.demoPayee).toHaveBeenCalled())
|
| 51 |
+
fireEvent.click(await screen.findByTestId('scenario-newPayee'))
|
| 52 |
+
await waitFor(() => expect(ai.orchestrate).toHaveBeenCalled())
|
| 53 |
+
expect(await screen.findByTestId('policy-decision')).toHaveTextContent('step_up')
|
| 54 |
+
expect(screen.getByTestId('risk-band')).toHaveTextContent('elevated')
|
| 55 |
+
expect(screen.getAllByText('Authoritative').length).toBeGreaterThan(0)
|
| 56 |
+
expect(screen.getByText(/have not paid before/i)).toBeInTheDocument()
|
| 57 |
+
})
|
| 58 |
+
|
| 59 |
+
it('renders the agent trace with Payment Core not executed on step-up', async () => {
|
| 60 |
+
renderPage()
|
| 61 |
+
fireEvent.click(await screen.findByTestId('scenario-newPayee'))
|
| 62 |
+
await screen.findByTestId('agentic-result')
|
| 63 |
+
expect(screen.getByText('Agent sequence')).toBeInTheDocument()
|
| 64 |
+
expect(screen.getByText('Payment Core')).toBeInTheDocument()
|
| 65 |
+
expect(screen.getAllByText('Not executed').length).toBeGreaterThan(0)
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
it('never renders capabilities, signatures, keys or full IBANs', async () => {
|
| 69 |
+
const { container } = renderPage()
|
| 70 |
+
fireEvent.click(await screen.findByTestId('scenario-newPayee'))
|
| 71 |
+
await screen.findByTestId('agentic-result')
|
| 72 |
+
const txt = container.textContent || ''
|
| 73 |
+
expect(txt).not.toMatch(/CapabilityGrant|ExecutionCapability|signature|private key|BEGIN /i)
|
| 74 |
+
expect(txt).not.toMatch(/608010167519/) // full IBAN body never shown
|
| 75 |
+
})
|
| 76 |
+
|
| 77 |
+
it('hides the developer panel by default', async () => {
|
| 78 |
+
renderPage()
|
| 79 |
+
fireEvent.click(await screen.findByTestId('scenario-newPayee'))
|
| 80 |
+
await screen.findByTestId('agentic-result')
|
| 81 |
+
const details = document.querySelector('details.dev-panel') as HTMLDetailsElement
|
| 82 |
+
expect(details).toBeTruthy()
|
| 83 |
+
expect(details.open).toBe(false)
|
| 84 |
+
})
|
| 85 |
+
})
|
web/src/pages/AgenticDemoPage.tsx
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useRef, useState } from 'react'
|
| 2 |
+
import { useI18n } from '../i18n'
|
| 3 |
+
import { Callout, Spinner } from '../components/ui'
|
| 4 |
+
import { ShadowBadges, AuthoritativeBadge, ShadowFootnote } from '../components/ShadowBadge'
|
| 5 |
+
import { AgentTrace } from '../components/AgentTrace'
|
| 6 |
+
import { addActivity } from '../activity/store'
|
| 7 |
+
import {
|
| 8 |
+
AGENTIC_DEMO_ENABLED, demoPayee, modelStatus, orchestrate,
|
| 9 |
+
type ModelStatus, type OrchestrateResult, type ScenarioInput,
|
| 10 |
+
} from '../api/ai'
|
| 11 |
+
|
| 12 |
+
const DEMO_IBAN = 'SA0380000000608010167519'
|
| 13 |
+
|
| 14 |
+
type ScenarioKey =
|
| 15 |
+
| 'normal' | 'newPayee' | 'unusualAmount' | 'highVelocity'
|
| 16 |
+
| 'providerUnavailable' | 'insufficientHistory'
|
| 17 |
+
|
| 18 |
+
// Predefined, safe synthetic scenarios — no arbitrary raw feature input, no arbitrary provider.
|
| 19 |
+
function scenarioInput(k: ScenarioKey, payee_ref: string, locale: string): ScenarioInput {
|
| 20 |
+
const base: ScenarioInput = {
|
| 21 |
+
payee_ref, amount_minor: 4000, known_payees: [payee_ref], history_payments: 25,
|
| 22 |
+
recent_24h: 1, approve: true, consent: true, locale, now: 43200,
|
| 23 |
+
}
|
| 24 |
+
switch (k) {
|
| 25 |
+
case 'newPayee': return { ...base, known_payees: [] }
|
| 26 |
+
case 'unusualAmount': return { ...base, amount_minor: 80000 }
|
| 27 |
+
case 'highVelocity': return { ...base, recent_24h: 6 }
|
| 28 |
+
case 'providerUnavailable': return { ...base, country: 'AE', currency: 'AED' } as ScenarioInput
|
| 29 |
+
case 'insufficientHistory': return { ...base, history_payments: 0, known_payees: [] }
|
| 30 |
+
default: return base
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
const SCENARIOS: ScenarioKey[] = [
|
| 35 |
+
'normal', 'newPayee', 'unusualAmount', 'highVelocity', 'providerUnavailable', 'insufficientHistory',
|
| 36 |
+
]
|
| 37 |
+
|
| 38 |
+
function decisionTone(d: string): 'ok' | 'bad' | 'warn' {
|
| 39 |
+
return d === 'allow' ? 'ok' : d === 'deny' ? 'bad' : 'warn'
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
export function AgenticDemoPage() {
|
| 43 |
+
const { t, locale } = useI18n()
|
| 44 |
+
const [status, setStatus] = useState<ModelStatus | null>(null)
|
| 45 |
+
const [payeeRef, setPayeeRef] = useState<string | null>(null)
|
| 46 |
+
const [result, setResult] = useState<OrchestrateResult | null>(null)
|
| 47 |
+
const [running, setRunning] = useState(false)
|
| 48 |
+
const [err, setErr] = useState<string | null>(null)
|
| 49 |
+
const [showDev, setShowDev] = useState(false)
|
| 50 |
+
const ac = useRef<AbortController | null>(null)
|
| 51 |
+
|
| 52 |
+
useEffect(() => {
|
| 53 |
+
if (!AGENTIC_DEMO_ENABLED) return
|
| 54 |
+
const c = new AbortController()
|
| 55 |
+
Promise.all([modelStatus(c.signal), demoPayee(DEMO_IBAN, c.signal)])
|
| 56 |
+
.then(([s, p]) => { setStatus(s); setPayeeRef(p.payee_ref) })
|
| 57 |
+
.catch(() => setErr(t('common.error')))
|
| 58 |
+
return () => c.abort()
|
| 59 |
+
}, [t])
|
| 60 |
+
|
| 61 |
+
async function run(k: ScenarioKey) {
|
| 62 |
+
if (!payeeRef) return
|
| 63 |
+
ac.current?.abort()
|
| 64 |
+
ac.current = new AbortController()
|
| 65 |
+
setRunning(true); setErr(null); setResult(null)
|
| 66 |
+
try {
|
| 67 |
+
const r = await orchestrate(scenarioInput(k, payeeRef, locale), ac.current.signal)
|
| 68 |
+
setResult(r)
|
| 69 |
+
addActivity({ kind: 'security-demo', title: t(`agentic.scenario.${k}` as never),
|
| 70 |
+
status: r.decision, reasonCodes: r.reason_codes,
|
| 71 |
+
auditVerified: r.audit?.verified, modelVersion: r.risk?.model_version })
|
| 72 |
+
} catch {
|
| 73 |
+
setErr(t('common.error'))
|
| 74 |
+
} finally {
|
| 75 |
+
setRunning(false)
|
| 76 |
+
}
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
if (!AGENTIC_DEMO_ENABLED) {
|
| 80 |
+
return (
|
| 81 |
+
<section className="page agentic" aria-labelledby="ag-h">
|
| 82 |
+
<h2 id="ag-h">{t('agentic.title')}</h2>
|
| 83 |
+
<Callout tone="info">{t('agentic.disabled')}</Callout>
|
| 84 |
+
</section>
|
| 85 |
+
)
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
return (
|
| 89 |
+
<section className="page agentic" aria-labelledby="ag-h">
|
| 90 |
+
<h2 id="ag-h">{t('agentic.title')}</h2>
|
| 91 |
+
<p className="muted">{t('agentic.subtitle')}</p>
|
| 92 |
+
<ShadowBadges />
|
| 93 |
+
|
| 94 |
+
<div className="card">
|
| 95 |
+
<h3>{t('agentic.scenario.title')}</h3>
|
| 96 |
+
<div className="quick-actions" role="group" aria-label={t('agentic.scenario.title')}>
|
| 97 |
+
{SCENARIOS.map((k) => (
|
| 98 |
+
<button key={k} className="btn" data-testid={`scenario-${k}`}
|
| 99 |
+
disabled={running || !payeeRef} onClick={() => run(k)}>
|
| 100 |
+
{t(`agentic.scenario.${k}` as never)}
|
| 101 |
+
</button>
|
| 102 |
+
))}
|
| 103 |
+
</div>
|
| 104 |
+
{running && <Spinner label={t('agentic.running')} />}
|
| 105 |
+
{err && <Callout tone="bad">{err}</Callout>}
|
| 106 |
+
</div>
|
| 107 |
+
|
| 108 |
+
{result && (
|
| 109 |
+
<div className="card" data-testid="agentic-result">
|
| 110 |
+
<div className="row-between">
|
| 111 |
+
<h3>{t('agentic.result.policy')} <AuthoritativeBadge /></h3>
|
| 112 |
+
<span className={`badge tone-${decisionTone(result.decision)}`} data-testid="policy-decision">
|
| 113 |
+
{result.decision}
|
| 114 |
+
</span>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<div className="result-grid">
|
| 118 |
+
<div>
|
| 119 |
+
<span className="muted small">{t('agentic.result.band')}</span>
|
| 120 |
+
<div><span className="badge tone-info" data-testid="risk-band">
|
| 121 |
+
{result.risk?.band ?? 'model_unavailable'}</span></div>
|
| 122 |
+
</div>
|
| 123 |
+
<div>
|
| 124 |
+
<span className="muted small">{t('agentic.result.auth')}</span>
|
| 125 |
+
<div className="mono">{result.auth_recommendation?.recommended_method ?? '—'}</div>
|
| 126 |
+
</div>
|
| 127 |
+
<div>
|
| 128 |
+
<span className="muted small">{t('agentic.result.rails')}</span>
|
| 129 |
+
<div className="mono">{(result.rails?.ranked ?? []).join(', ') || '—'}</div>
|
| 130 |
+
</div>
|
| 131 |
+
<div>
|
| 132 |
+
<span className="muted small">{t('agentic.result.audit')}</span>
|
| 133 |
+
<div><span className={`badge ${result.audit?.verified ? 'tone-ok' : 'tone-bad'}`}>
|
| 134 |
+
{result.audit?.verified ? t('agentic.result.auditVerified') : t('agentic.result.auditFailed')}
|
| 135 |
+
</span></div>
|
| 136 |
+
</div>
|
| 137 |
+
</div>
|
| 138 |
+
|
| 139 |
+
{result.explanation && result.explanation.messages.length > 0 && (
|
| 140 |
+
<div className="explanation">
|
| 141 |
+
<span className="muted small">{t('agentic.result.explanation')}</span>
|
| 142 |
+
<ul>{result.explanation.messages.map((m, i) => <li key={i}>{m}</li>)}</ul>
|
| 143 |
+
</div>
|
| 144 |
+
)}
|
| 145 |
+
|
| 146 |
+
<AgentTrace result={result} />
|
| 147 |
+
<ShadowFootnote />
|
| 148 |
+
|
| 149 |
+
<details className="dev-panel" open={showDev} onToggle={(e) => setShowDev((e.target as HTMLDetailsElement).open)}>
|
| 150 |
+
<summary>{showDev ? t('agentic.dev.hide') : t('agentic.dev.show')}</summary>
|
| 151 |
+
<p className="badge tone-warn">{t('agentic.dev.label')}</p>
|
| 152 |
+
<ul className="status-list">
|
| 153 |
+
<li><span>mode</span><span className="mono">shadow</span></li>
|
| 154 |
+
<li><span>provider</span><span className="mono">mock</span></li>
|
| 155 |
+
<li><span>model</span><span className="mono">{status?.model_version ?? '—'}</span></li>
|
| 156 |
+
<li><span>features</span><span className="mono">{status?.feature_version ?? '—'}</span></li>
|
| 157 |
+
<li><span>reason_codes</span><span className="mono">{(result.reason_codes || []).join(', ')}</span></li>
|
| 158 |
+
<li><span>fallback</span><span className="mono">{result.fallback ?? 'none'}</span></li>
|
| 159 |
+
<li><span>audit_len</span><span className="mono">{result.audit?.length ?? 0}</span></li>
|
| 160 |
+
</ul>
|
| 161 |
+
</details>
|
| 162 |
+
</div>
|
| 163 |
+
)}
|
| 164 |
+
</section>
|
| 165 |
+
)
|
| 166 |
+
}
|
web/src/pages/BiometricsLabPage.test.tsx
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi } from 'vitest'
|
| 2 |
+
import { render, screen } from '@testing-library/react'
|
| 3 |
+
import { I18nProvider } from '../i18n'
|
| 4 |
+
import { BiometricsLabPage } from './BiometricsLabPage'
|
| 5 |
+
|
| 6 |
+
const renderLab = (locale: 'en' | 'ar' = 'en') =>
|
| 7 |
+
render(<I18nProvider initial={locale}><BiometricsLabPage onNav={vi.fn()} /></I18nProvider>)
|
| 8 |
+
|
| 9 |
+
describe('BiometricsLabPage', () => {
|
| 10 |
+
it('shows the fingerprint-image demo with the sensor disclaimer', () => {
|
| 11 |
+
renderLab()
|
| 12 |
+
expect(screen.getByTestId('fingerprint-demo')).toBeInTheDocument()
|
| 13 |
+
expect(
|
| 14 |
+
screen.getByText(/A website cannot retrieve the fingerprint stored in your phone or computer sensor/i),
|
| 15 |
+
).toBeInTheDocument()
|
| 16 |
+
})
|
| 17 |
+
|
| 18 |
+
it('provides the disclaimer in Arabic too', () => {
|
| 19 |
+
renderLab('ar')
|
| 20 |
+
expect(screen.getByText(/لا يمكن لأي موقع إلكتروني قراءة البصمة/)).toBeInTheDocument()
|
| 21 |
+
})
|
| 22 |
+
})
|
web/src/pages/BiometricsLabPage.tsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useI18n } from '../i18n'
|
| 2 |
+
import { Callout } from '../components/ui'
|
| 3 |
+
|
| 4 |
+
/** Research/lab landing. The fingerprint-IMAGE demo lives ONLY here — never in the normal
|
| 5 |
+
* customer flow — with a prominent disclaimer that a website cannot read a phone's sensor. */
|
| 6 |
+
export function BiometricsLabPage({ onNav }: { onNav: (r: string) => void }) {
|
| 7 |
+
const { t } = useI18n()
|
| 8 |
+
return (
|
| 9 |
+
<section className="page lab" aria-labelledby="lab-h">
|
| 10 |
+
<h2 id="lab-h">{t('lab.title')}</h2>
|
| 11 |
+
<p className="muted">{t('lab.subtitle')}</p>
|
| 12 |
+
|
| 13 |
+
<div className="card" data-testid="fingerprint-demo">
|
| 14 |
+
<h3>{t('lab.fingerprint.title')}</h3>
|
| 15 |
+
<Callout tone="warn">{t('lab.fingerprint.disclaimer')}</Callout>
|
| 16 |
+
<div className="quick-actions">
|
| 17 |
+
<button className="btn" onClick={() => onNav('biometrics/enroll')}>{t('bio.dash.enroll') as never}</button>
|
| 18 |
+
<button className="btn" onClick={() => onNav('biometrics/verify')}>{t('bio.dash.verify') as never}</button>
|
| 19 |
+
<button className="btn" onClick={() => onNav('biometrics/liveness')}>{t('bio.dash.liveness') as never}</button>
|
| 20 |
+
<button className="btn" onClick={() => onNav('biometrics/reportcard')}>{t('bio.dash.reportcard') as never}</button>
|
| 21 |
+
</div>
|
| 22 |
+
</div>
|
| 23 |
+
|
| 24 |
+
<p className="muted small">
|
| 25 |
+
{t('bio.confirmDevice')} — {t('security.device.explain')}
|
| 26 |
+
</p>
|
| 27 |
+
</section>
|
| 28 |
+
)
|
| 29 |
+
}
|
web/src/pages/HomePage.test.tsx
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi } from 'vitest'
|
| 2 |
+
import { render, screen, fireEvent } from '@testing-library/react'
|
| 3 |
+
import { I18nProvider } from '../i18n'
|
| 4 |
+
import { HomePage } from './HomePage'
|
| 5 |
+
|
| 6 |
+
const renderHome = (onNav = vi.fn()) =>
|
| 7 |
+
render(<I18nProvider initial="en"><HomePage onNav={onNav} /></I18nProvider>)
|
| 8 |
+
|
| 9 |
+
describe('HomePage', () => {
|
| 10 |
+
it('shows greeting, security status and demo badge', () => {
|
| 11 |
+
renderHome()
|
| 12 |
+
expect(screen.getByRole('heading', { name: 'Welcome to AmanPay' })).toBeInTheDocument()
|
| 13 |
+
expect(screen.getByTestId('security-status')).toBeInTheDocument()
|
| 14 |
+
expect(screen.getByTestId('demo-badge')).toHaveTextContent(/No real-money payment/i)
|
| 15 |
+
})
|
| 16 |
+
|
| 17 |
+
it('has quick actions and navigates', () => {
|
| 18 |
+
const onNav = vi.fn()
|
| 19 |
+
renderHome(onNav)
|
| 20 |
+
fireEvent.click(screen.getByRole('button', { name: 'Pay' }))
|
| 21 |
+
expect(onNav).toHaveBeenCalledWith('pay')
|
| 22 |
+
fireEvent.click(screen.getByRole('button', { name: 'Security settings' }))
|
| 23 |
+
expect(onNav).toHaveBeenCalledWith('security')
|
| 24 |
+
})
|
| 25 |
+
|
| 26 |
+
it('does NOT offer any fingerprint upload in the customer home', () => {
|
| 27 |
+
const { container } = renderHome()
|
| 28 |
+
expect(container.querySelector('input[type="file"]')).toBeNull()
|
| 29 |
+
expect(screen.queryByText(/upload fingerprint/i)).toBeNull()
|
| 30 |
+
expect(screen.queryByText(/scan fingerprint/i)).toBeNull()
|
| 31 |
+
})
|
| 32 |
+
|
| 33 |
+
it('shows empty recent-activity state (no invented balance)', () => {
|
| 34 |
+
renderHome()
|
| 35 |
+
expect(screen.getByTestId('recent-empty')).toBeInTheDocument()
|
| 36 |
+
expect(screen.queryByText(/balance/i)).toBeNull()
|
| 37 |
+
})
|
| 38 |
+
})
|
web/src/pages/HomePage.tsx
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useI18n } from '../i18n'
|
| 2 |
+
import { useSession } from '../hooks/useSession'
|
| 3 |
+
import { Callout } from '../components/ui'
|
| 4 |
+
|
| 5 |
+
/** Customer-facing home dashboard (default route). Shows security status, quick actions and
|
| 6 |
+
* recent activity — never raw scores, thresholds, capabilities or full IBANs. */
|
| 7 |
+
export function HomePage({ onNav }: { onNav: (r: string) => void }) {
|
| 8 |
+
const { t } = useI18n()
|
| 9 |
+
const { activeUser } = useSession()
|
| 10 |
+
const protectedNow = !!activeUser
|
| 11 |
+
|
| 12 |
+
return (
|
| 13 |
+
<section className="page home" aria-labelledby="home-h">
|
| 14 |
+
<div className="row-between">
|
| 15 |
+
<h2 id="home-h">{t('home.greeting')}</h2>
|
| 16 |
+
<span className="badge tone-info" data-testid="demo-badge">{t('home.demoBadge')}</span>
|
| 17 |
+
</div>
|
| 18 |
+
|
| 19 |
+
<div className="card status-card" data-testid="security-status">
|
| 20 |
+
<Callout tone={protectedNow ? 'ok' : 'warn'}>
|
| 21 |
+
{protectedNow ? t('home.security.protected') : t('home.security.setup')}
|
| 22 |
+
</Callout>
|
| 23 |
+
<ul className="status-list">
|
| 24 |
+
<li>
|
| 25 |
+
<span>{t('home.security.passkey')}</span>
|
| 26 |
+
<span className={`badge ${protectedNow ? 'tone-ok' : 'tone-warn'}`}>
|
| 27 |
+
{protectedNow ? t('common.on') : t('common.notSet')}
|
| 28 |
+
</span>
|
| 29 |
+
</li>
|
| 30 |
+
<li>
|
| 31 |
+
<span>{t('home.security.oob')}</span>
|
| 32 |
+
<span className="badge tone-info">{t('common.off')}</span>
|
| 33 |
+
</li>
|
| 34 |
+
</ul>
|
| 35 |
+
<p className="muted small">{t('home.nextAction')}:{' '}
|
| 36 |
+
<button className="linklike" onClick={() => onNav('security')}>
|
| 37 |
+
{protectedNow ? t('home.quick.security') : t('security.device.setup')}
|
| 38 |
+
</button>
|
| 39 |
+
</p>
|
| 40 |
+
</div>
|
| 41 |
+
|
| 42 |
+
<div className="card">
|
| 43 |
+
<h3>{t('home.quick.title')}</h3>
|
| 44 |
+
<div className="quick-actions">
|
| 45 |
+
<button className="btn primary" onClick={() => onNav('pay')}>{t('home.quick.pay')}</button>
|
| 46 |
+
<button className="btn" onClick={() => onNav('activity')}>{t('home.quick.activity')}</button>
|
| 47 |
+
<button className="btn" onClick={() => onNav('security')}>{t('home.quick.security')}</button>
|
| 48 |
+
<button className="btn" onClick={() => onNav('biometrics/oob')}>{t('home.quick.confirm')}</button>
|
| 49 |
+
</div>
|
| 50 |
+
</div>
|
| 51 |
+
|
| 52 |
+
<div className="card">
|
| 53 |
+
<h3>{t('home.recent.title')}</h3>
|
| 54 |
+
<p className="muted empty-state" data-testid="recent-empty">{t('home.recent.empty')}</p>
|
| 55 |
+
</div>
|
| 56 |
+
</section>
|
| 57 |
+
)
|
| 58 |
+
}
|
web/src/pages/PayPage.test.tsx
CHANGED
|
@@ -4,16 +4,16 @@ import { I18nProvider } from '../i18n'
|
|
| 4 |
import { PayPage } from './PayPage'
|
| 5 |
import * as payApi from '../api/payments'
|
| 6 |
import { logout, setActiveUser } from '../auth/session'
|
|
|
|
| 7 |
|
| 8 |
vi.mock('../api/payments')
|
| 9 |
|
| 10 |
-
const HOSTILE = '<img src=x onerror="window.__xss=1">'
|
| 11 |
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 12 |
function mkPayment(over: Partial<any> = {}): any {
|
| 13 |
return {
|
| 14 |
id: 'p1', user_id: 'bob', status: 'succeeded', amount_minor: 15050, currency: 'SAR',
|
| 15 |
amount: '150.50 SAR', country: 'SA', merchant_id: 'm', provider: 'mock',
|
| 16 |
-
provider_ref: 'mp_1', correlation_id:
|
| 17 |
requires_action_url: null, locked: true, created_at: 0, updated_at: 0, ...over,
|
| 18 |
}
|
| 19 |
}
|
|
@@ -23,90 +23,95 @@ const CAPS: any = {
|
|
| 23 |
name: 'mock', supports_cancel: true, supports_refund: true, supports_partial_refund: true,
|
| 24 |
countries: ['*'], currencies: ['SAR'], payment_initiation: true, sandbox: true,
|
| 25 |
} } },
|
| 26 |
-
default: { country: 'SA', currency: 'SAR'
|
| 27 |
-
non_custodial: true,
|
| 28 |
}
|
| 29 |
const renderPay = () => render(<I18nProvider initial="en"><PayPage onNavEnroll={() => {}} /></I18nProvider>)
|
| 30 |
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
beforeEach(() => {
|
| 33 |
-
vi.resetAllMocks()
|
| 34 |
-
logout()
|
| 35 |
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 36 |
-
;(window as any).__xss = undefined
|
| 37 |
vi.spyOn(payApi, 'getProviders').mockResolvedValue(CAPS)
|
| 38 |
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment({ status: 'requires_customer_action' }))
|
| 39 |
})
|
| 40 |
|
| 41 |
-
it('
|
|
|
|
| 42 |
renderPay()
|
| 43 |
-
expect(screen.
|
| 44 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
})
|
| 46 |
|
| 47 |
-
it('
|
| 48 |
-
setActiveUser({ userId: 'bob', via: 'demo' })
|
| 49 |
-
const create = vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment({ status: 'requires_customer_action' }))
|
| 50 |
renderPay()
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
await waitFor(() => expect(create).toHaveBeenCalledTimes(1))
|
| 53 |
expect(create.mock.calls[0][0].userId).toBe('bob')
|
| 54 |
-
expect(create.mock.calls[0][0].userId).not.toBe('alice')
|
| 55 |
})
|
| 56 |
|
| 57 |
-
it('prevents duplicate submission (one create per
|
| 58 |
-
setActiveUser({ userId: 'bob', via: '
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
() => new Promise<any>((r) => { resolve = r }),
|
| 64 |
-
)
|
| 65 |
-
renderPay()
|
| 66 |
-
const btn = screen.getByRole('button', { name: 'Pay' })
|
| 67 |
fireEvent.click(btn); fireEvent.click(btn); fireEvent.click(btn)
|
| 68 |
resolve(mkPayment({ status: 'requires_customer_action' }))
|
| 69 |
await waitFor(() => expect(create).toHaveBeenCalledTimes(1))
|
| 70 |
})
|
| 71 |
|
| 72 |
-
it('renders hostile backend text as text, not HTML (XSS regression)', async () => {
|
| 73 |
-
setActiveUser({ userId: 'bob', via: 'demo' })
|
| 74 |
-
vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment())
|
| 75 |
-
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment())
|
| 76 |
-
const { container } = renderPay()
|
| 77 |
-
fireEvent.click(screen.getByRole('button', { name: 'Pay' }))
|
| 78 |
-
await waitFor(() => expect(screen.getByText('Succeeded')).toBeInTheDocument())
|
| 79 |
-
expect(container.querySelector('img')).toBeNull()
|
| 80 |
-
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 81 |
-
expect((window as any).__xss).toBeUndefined()
|
| 82 |
-
})
|
| 83 |
-
|
| 84 |
it('distinguishes processing from success', async () => {
|
| 85 |
-
setActiveUser({ userId: 'bob', via: '
|
| 86 |
vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment({ status: 'processing' }))
|
| 87 |
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment({ status: 'processing' }))
|
| 88 |
-
renderPay()
|
| 89 |
-
fireEvent.click(screen.getByRole('button', { name: '
|
| 90 |
-
|
| 91 |
-
expect(screen.
|
| 92 |
})
|
| 93 |
|
| 94 |
-
it('shows
|
| 95 |
-
setActiveUser({ userId: 'bob', via: '
|
| 96 |
-
vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment({ status: 'requires_customer_action' }))
|
| 97 |
-
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment({ status: 'requires_customer_action' }))
|
| 98 |
-
renderPay()
|
| 99 |
-
fireEvent.click(screen.getByRole('button', { name: 'Pay' }))
|
| 100 |
-
await waitFor(() => expect(screen.getByRole('button', { name: 'Cancel payment' })).toBeInTheDocument())
|
| 101 |
-
})
|
| 102 |
-
|
| 103 |
-
it('hides Cancel when terminal (succeeded)', async () => {
|
| 104 |
-
setActiveUser({ userId: 'bob', via: 'demo' })
|
| 105 |
vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment({ status: 'succeeded' }))
|
| 106 |
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment({ status: 'succeeded' }))
|
| 107 |
-
renderPay()
|
| 108 |
-
fireEvent.click(screen.getByRole('button', { name: '
|
| 109 |
-
|
|
|
|
| 110 |
expect(screen.queryByRole('button', { name: 'Cancel payment' })).toBeNull()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
})
|
| 112 |
})
|
|
|
|
| 4 |
import { PayPage } from './PayPage'
|
| 5 |
import * as payApi from '../api/payments'
|
| 6 |
import { logout, setActiveUser } from '../auth/session'
|
| 7 |
+
import { _resetActivity, listActivity } from '../activity/store'
|
| 8 |
|
| 9 |
vi.mock('../api/payments')
|
| 10 |
|
|
|
|
| 11 |
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
| 12 |
function mkPayment(over: Partial<any> = {}): any {
|
| 13 |
return {
|
| 14 |
id: 'p1', user_id: 'bob', status: 'succeeded', amount_minor: 15050, currency: 'SAR',
|
| 15 |
amount: '150.50 SAR', country: 'SA', merchant_id: 'm', provider: 'mock',
|
| 16 |
+
provider_ref: 'mp_1', correlation_id: 'c', consent_id: 'c', refunded_minor: 0,
|
| 17 |
requires_action_url: null, locked: true, created_at: 0, updated_at: 0, ...over,
|
| 18 |
}
|
| 19 |
}
|
|
|
|
| 23 |
name: 'mock', supports_cancel: true, supports_refund: true, supports_partial_refund: true,
|
| 24 |
countries: ['*'], currencies: ['SAR'], payment_initiation: true, sandbox: true,
|
| 25 |
} } },
|
| 26 |
+
default: { country: 'SA', currency: 'SAR' }, non_custodial: true,
|
|
|
|
| 27 |
}
|
| 28 |
const renderPay = () => render(<I18nProvider initial="en"><PayPage onNavEnroll={() => {}} /></I18nProvider>)
|
| 29 |
|
| 30 |
+
// Click through payee -> amount -> rail -> review -> auth.
|
| 31 |
+
async function toAuth() {
|
| 32 |
+
fireEvent.click(await screen.findByRole('button', { name: 'Next' })) // payee -> amount
|
| 33 |
+
fireEvent.click(screen.getByRole('button', { name: 'Next' })) // amount -> rail
|
| 34 |
+
await waitFor(() => expect(screen.getByRole('button', { name: 'Next' })).not.toBeDisabled()) // rail loaded
|
| 35 |
+
fireEvent.click(screen.getByRole('button', { name: 'Next' })) // rail -> review
|
| 36 |
+
fireEvent.click(screen.getByRole('button', { name: 'Next' })) // review -> auth
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
describe('PayPage (staged journey)', () => {
|
| 40 |
beforeEach(() => {
|
| 41 |
+
vi.resetAllMocks(); logout(); _resetActivity()
|
|
|
|
|
|
|
|
|
|
| 42 |
vi.spyOn(payApi, 'getProviders').mockResolvedValue(CAPS)
|
| 43 |
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment({ status: 'requires_customer_action' }))
|
| 44 |
})
|
| 45 |
|
| 46 |
+
it('walks payee -> amount -> rail -> review with a masked destination and new-payee note', async () => {
|
| 47 |
+
setActiveUser({ userId: 'bob', via: 'passkey' })
|
| 48 |
renderPay()
|
| 49 |
+
expect(screen.getByTestId('stage-payee')).toBeInTheDocument()
|
| 50 |
+
fireEvent.change(screen.getByLabelText('Payee IBAN'), { target: { value: 'SA4420000001234567891234' } })
|
| 51 |
+
expect(screen.getByText('This is a new payee.')).toBeInTheDocument()
|
| 52 |
+
fireEvent.click(screen.getByRole('button', { name: 'Next' }))
|
| 53 |
+
fireEvent.click(screen.getByRole('button', { name: 'Next' }))
|
| 54 |
+
await waitFor(() => expect(screen.getByRole('button', { name: 'Next' })).not.toBeDisabled())
|
| 55 |
+
fireEvent.click(screen.getByRole('button', { name: 'Next' }))
|
| 56 |
+
expect(screen.getByTestId('stage-review')).toBeInTheDocument()
|
| 57 |
+
expect(screen.getByText(/SA44 \*\*\*\* \*\*\*\* 1234/)).toBeInTheDocument()
|
| 58 |
+
expect(screen.queryByText('SA4420000001234567891234')).toBeNull() // never the full IBAN
|
| 59 |
+
expect(screen.getByText('Confirm with device biometrics')).toBeInTheDocument()
|
| 60 |
})
|
| 61 |
|
| 62 |
+
it('requires an active user to confirm', async () => {
|
|
|
|
|
|
|
| 63 |
renderPay()
|
| 64 |
+
await toAuth()
|
| 65 |
+
expect(screen.getByRole('button', { name: 'Confirm with device biometrics' })).toBeDisabled()
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
it('creates a payment with the ACTIVE user only at the auth stage', async () => {
|
| 69 |
+
setActiveUser({ userId: 'bob', via: 'passkey' })
|
| 70 |
+
const create = vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment({ status: 'requires_customer_action' }))
|
| 71 |
+
renderPay(); await toAuth()
|
| 72 |
+
fireEvent.click(screen.getByRole('button', { name: 'Confirm with device biometrics' }))
|
| 73 |
await waitFor(() => expect(create).toHaveBeenCalledTimes(1))
|
| 74 |
expect(create.mock.calls[0][0].userId).toBe('bob')
|
|
|
|
| 75 |
})
|
| 76 |
|
| 77 |
+
it('prevents duplicate submission (one create per confirm)', async () => {
|
| 78 |
+
setActiveUser({ userId: 'bob', via: 'passkey' })
|
| 79 |
+
let resolve!: (v: unknown) => void
|
| 80 |
+
const create = vi.spyOn(payApi, 'createPayment').mockReturnValue(new Promise((r) => { resolve = r }) as never)
|
| 81 |
+
renderPay(); await toAuth()
|
| 82 |
+
const btn = screen.getByRole('button', { name: 'Confirm with device biometrics' })
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
fireEvent.click(btn); fireEvent.click(btn); fireEvent.click(btn)
|
| 84 |
resolve(mkPayment({ status: 'requires_customer_action' }))
|
| 85 |
await waitFor(() => expect(create).toHaveBeenCalledTimes(1))
|
| 86 |
})
|
| 87 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
it('distinguishes processing from success', async () => {
|
| 89 |
+
setActiveUser({ userId: 'bob', via: 'passkey' })
|
| 90 |
vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment({ status: 'processing' }))
|
| 91 |
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment({ status: 'processing' }))
|
| 92 |
+
renderPay(); await toAuth()
|
| 93 |
+
fireEvent.click(screen.getByRole('button', { name: 'Confirm with device biometrics' }))
|
| 94 |
+
expect(await screen.findByTestId('processing')).toBeInTheDocument()
|
| 95 |
+
expect(screen.queryByTestId('success')).toBeNull()
|
| 96 |
})
|
| 97 |
|
| 98 |
+
it('shows a receipt and records a session activity event on a terminal result', async () => {
|
| 99 |
+
setActiveUser({ userId: 'bob', via: 'passkey' })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment({ status: 'succeeded' }))
|
| 101 |
vi.spyOn(payApi, 'getPayment').mockResolvedValue(mkPayment({ status: 'succeeded' }))
|
| 102 |
+
renderPay(); await toAuth()
|
| 103 |
+
fireEvent.click(screen.getByRole('button', { name: 'Confirm with device biometrics' }))
|
| 104 |
+
expect(await screen.findByTestId('receipt')).toBeInTheDocument()
|
| 105 |
+
expect(screen.getByTestId('success')).toBeInTheDocument()
|
| 106 |
expect(screen.queryByRole('button', { name: 'Cancel payment' })).toBeNull()
|
| 107 |
+
await waitFor(() => expect(listActivity('payment').length).toBe(1))
|
| 108 |
+
})
|
| 109 |
+
|
| 110 |
+
it('shows Cancel only in a cancellable (non-terminal) state', async () => {
|
| 111 |
+
setActiveUser({ userId: 'bob', via: 'passkey' })
|
| 112 |
+
vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment({ status: 'requires_customer_action' }))
|
| 113 |
+
renderPay(); await toAuth()
|
| 114 |
+
fireEvent.click(screen.getByRole('button', { name: 'Confirm with device biometrics' }))
|
| 115 |
+
await waitFor(() => expect(screen.getByRole('button', { name: 'Cancel payment' })).toBeInTheDocument())
|
| 116 |
})
|
| 117 |
})
|
web/src/pages/PayPage.tsx
CHANGED
|
@@ -3,37 +3,42 @@ import {
|
|
| 3 |
cancelPayment, createPayment, getPayment, getProviders, mockAdvance, refundPayment,
|
| 4 |
} from '../api/payments'
|
| 5 |
import { usePaymentPolling } from '../payments/usePaymentPolling'
|
| 6 |
-
import {
|
| 7 |
-
isCancellable, isRefundable, isSuccess, isTerminal, statusHelpKey,
|
| 8 |
-
} from '../payments/status'
|
| 9 |
import { formatMoney, toMinor } from '../payments/money'
|
| 10 |
-
import {
|
| 11 |
-
import { isSafeExternalUrl } from '../utils/url'
|
| 12 |
import { validateAmount, validateIban, validateMerchant } from '../utils/validate'
|
| 13 |
import { localizeError } from '../utils/errors'
|
| 14 |
import { useSession } from '../hooks/useSession'
|
| 15 |
import { useI18n } from '../i18n'
|
| 16 |
import { Button, Field, StatusBadge, Callout, Spinner } from '../components/ui'
|
| 17 |
-
import {
|
| 18 |
import type { MessageKey } from '../i18n/en'
|
| 19 |
import type { PaymentView, ProviderCapabilities } from '../types'
|
| 20 |
|
| 21 |
const DEMO_IBAN = 'SA0380000000608010167519'
|
| 22 |
-
const
|
|
|
|
| 23 |
|
| 24 |
function newIdemKey(): string {
|
| 25 |
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
| 26 |
-
? crypto.randomUUID()
|
| 27 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 28 |
}
|
| 29 |
|
| 30 |
-
export function PayPage({ onNavEnroll }: { onNavEnroll: () => void }) {
|
| 31 |
const { t, locale } = useI18n()
|
| 32 |
const { activeUser } = useSession()
|
|
|
|
| 33 |
const [amount, setAmount] = useState('150.50')
|
| 34 |
const [merchant, setMerchant] = useState('Blue Bottle')
|
| 35 |
const [iban, setIban] = useState(DEMO_IBAN)
|
| 36 |
const [reference, setReference] = useState('')
|
|
|
|
| 37 |
const [created, setCreated] = useState<PaymentView | null>(null)
|
| 38 |
const [submitting, setSubmitting] = useState(false)
|
| 39 |
const [busy, setBusy] = useState<string | null>(null)
|
|
@@ -42,9 +47,9 @@ export function PayPage({ onNavEnroll }: { onNavEnroll: () => void }) {
|
|
| 42 |
const [mockActive, setMockActive] = useState(false)
|
| 43 |
const idemRef = useRef<string>(newIdemKey())
|
| 44 |
const submittingRef = useRef(false)
|
|
|
|
| 45 |
|
| 46 |
const { payment: polled } = usePaymentPolling(created?.id ?? null)
|
| 47 |
-
// Prefer whichever snapshot is newer so manual actions reflect immediately.
|
| 48 |
const payment = useMemo<PaymentView | null>(() => {
|
| 49 |
if (polled && created) return polled.updated_at >= created.updated_at ? polled : created
|
| 50 |
return polled ?? created
|
|
@@ -64,96 +69,173 @@ export function PayPage({ onNavEnroll }: { onNavEnroll: () => void }) {
|
|
| 64 |
const amountErr = useMemo(() => validateAmount(amount), [amount])
|
| 65 |
const merchantErr = useMemo(() => validateMerchant(merchant), [merchant])
|
| 66 |
const ibanErr = useMemo(() => validateIban(iban), [iban])
|
| 67 |
-
const formInvalid = !!(amountErr || merchantErr || ibanErr)
|
| 68 |
const err = (k?: MessageKey) => (k ? t(k) : undefined)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
|
| 70 |
-
async function
|
| 71 |
if (submittingRef.current || created) return // duplicate-submission guard
|
| 72 |
if (!activeUser) { setError(t('error.noActiveUser')); return }
|
| 73 |
-
if (
|
| 74 |
-
submittingRef.current = true
|
| 75 |
-
setSubmitting(true)
|
| 76 |
-
setError(null)
|
| 77 |
try {
|
| 78 |
const p = await createPayment({
|
| 79 |
-
userId: activeUser.userId,
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
payeeIban: iban.trim(),
|
| 83 |
-
description: reference.trim(),
|
| 84 |
-
country: 'SA',
|
| 85 |
-
currency: 'SAR',
|
| 86 |
-
idempotencyKey: idemRef.current, // same key on retry -> backend idempotency
|
| 87 |
})
|
| 88 |
setCreated(p)
|
| 89 |
} catch (e) {
|
| 90 |
setError(localizeError(e, t, 'error.paymentCreate'))
|
| 91 |
} finally {
|
| 92 |
-
submittingRef.current = false
|
| 93 |
-
setSubmitting(false)
|
| 94 |
}
|
| 95 |
}
|
| 96 |
|
| 97 |
-
// Runs an action against the real backend, then refreshes to the authoritative status.
|
| 98 |
async function run(label: string, fn: () => Promise<unknown>, fallback: MessageKey) {
|
| 99 |
if (!created) return
|
| 100 |
setBusy(label); setError(null)
|
| 101 |
try {
|
| 102 |
const r = await fn()
|
| 103 |
-
const fresh = r && typeof r === 'object' && 'status' in r
|
| 104 |
-
? (r as PaymentView)
|
| 105 |
-
: await getPayment(created.id)
|
| 106 |
setCreated(fresh)
|
| 107 |
} catch (e) {
|
| 108 |
setError(localizeError(e, t, fallback))
|
| 109 |
-
} finally {
|
| 110 |
-
setBusy(null)
|
| 111 |
-
}
|
| 112 |
}
|
| 113 |
|
| 114 |
function reset() {
|
| 115 |
-
setCreated(null)
|
| 116 |
-
|
| 117 |
-
idemRef.current = newIdemKey() // a NEW action gets a NEW idempotency key
|
| 118 |
}
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
const canCancel = payment && isCancellable(payment.status) && caps?.supports_cancel !== false
|
| 123 |
const canRefund = payment && isRefundable(payment.status) && caps?.supports_refund !== false
|
| 124 |
const remaining = payment ? payment.amount_minor - payment.refunded_minor : 0
|
| 125 |
const showScenarios = mockActive && payment && !isTerminal(payment.status)
|
|
|
|
| 126 |
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
<
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
{error && <Callout tone="bad">{error}</Callout>}
|
| 156 |
-
|
| 157 |
{payment && (
|
| 158 |
<div className="receipt">
|
| 159 |
<div className="receipt-row">
|
|
@@ -162,104 +244,58 @@ export function PayPage({ onNavEnroll }: { onNavEnroll: () => void }) {
|
|
| 162 |
</div>
|
| 163 |
<p className="status-help">{t(statusHelpKey(payment.status) as MessageKey)}</p>
|
| 164 |
|
| 165 |
-
{/* Payment summary */}
|
| 166 |
-
<dl className="summary-grid">
|
| 167 |
-
<dt>{t('pay.merchant')}</dt><dd>{merchant}</dd>
|
| 168 |
-
<dt>{t('pay.iban')}</dt><dd className="mono" dir="ltr">{iban}</dd>
|
| 169 |
-
{activeUser && (<><dt>{t('nav.enroll')}</dt><dd>{activeUser.userId}</dd></>)}
|
| 170 |
-
</dl>
|
| 171 |
-
|
| 172 |
-
{/* Customer-action (simulated bank authorization) */}
|
| 173 |
{payment.status === 'requires_customer_action' && (
|
| 174 |
<Callout tone="warn">
|
| 175 |
<p>{t('pay.customerAction')}</p>
|
| 176 |
<p className="hint">{t('pay.mockBankInfo')}</p>
|
| 177 |
-
<
|
| 178 |
-
{
|
| 179 |
-
<a className="btn" href={actionUrl} target="_blank" rel="noopener noreferrer">
|
| 180 |
-
{t('pay.openBank')}
|
| 181 |
-
</a>
|
| 182 |
-
)}
|
| 183 |
-
<Button onClick={() => run('settle', () => mockAdvance(payment.id, 'SETTLED'), 'error.generic')}
|
| 184 |
-
disabled={busy !== null}>
|
| 185 |
-
{t('pay.simulateSettle')}
|
| 186 |
-
</Button>
|
| 187 |
-
</div>
|
| 188 |
</Callout>
|
| 189 |
)}
|
| 190 |
-
|
| 191 |
{(payment.status === 'processing' || payment.status === 'authorizing') && (
|
| 192 |
-
<Callout tone="warn"><Spinner label={t('pay.processing')} /></Callout>
|
| 193 |
)}
|
|
|
|
| 194 |
|
| 195 |
-
{
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
<dt>{t('pay.
|
| 201 |
-
<
|
| 202 |
-
<dt>{t('pay.
|
| 203 |
-
<dd>{
|
| 204 |
-
<dt>{t('pay.
|
| 205 |
-
<dd>{formatMoney(remaining, payment.currency, locale)}</dd>
|
| 206 |
</dl>
|
| 207 |
)}
|
| 208 |
|
| 209 |
-
{/* Cancel — only when the state + provider allow it */}
|
| 210 |
{canCancel && (
|
| 211 |
-
<Button onClick={() => run('cancel', () => cancelPayment(payment.id), 'error.cancel')}
|
| 212 |
-
|
| 213 |
-
{busy === 'cancel' ? <Spinner label={t('pay.submitting')} /> : t('pay.cancel')}
|
| 214 |
-
</Button>
|
| 215 |
)}
|
| 216 |
-
|
| 217 |
-
{/* Demo refund actions — use the REAL Payment Core refund API */}
|
| 218 |
{canRefund && remaining > 0 && (
|
| 219 |
<div className="row">
|
| 220 |
-
<Button onClick={() => run('prefund', () => refundPayment(payment.id, Math.floor(remaining / 2), 'demo'), 'error.refund')}
|
| 221 |
-
|
| 222 |
-
{t('scenario.partialRefund')}
|
| 223 |
-
</Button>
|
| 224 |
-
<Button onClick={() => run('frefund', () => refundPayment(payment.id, undefined, 'demo'), 'error.refund')}
|
| 225 |
-
disabled={busy !== null}>
|
| 226 |
-
{t('scenario.fullRefund')}
|
| 227 |
-
</Button>
|
| 228 |
</div>
|
| 229 |
)}
|
| 230 |
-
|
| 231 |
-
{/* Demo scenario selector — mock provider only, real backend transitions */}
|
| 232 |
{showScenarios && (
|
| 233 |
<details className="scenario">
|
| 234 |
<summary>{t('scenario.title')}</summary>
|
| 235 |
-
<p className="hint">{t('scenario.hint')}</p>
|
| 236 |
<div className="row wrap">
|
| 237 |
-
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'AUTH_IN_PROGRESS'), 'error.generic')} disabled={busy !== null}>{t('scenario.authorizing')}</Button>
|
| 238 |
-
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'SETTLING'), 'error.generic')} disabled={busy !== null}>{t('scenario.processing')}</Button>
|
| 239 |
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'SETTLED'), 'error.generic')} disabled={busy !== null}>{t('scenario.approve')}</Button>
|
| 240 |
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'DECLINED'), 'error.generic')} disabled={busy !== null}>{t('scenario.decline')}</Button>
|
| 241 |
-
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'TIMED_OUT'), 'error.generic')} disabled={busy !== null}>{t('scenario.expire')}</Button>
|
| 242 |
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'VOIDED'), 'error.generic')} disabled={busy !== null}>{t('scenario.cancelProvider')}</Button>
|
| 243 |
</div>
|
| 244 |
</details>
|
| 245 |
)}
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
| 249 |
)}
|
| 250 |
-
|
| 251 |
-
{/* Technical / support details */}
|
| 252 |
-
<details className="support">
|
| 253 |
-
<summary>{t('tech.title')}</summary>
|
| 254 |
-
<dl className="tech-grid mono" dir="ltr">
|
| 255 |
-
<dt>{t('tech.paymentId')}</dt><dd>{payment.id}</dd>
|
| 256 |
-
<dt>{t('tech.providerRef')}</dt><dd>{payment.provider_ref ?? '—'}</dd>
|
| 257 |
-
<dt>{t('tech.correlationId')}</dt><dd>{payment.correlation_id}</dd>
|
| 258 |
-
<dt>{t('tech.requestId')}</dt><dd>{getLastRequestId() ?? '—'}</dd>
|
| 259 |
-
<dt>{t('tech.created')}</dt><dd>{formatDateTime(payment.created_at, locale)}</dd>
|
| 260 |
-
<dt>{t('tech.updated')}</dt><dd>{formatDateTime(payment.updated_at, locale)}</dd>
|
| 261 |
-
</dl>
|
| 262 |
-
</details>
|
| 263 |
</div>
|
| 264 |
)}
|
| 265 |
</section>
|
|
|
|
| 3 |
cancelPayment, createPayment, getPayment, getProviders, mockAdvance, refundPayment,
|
| 4 |
} from '../api/payments'
|
| 5 |
import { usePaymentPolling } from '../payments/usePaymentPolling'
|
| 6 |
+
import { isCancellable, isRefundable, isSuccess, isTerminal, statusHelpKey } from '../payments/status'
|
|
|
|
|
|
|
| 7 |
import { formatMoney, toMinor } from '../payments/money'
|
| 8 |
+
import { formatDateTimeSafe } from '../utils/format'
|
|
|
|
| 9 |
import { validateAmount, validateIban, validateMerchant } from '../utils/validate'
|
| 10 |
import { localizeError } from '../utils/errors'
|
| 11 |
import { useSession } from '../hooks/useSession'
|
| 12 |
import { useI18n } from '../i18n'
|
| 13 |
import { Button, Field, StatusBadge, Callout, Spinner } from '../components/ui'
|
| 14 |
+
import { addActivity } from '../activity/store'
|
| 15 |
import type { MessageKey } from '../i18n/en'
|
| 16 |
import type { PaymentView, ProviderCapabilities } from '../types'
|
| 17 |
|
| 18 |
const DEMO_IBAN = 'SA0380000000608010167519'
|
| 19 |
+
const STAGES = ['payee', 'amount', 'rail', 'review', 'auth'] as const
|
| 20 |
+
type Stage = (typeof STAGES)[number]
|
| 21 |
|
| 22 |
function newIdemKey(): string {
|
| 23 |
return typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
| 24 |
+
? crypto.randomUUID() : `idem-${Date.now()}-${Math.random().toString(16).slice(2)}`
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
/** Mask a destination IBAN — never show/log the full value unnecessarily. */
|
| 28 |
+
function maskIban(iban: string): string {
|
| 29 |
+
const v = iban.replace(/\s+/g, '').toUpperCase()
|
| 30 |
+
return v.length >= 8 ? `${v.slice(0, 4)} **** **** ${v.slice(-4)}` : '****'
|
| 31 |
}
|
| 32 |
|
| 33 |
+
export function PayPage({ onNavEnroll, onNav }: { onNavEnroll: () => void; onNav?: (r: string) => void }) {
|
| 34 |
const { t, locale } = useI18n()
|
| 35 |
const { activeUser } = useSession()
|
| 36 |
+
const [stage, setStage] = useState<Stage>('payee')
|
| 37 |
const [amount, setAmount] = useState('150.50')
|
| 38 |
const [merchant, setMerchant] = useState('Blue Bottle')
|
| 39 |
const [iban, setIban] = useState(DEMO_IBAN)
|
| 40 |
const [reference, setReference] = useState('')
|
| 41 |
+
const [rail, setRail] = useState('sarie')
|
| 42 |
const [created, setCreated] = useState<PaymentView | null>(null)
|
| 43 |
const [submitting, setSubmitting] = useState(false)
|
| 44 |
const [busy, setBusy] = useState<string | null>(null)
|
|
|
|
| 47 |
const [mockActive, setMockActive] = useState(false)
|
| 48 |
const idemRef = useRef<string>(newIdemKey())
|
| 49 |
const submittingRef = useRef(false)
|
| 50 |
+
const loggedRef = useRef(false)
|
| 51 |
|
| 52 |
const { payment: polled } = usePaymentPolling(created?.id ?? null)
|
|
|
|
| 53 |
const payment = useMemo<PaymentView | null>(() => {
|
| 54 |
if (polled && created) return polled.updated_at >= created.updated_at ? polled : created
|
| 55 |
return polled ?? created
|
|
|
|
| 69 |
const amountErr = useMemo(() => validateAmount(amount), [amount])
|
| 70 |
const merchantErr = useMemo(() => validateMerchant(merchant), [merchant])
|
| 71 |
const ibanErr = useMemo(() => validateIban(iban), [iban])
|
|
|
|
| 72 |
const err = (k?: MessageKey) => (k ? t(k) : undefined)
|
| 73 |
+
const newPayee = iban.replace(/\s+/g, '') !== DEMO_IBAN
|
| 74 |
+
const rails = mockActive ? [{ id: 'sarie', name: 'SARIE (Mock)', available: true }] : []
|
| 75 |
+
|
| 76 |
+
// Any edit before creation clears a possible stale approval + idempotency key.
|
| 77 |
+
function editField(setter: (v: string) => void) {
|
| 78 |
+
return (v: string) => { setter(v); idemRef.current = newIdemKey() }
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
function goReview() { setStage('review') }
|
| 82 |
+
function editFrom(target: Stage) { setError(null); setStage(target) } // material change -> re-review
|
| 83 |
|
| 84 |
+
async function onConfirm() {
|
| 85 |
if (submittingRef.current || created) return // duplicate-submission guard
|
| 86 |
if (!activeUser) { setError(t('error.noActiveUser')); return }
|
| 87 |
+
if (amountErr || merchantErr || ibanErr) return
|
| 88 |
+
submittingRef.current = true; setSubmitting(true); setError(null)
|
|
|
|
|
|
|
| 89 |
try {
|
| 90 |
const p = await createPayment({
|
| 91 |
+
userId: activeUser.userId, amountMinor: toMinor(amount, 'SAR'),
|
| 92 |
+
merchantId: merchant.trim(), payeeIban: iban.trim(), description: reference.trim(),
|
| 93 |
+
country: 'SA', currency: 'SAR', idempotencyKey: idemRef.current,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
})
|
| 95 |
setCreated(p)
|
| 96 |
} catch (e) {
|
| 97 |
setError(localizeError(e, t, 'error.paymentCreate'))
|
| 98 |
} finally {
|
| 99 |
+
submittingRef.current = false; setSubmitting(false)
|
|
|
|
| 100 |
}
|
| 101 |
}
|
| 102 |
|
|
|
|
| 103 |
async function run(label: string, fn: () => Promise<unknown>, fallback: MessageKey) {
|
| 104 |
if (!created) return
|
| 105 |
setBusy(label); setError(null)
|
| 106 |
try {
|
| 107 |
const r = await fn()
|
| 108 |
+
const fresh = r && typeof r === 'object' && 'status' in r ? (r as PaymentView) : await getPayment(created.id)
|
|
|
|
|
|
|
| 109 |
setCreated(fresh)
|
| 110 |
} catch (e) {
|
| 111 |
setError(localizeError(e, t, fallback))
|
| 112 |
+
} finally { setBusy(null) }
|
|
|
|
|
|
|
| 113 |
}
|
| 114 |
|
| 115 |
function reset() {
|
| 116 |
+
setCreated(null); setError(null); setStage('payee'); loggedRef.current = false
|
| 117 |
+
idemRef.current = newIdemKey()
|
|
|
|
| 118 |
}
|
| 119 |
|
| 120 |
+
// Record a session activity event once the payment reaches a terminal state.
|
| 121 |
+
useEffect(() => {
|
| 122 |
+
if (payment && isTerminal(payment.status) && !loggedRef.current) {
|
| 123 |
+
loggedRef.current = true
|
| 124 |
+
addActivity({ kind: 'payment', title: merchant, status: payment.status,
|
| 125 |
+
amount: formatMoney(payment.amount_minor, payment.currency, locale),
|
| 126 |
+
reference: reference || payment.id })
|
| 127 |
+
}
|
| 128 |
+
}, [payment, merchant, reference, locale])
|
| 129 |
+
|
| 130 |
const canCancel = payment && isCancellable(payment.status) && caps?.supports_cancel !== false
|
| 131 |
const canRefund = payment && isRefundable(payment.status) && caps?.supports_refund !== false
|
| 132 |
const remaining = payment ? payment.amount_minor - payment.refunded_minor : 0
|
| 133 |
const showScenarios = mockActive && payment && !isTerminal(payment.status)
|
| 134 |
+
const stepNo = STAGES.indexOf(stage) + 1
|
| 135 |
|
| 136 |
+
// ---- pre-creation staged flow ---- //
|
| 137 |
+
if (!created) {
|
| 138 |
+
return (
|
| 139 |
+
<section className="card pay-flow" aria-labelledby="pay-h">
|
| 140 |
+
<div className="row-between">
|
| 141 |
+
<h2 id="pay-h">{t('pay.title')}</h2>
|
| 142 |
+
<span className="badge tone-info" data-testid="pay-step">
|
| 143 |
+
{t('pay.stepOf', { n: stepNo, total: STAGES.length })} · {t(`pay.stage.${stage}` as MessageKey)}
|
| 144 |
+
</span>
|
| 145 |
+
</div>
|
| 146 |
+
{!activeUser && (
|
| 147 |
+
<Callout tone="info">{t('pay.needLogin')}{' '}
|
| 148 |
+
<button className="linklike" onClick={onNavEnroll}>{t('nav.enroll')}</button></Callout>
|
| 149 |
+
)}
|
| 150 |
+
{activeUser && <p className="paying-as">{t('pay.payingAs', { user: activeUser.userId })}</p>}
|
| 151 |
+
{error && <Callout tone="bad">{error}</Callout>}
|
| 152 |
|
| 153 |
+
{stage === 'payee' && (
|
| 154 |
+
<div data-testid="stage-payee">
|
| 155 |
+
<Field id="p_merch" label={t('pay.merchant')} value={merchant}
|
| 156 |
+
onChange={(e) => editField(setMerchant)(e.target.value)} error={err(merchantErr)} />
|
| 157 |
+
<Field id="p_iban" label={t('pay.iban')} value={iban}
|
| 158 |
+
onChange={(e) => editField(setIban)(e.target.value)} error={err(ibanErr)} />
|
| 159 |
+
{newPayee && <Callout tone="warn">{t('pay.newPayeeNote')}</Callout>}
|
| 160 |
+
<Button onClick={() => setStage('amount')} disabled={!!merchantErr || !!ibanErr}>{t('pay.next')}</Button>
|
| 161 |
+
</div>
|
| 162 |
+
)}
|
| 163 |
+
{stage === 'amount' && (
|
| 164 |
+
<div data-testid="stage-amount">
|
| 165 |
+
<Field id="p_amt" label={t('pay.amount')} value={amount} inputMode="decimal"
|
| 166 |
+
onChange={(e) => editField(setAmount)(e.target.value)} error={err(amountErr)} />
|
| 167 |
+
<Field id="p_ref" label={t('pay.description')} value={reference}
|
| 168 |
+
onChange={(e) => setReference(e.target.value)} />
|
| 169 |
+
<div className="row">
|
| 170 |
+
<Button onClick={() => setStage('payee')}>{t('pay.back')}</Button>
|
| 171 |
+
<Button onClick={() => setStage('rail')} disabled={!!amountErr}>{t('pay.next')}</Button>
|
| 172 |
+
</div>
|
| 173 |
+
</div>
|
| 174 |
+
)}
|
| 175 |
+
{stage === 'rail' && (
|
| 176 |
+
<div data-testid="stage-rail">
|
| 177 |
+
<h3>{t('pay.rail.title')}</h3>
|
| 178 |
+
{rails.length === 0 && <Callout tone="warn">{t('pay.rail.none')}</Callout>}
|
| 179 |
+
{rails.map((r) => (
|
| 180 |
+
<label key={r.id} className={`rail-option ${rail === r.id ? 'selected' : ''}`}>
|
| 181 |
+
<input type="radio" name="rail" checked={rail === r.id} onChange={() => setRail(r.id)} />
|
| 182 |
+
<span className="rail-name">{r.name}</span>
|
| 183 |
+
<span className="badge tone-ok">{t('pay.rail.available')}</span>
|
| 184 |
+
<span className="muted small">{t('pay.rail.speed')}: {t('pay.rail.instant')}</span>
|
| 185 |
+
<span className="muted small">{t('pay.rail.feeUnavailable')}</span>
|
| 186 |
+
</label>
|
| 187 |
+
))}
|
| 188 |
+
<div className="row">
|
| 189 |
+
<Button onClick={() => setStage('amount')}>{t('pay.back')}</Button>
|
| 190 |
+
<Button onClick={goReview} disabled={rails.length === 0}>{t('pay.next')}</Button>
|
| 191 |
+
</div>
|
| 192 |
+
</div>
|
| 193 |
+
)}
|
| 194 |
+
{stage === 'review' && (
|
| 195 |
+
<div data-testid="stage-review" className="review">
|
| 196 |
+
<h3>{t('pay.stage.review')}</h3>
|
| 197 |
+
<dl className="summary-grid">
|
| 198 |
+
<dt>{t('pay.merchant')}</dt><dd>{merchant} {newPayee && <span className="badge tone-warn">{t('pay.newPayeeNote')}</span>}</dd>
|
| 199 |
+
<dt>{t('pay.destination')}</dt><dd className="mono" dir="ltr">{maskIban(iban)}</dd>
|
| 200 |
+
<dt>{t('pay.amount')}</dt><dd>{formatMoney(toMinor(amount, 'SAR'), 'SAR', locale)}</dd>
|
| 201 |
+
<dt>{t('pay.review.fee')}</dt><dd>{t('pay.rail.feeUnavailable')}</dd>
|
| 202 |
+
<dt>{t('pay.review.rail')}</dt><dd>SARIE (Mock)</dd>
|
| 203 |
+
<dt>{t('pay.review.reference')}</dt><dd>{reference || '—'}</dd>
|
| 204 |
+
<dt>{t('pay.review.authReq')}</dt><dd>{t('bio.confirmDevice')}</dd>
|
| 205 |
+
<dt>{t('pay.review.reason')}</dt><dd>{t('pay.deviceConfirm')}</dd>
|
| 206 |
+
</dl>
|
| 207 |
+
<p className="muted small">{t('pay.review.editNote')}</p>
|
| 208 |
+
<div className="row wrap">
|
| 209 |
+
<Button onClick={() => editFrom('payee')}>{t('pay.edit')} · {t('pay.stage.payee')}</Button>
|
| 210 |
+
<Button onClick={() => editFrom('amount')}>{t('pay.edit')} · {t('pay.stage.amount')}</Button>
|
| 211 |
+
<Button onClick={() => editFrom('rail')}>{t('pay.edit')} · {t('pay.stage.rail')}</Button>
|
| 212 |
+
<Button onClick={() => setStage('auth')}>{t('pay.next')}</Button>
|
| 213 |
+
</div>
|
| 214 |
+
</div>
|
| 215 |
+
)}
|
| 216 |
+
{stage === 'auth' && (
|
| 217 |
+
<div data-testid="stage-auth">
|
| 218 |
+
<h3>{t('pay.authTitle')}</h3>
|
| 219 |
+
<Callout tone="info">{t('security.device.explain')}</Callout>
|
| 220 |
+
<p className="muted">{t('pay.deviceConfirm')}</p>
|
| 221 |
+
<div className="row">
|
| 222 |
+
<Button onClick={() => setStage('review')}>{t('pay.back')}</Button>
|
| 223 |
+
<Button onClick={onConfirm} disabled={!activeUser || submitting} aria-busy={submitting}>
|
| 224 |
+
{submitting ? <Spinner label={t('pay.submitting')} /> : t('bio.confirmDevice')}
|
| 225 |
+
</Button>
|
| 226 |
+
</div>
|
| 227 |
+
</div>
|
| 228 |
+
)}
|
| 229 |
+
</section>
|
| 230 |
+
)
|
| 231 |
+
}
|
| 232 |
|
| 233 |
+
// ---- processing + receipt (post-creation) ---- //
|
| 234 |
+
const terminal = payment && isTerminal(payment.status)
|
| 235 |
+
return (
|
| 236 |
+
<section className="card pay-flow" aria-labelledby="pay-h2">
|
| 237 |
+
<h2 id="pay-h2">{terminal ? t('pay.receiptTitle') : t('pay.processingTitle')}</h2>
|
| 238 |
{error && <Callout tone="bad">{error}</Callout>}
|
|
|
|
| 239 |
{payment && (
|
| 240 |
<div className="receipt">
|
| 241 |
<div className="receipt-row">
|
|
|
|
| 244 |
</div>
|
| 245 |
<p className="status-help">{t(statusHelpKey(payment.status) as MessageKey)}</p>
|
| 246 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
{payment.status === 'requires_customer_action' && (
|
| 248 |
<Callout tone="warn">
|
| 249 |
<p>{t('pay.customerAction')}</p>
|
| 250 |
<p className="hint">{t('pay.mockBankInfo')}</p>
|
| 251 |
+
<Button onClick={() => run('settle', () => mockAdvance(payment.id, 'SETTLED'), 'error.generic')} disabled={busy !== null}>
|
| 252 |
+
{t('pay.simulateSettle')}</Button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
</Callout>
|
| 254 |
)}
|
|
|
|
| 255 |
{(payment.status === 'processing' || payment.status === 'authorizing') && (
|
| 256 |
+
<Callout tone="warn"><span data-testid="processing"><Spinner label={t('pay.processing')} /></span></Callout>
|
| 257 |
)}
|
| 258 |
+
{isSuccess(payment.status) && <Callout tone="ok"><span data-testid="success">{t('pay.succeeded')}</span></Callout>}
|
| 259 |
|
| 260 |
+
{terminal && (
|
| 261 |
+
<dl className="summary-grid" data-testid="receipt">
|
| 262 |
+
<dt>{t('pay.merchant')}</dt><dd>{merchant}</dd>
|
| 263 |
+
<dt>{t('pay.destination')}</dt><dd className="mono" dir="ltr">{maskIban(iban)}</dd>
|
| 264 |
+
<dt>{t('pay.amount')}</dt><dd>{formatMoney(payment.amount_minor, payment.currency, locale)}</dd>
|
| 265 |
+
<dt>{t('pay.review.fee')}</dt><dd>{t('pay.rail.feeUnavailable')}</dd>
|
| 266 |
+
<dt>{t('pay.review.rail')}</dt><dd>SARIE (Mock)</dd>
|
| 267 |
+
<dt>{t('pay.status.label')}</dt><dd><StatusBadge status={payment.status} /></dd>
|
| 268 |
+
<dt>{t('pay.dateTime')}</dt><dd>{formatDateTimeSafe(payment.updated_at, locale) ?? t('date.unavailable')}</dd>
|
| 269 |
+
<dt>{t('pay.review.reference')}</dt><dd>{reference || payment.id}</dd>
|
|
|
|
| 270 |
</dl>
|
| 271 |
)}
|
| 272 |
|
|
|
|
| 273 |
{canCancel && (
|
| 274 |
+
<Button onClick={() => run('cancel', () => cancelPayment(payment.id), 'error.cancel')} disabled={busy !== null}>
|
| 275 |
+
{busy === 'cancel' ? <Spinner label={t('pay.submitting')} /> : t('pay.cancel')}</Button>
|
|
|
|
|
|
|
| 276 |
)}
|
|
|
|
|
|
|
| 277 |
{canRefund && remaining > 0 && (
|
| 278 |
<div className="row">
|
| 279 |
+
<Button onClick={() => run('prefund', () => refundPayment(payment.id, Math.floor(remaining / 2), 'demo'), 'error.refund')} disabled={busy !== null}>{t('scenario.partialRefund')}</Button>
|
| 280 |
+
<Button onClick={() => run('frefund', () => refundPayment(payment.id, undefined, 'demo'), 'error.refund')} disabled={busy !== null}>{t('scenario.fullRefund')}</Button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 281 |
</div>
|
| 282 |
)}
|
|
|
|
|
|
|
| 283 |
{showScenarios && (
|
| 284 |
<details className="scenario">
|
| 285 |
<summary>{t('scenario.title')}</summary>
|
|
|
|
| 286 |
<div className="row wrap">
|
|
|
|
|
|
|
| 287 |
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'SETTLED'), 'error.generic')} disabled={busy !== null}>{t('scenario.approve')}</Button>
|
| 288 |
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'DECLINED'), 'error.generic')} disabled={busy !== null}>{t('scenario.decline')}</Button>
|
|
|
|
| 289 |
<Button onClick={() => run('s', () => mockAdvance(payment.id, 'VOIDED'), 'error.generic')} disabled={busy !== null}>{t('scenario.cancelProvider')}</Button>
|
| 290 |
</div>
|
| 291 |
</details>
|
| 292 |
)}
|
| 293 |
+
{terminal && (
|
| 294 |
+
<div className="row wrap">
|
| 295 |
+
<Button onClick={reset}>{t('pay.newPayment')}</Button>
|
| 296 |
+
{onNav && <Button onClick={() => onNav('activity')}>{t('pay.viewActivity')}</Button>}
|
| 297 |
+
</div>
|
| 298 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 299 |
</div>
|
| 300 |
)}
|
| 301 |
</section>
|
web/src/pages/SecurityPage.test.tsx
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
| 2 |
+
import { render, screen, waitFor } from '@testing-library/react'
|
| 3 |
+
import { I18nProvider } from '../i18n'
|
| 4 |
+
import { SecurityPage } from './SecurityPage'
|
| 5 |
+
import * as ai from '../api/ai'
|
| 6 |
+
|
| 7 |
+
vi.mock('../api/ai')
|
| 8 |
+
|
| 9 |
+
const renderSec = () =>
|
| 10 |
+
render(<I18nProvider initial="en"><SecurityPage onNav={vi.fn()} /></I18nProvider>)
|
| 11 |
+
|
| 12 |
+
describe('SecurityPage', () => {
|
| 13 |
+
beforeEach(() => {
|
| 14 |
+
vi.spyOn(ai, 'modelStatus').mockResolvedValue({
|
| 15 |
+
behavioural_model: 'logreg', model_version: 'v1', feature_version: 'features-v1',
|
| 16 |
+
reason_codes_version: 'reason_codes-v1', shadow_only: true, affects_payment: false,
|
| 17 |
+
label: 'DEMO ONLY',
|
| 18 |
+
})
|
| 19 |
+
})
|
| 20 |
+
afterEach(() => vi.restoreAllMocks())
|
| 21 |
+
|
| 22 |
+
it('explains device authentication without fingerprint upload', () => {
|
| 23 |
+
renderSec()
|
| 24 |
+
expect(screen.getByText(/stays on your device/i)).toBeInTheDocument()
|
| 25 |
+
expect(screen.queryByText(/upload fingerprint/i)).toBeNull()
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
it('shows shadow-mode disclosure and that the model does not change payments', async () => {
|
| 29 |
+
renderSec()
|
| 30 |
+
await waitFor(() => expect(ai.modelStatus).toHaveBeenCalled())
|
| 31 |
+
expect(screen.getByText('Shadow mode')).toBeInTheDocument()
|
| 32 |
+
expect(screen.getByText('Advisory only')).toBeInTheDocument()
|
| 33 |
+
expect(await screen.findByText(/does not approve, deny or modify payments/i)).toBeInTheDocument()
|
| 34 |
+
expect(await screen.findByText(/Not evidence of real-world/i)).toBeInTheDocument()
|
| 35 |
+
})
|
| 36 |
+
|
| 37 |
+
it('never renders a raw risk score or threshold', async () => {
|
| 38 |
+
const { container } = renderSec()
|
| 39 |
+
await waitFor(() => expect(ai.modelStatus).toHaveBeenCalled())
|
| 40 |
+
expect(container.textContent).not.toMatch(/score|threshold|coefficient/i)
|
| 41 |
+
})
|
| 42 |
+
})
|
web/src/pages/SecurityPage.tsx
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useState } from 'react'
|
| 2 |
+
import { useI18n } from '../i18n'
|
| 3 |
+
import { useSession } from '../hooks/useSession'
|
| 4 |
+
import { Callout, Spinner } from '../components/ui'
|
| 5 |
+
import { ShadowBadges, ShadowFootnote } from '../components/ShadowBadge'
|
| 6 |
+
import { modelStatus, type ModelStatus } from '../api/ai'
|
| 7 |
+
|
| 8 |
+
/** Customer-facing security page: device authentication + behavioural-protection (shadow) status.
|
| 9 |
+
* Never exposes raw scores or thresholds. */
|
| 10 |
+
export function SecurityPage({ onNav }: { onNav: (r: string) => void }) {
|
| 11 |
+
const { t } = useI18n()
|
| 12 |
+
const { activeUser } = useSession()
|
| 13 |
+
const [status, setStatus] = useState<ModelStatus | null>(null)
|
| 14 |
+
const [loading, setLoading] = useState(true)
|
| 15 |
+
const [err, setErr] = useState(false)
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
const c = new AbortController()
|
| 19 |
+
modelStatus(c.signal)
|
| 20 |
+
.then(setStatus)
|
| 21 |
+
.catch(() => setErr(true))
|
| 22 |
+
.finally(() => setLoading(false))
|
| 23 |
+
return () => c.abort()
|
| 24 |
+
}, [])
|
| 25 |
+
|
| 26 |
+
return (
|
| 27 |
+
<section className="page security" aria-labelledby="sec-h">
|
| 28 |
+
<h2 id="sec-h">{t('security.title')}</h2>
|
| 29 |
+
|
| 30 |
+
<div className="card">
|
| 31 |
+
<h3>{t('security.device.title')}</h3>
|
| 32 |
+
<p className="muted">{t('security.device.explain')}</p>
|
| 33 |
+
{activeUser ? (
|
| 34 |
+
<Callout tone="ok">{t('security.device.registered')}</Callout>
|
| 35 |
+
) : (
|
| 36 |
+
<div className="quick-actions">
|
| 37 |
+
<button className="btn primary" onClick={() => onNav('enroll')}>{t('security.device.setup')}</button>
|
| 38 |
+
<button className="btn" onClick={() => onNav('enroll')}>{t('security.device.signin')}</button>
|
| 39 |
+
</div>
|
| 40 |
+
)}
|
| 41 |
+
</div>
|
| 42 |
+
|
| 43 |
+
<div className="card" data-testid="behaviour-card">
|
| 44 |
+
<h3>{t('security.behaviour.title')}</h3>
|
| 45 |
+
<ShadowBadges />
|
| 46 |
+
{loading && <Spinner label={t('common.loading')} />}
|
| 47 |
+
{err && <Callout tone="warn">{t('home.security.unavailable')}</Callout>}
|
| 48 |
+
{status && (
|
| 49 |
+
<>
|
| 50 |
+
<Callout tone="info">{t('security.behaviour.status')}</Callout>
|
| 51 |
+
<p>{t('security.behaviour.noEffect')}</p>
|
| 52 |
+
<ul className="status-list">
|
| 53 |
+
<li><span>Model</span><span className="mono">{status.behavioural_model} · {status.model_version}</span></li>
|
| 54 |
+
<li><span>Features</span><span className="mono">{status.feature_version}</span></li>
|
| 55 |
+
</ul>
|
| 56 |
+
<ShadowFootnote />
|
| 57 |
+
</>
|
| 58 |
+
)}
|
| 59 |
+
</div>
|
| 60 |
+
|
| 61 |
+
<div className="card">
|
| 62 |
+
<h3>{t('security.oob.title')}</h3>
|
| 63 |
+
<p className="muted">{t('security.oob.explain')}</p>
|
| 64 |
+
<button className="btn" onClick={() => onNav('biometrics/oob')}>{t('home.quick.confirm')}</button>
|
| 65 |
+
</div>
|
| 66 |
+
</section>
|
| 67 |
+
)
|
| 68 |
+
}
|
web/src/styles.css
CHANGED
|
@@ -116,3 +116,67 @@ h2 { margin: 0 0 12px; font-size: 18px; }
|
|
| 116 |
.active-user { max-width: 110px; }
|
| 117 |
.modality-grid { grid-template-columns: 1fr; }
|
| 118 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
.active-user { max-width: 110px; }
|
| 117 |
.modality-grid { grid-template-columns: 1fr; }
|
| 118 |
}
|
| 119 |
+
|
| 120 |
+
/* ---------- PR C2.5 — Agentic Security UX ---------- */
|
| 121 |
+
.row-between { display:flex; align-items:center; justify-content:space-between; gap:.75rem; flex-wrap:wrap; }
|
| 122 |
+
.muted { color: var(--muted, #6b7280); }
|
| 123 |
+
.small { font-size: .82rem; }
|
| 124 |
+
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85rem; }
|
| 125 |
+
.linklike { background:none; border:none; color: var(--accent, #2563eb); cursor:pointer; padding:0; text-decoration:underline; font: inherit; }
|
| 126 |
+
.empty-state { padding: 1rem 0; }
|
| 127 |
+
|
| 128 |
+
.quick-actions { display:flex; flex-wrap:wrap; gap:.5rem; margin-top:.5rem; }
|
| 129 |
+
.btn.primary { background: var(--accent, #2563eb); color:#fff; }
|
| 130 |
+
.status-list { list-style:none; padding:0; margin:.5rem 0; }
|
| 131 |
+
.status-list li { display:flex; justify-content:space-between; align-items:center; padding:.35rem 0; border-bottom:1px solid rgba(0,0,0,.06); gap:.5rem; }
|
| 132 |
+
.status-card .callout { margin-bottom:.5rem; }
|
| 133 |
+
|
| 134 |
+
.shadow-badges { display:flex; flex-wrap:wrap; gap:.4rem; margin:.5rem 0; }
|
| 135 |
+
.badge { display:inline-block; padding:.15rem .5rem; border-radius:999px; font-size:.78rem; font-weight:600; }
|
| 136 |
+
|
| 137 |
+
.result-grid { display:grid; grid-template-columns:repeat(auto-fit,minmax(140px,1fr)); gap:.75rem; margin:.75rem 0; }
|
| 138 |
+
.explanation ul { margin:.35rem 0 0; padding-inline-start:1.1rem; }
|
| 139 |
+
|
| 140 |
+
.agent-trace { margin-top:1rem; }
|
| 141 |
+
.trace-list { list-style:none; padding:0; margin:0; }
|
| 142 |
+
.trace-step { border-inline-start:3px solid rgba(0,0,0,.12); padding:.5rem .75rem; margin:.35rem 0; border-radius:.4rem; background:rgba(0,0,0,.02); }
|
| 143 |
+
.trace-step.role-authoritative { border-inline-start-color:#16a34a; }
|
| 144 |
+
.trace-step.role-advisory { border-inline-start-color:#2563eb; }
|
| 145 |
+
.trace-step.role-user { border-inline-start-color:#d97706; }
|
| 146 |
+
.trace-step.role-notExecuted { border-inline-start-color:#dc2626; }
|
| 147 |
+
.trace-step.role-executed { border-inline-start-color:#16a34a; }
|
| 148 |
+
.trace-head { display:flex; justify-content:space-between; align-items:center; gap:.5rem; }
|
| 149 |
+
.trace-summary { display:block; font-size:.85rem; color:var(--muted,#6b7280); margin-top:.15rem; }
|
| 150 |
+
|
| 151 |
+
.dev-panel { margin-top:.75rem; border:1px dashed rgba(0,0,0,.2); border-radius:.5rem; padding:.5rem .75rem; }
|
| 152 |
+
.dev-panel summary { cursor:pointer; font-weight:600; }
|
| 153 |
+
|
| 154 |
+
/* Responsive navigation: desktop tabs vs mobile bottom bar */
|
| 155 |
+
.bottom-nav { display:none; }
|
| 156 |
+
@media (max-width: 720px) {
|
| 157 |
+
.desktop-nav { display:none; }
|
| 158 |
+
.bottom-nav {
|
| 159 |
+
display:flex; position:sticky; bottom:0; inset-inline:0; z-index:20;
|
| 160 |
+
background:var(--bg,#fff); border-top:1px solid rgba(0,0,0,.1);
|
| 161 |
+
justify-content:space-around; padding:.35rem .25rem; gap:.15rem;
|
| 162 |
+
}
|
| 163 |
+
.bnav { flex:1; background:none; border:none; padding:.5rem .25rem; font:inherit; color:var(--muted,#6b7280); min-height:44px; border-radius:.5rem; }
|
| 164 |
+
.bnav.active { color:var(--accent,#2563eb); font-weight:700; background:rgba(37,99,235,.08); }
|
| 165 |
+
.content { padding-bottom:4rem; }
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
/* ---------- PR C2.5 payment journey + activity ---------- */
|
| 169 |
+
.pay-flow .row { display:flex; gap:.5rem; margin-top:.5rem; flex-wrap:wrap; }
|
| 170 |
+
.pay-flow .row.wrap { flex-wrap:wrap; }
|
| 171 |
+
.rail-option { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; padding:.6rem .75rem; border:1px solid rgba(0,0,0,.12); border-radius:.5rem; margin:.4rem 0; cursor:pointer; }
|
| 172 |
+
.rail-option.selected { border-color: var(--accent,#2563eb); background: rgba(37,99,235,.06); }
|
| 173 |
+
.rail-name { font-weight:600; }
|
| 174 |
+
.review .summary-grid { margin:.5rem 0; }
|
| 175 |
+
.tabs { display:flex; gap:.35rem; flex-wrap:wrap; margin:.5rem 0; }
|
| 176 |
+
.tabs .tab { padding:.35rem .7rem; border-radius:999px; border:1px solid rgba(0,0,0,.12); background:none; cursor:pointer; font:inherit; }
|
| 177 |
+
.tabs .tab.active { background: var(--accent,#2563eb); color:#fff; border-color:transparent; }
|
| 178 |
+
.activity-list { list-style:none; padding:0; margin:.5rem 0; }
|
| 179 |
+
.activity-item { border:1px solid rgba(0,0,0,.1); border-radius:.5rem; padding:.6rem .75rem; margin:.4rem 0; }
|
| 180 |
+
.more-sheet { position:fixed; bottom:3.6rem; inset-inline:0; z-index:25; background:var(--bg,#fff); border-top:1px solid rgba(0,0,0,.12); display:flex; flex-direction:column; }
|
| 181 |
+
.more-item { padding:.9rem 1rem; text-align:start; background:none; border:none; border-bottom:1px solid rgba(0,0,0,.06); font:inherit; min-height:44px; cursor:pointer; }
|
| 182 |
+
@media (prefers-reduced-motion: reduce) { .spin { animation: none !important; } }
|
web/src/utils/format.test.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, it, expect } from 'vitest'
|
| 2 |
+
import { formatDateTimeSafe } from './format'
|
| 3 |
+
|
| 4 |
+
// A real, deterministic timestamp: 2023-11-14T22:13:20Z (epoch seconds 1700000000).
|
| 5 |
+
const REAL_SECONDS = 1_700_000_000
|
| 6 |
+
const REAL_ISO = '2023-11-14T22:13:20Z'
|
| 7 |
+
|
| 8 |
+
describe('formatDateTimeSafe', () => {
|
| 9 |
+
it('formats a valid epoch-seconds timestamp', () => {
|
| 10 |
+
const out = formatDateTimeSafe(REAL_SECONDS, 'en')
|
| 11 |
+
expect(out).toBeTruthy()
|
| 12 |
+
expect(out).toMatch(/2023/)
|
| 13 |
+
expect(out).not.toMatch(/1970|Invalid Date/)
|
| 14 |
+
})
|
| 15 |
+
|
| 16 |
+
it('formats a valid ISO timestamp', () => {
|
| 17 |
+
expect(formatDateTimeSafe(REAL_ISO, 'en')).toMatch(/2023/)
|
| 18 |
+
})
|
| 19 |
+
|
| 20 |
+
it('formats an ISO timestamp with an explicit timezone offset', () => {
|
| 21 |
+
expect(formatDateTimeSafe('2023-11-15T01:13:20+03:00', 'en')).toMatch(/2023/)
|
| 22 |
+
})
|
| 23 |
+
|
| 24 |
+
it('returns null for null', () => { expect(formatDateTimeSafe(null, 'en')).toBeNull() })
|
| 25 |
+
it('returns null for undefined', () => { expect(formatDateTimeSafe(undefined, 'en')).toBeNull() })
|
| 26 |
+
it('returns null for an empty string', () => { expect(formatDateTimeSafe('', 'en')).toBeNull() })
|
| 27 |
+
it('returns null for an invalid string', () => { expect(formatDateTimeSafe('not-a-date', 'en')).toBeNull() })
|
| 28 |
+
it('returns null for numeric zero', () => { expect(formatDateTimeSafe(0, 'en')).toBeNull() })
|
| 29 |
+
it('returns null for the 1970 epoch ISO', () => {
|
| 30 |
+
expect(formatDateTimeSafe('1970-01-01T00:00:00Z', 'en')).toBeNull()
|
| 31 |
+
})
|
| 32 |
+
it('returns null for a tiny placeholder epoch (e.g. 2)', () => {
|
| 33 |
+
expect(formatDateTimeSafe(2, 'en')).toBeNull()
|
| 34 |
+
})
|
| 35 |
+
it('returns null for a non-finite number', () => {
|
| 36 |
+
expect(formatDateTimeSafe(Number.NaN, 'en')).toBeNull()
|
| 37 |
+
expect(formatDateTimeSafe(Number.POSITIVE_INFINITY, 'en')).toBeNull()
|
| 38 |
+
})
|
| 39 |
+
|
| 40 |
+
it('renders a valid date in Arabic locale', () => {
|
| 41 |
+
const ar = formatDateTimeSafe(REAL_SECONDS, 'ar')
|
| 42 |
+
expect(ar).toBeTruthy()
|
| 43 |
+
expect(ar).not.toMatch(/1970|Invalid Date/)
|
| 44 |
+
// Arabic-Indic digits present (ar-SA formatting)
|
| 45 |
+
expect(ar).toMatch(/[٠-٩]/)
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
it('returns null in Arabic locale for an unavailable date (caller shows the AR fallback)', () => {
|
| 49 |
+
expect(formatDateTimeSafe(0, 'ar')).toBeNull()
|
| 50 |
+
expect(formatDateTimeSafe(null, 'ar')).toBeNull()
|
| 51 |
+
})
|
| 52 |
+
})
|
web/src/utils/format.ts
CHANGED
|
@@ -8,3 +8,37 @@ export function formatDateTime(epochSeconds: number, locale = 'en', tz = 'Asia/R
|
|
| 8 |
return new Date(epochSeconds * 1000).toISOString()
|
| 9 |
}
|
| 10 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
return new Date(epochSeconds * 1000).toISOString()
|
| 9 |
}
|
| 10 |
}
|
| 11 |
+
|
| 12 |
+
// Any real transaction is after this floor; anything before it (epoch 0, a placeholder
|
| 13 |
+
// like `2`, or `1970-01-01T00:00:00Z`) is treated as "no real timestamp".
|
| 14 |
+
const MIN_VALID_MS = Date.UTC(2000, 0, 1)
|
| 15 |
+
|
| 16 |
+
/**
|
| 17 |
+
* Safe timestamp formatter. Accepts an epoch-seconds number OR an ISO string. Returns `null`
|
| 18 |
+
* for missing/empty/invalid/epoch-equivalent values so the caller can show a localized
|
| 19 |
+
* "Date unavailable" fallback — it NEVER fabricates the current date and never renders 1970.
|
| 20 |
+
*/
|
| 21 |
+
export function formatDateTimeSafe(
|
| 22 |
+
value: number | string | null | undefined,
|
| 23 |
+
locale = 'en',
|
| 24 |
+
tz = 'Asia/Riyadh',
|
| 25 |
+
): string | null {
|
| 26 |
+
if (value === null || value === undefined) return null
|
| 27 |
+
let ms: number
|
| 28 |
+
if (typeof value === 'number') {
|
| 29 |
+
if (!Number.isFinite(value)) return null
|
| 30 |
+
ms = value * 1000 // backend convention: epoch seconds
|
| 31 |
+
} else {
|
| 32 |
+
const s = value.trim()
|
| 33 |
+
if (s === '') return null
|
| 34 |
+
ms = new Date(s).getTime() // ISO string (already ms-based)
|
| 35 |
+
}
|
| 36 |
+
if (!Number.isFinite(ms) || ms < MIN_VALID_MS) return null
|
| 37 |
+
try {
|
| 38 |
+
return new Intl.DateTimeFormat(locale === 'ar' ? 'ar-SA' : 'en-GB', {
|
| 39 |
+
dateStyle: 'medium', timeStyle: 'short', timeZone: tz,
|
| 40 |
+
}).format(new Date(ms))
|
| 41 |
+
} catch {
|
| 42 |
+
return null
|
| 43 |
+
}
|
| 44 |
+
}
|