OctoWiki

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.

TabSlugSummary
Account Infoaccount_inforead-only profile/company/prefs
Securitysecurity2FA + passkeys + change-password
API Keysapi_keysusername/password API credentials
Webhookswebhooksper-event endpoints, HMAC signing
IP Whitelistip_whitelistallow-list for the external API
IntegrationsintegrationsG2A 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.

  • ListGET /client/api/api-keys; each card shows name, Active/Inactive chip, monospace username + copy, dates.
  • Create — dialog with only a namePOST /client/api/api-keys; the response returns username and plaintext password, shown once under "Save these credentials now. The password will not be shown again." — the only time the secret appears.
  • RenamePUT /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}.
  • CRUDGET/POST /client/api/webhooks, PUT/DELETE /client/api/webhooks/:id.
  • Signing secret — HMAC-SHA256 over the raw body → X-Signature header (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").
  • TestPOST /client/api/webhooks/:id/test reports success + response-time or the HTTP status.
  • Enable/disable — a Switch firing PUT immediately (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:true entries enforce.
  • CRUDGET/POST /client/api/ip-whitelist, PUT/DELETE /client/api/ip-whitelist/:id; enable/disable via Switch.
  • A bare IP is normalized to /32 on 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.

ActionEndpoint
StatusGET /client/api/g2a/credentials{has_credentials, g2a_client_id?, is_enabled, token_url, …}
Generate / rotatePOST /client/api/g2a/credentials{g2a_client_id, client_secret, token_url, rotated, …}
Enable / disablePOST /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
1Shopify not implemented in the portal — flag + dead #! upsell only
2Webhook secret may be retrievable on list (reveal control on every card) — contradicts "only on create"
3No client-side IP/CIDR validation — presence-only; backend rejects
4Account Info fully read-only — no profile/company/prefs update endpoint; no locale
5Enable/disable toggles fire with no confirmation (webhooks, IP entries) — disabling an IP silently loosens access
6G2A has no disconnect — only disable + rotate
7Tab 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); types src/types/{auth,g2a}.ts

On this page