Compare commits
3 Commits
sync/curre
...
devin/1776
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46cce8f44e | ||
| f2e0434ad6 | |||
|
|
4a1f69a8e5 |
22
.gitea/workflows/deploy-to-phoenix.yml
Normal file
22
.gitea/workflows/deploy-to-phoenix.yml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
name: Deploy to Phoenix
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main, master]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Trigger Phoenix deployment
|
||||||
|
run: |
|
||||||
|
SHA="$(git rev-parse HEAD)"
|
||||||
|
BRANCH="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
curl -sSf -X POST "${{ secrets.PHOENIX_DEPLOY_URL }}" \
|
||||||
|
-H "Authorization: Bearer ${{ secrets.PHOENIX_DEPLOY_TOKEN }}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d "{\"repo\":\"${{ gitea.repository }}\",\"sha\":\"${SHA}\",\"branch\":\"${BRANCH}\",\"target\":\"default\"}"
|
||||||
291
docs/ux-responsive-strategy.md
Normal file
291
docs/ux-responsive-strategy.md
Normal file
@@ -0,0 +1,291 @@
|
|||||||
|
# Responsive UX/UI strategy
|
||||||
|
|
||||||
|
This document describes the responsive design system added to the
|
||||||
|
CurrenciCombo portal. It is intended to be the source of truth for
|
||||||
|
future responsive work.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
The portal ships to a mixed audience that includes treasury operators
|
||||||
|
at desks (≥ 1440 px displays), branch-office users on 13" laptops,
|
||||||
|
tablets in landscape, and mobile phones for on-call auditors. The
|
||||||
|
system has to adapt across all of those without:
|
||||||
|
|
||||||
|
- horizontal scroll at any viewport ≥ 320 px,
|
||||||
|
- layout shift on resize (no CLS),
|
||||||
|
- unusable tap targets on touch devices,
|
||||||
|
- losing keyboard navigation on desktop, or
|
||||||
|
- degrading desktop performance.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The responsive system is CSS-first and token-driven. There is **one**
|
||||||
|
runtime hook (`useBreakpoint`) used by the handful of components that
|
||||||
|
have to swap structure (nav, workspace gate) — everything else is
|
||||||
|
pure CSS.
|
||||||
|
|
||||||
|
### Layered stylesheet order
|
||||||
|
|
||||||
|
`src/main.tsx` imports styles in this order:
|
||||||
|
|
||||||
|
1. `src/styles/tokens.css` — design tokens (no visual side effects).
|
||||||
|
2. `src/index.css` — the original desktop baseline stylesheet.
|
||||||
|
3. `src/styles/responsive.css` — media-query overrides.
|
||||||
|
4. `src/styles/a11y.css` — focus ring, skip-to-content, sr-only,
|
||||||
|
touch-target enforcement.
|
||||||
|
|
||||||
|
Order matters: overrides must appear after the baseline so that rules
|
||||||
|
at equal specificity win. Nothing in `responsive.css` or `a11y.css`
|
||||||
|
changes behavior at `≥ lg` — every rule is wrapped in a
|
||||||
|
`@media (max-width: …)` or `@media (pointer: coarse)` query.
|
||||||
|
|
||||||
|
### Tokens
|
||||||
|
|
||||||
|
All sizing values that change with viewport live in
|
||||||
|
`src/styles/tokens.css`:
|
||||||
|
|
||||||
|
| Category | Tokens |
|
||||||
|
| ------------- | -------------------------------------------- |
|
||||||
|
| Breakpoints | `--bp-xs 0`, `--bp-sm 480`, `--bp-md 768`, `--bp-lg 1024`, `--bp-xl 1440` (px) |
|
||||||
|
| Fluid type | `--fs-2xs` through `--fs-4xl`, all `clamp()` |
|
||||||
|
| Fluid spacing | `--space-0` through `--space-12`, all `clamp()` |
|
||||||
|
| Line height | `--lh-tight`, `--lh-snug`, `--lh-normal`, `--lh-relaxed` |
|
||||||
|
| Motion | `--motion-fast 120ms`, `--motion-base 200ms`, `--motion-slow 320ms`, `--motion-ease` |
|
||||||
|
| Z-index | `--z-base`, `--z-sticky`, `--z-drawer-backdrop`, `--z-drawer`, `--z-dropdown`, `--z-modal`, `--z-toast`, `--z-tooltip`, `--z-focus` |
|
||||||
|
| Focus ring | `--focus-ring-color #60a5fa`, `--focus-ring-offset 2px`, `--focus-ring-width 2px` |
|
||||||
|
| Safe area | `--safe-top/right/bottom/left` via `env()` |
|
||||||
|
| Tap target | `--tap-min 44px` |
|
||||||
|
|
||||||
|
### Fluid scaling formula
|
||||||
|
|
||||||
|
Typography and spacing use `clamp(min, preferred, max)` where
|
||||||
|
`preferred` has a `vw` term so values interpolate continuously between
|
||||||
|
the smallest design viewport (320 px) and the largest (1440 px). This
|
||||||
|
is what the spec calls "avoid hard breakpoints where possible; prefer
|
||||||
|
fluid responsiveness" — between breakpoints, sizes grow continuously
|
||||||
|
rather than snapping.
|
||||||
|
|
||||||
|
Example, `--fs-base: clamp(0.8125rem, 0.77rem + 0.23vw, 1rem)`:
|
||||||
|
|
||||||
|
- at 320 px → 13 px (minimum),
|
||||||
|
- at 1024 px → ~15.1 px,
|
||||||
|
- at 1440 px → 16 px (maximum clamp).
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
|
||||||
|
| Name | CSS range | Semantic |
|
||||||
|
| ---- | ----------------------- | ---------------------- |
|
||||||
|
| xs | 0 – 479 px | Small phones, portrait |
|
||||||
|
| sm | 480 – 767 px | Larger phones |
|
||||||
|
| md | 768 – 1023 px | Tablets |
|
||||||
|
| lg | 1024 – 1439 px | Laptops |
|
||||||
|
| xl | ≥ 1440 px | External displays |
|
||||||
|
|
||||||
|
Semantic aliases exposed by `useBreakpoint()`:
|
||||||
|
|
||||||
|
- `isMobile` = `< md` (phones)
|
||||||
|
- `isTablet` = `md – lg` (tablets, portrait iPads)
|
||||||
|
- `isDesktop` = `≥ lg` (laptops, external monitors)
|
||||||
|
|
||||||
|
### Hooks
|
||||||
|
|
||||||
|
- **`useMediaQuery(query)`** — subscribes to a matchMedia `change`
|
||||||
|
event via `useSyncExternalStore`. No resize listener — re-renders
|
||||||
|
only when the query result actually flips.
|
||||||
|
- **`useBreakpoint()`** — returns `{ current, isXs/Sm/Md/Lg/Xl,
|
||||||
|
isMobile/Tablet/Desktop }`. Composed of four `useMediaQuery` calls
|
||||||
|
using the `--bp-*` values from the tokens.
|
||||||
|
- **`useReducedMotion()`** — wraps `(prefers-reduced-motion: reduce)`
|
||||||
|
so components that animate in JS can disable animations.
|
||||||
|
- **`useOrientation()`** — returns `'portrait'` or `'landscape'`.
|
||||||
|
|
||||||
|
All four hooks are SSR-safe (return `false` / default in a non-DOM
|
||||||
|
environment).
|
||||||
|
|
||||||
|
## Component inventory and strategy
|
||||||
|
|
||||||
|
### Global primitives
|
||||||
|
|
||||||
|
- **Skip-to-content link** — rendered at the top of the tree via
|
||||||
|
`<SkipToContent>`. Visually hidden until `:focus`, then slides in at
|
||||||
|
top-left. Target is `<main id="main-content" tabIndex={-1}>` in
|
||||||
|
`PortalLayout`.
|
||||||
|
- **VisuallyHidden** — `sr-only` wrapper, for adding accessible text
|
||||||
|
without visual presence.
|
||||||
|
|
||||||
|
### Portal chrome
|
||||||
|
|
||||||
|
- **Topbar** (`.portal-topbar`): fixed 48 px height at all sizes.
|
||||||
|
Below `md`, the "Production" environment badge and the "Solace Bank
|
||||||
|
Group" logo text are hidden so the hamburger, logo mark and user
|
||||||
|
avatar fit on a phone without wrapping.
|
||||||
|
- **Hamburger toggle** (`.portal-menu-toggle`): new `<button>` in the
|
||||||
|
topbar, hidden by default (CSS `display: none`), shown below `lg`.
|
||||||
|
44 × 44 tap area. `aria-expanded`, `aria-controls`, swappable
|
||||||
|
label/icon between open and closed.
|
||||||
|
- **Sidebar / drawer** (`.portal-sidebar`):
|
||||||
|
- `≥ lg` — behaves exactly as before (220 px wide, collapsible to
|
||||||
|
56 px rail via the existing collapse button).
|
||||||
|
- `md – lg` — forced into a 56 px icon-only rail (tablet treatment).
|
||||||
|
- `< md` — becomes an off-canvas drawer slid out from the left at
|
||||||
|
`min(320px, 84vw)` wide, over a backdrop at
|
||||||
|
`var(--z-drawer-backdrop)`. Drawer closes on: Escape key, backdrop
|
||||||
|
click, route change, window resize into `≥ md`. Body scroll is
|
||||||
|
locked while the drawer is open.
|
||||||
|
|
||||||
|
### Dashboard (`/dashboard`)
|
||||||
|
|
||||||
|
- KPI row uses `grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr))`
|
||||||
|
so six cards at xl collapse to four at lg, three at md, two at sm,
|
||||||
|
one at xs — fluidly.
|
||||||
|
- Header (title + time-range + refresh) stacks vertically below md;
|
||||||
|
the time-range chip row scrolls horizontally if needed.
|
||||||
|
- Two-column `.dashboard-grid` becomes single-column below `lg`.
|
||||||
|
|
||||||
|
### Tables (Transactions, Settlements, Accounts)
|
||||||
|
|
||||||
|
Two CSS patterns available:
|
||||||
|
|
||||||
|
1. **Scrolling wrapper (default).** Wrap the `<table>` in
|
||||||
|
`<div class="portal-table-wrapper">`. Below `sm` the wrapper
|
||||||
|
scrolls horizontally and the `table` holds a `min-width: 640px`
|
||||||
|
floor so columns never collapse to useless widths.
|
||||||
|
2. **Stacked-card mode (opt-in).** Add `portal-table--stack` to the
|
||||||
|
`<table>` and a `data-label="Column name"` attribute to each
|
||||||
|
`<td>`. At xs the rows render as cards with label-value pairs;
|
||||||
|
the `<thead>` is hidden off-screen but remains in the accessibility
|
||||||
|
tree.
|
||||||
|
|
||||||
|
### Forms (Reporting, Compliance, Treasury, Settings)
|
||||||
|
|
||||||
|
These pages already use CSS grid via the `.settings-grid` /
|
||||||
|
`.treasury-summary` / etc. classes; the responsive layer just forces
|
||||||
|
single-column layout below `md` via `grid-template-columns: 1fr
|
||||||
|
!important` and reduces container padding to `var(--space-4)` on
|
||||||
|
phones.
|
||||||
|
|
||||||
|
### Login page
|
||||||
|
|
||||||
|
- Below `md` the two-column layout (brand left / card right) stacks.
|
||||||
|
Both halves go to `max-width: 100%`.
|
||||||
|
- `min-height: 100vh; min-height: 100dvh` accommodates iOS Safari's
|
||||||
|
bottom toolbar.
|
||||||
|
- `safe-area-inset-*` padding for notched phones.
|
||||||
|
|
||||||
|
### Workspace / IDE mode (`App.tsx`)
|
||||||
|
|
||||||
|
The multi-panel IDE (TitleBar + ActivityBar + LeftPanel + Canvas +
|
||||||
|
RightPanel + BottomPanel + react-flow) is not realistically usable
|
||||||
|
below ~768 px of width. Matching the pattern used by VS Code Web,
|
||||||
|
Figma, and Replit:
|
||||||
|
|
||||||
|
- **`< md`** — show `<WorkspaceMobileGate>` instead of the IDE shell.
|
||||||
|
Explains what the user is seeing and deep-links to `/dashboard`,
|
||||||
|
`/transactions`, `/accounts` so they can keep using the portal.
|
||||||
|
- **`≥ md`** — render the full workspace exactly as before.
|
||||||
|
|
||||||
|
The gate is a plain `<section>` with three buttons, all 44 px tall,
|
||||||
|
and a single `<h2>` for landmark navigation.
|
||||||
|
|
||||||
|
## Performance
|
||||||
|
|
||||||
|
- **No layout shift.** Fluid `clamp()` sizes and `auto-fit` grids
|
||||||
|
scale continuously; no script-driven layout recalculation on
|
||||||
|
resize. `img, video, svg, picture` default to
|
||||||
|
`max-width: 100%; height: auto` so intrinsic aspect ratios are
|
||||||
|
preserved.
|
||||||
|
- **No resize listeners.** `useMediaQuery` subscribes to matchMedia's
|
||||||
|
native `change` event, not `window.resize`. React only re-renders
|
||||||
|
when a query actually flips.
|
||||||
|
- **CSS-first.** The only JS structure swap is the mobile drawer and
|
||||||
|
the workspace gate — both driven by `useBreakpoint()` which is one
|
||||||
|
tiny subscription shared across the app.
|
||||||
|
- **Print.** The portal has a minimal print stylesheet so treasury
|
||||||
|
reports print clean (no sidebar, no topbar, black-on-white).
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
- **Skip-to-content link** at the top of the tree.
|
||||||
|
- **Focus ring** via `:focus-visible`, 2 px solid `#60a5fa` with
|
||||||
|
2 px offset. Defined in `a11y.css`, tightens to 3 px in
|
||||||
|
`prefers-contrast: more`.
|
||||||
|
- **Landmarks** — `<main id="main-content">`, `<nav aria-label="Primary">`,
|
||||||
|
proper heading levels.
|
||||||
|
- **Tap targets** — `@media (pointer: coarse)` enforces 44 × 44 px
|
||||||
|
minimum on `button`, `a`, `[role="button"]`, `[role="tab"]`.
|
||||||
|
- **Reduced motion** — `@media (prefers-reduced-motion: reduce)`
|
||||||
|
drops animation durations to 0.001 ms; JS-driven animations read
|
||||||
|
`useReducedMotion()`.
|
||||||
|
- **No content hidden from screen readers** by the table transform —
|
||||||
|
`thead` is positioned off-screen in stacked-card mode but remains
|
||||||
|
accessible. Data labels are injected via `::before` content so
|
||||||
|
they're visible to sighted users but not announced twice to
|
||||||
|
screen readers.
|
||||||
|
|
||||||
|
## Gotchas and non-obvious decisions
|
||||||
|
|
||||||
|
1. **`html, body { overflow-x: hidden }`** — intentional global guard
|
||||||
|
against any descendant creating horizontal scroll. The IDE shell
|
||||||
|
(`.app-shell`) and portal root (`.portal-layout`) both cap at
|
||||||
|
`max-width: 100vw` so they contain the react-flow canvas
|
||||||
|
correctly.
|
||||||
|
2. **Drawer width is `min(320px, 84vw)`** — on a narrow phone the
|
||||||
|
drawer would otherwise cover the whole viewport; 84 vw leaves a
|
||||||
|
visible strip of backdrop so users know they can tap to close.
|
||||||
|
3. **`.portal-topbar-center` hides below md** — at 320 px the "Production"
|
||||||
|
badge pushes the user avatar off-screen. The identity/environment
|
||||||
|
is still visible in the user menu dropdown, so nothing is lost.
|
||||||
|
4. **Tablet (md – lg) forces the sidebar into 56 px rail** — rather
|
||||||
|
than respecting the `collapsed` state. Tablets in portrait don't
|
||||||
|
have room for a labeled 220 px nav plus meaningful content area.
|
||||||
|
5. **`use-syncExternalStore` for matchMedia** — avoids tearing
|
||||||
|
between the two `useState` + `useEffect` pattern most examples
|
||||||
|
use; means the hook reports the correct value on the very first
|
||||||
|
render after a layout change.
|
||||||
|
6. **`setDrawerOpen` from effects** — the
|
||||||
|
`react-hooks/set-state-in-effect` rule is narrowly disabled on the
|
||||||
|
"close drawer on route change" and "close drawer on resize-up"
|
||||||
|
effects. These are synchronizing internal state with *external*
|
||||||
|
inputs (router location, media query), which is one of the
|
||||||
|
legitimate uses of an effect. The rule would require either
|
||||||
|
refactoring the entire state into derived values (noisy) or
|
||||||
|
moving the close logic into every nav-item onClick (error-prone).
|
||||||
|
|
||||||
|
## Testing matrix
|
||||||
|
|
||||||
|
| Category | Viewports tested |
|
||||||
|
| ---------------- | --------------------------------------------------- |
|
||||||
|
| Phones | 320, 375, 414 |
|
||||||
|
| Tablets | 768 (portrait), 1024 (landscape) |
|
||||||
|
| Laptops | 1280, 1440 |
|
||||||
|
| Desktops | 1920, 2560 |
|
||||||
|
| Orientation | Portrait and landscape at 768, 414 |
|
||||||
|
| DPR | 1× and 2× (hairline borders at 2×) |
|
||||||
|
| Reduced motion | `prefers-reduced-motion: reduce` honored |
|
||||||
|
| Keyboard | Tab order, skip-link, focus ring, drawer Escape |
|
||||||
|
|
||||||
|
## File inventory
|
||||||
|
|
||||||
|
New files:
|
||||||
|
|
||||||
|
- `src/styles/tokens.css`
|
||||||
|
- `src/styles/responsive.css`
|
||||||
|
- `src/styles/a11y.css`
|
||||||
|
- `src/hooks/useMediaQuery.ts`
|
||||||
|
- `src/hooks/useBreakpoint.ts`
|
||||||
|
- `src/hooks/useReducedMotion.ts`
|
||||||
|
- `src/hooks/useOrientation.ts`
|
||||||
|
- `src/components/a11y/SkipToContent.tsx`
|
||||||
|
- `src/components/a11y/VisuallyHidden.tsx`
|
||||||
|
- `docs/ux-responsive-strategy.md` (this document)
|
||||||
|
|
||||||
|
Modified files:
|
||||||
|
|
||||||
|
- `index.html` — viewport meta with `viewport-fit=cover`, `theme-color`,
|
||||||
|
`color-scheme`, mobile-web-app meta.
|
||||||
|
- `src/main.tsx` — import order for the four CSS layers.
|
||||||
|
- `src/App.tsx` — workspace mobile gate wrapping the IDE entrypoint.
|
||||||
|
- `src/Portal.tsx` — `<SkipToContent>` mounted once at the route root.
|
||||||
|
- `src/components/portal/PortalLayout.tsx` — mobile drawer state,
|
||||||
|
hamburger toggle, backdrop, `<main id="main-content">` landmark,
|
||||||
|
route/resize/Escape auto-close.
|
||||||
13
index.html
13
index.html
@@ -3,7 +3,18 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta
|
||||||
|
name="viewport"
|
||||||
|
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||||
|
/>
|
||||||
|
<meta name="color-scheme" content="dark light" />
|
||||||
|
<meta name="theme-color" content="#0f1419" media="(prefers-color-scheme: dark)" />
|
||||||
|
<meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
|
<meta name="format-detection" content="telephone=no" />
|
||||||
|
<meta name="description" content="Solace Bank Group PLC — Treasury management, settlement orchestration, and ISO-20022 transaction builder." />
|
||||||
<title>Solace Bank Group PLC — Treasury Management Portal</title>
|
<title>Solace Bank Group PLC — Treasury Management Portal</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
@@ -1,27 +1,42 @@
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const emptyToUndefined = (value: unknown) => {
|
||||||
|
if (typeof value !== "string") return value;
|
||||||
|
const trimmed = value.trim();
|
||||||
|
return trimmed === "" ? undefined : trimmed;
|
||||||
|
};
|
||||||
|
|
||||||
|
const optionalString = () => z.preprocess(emptyToUndefined, z.string().optional());
|
||||||
|
const optionalUrl = () => z.preprocess(emptyToUndefined, z.string().url().optional());
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Environment variable validation schema
|
* Environment variable validation schema
|
||||||
*/
|
*/
|
||||||
const envSchema = z.object({
|
const envSchema = z.object({
|
||||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||||
PORT: z.string().transform(Number).pipe(z.number().int().positive()),
|
PORT: z.string().transform(Number).pipe(z.number().int().positive()),
|
||||||
DATABASE_URL: z.string().url().optional(),
|
DATABASE_URL: optionalUrl(),
|
||||||
API_KEYS: z.string().optional(),
|
API_KEYS: optionalString(),
|
||||||
REDIS_URL: z.string().url().optional(),
|
REDIS_URL: optionalUrl(),
|
||||||
LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"),
|
LOG_LEVEL: z.enum(["error", "warn", "info", "debug"]).default("info"),
|
||||||
ALLOWED_IPS: z.string().optional(),
|
ALLOWED_IPS: optionalString(),
|
||||||
SESSION_SECRET: z.string().min(32),
|
SESSION_SECRET: z.string().min(32),
|
||||||
JWT_SECRET: z.string().min(32).optional(),
|
JWT_SECRET: z.preprocess(emptyToUndefined, z.string().min(32).optional()),
|
||||||
AZURE_KEY_VAULT_URL: z.string().url().optional(),
|
AZURE_KEY_VAULT_URL: optionalUrl(),
|
||||||
AWS_SECRETS_MANAGER_REGION: z.string().optional(),
|
AWS_SECRETS_MANAGER_REGION: optionalString(),
|
||||||
SENTRY_DSN: z.string().url().optional(),
|
SENTRY_DSN: optionalUrl(),
|
||||||
// Chain-138 + NotaryRegistry wiring (arch §4.5). All optional; when
|
// Chain-138 + NotaryRegistry wiring (arch §4.5). All optional; when
|
||||||
// absent the notary adapter falls back to its deterministic mock.
|
// absent the notary adapter falls back to its deterministic mock.
|
||||||
CHAIN_138_RPC_URL: z.string().url().optional(),
|
CHAIN_138_RPC_URL: optionalUrl(),
|
||||||
CHAIN_138_CHAIN_ID: z.string().regex(/^\d+$/).optional(),
|
CHAIN_138_CHAIN_ID: z.preprocess(emptyToUndefined, z.string().regex(/^\d+$/).optional()),
|
||||||
NOTARY_REGISTRY_ADDRESS: z.string().regex(/^0x[0-9a-fA-F]{40}$/).optional(),
|
NOTARY_REGISTRY_ADDRESS: z.preprocess(
|
||||||
ORCHESTRATOR_PRIVATE_KEY: z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional(),
|
emptyToUndefined,
|
||||||
|
z.string().regex(/^0x[0-9a-fA-F]{40}$/).optional(),
|
||||||
|
),
|
||||||
|
ORCHESTRATOR_PRIVATE_KEY: z.preprocess(
|
||||||
|
emptyToUndefined,
|
||||||
|
z.string().regex(/^0x[0-9a-fA-F]{64}$/).optional(),
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -31,7 +46,7 @@ export const env = envSchema.parse({
|
|||||||
NODE_ENV: process.env.NODE_ENV,
|
NODE_ENV: process.env.NODE_ENV,
|
||||||
PORT: process.env.PORT || "8080",
|
PORT: process.env.PORT || "8080",
|
||||||
DATABASE_URL: process.env.DATABASE_URL,
|
DATABASE_URL: process.env.DATABASE_URL,
|
||||||
API_KEYS: process.env.API_KEYS,
|
API_KEYS: process.env.API_KEYS || process.env.ORCHESTRATOR_API_KEYS,
|
||||||
REDIS_URL: process.env.REDIS_URL,
|
REDIS_URL: process.env.REDIS_URL,
|
||||||
LOG_LEVEL: process.env.LOG_LEVEL,
|
LOG_LEVEL: process.env.LOG_LEVEL,
|
||||||
ALLOWED_IPS: process.env.ALLOWED_IPS,
|
ALLOWED_IPS: process.env.ALLOWED_IPS,
|
||||||
@@ -56,7 +71,7 @@ export function validateEnv() {
|
|||||||
NODE_ENV: process.env.NODE_ENV || "development",
|
NODE_ENV: process.env.NODE_ENV || "development",
|
||||||
PORT: process.env.PORT || "8080",
|
PORT: process.env.PORT || "8080",
|
||||||
DATABASE_URL: process.env.DATABASE_URL,
|
DATABASE_URL: process.env.DATABASE_URL,
|
||||||
API_KEYS: process.env.API_KEYS,
|
API_KEYS: process.env.API_KEYS || process.env.ORCHESTRATOR_API_KEYS,
|
||||||
REDIS_URL: process.env.REDIS_URL,
|
REDIS_URL: process.env.REDIS_URL,
|
||||||
LOG_LEVEL: process.env.LOG_LEVEL || "info",
|
LOG_LEVEL: process.env.LOG_LEVEL || "info",
|
||||||
ALLOWED_IPS: process.env.ALLOWED_IPS,
|
ALLOWED_IPS: process.env.ALLOWED_IPS,
|
||||||
@@ -65,6 +80,10 @@ export function validateEnv() {
|
|||||||
AZURE_KEY_VAULT_URL: process.env.AZURE_KEY_VAULT_URL,
|
AZURE_KEY_VAULT_URL: process.env.AZURE_KEY_VAULT_URL,
|
||||||
AWS_SECRETS_MANAGER_REGION: process.env.AWS_SECRETS_MANAGER_REGION,
|
AWS_SECRETS_MANAGER_REGION: process.env.AWS_SECRETS_MANAGER_REGION,
|
||||||
SENTRY_DSN: process.env.SENTRY_DSN,
|
SENTRY_DSN: process.env.SENTRY_DSN,
|
||||||
|
CHAIN_138_RPC_URL: process.env.CHAIN_138_RPC_URL,
|
||||||
|
CHAIN_138_CHAIN_ID: process.env.CHAIN_138_CHAIN_ID,
|
||||||
|
NOTARY_REGISTRY_ADDRESS: process.env.NOTARY_REGISTRY_ADDRESS,
|
||||||
|
ORCHESTRATOR_PRIVATE_KEY: process.env.ORCHESTRATOR_PRIVATE_KEY,
|
||||||
};
|
};
|
||||||
envSchema.parse(envWithDefaults);
|
envSchema.parse(envWithDefaults);
|
||||||
console.log("✅ Environment variables validated");
|
console.log("✅ Environment variables validated");
|
||||||
@@ -79,4 +98,3 @@ export function validateEnv() {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -70,16 +70,28 @@ app.get("/health", async (req, res) => {
|
|||||||
const health = await healthCheck();
|
const health = await healthCheck();
|
||||||
res.status(health.status === "healthy" ? 200 : 503).json(health);
|
res.status(health.status === "healthy" ? 200 : 503).json(health);
|
||||||
});
|
});
|
||||||
|
app.get("/api/health", async (req, res) => {
|
||||||
|
const health = await healthCheck();
|
||||||
|
res.status(health.status === "healthy" ? 200 : 503).json(health);
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/ready", async (req, res) => {
|
app.get("/ready", async (req, res) => {
|
||||||
const ready = await readinessCheck();
|
const ready = await readinessCheck();
|
||||||
res.status(ready ? 200 : 503).json({ ready });
|
res.status(ready ? 200 : 503).json({ ready });
|
||||||
});
|
});
|
||||||
|
app.get("/api/ready", async (req, res) => {
|
||||||
|
const ready = await readinessCheck();
|
||||||
|
res.status(ready ? 200 : 503).json({ ready });
|
||||||
|
});
|
||||||
|
|
||||||
app.get("/live", async (req, res) => {
|
app.get("/live", async (req, res) => {
|
||||||
const alive = await livenessCheck();
|
const alive = await livenessCheck();
|
||||||
res.status(alive ? 200 : 503).json({ alive });
|
res.status(alive ? 200 : 503).json({ alive });
|
||||||
});
|
});
|
||||||
|
app.get("/api/live", async (req, res) => {
|
||||||
|
const alive = await livenessCheck();
|
||||||
|
res.status(alive ? 200 : 503).json({ alive });
|
||||||
|
});
|
||||||
|
|
||||||
// Metrics endpoint
|
// Metrics endpoint
|
||||||
app.get("/metrics", async (req, res) => {
|
app.get("/metrics", async (req, res) => {
|
||||||
@@ -87,6 +99,11 @@ app.get("/metrics", async (req, res) => {
|
|||||||
const metrics = await getMetrics();
|
const metrics = await getMetrics();
|
||||||
res.send(metrics);
|
res.send(metrics);
|
||||||
});
|
});
|
||||||
|
app.get("/api/metrics", async (req, res) => {
|
||||||
|
res.setHeader("Content-Type", register.contentType);
|
||||||
|
const metrics = await getMetrics();
|
||||||
|
res.send(metrics);
|
||||||
|
});
|
||||||
|
|
||||||
// API routes with rate limiting
|
// API routes with rate limiting
|
||||||
app.use("/api", apiLimiter);
|
app.use("/api", apiLimiter);
|
||||||
@@ -173,4 +190,3 @@ async function start() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
start();
|
start();
|
||||||
|
|
||||||
|
|||||||
80
scripts/deployment/.env.prod.example
Normal file
80
scripts/deployment/.env.prod.example
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# CurrenciCombo orchestrator production env (Phoenix CT 8604 / any systemd host)
|
||||||
|
#
|
||||||
|
# Installed by scripts/deployment/install.sh to:
|
||||||
|
# /etc/currencicombo/orchestrator.env
|
||||||
|
#
|
||||||
|
# Loaded by the currencicombo-orchestrator.service systemd unit via
|
||||||
|
# EnvironmentFile=. Values that are committed here are safe defaults;
|
||||||
|
# secrets are left blank and must be set before first boot.
|
||||||
|
#
|
||||||
|
# The portal is a statically built SPA (nginx), so it takes NO runtime env.
|
||||||
|
# Any VITE_* vars needed at build time are baked into dist/ by
|
||||||
|
# scripts/deployment/deploy-currencicombo-8604.sh before the rsync.
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# Server
|
||||||
|
############################################################
|
||||||
|
NODE_ENV=production
|
||||||
|
PORT=8080
|
||||||
|
# Bind to loopback only when behind NPMplus on the same host; bind
|
||||||
|
# 0.0.0.0 if NPMplus is on a different host (the CT 8604 case, so 0.0.0.0).
|
||||||
|
HOST=0.0.0.0
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# Postgres (local to the CT per install.sh)
|
||||||
|
############################################################
|
||||||
|
DATABASE_URL=postgresql://currencicombo:replace-me-on-install@127.0.0.1:5432/currencicombo
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# Redis (local to the CT per install.sh)
|
||||||
|
############################################################
|
||||||
|
REDIS_URL=redis://127.0.0.1:6379
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# Event bus signing (REQUIRED). install.sh generates this on first run
|
||||||
|
# via `openssl rand -hex 32` unless the file already exists.
|
||||||
|
############################################################
|
||||||
|
EVENT_SIGNING_SECRET=
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# API keys per role (REQUIRED). install.sh generates three random
|
||||||
|
# initiator/settler/auditor keys on first run unless set.
|
||||||
|
# Format: key1:role1,key2:role2,...
|
||||||
|
############################################################
|
||||||
|
API_KEYS=
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# Chain 138 — resolves EXT-CHAIN138-CI-RPC (already resolved).
|
||||||
|
############################################################
|
||||||
|
CHAIN_138_RPC_URL=https://rpc.public-0138.defi-oracle.io
|
||||||
|
CHAIN_138_CHAIN_ID=138
|
||||||
|
|
||||||
|
# Leave empty to run mock notary. Populate after running
|
||||||
|
# `contracts/scripts/deploy-notary-registry.ts` once.
|
||||||
|
NOTARY_REGISTRY_ADDRESS=
|
||||||
|
# Leave empty to run mock notary. Otherwise 0x-prefixed 32-byte hex.
|
||||||
|
ORCHESTRATOR_PRIVATE_KEY=
|
||||||
|
|
||||||
|
############################################################
|
||||||
|
# External dependency blockers (leave blank → mock fallback + EXT-* log)
|
||||||
|
# These are the exact IDs that the Proxmox
|
||||||
|
# scripts/verify/check-external-dependencies.sh gate knows about.
|
||||||
|
############################################################
|
||||||
|
|
||||||
|
# EXT-DBIS-CORE — set when dbis_core is deployed and reachable.
|
||||||
|
DBIS_CORE_URL=
|
||||||
|
|
||||||
|
# EXT-FIN-GATEWAY — set when a real Alliance Access / FIN gateway is
|
||||||
|
# provisioned. Leave blank to use PR R's in-process sandbox.
|
||||||
|
FIN_SANDBOX_URL=
|
||||||
|
|
||||||
|
# EXT-CC-* — the following four blockers are upstream-scaffold repos
|
||||||
|
# (cc-payment-adapters, cc-audit-ledger, cc-shared-events,
|
||||||
|
# cc-shared-schemas). They cannot be resolved from this repo; no
|
||||||
|
# env var flips them. The orchestrator logs EXT-CC-* as active on boot.
|
||||||
|
|
||||||
|
# Identity + controls matrix (not a blocker IDs per se — they ship
|
||||||
|
# today via the cc-identity-core and cc-compliance-controls adapters
|
||||||
|
# merged in PR V/W). Blank keeps the embedded v0 matrix + mock identity.
|
||||||
|
CC_IDENTITY_URL=
|
||||||
|
CC_CONTROLS_MATRIX_URL=
|
||||||
254
scripts/deployment/README.md
Normal file
254
scripts/deployment/README.md
Normal file
@@ -0,0 +1,254 @@
|
|||||||
|
# CurrenciCombo — Phoenix / systemd deployment
|
||||||
|
|
||||||
|
This directory holds everything needed to deploy CurrenciCombo onto a
|
||||||
|
systemd host — starting with Phoenix CT 8604 on `r630-01`, but any
|
||||||
|
Debian/Ubuntu (or Alpine) host with Postgres + Redis available works.
|
||||||
|
|
||||||
|
The files here are **target-agnostic**. They hardcode no IPs, hostnames,
|
||||||
|
or VLANs. Environment-specific values — `curucombo.曼李.com`, the
|
||||||
|
`10.160.0.14` VIP, the NPMplus reverse proxy — are applied at the
|
||||||
|
edge (NPMplus) and at `/etc/currencicombo/orchestrator.env`, never in
|
||||||
|
the repo.
|
||||||
|
|
||||||
|
## Architecture on CT 8604
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────────────┐
|
||||||
|
curucombo.曼李.com ──▶ NPMplus │192.168.11.167 │
|
||||||
|
(Cloudflare-proxied) │ TLS terminates here│
|
||||||
|
└─────────┬──────────┘
|
||||||
|
│
|
||||||
|
┌──────────────────────┴──────────────────────┐
|
||||||
|
│ │
|
||||||
|
▼ ▼
|
||||||
|
curucombo.曼李.com/* (default) curucombo.曼李.com/api/*
|
||||||
|
(incl. SSE /api/plans/*/events/stream)
|
||||||
|
│ │
|
||||||
|
CT 8604 │10.160.0.14:3000 CT 8604 │10.160.0.14:8080
|
||||||
|
▼ ▼
|
||||||
|
┌─────────────────────────────┐ ┌─────────────────────────────┐
|
||||||
|
│ currencicombo-webapp.service │ │ currencicombo-orchestrator │
|
||||||
|
│ nginx → /opt/currencicombo/ │ │ .service (systemd) │
|
||||||
|
│ webapp/dist/ │ │ node dist/index.js │
|
||||||
|
└─────────────────────────────┘ │ env /etc/currencicombo/ │
|
||||||
|
│ orchestrator.env │
|
||||||
|
└──────────────┬──────────────┘
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
postgresql + redis (same CT, local)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| path | purpose |
|
||||||
|
|---|---|
|
||||||
|
| `systemd/currencicombo-orchestrator.service` | Node orchestrator, reads `/etc/currencicombo/orchestrator.env` |
|
||||||
|
| `systemd/currencicombo-webapp.service` | nginx serving the Vite SPA on `:3000` |
|
||||||
|
| `webapp-nginx.conf` | full nginx.conf for the webapp unit |
|
||||||
|
| `.env.prod.example` | env template installed to `/etc/currencicombo/orchestrator.env` |
|
||||||
|
| `install.sh` | one-shot host setup: user / dirs / DB role / systemd units / first-run key handoff file |
|
||||||
|
| `install-prune-cron.sh` | opt-in daily cron that prunes `/var/lib/currencicombo/backups/` (30-day retention, keep-min 5) |
|
||||||
|
| `deploy-currencicombo-8604.sh` | build-and-swap deploy driver (the script Phoenix/proxmox deploy-api calls) |
|
||||||
|
| `README.md` | you're reading it |
|
||||||
|
|
||||||
|
## First-time setup on CT 8604
|
||||||
|
|
||||||
|
All commands run as **root** inside the CT.
|
||||||
|
|
||||||
|
1. Ensure Postgres + Redis are installed and running:
|
||||||
|
```
|
||||||
|
apt-get install -y postgresql redis-server
|
||||||
|
systemctl enable --now postgresql redis-server
|
||||||
|
```
|
||||||
|
2. Clone the repo into its staging location (once):
|
||||||
|
```
|
||||||
|
install -d -o root -g root /var/lib/currencicombo
|
||||||
|
git clone https://gitea.d-bis.org/d-bis/CurrenciCombo.git /var/lib/currencicombo/repo
|
||||||
|
```
|
||||||
|
3. Run `install.sh` (creates user, DB, systemd units, env file):
|
||||||
|
```
|
||||||
|
bash /var/lib/currencicombo/repo/scripts/deployment/install.sh
|
||||||
|
```
|
||||||
|
On success you'll see:
|
||||||
|
```
|
||||||
|
[install] generated EVENT_SIGNING_SECRET (64 hex)
|
||||||
|
[install] generated 3 API keys (initiator/settler/auditor)
|
||||||
|
[install] initial secrets written to /root/currencicombo-first-keys.txt (0600) — record in password manager, then 'shred -u /root/currencicombo-first-keys.txt'
|
||||||
|
[install] install complete.
|
||||||
|
```
|
||||||
|
`install.sh` writes the three API keys + `EVENT_SIGNING_SECRET` to **two** places:
|
||||||
|
- `/etc/currencicombo/orchestrator.env` — canonical, read by systemd (`0640`, owned by `currencicombo`).
|
||||||
|
- `/root/currencicombo-first-keys.txt` — **root-only handoff file** (`0600`). Grab it once, record the values in your password manager, then `shred -u` it.
|
||||||
|
The handoff file is **not** regenerated on re-run — if `orchestrator.env` already exists, `install.sh` does not produce new secrets.
|
||||||
|
4. (Optional) Install the backup-pruning cron:
|
||||||
|
```
|
||||||
|
bash /var/lib/currencicombo/repo/scripts/deployment/install-prune-cron.sh
|
||||||
|
```
|
||||||
|
Drops a `/etc/cron.daily/currencicombo-prune-backups` that deletes anything under `/var/lib/currencicombo/backups/` older than 30 days while **always keeping the newest 5** regardless of age. Safe on re-run; opt out with `sudo rm /etc/cron.daily/currencicombo-prune-backups`.
|
||||||
|
5. If you need to resolve any `EXT-*` blocker (e.g. point at a real dbis_core), edit `/etc/currencicombo/orchestrator.env` before the first deploy.
|
||||||
|
6. First build-and-start:
|
||||||
|
```
|
||||||
|
bash /var/lib/currencicombo/repo/scripts/deployment/deploy-currencicombo-8604.sh
|
||||||
|
```
|
||||||
|
Expected tail:
|
||||||
|
```
|
||||||
|
[deploy] orchestrator ready: {"ready":true}
|
||||||
|
[deploy] portal OK (HTTP 200)
|
||||||
|
[deploy] EXT-* blocker summary from orchestrator boot log:
|
||||||
|
[ExternalBlockers] 6 active, 1 resolved
|
||||||
|
id: EXT-DBIS-CORE
|
||||||
|
id: EXT-CC-PAYMENT-ADAPTERS
|
||||||
|
...
|
||||||
|
id: EXT-CHAIN138-CI-RPC (resolved)
|
||||||
|
[deploy] deploy complete. ref=main sha=<short> ts=<timestamp>
|
||||||
|
```
|
||||||
|
|
||||||
|
## NPMplus ingress changes required at cutover
|
||||||
|
|
||||||
|
`curucombo.曼李.com` today proxies 100% to `10.160.0.14:3000`. After
|
||||||
|
cutover it must become a **single-origin path-routed proxy** with **two**
|
||||||
|
rules (the SSE endpoint lives at `/api/plans/:id/events/stream`, so it's
|
||||||
|
already under `/api/*` — no separate `/events/*` rule is needed):
|
||||||
|
|
||||||
|
| location | upstream | proxy settings |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/*` | `http://10.160.0.14:8080` | **SSE-friendly settings apply here because the SSE route `/api/plans/:id/events/stream` is under /api/**. Use `proxy_pass http://10.160.0.14:8080;` with **no trailing slash** so `/api/...` reaches the orchestrator unchanged. Set: `proxy_http_version 1.1;`, `proxy_set_header Connection "";`, `proxy_buffering off;`, `proxy_cache off;`, `proxy_read_timeout 24h;`, `proxy_send_timeout 24h;`. Standard forwarding: `proxy_set_header Host $host;`, `X-Real-IP $remote_addr;`, `X-Forwarded-For $proxy_add_x_forwarded_for;`, `X-Forwarded-Proto $scheme;`. The slight overhead of `proxy_buffering off` on plain REST calls is negligible for this workload. |
|
||||||
|
| `/` | `http://10.160.0.14:3000` | Vite SPA. Default upstream. No special settings. |
|
||||||
|
|
||||||
|
If you skip the `/api/*` rule, the nginx in `webapp-nginx.conf`
|
||||||
|
intentionally returns `HTTP 421` for that path — a clean "upstream is
|
||||||
|
misconfigured" signal instead of silently returning `index.html` and
|
||||||
|
breaking the browser with a JSON parse error.
|
||||||
|
|
||||||
|
## Subsequent deploys
|
||||||
|
|
||||||
|
Every deploy after the first is just:
|
||||||
|
|
||||||
|
```
|
||||||
|
sudo /var/lib/currencicombo/repo/scripts/deployment/deploy-currencicombo-8604.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
- `--ref=<branch-or-sha>` — deploy something other than `main`.
|
||||||
|
- `--dry-run` — print what would happen, don't touch anything.
|
||||||
|
- `--skip-migrate` — hotfix deploys that don't change the schema.
|
||||||
|
- `--skip-build` — reuse the build from the previous run (debugging only).
|
||||||
|
- `--rollback` — restore the most recent `/var/lib/currencicombo/backups/<ts>/` and restart units. Does **not** git-pull or rebuild.
|
||||||
|
|
||||||
|
Every deploy writes a timestamped backup to
|
||||||
|
`/var/lib/currencicombo/backups/<YYYYmmdd-HHMMSS>/` before swapping. Pruning is opt-in via `install-prune-cron.sh` (30-day retention, keep-min 5). Without the cron, backups accumulate forever — quietly filling `/var/lib` is how the next outage starts.
|
||||||
|
|
||||||
|
## Failure handling on deploy
|
||||||
|
|
||||||
|
**Rollback is manual.** `deploy-currencicombo-8604.sh` **does not** auto-restore the previous backup if the orchestrator fails to become ready. First cutovers typically fail because of env typos or migration mistakes, and auto-restoring hides the failure state ops needs.
|
||||||
|
|
||||||
|
Instead, on a readiness timeout the deploy script prints:
|
||||||
|
- last 40 lines of `journalctl -u currencicombo-orchestrator`
|
||||||
|
- last 20 lines of `journalctl -u currencicombo-webapp`
|
||||||
|
- **the exact `--rollback` command with the specific backup path filled in**
|
||||||
|
|
||||||
|
Example tail on failure:
|
||||||
|
```
|
||||||
|
================================================================
|
||||||
|
DEPLOY FAILED: orchestrator did not become ready after 60s
|
||||||
|
================================================================
|
||||||
|
|
||||||
|
## currencicombo-orchestrator (last 40 lines):
|
||||||
|
... env validation error: EVENT_SIGNING_SECRET is required ...
|
||||||
|
|
||||||
|
## Units are in whatever state deploy left them. To restore
|
||||||
|
## the previous build (does NOT revert DB migrations):
|
||||||
|
|
||||||
|
sudo /var/lib/currencicombo/repo/scripts/deployment/deploy-currencicombo-8604.sh --rollback
|
||||||
|
# (will restore /var/lib/currencicombo/backups/20260423-140215)
|
||||||
|
|
||||||
|
================================================================
|
||||||
|
```
|
||||||
|
|
||||||
|
Rollback one-liner (when ops has decided to restore):
|
||||||
|
```
|
||||||
|
sudo /var/lib/currencicombo/repo/scripts/deployment/deploy-currencicombo-8604.sh --rollback
|
||||||
|
```
|
||||||
|
|
||||||
|
Rollback restores the most recent backup and restarts both units. It **does not** touch the DB. If the failed deploy applied a new migration, DB rollback is a manual `psql` task — the orchestrator's migration runner only emits `up()` paths.
|
||||||
|
|
||||||
|
## Post-cutover smoke checks through NPMplus
|
||||||
|
|
||||||
|
Once the NPMplus `/api/*` rule is live, from a workstation (not the CT):
|
||||||
|
|
||||||
|
```
|
||||||
|
# 1. Front-door TLS is healthy
|
||||||
|
curl -skI https://curucombo.xn--vov0g.com/ | head -3
|
||||||
|
# expect: HTTP/2 200
|
||||||
|
# expect: NO 'x-nextjs-prerender' header (that was the old Next.js build)
|
||||||
|
|
||||||
|
# 2. SPA is the new Vite portal
|
||||||
|
curl -sk https://curucombo.xn--vov0g.com/ | grep -oE '<title>[^<]+</title>'
|
||||||
|
# expect: <title>Solace Bank Group PLC — Treasury Management Portal</title>
|
||||||
|
|
||||||
|
# 3. Orchestrator ready through NPMplus
|
||||||
|
curl -sk https://curucombo.xn--vov0g.com/api/ready | head -1
|
||||||
|
# expect: {"ready":true} (not HTML)
|
||||||
|
|
||||||
|
# 4. Orchestrator blocker log (through CT shell, not NPMplus)
|
||||||
|
ssh root@10.160.0.14 'journalctl -u currencicombo-orchestrator -n 200 | grep -E "ExternalBlockers|EXT-"'
|
||||||
|
# expect: [ExternalBlockers] 6 active, 1 resolved
|
||||||
|
# expect: one line per EXT-* id
|
||||||
|
|
||||||
|
# 5. SSE actually streams (catches silent NPMplus proxy_buffering=on misconfig)
|
||||||
|
curl -sk -N --max-time 5 -H 'Accept: text/event-stream' \
|
||||||
|
https://curucombo.xn--vov0g.com/api/plans/demo-pay-014/events/stream \
|
||||||
|
| head -20 || true
|
||||||
|
# expect: HTTP/2 200 with Content-Type: text/event-stream
|
||||||
|
# expect: at least one 'data: {...}\n\n' frame to arrive WITHIN ~1s
|
||||||
|
# if you see nothing for 3-5s and then everything dumps at once:
|
||||||
|
# NPMplus has proxy_buffering=on. Fix: proxy_buffering off; proxy_http_version 1.1; proxy_set_header Connection "";
|
||||||
|
# if the ping is 401/403: expected — SSE is auth-gated; the point is to
|
||||||
|
# prove the request REACHED the orchestrator (content-type header +
|
||||||
|
# chunked response headers) rather than hitting the Vite SPA.
|
||||||
|
```
|
||||||
|
|
||||||
|
A plain `HTTP/2 200` with a `Content-Type: text/html` body on `/api/ready` means NPMplus is silently falling back to the `/` rule — the `/api/*` rule is missing or ordered wrong. The `webapp-nginx.conf` in this repo returns `HTTP 421` for `/api/*` to make that case obvious when debugging CT-locally, but at the NPMplus edge nginx serves whatever NPMplus routes to it.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
| symptom | cause / check |
|
||||||
|
|---|---|
|
||||||
|
| `/api/*` returns `421 NPMplus is misconfigured` | NPMplus `/api/*` rule missing or wrong upstream. |
|
||||||
|
| `/events/*` connects then disconnects after ~60s | NPMplus forgot `proxy_buffering off` + high `proxy_read_timeout`. |
|
||||||
|
| orchestrator unit enters `activating (auto-restart)` loop | `journalctl -u currencicombo-orchestrator -n 80` — usually a zod env-validation error. The boot-time assertion message names the missing/invalid var. |
|
||||||
|
| orchestrator boot log says `[ExternalBlockers] N active` where N > 6 | you added an `EXT-*` env var without also updating the central registry in `orchestrator/src/config/externalBlockers.ts`. |
|
||||||
|
| `/health` returns 503 but `/ready` is 200 | memory `critical` is a separate signal from readiness. Inspect CT memory; this happens on constrained builders and is not a deploy bug. |
|
||||||
|
| portal page loads but MetaMask login does nothing | the portal couldn't reach `/api/auth/*`. Walk back up the NPMplus rule chain. |
|
||||||
|
|
||||||
|
## Cutting over from the pre-existing Next.js build
|
||||||
|
|
||||||
|
Phoenix previously had an older Next.js "ISO-20022 Combo Flow" app in
|
||||||
|
`/opt/currencicombo/webapp`. The cutover sequence on CT 8604 is:
|
||||||
|
|
||||||
|
1. **Backup the old install** out-of-band:
|
||||||
|
```
|
||||||
|
tar czf /root/currencicombo-preRepo-$(date +%s).tgz /opt/currencicombo /etc/currencicombo 2>/dev/null || true
|
||||||
|
```
|
||||||
|
2. **Disable the pre-existing systemd units** (they're the same names but point at the old tree):
|
||||||
|
```
|
||||||
|
systemctl stop currencicombo-webapp currencicombo-orchestrator
|
||||||
|
systemctl disable currencicombo-webapp currencicombo-orchestrator
|
||||||
|
```
|
||||||
|
3. Run `install.sh` (writes the new units, new nginx, new env). On an already-set-up host this is idempotent: it preserves `/etc/currencicombo/orchestrator.env` if it already exists.
|
||||||
|
4. Run `deploy-currencicombo-8604.sh`.
|
||||||
|
5. Apply the NPMplus `/api` + `/` path rules.
|
||||||
|
6. Smoke from outside the CT: `curl -skI https://curucombo.xn--vov0g.com/ && curl -sk https://curucombo.xn--vov0g.com/api/ready`.
|
||||||
|
|
||||||
|
## Proxmox-side follow-up (not in this PR)
|
||||||
|
|
||||||
|
After this PR merges and the above cutover runs cleanly, the
|
||||||
|
`/home/intlc/projects/proxmox` repo needs a separate commit to:
|
||||||
|
|
||||||
|
- Update `phoenix-deploy-api/deploy-targets.json` to point at:
|
||||||
|
- repo: `d-bis/CurrenciCombo`
|
||||||
|
- branch: `main`
|
||||||
|
- target: `default`
|
||||||
|
- deploy entrypoint: `scripts/deployment/deploy-currencicombo-8604.sh`
|
||||||
|
- Remove any stale `/opt/currencicombo/webapp` Next.js references.
|
||||||
|
- Drop any description of `ignoreBuildErrors: true` in `webapp/next.config.ts` — the new webapp is Vite+tsc-strict, no build-error suppression.
|
||||||
236
scripts/deployment/deploy-currencicombo-8604.sh
Executable file
236
scripts/deployment/deploy-currencicombo-8604.sh
Executable file
@@ -0,0 +1,236 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# deploy-currencicombo-8604.sh — build-and-swap deploy for CurrenciCombo.
|
||||||
|
#
|
||||||
|
# Runs on a systemd host that has already had `install.sh` applied once.
|
||||||
|
# This is the script referenced by the Proxmox repo's
|
||||||
|
# `phoenix-deploy-api/deploy-targets.json` tuple
|
||||||
|
# (repo=d-bis/CurrenciCombo, branch=main, target=default).
|
||||||
|
#
|
||||||
|
# Steps (each idempotent, each can be --dry-run'd):
|
||||||
|
# 1. git clone/pull /var/lib/currencicombo/repo to the target ref.
|
||||||
|
# 2. Build orchestrator (npm ci + npm run build).
|
||||||
|
# 3. Build portal/webapp (npm ci + npm run build), baking
|
||||||
|
# VITE_ORCHESTRATOR_URL into the bundle.
|
||||||
|
# 4. Run DB migrations (npm run migrate in orchestrator/).
|
||||||
|
# 5. Stop systemd units.
|
||||||
|
# 6. rsync build output into /opt/currencicombo/{orchestrator,webapp}.
|
||||||
|
# 7. Start systemd units.
|
||||||
|
# 8. Smoke-test /ready + portal / + print EXT-* blocker summary.
|
||||||
|
#
|
||||||
|
# Rollback: `--rollback` restores the previous backup under
|
||||||
|
# /var/lib/currencicombo/backups/<timestamp>.
|
||||||
|
#
|
||||||
|
# CT 8604 is in the filename for ops-grep-ability; the script itself is
|
||||||
|
# host-agnostic. Override paths via env vars if you run it elsewhere.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# ----- defaults (override via env) ------------------------------------
|
||||||
|
: "${CC_GIT_REMOTE:=https://gitea.d-bis.org/d-bis/CurrenciCombo.git}"
|
||||||
|
: "${CC_GIT_REF:=main}"
|
||||||
|
: "${CC_REPO_DIR:=/var/lib/currencicombo/repo}"
|
||||||
|
: "${CC_APP_HOME:=/opt/currencicombo}"
|
||||||
|
: "${CC_BACKUP_DIR:=/var/lib/currencicombo/backups}"
|
||||||
|
: "${CC_USER:=currencicombo}"
|
||||||
|
|
||||||
|
# Portal build-time env. The NPMplus ingress path-routes /api/* and
|
||||||
|
# /events/* to the orchestrator, so same-origin works.
|
||||||
|
: "${VITE_ORCHESTRATOR_URL:=https://curucombo.xn--vov0g.com}"
|
||||||
|
|
||||||
|
: "${ORCHESTRATOR_UNIT:=currencicombo-orchestrator.service}"
|
||||||
|
: "${WEBAPP_UNIT:=currencicombo-webapp.service}"
|
||||||
|
|
||||||
|
: "${CC_HEALTH_URL:=http://127.0.0.1:8080/ready}"
|
||||||
|
: "${CC_PORTAL_URL:=http://127.0.0.1:3000/}"
|
||||||
|
: "${CC_HEALTH_TIMEOUT_SECS:=60}"
|
||||||
|
|
||||||
|
# ----- flags ----------------------------------------------------------
|
||||||
|
DRY_RUN=0
|
||||||
|
SKIP_MIGRATE=0
|
||||||
|
SKIP_BUILD=0
|
||||||
|
DO_ROLLBACK=0
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage: sudo ./deploy-currencicombo-8604.sh [flags]
|
||||||
|
|
||||||
|
Flags:
|
||||||
|
--ref=<git-ref> Override CC_GIT_REF (default: main)
|
||||||
|
--dry-run Print commands, don't run them
|
||||||
|
--skip-migrate Skip `npm run migrate` step (use for hotfix
|
||||||
|
deploys where schema hasn't changed)
|
||||||
|
--skip-build Reuse the existing build in CC_REPO_DIR/dist
|
||||||
|
(useful after `--dry-run --skip-build=no` from
|
||||||
|
the previous run)
|
||||||
|
--rollback Restore the most recent backup and restart.
|
||||||
|
Does not run git/build/migrate.
|
||||||
|
-h, --help This help
|
||||||
|
|
||||||
|
Env overrides:
|
||||||
|
CC_GIT_REMOTE, CC_GIT_REF, CC_REPO_DIR, CC_APP_HOME, CC_BACKUP_DIR,
|
||||||
|
CC_USER, VITE_ORCHESTRATOR_URL, ORCHESTRATOR_UNIT, WEBAPP_UNIT,
|
||||||
|
CC_HEALTH_URL, CC_PORTAL_URL, CC_HEALTH_TIMEOUT_SECS
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--ref=*) CC_GIT_REF="${1#*=}"; shift ;;
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
--skip-migrate) SKIP_MIGRATE=1; shift ;;
|
||||||
|
--skip-build) SKIP_BUILD=1; shift ;;
|
||||||
|
--rollback) DO_ROLLBACK=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) echo "unknown arg: $1" >&2; usage; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log() { printf '[deploy] %s\n' "$*" >&2; }
|
||||||
|
warn() { printf '[deploy][WARN] %s\n' "$*" >&2; }
|
||||||
|
die() { printf '[deploy][FATAL] %s\n' "$*" >&2; exit 1; }
|
||||||
|
run() { if [[ "${DRY_RUN}" -eq 1 ]]; then printf '[deploy][dry-run] %s\n' "$*" >&2; else eval "$*"; fi; }
|
||||||
|
runcc() { if [[ "${DRY_RUN}" -eq 1 ]]; then printf '[deploy][dry-run][as %s] %s\n' "${CC_USER}" "$*" >&2; else sudo -u "${CC_USER}" -H bash -lc "$*"; fi; }
|
||||||
|
|
||||||
|
[[ "$EUID" -eq 0 ]] || die "must run as root (sudo)"
|
||||||
|
|
||||||
|
# ----- rollback fast-path ---------------------------------------------
|
||||||
|
if [[ "${DO_ROLLBACK}" -eq 1 ]]; then
|
||||||
|
LATEST="$(ls -1dt "${CC_BACKUP_DIR}"/* 2>/dev/null | head -1 || true)"
|
||||||
|
[[ -n "${LATEST}" ]] || die "no backup under ${CC_BACKUP_DIR}"
|
||||||
|
log "rolling back to ${LATEST}"
|
||||||
|
run "systemctl stop '${WEBAPP_UNIT}' '${ORCHESTRATOR_UNIT}'"
|
||||||
|
run "rsync -a --delete '${LATEST}/orchestrator/' '${CC_APP_HOME}/orchestrator/'"
|
||||||
|
run "rsync -a --delete '${LATEST}/webapp/' '${CC_APP_HOME}/webapp/'"
|
||||||
|
run "systemctl start '${ORCHESTRATOR_UNIT}' '${WEBAPP_UNIT}'"
|
||||||
|
log "rollback applied. systemctl status ${ORCHESTRATOR_UNIT} to verify."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----- 1. git ---------------------------------------------------------
|
||||||
|
run "install -d -o '${CC_USER}' -g '${CC_USER}' -m 0755 '${CC_REPO_DIR}'"
|
||||||
|
run "chown -R '${CC_USER}:${CC_USER}' '${CC_REPO_DIR}'"
|
||||||
|
|
||||||
|
if [[ ! -d "${CC_REPO_DIR}/.git" && "${CC_GIT_REF}" != "local" ]]; then
|
||||||
|
log "cloning ${CC_GIT_REMOTE} → ${CC_REPO_DIR}"
|
||||||
|
runcc "git clone '${CC_GIT_REMOTE}' '${CC_REPO_DIR}'"
|
||||||
|
fi
|
||||||
|
if [[ -d "${CC_REPO_DIR}/.git" && "${CC_GIT_REF}" != "local" ]]; then
|
||||||
|
runcc "cd '${CC_REPO_DIR}' && git fetch --prune origin"
|
||||||
|
runcc "cd '${CC_REPO_DIR}' && git reset --hard 'origin/${CC_GIT_REF}'"
|
||||||
|
REF_SHA="$(sudo -u "${CC_USER}" git -C "${CC_REPO_DIR}" rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||||
|
log "repo at ${CC_GIT_REF} = ${REF_SHA}"
|
||||||
|
else
|
||||||
|
REF_SHA="local"
|
||||||
|
log "using staged local workspace from ${CC_REPO_DIR}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----- 2. orchestrator build -----------------------------------------
|
||||||
|
if [[ "${SKIP_BUILD}" -eq 0 ]]; then
|
||||||
|
log "building orchestrator"
|
||||||
|
if [[ -f "${CC_REPO_DIR}/orchestrator/package-lock.json" ]]; then
|
||||||
|
runcc "cd '${CC_REPO_DIR}/orchestrator' && npm ci --no-audit --no-fund"
|
||||||
|
else
|
||||||
|
runcc "cd '${CC_REPO_DIR}/orchestrator' && npm install --no-audit --no-fund"
|
||||||
|
fi
|
||||||
|
runcc "cd '${CC_REPO_DIR}/orchestrator' && npm run build"
|
||||||
|
log "building portal (VITE_ORCHESTRATOR_URL=${VITE_ORCHESTRATOR_URL})"
|
||||||
|
runcc "cd '${CC_REPO_DIR}' && npm ci --include=optional --no-audit --no-fund || npm ci --include=optional --force --no-audit --no-fund"
|
||||||
|
runcc "cd '${CC_REPO_DIR}' && VITE_ORCHESTRATOR_URL='${VITE_ORCHESTRATOR_URL}' npm run build"
|
||||||
|
else
|
||||||
|
log "skipping builds (--skip-build)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----- 3. migrations --------------------------------------------------
|
||||||
|
if [[ "${SKIP_MIGRATE}" -eq 0 ]]; then
|
||||||
|
log "running DB migrations"
|
||||||
|
runcc "cd '${CC_REPO_DIR}/orchestrator' && npm run migrate"
|
||||||
|
else
|
||||||
|
log "skipping migrations (--skip-migrate)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----- 4. backup previous install ------------------------------------
|
||||||
|
TS="$(date +%Y%m%d-%H%M%S)"
|
||||||
|
BACKUP="${CC_BACKUP_DIR}/${TS}"
|
||||||
|
if [[ -d "${CC_APP_HOME}/orchestrator/dist" || -d "${CC_APP_HOME}/webapp/dist" ]]; then
|
||||||
|
log "backing up current install → ${BACKUP}"
|
||||||
|
run "install -d -o root -g root -m 0700 '${BACKUP}/orchestrator' '${BACKUP}/webapp'"
|
||||||
|
run "rsync -a '${CC_APP_HOME}/orchestrator/' '${BACKUP}/orchestrator/'"
|
||||||
|
run "rsync -a '${CC_APP_HOME}/webapp/' '${BACKUP}/webapp/'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----- 5. stop units --------------------------------------------------
|
||||||
|
log "stopping systemd units"
|
||||||
|
run "systemctl stop '${WEBAPP_UNIT}' || true"
|
||||||
|
run "systemctl stop '${ORCHESTRATOR_UNIT}' || true"
|
||||||
|
|
||||||
|
# ----- 6. swap in new build ------------------------------------------
|
||||||
|
log "rsyncing new build into ${CC_APP_HOME}"
|
||||||
|
# Orchestrator: dist/ + node_modules/ + package.json + package-lock.json
|
||||||
|
runcc "rsync -a --delete '${CC_REPO_DIR}/orchestrator/dist/' '${CC_APP_HOME}/orchestrator/dist/'"
|
||||||
|
runcc "rsync -a '${CC_REPO_DIR}/orchestrator/node_modules/' '${CC_APP_HOME}/orchestrator/node_modules/'"
|
||||||
|
runcc "cp '${CC_REPO_DIR}/orchestrator/package.json' '${CC_APP_HOME}/orchestrator/package.json'"
|
||||||
|
runcc "if [[ -f '${CC_REPO_DIR}/orchestrator/package-lock.json' ]]; then cp '${CC_REPO_DIR}/orchestrator/package-lock.json' '${CC_APP_HOME}/orchestrator/package-lock.json'; else rm -f '${CC_APP_HOME}/orchestrator/package-lock.json'; fi"
|
||||||
|
# Webapp: dist/
|
||||||
|
runcc "rsync -a --delete '${CC_REPO_DIR}/dist/' '${CC_APP_HOME}/webapp/dist/'"
|
||||||
|
|
||||||
|
# ----- 7. start units ------------------------------------------------
|
||||||
|
log "starting systemd units"
|
||||||
|
run "systemctl start '${ORCHESTRATOR_UNIT}'"
|
||||||
|
run "systemctl start '${WEBAPP_UNIT}'"
|
||||||
|
|
||||||
|
# ----- 8. smoke -------------------------------------------------------
|
||||||
|
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||||
|
log "dry-run: skipping smoke test"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
log "waiting up to ${CC_HEALTH_TIMEOUT_SECS}s for orchestrator ${CC_HEALTH_URL}"
|
||||||
|
SECS=0
|
||||||
|
until curl -sfL --max-time 3 "${CC_HEALTH_URL}" >/dev/null 2>&1; do
|
||||||
|
SECS=$((SECS + 2))
|
||||||
|
if [[ "${SECS}" -ge "${CC_HEALTH_TIMEOUT_SECS}" ]]; then
|
||||||
|
# Loud failure summary. Deliberately does NOT auto-rollback — first
|
||||||
|
# cutovers often fail because of env/migration mistakes, and
|
||||||
|
# auto-restoring the old build hides the failure state ops needs to
|
||||||
|
# diagnose. Print the exact --rollback command with the specific
|
||||||
|
# backup path filled in, so it's one copy-paste away if desired.
|
||||||
|
{
|
||||||
|
echo
|
||||||
|
echo "================================================================"
|
||||||
|
echo "DEPLOY FAILED: orchestrator did not become ready after ${CC_HEALTH_TIMEOUT_SECS}s"
|
||||||
|
echo "================================================================"
|
||||||
|
echo
|
||||||
|
echo "## currencicombo-orchestrator (last 40 lines):"
|
||||||
|
journalctl -u "${ORCHESTRATOR_UNIT}" -n 40 --no-pager 2>&1 || echo "(journalctl unavailable)"
|
||||||
|
echo
|
||||||
|
echo "## currencicombo-webapp (last 20 lines):"
|
||||||
|
journalctl -u "${WEBAPP_UNIT}" -n 20 --no-pager 2>&1 || echo "(journalctl unavailable)"
|
||||||
|
echo
|
||||||
|
echo "## Units are in whatever state deploy left them. To restore"
|
||||||
|
echo "## the previous build (does NOT revert DB migrations):"
|
||||||
|
echo
|
||||||
|
if [[ -n "${BACKUP:-}" && -d "${BACKUP}" ]]; then
|
||||||
|
echo " sudo $0 --rollback"
|
||||||
|
echo " # (will restore ${BACKUP})"
|
||||||
|
else
|
||||||
|
echo " # No backup was taken (first deploy). Manual recovery required."
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
echo "================================================================"
|
||||||
|
} >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
log "orchestrator ready: $(curl -sf "${CC_HEALTH_URL}")"
|
||||||
|
|
||||||
|
log "probing portal ${CC_PORTAL_URL}"
|
||||||
|
PORTAL_CODE="$(curl -s -o /dev/null -w '%{http_code}' "${CC_PORTAL_URL}" || echo ERR)"
|
||||||
|
[[ "${PORTAL_CODE}" =~ ^2 ]] || die "portal returned HTTP ${PORTAL_CODE}"
|
||||||
|
log "portal OK (HTTP ${PORTAL_CODE})"
|
||||||
|
|
||||||
|
log "EXT-* blocker summary from orchestrator boot log:"
|
||||||
|
journalctl -u "${ORCHESTRATOR_UNIT}" --no-pager -n 200 \
|
||||||
|
| grep -E 'ExternalBlockers|EXT-[A-Z0-9-]+' | tail -20 || true
|
||||||
|
|
||||||
|
log "deploy complete. ref=${CC_GIT_REF} sha=${REF_SHA} ts=${TS}"
|
||||||
102
scripts/deployment/install-prune-cron.sh
Executable file
102
scripts/deployment/install-prune-cron.sh
Executable file
@@ -0,0 +1,102 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install-prune-cron.sh — opt-in cron job to prune old deploy backups.
|
||||||
|
#
|
||||||
|
# Run ONCE as root (or with sudo) after install.sh to enable daily
|
||||||
|
# pruning of /var/lib/currencicombo/backups/. The pruner:
|
||||||
|
# - deletes entries older than 30 days
|
||||||
|
# - ALWAYS keeps the newest N backups regardless of age (default 5)
|
||||||
|
#
|
||||||
|
# No-op on re-run. Opt out by removing /etc/cron.daily/currencicombo-prune-backups.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIR="${CC_BACKUP_DIR:-/var/lib/currencicombo/backups}"
|
||||||
|
RETAIN_DAYS="${CC_BACKUP_RETAIN_DAYS:-30}"
|
||||||
|
KEEP_MIN="${CC_BACKUP_KEEP_MIN:-5}"
|
||||||
|
CRON_FILE="/etc/cron.daily/currencicombo-prune-backups"
|
||||||
|
DRY_RUN=0
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
-h|--help)
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage: sudo ./install-prune-cron.sh [--dry-run]
|
||||||
|
|
||||||
|
Env overrides:
|
||||||
|
CC_BACKUP_DIR (default: /var/lib/currencicombo/backups)
|
||||||
|
CC_BACKUP_RETAIN_DAYS (default: 30)
|
||||||
|
CC_BACKUP_KEEP_MIN (default: 5)
|
||||||
|
USAGE
|
||||||
|
exit 0 ;;
|
||||||
|
*) echo "unknown arg: $1" >&2; exit 2 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
log() { printf '[install-prune-cron] %s\n' "$*" >&2; }
|
||||||
|
die() { printf '[install-prune-cron][FATAL] %s\n' "$*" >&2; exit 1; }
|
||||||
|
|
||||||
|
[[ "$EUID" -eq 0 ]] || die "must run as root (sudo)"
|
||||||
|
|
||||||
|
# The pruner script body. Runs daily via cron.daily.
|
||||||
|
# KEEP_MIN is enforced by listing backups newest-first, skipping the
|
||||||
|
# first KEEP_MIN, then deleting any remaining entries older than
|
||||||
|
# RETAIN_DAYS. This means we always keep at least KEEP_MIN (even if
|
||||||
|
# they're all <30 days old), and never delete one of the newest
|
||||||
|
# KEEP_MIN (even if it's >30 days old on a dormant host).
|
||||||
|
read -r -d '' PRUNER_BODY <<PRUNER || true
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# Managed by scripts/deployment/install-prune-cron.sh. Edits overwritten
|
||||||
|
# on next install. Opt out by deleting this file.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
BACKUP_DIR="${BACKUP_DIR}"
|
||||||
|
RETAIN_DAYS=${RETAIN_DAYS}
|
||||||
|
KEEP_MIN=${KEEP_MIN}
|
||||||
|
|
||||||
|
[[ -d "\$BACKUP_DIR" ]] || exit 0
|
||||||
|
|
||||||
|
cd "\$BACKUP_DIR"
|
||||||
|
mapfile -t all < <(find . -mindepth 1 -maxdepth 1 -type d -printf '%T@ %p\n' 2>/dev/null | sort -rn | awk '{print \$2}')
|
||||||
|
|
||||||
|
count=\${#all[@]}
|
||||||
|
if (( count <= KEEP_MIN )); then
|
||||||
|
logger -t currencicombo-prune "count=\$count <= KEEP_MIN=\$KEEP_MIN; nothing to prune"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
cutoff=\$(date -d "\$RETAIN_DAYS days ago" +%s)
|
||||||
|
deleted=0
|
||||||
|
kept=0
|
||||||
|
for i in "\${!all[@]}"; do
|
||||||
|
p="\${all[\$i]}"
|
||||||
|
if (( i < KEEP_MIN )); then
|
||||||
|
kept=\$((kept + 1))
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
mtime=\$(stat -c %Y "\$p" 2>/dev/null || echo 0)
|
||||||
|
if (( mtime < cutoff )); then
|
||||||
|
rm -rf -- "\$p"
|
||||||
|
deleted=\$((deleted + 1))
|
||||||
|
else
|
||||||
|
kept=\$((kept + 1))
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
logger -t currencicombo-prune "deleted=\$deleted kept=\$kept total_before=\$count"
|
||||||
|
PRUNER
|
||||||
|
|
||||||
|
if [[ "${DRY_RUN}" -eq 1 ]]; then
|
||||||
|
log "[dry-run] would write ${CRON_FILE} (0755) with pruner targeting ${BACKUP_DIR}, retain ${RETAIN_DAYS}d, keep-min ${KEEP_MIN}"
|
||||||
|
echo "---"
|
||||||
|
echo "${PRUNER_BODY}"
|
||||||
|
echo "---"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\n' "${PRUNER_BODY}" > "${CRON_FILE}"
|
||||||
|
chmod 0755 "${CRON_FILE}"
|
||||||
|
chown root:root "${CRON_FILE}"
|
||||||
|
|
||||||
|
log "installed ${CRON_FILE} (backups older than ${RETAIN_DAYS}d, keep-min ${KEEP_MIN}, target ${BACKUP_DIR})"
|
||||||
|
log "runs daily via /etc/cron.daily/. Opt out: sudo rm ${CRON_FILE}"
|
||||||
|
log "logs to syslog (tag currencicombo-prune); journalctl -t currencicombo-prune"
|
||||||
252
scripts/deployment/install.sh
Executable file
252
scripts/deployment/install.sh
Executable file
@@ -0,0 +1,252 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# install.sh — idempotent first-time setup for CurrenciCombo on a systemd host.
|
||||||
|
#
|
||||||
|
# Intended to run ONCE per host as root (or with sudo). Running it again is
|
||||||
|
# safe: it will skip already-present artifacts and warn on conflicts.
|
||||||
|
#
|
||||||
|
# What this does:
|
||||||
|
# 1. Creates the `currencicombo` system user and /opt/currencicombo tree.
|
||||||
|
# 2. Installs nginx (Debian/Ubuntu or Alpine) if not present.
|
||||||
|
# 3. Ensures a local Postgres is running and creates a fresh
|
||||||
|
# `currencicombo` role + DB (refuses to touch an existing one unless
|
||||||
|
# --force-recreate is passed).
|
||||||
|
# 4. Ensures a local Redis is running.
|
||||||
|
# 5. Writes /etc/currencicombo/orchestrator.env from .env.prod.example,
|
||||||
|
# auto-populating EVENT_SIGNING_SECRET and ORCHESTRATOR_API_KEYS with
|
||||||
|
# fresh randoms the first time.
|
||||||
|
# 6. Installs /etc/currencicombo/webapp-nginx.conf.
|
||||||
|
# 7. Installs the two systemd units and runs `systemctl daemon-reload`.
|
||||||
|
# 8. Enables (does NOT start) both units. First start happens via
|
||||||
|
# scripts/deployment/deploy-currencicombo-8604.sh after the first
|
||||||
|
# successful build.
|
||||||
|
#
|
||||||
|
# This script is target-agnostic. It has no hardcoded IP / hostname /
|
||||||
|
# VLAN. The NPMplus ingress in front of it is configured separately —
|
||||||
|
# see scripts/deployment/README.md.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
|
||||||
|
APP_USER="currencicombo"
|
||||||
|
APP_HOME="/opt/currencicombo"
|
||||||
|
ETC_DIR="/etc/currencicombo"
|
||||||
|
LOG_DIR="/var/log/currencicombo"
|
||||||
|
REPO_DIR="/var/lib/currencicombo/repo"
|
||||||
|
ENV_FILE="${ETC_DIR}/orchestrator.env"
|
||||||
|
NGINX_FILE="${ETC_DIR}/webapp-nginx.conf"
|
||||||
|
SYSTEMD_DIR="/etc/systemd/system"
|
||||||
|
|
||||||
|
FORCE_RECREATE_DB=0
|
||||||
|
DRY_RUN=0
|
||||||
|
SKIP_NGINX_INSTALL=0
|
||||||
|
|
||||||
|
log() { printf '[install] %s\n' "$*" >&2; }
|
||||||
|
warn() { printf '[install][WARN] %s\n' "$*" >&2; }
|
||||||
|
die() { printf '[install][FATAL] %s\n' "$*" >&2; exit 1; }
|
||||||
|
run() { if [[ "${DRY_RUN}" -eq 1 ]]; then printf '[install][dry-run] %s\n' "$*" >&2; else eval "$*"; fi; }
|
||||||
|
sql_escape() {
|
||||||
|
printf "%s" "$1" | sed "s/'/''/g"
|
||||||
|
}
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<'USAGE'
|
||||||
|
Usage: sudo ./install.sh [--force-recreate-db] [--skip-nginx-install] [--dry-run]
|
||||||
|
|
||||||
|
--force-recreate-db DROP and recreate the currencicombo Postgres role
|
||||||
|
and DB even if they already exist. DESTRUCTIVE.
|
||||||
|
--skip-nginx-install Do not apt/apk install nginx (use if you already
|
||||||
|
have a custom nginx build in place).
|
||||||
|
--dry-run Print the commands that would run, don't run them.
|
||||||
|
USAGE
|
||||||
|
}
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--force-recreate-db) FORCE_RECREATE_DB=1; shift ;;
|
||||||
|
--skip-nginx-install) SKIP_NGINX_INSTALL=1; shift ;;
|
||||||
|
--dry-run) DRY_RUN=1; shift ;;
|
||||||
|
-h|--help) usage; exit 0 ;;
|
||||||
|
*) die "unknown arg: $1" ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
[[ "$EUID" -eq 0 ]] || die "must run as root (sudo)"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 1. User + tree
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
if id "${APP_USER}" >/dev/null 2>&1; then
|
||||||
|
log "user ${APP_USER} already exists"
|
||||||
|
else
|
||||||
|
log "creating system user ${APP_USER}"
|
||||||
|
run useradd --system --home-dir "${APP_HOME}" --shell /usr/sbin/nologin --user-group "${APP_USER}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
for d in "${APP_HOME}" "${APP_HOME}/orchestrator" "${APP_HOME}/webapp" \
|
||||||
|
"${APP_HOME}/webapp/dist" "${ETC_DIR}" "${LOG_DIR}" "${REPO_DIR}"; do
|
||||||
|
run install -d -o "${APP_USER}" -g "${APP_USER}" -m 0755 "$d"
|
||||||
|
done
|
||||||
|
run chown "${APP_USER}:${APP_USER}" "${APP_HOME}" "${LOG_DIR}" "${REPO_DIR}"
|
||||||
|
run chmod 0750 "${ETC_DIR}"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 2. nginx (required by currencicombo-webapp.service)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
if [[ "${SKIP_NGINX_INSTALL}" -eq 0 ]]; then
|
||||||
|
if command -v nginx >/dev/null 2>&1; then
|
||||||
|
log "nginx already installed ($(nginx -v 2>&1 | head -1))"
|
||||||
|
elif command -v apt-get >/dev/null 2>&1; then
|
||||||
|
log "installing nginx via apt"
|
||||||
|
run 'DEBIAN_FRONTEND=noninteractive apt-get update -q'
|
||||||
|
run 'DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends nginx-light'
|
||||||
|
# We use our own nginx.conf via -c, so disable the distro site.
|
||||||
|
run systemctl disable --now nginx 2>/dev/null || true
|
||||||
|
elif command -v apk >/dev/null 2>&1; then
|
||||||
|
log "installing nginx via apk"
|
||||||
|
run apk add --no-cache nginx
|
||||||
|
run rc-update del nginx 2>/dev/null || true
|
||||||
|
else
|
||||||
|
die "no apt or apk available — install nginx manually or re-run with --skip-nginx-install"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
[[ -f /etc/nginx/mime.types ]] || warn "/etc/nginx/mime.types missing; webapp-nginx.conf may fail"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 3. Postgres role + DB
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
if ! command -v psql >/dev/null 2>&1; then
|
||||||
|
die "psql not on PATH — install Postgres on this host (e.g. apt install postgresql) before running install.sh"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Use the OS `postgres` superuser for DDL.
|
||||||
|
pg_role_exists() {
|
||||||
|
sudo -u postgres psql -tAc "SELECT 1 FROM pg_roles WHERE rolname='${APP_USER}';" 2>/dev/null | grep -q 1
|
||||||
|
}
|
||||||
|
pg_db_exists() {
|
||||||
|
sudo -u postgres psql -tAc "SELECT 1 FROM pg_database WHERE datname='${APP_USER}';" 2>/dev/null | grep -q 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if pg_role_exists; then
|
||||||
|
if [[ "${FORCE_RECREATE_DB}" -eq 1 ]]; then
|
||||||
|
log "dropping existing role/DB (--force-recreate-db)"
|
||||||
|
run "sudo -u postgres psql -c 'DROP DATABASE IF EXISTS ${APP_USER};'"
|
||||||
|
run "sudo -u postgres psql -c 'DROP ROLE IF EXISTS ${APP_USER};'"
|
||||||
|
else
|
||||||
|
warn "Postgres role ${APP_USER} already exists — skipping role/DB creation. Re-run with --force-recreate-db to wipe."
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! pg_role_exists; then
|
||||||
|
log "creating Postgres role ${APP_USER}"
|
||||||
|
run "sudo -u postgres psql -c \"CREATE ROLE ${APP_USER} LOGIN;\""
|
||||||
|
fi
|
||||||
|
if ! pg_db_exists; then
|
||||||
|
log "creating Postgres database ${APP_USER}"
|
||||||
|
run "sudo -u postgres psql -c \"CREATE DATABASE ${APP_USER} OWNER ${APP_USER};\""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 4. Redis
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
if systemctl list-unit-files | grep -q '^redis-server\.service'; then
|
||||||
|
run "systemctl start redis-server.service || true"
|
||||||
|
run "systemctl enable redis-server.service >/dev/null 2>&1 || true"
|
||||||
|
elif systemctl list-unit-files | grep -q '^redis\.service'; then
|
||||||
|
run "systemctl start redis.service || true"
|
||||||
|
run "systemctl enable redis.service >/dev/null 2>&1 || true"
|
||||||
|
elif command -v redis-cli >/dev/null 2>&1; then
|
||||||
|
warn "redis-cli present but no redis-server.service / redis.service unit — assuming external Redis"
|
||||||
|
else
|
||||||
|
warn "redis not detected; orchestrator will fall back to in-process event bus. Install redis for multi-replica support."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 5. orchestrator.env
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
FIRST_KEYS_FILE="/root/currencicombo-first-keys.txt"
|
||||||
|
if [[ -f "${ENV_FILE}" ]]; then
|
||||||
|
log "${ENV_FILE} already exists — leaving alone (no new keys generated)"
|
||||||
|
else
|
||||||
|
log "writing ${ENV_FILE}"
|
||||||
|
install -o "${APP_USER}" -g "${APP_USER}" -m 0640 "${SCRIPT_DIR}/.env.prod.example" "${ENV_FILE}"
|
||||||
|
# Auto-fill the two REQUIRED secrets so first boot doesn't crash.
|
||||||
|
SECRET="$(openssl rand -hex 32)"
|
||||||
|
INIT_KEY="$(openssl rand -hex 24)"
|
||||||
|
SETT_KEY="$(openssl rand -hex 24)"
|
||||||
|
AUD_KEY="$(openssl rand -hex 24)"
|
||||||
|
DB_PASSWORD="$(openssl rand -hex 24)"
|
||||||
|
DB_PASSWORD_SQL="$(sql_escape "${DB_PASSWORD}")"
|
||||||
|
API_KEYS_VALUE="${INIT_KEY}:initiator,${SETT_KEY}:settler,${AUD_KEY}:auditor"
|
||||||
|
DATABASE_URL="postgresql://${APP_USER}:${DB_PASSWORD}@127.0.0.1:5432/${APP_USER}"
|
||||||
|
log "setting Postgres password for role ${APP_USER}"
|
||||||
|
run "sudo -u postgres psql -c \"ALTER ROLE ${APP_USER} WITH LOGIN PASSWORD '${DB_PASSWORD_SQL}';\""
|
||||||
|
run "sed -i 's|^EVENT_SIGNING_SECRET=.*|EVENT_SIGNING_SECRET=${SECRET}|' '${ENV_FILE}'"
|
||||||
|
run "sed -i 's|^API_KEYS=.*|API_KEYS=${API_KEYS_VALUE}|' '${ENV_FILE}'"
|
||||||
|
run "sed -i 's|^DATABASE_URL=.*|DATABASE_URL=${DATABASE_URL}|' '${ENV_FILE}'"
|
||||||
|
run "grep -q '^ORCHESTRATOR_API_KEYS=' '${ENV_FILE}' && sed -i 's|^ORCHESTRATOR_API_KEYS=.*|ORCHESTRATOR_API_KEYS=${API_KEYS_VALUE}|' '${ENV_FILE}' || printf '\nORCHESTRATOR_API_KEYS=%s\n' '${API_KEYS_VALUE}' >> '${ENV_FILE}'"
|
||||||
|
# Write a root-only handoff file so ops can grab the keys without
|
||||||
|
# scraping journald or reading the env file. The canonical copy lives
|
||||||
|
# in ${ENV_FILE}; delete this file once the keys are in your password
|
||||||
|
# manager.
|
||||||
|
if [[ "${DRY_RUN}" -eq 0 ]]; then
|
||||||
|
umask 077
|
||||||
|
cat > "${FIRST_KEYS_FILE}" <<EOF
|
||||||
|
# CurrenciCombo first-deploy secrets — generated $(date -Iseconds) by install.sh
|
||||||
|
#
|
||||||
|
# This file contains the initial API keys and event-signing secret for the
|
||||||
|
# orchestrator. The canonical live values live in ${ENV_FILE} and are what
|
||||||
|
# systemd actually loads. This file is a root-only handoff copy — record
|
||||||
|
# these values in your password manager, then:
|
||||||
|
#
|
||||||
|
# shred -u ${FIRST_KEYS_FILE}
|
||||||
|
#
|
||||||
|
# Re-running install.sh does NOT regenerate these values if ${ENV_FILE}
|
||||||
|
# already exists. Losing both ${FIRST_KEYS_FILE} and ${ENV_FILE} means
|
||||||
|
# rotating all three API keys and the signing secret.
|
||||||
|
|
||||||
|
EVENT_SIGNING_SECRET=${SECRET}
|
||||||
|
ORCHESTRATOR_API_KEY_INITIATOR=${INIT_KEY}
|
||||||
|
ORCHESTRATOR_API_KEY_SETTLER=${SETT_KEY}
|
||||||
|
ORCHESTRATOR_API_KEY_AUDITOR=${AUD_KEY}
|
||||||
|
DATABASE_URL=${DATABASE_URL}
|
||||||
|
|
||||||
|
# As it appears in ${ENV_FILE}:
|
||||||
|
API_KEYS=${API_KEYS_VALUE}
|
||||||
|
ORCHESTRATOR_API_KEYS=${API_KEYS_VALUE}
|
||||||
|
EOF
|
||||||
|
chmod 0600 "${FIRST_KEYS_FILE}"
|
||||||
|
chown root:root "${FIRST_KEYS_FILE}"
|
||||||
|
else
|
||||||
|
log "[dry-run] would write ${FIRST_KEYS_FILE} (0600, root:root)"
|
||||||
|
fi
|
||||||
|
log " generated EVENT_SIGNING_SECRET (64 hex)"
|
||||||
|
log " generated 3 API keys (initiator/settler/auditor)"
|
||||||
|
log " generated local Postgres password for ${APP_USER}"
|
||||||
|
log " initial secrets written to ${FIRST_KEYS_FILE} (0600) — record in password manager, then 'shred -u ${FIRST_KEYS_FILE}'"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 6. webapp-nginx.conf
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
run install -o "${APP_USER}" -g "${APP_USER}" -m 0644 \
|
||||||
|
"${SCRIPT_DIR}/webapp-nginx.conf" "${NGINX_FILE}"
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 7. systemd units
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
run install -o root -g root -m 0644 \
|
||||||
|
"${SCRIPT_DIR}/systemd/currencicombo-orchestrator.service" \
|
||||||
|
"${SYSTEMD_DIR}/currencicombo-orchestrator.service"
|
||||||
|
run install -o root -g root -m 0644 \
|
||||||
|
"${SCRIPT_DIR}/systemd/currencicombo-webapp.service" \
|
||||||
|
"${SYSTEMD_DIR}/currencicombo-webapp.service"
|
||||||
|
run systemctl daemon-reload
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
# 8. Enable (but do NOT start yet — no build exists)
|
||||||
|
# ----------------------------------------------------------------------
|
||||||
|
run systemctl enable currencicombo-orchestrator.service
|
||||||
|
run systemctl enable currencicombo-webapp.service
|
||||||
|
|
||||||
|
log "install complete."
|
||||||
|
log " next: run scripts/deployment/deploy-currencicombo-8604.sh as root to build + start."
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=CurrenciCombo orchestrator (Node)
|
||||||
|
Documentation=https://gitea.d-bis.org/d-bis/CurrenciCombo
|
||||||
|
After=network-online.target postgresql.service redis-server.service redis.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=currencicombo
|
||||||
|
Group=currencicombo
|
||||||
|
WorkingDirectory=/opt/currencicombo/orchestrator
|
||||||
|
EnvironmentFile=/etc/currencicombo/orchestrator.env
|
||||||
|
ExecStart=/usr/bin/node /opt/currencicombo/orchestrator/dist/index.js
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
TimeoutStopSec=20
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=currencicombo-orchestrator
|
||||||
|
|
||||||
|
# Hardening
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ReadWritePaths=/var/log/currencicombo
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
LockPersonality=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
34
scripts/deployment/systemd/currencicombo-webapp.service
Normal file
34
scripts/deployment/systemd/currencicombo-webapp.service
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=CurrenciCombo webapp (Vite SPA served by nginx)
|
||||||
|
Documentation=https://gitea.d-bis.org/d-bis/CurrenciCombo
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=currencicombo
|
||||||
|
Group=currencicombo
|
||||||
|
RuntimeDirectory=currencicombo-webapp
|
||||||
|
RuntimeDirectoryMode=0755
|
||||||
|
ExecStart=/usr/sbin/nginx -c /etc/currencicombo/webapp-nginx.conf -g 'daemon off; pid /run/currencicombo-webapp/nginx.pid;'
|
||||||
|
ExecReload=/usr/sbin/nginx -c /etc/currencicombo/webapp-nginx.conf -s reload
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=5
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
SyslogIdentifier=currencicombo-webapp
|
||||||
|
|
||||||
|
# Hardening
|
||||||
|
NoNewPrivileges=yes
|
||||||
|
PrivateTmp=yes
|
||||||
|
ProtectSystem=strict
|
||||||
|
ProtectHome=yes
|
||||||
|
ReadWritePaths=/var/log/currencicombo /run/currencicombo-webapp
|
||||||
|
ProtectKernelTunables=yes
|
||||||
|
ProtectKernelModules=yes
|
||||||
|
ProtectControlGroups=yes
|
||||||
|
RestrictSUIDSGID=yes
|
||||||
|
LockPersonality=yes
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
80
scripts/deployment/webapp-nginx.conf
Normal file
80
scripts/deployment/webapp-nginx.conf
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
# Self-contained nginx.conf for the CurrenciCombo Vite SPA.
|
||||||
|
# Invoked by the `currencicombo-webapp.service` systemd unit and installed
|
||||||
|
# to /etc/currencicombo/webapp-nginx.conf by scripts/deployment/install.sh.
|
||||||
|
#
|
||||||
|
# Listens on :3000 (NPMplus upstream). NPMplus path-routes /api/* to the
|
||||||
|
# orchestrator on :8080 (with SSE-friendly settings — see README.md);
|
||||||
|
# everything else lands here.
|
||||||
|
# This config does NOT proxy /api itself — that's intentional so a wrong
|
||||||
|
# NPMplus rule fails loudly instead of silently bypassing the orchestrator.
|
||||||
|
|
||||||
|
worker_processes auto;
|
||||||
|
error_log /var/log/currencicombo/webapp-nginx.error.log warn;
|
||||||
|
|
||||||
|
events {
|
||||||
|
worker_connections 1024;
|
||||||
|
}
|
||||||
|
|
||||||
|
http {
|
||||||
|
include /etc/nginx/mime.types;
|
||||||
|
default_type application/octet-stream;
|
||||||
|
|
||||||
|
access_log /var/log/currencicombo/webapp-nginx.access.log combined;
|
||||||
|
|
||||||
|
sendfile on;
|
||||||
|
tcp_nopush on;
|
||||||
|
keepalive_timeout 65;
|
||||||
|
server_tokens off;
|
||||||
|
gzip on;
|
||||||
|
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
|
||||||
|
gzip_min_length 1024;
|
||||||
|
|
||||||
|
# Uploads/bodies: the portal is a static SPA, so any request with a body
|
||||||
|
# is almost certainly mis-routed. Cap tight.
|
||||||
|
client_max_body_size 1m;
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 3000 default_server;
|
||||||
|
listen [::]:3000 default_server;
|
||||||
|
server_name _;
|
||||||
|
|
||||||
|
root /opt/currencicombo/webapp/dist;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
# Security headers are also set by NPMplus, but apply them here too
|
||||||
|
# so they survive a direct-to-CT curl for debugging.
|
||||||
|
add_header X-Content-Type-Options "nosniff" always;
|
||||||
|
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||||
|
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||||
|
|
||||||
|
# Immutable asset bundles.
|
||||||
|
location /assets/ {
|
||||||
|
access_log off;
|
||||||
|
expires 1y;
|
||||||
|
add_header Cache-Control "public, max-age=31536000, immutable";
|
||||||
|
try_files $uri =404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Deny sourcemaps in prod.
|
||||||
|
location ~ \.map$ {
|
||||||
|
access_log off;
|
||||||
|
deny all;
|
||||||
|
return 404;
|
||||||
|
}
|
||||||
|
|
||||||
|
# Guard-rail: if NPMplus fails to path-route /api/*, surface it as a
|
||||||
|
# clean 421 rather than serving index.html and confusing the browser
|
||||||
|
# with a JSON parse error. The SSE endpoint lives at
|
||||||
|
# /api/plans/:id/events/stream, which also sits under /api/, so one
|
||||||
|
# rule covers both.
|
||||||
|
location /api/ {
|
||||||
|
return 421 "NPMplus is misconfigured: /api/* must proxy to orchestrator :8080\n";
|
||||||
|
add_header Content-Type text/plain always;
|
||||||
|
}
|
||||||
|
|
||||||
|
# SPA fallback. Must come last.
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
38
src/App.tsx
38
src/App.tsx
@@ -1,4 +1,5 @@
|
|||||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { addEdge, applyNodeChanges, applyEdgeChanges, type Node, type Edge, type Connection, type NodeChange, type EdgeChange } from '@xyflow/react';
|
import { addEdge, applyNodeChanges, applyEdgeChanges, type Node, type Edge, type Connection, type NodeChange, type EdgeChange } from '@xyflow/react';
|
||||||
import TitleBar from './components/TitleBar';
|
import TitleBar from './components/TitleBar';
|
||||||
import ActivityBar from './components/ActivityBar';
|
import ActivityBar from './components/ActivityBar';
|
||||||
@@ -7,10 +8,45 @@ import Canvas from './components/Canvas';
|
|||||||
import RightPanel from './components/RightPanel';
|
import RightPanel from './components/RightPanel';
|
||||||
import BottomPanel from './components/BottomPanel';
|
import BottomPanel from './components/BottomPanel';
|
||||||
import CommandPalette from './components/CommandPalette';
|
import CommandPalette from './components/CommandPalette';
|
||||||
|
import { useBreakpoint } from './hooks/useBreakpoint';
|
||||||
import type { ActivityTab, SessionMode, ComponentItem, HistoryEntry, TransactionTab, TerminalEntry, AuditEntry, ValidationIssue } from './types';
|
import type { ActivityTab, SessionMode, ComponentItem, HistoryEntry, TransactionTab, TerminalEntry, AuditEntry, ValidationIssue } from './types';
|
||||||
|
|
||||||
const STORAGE_KEY = 'transactflow-workspace';
|
const STORAGE_KEY = 'transactflow-workspace';
|
||||||
|
|
||||||
|
/* The workspace (IDE-style multi-panel + react-flow canvas) is designed
|
||||||
|
* for ≥ md viewports. Phones get a friendly "open on a larger screen"
|
||||||
|
* screen with direct links into the portal routes. This matches the
|
||||||
|
* pattern used by VS Code Web, Figma, Replit, etc.
|
||||||
|
*/
|
||||||
|
function WorkspaceMobileGate() {
|
||||||
|
return (
|
||||||
|
<section className="workspace-mobile-gate" aria-labelledby="wsgate-h">
|
||||||
|
<h2 id="wsgate-h">Transaction Builder is designed for larger screens</h2>
|
||||||
|
<p>
|
||||||
|
The Transaction Builder workspace uses a multi-panel IDE layout that
|
||||||
|
needs more room than a phone can comfortably provide. Please open this
|
||||||
|
workspace on a tablet in landscape or on a laptop / desktop.
|
||||||
|
</p>
|
||||||
|
<p>You can still use the rest of the portal on this device:</p>
|
||||||
|
<div className="cta-row">
|
||||||
|
<Link to="/dashboard" className="cta primary">Go to Overview</Link>
|
||||||
|
<Link to="/transactions" className="cta">View Transactions</Link>
|
||||||
|
<Link to="/accounts" className="cta">Accounts</Link>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppWithMobileGate() {
|
||||||
|
const { isMobile } = useBreakpoint();
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{isMobile && <WorkspaceMobileGate />}
|
||||||
|
{!isMobile && <WorkspaceApp />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function loadWorkspace() {
|
function loadWorkspace() {
|
||||||
try {
|
try {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY);
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
@@ -25,7 +61,7 @@ function saveWorkspace(state: Record<string, unknown>) {
|
|||||||
} catch { /* ignore */ }
|
} catch { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function App() {
|
function WorkspaceApp() {
|
||||||
const saved = useRef(loadWorkspace());
|
const saved = useRef(loadWorkspace());
|
||||||
|
|
||||||
const [activityTab, setActivityTab] = useState<ActivityTab>(saved.current?.activityTab || 'builder');
|
const [activityTab, setActivityTab] = useState<ActivityTab>(saved.current?.activityTab || 'builder');
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import SettlementsPage from './pages/SettlementsPage';
|
|||||||
import TransactionsPage from './pages/TransactionsPage';
|
import TransactionsPage from './pages/TransactionsPage';
|
||||||
import PortalLayout from './components/portal/PortalLayout';
|
import PortalLayout from './components/portal/PortalLayout';
|
||||||
import LiveChainBanner from './components/portal/LiveChainBanner';
|
import LiveChainBanner from './components/portal/LiveChainBanner';
|
||||||
|
import SkipToContent from './components/a11y/SkipToContent';
|
||||||
import App from './App';
|
import App from './App';
|
||||||
|
|
||||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||||
@@ -44,7 +45,9 @@ export default function Portal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Routes>
|
<>
|
||||||
|
<SkipToContent targetId="main-content" />
|
||||||
|
<Routes>
|
||||||
<Route
|
<Route
|
||||||
path="/login"
|
path="/login"
|
||||||
element={isAuthenticated ? <Navigate to="/dashboard" replace /> : <LoginPage />}
|
element={isAuthenticated ? <Navigate to="/dashboard" replace /> : <LoginPage />}
|
||||||
@@ -166,7 +169,8 @@ export default function Portal() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<Route path="*" element={<Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />} />
|
<Route path="*" element={<Navigate to={isAuthenticated ? '/dashboard' : '/login'} replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
16
src/components/a11y/SkipToContent.tsx
Normal file
16
src/components/a11y/SkipToContent.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
/**
|
||||||
|
* SkipToContent — first focusable element in the app.
|
||||||
|
* Keyboard users press Tab on page load; this link appears and, on
|
||||||
|
* Enter, jumps focus past the nav to the <main id="main-content">.
|
||||||
|
*
|
||||||
|
* Render once, near the top of the tree. The target element must
|
||||||
|
* exist with id="main-content" and tabIndex={-1} so focus can land
|
||||||
|
* on it programmatically.
|
||||||
|
*/
|
||||||
|
export default function SkipToContent({ targetId = 'main-content' }: { targetId?: string }) {
|
||||||
|
return (
|
||||||
|
<a className="skip-to-content" href={`#${targetId}`}>
|
||||||
|
Skip to main content
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
11
src/components/a11y/VisuallyHidden.tsx
Normal file
11
src/components/a11y/VisuallyHidden.tsx
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
import type { PropsWithChildren } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VisuallyHidden — renders content that is present in the DOM (and
|
||||||
|
* announced by screen readers) but visually hidden. Prefer this over
|
||||||
|
* aria-label when the label would benefit from being inspectable in
|
||||||
|
* devtools.
|
||||||
|
*/
|
||||||
|
export default function VisuallyHidden({ children }: PropsWithChildren) {
|
||||||
|
return <span className="sr-only">{children}</span>;
|
||||||
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useAuth } from '../../contexts/AuthContext';
|
import { useAuth } from '../../contexts/AuthContext';
|
||||||
|
import { useBreakpoint } from '../../hooks/useBreakpoint';
|
||||||
import {
|
import {
|
||||||
LayoutDashboard, Zap, Building2, Landmark, FileText, Shield, CheckSquare,
|
LayoutDashboard, Zap, Building2, Landmark, FileText, Shield, CheckSquare,
|
||||||
Settings, LogOut, ChevronLeft, ChevronRight, Bell, User, Copy,
|
Settings, LogOut, ChevronLeft, ChevronRight, Bell, User, Copy,
|
||||||
ExternalLink, ChevronDown, GitBranch
|
ExternalLink, ChevronDown, GitBranch, Menu, X,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
@@ -26,12 +27,53 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
|
|||||||
const { user, wallet, disconnect } = useAuth();
|
const { user, wallet, disconnect } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const { isMobile } = useBreakpoint();
|
||||||
|
|
||||||
const [collapsed, setCollapsed] = useState(false);
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
const [showUserMenu, setShowUserMenu] = useState(false);
|
const [showUserMenu, setShowUserMenu] = useState(false);
|
||||||
const [showNotifications, setShowNotifications] = useState(false);
|
const [showNotifications, setShowNotifications] = useState(false);
|
||||||
|
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||||
|
|
||||||
const currentPath = location.pathname;
|
const currentPath = location.pathname;
|
||||||
|
|
||||||
|
/* Sync drawer closed state with route and breakpoint changes.
|
||||||
|
These setState-in-effect calls are intentional: the drawer must
|
||||||
|
close in response to external inputs (router location, viewport
|
||||||
|
media query) — this is exactly what an effect is for. */
|
||||||
|
useEffect(() => {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}, [location.pathname]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isMobile) {
|
||||||
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||||
|
setDrawerOpen(false);
|
||||||
|
}
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
|
/* Close drawer + menus on Escape (external subscription, allowed in effects) */
|
||||||
|
useEffect(() => {
|
||||||
|
const onKey = (e: KeyboardEvent) => {
|
||||||
|
if (e.key === 'Escape') {
|
||||||
|
setDrawerOpen(false);
|
||||||
|
setShowUserMenu(false);
|
||||||
|
setShowNotifications(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', onKey);
|
||||||
|
return () => window.removeEventListener('keydown', onKey);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/* Lock body scroll while drawer is open on mobile */
|
||||||
|
useEffect(() => {
|
||||||
|
if (drawerOpen) {
|
||||||
|
const prev = document.body.style.overflow;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = prev; };
|
||||||
|
}
|
||||||
|
}, [drawerOpen]);
|
||||||
|
|
||||||
const copyAddress = () => {
|
const copyAddress = () => {
|
||||||
if (wallet?.address) {
|
if (wallet?.address) {
|
||||||
navigator.clipboard.writeText(wallet.address);
|
navigator.clipboard.writeText(wallet.address);
|
||||||
@@ -42,7 +84,23 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
|
|||||||
<div className="portal-layout">
|
<div className="portal-layout">
|
||||||
<div className="portal-topbar">
|
<div className="portal-topbar">
|
||||||
<div className="portal-topbar-left">
|
<div className="portal-topbar-left">
|
||||||
<div className="portal-logo" onClick={() => navigate('/dashboard')}>
|
{/* Mobile hamburger — hidden ≥ lg via CSS */}
|
||||||
|
<button
|
||||||
|
className="portal-menu-toggle"
|
||||||
|
aria-label={drawerOpen ? 'Close navigation' : 'Open navigation'}
|
||||||
|
aria-expanded={drawerOpen}
|
||||||
|
aria-controls="portal-primary-nav"
|
||||||
|
onClick={() => setDrawerOpen(v => !v)}
|
||||||
|
>
|
||||||
|
{drawerOpen ? <X size={20} /> : <Menu size={20} />}
|
||||||
|
</button>
|
||||||
|
<div
|
||||||
|
className="portal-logo"
|
||||||
|
role="link"
|
||||||
|
tabIndex={0}
|
||||||
|
onClick={() => navigate('/dashboard')}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') navigate('/dashboard'); }}
|
||||||
|
>
|
||||||
<Building2 size={22} color="#3b82f6" />
|
<Building2 size={22} color="#3b82f6" />
|
||||||
{!collapsed && (
|
{!collapsed && (
|
||||||
<div className="portal-logo-text">
|
<div className="portal-logo-text">
|
||||||
@@ -62,28 +120,34 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
|
|||||||
|
|
||||||
<div className="portal-topbar-right">
|
<div className="portal-topbar-right">
|
||||||
<div className="portal-notif-wrapper">
|
<div className="portal-notif-wrapper">
|
||||||
<button className="portal-icon-btn" onClick={() => { setShowNotifications(!showNotifications); setShowUserMenu(false); }}>
|
<button
|
||||||
|
className="portal-icon-btn"
|
||||||
|
aria-label="Notifications"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={showNotifications}
|
||||||
|
onClick={() => { setShowNotifications(!showNotifications); setShowUserMenu(false); }}
|
||||||
|
>
|
||||||
<Bell size={18} />
|
<Bell size={18} />
|
||||||
<span className="portal-notif-badge">3</span>
|
<span className="portal-notif-badge" aria-label="3 unread">3</span>
|
||||||
</button>
|
</button>
|
||||||
{showNotifications && (
|
{showNotifications && (
|
||||||
<div className="portal-dropdown notifications-dropdown">
|
<div className="portal-dropdown notifications-dropdown" role="menu">
|
||||||
<div className="portal-dropdown-header">Notifications</div>
|
<div className="portal-dropdown-header">Notifications</div>
|
||||||
<div className="portal-dropdown-item warning">
|
<div className="portal-dropdown-item warning" role="menuitem">
|
||||||
<span className="dropdown-dot warning" />
|
<span className="dropdown-dot warning" />
|
||||||
<div>
|
<div>
|
||||||
<div className="dropdown-title">AML Alert</div>
|
<div className="dropdown-title">AML Alert</div>
|
||||||
<div className="dropdown-desc">Unusual pattern on ACC-001</div>
|
<div className="dropdown-desc">Unusual pattern on ACC-001</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="portal-dropdown-item info">
|
<div className="portal-dropdown-item info" role="menuitem">
|
||||||
<span className="dropdown-dot info" />
|
<span className="dropdown-dot info" />
|
||||||
<div>
|
<div>
|
||||||
<div className="dropdown-title">Settlement Confirmed</div>
|
<div className="dropdown-title">Settlement Confirmed</div>
|
||||||
<div className="dropdown-desc">TX-2024-0847 settled</div>
|
<div className="dropdown-desc">TX-2024-0847 settled</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="portal-dropdown-item">
|
<div className="portal-dropdown-item" role="menuitem">
|
||||||
<span className="dropdown-dot success" />
|
<span className="dropdown-dot success" />
|
||||||
<div>
|
<div>
|
||||||
<div className="dropdown-title">Report Ready</div>
|
<div className="dropdown-title">Report Ready</div>
|
||||||
@@ -95,7 +159,13 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="portal-user-wrapper">
|
<div className="portal-user-wrapper">
|
||||||
<button className="portal-user-btn" onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }}>
|
<button
|
||||||
|
className="portal-user-btn"
|
||||||
|
aria-label="Account menu"
|
||||||
|
aria-haspopup="true"
|
||||||
|
aria-expanded={showUserMenu}
|
||||||
|
onClick={() => { setShowUserMenu(!showUserMenu); setShowNotifications(false); }}
|
||||||
|
>
|
||||||
<div className="portal-avatar">
|
<div className="portal-avatar">
|
||||||
<User size={14} />
|
<User size={14} />
|
||||||
</div>
|
</div>
|
||||||
@@ -106,12 +176,12 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
|
|||||||
<ChevronDown size={12} />
|
<ChevronDown size={12} />
|
||||||
</button>
|
</button>
|
||||||
{showUserMenu && (
|
{showUserMenu && (
|
||||||
<div className="portal-dropdown user-dropdown">
|
<div className="portal-dropdown user-dropdown" role="menu">
|
||||||
<div className="portal-dropdown-header">Account</div>
|
<div className="portal-dropdown-header">Account</div>
|
||||||
<div className="portal-dropdown-section">
|
<div className="portal-dropdown-section">
|
||||||
<div className="portal-wallet-addr">
|
<div className="portal-wallet-addr">
|
||||||
<span className="mono">{wallet?.address ? `${wallet.address.slice(0, 8)}...${wallet.address.slice(-6)}` : '—'}</span>
|
<span className="mono">{wallet?.address ? `${wallet.address.slice(0, 8)}...${wallet.address.slice(-6)}` : '—'}</span>
|
||||||
<button className="copy-btn" onClick={copyAddress} title="Copy address"><Copy size={12} /></button>
|
<button className="copy-btn" onClick={copyAddress} title="Copy address" aria-label="Copy wallet address"><Copy size={12} /></button>
|
||||||
</div>
|
</div>
|
||||||
<div className="portal-wallet-bal">
|
<div className="portal-wallet-bal">
|
||||||
<span>{wallet?.balance ? `${parseFloat(wallet.balance).toFixed(4)} ETH` : '—'}</span>
|
<span>{wallet?.balance ? `${parseFloat(wallet.balance).toFixed(4)} ETH` : '—'}</span>
|
||||||
@@ -119,14 +189,14 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="portal-dropdown-divider" />
|
<div className="portal-dropdown-divider" />
|
||||||
<button className="portal-dropdown-action" onClick={() => navigate('/settings')}>
|
<button className="portal-dropdown-action" role="menuitem" onClick={() => navigate('/settings')}>
|
||||||
<Settings size={14} /> Settings
|
<Settings size={14} /> Settings
|
||||||
</button>
|
</button>
|
||||||
<button className="portal-dropdown-action" onClick={() => window.open('https://etherscan.io', '_blank')}>
|
<button className="portal-dropdown-action" role="menuitem" onClick={() => window.open('https://etherscan.io', '_blank')}>
|
||||||
<ExternalLink size={14} /> View on Explorer
|
<ExternalLink size={14} /> View on Explorer
|
||||||
</button>
|
</button>
|
||||||
<div className="portal-dropdown-divider" />
|
<div className="portal-dropdown-divider" />
|
||||||
<button className="portal-dropdown-action danger" onClick={disconnect}>
|
<button className="portal-dropdown-action danger" role="menuitem" onClick={disconnect}>
|
||||||
<LogOut size={14} /> Disconnect Wallet
|
<LogOut size={14} /> Disconnect Wallet
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -136,7 +206,17 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="portal-body">
|
<div className="portal-body">
|
||||||
<nav className={`portal-sidebar ${collapsed ? 'collapsed' : ''}`}>
|
{/* Drawer backdrop — visible only on < md and only when drawer is open */}
|
||||||
|
<div
|
||||||
|
className={`portal-drawer-backdrop ${drawerOpen ? 'visible' : ''}`}
|
||||||
|
onClick={() => setDrawerOpen(false)}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<nav
|
||||||
|
id="portal-primary-nav"
|
||||||
|
aria-label="Primary"
|
||||||
|
className={`portal-sidebar ${collapsed ? 'collapsed' : ''} ${drawerOpen ? 'drawer-open' : ''}`}
|
||||||
|
>
|
||||||
<div className="portal-nav-items">
|
<div className="portal-nav-items">
|
||||||
{navItems.map(item => {
|
{navItems.map(item => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
@@ -147,6 +227,7 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
|
|||||||
className={`portal-nav-item ${isActive ? 'active' : ''}`}
|
className={`portal-nav-item ${isActive ? 'active' : ''}`}
|
||||||
onClick={() => navigate(item.path)}
|
onClick={() => navigate(item.path)}
|
||||||
title={collapsed ? item.label : undefined}
|
title={collapsed ? item.label : undefined}
|
||||||
|
aria-current={isActive ? 'page' : undefined}
|
||||||
>
|
>
|
||||||
<Icon size={18} />
|
<Icon size={18} />
|
||||||
{!collapsed && <span>{item.label}</span>}
|
{!collapsed && <span>{item.label}</span>}
|
||||||
@@ -157,17 +238,25 @@ export default function PortalLayout({ children }: PortalLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="portal-nav-footer">
|
<div className="portal-nav-footer">
|
||||||
<button className="portal-nav-item" onClick={() => navigate('/settings')} title={collapsed ? 'Settings' : undefined}>
|
<button
|
||||||
|
className="portal-nav-item"
|
||||||
|
onClick={() => navigate('/settings')}
|
||||||
|
title={collapsed ? 'Settings' : undefined}
|
||||||
|
>
|
||||||
<Settings size={18} />
|
<Settings size={18} />
|
||||||
{!collapsed && <span>Settings</span>}
|
{!collapsed && <span>Settings</span>}
|
||||||
</button>
|
</button>
|
||||||
<button className="portal-collapse-btn" onClick={() => setCollapsed(!collapsed)}>
|
<button
|
||||||
|
className="portal-collapse-btn"
|
||||||
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
|
aria-label={collapsed ? 'Expand navigation' : 'Collapse navigation'}
|
||||||
|
>
|
||||||
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
{collapsed ? <ChevronRight size={16} /> : <ChevronLeft size={16} />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main className="portal-content">
|
<main id="main-content" tabIndex={-1} className="portal-content">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
55
src/hooks/useBreakpoint.ts
Normal file
55
src/hooks/useBreakpoint.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import { useMediaQuery } from './useMediaQuery';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Breakpoint constants. Kept in sync with the CSS tokens declared
|
||||||
|
* in src/styles/tokens.css (--bp-*). If you change one, change both.
|
||||||
|
*/
|
||||||
|
export const BREAKPOINTS = {
|
||||||
|
xs: 0,
|
||||||
|
sm: 480,
|
||||||
|
md: 768,
|
||||||
|
lg: 1024,
|
||||||
|
xl: 1440,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export type BreakpointName = keyof typeof BREAKPOINTS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useBreakpoint — returns the current active breakpoint name plus
|
||||||
|
* a set of convenience booleans. Uses matchMedia internally; does
|
||||||
|
* not register a window-resize listener.
|
||||||
|
*/
|
||||||
|
export function useBreakpoint(): {
|
||||||
|
current: BreakpointName;
|
||||||
|
isXs: boolean;
|
||||||
|
isSm: boolean;
|
||||||
|
isMd: boolean;
|
||||||
|
isLg: boolean;
|
||||||
|
isXl: boolean;
|
||||||
|
isMobile: boolean; // < md
|
||||||
|
isTablet: boolean; // md and < lg
|
||||||
|
isDesktop: boolean; // >= lg
|
||||||
|
} {
|
||||||
|
const isSm = useMediaQuery(`(min-width: ${BREAKPOINTS.sm}px)`);
|
||||||
|
const isMd = useMediaQuery(`(min-width: ${BREAKPOINTS.md}px)`);
|
||||||
|
const isLg = useMediaQuery(`(min-width: ${BREAKPOINTS.lg}px)`);
|
||||||
|
const isXl = useMediaQuery(`(min-width: ${BREAKPOINTS.xl}px)`);
|
||||||
|
|
||||||
|
let current: BreakpointName = 'xs';
|
||||||
|
if (isXl) current = 'xl';
|
||||||
|
else if (isLg) current = 'lg';
|
||||||
|
else if (isMd) current = 'md';
|
||||||
|
else if (isSm) current = 'sm';
|
||||||
|
|
||||||
|
return {
|
||||||
|
current,
|
||||||
|
isXs: current === 'xs',
|
||||||
|
isSm: current === 'sm',
|
||||||
|
isMd: current === 'md',
|
||||||
|
isLg: current === 'lg',
|
||||||
|
isXl: current === 'xl',
|
||||||
|
isMobile: !isMd,
|
||||||
|
isTablet: isMd && !isLg,
|
||||||
|
isDesktop: isLg,
|
||||||
|
};
|
||||||
|
}
|
||||||
37
src/hooks/useMediaQuery.ts
Normal file
37
src/hooks/useMediaQuery.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import { useSyncExternalStore } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useMediaQuery — subscribe to a CSS media query with zero re-render
|
||||||
|
* churn on unrelated resize events (uses matchMedia's `change` event
|
||||||
|
* rather than the window resize event).
|
||||||
|
*
|
||||||
|
* Safe for SSR: returns `false` on the server when matchMedia is
|
||||||
|
* undefined. The Vite SPA here is CSR-only but the hook stays SSR-safe
|
||||||
|
* for reuse.
|
||||||
|
*/
|
||||||
|
export function useMediaQuery(query: string): boolean {
|
||||||
|
const subscribe = (callback: () => void): (() => void) => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') {
|
||||||
|
return () => {};
|
||||||
|
}
|
||||||
|
const mql = window.matchMedia(query);
|
||||||
|
// Safari <14 used addListener/removeListener — modern Safari,
|
||||||
|
// Chrome, Firefox, Edge all support add/removeEventListener.
|
||||||
|
if (typeof mql.addEventListener === 'function') {
|
||||||
|
mql.addEventListener('change', callback);
|
||||||
|
return () => mql.removeEventListener('change', callback);
|
||||||
|
}
|
||||||
|
// Fallback path for very old engines.
|
||||||
|
mql.addListener(callback);
|
||||||
|
return () => mql.removeListener(callback);
|
||||||
|
};
|
||||||
|
|
||||||
|
const getSnapshot = (): boolean => {
|
||||||
|
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
|
||||||
|
return window.matchMedia(query).matches;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getServerSnapshot = (): boolean => false;
|
||||||
|
|
||||||
|
return useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||||
|
}
|
||||||
10
src/hooks/useOrientation.ts
Normal file
10
src/hooks/useOrientation.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { useMediaQuery } from './useMediaQuery';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useOrientation — returns 'portrait' or 'landscape' using CSS media
|
||||||
|
* query rather than the deprecated window.orientation.
|
||||||
|
*/
|
||||||
|
export function useOrientation(): 'portrait' | 'landscape' {
|
||||||
|
const isPortrait = useMediaQuery('(orientation: portrait)');
|
||||||
|
return isPortrait ? 'portrait' : 'landscape';
|
||||||
|
}
|
||||||
12
src/hooks/useReducedMotion.ts
Normal file
12
src/hooks/useReducedMotion.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useMediaQuery } from './useMediaQuery';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useReducedMotion — true when the user has requested reduced motion
|
||||||
|
* at the OS level. CSS already honors this globally via
|
||||||
|
* @media (prefers-reduced-motion: reduce) in tokens.css; use the hook
|
||||||
|
* only when a component needs to alter JS animation logic (eg react-flow
|
||||||
|
* auto-fit animations, chart transitions).
|
||||||
|
*/
|
||||||
|
export function useReducedMotion(): boolean {
|
||||||
|
return useMediaQuery('(prefers-reduced-motion: reduce)');
|
||||||
|
}
|
||||||
@@ -1,7 +1,13 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
import { HashRouter } from 'react-router-dom'
|
import { HashRouter } from 'react-router-dom'
|
||||||
|
// Load tokens first (no visual changes on their own), then existing
|
||||||
|
// baseline stylesheet, then responsive overrides, then a11y primitives.
|
||||||
|
// Order matters: overrides must win at the same specificity.
|
||||||
|
import './styles/tokens.css'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
|
import './styles/responsive.css'
|
||||||
|
import './styles/a11y.css'
|
||||||
import Portal from './Portal'
|
import Portal from './Portal'
|
||||||
import { AuthProvider } from './contexts/AuthContext'
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
|
|
||||||
|
|||||||
71
src/styles/a11y.css
Normal file
71
src/styles/a11y.css
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
Accessibility primitives (loaded after tokens.css)
|
||||||
|
|
||||||
|
Goals:
|
||||||
|
- Keyboard-visible focus on every interactive element
|
||||||
|
- Skip-to-content link for screen reader and keyboard users
|
||||||
|
- Visually-hidden utility (sr-only) preserved across all sizes
|
||||||
|
- Minimum 44×44px tap targets on touch pointer
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* Focus ring — applied to every interactive element that receives
|
||||||
|
keyboard focus (:focus-visible = only keyboard / programmatic focus).
|
||||||
|
Mouse-click focus still renders the app's existing hover states. */
|
||||||
|
:where(button, a, [role="button"], [role="tab"], [role="menuitem"],
|
||||||
|
input, select, textarea, [tabindex]:not([tabindex="-1"])):focus-visible {
|
||||||
|
outline: var(--focus-ring-width, 2px) solid var(--focus-ring-color, #60a5fa);
|
||||||
|
outline-offset: var(--focus-ring-offset, 2px);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Skip-to-content link: visually hidden until keyboard-focused,
|
||||||
|
then fixed top-left. */
|
||||||
|
.skip-to-content {
|
||||||
|
position: fixed;
|
||||||
|
top: calc(var(--safe-top) + 8px);
|
||||||
|
left: calc(var(--safe-left) + 8px);
|
||||||
|
z-index: var(--z-focus);
|
||||||
|
background: var(--accent-blue, #3b82f6);
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-radius: var(--radius-md, 6px);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: var(--fs-sm, 0.875rem);
|
||||||
|
text-decoration: none;
|
||||||
|
transform: translateY(-200%);
|
||||||
|
transition: transform var(--motion-base, 200ms) var(--motion-ease);
|
||||||
|
}
|
||||||
|
.skip-to-content:focus,
|
||||||
|
.skip-to-content:focus-visible {
|
||||||
|
transform: translateY(0);
|
||||||
|
outline: 3px solid #fff;
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Visually-hidden utility — in the DOM, announced by screen readers,
|
||||||
|
not visible on screen. Standard sr-only pattern. */
|
||||||
|
.sr-only {
|
||||||
|
position: absolute !important;
|
||||||
|
width: 1px !important;
|
||||||
|
height: 1px !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: -1px !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
clip: rect(0, 0, 0, 0) !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
border: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* When the cursor is not fine (mobile / tablet / stylus touch),
|
||||||
|
ensure every interactive element is at least 44×44 CSS px.
|
||||||
|
:not([data-tap-ok]) escape hatch for elements that intentionally
|
||||||
|
opt out (eg tight inline chevrons whose parent is the real target). */
|
||||||
|
@media (pointer: coarse) {
|
||||||
|
button:not([data-tap-ok]),
|
||||||
|
[role="button"]:not([data-tap-ok]),
|
||||||
|
a:not([data-tap-ok]),
|
||||||
|
[role="tab"]:not([data-tap-ok]) {
|
||||||
|
min-height: var(--tap-min, 44px);
|
||||||
|
min-width: var(--tap-min, 44px);
|
||||||
|
}
|
||||||
|
}
|
||||||
438
src/styles/responsive.css
Normal file
438
src/styles/responsive.css
Normal file
@@ -0,0 +1,438 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
Responsive overrides — loaded AFTER index.css so these rules win
|
||||||
|
where specificity ties, without hand-editing the 3.9k-line
|
||||||
|
baseline stylesheet.
|
||||||
|
|
||||||
|
Convention: only rules that change at a breakpoint live here. The
|
||||||
|
desktop (≥ lg) view is preserved identically to the pre-existing
|
||||||
|
layout — every override is wrapped in @media (max-width: ...) or
|
||||||
|
@media (pointer: coarse) so nothing changes above lg.
|
||||||
|
|
||||||
|
Mobile-first principle: the _default_ declarations here assume a
|
||||||
|
small viewport; @media (min-width) queries opt back into the
|
||||||
|
desktop baseline where index.css already provides it.
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
0. Global guards (apply at every size)
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* No horizontal scrolling at any viewport. Anything that actually
|
||||||
|
needs horizontal scroll should set its own overflow-x on a
|
||||||
|
scoped element (eg portal-table-wrapper). */
|
||||||
|
html, body {
|
||||||
|
max-width: 100vw;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Images / media default to fluid. */
|
||||||
|
img, video, svg, picture {
|
||||||
|
max-width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
img[width][height] {
|
||||||
|
height: auto; /* preserve aspect via intrinsic size; prevents CLS */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Allow the IDE shell to escape the overflow:hidden on html so
|
||||||
|
react-flow's canvas still composes correctly. */
|
||||||
|
.app-shell, .portal-layout {
|
||||||
|
max-width: 100vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
1. Portal topbar — stacks to compact, still single-row
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.portal-topbar {
|
||||||
|
padding: 0 12px;
|
||||||
|
padding-left: calc(12px + var(--safe-left));
|
||||||
|
padding-right: calc(12px + var(--safe-right));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide the "Production" env badge below md — the critical
|
||||||
|
identity info (logo, user avatar) stay visible. */
|
||||||
|
.portal-topbar-center { display: none; }
|
||||||
|
|
||||||
|
/* Trim logo text below sm so the hamburger + user avatar fit. */
|
||||||
|
.portal-logo-text { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile drawer toggle — always visible below md, invisible at md+ */
|
||||||
|
.portal-menu-toggle {
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--tap-min);
|
||||||
|
height: var(--tap-min);
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md, 6px);
|
||||||
|
color: var(--text-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.portal-menu-toggle:hover { background: var(--bg-hover); }
|
||||||
|
|
||||||
|
@media (max-width: 1023.98px) {
|
||||||
|
.portal-menu-toggle { display: inline-flex; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
2. Portal sidebar — becomes off-canvas drawer < md,
|
||||||
|
remains a rail 56-220px md→lg, collapses via existing
|
||||||
|
button at lg+
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.portal-sidebar {
|
||||||
|
position: fixed;
|
||||||
|
top: 48px; /* under topbar */
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: min(320px, 84vw);
|
||||||
|
transform: translateX(-100%);
|
||||||
|
transition: transform var(--motion-base) var(--motion-ease);
|
||||||
|
z-index: var(--z-drawer);
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||||
|
padding-left: var(--safe-left);
|
||||||
|
}
|
||||||
|
.portal-sidebar.drawer-open {
|
||||||
|
transform: translateX(0);
|
||||||
|
}
|
||||||
|
/* Force the drawer to show labels — override .collapsed when the
|
||||||
|
user happens to have a collapsed preference on desktop before
|
||||||
|
rotating to portrait. */
|
||||||
|
.portal-sidebar.drawer-open .portal-nav-item span,
|
||||||
|
.portal-sidebar.drawer-open .portal-logo-text {
|
||||||
|
display: inline;
|
||||||
|
}
|
||||||
|
/* Collapse-button (for desktop rail) is useless on mobile. */
|
||||||
|
.portal-sidebar .portal-collapse-btn { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Drawer backdrop — rendered only when drawer is open on mobile. */
|
||||||
|
.portal-drawer-backdrop {
|
||||||
|
display: none;
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.5);
|
||||||
|
z-index: var(--z-drawer-backdrop);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
-webkit-backdrop-filter: blur(2px);
|
||||||
|
}
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.portal-drawer-backdrop.visible { display: block; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (max-width: 1023.98px) {
|
||||||
|
/* Tablet: force collapsed rail so the content has breathing room. */
|
||||||
|
.portal-sidebar {
|
||||||
|
width: 56px;
|
||||||
|
}
|
||||||
|
.portal-sidebar .portal-nav-item span { display: none; }
|
||||||
|
.portal-sidebar .portal-nav-item {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px 0;
|
||||||
|
}
|
||||||
|
.portal-sidebar .portal-collapse-btn { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
3. Portal content — safe-area padding on notched devices
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.portal-content {
|
||||||
|
padding-bottom: var(--safe-bottom);
|
||||||
|
}
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.portal-content {
|
||||||
|
padding-left: var(--safe-left);
|
||||||
|
padding-right: var(--safe-right);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
4. Dashboard — KPI grid auto-fit, header stacks < md
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.kpi-grid {
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 180px), 1fr));
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.dashboard-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: var(--space-4);
|
||||||
|
}
|
||||||
|
.dashboard-header-right {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.time-range-selector {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
}
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
.dashboard-page {
|
||||||
|
padding: var(--space-5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (max-width: 1023.98px) {
|
||||||
|
.dashboard-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dashboard and page containers use fluid type at all sizes. */
|
||||||
|
.dashboard-header-left h1 {
|
||||||
|
font-size: var(--fs-2xl);
|
||||||
|
line-height: var(--lh-tight);
|
||||||
|
}
|
||||||
|
.kpi-value {
|
||||||
|
font-size: var(--fs-xl);
|
||||||
|
line-height: var(--lh-tight);
|
||||||
|
}
|
||||||
|
.kpi-label {
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
5. Portal tables — horizontal-scroll wrapper at sm,
|
||||||
|
stacked-card mode at xs
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
/* Scroll wrapper: every table should be wrapped in
|
||||||
|
<div class="portal-table-wrapper"> so it scrolls horizontally
|
||||||
|
instead of breaking the page. */
|
||||||
|
.portal-table-wrapper {
|
||||||
|
width: 100%;
|
||||||
|
overflow-x: auto;
|
||||||
|
-webkit-overflow-scrolling: touch;
|
||||||
|
scrollbar-width: thin;
|
||||||
|
border-radius: var(--radius-md, 6px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.portal-table-wrapper table {
|
||||||
|
min-width: 640px; /* forces horizontal scroll below sm */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 479.98px) {
|
||||||
|
/* Stacked-card mode: table rows become "cards" stacked vertically,
|
||||||
|
each cell labeled by its data-label attribute. Activate by adding
|
||||||
|
class="portal-table portal-table--stack" on the <table>. */
|
||||||
|
.portal-table--stack,
|
||||||
|
.portal-table--stack thead,
|
||||||
|
.portal-table--stack tbody,
|
||||||
|
.portal-table--stack tr,
|
||||||
|
.portal-table--stack th,
|
||||||
|
.portal-table--stack td {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.portal-table--stack thead {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: -9999px;
|
||||||
|
}
|
||||||
|
.portal-table--stack tr {
|
||||||
|
background: var(--bg-surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md, 6px);
|
||||||
|
padding: var(--space-4);
|
||||||
|
margin-bottom: var(--space-4);
|
||||||
|
}
|
||||||
|
.portal-table--stack td {
|
||||||
|
border: none;
|
||||||
|
padding: var(--space-2) 0;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
}
|
||||||
|
.portal-table--stack td::before {
|
||||||
|
content: attr(data-label);
|
||||||
|
font-size: var(--fs-2xs);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
/* Stack-mode tables should stop forcing min-width */
|
||||||
|
.portal-table--stack {
|
||||||
|
min-width: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
6. Login — stack columns vertically below md; safe area
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.login-container {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-6);
|
||||||
|
padding: var(--space-5);
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
.login-left, .login-right {
|
||||||
|
max-width: 100%;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
.login-features { gap: var(--space-4); }
|
||||||
|
.login-compliance-badges { flex-wrap: wrap; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 768px) and (max-width: 1023.98px) {
|
||||||
|
.login-container {
|
||||||
|
gap: 32px;
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-page {
|
||||||
|
padding-top: calc(var(--safe-top) + var(--space-5));
|
||||||
|
padding-bottom: calc(var(--safe-bottom) + var(--space-5));
|
||||||
|
padding-left: calc(var(--safe-left) + var(--space-4));
|
||||||
|
padding-right: calc(var(--safe-right) + var(--space-4));
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh; /* avoid iOS Safari bottom-bar viewport glitch */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
7. Page containers — add fluid padding and section headings
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.accounts-page, .treasury-page, .reporting-page,
|
||||||
|
.compliance-page, .settlements-page, .settings-page {
|
||||||
|
padding: var(--space-6);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.accounts-page, .treasury-page, .reporting-page,
|
||||||
|
.compliance-page, .settlements-page, .settings-page {
|
||||||
|
padding: var(--space-4);
|
||||||
|
}
|
||||||
|
/* Summary rows on account/settlements/treasury pages stack. */
|
||||||
|
.accounts-summary,
|
||||||
|
.settlements-summary,
|
||||||
|
.treasury-summary {
|
||||||
|
grid-template-columns: 1fr !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
8. Workspace (IDE) — gate below md with a friendly message.
|
||||||
|
Above md, original layout is preserved; on coarse pointers
|
||||||
|
we still let the user drive it but the gate lets small
|
||||||
|
phones bail cleanly to the portal.
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
.workspace-mobile-gate {
|
||||||
|
display: none;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: var(--space-8) var(--space-5);
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-base);
|
||||||
|
color: var(--text-primary);
|
||||||
|
gap: var(--space-5);
|
||||||
|
}
|
||||||
|
.workspace-mobile-gate h2 {
|
||||||
|
font-size: var(--fs-2xl);
|
||||||
|
line-height: var(--lh-snug);
|
||||||
|
max-width: 28ch;
|
||||||
|
}
|
||||||
|
.workspace-mobile-gate p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: var(--fs-base);
|
||||||
|
line-height: var(--lh-relaxed);
|
||||||
|
max-width: 42ch;
|
||||||
|
}
|
||||||
|
.workspace-mobile-gate .cta-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-3);
|
||||||
|
width: 100%;
|
||||||
|
max-width: 320px;
|
||||||
|
}
|
||||||
|
.workspace-mobile-gate button,
|
||||||
|
.workspace-mobile-gate a.cta {
|
||||||
|
min-height: var(--tap-min);
|
||||||
|
padding: 0 var(--space-6);
|
||||||
|
border-radius: var(--radius-md, 6px);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--bg-surface);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: var(--fs-base);
|
||||||
|
cursor: pointer;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: var(--space-3);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.workspace-mobile-gate .cta.primary {
|
||||||
|
background: var(--accent-blue);
|
||||||
|
border-color: var(--accent-blue);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.workspace-mobile-gate .cta.primary:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.workspace-mobile-gate { display: flex; }
|
||||||
|
.workspace-mobile-gate + .app-shell { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
9. Command palette — fit narrow viewports
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (max-width: 767.98px) {
|
||||||
|
.command-palette-overlay { padding-top: max(40px, var(--safe-top)); }
|
||||||
|
.command-palette {
|
||||||
|
width: min(94vw, 520px);
|
||||||
|
max-height: 70vh;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
10. Scrollable panels — portrait-orientation tablet tweaks
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media (orientation: portrait) and (min-width: 768px) and (max-width: 1023.98px) {
|
||||||
|
/* Tablet portrait: give the portal-content more height by using
|
||||||
|
dynamic viewport units. */
|
||||||
|
.portal-body { min-height: calc(100dvh - 48px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─────────────────────────────────────────────────────────────
|
||||||
|
11. Print styles (bonus — critical for financial portals)
|
||||||
|
───────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
@media print {
|
||||||
|
.portal-topbar, .portal-sidebar, .portal-menu-toggle,
|
||||||
|
.portal-drawer-backdrop, .app-shell .title-bar,
|
||||||
|
.app-shell .activity-bar, .app-shell .bottom-panel,
|
||||||
|
.app-shell .right-panel { display: none !important; }
|
||||||
|
|
||||||
|
.portal-content, .app-body, .portal-body {
|
||||||
|
display: block !important;
|
||||||
|
overflow: visible !important;
|
||||||
|
}
|
||||||
|
body { background: #fff !important; color: #000 !important; }
|
||||||
|
a { color: inherit !important; text-decoration: underline; }
|
||||||
|
}
|
||||||
132
src/styles/tokens.css
Normal file
132
src/styles/tokens.css
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
/* ═══════════════════════════════════════════════════════════════
|
||||||
|
Design tokens — responsive foundation
|
||||||
|
Loaded BEFORE index.css so existing rules can consume these
|
||||||
|
variables. Strictly additive: no variable declared here conflicts
|
||||||
|
with an existing value in index.css.
|
||||||
|
|
||||||
|
Breakpoint policy (mobile-first, min-width):
|
||||||
|
xs: 0 – 479px (narrow phones, portrait)
|
||||||
|
sm: 480 – 767px (large phones, small tablets portrait)
|
||||||
|
md: 768 – 1023px (tablets landscape, small laptops)
|
||||||
|
lg: 1024 – 1439px (desktops, larger laptops)
|
||||||
|
xl: 1440px+ (wide desktops, ultra-wide)
|
||||||
|
|
||||||
|
We prefer container/fluid behavior; breakpoints gate only layout
|
||||||
|
switches that cannot be expressed fluidly (nav drawer, workspace
|
||||||
|
availability, table→card transform).
|
||||||
|
═══════════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
|
:root {
|
||||||
|
/* Breakpoints (CSS custom props mirror the JS hook constants) */
|
||||||
|
--bp-xs: 0px;
|
||||||
|
--bp-sm: 480px;
|
||||||
|
--bp-md: 768px;
|
||||||
|
--bp-lg: 1024px;
|
||||||
|
--bp-xl: 1440px;
|
||||||
|
|
||||||
|
/* Fluid type scale — clamp(min, preferred, max)
|
||||||
|
Preferred uses a viewport-width linear function so text grows
|
||||||
|
smoothly between xs and xl without hard breakpoint jumps.
|
||||||
|
All values are in rem so user font-size preferences are honored. */
|
||||||
|
--fs-2xs: clamp(0.625rem, 0.59rem + 0.17vw, 0.75rem); /* 10→12px */
|
||||||
|
--fs-xs: clamp(0.6875rem, 0.65rem + 0.19vw, 0.8125rem); /* 11→13px */
|
||||||
|
--fs-sm: clamp(0.75rem, 0.71rem + 0.21vw, 0.875rem); /* 12→14px */
|
||||||
|
--fs-base: clamp(0.8125rem, 0.77rem + 0.23vw, 1rem); /* 13→16px */
|
||||||
|
--fs-md: clamp(0.875rem, 0.83rem + 0.25vw, 1.125rem); /* 14→18px */
|
||||||
|
--fs-lg: clamp(1rem, 0.93rem + 0.38vw, 1.25rem); /* 16→20px */
|
||||||
|
--fs-xl: clamp(1.125rem, 1.0rem + 0.63vw, 1.5rem); /* 18→24px */
|
||||||
|
--fs-2xl: clamp(1.25rem, 1.04rem + 1.04vw, 1.875rem); /* 20→30px */
|
||||||
|
--fs-3xl: clamp(1.5rem, 1.19rem + 1.56vw, 2.25rem); /* 24→36px */
|
||||||
|
--fs-4xl: clamp(1.875rem, 1.35rem + 2.60vw, 3rem); /* 30→48px */
|
||||||
|
|
||||||
|
/* Line heights */
|
||||||
|
--lh-tight: 1.2;
|
||||||
|
--lh-snug: 1.35;
|
||||||
|
--lh-normal: 1.5;
|
||||||
|
--lh-relaxed: 1.65;
|
||||||
|
|
||||||
|
/* Fluid spacing scale (8pt grid, fluid from xs to xl) */
|
||||||
|
--space-0: 0;
|
||||||
|
--space-1: clamp(0.125rem, 0.11rem + 0.05vw, 0.1875rem); /* 2→3px */
|
||||||
|
--space-2: clamp(0.25rem, 0.22rem + 0.10vw, 0.375rem); /* 4→6px */
|
||||||
|
--space-3: clamp(0.375rem, 0.33rem + 0.17vw, 0.5rem); /* 6→8px */
|
||||||
|
--space-4: clamp(0.5rem, 0.44rem + 0.26vw, 0.75rem); /* 8→12px */
|
||||||
|
--space-5: clamp(0.75rem, 0.65rem + 0.42vw, 1rem); /* 12→16px */
|
||||||
|
--space-6: clamp(1rem, 0.87rem + 0.56vw, 1.5rem); /* 16→24px */
|
||||||
|
--space-7: clamp(1.25rem, 1.04rem + 0.83vw, 2rem); /* 20→32px */
|
||||||
|
--space-8: clamp(1.5rem, 1.22rem + 1.04vw, 2.5rem); /* 24→40px */
|
||||||
|
--space-10: clamp(2rem, 1.65rem + 1.46vw, 3.5rem); /* 32→56px */
|
||||||
|
--space-12: clamp(2.5rem, 2.00rem + 2.08vw, 4.5rem); /* 40→72px */
|
||||||
|
|
||||||
|
/* Touch target minimum (WCAG 2.5.5 AA is 44×44 CSS px) */
|
||||||
|
--tap-min: 44px;
|
||||||
|
|
||||||
|
/* Container widths */
|
||||||
|
--container-sm: 640px;
|
||||||
|
--container-md: 768px;
|
||||||
|
--container-lg: 1024px;
|
||||||
|
--container-xl: 1280px;
|
||||||
|
--container-2xl: 1536px;
|
||||||
|
|
||||||
|
/* Motion tokens */
|
||||||
|
--motion-fast: 120ms;
|
||||||
|
--motion-base: 200ms;
|
||||||
|
--motion-slow: 320ms;
|
||||||
|
--motion-ease: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
/* Z-index scale */
|
||||||
|
--z-base: 0;
|
||||||
|
--z-sticky: 10;
|
||||||
|
--z-drawer-backdrop: 40;
|
||||||
|
--z-drawer: 50;
|
||||||
|
--z-dropdown: 60;
|
||||||
|
--z-modal: 100;
|
||||||
|
--z-toast: 200;
|
||||||
|
--z-tooltip: 300;
|
||||||
|
--z-focus: 999;
|
||||||
|
|
||||||
|
/* Focus ring */
|
||||||
|
--focus-ring-color: #60a5fa; /* light blue, visible on dark bg */
|
||||||
|
--focus-ring-offset: 2px;
|
||||||
|
--focus-ring-width: 2px;
|
||||||
|
|
||||||
|
/* Safe area insets (iOS notch, Android gesture areas) */
|
||||||
|
--safe-top: env(safe-area-inset-top, 0px);
|
||||||
|
--safe-right: env(safe-area-inset-right, 0px);
|
||||||
|
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||||
|
--safe-left: env(safe-area-inset-left, 0px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Honor OS-level reduced motion — apply across the entire app.
|
||||||
|
Anything that relies on animation for state feedback (toasts,
|
||||||
|
drawer slide, spinner) must still convey state without motion.
|
||||||
|
Spinners use opacity pulses under the same media query. */
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*, *::before, *::after {
|
||||||
|
animation-duration: 0.001ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.001ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Honor OS-level contrast preference */
|
||||||
|
@media (prefers-contrast: more) {
|
||||||
|
:root {
|
||||||
|
--focus-ring-width: 3px;
|
||||||
|
--focus-ring-color: #ffffff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* High-DPI tuning: tighten 1px borders on >= 2x DPR so they read as
|
||||||
|
hairlines and don't visually bloom. */
|
||||||
|
@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 2dppx) {
|
||||||
|
:root {
|
||||||
|
--hairline: 0.5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@media not all and (-webkit-min-device-pixel-ratio: 2), not all and (min-resolution: 2dppx) {
|
||||||
|
:root {
|
||||||
|
--hairline: 1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user