mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 18:28:15 +00:00
Phase 2 of hospital onboarding & self-service plan
(docs/superpowers/plans/2026-04-06-hospital-onboarding-self-service.md
checked in here for the helix-engage repo).
Frontend foundations for the staff-portal Settings hub and 6-step setup
wizard. Backend was Phase 1 (helix-engage-server commit).
New shared components (src/components/setup/):
- wizard-shell.tsx — fullscreen layout with left step navigator, progress
bar, and Skip-for-now affordance
- wizard-step.tsx — single-step wrapper with Mark Complete + Prev/Next/
Finish navigation, completion badge
- section-card.tsx — Settings hub card with title/description/icon, links
to a section page, optional status badge mirroring setup-state
New pages:
- pages/setup-wizard.tsx — top-level /setup route, fullscreen (no AppShell),
loads setup-state from sidecar, renders the active step. Each step has a
placeholder body for now; Phase 5 swaps placeholders for real form
components from the matching settings pages. Already functional end-to-end:
Mark Complete writes to PUT /api/config/setup-state/steps/<step>, Skip
posts to /dismiss, Finish navigates to /.
- pages/team-settings.tsx — moved the existing workspace member listing out
of the old monolithic settings.tsx into its own /settings/team route. No
functional change; Phase 3 will add the invite form + role editor here.
- pages/settings-placeholder.tsx — generic "Coming in Phase X" stub used by
routes for clinics, doctors, telephony, ai, widget until those pages land.
Modified pages:
- pages/settings.tsx — rewritten as the Settings hub (the new /settings
route). Renders SectionCards in 3 groups (Hospital identity, Care
delivery, Channels & automation) with completion badges sourced from
/api/config/setup-state. The hub links to existing pages (/branding,
/rules) and to placeholder pages for the not-yet-built sections.
- pages/login.tsx — after successful login, calls getSetupState() and
redirects to /setup if wizardRequired. Failures fall through to / so an
older sidecar without the setup-state endpoint still works.
- components/layout/sidebar.tsx — collapsed the Configuration group
(Rules Engine + Branding standalone entries) into the single Settings
entry that opens the hub. Removes the IconSlidersUp import that's no
longer used.
New types and helpers (src/lib/setup-state.ts):
- SetupState / SetupStepName / SetupStepStatus types mirroring the sidecar
shape
- SETUP_STEP_NAMES constant + SETUP_STEP_LABELS map (title + description
per step) — single source of truth used by the wizard, hub, and any
future surface that wants to render step metadata
- getSetupState / markSetupStepComplete / markSetupStepIncomplete /
dismissSetupWizard / resetSetupState helpers wrapping the api-client
Other:
- lib/api-client.ts — added apiClient.put() helper for the setup-state
step update mutations (PUT was the only verb missing from the existing
get/post/graphql helpers)
- main.tsx — registered new routes:
/setup (fullscreen, no AppShell)
/settings (the hub, replaces old settings.tsx)
/settings/team (moved member listing)
/settings/clinics (placeholder, Phase 3)
/settings/doctors (placeholder, Phase 3)
/settings/telephony (placeholder, Phase 4)
/settings/ai (placeholder, Phase 4)
/settings/widget (placeholder, Phase 4)
Tested via npx tsc --noEmit and npm run build (clean, only pre-existing
chunk-size and dynamic-import warnings unrelated to this change).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
242 lines
8.3 KiB
TypeScript
242 lines
8.3 KiB
TypeScript
import { notify } from './toast';
|
|
|
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
|
|
|
class AuthError extends Error {
|
|
constructor(message = 'Authentication required') {
|
|
super(message);
|
|
this.name = 'AuthError';
|
|
}
|
|
}
|
|
|
|
const getStoredToken = (): string | null => localStorage.getItem('helix_access_token');
|
|
const getRefreshToken = (): string | null => localStorage.getItem('helix_refresh_token');
|
|
|
|
const storeTokens = (accessToken: string, refreshToken: string) => {
|
|
localStorage.setItem('helix_access_token', accessToken);
|
|
localStorage.setItem('helix_refresh_token', refreshToken);
|
|
};
|
|
|
|
const clearTokens = () => {
|
|
localStorage.removeItem('helix_access_token');
|
|
localStorage.removeItem('helix_refresh_token');
|
|
};
|
|
|
|
// Shared auth headers
|
|
const authHeaders = (): Record<string, string> => {
|
|
const token = getStoredToken();
|
|
return {
|
|
'Content-Type': 'application/json',
|
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
};
|
|
};
|
|
|
|
// Token refresh — attempts to get a new access token using the refresh token
|
|
let refreshPromise: Promise<boolean> | null = null;
|
|
|
|
const tryRefreshToken = async (): Promise<boolean> => {
|
|
// Deduplicate concurrent refresh attempts
|
|
if (refreshPromise) return refreshPromise;
|
|
|
|
refreshPromise = (async () => {
|
|
const refreshToken = getRefreshToken();
|
|
if (!refreshToken) return false;
|
|
|
|
try {
|
|
const response = await fetch(`${API_URL}/auth/refresh`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ refreshToken }),
|
|
});
|
|
|
|
if (!response.ok) return false;
|
|
|
|
const data = await response.json();
|
|
if (data.accessToken && data.refreshToken) {
|
|
storeTokens(data.accessToken, data.refreshToken);
|
|
return true;
|
|
}
|
|
return false;
|
|
} catch {
|
|
return false;
|
|
} finally {
|
|
refreshPromise = null;
|
|
}
|
|
})();
|
|
|
|
return refreshPromise;
|
|
};
|
|
|
|
// Shared response handler — extracts error message, handles 401 with auto-refresh, toasts on failure
|
|
const handleResponse = async <T>(response: Response, silent = false, retryFn?: () => Promise<Response>): Promise<T> => {
|
|
if (response.status === 401 && retryFn) {
|
|
const refreshed = await tryRefreshToken();
|
|
if (refreshed) {
|
|
const retryResponse = await retryFn();
|
|
return handleResponse<T>(retryResponse, silent);
|
|
}
|
|
clearTokens();
|
|
if (!silent) notify.error('Session expired. Please log in again.');
|
|
throw new AuthError();
|
|
}
|
|
|
|
if (response.status === 401) {
|
|
clearTokens();
|
|
if (!silent) notify.error('Session expired. Please log in again.');
|
|
throw new AuthError();
|
|
}
|
|
|
|
const json = await response.json().catch(() => null);
|
|
|
|
// Sidecar may return 400 when the underlying platform token expired — retry with refreshed token
|
|
if (!response.ok && retryFn) {
|
|
const msg = (json?.message ?? '').toLowerCase();
|
|
if (msg.includes('agent identity') || msg.includes('token') || msg.includes('unauthenticated')) {
|
|
const refreshed = await tryRefreshToken();
|
|
if (refreshed) {
|
|
const retryResponse = await retryFn();
|
|
return handleResponse<T>(retryResponse, silent);
|
|
}
|
|
clearTokens();
|
|
if (!silent) notify.error('Session expired. Please log in again.');
|
|
throw new AuthError();
|
|
}
|
|
}
|
|
|
|
if (!response.ok) {
|
|
const message = json?.message ?? json?.error ?? `Request failed (${response.status})`;
|
|
if (!silent) notify.error(message);
|
|
throw new Error(message);
|
|
}
|
|
|
|
return json as T;
|
|
};
|
|
|
|
export const apiClient = {
|
|
// Auth — no token needed, no toast on failure (login page shows inline errors)
|
|
async login(email: string, password: string) {
|
|
const response = await fetch(`${API_URL}/auth/login`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password }),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const data = await response.json().catch(() => ({}));
|
|
throw new Error(data.message ?? 'Login failed');
|
|
}
|
|
|
|
const data = await response.json();
|
|
storeTokens(data.accessToken, data.refreshToken);
|
|
return data as {
|
|
accessToken: string;
|
|
refreshToken: string;
|
|
user?: {
|
|
id?: string;
|
|
email?: string;
|
|
firstName?: string;
|
|
lastName?: string;
|
|
avatarUrl?: string;
|
|
role?: string;
|
|
platformRoles?: string[];
|
|
};
|
|
};
|
|
},
|
|
|
|
// GraphQL — all platform data queries go through this
|
|
async graphql<T>(query: string, variables?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
|
|
const token = getStoredToken();
|
|
if (!token) throw new AuthError();
|
|
|
|
const doFetch = () => fetch(`${API_URL}/graphql`, {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
body: JSON.stringify({ query, variables }),
|
|
});
|
|
|
|
let response = await doFetch();
|
|
|
|
if (response.status === 401) {
|
|
const refreshed = await tryRefreshToken();
|
|
if (refreshed) {
|
|
response = await doFetch();
|
|
} else {
|
|
clearTokens();
|
|
if (!options?.silent) notify.error('Session expired', 'Please log in again.');
|
|
throw new AuthError();
|
|
}
|
|
}
|
|
|
|
let json = await response.json();
|
|
|
|
// Platform returns 200 with UNAUTHENTICATED error when token expires — retry with refresh
|
|
const authError = json.errors?.find((e: any) => e.extensions?.code === 'UNAUTHENTICATED');
|
|
if (authError) {
|
|
const refreshed = await tryRefreshToken();
|
|
if (refreshed) {
|
|
const retryResponse = await doFetch();
|
|
json = await retryResponse.json();
|
|
} else {
|
|
clearTokens();
|
|
if (!options?.silent) notify.error('Session expired', 'Please log in again.');
|
|
throw new AuthError();
|
|
}
|
|
}
|
|
|
|
if (json.errors) {
|
|
const message = json.errors[0]?.message ?? 'GraphQL error';
|
|
if (!options?.silent) notify.error('Query failed', message);
|
|
throw new Error(message);
|
|
}
|
|
|
|
return json.data;
|
|
},
|
|
|
|
// REST — all sidecar API calls go through these
|
|
async post<T>(path: string, body?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
|
|
const doFetch = () => fetch(`${API_URL}${path}`, {
|
|
method: 'POST',
|
|
headers: authHeaders(),
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const response = await doFetch();
|
|
return handleResponse<T>(response, options?.silent, doFetch);
|
|
},
|
|
|
|
async get<T>(path: string, options?: { silent?: boolean }): Promise<T> {
|
|
const doFetch = () => fetch(`${API_URL}${path}`, {
|
|
method: 'GET',
|
|
headers: authHeaders(),
|
|
});
|
|
const response = await doFetch();
|
|
return handleResponse<T>(response, options?.silent, doFetch);
|
|
},
|
|
|
|
async put<T>(path: string, body?: Record<string, unknown>, options?: { silent?: boolean }): Promise<T> {
|
|
const doFetch = () => fetch(`${API_URL}${path}`, {
|
|
method: 'PUT',
|
|
headers: authHeaders(),
|
|
body: body ? JSON.stringify(body) : undefined,
|
|
});
|
|
const response = await doFetch();
|
|
return handleResponse<T>(response, options?.silent, doFetch);
|
|
},
|
|
|
|
// Health check — silent, no toasts
|
|
async healthCheck(): Promise<{ status: string; platform: { reachable: boolean } }> {
|
|
try {
|
|
const response = await fetch(`${API_URL}/api/health`, { signal: AbortSignal.timeout(3000) });
|
|
if (!response.ok) return { status: 'down', platform: { reachable: false } };
|
|
return response.json();
|
|
} catch {
|
|
return { status: 'down', platform: { reachable: false } };
|
|
}
|
|
},
|
|
|
|
getStoredToken,
|
|
getRefreshToken,
|
|
storeTokens,
|
|
clearTokens,
|
|
isAuthenticated: () => !!getStoredToken(),
|
|
};
|