Skip to content
Payment Emulator LabDocumentationOpen console

Frontends

The operator console

apps/console is the Nuxt application an operator signs into. It registersapplications, rotates credentials, reads the ledger, releases and withdrawsmoney, administers banks and peo

9 min read

Índice / Index · Español

apps/console is the Nuxt application an operator signs into. It registers applications, rotates credentials, reads the ledger, releases and withdraws money, administers banks and people, and inspects traces and webhook deliveries.

It talks to nothing but its own Nitro server. Nitro talks to the API. That is the rule the whole application is arranged around: a credential stops at the server, and a page that wants data asks a gateway that only knows /api/….

#Its layers

Layer Holds Rule
app/core/ The shared runtime: gateways, the list pipeline, layout primitives, form primitives, formatting Written once. A module that reimplements any of it has forked the design system
app/modules/<entity>/ One entity's model, columns, forms and entity-specific components Knows its own domain and nothing about anyone else's
app/pages/ One screen each, assembling core and one module Holds no fetch logic of its own
server/api/ Nitro, one file per verb The only thing that may reach the emulator API

The operator console is written in Spanish. The public landing supports Spanish and English. Code, comments and these documents are in English.

#Public home and access

/ is the public Payment Emulator Lab landing. /applications holds the existing authenticated application listing. /login is the existing sign-in page and defaults to /applications after successful sign-in; protected deep links still use next. All control-plane Nitro routes retain their session requirement.

The landing lives in app/modules/public/landing/components/. Its typed ES/EN catalog is app/modules/public/landing/i18n/catalog.ts, mounted under public.landing by Nuxt I18n. useAppLocale() is the application locale boundary; Nuxt I18n owns the SSR-readable emulator_locale cookie. publicLocalePath() generates localized public links and SEO alternates. See the i18n architecture. The existing useColorScheme() cookie (emulator_scheme, dark by default) and html.dark drive both palettes without a second theme strategy. Landing CSS is namespaced and does not change the operator palette.

Three.js loads only when the decorative client scene enters the viewport. The scene caps DPR at 1.5 and rendering at 30 fps, pauses offscreen and in hidden tabs, renders a static view with reduced motion, and disposes its resources on unmount. An SSR card remains visible without JavaScript or WebGL. Scenario and trace panels are explicitly illustrative and do not call the API.

Documentation links open the local documentation portal, built from the bilingual Markdown guides. No GitHub access is required. NUXT_PUBLIC_SITE_URL is empty by default; set it to the deployed public origin and every absolute address the site emits — canonical, hreflang alternates, Open Graph image, robots.txt and sitemap.xml — follows it. Left empty, each request answers for the host it arrived on. No production hostname is assumed for local installations.

#Public addresses

The landing has one public address per language: / in Spanish and /en in English. The route decides the language, so each version can be linked and indexed on its own; the emulator_locale cookie only remembers which one the reader chose. Both canonicalise to themselves and declare the other through hreflang. /sitemap.xml lists both landings and the published guides under /docs/es and /docs/en. Everything behind the login answers X-Robots-Tag: noindex.

Run npm run dev:console from the repository root to preview the landing on port 3000. It needs no API or database to render. The login and authenticated console require the configured API and session password as before.

With the console running and NUXT_SESSION_PASSWORD configured, run npm run smoke:landing. It checks public SSR in both languages and themes, login links, protected page redirects and anonymous Nitro rejection without an API or database. Set CONSOLE_URL if the console uses another origin.

#core/ — the design system

There is exactly one design system: PrimeVue with the Aura preset, tuned in app/theme.ts. A second component library, a second icon set or a hand-rolled replacement for something PrimeVue already does are all defects. The wrappers below exist to make the correct thing the easy thing, not to hide PrimeVue.

#Forms

  • RField — one field: its label, its control, its help and its error. It mints the id, wires aria-describedby, and decides how the control takes its name. This is the file that knows the traps, and there are three:

    • Naming. InputText, Password, Textarea and ToggleSwitch mount a real <input>, so <label for> names them. Select renders a <span role="combobox">, which a for cannot name at all — those fields pass :labelable="false" and :aria-labelledby="field.labelledBy". A hand-written label/control pair only looks associated.
    • Components that swallow attributes. InputNumber has no inputProps, so an aria-describedby written on the component lands on the wrapping span and the inner input is left unnamed. It is reached with :pt="{ pcInputText: { root: { 'aria-describedby': field.describedBy } } }". InputChips does take inputId and inputProps, so a plain label works.
    • Height. See below.
  • One height, one type size. Every single-line control in a field is --control-h tall and 14px, asserted in RField. PrimeVue does not give them the same: a Select comes out 42px against an InputText's 39, an <input> with no font-size of its own inherits the browser's 16px while the panel is 14, and an input group holding a colour swatch is taller than both. Left alone, a row of fields is not a row.

  • The help is a tooltip on the label row, not a paragraph under the field. A ? next to the label, opening on hover and on focus. That is what makes two fields side by side the same height. Nothing is lost to a screen reader: the text still exists in the document, hidden, carrying the id that aria-describedby has always pointed at. The error stays in the flow — an operator whose form was just refused should not have to hunt for the reason.

    .tmp/fields-audit.mjs is the check: it opens every form, groups fields by the row they sit on, and fails any row whose controls differ in height or any help whose text no control references.

  • RFormSection — a group of fields. Renders fieldset+legend when it is named and a plain div when it is not, so an unnamed group does not announce an empty legend. min-column drives the responsive grid.

  • RFormActions — the row of buttons, so submit and cancel sit the same way on every screen.

  • useValidation(form, rules) — mirrors what the API's DTOs already declare. It is deliberately not a second source of truth: the server still refuses what it refuses; this only decides when the operator finds out. Errors stay hidden until a field is left or the form is submitted, because a form that turns red while you are typing the first character is a form arguing with you. A rule is (value, form) => string | null, so a cross-field rule is an inline arrow, not a framework feature.

