mapdash/frontend/src/auth.ts
aaverbitskiy b6b9c27cb0 auth: Keycloak OIDC — anonymous (truncated) vs manager (full) tiers
Backend (main.py + auth.py):
- Validate Bearer JWT against the intense-sale realm JWKS (RS256, issuer
  checked, aud skipped). Missing/invalid token = anonymous, never an error,
  so the public map keeps working when Keycloak is down.
- Anonymous truncation: /api/meta hides brand/manager/status facets;
  /api/bookings blanks brand/company_name/manager/task_id/task_status
  (bars stay, one neutral colour); /api/map keeps occupied_now but empties
  booking details. Hidden facet filters are ignored for anonymous callers.
- boards stay visible to all (physical inventory: address/city/dimension).

Frontend (keycloak-js):
- Silent SSO (check-sso) — anonymous by default, Войти/Выйти button.
- Bearer token attached + refreshed on every API call.
- Anonymous UI hides Brand/Manager/Status filters, Brand/Company toggles,
  appearance menu, collisions and labels; board detail panel (Planfix
  deep-links) gated to managers.

Test manager: user / 1234567890 (realm role `manager`).
2026-08-14 11:05:47 +00:00

70 lines
2.1 KiB
TypeScript

// Keycloak (OIDC) integration. The app is anonymous by default; logging in as a
// user with the realm role `manager` unlocks the full data view. Everything here
// degrades gracefully: if Keycloak is unreachable, initAuth() resolves anyway and
// the app stays in the anonymous tier.
import Keycloak from 'keycloak-js';
const keycloak = new Keycloak({
url: 'https://keycloak.intense-sale.ru',
realm: 'intense-sale',
clientId: 'mapdash',
});
let manager = false;
function computeManager(): boolean {
const roles = (keycloak.tokenParsed as { realm_access?: { roles?: string[] } } | undefined)
?.realm_access?.roles ?? [];
return keycloak.authenticated === true && roles.includes('manager');
}
/**
* Initialise Keycloak with a silent SSO check (no forced login). Never rejects —
* on any failure we log and continue as an anonymous visitor.
*/
export async function initAuth(): Promise<void> {
try {
await keycloak.init({
onLoad: 'check-sso',
silentCheckSsoRedirectUri: window.location.origin + '/silent-check-sso.html',
pkceMethod: 'S256',
});
manager = computeManager();
} catch (e) {
console.warn('Keycloak init failed — continuing as anonymous', e);
manager = false;
}
}
export function isManager(): boolean {
return manager;
}
export function isAuthenticated(): boolean {
return keycloak.authenticated === true;
}
export function login(): void {
// Return to the current view (filters in the URL) after login.
keycloak.login({ redirectUri: window.location.href });
}
export function logout(): void {
keycloak.logout({ redirectUri: window.location.origin });
}
/**
* Authorization header for API calls. Refreshes the token when it is close to
* expiry. Returns an empty object for anonymous callers (backend then serves the
* truncated tier).
*/
export async function authHeader(): Promise<Record<string, string>> {
if (!keycloak.authenticated) return {};
try {
await keycloak.updateToken(30);
} catch {
/* refresh failed — send the current token; backend falls back to anon if invalid */
}
return keycloak.token ? { Authorization: `Bearer ${keycloak.token}` } : {};
}