diff --git a/documentation/07-firmy-a-prava.md b/documentation/07-firmy-a-prava.md index b5cbfdd..b7b5164 100644 --- a/documentation/07-firmy-a-prava.md +++ b/documentation/07-firmy-a-prava.md @@ -45,6 +45,30 @@ Posilaji se jako query: `?scope=tenant&tenantId=tnt_automia`. U `tenant` a `mine` je potreba vedet **kterou** firmu, protoze uzivatel jich muze mit vic. Bez `tenantId` se pouzije prvni. +## Prepinac firmy je nad celym portalem + +Vybrana firma je **jedna hodnota pro cely dashboard** (`web/src/lib/tenant.ts`), +ne stav jedne stranky. Prepinac je v hlavicce nad obsahem a `apiFetch` doplnuje +`tenantId` do kazdeho dotazu na `/api/dashboard`. + +Doplnuje se to v API vrstve schvalne, ne na strankach. Kdyz si to mela pridavat +kazda stranka sama, vetsina na to zapomnela a server sahnul po vychozi firme - +uzivatel se prepnul na LogiTrans a v Lidech koukal na lidi Automie. To neni +nepohodli, to jsou cizi data pod hlavickou jine firmy. + +Vyjimky, kdy se nedoplnuje: + +| Kdy | Proc | +| --------------------------- | -------------------------------------------------------- | +| Cesta uz `tenantId` nese | proklik z widgetu na konkretni firmu ma prednost | +| `scope=all` | pohled pres vsechny firmy, jedna firma tam nic neznamena | +| Neni vybrano | server pouzije vychozi firmu uctu | +| Cesta mimo `/api/dashboard` | prihlaseni a verejny web s firmou nepracuji | + +Volba prezije obnoveni stranky (localStorage). Kdyz ulozena firma uz mezi +`access.tenants` neni - clovek z ni odesel, prihlasil se nekdo jiny na tomtez +pocitaci - spadne se na vychozi firmu uctu, aby kazdy dotaz nekoncil na 403. + ## Server je autorita Vsechno rozhoduje `src/data/access.ts`, jedno misto pro cely portal. Kdyby se to @@ -125,6 +149,13 @@ podle toho, ktera firma je prave zvolena. ## Co chybi +**Prava se scitaji pres vsechny firmy, neprepocitavaji se podle vybrane.** +`permissionsOf(user)` vezme role ze vsech clenstvi dohromady. Kdo je spravce +v jedne firme a resitel v druhe, ma prava spravce i po prepnuti do te druhe. +Data se tim neprolomi - server je porad filtruje podle firmy - ale tlacitka +ukazuji vic, nez by mela. Napravit to znamena predat firmu do `permissionsOf` +a prepocitat cache za dvojici uzivatel a firma. + | Chybi | Poznamka | | ------------------------- | ------------------------------------------------ | | Sprava clenstvi z portalu | memberships jdou zmenit jen v kodu | diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index 1c8a90d..e21f339 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -2,6 +2,37 @@ Nejnovejsi nahore. +## 2026-08-26 - prepinac firmy plati pro cely portal + +Prepinac byl stav uvnitr stranky Prehled. Prepnuti na LogiTrans zmenilo prehled +a nic jineho: ostatni stranky volaly API bez `tenantId` a server sahnul po +vychozi firme, takze v Lidech zustali lide Automie. To neni nepohodli, to jsou +cizi data pod hlavickou jine firmy. + +### Zmeneno + +- **Vybrana firma je jedna hodnota pro cely dashboard** + (`web/src/lib/tenant.ts`), ne stav stranky. Prezije obnoveni stranky. +- **`tenantId` doplnuje `apiFetch`**, ne jednotlive stranky. Kdyz si to mela + pridavat kazda stranka sama, vetsina na to zapomnela - a zapomnetlivost byla + prave ta chyba. Nedoplnuje se, kdyz cesta uz firmu nese (proklik z widgetu), + u `scope=all` a mimo `/api/dashboard`. +- **`useApiQuery` se pri prepnuti zepta znovu.** Bez toho by na strance zustala + cisla predchozi firmy, dokud by ji nekdo neobnovil. +- **Prepinac je v hlavicce nad obsahem.** Driv byl v Prehledu, ted plati + viditelne pro vsechno. Nahradil i text, ktery ukazoval natvrdo prvni firmu + ze seznamu bez ohledu na to, co bylo vybrane. +- **Ulozena firma, do ktere uzivatel uz nepatri, spadne na vychozi.** Jinak by + po odchodu z firmy koncil kazdy dotaz na 403. + +### Zjisteno u toho + +- **Prava se scitaji pres vsechny firmy**, neprepocitavaji se podle vybrane. + Kdo je spravce v jedne firme a resitel v druhe, ma po prepnuti porad prava + spravce. Data se tim neprolomi, server je filtruje dal podle firmy, ale + tlacitka ukazuji vic, nez by mela. Zapsane v + [07-firmy-a-prava.md](07-firmy-a-prava.md), sekce Co chybi. + ## 2026-08-26 - helpdesk: pozadavek, ktery vidi zadavatel i resitel Zakaznik nemel jak poslat pozadavek. Ticket pritom patri jedne firme, kdezto diff --git a/web/src/components/dashboard/DashboardLayout.tsx b/web/src/components/dashboard/DashboardLayout.tsx index 41b47ef..72f7c28 100644 --- a/web/src/components/dashboard/DashboardLayout.tsx +++ b/web/src/components/dashboard/DashboardLayout.tsx @@ -25,6 +25,7 @@ import { EventToasts } from '@/components/dashboard/EventToasts'; import { LiveIndicator } from '@/components/dashboard/LiveIndicator'; import { Logo } from '@/components/layout/Logo'; import { cn } from '@/lib/cn'; +import { setActiveTenant, useActiveTenant } from '@/lib/tenant'; import { useApiQuery } from '@/lib/useApiQuery'; import type { Access } from '@/types/dashboard'; @@ -64,6 +65,57 @@ export function DashboardLayout() { } /** Kratky popis pristupu do topbaru. Uzivatel musi poznat, co vlastne vidi. */ +/** + * Prepinac firmy. + * + * Stoji **nad celym dashboardem**, ne uvnitr jedne stranky. Driv byl v Prehledu + * a tykal se jen jeho: uzivatel se prepnul na jinou firmu a v Lidech dal koukal + * na lidi te puvodni. Vybrana firma je proto jedna hodnota pro cely portal + * a `apiFetch` ji dava do kazdeho dotazu. + * + * Kdo ma jednu firmu, vidi jen jeji nazev - vyber z jedne moznosti neni vyber. + */ +function TenantSwitch({ access }: { access: Access | null }) { + const activeTenant = useActiveTenant(); + const tenants = access?.tenants ?? []; + + /* + * Dorovnani po nacteni. Ulozena volba muze byt firma, do ktere uz uzivatel + * nepatri (odebrali ho, prihlasil se nekdo jiny na tomtez pocitaci) - pak by + * kazdy dotaz koncil na 403. Radsi se spadne na vychozi firmu uctu. + */ + useEffect(() => { + if (tenants.length === 0) return; + if (activeTenant && tenants.some((tenant) => tenant.id === activeTenant)) return; + setActiveTenant(access?.defaultTenantId ?? tenants[0]?.id ?? null); + }, [tenants, activeTenant, access?.defaultTenantId]); + + const current = tenants.find((tenant) => tenant.id === activeTenant); + + if (tenants.length <= 1) { + return ( +

