diff --git a/documentation/01-prehled-a-stav.md b/documentation/01-prehled-a-stav.md index 3930ad9..48132bc 100644 --- a/documentation/01-prehled-a-stav.md +++ b/documentation/01-prehled-a-stav.md @@ -28,6 +28,8 @@ React aplikaci ze slozky `dist/public`. | Obsah ticketu a sablony | hotovo | `{{parametr}}` ze spoustece do poli akce | | Vystupy kroku a predvalidace | hotovo | podminka se umi zeptat, co vratil predchozi krok | | Kanaly WhatsApp, FB, Instagram | hotovo | vcetne vzorovych automatizaci na prijem | +| Firmy a prava | hotovo | tri pohledy, uzivatel muze byt ve vic firmach | +| Sprava clenstvi z portalu | chybi | memberships jdou zmenit jen v kodu | | Bugs a wishes | chybi | vyvojarska agenda, samostatna evidence vedle ticketu | | Beh automatizaci | chybi | ulozeny strom se nevykonava, neni runtime | | Databaze | chybi | data jsou v pameti, restart je vrati na vychozi stav | diff --git a/documentation/03-architektura-a-mapa-kodu.md b/documentation/03-architektura-a-mapa-kodu.md index 5460392..5f6b9cf 100644 --- a/documentation/03-architektura-a-mapa-kodu.md +++ b/documentation/03-architektura-a-mapa-kodu.md @@ -41,6 +41,8 @@ image jen `dist`, takze staci jedna slozka. | `src/routes/contact.ts` | poptavkovy formular z webu | | `src/data/ticketStore.ts` | tickety, jejich resitele, log prubehu, prehled vytizeni | | `src/data/people.ts` | resitele ticketu - oddeleni od uzivatelu portalu | +| `src/data/tenants.ts` | firmy, ktere portal pouzivaji | +| `src/data/access.ts` | kdo co vidi - jedno misto pro cely portal | | `src/data/incidentStore.ts` | incidenty vcetne zmen a udalosti | | `src/data/automationStore.ts` | automatizace, strom akci, tokeny webhooku | | `src/data/connectors.ts` | katalog konektoru, jejich spousteču a akci | @@ -87,6 +89,14 @@ Kod zustava anglicky. **Data v pameti.** Vedome zjednoduseni prototypu. Uloziste jsou oddelena od rout, takze napojeni na databazi znamena prepsat soubory v `src/data/`, ne endpointy. +**Filtr na firmu je povinny argument.** `listTickets`, `listPeople` +i `listAutomations` vyzaduji `tenantIds`. Zapomenuty filtr tak neznamena "vse", +ale nezkompiluje se. Podrobnosti v [07-firmy-a-prava.md](07-firmy-a-prava.md). + +**Prava se nikdy nedovozuji na klientovi.** Server vraci `GET /api/dashboard/access` +s tim, co uzivatel smi. Kdyby si to klient pocital sam, pocitalo by se to na dvou +mistech a jednou se to rozejde. + **Resitel neni uzivatel.** Uzivatel se prihlasuje do portalu, resitel ma u sebe tickety. Technik muze mit tickety a ucet nikdy nemit. Spojka je e-mail, podrobnosti v [06-tickety.md](06-tickety.md). diff --git a/documentation/04-api.md b/documentation/04-api.md index 1aa7caa..58d6328 100644 --- a/documentation/04-api.md +++ b/documentation/04-api.md @@ -22,6 +22,7 @@ Vyzaduji `Authorization: Bearer `: | ------ | --------------------------------------------------- | | GET | `/api/auth/me` | | POST | `/api/auth/logout` | +| GET | `/api/dashboard/access` | | GET | `/api/dashboard/summary` | | GET | `/api/dashboard/people` | | GET | `/api/dashboard/tickets` | @@ -113,14 +114,24 @@ curl -X POST https://services.csbot.cz/apps//webhook/ \ Prototyp pozadavek prijme, zvaliduje a zapocita do metrik, ale strom akci nevykona - runtime neexistuje. +## Firmy a pohledy + +Prava popisuje [07-firmy-a-prava.md](07-firmy-a-prava.md), tady jen API. + +Endpointy dashboardu berou `scope` (`all`, `tenant`, `mine`) a `tenantId`. +`GET /api/dashboard/access` rekne, co uzivatel smi, aby to klient nedovozoval. + +Pozadavek na pohled nebo firmu bez opravneni vraci **403 nebo 404**, nikdy tise +zuzeny vysledek. Uzivatel nesmi koukat na cizi cisla v domneni, ze jsou spravna. + ## Tickety Popis modelu je v [06-tickety.md](06-tickety.md), tady jen to, co se tyka API. -`GET /api/dashboard/tickets` bere filtry v query: `assignee`, `status`, `channel`. -U `assignee` jsou dve zvlastni hodnoty: `me` znamena resitele odpovidajiciho -prihlasenemu uzivateli, `unassigned` frontu bez resitele. Neznama hodnota filtru -se zaloguje a ignoruje - je lepsi ukazat vic ticketu nez prazdny seznam +`GET /api/dashboard/tickets` bere navic filtry `assignee`, `status`, `channel`. +U `assignee` je zvlastni hodnota `unassigned` pro frontu bez resitele. +U pohledu `mine` se `assignee` ignoruje, pohled je silnejsi. Neznama hodnota +filtru se zaloguje a ignoruje - je lepsi ukazat vic ticketu nez prazdny seznam bez vysvetleni. Odpoved nese vedle `items` jeste `meId`. Klient podle nej pozna, ktere tickety diff --git a/documentation/05-dashboard-a-builder.md b/documentation/05-dashboard-a-builder.md index 93f5e03..51309e9 100644 --- a/documentation/05-dashboard-a-builder.md +++ b/documentation/05-dashboard-a-builder.md @@ -96,6 +96,24 @@ autorita, kopie na klientovi existuje jen proto, aby UI nenabidlo nesmysl. Dokud spoustec nema zadny parametr, nejde pridat podminka - nebylo by podle ceho se rozhodovat. Dialog to vysvetli. +## Sirka karet pri zanoreni + +Vetve ANO a NE jsou vedle sebe jen tehdy, kdyz je na to v dane karte misto. +Rozhoduje **sirka karty, ne sirka okna** - pouzivaji se container queries +(`@container` a `@2xl:grid-cols-2` v `FlowCanvas.tsx`). + +Duvod: kazde zanoreni pulí dostupnou sirku. S beznym `lg:grid-cols-2` vypadal +strom na sirokem monitoru dobre v prvni urovni a ve treti uz mel karty siroke +par desitek pixelu, takze se popisy lamaly po jednom slove. Container query se +od urcite hloubky sama prepne na vetve pod sebou. + +Ze stejneho duvodu se v uzke karte skryva popis akce (`hidden @xs:block`), +zmensuje ikona a ovladaci tlacitka se skladaji do sloupce. Nazev kroku +a nastaveni poli zustavaji vzdy videt - to je to podstatne. + +**Pri uprave stromu nepouzivat `sm:` / `lg:` na veci uvnitr karet.** Reaguji +na okno a v zanoreni lzou. + ## Co je v kterem kroku videt Krok vidi parametry spoustece **plus vystupy vsech kroku pred nim**. Akce muze diff --git a/documentation/07-firmy-a-prava.md b/documentation/07-firmy-a-prava.md new file mode 100644 index 0000000..e8d3970 --- /dev/null +++ b/documentation/07-firmy-a-prava.md @@ -0,0 +1,124 @@ +# 07 - Firmy a prava + +## Dve ruzne "firmy" + +Snadno se pletou, proto hned na zacatku: + +- **Tenant** je firma, ktera portal pouziva. Ma v nem svuj tym, svoje tickety + a svoje automatizace. Je to hranice viditelnosti. +- **`ticket.customer`** je zakaznik toho tenanta, tedy kdo pozadavek poslal. + S pravy nema nic spolecneho. + +Tenanti jsou v `src/data/tenants.ts`. + +## Uzivatel muze byt ve vic firmach + +Proto `memberships`, ne jedno `tenantId`. Typicky externista, ktery dela +servicedesk dvema klientum a v kazdem ma jinou roli. + +```ts +interface User { + id: string; + email: string; + name: string; + platformAdmin: boolean; // vidi napric vsemi firmami + memberships: Membership[]; // { tenantId, role } +} + +type TenantRole = 'admin' | 'agent'; +``` + +**Role je vzdy az uvnitr firmy.** Pristup napric firmami je zvlast jako +`platformAdmin` - to je nase pravo, ne zakaznicke. Kdyby byla role jen jedna +globalni, nesla by tahle situace vubec zapsat. + +## Tri pohledy na tickety + +| Pohled | Co ukazuje | Kdo smi | +| -------- | ------------------------- | ------------------------------ | +| `all` | napric vsemi firmami | jen `platformAdmin` | +| `tenant` | cela jedna firma | kdokoliv, kdo do ni patri | +| `mine` | jen tickety prihlaseneho | kdo ma navazaneho resitele | + +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. + +## Server je autorita + +Vsechno rozhoduje `src/data/access.ts`, jedno misto pro cely portal. Kdyby se to +rozlezlo po routach, driv nebo pozdeji vznikne endpoint, ktery filtr zapomene. + +`GET /api/dashboard/access` vraci, co uzivatel smi: + +```json +{ + "scopes": ["all", "tenant", "mine"], + "tenants": [{ "id": "tnt_automia", "name": "Automia" }], + "defaultTenantId": "tnt_automia", + "canAssignOthers": true, + "personId": "ppl_uhlir" +} +``` + +Klient podle toho kresli prepinac. **Nesmi si to dovozovat sam** - jinak by se +prava pocitala na dvou mistech a jednou se rozejdou. + +## Nikdy tise nezuzujeme + +Pozadavek na pohled nebo firmu, na kterou uzivatel nema pravo, vraci **chybu**, +ne potichu zuzeny vysledek: + +| Situace | Odpoved | +| -------------------------------- | ------- | +| pohled bez opravneni | 403 | +| firma, do ktere nepatri | 404 | +| ucet bez firmy | 403 | +| prirazeni ostatnim bez prava | 403 | + +Duvod: kdyby se pozadavek na cizi firmu jen prepnul na vlastni, uzivatel by +koukal na cizi cisla v domneni, ze jsou spravna. To je horsi nez chyba. + +## Filtr v ulozisti je povinny + +`listTickets`, `listPeople` a `listAutomations` maji `tenantIds` jako **povinny** +argument, ne volitelny. Zapomenuty filtr tak neznamena "vse", ale nezkompiluje se. + +Zapis (`assignTicket`, `updateTicketStatus`, `addComment`, `updateAutomation`, +`deleteAutomation`) bere `tenantIds` taky. Cizi zaznam se chova jako neexistujici, +tedy 404, ne 403 - z odpovedi nemá jit poznat, ze takove ID vubec existuje. + +Vyjimka je verejny webhook. Ten se autorizuje tokenem v adrese, ne prihlasenim, +takze si automatizaci najde pres vsechny firmy. + +## Prirazeni jen v ramci firmy + +`assignTicket` odmitne resitele z jine firmy. Jinak by ticket zmizel z prehledu +sve firmy a objevil se nekomu, kdo do ni nepatri. + +Prehazovat praci mezi lidmi smi jen `admin`. `agent` si smi vzit ticket na sebe, +ale nemuze ho poslat kolegovi - to hlida `canAssignOthers`. + +## Demo ucty + +Heslo je u vsech `demo1234`. + +| E-mail | Kdo je | +| ------------------------- | ----------------------------------------------- | +| `admin@automia.cz` | spravce platformy, vidi vsechny tri firmy | +| `karel.vomacka@automia.cz`| agent v Automii, admin u Nordisu - dve firmy | +| `martin.kriz@automia.cz` | bezny resitel jedne firmy | + +Druhy ucet je ten zajimavy: ukazuje prepinac firem i to, ze prava se lisi +podle toho, ktera firma je prave zvolena. + +## Co chybi + +| Chybi | Poznamka | +| ------------------------ | ---------------------------------------------------- | +| Sprava clenstvi z portalu| memberships jdou zmenit jen v kodu | +| Pozvanky uzivatelu | zadny onboarding | +| Tenant u incidentu | incidenty jsou zatim spolecne, nefiltruji se | +| Tenant u konektoru | katalog je spolecny, napojeni se zatim neeviduje | +| Audit pristupu | odepreni se jen loguje, nikde se neuklada | diff --git a/documentation/99-zmeny.md b/documentation/99-zmeny.md index d1ba763..20cb200 100644 --- a/documentation/99-zmeny.md +++ b/documentation/99-zmeny.md @@ -91,6 +91,29 @@ co vratil. Bez toho byla predvalidace k nicemu. Prijmove zamerne neprirazuji, smerovani si ticket prevezme a podminkou `assigned neni splneno` neprepise rucni rozhodnuti. +### Firmy a prava + +Treti a nejvazneji dira: portal nemel zadnou tenanci. Kterykoliv prihlaseny +uzivatel videl vsechny tickety vsech firem a cely seznam resitelu, `requireRole` +se nikde nevolal. Popis v [07-firmy-a-prava.md](07-firmy-a-prava.md). + +- Tenant jako hranice viditelnosti. `tenantId` na ticketu, resiteli + i automatizaci. +- Uzivatel muze patrit do **vic firem**, v kazde s jinou roli (`memberships`). + Pristup napric firmami je zvlast jako `platformAdmin`. +- Tri pohledy na tickety: `all`, `tenant`, `mine`. Admin mezi nimi prepina, + vcetne vyberu firmy. +- `src/data/access.ts` jako jedine misto, kde se rozhoduje o pravech. + `GET /api/dashboard/access` rekne klientovi, co smi kreslit. +- Filtr na firmu je v ulozistich **povinny argument**. Zapomenuty filtr + neznamena "vse", ale nezkompiluje se. +- Nikdy tise nezuzujeme. Cizi firma vraci 403 nebo 404. +- Prirazeni jen v ramci firmy. Prehazovat praci mezi lidmi smi jen admin, + agent si smi vzit ticket na sebe. +- `requireRole` nahrazen `requirePlatformAdmin`. Prava uvnitr firmy resi + `access.ts`, protoze zavisi na tom, ktera firma pozadavek zajima. +- Demo ucty pokryvaji vsechny tri situace vcetne cloveka ve dvou firmach. + ### Zapsano jako otevrene rozhodnuti Vsechny automatizace se stejnym spoustecem se spusti. Doporucene rozdeleni na to diff --git a/src/data/access.ts b/src/data/access.ts new file mode 100644 index 0000000..ef042f7 --- /dev/null +++ b/src/data/access.ts @@ -0,0 +1,151 @@ +/** + * Kdo co vidi. Jedno misto pro cely portal - kdyby se to rozlezlo po routach, + * driv nebo pozdeji nekde vznikne endpoint, ktery filtr zapomene. + * + * Tri pohledy na tickety: + * - `all` napric vsemi firmami, jen pro platformniho admina, + * - `tenant` cela jedna firma, pro kohokoliv, kdo do ni patri, + * - `mine` jen tickety prihlaseneho cloveka. + * + * Uzivatel muze patrit do vic firem, takze `tenant` a `mine` vzdy potrebuji + * vedet, o kterou firmu jde. + */ + +import { findPersonByEmail } from './people.js'; +import { findTenant, listTenants, type Tenant } from './tenants.js'; +import type { User } from '../types.js'; + +export type TicketScope = 'all' | 'tenant' | 'mine'; + +export interface Access { + /** Pohledy, ktere smi uzivatel pouzit. Klient podle toho kresli prepinac. */ + scopes: TicketScope[]; + /** Firmy, mezi kterymi muze prepinat. */ + tenants: Tenant[]; + /** Vychozi firma, kdyz zadnou nezvoli. null = zadna, nema kam. */ + defaultTenantId: string | null; + /** Smi prehazovat tickety mezi lidmi, ne jen brat na sebe. */ + canAssignOthers: boolean; + /** ID resitele odpovidajiciho uzivateli, nebo null. */ + personId: string | null; +} + +export function accessFor(user: User): Access { + const person = findPersonByEmail(user.email); + + const tenants = user.platformAdmin + ? listTenants() + : user.memberships + .map((membership) => findTenant(membership.tenantId)) + .filter((tenant): tenant is Tenant => { + if (!tenant) console.warn(`[access] ${user.email}: clenstvi v nezname firme`); + return tenant !== undefined; + }) + .sort((a, b) => a.name.localeCompare(b.name, 'cs')); + + const scopes: TicketScope[] = []; + if (user.platformAdmin) scopes.push('all'); + if (tenants.length > 0) scopes.push('tenant'); + // Bez navazaneho resitele nema "moje tickety" co ukazat. + if (person) scopes.push('mine'); + + // Admin aspon v jedne firme, nebo platformni admin, smi prehazovat praci. + const isAdminSomewhere = + user.platformAdmin || user.memberships.some((membership) => membership.role === 'admin'); + + return { + scopes, + tenants, + defaultTenantId: tenants[0]?.id ?? null, + canAssignOthers: isAdminSomewhere, + personId: person?.id ?? null, + }; +} + +export interface ResolvedScope { + scope: TicketScope; + /** Firmy, ze kterych se smi vracet data. Prazdne pole = nic. */ + tenantIds: string[]; + /** Firma, na kterou je pohled zamereny. null u pohledu `all`. */ + tenantId: string | null; + /** Vyplnene jen u pohledu `mine`. */ + personId: string | null; +} + +export interface ScopeDenied { + status: 403 | 404; + error: string; + message: string; +} + +/** + * Prevede pozadavek klienta na to, co se smi vratit. + * + * Zamerne vraci chybu misto tiseho zuzeni. Kdyby se pozadavek na cizi firmu + * jen potichu prepnul na vlastni, uzivatel by koukal na cizi cisla v domneni, + * ze jsou spravna. + */ +export function resolveScope( + user: User, + requested: { scope?: string; tenantId?: string }, +): ResolvedScope | ScopeDenied { + const access = accessFor(user); + + const scope = (requested.scope ?? (access.scopes.includes('tenant') ? 'tenant' : 'mine')) as + | TicketScope + | string; + + if (scope !== 'all' && scope !== 'tenant' && scope !== 'mine') { + return { status: 403, error: 'unknown_scope', message: `Neznámý pohled „${scope}".` }; + } + + if (!access.scopes.includes(scope)) { + console.warn(`[access] ${user.email}: pokus o pohled ${scope} bez opravneni`); + return { + status: 403, + error: 'forbidden', + message: + scope === 'all' + ? 'Pohled přes všechny firmy má jen správce platformy.' + : 'K tomuto pohledu nemáte oprávnění.', + }; + } + + if (scope === 'all') { + return { + scope, + tenantIds: access.tenants.map((tenant) => tenant.id), + tenantId: null, + personId: null, + }; + } + + const tenantId = requested.tenantId ?? access.defaultTenantId; + if (!tenantId) { + return { + status: 403, + error: 'no_tenant', + message: 'Účet nepatří do žádné firmy.', + }; + } + + if (!access.tenants.some((tenant) => tenant.id === tenantId)) { + console.warn(`[access] ${user.email}: pokus o firmu ${tenantId} bez clenstvi`); + return { + status: 404, + error: 'not_found', + message: 'Firma neexistuje, nebo do ní nepatříte.', + }; + } + + return { + scope, + tenantIds: [tenantId], + tenantId, + personId: scope === 'mine' ? access.personId : null, + }; +} + +export function isDenied(value: ResolvedScope | ScopeDenied): value is ScopeDenied { + return 'status' in value; +} diff --git a/src/data/automationStore.ts b/src/data/automationStore.ts index 2567713..3f2cd56 100644 --- a/src/data/automationStore.ts +++ b/src/data/automationStore.ts @@ -78,6 +78,8 @@ export function generateWebhookToken(): string { /** Polozka v seznamu automatizaci - bez celeho stromu. */ export interface Automation { id: string; + /** Firma, ktere automatizace patri. Hranice viditelnosti. */ + tenantId: string; name: string; kind: AutomationKind; enabled: boolean; @@ -104,6 +106,7 @@ export interface AutomationDetail extends Automation { interface StoredAutomation { id: string; + tenantId: string; name: string; kind: AutomationKind; enabled: boolean; @@ -284,9 +287,15 @@ function nextId(): string { return `AUT-${String(idCounter).padStart(2, '0')}`; } -function seed(automation: Omit) { +function seed( + automation: Omit & { + tenantId?: string; + }, +) { const id = nextId(); store.set(id, { + // Ukazkova data patri Automii, kdyz neni receno jinak. + tenantId: 'tnt_automia', ...automation, id, kind: deriveKind(automation.flow), @@ -758,23 +767,31 @@ function toDetail(stored: StoredAutomation): AutomationDetail { }; } -export function listAutomations(): Automation[] { +/** Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse". */ +export function listAutomations(tenantIds: string[]): Automation[] { // Nejnovejsi nahoru, aby prave vytvorena automatizace byla hned videt. return [...store.values()] + .filter((stored) => tenantIds.includes(stored.tenantId)) .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) .map(toSummary); } -export function getAutomation(id: string): AutomationDetail | undefined { +export function getAutomation(id: string, tenantIds: string[]): AutomationDetail | undefined { const stored = store.get(id); - return stored ? toDetail(stored) : undefined; + if (!stored) return undefined; + if (!tenantIds.includes(stored.tenantId)) { + console.warn(`[automations] pokus o cteni ${id} mimo povolene firmy`); + return undefined; + } + return toDetail(stored); } -export function createAutomation(name: string): AutomationDetail { +export function createAutomation(name: string, tenantId: string): AutomationDetail { const id = nextId(); const now = new Date().toISOString(); const stored: StoredAutomation = { id, + tenantId, name, kind: 'workflow', enabled: false, @@ -792,13 +809,25 @@ export function createAutomation(name: string): AutomationDetail { return toDetail(stored); } +/** Automatizace z povolenych firem. Cizi se chova jako neexistujici. */ +function findWritable(id: string, tenantIds: string[]): StoredAutomation | undefined { + const stored = store.get(id); + if (!stored) return undefined; + if (!tenantIds.includes(stored.tenantId)) { + console.warn(`[automations] pokus o zapis do ${id} mimo povolene firmy`); + return undefined; + } + return stored; +} + export function updateAutomation( id: string, patch: { name?: string; enabled?: boolean; flow?: AutomationFlow }, + tenantIds: string[], ): AutomationDetail | undefined { - const stored = store.get(id); + const stored = findWritable(id, tenantIds); if (!stored) { - console.warn(`[automations] pokus o upravu neexistujici automatizace: ${id}`); + console.warn(`[automations] pokus o upravu nedostupne automatizace: ${id}`); return undefined; } @@ -865,10 +894,13 @@ function withWebhookToken( } /** Vygeneruje novy token - stara adresa okamzite prestane fungovat. */ -export function regenerateWebhookToken(id: string): AutomationDetail | undefined { - const stored = store.get(id); +export function regenerateWebhookToken( + id: string, + tenantIds: string[], +): AutomationDetail | undefined { + const stored = findWritable(id, tenantIds); if (!stored) { - console.warn(`[automations] regenerace tokenu pro neexistujici automatizaci: ${id}`); + console.warn(`[automations] regenerace tokenu pro nedostupnou automatizaci: ${id}`); return undefined; } if (stored.flow.trigger?.connectorId !== 'webhook') { @@ -898,10 +930,11 @@ export function findByWebhookToken(token: string): AutomationDetail | undefined } /** Zapise beh automatizace - drzi metriky i graf zive. */ -export function recordRun(id: string, ok = true): AutomationDetail | undefined { - const stored = store.get(id); +export function recordRun(id: string, ok = true, tenantIds?: string[]): AutomationDetail | undefined { + // Bez omezeni volá webhook, ktery se autorizuje tokenem, ne prihlasenim. + const stored = tenantIds ? findWritable(id, tenantIds) : store.get(id); if (!stored) { - console.warn(`[automations] recordRun pro neexistujici automatizaci: ${id}`); + console.warn(`[automations] recordRun pro nedostupnou automatizaci: ${id}`); return undefined; } @@ -928,11 +961,12 @@ export function recordRun(id: string, ok = true): AutomationDetail | undefined { return toDetail(updated); } -export function deleteAutomation(id: string): boolean { - const name = store.get(id)?.name; - const existed = store.delete(id); +export function deleteAutomation(id: string, tenantIds: string[]): boolean { + const stored = findWritable(id, tenantIds); + const name = stored?.name; + const existed = stored !== undefined && store.delete(id); if (!existed) { - console.warn(`[automations] pokus o smazani neexistujici automatizace: ${id}`); + console.warn(`[automations] pokus o smazani nedostupne automatizace: ${id}`); } else { console.info(`[automations] smazana automatizace ${id}`); publish('automation.deleted', `Automatizace ${id} smazána: ${name ?? ''}`, { diff --git a/src/data/mock.ts b/src/data/mock.ts index 8dcf923..dbf58e6 100644 --- a/src/data/mock.ts +++ b/src/data/mock.ts @@ -24,10 +24,10 @@ export function getRunsSeries(days = 14) { return series; } -export function getSummary() { - const tickets = listTickets(); +export function getSummary(tenantIds: string[]) { + const tickets = listTickets({ tenantIds }); const incidents = listIncidents(); - const automations = listAutomations(); + const automations = listAutomations(tenantIds); const series = getRunsSeries(); // Dnesni sloupec grafu doplnujeme o skutecne behy, aby se simulace projevila. diff --git a/src/data/people.ts b/src/data/people.ts index 3249d75..2ed618d 100644 --- a/src/data/people.ts +++ b/src/data/people.ts @@ -11,6 +11,8 @@ export interface Person { id: string; + /** Firma, jejiz je clenem tymu. Hranice viditelnosti. */ + tenantId: string; name: string; email: string; /** Cim se v tymu zabyva. Jen popisek, nic nerozhoduje. */ @@ -22,6 +24,7 @@ export interface Person { export const people: Person[] = [ { id: 'ppl_vomacka', + tenantId: 'tnt_automia', name: 'Karel Vomáčka', email: 'karel.vomacka@automia.cz', role: 'Servicedesk', @@ -29,6 +32,7 @@ export const people: Person[] = [ }, { id: 'ppl_uhlir', + tenantId: 'tnt_automia', name: 'Jiří Uhlíř', email: 'admin@automia.cz', role: 'Vedoucí týmu', @@ -36,6 +40,7 @@ export const people: Person[] = [ }, { id: 'ppl_kriz', + tenantId: 'tnt_automia', name: 'Martin Kříž', email: 'martin.kriz@automia.cz', role: 'Integrace a API', @@ -43,15 +48,45 @@ export const people: Person[] = [ }, { id: 'ppl_novakova', + tenantId: 'tnt_automia', name: 'Eva Nováková', email: 'eva.novakova@automia.cz', role: 'Voiceboti', capacity: 6, }, + // Nordis ma vlastni tym. Karel Vomacka je jeho spravcem, ale resitele + // ma Nordis svoje - proto je videt, ze tenant neni jen stitek. + { + id: 'ppl_bartos', + tenantId: 'tnt_nordis', + name: 'Lukáš Bartoš', + email: 'lukas.bartos@nordis.cz', + role: 'Podpora', + capacity: 7, + }, + { + id: 'ppl_horakova', + tenantId: 'tnt_nordis', + name: 'Simona Horáková', + email: 'simona.horakova@nordis.cz', + role: 'Fakturace', + capacity: 5, + }, + { + id: 'ppl_kadlec', + tenantId: 'tnt_logitrans', + name: 'Ondřej Kadlec', + email: 'ondrej.kadlec@logitrans.cz', + role: 'Dispečink', + capacity: 6, + }, ]; -export function listPeople(): Person[] { - return [...people].sort((a, b) => a.name.localeCompare(b.name, 'cs')); +/** Bez omezeni na firmy vrati prazdno. Zapomenuty filtr nesmi znamenat "vse". */ +export function listPeople(tenantIds: string[]): Person[] { + return people + .filter((person) => tenantIds.includes(person.tenantId)) + .sort((a, b) => a.name.localeCompare(b.name, 'cs')); } export function findPerson(id: string): Person | undefined { diff --git a/src/data/tenants.ts b/src/data/tenants.ts new file mode 100644 index 0000000..26a1771 --- /dev/null +++ b/src/data/tenants.ts @@ -0,0 +1,31 @@ +/** + * Tenant = firma, ktera servicedesk provozuje. + * + * POZOR na dve ruzne "firmy", ktere se snadno pletou: + * - tenant je nas zakaznik, ten, kdo portal pouziva a ma v nem svuj tym, + * - `ticket.customer` je zakaznik toho tenanta, tedy kdo pozadavek poslal. + * + * Tenant je hranice viditelnosti. Nic se nesmi vratit napric tenanty, + * pokud to vyslovne nepovoli role `platform`. + * + * POZOR: data jsou v pameti procesu, restart je vrati na vychozi sadu. + */ + +export interface Tenant { + id: string; + name: string; +} + +export const tenants: Tenant[] = [ + { id: 'tnt_automia', name: 'Automia' }, + { id: 'tnt_nordis', name: 'Nordis a.s.' }, + { id: 'tnt_logitrans', name: 'LogiTrans' }, +]; + +export function listTenants(): Tenant[] { + return [...tenants].sort((a, b) => a.name.localeCompare(b.name, 'cs')); +} + +export function findTenant(id: string): Tenant | undefined { + return tenants.find((t) => t.id === id); +} diff --git a/src/data/ticketStore.ts b/src/data/ticketStore.ts index 24898cc..8761520 100644 --- a/src/data/ticketStore.ts +++ b/src/data/ticketStore.ts @@ -73,6 +73,8 @@ export interface TicketTraceEntry { export interface Ticket { id: string; + /** Firma, ktere ticket patri. Hranice viditelnosti, viz data/access.ts. */ + tenantId: string; subject: string; /** * Cely text pozadavku - telo e-mailu, zprava z WhatsApp, prepis hovoru. @@ -194,6 +196,7 @@ function seed(ticket: StoredTicket, trace: TraceInput[]) { seed( { id: 'TK-4821', + tenantId: 'tnt_automia', subject: 'Voicebot neodpovídá na volání po 18:00', body: 'Dobrý den, po šesté hodině to nikdo nebere. Zkoušeli jsme to včera i dnes, ' + @@ -306,6 +309,7 @@ seed( seed( { id: 'TK-4820', + tenantId: 'tnt_automia', subject: 'Přidat pole IČO do synchronizace CRM a fakturace', body: 'Zdravím, potřebovali bychom, aby se při synchronizaci přenášelo i IČO. ' + @@ -389,6 +393,7 @@ seed( seed( { id: 'TK-4819', + tenantId: 'tnt_automia', subject: 'Chybí denní report objednávek v e-mailu', body: 'Dobrý den, už třetí den nechodí ranní report. Můžete se na to prosím podívat?', sourceRef: 'wamid.HBgLNDIwNzc0OTAyMzMx', @@ -482,6 +487,7 @@ seed( seed( { id: 'TK-4817', + tenantId: 'tnt_logitrans', subject: 'Rozšíření hlasového scénáře o objednávku svozu', body: 'Chtěli bychom, aby si zákazník mohl objednat svoz rovnou po telefonu, ' + @@ -496,7 +502,7 @@ seed( }, status: 'open', priority: 'low', - assigneeId: 'ppl_vomacka', + assigneeId: 'ppl_kadlec', automationId: null, createdAt: minutesAgo(2_600), updatedAt: minutesAgo(420), @@ -524,13 +530,13 @@ seed( }, { kind: 'note', - label: 'Ticket ručně přiřazen na Karla Vomáčku.', + label: 'Ticket ručně přiřazen na Ondřeje Kadlece.', status: 'info', agoMinutes: 2_580, }, { kind: 'note', - label: 'Karel Vomáčka: Odhad odeslán, čeká se na objednávku.', + label: 'Ondřej Kadlec: Odhad odeslán, čeká se na objednávku.', status: 'info', agoMinutes: 420, }, @@ -540,6 +546,7 @@ seed( seed( { id: 'TK-4812', + tenantId: 'tnt_nordis', subject: 'Duplicitní zápis kontaktů z webového formuláře', body: 'Každé odeslání formuláře zakládá nový kontakt, i když stejný e-mail už v CRM je. ' + @@ -554,7 +561,7 @@ seed( }, status: 'resolved', priority: 'critical', - assigneeId: 'ppl_uhlir', + assigneeId: 'ppl_bartos', automationId: null, createdAt: minutesAgo(5_100), updatedAt: minutesAgo(1_500), @@ -582,7 +589,7 @@ seed( }, { kind: 'note', - label: 'Jiří Uhlíř: Do formuláře doplněna kontrola podle e-mailu, duplicity sloučeny.', + label: 'Lukáš Bartoš: Do formuláře doplněna kontrola podle e-mailu, duplicity sloučeny.', status: 'info', agoMinutes: 1_500, }, @@ -607,14 +614,20 @@ function toTicket(stored: StoredTicket): Ticket { // -------------------------------------------------------------------- dotazy export interface TicketFilter { + /** + * Firmy, ze kterych se smi vracet. Povinne - kdyby to slo vynechat, + * driv nebo pozdeji nekdo zapomene a endpoint vrati cizi data. + */ + tenantIds: string[]; /** ID resitele, nebo 'unassigned' pro nepridelene. */ assignee?: string; status?: TicketStatus; channel?: TicketChannel; } -export function listTickets(filter: TicketFilter = {}): Ticket[] { +export function listTickets(filter: TicketFilter): Ticket[] { const selected = tickets.filter((ticket) => { + if (!filter.tenantIds.includes(ticket.tenantId)) return false; if (filter.status && ticket.status !== filter.status) return false; if (filter.channel && ticket.channel !== filter.channel) return false; if (filter.assignee === 'unassigned') return ticket.assigneeId === null; @@ -632,15 +645,22 @@ export function listTickets(filter: TicketFilter = {}): Ticket[] { .map(toTicket); } -export function getTicket(id: string): TicketDetail | undefined { +/** Vraci ticket jen z povolenych firem. Cizi se tvari jako neexistujici. */ +export function getTicket(id: string, tenantIds: string[]): TicketDetail | undefined { const stored = tickets.find((t) => t.id === id); if (!stored) return undefined; + if (!tenantIds.includes(stored.tenantId)) { + console.warn(`[tickets] pokus o cteni ticketu ${id} mimo povolene firmy`); + return undefined; + } return { ...toTicket(stored), trace: traces.get(id) ?? [] }; } /** Prvni nevyrizeny ticket - pouziva simulace, kdyz uzivatel neurci ktery. */ -export function firstOpenTicket(): Ticket | undefined { - const stored = tickets.find((t) => t.status !== 'resolved'); +export function firstOpenTicket(tenantIds: string[]): Ticket | undefined { + const stored = tickets.find( + (t) => t.status !== 'resolved' && tenantIds.includes(t.tenantId), + ); return stored ? toTicket(stored) : undefined; } @@ -667,9 +687,11 @@ export interface Workload { openTotal: number; } -export function getWorkload(everyone: Person[]): Workload { +export function getWorkload(everyone: Person[], tenantIds: string[]): Workload { + const visible = tickets.filter((ticket) => tenantIds.includes(ticket.tenantId)); + const rows = everyone.map((person) => { - const mine = tickets.filter((t) => t.assigneeId === person.id); + const mine = visible.filter((t) => t.assigneeId === person.id); const open = mine.filter((t) => t.status !== 'resolved'); const oldest = open.reduce( (acc, t) => (acc === null || t.createdAt < acc ? t.createdAt : acc), @@ -691,14 +713,15 @@ export function getWorkload(everyone: Person[]): Workload { return { rows, - unassigned: tickets.filter((t) => t.assigneeId === null && t.status !== 'resolved').length, - openTotal: tickets.filter((t) => t.status !== 'resolved').length, + unassigned: visible.filter((t) => t.assigneeId === null && t.status !== 'resolved').length, + openTotal: visible.filter((t) => t.status !== 'resolved').length, }; } // --------------------------------------------------------------------- zmeny export interface CreateTicketInput { + tenantId: string; subject: string; body?: string; sourceRef?: string | null; @@ -724,6 +747,7 @@ export function createTicket(input: CreateTicketInput): Ticket { const stored: StoredTicket = { id: `TK-${ticketCounter}`, + tenantId: input.tenantId, subject: input.subject, body: input.body ?? '', sourceRef: input.sourceRef ?? null, @@ -748,10 +772,25 @@ export function createTicket(input: CreateTicketInput): Ticket { return toTicket(stored); } -export function updateTicketStatus(id: string, status: TicketStatus): Ticket | undefined { +/** Ticket z povolenych firem. Cizi se chova jako neexistujici. */ +function findWritable(id: string, tenantIds: string[]): StoredTicket | undefined { const ticket = tickets.find((t) => t.id === id); + if (!ticket) return undefined; + if (!tenantIds.includes(ticket.tenantId)) { + console.warn(`[tickets] pokus o zapis do ticketu ${id} mimo povolene firmy`); + return undefined; + } + return ticket; +} + +export function updateTicketStatus( + id: string, + status: TicketStatus, + tenantIds: string[], +): Ticket | undefined { + const ticket = findWritable(id, tenantIds); if (!ticket) { - console.warn(`[tickets] zmena stavu neexistujiciho ticketu: ${id}`); + console.warn(`[tickets] zmena stavu nedostupneho ticketu: ${id}`); return undefined; } @@ -781,10 +820,14 @@ export function updateTicketStatus(id: string, status: TicketStatus): Ticket | u } /** Prirazeni resitele. `null` ticket vrati zpatky do fronty. */ -export function assignTicket(id: string, assigneeId: string | null): Ticket | undefined { - const ticket = tickets.find((t) => t.id === id); +export function assignTicket( + id: string, + assigneeId: string | null, + tenantIds: string[], +): Ticket | undefined { + const ticket = findWritable(id, tenantIds); if (!ticket) { - console.warn(`[tickets] prirazeni neexistujiciho ticketu: ${id}`); + console.warn(`[tickets] prirazeni nedostupneho ticketu: ${id}`); return undefined; } @@ -793,6 +836,12 @@ export function assignTicket(id: string, assigneeId: string | null): Ticket | un console.warn(`[tickets] ${id}: prirazeni na neznameho resitele ${assigneeId}`); return undefined; } + // Resitel musi byt z tehoz tymu. Jinak by ticket zmizel z prehledu firmy + // a objevil se nekomu, kdo do ni nepatri. + if (person && person.tenantId !== ticket.tenantId) { + console.warn(`[tickets] ${id}: resitel ${person.id} je z jine firmy`); + return undefined; + } ticket.assigneeId = person?.id ?? null; ticket.updatedAt = new Date().toISOString(); @@ -816,10 +865,15 @@ export function assignTicket(id: string, assigneeId: string | null): Ticket | un } /** Komentar je jen dalsi radek logu - at je vsechno na jedne casove ose. */ -export function addComment(id: string, author: string, text: string): Ticket | undefined { - const ticket = tickets.find((t) => t.id === id); +export function addComment( + id: string, + author: string, + text: string, + tenantIds: string[], +): Ticket | undefined { + const ticket = findWritable(id, tenantIds); if (!ticket) { - console.warn(`[tickets] komentar k neexistujicimu ticketu: ${id}`); + console.warn(`[tickets] komentar k nedostupnemu ticketu: ${id}`); return undefined; } diff --git a/src/data/users.ts b/src/data/users.ts index c15ed99..1238100 100644 --- a/src/data/users.ts +++ b/src/data/users.ts @@ -2,9 +2,13 @@ import bcrypt from 'bcryptjs'; import type { User } from '../types.js'; /** - * PROTOTYP: uzivatele jsou v pameti. Az prijde realny dashboard, tohle nahradi - * databaze (viz docs/04-backend-api.md, sekce "Kam dal"). - * Hesla jsou hashovana pri startu, aby v kodu nebyl plaintext v uloziste-podobne strukture. + * PROTOTYP: uzivatele jsou v pameti. Az prijde databaze, tohle ji nahradi. + * Hesla se hashuji pri startu, aby v kodu nebyl plaintext. + * + * Demo ucty schvalne pokryvaji vsechny tri situace, ktere se lisi pravy: + * - platformni admin, ktery vidi napric firmami, + * - clovek ve dvou firmach, v kazde s jinou roli, + * - bezny resitel jedne firmy. */ const DEMO_PASSWORD = 'demo1234'; @@ -14,16 +18,28 @@ export const users: User[] = [ email: 'admin@automia.cz', passwordHash: bcrypt.hashSync(DEMO_PASSWORD, 10), name: 'Jiří Uhlíř', - role: 'admin', - company: 'Automia', + platformAdmin: true, + memberships: [{ tenantId: 'tnt_automia', role: 'admin' }], }, { id: 'usr_2', - email: 'klient@firma.cz', + email: 'karel.vomacka@automia.cz', passwordHash: bcrypt.hashSync(DEMO_PASSWORD, 10), - name: 'Petra Klientová', - role: 'client', - company: 'Firma s.r.o.', + name: 'Karel Vomáčka', + platformAdmin: false, + // Externista: v Automii resi tickety, u Nordisu spravuje jejich servicedesk. + memberships: [ + { tenantId: 'tnt_automia', role: 'agent' }, + { tenantId: 'tnt_nordis', role: 'admin' }, + ], + }, + { + id: 'usr_3', + email: 'martin.kriz@automia.cz', + passwordHash: bcrypt.hashSync(DEMO_PASSWORD, 10), + name: 'Martin Kříž', + platformAdmin: false, + memberships: [{ tenantId: 'tnt_automia', role: 'agent' }], }, ]; diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index 7a999d7..35d83fc 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -37,16 +37,19 @@ export function requireAuth(req: Request, res: Response, next: NextFunction) { } } -/** Omezi pristup na konkretni role. Pouzivat vzdy AZ za requireAuth. */ -export function requireRole(...roles: User['role'][]) { - return (req: Request, res: Response, next: NextFunction) => { - if (!req.user) { - console.error('[auth] requireRole pouzito bez requireAuth'); - return res.status(401).json({ error: 'unauthorized' }); - } - if (!roles.includes(req.user.role)) { - return res.status(403).json({ error: 'forbidden', message: 'Nedostatečná oprávnění.' }); - } - return next(); - }; +/** + * Jen pro spravce platformy, tedy pristup napric vsemi firmami. + * Prava uvnitr firmy resi `data/access.ts`, ne middleware - zavisi na tom, + * kterou firmu pozadavek zrovna zajima. + */ +export function requirePlatformAdmin(req: Request, res: Response, next: NextFunction) { + if (!req.user) { + console.error('[auth] requirePlatformAdmin pouzito bez requireAuth'); + return res.status(401).json({ error: 'unauthorized' }); + } + if (!req.user.platformAdmin) { + console.warn(`[auth] ${req.user.email}: pokus o platformni akci`); + return res.status(403).json({ error: 'forbidden', message: 'Nedostatečná oprávnění.' }); + } + return next(); } diff --git a/src/openapi.ts b/src/openapi.ts index 4eaea4f..bcd8f2e 100644 --- a/src/openapi.ts +++ b/src/openapi.ts @@ -49,12 +49,53 @@ export function buildOpenApiDocument() { }, User: { type: 'object', + description: + 'Uzivatel muze patrit do vic firem. Role je vzdy az uvnitr firmy, ' + + 'pristup napric firmami je zvlast jako platformAdmin.', properties: { id: { type: 'string', example: 'usr_1' }, email: { type: 'string', example: 'admin@automia.cz' }, name: { type: 'string', example: 'Jiri Uhlir' }, - role: { type: 'string', enum: ['admin', 'client'] }, - company: { type: 'string', example: 'Automia' }, + platformAdmin: { + type: 'boolean', + description: 'Vidi napric vsemi firmami a muze mezi nimi prepinat.', + }, + memberships: { + type: 'array', + items: { + type: 'object', + properties: { + tenantId: { type: 'string', example: 'tnt_automia' }, + role: { type: 'string', enum: ['admin', 'agent'] }, + }, + }, + }, + }, + }, + Access: { + type: 'object', + description: 'Co uzivatel smi. Klient podle toho kresli prepinac pohledu.', + properties: { + scopes: { + type: 'array', + items: { type: 'string', enum: ['all', 'tenant', 'mine'] }, + }, + tenants: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string', example: 'tnt_automia' }, + name: { type: 'string', example: 'Automia' }, + }, + }, + }, + defaultTenantId: { type: 'string', nullable: true }, + canAssignOthers: { + type: 'boolean', + description: 'Smi prehazovat tickety mezi lidmi, ne jen brat na sebe.', + }, + personId: { type: 'string', nullable: true }, }, }, LoginRequest: { @@ -419,6 +460,23 @@ export function buildOpenApiDocument() { responses: { '200': { description: 'Souhrnne metriky a casova rada' } }, }, }, + '/api/dashboard/access': { + get: { + tags: ['Dashboard'], + summary: 'Co uzivatel smi videt', + description: + 'Povolene pohledy, firmy k prepinani a prava. Klient si to nesmi dovozovat sam.', + security: [{ bearerAuth: [] }], + responses: { + '200': { + description: 'Opravneni', + content: { + 'application/json': { schema: { $ref: '#/components/schemas/Access' } }, + }, + }, + }, + }, + }, '/api/dashboard/people': { get: { tags: ['Tickety'], @@ -452,13 +510,27 @@ export function buildOpenApiDocument() { description: 'Neznama hodnota filtru se ignoruje a zaloguje, seznam se nezuzi.', security: [{ bearerAuth: [] }], parameters: [ + { + name: 'scope', + in: 'query', + schema: { type: 'string', enum: ['all', 'tenant', 'mine'] }, + description: + '`all` napric firmami (jen platformni admin), `tenant` cela firma, ' + + '`mine` jen moje tickety. Nepovoleny pohled vraci 403, nikdy se tise nezuzi.', + }, + { + name: 'tenantId', + in: 'query', + schema: { type: 'string' }, + description: 'Firma u pohledu `tenant` a `mine`. Bez clenstvi vraci 404.', + example: 'tnt_automia', + }, { name: 'assignee', in: 'query', schema: { type: 'string' }, description: - 'ID resitele, `me` pro prihlaseneho uzivatele, nebo `unassigned` pro frontu.', - example: 'me', + 'ID resitele nebo `unassigned` pro frontu. U pohledu `mine` se ignoruje.', }, { name: 'status', diff --git a/src/routes/auth.ts b/src/routes/auth.ts index 991183b..7fef4d1 100644 --- a/src/routes/auth.ts +++ b/src/routes/auth.ts @@ -41,12 +41,14 @@ authRouter.post('/login', async (req, res) => { return invalid(); } - const payload: JwtPayload = { sub: user.id, email: user.email, role: user.role }; + const payload: JwtPayload = { sub: user.id, email: user.email }; const token = jwt.sign(payload, config.jwtSecret, { expiresIn: config.jwtExpiresIn as jwt.SignOptions['expiresIn'], }); - console.info(`[auth] prihlasen: ${user.email} (${user.role})`); + console.info( + `[auth] prihlasen: ${user.email} (firem: ${user.memberships.length}, platforma: ${user.platformAdmin})`, + ); return res.json({ token, user: toPublicUser(user) }); }); diff --git a/src/routes/dashboard.ts b/src/routes/dashboard.ts index 29a64cf..d5e931e 100644 --- a/src/routes/dashboard.ts +++ b/src/routes/dashboard.ts @@ -1,6 +1,12 @@ -import { Router } from 'express'; +import { Router, type Request, type Response } from 'express'; import { z } from 'zod'; import { publicBaseUrl } from '../config.js'; +import { + accessFor, + isDenied, + resolveScope, + type ResolvedScope, +} from '../data/access.js'; import { createAutomation, deleteAutomation, @@ -40,8 +46,32 @@ export const dashboardRouter = Router(); // Cely dashboard je jen pro prihlasene. dashboardRouter.use(requireAuth); -dashboardRouter.get('/summary', (_req, res) => { - res.json(getSummary()); +/** + * Prevede query na povolene firmy. Pri odepreni rovnou odpovi a vrati null, + * takze volajici jen zkontroluje `if (!scope) return;`. + */ +function scopeOrDeny(req: Request, res: Response): ResolvedScope | null { + const resolved = resolveScope(req.user!, { + scope: typeof req.query.scope === 'string' ? req.query.scope : undefined, + tenantId: typeof req.query.tenantId === 'string' ? req.query.tenantId : undefined, + }); + + if (isDenied(resolved)) { + res.status(resolved.status).json({ error: resolved.error, message: resolved.message }); + return null; + } + return resolved; +} + +/** Co uzivatel smi, aby klient nemusel hadat, ktere prepinace kreslit. */ +dashboardRouter.get('/access', (req, res) => { + res.json(accessFor(req.user!)); +}); + +dashboardRouter.get('/summary', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + return res.json(getSummary(scope.tenantIds)); }); dashboardRouter.get('/incidents', (_req, res) => { @@ -50,10 +80,13 @@ dashboardRouter.get('/incidents', (_req, res) => { // ------------------------------------------------------------------- tickety -/** Resitele. Klient je potrebuje do nabidky prirazeni i do prehledu firmy. */ +/** Resitele vybrane firmy. Klient je potrebuje do nabidky prirazeni i do prehledu. */ dashboardRouter.get('/people', (req, res) => { - const me = req.user ? findPersonByEmail(req.user.email) : undefined; - res.json({ items: listPeople(), meId: me?.id ?? null }); + const scope = scopeOrDeny(req, res); + if (!scope) return; + + const me = findPersonByEmail(req.user!.email); + return res.json({ items: listPeople(scope.tenantIds), meId: me?.id ?? null }); }); const ticketStatuses: TicketStatus[] = ['new', 'open', 'waiting', 'resolved']; @@ -71,16 +104,22 @@ const ticketChannels: TicketChannel[] = [ * Filtr ze query parametru. Nesmyslnou hodnotu zahodime a zalogujeme - * je lepsi ukazat vic ticketu nez prazdny seznam bez vysvetleni. */ -function ticketFilterFrom(query: Record, myPersonId: string | null): TicketFilter { - const filter: TicketFilter = {}; +function ticketFilterFrom(query: Record, scope: ResolvedScope): TicketFilter { + const filter: TicketFilter = { tenantIds: scope.tenantIds }; + + // Pohled "moje" je silnejsi nez rucni filtr na resitele. + if (scope.scope === 'mine') { + filter.assignee = scope.personId ?? '__nikdo__'; + return applyRest(query, filter); + } const assignee = typeof query.assignee === 'string' ? query.assignee : undefined; - if (assignee === 'me') { - // Prihlaseny uzivatel nemusi byt resitel - pak nema smysl nic vracet. - filter.assignee = myPersonId ?? '__nikdo__'; - } else if (assignee) { - filter.assignee = assignee; - } + if (assignee) filter.assignee = assignee; + + return applyRest(query, filter); +} + +function applyRest(query: Record, filter: TicketFilter): TicketFilter { const status = typeof query.status === 'string' ? query.status : undefined; if (status) { @@ -98,24 +137,46 @@ function ticketFilterFrom(query: Record, myPersonId: string | n } dashboardRouter.get('/tickets', (req, res) => { - const me = req.user ? findPersonByEmail(req.user.email) : undefined; - const filter = ticketFilterFrom(req.query as Record, me?.id ?? null); - res.json({ items: listTickets(filter), meId: me?.id ?? null }); + const scope = scopeOrDeny(req, res); + if (!scope) return; + + const me = findPersonByEmail(req.user!.email); + const filter = ticketFilterFrom(req.query as Record, scope); + + return res.json({ + items: listTickets(filter), + meId: me?.id ?? null, + scope: scope.scope, + tenantId: scope.tenantId, + }); }); /** Kdo co ma u sebe. MUSI byt pred /tickets/:id, jinak by to spadlo na detail. */ -dashboardRouter.get('/tickets/workload', (_req, res) => { - res.json(getWorkload(listPeople())); +dashboardRouter.get('/tickets/workload', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + return res.json(getWorkload(listPeople(scope.tenantIds), scope.tenantIds)); }); dashboardRouter.get('/tickets/:id', (req, res) => { - const ticket = getTicket(req.params.id); + const scope = scopeOrDeny(req, res); + if (!scope) return; + + // Detail hledame pres vsechny firmy uzivatele, ne jen pres tu prave zvolenou. + // Jinak by odkaz z pohledu "vse" na ticket jine firmy vratil 404. + const reachable = accessFor(req.user!).tenants.map((tenant) => tenant.id); + const ticket = getTicket(req.params.id, reachable); if (!ticket) { return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); } return res.json(ticket); }); +/** Firmy, na ktere ma uzivatel dosah pri zapisu. */ +function writableTenants(req: Request): string[] { + return accessFor(req.user!).tenants.map((tenant) => tenant.id); +} + const assignSchema = z.object({ /** null = vratit do fronty. */ assigneeId: z.string().min(1).nullable(), @@ -130,11 +191,20 @@ dashboardRouter.post('/tickets/:id/assign', (req, res) => { }); } - const ticket = assignTicket(req.params.id, parsed.data.assigneeId); + // Prehazovat praci mezi lidmi smi jen admin. Agent si smi vzit ticket na sebe. + const access = accessFor(req.user!); + if (!access.canAssignOthers && parsed.data.assigneeId !== access.personId) { + return res.status(403).json({ + error: 'forbidden', + message: 'Přiřazovat ostatním může jen správce firmy. Ticket si můžete vzít na sebe.', + }); + } + + const ticket = assignTicket(req.params.id, parsed.data.assigneeId, writableTenants(req)); if (!ticket) { return res.status(404).json({ error: 'not_found', - message: 'Ticket nebo řešitel neexistuje.', + message: 'Ticket nebo řešitel neexistuje, nebo je řešitel z jiné firmy.', }); } return res.json(ticket); @@ -150,7 +220,7 @@ dashboardRouter.post('/tickets/:id/status', (req, res) => { return res.status(400).json({ error: 'validation_error', message: 'Neplatný stav ticketu.' }); } - const ticket = updateTicketStatus(req.params.id, parsed.data.status); + const ticket = updateTicketStatus(req.params.id, parsed.data.status, writableTenants(req)); if (!ticket) { return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); } @@ -171,7 +241,7 @@ dashboardRouter.post('/tickets/:id/comment', (req, res) => { } const author = req.user?.name ?? 'Portál'; - const ticket = addComment(req.params.id, author, parsed.data.text); + const ticket = addComment(req.params.id, author, parsed.data.text, writableTenants(req)); if (!ticket) { return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); } @@ -351,12 +421,14 @@ function validateFlowReferences(flow: z.infer): string[] { return problems; } -dashboardRouter.get('/automations', (_req, res) => { - res.json({ items: listAutomations() }); +dashboardRouter.get('/automations', (req, res) => { + const scope = scopeOrDeny(req, res); + if (!scope) return; + return res.json({ items: listAutomations(scope.tenantIds) }); }); dashboardRouter.get('/automations/:id', (req, res) => { - const automation = getAutomation(req.params.id); + const automation = getAutomation(req.params.id, writableTenants(req)); if (!automation) { return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); } @@ -372,7 +444,17 @@ dashboardRouter.post('/automations', (req, res) => { }); } - const automation = createAutomation(parsed.data.name); + // Zakladat se musi do konkretni firmy, pohled "vse" na to nestaci. + const scope = scopeOrDeny(req, res); + if (!scope) return; + if (!scope.tenantId) { + return res.status(400).json({ + error: 'tenant_required', + message: 'Vyberte firmu, do které se má automatizace založit.', + }); + } + + const automation = createAutomation(parsed.data.name, scope.tenantId); return res.status(201).json(automation); }); @@ -400,7 +482,7 @@ dashboardRouter.put('/automations/:id', (req, res) => { } } - const updated = updateAutomation(req.params.id, { ...parsed.data, flow }); + const updated = updateAutomation(req.params.id, { ...parsed.data, flow }, writableTenants(req)); if (!updated) { return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); } @@ -409,7 +491,7 @@ dashboardRouter.put('/automations/:id', (req, res) => { /** Nova adresa webhooku. Stara okamzite prestane fungovat - zamer, ne chyba. */ dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => { - const updated = regenerateWebhookToken(req.params.id); + const updated = regenerateWebhookToken(req.params.id, writableTenants(req)); if (!updated) { return res.status(404).json({ error: 'not_found', @@ -420,7 +502,7 @@ dashboardRouter.post('/automations/:id/webhook/regenerate', (req, res) => { }); dashboardRouter.delete('/automations/:id', (req, res) => { - if (!deleteAutomation(req.params.id)) { + if (!deleteAutomation(req.params.id, writableTenants(req))) { return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); } return res.status(204).end(); diff --git a/src/routes/simulate.ts b/src/routes/simulate.ts index 15b09d8..4edc3a0 100644 --- a/src/routes/simulate.ts +++ b/src/routes/simulate.ts @@ -14,6 +14,7 @@ import { type TicketPriority, type TraceInput, } from '../data/ticketStore.js'; +import { accessFor } from '../data/access.js'; import { requireAuth } from '../middleware/auth.js'; export const simulateRouter = Router(); @@ -342,8 +343,19 @@ simulateRouter.post('/', (req, res) => { }); } + // Simulace pisi do dat, takze musi vedet, ci. Bez firmy nema kam. + const access = accessFor(req.user!); + const tenantId = access.defaultTenantId; + if (!tenantId) { + return res.status(403).json({ + error: 'no_tenant', + message: 'Účet nepatří do žádné firmy, simulace nemá kam zapsat.', + }); + } + const tenantIds = access.tenants.map((tenant) => tenant.id); + const input = parsed.data; - console.info(`[simulace] ${input.action} spustil ${req.user?.email}`); + console.info(`[simulace] ${input.action} spustil ${req.user?.email} pro ${tenantId}`); switch (input.action) { case 'ticket.created': { @@ -356,6 +368,7 @@ simulateRouter.post('/', (req, res) => { const known = (input.knownCustomer ?? true) ? pick(knownCompanies) : null; const ticket = createTicket({ + tenantId, subject, // Kanal posila cely text, predmet je jen jeho zkraceni. body: input.body ?? `${subject}. Poslal ${contact} přes ${preset.triggerLabel}.`, @@ -376,14 +389,14 @@ simulateRouter.post('/', (req, res) => { } case 'ticket.resolved': { - const target = input.ticketId ? { id: input.ticketId } : firstOpenTicket(); + const target = input.ticketId ? { id: input.ticketId } : firstOpenTicket(tenantIds); if (!target) { return res.status(409).json({ error: 'nothing_to_resolve', message: 'Není co vyřešit, všechny tickety jsou hotové.', }); } - const ticket = updateTicketStatus(target.id, 'resolved'); + const ticket = updateTicketStatus(target.id, 'resolved', tenantIds); if (!ticket) { return res.status(404).json({ error: 'not_found', message: 'Ticket neexistuje.' }); } @@ -416,7 +429,7 @@ simulateRouter.post('/', (req, res) => { } case 'automation.run': { - const automations = listAutomations().filter((a) => a.enabled); + const automations = listAutomations(tenantIds).filter((a) => a.enabled); const targetId = input.automationId ?? automations[0]?.id; if (!targetId) { return res.status(409).json({ @@ -424,7 +437,7 @@ simulateRouter.post('/', (req, res) => { message: 'Není co spustit, žádná automatizace není aktivní.', }); } - const automation = recordRun(targetId, input.ok ?? true); + const automation = recordRun(targetId, input.ok ?? true, tenantIds); if (!automation) { return res.status(404).json({ error: 'not_found', message: 'Automatizace neexistuje.' }); } diff --git a/src/types.ts b/src/types.ts index 33e27de..3643715 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,18 @@ -export type UserRole = 'admin' | 'client'; +/** + * Uzivatel muze patrit do vic firem najednou (napr. externista, ktery dela + * servicedesk dvema klientum). Proto `memberships`, ne jedno `tenantId`. + * + * Role je vzdy az uvnitr firmy. Pristup napric firmami je zvlast, jako + * `platformAdmin` - to je nase pravo, ne zakaznicke. + */ + +/** Role uvnitr jedne firmy. */ +export type TenantRole = 'admin' | 'agent'; + +export interface Membership { + tenantId: string; + role: TenantRole; +} export interface User { id: string; @@ -6,8 +20,9 @@ export interface User { /** bcrypt hash - nikdy neposilat na klienta */ passwordHash: string; name: string; - role: UserRole; - company: string; + /** Vidi napric vsemi firmami a muze mezi nimi prepinat. */ + platformAdmin: boolean; + memberships: Membership[]; } /** Verze uzivatele bezpecna pro odeslani na klienta. */ @@ -15,17 +30,21 @@ export interface PublicUser { id: string; email: string; name: string; - role: UserRole; - company: string; + platformAdmin: boolean; + memberships: Membership[]; } export interface JwtPayload { sub: string; email: string; - role: UserRole; } export function toPublicUser(user: User): PublicUser { const { passwordHash: _passwordHash, ...rest } = user; return rest; } + +/** Role uzivatele v dane firme, nebo undefined kdyz do ni nepatri. */ +export function roleIn(user: User, tenantId: string): TenantRole | undefined { + return user.memberships.find((m) => m.tenantId === tenantId)?.role; +} diff --git a/web/src/auth/AuthContext.tsx b/web/src/auth/AuthContext.tsx index bcf1d3f..d75a668 100644 --- a/web/src/auth/AuthContext.tsx +++ b/web/src/auth/AuthContext.tsx @@ -2,12 +2,21 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } import type { ReactNode } from 'react'; import { apiFetch, getToken, setToken } from '@/lib/api'; +export type TenantRole = 'admin' | 'agent'; + +export interface Membership { + tenantId: string; + role: TenantRole; +} + export interface AuthUser { id: string; email: string; name: string; - role: 'admin' | 'client'; - company: string; + /** Vidi napric vsemi firmami a muze mezi nimi prepinat. */ + platformAdmin: boolean; + /** Uzivatel muze patrit do vic firem, v kazde s jinou roli. */ + memberships: Membership[]; } interface AuthState { diff --git a/web/src/components/dashboard/DashboardLayout.tsx b/web/src/components/dashboard/DashboardLayout.tsx index ccfa6cb..a60ae08 100644 --- a/web/src/components/dashboard/DashboardLayout.tsx +++ b/web/src/components/dashboard/DashboardLayout.tsx @@ -13,13 +13,15 @@ import { } from 'lucide-react'; import { useEffect, useState } from 'react'; import { Link, NavLink, Outlet, useLocation, useNavigate } from 'react-router-dom'; -import { useAuth } from '@/auth/AuthContext'; +import { useAuth, type AuthUser } from '@/auth/AuthContext'; import { EventStreamProvider } from '@/components/dashboard/EventStreamProvider'; import { EventToasts } from '@/components/dashboard/EventToasts'; import { LiveIndicator } from '@/components/dashboard/LiveIndicator'; import { SimulationModal } from '@/components/dashboard/SimulationModal'; import { Logo } from '@/components/layout/Logo'; import { cn } from '@/lib/cn'; +import { useApiQuery } from '@/lib/useApiQuery'; +import type { Access } from '@/types/dashboard'; const nav = [ { to: '/dashboard', label: 'Přehled', icon: LayoutDashboard, end: true }, @@ -43,8 +45,18 @@ export function DashboardLayout() { ); } +/** Kratky popis pristupu do topbaru. Uzivatel musi poznat, co vlastne vidi. */ +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; + if (count > 1) return `Přístup do ${count} firem`; + const role = user?.memberships[0]?.role; + return role === 'admin' ? 'Správce firmy' : 'Řešitel'; +} + function DashboardShell() { const { user, logout } = useAuth(); + const access = useApiQuery('/api/dashboard/access'); const navigate = useNavigate(); const location = useLocation(); const [sidebarOpen, setSidebarOpen] = useState(false); @@ -157,11 +169,9 @@ function DashboardShell() {

