Firmy a prava: tenance napric portalem

Portal nemel zadnou tenanci. Kterykoliv prihlaseny uzivatel videl vsechny
tickety vsech firem i cely seznam resitelu, requireRole se nikde nevolal.

Tenant je hranice viditelnosti, tenantId na ticketu, resiteli i automatizaci.
Uzivatel muze patrit do vic firem, v kazde s jinou roli. Pristup napric firmami
je zvlast jako platformAdmin.

Tri pohledy na tickety: all, tenant, mine. Admin mezi nimi prepina vcetne
vyberu firmy. O pravech rozhoduje jedine data/access.ts, klient si nic
nedovozuje a bere je z GET /api/dashboard/access.

Filtr na firmu je v ulozistich povinny argument, takze zapomenuty filtr
neznamena vse, ale nezkompiluje se. Cizi firma vraci 403 nebo 404, nikdy
tise zuzeny vysledek.

Prirazeni jen v ramci firmy. Prehazovat praci mezi lidmi smi jen admin,
agent si smi vzit ticket na sebe.

Zmena prihlasovani: ucet klient@firma.cz zanikl, demo ucty jsou nove.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JiriUhlir
2026-08-03 12:59:30 +02:00
parent dd021b5f69
commit 2a3d75c85e
25 changed files with 997 additions and 163 deletions
+11 -2
View File
@@ -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 {
@@ -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<Access>('/api/dashboard/access');
const navigate = useNavigate();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
@@ -157,11 +169,9 @@ function DashboardShell() {
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-semibold text-white">
{user?.company ?? 'Klientský portál'}
</p>
<p className="truncate text-xs text-white/40">
{user?.role === 'admin' ? 'Interní přístup' : 'Klientský přístup'}
{access.data?.tenants[0]?.name ?? 'Klientský portál'}
</p>
<p className="truncate text-xs text-white/40">{accessLabel(user, access.data)}</p>
</div>
<LiveIndicator className="hidden sm:inline-flex" />
@@ -304,15 +304,18 @@ function ActionCard({
const inputs = resolved.operation.inputs;
return (
<div className="rounded-card border border-ink-600/60 bg-ink-800/60 p-4 transition-colors hover:border-ink-600">
<div className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300">
<Icon className="size-4.5" />
<div className="@container rounded-card border border-ink-600/60 bg-ink-800/60 p-3 transition-colors hover:border-ink-600 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-ink-700/70 text-brand-300 @xs:size-10">
<Icon className="size-4 @xs:size-4.5" />
</span>
<div className="min-w-0 flex-1">
<p className="text-xs text-white/40">{resolved.connector.name}</p>
<p className="mt-0.5 font-semibold text-white">{resolved.operation.name}</p>
<p className="mt-0.5 text-sm text-white/50">{resolved.operation.description}</p>
<p className="truncate text-xs text-white/40">{resolved.connector.name}</p>
<p className="mt-0.5 font-semibold break-words text-white">{resolved.operation.name}</p>
{/* Popis je jen doplnek. V uzkem sloupci by se lamal po slovech. */}
<p className="mt-0.5 hidden text-sm text-white/50 @xs:block">
{resolved.operation.description}
</p>
</div>
<StepControls
canMoveUp={canMoveUp}
@@ -386,10 +389,10 @@ function ConditionCard({
}
return (
<div className="rounded-card border border-accent-400/30 bg-accent-500/6 p-4">
<div className="flex items-start gap-3">
<span className="grid size-10 shrink-0 place-items-center rounded-xl bg-accent-500/20 text-accent-300">
<GitBranch className="size-4.5" />
<div className="@container rounded-card border border-accent-400/30 bg-accent-500/6 p-3 sm:p-4">
<div className="flex items-start gap-2.5 sm:gap-3">
<span className="grid size-8 shrink-0 place-items-center rounded-xl bg-accent-500/20 text-accent-300 @xs:size-10">
<GitBranch className="size-4 @xs:size-4.5" />
</span>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
@@ -403,7 +406,7 @@ function ConditionCard({
podmínku odeberte.
</p>
) : (
<p className="mt-0.5 font-semibold text-white">
<p className="mt-0.5 font-semibold break-words text-white">
{describeCondition(field, step.operator, step.value)}
</p>
)}
@@ -472,12 +475,18 @@ function ConditionCard({
/>
</div>
<div className="mt-4 grid gap-4 lg:grid-cols-2">
{/*
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.
*/}
<div className="mt-4 grid gap-4 @2xl:grid-cols-2">
{(['yes', 'no'] as const).map((branch) => (
<div
key={branch}
className={cn(
'rounded-xl border p-3',
'min-w-0 rounded-xl border p-3',
branch === 'yes'
? 'border-ok-400/25 bg-ok-500/5'
: 'border-danger-400/25 bg-danger-500/5',
@@ -523,14 +532,15 @@ function StepControls({
onMove: (offset: number) => void;
onRemove: () => void;
}) {
// V uzke karte se ovladani skryva do sloupce, aby nebralo sirku textu.
return (
<div className="flex shrink-0 items-center gap-0.5">
<div className="flex shrink-0 flex-col items-center gap-0.5 @xs:flex-row">
<button
type="button"
onClick={() => onMove(-1)}
disabled={!canMoveUp}
title="Posunout výš"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25"
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25 @xs:size-8"
>
<ChevronUp className="size-4" />
<span className="sr-only">Posunout výš</span>
@@ -540,7 +550,7 @@ function StepControls({
onClick={() => onMove(1)}
disabled={!canMoveDown}
title="Posunout níž"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25"
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-white/5 hover:text-white disabled:pointer-events-none disabled:opacity-25 @xs:size-8"
>
<ChevronDown className="size-4" />
<span className="sr-only">Posunout níž</span>
@@ -549,7 +559,7 @@ function StepControls({
type="button"
onClick={onRemove}
title="Odebrat krok"
className="grid size-8 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400"
className="grid size-7 place-items-center rounded-lg text-white/35 transition-colors hover:bg-danger-500/12 hover:text-danger-400 @xs:size-8"
>
<Trash2 className="size-3.5" />
<span className="sr-only">Odebrat krok</span>
+35 -2
View File
@@ -1,11 +1,14 @@
import { Construction } from 'lucide-react';
import { useAuth } from '@/auth/AuthContext';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type { Access } from '@/types/dashboard';
/** Zamerne jen kostra - obsah nastaveni doresime pri stavbe dashboardu. */
export default function Settings() {
usePageMeta({ title: 'Nastavení — portál Automia' });
const { user } = useAuth();
const access = useApiQuery<Access>('/api/dashboard/access');
return (
<div className="space-y-6">
@@ -19,11 +22,41 @@ export default function Settings() {
<dl className="mt-4 grid gap-4 sm:grid-cols-2">
<Row label="Jméno" value={user?.name ?? '—'} />
<Row label="E-mail" value={user?.email ?? '—'} />
<Row label="Organizace" value={user?.company ?? '—'} />
<Row label="Role" value={user?.role === 'admin' ? 'Interní správce' : 'Klient'} />
<Row
label="Přístup"
value={user?.platformAdmin ? 'Správce platformy' : 'Účet firmy'}
/>
<Row label="Firem" value={String(user?.memberships.length ?? 0)} />
</dl>
</section>
<section className="glass rounded-card p-5 sm:p-6">
<h2 className="font-semibold text-white">Členství ve firmách</h2>
<p className="mt-1 text-sm text-white/45">
Role je vždy uvnitř firmy. Ve dvou firmách můžete mít různá práva.
</p>
{access.data && access.data.tenants.length > 0 ? (
<ul className="mt-4 space-y-2">
{access.data.tenants.map((tenant) => {
const role = user?.memberships.find((m) => m.tenantId === tenant.id)?.role;
return (
<li
key={tenant.id}
className="flex flex-wrap items-center justify-between gap-2 rounded-xl border border-ink-600/50 bg-ink-850/50 px-4 py-3"
>
<span className="text-sm text-white/80">{tenant.name}</span>
<span className="text-xs text-white/45">
{role === 'admin' ? 'Správce firmy' : role === 'agent' ? 'Řešitel' : 'Přes platformu'}
</span>
</li>
);
})}
</ul>
) : (
<p className="mt-4 text-sm text-white/40">Účet nepatří do žádné firmy.</p>
)}
</section>
<section className="rounded-card border border-dashed border-ink-600/70 p-8 text-center">
<Construction className="mx-auto size-6 text-white/30" />
<h2 className="mt-3 font-semibold text-white">Připravujeme</h2>
+68 -20
View File
@@ -14,8 +14,10 @@ import { formatDateTime, formatRelative } from '@/lib/format';
import { useApiQuery } from '@/lib/useApiQuery';
import { usePageMeta } from '@/lib/usePageMeta';
import type {
Access,
TicketChannel,
TicketListResponse,
TicketScope,
TicketStatus,
Workload,
} from '@/types/dashboard';
@@ -30,6 +32,12 @@ const statusFilters: Array<{ value: TicketStatus; label: string }> = [
{ value: 'resolved', label: 'Vyřešené' },
];
const scopeOptions: Array<{ value: TicketScope; label: string; hint: string }> = [
{ value: 'all', label: 'Vše', hint: 'Napříč všemi firmami. Jen pro správce platformy.' },
{ value: 'tenant', label: 'Firma', hint: 'Všechny tickety vybrané firmy.' },
{ value: 'mine', label: 'Moje', hint: 'Jen tickety, které mám u sebe.' },
];
const channelFilters: Array<{ value: TicketChannel; label: string }> = [
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'facebook', label: 'Facebook' },
@@ -43,7 +51,12 @@ const channelFilters: Array<{ value: TicketChannel; label: string }> = [
export default function Tickets() {
usePageMeta({ title: 'Tickety — portál Automia' });
/** null = vsichni, 'me' = prihlaseny, 'unassigned' = fronta, jinak ID resitele. */
// Co uzivatel smi, rozhoduje server. Klient jen kresli, co prislo.
const access = useApiQuery<Access>('/api/dashboard/access');
const [scope, setScope] = useState<TicketScope | null>(null);
const [tenantId, setTenantId] = useState<string | null>(null);
/** null = vsichni, 'unassigned' = fronta, jinak ID resitele. */
const [assignee, setAssignee] = useState<string | null>(null);
const [status, setStatus] = useState<TicketStatus | null>(null);
const [channel, setChannel] = useState<TicketChannel | null>(null);
@@ -52,17 +65,26 @@ export default function Tickets() {
// Filtrovani resi server, aby seznam a prehled nikdy neukazovaly jina cisla.
const path = useMemo(() => {
const params = new URLSearchParams();
if (scope) params.set('scope', scope);
if (tenantId) params.set('tenantId', tenantId);
if (assignee) params.set('assignee', assignee);
if (status) params.set('status', status);
if (channel) params.set('channel', channel);
const search = params.toString();
return `/api/dashboard/tickets${search ? `?${search}` : ''}`;
}, [assignee, status, channel]);
}, [scope, tenantId, assignee, status, channel]);
const workloadPath = useMemo(() => {
const params = new URLSearchParams();
// Prehled vytizeni je vzdy za firmu. U pohledu "vse" nema tym co znamenat.
if (scope === 'all') params.set('scope', 'all');
else if (tenantId) params.set('tenantId', tenantId);
const search = params.toString();
return `/api/dashboard/tickets/workload${search ? `?${search}` : ''}`;
}, [scope, tenantId]);
const tickets = useApiQuery<TicketListResponse>(path, { refetchOn: [...ticketEvents] });
const workload = useApiQuery<Workload>('/api/dashboard/tickets/workload', {
refetchOn: [...ticketEvents],
});
const workload = useApiQuery<Workload>(workloadPath, { refetchOn: [...ticketEvents] });
// Hledani je jen dohledani v uz nactenem seznamu, proto na klientovi.
const items = useMemo(() => {
@@ -78,9 +100,15 @@ export default function Tickets() {
);
}, [tickets.data, query]);
const meId = tickets.data?.meId ?? null;
const data = tickets.data;
const meId = data?.meId ?? null;
const filtered = assignee !== null || status !== null || channel !== null;
const allowedScopes = access.data?.scopes ?? [];
const tenants = access.data?.tenants ?? [];
// Dokud server neodpovi, drzime se toho, co poslal u seznamu.
const activeScope = scope ?? data?.scope ?? null;
return (
<div className="space-y-6">
<header>
@@ -105,21 +133,41 @@ export default function Tickets() {
/>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{/* Pohled je jina osa nez filtry - urcuje, ktera data vubec dorazi. */}
<div className="mt-4 flex flex-wrap items-center gap-2">
{scopeOptions
.filter((option) => allowedScopes.includes(option.value))
.map((option) => (
<Chip
key={option.value}
active={activeScope === option.value}
onClick={() => setScope(option.value)}
title={option.hint}
>
{option.label}
</Chip>
))}
{/* Prepinac firmy davá smysl jen tomu, kdo jich ma vic. */}
{tenants.length > 1 && activeScope !== 'all' && (
<select
value={tenantId ?? data?.tenantId ?? ''}
onChange={(event) => setTenantId(event.target.value)}
aria-label="Firma"
className="rounded-full border border-ink-600/70 bg-ink-850/70 px-3 py-1.5 text-xs text-white 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>
)}
</div>
<div className="mt-2 flex flex-wrap gap-2">
<Chip active={assignee === null} onClick={() => setAssignee(null)}>
Všechny
</Chip>
<Chip
active={assignee === 'me'}
onClick={() => setAssignee(assignee === 'me' ? null : 'me')}
disabled={meId === null}
title={
meId === null
? 'Přihlášený účet není v seznamu řešitelů, nemá tedy vlastní tickety.'
: undefined
}
>
Moje
Kdokoliv
</Chip>
<Chip
active={assignee === 'unassigned'}
+24
View File
@@ -17,9 +17,27 @@ export type TicketChannel =
export type IncidentSeverity = 'sev1' | 'sev2' | 'sev3';
export type IncidentStatus = 'investigating' | 'identified' | 'monitoring' | 'resolved';
export interface Tenant {
id: string;
name: string;
}
/** Pohled na tickety. Server rozhoduje, ktery smi kdo pouzit. */
export type TicketScope = 'all' | 'tenant' | 'mine';
/** Co uzivatel smi. Klient si to nesmi dovozovat sam. */
export interface Access {
scopes: TicketScope[];
tenants: Tenant[];
defaultTenantId: string | null;
canAssignOthers: boolean;
personId: string | null;
}
/** Resitel ticketu. Nemusi mit ucet v portalu, spojka je e-mail. */
export interface Person {
id: string;
tenantId: string;
name: string;
email: string;
role: string;
@@ -37,6 +55,8 @@ export interface TicketCustomer {
export interface Ticket {
id: string;
/** Firma, ktere ticket patri. */
tenantId: string;
subject: string;
/** Cely text pozadavku. Prazdny = krok "Zalozit ticket" obsah nenaplnil. */
body: string;
@@ -80,6 +100,10 @@ export interface TicketDetail extends Ticket {
export interface TicketListResponse {
items: Ticket[];
meId: string | null;
/** Pohled, ktery server skutecne pouzil. */
scope: TicketScope;
/** Firma, na kterou je pohled zamereny. null u pohledu `all`. */
tenantId: string | null;
}
export interface PeopleResponse {