#Listings

  • useEntityCollection — the whole list pipeline for one entity: params, fetching, paging, sorting, filtering, searching, deleting, and optional URL sync so a filtered view is a link. No page builds a request by hand.
  • RTable — the table itself, plus a column picker, a loading skeleton, and two distinct empty states: a listing nobody has filled yet, and a filter that matched nothing. They are not the same message.
  • <module>.columns.ts — each module declares its own columns. A column that claims sortable must name a field the endpoint declared sortable, or the API answers unsortable_field rather than quietly ignoring it. See the list contract.

#Layout and shell

  • AppSidebar / AppTopbar — the shell. The sidebar collapses to icons with tooltips; both read core/navigation.ts.
  • navigation.ts — the grouped, role-filtered navigation. Hiding an entry a role cannot open is courtesy; the protection is that the API answers 403 and the route middleware refuses. But a panel that offers pages you cannot open is a panel that looks broken.
  • RPageHeader — title, one line saying what the screen is for, and the primary action.
  • RStat — a dense figure tile, deliberately not a Card.
  • RTour — the guided tour host. Steps are registered per page with useHelpTour().register(...); a step whose target is not on the page is dropped rather than pointing at nothing.

#Feedback and failure

  • useNotifysucceeded / failed, over PrimeVue's Toast.
  • useConfirmActiondestructive(...), over ConfirmDialog. Nothing in the console uses window.confirm, and no destructive action is one click.
  • REmptyState — icon, title, one line of why, and the way out.
  • useAppError().explain(error, localizedFallback) — maps semantic REST codes/status to safe localized copy. Inline failures use computed presentation; persistent error notifications use getters so language changes update existing messages without repeating the request or exposing backend error text.

#Data access

  • createRestGateway({ resource }) — one resource's verbs, built from its path. Every path is a Nitro route on this application, never the emulator API. During server rendering it uses useRequestFetch, because $fetch does not forward the incoming session cookie and a server-rendered listing would otherwise be answered 401 by our own server.
  • core/data/contracts/ — the shared list-params shape, and the serializer that maps it onto the query the API reads. Declared once; a new listing inherits the wire format instead of inventing one.

#Appearance

  • app/theme.ts — the Aura preset, tuned. It is loaded through primevue.importTheme and not through options.theme: a preset named as a string goes through Nuxt's runtime-config serialisation, PrimeVue emits empty variable blocks, and every var(--p-*) in the panel resolves to nothing. The symptom is a completely unstyled application, and it is not obvious from the configuration that anything is wrong.
  • useColorScheme — light and dark, persisted in a cookie so the server renders the same scheme the browser is about to show. darkModeSelector is .dark on <html>.
  • assets/css/console.css — the tokens: spacing scale, radii, the content width, surface and text colours for both schemes. Arbitrary padding, radii or colours in a component are a defect; the token exists.

#Adding a screen

  1. modules/<entity>/<entity>.model.ts — the interface and createRestGateway({ resource: '/api/<entity>' }).
  2. modules/<entity>/<entity>.columns.ts — the columns, matching what the endpoint declared sortable and filterable.
  3. server/api/<entity>/… — one file per verb, proxyList for the listing.
  4. pages/<entity>.vueRPageHeader, useEntityCollection, RTable, and a form built from RFormSection + RField.
  5. core/navigation.ts — the entry, with roles when a role cannot open it.
  6. A tour, with useHelpTour().register(listViewTour({ … })).

#Verifying a change

Render it. npm run typecheck --workspace @payment-emulator/console catches types and nothing about whether the panel looks right; grepping served HTML catches even less. Sign in, screenshot the screen in both colour schemes, and exercise the write path — the failures this console has actually had (an unstyled theme, a label pointing at a span, an input group overlapping its button, a blank page for a missing record) were all invisible to every check except looking.

Payment Emulator Lab · RonuSoftwareMIT