- {user?.company ?? 'Klientský portál'} -

-

- {user?.role === 'admin' ? 'Interní přístup' : 'Klientský přístup'} + {access.data?.tenants[0]?.name ?? 'Klientský portál'}

+

{accessLabel(user, access.data)}

diff --git a/web/src/components/dashboard/flow/FlowCanvas.tsx b/web/src/components/dashboard/flow/FlowCanvas.tsx index 48eb962..ce8235f 100644 --- a/web/src/components/dashboard/flow/FlowCanvas.tsx +++ b/web/src/components/dashboard/flow/FlowCanvas.tsx @@ -304,15 +304,18 @@ function ActionCard({ const inputs = resolved.operation.inputs; return ( -
-
- - +
+
+ +
-

{resolved.connector.name}

-

{resolved.operation.name}

-

{resolved.operation.description}

+

{resolved.connector.name}

+

{resolved.operation.name}

+ {/* Popis je jen doplnek. V uzkem sloupci by se lamal po slovech. */} +

+ {resolved.operation.description} +

-
- - +
+
+ +
@@ -403,7 +406,7 @@ function ConditionCard({ podmínku odeberte.

) : ( -

+

{describeCondition(field, step.operator, step.value)}

)} @@ -472,12 +475,18 @@ function ConditionCard({ />
-
+ {/* + Vetve vedle sebe jen tehdy, kdyz je na to v TETO karte misto. + Rozhoduje sirka karty, ne okna - kazde zanoreni ji pulí, takze + od urcite hloubky se vetve samy prepnou pod sebe misto toho, + aby se karty zmackly na par pixelu. + */} +
{(['yes', 'no'] as const).map((branch) => (
void; onRemove: () => void; }) { + // V uzke karte se ovladani skryva do sloupce, aby nebralo sirku textu. return ( -
+