Skip to main content

Frontend Development

This guide covers development patterns for the VAMS React frontend, including project structure, API integration, authentication, theming, and component conventions.

Technology Stack

TechnologyVersionPurpose
React18.xUI framework
TypeScript5.xType system
Vite8.xBuild tooling
Cloudscape Design System3.xAWS UI component library
AWS Amplifyv6Authentication integration
React Routerv7Client-side routing (HashRouter)
@badgateway/oauth2-client2.xExternal OAuth2 PKCE flow

Exact dependency ranges are declared in web/package.json.

React 18

The app mounts with createRoot from react-dom/client, so React 18 APIs such as useId, useSyncExternalStore, useTransition, and useDeferredValue are available.

Project Structure

web/src/
App.tsx # Root shell, HashRouter, TopNavigation
routes.tsx # Centralized route table with React.lazy
config.ts # Static config (APP_TITLE, APP_TITLE_PREFIX, DEV_API_ENDPOINT)
synonyms.tsx # Customizable display names (Asset, Database, Comment)
index.tsx # Entry point (createRoot)

FedAuth/Auth.tsx # Dual-mode authentication orchestrator

services/ # API and data services (ONLY place to import apiClient)
APIService.ts # Main API service (~40+ exports)
apiClient.ts # Custom fetch wrapper (internal only)
appCache.ts # localStorage cache (replaces Amplify Cache)
AssetUploadService.ts
AssetVersionService.ts
FileOperationsService.ts
MetadataService.ts
MetadataSchemaService.ts

context/ # React Context providers
AssetContext.ts # Asset list state
AssetDetailContext.ts # Single asset detail with useReducer
WorkflowContext.ts # Workflow state

pages/ # Thin page wrappers (lazy-loaded)
components/ # Domain/feature components
visualizerPlugin/ # 3D/media viewer plugin system (viewers)
layout/Navigation.tsx # Left sidebar navigation
styles/theme.css # CSS custom properties for dark/light theme
utils/authTokenUtils.ts # Dual-mode token utilities

common/constants/ # Shared constants
fileFormats.ts # Preview file formats and .previewFile. pattern
featuresEnabled.ts # Feature flag name constants
actions.ts # Action name constants
permissionConstraintTypes.ts # Permission constraint type constants
constants/
uploadLimits.ts # Upload limits (part sizes, file counts, preview size)

Critical Rules

Rule 1: All API Calls Through Service Layer

All API calls must go through service-layer files in src/services/. Components and pages must never import apiClient directly.

// CORRECT -- import from a service file
import { fetchAssets, deleteAsset } from "../../services/APIService";
const result = await fetchAssets({ databaseId });

// WRONG -- never import apiClient in components or pages
import { apiClient } from "../../services/apiClient";

// WRONG -- never use fetch or axios directly
const response = await fetch("/api/databases");

The following service files are the only files that may import apiClient:

Service FileResponsibility
APIService.tsGeneral CRUD, auth, search, subscriptions, tags
AssetUploadService.tsAmazon S3 multipart upload operations
AssetVersionService.tsVersion management
FileOperationsService.tsFile operations
MetadataService.tsMetadata CRUD
MetadataSchemaService.tsSchema management

Rule 2: Cloudscape Individual Imports

Always import Cloudscape components from their individual subpaths. Barrel imports cause the entire library to be bundled.

// CORRECT -- tree-shakeable individual imports
import Button from "@cloudscape-design/components/button";
import Table from "@cloudscape-design/components/table";
import Header from "@cloudscape-design/components/header";

// WRONG -- imports entire library
import { Button, Table, Header } from "@cloudscape-design/components";

Rule 3: HashRouter for Routing

VAMS uses HashRouter, meaning all URLs use the /#/path format. This is required for Amazon CloudFront and Application Load Balancer compatibility where all paths serve the same index.html.

// CORRECT -- use React Router navigation
import { useNavigate } from "react-router-dom";
navigate("/databases/mydb/assets");

// WRONG -- do not use BrowserRouter
import { BrowserRouter } from "react-router-dom";

Deep link URL patterns (defined in routes.tsx):

