From 7dffcef66170fe7c471d5b5ab0d59b01ffdd81b2 Mon Sep 17 00:00:00 2001 From: "Schero D." Date: Sat, 7 Nov 2026 22:08:57 +0100 Subject: [PATCH 1/9] fix(server): keep bookmarks and permissions consistent across several processes Every write to an existing bookmark (edit, share, update view, pin, delete) now locks the bookmark row with SELECT ... FOR UPDATE inside its transaction, the pattern Strapi's admin uses for the first admin. Writes to one bookmark run one after another across processes: share edits no longer interleave, parallel pins no longer insert two preference rows, a delete no longer deadlocks with a parallel edit, and a write that waited for a delete answers 404 instead of leaving orphaned share or pin rows. The 3.x permission migration grants each role's actions under a role row lock. The Super Admin role gets the Magic Mark actions the same way before Strapi's unlocked super admin sync runs, so a simultaneous start no longer duplicates permission rows; leftovers from earlier starts are removed in bootstrap. Found and verified with 8 Strapi 5.61 processes on Postgres 16 (3.x migration started by all of them at once plus about 1,260 parallel requests). The fake db in the tests now yields per query and implements row locks, so the races are reproduced in unit tests. Co-Authored-By: Claude Opus 5.5 --- server/src/bootstrap.ts | 10 +++ server/src/constants.ts | 2 + server/src/register.ts | 10 +++ server/src/services/bookmarks.ts | 55 ++++++++++--- server/src/services/maintenance.ts | 121 +++++++++++++++++++++++++---- tests/helpers/fake-strapi.cjs | 75 +++++++++++++++++- tests/server-lifecycle.test.cjs | 96 +++++++++++++++++++++++ tests/server-service.test.cjs | 91 ++++++++++++++++++++++ 8 files changed, 429 insertions(+), 31 deletions(-) diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index bbed521..091413e 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -3,6 +3,8 @@ * * 1. Deletes the store keys of the removed 3.x license system (no network calls, no timers). * 2. Migrates 3.x bookmark rows (paths, content types, share JSON, global pins). + * 2b. Removes duplicate Magic Mark permission rows that a simultaneous start of several + * processes can leave (Strapi's super admin permission sync runs in every process). * 3. Subscribes to admin user and role deletions to clean up shares, pins and private * bookmarks of deleted users. `destroy` removes the subscription. */ @@ -40,6 +42,14 @@ const bootstrap = async ({ strapi }: { strapi: Core.Strapi }) => { strapi.log.error('[magic-mark] Bookmark migration failed', { name: (error as Error)?.name }); } + try { + await maintenance.removeDuplicatePermissions(); + } catch (error) { + strapi.log.error('[magic-mark] Removing duplicate permissions failed', { + name: (error as Error)?.name, + }); + } + const captureIds = async (event: { model: { uid: string }; params: { where?: unknown }; diff --git a/server/src/constants.ts b/server/src/constants.ts index 95bf984..6dd715e 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -9,6 +9,8 @@ export const PREFERENCE_UID = 'plugin::magic-mark.bookmark-preference'; export const ADMIN_USER_UID = 'admin::user'; export const ADMIN_ROLE_UID = 'admin::role'; export const ADMIN_PERMISSION_UID = 'admin::permission'; +/** Code of Strapi's built-in super admin role. */ +export const SUPER_ADMIN_CODE = 'strapi-super-admin'; /** * Permission action uids registered with the admin action provider. diff --git a/server/src/register.ts b/server/src/register.ts index c164296..61ed5f7 100644 --- a/server/src/register.ts +++ b/server/src/register.ts @@ -7,6 +7,9 @@ * - Hooks the 3.x -> 4.x permission migration into `strapi::content-types.afterSync`, which runs * after the schema sync and before the admin bootstrap deletes permissions of unregistered * (3.x) actions. Roles therefore keep their access across the upgrade. + * - In the same hook, gives the super admin role the Magic Mark actions under a row lock, so + * the admin's unlocked super admin sync has nothing left to insert when several processes + * start at once (no duplicate permission rows). */ import type { Core } from '@strapi/strapi'; import { ACTION_UIDS, PLUGIN_ID } from './constants'; @@ -40,6 +43,13 @@ const register = async ({ strapi }: { strapi: Core.Strapi }) => { name: (error as Error)?.name, }); } + try { + await strapi.plugin(PLUGIN_ID).service('maintenance').grantSuperAdminActions(); + } catch (error) { + strapi.log.error('[magic-mark] Granting super admin actions failed', { + name: (error as Error)?.name, + }); + } }); }; diff --git a/server/src/services/bookmarks.ts b/server/src/services/bookmarks.ts index 08a6e08..38ec4b4 100644 --- a/server/src/services/bookmarks.ts +++ b/server/src/services/bookmarks.ts @@ -15,6 +15,13 @@ * page are loaded with one query each (no N+1). Writes that touch several rows run in one * transaction, and no state is kept in the process, so any number of Strapi processes can * serve the same database. + * + * Concurrency: every write to an existing bookmark (edit, share, view, pin, delete) first locks + * the bookmark row with `SELECT ... FOR UPDATE` inside its transaction, the pattern Strapi's own + * admin uses for the first admin. Writes to one bookmark therefore run one after another across + * processes: two share edits never interleave their delete-and-insert steps, two pins never both + * insert a preference row, and a write that waited for a delete answers 404 instead of leaving + * share or pin rows behind. SQLite ignores `FOR UPDATE` and serialises writers on its own. */ import type { Core } from '@strapi/strapi'; import { errors } from '@strapi/utils'; @@ -358,7 +365,26 @@ const bookmarkService = ({ strapi }: { strapi: Core.Strapi }) => { return { isPublic: visibility === 'public', roles, users }; }; - /** Replaces the share rows of a bookmark. Call inside a transaction. */ + /** + * Locks the bookmark row until the surrounding transaction ends (`FOR UPDATE`). + * @param trx - Transaction from `db.transaction`. + * @param documentId - Bookmark documentId. + * @throws {errors.NotFoundError} When the bookmark no longer exists (for example deleted by + * another process while this request waited for the lock). + */ + const lockBookmark = async (trx: unknown, documentId: string) => { + const row = await db + .queryBuilder(BOOKMARK_UID) + .select(['id']) + .where({ documentId }) + .first() + .transacting(trx) + .forUpdate() + .execute(); + if (!row) throw notFound(); + }; + + /** Replaces the share rows of a bookmark. Call inside a transaction that holds the lock. */ const replaceShares = async (bookmarkDocumentId: string, roles: string[], users: string[]) => { await db.query(SHARE_UID).deleteMany({ where: { bookmarkDocumentId } }); const data = [ @@ -496,7 +522,8 @@ const bookmarkService = ({ strapi }: { strapi: Core.Strapi }) => { /** * Updates name, description, icon and sharing of a bookmark. - * @throws {errors.NotFoundError} / {errors.ForbiddenError} See findEditable. + * @throws {errors.NotFoundError} / {errors.ForbiddenError} See findEditable; also 404 when + * the bookmark was deleted while this request waited for the row lock. * @sideeffect Updates the bookmark and replaces its share rows in one transaction. */ async update(viewer: Viewer, documentId: string, input: BookmarkInput) { @@ -508,7 +535,8 @@ const bookmarkService = ({ strapi }: { strapi: Core.Strapi }) => { if (input.icon !== undefined) data.emoji = input.icon; if (sharing) data.isPublic = sharing.isPublic; - await db.transaction(async () => { + await db.transaction(async ({ trx }) => { + await lockBookmark(trx, documentId); await documents().update({ documentId, data, fields: ['documentId'] }); if (sharing) await replaceShares(documentId, sharing.roles, sharing.users); }); @@ -520,10 +548,13 @@ const bookmarkService = ({ strapi }: { strapi: Core.Strapi }) => { */ async updateView(viewer: Viewer, documentId: string, query: string) { await findEditable(viewer, documentId); - await documents().update({ - documentId, - data: { query, updaterId: viewer.userId }, - fields: ['documentId'], + await db.transaction(async ({ trx }) => { + await lockBookmark(trx, documentId); + await documents().update({ + documentId, + data: { query, updaterId: viewer.userId }, + fields: ['documentId'], + }); }); return this.findOne(viewer, documentId); }, @@ -553,7 +584,8 @@ const bookmarkService = ({ strapi }: { strapi: Core.Strapi }) => { */ async delete(viewer: Viewer, documentId: string) { await findEditable(viewer, documentId); - await db.transaction(async () => { + await db.transaction(async ({ trx }) => { + await lockBookmark(trx, documentId); await db.query(SHARE_UID).deleteMany({ where: { bookmarkDocumentId: documentId } }); await db.query(PREFERENCE_UID).deleteMany({ where: { bookmarkDocumentId: documentId } }); await documents().delete({ documentId }); @@ -563,12 +595,15 @@ const bookmarkService = ({ strapi }: { strapi: Core.Strapi }) => { /** * Pins or unpins a visible bookmark for the viewer only. Idempotent: updates every - * preference row of the pair and creates one only when none exists. + * preference row of the pair and creates one only when none exists. The bookmark row lock + * makes the check-then-insert safe across processes. + * @throws {errors.NotFoundError} When the bookmark is not visible or was deleted meanwhile. * @sideeffect Writes one bookmark-preference row. */ async setPinned(viewer: Viewer, documentId: string, pinned: boolean) { await findVisible(viewer, documentId); - await db.transaction(async () => { + await db.transaction(async ({ trx }) => { + await lockBookmark(trx, documentId); const where = { userId: viewer.userId, bookmarkDocumentId: documentId }; const { count } = await db.query(PREFERENCE_UID).updateMany({ where, diff --git a/server/src/services/maintenance.ts b/server/src/services/maintenance.ts index dc268c0..65e30c0 100644 --- a/server/src/services/maintenance.ts +++ b/server/src/services/maintenance.ts @@ -9,6 +9,8 @@ */ import type { Core } from '@strapi/strapi'; import { + ACTION_UIDS, + type ActionKey, ADMIN_PERMISSION_UID, ADMIN_ROLE_UID, ADMIN_USER_UID, @@ -19,6 +21,7 @@ import { PLUGIN_ID, PREFERENCE_UID, SHARE_UID, + SUPER_ADMIN_CODE, actionId, } from '../constants'; import { normalizeListViewPath, normalizeListViewQuery } from '../validation'; @@ -132,6 +135,44 @@ const maintenanceService = ({ strapi }: { strapi: Core.Strapi }) => { } }; + /** + * Grants `actions` to a role once. Several processes run this at the same start; locking the + * role row (`SELECT ... FOR UPDATE`, ignored by SQLite, which serialises writers anyway) makes + * the check-then-insert run in one process at a time, so no permission is duplicated. + * @param roleId - Numeric admin role id. + * @param actions - Full action ids. + * @sideeffect Creates admin permission rows through the admin permission service. + */ + const grantActions = async (roleId: number, actions: string[]) => { + await db.transaction(async ({ trx }) => { + const role = await db + .queryBuilder(ADMIN_ROLE_UID) + .select(['id']) + .where({ id: roleId }) + .first() + .transacting(trx) + .forUpdate() + .execute(); + if (!role) return; + const existing = await db.query(ADMIN_PERMISSION_UID).findMany({ + where: { role: { id: roleId }, action: { $in: actions } }, + select: ['action'], + }); + const have = new Set(existing.map((row: { action: string }) => row.action)); + const missing = actions.filter((action) => !have.has(action)); + if (!missing.length) return; + await strapi.admin.services.permission.createMany( + missing.map((action) => ({ + action, + subject: null, + properties: {}, + conditions: [], + role: roleId, + })) + ); + }); + }; + return { /** * Deletes the store keys of the removed 3.x license system. @@ -203,6 +244,7 @@ const maintenanceService = ({ strapi }: { strapi: Core.Strapi }) => { /** * Grants the 4.x actions to every role that held a 3.x action. Runs before the admin * removes permissions of unregistered actions, so existing roles keep their access. + * Safe when every process of a cluster runs it at once (per-role row lock). * @sideeffect Creates admin permission rows. */ async migrateLegacyPermissions() { @@ -217,24 +259,69 @@ const maintenanceService = ({ strapi }: { strapi: Core.Strapi }) => { for (const key of LEGACY_ACTION_MAP[permission.action] ?? []) set.add(actionId(key)); wanted.set(permission.role.id, set); } - for (const [roleId, actions] of wanted) { - const existing = await db.query(ADMIN_PERMISSION_UID).findMany({ - where: { role: { id: roleId }, action: { $in: [...actions] } }, - select: ['action'], - }); - const have = new Set(existing.map((row: { action: string }) => row.action)); - const missing = [...actions].filter((action) => !have.has(action)); - if (!missing.length) continue; - await strapi.admin.services.permission.createMany( - missing.map((action) => ({ - action, - subject: null, - properties: {}, - conditions: [], - role: roleId, - })) - ); + for (const [roleId, actions] of wanted) await grantActions(roleId, [...actions]); + }, + + /** + * Gives the super admin role every Magic Mark action before the admin bootstrap runs. + * Strapi's super admin sync (`resetSuperAdminPermissions`, admin bootstrap) does the same, + * but without a lock: when several processes start at once, each one inserts the missing + * actions and the role ends up with duplicates. Granting them here, under the role row lock, + * leaves the admin sync nothing to insert. Does nothing before the role exists (first start + * of a new project). + * @sideeffect Creates admin permission rows for the super admin role. + */ + async grantSuperAdminActions() { + const role = await db.query(ADMIN_ROLE_UID).findOne({ + where: { code: SUPER_ADMIN_CODE }, + select: ['id'], + }); + if (!role) return; + await grantActions( + role.id, + Object.keys(ACTION_UIDS).map((key) => actionId(key as ActionKey)) + ); + }, + + /** + * Removes duplicate Magic Mark permission rows (same role, action, subject, properties and + * conditions), keeping the oldest. Duplicates can be left by an earlier start of several + * processes at once on a new project, where the super admin role did not exist yet when + * `grantSuperAdminActions` ran. Concurrent runs pick the same row to keep. + * @returns Number of rows removed. + * @sideeffect Deletes admin permission rows through the admin permission service. + */ + async removeDuplicatePermissions() { + const rows: { + id: number; + action: string; + subject?: string | null; + properties?: unknown; + conditions?: unknown; + role?: { id: number } | null; + }[] = await db.query(ADMIN_PERMISSION_UID).findMany({ + where: { + action: { $in: Object.keys(ACTION_UIDS).map((key) => actionId(key as ActionKey)) }, + }, + select: ['id', 'action', 'subject', 'properties', 'conditions'], + populate: { role: { select: ['id'] } }, + orderBy: { id: 'asc' }, + }); + const seen = new Set(); + const duplicates: number[] = []; + for (const row of rows) { + const key = JSON.stringify([ + row.role?.id ?? null, + row.action, + row.subject ?? null, + row.properties ?? {}, + row.conditions ?? [], + ]); + if (seen.has(key)) duplicates.push(row.id); + else seen.add(key); } + if (duplicates.length) await strapi.admin.services.permission.deleteByIds(duplicates); + return duplicates.length; }, /** diff --git a/tests/helpers/fake-strapi.cjs b/tests/helpers/fake-strapi.cjs index 45383e8..fc7965c 100644 --- a/tests/helpers/fake-strapi.cjs +++ b/tests/helpers/fake-strapi.cjs @@ -2,7 +2,8 @@ /** * In-memory stand-in for the parts of Strapi the plugin's services use: `db.query(uid)`, - * `documents(uid)`, `db.transaction`, `store`, `log` and the admin permission service. + * `documents(uid)`, `db.transaction`, `db.queryBuilder` (row locks), `store`, `log` and the + * admin permission service. * Where clauses support the operators the plugin sends ($and, $or, $in, $ne, $null, * $notNull, $containsi, $gt and nested relation objects). */ @@ -96,8 +97,12 @@ const createFakeStrapi = ({ tables = {}, store = {} } = {}) => { }; for (const [uid, rows] of Object.entries(tables)) for (const row of rows) insert(uid, row); + // Every query yields once, so concurrent service calls interleave like requests on + // several processes do. + const tick = () => new Promise((resolve) => setImmediate(resolve)); const query = (uid) => ({ async findMany({ where, select, orderBy, limit, populate } = {}) { + await tick(); calls.queries.push({ uid, op: 'findMany' }); let rows = sortRows( table(uid).filter((r) => matches(r, where)), @@ -129,21 +134,26 @@ const createFakeStrapi = ({ tables = {}, store = {} } = {}) => { }; }, async count({ where } = {}) { + await tick(); return table(uid).filter((r) => matches(r, where)).length; }, async create({ data: row }) { + await tick(); return insert(uid, row); }, async createMany({ data: rows }) { + await tick(); rows.forEach((row) => insert(uid, row)); return { count: rows.length }; }, async updateMany({ where, data: patch }) { + await tick(); const rows = table(uid).filter((r) => matches(r, where)); rows.forEach((r) => Object.assign(r, patch)); return { count: rows.length }; }, async deleteMany({ where }) { + await tick(); const keep = table(uid).filter((r) => !matches(r, where)); const count = table(uid).length - keep.length; data.set(uid, keep); @@ -188,6 +198,51 @@ const createFakeStrapi = ({ tables = {}, store = {} } = {}) => { }, }); + // Row locks: `queryBuilder(...).forUpdate().execute()` waits until no other open + // transaction holds the same row, like `SELECT ... FOR UPDATE` on Postgres. Locks are + // released when the transaction callback settles. + const held = new Map(); + const acquire = async (key, trx) => { + while (held.has(key) && held.get(key).trx !== trx) await held.get(key).released; + if (held.has(key)) return; + let release; + const released = new Promise((resolve) => (release = resolve)); + held.set(key, { trx, released }); + trx.locks.push(() => { + held.delete(key); + release(); + }); + }; + const queryBuilder = (uid) => { + const state = { where: {}, trx: null, forUpdate: false }; + const qb = { + select: () => qb, + where: (where) => ((state.where = where), qb), + first: () => qb, + transacting: (trx) => ((state.trx = trx), qb), + forUpdate: () => ((state.forUpdate = true), qb), + async execute() { + calls.queries.push({ uid, op: state.forUpdate ? 'lock' : 'select', where: state.where }); + if (state.forUpdate) { + if (!state.trx?.locks) throw new Error('fake db: forUpdate outside a transaction'); + const row = table(uid).find((r) => matches(r, state.where)); + await acquire(`${uid}:${row ? row.id : JSON.stringify(state.where)}`, state.trx); + } + const row = table(uid).find((r) => matches(r, state.where)); + return row ? { id: row.id } : null; + }, + }; + return qb; + }; + const transaction = async (cb) => { + const trx = { locks: [] }; + try { + return await cb({ trx }); + } finally { + trx.locks.splice(0).forEach((release) => release()); + } + }; + const storeValues = new Map(Object.entries(store)); const logs = []; const services = {}; @@ -199,7 +254,8 @@ const createFakeStrapi = ({ tables = {}, store = {} } = {}) => { insert, db: { query, - transaction: async (cb) => cb({}), + transaction, + queryBuilder, lifecycles: { subscribe: (sub) => { strapi.subscriber = sub; @@ -225,8 +281,19 @@ const createFakeStrapi = ({ tables = {}, store = {} } = {}) => { admin: { services: { permission: { - createMany: async (permissions) => - permissions.map((p) => insert('admin::permission', { ...p, role: { id: p.role } })), + createMany: async (permissions) => { + await tick(); + return permissions.map((p) => + insert('admin::permission', { ...p, role: { id: p.role } }) + ); + }, + deleteByIds: async (ids) => { + await tick(); + data.set( + 'admin::permission', + table('admin::permission').filter((row) => !ids.includes(row.id)) + ); + }, actionProvider: { registerMany: async (actions) => { strapi.registeredActions = actions; diff --git a/tests/server-lifecycle.test.cjs b/tests/server-lifecycle.test.cjs index 9faf21e..cb25c7a 100644 --- a/tests/server-lifecycle.test.cjs +++ b/tests/server-lifecycle.test.cjs @@ -152,10 +152,79 @@ test('concurrent migrations (several processes) insert each share once', async ( assert.equal(strapi.table(SHARE).length, 2); }); +const legacyPermissionFixture = () => + withServices( + createFakeStrapi({ + tables: { + 'admin::role': [ + { id: 7, documentId: 'roleseven', name: 'Seven' }, + { id: 8, documentId: 'roleeight', name: 'Eight' }, + ], + 'admin::permission': [ + { action: 'plugin::magic-mark.settings.read', role: { id: 7 } }, + { action: 'plugin::magic-mark.settings.update', role: { id: 8 } }, + { action: 'plugin::magic-mark.bookmarks.read', role: { id: 8 } }, + ], + }, + }) + ); + +const actionsOf = (strapi, id) => + strapi + .table('admin::permission') + .filter((p) => p.role.id === id && p.action.includes('bookmarks.')) + .map((p) => p.action.split('.').pop()) + .sort(); + +test('8 processes migrating 3.x permissions at once create each permission once', async () => { + const strapi = legacyPermissionFixture(); + await Promise.all( + Array.from({ length: 8 }, () => maintenanceService({ strapi }).migrateLegacyPermissions()) + ); + assert.deepEqual(actionsOf(strapi, 7), ['read']); + assert.deepEqual(actionsOf(strapi, 8), ['create', 'delete', 'read', 'share', 'update']); +}); + +test('duplicate Magic Mark permissions from a simultaneous start are removed, the oldest stays', async () => { + const strapi = legacyPermissionFixture(); + const manage = 'plugin::magic-mark.bookmarks.manage'; + for (let i = 0; i < 6; i++) + strapi.insert('admin::permission', { action: manage, role: { id: 8 } }); + strapi.insert('admin::permission', { + action: manage, + role: { id: 8 }, + conditions: ['admin::is-creator'], + }); + strapi.insert('admin::permission', { action: manage, role: { id: 7 } }); + strapi.insert('admin::permission', { action: 'plugin::other.read', role: { id: 8 } }); + strapi.insert('admin::permission', { action: 'plugin::other.read', role: { id: 8 } }); + const firstId = strapi.table('admin::permission').find((p) => p.action === manage).id; + + const removed = await Promise.all( + Array.from({ length: 4 }, () => maintenanceService({ strapi }).removeDuplicatePermissions()) + ); + assert.ok(removed.some((n) => n === 5)); + const rows = strapi.table('admin::permission').filter((p) => p.action === manage); + assert.equal(rows.length, 3, 'one per role plus the row with other conditions'); + assert.ok( + rows.some((p) => p.id === firstId), + 'the oldest row stays' + ); + assert.equal( + strapi.table('admin::permission').filter((p) => p.action === 'plugin::other.read').length, + 2, + 'other plugins are not touched' + ); +}); + test('3.x permissions are mapped to the new actions for the same roles', async () => { const strapi = withServices( createFakeStrapi({ tables: { + 'admin::role': [ + { id: 7, documentId: 'roleseven', name: 'Seven' }, + { id: 8, documentId: 'roleeight', name: 'Eight' }, + ], 'admin::permission': [ { action: 'plugin::magic-mark.settings.read', role: { id: 7 } }, { action: 'plugin::magic-mark.settings.update', role: { id: 8 } }, @@ -211,3 +280,30 @@ test('the user deletion lifecycle captures ids before delete and cleans up after ); await server.destroy({ strapi }); }); + +test('the afterSync hook gives the super admin every action once, also with 8 processes', async () => { + const strapi = withServices( + createFakeStrapi({ + tables: { + 'admin::role': [ + { id: 1, documentId: 'rolesuper', name: 'Super Admin', code: 'strapi-super-admin' }, + ], + 'admin::permission': [{ action: 'plugin::magic-mark.bookmarks.read', role: { id: 1 } }], + }, + }) + ); + await server.register({ strapi }); + const hook = strapi.hooks['strapi::content-types.afterSync']; + assert.equal(typeof hook, 'function'); + await Promise.all([ + hook(), + ...Array.from({ length: 7 }, () => maintenanceService({ strapi }).grantSuperAdminActions()), + ]); + assert.deepEqual(actionsOf(strapi, 1), ['create', 'delete', 'manage', 'read', 'share', 'update']); +}); + +test('without a super admin role (first start of a new project) nothing is granted', async () => { + const strapi = withServices(createFakeStrapi()); + await strapi.services.maintenance.grantSuperAdminActions(); + assert.equal(strapi.table('admin::permission').length, 0); +}); diff --git a/tests/server-service.test.cjs b/tests/server-service.test.cjs index 8bf62cd..6d2c08c 100644 --- a/tests/server-service.test.cjs +++ b/tests/server-service.test.cjs @@ -342,3 +342,94 @@ test('duplicate normalises a legacy query: page and unknown keys are dropped (MM row.query = 'filters[__proto__][x]=1'; assert.equal((await service.duplicate(viewer('editor'), pub.documentId)).query, ''); }); + +// Several Strapi processes on one database: requests for the same bookmark interleave. The +// fake db yields on every query and implements row locks like Postgres `FOR UPDATE`. + +test('8 parallel pins of the same bookmark by one user store one preference row', async () => { + const { strapi, service } = setup(); + const created = await create(service, 'owner'); + await Promise.all( + Array.from({ length: 8 }, () => service.setPinned(viewer('owner'), created.documentId, true)) + ); + const prefs = strapi + .table('plugin::magic-mark.bookmark-preference') + .filter((p) => p.bookmarkDocumentId === created.documentId && p.userId === 'owner'); + assert.equal(prefs.length, 1); + assert.equal(prefs[0].isPinned, true); +}); + +test('parallel share edits of one bookmark leave exactly one of the requested share sets', async () => { + const { strapi, service } = setup(); + const created = await create(service, 'owner'); + const sets = [ + { visibility: 'shared', roles: ['roleeditor'], users: ['editor'] }, + { visibility: 'shared', roles: ['roleauthor'], users: ['outsider'] }, + { visibility: 'shared', roles: ['roleeditor', 'roleauthor'], users: [] }, + { visibility: 'shared', roles: [], users: ['editor', 'outsider'] }, + ]; + await Promise.all( + [...sets, ...sets].map((set) => service.update(viewer('owner'), created.documentId, set)) + ); + const rows = strapi + .table('plugin::magic-mark.bookmark-share') + .filter((s) => s.bookmarkDocumentId === created.documentId) + .map((s) => `${s.targetType}:${s.targetId}`) + .sort(); + assert.equal(rows.length, new Set(rows).size, 'no duplicate share rows'); + const allowed = sets.map((set) => + [...set.roles.map((r) => `role:${r}`), ...set.users.map((u) => `user:${u}`)].sort().join(',') + ); + assert.ok(allowed.includes(rows.join(',')), `unexpected share set ${rows.join(',')}`); +}); + +test('writes that race a delete answer 404 and leave no share or pin rows behind', async () => { + const { strapi, service } = setup(); + const created = await create(service, 'owner', { visibility: 'shared', users: ['editor'] }); + const id = created.documentId; + const results = await Promise.allSettled([ + service.delete(viewer('owner'), id), + service.update(viewer('owner'), id, { visibility: 'shared', roles: ['roleeditor'] }), + service.setPinned(viewer('owner'), id, true), + service.setPinned(viewer('editor'), id, true), + service.updateView(viewer('owner'), id, 'sort=title:ASC'), + service.delete(viewer('owner'), id), + ]); + for (const result of results) { + if (result.status === 'rejected') assert.equal(result.reason.name, 'NotFoundError'); + } + const deletes = [results[0], results[5]]; + assert.equal(deletes.filter((r) => r.status === 'fulfilled').length, 1, 'one delete wins'); + assert.equal(strapi.table('plugin::magic-mark.bookmark').length, 0); + assert.equal( + strapi.table('plugin::magic-mark.bookmark-share').filter((s) => s.bookmarkDocumentId === id) + .length, + 0 + ); + assert.equal( + strapi + .table('plugin::magic-mark.bookmark-preference') + .filter((p) => p.bookmarkDocumentId === id).length, + 0 + ); +}); + +test('every write to an existing bookmark locks its row first', async () => { + const { strapi, service } = setup(); + const created = await create(service, 'owner'); + const id = created.documentId; + const locksAfter = async (fn) => { + strapi.calls.queries.length = 0; + await fn(); + return strapi.calls.queries.filter( + (q) => q.op === 'lock' && q.uid === 'plugin::magic-mark.bookmark' + ).length; + }; + assert.equal(await locksAfter(() => service.update(viewer('owner'), id, { name: 'x' })), 1); + assert.equal( + await locksAfter(() => service.updateView(viewer('owner'), id, 'sort=title:ASC')), + 1 + ); + assert.equal(await locksAfter(() => service.setPinned(viewer('owner'), id, true)), 1); + assert.equal(await locksAfter(() => service.delete(viewer('owner'), id)), 1); +}); From 2e6a729da2aade5041a18033b432d632329099d7 Mon Sep 17 00:00:00 2001 From: "Schero D." Date: Sat, 7 Nov 2026 22:08:57 +0100 Subject: [PATCH 2/9] fix(admin): give the bookmark name link a 32 px target The Design System link was as tall as its text (20 px), below the 24 px WCAG 2.5.8 minimum and the 32 px the admin uses on small screens. Padding on the link itself (DS Box props, no custom styles) makes the target 32 px. Co-Authored-By: Claude Opus 5.5 --- admin/src/pages/BookmarksPage.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/admin/src/pages/BookmarksPage.tsx b/admin/src/pages/BookmarksPage.tsx index 223719b..9eb2dfe 100644 --- a/admin/src/pages/BookmarksPage.tsx +++ b/admin/src/pages/BookmarksPage.tsx @@ -335,6 +335,10 @@ export const BookmarksPage = () => { { if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return; event.preventDefault(); From 6876a06e566273aeb533ca872ed5fc89a6a9aa2c Mon Sep 17 00:00:00 2001 From: "Schero D." Date: Sat, 7 Nov 2026 22:09:07 +0100 Subject: [PATCH 3/9] build: match the Strapi 5 app template peers and fix package metadata @strapi/utils is used by the server code and is now a dependency: Strapi apps do not list it, so as a peer it made yarn 1 warn about an unmet peer. @strapi/design-system, @strapi/icons and react-intl reach an app only through @strapi/admin and are optional peers. react and react-dom use ^18.0.0 like create-strapi-app 5.61.0. Installing the tarball into a new Strapi 5.61 app prints no plugin warning with npm 11, yarn 1.22 or pnpm 12 (yarn 1 printed four before); the pnpm app still builds its admin. The description is plain, homepage points at the project site, the repository URL is in the normalised git+https form and keywords are updated. BREAKING CHANGE: Magic Mark 4 needs @strapi/strapi 5.57 or newer. The plugin-internal routes /license/*, /roles, /users and /bookmarks/reorder are removed (sharing lookups moved to /sharing/roles and /sharing/users), every admin route now checks the new bookmark permissions, and the 3.x permissions settings.read and settings.update are replaced by six bookmark permissions. Roles, shares and pins are migrated on the first start; see the CHANGELOG migration notes. Co-Authored-By: Claude Opus 5.5 --- package-lock.json | 158 +++++++++++++++++++++-------------------- package.json | 64 ++++++++++------- tests/package.test.cjs | 39 ++++++++-- 3 files changed, 151 insertions(+), 110 deletions(-) diff --git a/package-lock.json b/package-lock.json index d778ca1..4ec0f0b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "3.4.0", "license": "MIT", "dependencies": { + "@strapi/utils": "^5.57.0", "qs": "^6.17.1", "zod": "^4.6.7" }, @@ -25,7 +26,6 @@ "@strapi/sdk-plugin": "^6.1.1", "@strapi/strapi": "5.61.0", "@strapi/typescript-utils": "5.61.0", - "@strapi/utils": "5.61.0", "@types/qs": "^6.15.1", "@types/react": "^18.3.31", "@types/react-dom": "^18.3.7", @@ -51,12 +51,22 @@ "@strapi/design-system": "^2.0.0", "@strapi/icons": "^2.0.0", "@strapi/strapi": "^5.57.0", - "@strapi/utils": "^5.57.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^18.0.0", + "react-dom": "^18.0.0", "react-intl": "^6.0.0", "react-router-dom": "^6.0.0", "styled-components": "^6.0.0" + }, + "peerDependenciesMeta": { + "@strapi/design-system": { + "optional": true + }, + "@strapi/icons": { + "optional": true + }, + "react-intl": { + "optional": true + } } }, "node_modules/@actions/core": { @@ -810,7 +820,6 @@ "version": "7.29.10", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.9.0" @@ -3264,7 +3273,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/@lazy-node/types-path/-/types-path-1.0.3.tgz", "integrity": "sha512-5Bnl5s5jh7o14i0oa7gj+Y0fDLIlri3+KVZmv4gk0OFGuOrOEmWBBCI9ky3Syip5g/yPHZdfa+WO5BVJMUpMdw==", - "dev": true, "license": "ISC", "dependencies": { "@types/node": "*", @@ -6706,7 +6714,6 @@ "version": "1.1.0", "resolved": "https://registry.npmjs.org/@sindresorhus/slugify/-/slugify-1.1.0.tgz", "integrity": "sha512-ujZRbmmizX26yS/HnB3P9QNlNa4+UvHh+rIse3RbOXLp8yl6n1TxB4t7NHggtVgS8QmmOtzXo48kCxZGACpkPw==", - "dev": true, "license": "MIT", "dependencies": { "@sindresorhus/transliterate": "^0.1.1", @@ -6723,7 +6730,6 @@ "version": "0.1.2", "resolved": "https://registry.npmjs.org/@sindresorhus/transliterate/-/transliterate-0.1.2.tgz", "integrity": "sha512-5/kmIOY9FF32nicXH+5yLNTX4NJ4atl7jRgqAJuIn/iyDFXBktOKDxCvyGE/EzmF4ngSUvjXxQUQlQiZ5lfw+w==", - "dev": true, "license": "MIT", "dependencies": { "escape-string-regexp": "^2.0.0", @@ -6740,7 +6746,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11827,7 +11832,6 @@ "version": "5.61.0", "resolved": "https://registry.npmjs.org/@strapi/utils/-/utils-5.61.0.tgz", "integrity": "sha512-56koR0OD+nU1uH6bmrGgiyCmzN8GURl77XanJgQhIzW+yEh5TDSX12TOkEt4j1y1GbIDYwChg3jAWNDsXo3qYA==", - "dev": true, "license": "SEE LICENSE IN LICENSE", "dependencies": { "@sindresorhus/slugify": "1.1.0", @@ -11850,7 +11854,6 @@ "version": "5.1.1", "resolved": "https://registry.npmjs.org/execa/-/execa-5.1.1.tgz", "integrity": "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==", - "dev": true, "license": "MIT", "dependencies": { "cross-spawn": "^7.0.3", @@ -11874,7 +11877,6 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -11887,7 +11889,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz", "integrity": "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">=10.17.0" @@ -11897,7 +11898,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", "integrity": "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -11910,7 +11910,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-4.0.1.tgz", "integrity": "sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.0.0" @@ -11923,7 +11922,6 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/onetime/-/onetime-5.1.2.tgz", "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", - "dev": true, "license": "MIT", "dependencies": { "mimic-fn": "^2.1.0" @@ -11939,14 +11937,12 @@ "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true, "license": "ISC" }, "node_modules/@strapi/utils/node_modules/strip-final-newline": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/strip-final-newline/-/strip-final-newline-2.0.0.tgz", "integrity": "sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -11956,7 +11952,6 @@ "version": "0.32.9", "resolved": "https://registry.npmjs.org/yup/-/yup-0.32.9.tgz", "integrity": "sha512-Ci1qN+i2H0XpY7syDQ0k5zKQ/DoxO0LzPg8PAR/X4Mpj6DqaeCoIYEEjDJwhArh3Fa7GWbQQVDZKeXYlSH4JMg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.10.5", @@ -11975,7 +11970,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", - "dev": true, "license": "MIT", "funding": { "url": "https://github.com/sponsors/colinhacks" @@ -12476,6 +12470,72 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.11.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.4", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.3.3", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", @@ -12968,7 +13028,6 @@ "version": "4.17.26", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.26.tgz", "integrity": "sha512-48NWfiY6oNcKOh47Z4h+sIvwcQR91h6KPhaYKgeMIrkT2jV82Gy90SErj60vW8jQbeMSOSbhIv3bZ7JoqPPVhA==", - "dev": true, "license": "MIT" }, "node_modules/@types/mdast": { @@ -12999,7 +13058,6 @@ "version": "26.6.5", "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~8.9.0" @@ -14187,7 +14245,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/argv-formatter": { @@ -14546,7 +14603,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", - "dev": true, "license": "MIT", "dependencies": { "fill-range": "^7.1.1" @@ -15963,7 +16019,6 @@ "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", - "dev": true, "license": "MIT", "dependencies": { "path-key": "^3.1.0", @@ -16103,7 +16158,6 @@ "version": "2.30.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-2.30.0.tgz", "integrity": "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==", - "dev": true, "license": "MIT", "dependencies": { "@babel/runtime": "^7.21.0" @@ -16295,7 +16349,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -17043,7 +17096,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" @@ -17598,7 +17650,6 @@ "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", - "dev": true, "license": "MIT", "dependencies": { "to-regex-range": "^5.0.1" @@ -17667,7 +17718,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", - "dev": true, "license": "MIT", "dependencies": { "locate-path": "^6.0.0", @@ -17684,7 +17734,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/find-up-simple/-/find-up-simple-1.0.1.tgz", "integrity": "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=18" @@ -17697,7 +17746,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -17724,7 +17772,6 @@ "version": "1.2.53", "resolved": "https://registry.npmjs.org/find-yarn-workspace-root2/-/find-yarn-workspace-root2-1.2.53.tgz", "integrity": "sha512-0P66/qSVapUMgxLt0BwnKQ2VEg7uR/PIX82y/YuKOf8oHK437uq3OcMUdB4NGDJrCZ2is6jME0yZcV9nAM0RoQ==", - "dev": true, "license": "Apache-2.0", "dependencies": { "micromatch": "^4.0.8", @@ -17737,7 +17784,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-5.0.0.tgz", "integrity": "sha512-NPE8TDbzl/3YQYY7CSS228s3g2ollTFnc+Qi3tqmqJp9Vg2ovUpixcJEo2HJScN2Ez+kEaal6y70c0ehqJBJeA==", - "dev": true, "license": "MIT", "dependencies": { "find-up": "^5.0.0" @@ -18663,7 +18709,6 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, "license": "ISC" }, "node_modules/handlebars": { @@ -19138,7 +19183,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", - "dev": true, "license": "MIT", "dependencies": { "depd": "2.0.0", @@ -19411,7 +19455,6 @@ "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "dev": true, "license": "ISC" }, "node_modules/ini": { @@ -19946,7 +19989,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.12.0" @@ -20159,7 +20201,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "dev": true, "license": "ISC" }, "node_modules/isobject": { @@ -20275,7 +20316,6 @@ "version": "4.3.2", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", - "dev": true, "funding": [ { "type": "github", @@ -21437,7 +21477,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/load-yaml-file/-/load-yaml-file-1.0.0.tgz", "integrity": "sha512-Xw+A/X4c5R6GWu7ZUQgw1rnbfUr1P/hAZbDTrreo+/fP/YSGgxAKoWe2IcT+bt4RHuyIca6S7SMGcWx+QI3WIw==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.8", @@ -21452,7 +21491,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-5.0.0.tgz", "integrity": "sha512-p+byADHF7SzEcVnLvc/r3uognM1hUhObuHXxJcgLCfD194XAkaLbjq3Wzb0N5G2tgIjH0dgT708Z51QxMeu60A==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -21498,7 +21536,6 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", - "dev": true, "license": "MIT", "dependencies": { "p-locate": "^5.0.0" @@ -21514,14 +21551,12 @@ "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", - "dev": true, "license": "MIT" }, "node_modules/lodash-es": { "version": "4.18.1", "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", - "dev": true, "license": "MIT" }, "node_modules/lodash.capitalize": { @@ -21542,7 +21577,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/lodash.deburr/-/lodash.deburr-4.1.0.tgz", "integrity": "sha512-m/M1U1f3ddMCs6Hq2tAsYThTBDaAKFDX3dwDo97GEYzamXi9SqUpjWi/Rrj/gf3X2n8ktwgZrlP1z6E3v/IExQ==", - "dev": true, "license": "MIT" }, "node_modules/lodash.escaperegexp": { @@ -22231,7 +22265,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", - "dev": true, "license": "MIT" }, "node_modules/methods": { @@ -22733,7 +22766,6 @@ "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", - "dev": true, "license": "MIT", "dependencies": { "braces": "^3.0.3", @@ -22803,7 +22835,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -23121,7 +23152,6 @@ "version": "0.2.1", "resolved": "https://registry.npmjs.org/nanoclone/-/nanoclone-0.2.1.tgz", "integrity": "sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -23222,7 +23252,6 @@ "version": "1.1.12", "resolved": "https://registry.npmjs.org/node-machine-id/-/node-machine-id-1.1.12.tgz", "integrity": "sha512-QNABxbrPa3qEIfrE6GOJ7BYIuignnJw7iQ2YPbc3Nla1HzRJjXzZOiikfF8m7eAMfichLt3M4VgLOetqgDmgGQ==", - "dev": true, "license": "MIT" }, "node_modules/node-plop": { @@ -25654,7 +25683,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", - "dev": true, "license": "MIT", "dependencies": { "p-limit": "^3.0.2" @@ -25670,7 +25698,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", - "dev": true, "license": "MIT", "dependencies": { "yocto-queue": "^0.1.0" @@ -25686,7 +25713,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, "license": "MIT", "dependencies": { "aggregate-error": "^3.0.0" @@ -25702,7 +25728,6 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", "integrity": "sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==", - "dev": true, "license": "MIT", "dependencies": { "clean-stack": "^2.0.0", @@ -25716,7 +25741,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -25726,7 +25750,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -26065,7 +26088,6 @@ "version": "1.0.24", "resolved": "https://registry.npmjs.org/path-is-network-drive/-/path-is-network-drive-1.0.24.tgz", "integrity": "sha512-sux7NWiMq/ul8EEQTQbdM1m/zr+Rrq11/P9tEBgxMgTnVHS8f54tQm0kfrTxkvPNg/OVkRjHNgSia5VxnawOZg==", - "dev": true, "license": "ISC", "dependencies": { "tslib": "^2" @@ -26075,7 +26097,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -26142,7 +26163,6 @@ "version": "1.0.21", "resolved": "https://registry.npmjs.org/path-strip-sep/-/path-strip-sep-1.0.21.tgz", "integrity": "sha512-V5Lvyhx0fE6/wEk/YseTtoRQIaD32cepnzrQ1b3kOzOxxDoSglry8IZ1b6LPObIeIbpC0+i9ygUsBNhkOttQKw==", - "dev": true, "license": "ISC", "dependencies": { "tslib": "^2" @@ -26196,7 +26216,6 @@ "version": "2.3.2", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, "license": "MIT", "engines": { "node": ">=8.6" @@ -26606,7 +26625,6 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/preferred-pm/-/preferred-pm-5.0.0.tgz", "integrity": "sha512-qs9WZBOIW4tXjxoYxUathIb53hcV3cjVpLJl8Y0h3QOiyqnbDzWmw0jvTI5YOw/RAkhDsB/sFVt9pYV+r6NP2A==", - "dev": true, "license": "MIT", "dependencies": { "find-up-simple": "^1.0.1", @@ -26746,7 +26764,6 @@ "version": "2.0.6", "resolved": "https://registry.npmjs.org/property-expr/-/property-expr-2.0.6.tgz", "integrity": "sha512-SVtmxhRE/CGkn3eZY1T6pC8Nln6Fr/lu1mKSgRud0eC73whjGfoAogbn78LkD8aFL0zz3bAFerKSnOl7NlErBA==", - "dev": true, "license": "MIT" }, "node_modules/property-information": { @@ -28559,7 +28576,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", - "dev": true, "license": "ISC" }, "node_modules/shallow-clone": { @@ -28629,7 +28645,6 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", - "dev": true, "license": "MIT", "dependencies": { "shebang-regex": "^3.0.0" @@ -28642,7 +28657,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -29147,7 +29161,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.8" @@ -29939,7 +29952,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, "license": "MIT", "dependencies": { "is-number": "^7.0.0" @@ -29952,7 +29964,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.6" @@ -29981,7 +29992,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/toposort/-/toposort-2.0.2.tgz", "integrity": "sha512-0a5EOkAUp8D4moMi2W8ZF8jcga7BgZd91O/yabJCFY8az+XSzeGyTKs0Aoo897iV1Nj6guFq8orWDS96z91oGg==", - "dev": true, "license": "MIT" }, "node_modules/totalist": { @@ -30093,7 +30103,6 @@ "version": "3.0.19", "resolved": "https://registry.npmjs.org/ts-type/-/ts-type-3.0.19.tgz", "integrity": "sha512-TiOULsPS+p6WCE4PFXVh9A5/OfgpsWbFpIOfaalGxLOMtlTANp6hmAn3dT46ppWnpBVxz8PcJshg6JqZABpETQ==", - "dev": true, "license": "ISC", "dependencies": { "@types/node": "*", @@ -30108,7 +30117,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tsscmp": { @@ -30188,7 +30196,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/typedarray-dts/-/typedarray-dts-1.0.0.tgz", "integrity": "sha512-Ka0DBegjuV9IPYFT1h0Qqk5U4pccebNIJCGl8C5uU7xtOs+jpJvKGAY4fHGK25hTmXZOEUl9Cnsg5cS6K/b5DA==", - "dev": true, "license": "MIT" }, "node_modules/typedarray-to-buffer": { @@ -30374,7 +30381,6 @@ "version": "8.9.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", - "dev": true, "license": "MIT" }, "node_modules/unicode-emoji-modifier-base": { @@ -30548,7 +30554,6 @@ "version": "3.1.25", "resolved": "https://registry.npmjs.org/upath2/-/upath2-3.1.25.tgz", "integrity": "sha512-fPfgMpV+E3tcLMOdeJzQihp8D06q6Ro97gSdyMMNBoQg/w7LcLqf0+TiFvYdJzcxu02OMDM3nnTdl8EIFHfnBw==", - "dev": true, "license": "ISC", "dependencies": { "@lazy-node/types-path": "^1.0.3", @@ -31603,7 +31608,6 @@ "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", - "dev": true, "license": "ISC", "dependencies": { "isexe": "^2.0.0" @@ -31635,7 +31639,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/which-pm/-/which-pm-4.0.0.tgz", "integrity": "sha512-kLoLJ/y2o0GnU8ZNgI5/nlCbyjpUlqtaJQjMrzR2sKyQQ3BcJJ61oSOdZWIrfoScunyZS5w9U0wyFb0Bij9cyg==", - "dev": true, "license": "MIT", "dependencies": { "load-yaml-file": "^1.0.0" @@ -32010,7 +32013,6 @@ "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", - "dev": true, "license": "MIT", "engines": { "node": ">=10" diff --git a/package.json b/package.json index 9208c01..d27013f 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,30 @@ { + "name": "strapi-plugin-magic-mark", "version": "3.4.0", + "description": "Bookmarks for the Strapi 5 Content Manager: save list views with their filters, sort order, search and locale, pin them and share them with roles or admin users.", "keywords": [ "strapi", "strapi-plugin", "strapi-v5", - "bookmarks", - "query-builder", + "strapi-5", "content-manager", + "bookmarks", + "saved-views", + "saved-filters", "filters", - "productivity", + "list-view", "admin-panel" ], + "homepage": "https://schero94.github.io/Magicmark/", + "bugs": { + "url": "https://github.com/Schero94/Magicmark/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/Schero94/Magicmark.git" + }, + "license": "MIT", + "author": "Schero D.", "type": "commonjs", "exports": { "./package.json": "./package.json", @@ -52,6 +66,7 @@ "prepublishOnly": "npm test && npm run build && npm run verify && npm run verify:runtime" }, "dependencies": { + "@strapi/utils": "^5.57.0", "qs": "^6.17.1", "zod": "^4.6.7" }, @@ -68,7 +83,6 @@ "@strapi/sdk-plugin": "^6.1.1", "@strapi/strapi": "5.61.0", "@strapi/typescript-utils": "5.61.0", - "@strapi/utils": "5.61.0", "@types/qs": "^6.15.1", "@types/react": "^18.3.31", "@types/react-dom": "^18.3.7", @@ -90,37 +104,35 @@ "@strapi/design-system": "^2.0.0", "@strapi/icons": "^2.0.0", "@strapi/strapi": "^5.57.0", - "@strapi/utils": "^5.57.0", - "react": "^18.3.1", - "react-dom": "^18.3.1", + "react": "^18.0.0", + "react-dom": "^18.0.0", "react-intl": "^6.0.0", "react-router-dom": "^6.0.0", "styled-components": "^6.0.0" }, - "strapi": { - "kind": "plugin", - "name": "magic-mark", - "displayName": "Magic Mark", - "description": "Save Content Manager list views with their filters as bookmarks and share them with roles and users." - }, - "name": "strapi-plugin-magic-mark", - "description": "Bookmarks and advanced filters for the Strapi 5 Content Manager: save list views, share them with roles and users. Free and open source.", - "license": "MIT", - "private": false, - "author": "Schero D.", - "repository": { - "type": "git", - "url": "https://github.com/Schero94/Magicmark.git" - }, - "bugs": { - "url": "https://github.com/Schero94/Magicmark/issues" + "peerDependenciesMeta": { + "@strapi/design-system": { + "optional": true + }, + "@strapi/icons": { + "optional": true + }, + "react-intl": { + "optional": true + } }, - "homepage": "https://github.com/Schero94/Magicmark#readme", "engines": { "node": "^22.22.2 || >=24.15.0", "npm": ">=6.0.0" }, "publishConfig": { "access": "public" - } + }, + "strapi": { + "kind": "plugin", + "name": "magic-mark", + "displayName": "Magic Mark", + "description": "Save Content Manager list views with their filters as bookmarks and share them with roles and users." + }, + "private": false } diff --git a/tests/package.test.cjs b/tests/package.test.cjs index e210878..86b9b4e 100644 --- a/tests/package.test.cjs +++ b/tests/package.test.cjs @@ -9,25 +9,52 @@ const root = path.join(__dirname, '..'); const pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); test('runtime dependencies are only what the plugin imports itself (PKG-1, DEP-01)', () => { - assert.deepEqual(Object.keys(pkg.dependencies).sort(), ['qs', 'zod']); + // @strapi/utils is imported by the server code. Strapi apps do not list it, so as a peer it + // made yarn 1 warn about an unmet peer; as a dependency it dedupes with Strapi's own copy. + assert.deepEqual(Object.keys(pkg.dependencies).sort(), ['@strapi/utils', 'qs', 'zod']); + assert.match(pkg.dependencies['@strapi/utils'], /^\^5\./); for (const name of ['@heroicons/react', '@types/qs', '@strapi/sdk-plugin', '@swc/core']) { assert.equal(pkg.dependencies[name], undefined, `${name} is not a runtime dependency`); assert.equal(pkg.peerDependencies[name], undefined, `${name} is not a peer`); } assert.equal(pkg.devDependencies['@heroicons/react'], undefined); - for (const peer of [ + assert.equal(pkg.peerDependencies['@strapi/utils'], undefined, '@strapi/utils is not a peer'); +}); + +test('peers match the Strapi 5 app template; admin-only packages are optional peers (PKG-2)', () => { + // create-strapi-app 5.61.0 adds react ^18.0.0, react-dom ^18.0.0, react-router-dom ^6.30.3 + // and styled-components ^6.0.0 to every app. The Design System, icons and react-intl reach + // the app only through @strapi/admin, so they are optional: no install warning, and the + // admin build still resolves them from the app. + assert.deepEqual(Object.keys(pkg.peerDependencies).sort(), [ '@strapi/design-system', '@strapi/icons', '@strapi/strapi', - '@strapi/utils', 'react', 'react-dom', 'react-intl', 'react-router-dom', 'styled-components', - ]) { - assert.ok(pkg.peerDependencies[peer], `${peer} is a peer`); - } + ]); + assert.deepEqual(pkg.peerDependenciesMeta, { + '@strapi/design-system': { optional: true }, + '@strapi/icons': { optional: true }, + 'react-intl': { optional: true }, + }); + assert.equal(pkg.peerDependencies['@strapi/strapi'], '^5.57.0'); + assert.equal(pkg.peerDependencies.react, '^18.0.0'); + assert.equal(pkg.peerDependencies['react-dom'], '^18.0.0'); +}); + +test('package metadata points at the project site and repository (PKG-5)', () => { + assert.equal(pkg.homepage, 'https://schero94.github.io/Magicmark/'); + assert.deepEqual(pkg.repository, { + type: 'git', + url: 'git+https://github.com/Schero94/Magicmark.git', + }); + assert.equal(pkg.bugs.url, 'https://github.com/Schero94/Magicmark/issues'); + assert.doesNotMatch(pkg.description, /enterprise|powerful|ultimate|blazing/i); + assert.ok(pkg.keywords.includes('strapi-plugin')); }); test('Strapi peer pins are documented majors: React 18, React Router 6, styled-components 6, react-intl 6', () => { From abc2cc990b56277362c334fa6ddf6db82dc8c5fb Mon Sep 17 00:00:00 2001 From: "Schero D." Date: Sat, 7 Nov 2026 22:09:07 +0100 Subject: [PATCH 4/9] ci: test against the lowest supported Strapi version The peer range says @strapi/strapi ^5.57.0, so a CI job installs 5.57.0 next to the lockfile and runs both type checks, the tests, the build and both verify steps. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/test.yml | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 5ec4af8..6c80847 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -80,3 +80,39 @@ jobs: test -f .releaserc.json test -f README.md test -f LICENSE + + peer-floor: + # peerDependencies say @strapi/strapi ^5.57.0: prove the lowest supported version, not only + # the acceptance version in the lockfile. + name: Lowest supported Strapi (5.57.0) + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - name: Checkout code + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Setup Node.js + uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v7.1.0 + with: + node-version: '22' + cache: npm + + - name: Install dependencies + run: npm ci --legacy-peer-deps + + - name: Install Strapi 5.57.0 + run: npm install --no-save --legacy-peer-deps --ignore-scripts @strapi/strapi@5.57.0 @strapi/utils@5.57.0 @strapi/typescript-utils@5.57.0 + + - name: Show installed Strapi version + run: node -p "require('@strapi/strapi/package.json').version" + + - name: Type checks, tests, build and verify + run: | + npm run test:ts:back + npm run test:ts:front + npm test + npm run build + npm run verify + npm run verify:runtime From 98fed3ca0315097e3f9735302aab3aa870bd280e Mon Sep 17 00:00:00 2001 From: "Schero D." Date: Sat, 7 Nov 2026 22:10:00 +0100 Subject: [PATCH 5/9] docs: rewrite README and project site for the 4.0 release README: value first, badges, quick start for npm, yarn and pnpm, screenshot gallery, full configuration, permission and admin API reference with a response example, security, performance, running several instances (Railway replicas, pm2 cluster) with the measured 8-process result and the Strapi schema sync note, compatibility, upgrade notes from 3.x, troubleshooting, FAQ and contributing links. Site: hero with the product screenshot, steps, gallery, permissions, API, security, several instances, upgrade and FAQ sections; dark mode, Open Graph, Twitter and JSON-LD metadata. site.css is the byte-identical shared theme again (and excluded from Prettier); page styles and dark mode live in site-extra.css. Checked at 320, 390, 1024 and 1440 px in light and dark: no overflow, no low-contrast text, no broken images or anchors. CHANGELOG: [Unreleased] grouped into breaking changes, migration, removed, added, changed, fixed and security. Co-Authored-By: Claude Opus 5.5 --- .prettierignore | 3 + CHANGELOG.md | 117 +++++--- README.md | 529 ++++++++++++++++++++-------------- docs/site/index.html | 602 +++++++++++++++++++++++++++------------ docs/site/site-extra.css | 211 ++++++++++++++ docs/site/site.css | 589 +++++--------------------------------- 6 files changed, 1099 insertions(+), 952 deletions(-) create mode 100644 docs/site/site-extra.css diff --git a/.prettierignore b/.prettierignore index 10ebc51..9cdab43 100644 --- a/.prettierignore +++ b/.prettierignore @@ -12,3 +12,6 @@ CHANGELOG.md # Agent rules synced from the workbench templates .agents + +# Shared Pages theme, byte-identical to the workbench template (templates/plugin-pages/site.css) +docs/site/site.css diff --git a/CHANGELOG.md b/CHANGELOG.md index d487326..00d21b5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,83 @@ ## [Unreleased] -Magic Mark is now free, rebuilt on the Strapi Design System and secured on the server. - -### Free - -- Removed the license system: license guard, license routes and settings pages, upgrade page, - feature gates, the 10/50 bookmark limit, the keep-alive heartbeat and every call to the license - server. No telemetry, no timers, no outbound requests. +Magic Mark 4.0 is free, rebuilt on the Strapi Design System, secured on the server and safe to run +on several Strapi processes at once. + +### Breaking changes + +- The plugin-internal admin routes `/license/*`, `/roles`, `/users` and `/bookmarks/reorder` were + removed; the sharing lookups moved to `/sharing/roles` and `/sharing/users`. +- The 3.x permissions **Read** and **Edit** (`settings.read`, `settings.update`) were replaced by + six bookmark permissions (migrated automatically, see Migration). +- `@strapi/strapi` 5.57 or newer 5.x is required. +- Every admin route now checks the Magic Mark permissions; before, any authenticated admin could + call every route. + +### Migration + +1. Update the package, rebuild the admin (`npm run build`) and start Strapi. Roles keep their + access: `settings.read` becomes Read bookmarks; `settings.update` becomes Read, Create, Edit + own, Delete own and Share bookmarks. +2. Give **Manage shared bookmarks of others** to roles that should tidy up other users' shared + bookmarks. +3. 3.x shares (JSON columns) and global pins are migrated on the first start; a pin becomes the + owner's personal pin. The custom manual order is no longer used. +4. Code that called the removed routes must use `/sharing/roles` and `/sharing/users`. The + plugin's own admin UI was their only consumer. +5. Several instances: the upgrade adds two tables, and Strapi's schema sync is not safe when + several processes run it at the same moment (7 of 8 processes stopped with "relation already + exists" in the test and came up after a restart). Start one instance first, or let the + platform restart the others. + +### Removed + +- The license system: license guard, license routes and settings pages, upgrade page, feature + gates, the 10/50 bookmark limit, the keep-alive heartbeat and every call to the license server. + No telemetry, no installation ping, no timers, no outbound requests. - The stored `licenseKey` and `lastValidated` plugin store keys are deleted on the first start. -- Removed `COPYRIGHT_NOTICE.txt`; the plugin is MIT licensed. +- `COPYRIGHT_NOTICE.txt`; the plugin is MIT licensed. +- The `@heroicons/react` dependency. -### Security and fixes +### Added -- Every admin route now requires `admin::isAuthenticatedAdmin` and `admin::hasPermissions`. Before, - any authenticated admin could call every route, including the admin user list, because the - admin strategy ignores `auth.scope`. +- Rebuilt admin UI with Design System components only: Bookmarks popover and Advanced filters + button in the list view toolbar, bookmarks page with search, scope, content type filter, sorting + and pagination, create and edit dialog with sharing, delete confirmation, dark mode, 320 px + layouts, five languages. +- Per-user pins (also for shared bookmarks), duplicate, "update with current view", copy link. +- "Pinned bookmarks" homepage widget. +- Six permissions: Read, Create, Edit own, Delete own, Share and Manage shared bookmarks of + others. Managers see every public or shared bookmark under **Show > All shared bookmarks**, also + when they are not a recipient and when the owner was deleted; private bookmarks of other users + stay private. +- Admin user and role deletion clean up shares, pins and private bookmarks. +- CI builds and tests once more against the lowest supported Strapi version (5.57.0). + +### Changed + +- Package: `@strapi/utils` is a dependency instead of a peer (Strapi apps do not list it, so + yarn 1 warned about an unmet peer). `@strapi/design-system`, `@strapi/icons` and `react-intl` + are optional peers because apps get them through `@strapi/admin`; `react` and `react-dom` peers + are `^18.0.0` like the Strapi 5 app template. Installing into a new Strapi 5.61 app with npm 11, + yarn 1.22 or pnpm prints no plugin warning (yarn 1 printed four before). +- Package metadata: plain description, `homepage` points at the project site, keywords updated. +- README and project site rewritten: quick start, full permission and API reference, examples, + security, performance, running several instances, upgrade notes, FAQ; the site has dark mode, + Open Graph and structured data. +- The bookmark name link has a 32 px target height. + +### Fixed + +- Several Strapi processes on one database (tested with 8 processes on Postgres 16): + - every write to an existing bookmark (edit, share, update view, pin, delete) locks the + bookmark row first, so parallel share edits no longer leave duplicate share rows, parallel + pins no longer create two preference rows, a delete no longer deadlocks with a parallel edit, + and an edit or pin that raced a delete answers 404 instead of leaving orphaned rows; + - the 3.x permission migration grants each role's actions under a row lock (before, each + process inserted them and roles ended up with duplicate permissions); + - the Super Admin role gets the Magic Mark actions under a row lock before Strapi's own unlocked + Super Admin sync runs, so a simultaneous start no longer duplicates them; leftover duplicates + are removed at the next start. - Request validation with strict zod schemas; paths must be Content Manager list views and queries are normalised with `qs` (no prototype keys, no `page`). - Sharing with individual users works (3.x compared numeric ids with documentIds). @@ -38,33 +101,11 @@ Magic Mark is now free, rebuilt on the Strapi Design System and secured on the s - The Bookmarks popover requests pinned bookmarks separately, so a pin is never cut off, and links to the Magic Mark page when a content type has more than 100 bookmarks. -### Features +### Security -- Rebuilt admin UI with Design System components only: Bookmarks popover and Advanced filters - button in the list view toolbar, bookmarks page with search, scope, content type filter, sorting - and pagination, create and edit dialog with sharing, delete confirmation, dark mode, 320 px - layouts, five languages. -- Per-user pins (also for shared bookmarks), duplicate, "update with current view", copy link. -- "Pinned bookmarks" homepage widget. -- New permissions: Read, Create, Edit own, Delete own, Share and Manage shared bookmarks of - others. Managers see every public or shared bookmark under **Show > All shared bookmarks**, - also when they are not a recipient and when the owner was deleted; private bookmarks of other - users stay private. -- Admin user and role deletion clean up shares, pins and private bookmarks. - -### Migration notes - -- Roles keep their access: 3.x `settings.read` maps to Read bookmarks; `settings.update` maps to - Read, Create, Edit own, Delete own and Share bookmarks. Give Manage shared bookmarks of others - to roles that should tidy up other users' shared bookmarks. -- 3.x shares (JSON columns) and global pins are migrated on the first start; pins become the - owner's personal pin. The custom manual order is no longer used. -- The plugin-internal admin routes changed (`/license/*`, `/roles`, `/users` and - `/bookmarks/reorder` were removed; sharing lookups moved to `/sharing/roles` and `/sharing/users`). - The plugin's own admin UI is their only consumer. -- Peer dependencies: `@strapi/design-system`, `@strapi/icons` and `@strapi/utils` are now peers; - `@heroicons/react` is no longer installed. `@strapi/strapi` and `@strapi/utils` need 5.57 or - newer 5.x. +- Every admin route requires `admin::isAuthenticatedAdmin` and `admin::hasPermissions`. Before, + any authenticated admin could call every route, including the admin user list, because the + admin strategy ignores `auth.scope`. # [3.4.0](https://github.com/Schero94/Magicmark/compare/v3.3.2...v3.4.0) (2026-02-08) diff --git a/README.md b/README.md index c710182..7deb597 100644 --- a/README.md +++ b/README.md @@ -1,300 +1,393 @@ # Magic Mark -Bookmarks and advanced filters for the Strapi 5 Content Manager. Filter a list, save the view as -a bookmark, open it again with one click and share it with roles or individual admin users. +**Saved views for the Strapi 5 Content Manager.** Filter a list once, save it as a bookmark and +get back to exactly that list with one click. Share bookmarks with roles or single admin users, +pin your favourites to the Strapi homepage, and build filters the native filter bar cannot +express ("any condition", "is one of", "is between", relation and component fields). -Free and open source (MIT). No license key, no limits, no tracking, no calls to external -servers. +Free and open source under the MIT license. No license key, no limits, no telemetry and no calls +to external servers. -[![npm](https://img.shields.io/npm/v/strapi-plugin-magic-mark.svg)](https://www.npmjs.com/package/strapi-plugin-magic-mark) +[![npm version](https://img.shields.io/npm/v/strapi-plugin-magic-mark.svg)](https://www.npmjs.com/package/strapi-plugin-magic-mark) +[![npm downloads](https://img.shields.io/npm/dm/strapi-plugin-magic-mark.svg)](https://www.npmjs.com/package/strapi-plugin-magic-mark) [![Test & Verify](https://github.com/Schero94/Magicmark/actions/workflows/test.yml/badge.svg)](https://github.com/Schero94/Magicmark/actions/workflows/test.yml) +[![Strapi 5.57+](https://img.shields.io/badge/Strapi-5.57%2B-4945ff.svg)](#compatibility) [![License: MIT](https://img.shields.io/badge/license-MIT-blue.svg)](./LICENSE) -Documentation site: [https://schero94.github.io/Magicmark/](https://schero94.github.io/Magicmark/) +Website: [schero94.github.io/Magicmark](https://schero94.github.io/Magicmark/) · +npm: [strapi-plugin-magic-mark](https://www.npmjs.com/package/strapi-plugin-magic-mark) · +Issues: [github.com/Schero94/Magicmark/issues](https://github.com/Schero94/Magicmark/issues) -![Bookmarks popover in the Content Manager list view](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/list-view-bookmarks.png) - -## Features - -- **Save view** in the list view toolbar keeps the content type, filters, sort order, page size, - search text and locale of the list you are looking at. -- One click on a bookmark opens the list exactly like that again, without a page reload. -- Share a bookmark with roles, with single admin users or with every admin. Recipients can open, - pin and copy it but not change it. -- Pins are per user. Pinned bookmarks come first in the toolbar and on the Strapi homepage widget. -- **Advanced filters** add "any condition", "is one of", "is none of", "is between", relation - fields and component fields to the native filters. -- A Magic Mark page with search, scope, content type filter, sorting and pagination for all your - bookmarks. -- Six role permissions, checked on the server for every request. -- Built only with Strapi Design System components: light and dark mode, layouts down to 320 px, - keyboard access, five languages. +![The Bookmarks popover next to View settings in the Content Manager list view](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/list-view-bookmarks.png) ## Contents +- [Why Magic Mark](#why-magic-mark) - [Features](#features) -- [Quick start](#quick-start) +- [Quick start (1 minute)](#quick-start-1-minute) - [Screenshots](#screenshots) -- [Core concepts](#core-concepts) -- [Screens](#screens) -- [Compatibility](#compatibility) -- [Examples](#examples) +- [How it works](#how-it-works) - [Configuration](#configuration) -- [Admin API and permissions](#admin-api-and-permissions) +- [Permissions](#permissions) +- [Examples](#examples) +- [Admin API](#admin-api) - [Security and privacy](#security-and-privacy) -- [Performance and several instances](#performance-and-several-instances) -- [Troubleshooting](#troubleshooting) +- [Performance](#performance) +- [Running several instances (Railway replicas, pm2 cluster)](#running-several-instances-railway-replicas-pm2-cluster) +- [Compatibility](#compatibility) - [Upgrading from 3.x](#upgrading-from-3x) +- [Troubleshooting](#troubleshooting) - [FAQ](#faq) - [Development and tests](#development-and-tests) -- [Contributing](#contributing) -- [Maintenance status](#maintenance-status) -- [Languages](#languages) +- [Contributing and support](#contributing-and-support) - [License](#license) -## Quick start - -Install the package, enable it, rebuild the admin and start Strapi: - -```bash -npm install strapi-plugin-magic-mark -``` +## Why Magic Mark -```js -// config/plugins.js (or .ts) -module.exports = () => ({ - 'magic-mark': { enabled: true }, -}); -``` +Editors open the same lists every day: drafts waiting for review, articles without an SEO title, +products of one category in one locale. In plain Strapi they rebuild those filters by hand or keep +long admin URLs in their browser bookmarks. Those break when the admin URL changes, and nobody can +share them by role. -```bash -npm run build -npm run develop -``` +Magic Mark stores the list view itself (content type, filters, sort order, page size, search text +and locale) inside Strapi, right next to the list it belongs to. Role permissions decide who may +read, create, edit, share or tidy up bookmarks, and the server checks every one of them. -Then give your roles the permissions they need under **Settings > Roles > Plugins > Magic -Mark**. Super Admins have all of them. +## Features -To save your first bookmark, open any collection type in the Content Manager, set a filter, open -the star button next to **View settings** and choose **Save view**. Name it and save. The bookmark -is now in the same star menu, on the Magic Mark page and, once pinned, in the homepage widget. +- **Save view** in the list view toolbar keeps the content type, filters, sort order, page size, + search text and locale of the list you are looking at. +- **One click to open.** A bookmark opens its list without a page reload. The Content Manager's + remembered settings for that content type are replaced, so old filters never mix in. +- **Sharing.** Keep a bookmark private, share it with roles and single admin users, or with all + admins. Recipients can open, pin and duplicate it; only the owner or a manager can change it. +- **Pins per user.** Pinned bookmarks come first in the toolbar and in the "Pinned bookmarks" + widget on the Strapi homepage. Pinning a shared bookmark changes only your own list. +- **Advanced filters** add "all conditions" or "any condition", the operators "is one of", "is + none of" and "is between", and fields of relations and non-repeatable components. "All + conditions" filters show up as native filter tags. +- **Keep views current.** "Update with current view" saves the list you are looking at into an + existing bookmark; its name and sharing stay. +- **Magic Mark page** with search, scope (created by me, shared with me, pinned, all shared), + content type filter, sorting and pagination. Row actions: pin, open, edit, duplicate, copy + link, delete. +- **Six role permissions**, checked on the server for every request. +- **Strapi-native UI.** Built only from Strapi Design System components, with light and dark + mode, layouts from 1440 px down to 320 px, full keyboard access and five languages (English, + German, French, Spanish, Portuguese). +- **Safe with several Strapi processes** on one database, tested with 8 processes on Postgres + (see [Running several instances](#running-several-instances-railway-replicas-pm2-cluster)). + +## Quick start (1 minute) + +1. Install the package in your Strapi 5 project: + + ```bash + npm install strapi-plugin-magic-mark + # or + yarn add strapi-plugin-magic-mark + # or + pnpm add strapi-plugin-magic-mark + ``` + +2. Rebuild the admin panel and start Strapi: + + ```bash + npm run build + npm run develop + ``` + + Strapi enables installed plugins by itself. If your `config/plugins` file lists plugins + explicitly, add `'magic-mark': { enabled: true }`. + +3. Open any collection type in the Content Manager, set a filter, open **Bookmarks** next to + **View settings** and choose **Save view**. Give it a name and save. + +Super Admins have every Magic Mark permission. For other roles, turn on what they need under +**Settings > Roles > (role) > Plugins > Magic Mark** (see [Permissions](#permissions)). ## Screenshots All screenshots use synthetic data on an isolated Strapi 5.61 host. -### Bookmarks in the Content Manager - -![Bookmarks popover next to View settings in the Content Manager list view](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/list-view-bookmarks.png) - -### Advanced filters - -![Advanced filters dialog with two conditions](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/advanced-filters.png) - -### Save view as bookmark - -![Save view dialog with the captured filters and sharing options](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/save-view.png) - -### Bookmarks page - -![Bookmarks page with table, search and filters](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/bookmarks.png) - -![Bookmarks page in dark mode](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/bookmarks-dark.png) +| Bookmarks in the list view | Advanced filters | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | +| ![Bookmarks popover next to View settings in the Content Manager list view](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/list-view-bookmarks.png) | ![Advanced filters dialog with two conditions](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/advanced-filters.png) | +| **Save view as bookmark** | **Magic Mark page** | +| ![Save view dialog with the captured filters and sharing options](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/save-view.png) | ![Bookmarks page with table, search and filters](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/bookmarks.png) | +| **Dark mode** | **Homepage widget** | +| ![Bookmarks page in dark mode](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/bookmarks-dark.png) | ![Pinned bookmarks widget on the Strapi homepage](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/homepage-widget.png) | +| **Delete confirmation** | **Mobile, 390 px** | +| ![Delete bookmark confirmation dialog](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/delete-bookmark.png) | ![Bookmarks page at 390 px width](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/mobile-bookmarks.png) | -### Homepage widget - -![Pinned bookmarks widget on the Strapi homepage](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/homepage-widget.png) - -### Mobile (390 px) - -![Bookmarks page at 390 px width](https://raw.githubusercontent.com/Schero94/Magicmark/main/pics/mobile-bookmarks.png) - -## Core concepts +## How it works | Term | Meaning | | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Bookmark | A saved Content Manager list view: content type, filters, sort order, page size, search text and locale. The page number is not saved, so a bookmark always opens on page 1. | -| Owner | The admin user who created the bookmark. Only the owner, or a user with **Manage shared bookmarks of others**, can edit or delete it. | +| Owner | The admin user who created the bookmark. The owner, or someone with **Manage shared bookmarks of others**, can edit or delete it. | | Visibility | **Only me**, **Specific roles and users** or **All admin users**. Recipients can open, pin and duplicate a bookmark, but not change it. | -| Pin | Per user. Pinning a shared bookmark changes only your own list and your homepage widget. | -| Advanced filters | A filter editor for the list view with "all conditions" or "any condition", extra operators (is one of, is none of, is between), relation fields and component fields. | +| Pin | Per user. Pinning a shared bookmark changes only your own toolbar list and homepage widget. | +| Advanced filters | A filter editor for the list view with "all conditions" or "any condition", extra operators, and relation and component fields. | -## Screens +Where you find it: -| Screen | Where | Who | -| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------- | -| **Advanced filters** button | Content Manager list view toolbar, next to View settings | Every admin who can open the list | -| **Bookmarks** button (popover) | Same toolbar. Lists the bookmarks of this content type, pinned first (up to 100; a link opens the rest on the Magic Mark page). **Save view** captures the current list; the refresh icon saves the current view into an existing bookmark. | Read bookmarks (saving: Create bookmarks) | -| **Magic Mark** page | Main menu. Search, show "created by me", "shared with me" or "pinned", filter by content type, sort and paginate. Row actions: pin, open, edit, duplicate, copy link, delete. | Read bookmarks | -| **Pinned bookmarks** widget | Strapi homepage (Add widget) | Read bookmarks | - -Opening a bookmark is a normal in-app navigation (no page reload). The Content Manager's -remembered list settings for that content type are replaced by the bookmark's, so filters from -an earlier visit do not mix in. +| Screen | Where | Who sees it | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------- | +| **Bookmarks** button | Content Manager list view toolbar, next to View settings. Lists the bookmarks of this content type, pinned first. Up to 100 are listed; a link opens the rest on the Magic Mark page. **Save view** captures the current list; the refresh icon saves the current view into an existing bookmark. | Read bookmarks (saving needs Create bookmarks) | +| **Advanced filters** button | Same toolbar | Every admin who can open the list | +| **Magic Mark** page | Main menu | Read bookmarks | +| **Pinned bookmarks** widget | Strapi homepage | Read bookmarks | With **All conditions** the filters use the same `$and` shape as Strapi's own filters, so they -appear as native filter tags and can be removed one by one. With **Any condition** the list is -filtered correctly, but Strapi does not show those conditions as tags; open Advanced filters to -change or clear them. +appear as filter tags and can be removed one by one. With **Any condition** the list is filtered +correctly, but Strapi does not show those conditions as tags; open Advanced filters to change or +clear them. -## Compatibility +## Configuration -| Plugin | Strapi | Node.js | Notes | -| -------------------------- | ----------------- | --------------------- | ----------------------------------------------------------------------------------------------------------- | -| Next release (this branch) | 5.57 or newer 5.x | ^22.22.2 or >=24.15.0 | Acceptance host: Strapi 5.61.0 with SQLite. Free; 3.x data and permissions are migrated on the first start. | -| 3.x | 5.x | 22 | Contained the license system; no longer maintained. | +Magic Mark has no configuration options and reads no environment variables. Access is controlled +only with role permissions. -React 18, React Router 6, styled-components 6 and react-intl 6 are peer dependencies because the -Strapi 5 admin ships these majors. TypeScript stays on 5.9 for development because -`@strapi/sdk-plugin` 6.1.1 builds with TypeScript 5.9.3 and typescript-eslint 8 supports -TypeScript below 6.1. +| Setting | Where | Default | +| ------------------------- | -------------------------------------------------------------------------- | ----------------------------------------------------------------- | +| Turn the plugin on or off | `config/plugins.js` or `.ts`: `'magic-mark': { enabled: true }` or `false` | On once installed | +| Who may do what | Settings > Roles > Plugins > Magic Mark | Super Admin: everything. Other roles: nothing until you grant it. | -## Configuration +## Permissions -Magic Mark has no configuration options and reads no environment variables. Everything is -controlled with role permissions. +| Permission (Settings > Roles > Plugins) | Action id | Allows | +| --------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Read bookmarks | `plugin::magic-mark.bookmarks.read` | See and open bookmarks, pin them, the Magic Mark page and the homepage widget | +| Create bookmarks | `plugin::magic-mark.bookmarks.create` | Save views, duplicate bookmarks | +| Edit own bookmarks | `plugin::magic-mark.bookmarks.update` | Rename, describe, change the icon and update the saved view of your bookmarks | +| Delete own bookmarks | `plugin::magic-mark.bookmarks.delete` | Delete your bookmarks | +| Share bookmarks | `plugin::magic-mark.bookmarks.share` | Share with roles, single admin users or all admins, and search admin users for sharing | +| Manage shared bookmarks of others | `plugin::magic-mark.bookmarks.manage` | See every public or shared bookmark under **Show > All shared bookmarks**, also when you are not a recipient or the owner was deleted. Editing and deleting them also need Edit own and Delete own. | + +Private bookmarks of other users stay private, also for managers and Super Admins. The user +search shows email addresses only to admins who may read admin users (`admin::users.read`). ## Examples -**A review queue for editors.** Filter Articles by `stage` is `review`, sort by `updatedAt`, save -the view as "Waiting for review" and share it with the Editor role. Every editor sees it in the -star menu of Articles and can pin it for the homepage. +**A review queue for the editor team.** Filter Articles by `stage` is `review`, sort by +`updatedAt` descending, save the view as "Waiting for review" and share it with the Editor role. +Every editor finds it in the Bookmarks menu of Articles and can pin it to the homepage. -**Your own working set.** Use Advanced filters with **Any condition** to combine "stage is one of -draft, review" with "featured is true", save it as a private bookmark and pin it. +**A personal working set.** In Advanced filters choose **Any condition**, add "stage is one of +idea, review" and "author is anna.beispiel", apply, then save the view as a private bookmark and +pin it. -**Keep a shared bookmark current.** Change the filters in the list, open the star menu and use -the refresh icon next to the bookmark. It now saves the current view; the name and sharing stay. +**Keep a shared bookmark up to date.** Change the filters in the list, open Bookmarks and use the +refresh icon next to the bookmark. It now stores the current view; the name and the people it is +shared with stay the same. -**List bookmarks from a script.** The admin API takes an admin JWT, like every Strapi admin route: +**Read bookmarks from a script.** The routes are admin routes, so they need an admin JWT, like +every Strapi admin route: ```bash curl -s "http://localhost:1337/magic-mark/bookmarks?contentType=api::article.article&scope=pinned" \ -H "Authorization: Bearer $ADMIN_JWT" ``` -## Admin API and permissions +```json +{ + "data": [ + { + "documentId": "k3l0x9q2m8v7c1b4n6z5w0aa", + "name": "Waiting for review", + "description": "", + "icon": "pencil", + "path": "/content-manager/collection-types/api::article.article", + "contentType": "api::article.article", + "query": "filters[$and][0][stage][$eq]=review&sort=updatedAt:DESC", + "visibility": "shared", + "owner": { "name": "Anna Beispiel", "isDeleted": false }, + "isOwner": true, + "isPinned": true, + "canEdit": true, + "createdAt": "2026-11-07T10:00:00.000Z", + "updatedAt": "2026-11-07T10:05:00.000Z" + } + ], + "meta": { + "pagination": { "page": 1, "pageSize": 25, "pageCount": 1, "total": 1 }, + "permissions": { "canShare": true, "canManage": false } + } +} +``` + +## Admin API All routes are admin routes under `/magic-mark`. Each one requires an authenticated admin (`admin::isAuthenticatedAdmin`) and every listed action (`admin::hasPermissions`). Owner checks -run in the service: a bookmark you cannot see answers `404`, a bookmark you can see but do not -own answers `403`. - -| Permission (Settings > Roles > Plugins) | Action id | -| --------------------------------------------- | ------------------------------------- | -| Read bookmarks | `plugin::magic-mark.bookmarks.read` | -| Create bookmarks | `plugin::magic-mark.bookmarks.create` | -| Edit own bookmarks | `plugin::magic-mark.bookmarks.update` | -| Delete own bookmarks | `plugin::magic-mark.bookmarks.delete` | -| Share bookmarks | `plugin::magic-mark.bookmarks.share` | -| Manage shared bookmarks of others (see below) | `plugin::magic-mark.bookmarks.manage` | - -**Manage shared bookmarks of others** is meant for the people who keep the bookmark list tidy. -It covers every bookmark that is not private: public ones, ones shared with any role or user -(also when you are not a recipient) and ones whose owner was deleted. They are listed on the -Magic Mark page under **Show > All shared bookmarks**. Private bookmarks of other users stay -private, also for managers and Super Admins. Editing and deleting still need Edit own and -Delete own bookmarks. - -| Method and path | Required actions | Notes | -| ------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET /bookmarks` | read | Query: `page`, `pageSize` (max 100), `q`, `scope` (`all`, `mine`, `shared`, `pinned`, `managed`), `sort` (`name:asc`, `name:desc`, `updatedAt:desc`, `updatedAt:asc`), `contentType` | -| `POST /bookmarks` | read, create | Body: `name`, `path`, `query`, `description`, `icon`, `visibility`, `roles`, `users`. Sharing also needs share. | -| `GET /bookmarks/:id` | read | Owners and managers also get the sharing lists | -| `PUT /bookmarks/:id` | read, update | Owner or manage | -| `DELETE /bookmarks/:id` | read, delete | Owner or manage | -| `PUT /bookmarks/:id/view` | read, update | Replaces the saved query ("update with current view") | -| `POST /bookmarks/:id/duplicate` | read, create | Private copy owned by the caller | -| `PUT /bookmarks/:id/pin` | read | Body `{ "pinned": true }`; per user | -| `GET /sharing/roles` | share | `documentId` and name | -| `GET /sharing/users` | share | Search `q`, `page`; 20 per page; email only with `admin::users.read` | - -Errors follow Strapi's format `{ data: null, error: { status, name, message, details } }`. -Invalid input answers `400 ValidationError` with field details. - -There are no Content API routes. +run in the service: a bookmark you cannot see answers `404`; a bookmark you can see but may not +change answers `403`. There are no Content API routes. + +| Method and path | Required actions | Notes | +| ------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET /bookmarks` | read | Query: `page`, `pageSize` (1 to 100), `q` (name search), `scope` (`all`, `mine`, `shared`, `pinned`, `managed`), `sort` (`name:asc`, `name:desc`, `updatedAt:desc`, `updatedAt:asc`), `contentType` | +| `POST /bookmarks` | read, create | Body: `name`, `path`, `query`, `description`, `icon`, `visibility` (`private`, `shared`, `public`), `roles`, `users`. Anything but private also needs share. | +| `GET /bookmarks/:id` | read | Owners and managers also get the sharing lists | +| `PUT /bookmarks/:id` | read, update | Name, description, icon and sharing. Owner or manage. | +| `DELETE /bookmarks/:id` | read, delete | Owner or manage. Also removes its shares and pins. | +| `PUT /bookmarks/:id/view` | read, update | Body `{ "query": "..." }`. Replaces the saved view. | +| `POST /bookmarks/:id/duplicate` | read, create | Optional body `{ "name": "..." }`. Private copy owned by the caller. | +| `PUT /bookmarks/:id/pin` | read | Body `{ "pinned": true }`. Per user. | +| `GET /sharing/roles` | share | `documentId` and name of every role | +| `GET /sharing/users` | share | Query `q`, `page`; 20 active users per page; email only with `admin::users.read` | + +Errors use Strapi's format `{ "data": null, "error": { "status", "name", "message", "details" } }`. +Invalid input answers `400 ValidationError` with field details. Request bodies are strict, so +unknown fields are rejected. ## Security and privacy - No license system, no outbound requests, no timers and no telemetry. On the first start the 3.x license keys are deleted from the plugin store. -- Every route checks permissions on the server; the menu and buttons only follow them. -- `path` must be a Content Manager list view (`/content-manager/collection-types/`), so a - shared bookmark cannot send anyone to another admin page or off-site. Queries are parsed with - `qs` with prototype keys disabled, limited in size and depth, and stored without `page`. -- Request bodies are validated with strict zod schemas; unknown fields are rejected. -- Recipients never see who else a bookmark is shared with. The user search returns names only, - plus email for admins who may read admin users. +- Every route checks authentication and permissions on the server. The menu and buttons only + follow those rules; they are not the security boundary. +- `path` must be a Content Manager list view (`/content-manager/collection-types/` or + `/content-manager/single-types/`), so a shared bookmark cannot send anyone to another + admin page or off-site. +- Saved queries are parsed with `qs` with prototype keys disabled, limited in size (8192 + characters), filter depth (8) and value length (1000 characters), and stored without the page + number. Only `filters`, `sort`, `pageSize`, `_q` and the i18n locale are kept. +- Request bodies are validated with strict zod schemas. Share lists are capped at 200 targets, + and every target must exist (users must be active). +- Recipients never see who else a bookmark is shared with. - Deleting an admin user removes their pins, the shares that point at them and their private - bookmarks. Their public or shared bookmarks stay and show "Deleted user"; someone with - **Manage shared bookmarks of others** finds them under **All shared bookmarks** and can edit or - delete them. Deleting a role removes its shares. -- Logs contain no keys, emails or request bodies. -- Several Strapi processes can share one database: the plugin keeps no state in memory, lists - are filtered and paginated in the database, and migrations claim each row with a guarded update - inside a transaction. - -## Performance and several instances - -- Lists are filtered, searched, sorted and paginated in the database (at most 100 rows per page). - Owner names, pins and share flags of a page are loaded with one query each, so a page costs + bookmarks. Their public or shared bookmarks stay and show "Deleted user"; a manager finds them + under **All shared bookmarks**. Deleting a role removes its shares. +- Logs contain no keys, emails, ids or request bodies, only error names. + +Found a security problem? Please open an issue without exploit details and ask for a private +contact. + +## Performance + +- Lists are filtered, searched, sorted and paginated in the database, at most 100 rows per + request. Owner names, pins and share flags of a page load with one query each, so a page costs the same number of queries for 10 or 100 bookmarks. -- The plugin keeps no state in memory and starts no timers, so several replicas and workers - (for example 2 replicas x 4 pm2 workers) can share one database. Parallel requests were tested - on SQLite; a run on Postgres with several real processes is still to do. -- The 3.x migration runs on every process at start-up. Each row is claimed with a guarded update - inside a transaction, so shares and pins are written once even when processes start together. -- Pinning is idempotent: pinning twice, from two tabs or two processes, leaves the bookmark - pinned. +- The user search for sharing is paginated (20 per page) and runs in the database. +- The plugin keeps nothing in memory between requests and starts no timers or background jobs. + +## Running several instances (Railway replicas, pm2 cluster) + +Magic Mark keeps no state in the Node.js process, so any number of Strapi processes can serve the +same database: Railway replicas, Kubernetes pods, `pm2 start --instances 4`, or all of them +together. + +How writes stay correct across processes: + +- Every write to an existing bookmark (edit, share, update view, pin, delete) first locks the + bookmark row with `SELECT ... FOR UPDATE` inside its transaction. Strapi's admin uses the same + pattern for the first admin user. Two share edits never interleave, two pins never create two + rows, and a request that waited for a delete answers 404 instead of leaving share or pin rows + behind. SQLite ignores `FOR UPDATE` and runs one writer at a time anyway. +- The 3.x upgrade runs in every process at start-up. Each bookmark row is claimed with a guarded + update inside a transaction, and permissions are granted per role under a row lock, so shares, + pins and permissions are written once. +- The plugin gives the Super Admin role its permissions under a row lock before Strapi's own + Super Admin sync runs, so starting several processes at once does not duplicate permission + rows. Duplicates left by an earlier simultaneous start are removed at the next start. + +Tested on Strapi 5.61.0 with Postgres 16: 8 processes in two groups of four, started at the same +moment on a database with 450 bookmarks in the 3.x format, then about 1,260 parallel create, +edit, share, update view, pin, duplicate and delete requests spread over all 8 processes. Result: +no lost or duplicated bookmark, share, pin or permission rows, no orphaned share or pin rows and +no server errors. The same run against the previous code found duplicated pins and permissions, +orphaned shares and a deadlock; those are fixed. + +One thing to know for upgrades that change the database schema (3.x to 4.x adds two tables): +Strapi's own schema sync is not safe to run in several processes at the same moment. In the test +above, 7 of 8 processes stopped at the first start with `relation "magic_bookmark_shares" already +exists` and came up normally when restarted, with correct data. This comes from Strapi core and +happens with any plugin or content type that adds tables. Start one instance first and scale up +once it runs, or rely on your platform's restart policy (Railway restarts crashed replicas, pm2 +restarts crashed workers). -## Troubleshooting +## Compatibility + +| Magic Mark | Strapi | Node.js | Notes | +| ---------- | ------------------- | --------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| 4.x | 5.57.0 or newer 5.x | 22.22.2 or newer 22.x, 24.15.0 or newer | Free. Acceptance host: Strapi 5.61.0 on SQLite and Postgres 16. 3.x data and permissions migrate on the first start. | +| 3.x | 5.x | 22 | Contained the license system. No longer maintained. | -| Symptom | Cause and fix | -| --------------------------------------------- | -------------------------------------------------------------------------------------- | -| No Magic Mark menu entry or Bookmarks button | The role lacks **Read bookmarks**. | -| Save view is missing | The role lacks **Create bookmarks**. | -| No sharing options in the dialog | The role lacks **Share bookmarks**. | -| Users are listed without email | Email is shown only to admins who can read admin users. | -| Advanced filters show only a few fields | The role can read only those fields of the content type in the Content Manager. | -| "This bookmark does not point to a list view" | The bookmark was saved by 3.x with an unusual path. Delete it and save the view again. | +Install with npm, yarn 1 or pnpm. The plugin's peers match what a new Strapi 5 app already has: +`@strapi/strapi`, `react` 18, `react-dom` 18, `react-router-dom` 6 and `styled-components` 6 are +required peers; `@strapi/design-system`, `@strapi/icons` and `react-intl` are optional peers, +because a Strapi app gets them through `@strapi/admin`. Installing the plugin into a new Strapi +5.61 app with npm 11, yarn 1.22 or pnpm prints no warning caused by the plugin. ## Upgrading from 3.x -No manual steps. On the first start after the upgrade: +There are no manual steps. Update the package, rebuild the admin (`npm run build`) and start +Strapi. On the first start: - Roles with the 3.x permission **Read** (`settings.read`) get **Read bookmarks**. Roles with **Edit** (`settings.update`) get Read, Create, Edit own, Delete own and Share bookmarks. Strapi then removes the old permissions. -- Bookmarks shared with users or roles move to the new share table. Shares to users that no - longer exist are dropped. Relative 3.x paths are fixed. +- Bookmarks shared with users or roles move to the new share table. Shares to users or roles that + no longer exist are dropped. Relative 3.x paths are fixed, saved queries are cleaned like new + ones (the page number and unknown keys are removed), and the content type of each bookmark is + filled in. - A bookmark pinned in 3.x stays pinned for its owner. Pins are per user from now on. - The custom order (move up and down) is gone; lists are sorted by name or by last update. - The license key and its validation date are deleted. The Upgrade and License settings pages are gone; every feature is available to everyone. - The 3.x icon ids are kept and shown with Strapi icons. +Breaking changes for code that called the plugin directly: the routes `/license/*`, `/roles`, +`/users` and `/bookmarks/reorder` were removed, and the sharing lookups moved to +`/sharing/roles` and `/sharing/users`. The plugin's own admin UI was their only consumer. The +plugin now needs Strapi 5.57 or newer. Give **Manage shared bookmarks of others** to roles that +should tidy up other users' shared bookmarks. If you run several instances, read the schema note +in [Running several instances](#running-several-instances-railway-replicas-pm2-cluster) before +you deploy the upgrade. + +## Troubleshooting + +| Symptom | Cause and fix | +| ------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| No Magic Mark menu entry, no Bookmarks button | The role lacks **Read bookmarks**. | +| **Save view** is missing | The role lacks **Create bookmarks**. | +| No sharing options in the dialog | The role lacks **Share bookmarks**. | +| Users are listed without email | Email is shown only to admins who can read admin users. | +| Advanced filters show only some fields | The role can read only those fields of the content type in the Content Manager. | +| "This bookmark does not point to a list view" | A 3.x bookmark had an unusual path. Delete it and save the view again. | +| After an upgrade with several instances, some processes stopped with "already exists" | Strapi's schema sync ran in several processes at once. Restart them; see [Running several instances](#running-several-instances-railway-replicas-pm2-cluster). | +| The plugin does not show up after installing | Rebuild the admin panel (`npm run build`) and restart Strapi. | + ## FAQ -**Is Magic Mark free?** Yes. The license system of 3.x is gone; every feature is available to +**Is Magic Mark free?** Yes. The 3.x license system is gone; every feature is available to everyone under the MIT license. -**Does the plugin send data anywhere?** No. It makes no outbound requests and has no telemetry. +**Does the plugin send data anywhere?** No. It makes no outbound requests and has no telemetry, +not even an installation ping. **Can I see the private bookmarks of other admins?** No, also not as Super Admin. Managers see public and shared bookmarks of everyone under **Show > All shared bookmarks**. -**What happens to bookmarks when an admin user is deleted?** Their private bookmarks, pins and the -shares that point at them are removed. Their public and shared bookmarks stay and show "Deleted -user" until a manager removes them. +**What happens to bookmarks when an admin user is deleted?** Their private bookmarks, their pins +and the shares that point at them are removed. Their public and shared bookmarks stay and show +"Deleted user" until a manager removes them. **Does a bookmark open on the page I saved it on?** No. The page number is not saved, so a -bookmark always opens on page 1 with its filters, sort, page size, search and locale. +bookmark opens on page 1 with its filters, sort, page size, search and locale. + +**Are the columns of the list view saved?** No. Columns are a per-user Content Manager setting +and stay as they are. **Why do "Any condition" filters not show as filter tags?** Strapi shows only `$and` filters as tags. The list is still filtered correctly; open Advanced filters to change or clear them. -**Are the columns of the list view saved?** No. Columns are a per-user Content Manager setting -and stay as they are. +**Does it work with a custom admin URL?** Yes. Bookmarks store the path inside the admin, not the +full URL. + +**Can I run it on several Strapi instances?** Yes, see +[Running several instances](#running-several-instances-railway-replicas-pm2-cluster). ## Development and tests @@ -310,27 +403,23 @@ npm run verify npm run verify:runtime ``` -`npm test` runs the Node test runner over the server services, validation, routes (with the real -`admin::hasPermissions` policy), lifecycles and migrations, the admin filter and list view -helpers, translation completeness and source rules. Releases use semantic-release from `main`; -commit messages follow Conventional Commits. - -## Contributing - -Issues and pull requests are welcome at -[github.com/Schero94/Magicmark](https://github.com/Schero94/Magicmark/issues). Before you open a -pull request, run the commands from [Development and tests](#development-and-tests); CI runs the -same ones. Use Conventional Commits (`fix:`, `feat:`, `docs:`), because the release version is -derived from them. Screenshots must use synthetic data only. - -## Maintenance status +`npm test` runs the Node test runner over the server services (access rules, sharing, pins, +concurrent writes with row locks), validation, routes with the real `admin::hasPermissions` +policy, lifecycles and the 3.x migrations (also with 8 parallel runs), the admin filter and list +view helpers, translation completeness and package rules. CI runs every command above on Node 22 +and 24, and type-checks, tests and builds once more against the lowest supported Strapi version +(5.57.0). -Actively maintained. Dependabot proposes updates weekly; patch and minor updates are merged -after lint, tests, type checks, build and verify pass. Strapi peer majors are updated by hand. +## Contributing and support -## Languages +- Bugs and feature requests: [open an issue](https://github.com/Schero94/Magicmark/issues/new/choose). +- Pull requests are welcome. Run the commands from [Development and tests](#development-and-tests) + first; CI runs the same ones. Use Conventional Commits (`fix:`, `feat:`, `docs:`), because the + release version is derived from them. Screenshots must use synthetic data only. -English, German, French, Spanish and Portuguese. +Releases are published to npm by semantic-release from `main`. Dependabot proposes dependency +updates weekly; patch and minor updates are merged after lint, tests, type checks, build and +verify pass, and Strapi peer majors are updated by hand. See [CHANGELOG.md](./CHANGELOG.md). ## License diff --git a/docs/site/index.html b/docs/site/index.html index fd590f8..e9eec4d 100644 --- a/docs/site/index.html +++ b/docs/site/index.html @@ -3,9 +3,12 @@ + + Magic Mark (Magicmark): saved views and bookmarks for the Strapi 5 Content Manager + @@ -13,23 +16,49 @@ - + + + - Magic Mark (Magicmark) | Strapi plugin documentation + + + + + @@ -41,33 +70,44 @@ Magic Mark
-
- Strapi plugin -

