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
co-authored by Claude Opus 5
parent dd021b5f69
commit 2a3d75c85e
25 changed files with 997 additions and 163 deletions
+4 -2
View File
@@ -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) });
});
+113 -31
View File
@@ -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<string, unknown>, myPersonId: string | null): TicketFilter {
const filter: TicketFilter = {};
function ticketFilterFrom(query: Record<string, unknown>, 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<string, unknown>, filter: TicketFilter): TicketFilter {
const status = typeof query.status === 'string' ? query.status : undefined;
if (status) {
@@ -98,24 +137,46 @@ function ticketFilterFrom(query: Record<string, unknown>, myPersonId: string | n
}
dashboardRouter.get('/tickets', (req, res) => {
const me = req.user ? findPersonByEmail(req.user.email) : undefined;
const filter = ticketFilterFrom(req.query as Record<string, unknown>, 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<string, unknown>, 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<typeof flowSchema>): 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();
+18 -5
View File
@@ -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.' });
}