+ {current?.name ?? tenants[0]?.name ?? 'Klientský portál'} +

+ ); + } + + return ( + + ); +} + function accessLabel(user: AuthUser | null, access: Access | null): string { if (user?.platformAdmin) return 'Správce platformy, vidí všechny firmy'; const count = access?.tenants.length ?? 0; @@ -201,9 +253,7 @@ function DashboardShell() {
-

- {access.data?.tenants[0]?.name ?? 'Klientský portál'} -

+

{accessLabel(user, access.data)}

diff --git a/web/src/components/dashboard/NewTicketDialog.tsx b/web/src/components/dashboard/NewTicketDialog.tsx index 68de024..2767115 100644 --- a/web/src/components/dashboard/NewTicketDialog.tsx +++ b/web/src/components/dashboard/NewTicketDialog.tsx @@ -57,13 +57,10 @@ function Field({ export function NewTicketDialog({ open, - tenantId, onClose, onCreated, }: { open: boolean; - /** Firma, do ktere ticket patri. Posila se na server, ne se vybira ve formulari. */ - tenantId: string | null; onClose: () => void; onCreated: () => void; }) { @@ -87,15 +84,18 @@ export function NewTicketDialog({ if (!open) return; setError(null); - const query = tenantId ? `?tenantId=${encodeURIComponent(tenantId)}` : ''; - // Typy i řešitelé jsou vlastnost firmy, proto se načítají s jejím ID. - apiFetch<{ items: TicketTypeOption[] }>(`/api/dashboard/settings/ticket-types${query}`) + /* + Typy i řešitelé jsou vlastnost firmy. Její ID se sem nepředává - doplní + ho `apiFetch` podle přepínače nad dashboardem, takže se nemůže rozejít + s tím, co má uživatel právě vybrané. + */ + apiFetch<{ items: TicketTypeOption[] }>('/api/dashboard/settings/ticket-types') .then((response) => setTypes(response.items)) .catch(() => setTypes([])); - apiFetch(`/api/dashboard/people${query}`) + apiFetch('/api/dashboard/people') .then(setPeople) .catch(() => setPeople(null)); - }, [open, tenantId]); + }, [open]); function reset() { setSubject(''); @@ -121,8 +121,7 @@ export function NewTicketDialog({ setSaving(true); setError(null); try { - const query = tenantId ? `?tenantId=${encodeURIComponent(tenantId)}` : ''; - await apiFetch(`/api/dashboard/tickets${query}`, { + await apiFetch('/api/dashboard/tickets', { method: 'POST', body: JSON.stringify({ subject: subject.trim(), diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 866f22a..910a5f6 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -3,6 +3,8 @@ * aby se autorizace a chybove hlaseni resily na jednom miste. */ +import { withTenant } from '@/lib/tenant'; + const TOKEN_KEY = 'automia.token'; /** @@ -79,7 +81,16 @@ export async function apiFetch(path: string, options: RequestOptions = {}): P const { body, auth = true, headers, ...rest } = options; const token = auth ? getToken() : null; - const response = await fetch(apiUrl(path), { + /* + * Vybrana firma se doplnuje **tady**, ne na strankach. + * + * Kdyz si ji mela pridavat kazda stranka sama, vetsina na to zapomnela + * a server sahnul po vychozi firme - uzivatel se prepnul na Logitrans + * a v Lidech koukal na lidi Automie. Jedno misto to resi natrvalo. + */ + const target = withTenant(path); + + const response = await fetch(apiUrl(target), { ...rest, headers: { ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}), diff --git a/web/src/lib/tenant.ts b/web/src/lib/tenant.ts new file mode 100644 index 0000000..0c51263 --- /dev/null +++ b/web/src/lib/tenant.ts @@ -0,0 +1,90 @@ +/** + * Firma, za kterou se cely portal kresli. + * + * **Je to jedna hodnota pro cely dashboard, ne stav jedne stranky.** Driv byl + * prepinac uvnitr Prehledu, takze prepnuti na Logitrans zmenilo prehled a nic + * jineho - v Lidech zustali lide Automie. Ostatni stranky volaly API bez + * `tenantId` a server sahnul po vychozi firme. To neni nepohodli, to je cizi + * data pod hlavickou jine firmy. + * + * Proto je hodnota **mimo React**: `apiFetch` neni komponenta a musi se k ni + * dostat taky. Doplnuje `tenantId` do kazdeho dotazu na `/api/dashboard`, takze + * na to nejde na zadne strance zapomenout - a prave zapomnetlivost byla ta chyba. + */ + +import { useEffect, useState } from 'react'; + +const STORAGE_KEY = 'automia.tenant'; + +let activeTenantId: string | null = null; +const listeners = new Set<(tenantId: string | null) => void>(); + +/** Naposledy vybrana firma. Prezije obnoveni stranky. */ +function restore(): string | null { + try { + return localStorage.getItem(STORAGE_KEY); + } catch (err) { + console.warn('[tenant] localStorage neni dostupne:', err); + return null; + } +} + +activeTenantId = restore(); + +export function getActiveTenant(): string | null { + return activeTenantId; +} + +/** + * Prepne firmu. Nic se nevaliduje tady - co uzivatel smi, rika server + * v `access.tenants` a podle nej se nabidka kresli. + */ +export function setActiveTenant(tenantId: string | null): void { + if (tenantId === activeTenantId) return; + activeTenantId = tenantId; + + try { + if (tenantId) localStorage.setItem(STORAGE_KEY, tenantId); + else localStorage.removeItem(STORAGE_KEY); + } catch (err) { + console.warn('[tenant] volbu firmy nelze ulozit:', err); + } + + for (const listener of listeners) listener(tenantId); +} + +/** Prihlasi se k odberu zmen. Vraci funkci na odhlaseni. */ +export function onTenantChange(listener: (tenantId: string | null) => void): () => void { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +/** Vybrana firma jako stav komponenty. Prekresli se pri kazdem prepnuti. */ +export function useActiveTenant(): string | null { + const [tenantId, setTenantId] = useState(getActiveTenant); + useEffect(() => onTenantChange(setTenantId), []); + return tenantId; +} + +/** + * Doplni do cesty vybranou firmu. + * + * Nedoplnuje se, kdyz: + * - cesta uz `tenantId` nese. Stranka, ktera si firmu urcuje sama (proklik + * z widgetu na konkretni firmu), ma prednost. + * - jde o pohled pres vsechny firmy (`scope=all`). Tam by to nic neznamenalo. + * - neni vybrano. Server pak pouzije vychozi firmu uctu, jako driv. + */ +export function withTenant(path: string): string { + if (!activeTenantId) return path; + if (!path.startsWith('/api/dashboard')) return path; + + const [base, search = ''] = path.split('?'); + const params = new URLSearchParams(search); + if (params.has('tenantId') || params.get('scope') === 'all') return path; + + params.set('tenantId', activeTenantId); + return `${base}?${params.toString()}`; +} diff --git a/web/src/lib/useApiQuery.ts b/web/src/lib/useApiQuery.ts index 1e7a1ed..8f448cf 100644 --- a/web/src/lib/useApiQuery.ts +++ b/web/src/lib/useApiQuery.ts @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useEventStream } from '@/components/dashboard/EventStreamProvider'; import { apiFetch } from '@/lib/api'; +import { getActiveTenant, onTenantChange } from '@/lib/tenant'; import type { DashboardEventType } from '@/types/events'; export interface QueryState { @@ -37,9 +38,17 @@ export function useApiQuery(path: string, options: QueryOptions = {}): QueryS const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [tick, setTick] = useState(0); + /* + * Firma je soucasti kazdeho dotazu (doplnuje ji `apiFetch`), takze jeji + * zmena znamena jina data. Bez tehle zavislosti by na strance zustala + * cisla predchozi firmy, dokud by ji nekdo neobnovil. + */ + const [tenantId, setTenantId] = useState(getActiveTenant); const reload = useCallback(() => setTick((value) => value + 1), []); + useEffect(() => onTenantChange(setTenantId), []); + const enabled = options.enabled !== false; // Telo se do zavislosti effectu neda dat primo - nova reference pri kazdem // vykresleni by znamenala dotaz v nekonecne smycce. @@ -76,7 +85,7 @@ export function useApiQuery(path: string, options: QueryOptions = {}): QueryS return () => { cancelled = true; }; - }, [path, tick, bodyKey, enabled]); + }, [path, tick, bodyKey, enabled, tenantId]); // Obnoveni dat pri prichozi udalosti ze streamu. const { subscribe } = useEventStream(); diff --git a/web/src/pages/dashboard/Overview.tsx b/web/src/pages/dashboard/Overview.tsx index bd4aafd..b70a9db 100644 --- a/web/src/pages/dashboard/Overview.tsx +++ b/web/src/pages/dashboard/Overview.tsx @@ -23,7 +23,6 @@ import { cn } from '@/lib/cn'; import { useApiQuery } from '@/lib/useApiQuery'; import { usePageMeta } from '@/lib/usePageMeta'; import type { - Access, DashboardSummary, Incident, LayoutItem, @@ -59,19 +58,21 @@ export default function Overview() { usePageMeta({ title: 'Přehled - portál Automia' }); const { user } = useAuth(); - const access = useApiQuery('/api/dashboard/access'); - /** Firma, za kterou se prehled kresli. Rozlozeni se uklada pro ni. */ - const [tenantId, setTenantId] = useState(null); - const activeTenant = tenantId ?? access.data?.defaultTenantId ?? null; - const tenantQuery = activeTenant ? `?tenantId=${activeTenant}` : ''; + /* + * Firma se nevybira tady. Prepinac je nad celym dashboardem a `apiFetch` + * doplnuje `tenantId` do kazdeho dotazu - driv byl prepinac jen v teto + * strance a na ostatnich zustavala firma puvodni. Rozlozeni se tim uklada + * pro tu firmu, ktera je prave vybrana, aniz by to stranka resila. + */ - const layout = useApiQuery(`/api/dashboard/layout${tenantQuery}`); + const layout = useApiQuery('/api/dashboard/layout'); const catalog = useApiQuery>('/api/dashboard/widgets'); // Data si nacita prehled, ne jednotlive widgety. Deset dlazdic nesmi // znamenat deset stejnych dotazu. - const scopeQuery = activeTenant ? `?scope=tenant&tenantId=${activeTenant}` : ''; + // Jen pohled. Firmu doplni `apiFetch` podle prepinace nahore. + const scopeQuery = '?scope=tenant'; const summary = useApiQuery(`/api/dashboard/summary${scopeQuery}`, { refetchOn: [ 'ticket.created', @@ -230,7 +231,7 @@ export default function Overview() { setSaving(true); setSaveError(null); try { - await apiFetch(`/api/dashboard/layout${tenantQuery}`, { + await apiFetch('/api/dashboard/layout', { method: 'PUT', body: { items: draft }, }); @@ -250,7 +251,7 @@ export default function Overview() { setSaving(true); setSaveError(null); try { - const fresh = await apiFetch(`/api/dashboard/layout${tenantQuery}`, { + const fresh = await apiFetch('/api/dashboard/layout', { method: 'DELETE', }); setDraft(fresh.items); @@ -284,22 +285,6 @@ export default function Overview() {
- {/* Prepinac firmy davá smysl jen tomu, kdo jich ma vic. */} - {!editing && (access.data?.tenants.length ?? 0) > 1 && ( - - )} - {editing ? ( <>