Save the view. Open it again with one click.

-

- Magic Mark adds bookmarks and advanced filters to the Strapi 5 Content Manager. Filter a - list, save it as a bookmark and share it with roles or admin users. Free, with no - license key, no limits and no external calls. -

- -
- Strapi 5.57+ (tested on 5.61) - Node 22 and 24 - Free, MIT license +
+
+ Strapi 5 plugin +

Save the view. Open it again with one click.

+

+ Magic Mark adds saved views to the Strapi Content Manager. Filter a list once, save it + as a bookmark and share it with roles or admin users. Free under the MIT license, with + no license key, no limits and no external calls. +

+ + npm install strapi-plugin-magic-mark +
+ Strapi 5.57+ (tested on 5.61) + Node 22 and 24 + npm, yarn, pnpm + MIT license +
+
+ The Bookmarks popover open next to View settings in the Content Manager list view +
@@ -75,11 +115,15 @@

Save the view. Open it again with one click.

@@ -88,102 +132,206 @@

Save the view. Open it again with one click.

Features

- Everything is built with the Strapi Design System, so Magic Mark looks and behaves like - the rest of the admin panel, in light and dark mode, from 1440 px down to 320 px. + Editors open the same lists every day. Magic Mark keeps those lists as bookmarks inside + Strapi, with permissions per role and a UI built only from Strapi Design System + components, in light and dark mode, from 1440 px down to 320 px.

