MHamdan commited on
Commit
adb91eb
·
verified ·
1 Parent(s): cd88779

CI deploy 895c0ed

Browse files
build_info.json CHANGED
@@ -1 +1 @@
1
- {"commit":"0c6476e","build_time":"2026-07-13T04:57:19Z","frontend":"1.0.0"}
 
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 is reachable from nav and lists modalities', async ({ page }) => {
37
- await page.goto('/#/pay')
38
- await page.getByRole('button', { name: 'Biometrics' }).click()
 
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', 'enroll', 'biometrics', 'biometrics/enroll', 'biometrics/verify',
 
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 === 'pay') return 'pay'
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 ${route === r ? 'active' : ''}`} aria-current={route === r} onClick={() => nav(r)}>{label}</button>
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('pay', t('nav.pay'))}
61
- {tab('enroll', t('nav.enroll'))}
62
- {tab('biometrics', t('nav.biometrics'))}
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
- <p className="tagline">{t('app.tagline')}</p>
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: HOSTILE, consent_id: 'c', refunded_minor: 0,
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', timezone: 'Asia/Riyadh', locales: ['ar', 'en'] },
27
- non_custodial: true,
28
  }
29
  const renderPay = () => render(<I18nProvider initial="en"><PayPage onNavEnroll={() => {}} /></I18nProvider>)
30
 
31
- describe('PayPage', () => {
 
 
 
 
 
 
 
 
 
32
  beforeEach(() => {
33
- vi.resetAllMocks() // clears call history AND implementations of the auto-mocked module
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('blocks payment when no active user and directs to Enroll', () => {
 
42
  renderPay()
43
- expect(screen.getByText(/Sign in on the Enroll tab/i)).toBeInTheDocument()
44
- expect(screen.getByRole('button', { name: 'Pay' })).toBeDisabled()
 
 
 
 
 
 
 
 
 
45
  })
46
 
47
- it('creates a payment using the ACTIVE user (never hard-coded alice)', async () => {
48
- setActiveUser({ userId: 'bob', via: 'demo' })
49
- const create = vi.spyOn(payApi, 'createPayment').mockResolvedValue(mkPayment({ status: 'requires_customer_action' }))
50
  renderPay()
51
- fireEvent.click(screen.getByRole('button', { name: 'Pay' }))
 
 
 
 
 
 
 
 
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 action)', async () => {
58
- setActiveUser({ userId: 'bob', via: 'demo' })
59
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
60
- let resolve: (v: any) => void = () => {}
61
- const create = vi.spyOn(payApi, 'createPayment').mockImplementation(
62
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
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: 'demo' })
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: 'Pay' }))
90
- await waitFor(() => expect(screen.getAllByText('Processing').length).toBeGreaterThan(0))
91
- expect(screen.queryByText('Payment completed successfully.')).toBeNull()
92
  })
93
 
94
- it('shows Cancel only in a cancellable state, not when succeeded', async () => {
95
- setActiveUser({ userId: 'bob', via: 'demo' })
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: 'Pay' }))
109
- await waitFor(() => expect(screen.getByText('Succeeded')).toBeInTheDocument())
 
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 { formatDateTime } from '../utils/format'
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 { getLastRequestId } from '../api/client'
18
  import type { MessageKey } from '../i18n/en'
19
  import type { PaymentView, ProviderCapabilities } from '../types'
20
 
21
  const DEMO_IBAN = 'SA0380000000608010167519'
22
- const ALLOWED_ACTION_HOSTS = ['mock-bank.example'] // real provider hosts added via config
 
23
 
24
  function newIdemKey(): string {
25
  return typeof crypto !== 'undefined' && 'randomUUID' in crypto
26
- ? crypto.randomUUID()
27
- : `idem-${Date.now()}-${Math.random().toString(16).slice(2)}`
 
 
 
 
 
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 onPay() {
71
  if (submittingRef.current || created) return // duplicate-submission guard
72
  if (!activeUser) { setError(t('error.noActiveUser')); return }
73
- if (formInvalid) return
74
- submittingRef.current = true
75
- setSubmitting(true)
76
- setError(null)
77
  try {
78
  const p = await createPayment({
79
- userId: activeUser.userId, // active user — never hard-coded
80
- amountMinor: toMinor(amount, 'SAR'),
81
- merchantId: merchant.trim(),
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
- setError(null)
117
- idemRef.current = newIdemKey() // a NEW action gets a NEW idempotency key
118
  }
119
 
120
- const actionUrl = payment?.requires_action_url ?? null
121
- const actionUrlSafe = actionUrl ? isSafeExternalUrl(actionUrl, ALLOWED_ACTION_HOSTS) : false
 
 
 
 
 
 
 
 
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
- return (
128
- <section className="card" aria-labelledby="pay-h">
129
- <h2 id="pay-h">{t('pay.title')}</h2>
130
-
131
- {!activeUser && (
132
- <Callout tone="info">
133
- {t('pay.needLogin')}{' '}
134
- <button className="link" onClick={onNavEnroll}>{t('nav.enroll')}</button>
135
- </Callout>
136
- )}
137
- {activeUser && <p className="paying-as">{t('pay.payingAs', { user: activeUser.userId })}</p>}
 
 
 
 
 
138
 
139
- {!created && (
140
- <>
141
- <Field id="p_amt" label={t('pay.amount')} value={amount} inputMode="decimal"
142
- onChange={(e) => setAmount(e.target.value)} error={err(amountErr)} />
143
- <Field id="p_merch" label={t('pay.merchant')} value={merchant}
144
- onChange={(e) => setMerchant(e.target.value)} error={err(merchantErr)} />
145
- <Field id="p_iban" label={t('pay.iban')} value={iban}
146
- onChange={(e) => setIban(e.target.value)} error={err(ibanErr)} />
147
- <Field id="p_ref" label={t('pay.description')} value={reference}
148
- onChange={(e) => setReference(e.target.value)} />
149
- <Button onClick={onPay} disabled={!activeUser || submitting || formInvalid} aria-busy={submitting}>
150
- {submitting ? <Spinner label={t('pay.submitting')} /> : t('pay.submit')}
151
- </Button>
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
- <div className="row">
178
- {actionUrlSafe && actionUrl && (
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
- {isSuccess(payment.status) && <Callout tone="ok">{t('pay.succeeded')}</Callout>}
196
-
197
- {/* Refund visibility */}
198
- {payment.refunded_minor > 0 && (
199
- <dl className="summary-grid">
200
- <dt>{t('pay.original')}</dt>
201
- <dd>{formatMoney(payment.amount_minor, payment.currency, locale)}</dd>
202
- <dt>{t('pay.refundedAmount')}</dt>
203
- <dd>{formatMoney(payment.refunded_minor, payment.currency, locale)}</dd>
204
- <dt>{t('pay.remaining')}</dt>
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
- disabled={busy !== null}>
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
- disabled={busy !== null}>
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
- {isTerminal(payment.status) && (
248
- <Button onClick={reset}>{t('pay.newPayment')}</Button>
 
 
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
+ }