Courier MFT

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

TechnologyPurpose
Next.js (App Router)Framework — routing, static export, layouts
React 19+UI library
TypeScriptType safety across all frontend code
Tailwind CSSUtility-first styling
shadcn/uiComponent 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 ReactIcon library (consistent with shadcn/ui)
React Hook FormForm state management (job builder, connection editor, key import)
ZodSchema validation (client-side, mirrors FluentValidation rules)
date-fnsDate formatting and manipulation
next-themesDark/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 to output: 'standalone' because the frontend uses ~17 dynamic [id] routes, all of which are "use client" components. Next.js output: 'export' requires generateStaticParams on 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:

FeatureStatusCourier'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 usedAll mutations go through the REST API via TanStack Query's useMutation
Route Handlers (app/api/...)Not usedThe .NET API is the only backend; the frontend never proxies or transforms requests
Middleware (middleware.ts)Not usedAuth 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 optimizationNot usedStandard <img> tags. Courier's UI is data-heavy, not image-heavy — no optimization needed.
Incremental Static Regeneration (ISR)Not usedAll 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:

  1. The browser generates a random code_verifier (128-bit entropy) and derives a code_challenge (SHA-256 hash)
  2. The authorization request sends the code_challenge to Entra ID
  3. Entra ID returns an authorization code to the redirect URI
  4. The browser exchanges the code + code_verifier for tokens — Entra ID verifies the challenge, preventing authorization code interception attacks
  5. 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 RangeUI 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:

  1. Basics — Name, description, enabled toggle, tags
  2. 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's configurationSchema (JSON Schema with uiHint extensions). Steps are reorderable via drag-and-drop.
  3. Failure Policy — Policy type selector, retry count, backoff configuration
  4. Schedule — Optional cron expression builder with human-readable preview, or one-shot datetime picker
  5. 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:

uiHintRendered Control
connection-pickerConnection dropdown (filtered by protocol if schema specifies)
key-pickerPGP/SSH key dropdown (filtered by status: active only)
file-patternText input with glob pattern preview
pathText input with path autocomplete (if connection supports listing)
passwordPassword 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):

StateColorUsage
Active / Completed / EnabledGreenJobs, connections, monitors, keys, executions
Running / QueuedBlueExecutions
PausedYellowExecutions, monitors
Disabled / RetiredGrayJobs, connections, monitors, keys
Failed / Error / RevokedRedExecutions, monitors, keys
ExpiringOrangeKeys 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.

EnvironmentHostingAPI URL
Developmentnext dev (local via Aspire)http://localhost:5000/api/v1
StagingContainer App (Node.js standalone)https://courier-staging.corp.com/api/v1
ProductionContainer 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.