Bookmarks in the list view

A Bookmarks button next to View settings lists the bookmarks of the current content - type. Save view captures filters, sort, page size, search and locale. + type, pinned first. Save view keeps filters, sort order, page size, search and + locale.

Advanced filters

Match all or any condition, use "is one of", "is none of" and "is between", and - filter on relation and component fields. All-conditions filters appear as native + filter on relation and component fields. "All conditions" filters show as native filter tags.

-

Sharing

+

Sharing by role or user

+

+ Keep a bookmark private, share it with roles and single admin users, or with all + admins. Only the owner or a manager changes it; recipients can pin and duplicate it. +

+
+
+

Pins, page and widget

+

+ Pins are per user. Pinned bookmarks appear first in the toolbar and in the "Pinned + bookmarks" widget on the Strapi homepage. The Magic Mark page searches, filters and + sorts everything you can see. +

+
+
+

Checked on the server

- Keep a bookmark private, share it with roles and individual admin users, or with all - admins. Only the owner can change it; recipients can pin and duplicate it. + Six role permissions, owner checks and strict input validation on every request. A + bookmark can only point to a Content Manager list.

-

Bookmarks page and widget

+

Ready for several instances

- Search, filter and sort all bookmarks you can see, and pin your favourites to the - Strapi homepage widget. + Row locks keep edits, shares and pins correct when Railway replicas or pm2 workers + share one database. Tested with 8 processes on Postgres.

