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.
| Mode | Endpoints | Auth Required | Audit Trail | Use Case |
|---|---|---|---|---|
Dev endpoints (/api/*/dev/*) | 49 endpoints | No | Service account only | Local development, pipeline scripts, prototyping |
Authenticated endpoints (/api/*) | All endpoints | Azure AD JWT | Per-user (UPN) | Production web apps |
Local Development (Dev Endpoints)
What you need
- Network access to
api.progressivesurface.com(PSI network or VPN) - 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:
| Origin | Status |
|---|---|
http://localhost:5173 | Allowed (Vite default) |
http://localhost:3000 | Allowed |
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=trueWhen 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
| Requirement | Who handles it | How to request |
|---|---|---|
| Entra app registration | Adam (IT admin) | Submit a request with: app name, production URL(s), and redirect URI(s). See Requesting an Entra App Registration below. |
| CORS origin added | Developer | PR to the API repo (see below) |
| User AD attributes | Already done | All PSI users already have UniData credentials in AD |
| Network access | IT | App 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:
- Application name (e.g., “PSI Parts Dashboard”)
- Application type: Single-page application (SPA)
- 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)
- API permissions needed:
- PSI.UniData.API access (
user_impersonationscope) — 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.ReadorUser.Read.All. - List any additional permissions in your request so they can be configured and admin-consented at registration time.
- PSI.UniData.API access (
- 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-react2. 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=trueFor 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
.envfiles — only.env.examplewith placeholders - Never hardcode Client IDs, Tenant IDs, or secrets in source code
- Use
sessionStoragefor MSAL cache (notlocalStorage) to limit token exposure - All values from environment variables via
import.meta.env.VITE_* -
.envis in.gitignorein 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.jsonanddeploy/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_SCOPEmatches 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_ENDPOINTSis NOT set totruein production config
Reference Apps
These existing PSI apps use the exact patterns above — use them as reference:
| App | Repo | Auth Pattern |
|---|---|---|
| PSI Explorer | bom-explorer-web | MSAL + dev endpoint toggle |
| Redbook Web | redbook-web | MSAL + useAuth hook + React Query |
| PSI Portal | psi-portal | MSAL v5 + Entra group enforcement |
See Also
- UniData API Documentation — full endpoint reference
- Deploy to Azure — infrastructure and CI/CD setup
- Web App Compliance Standard — security and architecture requirements
Last updated: April 2026