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:
@@ -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'
|
||||
);
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -695,7 +695,7 @@ export default function Offers() {
|
||||
</IconButton>
|
||||
)
|
||||
)}
|
||||
{hasPermission("offers.export") && (
|
||||
{hasPermission("offers.view") && (
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => handlePdf(q)}
|
||||
|
||||
@@ -434,7 +434,7 @@ export default function OrderDetail() {
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
{hasPermission("orders.export") && (
|
||||
{hasPermission("orders.view") && (
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="inherit"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user