-
- Bookmarks popover in the Content Manager list view -
Bookmarks for the current content type, pinned first.
-
-
- Advanced filters dialog with two conditions -
Advanced filters with the native operator wording.
-
-
- Magic Mark bookmarks page with search, filters and table -
The bookmarks page: owner, visibility, filters and row actions.
-
+
+ +
+

How it works

+
    +
  1. + +

    Filter a list

    +

    + Open any collection type in the Content Manager and set filters, sort order, search + or locale, with the native filters or with Advanced filters. +

    +
  2. +
  3. + +

    Save the view

    +

    + Open Bookmarks next to View settings and choose Save view. Name it, pick an icon and + decide who can see it. +

    +
  4. +
  5. + +

    Open it again

    +

    + One click in the toolbar, on the Magic Mark page or in the homepage widget opens the + list exactly like that, on page 1. +

    +
  6. +

Install

- Requires Strapi 5.57 or newer 5.x and Node.js 22.22.2 or newer (or 24.15.0 or newer). + Needs Strapi 5.57 or newer 5.x and Node.js 22.22.2 or newer (or 24.15.0 or newer). + Installing into a new Strapi 5.61 app with npm 11, yarn 1.22 or pnpm prints no warning + caused by the plugin.

-
Install from npm
-
npm install strapi-plugin-magic-mark
+
1. Install the package
+
npm install strapi-plugin-magic-mark
+# or: yarn add strapi-plugin-magic-mark
+# or: pnpm add strapi-plugin-magic-mark
-
config/plugins.ts
-
export default () => ({
-  'magic-mark': { enabled: true },
-});
+
2. Rebuild the admin and start Strapi
+
npm run build
+npm run develop

