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 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..db0bcdb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,20 +1,91 @@ ## [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. +- 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 + 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 + +- 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). @@ -37,34 +108,14 @@ Magic Mark is now free, rebuilt on the Strapi Design System and secured on the s 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". -### 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..28fb2c1 100644 --- a/README.md +++ b/README.md @@ -1,300 +1,394 @@ # 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. + 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`, +`/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 +404,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/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/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 })} 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/BookmarksPage.tsx b/admin/src/pages/BookmarksPage.tsx index 223719b..3ec7e70 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'; @@ -52,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'; @@ -106,6 +110,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(); @@ -335,6 +345,7 @@ export const BookmarksPage = () => { { if (event.metaKey || event.ctrlKey || event.shiftKey || event.button !== 0) return; event.preventDefault(); @@ -620,8 +631,8 @@ export const BookmarksPage = () => { total={pagination.total} defaultPageSize={25} > - - + {showPageSize ? : null} + ) : null} 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/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/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; } } 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/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/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); +}); 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/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', () => { 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); +}); 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, /