RouteComponentDeep Link Format
/databases/Databases/#/databases/
/databases/:databaseId/assets/:assetIdViewAsset/#/databases/<dbId>/assets/<assetId>
/databases/:databaseId/assets/:assetId/file/*ViewFile/#/databases/<dbId>/assets/<assetId>/file/<encodedPath>

File paths must be URL-encoded (encodeURIComponent). Version parameters: ?version=<fileVersionId> or ?assetVersion=<assetVersionId> (asset version takes priority). See Web Interface - Deep Linking for the full reference.

Rule 4: Lazy Load All Pages

Every page component in routes.tsx must be lazy-loaded for route-level code splitting.

// CORRECT
const MyPage = React.lazy(() => import("./pages/MyPage"));

// WRONG -- defeats code splitting
import MyPage from "./pages/MyPage";

Rule 5: No Global State Library

VAMS uses React Context API with useReducer for shared state. Do not introduce Redux, Zustand, MobX, or any other global state library.

// CORRECT
const MyContext = createContext<MyContextType | undefined>(undefined);

// WRONG
import { createStore } from "redux";
import create from "zustand";

API Integration Pattern

API Request Routing (base domain vs. direct API Gateway)

VAMS clients (both the web application and the VAMS CLI) reach the backend through two distinct paths, and understanding the split matters for deployment fronting and CORS:

  • Bootstrap only — the base (static-website) domain /api/*: The single request the web app makes to its own origin is GET /api/amplify-config, fetched from window.location.origin (the Amazon CloudFront distribution or ALB domain). This is the one endpoint that must be reachable through the web front. The response includes the api field — the API Gateway invoke URL (stage-inclusive).
  • Everything else — directly to Amazon API Gateway: After bootstrap, apiClient sets its base URL to that api value (config.api, stored in localStorage.api_path) and sends all subsequent calls straight to the API Gateway endpoint — including secure-config, auth/*, database/*, and every data operation. These never traverse the web front.

The VAMS CLI works the same way: vamscli setup fetches /api/amplify-config from the base URL you provide, extracts the API Gateway URL, and issues all later commands directly against API Gateway.

Why this matters for ALB deployments and CORS

Under an ALB deployment the front does not proxy /api/* — it issues an HTTP redirect to the API Gateway host. Because only the bootstrap amplify-config request uses the front, only that one endpoint is fetched cross-origin (front origin → API Gateway host) and needs an Access-Control-Allow-Origin header on its response. All other calls go directly to API Gateway from the start, where the REST API's CORS (the OPTIONS preflight MOCK method and per-response CORS headers) applies uniformly. This keeps the fronting layer thin and avoids routing bulk API traffic (and CORS preflights) through the ALB redirect.

The Return Tuple Pattern

Most APIService.ts functions return [boolean, data/errorMessage] tuples:

export const fetchSomething = async ({ databaseId }: { databaseId: string }) => {
try {
const response = await apiClient.get(`database/${databaseId}/something`);
if (response.message) {
if (
response.message.indexOf("error") !== -1 ||
response.message.indexOf("Error") !== -1
) {
console.log(response.message);
return [false, response.message];
} else {
return [true, response.message];
}
} else {
return response;
}
} catch (error: any) {
console.log(error);
return [false, error?.message];
}
};

Consuming API Results

Always check the boolean flag before using the data:

const result = await fetchAssets({ databaseId });
if (result === false || result[0] === false) {
setError(result ? result[1] : "Unknown error");
return;
}
const data = result[1];
Never Assume Success

The API service functions can return false, [false, errorMessage], or [true, data]. Always check the result before processing.

Pagination

The backend uses NextToken-based pagination:

let allItems: any[] = [];
let nextToken: string | null = null;
do {
const response = await apiClient.get(endpoint, {
queryStringParameters: {
...(nextToken && { startingToken: nextToken }),
},
});
allItems = [...allItems, ...(response.items || [])];
nextToken = response.nextToken;
} while (nextToken);

Authentication System

VAMS supports two authentication modes, determined at runtime from the /api/amplify-config endpoint.

Dual Auth Architecture

The auth orchestrator in src/FedAuth/Auth.tsx selects the mode based on runtime configuration:

  • window.DISABLE_COGNITO === true -- External OAuth2 mode
  • window.COGNITO_FEDERATED === true -- Federated Cognito mode
  • Otherwise -- Standard Amazon Cognito mode

Token Utilities

Always use the dual-mode token utilities for authentication. Never access Amplify Auth directly.

import { getDualValidAccessToken, getDualAuthorizationHeader } from "../utils/authTokenUtils";

// Gets valid access token from whichever auth mode is active
const token = await getDualValidAccessToken();

// Gets authorization header for manual requests
const header = await getDualAuthorizationHeader();

State Management

Context Pattern

Follow the existing AssetDetailContext.ts pattern for new contexts:

import { createContext, useReducer } from "react";

export interface MyAction {
type: string;
payload: any;
}

export const myReducer = (state: MyState, action: MyAction): MyState => {
switch (action.type) {
case "SET_DATA":
return action.payload;
default:
return state;
}
};

export type MyContextType = {
state: MyState;
dispatch: any;
};

export const MyContext = createContext<MyContextType | undefined>(undefined);

Existing Contexts

ContextFilePurpose
AssetContextcontext/AssetContext.tsAsset list state
AssetDetailContextcontext/AssetDetailContext.tsSingle asset detail
WorkflowContextcontext/WorkflowContext.tsWorkflow state
Intentional Typos

The filenames AssetContex.ts and WorkflowContex.ts are intentional legacy names. Never rename them.

Theme System

VAMS supports dark and light themes. The default theme is dark mode.

How Theming Works

  • src/styles/theme.css defines CSS custom properties with dark/light variants
  • The .awsui-dark-mode class on <body> activates dark mode values
  • Cloudscape's applyMode() toggles the Cloudscape component dark mode
  • Users switch themes via the Settings dropdown in the top navigation
  • Theme preference is persisted to localStorage

Using Theme-Aware Styles

When adding new styles, use CSS custom properties from theme.css or Cloudscape design tokens:

@use "@cloudscape-design/design-tokens" as awsui;

.my-container {
padding: awsui.$space-l;
color: awsui.$color-text-body-default;
}
Dark Mode Compatibility

Never hardcode colors or spacing that Cloudscape provides as design tokens. Use CSS custom properties from theme.css or Cloudscape tokens to ensure dark mode compatibility.

Configuration System

Static Configuration

src/config.ts defines build-time settings through the VAMSConfig interface. Organizations should modify this file to match their branding and environment.

interface VAMSConfig {
APP_TITLE: string; // Browser tab title
APP_NAME: string; // Short name in footer and UI references
FOOTER_COPYRIGHT: string; // Footer copyright text (empty string hides footer)
CUSTOMER_LOGO?: string; // Optional custom logo URL for sidebar navigation
APP_TITLE_PREFIX: string; // Short prefix for browser tab titles (e.g. "VAMS - Databases")
DEV_API_ENDPOINT: string; // API endpoint for local development
}
FieldDefaultDescription
APP_TITLE"VAMS - Visual Asset Management System"Displayed in the browser tab and login pages
APP_NAME"Visual Asset Management System"Short name used in the footer and logo alt text
FOOTER_COPYRIGHT"(c) 2026, Amazon Web Services..."Copyright text in the page footer. Set to empty string to hide the footer entirely.
CUSTOMER_LOGOundefinedURL to a custom logo for the sidebar navigation header. Supports relative paths or absolute URLs. Leave undefined for the default VAMS logo.
APP_TITLE_PREFIX"VAMS"Short prefix used in browser tab titles (e.g. "VAMS - Databases", "VAMS - myDb - My Asset"). Used by the usePageTitle hook to set document.title.
DEV_API_ENDPOINT""API endpoint for local development. Empty string uses same origin (production default). Set to an API Gateway URL or http://localhost:8002/ for development.

Example customization:

const config: VAMSConfig = {
APP_TITLE: "My Company - Asset Manager",
APP_NAME: "My Company Asset Manager",
FOOTER_COPYRIGHT: "(c) 2026, My Company. All rights reserved.",
CUSTOMER_LOGO: "/my-company-logo.png",
APP_TITLE_PREFIX: "MyCompany",
DEV_API_ENDPOINT: "",
};

Runtime Configuration

Configuration is loaded at startup in two stages:

  1. GET /api/amplify-config -- Cached in appCache, configures Amplify v6
  2. GET /api/secure-config -- Additional config requiring authentication

Access runtime config through appCache:

import { appCache } from "../services/appCache";

const config = appCache.getItem("config");

Feature Flags

Feature flags are stored in config.featuresEnabled as an array of strings:

const config = appCache.getItem("config");
if (config?.featuresEnabled?.includes("LOCATIONSERVICES")) {
// Enable map features
}

Known feature flags:

FlagPurpose
LOCATIONSERVICESMap and geospatial features
NOOPENSEARCHDisable OpenSearch-dependent features
ALLOWUNSAFEEVALRequired for certain 3D file visualizer viewers

Display Name Customization (Synonyms)

VAMS uses a synonym system to make entity display names customizable. The file src/synonyms.tsx defines configurable display names for core entity types. Organizations can change these values to match their terminology (for example, renaming "Asset" to "Model" or "Database" to "Repository").

Available synonyms:

KeyDefaultUsage
Asset"Asset"Title case singular (headers, labels, modal titles)
Assets"Assets"Title case plural (page titles, navigation, tab labels)
asset"asset"Lowercase singular (inline text, descriptions, errors)
assets"assets"Lowercase plural (placeholders, descriptions)
Database"Database"Title case singular
Databases"Databases"Title case plural
database"database"Lowercase singular
databases"databases"Lowercase plural
Comment"Comment"Title case singular (Comments tab feature only)
Comments"Comments"Title case plural (Comments tab feature only)
comment"comment"Lowercase singular (Comments tab feature only)
comments"comments"Lowercase plural (Comments tab feature only)
Web UI Only

Synonyms affect only the web interface display text. They do not change API request or response payloads, backend behavior, CLI output, or any data stored in Amazon DynamoDB. The backend API and CLI will continue to use the original terms ("Asset", "Database", "Comment") regardless of synonym configuration.

Usage rules:

  1. All user-visible text that mentions "Asset", "Database", or "Comment" (the Comments tab feature) must use the Synonyms import instead of hardcoded strings.
  2. Match the casing of the original text to the correct synonym key (Synonyms.Asset for title case, Synonyms.asset for lowercase).
  3. Do not use Synonyms for API body values, variable names, property names, type names, route paths, or internal identifiers -- only for display text.
  4. Comment synonyms apply only to the Comments tab feature (adding/editing/deleting comments on assets). The word "Comment" in other contexts (like version comment fields) should remain hardcoded.
import Synonyms from "../../synonyms";

// Headers and labels -- use title case synonyms
<Header>{Synonyms.Assets}</Header>
<FormField label={`${Synonyms.Asset} Name`}>

// Descriptions and inline text -- use lowercase synonyms
<p>{`Select a ${Synonyms.database} to continue`}</p>
placeholder={`Search ${Synonyms.assets} and files...`}

// Error messages
setError(`${Synonyms.Asset} not found`);

// Template literals for compound text
header={`Create New ${Synonyms.Asset} Version`}
description={`Please provide a reason for archiving this ${Synonyms.asset}.`}

// WRONG -- do not use Synonyms in API body values
const body = { entityName: "Asset" }; // Keep hardcoded for API

Shared Constants

Shared literal values (file format lists, file-name patterns, upload limits) are defined once in dedicated constants files. Always import these constants instead of redefining the literal values in components or pages, so all usages can be found and changed in one place.

FileDefines
common/constants/fileFormats.tspreviewFileFormats (allowed preview extensions), PREVIEW_FILE_PATTERN
constants/uploadLimits.tsUpload part sizes, file counts, retry attempts, MAX_PREVIEW_FILE_SIZE
common/constants/featuresEnabled.tsFeature flag name constants
common/constants/authRoutes.tsAllowed-API-routes cache key/TTL, isApiRouteAllowed() helper
common/constants/apiKeys.tsUSER_API_KEY_MAX_EXPIRATION_DAYS (mirrors the backend models/apiKeys.py)

Preview File Constants

common/constants/fileFormats.ts is the single source of truth for the file-level preview file pattern and the allowed preview image extensions:

import { previewFileFormats, PREVIEW_FILE_PATTERN } from "../../common/constants/fileFormats";

// Check whether a file is a preview file ({baseFile}.previewFile.{ext})
const isPreview = fileName.includes(PREVIEW_FILE_PATTERN);

// Validate a preview file extension
const isAllowed = previewFileFormats.includes(fileExt);

For a file-level preview file the extension is everything after the .previewFile. marker, not the text after the last dot: model.gltf.previewFile.p.png has the extension .p.png. utils/fileExtensionValidation.ts exposes getPreviewFileExtension() and isPreviewExtensionAllowed() for this, and validateFiles() applies them to every preview file whether or not the database restricts extensions. Use those helpers rather than getFileExtension(), which reads the last dot and would accept a name the API rejects.

constants/uploadLimits.ts re-exports previewFileFormats as ALLOWED_PREVIEW_EXTENSIONS alongside the upload limit values, so upload code can import everything from one place.

Backend Mirror

The preview file pattern and allowed preview extensions are mirrored in the backend at backend/backend/common/s3PathPatterns.py (PREVIEW_FILE_PATTERN, ALLOWED_PREVIEW_FILE_EXTENSIONS). Keep the two in sync when changing them.

Adding New Pages and Components

Adding a New Page

  1. Create the page component in src/pages/:

    import React from "react";
    import MyComponent from "../components/myfeature/MyComponent";

    const MyPage: React.FC = () => <MyComponent />;
    export default MyPage;
  2. Add the lazy import and route in src/routes.tsx:

    const MyPage = React.lazy(() => import("./pages/MyPage"));

    // In routeTable array:
    {
    path: "/myfeature",
    Page: MyPage,
    active: "#/myfeature/",
    },
  3. Add a navigation item in src/layout/Navigation.tsx if needed.

The route is automatically permission-filtered via the webRoutes() API call. The backend must also allow the route in the Casbin policy.

Component Template

import React, { useState, useEffect, useCallback } from "react";
import { useParams } from "react-router-dom";
import Box from "@cloudscape-design/components/box";
import Header from "@cloudscape-design/components/header";
import SpaceBetween from "@cloudscape-design/components/space-between";
import Table from "@cloudscape-design/components/table";
import Synonyms from "../../synonyms";
import { fetchSomething } from "../../services/APIService";

const MyComponent: React.FC = () => {
const { databaseId } = useParams();
const [loading, setLoading] = useState(true);
const [items, setItems] = useState<any[]>([]);

const fetchData = useCallback(async () => {
setLoading(true);
try {
const result = await fetchSomething({ databaseId });
if (result === false || result[0] === false) {
return;
}
setItems(result[1] || result);
} finally {
setLoading(false);
}
}, [databaseId]);

useEffect(() => {
fetchData();
}, [fetchData]);

return (
<SpaceBetween size="l">
<Header variant="h1">{Synonyms.Assets}</Header>
<Table loading={loading} items={items} columnDefinitions={[]} />
</SpaceBetween>
);
};

export default MyComponent;

Content Security Policy and Inline Scripts

The VAMS web app ships with a Content Security Policy generated at deploy time by infra/lib/helper/security.ts and embedded in the static-web nested stack's response headers. The policy is permissive enough to accommodate external viewer plugins whose inline <script> blocks we cannot pre-hash, while remaining restrictive elsewhere (default-src 'none', object-src 'none', frame-ancestors 'self' for same-origin framing only, HTTPS upgrades, etc.).

script-src uses 'unsafe-inline'

script-src includes 'unsafe-inline' to allow add-on viewers (such as Physna's hosted viewer, which embeds inline <script> blocks inside its iframe HTML) to run without requiring VAMS to maintain a rolling per-version SHA allowlist. Maintaining hashes or a CSP nonce per external-viewer release is not sustainable because:

  • Viewer vendors rev their bundles frequently, which would break VAMS on every upstream publish until someone updated the allowlist.
  • Most add-on viewers inject inline scripts from a sandboxed iframe whose origin is not VAMS, and the browser will not forward a nonce across origins.

Browsers ignore 'unsafe-inline' whenever any hash or nonce source is also present, so the directive only actually takes effect in this deployment because neither is used.

app.webUi.allowUnsafeEvalFeatures

Controls whether 'unsafe-eval' is added to script-src. Some viewer plugins depend on WebAssembly runtimes that compile JavaScript at runtime and require this relaxation — notably the Needle USD WASM viewer, the SuperSplat Editor, the ThatOpen IFC BIM viewer, and the Three.js CAD viewer for STEP/IGES/BREP files. When this flag is false (the default), those viewers will not initialize. Enable only if your security posture allows it.

Add-on origins

When add-ons that embed external content are enabled, CSP is extended dynamically. For example, with the Physna add-on enabled the configured apiBaseEndpoint origin is added to both frame-src (so the Physna hosted viewer can be loaded in an iframe) and connect-src (so any auxiliary fetches to Physna succeed). See generateContentSecurityPolicy() in infra/lib/helper/security.ts for the full set of conditional rules.

Anti-Patterns

Anti-PatternCorrect Approach
Import apiClient in componentsImport from APIService.ts or other service files
Barrel import from @cloudscape-design/componentsUse individual subpath imports
Use BrowserRouterUse HashRouter
Eagerly import page componentsUse React.lazy() in routes.tsx
Add Redux, Zustand, or MobXUse React Context + useReducer
Use Amplify CacheUse appCache from src/services/appCache.ts
Access Amplify Auth directlyUse getDualValidAccessToken() from authTokenUtils.ts
Hardcode "Asset" or "Database" stringsUse Synonyms from src/synonyms.tsx
Use console.error for loggingUse console.log (match existing convention)

Next Steps