- Rebuild the admin panel with npm run build, then give your roles the Magic - Mark permissions under Settings, Roles, Plugins. + Strapi enables installed plugins by itself. If your config/plugins file + lists plugins explicitly, add 'magic-mark': { enabled: true }. Super Admins + have every permission; give other roles what they need under Settings, Roles, Plugins, + Magic Mark.

+
+

Screens

+

All screenshots use synthetic data on a Strapi 5.61 host.

+ +
+
-

Configuration

+

Configuration and permissions

- There are no configuration options and no environment variables. Access is controlled - with role permissions. + There are no configuration options and no environment variables. Role permissions decide + what each admin can do. The owner of a bookmark can always edit and delete it (with Edit + own and Delete own).

- @@ -197,7 +345,7 @@

Configuration

- + @@ -207,7 +355,7 @@

Configuration

- + @@ -223,8 +371,8 @@

Configuration

@@ -232,163 +380,255 @@

Configuration

-
-

Security and privacy

-
    -
  • - Every admin route requires an authenticated admin and the listed permissions - (admin::hasPermissions). A bookmark you cannot see answers 404; one you - can see but do not own answers 403 when you try to change it. -
  • -
  • - Saved paths must be Content Manager list views, so a shared bookmark cannot lead to - another admin page or off-site. Queries are parsed with prototype keys disabled and - stored without the page number. -
  • -
  • - Request bodies are validated with strict schemas. Recipients never see who else a - bookmark is shared with; email addresses are shown only to admins who may read admin - users. -
  • -
  • - No license system, no telemetry, no outbound requests and no timers. Stored 3.x - license keys are deleted on the first start. -
  • -
  • - Deleting an admin user removes their pins, the shares pointing at them and their - private bookmarks. -
  • -
