Frontend Architecture
Next.js dashboard for managing all file transfer operations through a modern UI.
Courier's frontend is a Next.js application using the App Router, statically exported and served from a CDN. It is an internal tool behind Entra ID authentication — there are no public-facing pages or SEO requirements. All data flows through the Courier REST API (Section 10).
11.1 Tech Stack
| Technology | Purpose |
|---|---|
| Next.js (App Router) | Framework — routing, static export, layouts |
| React 19+ | UI library |
| TypeScript | Type safety across all frontend code |
| Tailwind CSS | Utility-first styling |
| shadcn/ui | Component library (Radix UI primitives + Tailwind) |
| TanStack Query (React Query) | Server state management — caching, refetching, mutations |
| MSAL.js (@azure/msal-browser) | Entra ID authentication — OAuth 2.0 PKCE flow |
| Lucide React | Icon library (consistent with shadcn/ui) |
| React Hook Form | Form state management (job builder, connection editor, key import) |
| Zod | Schema validation (client-side, mirrors FluentValidation rules) |
| date-fns | Date formatting and manipulation |
| next-themes | Dark/light mode theming |
11.2 Rendering Strategy
Standalone server (output: 'standalone' in next.config.ts). Next.js compiles a self-contained Node.js server that handles routing, serves static assets, and supports all App Router features — packaged into a minimal Docker image without node_modules.
Design decision (2026-03-15): The original design specified
output: 'export'(static HTML + nginx). This was changed tooutput: 'standalone'because the frontend uses ~17 dynamic[id]routes, all of which are"use client"components. Next.jsoutput: 'export'requiresgenerateStaticParamson every dynamic route, which would need stubs added to all detail pages. The standalone approach works immediately with the existing codebase, handles all client-side routing natively, and avoids the need for nginx SPA fallback configuration. The tradeoff is a slightly larger Docker image (~150MB vs ~25MB for nginx) — acceptable for an internal enterprise application.
Client-side data fetching: All API calls happen in the browser via TanStack Query after authentication. Pages render loading skeletons until data arrives. No server-side rendering or data fetching is used — the Node.js server only handles routing and static asset serving.
Build-time configuration: Environment-specific values (API base URL, Entra ID client ID, tenant ID) are injected at build time via NEXT_PUBLIC_* environment variables. Separate builds are produced for dev, staging, and production.
11.2.1 Client-Side SPA Constraints
Although the frontend runs on a Node.js server, Courier deliberately avoids server-side features. The frontend is a pure client-side SPA that uses Next.js for routing, layout system, and build tooling. There is no server-side rendering, no server-side auth, and no backend-for-frontend (BFF) pattern. The browser is the only meaningful execution environment.
Server-side features intentionally not used:
| Feature | Status | Courier's Approach |
|---|---|---|
| Server Components (async data fetching) | Not used — all components are "use client" | TanStack Query for all data fetching, with loading skeletons |
Server Actions ("use server") | Not used | All mutations go through the REST API via TanStack Query's useMutation |
Route Handlers (app/api/...) | Not used | The .NET API is the only backend; the frontend never proxies or transforms requests |
Middleware (middleware.ts) | Not used | Auth guard is a client-side AuthProvider wrapper that checks token state before rendering children (see 11.3). Route protection is enforced in the browser, with the API as the authoritative gate. |
next/image optimization | Not used | Standard <img> tags. Courier's UI is data-heavy, not image-heavy — no optimization needed. |
| Incremental Static Regeneration (ISR) | Not used | All data is fetched client-side; pages don't need regeneration. |
11.2.2 Authentication Security in a Static SPA
Since there is no server component, all authentication state lives in the browser. This has specific security implications that are addressed by the OAuth 2.0 Authorization Code + PKCE flow:
Why Authorization Code + PKCE (not Implicit Flow):
The OAuth 2.0 Implicit Flow was historically used for SPAs but is now deprecated by OAuth 2.1 because it exposes access tokens in the URL fragment (susceptible to browser history leaks and referrer header exfiltration). Courier uses the Authorization Code flow with Proof Key for Code Exchange (PKCE), which is the current best practice for public clients:
- The browser generates a random
code_verifier(128-bit entropy) and derives acode_challenge(SHA-256 hash) - The authorization request sends the
code_challengeto Entra ID - Entra ID returns an authorization code to the redirect URI
- The browser exchanges the code +
code_verifierfor tokens — Entra ID verifies the challenge, preventing authorization code interception attacks - No client secret is used (the app registration is configured as a "public client" in Entra ID)
Token storage: MSAL.js stores tokens in sessionStorage (cleared on tab close), never localStorage (persists across sessions and is accessible to any script on the same origin). The access token lifetime is controlled by Entra ID (default: 1 hour). Refresh tokens are handled by MSAL.js via silent iframe-based renewal — the refresh token itself is never exposed to application JavaScript.
What the SPA cannot enforce: Client-side auth checks (hiding UI elements for unauthorized roles, redirecting unauthenticated users to login) are a UX convenience, not a security boundary. A determined user could modify client-side JavaScript to bypass any UI restriction. The API enforces all authorization server-side — every endpoint validates the bearer token and checks role claims (Section 12.2). If a user manipulates the UI to call an endpoint they shouldn't access, the API returns 403 Forbidden.
XSS as the primary threat: In an SPA where tokens live in the browser, XSS is the most dangerous attack vector — injected script can read sessionStorage and exfiltrate tokens. Mitigations: strict Content-Security-Policy header (Section 12.6.2), no dangerouslySetInnerHTML without sanitization, React's default escaping of rendered values, dependency auditing via npm audit in CI.
11.3 Authentication Flow
User visits Courier
│
▼
┌─────────────────────────┐
│ MSAL.js checks for │
│ cached token │
└────────────┬────────────┘
│
┌────▼────┐
│ Token? │
└────┬────┘
No │ Yes
┌────────▼──┐ │
│ Redirect │ │
│ to Entra │ │
│ ID login │ │
└────────┬──┘ │
│ │
┌────────▼──┐ │
│ Auth code │ │
│ callback │ │
│ + PKCE │ │
└────────┬──┘ │
│ │
┌────────▼─────▼──────┐
│ Access token in │
│ memory (MSAL cache) │
└────────────┬────────┘
│
┌────────────▼────────────┐
│ API calls include │
│ Authorization: Bearer │
│ header via interceptor │
└─────────────────────────┘
MSAL.js configuration:
const msalConfig: Configuration = {
auth: {
clientId: process.env.NEXT_PUBLIC_ENTRA_CLIENT_ID!,
authority: `https://login.microsoftonline.com/${process.env.NEXT_PUBLIC_ENTRA_TENANT_ID}`,
redirectUri: process.env.NEXT_PUBLIC_REDIRECT_URI!,
},
cache: {
cacheLocation: "sessionStorage", // Not localStorage — cleared on tab close
storeAuthStateInCookie: false,
},
};
const loginRequest: PopupRequest = {
scopes: [`api://${process.env.NEXT_PUBLIC_ENTRA_CLIENT_ID}/access_as_user`],
};
Token lifecycle: MSAL.js handles token refresh automatically via silent token acquisition. If the silent refresh fails (e.g., session expired), the user is redirected to the Entra ID login page. The access token is never stored in localStorage — only in sessionStorage or MSAL's in-memory cache.
Role extraction: The roles claim from the Entra ID token is decoded client-side to control UI visibility (e.g., hiding "Create Connection" for Viewer role). This is a UX convenience only — the API enforces authorization server-side regardless of what the frontend shows.
11.4 Project Structure
Courier.Frontend/
├── src/
│ ├── app/ ← Next.js App Router
│ │ ├── layout.tsx ← Root layout (providers, sidebar, theme)
│ │ ├── page.tsx ← Dashboard (home page)
│ │ ├── jobs/
│ │ │ ├── page.tsx ← Job list
│ │ │ ├── new/page.tsx ← Job builder (create)
│ │ │ ├── [id]/
│ │ │ │ ├── page.tsx ← Job detail
│ │ │ │ ├── edit/page.tsx ← Job builder (edit)
│ │ │ │ ├── executions/page.tsx ← Execution history
│ │ │ │ └── executions/[execId]/page.tsx ← Execution detail
│ │ │ └── loading.tsx ← Skeleton loader
│ │ ├── chains/
│ │ │ ├── page.tsx ← Chain list
│ │ │ ├── new/page.tsx
│ │ │ └── [id]/page.tsx
│ │ ├── connections/
│ │ │ ├── page.tsx ← Connection list
│ │ │ ├── new/page.tsx
│ │ │ └── [id]/page.tsx
│ │ ├── keys/
│ │ │ ├── pgp/
│ │ │ │ ├── page.tsx ← PGP key list
│ │ │ │ ├── generate/page.tsx
│ │ │ │ ├── import/page.tsx
│ │ │ │ └── [id]/page.tsx
│ │ │ └── ssh/
│ │ │ ├── page.tsx ← SSH key list
│ │ │ ├── generate/page.tsx
│ │ │ ├── import/page.tsx
│ │ │ └── [id]/page.tsx
│ │ ├── monitors/
│ │ │ ├── page.tsx
│ │ │ ├── new/page.tsx
│ │ │ └── [id]/page.tsx
│ │ ├── audit/
│ │ │ └── page.tsx ← Audit log (filterable)
│ │ └── settings/
│ │ └── page.tsx ← System settings (Admin only)
│ │
│ ├── components/
│ │ ├── ui/ ← shadcn/ui components (Button, Dialog, Table, etc.)
│ │ ├── layout/
│ │ │ ├── Sidebar.tsx ← Navigation sidebar
│ │ │ ├── Header.tsx ← Top bar (user, theme toggle, role badge)
│ │ │ └── Breadcrumbs.tsx
│ │ ├── shared/
│ │ │ ├── DataTable.tsx ← Generic paginated table with sort/filter
│ │ │ ├── StatusBadge.tsx ← Colored badge for entity states
│ │ │ ├── TagPicker.tsx ← Tag selector/creator
│ │ │ ├── ConfirmDialog.tsx ← Destructive action confirmation
│ │ │ ├── EmptyState.tsx ← Zero-state illustrations
│ │ │ ├── ErrorDisplay.tsx ← Maps ApiError to user-friendly message
│ │ │ ├── LoadingSkeleton.tsx ← Shimmer placeholders
│ │ │ └── RoleGate.tsx ← Conditionally renders children based on role
│ │ ├── jobs/
│ │ │ ├── JobBuilder.tsx ← Multi-step form for job creation/editing
│ │ │ ├── StepConfigurator.tsx ← Dynamic form per step type (from step-types API)
│ │ │ ├── JobExecutionTimeline.tsx ← Visual timeline of step execution states
│ │ │ ├── FailurePolicyEditor.tsx
│ │ │ └── CronEditor.tsx ← Human-friendly cron expression builder
│ │ ├── connections/
│ │ │ ├── ConnectionForm.tsx
│ │ │ ├── ConnectionTestResult.tsx ← Displays test outcome with algorithm details
│ │ │ └── FipsOverrideBanner.tsx ← Warning banner when FIPS override enabled
│ │ ├── keys/
│ │ │ ├── KeyGenerateForm.tsx
│ │ │ ├── KeyImportForm.tsx
│ │ │ ├── KeyLifecycleBadge.tsx ← Active/Expiring/Retired/Revoked status
│ │ │ └── KeyExpiryWarning.tsx
│ │ ├── monitors/
│ │ │ ├── MonitorForm.tsx
│ │ │ ├── MonitorStateBadge.tsx
│ │ │ └── FileLogTable.tsx ← Triggered file history
│ │ └── dashboard/
│ │ ├── SummaryCards.tsx ← Top-level metric cards
│ │ ├── RecentExecutionsTable.tsx
│ │ ├── ActiveMonitorsList.tsx
│ │ └── KeyExpiryList.tsx
│ │
│ ├── lib/
│ │ ├── api/
│ │ │ ├── client.ts ← Fetch wrapper with auth header injection
│ │ │ ├── types.ts ← ApiResponse<T>, PagedApiResponse<T>, ApiError
│ │ │ ├── jobs.ts ← Job API functions (listJobs, createJob, etc.)
│ │ │ ├── chains.ts
│ │ │ ├── connections.ts
│ │ │ ├── pgp-keys.ts
│ │ │ ├── ssh-keys.ts
│ │ │ ├── monitors.ts
│ │ │ ├── tags.ts
│ │ │ ├── audit.ts
│ │ │ ├── settings.ts
│ │ │ ├── dashboard.ts
│ │ │ └── step-types.ts
│ │ ├── auth/
│ │ │ ├── msal.ts ← MSAL instance, config, login/logout
│ │ │ ├── AuthProvider.tsx ← React context provider for auth state
│ │ │ └── useAuth.ts ← Hook: user, roles, token, isAuthenticated
│ │ ├── hooks/
│ │ │ ├── useJobs.ts ← TanStack Query hooks for jobs
│ │ │ ├── useConnections.ts
│ │ │ ├── useKeys.ts
│ │ │ ├── useMonitors.ts
│ │ │ ├── useTags.ts
│ │ │ ├── useAuditLog.ts
│ │ │ ├── useDashboard.ts
│ │ │ └── usePagination.ts ← Shared pagination state hook
│ │ ├── utils/
│ │ │ ├── errors.ts ← Error code → user-friendly message mapping
│ │ │ ├── dates.ts ← date-fns formatters
│ │ │ ├── cron.ts ← Cron expression ↔ human description
│ │ │ └── constants.ts ← API base URL, roles, pagination defaults
│ │ └── validations/
│ │ ├── job.schema.ts ← Zod schemas for job forms
│ │ ├── connection.schema.ts
│ │ ├── key.schema.ts
│ │ └── monitor.schema.ts
│ │
│ └── styles/
│ └── globals.css ← Tailwind directives, shadcn/ui theme tokens
│
├── public/ ← Static assets (favicon, logo)
├── next.config.ts ← output: 'standalone', env vars, image config
├── tailwind.config.ts ← Theme, custom colors, font
├── tsconfig.json
└── package.json
11.5 API Client Layer
All API communication is centralized in lib/api/. A typed fetch wrapper handles authentication, response envelope unwrapping, and error normalization.
Fetch client:
import { msalInstance, loginRequest } from "@/lib/auth/msal";
import type { ApiResponse, PagedApiResponse, ApiError } from "./types";
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL!;
async function getAuthHeaders(): Promise<HeadersInit> {
const account = msalInstance.getActiveAccount();
if (!account) throw new Error("No active account");
const { accessToken } = await msalInstance.acquireTokenSilent({
...loginRequest,
account,
});
return {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
};
}
export async function apiGet<T>(path: string): Promise<ApiResponse<T>> {
const res = await fetch(`${API_BASE}${path}`, {
headers: await getAuthHeaders(),
});
const body: ApiResponse<T> = await res.json();
if (!body.success) throw new ApiClientError(body.error!);
return body;
}
export async function apiGetPaged<T>(
path: string,
params: Record<string, string>
): Promise<PagedApiResponse<T>> {
const query = new URLSearchParams(params).toString();
const res = await fetch(`${API_BASE}${path}?${query}`, {
headers: await getAuthHeaders(),
});
const body: PagedApiResponse<T> = await res.json();
if (!body.success) throw new ApiClientError(body.error!);
return body;
}
export async function apiPost<T>(path: string, data?: unknown): Promise<ApiResponse<T>> {
const res = await fetch(`${API_BASE}${path}`, {
method: "POST",
headers: await getAuthHeaders(),
body: data ? JSON.stringify(data) : undefined,
});
const body: ApiResponse<T> = await res.json();
if (!body.success) throw new ApiClientError(body.error!);
return body;
}
// apiPut, apiDelete follow the same pattern
export class ApiClientError extends Error {
constructor(public error: ApiError) {
super(error.message);
this.name = "ApiClientError";
}
}
TypeScript types (mirroring the backend standard response model):
export interface ApiResponse<T = unknown> {
data: T | null;
error: ApiError | null;
success: boolean;
timestamp: string;
}
export interface PagedApiResponse<T = unknown> {
data: T[];
pagination: PaginationMeta;
error: ApiError | null;
success: boolean;
timestamp: string;
}
export interface PaginationMeta {
page: number;
pageSize: number;
totalCount: number;
totalPages: number;
}
export interface ApiError {
code: number;
systemMessage: string;
message: string;
details?: FieldError[];
}
export interface FieldError {
field: string;
message: string;
}
11.6 Server State Management (TanStack Query)
All server data is managed through TanStack Query. No global state store (Redux, Zustand) is used — TanStack Query handles caching, background refetching, optimistic updates, and cache invalidation.
Query hook pattern (example: Jobs):
// lib/hooks/useJobs.ts
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import * as jobsApi from "@/lib/api/jobs";
export const jobKeys = {
all: ["jobs"] as const,
lists: () => [...jobKeys.all, "list"] as const,
list: (filters: JobFilter) => [...jobKeys.lists(), filters] as const,
details: () => [...jobKeys.all, "detail"] as const,
detail: (id: string) => [...jobKeys.details(), id] as const,
executions: (id: string) => [...jobKeys.detail(id), "executions"] as const,
};
export function useJobs(filters: JobFilter) {
return useQuery({
queryKey: jobKeys.list(filters),
queryFn: () => jobsApi.listJobs(filters),
});
}
export function useJob(id: string) {
return useQuery({
queryKey: jobKeys.detail(id),
queryFn: () => jobsApi.getJob(id),
});
}
export function useCreateJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: jobsApi.createJob,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: jobKeys.lists() });
},
});
}
export function useExecuteJob() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => jobsApi.executeJob(id),
onSuccess: (_, id) => {
queryClient.invalidateQueries({ queryKey: jobKeys.executions(id) });
},
});
}
Cache configuration:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 30_000, // Data considered fresh for 30 seconds
gcTime: 5 * 60_000, // Unused cache garbage collected after 5 minutes
retry: 1, // One retry on network failure
refetchOnWindowFocus: true, // Refetch when user returns to tab
},
},
});
Polling for active executions: Job and chain execution detail pages use refetchInterval to poll for state changes while an execution is in progress:
export function useJobExecution(jobId: string, execId: string) {
return useQuery({
queryKey: [...jobKeys.detail(jobId), "execution", execId],
queryFn: () => jobsApi.getExecution(jobId, execId),
refetchInterval: (query) => {
const state = query.state.data?.data?.state;
return state === "running" || state === "queued" ? 3_000 : false;
},
});
}
11.7 Error Handling
API errors follow the standard response model (Section 10.1) with numeric error codes. The frontend maps these codes to user-friendly messages and appropriate UI treatments.
Error display strategy:
| Error Code Range | UI Treatment |
|---|---|
| 1000 (validation) | Inline field-level errors on the form, highlighted with details[].field mapping |
| 1030 (not found) | Redirect to list page with toast notification |
| 1050–1052 (conflicts) | Toast notification with message text and suggested action |
| 1010–1020 (auth/permission) | Redirect to login (1010/1011) or show "insufficient permissions" banner (1020) |
| 2000–2007 (job state) | Toast notification with specific guidance |
| 3000–3003 (connection test) | Inline display in connection test result panel |
| 4000–4013 (key errors) | Inline form errors or toast depending on context |
| 7001–7003 (archive security) | Step failure detail in execution view |
| 5000–5004 (monitor errors) | Toast notification |
| 1099 (internal) | Generic error banner with correlation reference from message |
Error utility:
// lib/utils/errors.ts
import { toast } from "sonner"; // shadcn/ui compatible toast
import type { ApiError } from "@/lib/api/types";
export function handleApiError(error: ApiError, router?: AppRouterInstance) {
if (error.code === 1010 || error.code === 1011) {
// Token expired or invalid — force re-auth
msalInstance.loginRedirect(loginRequest);
return;
}
if (error.code === 1030 && router) {
toast.error(error.message);
router.back();
return;
}
if (error.code === 1000 && error.details) {
// Validation — handled by form, not toast
return;
}
// Default: show toast
toast.error(error.message);
}
11.8 Role-Based UI
The frontend reads roles from the Entra ID token and conditionally renders UI elements. This is purely cosmetic — the API enforces authorization regardless.
RoleGate component:
// components/shared/RoleGate.tsx
import { useAuth } from "@/lib/auth/useAuth";
type Role = "Admin" | "Operator" | "Viewer";
interface RoleGateProps {
allowed: Role[];
children: React.ReactNode;
fallback?: React.ReactNode; // Optional: show something else for insufficient role
}
export function RoleGate({ allowed, children, fallback = null }: RoleGateProps) {
const { roles } = useAuth();
const hasAccess = roles.some((role) => allowed.includes(role));
return hasAccess ? <>{children}</> : <>{fallback}</>;
}
Usage examples:
// Only Admin sees "Create Connection" button
<RoleGate allowed={["Admin"]}>
<Button onClick={() => router.push("/connections/new")}>
New Connection
</Button>
</RoleGate>
// Admin and Operator see "Execute" button
<RoleGate allowed={["Admin", "Operator"]}>
<Button onClick={() => executeJob.mutate(job.id)}>
Execute Now
</Button>
</RoleGate>
// FIPS override toggle — Admin only, with warning
<RoleGate allowed={["Admin"]}>
<FipsOverrideBanner connectionId={connection.id} />
</RoleGate>
11.9 Key UI Components
11.9.1 DataTable
A generic, reusable table component used across all list pages. Built on shadcn/ui's Table with integrated pagination, sorting, and filtering.
Features:
- Column definitions with sortable flag, custom cell renderers
- Controlled pagination synced with URL query parameters (
?page=2&pageSize=25) - Sort state synced with URL (
?sort=name:asc) - Filter controls rendered above the table per resource type
- Loading skeleton state while data is fetching
- Empty state with illustration and call-to-action
- Row actions dropdown (view, edit, delete, execute)
Pagination synced with URL:
// lib/hooks/usePagination.ts
import { useSearchParams, useRouter } from "next/navigation";
export function usePagination(defaults = { page: 1, pageSize: 25 }) {
const searchParams = useSearchParams();
const router = useRouter();
const page = Number(searchParams.get("page")) || defaults.page;
const pageSize = Number(searchParams.get("pageSize")) || defaults.pageSize;
const sort = searchParams.get("sort") || undefined;
const setPage = (newPage: number) => {
const params = new URLSearchParams(searchParams.toString());
params.set("page", String(newPage));
router.push(`?${params.toString()}`);
};
return { page, pageSize, sort, setPage };
}
11.9.2 Job Builder
The most complex UI component. A multi-step wizard for creating and editing jobs.
Sections:
- Basics — Name, description, enabled toggle, tags
- Steps — Ordered list of steps. Each step has a type dropdown (populated from
/api/v1/step-types). Selecting a type renders a dynamic form generated from the step type'sconfigurationSchema(JSON Schema withuiHintextensions). Steps are reorderable via drag-and-drop. - Failure Policy — Policy type selector, retry count, backoff configuration
- Schedule — Optional cron expression builder with human-readable preview, or one-shot datetime picker
- Review — Summary of the complete job configuration before save
Dynamic step configuration rendering:
The step type registry API (Section 10.12) returns a JSON Schema for each step type with custom uiHint extensions. The StepConfigurator component interprets these hints to render appropriate inputs:
uiHint | Rendered Control |
|---|---|
connection-picker | Connection dropdown (filtered by protocol if schema specifies) |
key-picker | PGP/SSH key dropdown (filtered by status: active only) |
file-pattern | Text input with glob pattern preview |
path | Text input with path autocomplete (if connection supports listing) |
password | Password input with show/hide toggle |
| (none) | Inferred from JSON Schema type: string → text input, boolean → switch, number → number input, enum → select |
11.9.3 Execution Timeline
A visual timeline component displayed on the job execution detail page. Shows each step as a horizontal bar with state coloring, duration, bytes processed, and error details if failed.
Step 1: Download from Partner ████████████████░░░░░ 12.4s 3.2 MB ✓ Completed
Step 2: Decrypt PGP files ██████████░░░░░░░░░░░ 6.1s 3.1 MB ✓ Completed
Step 3: Decompress ZIP ████████████████████░ 18.7s 15.8 MB ✓ Completed
Step 4: Upload to Internal SFTP █████░░░░░░░░░░░░░░░░ FAILED after 4.2s
Error: Connection refused (host: internal-sftp.corp.com:22)
11.10 Theming
Courier uses the shadcn/ui theming system with CSS custom properties. Light and dark modes are supported via next-themes, with the user's preference persisted in localStorage.
Design tokens (defined in globals.css):
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 222.2 84% 4.9%;
--primary: 222.2 47.4% 11.2%;
--primary-foreground: 210 40% 98%;
--destructive: 0 84.2% 60.2%;
--muted: 210 40% 96.1%;
--accent: 210 40% 96.1%;
--border: 214.3 31.8% 91.4%;
--ring: 222.2 84% 4.9%;
--radius: 0.5rem;
}
.dark {
--background: 222.2 84% 4.9%;
--foreground: 210 40% 98%;
/* ... dark variants */
}
}
Status colors (consistent across all entity state badges):
| State | Color | Usage |
|---|---|---|
| Active / Completed / Enabled | Green | Jobs, connections, monitors, keys, executions |
| Running / Queued | Blue | Executions |
| Paused | Yellow | Executions, monitors |
| Disabled / Retired | Gray | Jobs, connections, monitors, keys |
| Failed / Error / Revoked | Red | Executions, monitors, keys |
| Expiring | Orange | Keys approaching expiration |
11.11 Build & Deployment
Build:
# Install dependencies
npm ci
# Build standalone server
npm run build # next build → outputs to .next/standalone/
# Contents of .next/standalone/:
# ├── server.js ← self-contained Node.js server
# ├── .next/static/... ← JS/CSS bundles (copied separately in Docker)
# ├── public/... ← static assets (copied separately in Docker)
# └── node_modules/... ← minimal production dependencies
Deployment: The .next/standalone/ directory is a self-contained Node.js server. Run with node server.js — no npm start or full node_modules required.
| Environment | Hosting | API URL |
|---|---|---|
| Development | next dev (local via Aspire) | http://localhost:5000/api/v1 |
| Staging | Container App (Node.js standalone) | https://courier-staging.corp.com/api/v1 |
| Production | Container App (Node.js standalone) | https://courier.corp.com/api/v1 |
Routing: The standalone Node.js server handles all client-side routing natively — no nginx SPA fallback or try_files configuration needed.