Connect a Web App to PSI.UniData.API

How to connect a new React/TypeScript web app to the PSI UniData API — for local development and production deployment.


Two Modes of Access

The API supports two access patterns. Most developers start with dev endpoints and add authentication later.

ModeEndpointsAuth RequiredAudit TrailUse Case
Dev endpoints (/api/*/dev/*)49 endpointsNoService account onlyLocal development, pipeline scripts, prototyping
Authenticated endpoints (/api/*)All endpointsAzure AD JWTPer-user (UPN)Production web apps

Local Development (Dev Endpoints)

What you need

  1. Network access to api.progressivesurface.com (PSI network or VPN)
  2. That’s it.

Getting started

Dev endpoints require no authentication. Start fetching immediately:

// No auth needed — just fetch
const response = await fetch('https://api.progressivesurface.com/api/bom/dev/job/2242');
const bom = await response.json();

CORS

Your local dev server must run on a port already in the CORS allowlist:

OriginStatus
http://localhost:5173Allowed (Vite default)
http://localhost:3000Allowed

If you need a different port, see Adding a CORS Origin below.

Dev endpoint pattern

Every API domain has a /dev/ variant. Examples:

GET /api/bom/dev/job/{jobNumber}        — BOM explosion
GET /api/parts/dev/{partNumber}         — Part details
GET /api/parts/dev/search?q={query}     — Part search
GET /api/inventory/dev/{partNumber}/status
GET /api/obsolete-parts/dev/search?description={keyword}

Full endpoint list: see UniData API documentation.

Environment variables for dev mode

Create a .env file from the .env.example template:

# .env (local development)
VITE_API_URL=https://api.progressivesurface.com/api
VITE_USE_DEV_ENDPOINTS=true

When VITE_USE_DEV_ENDPOINTS=true, your app should use /dev/ endpoint paths and skip token acquisition.


Production Deployment (Authenticated Endpoints)

Production web apps must authenticate users via Azure AD so the API can connect to UniData as that user (preserving the audit trail).

Prerequisites

RequirementWho handles itHow to request
Entra app registrationAdam (IT admin)Submit a request with: app name, production URL(s), and redirect URI(s). See Requesting an Entra App Registration below.
CORS origin addedDeveloperPR to the API repo (see below)
User AD attributesAlready doneAll PSI users already have UniData credentials in AD
Network accessITApp must reach api.progressivesurface.com:443

Requesting an Entra App Registration

Before your app can use authenticated endpoints, it needs an app registration in Azure AD (Entra ID). Adam creates these — submit your request with the following information:

  1. Application name (e.g., “PSI Parts Dashboard”)
  2. Application type: Single-page application (SPA)
  3. Redirect URIs — every URL your app will run on:
    • http://localhost:5173 (local dev)
    • https://your-app.azurewebsites.net (Azure staging)
    • https://yourapp.progressivesurface.com (production custom domain)
  4. API permissions needed:
    • PSI.UniData.API access (user_impersonation scope) — required for all apps
    • If your app needs to check group membership (e.g., restricting features to specific departments), you’ll also need Microsoft Graph permissions like GroupMember.Read.All. Think about this up front — PSI Portal uses this for badge provisioning and calibration access.
    • If your app reads user profiles beyond basic login info, you may need User.Read or User.Read.All.
    • List any additional permissions in your request so they can be configured and admin-consented at registration time.
  5. Who will use it: all PSI users, or a specific group

What you’ll get back:

  • A Client ID for your app (GUID)
  • The Tenant ID (same for all PSI apps)
  • The API scope to request tokens for
  • Admin consent for any Graph permissions requested

Never hardcode these values in source code. Use environment variables (see below).

Adding a CORS origin for production

When your app has a production URL, add it to the API’s CORS config. This is a PR to the PSI.UniData.API repo:

File 1: src/PSI.UniData.API/appsettings.json — add to the Cors.AllowedOrigins array File 2: deploy/appsettings.Production.json — add to the Cors.AllowedOrigins array

"AllowedOrigins": [
    "https://existing-app.progressivesurface.com",
    "https://your-new-app.progressivesurface.com"
]

Push to master — GitHub Actions auto-deploys to PS-PROXY within ~60 seconds.


Authentication Setup (MSAL.js)

All PSI web apps use the same MSAL.js pattern. Here’s the standard setup.

1. Install dependencies

npm install @azure/msal-browser @azure/msal-react

2. Environment variables

Create .env.example in your repo root (with placeholder values only — never commit real IDs):

# Azure AD Configuration
VITE_AZURE_CLIENT_ID=your-client-id
VITE_AZURE_TENANT_ID=your-tenant-id
VITE_API_SCOPE=api://your-api-client-id/.default
 
# API Configuration
VITE_API_URL=https://api.progressivesurface.com/api
VITE_USE_DEV_ENDPOINTS=true

For local dev, create .env (gitignored) with the real values you received from the Entra registration.

3. MSAL config (src/auth/msalConfig.ts)

import { Configuration, LogLevel } from '@azure/msal-browser';
 
export const msalConfig: Configuration = {
  auth: {
    clientId: import.meta.env.VITE_AZURE_CLIENT_ID,
    authority: `https://login.microsoftonline.com/${import.meta.env.VITE_AZURE_TENANT_ID}`,
    redirectUri: window.location.origin,
    postLogoutRedirectUri: window.location.origin,
  },
  cache: {
    cacheLocation: 'sessionStorage',
    storeAuthStateInCookie: false,
  },
  system: {
    loggerOptions: {
      loggerCallback: (level, message, containsPii) => {
        if (containsPii) return;
        if (level === LogLevel.Error) console.error(message);
        if (level === LogLevel.Warning) console.warn(message);
      },
      logLevel: LogLevel.Warning,
    },
  },
};
 
export const apiScopes = {
  default: [import.meta.env.VITE_API_SCOPE],
};
 
export const loginRequest = {
  scopes: ['openid', 'profile', 'email'],
};

4. Auth hook (src/auth/useAuth.ts)

import { useMsal, useIsAuthenticated } from '@azure/msal-react';
import { InteractionStatus, BrowserAuthError } from '@azure/msal-browser';
import { loginRequest, apiScopes } from './msalConfig';
 
export function useAuth() {
  const { instance, accounts, inProgress } = useMsal();
  const isAuthenticated = useIsAuthenticated();
  const account = accounts[0] ?? undefined;
  const isLoading = inProgress !== InteractionStatus.None;
 
  const user = account
    ? {
        name: account.name || 'Unknown',
        email: account.username || '',
        username: account.username?.split('@')[0] || '',
      }
    : null;
 
  const login = async () => {
    if (inProgress !== InteractionStatus.None) return;
    try {
      await instance.loginRedirect(loginRequest);
    } catch (error) {
      if (error instanceof BrowserAuthError && error.errorCode === 'interaction_in_progress') {
        sessionStorage.clear();
        window.location.reload();
        return;
      }
      throw error;
    }
  };
 
  const logout = () => instance.logoutRedirect({ postLogoutRedirectUri: window.location.origin });
 
  const getAccessToken = async (): Promise<string | null> => {
    if (import.meta.env.VITE_USE_DEV_ENDPOINTS === 'true') return null;
    if (!account) return null;
 
    try {
      const response = await instance.acquireTokenSilent({ ...apiScopes, account });
      return response.accessToken;
    } catch {
      try {
        const response = await instance.acquireTokenPopup(apiScopes);
        return response.accessToken;
      } catch (err) {
        console.error('Token acquisition failed:', err);
        return null;
      }
    }
  };
 
  return { isAuthenticated, isLoading, user, account, login, logout, getAccessToken };
}

5. App bootstrap (src/main.tsx)

import { PublicClientApplication } from '@azure/msal-browser';
import { MsalProvider } from '@azure/msal-react';
import { msalConfig } from './auth/msalConfig';
 
const msalInstance = new PublicClientApplication(msalConfig);
 
msalInstance.initialize().then(() => {
  msalInstance.handleRedirectPromise().then((response) => {
    if (response?.account) {
      msalInstance.setActiveAccount(response.account);
    }
  });
 
  const root = ReactDOM.createRoot(document.getElementById('root')!);
  root.render(
    <MsalProvider instance={msalInstance}>
      <App />
    </MsalProvider>
  );
});

6. Making authenticated API calls

const { getAccessToken } = useAuth();
const useDevEndpoints = import.meta.env.VITE_USE_DEV_ENDPOINTS === 'true';
 
async function fetchBom(jobNumber: string) {
  const token = await getAccessToken();
  const path = useDevEndpoints
    ? `/bom/dev/job/${jobNumber}`
    : `/bom/job/${jobNumber}`;
 
  const response = await fetch(`${import.meta.env.VITE_API_URL}${path}`, {
    headers: token ? { Authorization: `Bearer ${token}` } : {},
  });
 
  return response.json();
}

Security Checklist

  • Never commit .env files — only .env.example with placeholders
  • Never hardcode Client IDs, Tenant IDs, or secrets in source code
  • Use sessionStorage for MSAL cache (not localStorage) to limit token exposure
  • All values from environment variables via import.meta.env.VITE_*
  • .env is in .gitignore in your repo
  • Production values are set in Azure App Service Configuration or GitHub Actions secrets, not in code
  • CORS origin is HTTPS (no HTTP in production)

Deployment Checklist

When your app is ready for production:

  • Requested Entra app registration from Adam (app name, URLs, redirect URIs)
  • Received Client ID and API scope
  • Added production CORS origin to API repo (both appsettings.json and deploy/appsettings.Production.json)
  • Set environment variables in Azure App Service or Static Web App config
  • Verified auth flow works: login token authenticated API call
  • Verified dev endpoints still work locally with VITE_USE_DEV_ENDPOINTS=true

Troubleshooting

”CORS error” in browser console

Your app’s origin is not in the API’s CORS allowlist. Add it per Adding a CORS Origin.

”401 Unauthorized” on authenticated endpoints

  • Token might be expired — check getAccessToken() is called before each request
  • Token audience might not match the API — verify VITE_API_SCOPE matches the API’s audience
  • User might not have UniData credentials in AD extensionAttribute2

”interaction_in_progress” error loop

MSAL state got stuck. The useAuth hook handles this by clearing sessionStorage and reloading. If it persists, manually clear browser session storage.

Token works locally but not in production

  • Check that the production redirect URI is registered in the Entra app registration
  • Check that VITE_USE_DEV_ENDPOINTS is NOT set to true in production config

Reference Apps

These existing PSI apps use the exact patterns above — use them as reference:

AppRepoAuth Pattern
PSI Explorerbom-explorer-webMSAL + dev endpoint toggle
Redbook Webredbook-webMSAL + useAuth hook + React Query
PSI Portalpsi-portalMSAL v5 + Entra group enforcement

See Also


Last updated: April 2026