-
- -
-

Performance and several instances

-
    -
  • - Lists are filtered, searched, sorted and paginated in the database, at most 100 rows - per page. Owner names, pins and share flags of a page load with one query each. -
  • -
  • - The plugin keeps no state in memory and starts no timers, so several replicas and - workers can share one database. Parallel requests were tested on SQLite; a run on - Postgres with several real processes is still to do. -
  • -
  • - The 3.x migration claims each row with a guarded update inside a transaction, so - shares and pins are written once even when processes start together. -
  • -
+
+

Examples

+

A review queue for the editor team

+

+ Filter Articles by stage is review, sort by + updatedAt descending, save the view as "Waiting for review" and share it + with the Editor role. Every editor finds it in the Bookmarks menu and can pin it. +

+

A personal working set

+

+ In Advanced filters choose Any condition, add "stage is one of idea, review" and "author + is anna.beispiel", apply, save it as a private bookmark and pin it. +

+

Read bookmarks from a script

+
+
Admin API with an admin JWT
+
curl -s "http://localhost:1337/magic-mark/bookmarks?scope=pinned" \
+  -H "Authorization: Bearer $ADMIN_JWT"
+

Admin API

- Admin routes under /magic-mark. There are no Content API routes. + Admin routes under /magic-mark. Each one needs an authenticated admin and + every listed permission. A bookmark you cannot see answers 404; one you may not change + answers 403. There are no Content API routes.

+ Magic Mark permissions
Read bookmarks plugin::magic-mark.bookmarks.readSee and open bookmarks, pin them, the bookmarks page and widget.See and open bookmarks, pin them, the Magic Mark page and the widget.
Create bookmarks
Edit own bookmarks plugin::magic-mark.bookmarks.updateRename, describe and update the saved view of your bookmarks.Rename, describe, change the icon and update the saved view.
Delete own bookmarks Manage shared bookmarks of others plugin::magic-mark.bookmarks.manage - Edit and delete every public or shared bookmark, also ones of deleted users - (Show > All shared bookmarks). Private bookmarks of others stay private. + See every public or shared bookmark under Show > All shared bookmarks, also + ones of deleted users. Private bookmarks of others stay private.
- - - + - - + - + - - + - + - - + - - + - + - - + - + - - + - - + - - + - - + - +
+ Magic Mark admin API routes
MethodPathRoute Permissions Purpose
GET/bookmarksGET /bookmarks readPaginated list with search, scope, sort and content type filters. + Paginated list. Query: page, pageSize (max 100), + q, scope, sort, contentType. +
POST/bookmarksPOST /bookmarks read, createCreate a bookmark; sharing also needs share.Create a bookmark. Sharing also needs share.
GET/bookmarks/:idGET /bookmarks/:id read One bookmark; sharing lists for the owner and managers.
PUT/bookmarks/:idPUT /bookmarks/:id read, updateEdit name, description, icon and sharing (owner or manage).Edit name, description, icon and sharing.
DELETE/bookmarks/:idDELETE /bookmarks/:id read, deleteDelete a bookmark (owner or manage).Delete a bookmark with its shares and pins.
PUT/bookmarks/:id/viewPUT /bookmarks/:id/view read, update Save the current view into a bookmark.
POST/bookmarks/:id/duplicatePOST /bookmarks/:id/duplicate read, create Private copy owned by the caller.
PUT/bookmarks/:id/pinPUT /bookmarks/:id/pin read Pin or unpin for the caller only.
GET/sharing/roles, /sharing/usersGET /sharing/roles, GET /sharing/users shareRoles, and a paginated admin user search for sharing.Roles, and a paginated search of active admin users.
-
-

FAQ

-

Is Magic Mark free?

-

Yes. The license system of 3.x is gone and every feature is available to everyone.

-

Does the plugin send data anywhere?

-

No. It makes no outbound requests and has no telemetry.

-

Can managers see private bookmarks of other admins?

-

- No, also not as Super Admin. Managers see public and shared bookmarks of everyone under - Show > All shared bookmarks, including ones whose owner was deleted. +

+

Security and privacy

+
    +
  • + No license system, no telemetry, no outbound requests and no timers. Stored 3.x + license keys are deleted on the first start. +
  • +
  • + Every route checks authentication and permissions on the server; menus and buttons + only follow those rules. +
  • +
  • + Saved paths must be Content Manager list views, so a shared bookmark cannot lead to + another admin page or off-site. Queries are parsed with prototype keys disabled, + limited in size and depth, and stored without the page number. +
  • +
  • + Request bodies are validated with strict schemas. Recipients never see who else a + bookmark is shared with; email addresses are shown only to admins who may read admin + users. +
  • +
  • + Deleting an admin user removes their pins, the shares pointing at them and their + private bookmarks. Their shared bookmarks stay, marked with the deleted owner, until a + manager removes them. +
  • +
+
+ +
+

Running several instances

+

+ Railway replicas, Kubernetes pods and pm2 cluster workers can share one database. The + plugin keeps no state in the process and starts no timers.

-

Does a bookmark open on the page I saved it on?

-

- No. The page number is not saved, so a bookmark opens on page 1 with its filters, sort, - page size, search and locale. +

    +
  • + Every write to an existing bookmark locks its row (SELECT ... FOR UPDATE) + inside a transaction, so parallel share edits, pins and deletes never leave duplicate + or orphaned rows. +
  • +
  • + The 3.x upgrade claims each row with a guarded update and grants permissions per role + under a row lock, so every process can run it at the same start. +
  • +
  • + Tested on Strapi 5.61 and Postgres 16 with 8 processes (two groups of four) and about + 1,260 parallel requests: no lost or duplicated rows, no server errors. +
  • +
+

+ Strapi's own schema sync is not safe in several processes at the same moment. When an + upgrade adds tables (3.x to 4.x adds two), start one instance first, or let your + platform restart the processes that stop with "already exists".

-

Why do "Any condition" filters not show as filter tags?

- Strapi shows only $and filters as tags. The list is still filtered - correctly; open Advanced filters to change or clear them. + Lists are filtered, sorted and paginated in the database (at most 100 rows per request) + and load owner names, pins and share flags with one query each. +

+
+ +
+

Upgrading from 3.x

+

+ No manual steps: update the package, rebuild the admin and start Strapi.

