diff --git a/Dockerfile b/Dockerfile
index 61e2474..ab346c5 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -3,6 +3,7 @@ WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY main.py .
+COPY auth.py .
COPY static ./static
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
diff --git a/auth.py b/auth.py
new file mode 100644
index 0000000..b09e7bf
--- /dev/null
+++ b/auth.py
@@ -0,0 +1,59 @@
+"""Keycloak JWT validation for role-based response truncation.
+
+The map dashboard has two tiers:
+ * anonymous — no token (or an invalid/expired one). Sees a truncated view.
+ * manager — a valid Keycloak access token carrying the realm role `manager`.
+ Sees the full data.
+
+Design rule: a MISSING or INVALID token is NOT an error — it simply means the
+caller is anonymous. So role_from_authorization() never raises; the worst case
+degrades to the anonymous tier. This keeps the public map working even when
+Keycloak is unreachable or a token has expired.
+
+Validation checks the signature (RS256, keys fetched from the realm JWKS with
+in-process caching + automatic rotation on unknown kid) and the issuer. The
+access token's audience is `account` (Keycloak default), so we intentionally do
+NOT verify `aud`; authorization is decided by the presence of the realm role.
+"""
+
+import os
+
+import jwt
+from jwt import PyJWKClient
+
+# Public realm URL — matches the `iss` Keycloak stamps into the token, so issuer
+# validation lines up without extra config. Overridable via env for other envs.
+KC_REALM_URL = os.environ.get(
+ "KC_REALM_URL", "https://keycloak.intense-sale.ru/realms/intense-sale"
+)
+KC_ISSUER = KC_REALM_URL
+JWKS_URL = f"{KC_REALM_URL}/protocol/openid-connect/certs"
+MANAGER_ROLE = "manager"
+
+# PyJWKClient caches signing keys and re-fetches when it sees an unknown `kid`
+# (key rotation), so we build it once at import time.
+_jwk_client = PyJWKClient(JWKS_URL)
+
+
+def role_from_authorization(authorization: str | None) -> str:
+ """Return "manager" for a valid token with the manager role, else "anonymous".
+
+ Never raises: any problem (no header, wrong scheme, bad signature, expired,
+ JWKS fetch failure) falls through to "anonymous".
+ """
+ if not authorization or not authorization.lower().startswith("bearer "):
+ return "anonymous"
+ token = authorization[7:].strip()
+ try:
+ signing_key = _jwk_client.get_signing_key_from_jwt(token)
+ claims = jwt.decode(
+ token,
+ signing_key.key,
+ algorithms=["RS256"],
+ issuer=KC_ISSUER,
+ options={"verify_aud": False},
+ )
+ except Exception:
+ return "anonymous"
+ roles = claims.get("realm_access", {}).get("roles", []) or []
+ return MANAGER_ROLE if MANAGER_ROLE in roles else "anonymous"
diff --git a/frontend/index.html b/frontend/index.html
index f752f8c..0d29f98 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -33,6 +33,7 @@
+
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 071a456..c69ff98 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.1.0",
"dependencies": {
"@fontsource/ibm-plex-sans": "^5.3.0",
+ "keycloak-js": "^26.2.4",
"open-props": "^1.7.23",
"vis-data": "^7.1.9",
"vis-timeline": "^7.7.3"
@@ -880,6 +881,15 @@
"license": "(Apache-2.0 OR MIT)",
"peer": true
},
+ "node_modules/keycloak-js": {
+ "version": "26.2.4",
+ "resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-26.2.4.tgz",
+ "integrity": "sha512-PnXpR3ubETGOt0B/Qt2lxmPbkZr5bc3vlQsOqDoTPPQsZRp7JjhTKxlJ187uWh8qJhvBab6Gsjb06a8ayOPfuw==",
+ "license": "Apache-2.0",
+ "workspaces": [
+ "test"
+ ]
+ },
"node_modules/moment": {
"version": "2.30.1",
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index dce464e..1316acf 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,6 +10,7 @@
},
"dependencies": {
"@fontsource/ibm-plex-sans": "^5.3.0",
+ "keycloak-js": "^26.2.4",
"open-props": "^1.7.23",
"vis-data": "^7.1.9",
"vis-timeline": "^7.7.3"
diff --git a/frontend/public/silent-check-sso.html b/frontend/public/silent-check-sso.html
new file mode 100644
index 0000000..6bb81c2
--- /dev/null
+++ b/frontend/public/silent-check-sso.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+
diff --git a/frontend/src/api.ts b/frontend/src/api.ts
index c3fbe7d..d193a4b 100644
--- a/frontend/src/api.ts
+++ b/frontend/src/api.ts
@@ -1,6 +1,7 @@
// Typed wrappers around the mapdash HTTP API. All network access for the app
// goes through here, so response shapes stay in one typed place.
import type { Meta, Board, Booking, Filters, MapSurface } from './types';
+import { authHeader } from './auth';
function buildQuery(f: Filters): string {
const params = new URLSearchParams({
@@ -17,7 +18,7 @@ function buildQuery(f: Filters): string {
}
async function getJson(url: string): Promise {
- const res = await fetch(url);
+ const res = await fetch(url, { headers: await authHeader() });
if (!res.ok) {
throw new Error(`Request failed: ${url} -> HTTP ${res.status}`);
}
diff --git a/frontend/src/auth.ts b/frontend/src/auth.ts
new file mode 100644
index 0000000..e1fb69d
--- /dev/null
+++ b/frontend/src/auth.ts
@@ -0,0 +1,69 @@
+// 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 {
+ 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> {
+ 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}` } : {};
+}
diff --git a/frontend/src/main.ts b/frontend/src/main.ts
index d9ee014..f42c864 100644
--- a/frontend/src/main.ts
+++ b/frontend/src/main.ts
@@ -19,6 +19,7 @@ import { loadAppearance, applyCssVars, buildAppearanceMenu, colorForStatus } fro
import { createMapView } from './map';
import { createViewModes, type ViewMode } from './viewmode';
import { escapeHtml } from './util';
+import { initAuth, isManager, isAuthenticated, login, logout } from './auth';
const el = {
search: document.getElementById('search') as HTMLInputElement,
@@ -50,8 +51,35 @@ const el = {
loadBar: document.getElementById('load-bar') as HTMLElement,
emptyResetTimeline: document.getElementById('empty-reset-timeline') as HTMLButtonElement,
emptyResetMap: document.getElementById('empty-reset-map') as HTMLButtonElement,
+ authBtn: document.getElementById('auth-btn') as HTMLButtonElement,
};
+// ---- role-based UI (anonymous vs manager) ----
+// Backend enforces truncation; here we just hide the controls that only make
+// sense for the full-data (manager) tier so the anonymous view stays clean.
+function applyRoleUi(): void {
+ const manager = isManager();
+ const hideField = (node: Element | null): void => {
+ const f = node?.closest('.field') as HTMLElement | null;
+ if (f) f.style.display = manager ? '' : 'none';
+ };
+ // Client-facing facets: Brand, Manager, Status. (Search stays — it filters by
+ // address/board code, which anonymous visitors are allowed to use.)
+ hideField(document.getElementById('brand-dropdown'));
+ hideField(document.getElementById('manager-dropdown'));
+ hideField(document.getElementById('status-dropdown'));
+ // Brand/Company toggles and the appearance (colour) menu.
+ const checks = document.querySelector('.checks') as HTMLElement | null;
+ if (checks) checks.style.display = manager ? '' : 'none';
+ const decor = document.querySelector('.decor-wrap') as HTMLElement | null;
+ if (decor) decor.style.display = manager ? '' : 'none';
+
+ // Login / logout button.
+ el.authBtn.style.display = '';
+ el.authBtn.textContent = isAuthenticated() ? 'Выйти' : 'Войти';
+ el.authBtn.onclick = () => (isAuthenticated() ? logout() : login());
+}
+
// ---- theme toggle (data-theme is set pre-paint by the inline head script) ----
function currentTheme(): 'light' | 'dark' {
return document.documentElement.getAttribute('data-theme') === 'dark' ? 'dark' : 'light';
@@ -99,6 +127,11 @@ let lastBoards: Board[] = [];
let lastBookings: Booking[] = [];
function flags(): RenderFlags {
+ // Anonymous tier: no labels (data is blanked anyway) and no collision
+ // highlighting, so every bar renders in the single neutral status colour.
+ if (!isManager()) {
+ return { withBrand: false, withCompany: false, withCollisions: false };
+ }
return {
withBrand: el.showBrand.checked,
withCompany: el.showCompany.checked,
@@ -363,6 +396,8 @@ function closeBoardPanel(): void {
boardBackdrop.hidden = true;
}
function openBoardPanel(boardId: string): void {
+ // Detail panel exposes client/brand/manager + Planfix deep-links — managers only.
+ if (!isManager()) return;
const board = lastBoards.find((b) => b.board_id === boardId);
const bookings = lastBookings
.filter((b) => b.board_id === boardId)
@@ -410,7 +445,14 @@ async function init(): Promise {
dimensionDropdown.setValues([], 'Все размеры');
managerDropdown.setValues([], 'Все менеджеры');
statusDropdown.setValues([], 'Все статусы');
+ await initAuth(); // resolve role before first data load (degrades to anon on failure)
+ applyRoleUi();
applyUrlToFilters(); // restore filters from a shared link
+ if (!isManager()) {
+ // A shared link may carry hidden facets; drop them so the anonymous view
+ // neither queries nor re-serialises Brand/Manager/Status.
+ for (const d of [brandDropdown, managerDropdown, statusDropdown]) d.clear();
+ }
rebuildDecorMenu();
view.applyLayout();
await loadData(true);
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index 7586f80..b1f9a19 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -312,6 +312,15 @@ input::placeholder { color: var(--text-muted); }
transition: border-color var(--transition), background var(--transition);
}
.theme-toggle:hover { border-color: var(--border-strong); background: var(--surface); }
+
+/* Login / logout button (mirrors the toggle styling, sized to its text label) */
+.auth-btn {
+ border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg);
+ color: var(--text); height: 34px; padding: 0 14px; font-size: 13px; font-weight: 600;
+ line-height: 1; cursor: pointer; display: inline-flex; align-items: center;
+ transition: border-color var(--transition), background var(--transition);
+}
+.auth-btn:hover { border-color: var(--border-strong); background: var(--surface); }
/* Dark theme temporarily hidden — toggle stays in the DOM, just not shown.
Re-enable by removing this rule + restoring the head script in index.html. */
.theme-toggle { display: none; }
diff --git a/main.py b/main.py
index 7e2dbda..71d98ef 100644
--- a/main.py
+++ b/main.py
@@ -1,10 +1,18 @@
import asyncio
import os
import httpx
-from fastapi import FastAPI, Query
+from fastapi import Depends, FastAPI, Header, Query
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles
+from auth import role_from_authorization
+
+
+def get_role(authorization: str | None = Header(default=None)) -> str:
+ # Resolve the caller's tier from the Bearer token. "anonymous" is the safe
+ # default (missing/invalid token); "manager" unlocks the full data view.
+ return role_from_authorization(authorization)
+
CH_HOST = os.environ.get("CLICKHOUSE_HOST", "ClickHouse")
CH_PORT = os.environ.get("CLICKHOUSE_PORT", "8123")
CH_USER = os.environ.get("CLICKHOUSE_USER", "default")
@@ -156,7 +164,11 @@ def parse_filters(city: str, dimension: str, brand: str, manager: str, status: s
@app.get("/api/meta")
-async def meta(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
+async def meta(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
+ # Anonymous tier: the Brand/Manager/Status facets are hidden, so ignore those
+ # incoming filters (a crafted request can't use them to probe hidden data).
+ if role != "manager":
+ brand = manager = status = ""
params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end)
hds = bool(params["date_start"])
hde = bool(params["date_end"])
@@ -185,6 +197,15 @@ async def meta(city: str = "", dimension: str = "", brand: str = "", manager: st
params,
),
)
+ if role != "manager":
+ # Truncated tier: never expose client-facing facet values.
+ return {
+ "cities": [r["city"] for r in cities],
+ "dimensions": [r["dimension"] for r in dimensions],
+ "brands": [],
+ "managers": [],
+ "statuses": [],
+ }
return {
"cities": [r["city"] for r in cities],
"dimensions": [r["dimension"] for r in dimensions],
@@ -195,7 +216,11 @@ async def meta(city: str = "", dimension: str = "", brand: str = "", manager: st
@app.get("/api/boards")
-async def boards(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
+async def boards(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
+ # Board inventory (address/city/dimension) is visible to everyone; anonymous
+ # just can't filter by the hidden facets.
+ if role != "manager":
+ brand = manager = status = ""
params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end)
where = build_where(city=True, dimension=True, brand=True, manager=True, status=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
sql = f"""
@@ -214,7 +239,9 @@ async def boards(city: str = "", dimension: str = "", brand: str = "", manager:
@app.get("/api/bookings")
-async def bookings(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
+async def bookings(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = "", date_start: str = "", date_end: str = ""):
+ if role != "manager":
+ brand = manager = status = ""
params = parse_filters(city, dimension, brand, manager, status, search, date_start, date_end)
where = build_where(city=True, dimension=True, brand=True, manager=True, status=True, search=True, date_start=bool(params["date_start"]), date_end=bool(params["date_end"]))
sql = f"""
@@ -236,11 +263,23 @@ async def bookings(city: str = "", dimension: str = "", brand: str = "", manager
# collapse to canonical "Фамилия Имя" names for display.
for r in rows:
r["manager"] = sorted({canon_manager(m) for m in (r.get("manager") or []) if m})
+ if role != "manager":
+ # Truncated tier: keep the occupancy bar (board_id + dates) but strip every
+ # client-facing field so the timeline shows neutral, unlabelled bars and the
+ # board panel / Planfix deep-links have nothing to reveal.
+ for r in rows:
+ r["brand"] = ""
+ r["company_name"] = ""
+ r["manager"] = []
+ r["task_id"] = ""
+ r["task_status"] = ""
return rows
@app.get("/api/map")
-async def map_data(city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = ""):
+async def map_data(role: str = Depends(get_role), city: str = "", dimension: str = "", brand: str = "", manager: str = "", status: str = "", search: str = ""):
+ if role != "manager":
+ brand = manager = status = ""
# Surfaces come from board_info (has coordinates). Occupancy is computed "as of
# today" and is intentionally NOT affected by the date-range filters — only the
# Город/Бренд/Размер/Менеджер/Поиск facets narrow which surfaces are shown.
@@ -292,13 +331,16 @@ async def map_data(city: str = "", dimension: str = "", brand: str = "", manager
for b in current:
by_key.setdefault(b["board_key"], []).append(b)
+ anon = role != "manager"
out = []
for s in surfaces:
active = by_key.get(s["board_key"], [])
out.append({
**s,
"occupied_now": len(active) > 0,
- "bookings": active,
+ # Truncated tier keeps the free/occupied marker colour but hides the
+ # balloon's booking details (company/brand/status).
+ "bookings": [] if anon else active,
})
return out
diff --git a/requirements.txt b/requirements.txt
index e5dd7aa..61352f1 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,3 +1,4 @@
fastapi==0.115.0
uvicorn==0.30.6
httpx==0.27.2
+PyJWT[crypto]==2.10.1
diff --git a/static/silent-check-sso.html b/static/silent-check-sso.html
new file mode 100644
index 0000000..6bb81c2
--- /dev/null
+++ b/static/silent-check-sso.html
@@ -0,0 +1,10 @@
+
+
+
+
+
+