Client Portal — Settings
The client portal's six settings tabs — Account Info (read-only), Security (2FA/passkeys), API Keys (username/password credentials shown once), Webhooks (per-event, HTTPS-enforced, HMAC signing + rotate/test), IP Whitelist (empty = allow-all, no client-side CIDR validation), and Integrations (G2A self-service OAuth issuing; Shopify is NOT implemented — flag + dead upsell only).
The settings page ((main)/settings) is a responsive master-detail with six tabs driven by data/settings/settings-tabs.tsx. All tabs mount eagerly (each panel's data fetch fires on page load, not on tab select), and tab state syncs from ?tab= but clicking a tab doesn't push back to the URL. Security/2FA/passkeys are covered on the Auth page; this page details the other five.
| Tab | Slug | Summary |
|---|---|---|
| Account Info | account_info | read-only profile/company/prefs |
| Security | security | 2FA + passkeys + change-password |
| API Keys | api_keys | username/password API credentials |
| Webhooks | webhooks | per-event endpoints, HMAC signing |
| IP Whitelist | ip_whitelist | allow-list for the external API |
| Integrations | integrations | G2A only (Shopify not implemented) |
Shared settings infra
Every tab hand-rolls raw MUI <Dialog> + local useState — the reusable SettingsFormDialog is used only by ChangePassword. Only the Webhooks form uses react-hook-form + yup; API-Keys/IP-Whitelist use controlled inputs with inline checks. No SWR here — plain useEffect fetch + imperative axios. Notifications are mostly inline MUI <Alert>, not the global snackbar. And remember the Stack-row-default gotcha — vertical stacks need explicit direction="column".
Account Info — read-only
Pulls the current user from useAuthContext() (populated by GET /client/api/me) and displays it via InfoCard/InfoCardAttribute. Three sections: Company (client_name), User (name, email, role ∈ owner/admin/write/read), Preferences (currency, timezone).
There is no profile-update endpoint. /api/me is GET-only; company name, currency, and timezone are surfaced but not editable from the portal. No locale field exists at all. Password change lives on the Security tab.
API Keys
Credentials are username + password (basic-auth style), not opaque bearer tokens. Model {id, name, username, has_password, is_active, …} — the list never carries the password.
- List —
GET /client/api/api-keys; each card shows name, Active/Inactive chip, monospace username + copy, dates. - Create — dialog with only a
name→POST /client/api/api-keys; the response returnsusernameand plaintextpassword, shown once under "Save these credentials now. The password will not be shown again." — the only time the secret appears. - Rename —
PUT /client/api/api-keys/:id(name only). - Reset password (rotate) — the user supplies the new password (min 12 chars + confirm) →
POST /client/api/api-keys/:id/reset-password; warns existing sessions are invalidated. - Delete — confirmed →
DELETE /client/api/api-keys/:id.
The tab refetches the whole list after create/update/delete. A legacy singular settingsApi (/client/api/credentials*) is dead relative to this tab — cleanup candidate.
Webhooks
Multiple endpoints per account (not a single URL). Form uses rhf + yup: url must be a valid HTTPS URL, and ≥1 event is required.
- 15 event types, grouped and feature-gated via
useFeatures()(wallet always shown; vouchers/topups/esim gated on the client's enabled features, but already-subscribed events stay visible):order.{delivered,partially_delivered,cancelled,failed},topup.{delivered,failed,cancelled},esim.{delivered,failed,cancelled,installed,activated,depleted},wallet.{credited,debited}. - CRUD —
GET/POST /client/api/webhooks,PUT/DELETE /client/api/webhooks/:id. - Signing secret — HMAC-SHA256 over the raw body →
X-Signatureheader (the UI shows a Node verification snippet). The card renders the secret masked with a reveal toggle + copy + rotate (POST /client/api/webhooks/:id/rotate-secret, "current key stops working immediately"). - Test —
POST /client/api/webhooks/:id/testreports success + response-time or the HTTP status. - Enable/disable — a
SwitchfiringPUTimmediately (no confirm).
Webhook-secret visibility discrepancy
The type comments say the signing token is "only returned on create," yet WebhookCard renders a reveal/copy control for webhook.token on every listed webhook. If the backend actually returns token on GET /webhooks, the signing secret is retrievable in plaintext at any time — verify against the Go handler.
IP Whitelist
Named IP/CIDR entries restricting the external API (not the portal). Model {id, name, cidr, is_active, …}; the UI caps entries at 5.
- Semantics: empty = ALLOW-ALL. Repeatedly stated in-UI ("Your API is open to all IPs until you add your first entry"). So the whitelist is opt-in — no entries means no restriction, not deny-all. Only
is_active:trueentries enforce. - CRUD —
GET/POST /client/api/ip-whitelist,PUT/DELETE /client/api/ip-whitelist/:id; enable/disable viaSwitch. - A bare IP is normalized to
/32on the backend (per the in-UI CIDR help).
No client-side IP/CIDR validation
The add/edit forms validate presence only — there is no IPv4/IPv6/CIDR regex; malformed input round-trips to the backend, whose error is surfaced verbatim. (Contrast Webhooks, which enforces HTTPS via yup.) Also: disabling an IP entry silently loosens API access with no confirmation.
Integrations — G2A only
Shopify is NOT a client-portal integration
Despite the backend Shopify machinery, the portal has no Shopify connector — no store-domain/token fields, no OAuth, no /client Shopify endpoint. All that exists is a hasShopify feature flag (unused for any UI) and a dead marketing upsell slide on the dashboard carousel with buttonLink: '#!'. Document Shopify as planned/upsell-only, not implemented in the portal.
The Integrations tab exposes exactly one integration: G2A Marketplace — and it's a self-service OAuth credential-issuer, not a stored-credential form. The client's Octopus backend acts as the OAuth server; the portal user generates an Octopus-side client_id/client_secret and hands them to G2A, which uses them to authenticate and pull codes when a card sells. This is the client-facing front door to the G2A inbound channel.
| Action | Endpoint |
|---|---|
| Status | GET /client/api/g2a/credentials → {has_credentials, g2a_client_id?, is_enabled, token_url, …} |
| Generate / rotate | POST /client/api/g2a/credentials → {g2a_client_id, client_secret, token_url, rotated, …} |
| Enable / disable | POST /client/api/g2a/enabled {enabled} |
Flow: not-set-up → "Generate credentials" → a one-time secret dialog (Client ID + Client Secret, "will not be shown again") → then the card shows Client ID, Token URL (${API_URL}${token_url}), and a "Channel enabled" Switch (is_enabled, default false server-side, optimistic with revert). If credentials exist the button becomes "Rotate credentials" (confirmation: "invalidates the current client secret immediately"). There is no hard delete/disconnect — disable + rotate are the only lifecycle controls.
Findings
| # | Finding |
|---|---|
| 1 | Shopify not implemented in the portal — flag + dead #! upsell only |
| 2 | Webhook secret may be retrievable on list (reveal control on every card) — contradicts "only on create" |
| 3 | No client-side IP/CIDR validation — presence-only; backend rejects |
| 4 | Account Info fully read-only — no profile/company/prefs update endpoint; no locale |
| 5 | Enable/disable toggles fire with no confirmation (webhooks, IP entries) — disabling an IP silently loosens access |
| 6 | G2A has no disconnect — only disable + rotate |
| 7 | Tab selection doesn't update ?tab= (deep-links work, tab clicks aren't bookmarkable); legacy settingsApi credentials endpoints are dead |
Key files
- Tabs:
src/data/settings/settings-tabs.tsx,src/components/sections/settings/index.tsx - Panels:
.../settings/{account-info,api-keys,webhooks,ip-whitelist,integrations}/* - Services:
src/services/api/client.ts(apiKeysApi,webhooksApi,ipWhitelistApi,g2aApi); typessrc/types/{auth,g2a}.ts
Client Portal — Auth & 2FA
The client portal's authentication end-to-end — the httpOnly-cookie session model, the login + TOTP-2FA + passkey flows with a sequence diagram, the axios 401→refresh single-flight, client-side route guards, forgot/reset password, logout, the full endpoint map, and the findings (no CSRF token, no schema validation on login, doc-vs-code drift).
Grasshopper — Claim Worker
The customer-facing voucher/topup/eSIM claim worker end-to-end — Hono+D1 on Cloudflare Workers, the verify→submit→poll reveal flow with sequence diagrams, AES-256-GCM encryption, HMAC nonces & poll tokens, rate limiting, circuit breaker & idempotent recovery, telemetry, deployment, and the full security risk register.