+
    +
  • + 3.x Read becomes Read bookmarks; 3.x Edit becomes Read, Create, Edit own, Delete own + and Share bookmarks. +
  • +
  • + Shares move to their own table, 3.x pins become the owner's pin, relative paths and + saved queries are cleaned up, and the license keys are deleted. +
  • +
  • + The internal routes /license/*, /roles, + /users and /bookmarks/reorder are gone; the custom manual + order is no longer used. Strapi 5.57 or newer is required. +
  • +
+
+ +
+

FAQ

+
+ Is Magic Mark free? +

Yes. Every feature is available to everyone under the MIT license.

+
+
+ Does the plugin send data anywhere? +

No. It makes no outbound requests and has no telemetry, not even an install ping.

+
+
+ Can managers see private bookmarks of other admins? +

+ No, also not as Super Admin. Managers see public and shared bookmarks of everyone + under Show > All shared bookmarks, including ones whose owner was deleted. +

+
+
+ Does a bookmark open on the page I saved it on? +

+ No. The page number is not saved, so a bookmark opens on page 1 with its filters, + sort, page size, search and locale. +

+
+
+ Why do "Any condition" filters not show as filter tags? +

+ Strapi shows only $and filters as tags. The list is still filtered + correctly; open Advanced filters to change or clear them. +

+
+
+ Are the list view columns saved? +

No. Columns are a per-user Content Manager setting and stay as they are.

+
-

Releases and testing

+

Releases, compatibility and contributing

- Releases use semantic-release from the main branch with Conventional Commits. CI runs - lint, format check, tests, type checks, build and verify on Node 22 and 24. + Releases are published to npm by semantic-release from the main branch. CI runs lint, + format check, tests, type checks, build and verify on Node 22 and 24, and once more + against Strapi 5.57.0, the lowest supported version.

+
+ + + + + + + + + + + + + + + + + + + + + +
+ Compatibility +
Magic MarkStrapiNode.js
4.x5.57.0 or newer 5.x (tested on 5.61.0)22.22.2 or newer 22.x, 24.15.0 or newer
3.x5.x (no longer maintained)22
+
-
Run checks locally
+
Run the checks locally
npm ci --legacy-peer-deps
 npm run lint
 npm run format:check
@@ -396,13 +636,11 @@ 

Releases and testing

npm run build npm run verify
-

- Upgrading from 3.x needs no manual steps: permissions, shares and pins are migrated on - the first start. See the README for details. -

diff --git a/docs/site/site-extra.css b/docs/site/site-extra.css new file mode 100644 index 0000000..0ec334a --- /dev/null +++ b/docs/site/site-extra.css @@ -0,0 +1,211 @@ +/* + * Magic Mark page styles on top of the shared theme (site.css, kept byte-identical to the + * workbench template). Adds dark mode, the hero screenshot, steps, the gallery and small + * layout helpers. Colours are tokens so light and dark stay in one place. + */ +:root { + --link: #4932c6; + --link-hover: #2d1d89; + --nav-text: #4b5367; + --accent-soft: #f0edff; + --number-ink: #3821a1; + --number-bg: #eee9ff; + --callout-ink: #3c465c; + --callout-bg: #e9f9f9; + --warning-line: #b26b00; + --warning-bg: #fff6e4; + --th-ink: #40495f; + --th-bg: #f7f8fb; + --td-ink: #4b5468; + --shot-shadow: 0 18px 50px rgba(26, 32, 60, 0.18); +} + +@media (prefers-color-scheme: dark) { + :root { + color-scheme: dark; + --ink: #eef0f8; + --muted: #a9b0c4; + --line: #2c3350; + --paper: #10131f; + --white: #181c2c; + --link: #b3a8ff; + --link-hover: #d6cfff; + --nav-text: #c1c7d8; + --accent-soft: #262a48; + --number-ink: #d9d2ff; + --number-bg: #2e2a5c; + --callout-ink: #d3dae8; + --callout-bg: #13302f; + --warning-line: #e4a83b; + --warning-bg: #33280f; + --th-ink: #d0d5e4; + --th-bg: #1d2236; + --td-ink: #c3c9da; + --shot-shadow: 0 18px 50px rgba(0, 0, 0, 0.45); + } +} + +a { + color: var(--link); +} +a:hover { + color: var(--link-hover); +} +.side-nav a { + color: var(--nav-text); +} +.side-nav a:hover { + background: var(--accent-soft); +} +.number { + color: var(--number-ink); + background: var(--number-bg); +} +.callout { + color: var(--callout-ink); + background: var(--callout-bg); +} +.callout.warning { + border-color: var(--warning-line); + background: var(--warning-bg); +} +th { + color: var(--th-ink); + background: var(--th-bg); +} +td { + color: var(--td-ink); +} +.link-list a { + background: var(--white); +} +.top-links a:focus-visible, +.hero a:focus-visible { + outline-color: var(--lime); +} + +/* Hero: copy on the left, product screenshot on the right; stacked below 1100 px. */ +.hero-grid { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr); + gap: clamp(1.5rem, 4vw, 3rem); + align-items: center; +} +.hero-shot { + margin: 0; +} +.hero-shot img { + display: block; + width: 100%; + height: auto; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 14px; + box-shadow: 0 24px 60px rgba(5, 8, 20, 0.45); +} +.hero-install { + display: inline-flex; + max-width: 100%; + margin-top: 1.1rem; + padding: 0.55rem 0.8rem; + border: 1px solid rgba(255, 255, 255, 0.2); + border-radius: 10px; + color: #f1f3fa; + background: rgba(9, 13, 27, 0.45); + font-size: 0.9rem; + overflow-wrap: anywhere; +} + +/* Three numbered steps. */ +.steps { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 1rem; + margin: 0; + padding: 0; + list-style: none; +} +.steps .card p + p { + margin-top: 0.5rem; +} + +/* Screenshot gallery. */ +.gallery { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 1.25rem; +} +.shot { + margin: 0; +} +.shot img { + display: block; + width: 100%; + height: auto; + border: 1px solid var(--line); + border-radius: 12px; + box-shadow: var(--shot-shadow); +} +.shot figcaption { + margin-top: 0.55rem; + color: var(--muted); + font-size: 0.86rem; +} +.shot.wide { + grid-column: 1 / -1; +} +.shot.phone img { + max-width: 300px; + margin: 0 auto; +} + +/* Small tables inside cards and readable code inside tables. */ +td code, +th code { + overflow-wrap: anywhere; +} +.section h3 { + margin: 1.4rem 0 0.4rem; + font-size: 1.08rem; + letter-spacing: -0.015em; +} +details { + margin: 0 0 0.6rem; + padding: 0.75rem 1rem; + border: 1px solid var(--line); + border-radius: 12px; + background: var(--white); +} +details summary { + cursor: pointer; + font-weight: 700; +} +details p { + margin: 0.6rem 0 0; + color: var(--muted); +} +.check-list li { + margin-bottom: 0.4rem; +} + +@media (max-width: 1100px) { + .hero-grid { + grid-template-columns: 1fr; + } +} +@media (max-width: 820px) { + .steps, + .gallery { + grid-template-columns: 1fr; + } +} + +.visually-hidden { + position: absolute; + width: 1px; + height: 1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; +} diff --git a/docs/site/site.css b/docs/site/site.css index ecc88d2..9b92b8b 100644 --- a/docs/site/site.css +++ b/docs/site/site.css @@ -9,529 +9,94 @@ --cyan: #32c6c8; --lime: #c9f27b; --radius: 18px; - --link: #4932c6; - --link-hover: #2d1d89; - --nav-text: #4b5367; - --accent-soft: #f0edff; - --number-ink: #3821a1; - --number-bg: #eee9ff; - --callout-ink: #3c465c; - --callout-bg: #e9f9f9; - --warning-line: #e4a83b; - --warning-bg: #fff6e4; - --th-ink: #40495f; - --th-bg: #f7f8fb; - --td-ink: #555f74; - font-family: - Inter, - ui-sans-serif, - system-ui, - -apple-system, - BlinkMacSystemFont, - 'Segoe UI', - sans-serif; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; font-synthesis: none; text-rendering: optimizeLegibility; } -@media (prefers-color-scheme: dark) { - :root { - color-scheme: dark; - --ink: #eef0f8; - --muted: #a9b0c4; - --line: #2c3350; - --paper: #10131f; - --white: #181c2c; - --link: #a99bff; - --link-hover: #cfc6ff; - --nav-text: #c1c7d8; - --accent-soft: #262a48; - --number-ink: #d9d2ff; - --number-bg: #2e2a5c; - --callout-ink: #d3dae8; - --callout-bg: #13302f; - --warning-line: #e4a83b; - --warning-bg: #33280f; - --th-ink: #d0d5e4; - --th-bg: #1d2236; - --td-ink: #b9c0d3; - } -} +* { box-sizing: border-box; } +html { scroll-behavior: smooth; scroll-padding-top: 5.5rem; } +body { margin: 0; background: var(--paper); color: var(--ink); line-height: 1.65; } +a { color: #4932c6; text-underline-offset: .18em; } +a:hover { color: #2d1d89; } +a:focus-visible, button:focus-visible { outline: 3px solid #16a8b5; outline-offset: 3px; border-radius: 6px; } +code, pre { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } +code { font-size: .92em; } -* { - box-sizing: border-box; -} -html { - scroll-behavior: smooth; - scroll-padding-top: 5.5rem; -} -body { - margin: 0; - background: var(--paper); - color: var(--ink); - line-height: 1.65; -} -a { - color: var(--link); - text-underline-offset: 0.18em; -} -a:hover { - color: var(--link-hover); -} -a:focus-visible, -button:focus-visible { - outline: 3px solid #16a8b5; - outline-offset: 3px; - border-radius: 6px; -} -code, -pre { - font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; -} -code { - font-size: 0.92em; -} - -.skip-link { - position: absolute; - left: 1rem; - top: -5rem; - z-index: 10; - padding: 0.65rem 1rem; - border-radius: 8px; - background: var(--white); - color: var(--ink); -} -.skip-link:focus { - top: 1rem; -} -.topbar { - position: sticky; - top: 0; - z-index: 5; - background: rgba(17, 22, 39, 0.97); - color: #f7f8fc; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - backdrop-filter: blur(12px); -} -.topbar-inner { - max-width: 1220px; - margin: 0 auto; - padding: 0.8rem 1.5rem; - display: flex; - align-items: center; - justify-content: space-between; - gap: 1rem; -} -.brand { - display: flex; - align-items: center; - gap: 0.7rem; - min-width: 0; - color: #fff; - text-decoration: none; - font-weight: 750; - letter-spacing: -0.02em; -} -.brand-mark { - display: grid; - place-items: center; - width: 2rem; - height: 2rem; - flex: 0 0 auto; - border-radius: 9px; - background: linear-gradient(140deg, var(--purple), var(--cyan)); - color: #fff; - font-size: 0.8rem; -} -.top-links { - display: flex; - align-items: center; - gap: 1.15rem; -} -.top-links a { - color: #d7def0; - font-size: 0.88rem; - font-weight: 600; - text-decoration: none; -} -.top-links a:hover { - color: #fff; -} +.skip-link { position: absolute; left: 1rem; top: -5rem; z-index: 10; padding: .65rem 1rem; border-radius: 8px; background: var(--white); color: var(--ink); } +.skip-link:focus { top: 1rem; } +.topbar { position: sticky; top: 0; z-index: 5; background: rgba(17, 22, 39, .97); color: #f7f8fc; border-bottom: 1px solid rgba(255,255,255,.1); backdrop-filter: blur(12px); } +.topbar-inner { max-width: 1220px; margin: 0 auto; padding: .8rem 1.5rem; display: flex; align-items: center; justify-content: space-between; gap: 1rem; } +.brand { display: flex; align-items: center; gap: .7rem; min-width: 0; color: #fff; text-decoration: none; font-weight: 750; letter-spacing: -.02em; } +.brand-mark { display: grid; place-items: center; width: 2rem; height: 2rem; flex: 0 0 auto; border-radius: 9px; background: linear-gradient(140deg, var(--purple), var(--cyan)); color: #fff; font-size: .8rem; } +.top-links { display: flex; align-items: center; gap: 1.15rem; } +.top-links a { color: #d7def0; font-size: .88rem; font-weight: 600; text-decoration: none; } +.top-links a:hover { color: #fff; } -.hero-wrap { - padding: 2.2rem 1.5rem 0; -} -.hero { - position: relative; - overflow: hidden; - max-width: 1220px; - min-height: 300px; - margin: 0 auto; - padding: clamp(2rem, 5vw, 4rem); - border-radius: 26px; - color: #fff; - background: - radial-gradient(circle at 86% 20%, rgba(50, 198, 200, 0.42), transparent 28%), - radial-gradient(circle at 70% 110%, rgba(109, 74, 255, 0.52), transparent 42%), - linear-gradient(125deg, #171b31 0%, #242442 60%, #173748 100%); - box-shadow: 0 20px 60px rgba(26, 32, 60, 0.16); -} -.hero::after { - position: absolute; - right: 7%; - top: 15%; - width: 190px; - height: 190px; - border: 1px solid rgba(255, 255, 255, 0.16); - border-radius: 50%; - box-shadow: - 0 0 0 25px rgba(255, 255, 255, 0.035), - 0 0 0 58px rgba(255, 255, 255, 0.025); - content: ''; - pointer-events: none; -} -.hero-copy { - position: relative; - z-index: 1; - max-width: 760px; -} -.eyebrow { - display: inline-flex; - align-items: center; - gap: 0.5rem; - color: var(--lime); - font-size: 0.75rem; - font-weight: 800; - letter-spacing: 0.13em; - text-transform: uppercase; -} -.eyebrow::before { - width: 1.25rem; - height: 2px; - background: var(--lime); - content: ''; -} -.hero h1 { - margin: 0.8rem 0 0.65rem; - max-width: 760px; - font-size: clamp(2.35rem, 5vw, 4.35rem); - line-height: 1.02; - letter-spacing: -0.055em; -} -.hero p { - max-width: 650px; - margin: 0; - color: #d8def0; - font-size: clamp(1rem, 1.5vw, 1.16rem); -} -.hero-actions { - display: flex; - flex-wrap: wrap; - gap: 0.7rem; - margin-top: 1.55rem; -} -.button { - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 2.75rem; - padding: 0.65rem 1rem; - border: 1px solid rgba(255, 255, 255, 0.25); - border-radius: 10px; - color: #fff; - background: rgba(255, 255, 255, 0.08); - font-weight: 700; - text-decoration: none; -} -.button.primary { - border-color: var(--lime); - color: #151a2d; - background: var(--lime); -} -.button:hover { - transform: translateY(-1px); - color: #fff; - background: rgba(255, 255, 255, 0.15); -} -.button.primary:hover { - color: #151a2d; - background: #d8ff9a; -} -.badges { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 1.25rem; -} -.badge { - padding: 0.25rem 0.6rem; - border: 1px solid rgba(255, 255, 255, 0.22); - border-radius: 999px; - color: #e8ecf7; - background: rgba(9, 13, 27, 0.24); - font-size: 0.76rem; - font-weight: 650; -} +.hero-wrap { padding: 2.2rem 1.5rem 0; } +.hero { position: relative; overflow: hidden; max-width: 1220px; min-height: 300px; margin: 0 auto; padding: clamp(2rem, 5vw, 4rem); border-radius: 26px; color: #fff; background: radial-gradient(circle at 86% 20%, rgba(50,198,200,.42), transparent 28%), radial-gradient(circle at 70% 110%, rgba(109,74,255,.52), transparent 42%), linear-gradient(125deg, #171b31 0%, #242442 60%, #173748 100%); box-shadow: 0 20px 60px rgba(26,32,60,.16); } +.hero::after { position: absolute; right: 7%; top: 15%; width: 190px; height: 190px; border: 1px solid rgba(255,255,255,.16); border-radius: 50%; box-shadow: 0 0 0 25px rgba(255,255,255,.035), 0 0 0 58px rgba(255,255,255,.025); content: ""; pointer-events: none; } +.hero-copy { position: relative; z-index: 1; max-width: 760px; } +.eyebrow { display: inline-flex; align-items: center; gap: .5rem; color: var(--lime); font-size: .75rem; font-weight: 800; letter-spacing: .13em; text-transform: uppercase; } +.eyebrow::before { width: 1.25rem; height: 2px; background: var(--lime); content: ""; } +.hero h1 { margin: .8rem 0 .65rem; max-width: 760px; font-size: clamp(2.35rem, 5vw, 4.35rem); line-height: 1.02; letter-spacing: -.055em; } +.hero p { max-width: 650px; margin: 0; color: #d8def0; font-size: clamp(1rem, 1.5vw, 1.16rem); } +.hero-actions { display: flex; flex-wrap: wrap; gap: .7rem; margin-top: 1.55rem; } +.button { display: inline-flex; align-items: center; justify-content: center; min-height: 2.75rem; padding: .65rem 1rem; border: 1px solid rgba(255,255,255,.25); border-radius: 10px; color: #fff; background: rgba(255,255,255,.08); font-weight: 700; text-decoration: none; } +.button.primary { border-color: var(--lime); color: #151a2d; background: var(--lime); } +.button:hover { transform: translateY(-1px); color: #fff; background: rgba(255,255,255,.15); } +.button.primary:hover { color: #151a2d; background: #d8ff9a; } +.badges { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: 1.25rem; } +.badge { padding: .25rem .6rem; border: 1px solid rgba(255,255,255,.22); border-radius: 999px; color: #e8ecf7; background: rgba(9,13,27,.24); font-size: .76rem; font-weight: 650; } -.layout { - display: grid; - grid-template-columns: 205px minmax(0, 1fr); - gap: clamp(1.5rem, 4vw, 3.5rem); - max-width: 1180px; - margin: 0 auto; - padding: 2.6rem 1.5rem 4rem; -} -.side-nav { - position: sticky; - top: 5.7rem; - align-self: start; - display: grid; - gap: 0.2rem; - padding: 0.55rem; - border: 1px solid var(--line); - border-radius: 14px; - background: var(--white); -} -.side-nav a { - padding: 0.55rem 0.7rem; - border-radius: 8px; - color: var(--nav-text); - font-size: 0.87rem; - font-weight: 620; - text-decoration: none; -} -.side-nav a:hover { - color: var(--ink); - background: var(--accent-soft); -} -.content { - min-width: 0; -} -.section { - margin: 0 0 2.4rem; - scroll-margin-top: 6rem; -} -.section h2 { - margin: 0 0 0.6rem; - font-size: clamp(1.45rem, 2.5vw, 2rem); - line-height: 1.2; - letter-spacing: -0.035em; -} -.section-lead { - max-width: 780px; - margin: 0 0 1.2rem; - color: var(--muted); -} -.card-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 1rem; -} -.card { - padding: 1.1rem 1.2rem; - border: 1px solid var(--line); - border-radius: var(--radius); - background: var(--white); - box-shadow: 0 8px 24px rgba(31, 43, 74, 0.035); -} -.card h3 { - margin: 0 0 0.4rem; - font-size: 1.02rem; - letter-spacing: -0.015em; -} -.card p { - margin: 0; - color: var(--muted); - font-size: 0.93rem; -} -.number { - display: grid; - place-items: center; - width: 2rem; - height: 2rem; - margin-bottom: 0.75rem; - border-radius: 10px; - color: var(--number-ink); - background: var(--number-bg); - font-weight: 800; -} -.callout { - margin: 1rem 0; - padding: 0.9rem 1rem; - border-left: 3px solid var(--cyan); - border-radius: 0 10px 10px 0; - color: var(--callout-ink); - background: var(--callout-bg); -} -.callout.warning { - border-color: var(--warning-line); - background: var(--warning-bg); -} -.code-card { - overflow: hidden; - margin: 1rem 0; - border: 1px solid #272d44; - border-radius: 14px; - color: #f1f3fa; - background: #171b2b; -} -.code-card pre { - overflow-x: auto; - margin: 0; - padding: 1rem 1.1rem; - font-size: 0.88rem; - line-height: 1.6; - tab-size: 2; -} -.code-label { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0.55rem 1rem; - border-bottom: 1px solid rgba(255, 255, 255, 0.1); - color: #b8bfd3; - font-size: 0.76rem; -} -.table-wrap { - overflow-x: auto; - border: 1px solid var(--line); - border-radius: 14px; - background: var(--white); -} -table { - width: 100%; - border-collapse: collapse; - text-align: left; -} -th, -td { - padding: 0.75rem 0.9rem; - border-bottom: 1px solid var(--line); - vertical-align: top; -} -th { - color: var(--th-ink); - background: var(--th-bg); - font-size: 0.8rem; -} -td { - color: var(--td-ink); - font-size: 0.9rem; -} -tr:last-child td { - border-bottom: 0; -} -.link-list { - display: flex; - flex-wrap: wrap; - gap: 0.65rem; -} -.link-list a { - padding: 0.45rem 0.7rem; - border: 1px solid var(--line); - border-radius: 9px; - background: var(--white); - font-size: 0.87rem; - font-weight: 650; - text-decoration: none; -} -.footer { - max-width: 1180px; - margin: 0 auto; - padding: 1.5rem; - border-top: 1px solid var(--line); - color: var(--muted); - font-size: 0.82rem; -} -.footer p { - margin: 0.2rem 0; -} +.layout { display: grid; grid-template-columns: 205px minmax(0, 1fr); gap: clamp(1.5rem, 4vw, 3.5rem); max-width: 1180px; margin: 0 auto; padding: 2.6rem 1.5rem 4rem; } +.side-nav { position: sticky; top: 5.7rem; align-self: start; display: grid; gap: .2rem; padding: .55rem; border: 1px solid var(--line); border-radius: 14px; background: var(--white); } +.side-nav a { padding: .55rem .7rem; border-radius: 8px; color: #4b5367; font-size: .87rem; font-weight: 620; text-decoration: none; } +.side-nav a:hover { color: var(--ink); background: #f0edff; } +.content { min-width: 0; } +.section { margin: 0 0 2.4rem; scroll-margin-top: 6rem; } +.section h2 { margin: 0 0 .6rem; font-size: clamp(1.45rem, 2.5vw, 2rem); line-height: 1.2; letter-spacing: -.035em; } +.section-lead { max-width: 780px; margin: 0 0 1.2rem; color: var(--muted); } +.card-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } +.card { padding: 1.1rem 1.2rem; border: 1px solid var(--line); border-radius: var(--radius); background: var(--white); box-shadow: 0 8px 24px rgba(31,43,74,.035); } +.card h3 { margin: 0 0 .4rem; font-size: 1.02rem; letter-spacing: -.015em; } +.card p { margin: 0; color: var(--muted); font-size: .93rem; } +.number { display: grid; place-items: center; width: 2rem; height: 2rem; margin-bottom: .75rem; border-radius: 10px; color: #3821a1; background: #eee9ff; font-weight: 800; } +.callout { margin: 1rem 0; padding: .9rem 1rem; border-left: 3px solid var(--cyan); border-radius: 0 10px 10px 0; color: #3c465c; background: #e9f9f9; } +.callout.warning { border-color: #e4a83b; background: #fff6e4; } +.code-card { overflow: hidden; margin: 1rem 0; border: 1px solid #272d44; border-radius: 14px; color: #f1f3fa; background: #171b2b; } +.code-card pre { overflow-x: auto; margin: 0; padding: 1rem 1.1rem; font-size: .88rem; line-height: 1.6; tab-size: 2; } +.code-label { display: flex; align-items: center; justify-content: space-between; padding: .55rem 1rem; border-bottom: 1px solid rgba(255,255,255,.1); color: #b8bfd3; font-size: .76rem; } +.table-wrap { overflow-x: auto; border: 1px solid var(--line); border-radius: 14px; background: var(--white); } +table { width: 100%; border-collapse: collapse; text-align: left; } +th, td { padding: .75rem .9rem; border-bottom: 1px solid var(--line); vertical-align: top; } +th { color: #40495f; background: #f7f8fb; font-size: .8rem; } +td { color: #555f74; font-size: .9rem; } +tr:last-child td { border-bottom: 0; } +.link-list { display: flex; flex-wrap: wrap; gap: .65rem; } +.link-list a { padding: .45rem .7rem; border: 1px solid var(--line); border-radius: 9px; background: #fff; font-size: .87rem; font-weight: 650; text-decoration: none; } +.footer { max-width: 1180px; margin: 0 auto; padding: 1.5rem; border-top: 1px solid var(--line); color: var(--muted); font-size: .82rem; } +.footer p { margin: .2rem 0; } @media (max-width: 820px) { - .hero-wrap { - padding: 1rem 0.85rem 0; - } - .hero { - min-height: 260px; - border-radius: 20px; - } - .hero::after { - right: -4rem; - top: 2rem; - opacity: 0.55; - } - .layout { - grid-template-columns: 1fr; - gap: 1.15rem; - padding: 1.4rem 0.85rem 3rem; - } - .side-nav { - position: static; - display: flex; - overflow-x: auto; - gap: 0.15rem; - white-space: nowrap; - scrollbar-width: thin; - } - .side-nav a { - flex: 0 0 auto; - } + .hero-wrap { padding: 1rem .85rem 0; } + .hero { min-height: 260px; border-radius: 20px; } + .hero::after { right: -4rem; top: 2rem; opacity: .55; } + .layout { grid-template-columns: 1fr; gap: 1.15rem; padding: 1.4rem .85rem 3rem; } + .side-nav { position: static; display: flex; overflow-x: auto; gap: .15rem; white-space: nowrap; scrollbar-width: thin; } + .side-nav a { flex: 0 0 auto; } } @media (max-width: 560px) { - .topbar-inner { - padding: 0.7rem 0.85rem; - } - .top-links { - gap: 0.65rem; - } - .top-links a { - font-size: 0.78rem; - } - .brand { - font-size: 0.9rem; - } - .hero { - padding: 1.7rem 1.25rem; - } - .hero h1 { - font-size: clamp(2.1rem, 11vw, 3rem); - } - .card-grid { - grid-template-columns: 1fr; - } - .section { - margin-bottom: 2rem; - } - th, - td { - padding: 0.65rem; - } + .topbar-inner { padding: .7rem .85rem; } + .top-links { gap: .65rem; } + .top-links a { font-size: .78rem; } + .brand { font-size: .9rem; } + .hero { padding: 1.7rem 1.25rem; } + .hero h1 { font-size: clamp(2.1rem, 11vw, 3rem); } + .card-grid { grid-template-columns: 1fr; } + .section { margin-bottom: 2rem; } + th, td { padding: .65rem; } } @media (prefers-reduced-motion: reduce) { - *, - *::before, - *::after { - scroll-behavior: auto !important; - transition-duration: 0.01ms !important; - animation-duration: 0.01ms !important; - } -} - -.shot { - margin: 24px 0 0; -} - -.shot img { - display: block; - width: 100%; - height: auto; - border: 1px solid var(--line); - border-radius: 8px; -} - -.shot figcaption { - margin-top: 8px; - color: var(--muted); - font-size: 0.875rem; + *, *::before, *::after { scroll-behavior: auto !important; transition-duration: .01ms !important; animation-duration: .01ms !important; } } From 39cf4147c6aed8795c242f7ba5a1e54c7fa1f4ee Mon Sep 17 00:00:00 2001 From: "Schero D." Date: Sat, 7 Nov 2026 22:13:12 +0100 Subject: [PATCH 6/9] fix(admin): keep the bookmarks pagination inside the screen on phones The admin pagination row does not wrap, so with many pages it ran past the right edge at 390 px and 320 px (measured in the browser). Below the medium breakpoint the links show only the current page between the first and the last page, and below the small breakpoint the page size select is left out. The breakpoints come from the theme through the admin's useMediaQuery hooks. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 +++ admin/src/pages/BookmarksPage.tsx | 13 +++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00d21b5..ccb36b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,9 @@ on several Strapi processes at once. security, performance, running several instances, upgrade notes, FAQ; the site has dark mode, Open Graph and structured data. - The bookmark name link has a 32 px target height. +- The pagination of the bookmarks page fits phones: below 768 px it shows only the current page + between the first and the last, and below 520 px it leaves out the page size select. Before, it + ran past the right edge at 390 px and 320 px. ### Fixed diff --git a/admin/src/pages/BookmarksPage.tsx b/admin/src/pages/BookmarksPage.tsx index 9eb2dfe..a00edb2 100644 --- a/admin/src/pages/BookmarksPage.tsx +++ b/admin/src/pages/BookmarksPage.tsx @@ -34,6 +34,8 @@ import { SearchInput, useClipboard, useIsDesktop, + useIsMobile, + useMediaQuery, useNotification, useQueryParams, useRBAC, @@ -41,6 +43,7 @@ import { import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useIntl } from 'react-intl'; import { NavLink, useHref } from 'react-router-dom'; +import { useTheme } from 'styled-components'; import { BookmarkActions } from '../components/BookmarkActions'; import { BookmarkFormModal } from '../components/BookmarkFormModal'; import { useBookmarksApi } from '../hooks/useBookmarksApi'; @@ -106,6 +109,12 @@ export const BookmarksPage = () => { // Below the large breakpoint the seven-column table would scroll horizontally and hide the // row actions, so tablets and phones get cards that show the same information. const showCards = !useIsDesktop(); + // The admin's pagination row does not wrap. On phones it shows only the current page between + // the first and last page, and below the small breakpoint (520 px) it drops the page size + // select, so the row fits 320 px. + const theme = useTheme(); + const isMobile = useIsMobile(); + const showPageSize = useMediaQuery(theme.breakpoints.small); const { names } = useContentTypeNames(); const { allowedActions } = useRBAC(ALL_PERMISSIONS); const [{ query }, setQuery] = useQueryParams(); @@ -624,8 +633,8 @@ export const BookmarksPage = () => { total={pagination.total} defaultPageSize={25} > - - + {showPageSize ? : null} + ) : null} From ccc4560e2c82112a9a5caacd76eeea50a347e719 Mon Sep 17 00:00:00 2001 From: "Schero D." Date: Sat, 7 Nov 2026 22:26:46 +0100 Subject: [PATCH 7/9] fix(admin): keep focus inside the dialogs and give small links 32 px targets A Tab walk showed focus leaving the bookmark dialog and the Advanced filters dialog for the page behind them. In Design System 2.2.4 every Combobox trigger is wrapped in a Radix FocusScope that stays mounted while the list is closed, which pauses the surrounding Modal's focus scope (@strapi/ui-primitives ComboboxTrigger, trapped: open). Native Strapi modals without a Combobox keep focus. Both dialogs now loop Tab and Shift+Tab at their first and last focusable element. measure.js found 20 to 24 px tall targets for the bookmark links in the popover and the homepage widget and 16 px for "Add condition". They get the same padding and negative margin as the page links (DS spacing props, no custom styles), so the target is 32 px and the text does not move. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 +- admin/src/components/AdvancedFiltersModal.tsx | 5 +- admin/src/components/BookmarkFormModal.tsx | 3 +- admin/src/components/BookmarksPopover.tsx | 10 +++- admin/src/pages/BookmarksPage.tsx | 6 +- admin/src/utils/focusLoop.ts | 60 +++++++++++++++++++ admin/src/utils/targets.ts | 22 +++++++ admin/src/widgets/PinnedBookmarksWidget.tsx | 2 + tests/admin-listview.test.cjs | 12 ++++ 9 files changed, 117 insertions(+), 8 deletions(-) create mode 100644 admin/src/utils/focusLoop.ts create mode 100644 admin/src/utils/targets.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ccb36b8..4d8f4f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,10 @@ on several Strapi processes at once. - README and project site rewritten: quick start, full permission and API reference, examples, security, performance, running several instances, upgrade notes, FAQ; the site has dark mode, Open Graph and structured data. -- The bookmark name link has a 32 px target height. +- Bookmark links on the page, in the Bookmarks popover and in the homepage widget, and the "Add + condition" button, have a 32 px target height without moving their text. +- Tab no longer leaves the bookmark dialog and the Advanced filters dialog. Design System 2.2.4 + pauses a Modal's focus trap when it contains a Combobox; both dialogs now loop focus themselves. - The pagination of the bookmarks page fits phones: below 768 px it shows only the current page between the first and the last, and below 520 px it leaves out the page size select. Before, it ran past the right edge at 390 px and 320 px. diff --git a/admin/src/components/AdvancedFiltersModal.tsx b/admin/src/components/AdvancedFiltersModal.tsx index 9d28c28..6965a06 100644 --- a/admin/src/components/AdvancedFiltersModal.tsx +++ b/admin/src/components/AdvancedFiltersModal.tsx @@ -46,6 +46,8 @@ import { validateRow, } from '../utils/filters'; import { msg, operatorLabel } from '../utils/translations'; +import { keepFocusInside } from '../utils/focusLoop'; +import { TEXT_BUTTON_TARGET } from '../utils/targets'; interface AdvancedFiltersModalProps { open: boolean; @@ -151,7 +153,7 @@ export const AdvancedFiltersModal = ({ open, onOpenChange, uid }: AdvancedFilter return ( - + {formatMessage(msg('filters.title', 'Advanced filters'))} @@ -251,6 +253,7 @@ export const AdvancedFiltersModal = ({ open, onOpenChange, uid }: AdvancedFilter } + {...TEXT_BUTTON_TARGET} onClick={() => setRows((prev) => [...prev, emptyRow()])} > {formatMessage(msg('filters.add', 'Add condition'))} diff --git a/admin/src/components/BookmarkFormModal.tsx b/admin/src/components/BookmarkFormModal.tsx index b9f800c..d502628 100644 --- a/admin/src/components/BookmarkFormModal.tsx +++ b/admin/src/components/BookmarkFormModal.tsx @@ -36,6 +36,7 @@ import { msg } from '../utils/translations'; import { ICON_LABELS } from '../utils/iconLabels'; import { FilterSummary } from './FilterSummary'; import { SharingFields, type SharingValue } from './SharingFields'; +import { keepFocusInside } from '../utils/focusLoop'; export interface CapturedView { path: string; @@ -252,7 +253,7 @@ export const BookmarkFormModal = ({ return ( <> - + {title} diff --git a/admin/src/components/BookmarksPopover.tsx b/admin/src/components/BookmarksPopover.tsx index bc71152..b3246ae 100644 --- a/admin/src/components/BookmarksPopover.tsx +++ b/admin/src/components/BookmarksPopover.tsx @@ -34,6 +34,7 @@ import { type ListViewLocation, pickSavedQuery } from '../utils/listView'; import { stringifyQuery } from '../utils/query'; import { msg } from '../utils/translations'; import { BookmarkFormModal, type CapturedView } from './BookmarkFormModal'; +import { LINK_TARGET } from '../utils/targets'; interface BookmarksPopoverProps { listView: ListViewLocation; @@ -253,6 +254,7 @@ export const BookmarksPopover = ({ { if ( event.metaKey || @@ -325,12 +327,18 @@ export const BookmarksPopover = ({ setOpen(false)} > {formatMessage(msg('popover.showAll', 'Show all {total} bookmarks'), { total })} ) : ( - setOpen(false)}> + setOpen(false)} + {...LINK_TARGET} + > {formatMessage(msg('popover.manage', 'Manage bookmarks'))} )} diff --git a/admin/src/pages/BookmarksPage.tsx b/admin/src/pages/BookmarksPage.tsx index a00edb2..3ec7e70 100644 --- a/admin/src/pages/BookmarksPage.tsx +++ b/admin/src/pages/BookmarksPage.tsx @@ -55,6 +55,7 @@ import { countConditions } from '../utils/filters'; import { getIcon } from '../utils/icons'; import { parseQuery } from '../utils/query'; import { msg } from '../utils/translations'; +import { LINK_TARGET } from '../utils/targets'; type Scope = 'all' | 'mine' | 'shared' | 'pinned' | 'managed'; @@ -344,10 +345,7 @@ export const BookmarksPage = () => { { if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return; event.preventDefault(); diff --git a/admin/src/utils/focusLoop.ts b/admin/src/utils/focusLoop.ts new file mode 100644 index 0000000..b47c3bf --- /dev/null +++ b/admin/src/utils/focusLoop.ts @@ -0,0 +1,60 @@ +/** + * Keeps keyboard focus inside a Design System modal. + * + * DS 2.2.4 wraps every Combobox trigger in a Radix FocusScope that stays mounted while the list + * is closed. Mounting it pauses the focus scope of the surrounding Modal, so Tab walks out of the + * dialog into the page behind it (measured in the bookmark and Advanced filters dialogs; native + * Strapi modals without a Combobox keep focus). This handler restores the loop: Tab on the last + * focusable element moves to the first one, Shift+Tab on the first moves to the last. + */ +import type { KeyboardEvent } from 'react'; + +const FOCUSABLE = [ + 'a[href]', + 'button:not([disabled])', + 'input:not([disabled]):not([type="hidden"])', + 'select:not([disabled])', + 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', + '[contenteditable="true"]', +].join(','); + +/** + * Element that should receive focus instead of the browser default, or null to let the browser + * move focus. + * @param nodes - Focusable elements of the dialog in tab order. + * @param active - Currently focused element. + * @param backwards - True for Shift+Tab. + * @returns The element to focus, or null. + */ +export const loopTarget = (nodes: T[], active: T | null, backwards: boolean): T | null => { + if (!nodes.length) return null; + const first = nodes[0]; + const last = nodes[nodes.length - 1]; + const index = active === null ? -1 : nodes.indexOf(active); + if (index === -1) return backwards ? last : first; + if (!backwards && active === last) return first; + if (backwards && active === first) return last; + return null; +}; + +/** + * `onKeyDown` handler for `Modal.Content`. + * @param event - Keyboard event from the modal content. + * @sideeffect Moves focus and prevents the default Tab behaviour at the ends of the dialog. + */ +export const keepFocusInside = (event: KeyboardEvent) => { + if (event.key !== 'Tab' || event.defaultPrevented) return; + const container = event.currentTarget; + const active = document.activeElement as HTMLElement | null; + // Keys pressed inside an open list (rendered in a portal) belong to that list. + if (active && !container.contains(active)) return; + const nodes = Array.from(container.querySelectorAll(FOCUSABLE)).filter( + (node) => node.getClientRects().length > 0 && node.getAttribute('aria-hidden') !== 'true' + ); + const target = loopTarget(nodes, active, event.shiftKey); + if (target) { + event.preventDefault(); + target.focus(); + } +}; diff --git a/admin/src/utils/targets.ts b/admin/src/utils/targets.ts new file mode 100644 index 0000000..e07119d --- /dev/null +++ b/admin/src/utils/targets.ts @@ -0,0 +1,22 @@ +/** + * Spacing props that give small Design System controls a 32 px pointer target without moving + * them: the padding enlarges the clickable box, the negative margin keeps the text where it was. + * 32 px is the target size the admin uses on small screens; WCAG 2.5.8 asks for 24 px. + * 1rem is 10 px in the Strapi admin. + */ + +/** For a DS `Link` whose text line is 20 px tall. */ +export const LINK_TARGET = { + paddingTop: '0.6rem', + paddingBottom: '0.6rem', + marginTop: '-0.6rem', + marginBottom: '-0.6rem', +} as const; + +/** For a DS `TextButton`, whose box is 16 px tall. */ +export const TEXT_BUTTON_TARGET = { + paddingTop: '0.8rem', + paddingBottom: '0.8rem', + marginTop: '-0.8rem', + marginBottom: '-0.8rem', +} as const; diff --git a/admin/src/widgets/PinnedBookmarksWidget.tsx b/admin/src/widgets/PinnedBookmarksWidget.tsx index ef08d46..08eafa4 100644 --- a/admin/src/widgets/PinnedBookmarksWidget.tsx +++ b/admin/src/widgets/PinnedBookmarksWidget.tsx @@ -12,6 +12,7 @@ import { useContentTypeNames } from '../hooks/useSchemas'; import type { Bookmark } from '../types'; import { getIcon } from '../utils/icons'; import { msg } from '../utils/translations'; +import { LINK_TARGET } from '../utils/targets'; const PinnedBookmarksWidget = () => { const { formatMessage } = useIntl(); @@ -61,6 +62,7 @@ const PinnedBookmarksWidget = () => { { if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return; diff --git a/tests/admin-listview.test.cjs b/tests/admin-listview.test.cjs index b1ffc28..0290fe0 100644 --- a/tests/admin-listview.test.cjs +++ b/tests/admin-listview.test.cjs @@ -80,3 +80,15 @@ test('opening a bookmark replaces the remembered Content Manager list settings ( assert.equal(store.has('STRAPI_LIST_VIEW_SETTINGS:api::article.article:uuid-1'), false); assert.equal(store.has('STRAPI_LIST_VIEW_SETTINGS:api::article.article'), false); }); + +test('modal focus loop wraps Tab at both ends and leaves the middle to the browser (A11Y-FOCUS)', () => { + const { loopTarget } = require('../admin/src/utils/focusLoop.ts'); + const nodes = ['name', 'icon', 'cancel', 'save']; + assert.equal(loopTarget(nodes, 'save', false), 'name'); + assert.equal(loopTarget(nodes, 'name', true), 'save'); + assert.equal(loopTarget(nodes, 'icon', false), null); + assert.equal(loopTarget(nodes, 'cancel', true), null); + assert.equal(loopTarget(nodes, 'outside', false), 'name', 'focus outside comes back in'); + assert.equal(loopTarget(nodes, 'outside', true), 'save'); + assert.equal(loopTarget([], 'x', false), null); +}); From 9e28d47ea492ef276e625b2b6f4ed4fe9e4ed2a0 Mon Sep 17 00:00:00 2001 From: "Schero D." Date: Sat, 7 Nov 2026 23:03:03 +0100 Subject: [PATCH 8/9] fix(admin): drop the unnamed tag buttons from the Roles field The Tab walk reached a button without an accessible name in the Roles field: in Design System 2.2.4 the remove button of each MultiSelect tag contains only an icon. Without tags the trigger lists the chosen roles as text and roles are removed by unticking them in the list, so every tab stop in the dialog has a name. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ admin/src/components/SharingFields.tsx | 3 ++- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d8f4f3..2d8c6a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -66,6 +66,8 @@ on several Strapi processes at once. Open Graph and structured data. - Bookmark links on the page, in the Bookmarks popover and in the homepage widget, and the "Add condition" button, have a 32 px target height without moving their text. +- The Roles field in the bookmark dialog lists the chosen roles as text. Design System 2.2.4 tags + had remove buttons without an accessible name; roles are removed by unticking them in the list. - Tab no longer leaves the bookmark dialog and the Advanced filters dialog. Design System 2.2.4 pauses a Modal's focus trap when it contains a Combobox; both dialogs now loop focus themselves. - The pagination of the bookmarks page fits phones: below 768 px it shows only the current page diff --git a/admin/src/components/SharingFields.tsx b/admin/src/components/SharingFields.tsx index 3765769..3a732d1 100644 --- a/admin/src/components/SharingFields.tsx +++ b/admin/src/components/SharingFields.tsx @@ -126,8 +126,9 @@ export const SharingFields = ({ value, onChange, error }: SharingFieldsProps) => ) : null} {formatMessage(msg('form.roles', 'Roles'))} + {/* No tags: in DS 2.2.4 each tag's remove button has no accessible name. Roles are + removed by unticking them in the list, and the trigger lists the chosen roles. */} onChange({ ...value, roles: next })} From 6ddbca96a2a3026c459c6ed6f5fe048d840243c7 Mon Sep 17 00:00:00 2001 From: "Schero D." Date: Sat, 7 Nov 2026 23:54:37 +0100 Subject: [PATCH 9/9] fix(admin): show the native "Page not found" state for unknown plugin paths Old links below /plugins/magic-mark (pages that 3.x had) rendered Strapi's "Something went wrong" error. They now show a not-found page built like Strapi's own NotFoundPage, with a link back to Magic Mark, in five languages. Measured clean at 1440/390/320 in light and dark; the Tab walk reaches the link and the link opens the bookmarks page. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 2 ++ README.md | 3 +- admin/src/pages/App.tsx | 6 ++-- admin/src/pages/NotFoundPage.tsx | 48 ++++++++++++++++++++++++++++++++ admin/src/translations/de.json | 3 ++ admin/src/translations/en.json | 3 ++ admin/src/translations/es.json | 3 ++ admin/src/translations/fr.json | 3 ++ admin/src/translations/pt.json | 3 ++ tests/source-contract.test.cjs | 10 +++++++ 10 files changed, 81 insertions(+), 3 deletions(-) create mode 100644 admin/src/pages/NotFoundPage.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d8c6a1..db0bcdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -108,6 +108,8 @@ on several Strapi processes at once. dialog says why instead of showing a generic error. - The Bookmarks popover requests pinned bookmarks separately, so a pin is never cut off, and links to the Magic Mark page when a content type has more than 100 bookmarks. +- Unknown paths below the Magic Mark page (old links to pages that no longer exist) show the + native "Page not found" state with a link back to Magic Mark instead of "Something went wrong". ### Security diff --git a/README.md b/README.md index 7deb597..28fb2c1 100644 --- a/README.md +++ b/README.md @@ -335,7 +335,8 @@ Strapi. On the first start: - A bookmark pinned in 3.x stays pinned for its owner. Pins are per user from now on. - The custom order (move up and down) is gone; lists are sorted by name or by last update. - The license key and its validation date are deleted. The Upgrade and License settings pages are - gone; every feature is available to everyone. + gone; every feature is available to everyone. Old browser bookmarks to those settings pages + open an empty settings area, which is how Strapi treats any unknown settings path. - The 3.x icon ids are kept and shown with Strapi icons. Breaking changes for code that called the plugin directly: the routes `/license/*`, `/roles`, diff --git a/admin/src/pages/App.tsx b/admin/src/pages/App.tsx index 875612b..cd16336 100644 --- a/admin/src/pages/App.tsx +++ b/admin/src/pages/App.tsx @@ -1,17 +1,19 @@ /** * Plugin page router. The menu link is hidden without `bookmarks.read`; the page checks the - * permission again and renders the native "no permissions" state. + * permission again and renders the native "no permissions" state. Unknown sub-paths (such as the + * pages that 3.x had below the plugin page) show the native "Page not found" state. */ import { Page } from '@strapi/strapi/admin'; import { Route, Routes } from 'react-router-dom'; import { PERMISSIONS } from '../permissions'; import { BookmarksPage } from './BookmarksPage'; +import { NotFoundPage } from './NotFoundPage'; const App = () => ( } /> - } /> + } /> ); diff --git a/admin/src/pages/NotFoundPage.tsx b/admin/src/pages/NotFoundPage.tsx new file mode 100644 index 0000000..b9c2ccb --- /dev/null +++ b/admin/src/pages/NotFoundPage.tsx @@ -0,0 +1,48 @@ +/** + * "Page not found" for unknown paths below the plugin page, for example pages that + * 3.x had below `/plugins/magic-mark` and that no longer exist. + * Built like Strapi's own NotFoundPage (@strapi/admin 5.61, not exported), so an old bookmark or + * link lands on the native not-found state instead of an error. + */ +import { EmptyStateLayout, LinkButton } from '@strapi/design-system'; +import { ArrowRight } from '@strapi/icons'; +import { EmptyPictures } from '@strapi/icons/symbols'; +import { Layouts, Page } from '@strapi/strapi/admin'; +import { useIntl } from 'react-intl'; +import { NavLink } from 'react-router-dom'; +import pluginId from '../pluginId'; +import { msg } from '../utils/translations'; + +export const NotFoundPage = () => { + const { formatMessage } = useIntl(); + const title = formatMessage(msg('notFound.title', 'Page not found')); + return ( + + {title} + + + } + content={formatMessage( + msg( + 'notFound.content', + 'This page does not exist. Your bookmarks are on the Magic Mark page.' + ) + )} + action={ + } + > + {formatMessage(msg('notFound.back', 'Go to Magic Mark'))} + + } + hasRadius + shadow="tableShadow" + /> + + + ); +}; diff --git a/admin/src/translations/de.json b/admin/src/translations/de.json index aded754..b286c42 100644 --- a/admin/src/translations/de.json +++ b/admin/src/translations/de.json @@ -95,6 +95,9 @@ "icons.terminal": "Code", "icons.user": "Person", "icons.wrench": "Puzzleteil", + "notFound.back": "Zu Magic Mark", + "notFound.content": "Diese Seite gibt es nicht. Die Lesezeichen sind auf der Magic-Mark-Seite.", + "notFound.title": "Seite nicht gefunden", "notification.created": "Lesezeichen erstellt.", "notification.deleteError": "Das Lesezeichen konnte nicht gelöscht werden.", "notification.deleted": "Lesezeichen gelöscht.", diff --git a/admin/src/translations/en.json b/admin/src/translations/en.json index c21ed03..16e73aa 100644 --- a/admin/src/translations/en.json +++ b/admin/src/translations/en.json @@ -95,6 +95,9 @@ "icons.terminal": "Code", "icons.user": "Person", "icons.wrench": "Puzzle piece", + "notFound.back": "Go to Magic Mark", + "notFound.content": "This page does not exist. Your bookmarks are on the Magic Mark page.", + "notFound.title": "Page not found", "notification.created": "Bookmark created.", "notification.deleted": "Bookmark deleted.", "notification.deleteError": "The bookmark could not be deleted.", diff --git a/admin/src/translations/es.json b/admin/src/translations/es.json index c2ee898..b151d90 100644 --- a/admin/src/translations/es.json +++ b/admin/src/translations/es.json @@ -95,6 +95,9 @@ "icons.terminal": "Código", "icons.user": "Persona", "icons.wrench": "Pieza de puzle", + "notFound.back": "Ir a Magic Mark", + "notFound.content": "Esta página no existe. Los marcadores están en la página de Magic Mark.", + "notFound.title": "Página no encontrada", "notification.created": "Marcador creado.", "notification.deleteError": "No se pudo eliminar el marcador.", "notification.deleted": "Marcador eliminado.", diff --git a/admin/src/translations/fr.json b/admin/src/translations/fr.json index 70aed3a..dd621ce 100644 --- a/admin/src/translations/fr.json +++ b/admin/src/translations/fr.json @@ -95,6 +95,9 @@ "icons.terminal": "Code", "icons.user": "Personne", "icons.wrench": "Pièce de puzzle", + "notFound.back": "Aller à Magic Mark", + "notFound.content": "Cette page n'existe pas. Les favoris se trouvent sur la page Magic Mark.", + "notFound.title": "Page introuvable", "notification.created": "Favori créé.", "notification.deleteError": "Le favori n'a pas pu être supprimé.", "notification.deleted": "Favori supprimé.", diff --git a/admin/src/translations/pt.json b/admin/src/translations/pt.json index 9a80a67..9c1f3ca 100644 --- a/admin/src/translations/pt.json +++ b/admin/src/translations/pt.json @@ -95,6 +95,9 @@ "icons.terminal": "Código", "icons.user": "Pessoa", "icons.wrench": "Peça de quebra-cabeça", + "notFound.back": "Ir para o Magic Mark", + "notFound.content": "Esta página não existe. Os favoritos estão na página do Magic Mark.", + "notFound.title": "Página não encontrada", "notification.created": "Favorito criado.", "notification.deleteError": "Não foi possível excluir o favorito.", "notification.deleted": "Favorito excluído.", diff --git a/tests/source-contract.test.cjs b/tests/source-contract.test.cjs index b9fbbc5..1ee29b5 100644 --- a/tests/source-contract.test.cjs +++ b/tests/source-contract.test.cjs @@ -149,3 +149,13 @@ test('a rejected list view query is explained, not hidden behind a generic error assert.match(form, /form\.queryTooLong/); assert.match(popover, /fieldErrors\(error\)\.query/); }); + +test('removed 3.x plugin pages show the native "Page not found" state, not an error (MM-404)', () => { + const app = read(path.join(root, 'admin/src/pages/App.tsx')); + assert.match(app, /} \/>/); + assert.doesNotMatch(app, /Page\.Error/, 'unknown paths are not an error'); + const page = read(path.join(root, 'admin/src/pages/NotFoundPage.tsx')); + assert.match(page, /