Refactor API calls in product editor and bulk edit components to use apiClient instead of axios. Enhance UploadFlow and MatchColumnsStep to support restoring previously matched columns for better user experience during back navigation. Introduce mapping signature handling in ValidationStep for improved data integrity on navigation. Update types and store management to accommodate new features.
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
-- Permissions UI cleanup — 2026-05-28
|
||||
--
|
||||
-- WHAT THIS DOES
|
||||
-- Rewrites permissions.name and permissions.category for clarity.
|
||||
-- Consolidates 17 categories down to 10. Renames ambiguous entries so
|
||||
-- the User Management UI reads cleanly. Does NOT touch permissions.code,
|
||||
-- so every existing route gate (backend requirePermission and frontend
|
||||
-- Protected page=) and every row in user_permissions keeps working
|
||||
-- without any code change or remapping.
|
||||
--
|
||||
-- WHAT THIS DOES *NOT* DO
|
||||
-- No DROP/DELETE of any permission (except in the optional block at the
|
||||
-- very bottom — commented out by default). No changes to permissions.code.
|
||||
-- No INSERT of new permissions. No changes to other tables.
|
||||
--
|
||||
-- SAFETY
|
||||
-- Wrapped in a transaction. Run end-to-end; if any row count is wrong,
|
||||
-- ROLLBACK and inspect. The auth middleware caches the user's loaded
|
||||
-- permissions for 60s — after this runs, names refresh on next cache
|
||||
-- miss for the admin UI. user_permissions joins by id, so granted
|
||||
-- permissions remain granted across renames.
|
||||
|
||||
BEGIN;
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 1. Category consolidation
|
||||
------------------------------------------------------------------------
|
||||
|
||||
-- 1a. Orphan "Pages" (just the Settings page) → Pages/Settings
|
||||
UPDATE permissions SET category = 'Pages/Settings'
|
||||
WHERE code = 'access:settings';
|
||||
|
||||
-- 1b. Existing settings:* tab-access permissions stay under a renamed
|
||||
-- "Settings Tabs" category (was "Settings") so it reads less ambiguously
|
||||
-- next to "Pages/Settings" and "Write Actions".
|
||||
UPDATE permissions SET category = 'Settings Tabs'
|
||||
WHERE category = 'Settings';
|
||||
|
||||
-- 1c. Dashboard widget perms keep their codes but get a clearer category name.
|
||||
UPDATE permissions SET category = 'Dashboard Widgets'
|
||||
WHERE category = 'Dashboard Components';
|
||||
|
||||
-- 1d. Collapse the 7 single-member "new permission" categories
|
||||
-- (Imports, Data, AI, Templates, Images, Audit, ACOT, Dashboard
|
||||
-- [the dashboard-server one — distinct from Pages/Dashboard])
|
||||
-- into a single "Write Actions" category.
|
||||
UPDATE permissions SET category = 'Write Actions'
|
||||
WHERE category IN ('Imports', 'Data', 'AI', 'Templates',
|
||||
'Images', 'Audit', 'ACOT', 'Dashboard');
|
||||
|
||||
-- 1e. The "Admin" category (just Show Debug) stays as-is — single member
|
||||
-- but conceptually distinct enough that bucketing under Write Actions
|
||||
-- would muddy the meaning. Leaving it alone.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 2. Name renames for clarity
|
||||
------------------------------------------------------------------------
|
||||
|
||||
-- 2a. Distinguish "page that lets you do X" from "permission to do X"
|
||||
-- by suffixing the page-access permissions with " (page)" where the
|
||||
-- name otherwise collides with the corresponding write permission.
|
||||
UPDATE permissions SET name = 'Import Products (page)'
|
||||
WHERE code = 'access:import';
|
||||
|
||||
UPDATE permissions SET name = 'Product Editor (page)'
|
||||
WHERE code = 'access:product_editor';
|
||||
|
||||
UPDATE permissions SET name = 'Bulk Edit (page)'
|
||||
WHERE code = 'access:bulk_edit';
|
||||
|
||||
-- 2b. Settings tab-access perms get a uniform "Settings: X Tab" name so
|
||||
-- they sort together and read as "what you're seeing access to" not
|
||||
-- "the feature itself."
|
||||
UPDATE permissions SET name = 'Settings: Data Management Tab'
|
||||
WHERE code = 'settings:data_management';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: Reusable Images Tab'
|
||||
WHERE code = 'settings:library_management';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: AI Prompts Tab'
|
||||
WHERE code = 'settings:prompt_management';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: Templates Tab'
|
||||
WHERE code = 'settings:templates';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: User Management Tab'
|
||||
WHERE code = 'settings:user_management';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: Audit Log Tab'
|
||||
WHERE code = 'settings:audit_log';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: Global Tab'
|
||||
WHERE code = 'settings:global';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: Products Tab'
|
||||
WHERE code = 'settings:products';
|
||||
|
||||
UPDATE permissions SET name = 'Settings: Vendors Tab'
|
||||
WHERE code = 'settings:vendors';
|
||||
|
||||
-- 2c. Write-action perms get verb-leading names so it's obvious what
|
||||
-- granting them actually allows.
|
||||
UPDATE permissions
|
||||
SET name = 'Product Import: Upload & Submit',
|
||||
description = 'Allows POST/PUT/DELETE on /api/import — image uploads, '
|
||||
|| 'product submission, deletions, and generate-upc. Does NOT '
|
||||
|| 'grant access to the Import Products page (access:import).'
|
||||
WHERE code = 'product_import';
|
||||
|
||||
UPDATE permissions
|
||||
SET name = 'Data Management: Run Operations',
|
||||
description = 'Allows POST/PUT/DELETE on /api/csv — CSV operations, '
|
||||
|| 'full updates, full resets. Does NOT grant access to the '
|
||||
|| 'Data Management settings tab (settings:data_management).'
|
||||
WHERE code = 'data_management';
|
||||
|
||||
UPDATE permissions
|
||||
SET name = 'Reusable Images: Upload & Delete',
|
||||
description = 'Allows uploads and deletions on /api/reusable-images. '
|
||||
|| 'Distinct from product_import (which gates uploads inside '
|
||||
|| 'the product import flow).'
|
||||
WHERE code = 'image_admin';
|
||||
|
||||
UPDATE permissions
|
||||
SET name = 'Templates: Create & Edit',
|
||||
description = 'Allows POST/PUT/DELETE on /api/templates.'
|
||||
WHERE code = 'templates_write';
|
||||
|
||||
UPDATE permissions
|
||||
SET name = 'AI: Edit Prompts & Validation',
|
||||
description = 'Allows write access to /api/ai-prompts and /api/ai-validation.'
|
||||
WHERE code = 'ai_admin';
|
||||
|
||||
UPDATE permissions
|
||||
SET name = 'Klaviyo: Clear Cache',
|
||||
description = 'Allows POST /api/klaviyo/events/clearCache.'
|
||||
WHERE code = 'klaviyo_admin';
|
||||
|
||||
UPDATE permissions
|
||||
SET name = 'Meta: Mutate Campaigns',
|
||||
description = 'Allows PATCH/POST on /api/meta/campaigns/*.'
|
||||
WHERE code = 'meta_write';
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 3. Verification — should all return non-zero
|
||||
------------------------------------------------------------------------
|
||||
-- Uncomment to inspect before commit:
|
||||
-- SELECT category, COUNT(*) FROM permissions GROUP BY category ORDER BY category;
|
||||
-- SELECT code, name, category FROM permissions
|
||||
-- WHERE category IN ('Write Actions', 'Settings Tabs', 'Pages/Settings')
|
||||
-- ORDER BY category, name;
|
||||
|
||||
COMMIT;
|
||||
|
||||
------------------------------------------------------------------------
|
||||
-- 4. OPTIONAL — drop unused "Reserved for future" codes
|
||||
------------------------------------------------------------------------
|
||||
-- These five codes are referenced only in their own description ("Reserved
|
||||
-- for…") and appear in NO route gate, NO Protected page=, and NO frontend
|
||||
-- permissions.includes() check. Verified 2026-05-28.
|
||||
--
|
||||
-- Run this block separately if you want to drop them. user_permissions has
|
||||
-- ON DELETE CASCADE on permission_id is NOT configured (only on user_id),
|
||||
-- so we must clear user_permissions rows first.
|
||||
--
|
||||
-- BEGIN;
|
||||
-- DELETE FROM user_permissions
|
||||
-- WHERE permission_id IN (SELECT id FROM permissions
|
||||
-- WHERE code IN ('klaviyo_write', 'google_write',
|
||||
-- 'typeform_write', 'acot_admin',
|
||||
-- 'audit_read'));
|
||||
-- DELETE FROM permissions
|
||||
-- WHERE code IN ('klaviyo_write', 'google_write', 'typeform_write',
|
||||
-- 'acot_admin', 'audit_read');
|
||||
-- COMMIT;
|
||||
@@ -66,7 +66,16 @@ export function authenticate({ pool, secret = process.env.JWT_SECRET, kioskIps =
|
||||
const kioskIpSet = parseKioskIps(kioskIps);
|
||||
|
||||
return async function authenticateMiddleware(req, res, next) {
|
||||
if (kioskIpSet.size > 0 && kioskIpSet.has(req.ip)) {
|
||||
// Kiosk IP bypass ONLY when no Authorization header was provided. A real
|
||||
// user on the same network (e.g. logged-in staff sharing the office NAT)
|
||||
// must keep their actual identity and permissions — otherwise this bypass
|
||||
// silently downgrades them to the permissionless kiosk user and they get
|
||||
// 403 on every gated route.
|
||||
if (
|
||||
kioskIpSet.size > 0 &&
|
||||
kioskIpSet.has(req.ip) &&
|
||||
!req.headers.authorization
|
||||
) {
|
||||
req.user = {
|
||||
id: 'kiosk',
|
||||
username: 'kiosk',
|
||||
|
||||
@@ -159,6 +159,25 @@ describe('authenticate middleware', () => {
|
||||
expect(req.user.is_kiosk).toBeUndefined();
|
||||
});
|
||||
|
||||
it('does NOT bypass when a Bearer token is present, even from a kiosk IP', async () => {
|
||||
// A real user logged in from the same NAT'd network as the kiosk must
|
||||
// keep their actual identity — otherwise the bypass silently strips
|
||||
// their permissions and they 403 on gated routes.
|
||||
const pool = makeFakePool({ 1: activeUser }, { 1: ['product_import'] });
|
||||
const mw = authenticate({ pool, secret: SECRET, kioskIps: '203.0.113.7' });
|
||||
const req = {
|
||||
headers: { authorization: `Bearer ${validToken}` },
|
||||
ip: '203.0.113.7',
|
||||
};
|
||||
const res = makeRes();
|
||||
const next = vi.fn();
|
||||
await mw(req, res, next);
|
||||
expect(next).toHaveBeenCalledOnce();
|
||||
expect(req.user.id).toBe(1);
|
||||
expect(req.user.is_kiosk).toBeUndefined();
|
||||
expect(req.user.permissions).toEqual(['product_import']);
|
||||
});
|
||||
|
||||
it('does not bypass when KIOSK_IPS is empty, even if req.ip is undefined', async () => {
|
||||
const pool = makeFakePool({ 1: activeUser });
|
||||
const mw = authenticate({ pool, secret: SECRET, kioskIps: '' });
|
||||
|
||||
Reference in New Issue
Block a user