Prepinac firmy plati pro cely portal, ne jen pro prehled

Prepinac byl stav uvnitr stranky Prehled, takze prepnuti na LogiTrans
zmenilo prehled a nic jineho - ostatni stranky volaly API bez tenantId
a server sahnul po vychozi firme. V Lidech tak zustali lide Automie.

- vybrana firma je jedna hodnota pro cely dashboard (web/src/lib/tenant.ts)
  a prezije obnoveni stranky
- tenantId doplnuje apiFetch, ne jednotlive stranky. Kdyz si to mela
  pridavat kazda stranka sama, vetsina na to zapomnela - a prave to byla
  ta chyba. Nedoplnuje se, kdyz cesta uz firmu nese, u scope=all
  a mimo /api/dashboard.
- useApiQuery se pri prepnuti zepta znovu, jinak by na strance zustala
  cisla predchozi firmy
- prepinac je v hlavicce nad obsahem a nahradil text, ktery ukazoval
  natvrdo prvni firmu ze seznamu bez ohledu na vyber
- ulozena firma, do ktere uzivatel uz nepatri, spadne na vychozi

Zjisteno u toho a zapsane do 07-firmy-a-prava.md: prava se scitaji pres
vsechny firmy, neprepocitavaji se podle vybrane. Data se tim neprolomi,
server je filtruje dal, ale tlacitka ukazuji vic, nez by mela.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-26 07:48:37 +02:00
co-authored by Claude Opus 5
parent a771834e57
commit 2329ccbdc1
9 changed files with 247 additions and 42 deletions
+31
View File
@@ -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 |
+31
View File
@@ -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
@@ -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 (
<p className="truncate text-sm font-semibold text-white">
{current?.name ?? tenants[0]?.name ?? 'Klientský portál'}
</p>
);
}
return (
<select
value={activeTenant ?? ''}
onChange={(event) => setActiveTenant(event.target.value)}
aria-label="Firma, za kterou pracujete"
className="-ml-2 max-w-full truncate rounded-lg border border-transparent bg-transparent px-2 py-0.5 text-sm font-semibold text-white transition-colors hover:border-ink-600/70 hover:bg-ink-850/70 focus:border-brand-400/70 focus:outline-none"
>
{tenants.map((tenant) => (
<option key={tenant.id} value={tenant.id} className="bg-ink-850">
{tenant.name}
</option>
))}
</select>
);
}
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() {
</button>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">
{access.data?.tenants[0]?.name ?? 'Klientský portál'}
</p>
<TenantSwitch access={access.data} />
<p className="truncate text-xs text-white/40">{accessLabel(user, access.data)}</p>
</div>
@@ -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<PeopleResponse>(`/api/dashboard/people${query}`)
apiFetch<PeopleResponse>('/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(),
+12 -1
View File
@@ -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<T>(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' } : {}),
+90
View File
@@ -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()}`;
}
+10 -1
View File
@@ -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<T> {
@@ -37,9 +38,17 @@ export function useApiQuery<T>(path: string, options: QueryOptions = {}): QueryS
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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<T>(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();
+11 -26
View File
@@ -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<Access>('/api/dashboard/access');
/** Firma, za kterou se prehled kresli. Rozlozeni se uklada pro ni. */
const [tenantId, setTenantId] = useState<string | null>(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<LayoutResponse>(`/api/dashboard/layout${tenantQuery}`);
const layout = useApiQuery<LayoutResponse>('/api/dashboard/layout');
const catalog = useApiQuery<ListResponse<WidgetDefinition>>('/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<DashboardSummary>(`/api/dashboard/summary${scopeQuery}`, {
refetchOn: [
'ticket.created',
@@ -230,7 +231,7 @@ export default function Overview() {
setSaving(true);
setSaveError(null);
try {
await apiFetch<LayoutResponse>(`/api/dashboard/layout${tenantQuery}`, {
await apiFetch<LayoutResponse>('/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<LayoutResponse>(`/api/dashboard/layout${tenantQuery}`, {
const fresh = await apiFetch<LayoutResponse>('/api/dashboard/layout', {
method: 'DELETE',
});
setDraft(fresh.items);
@@ -284,22 +285,6 @@ export default function Overview() {
</div>
<div className="flex flex-wrap items-center gap-2">
{/* Prepinac firmy davá smysl jen tomu, kdo jich ma vic. */}
{!editing && (access.data?.tenants.length ?? 0) > 1 && (
<select
value={activeTenant ?? ''}
onChange={(event) => setTenantId(event.target.value)}
aria-label="Firma"
className="rounded-xl border border-ink-600/70 bg-ink-850/70 px-3 py-2 text-sm text-white focus:border-brand-400/70 focus:outline-none"
>
{access.data?.tenants.map((tenant) => (
<option key={tenant.id} value={tenant.id} className="bg-ink-850">
{tenant.name}
</option>
))}
</select>
)}
{editing ? (
<>
<Button variant="secondary" size="sm" onClick={() => setPicker(true)}>
-1
View File
@@ -159,7 +159,6 @@ export default function Tickets() {
<NewTicketDialog
open={creating}
tenantId={tenantId ?? data?.tenantId ?? null}
onClose={() => setCreating(false)}
onCreated={() => tickets.reload()}
/>