From f09a421b5a556680484cd41864f7e1062273c7b9 Mon Sep 17 00:00:00 2001 From: "Schero D." Date: Sat, 7 Nov 2026 15:28:28 +0100 Subject: [PATCH 01/22] chore: import pending filter and query hardening work Imports the uncommitted Codex work as the overhaul base: Content Manager filter depth guard, filterable attribute allowlist, percent-safe query round trip, malformed filter parsing, dropdown positioning and log redaction, with regression tests (39/39 pass, both type checks clean). The release workflow change that removed the NPM_TOKEN fallback is not imported; the fallback stays until npm trusted publishing is confirmed. Co-Authored-By: Claude Opus 5.5 --- .../skills/strapi-plugin-maintenance/SKILL.md | 10 +- .releaserc.json | 2 +- admin/src/components/AdvancedFilterButton.tsx | 85 +- admin/src/components/CreateEditModal.tsx | 12 +- admin/src/components/CustomSelect.tsx | 33 +- admin/src/components/LicenseGuard.tsx | 38 +- admin/src/components/QueryBuilder.tsx | 22 +- .../components/SimpleAdvancedFilterModal.tsx | 25 +- .../src/components/StrapiStyleFilterModal.tsx | 48 +- admin/src/components/ViewsWidget.tsx | 56 +- admin/src/hooks/useFeatureGate.ts | 36 +- admin/src/hooks/useRelationSchema.ts | 48 +- admin/src/index.ts | 2 +- admin/src/pages/HomePageModern.tsx | 175 +- admin/src/pages/License/index.tsx | 20 +- admin/src/pages/UpgradePage.tsx | 10 +- admin/src/translations/de.json | 6 +- admin/src/translations/en.json | 6 +- admin/src/translations/es.json | 6 +- admin/src/translations/fr.json | 6 +- admin/src/translations/pt.json | 6 +- .../contentManagerFilterCompatibility.ts | 21 + admin/src/utils/dropdownPosition.ts | 50 + admin/src/utils/filterableAttributes.mjs | 62 + admin/src/utils/licenseTier.ts | 32 + admin/src/utils/queryGenerator.ts | 3 - admin/src/utils/queryParser.ts | 8 +- admin/src/utils/queryToStructure.ts | 140 +- package-lock.json | 8961 ++++++++++------- server/routes/admin.js | 36 +- server/src/bootstrap.ts | 26 +- server/src/controllers/license.ts | 121 +- server/src/middlewares/license-check.ts | 4 +- server/src/policies/license-check.ts | 4 +- server/src/routes/admin.ts | 16 +- server/src/services/license-guard.ts | 203 +- server/src/services/service.ts | 7 +- tests/admin-optional-labels.test.js | 27 + tests/bookmark-access.test.js | 8 +- tests/filterable-attributes.test.js | 48 + tests/functional-regressions.test.js | 481 + 41 files changed, 6739 insertions(+), 4171 deletions(-) create mode 100644 admin/src/utils/contentManagerFilterCompatibility.ts create mode 100644 admin/src/utils/dropdownPosition.ts create mode 100644 admin/src/utils/filterableAttributes.mjs create mode 100644 admin/src/utils/licenseTier.ts create mode 100644 tests/admin-optional-labels.test.js create mode 100644 tests/filterable-attributes.test.js create mode 100644 tests/functional-regressions.test.js diff --git a/.agents/skills/strapi-plugin-maintenance/SKILL.md b/.agents/skills/strapi-plugin-maintenance/SKILL.md index 07b0b77..169dcd9 100644 --- a/.agents/skills/strapi-plugin-maintenance/SKILL.md +++ b/.agents/skills/strapi-plugin-maintenance/SKILL.md @@ -9,4 +9,12 @@ Use Node 22. Read local rules, query current Strapi 5 plugin/route/permission an Trace both `server/src/routes/admin.ts` and `server/routes/admin.js`, the controller/services and `admin/src/permissions.ts`. Routes and menu links currently use empty `policies`/`permissions`; do not assume this means unauthenticated access, but prove the admin auth scope and role/permission boundary at runtime. Make a matrix for anonymous, admin lacking permission, allowed admin, bookmark owner and another admin. Check create/reorder/pin/share/delete ownership, role visibility, license key writes and limits server-side. Preserve the intentional share semantics while denying cross-user modification. -Use Strapi 5 Design System, permission-backed menu and accessible bookmark actions, empty/error/loading states and responsive query-builder layout. Run `npm run build`, `npm run verify`, `npm run test:ts:front`, `npm run test:ts:back`, plus focused owner/deny tests; then shared app build/boot and Playwright CLI navigation. Do not present successful typechecks as proof of authorization. +For UI changes, first read the workbench root skill's `references/ui-ux-standards.md`; it defines the shared responsive, alignment, readable-copy, accessibility and Playwright acceptance checks. Use Strapi 5 Design System, permission-backed menu and accessible bookmark actions, empty/error/loading states and responsive query-builder layout. Run `npm run build`, `npm run verify`, `npm run test:ts:front`, `npm run test:ts:back`, plus focused owner/deny tests; then shared app build/boot and Playwright CLI navigation. Do not present successful typechecks as proof of authorization. + +## Shared product quality contract + +Use spec-first development: define acceptance criteria and API response/error contracts before implementation. + +Follow the detailed JSDoc contract in the always-applied plugin rule for new and substantially changed exported or non-trivial functions, handlers, services, hooks, middleware, policies and components. Document purpose, context, applicable `@param` and `@returns`, and only actually propagated `@throws`. Verify HTTP `@route`, access, inputs and outcomes against the implementation; describe applicable mutations and security/privacy effects with `@sideeffect`. These are custom tags; respect configured tools, never invent behavior, and update comments when contracts change. + +Every behavior change starts with a written current-state and target-state analysis covering screens, controls, routes, roles, data, known gaps and non-goals. Define acceptance criteria and API response/error contracts before implementation, then add a failing regression test first. A primary task should take no more than four meaningful user interactions from the plugin entry page; exclude login and mandatory security confirmations, and measure and document any justified exception. Use the Strapi Design System, responsive layouts, keyboard accessibility and explicit loading, empty, success and error states. Enforce authentication, plugin permissions, validation and owner boundaries server-side. Keep API error contracts consistent within each Strapi surface and never return stack traces or internal details. Do not log secrets, tokens, cookies, request bodies, email, IP or stable user/session identifiers. Use fakes for providers and record unverified behavior accurately. diff --git a/.releaserc.json b/.releaserc.json index b23f122..25e8b89 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -5,7 +5,7 @@ "@semantic-release/release-notes-generator", "@semantic-release/changelog", "@semantic-release/npm", - ["@semantic-release/github", { "labels": false, "successComment": false }], + ["@semantic-release/github", { "labels": false, "successComment": false, "failCommentCondition": false }], [ "@semantic-release/git", { diff --git a/admin/src/components/AdvancedFilterButton.tsx b/admin/src/components/AdvancedFilterButton.tsx index 4a5cefb..ff0c633 100644 --- a/admin/src/components/AdvancedFilterButton.tsx +++ b/admin/src/components/AdvancedFilterButton.tsx @@ -6,6 +6,7 @@ import { useFetchClient } from '@strapi/strapi/admin'; import styled from 'styled-components'; import StrapiStyleFilterModal from './StrapiStyleFilterModal'; import { useLicenseInfo } from '../hooks/useFeatureGate'; +import { getFilterableAttributes } from '../utils/filterableAttributes.mjs'; // ================ STYLED COMPONENTS ================ const FilterButtonGroup = styled.div` @@ -91,7 +92,7 @@ const AdvancedFilterButton: React.FC = () => { const navigate = useNavigate(); const location = useLocation(); const { get } = useFetchClient(); - const { isPremium, isAdvanced } = useLicenseInfo(); + const { isAdvanced } = useLicenseInfo(); const [availableFields, setAvailableFields] = useState>([]); const [availableRelations, setAvailableRelations] = useState>([]); const [hasActiveFilters, setHasActiveFilters] = useState(false); @@ -122,22 +123,18 @@ const AdvancedFilterButton: React.FC = () => { const fetchSchema = async () => { const uid = extractContentTypeUid(); if (!uid) { - console.log('[AdvancedFilter] No content type UID found in URL'); return; } try { - console.log('[AdvancedFilter] Fetching schema for:', uid); - // Step 1: Get the REAL schema from content-type-builder (has correct attribute types) let realAttributes: Record = {}; try { const schemaResponse = await get(`/content-type-builder/content-types/${uid}`); const schemaInfo = schemaResponse.data?.data?.schema || schemaResponse.data?.schema || {}; realAttributes = schemaInfo.attributes || {}; - console.log('[AdvancedFilter] Real schema attributes:', realAttributes); - } catch (e) { - console.log('[AdvancedFilter] Content-type-builder endpoint failed:', e); + } catch { + // Continue with field metadata if the schema endpoint is unavailable. } // Step 2: Also get configuration for field metadata (labels, etc.) @@ -146,64 +143,18 @@ const AdvancedFilterButton: React.FC = () => { const configResponse = await get(`/content-manager/content-types/${uid}/configuration`); const contentTypeData = configResponse.data?.data?.contentType || configResponse.data?.contentType || {}; metadatas = contentTypeData.metadatas || {}; - console.log('[AdvancedFilter] Configuration metadatas:', metadatas); - } catch (e) { - console.log('[AdvancedFilter] Configuration endpoint failed:', e); + } catch { + // The real schema remains sufficient when display metadata is unavailable. } - // Step 3: Extract fields and relations from REAL attributes - const fields: Array<{ name: string; type: string }> = []; - const relations: Array<{ name: string; target: string }> = []; - const fieldNames = new Set(); - - // Process real attributes (this has correct types!) - Object.keys(realAttributes).forEach((key) => { - const attr = realAttributes[key]; - const attrType = attr.type; - - // Skip internal fields - if (['createdBy', 'updatedBy', 'localizations', 'locale'].includes(key)) { - return; - } - - // Check if it's a relation - if (attrType === 'relation') { - relations.push({ name: key, target: attr.target }); - console.log('[AdvancedFilter] Found relation:', key, '-> target:', attr.target); - } else if (attrType === 'component' || attrType === 'dynamiczone') { - // Skip components and dynamic zones for filtering - console.log('[AdvancedFilter] Skipping component/dynamiczone:', key); - } else if (attrType === 'media') { - // Skip media for now (complex filtering) - console.log('[AdvancedFilter] Skipping media:', key); - } else if (!fieldNames.has(key)) { - // Regular field - fieldNames.add(key); - fields.push({ - name: key, - type: attrType || 'string' - }); - } - }); - - // Add default fields if not already present - ['id', 'documentId', 'createdAt', 'updatedAt'].forEach(defaultField => { - if (!fieldNames.has(defaultField)) { - let type = 'string'; - if (defaultField === 'id') type = 'integer'; - if (defaultField === 'createdAt' || defaultField === 'updatedAt') type = 'datetime'; - fields.unshift({ name: defaultField, type }); - } - }); - + // Exclude private, hidden and unsupported attributes before populating + // filters that are serialized into Content Manager requests. + const { fields, relations } = getFilterableAttributes(realAttributes, metadatas); setAvailableFields(fields); setAvailableRelations(relations); - console.log('[AdvancedFilter] Final fields:', fields); - console.log('[AdvancedFilter] Final relations:', relations); - - } catch (error) { - console.error('[AdvancedFilter] Error fetching schema:', error); + } catch { + console.error('[AdvancedFilter] Error fetching schema'); // Fallback to basic fields setAvailableFields([ { name: 'id', type: 'integer' }, @@ -218,8 +169,6 @@ const AdvancedFilterButton: React.FC = () => { }, [location.pathname]); const handleApplyFilters = (queryString: string) => { - console.log('[AdvancedFilter] Applying filters:', queryString); - // Get current path const currentPath = location.pathname; @@ -244,8 +193,6 @@ const AdvancedFilterButton: React.FC = () => { cleanParams.set('page', '1'); const finalUrl = `${currentPath}?${cleanParams.toString()}`; - console.log('[AdvancedFilter] Final URL:', finalUrl); - // Navigate first, then reload to ensure Strapi CM picks up the filters navigate(finalUrl); @@ -280,7 +227,6 @@ const AdvancedFilterButton: React.FC = () => { const getCurrentFilters = () => { const params = new URLSearchParams(location.search); const currentQuery = params.toString(); - console.log('[AdvancedFilter] Current query from URL:', currentQuery); return currentQuery; }; @@ -289,10 +235,11 @@ const AdvancedFilterButton: React.FC = () => { setShowModal(true)} - title="Open advanced filter builder" + title={isAdvanced ? 'Open advanced filter builder' : 'Open filter builder'} + aria-label={isAdvanced ? 'Open advanced filter builder' : 'Open filter builder'} > - Advanced Filter + {isAdvanced ? 'Advanced Filter' : 'Filter'} {hasActiveFilters && } @@ -310,9 +257,9 @@ const AdvancedFilterButton: React.FC = () => { onClose={() => setShowModal(false)} onApply={handleApplyFilters} availableFields={availableFields} - availableRelations={availableRelations} + availableRelations={isAdvanced ? availableRelations : []} currentQuery={getCurrentFilters()} - enableNestedGroups={isPremium || isAdvanced} + enableNestedGroups={isAdvanced} /> )} diff --git a/admin/src/components/CreateEditModal.tsx b/admin/src/components/CreateEditModal.tsx index e9b86de..241d0fb 100644 --- a/admin/src/components/CreateEditModal.tsx +++ b/admin/src/components/CreateEditModal.tsx @@ -670,8 +670,8 @@ const CreateEditModal: React.FC = ({ const response = await get(`/${pluginId}/roles`); const roles = response.data?.data?.data || response.data?.data || response.data || []; setAvailableRoles(roles); - } catch (error) { - console.error('[Magic-Mark] Error fetching roles:', error); + } catch { + console.error('[Magic-Mark] Error fetching roles'); } finally { setLoadingRoles(false); } @@ -698,8 +698,8 @@ const CreateEditModal: React.FC = ({ }) : []; setAvailableUsers(users); - } catch (error) { - console.error('[Magic-Mark] Error fetching users:', error); + } catch { + console.error('[Magic-Mark] Error fetching users'); try { const response = await get(`/${pluginId}/users`); const users = response.data?.data?.data || response.data?.data || response.data || []; @@ -761,8 +761,8 @@ const CreateEditModal: React.FC = ({ } onSuccess(); - } catch (error) { - console.error('[Magic-Mark] Error saving bookmark:', error); + } catch { + console.error('[Magic-Mark] Error saving bookmark'); setError(formatMessage({ id: `${pluginId}.error.save`, defaultMessage: 'Failed to save bookmark' })); } finally { setIsSubmitting(false); diff --git a/admin/src/components/CustomSelect.tsx b/admin/src/components/CustomSelect.tsx index 4b17673..ca06e98 100644 --- a/admin/src/components/CustomSelect.tsx +++ b/admin/src/components/CustomSelect.tsx @@ -3,6 +3,7 @@ import React, { useState, useRef, useEffect } from 'react'; import ReactDOM from 'react-dom'; import { Box, Typography } from '@strapi/design-system'; import styled from 'styled-components'; +import { calculateDropdownPosition } from '../utils/dropdownPosition'; const SelectContainer = styled.div` position: relative; @@ -60,12 +61,12 @@ const IconWrapper = styled.span<{ $isOpen: boolean }>` color: ${(p) => p.theme.colors.neutral500}; `; -const DropdownList = styled.div<{ $isOpen: boolean; top: number; left: number; width: number }>` +const DropdownList = styled.div<{ $isOpen: boolean; $top: number; $left: number; $width: number; $maxHeight: number }>` position: fixed; - top: ${props => props.top}px; - left: ${props => props.left}px; - width: ${props => props.width}px; - max-height: 400px; + top: ${props => props.$top}px; + left: ${props => props.$left}px; + width: ${props => props.$width}px; + max-height: ${props => props.$maxHeight}px; overflow-y: auto; background: ${(p) => p.theme.colors.neutral0}; border: 1px solid rgba(128, 128, 128, 0.25); @@ -142,20 +143,19 @@ const CustomSelect: React.FC = ({ }) => { const [isOpen, setIsOpen] = useState(false); const [searchTerm, setSearchTerm] = useState(''); - const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0 }); + const [dropdownPos, setDropdownPos] = useState({ top: 0, left: 0, width: 0, maxHeight: 0, openAbove: false }); const containerRef = useRef(null); const buttonRef = useRef(null); const searchInputRef = useRef(null); - // Calculate dropdown position + /** Updates the fixed dropdown coordinates from the trigger button's viewport box. */ const updatePosition = () => { if (buttonRef.current) { const rect = buttonRef.current.getBoundingClientRect(); - setDropdownPos({ - top: rect.bottom + 4, - left: rect.left, - width: rect.width, - }); + setDropdownPos(calculateDropdownPosition( + { top: rect.top, bottom: rect.bottom, left: rect.left, width: rect.width }, + { width: window.innerWidth, height: window.innerHeight }, + )); } }; @@ -213,11 +213,12 @@ const CustomSelect: React.FC = ({ }; const dropdownContent = ( - {searchable && ( diff --git a/admin/src/components/LicenseGuard.tsx b/admin/src/components/LicenseGuard.tsx index 96b2ccf..9fb13f6 100644 --- a/admin/src/components/LicenseGuard.tsx +++ b/admin/src/components/LicenseGuard.tsx @@ -7,10 +7,11 @@ import { TextInput, Loader, } from '@strapi/design-system'; -import { useFetchClient, useNotification } from '@strapi/strapi/admin'; +import { useFetchClient, useNotification, useRBAC } from '@strapi/strapi/admin'; import { Check, Key, Cross, Lock, Sparkle, Lightbulb, User, Mail } from '@strapi/icons'; import { useNavigate } from 'react-router-dom'; import styled, { keyframes } from 'styled-components'; +import pluginPermissions from '../permissions'; // Animations const fadeIn = keyframes` @@ -148,6 +149,7 @@ const LicenseGuard: React.FC = ({ children }) => { const { get, post } = useFetchClient(); const { toggleNotification } = useNotification(); const navigate = useNavigate(); + const { allowedActions, isLoading: isPermissionLoading } = useRBAC(pluginPermissions.updateSettings); const [isChecking, setIsChecking] = useState(true); const [needsLicense, setNeedsLicense] = useState(false); @@ -166,16 +168,19 @@ const LicenseGuard: React.FC = ({ children }) => { useEffect(() => { checkLicenseStatus(); - fetchAdminUser(); }, []); + useEffect(() => { + if (!isPermissionLoading && allowedActions.canUpdate === true) { + fetchAdminUser(); + } + }, [isPermissionLoading, allowedActions.canUpdate]); + const fetchAdminUser = async () => { try { const response: any = await get('/admin/users/me'); - console.log('Admin user response:', response); // The API returns { data: { data: {...} } } structure const userData = response.data?.data || response.data; - console.log('User data extracted:', userData); if (userData) { setAdminUser(userData); setFormData({ @@ -183,14 +188,9 @@ const LicenseGuard: React.FC = ({ children }) => { firstName: userData.firstname || '', lastName: userData.lastname || '', }); - console.log('Admin user set:', { - email: userData.email, - firstname: userData.firstname, - lastname: userData.lastname - }); } - } catch (error) { - console.error('Error fetching admin user:', error); + } catch { + // User details are only used to prefill the activation form. } }; @@ -204,8 +204,7 @@ const LicenseGuard: React.FC = ({ children }) => { } else { setNeedsLicense(true); } - } catch (error) { - console.error('Error checking license:', error); + } catch { setNeedsLicense(true); } finally { setIsChecking(false); @@ -217,7 +216,6 @@ const LicenseGuard: React.FC = ({ children }) => { setIsCreating(true); try { - console.log('Auto-creating license with admin user data...'); const response: any = await post('/magic-mark/license/auto-create', {}); if (response.data && response.data.success) { @@ -235,7 +233,6 @@ const LicenseGuard: React.FC = ({ children }) => { throw new Error('Failed to auto-create license'); } } catch (error: any) { - console.error('Error auto-creating license:', error); toggleNotification({ type: 'danger', message: error?.response?.data?.error?.message || 'Failed to auto-create license. Try manual creation.', @@ -259,9 +256,7 @@ const LicenseGuard: React.FC = ({ children }) => { setIsCreating(true); try { - console.log('Creating license with data:', formData); const response: any = await post('/magic-mark/license/create', formData); - console.log('License creation response:', response); if (response.data && response.data.success) { toggleNotification({ @@ -278,7 +273,6 @@ const LicenseGuard: React.FC = ({ children }) => { throw new Error('Failed to create license'); } } catch (error: any) { - console.error('Error creating license:', error); toggleNotification({ type: 'danger', message: error?.response?.data?.error?.message || 'Failed to create license. Please try again.', @@ -301,7 +295,6 @@ const LicenseGuard: React.FC = ({ children }) => { setIsCreating(true); try { - console.log('Validating existing license key...'); const pluginStore: any = await post('/magic-mark/license/store-key', { licenseKey: existingLicenseKey.trim(), email: existingEmail.trim(), @@ -322,7 +315,6 @@ const LicenseGuard: React.FC = ({ children }) => { throw new Error('Invalid license key or email'); } } catch (error: any) { - console.error('Error validating license key:', error); toggleNotification({ type: 'danger', message: error?.response?.data?.error?.message || 'Invalid license key or email address. Please check and try again.', @@ -335,7 +327,7 @@ const LicenseGuard: React.FC = ({ children }) => { navigate('/content-manager'); }; - if (isChecking) { + if (isChecking || isPermissionLoading) { return ( Checking license... @@ -343,6 +335,10 @@ const LicenseGuard: React.FC = ({ children }) => { ); } + if (needsLicense && allowedActions.canUpdate !== true) { + return <>{children}; + } + if (needsLicense) { return ( diff --git a/admin/src/components/QueryBuilder.tsx b/admin/src/components/QueryBuilder.tsx index 9beda27..c63aa10 100644 --- a/admin/src/components/QueryBuilder.tsx +++ b/admin/src/components/QueryBuilder.tsx @@ -511,8 +511,8 @@ interface QueryBuilderProps { availableRelations?: RelationInfo[]; onQueryChange: (query: ConditionGroup) => void; initialStructure?: ConditionGroup | null; - isPremium?: boolean; - /** Enable deep relation filtering (requires Premium) */ + canUseSubGroups?: boolean; + /** Enable deep relation filtering, an Advanced-tier feature. */ enableDeepFiltering?: boolean; } @@ -537,11 +537,11 @@ const QueryBuilder: React.FC = ({ availableRelations = [], onQueryChange, initialStructure, - isPremium = true, - enableDeepFiltering = true, + canUseSubGroups = false, + enableDeepFiltering = false, }) => { // Debug log props - console.log('[QueryBuilder] Props received:', { isPremium, enableDeepFiltering, availableRelationsCount: availableRelations.length }); + console.log('[QueryBuilder] Props received:', { canUseSubGroups, enableDeepFiltering, availableRelationsCount: availableRelations.length }); const [rootGroup, setRootGroup] = useState( initialStructure || { id: 'root', @@ -614,9 +614,7 @@ const QueryBuilder: React.FC = ({ const newGroup = JSON.parse(JSON.stringify(rootGroup)); const parentGroup = findGroup(newGroup, parentGroupId); if (parentGroup) { - // Free users can add 1 sub-group at root level, Premium users can add nested groups - const currentLevel = parentGroupId === 'root' ? 0 : 1; - if (!isPremium && currentLevel >= 1) return; + if (!canUseSubGroups) return; const newId = Date.now().toString(); parentGroup.conditions.push({ @@ -699,7 +697,7 @@ const QueryBuilder: React.FC = ({ const canDelete = group.conditions.length > 1 || level > 0; // Determine if we should use deep filtering - const useDeepFilter = enableDeepFiltering && isPremium && availableRelations.length > 0; + const useDeepFilter = enableDeepFiltering && availableRelations.length > 0; const fieldPath = condition.fieldPath || [condition.field].filter(Boolean); // Check if the first selected field is a relation (for two-row layout) @@ -990,10 +988,10 @@ const QueryBuilder: React.FC = ({ size="S" startIcon={} onClick={() => addGroup(group.id)} - disabled={!isPremium && level >= 1} - title={!isPremium && level >= 1 ? "Upgrade to Premium for more nested groups" : "Add a sub-group"} + disabled={!canUseSubGroups} + title={!canUseSubGroups ? "Upgrade to Advanced to add sub-groups" : "Add a sub-group"} > - Add Sub-Group {!isPremium && level >= 1 && '(Premium)'} + Add Sub-Group {!canUseSubGroups && '(Advanced)'} )} diff --git a/admin/src/components/SimpleAdvancedFilterModal.tsx b/admin/src/components/SimpleAdvancedFilterModal.tsx index e814dfa..6fb8f98 100644 --- a/admin/src/components/SimpleAdvancedFilterModal.tsx +++ b/admin/src/components/SimpleAdvancedFilterModal.tsx @@ -69,12 +69,8 @@ const SimpleAdvancedFilterModal: React.FC = ({ availableRelations = [], currentQuery = '', }) => { - const { isPremium, isAdvanced, tier, isLoading } = useLicenseInfo(); + const { isAdvanced } = useLicenseInfo(); - // Debug log for license status - React.useEffect(() => { - console.log('[SimpleAdvancedFilter] License status:', { isPremium, isAdvanced, tier, isLoading }); - }, [isPremium, isAdvanced, tier, isLoading]); const [queryStructure, setQueryStructure] = useState(null); const [initialStructure, setInitialStructure] = useState(null); const [initialFiltersLoaded, setInitialFiltersLoaded] = useState(false); @@ -85,8 +81,6 @@ const SimpleAdvancedFilterModal: React.FC = ({ // Load current filters from URL when modal opens React.useEffect(() => { if (!initialFiltersLoaded) { - console.log('[SimpleAdvancedFilter] Loading current query:', currentQuery); - // Initialize populateFields from availableRelations if (availableRelations.length > 0) { const initialFields = availableRelations.map(rel => ({ @@ -94,7 +88,6 @@ const SimpleAdvancedFilterModal: React.FC = ({ enabled: false, deep: false, })); - console.log('[SimpleAdvancedFilter] Initializing populateFields:', initialFields); setPopulateFields(initialFields); } @@ -145,7 +138,6 @@ const SimpleAdvancedFilterModal: React.FC = ({ } setInitialFiltersLoaded(true); - console.log('[SimpleAdvancedFilter] Initial structure loaded'); } }, [currentQuery]); @@ -177,7 +169,6 @@ const SimpleAdvancedFilterModal: React.FC = ({ if (relationsInFilter.size > 0) { setPopulateFields(prev => prev.map(p => { if (relationsInFilter.has(p.name) && !p.enabled) { - console.log('[SimpleAdvancedFilter] Auto-enabling populate for:', p.name); return { ...p, enabled: true }; } return p; @@ -187,7 +178,6 @@ const SimpleAdvancedFilterModal: React.FC = ({ const handleApply = () => { if (!queryStructure) { - console.warn('[SimpleAdvancedFilter] No query structure defined'); return; } @@ -198,7 +188,6 @@ const SimpleAdvancedFilterModal: React.FC = ({ populateFields ); - console.log('[SimpleAdvancedFilter] Applying query:', queryString); onApply(queryString); onClose(); }; @@ -228,19 +217,19 @@ const SimpleAdvancedFilterModal: React.FC = ({ availableRelations={availableRelations} onQueryChange={handleQueryChange} initialStructure={initialStructure} - isPremium={isPremium || isAdvanced} - enableDeepFiltering={isPremium || isAdvanced} + canUseSubGroups={isAdvanced} + enableDeepFiltering={isAdvanced} /> - {/* Upgrade prompt for non-premium users */} - {!isPremium && !isAdvanced && ( + {/* Upgrade prompt for users below the Advanced tier */} + {!isAdvanced && ( )} diff --git a/admin/src/components/StrapiStyleFilterModal.tsx b/admin/src/components/StrapiStyleFilterModal.tsx index 93a124d..34ec8b7 100644 --- a/admin/src/components/StrapiStyleFilterModal.tsx +++ b/admin/src/components/StrapiStyleFilterModal.tsx @@ -16,11 +16,13 @@ import { } from '@strapi/design-system'; import { Cross as CrossIcon } from '@strapi/icons'; import { Cross } from '@strapi/icons'; +import { useIntl } from 'react-intl'; import styled from 'styled-components'; import CustomSelect from './CustomSelect'; import { useRelationSchema } from '../hooks/useRelationSchema'; import { generateFromRows, generateFromGroup, getOperatorsForType, type FilterRow, type FilterGroup as FilterGroupType, type LogicConnector } from '../utils/queryGenerator'; -import { parseQueryToRows } from '../utils/queryToStructure'; +import { parseQueryToFilterGroup, parseQueryToRows } from '../utils/queryToStructure'; +import { isFilterQuerySupportedByContentManager } from '../utils/contentManagerFilterCompatibility'; const ModalOverlay = styled.div` position: fixed; @@ -470,6 +472,12 @@ const ModalFooter = styled.div` margin-top: 8px; `; +const ApplyError = styled.div` + margin-top: 12px; + color: #b42318; + font-size: 13px; +`; + const GroupContainer = styled.div<{ $logic: 'AND' | 'OR'; $level: number }>` border: 2px solid ${(p) => p.$logic === 'AND' ? '#0EA5E9' : '#F97316'}; border-radius: 8px; @@ -560,7 +568,7 @@ interface StrapiStyleFilterModalProps { availableFields: Array<{ name: string; type: string }>; availableRelations?: Array<{ name: string; target?: string }>; currentQuery?: string; - enableNestedGroups?: boolean; // Premium feature flag + enableNestedGroups?: boolean; // Advanced-tier feature flag } /** @@ -574,6 +582,7 @@ const StrapiStyleFilterModal: React.FC = ({ currentQuery = '', enableNestedGroups = false, }) => { + const { formatMessage } = useIntl(); const { getRelationFields } = useRelationSchema(); const [fieldOptions, setFieldOptions] = useState([]); @@ -581,6 +590,11 @@ const StrapiStyleFilterModal: React.FC = ({ const [rows, setRows] = useState([]); const [connectors, setConnectors] = useState([]); const [rootGroup, setRootGroup] = useState(null); + const [applyError, setApplyError] = useState(''); + + useEffect(() => { + setApplyError(''); + }, [rootGroup, rows, connectors]); const buildFieldOptions = useCallback(async () => { const direct: FieldOption[] = availableFields.map(f => ({ @@ -621,7 +635,12 @@ const StrapiStyleFilterModal: React.FC = ({ useEffect(() => { if (enableNestedGroups) { - // Initialize with root group + if (currentQuery) { + setRootGroup(parseQueryToFilterGroup(currentQuery) as FilterGroupType); + return; + } + + // Start with one blank row when there is no saved query to restore. setRootGroup({ id: 'root', logic: 'AND', @@ -889,11 +908,26 @@ const StrapiStyleFilterModal: React.FC = ({ setRootGroup(update(rootGroup)); }; + /** Applies only query keys that Strapi Admin can parse without truncation. */ + const tryApplyQuery = (queryString: string): boolean => { + if (!isFilterQuerySupportedByContentManager(queryString)) { + setApplyError(formatMessage({ + id: 'magic-mark.error.queryDepth', + defaultMessage: 'This filter is deeper than the Content Manager can read. Simplify it to a single top-level AND/OR group.', + })); + return false; + } + + setApplyError(''); + onApply(queryString); + onClose(); + return true; + }; + const handleApply = () => { if (enableNestedGroups && rootGroup) { const queryString = generateFromGroup(rootGroup); - onApply(queryString); - onClose(); + tryApplyQuery(queryString); return; } @@ -910,8 +944,7 @@ const StrapiStyleFilterModal: React.FC = ({ return; } const queryString = generateFromRows(validRows, connectors.slice(0, validRows.length - 1)); - onApply(queryString); - onClose(); + tryApplyQuery(queryString); }; const handleClearAll = () => { @@ -1209,6 +1242,7 @@ const StrapiStyleFilterModal: React.FC = ({ Apply + {applyError && {applyError}} diff --git a/admin/src/components/ViewsWidget.tsx b/admin/src/components/ViewsWidget.tsx index 15b055e..c61a26e 100644 --- a/admin/src/components/ViewsWidget.tsx +++ b/admin/src/components/ViewsWidget.tsx @@ -3,12 +3,13 @@ import { Plus } from '@strapi/icons'; import { BookmarkIcon } from '@heroicons/react/24/outline'; import { useIntl } from 'react-intl'; import { useNavigate } from 'react-router-dom'; -import { useFetchClient } from '@strapi/strapi/admin'; +import { useFetchClient, useRBAC } from '@strapi/strapi/admin'; import styled from 'styled-components'; import ViewsListPopover from './ViewsListPopover'; import CreateEditModal from './CreateEditModal'; import pluginId from '../pluginId'; +import pluginPermissions from '../permissions'; // ================ STYLED COMPONENTS ================ const WidgetContainer = styled.div` @@ -34,6 +35,11 @@ const ActionButton = styled.button` cursor: pointer; transition: all 0.15s ease; white-space: nowrap; + + &:disabled { + cursor: not-allowed; + opacity: 0.55; + } &:hover { background: ${(p) => p.theme.colors.neutral100}; @@ -111,6 +117,13 @@ interface ViewsWidgetProps { onShowViews?: () => void; } +interface LicenseLimitsResponse { + success: boolean; + data?: { + canCreate: boolean; + }; +} + /** * Widget for saving and accessing bookmarks in the Content Manager */ @@ -118,6 +131,7 @@ const ViewsWidget: React.FC = () => { const { formatMessage } = useIntl(); const navigate = useNavigate(); const { get } = useFetchClient(); + const { allowedActions, isLoading: isPermissionLoading } = useRBAC(pluginPermissions.updateSettings); const viewsButtonRef = useRef(null); const [showCreateModal, setShowCreateModal] = React.useState(false); const [viewsPopoverVisible, setViewsPopoverVisible] = React.useState(false); @@ -125,6 +139,8 @@ const ViewsWidget: React.FC = () => { const [isLoadingViews, setIsLoadingViews] = React.useState(false); const [currentPath, setCurrentPath] = React.useState(''); const [currentQuery, setCurrentQuery] = React.useState(''); + const [canCreate, setCanCreate] = React.useState(false); + const [isCheckingLimit, setIsCheckingLimit] = React.useState(true); /** * Fetches all bookmarks from the API @@ -137,17 +153,37 @@ const ViewsWidget: React.FC = () => { const { data } = await get<{ data?: any[] }>(url); setBookmarks(data.data || []); } catch (error) { - console.error('[Magic-Mark] Error fetching bookmarks:', error); + console.error('[Magic-Mark] Error fetching bookmarks'); setBookmarks([]); } finally { setIsLoadingViews(false); } }; + /** Loads the server's authoritative bookmark quota before enabling Save. */ + const getLicenseLimits = async () => { + setIsCheckingLimit(true); + try { + const response = await get('/magic-mark/license/limits'); + setCanCreate( + allowedActions.canUpdate === true && + response.data?.success === true && + response.data?.data?.canCreate === true, + ); + } catch (error) { + console.error('[Magic-Mark] Could not check bookmark limit'); + setCanCreate(false); + } finally { + setIsCheckingLimit(false); + } + }; + /** * Opens the create bookmark modal with current path/query */ const handleOpenModal = () => { + if (isPermissionLoading || allowedActions.canUpdate !== true || isCheckingLimit || !canCreate) return; + let path = window.location.pathname; if (path.startsWith('/admin/')) { @@ -165,6 +201,10 @@ const ViewsWidget: React.FC = () => { getBookmarks(); }, [showCreateModal]); + React.useEffect(() => { + getLicenseLimits(); + }, [isPermissionLoading, allowedActions.canUpdate]); + /** * Navigates to the selected bookmark */ @@ -181,11 +221,21 @@ const ViewsWidget: React.FC = () => { {/* Save Bookmark Button */} Save diff --git a/admin/src/hooks/useFeatureGate.ts b/admin/src/hooks/useFeatureGate.ts index ab70ac6..6f469e8 100644 --- a/admin/src/hooks/useFeatureGate.ts +++ b/admin/src/hooks/useFeatureGate.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { useFetchClient } from '@strapi/strapi/admin'; +import { isAdvancedTier, isPremiumTier, isTierAtLeast, type LicenseTier } from '../utils/licenseTier'; /** * Feature definitions for MagicMark @@ -14,7 +15,7 @@ export const FEATURES = { extendedBookmarks: { tier: 'premium', limit: 50 }, queryHistory: { tier: 'premium' }, exportBookmarks: { tier: 'premium' }, - sharedBookmarks: { tier: 'premium' }, + sharedBookmarks: { tier: 'free' }, // Advanced tier features unlimitedBookmarks: { tier: 'advanced', limit: -1 }, @@ -31,7 +32,7 @@ interface LicenseLimits { maxBookmarks: number; currentBookmarks: number; canCreate: boolean; - tier: 'free' | 'premium' | 'advanced'; + tier: LicenseTier; features: { queryHistory: boolean; export: boolean; @@ -69,8 +70,8 @@ export const useFeatureGate = (feature: FeatureKey): UseFeatureGateResult => { if (response.data?.success) { setLimits(response.data.data); } - } catch (error) { - console.error('[useFeatureGate] Error fetching license limits:', error); + } catch { + console.error('[useFeatureGate] Error fetching license limits'); } finally { setIsLoading(false); } @@ -84,11 +85,10 @@ export const useFeatureGate = (feature: FeatureKey): UseFeatureGateResult => { const currentTier = limits?.tier || 'free'; // Determine if feature is available - const tierOrder = { free: 0, premium: 1, advanced: 2 }; - const canUse = tierOrder[currentTier] >= tierOrder[requiredTier]; + const canUse = isTierAtLeast(currentTier, requiredTier); - const isPremium = currentTier === 'premium' || currentTier === 'advanced'; - const isAdvanced = currentTier === 'advanced'; + const isPremium = isPremiumTier(currentTier); + const isAdvanced = isAdvancedTier(currentTier); const showUpgrade = useCallback(() => { // Navigate to upgrade page @@ -118,19 +118,15 @@ export const useLicenseInfo = () => { useEffect(() => { const fetchLimits = async () => { try { - console.log('[useLicenseInfo] Fetching license limits...'); const response: any = await get('/magic-mark/license/limits'); - console.log('[useLicenseInfo] Raw API response:', response); - console.log('[useLicenseInfo] Response data:', response.data); if (response.data?.success) { - console.log('[useLicenseInfo] Setting limits:', response.data.data); setLimits(response.data.data); } else { - console.warn('[useLicenseInfo] API returned success=false or missing data'); + console.warn('[useLicenseInfo] API returned no license limits'); } - } catch (error) { - console.error('[useLicenseInfo] Error fetching license limits:', error); + } catch { + console.error('[useLicenseInfo] Error fetching license limits'); } finally { setIsLoading(false); } @@ -140,12 +136,9 @@ export const useLicenseInfo = () => { }, []); const tier = limits?.tier || 'free'; - const isPremium = tier === 'premium' || tier === 'advanced'; - const isAdvanced = tier === 'advanced'; + const isPremium = isPremiumTier(tier); + const isAdvanced = isAdvancedTier(tier); - // Debug log the final computed values - console.log('[useLicenseInfo] Computed values:', { tier, isPremium, isAdvanced, limits }); - return { isLoading, limits, @@ -155,8 +148,7 @@ export const useLicenseInfo = () => { isAdvanced, canUseFeature: (feature: FeatureKey): boolean => { const featureConfig = FEATURES[feature]; - const tierOrder = { free: 0, premium: 1, advanced: 2 }; - return tierOrder[tier] >= tierOrder[featureConfig.tier]; + return isTierAtLeast(tier, featureConfig.tier); }, }; }; diff --git a/admin/src/hooks/useRelationSchema.ts b/admin/src/hooks/useRelationSchema.ts index af9c099..8fb0b50 100644 --- a/admin/src/hooks/useRelationSchema.ts +++ b/admin/src/hooks/useRelationSchema.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { useState, useCallback, useRef } from 'react'; import { useFetchClient } from '@strapi/strapi/admin'; +import { getFilterableAttributes } from '../utils/filterableAttributes.mjs'; interface SchemaAttribute { type: string; @@ -88,43 +89,20 @@ export const useRelationSchema = () => { const attributes = await fetchSchema(uid); if (!attributes) return []; - const fields: RelationField[] = []; - const skipFields = ['createdBy', 'updatedBy', 'localizations', 'locale']; + const filterable = getFilterableAttributes(attributes); + const fields: RelationField[] = filterable.fields.map((field) => ({ + ...field, + isRelation: false, + })); - Object.entries(attributes).forEach(([name, attr]) => { - // Skip internal fields - if (skipFields.includes(name)) return; + fields.push(...filterable.relations.map((relation) => ({ + name: relation.name, + type: 'relation', + isRelation: true, + target: relation.target, + }))); - // Skip media, components, dynamiczones - if (['media', 'component', 'dynamiczone'].includes(attr.type)) return; - - const isRelation = attr.type === 'relation'; - - fields.push({ - name, - type: attr.type, - isRelation, - target: isRelation ? attr.target : undefined, - }); - }); - - // Add default fields - const defaultFields = [ - { name: 'id', type: 'integer', isRelation: false }, - { name: 'documentId', type: 'string', isRelation: false }, - { name: 'createdAt', type: 'datetime', isRelation: false }, - { name: 'updatedAt', type: 'datetime', isRelation: false }, - ]; - - // Prepend defaults if not already present - const existingNames = new Set(fields.map(f => f.name)); - defaultFields.forEach(df => { - if (!existingNames.has(df.name)) { - fields.unshift(df); - } - }); - - return fields; + return fields; }, [fetchSchema]); /** diff --git a/admin/src/index.ts b/admin/src/index.ts index f3e2af1..689cb60 100644 --- a/admin/src/index.ts +++ b/admin/src/index.ts @@ -65,7 +65,7 @@ export default { id: 'magic-mark-license', to: `${pluginId}/license`, Component: () => import(/* webpackChunkName: "magic-mark-license" */ './pages/License') as any, - permissions: pluginPermissions.accessSettings, + permissions: pluginPermissions.updateSettings, }, ] ); diff --git a/admin/src/pages/HomePageModern.tsx b/admin/src/pages/HomePageModern.tsx index 6b43058..f668b55 100644 --- a/admin/src/pages/HomePageModern.tsx +++ b/admin/src/pages/HomePageModern.tsx @@ -34,13 +34,14 @@ import { ClockIcon, MagnifyingGlassIcon, BookOpenIcon, + ChevronUpIcon, + ChevronDownIcon, } from '@heroicons/react/24/outline'; -import { useFetchClient } from '@strapi/strapi/admin'; +import { useFetchClient, useRBAC } from '@strapi/strapi/admin'; import pluginId from '../pluginId'; +import pluginPermissions from '../permissions'; import CreateEditModal, { getIconById } from '../components/CreateEditModal'; import FilterPreview from '../components/FilterPreview'; -import { useLicenseInfo } from '../hooks/useFeatureGate'; -import UpgradePrompt from '../components/UpgradePrompt'; // ================ THEME ================ const theme = { @@ -396,6 +397,7 @@ const ActionButtons = styled(Flex)` transition: all ${theme.transitions.fast}; gap: ${theme.spacing.xs}; justify-content: flex-end; + flex-wrap: wrap; `; const FloatingIcon = styled.div` @@ -484,7 +486,8 @@ const HomePageModern: React.FC = () => { const { formatMessage } = useIntl(); const navigate = useNavigate(); const { get, post, del } = useFetchClient(); - const { isPremium, isAdvanced, tier, canUseFeature } = useLicenseInfo(); + const { allowedActions, isLoading: isPermissionLoading } = useRBAC(pluginPermissions.updateSettings); + const canManageBookmarks = !isPermissionLoading && allowedActions.canUpdate === true; const [currentUser, setCurrentUser] = useState(null); const [bookmarks, setBookmarks] = useState([]); const [loading, setLoading] = useState(true); @@ -496,10 +499,11 @@ const HomePageModern: React.FC = () => { const [entriesPerPage, setEntriesPerPage] = useState('10'); const [availableRoles, setAvailableRoles] = useState([]); const [availableUsers, setAvailableUsers] = useState([]); + const [licenseLimitsLoaded, setLicenseLimitsLoaded] = useState(false); const [licenseLimits, setLicenseLimits] = useState({ maxBookmarks: 10, currentBookmarks: 0, - canCreate: true, + canCreate: false, tier: 'free', features: {} }); @@ -516,10 +520,9 @@ const HomePageModern: React.FC = () => { try { const response = await get(`/${pluginId}/roles`); const roles = response.data?.data?.data || response.data?.data || []; - console.log('[Magic-Mark] Available roles in HomePage:', roles); setAvailableRoles(roles); - } catch (error) { - console.error('[Magic-Mark] Error fetching roles:', error); + } catch { + console.error('[Magic-Mark] Error fetching roles'); } }; @@ -527,25 +530,22 @@ const HomePageModern: React.FC = () => { try { // Use Strapi's existing admin API directly const response = await get('/admin/users?pageSize=100&page=1&sort=firstname'); - console.log('[Magic-Mark] Admin users API response:', response); // Extract users from response - they are in response.data.data.results const allUsers = response.data?.data?.results || response.data?.results || []; - console.log('[Magic-Mark] All users from API in HomePage:', allUsers); // Filter out current user if we have their ID const users = currentUser && Array.isArray(allUsers) ? allUsers.filter(u => u.id !== currentUser.id) : allUsers; - console.log('[Magic-Mark] Available users in HomePage:', users); setAvailableUsers(users); - } catch (error) { - console.error('[Magic-Mark] Error fetching users from admin API:', error); + } catch { + console.error('[Magic-Mark] Error fetching users from admin API'); // Fallback to custom endpoint try { const response = await get(`/${pluginId}/users`); const users = response.data?.data?.data || response.data?.data || []; setAvailableUsers(users); - } catch (fallbackError) { + } catch { console.warn('[Magic-Mark] Both user endpoints failed'); setAvailableUsers([]); } @@ -555,13 +555,11 @@ const HomePageModern: React.FC = () => { const fetchCurrentUser = async () => { try { const response = await get('/admin/users/me'); - console.log('[Magic-Mark] Current user response:', response); // The response structure may vary, handle both cases const userData = response.data?.data || response.data || response; setCurrentUser(userData); - console.log('[Magic-Mark] Current user set to:', userData); - } catch (error) { - console.error('[Magic-Mark] Error fetching current user:', error); + } catch { + console.error('[Magic-Mark] Error fetching current user'); } }; @@ -589,18 +587,17 @@ const HomePageModern: React.FC = () => { return false; }; - /** - * Fetch license limits and feature flags - */ + /** Loads the server quota used to enable the bookmark creation control. */ const fetchLicenseLimits = async () => { try { const response = await get(`/${pluginId}/license/limits`); if (response.data?.success) { setLicenseLimits(response.data.data); - console.log('[Magic-Mark] License limits loaded:', response.data.data); } - } catch (error) { - console.error('[Magic-Mark] Error fetching license limits:', error); + } catch { + console.error('[Magic-Mark] Error fetching license limits'); + } finally { + setLicenseLimitsLoaded(true); } }; @@ -609,11 +606,9 @@ const HomePageModern: React.FC = () => { setLoadError(false); try { const { data } = await get(`/${pluginId}/bookmarks`); - console.log('[Magic-Mark HomePage] Bookmarks loaded:', data); - console.log('[Magic-Mark HomePage] Current user state:', currentUser); setBookmarks(data.data || []); - } catch (error) { - console.error('[Magic-Mark HomePage] Error fetching bookmarks:', error); + } catch { + console.error('[Magic-Mark HomePage] Error fetching bookmarks'); setBookmarks([]); setLoadError(true); } finally { @@ -637,10 +632,9 @@ const HomePageModern: React.FC = () => { try { await del(`/${pluginId}/bookmarks/${deleteId}`); - console.log('[Magic-Mark HomePage] Bookmark deleted:', deleteId); fetchBookmarks(); - } catch (error) { - console.error('[Magic-Mark HomePage] Error deleting bookmark:', error); + } catch { + console.error('[Magic-Mark HomePage] Error deleting bookmark'); } }; @@ -655,10 +649,44 @@ const HomePageModern: React.FC = () => { await post(`/${pluginId}/bookmarks/${pinId}/pin`, { isPinned: !bookmark.isPinned }); - console.log('[Magic-Mark HomePage] Bookmark pinned:', pinId); fetchBookmarks(); - } catch (error) { - console.error('[Magic-Mark HomePage] Error pinning bookmark:', error); + } catch { + console.error('[Magic-Mark HomePage] Error pinning bookmark'); + } + }; + + /** Returns this user's bookmarks in their persisted manual order. */ + const getOwnedBookmarksInOrder = (): Bookmark[] => + bookmarks + .filter(isCurrentUserOwner) + .slice() + .sort((left, right) => left.order - right.order); + + /** Finds a bookmark's position among items this admin is allowed to reorder. */ + const getOwnedBookmarkPosition = (bookmark: Bookmark): number => { + const bookmarkId = bookmark.documentId || bookmark.id; + return getOwnedBookmarksInOrder().findIndex( + (item) => (item.documentId || item.id) === bookmarkId, + ); + }; + + /** Persists a one-step reorder of the current user's bookmarks. */ + const handleReorder = async (bookmark: Bookmark, direction: 'up' | 'down') => { + if (!canManageBookmarks || !isCurrentUserOwner(bookmark)) return; + + const ordered = getOwnedBookmarksInOrder(); + const currentIndex = getOwnedBookmarkPosition(bookmark); + const nextIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; + if (currentIndex < 0 || nextIndex < 0 || nextIndex >= ordered.length) return; + + [ordered[currentIndex], ordered[nextIndex]] = [ordered[nextIndex], ordered[currentIndex]]; + const bookmarkIds = ordered.map((item) => item.documentId || item.id); + + try { + await post(`/${pluginId}/bookmarks/reorder`, { bookmarkIds }); + await fetchBookmarks(); + } catch { + console.error('[Magic-Mark] Error reordering bookmarks'); } }; @@ -667,15 +695,17 @@ const HomePageModern: React.FC = () => { setShowModal(true); }; - const handleCreate = () => { + /** Opens Content Manager so a new bookmark can capture a real view path and query. */ + const handleOpenContentManager = () => { + if (!canManageBookmarks || !licenseLimitsLoaded) return; + // Check if bookmark limit is reached if (!licenseLimits.canCreate && licenseLimits.maxBookmarks !== -1) { alert(`Bookmark limit reached (${licenseLimits.currentBookmarks}/${licenseLimits.maxBookmarks}). Please upgrade to create more bookmarks.`); navigate(`/settings/${pluginId}/upgrade`); return; } - setEditingBookmark(null); - setShowModal(true); + navigate('/content-manager'); }; const handleModalClose = () => { @@ -703,7 +733,6 @@ const HomePageModern: React.FC = () => { ? `${path}?${bookmark.query}` : path; - console.log('[HomePage] Navigating to:', url); navigate(url); } }; @@ -734,13 +763,29 @@ const HomePageModern: React.FC = () => { {/* Gradient Header */}
- - - <BookOpenIcon /> MagicMark - - - Save filtered views and navigate with one click - + + + + <BookOpenIcon /> MagicMark + + + Save filtered views and navigate with one click + + + {canManageBookmarks && ( + + )}
@@ -773,8 +818,8 @@ const HomePageModern: React.FC = () => { !licenseLimits.canCreate && navigate(`/settings/${pluginId}/upgrade`)} - style={{ cursor: !licenseLimits.canCreate ? 'pointer' : 'default' }} + onClick={() => licenseLimitsLoaded && !licenseLimits.canCreate && navigate(`/settings/${pluginId}/upgrade`)} + style={{ cursor: licenseLimitsLoaded && !licenseLimits.canCreate ? 'pointer' : 'default' }} > { {licenseLimits.currentBookmarks}/{licenseLimits.maxBookmarks === -1 ? '∞' : licenseLimits.maxBookmarks} - {licenseLimits.canCreate + {!licenseLimitsLoaded + ? 'Checking bookmark limit' + : licenseLimits.canCreate ? formatMessage({ id: `${pluginId}.stats.limit`, defaultMessage: 'Bookmark Limit' }) : formatMessage({ id: `${pluginId}.stats.limitReached`, defaultMessage: 'Limit Reached - Upgrade' }) } @@ -992,7 +1039,33 @@ const HomePageModern: React.FC = () => { - {isCurrentUserOwner(bookmark) && } + {canManageBookmarks && isCurrentUserOwner(bookmark) && } + {canManageBookmarks && isCurrentUserOwner(bookmark) && {/* Show delete for owned bookmarks */} - {isCurrentUserOwner(bookmark) && ( + {canManageBookmarks && isCurrentUserOwner(bookmark) && (