Skip to content

Internal dashboard apps

apps/internal-ops is the one shared app for internal-only, web-first dashboards. New dashboards are new tabs/screens in this app, not new Expo apps.

Adding a new tab

  1. Create a screen under apps/internal-ops/app/screens/.
  2. Register it in apps/internal-ops/app/navigation/tabs.tsx: tsx { key: "devices", title: "Devices", component: DevicesScreen }
  3. Restrict it with a roles array (user needs at least one). Omit roles to allow any signed-in internal user: tsx { key: "devices", title: "Devices", roles: [INTERNAL_ROLES.ADMIN], component: DevicesScreen }

Gating part of a view

Use RoleGate to hide a section instead of a whole tab:

<RoleGate roles={[INTERNAL_ROLES.ADMIN]}>
  <DangerousBulkActionButton />
</RoleGate>

Adding a new role

Add it to INTERNAL_ROLES in apps/internal-ops/app/auth/roles.ts. It only matters once it also exists as a Keycloak realm role and is assigned to someone.

Access control

The backend runs a separate internal API and PostgREST instance (internal-postgrest, port 3600, schema internal_api). Its anonymous role has no grants. Requests need a JWT with role: internal_api_role. See vibrant-platform/docs/architecture/internal-api.md.

This app signs in through the vibrant-internal-web Keycloak client, never vibrant-mobile/postgrest, and never through AccountsApi (see below). It uses Authorization Code with S256 PKCE through Expo AuthSession. Keycloak owns the password and MFA screens.

Two gates exist today, and they are not equally strong:

  1. App-level, real today: AuthGate requires the internal_staff realm role to get past sign-in. TabNavigator/RoleGate filter tabs and sections by role.
  2. Data-level, weak, local-only: vibrant-internal-web's role claim is hardcoded to internal_api_role for anyone who can sign in with that client, regardless of realm role. Production needs to gate this on a real realm role. That wiring belongs in ../vibrant-infra.

Be conservative about who gets a vibrant-internal-web account until that gap is closed.

Granting access means creating or assigning a user in the internal realm. Staff users inherit internal_staff by default in that realm; assign any elevated roles such as internal_admin explicitly. There is no self-serve signup.

Authentication configuration

apps/internal-ops/app/config/keycloak.ts is the source of truth for the Keycloak URL, realm, client ID, and optional deployed callback. The app derives local callbacks and uses KEYCLOAK_REDIRECT_URI only for the exact HTTPS callback registered for a deployed web app.

apps/internal-ops/app/services/api/APIFactory.ts passes accountsApiUrl: "" to disable the customer AccountsApi login path. Its shared session storage is scoped to internal ops.

MFA policy

The vibrant-internal-web client has standard flow and S256 PKCE enabled and direct password grants disabled. The running local realm binds its Internal staff browser flow, which contains an Internal staff TOTP conditional branch: users with internal_staff must complete TOTP after their password, while users without that role follow the normal browser flow.

On a staff member's first login, Keycloak prompts for authenticator setup. Scan the QR code with a TOTP application (Google Authenticator, 1Password, Microsoft Authenticator, Authy, etc.) and complete the generated code prompt. Keycloak's TOTP and recovery-code setup actions are enabled. Recovery codes can be generated in the Account Console under Account Security → Signing in, but recovery-code sign-in has not yet been added to the required staff branch; reset a lost authenticator through an administrator until that fallback is explicitly enabled and validated.

The browser-flow policy is live configuration in the existing local Keycloak database; Keycloak does not re-import it into an already-created realm. Keep the local Keycloak data volume when restarting. If the realm is recreated, reapply this flow before testing internal ops.

Production must also replace the local hardcoded PostgREST role mapper with a role-derived mapper.

Where the data comes from

getApiService() in APIFactory.ts points at the internal PostgREST (:3600), not the public one. Extend internal_api_role's grants in vibrant-platform migrations as dashboards need more data.

Running it locally

npm run web:internal-ops

Copy apps/internal-ops/.env.example to .env. Local defaults already match: the vibrant-internal-web client, internal PostgREST on :3600, and [email protected] / VibrantDemo!1.

Deployment

Not established yet. expo export --platform web produces static assets.