feat(perms): drop dead document export permissions; seed the 12 survivors via migration

offers.export/orders.export/invoices.export gated nothing on the backend (PDF
routes enforce *.view); they only toggled frontend buttons and showed dead
role checkboxes. Migration deletes them (+ role grants) and idempotently seeds
the 12 enforced doc perms (offers/orders/invoices x view/create/edit/delete) +
admin grant, so a fresh prod DB has them (closes the seed-only drift gap).
Route reverts to orders.view; the 8 frontend *.export checks now use *.view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-09 21:19:38 +02:00
parent 3268cf2100
commit d445384408
10 changed files with 76 additions and 34 deletions

View File

@@ -0,0 +1,61 @@
-- Drop the three DEAD document "export" permissions and make the 12 surviving
-- document permissions reproducible on a fresh production database.
--
-- Why: `offers.export`, `orders.export`, `invoices.export` gate NOTHING on the
-- backend — every PDF/export route is guarded by `*.view`. They only ever
-- toggled frontend buttons and showed up as dead checkboxes in role management.
-- The 12 real keys (offers/orders/invoices x view/create/edit/delete) ARE each
-- enforced on >=1 route and must survive.
--
-- All 15 keys were previously defined ONLY in prisma/seed.ts (dev-only), so a
-- fresh production DB never had ANY of them. This migration both removes the 3
-- dead keys AND seeds the 12 survivors + their admin grant, mirroring the
-- prisma/seed.ts column set exactly (name, display_name, module, description,
-- created_at; role_permissions has only the composite PK).
--
-- Idempotent: safe whether the rows exist or not, and safe on a prod DB that
-- never had them. Deletes are no-ops when absent; INSERT IGNORE skips on the
-- unique `permissions.name` key and on the `role_permissions` composite PK.
-- 1. Remove the dead export role assignments first (FK-safe: children before
-- parents). No-op when the permissions/assignments don't exist.
DELETE FROM `role_permissions`
WHERE `permission_id` IN (
SELECT `id` FROM `permissions`
WHERE `name` IN ('offers.export', 'orders.export', 'invoices.export')
);
-- 2. Remove the dead export permissions themselves.
DELETE FROM `permissions`
WHERE `name` IN ('offers.export', 'orders.export', 'invoices.export');
-- 3. Idempotently (re)insert the 12 surviving document permissions. INSERT
-- IGNORE skips any that already exist (unique `name`). display_name /
-- description / module match prisma/seed.ts verbatim.
INSERT IGNORE INTO `permissions` (`name`, `display_name`, `module`, `description`, `created_at`) VALUES
('offers.view', 'Zobrazit nabídky', 'offers', 'Prohlížet seznam nabídek', NOW()),
('offers.create', 'Vytvořit nabídku', 'offers', 'Vytvářet nové nabídky', NOW()),
('offers.edit', 'Upravit nabídku', 'offers', 'Upravovat existující nabídky', NOW()),
('offers.delete', 'Smazat nabídku', 'offers', 'Mazat nabídky', NOW()),
('orders.view', 'Zobrazit objednávky', 'orders', 'Prohlížet seznam objednávek', NOW()),
('orders.create', 'Vytvořit objednávku', 'orders', 'Vytvářet nové objednávky', NOW()),
('orders.edit', 'Upravit objednávku', 'orders', 'Upravovat existující objednávky', NOW()),
('orders.delete', 'Smazat objednávku', 'orders', 'Mazat objednávky', NOW()),
('invoices.view', 'Zobrazit faktury', 'invoices', 'Prohlížet seznam faktur', NOW()),
('invoices.create', 'Vytvořit fakturu', 'invoices', 'Vytvářet nové faktury', NOW()),
('invoices.edit', 'Upravit fakturu', 'invoices', 'Upravovat existující faktury', NOW()),
('invoices.delete', 'Smazat fakturu', 'invoices', 'Mazat faktury', NOW());
-- 4. Grant all 12 survivors to the admin role. INSERT IGNORE on the composite
-- PK (role_id, permission_id) makes this idempotent and preserves any other
-- role assignments that already exist.
INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`)
SELECT r.id, p.id
FROM `roles` r
CROSS JOIN `permissions` p
WHERE r.name = 'admin'
AND p.name IN (
'offers.view', 'offers.create', 'offers.edit', 'offers.delete',
'orders.view', 'orders.create', 'orders.edit', 'orders.delete',
'invoices.view', 'invoices.create', 'invoices.edit', 'invoices.delete'
);

View File

@@ -95,12 +95,6 @@ const PERMISSIONS: {
module: "offers",
description: "Mazat nabídky",
},
{
name: "offers.export",
display_name: "Exportovat nabídku",
module: "offers",
description: "Exportovat nabídky do PDF",
},
// Objednávky
{
@@ -127,12 +121,6 @@ const PERMISSIONS: {
module: "orders",
description: "Mazat objednávky",
},
{
name: "orders.export",
display_name: "Exportovat objednávku",
module: "orders",
description: "Exportovat objednávky do PDF",
},
// Faktury
{
@@ -159,12 +147,6 @@ const PERMISSIONS: {
module: "invoices",
description: "Mazat faktury",
},
{
name: "invoices.export",
display_name: "Exportovat fakturu",
module: "invoices",
description: "Exportovat faktury do PDF",
},
// Projekty
{

View File

@@ -1147,7 +1147,7 @@ export default function InvoiceDetail() {
</Box>
</Box>
<Box sx={headerActionsSx}>
{hasPermission("invoices.export") && (
{hasPermission("invoices.view") && (
<Button
onClick={() => handleViewPdf(invoice.language || "cs")}
variant="outlined"
@@ -1690,7 +1690,7 @@ export default function InvoiceDetail() {
</Box>
</Box>
<Box sx={headerActionsSx}>
{isEdit && invoice && hasPermission("invoices.export") && (
{isEdit && invoice && hasPermission("invoices.view") && (
<Button
onClick={() => handleViewPdf(invoice.language || "cs")}
variant="outlined"

View File

@@ -599,7 +599,7 @@ export default function Invoices() {
>
{inv.status === "paid" ? ViewIcon : EditIcon}
</IconButton>
{hasPermission("invoices.export") && (
{hasPermission("invoices.view") && (
<IconButton
size="small"
onClick={() => handlePdf(inv)}

View File

@@ -649,7 +649,7 @@ export default function IssuedOrderDetail() {
// Form is editable only in create mode or while draft/sent.
const editable = !isEdit || form.status === "draft" || form.status === "sent";
const canExport = hasPermission("orders.export");
const canExport = hasPermission("orders.view");
// ─── Totals (live) ───
const totals = useMemo(() => {

View File

@@ -261,7 +261,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
>
{ViewIcon}
</IconButton>
{hasPermission("orders.export") && (
{hasPermission("orders.view") && (
<IconButton
size="small"
onClick={() => handleExportPdf(o)}
@@ -356,7 +356,7 @@ export default function IssuedOrders({ month, year }: IssuedOrdersProps) {
title="Zatím žádné vydané objednávky."
description={
hasPermission("orders.create")
? "Vytvořte první tlačítkem „Vytvořit objednávku vydanou“."
? "Vytvořte první tlačítkem „Vytvořit objednávku."
: undefined
}
/>

View File

@@ -1081,7 +1081,7 @@ export default function OfferDetail() {
</Box>
</Box>
<Box sx={headerActionsSx}>
{isEdit && hasPermission("offers.export") && (
{isEdit && hasPermission("offers.view") && (
<Button
onClick={handlePdf}
variant="outlined"

View File

@@ -695,7 +695,7 @@ export default function Offers() {
</IconButton>
)
)}
{hasPermission("offers.export") && (
{hasPermission("offers.view") && (
<IconButton
size="small"
onClick={() => handlePdf(q)}

View File

@@ -434,7 +434,7 @@ export default function OrderDetail() {
</Button>
)
)}
{hasPermission("orders.export") && (
{hasPermission("orders.view") && (
<Button
variant="outlined"
color="inherit"

View File

@@ -1,6 +1,6 @@
import { FastifyInstance } from "fastify";
import prisma from "../../config/database";
import { requireAnyPermission } from "../../middleware/auth";
import { requirePermission } from "../../middleware/auth";
import { parseId, success } from "../../utils/response";
import { localDateCzStr } from "../../utils/date";
import { htmlToPdf } from "../../utils/html-to-pdf";
@@ -821,12 +821,11 @@ ${indentCSS}
export default async function issuedOrdersPdfRoutes(fastify: FastifyInstance) {
fastify.get<{ Params: { id: string } }>(
"/:id",
// Mirror invoices-pdf (invoices.view): view-level access serves both the
// user-facing Export PDF and the ?save=1 NAS archive. Using requireAny so an
// order editor (who has orders.view) can trigger archival on save without
// also needing orders.export — otherwise the fire-and-forget archive 403s
// silently. orders.export holders keep access too.
{ preHandler: requireAnyPermission("orders.view", "orders.export") },
// Mirror invoices-pdf (invoices.view): view-level access serves BOTH the
// user-facing Export PDF and the ?save=1 NAS archive. There is no separate
// orders.export permission (it was dead — gated nothing — and was removed);
// orders.view is the single gate for both paths.
{ preHandler: requirePermission("orders.view") },
async (request, reply) => {
const id = parseId(request.params.id, reply);
if (id === null) return;