Compare commits

..

539 Commits

Author SHA1 Message Date
hykocx 6cb968c005 chore: bump version to 1.4.210 2026-04-26 20:38:33 -04:00
hykocx e5b21c0d54 feat(media): extract media details into reusable modal component
- add `MediaDetailsModal.client.js` with support for `media={…}` or `slug="…"` props
- add `GET /zen/api/media/by-slug/:slug` route for slug-based lookup
- refactor `MediaPage.client.js` to use the new modal instead of inline details panel
- export `MediaDetailsModal` as `./features/media/details-modal` in package.json
- update `BlockEditor` image block to open `MediaDetailsModal` for media editing
- update media feature README to document new component and route
2026-04-26 20:38:29 -04:00
hykocx e9a5750928 chore: bump version to 1.4.209 2026-04-26 20:22:26 -04:00
hykocx 31d0359163 docs(BlockEditor): document mediaSlug media library link and add server helpers
- update image block schema in README table to include `mediaSlug?` and clarify fields
- add "Liaison avec la médiathèque" section documenting mediaSlug behavior, read-only alt/caption, and internal `_` fields
- document new server helpers (`normalizeImageBlocks`, `enrichBlocksWithMedia`, `syncBlockImageReferences`) with usage examples
- add `block_image` field convention to media feature README with cross-references
- implement `mediaLink.server.js` with the three server-side helpers
- store `mediaSlug` on image block at insertion time in `Image.client.js`
- persist `mediaSlug` through clipboard paste/duplicate in `clipboard.js`
- export `mediaLink` entry point in `package.json` exports map
2026-04-26 20:22:22 -04:00
hykocx 8c5c3baec4 chore: bump version to 1.4.208 2026-04-26 19:44:12 -04:00
hykocx cb576f1036 fix(media): replace EyeIcon with UserGroupIcon for public visibility indicator 2026-04-26 19:44:09 -04:00
hykocx 3e9d1b22fd chore: bump version to 1.4.207 2026-04-26 19:40:47 -04:00
hykocx fbcaed6816 docs(admin): document active item and breadcrumb logic for nav registration
- add note in DEV.md explaining basePath auto-deduction and breadcrumb slug convention
- update README.md to document new `basePath` param in `registerNavItem` and detail active item/breadcrumb behavior
- update navigation file listing in README.md to include new exported helpers
- implement `getNavItemBasePath` and `findActiveNavContext` in navigation.js
- use `basePath` in AdminSidebar to determine active item via longest-prefix match
- use `basePath` in AdminTop to build breadcrumb with section, item, and action labels
- expose new navigation helpers from admin index.js and registry.js
2026-04-26 19:40:40 -04:00
hykocx 2d76b56deb chore: bump version to 1.4.206 2026-04-26 19:24:20 -04:00
hykocx 5ff1e0cd3c fix(media): apply fill positioning classes to private image fallback 2026-04-26 19:24:17 -04:00
hykocx c7ec34c560 chore: bump version to 1.4.205 2026-04-26 19:22:58 -04:00
hykocx 66ced30d8f perf(media): memoize toast context and improve loading state UX
- wrap ToastContext value in useMemo to prevent unnecessary re-renders
- show skeleton only on initial load, use opacity transition for subsequent fetches
- destructure toastError from toast context to stabilize fetchItems dependency
2026-04-26 19:22:55 -04:00
hykocx 87e5889b76 chore: bump version to 1.4.204 2026-04-26 19:18:39 -04:00
hykocx 8e37eb53ff feat(media): add MediaImage wrapper to handle public/private image rendering
- add `MediaImage.client.js` component that routes to `next/image` for public media and native `<img>` for private media to prevent CDN cache leaking private content
- replace direct `<img>` usage in `MediaGrid` and `MediaPage` with `MediaImage`
- document `MediaImage` usage and rationale in `src/features/media/README.md`
- update `docs/dev/ARCHITECTURE.md` to reference the `MediaImage` wrapper convention
2026-04-26 19:18:35 -04:00
hykocx 90e172f571 chore: bump version to 1.4.203 2026-04-26 17:41:05 -04:00
hykocx 56c334684f feat(ui): add MediaPicker integration to BlockEditor image block
- import MediaPicker from features/media to avoid circular dependency with @zen/core
- add "Choisir un média" button in ImageUrlForm alongside existing URL input
- insert image block with `/zen/api/media/file/<slug>` src on media selection
- update README to document dual-source form (external URL + media library) and revise known limitations
2026-04-26 17:40:58 -04:00
hykocx 9723f40df2 chore: bump version to 1.4.202 2026-04-26 17:12:47 -04:00
hykocx 3cc5a49518 refactor(media): promote media nav item to top-level sidebar entry
- replace generic "Contenu" section with a dedicated "media" section sharing the same id/label as the item
- update sectionId and order to trigger shouldRenderAsDirectLink in AdminSidebar
- update README to reflect top-level entry instead of nested section
2026-04-26 17:12:43 -04:00
hykocx 1070bd7874 chore: bump version to 1.4.201 2026-04-26 17:10:26 -04:00
hykocx fcb1a192ba refactor(media): remove redundant card wrappers from media page 2026-04-26 17:10:22 -04:00
hykocx 73529b5caf docs(config): reorder env example variables and clean up comments 2026-04-26 17:07:55 -04:00
hykocx 621d1b48ee chore: bump version to 1.4.200 2026-04-26 17:07:22 -04:00
hykocx c9f7b23498 feat(media): add media management feature module
- add `ZEN_MEDIA` env flag and document it in `.env.example`
- add media schema, server routes, and API handlers (`api.server.js`, `routes.server.js`, `schema.server.js`)
- add `MediaPage`, `MediaGrid`, `MediaFilters`, and `MediaPicker` client components
- expose `@zen/core/features/media` and `@zen/core/features/media/picker` package exports
- register media navigation and permissions; wire module into `init.js`
- document media API, client picker usage, and boundary rules in `MODULES.md` and `ARCHITECTURE.md`
- add `src/features/media/README.md`
2026-04-26 17:07:19 -04:00
hykocx f5d627f324 chore: bump version to 1.4.199 2026-04-26 16:28:44 -04:00
hykocx 67274687a3 style(ui): simplify image caption and alt input layout using full-width utility class 2026-04-26 16:28:40 -04:00
hykocx c3b54d9361 chore: bump version to 1.4.198 2026-04-26 16:26:44 -04:00
hykocx d66b107636 feat(BlockEditor): add image alignment, link, and replace/delete controls
- add align (left/center/right/full), href, newTab fields to image block
- render floating toolbar on image hover with alignment buttons and link popover
- add replace and delete actions to image toolbar
- wrap image in <a> in disabled mode and HTML export when href is set
- update htmlToBlocks/blocksToHtml to serialize/parse align, href, newTab
- guard handleContainerMouseDown to prevent multi-block selection on input/textarea focus
- add alignment and link icons to shared icons index
- update README with image block spec and toolbar behaviour
2026-04-26 16:26:41 -04:00
hykocx 83490de15d chore: bump version to 1.4.197 2026-04-26 16:03:31 -04:00
hykocx 5ecbf13348 fix(ui): replace form submit with explicit key and click handlers in image block
- remove form element and onSubmit in favor of a plain div
- add handleKeyDown to trigger submit on Enter key press
- attach onClick handler directly to the insert button with type="button"
2026-04-26 16:03:28 -04:00
hykocx b721574e58 chore: bump version to 1.4.196 2026-04-26 16:01:56 -04:00
hykocx a1bcc4bfb9 fix(ui): add null guard for missing element in caret utils 2026-04-26 16:01:53 -04:00
hykocx 688ae224ab chore: bump version to 1.4.195 2026-04-26 15:57:20 -04:00
hykocx 7ac4caea23 feat(ui): add current block type label to actions menu 2026-04-26 15:57:18 -04:00
hykocx d983635491 chore: bump version to 1.4.194 2026-04-26 15:53:25 -04:00
hykocx 543c4f5029 refactor(BlockEditor): replace hover-based submenu open/close with click-toggle
- remove timer-based submenu close logic (scheduleSubmenuClose, cancelSubmenuClose, submenuTimerRef) from BlockActionsMenu
- replace onMouseEnter/onMouseLeave handlers with onClick toggle on submenu trigger
- remove SUBMENU_CLOSE_DELAY constant and hover handlers from inline Toolbar submenus
- update README to reflect click-to-open/close-on-outside-click behavior for all submenus
2026-04-26 15:53:21 -04:00
hykocx d7e723770f chore: bump version to 1.4.193 2026-04-26 15:46:54 -04:00
hykocx b54dce9445 fix(ui): remove autofocus from link form url input 2026-04-26 15:46:52 -04:00
hykocx 42f1c47624 chore: bump version to 1.4.192 2026-04-26 15:45:21 -04:00
hykocx ff10c2ffea fix(ui): prevent inline toolbar from hiding when interacting with submenus
- keep toolbar visible when focus moves to an element inside `[data-inline-toolbar]`
- unpin toolbar on cleanup when submenu closes to avoid stale pinned state
2026-04-26 15:45:18 -04:00
hykocx be5bdf15b7 chore: bump version to 1.4.191 2026-04-26 15:39:45 -04:00
hykocx db468b56b5 refactor(block-editor): extract shared menu styles into dedicated module
- add `menuStyles.js` with reusable `BOX_CLASS`, `ITEM_CLASS`, `ITEM_DANGER_CLASS`, and `SEPARATOR_CLASS` constants
- replace inline tailwind strings in `Block.client.js` with imported style constants
- update `BlockEditor.client.js`, `LinkPopover.client.js`, and `Toolbar.client.js` to use shared menu styles
- update `README.md` to document the new `menuStyles.js` file
2026-04-26 15:39:41 -04:00
hykocx 3e90ef8c5d chore: bump version to 1.4.190 2026-04-26 15:19:38 -04:00
hykocx 94a7bcf44d feat(ui): add link popover component for inline link editing
- add LinkPopover.client.js component for creating, editing, and removing links
- replace autoOpenLink ref-based approach with dedicated linkPopover state
- import and integrate removeMark utility in BlockEditor
- wire up handleLinkPopoverSet and handleLinkPopoverRemove handlers
- open link popover on link click instead of expanding caret range
- close link popover on mousedown outside popover and toolbar
- refactor InlineToolbar to delegate link editing to linkPopover
2026-04-26 15:19:35 -04:00
hykocx 4a755d347c chore: bump version to 1.4.189 2026-04-26 15:11:03 -04:00
hykocx 8159b5316a feat(BlockEditor): auto-open link popover when clicking on existing link
- add `linkRangeAt` import to detect link span under caret
- handle `mouseup` on container to detect collapsed click inside a link mark
- set `autoOpenLink` flag via ref and expand selection to full link range
- pass `autoOpenLink` to toolbar state and use `initialPopover='link'` prop
- initialize toolbar popover state from `initialPopover` prop
- pre-fill link url and new-tab from existing mark when `initialPopover` is set
- add `linkRangeAt` helper in `types.js` to find enclosing link range at offset
2026-04-26 15:11:00 -04:00
hykocx 33ee62e908 chore: bump version to 1.4.188 2026-04-26 15:06:34 -04:00
hykocx 9f328bc818 feat(BlockEditor): add setMark utility and wire link submission to it
- add `setMark` helper in `types.js` that removes then applies a mark, replacing existing marks of the same type without toggling
- expose `applySetMark` in `BlockEditor.client.js` and pass it as `onSetMark` prop to `InlineToolbar`
- switch `handleLinkSubmit` in `Toolbar.client.js` to use `onSetMark` instead of `onToggleMark` so re-submitting a link always applies the new href
2026-04-26 15:06:31 -04:00
hykocx 0941994e44 chore: bump version to 1.4.187 2026-04-26 15:03:19 -04:00
hykocx 62cfb76d99 fix(ui): replace form with div in link popover to prevent unintended submit behavior
- swap `<form>` wrapper for `<div>` to avoid native form submission
- add `onKeyDown` handler on input to trigger submit on Enter key
- change button type from `submit` to `button` with explicit `onClick` handler
2026-04-26 15:03:17 -04:00
hykocx 43d2328082 chore: bump version to 1.4.186 2026-04-26 12:12:08 -04:00
hykocx 88e1840c8a chore(admin): update nav section order values for system and devkit sections 2026-04-26 12:11:56 -04:00
hykocx 0d45f18a0c chore: bump version to 1.4.185 2026-04-26 12:01:16 -04:00
hykocx bbd12e7596 style(BlockEditor): adjust container padding values 2026-04-26 12:01:13 -04:00
hykocx 7b642d71b3 chore: bump version to 1.4.184 2026-04-26 11:55:54 -04:00
hykocx d57d3a1ca1 feat(BlockEditor): add minHeight prop to control minimum container height
- accept `minHeight` as number (converted to px) or css string value
- apply inline style on container when prop is defined
- document new prop in README
2026-04-26 11:55:51 -04:00
hykocx c9d41a8abe docs(styles): add tailwind source scanning for zen modules
- add `@source` directive in zen.css to auto-scan `@zen/module-*/dist/**/*.js`
- document tailwind class auto-discovery mechanism for modules in MODULES.md
2026-04-26 11:53:18 -04:00
hykocx cdd1e39c9a chore: bump version to 1.4.183 2026-04-26 11:37:45 -04:00
hykocx 3fea89dbd4 feat(ui): add size and variant props to Input component
- add `size` prop (sm | md | lg) with corresponding tailwind size classes
- add `variant` prop supporting `default` and `ghost` styles
- add focus state tracking to enable ghost→default transition on focus
- forward `onFocus` and `onBlur` callbacks with internal focus handling
- isolate color input styling from size/variant logic
2026-04-26 11:37:42 -04:00
hykocx 649c69f408 chore: bump version to 1.4.182 2026-04-26 11:04:27 -04:00
hykocx d170058509 refactor(icons): add categoryIcon support for representative category icons
- add optional `categoryIcon` flag to icon metadata helper in add-remove.js and business.js
- mark Add01Icon and ChartLineData01Icon as category representative icons
- use categoryIcon flag in IconsPage to display the most representative icon per category
- add `cursor-pointer` to category sidebar buttons and widen sidebar from 200px to 250px
2026-04-26 11:04:19 -04:00
hykocx f4070f6611 chore: bump version to 1.4.181 2026-04-26 10:53:16 -04:00
hykocx 8d8c773c00 refactor(icons): remove export from internal helper function m 2026-04-26 10:53:13 -04:00
hykocx f1905a52cb chore: bump version to 1.4.180 2026-04-26 10:51:30 -04:00
hykocx 8254e05202 chore(ui): add business icons export to shared icons index 2026-04-26 10:51:27 -04:00
hykocx 3dc6c2a60e chore: bump version to 1.4.179 2026-04-26 10:50:56 -04:00
hykocx 4afe334c6b feat(icons): add business icon set and improve devkit icon copy behavior
- add `src/shared/icons/business.js` with new business-related icons
- move `Wallet03Icon` from `index.js` to `business.js`
- update `index.js` to export icons from business module
- add shift+click to copy JSX snippet in icons devkit page
- update icon button tooltip to hint shift shortcut
- document apostrophe escaping rule in icons README
2026-04-26 10:50:52 -04:00
hykocx 6680551eee chore: bump version to 1.4.178 2026-04-26 09:57:13 -04:00
hykocx 0b73ff1d04 docs(icons): add duplicate detection guide and remove duplicate icon definitions
- add duplicate detection section in README.md with bash commands to identify duplicates
- remove `Cancel01Icon` duplicate definition from `index.js`
- remove `Delete02Icon` duplicate definition from `index.js`
2026-04-26 09:57:09 -04:00
hykocx 615385b6b3 chore: bump version to 1.4.177 2026-04-26 09:53:20 -04:00
hykocx 85d94fe135 refactor(icons): update add-remove icon components svg attributes and paths 2026-04-26 09:53:17 -04:00
hykocx f9cf825648 chore: bump version to 1.4.176 2026-04-26 09:42:27 -04:00
hykocx 8d51e9ba03 style(admin): improve icon card layout with square aspect ratio and balanced spacing 2026-04-26 09:42:24 -04:00
hykocx f26a5bb9ec chore: bump version to 1.4.175 2026-04-26 09:41:01 -04:00
hykocx d30c6b49fd style(admin): improve icon card layout and label display in icons devkit page 2026-04-26 09:40:58 -04:00
hykocx 5cd3b248ac chore: bump version to 1.4.174 2026-04-26 09:38:06 -04:00
hykocx 668890fa7f refactor(admin): improve icons page sidebar layout and category display
- store first icon reference alongside count in category map
- add hasSidebar flag to conditionally adjust layout classes
- make sidebar full-width on mobile with horizontal scrolling category list
- reduce icon size and spacing to accommodate sidebar presence
- adjust grid columns breakpoints when sidebar is visible
2026-04-26 09:38:03 -04:00
hykocx 491219f976 chore: bump version to 1.4.173 2026-04-26 09:31:20 -04:00
hykocx 7412de96ea feat(admin): add category filter and keyword search to icons devkit page
- add category sidebar with icon counts derived from Icon.category metadata
- extend search to match icon keywords via Icon.keywords array
- add AddRemoveIcon to shared icons with category and keywords metadata
- add icons README documenting category and keywords conventions
2026-04-26 09:31:10 -04:00
hykocx b598ce7ed7 chore: bump version to 1.4.172 2026-04-25 21:03:16 -04:00
hykocx a1069c3e3d feat(BlockEditor): add notion native json paste support
- import `notionJsonToBlocks` in `Block.client.js` and prioritize `text/_notion-blocks-v3-production` mime over `text/html` on paste
- implement `notionJsonToBlocks` and `notionValueToBlock` in `clipboard.js` to convert notion block json to editor blocks, preserving native types (`to_do`, `sub_sub_header`, etc.)
- update README to document the notion mime priority in paste handling
2026-04-25 21:03:13 -04:00
hykocx 6a73769d8e chore: bump version to 1.4.171 2026-04-25 21:00:59 -04:00
hykocx 0fa20ace1e fix(clipboard): add support for notion and github-flavored markdown checklist formats
- detect `to-do-list` (Notion) and `task-list` (GitHub MD) classes as checklist containers
- handle notion `<div class="checkbox-on/off">` as checkbox indicator in list items
- update README to document newly recognized HTML tags for checklist input
2026-04-25 21:00:55 -04:00
hykocx 507e6b7d03 chore: bump version to 1.4.170 2026-04-25 20:57:21 -04:00
hykocx 303042e749 fix(ui): adjust submenu placement based on available viewport space
- add refs for submenu trigger and panel elements
- compute available space above/below using getBoundingClientRect
- dynamically set submenu side to avoid overflow outside viewport
2026-04-25 20:57:18 -04:00
hykocx 452bd51d46 chore: bump version to 1.4.169 2026-04-25 20:55:50 -04:00
hykocx a1f71860fe feat(ui): add repeat icon and improve block actions menu transform option
- add RepeatIcon component to shared icons index
- import and display RepeatIcon in the transform menu item of BlockActionsMenu
- remove maxHeight constraint and overflow-y-auto from actions menu panel
2026-04-25 20:55:45 -04:00
hykocx e1ccd6ded9 chore: bump version to 1.4.168 2026-04-25 20:51:55 -04:00
hykocx c32ab0909c feat(ui): add smart dropdown placement to block editor menus
- introduce `useDropdownPlacement` hook to compute above/below positioning based on available viewport space
- apply dynamic `maxHeight` and position class to `BlockInsertMenu` and `BlockActionsMenu` panels
- attach `triggerRef` to menu trigger buttons for accurate anchor rect calculation
- use `useLayoutEffect` to avoid layout flicker on open
2026-04-25 20:51:52 -04:00
hykocx 2204cefabf chore: bump version to 1.4.167 2026-04-25 20:49:42 -04:00
hykocx 63ba04d583 fix(ui): anchor slash menu to bottom edge when flipped above cursor
- initialize position state with a `bottom` field set to null
- use `bottom` css property instead of `top` when menu flips above the anchor to prevent floating when content shrinks on query filtering
- remove `items.length` from layout effect dependencies since repositioning on item count change caused the flipping issue
- build `positionStyle` object conditionally based on whether `bottom` is set
2026-04-25 20:49:38 -04:00
hykocx c9609cb770 chore: bump version to 1.4.166 2026-04-25 20:46:49 -04:00
hykocx e928e5317c refactor(BlockEditor): add BlockInsertMenu component and unify block type icon styling
- introduce `BlockInsertMenu` dropdown to insert a new block after the current one
- extract `TYPE_ICON_BOX_CLASS` constant shared between insert menu and transform menu
- align `BlockActionsMenu` transform list item padding/gap to match new insert menu style
- update README to document the new insert menu behaviour and enabled blocks filtering
2026-04-25 20:46:45 -04:00
hykocx 56767cff0f chore: bump version to 1.4.165 2026-04-25 20:42:19 -04:00
hykocx 9d8133c7f5 refactor(ui): replace headless ui menu with custom dropdown in BlockActionsMenu
- remove Headless UI Menu and Fragment imports, unused TextIcon import
- implement manual BlockActionsMenu component to avoid pointerdown-triggered open interfering with drag start
- open dropdown only on click after checking justDragged flag
- handle outside click and Escape key for closing
- migrate submenu hover logic from BlockMenuTransformItem into new component
2026-04-25 20:42:12 -04:00
hykocx 52d22e4171 chore: bump version to 1.4.164 2026-04-25 20:36:18 -04:00
hykocx bde634d169 feat(ui): add block transform submenu with hover panel and drag fix
- add `BlockMenuTransformItem` component with hover-triggered submenu panel
- import `ArrowRight01Icon` and `TextIcon` icons for transform UI
- track drag state via `justDraggedRef` to prevent menu opening after drag
- expose `close` from `<Menu>` render prop to allow manual close on transform select
- wire transform options from block registry into the new submenu item
2026-04-25 20:35:51 -04:00
hykocx 8b3baa39f8 chore: bump version to 1.4.163 2026-04-25 20:29:22 -04:00
hykocx 53ace7fc1f refactor(BlockEditor): replace drag handle button with headless ui menu
- add context menu on drag handle with transform, duplicate and delete actions
- introduce `MenuOpenSync` helper to keep handle visible while menu is open
- pass `onTransformBlock`, `onDuplicateBlock`, `onDeleteBlock` and `enabledBlocks` props to Block
- compute `transformOptions` via `useMemo` filtering allowed text block types
- update BlockEditor to wire new block action handlers down to each Block
- update README to document new block action props and menu behavior
2026-04-25 20:29:18 -04:00
hykocx 515b95c8d3 chore: bump version to 1.4.162 2026-04-25 20:19:39 -04:00
hykocx 332b7d31ef chore: bump version to 1.4.161 2026-04-25 20:19:35 -04:00
hykocx 085a779c74 feat(BlockEditor): add rich paste support with html-to-blocks parsing
- add clipboard.js with htmlToBlocks, blocksToHtml, and blocksToPlainText helpers
- handle single-paragraph html paste as inline splice preserving block type
- handle multi-block html paste by splitting current block and merging head/tail paragraphs
- add onPasteInline and onPasteBlocks props to Block component
- implement handlePasteInline and handlePasteBlocks in BlockEditor
- fallback to plain text insertion when html is absent or yields no blocks
- update README to document clipboard behaviour and new paste handlers
2026-04-25 20:19:32 -04:00
hykocx 547b975c01 chore: bump version to 1.4.160 2026-04-25 20:07:51 -04:00
hykocx 0c10dd0142 docs(devkit): add dot badge variants preview to components page 2026-04-25 20:07:48 -04:00
hykocx 9670e03da8 chore: bump version to 1.4.159 2026-04-25 20:05:36 -04:00
hykocx c87f74a18e fix(BlockEditor): close slash menu when clicking outside or on different block
- add `handleFocus` in Block to reopen slash menu on focus if content starts with `/`
- add `outerRef` on editor root div to detect outside clicks
- add `useEffect` with document `mousedown` listener to close slash menu when clicking outside the editor or on a different block
- add `data-slash-menu` attribute on SlashMenu to exclude it from the close trigger
2026-04-25 20:05:32 -04:00
hykocx 20f31269e4 chore: bump version to 1.4.158 2026-04-25 19:36:37 -04:00
hykocx 3d9431389b fix(BlockEditor): handle br tags as single character in caret offset calculation
- replace `getCaretOffset` with `countCharsUpTo` to treat `<br>` as 1 char
- rewrite `locateOffset` to walk dom tree and account for `<br>` nodes
- add `textLength` helper to compute model-consistent node length
- update `getCaretOffset` and `getCaretRange` to use new counting logic
2026-04-25 19:36:30 -04:00
hykocx 51cbf11729 chore: bump version to 1.4.157 2026-04-25 19:31:17 -04:00
hykocx e26314b38d fix(BlockEditor): handle newline characters in inline nodes by inserting br elements 2026-04-25 19:31:13 -04:00
hykocx 5a22eb5330 chore: bump version to 1.4.156 2026-04-25 19:25:01 -04:00
hykocx f88fd15b71 style(ui): set explicit dimensions on TextClearIcon in inline toolbar 2026-04-25 19:24:58 -04:00
hykocx bd45507635 chore: bump version to 1.4.155 2026-04-25 19:23:48 -04:00
hykocx 30cd0bbd81 feat(BlockEditor): add clear formatting button to inline toolbar
- add `removeAllMarks` function to inline/types.js
- implement `applyRemoveAllMarks` handler in BlockEditor client
- add `TextClearIcon` button with separator in Toolbar component
- expose `onClearMarks` prop on InlineToolbar
- update README to document new clear formatting action
2026-04-25 19:23:45 -04:00
hykocx 741bf39a39 feat(ui): replace code button text label with CodeSimpleIcon
- add CodeSimpleIcon svg to shared icons index
- update inline toolbar code button to use CodeSimpleIcon instead of text label
2026-04-25 19:19:24 -04:00
hykocx ec83f87fd2 chore: bump version to 1.4.154 2026-04-25 19:16:13 -04:00
hykocx b4bebfd1bd fix(BlockEditor): preserve selected index only when block id and query match 2026-04-25 19:16:09 -04:00
hykocx d859874122 fix(BlockEditor): trigger select-all blocks when block content is empty on Ctrl+A 2026-04-25 19:12:04 -04:00
hykocx 21634f5a38 chore: bump version to 1.4.153 2026-04-25 19:09:46 -04:00
hykocx c7b96f2e16 fix(ui): improve block editor placeholder visibility with fade transition
- remove unused `placeholder` prop from BlockEditor component
- add `block-editor--sole-empty` class when editor has a single empty block
- show placeholder via opacity transition instead of content injection
- always render `attr(data-placeholder)` content, toggle visibility with opacity
- add 150ms ease transition for smooth placeholder fade in/out
- show placeholder when block is focused, hovered, or editor is sole-empty
2026-04-25 19:09:41 -04:00
hykocx d225ff2e5f chore: bump version to 1.4.152 2026-04-25 18:55:54 -04:00
hykocx 0000f22066 feat(BlockEditor): add single block selection via drag handle click
- add `onSelectBlock` prop to Block component wired to drag handle click
- implement `selectBlock` function in BlockEditor to select a single block and clear text selection
2026-04-25 18:55:51 -04:00
hykocx 97ebaf0635 chore: bump version to 1.4.151 2026-04-25 18:53:03 -04:00
hykocx 219fb36da1 refactor(BlockEditor): replace emoji icons with react icon components and add free color picker support
- update blockRegistry to accept ReactNode icons instead of emoji strings
- replace emoji icons in all built-in block types with icon components from shared icons
- add `isHexColor` and `collectUsedColors` helpers to inline/types.js
- extend `color` and `highlight` marks to accept hex color strings in addition to palette keys
- pass `usedColors` (collected from document) to InlineToolbar
- update InlineToolbar color popover to show used colors and a free color input
- add new icons to shared icons index
- update README to reflect icon, color, and toolbar popover changes
2026-04-25 18:52:59 -04:00
hykocx 3f93503996 chore: bump version to 1.4.150 2026-04-25 18:35:15 -04:00
hykocx 9893ade233 fix(ui): move onMouseDown prevention to individual toolbar buttons and default linkNewTab to false
- move `e.preventDefault()` from toolbar container to each individual button to avoid broad focus prevention
- default `linkNewTab` state to `false` instead of `true` for new links
- add `onMouseDown` prevention to color grid buttons
2026-04-25 18:35:11 -04:00
hykocx 2666d1a7fd chore: bump version to 1.4.149 2026-04-25 18:32:25 -04:00
hykocx fdb36c39e5 feat(ui): add "open in new tab" option to block editor link toolbar
- add `linkNewTab` state (default true) in InlineToolbar
- pass `newTab` flag through `onToggleMark` on link submit and remove
- restore `newTab` value when reopening the link popover
- add checkbox ui in link popover to toggle new tab behavior
- update link serialization to render `target="_blank" rel="noopener noreferrer"` when `newTab` is set
- add `newTab` field to link mark type definition
2026-04-25 18:32:21 -04:00
hykocx 2c132b3a8a chore: bump version to 1.4.148 2026-04-25 18:30:17 -04:00
hykocx 7b6bf67f36 fix(ui): keep inline toolbar visible when popover is open
- add `toolbarPinnedRef` in BlockEditor to prevent toolbar from closing while a popover is focused
- pass `onPinChange` callback to InlineToolbar to toggle the pinned state
- notify parent via `onPinChange` whenever popover state changes
- remove rect-change effect that was incorrectly closing popovers on selection update
2026-04-25 18:30:02 -04:00
hykocx d9fbe29031 chore: bump version to 1.4.147 2026-04-25 18:27:24 -04:00
hykocx 5a8d2ad02f feat(BlockEditor): add inline formatting with rich content model
- migrate block content from plain strings to InlineNode[] structure
- add inline toolbar (bold, italic, code, color, link) on text selection
- add checklist block type with toggle support
- add image block type (URL-based, phase 2)
- add inline serialization helpers (inlineToDom, domToInline)
- add inline types and length utilities
- extend caret utils with range get/set support
- update block registry and all existing block types for new content model
- update demo blocks in ComponentsPage to use rich inline content
- update README to reflect new architecture
2026-04-25 18:27:20 -04:00
hykocx 3eeaebfa68 chore: bump version to 1.4.146 2026-04-25 18:08:02 -04:00
hykocx 4bc7319056 refactor(ui): replace PlusSignIcon with Add01Icon in BlockEditor
- swap PlusSignIcon for Add01Icon in Block.client.js add button
- remove PlusSignIcon export from shared icons index
- update README to reflect renamed icon
2026-04-25 18:07:59 -04:00
hykocx d7aa3532d1 chore: bump version to 1.4.145 2026-04-25 18:07:21 -04:00
hykocx 1fcd57807f refactor(BlockEditor): replace text symbols with icon components in block controls
- replace `+` and `⋮⋮` text with `PlusSignIcon` and `DragDropVerticalIcon` components
- add `Add01Icon` export to shared icons index
- update README to reference icon names and link to icons source
2026-04-25 18:07:18 -04:00
hykocx 4bd51bcd13 chore: bump version to 1.4.144 2026-04-25 18:05:36 -04:00
hykocx 980f9cc5a0 refactor(ui): implement mouse-driven multi-block selection with text and marquee modes
- add `sameSet` helper to compare two Sets for equality
- add `dragRef` to track drag state (mode, startBlockId, origin coords)
- replace `selectionchange`-based detection with `mousemove`/`mouseup` listeners
- support `text` mode: stay native until cursor leaves origin block, then switch to `block` mode
- support `block` mode: extend selection across blocks as cursor moves between them
- support `marquee` mode: rect-based selection when mousedown starts outside a contentEditable
- use `sameSet` to avoid redundant `setSelectedBlockIds` calls on unchanged selections
2026-04-25 18:05:34 -04:00
hykocx 1a132bb1af chore: bump version to 1.4.143 2026-04-25 18:01:22 -04:00
hykocx 0d7b654a2d feat(BlockEditor): add multi-block selection with ctrl+a and delete support
- add `isSelected` prop and overlay highlight to Block component
- implement double ctrl+a: first selects block content, second selects all blocks
- add `onSelectAllBlocks` callback prop to Block
- add `selectedBlockIds` state and `selectAllBlocks`/`deleteSelectedBlocks` helpers in BlockEditor
- detect cross-block native selection via `selectionchange` and convert to block selection
- handle backspace/delete key to remove all selected blocks
- clear block selection on click outside or focus change
- update README to document multi-block selection behaviour
- export new icons used by the feature
2026-04-25 18:01:18 -04:00
hykocx 8bed913459 chore: bump version to 1.4.142 2026-04-25 17:56:32 -04:00
hykocx 14c2c3d6bf fix(BlockEditor): prevent block merge when backspace pressed with active selection
- skip merge-with-previous-block trigger if selection is not collapsed
- update README to document the collapsed-selection guard on Backspace
2026-04-25 17:56:29 -04:00
hykocx 60689b8c4d chore: bump version to 1.4.141 2026-04-25 17:53:19 -04:00
hykocx 9df91bf412 fix(BlockEditor): constrain select-all to current block and resize slash menu
- intercept Ctrl/Cmd+A to select only the current block's content, preventing cross-block merge bug
- increase slash menu width from 280 to 375 and max-height from 320 to 360
- enlarge icon container, increase gap and padding, and upsize text in slash menu items
2026-04-25 17:53:16 -04:00
hykocx 489147d25d chore: bump version to 1.4.140 2026-04-25 17:49:12 -04:00
hykocx 9d155a28c9 refactor(ui): improve slash menu layout and add shortcut hints
- increase menu width and max height constants
- add SHORTCUT_HINT map displaying markdown shortcuts per block type
- update empty state and list container to use rounded-lg and shadow-md
- add section label header above block items
- show monospace shortcut hint on each menu item
- tighten item padding and icon size for denser layout
2026-04-25 17:49:09 -04:00
hykocx b775b05c15 chore: bump version to 1.4.139 2026-04-25 17:46:08 -04:00
hykocx c74737e5d9 fix(ui): improve slash menu keyboard handling and adaptive positioning
- move slash menu keyboard listener to document capture phase to prevent contentEditable default behaviors
- use circular navigation for arrow keys in slash menu
- separate undo/redo shortcuts into dedicated global keydown handler
- add adaptive positioning for slash menu with flip-up, horizontal clamp, and max-height constraints
2026-04-25 17:46:04 -04:00
hykocx fdf35f36a3 chore: bump version to 1.4.138 2026-04-25 17:42:55 -04:00
hykocx 7fa2353296 style(BlockEditor): improve block spacing and placeholder visibility behavior
- increase block vertical padding from py-0.5 to py-1.5 for better readability
- increase editor container vertical padding from py-3 to py-6
- show placeholder text only on hover or focus instead of always visible
2026-04-25 17:42:51 -04:00
hykocx 645a54dba5 chore: bump version to 1.4.137 2026-04-25 17:37:26 -04:00
hykocx 54386d3fe3 feat(ui): add BlockEditor component with block types, slash menu, and drag-and-drop
- add BlockEditor orchestrator with controlled block list and keyboard navigation
- add Block client component with contentEditable sync, drag handles, and markdown shortcuts
- add SlashMenu for inserting block types via `/` command
- add blockRegistry and block type definitions (paragraph, heading, bullet list, numbered list, quote, code, divider)
- add caret and id utility helpers
- export BlockEditor from shared components index
- add BlockEditor demo to admin devkit ComponentsPage
- add README documenting usage and architecture
2026-04-25 17:37:23 -04:00
hykocx 0c99bf5002 chore: bump version to 1.4.136 2026-04-25 17:08:00 -04:00
hykocx 4e759767f2 feat(admin): add TagInput component demo to devkit components page
- import TagInput component and useState hook
- define ROLE_OPTIONS with color metadata for demo purposes
- add TagInputDemo wrapper component to manage local state
- add PreviewBlock showcasing default, colored badge, and error variants
2026-04-25 17:07:57 -04:00
hykocx 8b61da7322 chore: bump version to 1.4.135 2026-04-25 17:05:36 -04:00
hykocx cd6064b98f refactor(ui): replace RoleBadge with generic Badge component
- add `dot` and `onRemove` props to Badge for colored dot and removable tag support
- delete RoleBadge component in favor of Badge with dot prop
- update UserCreateModal, UserEditModal, and UsersPage to use Badge instead of RoleBadge
- remove RoleBadge export from shared components index
2026-04-25 17:05:32 -04:00
hykocx e78f5321e6 docs(modules): document AdminHeader component usage and clarify admin/components exports 2026-04-25 15:55:10 -04:00
hykocx 5474368a7e chore: bump version to 1.4.134 2026-04-25 15:15:31 -04:00
hykocx f14731e554 fix(cli): export ZenModulesClient component from client manifest to ensure side-effects execute in browser
- update `renderClientManifest` to export a `ZenModulesClient` React component instead of `export {}`
- update docs to explain why rendering the component is required under Next.js 15+/Turbopack and add usage example in `app/layout.js`
2026-04-25 15:15:27 -04:00
hykocx 9cdc945639 chore: bump version to 1.4.133 2026-04-25 15:05:31 -04:00
hykocx cb547f6400 docs(core): update server boundary rules and fix db import paths
- document `.server.js` suffix requirement for node-only imports in DEV.md
- add client-safe subentries table and server-only barrel warnings in MODULES.md
- fix `crud.js` and `database/index.js` to import from `db.server.js`
- replace `createRequire` with `pathToFileURL` in `discover.server.js` for ESM-only modules
- update admin navigation and registry to use safe client-compatible imports
- bump version to 1.4.132
2026-04-25 15:05:26 -04:00
hykocx 0b32e8aa97 refactor(database): rename db.js to db.server.js 2026-04-25 15:05:21 -04:00
hykocx eb87d9070d chore: bump version to 1.4.131 2026-04-25 14:43:08 -04:00
hykocx b460ed0619 docs(modules): update server/client boundary docs and client manifest generation
- update MODULES.md to document dual-entry pattern (main vs ./client) and explain why client entry must not import server-only code
- filter client manifest to only include modules exposing a `./client` subpath export
- add `moduleHasClientEntry` helper in discover.server.js to check package.json exports
- update cli.js to use `moduleHasClientEntry` when rendering the client manifest
- update init.js and modules/index.js to align with new client entry convention
2026-04-25 14:43:00 -04:00
hykocx 92f3e4c561 chore: bump version to 1.4.130 2026-04-25 14:34:46 -04:00
hykocx 1b85d6fac7 docs(modules): add client manifest generation and update discovery docs
- introduce `OUTPUT_CLIENT` constant and `renderClientManifest` for `'use client'` bundle
- rename `renderManifest` to `renderServerManifest` for clarity
- update `sync` command to write both server and client manifests
- update `findInstalledModuleNames` to support custom package path resolution
- rewrite MODULES.md to explain dual-manifest architecture and client hydration rationale
2026-04-25 14:34:43 -04:00
hykocx c793bc418c chore: bump version to 1.4.129 2026-04-25 14:25:06 -04:00
hykocx 94ab6c36cb docs(modules): update module discovery architecture to static manifest approach
- replace dynamic import strategy with static manifest generated by `zen-modules sync` cli
- add `zen-modules` binary entry point in `package.json`
- add `cli.js` implementing the `zen-modules sync` command
- update `discover.server.js` to consume static manifest instead of scanning at runtime
- update `index.js` to reflect new module registration flow
- update `init.js` to accept pre-resolved modules from manifest
- revise docs to document manifest format, sync triggers, and build requirements
2026-04-25 14:24:56 -04:00
hykocx c9a3634fc9 chore: bump version to 1.4.128 2026-04-25 13:14:44 -04:00
hykocx d6befcfa91 docs(modules): update module structure to reflect tsup build workflow
- fix entry point path from `index.js` to `src/index.js` compiled to `dist/index.js`
- update file tree to show `src/` as source root and `dist/` as published output
- update `package.json` example with correct `main`, `exports`, `files`, and build scripts
- add explanation of mandatory pre-build step before publish
- document why JSX must be compiled (turbopackIgnore + Node native resolution)
- add minimal `tsup.config.js` example with `bundle: false` and JSX loader
- add verification step to check compiled output before publishing
2026-04-25 13:13:07 -04:00
hykocx 65e833d020 chore: bump version to 1.4.127 2026-04-25 13:08:17 -04:00
hykocx 6b3bb6a4ee fix(storage): make next/headers import lazy in api.js to avoid module resolution failure
- replace top-level `import { cookies } from 'next/headers'` with lazy `await import('next/headers')` inside handler
- document the constraint in PROJECT.md: no top-level next/headers or next/navigation imports reachable from module register() chains
2026-04-25 13:08:14 -04:00
hykocx 7d6765b58b chore: bump version to 1.4.126 2026-04-25 13:01:10 -04:00
hykocx 7afcb2cb5a refactor(admin): split protect guards into dedicated export path
- remove `protectAdmin`/`isAdmin` re-exports from `features/admin/index.js` to avoid top-level `next/headers` import
- add `./features/admin/protect` export entry in `package.json`
- lazy-import `next/headers` in `router.js` `requireAuth` to defer resolution
- update `features/admin/README.md` to document new import paths
- translate `features/auth/index.js` comment to French for consistency
2026-04-25 13:01:06 -04:00
hykocx 6bbf3f1507 chore: bump version to 1.4.125 2026-04-25 12:52:36 -04:00
hykocx 34f0b9da22 refactor(auth): remove actions re-export from server barrel to avoid next/headers import issue
- update barrel comment to document why actions.js is excluded
- remove re-exports of server actions that depend on `next/headers` at module load time
- instruct consumers to import actions explicitly via @zen/core/features/auth/actions
2026-04-25 12:52:32 -04:00
hykocx b17895e162 chore: bump version to 1.4.124 2026-04-25 12:44:28 -04:00
hykocx 9f709df357 fix(discover): add turbopack and webpack ignore hints to dynamic import 2026-04-25 12:43:53 -04:00
hykocx 143d9bd2cc chore: bump version to 1.4.123 2026-04-25 12:39:25 -04:00
hykocx 7f89c35969 refactor(init): skip module register() call during db init to avoid next.js imports
- update comment to clarify that only manifest.permissions are registered before seed
- remove register() invocation from loadModules() to prevent incompatible next.js imports in cli context
2026-04-25 12:39:11 -04:00
hykocx de745cb924 docs: add root cause fix principle to coding standards
- add "no whack-a-mole" rule in CLAUDE.md to enforce fixing root causes over symptoms
- add equivalent root cause principle in docs/DEV.md coding standards section
2026-04-25 12:39:06 -04:00
hykocx 7c1341d439 docs(modules): update module file structure to use src/ layout
- move db.js, api.js and register-server.js under src/ with updated naming conventions
- add explanation of src/ as the module code directory with index.js as public entry point
- document index.js pattern for re-exporting register, createTables and dropTables from src/
- add package.json files field example to restrict published assets
2026-04-25 12:20:23 -04:00
hykocx e783a39ced chore: bump version to 1.4.122 2026-04-25 10:50:18 -04:00
hykocx a3aff9fa49 feat(modules): add external module system with auto-discovery and public pages support
- add `src/core/modules/` with registry, discovery (server), and public index
- add `src/core/public-pages/` with registry, server component, and public index
- add `src/core/users/permissions-registry.js` for runtime permission registration
- expose `./modules`, `./public-pages`, and `./public-pages/server` package exports
- rename `registerFeatureRoutes` to `registerApiRoutes` with backward-compatible alias
- extend `seedDefaultRolesAndPermissions` to include module-registered permissions
- update `initializeZen` and shared init to wire module discovery and registration
- add `docs/MODULES.md` documenting the `@zen/module-*` authoring contract
- update `docs/DEV.md` with references to module system docs
2026-04-25 10:50:13 -04:00
hykocx 3098940905 chore: bump version to 1.4.121 2026-04-25 10:12:37 -04:00
hykocx efc7c93c6b fix(auth): prevent admin from revoking their last users.manage role
- add self-lockout guard in handleRevokeUserRole api handler
- sequence role additions before removals and handle delete errors in UserEditModal
- document the security rule in core/users README
2026-04-25 10:12:31 -04:00
hykocx 78ba61e60e chore: bump version to 1.4.120 2026-04-25 10:02:54 -04:00
hykocx 0d6b06f217 feat(users): allow system roles to be renamed but not have permissions changed
- update `updateRole` to allow name changes for system roles while blocking permission updates
- remove edit button restriction for system roles in roles page
- disable name field only was replaced by disabling permissions checkboxes for system roles in edit modal
- update README to reflect new system role update policy
2026-04-25 10:02:51 -04:00
hykocx 584e96a00d chore: bump version to 1.4.119 2026-04-25 09:59:37 -04:00
hykocx 826ce3dcd1 fix(auth): prevent system roles from being updated
- throw error in updateRole when role is system-protected
- hide edit button in roles table for system roles
- update README to reflect roles cannot be modified (not just renamed)
2026-04-25 09:59:33 -04:00
hykocx ebdeea7287 chore: bump version to 1.4.118 2026-04-25 09:47:37 -04:00
hykocx 2360021376 refactor(users)!: merge users.edit and users.delete into users.manage permission
BREAKING CHANGE: permissions `users.edit` and `users.delete` have been replaced by a single `users.manage` permission; any role or code referencing the old keys must be updated

- remove `USERS_EDIT` and `USERS_DELETE` from `PERMISSIONS` and `PERMISSION_DEFINITIONS`
- add `USERS_MANAGE` permission covering create, edit and delete actions
- update `db.js` to use `users.manage` in permission checks
- update `auth/api.js` to reference the new permission key
- update `UsersPage.client.js` to check `users.manage` instead of old keys
- update `api/define.js` and all README examples to reflect the new key
2026-04-25 09:47:34 -04:00
hykocx 27ebc91d31 chore: bump version to 1.4.117 2026-04-25 09:39:06 -04:00
hykocx ab4ecd1ccf refactor(users): remove content, media, and settings permissions
- strip content.*, media.*, and settings.* permission keys from PERMISSIONS constant
- remove corresponding entries from PERMISSION_DEFINITIONS
- drop content and media permission groups from db seed data
- update README examples and permission table to reflect reduced scope
2026-04-25 09:39:00 -04:00
hykocx 2f91a8bcd3 chore: bump version to 1.4.116 2026-04-25 09:31:58 -04:00
hykocx 74bc3073a7 feat(admin): add permission-based widget visibility on dashboard
- add optional `permission` field to `registerWidget` api
- filter widgets in `DashboardPage` based on user permissions
- register users widget with `users.view` permission requirement
- document `permission` parameter in admin README
2026-04-25 09:31:54 -04:00
hykocx 01a08b0005 chore: bump version to 1.4.115 2026-04-25 09:27:10 -04:00
hykocx 97f8baf502 feat(admin): add permission-based filtering to admin navigation
- add optional `permission` field to nav items in registry
- filter nav items by user permissions in `buildNavigationSections`
- auto-hide sections when all their items are filtered out
- fetch user permissions in `AdminLayout.server.js` and pass to navigation builder
- update docs and README to document `permission` param and new signature
2026-04-25 09:27:07 -04:00
hykocx cb8266d9a9 chore: bump version to 1.4.114 2026-04-25 09:23:31 -04:00
hykocx 531381430d docs(claude): require documentation updates after every code change 2026-04-25 09:23:27 -04:00
hykocx c959b16db5 refactor(api): add granular permission enforcement on admin routes
- add optional `permission` field to route definitions with type validation in `define.js`
- check `hasPermission()` in router after `requireAdmin()` and return 403 if denied
- document `permission` and `skipRateLimit` optional fields in api README
- load user permissions in `AdminPage.server.js` and pass them to client via `user` prop
- use `user.permissions` in `RolesPage` and `UsersPage` to conditionally render actions
- expose permission-gated API routes in `auth/api.js`
2026-04-25 09:21:07 -04:00
hykocx 188e1d82f8 style(auth): polish french copy in auth email templates
- simplify em-dash sentence in EmailChangeConfirmEmail footer note
- replace "notre équipe de support" with "le support" across notify/changed/admin_new variants
- shorten InvitationEmail title by removing "Bienvenue —" prefix
- reword PasswordChangedEmail body and footer note for clarity
- align PasswordResetEmail and VerificationEmail copy with same tone
2026-04-25 09:11:20 -04:00
hykocx 0eee8af8b4 chore: bump version to 1.4.113 2026-04-25 09:06:19 -04:00
hykocx 03b24ce320 fix(auth): remove redundant truthy check in hasPassword condition 2026-04-25 09:06:16 -04:00
hykocx 3b442f2cf5 chore: bump version to 1.4.112 2026-04-25 09:04:17 -04:00
hykocx 12c1e36c3c feat(auth): export completeAccountSetup function 2026-04-25 09:04:14 -04:00
hykocx 0f199bb5cd chore: bump version to 1.4.111 2026-04-25 09:03:19 -04:00
hykocx abd9d651dc feat(auth): add user invitation flow with account setup
- add `createAccountSetup`, `verifyAccountSetupToken`, `deleteAccountSetupToken` to verifications core
- add `completeAccountSetup` function to auth core for password creation on invite
- add `InvitationEmail` template for sending invite links
- add `SetupAccountPage` client page for invited users to set their password
- add `UserCreateModal` admin component to invite new users
- wire invitation action and API endpoint in auth feature
- update admin `UsersPage` to include user creation modal
- update auth and admin README docs
2026-04-25 09:03:15 -04:00
hykocx 96c8cf1e97 chore: bump version to 1.4.110 2026-04-25 08:34:47 -04:00
hykocx eff66e0a70 style(admin): swap light/dark text colors on icon label in icons page 2026-04-25 08:34:40 -04:00
hykocx ccc6e28d9d style(admin): fix icon color to support light and dark mode 2026-04-25 08:33:41 -04:00
hykocx f481844932 docs(admin): add README documentation for admin and auth features
- add comprehensive README for admin feature covering structure, API, registry, and extension points
- add comprehensive README for auth feature covering structure, API, and usage examples
2026-04-24 21:53:47 -04:00
hykocx 203bd82dd9 docs(core): add README files for all core framework modules
- add cron/README.md documenting the node-cron wrapper API and job registration pattern
- add email/README.md documenting the Resend wrapper, env vars, and template usage
- add payments/README.md documenting the payments module
- add pdf/README.md documenting the pdf generation module
- add themes/README.md documenting the theming system
- add toast/README.md documenting the toast notification module
- add users/README.md documenting the users module
2026-04-24 21:48:31 -04:00
hykocx e1ee9ef564 chore: bump version to 1.4.109 2026-04-24 21:38:30 -04:00
hykocx 238666f9cc fix(rateLimit): return loopback ip in development to keep rate limiting active
- use `127.0.0.1` as fallback ip when `NODE_ENV === 'development'` in both `getIpFromHeaders` and `getIpFromRequest`
- preserve `unknown` fallback in production to suspend rate limiting when no trusted proxy is configured
- update comments to reflect environment-specific behaviour
2026-04-24 21:38:27 -04:00
hykocx 879fee1b80 chore: bump version to 1.4.108 2026-04-24 21:34:38 -04:00
hykocx f46116394c feat(auth): add proxy support and pass ip/user-agent to login
- add ZEN_TRUST_PROXY env variable in .env.example for reverse proxy config
- replace getClientIp() with getIpFromHeaders() using next/headers for ip resolution
- forward ipAddress and userAgent to login action for session tracking
2026-04-24 21:34:35 -04:00
hykocx f6f2938e3b chore: bump version to 1.4.107 2026-04-24 21:25:00 -04:00
hykocx 860d44d728 style(auth): replace min-h-dvh with min-h-screen on auth page container 2026-04-24 21:24:57 -04:00
hykocx 5218f3f205 chore: bump version to 1.4.106 2026-04-24 21:22:15 -04:00
hykocx 1e529a6741 style(auth): improve auth page layout for mobile viewports
- use `min-h-dvh`, `flex-col`, and top-aligned justify on small screens in AuthPage
- add `mx-auto` to all auth page cards for consistent centering
2026-04-24 21:22:12 -04:00
hykocx dd322bcc86 chore: bump version to 1.4.105 2026-04-24 21:16:28 -04:00
hykocx b39e316b4a fix(admin): improve breadcrumb segment matching for nested nav items
- replace fixed `[first, second]` destructuring with dynamic segment-aware matching
- find nav items using prefix segment comparison instead of first-segment-only match
- compute `itemSegCount` from matched nav item href to support multi-segment routes
- derive sub-segment index dynamically so breadcrumb labels resolve correctly for nested paths
2026-04-24 21:16:25 -04:00
hykocx 190664bfbe chore: bump version to 1.4.104 2026-04-24 21:12:51 -04:00
hykocx 9138474512 style(icons): increase stroke width of arrow left and up icons from 1.5 to 2 2026-04-24 21:12:49 -04:00
hykocx 00ea4af242 chore: bump version to 1.4.103 2026-04-24 21:11:58 -04:00
hykocx 1032276d49 refactor(ui): replace chevron icons with arrow icon variants
- swap `ChevronDownIcon` and `ChevronRightIcon` for `ArrowDown01Icon` and `ArrowRight01Icon` in AdminSidebar and AdminTop
- add `ArrowDown01Icon`, `ArrowLeft01Icon`, `ArrowRight01Icon`, and `ArrowUp01Icon` to shared icons index
- remove `ChevronDownIcon` and `ChevronRightIcon` from shared icons index
2026-04-24 21:11:53 -04:00
hykocx 5f625adc76 chore: bump version to 1.4.102 2026-04-24 21:10:15 -04:00
hykocx 310277f5cd refactor(ui): replace ChevronDownIcon with ArrowDown01Icon in Table
- add ArrowDown01Icon svg component to shared icons index
- update Table.js to use ArrowDown01Icon instead of ChevronDownIcon for sort indicator
2026-04-24 21:10:12 -04:00
hykocx 4474ab8204 chore: bump version to 1.4.101 2026-04-24 21:08:55 -04:00
hykocx bd31d29ac7 refactor(ui): replace ArrowDown01Icon with ChevronDownIcon in Table
- swap ArrowDown01Icon for ChevronDownIcon in Table sort indicator
- remove ArrowDown01Icon export from shared icons index
2026-04-24 21:08:52 -04:00
hykocx 4ba9cac007 chore: bump version to 1.4.100 2026-04-24 21:06:10 -04:00
hykocx a73357b759 refactor(ui): replace inline svg icons with icon components
- replace inline checkmark svg in ColorPicker with Tick02Icon
- replace inline sort arrow svg in Table with ArrowDown01Icon
- add ArrowDown01Icon to shared icons index
2026-04-24 21:06:07 -04:00
hykocx b200346d04 chore: bump version to 1.4.99 2026-04-24 21:02:36 -04:00
hykocx 759184f0ed refactor(admin): replace inline svgs with icon components and fix icon colors
- replace inline hamburger/close svg with Menu01Icon component in AdminTop
- replace inline chevron svg with ChevronRightIcon component for breadcrumbs
- add ChevronRightIcon and Menu01Icon imports to AdminTop
- fix UserCircle02Icon fill values from hardcoded #ffffff to currentColor
2026-04-24 21:02:33 -04:00
hykocx 650d2dbb27 chore: bump version to 1.4.98 2026-04-24 20:52:55 -04:00
hykocx 2d3d450e19 refactor(admin): replace inline svgs with icon components
- add `Logout02Icon` to admin top bar logout button
- add `SmartPhone01Icon` and `ComputerIcon` to profile page session list
- update icons index to use hugeicons react package imports
2026-04-24 20:52:51 -04:00
hykocx c25a518d87 refactor(ui): replace custom icon spinner with inline svg in Loading component
- remove Recycle03Icon dependency and use native svg spinner
- adjust size values for sm, md, and lg variants
- update loading text from "Loading...." to "Chargement"
2026-04-24 20:37:31 -04:00
hykocx 8d5a785494 style(ui): reduce dark mode opacity for danger, success, and warning button variants 2026-04-24 20:35:08 -04:00
hykocx 957e322f9f style(devkit): add explicit text color to card variant labels 2026-04-24 20:33:16 -04:00
hykocx f77635b7b3 chore: bump version to 1.4.97 2026-04-24 20:31:12 -04:00
hykocx 47437ecca8 style(admin): improve icons grid layout and card appearance
- increase grid columns across breakpoints including md, 2xl, and custom 16-col
- add aspect-square and justify-center to icon cards for uniform sizing
- update card style with solid border and background instead of transparent hover-only
- enlarge icon size from w-5/h-5 to w-7/h-7 and set color to white
- add full-width and padding to icon label for better text containment
2026-04-24 20:31:09 -04:00
hykocx 50f04f762b chore: bump version to 1.4.96 2026-04-24 20:27:33 -04:00
hykocx 970092fccb feat(admin): add devkit developer tools section
- add `ZEN_DEVKIT` env variable to enable/disable devkit
- add `isDevkitEnabled()` utility and export it from public api
- register devkit nav section and items conditionally when devkit is enabled
- add devkit route handling in admin page client and server
- add DevkitPage, ComponentsPage, and IconsPage client components
2026-04-24 20:27:30 -04:00
hykocx 345218641c chore: bump version to 1.4.95 2026-04-24 17:58:58 -04:00
hykocx 183d151f0f style(admin): update card width classes from min-w to max-w on profile and settings pages
- replace `sm:min-w-3/5` with `lg:max-w-4/5` on all profile page cards
- replace `min-w-3/5` with `w-full lg:max-w-4/5` on settings page cards
2026-04-24 17:58:55 -04:00
hykocx 27a9cbc12f chore: bump version to 1.4.94 2026-04-24 17:54:40 -04:00
hykocx 77ca4fe66f fix(ui): improve mobile responsiveness across admin components
- reduce app name font size from text-lg to text-sm in AdminTop mobile header
- make profile page cards full-width on mobile with sm:min-w-3/5 breakpoint
- stack photo upload layout vertically on mobile using flex-col sm:flex-row
- add flex-wrap to photo action buttons for small screens
- make TabNav horizontally scrollable with hidden scrollbar on mobile
- add shrink-0 and whitespace-nowrap to tab buttons to prevent wrapping
2026-04-24 17:54:37 -04:00
hykocx ba289d1a28 chore: bump version to 1.4.93 2026-04-24 17:51:02 -04:00
hykocx b90b4e7bcc refactor(ui): redesign mobile card layout in Table component
- replace fixed column-slice layout with mobileHidden filter for flexible column visibility
- render primary column as prominent header with semantic styling
- display remaining columns in a responsive 2-column dl grid with label/value pairs
- update MobileSkeletonCard to reflect new grid structure based on visible column count
2026-04-24 17:50:41 -04:00
hykocx 5743eb7f53 chore: bump version to 1.4.92 2026-04-24 17:48:49 -04:00
hykocx 932e9b9373 style(admin): simplify mobile menu toggle button styling 2026-04-24 17:48:46 -04:00
hykocx e27fe939c5 docs: rewrite redaction guide with clearer structure and scope
- split voice guidelines into two contexts: interface and documentation
- replace generic editorial rules with format-specific sections (ui, labels, errors, doc)
- remove site/email/proposal sections out of scope for this project
- add concrete examples for interface messages, buttons, and error states
- streamline typography and punctuation rules
2026-04-24 17:47:08 -04:00
hykocx 227ecc9e7d docs(DEV.md): reorganize and clarify dev standards and build documentation
- move promise and env-var principles to top of code standards section
- clarify build strategy: bundle:false applies to all src/ files, not just suffixed ones
- update RSC boundary explanation to describe module isolation approach
- add note about jsx loader handling .js files via esbuild
- remove redundant section headers and inline comments in examples
- fix grammar in singleton bundling warning
2026-04-24 17:41:41 -04:00
hykocx f8ff34f815 chore: bump version to 1.4.91 2026-04-24 17:35:30 -04:00
hykocx ecb4929753 docs(design): update design system specifications and component styles
- remove color palette table from main color section in DESIGN.md
- update typography scale (reduce body/secondary text sizes)
- revise border radius values for badges, buttons, modals, and cards
- expand sidebar navigation section with detailed structure and visual states
- add accordion section behavior and item template specs
- update Badge component to use fully rounded corners and adjust sizing
- update Modal component border radius and backdrop styles
2026-04-24 17:35:25 -04:00
hykocx 04987e41f9 docs(dev): add reference to design documentation in DEV.md 2026-04-24 17:20:17 -04:00
hykocx af8c082463 docs(project): update project description from cms to multi-purpose platform
- update tagline in README.md and docs to reflect multi-purpose scope
- rephrase project description in PROJECT.md to position zen as an extensible admin platform
- remove detailed core documentation sections from PROJECT.md
- fix typo "palteforme" in directory structure comment
- replace cms references with platform wording in DEV.md
2026-04-24 17:19:33 -04:00
hykocx 342341e141 chore: bump version to 1.4.90 2026-04-24 17:11:42 -04:00
hykocx 227b05a61e refactor(auth): extract shared page header into AuthPageHeader component
- add AuthPageHeader component to centralize title/description markup
- replace inline header divs in LoginPage, RegisterPage, LogoutPage, ForgotPasswordPage, ResetPasswordPage, and ConfirmEmailPage with AuthPageHeader
2026-04-24 17:11:37 -04:00
hykocx 6ad16cddf9 chore: bump version to 1.4.89 2026-04-24 17:01:53 -04:00
hykocx d0e407b67d fix(admin): update session data check to use sessions property 2026-04-24 17:01:50 -04:00
hykocx 6e794703ff chore: bump version to 1.4.88 2026-04-24 17:00:08 -04:00
hykocx a92b4334f1 feat(admin): add session management tab to profile page
- add sessions tab with active session list in ProfilePage
- fetch and display sessions with current session highlight
- implement single and bulk session revocation with redirect on self-revoke
- add session-related api helpers in auth api
2026-04-24 16:59:54 -04:00
hykocx 221836d91c chore: bump version to 1.4.87 2026-04-24 16:50:46 -04:00
hykocx f60137011d style(admin): simplify password reset button markup and remove border separator 2026-04-24 16:50:39 -04:00
hykocx 4549299d50 style(admin): update dark mode border color to neutral-800 in UserEditModal 2026-04-24 16:48:18 -04:00
hykocx 3f5bbfda0b style(admin): clean up password section layout in user edit modal 2026-04-24 16:47:56 -04:00
hykocx bba1e9bc9a chore: bump version to 1.4.86 2026-04-24 15:52:38 -04:00
hykocx ec0edf89b9 fix(admin): require current password for self password change and fix field ordering
- initialize `newPassword` in form state on load
- add `needsCurrentPassword` flag triggered by email or password change when editing self
- route self password change to profile endpoint with current password verification
- move role tag input above password section and update current password field visibility logic
2026-04-24 15:52:34 -04:00
hykocx f22fcb6f68 chore: bump version to 1.4.85 2026-04-24 15:45:59 -04:00
hykocx c844bc5e86 feat(admin): add password management to user edit modal and profile page
- add new password field in UserEditModal with optional admin-set password on save
- add send password reset link button with loading state in UserEditModal
- add password change section with strength indicator in ProfilePage
- expose sendPasswordResetEmail utility in auth api
2026-04-24 15:45:56 -04:00
hykocx 661f6c0783 chore: bump version to 1.4.84 2026-04-24 15:31:31 -04:00
hykocx 25f93526a5 feat(admin): add RoleBadge component and integrate it in user management views
- add new RoleBadge shared component for consistent role display
- export RoleBadge from shared components index
- replace inline Badge usage with RoleBadge in UsersPage role column
- use RoleBadge via renderTag prop in UserEditModal role TagInput
- simplify TagInput Pill to a generic unstyled pill, removing color logic
2026-04-24 15:31:28 -04:00
hykocx f413c4fa0f chore: bump version to 1.4.83 2026-04-24 15:25:00 -04:00
hykocx 69bc05944c fix(ui): render TagInput dropdown via portal to avoid overflow clipping
- import createPortal from react-dom
- add dropdownStyle state to track fixed position coordinates
- calculate and update dropdown position on open, scroll, and resize
- render dropdown and empty-state divs into document.body using createPortal
2026-04-24 15:24:56 -04:00
hykocx b5d228b8ac chore: bump version to 1.4.82 2026-04-24 15:20:55 -04:00
hykocx 70000e0761 refactor(admin): embed roles data in user list query and update role display
- remove separate `/zen/api/roles` fetch and `roleColorMap` state from UsersPage
- update SQL query to include aggregated roles array per user via subquery
- replace single role badge with multi-badge display supporting overflow indicator
2026-04-24 15:20:51 -04:00
hykocx d6b7575444 chore: bump version to 1.4.81 2026-04-24 15:17:03 -04:00
hykocx 48755c03f3 refactor(admin): remove email_verified field from user edit modal 2026-04-24 15:17:00 -04:00
hykocx f211946562 chore: bump version to 1.4.80 2026-04-24 15:13:10 -04:00
hykocx b88f84e2a1 refactor(admin): wrap profile page content in fragment 2026-04-24 15:13:06 -04:00
hykocx 4b17852ace chore: bump version to 1.4.79 2026-04-24 15:11:35 -04:00
hykocx 87990390c1 refactor(admin): replace inline email form with modal dialog
- import Modal component from shared components
- rename emailFormOpen state to emailModalOpen for clarity
- convert handleEmailSubmit from form event handler to plain async function
- move email change form into a Modal instead of inline collapsible form
- pass pendingEmailMessage as Input description prop instead of separate paragraph
- simplify toggle button to only show when no pending message
2026-04-24 15:11:29 -04:00
hykocx 8b0041bd32 chore: bump version to 1.4.78 2026-04-24 15:07:11 -04:00
hykocx cc0fe5aca7 chore(deps): upgrade postcss to 8.5.10 via package override
- add overrides field in package.json to force postcss ^8.5.10
- replace next's bundled postcss 8.4.31 with deduped postcss 8.5.10 in lockfile
2026-04-24 15:07:05 -04:00
hykocx 6b95cdf535 chore: bump version to 1.4.77 2026-04-24 15:04:44 -04:00
hykocx 66c862cf73 feat(admin): add email change flow with confirmation for users
- add `ConfirmEmailChangePage.client.js` for email change token confirmation
- add `emailChange.js` core utility to generate and verify email change tokens
- add `EmailChangeConfirmEmail.js` and `EmailChangeNotifyEmail.js` email templates
- update `UserEditModal` to handle email changes with password verification for self-edits
- update `ProfilePage` to support email change initiation
- update `UsersPage` to pass `currentUserId` to `UserEditModal`
- add email change API endpoints in `auth/api.js` and `auth/email.js`
- register `ConfirmEmailChangePage` in `AdminPage.client.js`
2026-04-24 15:04:36 -04:00
hykocx f31b97cff4 chore: bump version to 1.4.76 2026-04-23 19:55:40 -04:00
hykocx 995edae513 feat(auth): expose individual auth page components as a public entry point 2026-04-23 19:55:35 -04:00
hykocx c901e81c83 chore: bump version to 1.4.75 2026-04-23 19:39:01 -04:00
hykocx 362804b650 style(auth): replace "E-mail" label with "Courriel" in auth pages 2026-04-23 19:38:57 -04:00
hykocx 45e664739e chore: bump version to 1.4.74 2026-04-23 19:38:19 -04:00
hykocx 4b27c1efea style(auth): update form placeholders to french localization 2026-04-23 19:38:16 -04:00
hykocx d3bde53762 chore: bump version to 1.4.73 2026-04-23 19:24:24 -04:00
hykocx 472c6ebd7f style(ui): replace focus:ring-0 with focus:[box-shadow:none] for fullghost button variant 2026-04-23 19:24:21 -04:00
hykocx 13c263a7df chore: bump version to 1.4.72 2026-04-23 19:22:27 -04:00
hykocx e4a0967203 style(ui): remove focus ring from fullghost button variant 2026-04-23 19:22:24 -04:00
hykocx f7801c37d3 chore: bump version to 1.4.71 2026-04-23 19:21:31 -04:00
hykocx f48f002fcd refactor(auth): replace anchor tags with fullghost Button variant for navigation links 2026-04-23 19:21:27 -04:00
hykocx 9f70d740ad chore: bump version to 1.4.70 2026-04-23 18:21:25 -04:00
hykocx dbea58a978 refactor(auth): replace anchor navigation links with Button components and improve auth page styling 2026-04-23 18:21:21 -04:00
hykocx da2bd0b4e7 chore: bump version to 1.4.69 2026-04-23 18:16:55 -04:00
hykocx 1aac03c2dc refactor(auth): remove GET /users/me endpoint and related exports 2026-04-23 18:16:46 -04:00
hykocx e7aad33682 chore: bump version to 1.4.68 2026-04-22 20:41:38 -04:00
hykocx ad4847e1c5 fix(auth): hide error/success messages when user is authenticated or conflicting states exist 2026-04-22 20:41:35 -04:00
hykocx fc0a4b744b chore: bump version to 1.4.67 2026-04-22 20:39:08 -04:00
hykocx 189dcfc726 style(auth): replace inline card styles with Card component and clean up comments in ConfirmEmailPage 2026-04-22 20:39:05 -04:00
hykocx 3e95387879 chore: bump version to 1.4.66 2026-04-22 20:26:49 -04:00
hykocx bbb55605c3 refactor(admin): simplify ProfilePage with tabs and component cleanup 2026-04-22 20:26:45 -04:00
hykocx 7d6a13a57b chore: bump version to 1.4.65 2026-04-22 20:22:57 -04:00
hykocx 68d97c81da style(admin): increase settings card min-width from 1/2 to 3/5 2026-04-22 20:22:54 -04:00
hykocx 6316ecd027 chore: bump version to 1.4.64 2026-04-22 20:22:09 -04:00
hykocx f082ef4fda style(ui): adjust layout alignment and sizing in settings page and tab nav 2026-04-22 20:22:07 -04:00
hykocx e647aef47e chore: bump version to 1.4.63 2026-04-22 20:16:10 -04:00
hykocx 3e7e0387a1 refactor(admin): generalize breadcrumb fallback to handle unknown single-segment routes 2026-04-22 20:16:07 -04:00
hykocx 4f8dde1d21 chore: bump version to 1.4.62 2026-04-22 20:14:32 -04:00
hykocx 3b04971483 feat(admin): add position parameter to registerNavItem 2026-04-22 20:14:29 -04:00
hykocx e8a3ecf86e chore: bump version to 1.4.61 2026-04-22 20:12:20 -04:00
hykocx ccdd309414 feat(admin): add bottom navigation items and settings page to admin panel 2026-04-22 20:12:18 -04:00
hykocx 739a0b2399 chore: bump version to 1.4.60 2026-04-22 19:58:46 -04:00
hykocx fef71aaf92 refactor(admin): remove quick links section and replace anchor tags with Next.js Link components 2026-04-22 19:58:43 -04:00
hykocx 766ee07ffc chore: bump version to 1.4.59 2026-04-22 19:54:51 -04:00
hykocx e99970b9b2 style(admin): update logout button text and hover colors for better contrast 2026-04-22 19:54:48 -04:00
hykocx 44a811fd3c chore: bump version to 1.4.58 2026-04-22 19:52:26 -04:00
hykocx 45ce8fda59 style(ui): increase dark mode opacity for danger, success, and warning button variants 2026-04-22 19:52:16 -04:00
hykocx 5f61d59777 chore: bump version to 1.4.57 2026-04-22 19:49:18 -04:00
hykocx 5d4d1866b5 style(ui): add flex alignment classes to button text span 2026-04-22 19:49:15 -04:00
hykocx 17e8961559 chore: bump version to 1.4.56 2026-04-22 19:48:28 -04:00
hykocx b60514aebc style(ui): wrap button children in span with size-aware min-height class 2026-04-22 19:48:23 -04:00
hykocx 5f93dda87d chore: bump version to 1.4.55 2026-04-22 19:46:36 -04:00
hykocx 354cac3b27 style(ui): increase button md size text from 12px to 13px 2026-04-22 19:46:34 -04:00
hykocx 4c5346fc5c chore: bump version to 1.4.54 2026-04-22 19:45:59 -04:00
hykocx 18f1fcdbd0 style(ui): change default button size from sm to md and remove explicit size="sm" props 2026-04-22 19:45:56 -04:00
hykocx 482578af84 chore: bump version to 1.4.53 2026-04-22 19:42:42 -04:00
hykocx ad7b907e57 refactor(ui): apply size class directly to icon component instead of wrapper span 2026-04-22 19:42:39 -04:00
hykocx 324f81bb50 chore: bump version to 1.4.52 2026-04-22 19:42:01 -04:00
hykocx 0dc6092780 refactor(ui): change Button icon prop to accept component reference instead of JSX element 2026-04-22 19:41:57 -04:00
hykocx d0db9331f1 chore: bump version to 1.4.51 2026-04-22 19:40:33 -04:00
hykocx 9ca3e0a83b refactor(admin): pass icon components as references instead of JSX elements 2026-04-22 19:40:28 -04:00
hykocx 68d427f2a2 chore: bump version to 1.4.50 2026-04-22 19:39:14 -04:00
hykocx 173cea270f style(ui): add leading-none to button base class 2026-04-22 19:39:10 -04:00
hykocx 5398e2ac1d chore: bump version to 1.4.49 2026-04-22 19:38:05 -04:00
hykocx e2dd60843f style(ui): add icon-only button sizing and fix action column alignment 2026-04-22 19:38:02 -04:00
hykocx 602b4f13cf chore: bump version to 1.4.48 2026-04-22 19:35:42 -04:00
hykocx 0fd01d2b68 fix(ui): add right alignment support for table columns using align prop 2026-04-22 19:35:39 -04:00
hykocx 5f7288974f chore: bump version to 1.4.47 2026-04-22 19:33:43 -04:00
hykocx 52f8ea2b13 style(ui): update dark mode background color from #090909 to #0B0B0B across auth and admin components 2026-04-22 19:33:40 -04:00
hykocx 7b76763741 chore: bump version to 1.4.46 2026-04-22 19:32:17 -04:00
hykocx 869afbcb85 style(ui): update dark mode background colors to use #090909 instead of neutral variants 2026-04-22 19:32:14 -04:00
hykocx cfbfbaaa3b chore: bump version to 1.4.45 2026-04-22 19:30:08 -04:00
hykocx 312c8e0239 fix(admin): remove active section override on collapsed state 2026-04-22 19:30:05 -04:00
hykocx 20612e3b48 chore: bump version to 1.4.44 2026-04-22 19:22:17 -04:00
hykocx 456b1746bd feat(admin): extract AdminLayout as a separate server component 2026-04-22 19:22:14 -04:00
hykocx cc4527d488 feat(admin): persist sidebar collapsed sections state in sessionStorage 2026-04-22 19:13:00 -04:00
hykocx f3f7c7a011 chore: bump version to 1.4.43 2026-04-22 19:10:21 -04:00
hykocx 35cfa8b51a fix(admin): collapse inactive sidebar sections by default and fix toggle logic 2026-04-22 19:10:17 -04:00
hykocx fa43e2c034 chore: bump version to 1.4.42 2026-04-22 19:07:21 -04:00
hykocx 6cff764e2f fix(admin): keep active section expanded in sidebar without useEffect 2026-04-22 19:07:18 -04:00
hykocx fd4c313bb8 chore: bump version to 1.4.41 2026-04-22 19:03:46 -04:00
hykocx f45d295961 refactor(admin): derive pageTitle from state instead of inline in breadcrumb function 2026-04-22 19:03:43 -04:00
hykocx 9bbfd4d319 chore: bump version to 1.4.40 2026-04-22 19:00:35 -04:00
hykocx 4e56882dd4 refactor(admin): replace AdminPageTitleContext with direct registry lookup for breadcrumbs 2026-04-22 19:00:32 -04:00
hykocx 8eb508574b chore: bump version to 1.4.39 2026-04-22 18:54:52 -04:00
hykocx 0317a83ec6 refactor(admin): simplify sidebar toggle by removing router navigation and active section guard 2026-04-22 18:54:49 -04:00
hykocx f9d4dce892 chore: bump version to 1.4.38 2026-04-22 17:58:21 -04:00
hykocx b3e88989de feat(admin): sync page title to admin shell via context 2026-04-22 17:58:13 -04:00
hykocx 3edc7267d8 chore: bump version to 1.4.37 2026-04-22 17:58:07 -04:00
hykocx 843f992b1f feat(admin): replace prop-based page title with context provider 2026-04-22 17:58:04 -04:00
hykocx 18e0cb3486 chore: bump version to 1.4.36 2026-04-22 17:52:51 -04:00
hykocx 7c92f34245 feat(admin): pass current page title from server to AdminTop via props 2026-04-22 17:52:48 -04:00
hykocx c41172a397 chore: bump version to 1.4.35 2026-04-22 17:46:57 -04:00
hykocx fa40565686 refactor(admin): migrate page titles from static map to self-registering pages 2026-04-22 17:46:53 -04:00
hykocx 94aaeb241b refactor(admin): extract page titles into a shared constants file 2026-04-22 17:43:23 -04:00
hykocx 51adae4e0f chore: bump version to 1.4.34 2026-04-22 17:39:28 -04:00
hykocx 5feceb09f2 refactor(admin): use registry titles for breadcrumb labels 2026-04-22 17:39:24 -04:00
hykocx aeab10c1d2 chore: bump version to 1.4.33 2026-04-22 17:36:32 -04:00
hykocx 5fec68c1fc fix(admin): add profile breadcrumb and fix badge dark mode styling 2026-04-22 17:36:29 -04:00
hykocx 86bd97f50c chore: bump version to 1.4.32 2026-04-22 17:30:52 -04:00
hykocx e5df0e102b style(ui): replace dark hover bg from neutral-950 to neutral-900 and use RelativeDate component in UsersPage 2026-04-22 17:30:48 -04:00
hykocx 52443591b2 feat(ui): translate Badge component labels to French 2026-04-22 17:26:58 -04:00
hykocx 2757f2edc5 chore: bump version to 1.4.31 2026-04-22 17:24:02 -04:00
hykocx 84f03a2d79 fix(admin): always render breadcrumb container to keep user menu pinned right 2026-04-22 17:23:59 -04:00
hykocx 5a8d1bb0ff chore: bump version to 1.4.30 2026-04-22 17:22:36 -04:00
hykocx 3bd2e4bfba feat(admin): replace app name with dashboard icon in breadcrumb root 2026-04-22 17:22:33 -04:00
hykocx cd91d40091 chore: bump version to 1.4.29 2026-04-22 17:18:36 -04:00
hykocx 794e0866ec chore(deps): upgrade node-cron from v3 to v4 and update cron scheduling options 2026-04-22 17:18:24 -04:00
hykocx 9bb1b398c7 chore: bump version to 1.4.28 2026-04-22 17:15:55 -04:00
hykocx 0f42f202a2 refactor(ui): move hover background styles from tr to td using group utility 2026-04-22 17:15:51 -04:00
hykocx 5bd40dd19d chore: bump version to 1.4.27 2026-04-22 17:10:56 -04:00
hykocx 0e9f70094d style(ui): replace dark mode opacity-based colors with explicit hex values 2026-04-22 17:10:53 -04:00
hykocx bc63618190 chore: bump version to 1.4.26 2026-04-22 17:00:04 -04:00
hykocx bf33754e74 style(ColorPicker): increase custom swatch column width from w-14 to w-18 2026-04-22 17:00:00 -04:00
hykocx 43751ed0b5 chore: bump version to 1.4.25 2026-04-22 16:59:05 -04:00
hykocx 2c781d4223 style(ColorPicker): update preset color palette and improve layout responsiveness 2026-04-22 16:59:01 -04:00
hykocx ccb35c6420 chore: bump version to 1.4.24 2026-04-22 16:38:03 -04:00
hykocx 60b3022a23 feat(ui): expand color picker preset grid from 9×2 to 10×3 with updated colors 2026-04-22 16:37:59 -04:00
hykocx 102d7acd40 chore: bump version to 1.4.23 2026-04-22 16:34:18 -04:00
hykocx 2c02890216 refactor(ColorPicker): redesign layout with big custom swatch and extracted Checkmark component 2026-04-22 16:34:10 -04:00
hykocx dbadd30837 chore: bump version to 1.4.22 2026-04-22 16:30:46 -04:00
hykocx 866da94f06 feat(ui): add ColorPicker component and replace native color input in RoleEditModal 2026-04-22 16:30:41 -04:00
hykocx 3035d70d59 chore: bump version to 1.4.21 2026-04-22 16:25:20 -04:00
hykocx db39e7b36a style(ui): adjust font sizes and fix switch toggle alignment 2026-04-22 16:25:17 -04:00
hykocx 681de18d93 chore: bump version to 1.4.20 2026-04-22 16:20:47 -04:00
hykocx 71fe05bd2b feat(users): add description field to permission definitions 2026-04-22 16:20:43 -04:00
hykocx da5a1c6587 chore: bump version to 1.4.19 2026-04-22 16:15:46 -04:00
hykocx f54b2640ad refactor(admin): replace parameterized routes with modal-based editing for users and roles 2026-04-22 16:15:43 -04:00
hykocx 16edecdc56 chore: bump version to 1.4.18 2026-04-22 16:07:50 -04:00
hykocx 1bdabd8417 style(ui): update semantic color variants to use opacity-based tailwind classes 2026-04-22 16:07:46 -04:00
hykocx 072ed33ca3 chore: bump version to 1.4.17 2026-04-22 16:04:06 -04:00
hykocx f8ef884b63 style(ui): update color palette to use darker sh 2026-04-22 16:04:03 -04:00
hykocx 32d6fca3ce chore: bump version to 1.4.16 2026-04-22 15:57:12 -04:00
hykocx 5ab789667c fix(admin): update sidebar and breadcrumb links to point to /admin/dashboard 2026-04-22 15:57:09 -04:00
hykocx 4b0de4c724 chore: bump version to 1.4.15 2026-04-22 15:51:04 -04:00
hykocx 0c860d9fe5 feat(admin): replace single page name with dynamic breadcrumb navigation in AdminTop 2026-04-22 15:51:01 -04:00
hykocx cc5c556396 chore: bump version to 1.4.14 2026-04-22 15:38:43 -04:00
hykocx 118f399208 refactor(admin): move back button and action to a flex container on the right side of AdminHeader 2026-04-22 15:38:39 -04:00
hykocx 868e07b394 chore: bump version to 1.4.13 2026-04-22 15:36:52 -04:00
hykocx fe4ca228cc refactor(admin): simplify AdminHeader component by removing inline logic 2026-04-22 15:36:48 -04:00
hykocx e70499fa36 chore: bump version to 1.4.12 2026-04-22 15:28:03 -04:00
hykocx 43fd0fb14f style(ui): reduce badge border opacity to 30% in light mode 2026-04-22 15:27:59 -04:00
hykocx 4bff03bfe6 chore: bump version to 1.4.11 2026-04-22 15:27:36 -04:00
hykocx 3ead956571 style(Badge): update light mode border colors from 200 to 800 shade for all variants 2026-04-22 15:27:32 -04:00
hykocx 8e8db997d6 chore: bump version to 1.4.10 2026-04-22 15:26:55 -04:00
hykocx 4ec1a238f9 style(ui): update Badge variant colors to use consistent opacity-based backgrounds 2026-04-22 15:26:52 -04:00
hykocx 7633b151de chore: bump version to 1.4.9 2026-04-22 15:24:25 -04:00
hykocx 834385fd56 style(Badge): update dark mode color shades from 500 to 600 variants 2026-04-22 15:24:21 -04:00
hykocx ab41ba9df5 chore: bump version to 1.4.8 2026-04-22 15:14:10 -04:00
hykocx 18270540cc refactor(admin): replace inline avatar logic with shared UserAvatar component 2026-04-22 15:14:07 -04:00
hykocx 92265f450e chore: bump version to 1.4.7 2026-04-22 15:08:49 -04:00
hykocx 1613bd5275 feat(admin): add dynamic role color support for user badges 2026-04-22 15:08:46 -04:00
hykocx 13410a6dd9 chore: bump version to 1.4.6 2026-04-22 15:02:13 -04:00
hykocx 96c8352dcf refactor(ui): move system badge to dedicated column and update Badge styles 2026-04-22 15:02:10 -04:00
hykocx e00ec4bddb chore: bump version to 1.4.5 2026-04-22 14:55:23 -04:00
hykocx 7ca818da5a fix(ui): fix missing space between rounded-lg and transition-all in Button class 2026-04-22 14:55:19 -04:00
hykocx 7c85e54b26 chore: bump version to 1.4.4 2026-04-22 14:46:33 -04:00
hykocx c3eeb3ca73 style(ui): update border radius and spacing across shared components 2026-04-22 14:46:30 -04:00
hykocx b21f0e6b67 chore: bump version to 1.4.3 2026-04-22 14:41:57 -04:00
hykocx f5c8dc842d refactor(auth): make COOKIE_NAME a private module-level constant 2026-04-22 14:41:53 -04:00
hykocx fd98f87e5a chore: bump version to 1.4.2 2026-04-22 14:40:17 -04:00
hykocx d64423c1ad docs(tsup): update build config comments and fix jsx import extensions 2026-04-22 14:40:09 -04:00
hykocx 0106bc4ea0 feat(core)!: introduce runtime extension registry and flat module conventions
BREAKING CHANGE: sup config now derives entries from package.json#exports and a server/client glob instead of manual lists; module structure follows flat + barrel convention with .server.js/.client.js runtime suffixes
2026-04-22 14:13:30 -04:00
hykocx 61388f04a6 refactor: reorganize feature modules with consistent naming conventions and flattened structure 2026-04-22 14:12:15 -04:00
hykocx 256df9102c chore: bump version to 1.3.47 2026-04-22 13:28:33 -04:00
hykocx 40fc8a21e4 chore(config): replace auth dashboard entry with admin dashboard widget entries in tsup config 2026-04-22 13:28:29 -04:00
hykocx c89f1cbbe9 chore: bump version to 1.3.46 2026-04-22 13:27:08 -04:00
hykocx 12f66a2115 feat(admin): add core users widget and reorganize dashboard widget registration 2026-04-22 13:27:04 -04:00
hykocx 692f639cb5 chore: bump version to 1.3.45 2026-04-22 13:03:10 -04:00
hykocx cdcd704d84 style(admin): refine header user menu typography and layout 2026-04-22 13:03:07 -04:00
hykocx 7cabdff799 chore: bump version to 1.3.44 2026-04-22 12:58:32 -04:00
hykocx e00d6b3c42 refactor(admin): replace inline SVGs with icon components and fix icon imports 2026-04-22 12:58:28 -04:00
hykocx 17065292b8 chore: bump version to 1.3.43 2026-04-22 12:50:24 -04:00
hykocx c08c0a622f style(admin): update dropdown menu layout and remove user info section 2026-04-22 12:50:20 -04:00
hykocx 7bd5aedb3b style(admin): update user avatar and name styling in AdminHeader 2026-04-22 12:46:29 -04:00
hykocx 83672e9325 style(admin): standardize subtitle text size and color across admin pages 2026-04-22 12:43:20 -04:00
hykocx dade434bd7 chore: bump version to 1.3.42 2026-04-22 12:38:29 -04:00
hykocx 8752ce168e style(ui): update sidebar width to 230px and refactor StatCard layout 2026-04-22 12:38:26 -04:00
hykocx 0eeeb9542f chore: bump version to 1.3.41 2026-04-22 12:33:57 -04:00
hykocx f45322736f style(admin): move font-normal from base to inactive class in AdminSidebar 2026-04-22 12:33:54 -04:00
hykocx 1bc8681b9e chore: bump version to 1.3.40 2026-04-22 12:10:25 -04:00
hykocx 836134f701 refactor(admin): rename sidebar CSS class variables for clarity 2026-04-22 12:10:22 -04:00
hykocx d8313cfeda chore: bump version to 1.3.39 2026-04-22 12:05:49 -04:00
hykocx 384eadf7b7 style(admin): update sidebar width to 240px and refine nav item styles 2026-04-22 12:05:46 -04:00
hykocx 1a46254221 chore(config): add git push step to release script 2026-04-22 12:00:56 -04:00
hykocx a85b3f1c1e chore: bump version to 1.3.38 2026-04-22 12:00:18 -04:00
hykocx 59666807ae chore(scripts): add release script for automated version bump and publish 2026-04-22 12:00:15 -04:00
hykocx e8d87b0a8f style(admin): adjust sidebar nav item spacing and remove sub-item icons 2026-04-22 11:57:25 -04:00
hykocx 408653452c style(admin): add active parent style for expanded sidebar sections 2026-04-22 11:50:36 -04:00
hykocx 066b1eac0a chore: bump version to 1.3.37 2026-04-22 11:49:37 -04:00
hykocx 2420a2cb1d fix(admin): correct active state condition for collapsed sidebar sections 2026-04-22 11:49:24 -04:00
hykocx 99ef8b2326 chore: bump version to 1.3.36 2026-04-22 11:47:05 -04:00
hykocx d6506eab5a style(admin): update layout font, max-width, and sidebar active item styles 2026-04-22 11:46:48 -04:00
hykocx 0111e4b548 chore(build): bump version to 1.3.35 and fix build:css font copy command 2026-04-22 11:33:53 -04:00
hykocx d4e3b395e3 chore: bump version to 1.3.34 2026-04-22 11:31:10 -04:00
hykocx 138183f3a8 refactor(style): apply new design 2026-04-22 11:30:33 -04:00
hykocx 4d84669f9f chore: bump version to 1.3.33 2026-04-22 11:11:15 -04:00
hykocx 237a49a997 chore(build): bump version to 1.3.33 and include fonts in build output 2026-04-22 11:11:08 -04:00
hykocx e2d688c47a chore(release): bump version to 1.3.32 2026-04-22 11:09:30 -04:00
hykocx 345371d43c style(admin): reduce header height and simplify layout spacing and menu item focus styles 2026-04-22 11:09:12 -04:00
hykocx ba3b6239b1 docs: add project plan documentation in French 2026-04-22 10:49:14 -04:00
hykocx 88c04045d2 chore: bump version to 1.3.31 2026-04-19 17:16:02 -04:00
hykocx 6b71818531 feat(admin): navigate to first section item when expanding collapsed sidebar section 2026-04-19 17:15:52 -04:00
hykocx 2b9a33f37c chore: bump version to 1.3.30 2026-04-19 17:12:19 -04:00
hykocx f2ddb0f413 style(ui): increase pagination container padding 2026-04-19 17:12:09 -04:00
hykocx 9b44b3a6af chore: bump version to 1.3.29 2026-04-19 17:11:21 -04:00
hykocx e881f04ca2 style(ui): reduce pagination size and hide nav buttons on single page 2026-04-19 17:10:55 -04:00
hykocx a6a681e358 chore: bump version to 1.3.28 2026-04-19 17:04:39 -04:00
hykocx f387511c40 refactor(admin): remove group toggle feature and simplify permissions UI in RoleEditPage 2026-04-19 17:04:29 -04:00
hykocx d855485ef1 chore(config): bump version to 1.3.27 2026-04-19 16:57:04 -04:00
hykocx 3a339d07da chore: bump version to 1.3.27 2026-04-19 16:56:58 -04:00
hykocx dcd4d9b9f9 refactor(admin): replace raw form elements with shared Input, Textarea, and Switch components in RoleEditPage 2026-04-19 16:56:50 -04:00
hykocx 10660bedf5 chore(release): bump version to 1.3.26 2026-04-19 16:42:56 -04:00
hykocx f08376d979 feat(users): refactor users system 2026-04-19 16:42:33 -04:00
hykocx af8da2aa86 docs(storage): add readme for the storage module 2026-04-19 16:09:31 -04:00
hykocx 5132d0b52a chore: bump version to 1.3.25 2026-04-19 16:06:51 -04:00
hykocx 692a014dd8 refactor(storage): replace configureStorageApi with additive registration pattern 2026-04-19 16:06:32 -04:00
hykocx b49cddece3 docs(assets): update git banner image 2026-04-15 20:55:12 -04:00
hykocx 5325b80c05 docs(TODO): mark theme task as done and add storage permissions task 2026-04-15 20:53:46 -04:00
hykocx 962d4c5008 chore(init): remove configureStorageApi initialization from zen setup 2026-04-15 20:52:33 -04:00
hykocx fc7d4ffe1f chore: bump version to 1.3.24 2026-04-15 20:51:09 -04:00
hykocx 91dff122c4 feat(storage): configure storage API with default access policies during initialization 2026-04-15 20:50:57 -04:00
hykocx c0a62fe87a chore: bump version to 1.3.23 2026-04-15 20:46:34 -04:00
hykocx 0eee13542d style(ui): remove hover translate-y animation from StatCard component 2026-04-15 20:46:26 -04:00
hykocx 1c6eb0c818 chore: bump version to 1.3.22 2026-04-15 20:43:21 -04:00
hykocx 41edccc1a3 refactor(admin): replace static dashboard stats with dynamic widget registry 2026-04-15 20:43:10 -04:00
hykocx 371a69c499 chore: bump version to 1.3.21 2026-04-15 19:58:02 -04:00
hykocx 73a8639324 fix(admin): prevent menu from closing when toggling theme in header dropdown 2026-04-15 19:57:46 -04:00
hykocx c7018848a1 chore: bump version to 1.3.20 2026-04-15 19:55:54 -04:00
hykocx 1d64ffd6f5 refactor(admin): replace ThemeToggle component with inline theme hook in AdminHeader 2026-04-15 19:55:38 -04:00
hykocx 8b8accdfce chore(release): bump version to 1.3.19 2026-04-15 19:47:48 -04:00
hykocx 44570eb773 refactor(storage): unify storage env vars by removing B2-specific prefixes 2026-04-15 19:47:34 -04:00
hykocx 2f5cf9fe22 chore: bump version to 1.3.18 2026-04-15 17:50:57 -04:00
hykocx a3cb55814f feat: extract ThemeWatcher component for system theme detection 2026-04-15 17:50:43 -04:00
hykocx 2b79abb351 chore: bump version to 1.3.17 and add themes export 2026-04-15 17:06:41 -04:00
hykocx 0d940e3997 refactor: extract theme logic into shared core module 2026-04-15 17:06:37 -04:00
hykocx e1a7815b76 docs: add TODO.md with planned features roadmap 2026-04-14 20:20:25 -04:00
hykocx be064011b3 chore: bump version to 1.3.16 2026-04-14 20:15:45 -04:00
hykocx 23ef354224 chore: bump version to 1.3.16 2026-04-14 20:15:37 -04:00
hykocx 6cd6ce6f6f build: set esbuildOptions outbase to 'src' in tsup config
Set `options.outbase = 'src'` in the esbuildOptions callback to ensure
output files preserve the correct directory structure relative to the
`src` folder, preventing path flattening during the build process.
2026-04-14 20:15:30 -04:00
hykocx 240bfd1ff1 chore: bump version from 1.3.14 to 1.3.15 2026-04-14 20:01:33 -04:00
hykocx 9cb761adbd chore: include .env.example in published package files 2026-04-14 20:01:22 -04:00
hykocx e1593ce0bf chore: bump version to 1.3.14 and update zen-db bin path
- Increment package version from 1.3.13 to 1.3.14
- Update `zen-db` binary path from `dist/cli/database.js`
  to `dist/core/database/cli.js` to reflect new file structure
2026-04-14 19:58:31 -04:00
hykocx 7ef37e3ebd refactor: reorganize package exports under namespaced paths
- Prefix feature exports with `features/` (auth, admin, provider)
- Prefix shared exports with `shared/` (components, icons, lib, config, logger, rate-limit)
- Add new explicit exports for `shared/logger`, `shared/config`, and `shared/rate-limit`
- Update internal imports to use package self-referencing (`@zen/core/shared/*`) instead of relative paths
2026-04-14 19:57:48 -04:00
hykocx cee521b0e4 refactor(auth): replace relative imports with @zen/core alias
Update BaseLayout imports in auth email templates to use the
`@zen/core/email/templates` module alias instead of relative paths,
improving maintainability and consistency across the codebase.
2026-04-14 19:35:19 -04:00
hykocx 9584b23ed7 fix: correct import paths and remove DatabaseError export
- Fix BaseLayout import paths in auth email templates from
  `../../core/...` to `../../../core/...` to match correct
  directory depth
- Remove unused `DatabaseError` from db.js exports
2026-04-14 19:31:00 -04:00
hykocx 6a5f43d50e fix: update database CLI entry point path
Move the database CLI entry point from `src/cli/database.js` to
`src/core/database/cli.js` to better reflect its location within
the core database module. Update both the `package.json` bin path
and `tsup.config.js` build entry accordingly.
2026-04-14 19:29:07 -04:00
hykocx 91c86172e4 fix: update database CLI entry point path
Move the database CLI from `src/cli/database.js` to
`src/core/database/cli.js` to better reflect its association
with the database module. Update both the `package.json` bin
path and `tsup.config.js` entry points accordingly.
2026-04-14 19:29:02 -04:00
hykocx 3131df2b71 refactor: remove module system integration from admin and CLI
Removes all module-related logic from the admin dashboard, CLI database
initialization, and AdminPages component:

- Drop `initModules` call from `db init` CLI command and simplify the
  completion message to only reflect core feature tables
- Remove `getModuleDashboardStats` and module page routing from admin
  stats actions and update usage documentation accordingly
- Simplify `AdminPagesClient` to remove module page loading, lazy
  components, and module-specific props (`moduleStats`, `modulePageInfo`,
  `routeInfo`, `enabledModules`)
2026-04-14 19:26:48 -04:00
hykocx 242ea69664 feat(storage): refactor storage config and remove module registry
Introduce a dedicated `storage-config.js` for registering public
prefixes and access policies via `configureStorageApi()`, replacing the
previous `getAllStoragePublicPrefixes` / `getAllStorageAccessPolicies`
imports from the module registry.

Remove `getAllApiRoutes()` from the router so module-level routes are no
longer auto-collected; feature routes must now be registered explicitly
via `registerFeatureRoutes()` during `initializeZen()`.

Update `.env.example` to document separate `ZEN_STORAGE_PROVIDER`,
`ZEN_STORAGE_B2_*` variables for Backblaze B2 alongside the existing
Cloudflare R2 variables, making provider selection explicit.

Clean up admin navigation and page components to drop module-injected
nav entries, keeping only core and system sections.
2026-04-14 17:43:06 -04:00
hykocx 4a06cace5d refactor: remove modules system from core package
- Remove all module-related entry points from package.json exports
- Remove module source files from tsup build configuration
- Clean up external dependencies related to modules
- Update DEV.md to reflect modules removal from architecture
- Clarify package description to specify Next.js CMS
2026-04-14 17:27:04 -04:00
hykocx 936d21fdec docs/feat: add storage policies to discovery and refactor utils
- Add `storagePublicPrefixes` and `storageAccessPolicies` fields to
  both internal and external module config loading in discovery.js
- Add a module-level `MIME_TYPES` constant in storage/utils.js to
  avoid recreating the object on every `getMimeType` call
- Remove unused `validateImageDimensions` export from storage/index.js
- Remove dead `isFinite` check after `Math.min/max` in `getPresignedUrl`
  (result is always finite at that point)
- Remove unused `warn` import from storage/utils.js
- Add documentation rule in DEV.md: comments must always reflect the
  actual behavior of the code they describe
2026-04-14 17:23:43 -04:00
hykocx 2e348a1608 feat(storage): add configurable storage access policies
Replace hardcoded `users/` path-based access control with a
declarative `storageAccessPolicies` system defined per module via
`defineModule()`.

- Add `storageAccessPolicies` field to `defineModule()` defaults with
  support for `owner` and `admin` policy types
- Expose `getAllStorageAccessPolicies()` from the modules/storage layer
- Refactor `handleGetFile` in `storage/api.js` to resolve access
  control dynamically from registered policies instead of hardcoded
  path checks
- Add `ZEN_STORAGE_ENDPOINT` env var and update `.env.example` to
  support S3-compatible backends (Cloudflare R2, Backblaze B2)
- Document the env/doc sync convention in `DEV.md`
2026-04-14 17:09:27 -04:00
hykocx 67de464e1d refactor(pdf): simplify PDF module by removing redundant utilities
Remove helper functions (cmToPoints, inchesToPoints, mmToPoints,
createElement, PAGE_SIZES) and consolidate re-exports from
@react-pdf/renderer into a single export statement. Retain only
the getFilename utility and streamline the module to reduce
unnecessary abstraction over the underlying library.
2026-04-13 18:50:13 -04:00
hykocx dd6eda3a8a refactor(payments): simplify Stripe module with singleton and static imports
- Replace dynamic `import('stripe')` with static import for clarity
- Introduce singleton pattern for Stripe instance to avoid re-initialization
- Convert `getStripe()` from async to sync function
- Remove redundant JSDoc comments to reduce verbosity
- Remove `paymentMethodTypes` option from `createCheckoutSession`
- Remove default export of `stripe` instance from payments index
- Add webhook signature verification and idempotency key helpers
- Add customer and subscription management utilities
2026-04-13 18:42:48 -04:00
hykocx 87a04db04b feat(email): refactor email module and improve config handling
- Simplify `sendEmail` by extracting `resolveFrom` and `buildPayload` helpers
- Remove `sendAuthEmail` and `sendAppEmail` exports, keeping only `sendEmail` and `sendBatchEmails`
- Replace hardcoded fallback sender with env-based validation (throws if missing)
- Update `BaseLayout` to resolve `supportEmail` from `ZEN_SUPPORT_EMAIL` env var instead of hardcoded default
- Conditionally render support section only when a support email is available
- Remove verbose JSDoc comments and reduce overall code verbosity
2026-04-13 18:37:06 -04:00
hykocx 59fce3cd91 refactor(api): update README and refactor api route registration
Restructure the core API to separate infrastructure routes from feature
routes. Key changes:

- Add `runtime.js` for global state: session resolver and feature route
  registry
- Add `file-response.js` for streaming file responses (storage endpoint)
- Remove feature routes (auth/users) from `core-routes.js`, keeping only
  true infrastructure routes (health, storage)
- Introduce `registerFeatureRoutes()` so features self-register during
  `initializeZen()` instead of being hardcoded in `core-routes.js`
- Add `UserFacingError` class to safely surface client-facing errors
  without leaking internal details
- Fix import path for `rateLimit.js` to use shared lib location
- Update README to reflect new two-step registration flow and clarify
  the role of `core-routes.js`
2026-04-13 17:20:14 -04:00
hykocx a3921a0b98 feat(database): refactor CLI, add column whitelist, and SSL config
- Add `ZEN_DB_SSL_DISABLED` env variable to allow disabling SSL for database connections
- Refactor database CLI to split init logic into `initFeatures` and `initModules` for modular table initialization, with graceful fallback when modules are absent
- Extract `printHelp` and `askConfirmation` helpers for cleaner CLI structure
- Ensure `closePool` is called on both success and error paths in CLI
- Add `filterAllowedColumns` utility in `crud.js` to enforce column whitelists, preventing mass-assignment of privileged fields (e.g. `role`, `email_verified`)
- Update drop command description from "auth tables" to "all tables"
2026-04-13 16:35:23 -04:00
hykocx 6521179e10 feat(cron): refactor cron utility with validation and metadata
- Add input validation for name, schedule expression, and handler
- Store full CronEntry metadata (handler, schedule, timezone, registeredAt)
  instead of raw job instance to support introspection
- Add JSDoc typedefs for CronEntry and improve all function docs
- Use globalThis symbol store to survive Next.js hot-reload
- Remove verbose per-run info logs to reduce noise
- Replace `||` with `??` for runOnInit default to handle falsy correctly
- Fix stop/stopAll to access `entry.job` from new storage structure
2026-04-13 15:30:17 -04:00
hykocx 060eb367d8 build(tsup): add @zen/core/api to external list and document rule
- Add `@zen/core/api` to the `external` array in `tsup.config.js` to
  prevent build failures caused by unresolved `dist/` imports at build time
- Document the externals rule in `docs/DEV.md`: any `@zen/core/*` import
  used in bundled module files must be declared as external, with an
  explanation of why and a code example to follow
2026-04-13 15:16:02 -04:00
hykocx df9378cae0 chore: bump version from 1.3.12 to 1.3.13 2026-04-13 15:13:43 -04:00
290 changed files with 24595 additions and 14376 deletions
+20 -4
View File
@@ -10,13 +10,23 @@ ZEN_CURRENCY=CAD
ZEN_CURRENCY_SYMBOL=$ ZEN_CURRENCY_SYMBOL=$
ZEN_SUPPORT_EMAIL=support@exemple.com ZEN_SUPPORT_EMAIL=support@exemple.com
# PROXY (activer si derrière un reverse proxy)
ZEN_TRUST_PROXY=false
# DATABASE # DATABASE
ZEN_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/postgres ZEN_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/postgres
ZEN_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev ZEN_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev
ZEN_DB_SSL_DISABLED=false
# STORAGE (Cloudflare R2 for now) # STORAGE
ZEN_STORAGE_BUCKET=my-bucket-name # Fournisseur : 'r2' (défaut) ou 'backblaze'
ZEN_STORAGE_REGION=your-account-id ZEN_STORAGE_PROVIDER=r2
# R2 endpoint : <accountId>.r2.cloudflarestorage.com
# Backblaze endpoint : s3.<region>.backblazeb2.com
# REGION optionnelle pour R2 (défaut : auto), obligatoire pour Backblaze
ZEN_STORAGE_ENDPOINT=
ZEN_STORAGE_REGION=
ZEN_STORAGE_BUCKET=
ZEN_STORAGE_ACCESS_KEY= ZEN_STORAGE_ACCESS_KEY=
ZEN_STORAGE_SECRET_KEY= ZEN_STORAGE_SECRET_KEY=
@@ -41,5 +51,11 @@ ZEN_PUBLIC_LOGO_WHITE=
ZEN_PUBLIC_LOGO_BLACK= ZEN_PUBLIC_LOGO_BLACK=
ZEN_PUBLIC_LOGO_URL= ZEN_PUBLIC_LOGO_URL=
# MEDIA
ZEN_MEDIA=false
# OTHERS # OTHERS
NEXT_TELEMETRY_DISABLED=1 NEXT_TELEMETRY_DISABLED=1
# DEVKIT (developer tools)
ZEN_DEVKIT=false
+11 -1
View File
@@ -1,3 +1,13 @@
# Claude Code Rules # Claude Code Rules
Always read [docs/DEV.md](docs/DEV.md) at the start of every conversation before doing any work in this project. Always read and respect [docs/DEV.md](docs/DEV.md) at the start of every conversation before doing any work in this project.
After every code change, update the relevant documentation. This includes:
- `docs/` for cross-cutting conventions, architecture decisions, and design rules
- co-located `README.md` files in `src/core/<module>/` and `src/features/<feature>/` for module-level behaviour
No task is complete until all impacted documentation is up to date.
## No whack-a-mole
Always fix the **root cause**, never the symptoms. If the same type of error appears in a second file after fixing the first, that's the signal the real fix is elsewhere — trace the import or call chain to understand why this code is reached in this context, then fix at that point.
+1 -1
View File
@@ -1,6 +1,6 @@
# ZEN # ZEN
Un CMS Next.js construit sur l'essentiel, rien de plus, rien de moins. Une plateforme multi-usage construite sur l'essentiel, rien de plus, rien de moins.
![banner](/assets/git-banner.png) ![banner](/assets/git-banner.png)
Binary file not shown.

Before

Width:  |  Height:  |  Size: 507 KiB

After

Width:  |  Height:  |  Size: 484 KiB

+285
View File
@@ -0,0 +1,285 @@
# ZEN — Design Language
Document de référence visuelle. Décrit l'apparence, le ressenti et les règles visuelles du produit. Valable pour toute reproduction, quelle que soit la technologie.
---
## Philosophie
ZEN est un outil de travail. Pas un produit marketing. L'interface disparaît pour laisser place au contenu.
**Un seul principe directeur : ne jamais ajouter un élément sans raison.**
Chaque couleur, bordure, espace ou texte supplémentaire doit justifier sa présence. Le doute se résout toujours en faveur de la suppression.
---
## Palette
### Base
Le produit est **noir et blanc**. Pas de couleurs de marque, pas de teintes bleues ou violettes dans les fonds. Le fond est blanc pur. Le texte est presque noir.
### Accents sémantiques
Quatre couleurs uniquement. Utilisées **avec grande parcimonie** — pour les statuts, les alertes, les deltas. Jamais comme couleur décorative. Jamais sur un fond.
| Couleur | Usage |
|---|---|
| **Bleu** | Information, liens, action principale |
| **Vert** | Succès, actif, delta positif |
| **Ambre** | Avertissement, en attente, brouillon |
| **Rouge** | Erreur, danger, suppression, delta négatif |
Chaque couleur existe en trois déclinaisons : la couleur elle-même (pour le texte), un fond très pâle, et une bordure légère. Ces trois valeurs forment toujours un ensemble — on ne mélange pas les déclinaisons de couleurs différentes.
### Ce qu'on n'utilise pas
- Dégradés
- Couleurs de fond dans la sidebar ou les sections (sauf hover/actif)
- Couleurs de marque personnalisées
- Transparence et flou (sauf l'overlay des modales)
---
## Typographie
**Police unique : IBM Plex Sans.** Pour les codes, IDs, montants et dates : IBM Plex Mono.
### Hiérarchie
| Rôle | Taille | Graisse | Notes |
|---|---|---|---|
| Titre de page | 20px | 600 | |
| Titre de section | 1420px | 600 | |
| Corps de texte | 13px | 400 | |
| Texte secondaire | 12px | 400 | Dans les tableaux, les listes |
| Labels / captions | 12px | 400500 | |
| Labels colonnes | 11px | 500 | Uppercase, lettre-espacement +0.04em |
| Codes / IDs / montants | 1113px | 400 | IBM Plex Mono |
**Taille minimale : 11px.** En dessous, on ne descend pas.
### Règles d'écriture
- **Sentence case partout.** "Tableau de bord", pas "TABLEAU DE BORD".
- Seule exception : les en-têtes de colonnes de tableau, en majuscules petits.
- Texte court et direct. Pas de phrase là où un mot suffit.
- Les montants financiers, les dates ISO et les identifiants sont toujours en monospace.
---
## Espace
Base : **4px**. Tout espacement est un multiple de 4.
| Valeur | Usage typique |
|---|---|
| 4px | Espacement interne minimal (gap entre icône et texte) |
| 8px | Padding compact, gap entre éléments proches |
| 12px | Padding interne des petits composants |
| 16px | Padding standard des cartes et panneaux |
| 24px | Espacement entre sections |
| 32px | Padding latéral du contenu principal |
| 4864px | Espacement entre blocs majeurs |
La densité est **moyenne**. Pas étouffant, pas aéré. Un admin est un outil qu'on utilise plusieurs heures par jour — la densité confortable prime sur l'esthétique aérée.
---
## Formes et bordures
### Rayon de bordure
| Contexte | Rayon |
|---|---|
| Badges, étiquettes, petits éléments | Complet |
| Boutons, champs de saisie | 8px |
| Modales, panneaux flottants importants, tableau, cartes | 12px |
| Avatars, indicateurs ronds | Cercle complet |
**12px est le rayon standard.** On ne mélange pas les rayons dans un même composant.
### Bordures
Toutes les séparations sont des **bordures 1px solid**. Pas d'ombres sur les cartes et surfaces statiques. Pas de lignes de 2px ou plus.
Les ombres existent uniquement pour les éléments qui **flottent au-dessus** de l'interface : menus déroulants, modales, toasts. Elles sont discrètes — juste assez pour indiquer la profondeur.
---
## Structure de l'admin
```
┌─────────────────────────────────────────────┐
│ Sidebar 230px │ Barre supérieure 48px │
│ ├─────────────────────────── │
│ Logo │ │
│ │ Zone de contenu │
│ Nav principale │ padding 28px / 32px │
│ │ │
│ ────────────── │ │
│ Paramètres │ │
└─────────────────────────────────────────────┘
```
- **Sidebar** : fixe, 230px. Fond blanc (`#ffffff`). Séparée par une bordure droite.
- **Barre supérieure** : fixe, 48px. Fond blanc. Fil d'Ariane à gauche, utilisateur à droite.
- **Contenu** : gris très pâle (`#fafafa` / `neutral-50`). Les cartes et tableaux ont un fond blanc, ce qui crée naturellement la séparation visuelle sans avoir besoin de bordures de section. Sur thème sombre le fond deviens noir est le boite sont grise.
---
## Navigation sidebar admin
### Structure
La sidebar fait **230px** de large, fixe. Fond blanc / noir complet en sombre. Séparée du contenu par une bordure droite `1px`.
Elle se compose de trois zones verticales :
1. **Logo** — hauteur 48px, aligné sur la barre supérieure. Séparé par une bordure basse.
2. **Navigation principale** — défile si nécessaire. Padding `8px` autour des items.
3. **Items épinglés en bas** — optionnels. Séparés par une bordure haute. Jamais dans la zone défilable.
### Items de navigation
Deux types d'items coexistent :
- **Lien direct** : section avec un seul item dont le nom est identique au titre de la section. S'affiche comme un lien simple, sans chevron.
- **Section accordéon** : section avec plusieurs items. Un bouton parent déploie ou replie les sous-items.
### États visuels
| État | Apparence |
|---|---|
| Inactif | Texte `neutral-500` (sombre : `neutral-400`), fond transparent |
| Hover | Fond `neutral-100` (sombre : `neutral-900`), texte `neutral-900` (sombre : blanc) |
| Actif replié | Fond `neutral-100` (sombre : `neutral-900`), texte noir (sombre : blanc), graisse `500` |
| Actif ouvert (parent) | Pas de fond, texte noir (sombre : blanc), graisse `500` — le fond actif est sur le sous-item |
| Sous-item actif | Fond `neutral-100` (sombre : `neutral-900`), texte noir (sombre : blanc), graisse `500` |
### Gabarit d'un item
- Padding : `7px` vertical, `10px` horizontal.
- Rayon : `8px`.
- Icône à gauche, `15×15px`, `flex-shrink-0`. Gap icônetexte : `8px`.
- Texte : `13px`.
- Transition : `120ms ease-out`.
- Badge numérique (optionnel) : à droite, fond rouge, texte blanc, `10px`, rayon `8px`.
### Sous-items
Pas d'icône. Indentation via `padding-left: 25px`. Même gabarit que les items parents.
### Accordéon
Le chevron (`12×12px`) est à l'extrême droite du bouton parent. Il pivote de `-90deg` quand la section est repliée, revient à `0deg` quand elle est ouverte. L'animation du conteneur : `max-height` + `opacity`, `120ms ease-out`.
Une section s'ouvre automatiquement au chargement si elle contient l'item actif courant. Toutes les autres sont repliées par défaut.
### Mobile
En dessous de `1024px`, la sidebar sort du flux et se positionne en fixe sur toute la hauteur. Elle est masquée par défaut (`translate-x: -100%`) et s'ouvre via un bouton externe. Un overlay semi-transparent (`black/50`) couvre le contenu derrière. Cliquer l'overlay ou naviguer vers un lien ferme la sidebar. Au redimensionnement au-delà de `1024px`, la sidebar mobile se ferme automatiquement.
---
## Boutons
Sept variantes :
| Variante | Apparence | Usage |
|---|---|---|
| **Primaire** | Fond `neutral-900`, texte blanc | Action principale de la page |
| **Secondaire** | Fond transparent, bordure `neutral-300`, texte `neutral-700` | Actions secondaires, annulation |
| **Fantôme** | Fond transparent, pas de bordure, texte `neutral-600` | Actions tertiaires |
| **Fantôme plein** | Identique au fantôme, sans focus ring | Boutons très discrets dans des zones déjà interactives |
| **Danger** | Fond rouge très pâle, bordure rouge pâle, texte rouge | Suppression, actions destructives |
| **Succès** | Fond vert très pâle, bordure verte pâle, texte vert | Confirmation, validation |
| **Avertissement** | Fond ambre très pâle, bordure ambre pâle, texte ambre | Alertes, actions risquées |
**Il ne doit jamais y avoir plus d'un bouton primaire par section ou en-tête de page.**
### Tailles
| Taille | Padding horizontal | Padding vertical | Texte |
|---|---|---|---|
| **sm** | 10px | 5px | 12px |
| **md** | 12px | 7px | 13px |
| **lg** | 14px | 9px | 14px |
La taille par défaut est `md`.
### Icônes et chargement
- Une icône peut se placer à gauche ou à droite du texte.
- Un bouton icône seule (sans texte) a un padding uniforme.
- L'état de chargement remplace le contenu par un spinner et désactive l'interaction.
- L'état désactivé : opacité 50 %, curseur interdit.
### Règles communes
- Rayon : 8px (`rounded-lg`).
- Transition : 120ms, `ease-out`.
- Focus : anneau fin de la couleur de la variante.
---
## Cartes et panneaux
- Fond blanc.
- Bordure 1px.
- Rayon 12px (rounded-xl).
- Padding interne 1620px.
- **Pas d'ombre.**
- En-tête interne séparé du contenu par une bordure horizontale.
- Pied de panneau (actions de formulaire) séparé par une bordure horizontale.
---
## Tableaux
- En-têtes de colonnes : texte 11px uppercase, couleur secondaire.
- Lignes : fond blanc. Hover : fond légèrement grisé.
- Bordure horizontale entre chaque ligne. Pas de bordure sur la dernière ligne.
- Données numériques, IDs, dates : IBM Plex Mono.
- Actions (modifier, supprimer) : à l'extrême droite, boutons icône discrets.
- Pas de fond alterné sur les lignes.
---
## Badges de statut
Petits éléments inline qui indiquent un état. Toujours composés de trois éléments : fond pâle de la couleur sémantique, bordure légère, texte de la couleur sémantique.
Rayon : full. Taille du texte : 11px.
---
## États interactifs
| État | Apparence |
|---|---|
| Hover | Fond légèrement plus sombre (45% de gris) |
| Actif / focus | Bordure de 1px devient la couleur du texte principal |
| Désactivé | Opacité 50%, curseur interdit |
| Chargement | Pas défini — à traiter au cas par cas |
Toutes les transitions durent **120ms**, courbe `ease-out`. Aucune animation décorative.
---
## Ce qu'on n'utilise jamais
- Dégradés de couleur sur les fonds ou les boutons
- Cartes avec une bordure colorée uniquement sur le côté gauche
- Ombres internes
- Texte en gras uniquement pour l'emphase décorative
- Icônes sans fonction claire
- Statistiques ou chiffres inventés pour remplir l'espace
- Couleurs de fond dans les sections de contenu
---
## Thème sombre
Le thème sombre est une inversion calibrée : les fonds passent au noir complet puis les zone par dessus sont noir-gris, les textes s'éclaircissent, les accents gagnent légèrement en luminosité pour rester lisibles. La structure, les espaces et les proportions restent identiques.
+87 -32
View File
@@ -4,19 +4,27 @@ Ce document couvre les conventions de code, les règles de sécurité et la proc
Pour les conventions de rédaction : [LANGUE.md](./dev/LANGUE.md) et [REDACTION.md](./dev/REDACTION.md). Pour les conventions de rédaction : [LANGUE.md](./dev/LANGUE.md) et [REDACTION.md](./dev/REDACTION.md).
Pour l'architecture partagée (modules, composants, icônes) : [ARCHITECTURE.md](./dev/ARCHITECTURE.md). Pour les décisions de design et l'identité visuelle : [DESIGN.md](./DESIGN.md).
Pour l'architecture partagée (composants, icônes) : [ARCHITECTURE.md](./dev/ARCHITECTURE.md).
Pour la procédure de publication du package : [PUBLICATION.md](./dev/PUBLICATION.md). Pour la procédure de publication du package : [PUBLICATION.md](./dev/PUBLICATION.md).
Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md). Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md).
> **Contexte projet** : les utilisateurs finaux créent leur projet via `npx @zen/start`, qui génère automatiquement une structure Next.js avec le CMS déjà intégré. Lors d'une assistance ou d'une revue, partez du principe que le projet cible est déjà structuré de cette façon. Pour la création de modules externes `@zen/module-*` : [MODULES.md](./MODULES.md).
> **Contexte projet** : les utilisateurs finaux créent leur projet via `npx @zen/start`, qui génère automatiquement une structure Next.js avec la plateforme déjà intégré. Lors d'une assistance ou d'une revue, partez du principe que le projet cible est déjà structuré de cette façon.
--- ---
## Standards de code ## Standards de code
### Principes généraux **On corrige la cause racine, jamais les symptômes.** Si le même type d'erreur réapparaît dans un deuxième fichier après avoir corrigé le premier, c'est le signe que le vrai fix est ailleurs. Remonter la chaîne d'imports ou d'appels jusqu'à comprendre *pourquoi* ce code est atteint dans ce contexte, puis corriger à cet endroit. Patcher les fichiers un par un (whack-a-mole) masque le problème et garantit qu'un troisième cas surgira.
**Les promesses ne s'ignorent pas.** Chaque `Promise` est `await`ée ou `.catch()`ée. Une promesse silencieuse qui échoue est un bug invisible.
**Les variables d'environnement et la documentation se mettent à jour avec le code.** Toute variable ajoutée, renommée ou supprimée doit être reflétée dans `.env.example`. Toute décision architecturale ou convention nouvelle doit être documentée dans le fichier `docs/` concerné.
**Une fonction, une responsabilité.** Si elle fait deux choses, c'est deux fonctions. Si elle ne tient pas sur un écran, la découper. **Une fonction, une responsabilité.** Si elle fait deux choses, c'est deux fonctions. Si elle ne tient pas sur un écran, la découper.
@@ -24,43 +32,90 @@ Pour les conventions de commit : [COMMITS.md](./dev/COMMITS.md).
**Les données entrantes sont suspectes.** On valide en entrée de fonction. On ne suppose pas que l'appelant a fait le travail. **Les données entrantes sont suspectes.** On valide en entrée de fonction. On ne suppose pas que l'appelant a fait le travail.
**Les promesses ne s'ignorent pas.** Chaque `Promise` est `await`ée ou `.catch()`ée. Une promesse silencieuse qui échoue est un bug invisible.
**La portée des variables est minimale.** On déclare au plus près de l'usage. Pas de variables réutilisées pour deux rôles différents. **La portée des variables est minimale.** On déclare au plus près de l'usage. Pas de variables réutilisées pour deux rôles différents.
**ESLint passe sans avertissement.** Un warning ignoré aujourd'hui est un bug non détecté demain. **ESLint passe sans avertissement.** Un warning ignoré aujourd'hui est un bug non détecté demain.
**Les commentaires reflètent toujours le comportement réel du code.** Un commentaire obsolète est pire qu'un commentaire absent — il induit en erreur. Quand on modifie une fonction, on met à jour son commentaire. Un commentaire qui contredit le code est un bug de documentation.
---
## Conventions d'arborescence
Une feature (`src/features/<nom>/` ou `src/core/<nom>/`) suit la règle **flat + un barrel** :
un `index.js` qui ré-exporte, des fichiers plats côte à côte pour l'implémentation. Pas de sous-dossier `lib/`, `middleware/`, `actions/` quand il ne contient qu'un ou deux fichiers — remonter directement au niveau du dossier feature.
Les sous-dossiers sont autorisés uniquement quand ils contiennent plusieurs fichiers du même rôle : `components/`, `pages/`, `templates/`, `widgets/`.
### Suffixes de runtime
Tout fichier épinglé à une frontière Next.js porte le suffixe dans son nom :
- `.server.js` → code serveur strict (peut importer `pg`, `fs`, etc.)
- `.client.js` → débute par `'use client'`
- pas de suffixe → module neutre, utilisable des deux côtés
- `actions.js` → débute par `'use server'` (server actions Next.js)
Ces suffixes ne sont pas cosmétiques : **le build les utilise comme source de vérité**. Le build compile l'intégralité de `src/` avec `bundle: false` — chaque fichier reste un module séparé, ce qui permet à Next.js de respecter les frontières RSC et `'use client'` sans que le bundler ne fusionne les modules.
**Tout fichier qui `import 'pg'`, `'fs'`, `'net'`, `'node:*'` doit porter `.server.js`.** Sans ce suffixe, le fichier est réputé neutre — un barrel client peut le ré-exporter par mégarde, et Turbopack/Webpack tracent la chaîne d'imports statiques jusqu'à `pg` dans le bundle browser. L'erreur `Module not found: Can't resolve 'dns'` côté client est typiquement causée par un fichier serveur sans suffixe atteint via un barrel mixte.
---
## Build et configuration tsup
`tsup.config.js` compile **tous les fichiers `.js` et `.jsx` de `src/`** avec `bundle: false`. Chaque fichier devient un module standalone dans `dist/` ; les imports relatifs sont préservés tels quels. La structure `dist/` reflète exactement `src/` grâce à `outbase: 'src'`.
- **Ajouter un module public = éditer seulement `package.json#exports`.** L'entry list se régénère au prochain build via un walk de `src/`.
- **Pas de bundling des fichiers internes** : les modules de registre (`registry.js`, etc.) sont des singletons — les bundler créerait une copie inline distincte et casserait le partage d'état entre les pages et les widgets.
- Les self-imports `@zen/core/*` sont générés automatiquement à partir des clés de `exports` et restent toujours dans la liste `external`.
- Les fichiers `.js` et `.jsx` sont tous traités comme du JSX via esbuild (`loader: { '.js': 'jsx' }`, `jsx: 'automatic'`) — pas besoin d'extension `.jsx` pour écrire du JSX.
---
## Étendre l'admin
L'admin utilise un **registre runtime** pour permettre aux projets consommateurs d'ajouter des widgets, des entrées de navigation, et des pages sans modifier le core.
> Pour distribuer ces extensions sous forme de package npm réutilisable plutôt que de les écrire en local, voir [MODULES.md](./MODULES.md) — chaque module `@zen/module-*` installé est auto-découvert et activé.
```js
// app/zen.extensions.js — projet consommateur
import {
registerWidget,
registerWidgetFetcher,
registerNavItem,
registerNavSection,
registerPage,
} from '@zen/core/features/admin';
import OrdersWidget from './admin/OrdersWidget';
import OrdersPage from './admin/OrdersPage';
import { countOrders } from './admin/orders.server';
registerWidgetFetcher('orders', async () => ({ total: await countOrders() }));
registerWidget({ id: 'orders', Component: OrdersWidget, order: 20 });
registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'ShoppingBag03Icon', order: 30 });
registerNavItem({ id: 'orders', label: 'Commandes', icon: 'ShoppingBag03Icon', href: '/admin/orders', sectionId: 'commerce', permission: 'orders.view' });
registerPage({ slug: 'orders', Component: OrdersPage, title: 'Commandes' });
```
L'item actif dans la sidebar et le fil d'Ariane sont calculés automatiquement à partir d'un `basePath` déduit de `href` (si `href` finit par `/list`, on retire le suffixe). Une convention `slug:edit` / `slug:new` permet de personnaliser les labels du breadcrumb sur les sous-routes — voir [src/features/admin/README.md](../src/features/admin/README.md#item-actif-et-fil-dariane) pour le détail.
```js
// app/layout.js — un seul import suffit ; les side effects enregistrent tout.
import './zen.extensions';
```
--- ---
## Sécurité ## Sécurité
### Données entrantes **Données entrantes** : toute donnée externe est considérée malveillante par défaut. On valide côté serveur uniquement.
Toute donnée externe est considérée malveillante par défaut : requêtes HTTP, données formulaire, réponses d'API tierce, contenu de fichier. On valide côté serveur. Le client ne contrôle rien de critique. **Base de données** : uniquement des requêtes paramétrées — jamais de SQL construit par concaténation de chaînes.
### Base de données **Secrets** : aucun token, clé API ou mot de passe dans le code. Tout passe par des variables d'environnement, jamais commitées.
On n'écrit jamais de SQL par concaténation de chaînes. Uniquement des requêtes paramétrées : **Erreurs exposées** : pas de stack trace, nom de table ou requête SQL retournés au client. On log côté serveur, on renvoie un message générique côté client.
```ts
// ✓
await pool.query('SELECT * FROM users WHERE id = $1', [userId])
// ✗
await pool.query(`SELECT * FROM users WHERE id = '${userId}'`)
```
### Secrets
Aucun token, clé API ou mot de passe dans le code. Tout passe par des variables d'environnement. Les clés Stripe, les tokens Resend et les credentials PostgreSQL ne sont jamais commités.
```bash
# .env.local — jamais dans git
DATABASE_URL=...
STRIPE_SECRET_KEY=...
RESEND_API_KEY=...
```
### Erreurs exposées
Les messages d'erreur retournés à l'utilisateur ne contiennent pas de détails internes : pas de stack trace, pas de nom de table, pas de requête SQL. On log côté serveur, on renvoie un message générique côté client.
+501
View File
@@ -0,0 +1,501 @@
# Modules externes `@zen/module-*`
Un **module** est un package npm distinct qui ajoute des fonctionnalités à un projet construit avec `@zen/core` — sans aucune modification de code dans le projet consommateur.
```bash
npm install @zen/module-billing
# ajouter les variables d'env documentées dans le README du module
npx zen-db init # crée les tables du module et seed ses permissions
npm run dev # tout est câblé : pages admin, sidebar, widgets, API, /zen/<module>/...
```
Aucun fichier de configuration manuelle. La plateforme découvre les modules par scan des dépendances `package.json`.
---
## Découverte
Les modules sont activés via deux **manifestes statiques** générés par la CLI `zen-modules sync` dans le projet consommateur :
- `app/.zen/modules.generated.js` — manifeste serveur (importé par `instrumentation.js`).
- `app/.zen/modules.client.js` — manifeste client (`'use client'`, exporte un Component à rendre dans `app/layout.js`).
Le manifeste serveur peuple le registre côté Node via le top-level await à l'import. Le manifeste client exporte `ZenModulesClient` — un Component React — que `app/layout.js` doit **rendre** dans son tree (pas seulement importer). Sous Next.js 15+/Turbopack, un `import` purement side-effect d'un fichier `'use client'` orphelin (sans Component utilisé) inclut bien le fichier dans le bundle browser mais n'en exécute pas le code top-level — `registerPage()` / `registerWidget()` ne tourneraient jamais côté client. Rendre le Component force l'exécution.
```js
// app/.zen/modules.generated.js — AUTO-GÉNÉRÉ (serveur)
import * as m0_zen_module_posts from '@zen/module-posts';
export const modules = [
{ name: '@zen/module-posts', exports: m0_zen_module_posts },
];
await Promise.all(modules.map(m => m.exports.register?.()));
```
```js
// app/.zen/modules.client.js — AUTO-GÉNÉRÉ (client)
'use client';
import '@zen/module-posts/client';
export default function ZenModulesClient() {
return null;
}
```
```js
// app/layout.js — projet consommateur
import ZenModulesClient from './.zen/modules.client.js';
export default function RootLayout({ children }) {
return (
<html>
<body>
<ZenModulesClient />
{children}
</body>
</html>
);
}
```
Le manifeste client importe la **sous-entrée `./client`** de chaque module (et non son main entry). C'est essentiel : le main entry tire `createTables` / `registerApiRoutes` / la chaîne `register-server.js` qui dépend de `pg`, `fs`, `next/headers`, etc. — code serveur incompatible avec le bundle browser. Seuls les modules qui exposent `./client` dans leur `package.json#exports` sont inclus dans le manifeste client ; les modules purement back-end (API/DB) sont absents du bundle browser.
L'importation statique (le `import * as ...`) permet à Turbopack/Webpack d'analyser le graphe complet du module — JSX, `next/headers`, `next/navigation`, frontières `'use client'` — exactement comme pour le code de l'application elle-même.
### Critères de détection
`zen-modules sync` scanne `dependencies` + `devDependencies` du `package.json` du projet et inclut tout package matchant :
- **Préfixe officiel** : `@zen/module-*`
- **Préfixe non-scopé** : `zen-module-*`
- **Tiers** : tout package dont le `package.json` contient `"keywords": ["zen-module"]`
### Quand resync ?
Le template `@zen/start` câble la CLI dans :
```json
{
"scripts": {
"postinstall": "zen-modules sync",
"dev": "zen-modules sync && next dev",
"build": "zen-modules sync && next build"
}
}
```
`postinstall` couvre `npm install @zen/module-X`. Les hooks `dev`/`build` couvrent les retraits / changements de version qui ne déclenchent pas de re-install. La commande est idempotente — pas d'écriture si le contenu est identique.
`app/.zen/modules.generated.js` est gitignoré : régénéré localement, jamais commit.
---
## Forme d'un module
Le point d'entrée du package (`main` ou `exports["."]`) doit exporter :
```js
// @zen/module-blog/src/index.js (compilé vers dist/index.js par tsup)
export const manifest = {
name: '@zen/module-blog',
version: '1.0.0',
permissions: [
{ key: 'blog.view', name: 'Voir les billets', description: 'Consultation', group_name: 'Blog' },
{ key: 'blog.manage', name: 'Gérer les billets', description: 'CRUD', group_name: 'Blog' },
],
envVars: [
{ key: 'BLOG_UPLOAD_DIR', required: false, description: 'Répertoire des médias' },
],
};
export async function register() {
// Tous les enregistrements runtime se font ici (voir API ci-dessous).
await import('./register-server.js');
}
export { createTables, dropTables } from './db.js';
```
| Export | Type | Obligatoire |
|--------|------|-------------|
| `manifest` | objet (voir ci-dessous) | oui |
| `register` | `() => void \| Promise<void>` | oui |
| `createTables` | `async () => { created?: string[], skipped?: string[] }` | si le module a des tables |
| `dropTables` | `async () => void` | si le module a des tables |
### Manifest
| Champ | Type | Description |
|-------|------|-------------|
| `name` | `string` | Nom du package (utilisé comme identifiant unique). |
| `version` | `string` | Version du module (logguée au boot). |
| `permissions` | `Array` | Permissions ajoutées au catalogue. Auto-attribuées au rôle `admin` au prochain `zen-db init`. |
| `envVars` | `Array` | Variables d'env du module ; les `required: true` absentes émettent un warning au boot. |
---
## API d'enregistrement
Toutes ces fonctions s'utilisent depuis le hook `register()` du module.
### Permissions
Déclarées dans `manifest.permissions`. Le core les enregistre automatiquement avant le seed BD et les attribue au rôle `admin`. À la connexion, l'admin peut les distribuer à d'autres rôles via `/admin/roles`.
### Sidebar admin
```js
import { registerNavSection, registerNavItem } from '@zen/core/features/admin';
registerNavSection({ id: 'blog', title: 'Blog', icon: 'Notebook01Icon', order: 40 });
registerNavItem({
id: 'blog-posts',
label: 'Billets',
icon: 'Notebook01Icon',
href: '/admin/blog',
sectionId: 'blog',
permission: 'blog.view',
});
```
### Pages admin
```js
import { registerPage } from '@zen/core/features/admin';
import BlogAdminPage from './admin/BlogAdminPage.client.js';
registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });
```
Rendue sous `/admin/blog`.
Pour l'en-tête des pages admin (titre, description, bouton retour, action), utiliser `AdminHeader` importé depuis `@zen/core/features/admin/components` :
```js
'use client';
import { AdminHeader } from '@zen/core/features/admin/components';
// Page liste avec bouton d'action
export default function BlogListPage() {
return (
<div className="flex flex-col gap-6">
<AdminHeader
title="Billets"
description="Billets disponibles."
action={<Button onClick={...}>Créer un billet</Button>}
/>
{/* ... */}
</div>
);
}
// Page formulaire avec bouton retour
export default function BlogCreatePage() {
return (
<div className="flex flex-col gap-6">
<AdminHeader
title="Créer un billet"
description="Remplir les champs et enregistrer."
backHref="/admin/blog"
/>
{/* ... */}
</div>
);
}
```
| Prop | Type | Description |
|------|------|-------------|
| `title` | `string` | Titre de la page. |
| `description` | `string` | Sous-titre optionnel. |
| `backHref` | `string` | Si fourni, affiche un bouton « ← Retour » qui navigue vers cette URL. |
| `backLabel` | `string` | Label du bouton retour (défaut : `← Retour`). |
| `action` | `ReactNode` | Élément affiché à droite (bouton créer, etc.). |
### Médias attachés au contenu
Pour qu'un module puisse attacher des médias (image à la une, galerie, PDF) à ses propres ressources, utiliser `@zen/core/features/media` côté serveur et `@zen/core/features/media/picker` côté client.
```js
// côté serveur — au moment de sauvegarder un billet :
import { attachMedia, detachAllForSource } from '@zen/core/features/media';
await attachMedia({
mediaId: payload.featuredImageId,
sourceType: '@zen/module-blog:post',
sourceId: post.id,
field: 'featured_image',
});
// au moment de supprimer le billet : libérer toutes les références.
await detachAllForSource({ sourceType: '@zen/module-blog:post', sourceId: post.id });
```
```jsx
// côté client — sélecteur dans un formulaire :
'use client';
import MediaPicker from '@zen/core/features/media/picker';
<MediaPicker
isOpen={open}
onClose={() => setOpen(false)}
accept="image/*"
visibility="public"
onSelect={(media) => setFeaturedImage(media)}
/>
```
Le module Médias doit être activé (`ZEN_MEDIA=true`) dans le projet consommateur — sinon les permissions ne sont pas seedées et les API renvoient 403. Vérifier avec `isMediaEnabled()` côté serveur si on veut adapter l'UI.
### Widgets dashboard
```js
// côté serveur
import { registerWidgetFetcher } from '@zen/core/features/admin';
registerWidgetFetcher('blog-posts', async () => ({ count: await countPosts() }));
// côté client
'use client';
import { registerWidget } from '@zen/core/features/admin';
registerWidget({ id: 'blog-posts', Component: BlogWidget, order: 40, permission: 'blog.view' });
```
### Routes API
```js
import { registerApiRoutes } from '@zen/core/api';
import { defineApiRoutes, apiSuccess } from '@zen/core/api';
const routes = defineApiRoutes([
{ path: '/blog/posts', method: 'GET', handler: handleListPosts, auth: 'admin', permission: 'blog.view' },
{ path: '/blog/posts', method: 'POST', handler: handleCreatePost, auth: 'admin', permission: 'blog.manage' },
{ path: '/blog/posts/:id', method: 'GET', handler: handleGetPost, auth: 'public' },
]);
registerApiRoutes(routes);
```
Le router applique automatiquement la session, le rate-limit et la vérification de permission. Les routes sont accessibles sous `/zen/api/*`.
| Champ `auth` | Comportement |
|--------------|--------------|
| `'public'` | Aucune session requise. |
| `'user'` | Session valide. |
| `'admin'` | Session avec permission `admin.access`. La permission granulaire `permission` est aussi vérifiée si fournie. |
### Pages publiques `/zen/<module>/...`
```js
import { registerPublicModulePage } from '@zen/core/public-pages';
import BlogPublicPage from './public/BlogPublicPage.js';
registerPublicModulePage({ moduleName: 'blog', Component: BlogPublicPage });
```
URL : `/zen/blog/<...>`. Le composant reçoit `{ params, segments }` :
```js
function BlogPublicPage({ params, segments }) {
// /zen/blog/post/abc-123 → segments = ['post', 'abc-123']
if (segments[0] === 'post') return <PostView id={segments[1]} />;
return <BlogIndex />;
}
```
Le namespace `api` est réservé aux routes API et ne peut être utilisé comme `moduleName`.
### Migrations BD
```js
// db.js
import { query, tableExists } from '@zen/core/database';
const TABLES = [
{ name: 'zen_blog_posts', sql: `CREATE TABLE zen_blog_posts (...)` },
];
export async function createTables() {
const created = [];
const skipped = [];
for (const t of TABLES) {
if (await tableExists(t.name)) { skipped.push(t.name); continue; }
await query(t.sql);
created.push(t.name);
}
return { created, skipped };
}
export async function dropTables() {
for (const t of [...TABLES].reverse()) {
await query(`DROP TABLE IF EXISTS "${t.name}" CASCADE`);
}
}
```
Convention : préfixer toutes les tables par `zen_<module>_` pour éviter les collisions.
---
## Frontières serveur/client
Un module avec partie admin expose **deux entrées** dans son `package.json#exports` :
```json
{
"exports": {
".": { "import": "./dist/index.js" },
"./client": { "import": "./dist/client.js" }
}
}
```
| Entrée | Bundle | Contenu |
|--------|--------|---------|
| `.` (main) | serveur | `manifest`, `register()`, `createTables`, `dropTables`. Le `register()` tire la chaîne `register-server.js` (API routes, navigation, fetchers, storage prefixes, hooks DB). |
| `./client` | client | `'use client'` ; uniquement `registerPage({ Component })` et `registerWidget({ Component })`. Aucun import vers `pg`, `fs`, `.server.js` ou `register-server.js`. |
Le manifeste serveur importe `@zen/module-X` (main) ; le manifeste client importe `@zen/module-X/client`. La séparation est obligatoire : tout ce qui est statiquement importé depuis l'entrée client finit dans le bundle browser, et `pg` / `fs` / `next/headers` y crashent.
```js
// src/index.js (entrée serveur)
export const manifest = { /* ... */ };
export async function register() { await import('./register-server.js'); }
export { createTables, dropTables } from './db.server.js';
```
```js
// src/register-server.js (server-only — appelée par register())
import { registerNavItem, registerNavSection } from '@zen/core/features/admin';
import { registerApiRoutes } from '@zen/core/api';
import { routes } from './api.server.js';
// PAS d'import de Component .client.js ici — ils vivent dans client.js.
registerNavSection({ /* ... */ });
registerApiRoutes(routes);
```
```js
// src/client.js (entrée client — chargée par le manifeste client uniquement)
'use client';
import { registerPage } from '@zen/core/features/admin';
import BlogAdminPage from './admin/BlogAdminPage.client.js';
registerPage({ slug: 'blog', Component: BlogAdminPage, title: 'Blog' });
```
### Sous-entrées `@zen/core/*` safe-pour-client
Le `client.js` d'un module ne doit jamais importer un barrel mixte (un barrel qui ré-exporte du code serveur à côté de constantes client-safe). Si on le fait, Turbopack/Webpack tracent toute la chaîne d'imports statiques et ramènent `pg`/`fs`/`next/headers` dans le bundle browser — qui crashe avec `Module not found: Can't resolve 'dns'` ou équivalent.
Sous-entrées explicitement safe pour un import depuis un fichier `'use client'` :
| Sous-entrée | Contenu |
|-------------|---------|
| `@zen/core/users/constants` | `PERMISSIONS`, `PERMISSION_DEFINITIONS`, `getPermissionGroups` — aucun import serveur. |
| `@zen/core/features/admin` | `registerPage`, `registerWidget`, `registerNavItem`, `registerNavSection`, `buildNavigationSections`. Neutre côté boundary. |
| `@zen/core/features/admin/components` | Composants client : `AdminHeader`, `AdminShell`, `AdminSidebar`, `ThemeToggle`, modals. |
| `@zen/core/features/media/picker` | Composant client `MediaPicker` (modale de sélection de média). Importer **uniquement ce sous-chemin** depuis un client — `@zen/core/features/media` (barrel) tire le code serveur. |
| `@zen/core/themes` | Tokens/utilitaires de thème. |
| `@zen/core/toast` | API toast côté client. |
| `@zen/core/shared/icons` | Composants d'icônes. |
| `@zen/core/shared/components` | Composants partagés. |
Tout ce qui n'est pas dans cette liste — en particulier `@zen/core/users` (barrel complet), `@zen/core/database`, `@zen/core/api`, `@zen/core/storage` — est du code serveur. Ne JAMAIS l'importer depuis un fichier `'use client'` ou un fichier transitivement importé par un `'use client'`.
---
## Variables d'environnement
Toute variable requise par le module doit être déclarée dans `manifest.envVars` et documentée dans le `README.md` du module. Les variables `required: true` absentes génèrent un warning au boot — elles ne crashent pas le serveur, le module gère son propre fallback.
---
## Classes Tailwind
Le projet consommateur importe `@zen/core/styles/zen.css`, qui déclare deux directives `@source` :
1. `@source "../../**/*.js"` — scanne `node_modules/@zen/core/dist/**/*.js`
2. `@source "../../../../module-*/dist/**/*.js"` — scanne `node_modules/@zen/module-*/dist/**/*.js`
Conséquence pour un module : les classes Tailwind utilisées dans les composants du module sont générées **automatiquement** par le Tailwind du consommateur, à condition que :
- Le package soit publié sous le nom `@zen/module-<nom>` (la convention est déjà imposée par la découverte runtime des modules).
- Les fichiers compilés se trouvent dans `dist/` (pattern de scan).
Aucune action n'est requise côté module ni côté consommateur — un module peut donc utiliser librement n'importe quelle classe Tailwind, y compris des variantes responsives (`md:`, `lg:`...) ou des valeurs arbitraires (`w-[300px]`).
---
## Squelette minimal d'un module
```
@zen/module-blog/
├── package.json # name: "@zen/module-blog", main: "./dist/index.js"
├── tsup.config.js # build avec bundle: false, loader JSX, outbase: 'src'
├── README.md # documente les env vars et la configuration
├── src/
│ ├── index.js # exporte manifest, register, createTables, dropTables
│ ├── db.server.js # createTables/dropTables
│ ├── register-server.js # imports déclencheurs (chargé par register())
│ ├── api.server.js # routes API (registerApiRoutes)
│ ├── admin/
│ │ ├── BlogAdminPage.client.js # registerPage + composant
│ │ └── widgets/... # registerWidgetFetcher + registerWidget
│ └── public/
│ └── BlogPublicPage.js # registerPublicModulePage
└── dist/ # généré par `npm run build` — c'est ce qui est publié
```
Le champ `files` dans `package.json` publie **uniquement** `dist/`, `README.md` et `LICENSE` :
```json
{
"main": "./dist/index.js",
"exports": { ".": { "import": "./dist/index.js" } },
"files": ["dist", "README.md", "LICENSE"],
"scripts": {
"build": "tsup",
"prepublishOnly": "npm run build"
}
}
```
---
## Build avant publish
**Tout module `@zen/module-*` est pré-compilé avant publication**, comme `@zen/core` lui-même. Le build `tsup` avec `bundle: false` transforme le JSX, préserve les directives `'use client'` en haut des fichiers compilés, et garde un fichier d'entrée par fichier de sortie pour respecter les frontières RSC quand le projet consommateur bundle le module.
C'est ce qui permet au manifeste statique généré par `zen-modules sync` de simplement faire `import * as ... from '@zen/module-X'` et de laisser Turbopack/Webpack composer le reste : aucune transformation runtime n'est requise côté consommateur.
### Exemple de `tsup.config.js` minimal
```js
import { defineConfig } from 'tsup';
export default defineConfig({
entry: ['src/**/*.js', 'src/**/*.jsx'],
format: ['esm'],
outDir: 'dist',
outbase: 'src',
bundle: false,
splitting: false,
clean: true,
loader: { '.js': 'jsx' },
jsx: 'automatic',
});
```
### Vérifier qu'un module est correctement compilé
Avant `npm publish`, ouvrir `dist/` et confirmer qu'aucun fichier ne contient de syntaxe JSX brute (chercher `<` suivi d'une majuscule). Tous les composants doivent apparaître sous forme d'appels `jsx(...)` ou `React.createElement(...)`.
---
## Cycle de vie complet
| Étape | Côté core | Côté module |
|-------|-----------|-------------|
| Install | — | `npm install @zen/module-X` |
| Configuration | — | Ajout des `envVars` au `.env` |
| Migration BD | `zen-db init` scanne, charge le module, registerPermissions(), seed, createTables() | `createTables()` exécuté |
| Boot serveur | `instrumentation.js``initializeZen()` scanne, charge, registerPermissions(), `register()` | `register()` exécuté côté serveur |
| Premier render client | bundle client traverse les imports → composants client enregistrés | `registerWidget()` exécuté côté client |
| Runtime | router dispatch les requêtes, admin résout les pages/widgets via le registre | aucun travail supplémentaire |
+71
View File
@@ -0,0 +1,71 @@
# ZEN — Plan du projet
> Une plateforme multi-usage construite sur l'essentiel, rien de plus, rien de moins.
ZEN est une plateforme admin Next.js qui sert de base à tout type d'application : site vitrine, système de facturation, espace cloud, boutique en ligne, ou n'importe quoi d'autre. L'admin est extensible, on y greffe ce qu'on veut, sans modifier le core.
Elle s'installe automatiquement dans n'importe quel projet Next.js via :
```bash
npx @zen/start
```
Le package principal est `@zen/core`. Il fournit toute l'infrastructure fondamentale : authentification, base de données, stockage, courriels, paiements, PDF, tâches planifiées, notifications. Chaque fonctionnalité est indépendante — les cores ne se connaissent pas entre eux. Le reste de la plateforme s'appuie sur chacun d'eux sans les coupler.
---
## Principes directeurs
**Core purity** — Chaque core contient uniquement du code propre à son domaine. Aucune logique métier, aucune dépendance vers un autre core.
**Minimal by default** — Pas de fonctionnalité superflue. Si ce n'est pas nécessaire, ça n'existe pas.
**Sécuritaire par défaut** — Requêtes paramétrées, protection CSRF, limitation de débit, validation en entrée, erreurs opaques vers le client.
**Performant** — Connexions en pool, cache HTTP, génération différée des services.
---
## Structure du projet
```
src/
core/ # Infrastructure fondamentale — la base de tout
features/ # Fonctionnalités de la palteforme utilisant les cores
shared/ # Utilitaires, composants et styles partagés
```
---
## Résumé des règles absolues
| Domaine | Règle |
|---|---|
| API | Toutes les routes HTTP passent par `core/api`. Aucune autre route API. |
| Base de données | Tout accès DB passe par `core/database`. Jamais de requêtes directes. |
| Courriels | Tout envoi de courriel passe par `core/email`. |
| Cron | Toutes les tâches planifiées s'enregistrent dans `core/cron`. |
| Stockage | Tout accès fichier passe par `core/storage`. |
| Notifications | Un seul système de toast dans toute l'app : `core/toast`. |
| Authentification | Toute auth de site passe par `features/auth`. |
---
### Pas de `next/headers` ni `next/navigation` au top-level dans la chaîne `register()` des modules
Tout fichier qui peut être atteint depuis le `register()` d'un module externe (directement ou transitivement, via un barrel `@zen/core/*` qu'il importe) **ne doit pas** importer `next/headers` ou `next/navigation` au top-level. L'import doit être lazy à l'intérieur du handler :
```js
export async function handleSomething(request) {
const { cookies } = await import('next/headers');
// ...
}
```
**Pourquoi.** [`discover.server.js`](../src/core/modules/discover.server.js) charge chaque module via `import(/* turbopackIgnore */ name)` pour empêcher Turbopack/Webpack de bundler un nom dynamique. Conséquence : Node résout tout l'arbre d'imports transitifs en ESM natif, **sans** la condition `react-server`. Or `next/headers` (Next.js 15+) n'est exposé que via cette condition — un import top-level échoue alors avec `Cannot find module 'next/headers'`.
**Conséquences pratiques.**
- Les fichiers qui ont besoin de Next.js au top-level (par ex. [`features/admin/protect.js`](../src/features/admin/protect.js), [`features/auth/actions.js`](../src/features/auth/actions.js)) ne sont jamais réexportés par les barrels accessibles aux modules ; ils sont exposés via des sous-chemins dédiés dans `package.json#exports` (ex. `@zen/core/features/admin/protect`).
- Pour les handlers qui restent dans un barrel exposé (ex. [`core/api/router.js`](../src/core/api/router.js), [`core/storage/api.js`](../src/core/storage/api.js)), `cookies` et compagnie sont importés en lazy à l'intérieur du handler.
Avant d'ajouter un `import { cookies } from 'next/headers'` à un fichier core, vérifier qu'aucun barrel exposé aux modules ne le tire transitivement. Sinon, garder l'import lazy.
+5 -1
View File
@@ -31,4 +31,8 @@ Ces modules existent pour éviter la duplication. Avant d'écrire du code utilit
**Tâches planifiées** — Utiliser `src/core/cron` pour créer des tâches cron. **Tâches planifiées** — Utiliser `src/core/cron` pour créer des tâches cron.
**API** — Utiliser `src/core/api` pour l'API admin et publique. Définir les routes avec `defineApiRoutes()` (valide la config au démarrage). L'authentification est déclarée dans la définition de route (`auth: 'public' | 'user' | 'admin'`) — ne jamais la vérifier manuellement dans un handler. Retourner `apiSuccess()` / `apiError()` dans tous les handlers. Voir `src/core/api/README.md` pour le détail. **API** — Utiliser `src/core/api` pour l'API admin et publique. Définir les routes avec `defineApiRoutes()` (valide la config au démarrage). L'authentification est déclarée dans la définition de route (`auth: 'public' | 'user' | 'admin'`) — ne jamais la vérifier manuellement dans un handler. Retourner `apiSuccess()` / `apiError()` dans tous les handlers. Voir `src/core/api/README.md` pour le détail.
**Médias** — Pour gérer les fichiers attachés au contenu publié (images, PDFs, vidéos), utiliser `src/features/media`. Activable via `ZEN_MEDIA=true`. Expose `uploadMedia`/`deleteMedia`/`attachMedia`/`detachMedia` côté serveur et un composant `MediaPicker` réutilisable côté client. Voir `src/features/media/README.md`. Distinct d'un futur module `files` (style Drive/Dropbox) — ne pas confondre les deux usages.
Pour **afficher** une image média, utiliser le wrapper `MediaImage` du module (pas `<img>` direct, pas `next/image` direct). Il route les médias publics via `next/image` (srcset AVIF/WebP, lazy) et garde un `<img>` natif pour les médias privés — `/_next/image` cache la réponse optimisée par URL et fuiterait l'image privée à tous les visiteurs après le premier hit.
+103 -127
View File
@@ -1,163 +1,139 @@
# RÉDACTION # Rédaction
Dernière modification : 2026-04-12
## La voix de l'entreprise Ce guide s'applique à tout contenu textuel visible par l'utilisateur dans ce projet : l'interface admin, les messages d'état, les documentations dans `docs/` et les README.
On parle comme une personne, pas comme une organisation. Direct, sans fioritures.
On dit **"on"** — jamais "nous sommes heureux de", jamais "notre équipe d'experts". Une seule exception : quand on cite quelqu'un nommément, on peut utiliser "je".
La technique est là parce qu'elle est utile, pas pour impressionner. Si une explication technique ne sert pas le lecteur, elle ne sert pas le texte.
--- ---
## Ce qu'on évite absolument ## Deux contextes, deux voix
**Les superlatifs vides** **Interface (labels, messages, descriptions)** : la plateforme rapporte ce qui s'est passé. On utilise l'impersonnel et le passif descriptif. L'utilisateur est le sujet implicite.
*de premier plan, leader, de pointe, best-in-class, incontournable*
**Les promesses floues** **Documentation** : on s'adresse directement au développeur qui lit. Phrases courtes, verbes actifs, ton direct.
*solutions sur mesure, accompagnement personnalisé, approche holistique*
---
## Ce qu'on évite partout
**Superlatifs et formules vides**
*de premier plan, robuste, puissant, tout-en-un, intuitif, seamless*
**Le corporate** **Le corporate**
*nous nous engageons à, notre mission est de, dans une optique de, à cet effet* *notre plateforme vous permet de, dans une optique de, à cet effet*
**La sur-promesse** **Chevilles inutiles**
Si on ne peut pas le garantir, on ne l'écrit pas. Jamais. *Ainsi, En effet, Il convient de noter que, N'hésitez pas à, Veuillez noter que*
**Les métaphores usées**
*pont entre, clé en main, écosystème, synergies, à 360°*
**Le tiret long (—)** **Le tiret long (—)**
Reformuler la phrase plutôt que d'insérer une incise. Reformuler la phrase à la place.
**Les chevilles inutiles** **La sur-promesse**
*Ainsi, En effet, Il convient de noter que, N'hésitez pas à* Si on ne peut pas le garantir, on ne l'écrit pas.
**Le passif sans raison**
Préférer la forme active. "On a livré le projet" plutôt que "le projet a été livré".
--- ---
## Formules qui marchent ## Interface
**Plutôt ça :** ### Voix de l'interface
> "On dit non quand c'est la bonne réponse."
> "On pense à comment les choses vont tenir dans six mois."
> "Ce n'est pas le bon projet pour nous. Voilà pourquoi."
**Pas ça :** La plateforme rapporte. Elle ne dit pas "on a fait" — elle dit ce qui s'est passé.
> "Notre approche centrée client garantit des résultats optimaux."
> "Nous mettons notre expertise au service de vos ambitions."
> "Une équipe passionnée à votre écoute."
**La règle d'or :** si ça pourrait figurer dans la communication d'un concurrent sans changer un mot, c'est à réécrire. > ✓ "Les modifications ont été enregistrées."
> ✓ "La page a été supprimée."
> ✗ "On a enregistré vos modifications."
> ✗ "Nous avons bien reçu votre demande."
Pas de "vous" ni de "nous". L'état parle pour lui-même.
### Labels et boutons
Courts, un verbe d'action à l'infinitif, sans point.
> ✓ "Enregistrer", "Supprimer", "Ajouter une page"
> ✗ "Cliquez ici pour enregistrer vos modifications"
Pas de majuscule à chaque mot.
> ✓ "Ajouter un élément"
> ✗ "Ajouter Un Élément"
### Messages d'erreur
Nommer ce qui ne va pas. Dire quoi faire si c'est utile.
> ✓ "Ce champ est requis."
> ✓ "Ce slug est déjà utilisé. Choisir un autre."
> ✗ "Une erreur s'est produite. Veuillez réessayer."
Pas de code d'erreur technique si l'utilisateur n'est pas développeur.
### Messages de confirmation
Une phrase. Ce qui s'est passé, au passé passif. Sans "avec succès".
> ✓ "Page enregistrée."
> ✓ "Modifications enregistrées."
> ✓ "Élément supprimé."
> ✗ "Vos modifications ont été enregistrées avec succès !"
### États vides
Expliquer la situation, pas juste la constater. Ajouter l'action à faire si applicable.
> ✓ "Aucune page pour l'instant. Créer la première."
> ✗ "Aucun résultat trouvé."
### Textes d'aide et descriptions
Une phrase. Répondre à "pourquoi" ou "comment", pas aux deux.
> ✓ "Utilisé dans l'URL. Ne peut pas être modifié après publication."
> ✗ "Le slug est un identifiant unique utilisé pour générer l'URL de la page. Il doit être en minuscules et ne peut pas contenir d'espaces ou de caractères spéciaux."
--- ---
## Structure des textes ## Documentation
### Voix de la documentation
On s'adresse directement au développeur. Verbes actifs, phrases courtes, sans intermédiaire.
> ✓ "Le registre permet d'ajouter des widgets sans toucher au core. Importer `registerWidget` et passer un composant."
> ✗ "Le registre est un système centralisé qui permet la gestion modulaire des composants d'interface."
### Structure
- **Titre :** ce que le document permet de faire.
- **Première phrase :** pourquoi ce document existe, à qui il s'adresse.
- **Prérequis si nécessaire :** ce qu'il faut savoir ou avoir avant.
- **Corps :** une idée par section, titres descriptifs.
- **Notes :** à la fin, pas dans le milieu.
### Titres ### Titres
Courts, affirmatifs, sans point. Une idée, pas une liste. Pas de question rhétorique.
> ✓ "Zéro raccourci. Zéro compromis." Descriptifs, pas génériques.
> ✓ "On livre. On reste."
> "Une approche rigoureuse pour des résultats durables" > "Ajouter un widget à l'admin"
> ✗ "Pourquoi nous choisir ?" > ✗ "Configuration"
### Corps de texte ### Corps de texte
Phrases courtes. Une idée par phrase. Maximum deux virgules par phrase — si on en compte plus, couper.
Paragraphes de trois à quatre phrases maximum. Une ligne blanche entre chaque paragraphe. Phrases courtes. Une idée par phrase. Paragraphes de trois à quatre phrases maximum.
Pas de bullet points pour des concepts. Seulement pour des listes réelles (étapes, éléments techniques, options). Commencer par un constat ou une situation concrète, pas par une définition.
### Appels à l'action Pas de listes à puces pour des explications. Les listes servent pour les étapes, les options, les éléments techniques.
Concrets et à l'infinitif. Pas d'exclamation.
> ✓ "Discuter de votre projet"
> ✓ "Voir comment on travaille"
> ✗ "Contactez-nous pour en savoir plus !"
> ✗ "Passez à l'action"
--- ---
## Selon le format ## Révision avant de livrer
### Site web - [ ] La première phrase dit déjà l'essentiel.
Chaque page a une seule idée principale. Le visiteur doit comprendre l'essentiel en dix secondes. Les détails viennent après, pour ceux qui cherchent. - [ ] Chaque phrase dit quelque chose. Les phrases qui meublent sont supprimées.
- [ ] L'interface utilise l'impersonnel. La doc s'adresse directement au lecteur.
Pas de jargon en page d'accueil. Le jargon technique appartient aux pages de service, là où le lecteur s'y attend. - [ ] Il n'y a pas de tiret long (—).
- [ ] Les fautes sont corrigées.
### Courriels - [ ] L'utilisateur sait quoi faire ou quoi retenir après avoir lu.
Objet : concret, pas accrocheur. Dire ce dont il s'agit, pas promettre quelque chose.
> ✓ "Proposition — Refonte infrastructure"
> ✗ "Une opportunité à ne pas manquer"
Corps du courriel : aller droit au but dès la première phrase. Pas de "j'espère que ce message vous trouve bien." Si on doit écrire plus de quatre paragraphes, se demander si c'est le bon format.
### Applications et interfaces
Chaque message, étiquette ou instruction doit avoir un seul sens possible. Pas de formulation qui force l'utilisateur à interpréter.
Le ton reste humain, même dans un contexte technique. "Une erreur s'est produite" est mieux que "Erreur 403 — accès non autorisé" si le lecteur n'est pas développeur.
Les messages de confirmation, d'erreur et d'aide suivent les mêmes règles que le reste : courts, directs, actifs.
### Articles et billets
Un angle précis, pas un survol. Mieux vaut un article sur un problème concis que dix paragraphes vagues.
Commencer par un fait, un constat ou une situation — pas par une définition.
> ✓ "La plupart des migrations ratent pour la même raison : on sous-estime ce qui dépend de ce qu'on déplace."
> ✗ "La migration de données est un processus complexe qui nécessite une planification rigoureuse."
Terminer sur ce qu'on retient, pas sur une invitation à nous contacter.
### Propositions et soumissions
Pas de section "qui sommes-nous" en ouverture. Le client le sait déjà ou s'en fout pour l'instant.
Commencer par le problème du client, tel qu'on l'a compris. S'il se reconnaît dans les deux premiers paragraphes, le reste a de la valeur.
La solution vient après le diagnostic. Jamais avant.
### Documents internes
Mêmes règles de clarté que pour les textes externes. Un document interne mal écrit crée autant de confusion qu'une mauvaise communication client.
Titres de section descriptifs, pas génériques. "Décision retenue et raison" est mieux que "Résultats".
Si un document dépasse deux pages, se demander si l'information ne serait pas mieux transmise autrement.
### Réseaux sociaux
Un seul message par publication. Si on a besoin d'un fil de publications pour expliquer, c'est probablement un article.
Le ton reste celui de l'entreprise — pas plus décontracté sous prétexte que c'est un réseau social.
Pas de hashtags décoratifs. Seulement ceux qui servent la découverte du contenu.
--- ---
## Parler de ce qu'on fait *Ce guide évolue. Si une formule sonne faux ou si un cas n'est pas couvert, l'ajuster.*
Ne jamais affirmer qu'on est "les meilleurs" ou "différents des autres". Le montrer, pas le dire.
Quand on parle de ce qu'on fait, on parle de situations concrètes. Pas de généralités.
> ✓ "On a refusé un mandat parce que le calendrier ne tenait pas."
> ✗ "On valorise l'honnêteté dans nos relations clients."
---
## Révision d'un texte
Avant de publier ou d'envoyer, se poser ces questions :
1. **Est-ce qu'un concurrent pourrait signer ce texte ?** Si oui, réécrire.
2. **Est-ce qu'on promet quelque chose qu'on ne contrôle pas ?** Retirer ou nuancer.
3. **Est-ce que chaque phrase dit quelque chose ?** Supprimer celles qui ne font que meubler.
4. **Est-ce que le lecteur sait quoi faire ou quoi penser après ?** Si non, préciser.
5. **A-t-on utilisé le mot "solution" ?** Le remplacer. Presque toujours.
---
*Ce guide évolue. Si une formule sonne faux ou si un cas n'est pas couvert, on l'ajuste.*
-255
View File
@@ -1,255 +0,0 @@
# Créer un module externe
Un module externe est un package npm indépendant qui s'intègre dans une app qui utilise `@zen/core`. Il n'a pas besoin de modifier le code source du CMS.
---
## Convention de nommage
```
@scope/zen-nom-du-module
```
Exemples : `@zen/core-invoice`, `@zen/core-nuage`.
---
## Structure du package
```
zen-invoice/
├── index.js # Point d'entrée — exporte defineModule()
├── admin/ # Composants React pour l'admin
│ ├── InvoiceListPage.js
│ ├── InvoiceCreatePage.js
│ └── InvoiceEditPage.js
├── db.js # createTables() et dropTables()
├── actions.js # Server actions pour pages publiques
├── metadata.js # Générateurs de métadonnées SEO
├── package.json
└── .env.example
```
---
## index.js
On utilise `defineModule()` importé depuis `@zen/core/modules/define`.
```js
import { lazy } from 'react';
import { defineModule } from '@zen/core/modules/define';
import { createTables, dropTables } from './db.js';
import { getInvoiceByToken } from './actions.js';
import { generatePaymentMetadata } from './metadata.js';
const InvoiceListPage = lazy(() => import('./admin/InvoiceListPage.js'));
const InvoiceCreatePage = lazy(() => import('./admin/InvoiceCreatePage.js'));
const InvoiceEditPage = lazy(() => import('./admin/InvoiceEditPage.js'));
export default defineModule({
name: 'invoice',
displayName: 'Facturation',
version: '1.0.0',
description: 'Gestion des factures et paiements.',
dependencies: [],
envVars: ['STRIPE_SECRET_KEY', 'ZEN_INVOICE_TAX_RATE'],
// Navigation dans le panneau admin
navigation: [
{
id: 'invoice',
title: 'Facturation',
icon: 'Invoice03Icon',
items: [
{ name: 'Factures', href: '/admin/invoice/list', icon: 'Invoice03Icon' },
{ name: 'Nouvelle', href: '/admin/invoice/new', icon: 'Add01Icon' },
],
},
],
// Pages admin avec leurs composants lazy
adminPages: {
'/admin/invoice/list': InvoiceListPage,
'/admin/invoice/new': InvoiceCreatePage,
'/admin/invoice/edit': InvoiceEditPage,
},
// Résolveur pour les routes dynamiques (ex: /admin/invoice/edit/123)
pageResolver(path) {
const parts = path.split('/').filter(Boolean);
if (parts[0] !== 'admin' || parts[1] !== 'invoice') return null;
if (parts[2] === 'list') return InvoiceListPage;
if (parts[2] === 'new') return InvoiceCreatePage;
if (parts[2] === 'edit') return InvoiceEditPage;
return null;
},
publicPages: {},
publicRoutes: [
{ pattern: ':token', description: 'Page de paiement' },
{ pattern: ':token/pdf', description: 'Télécharger la facture PDF' },
],
dashboardWidgets: [],
// Base de données
db: { createTables, dropTables },
// Server actions pour les pages publiques (/zen/invoice/...)
actions: { getInvoiceByToken },
// Générateurs de métadonnées SEO
metadata: {
payment: generatePaymentMetadata,
},
// Appelé une fois au démarrage du serveur
// ctx donne accès aux services du CMS
async setup(ctx) {
const stripe = await ctx.payments.then(p => p.stripe);
// Initialiser des webhooks, vérifier la config, etc.
console.log('[invoice] Stripe prêt :', !!stripe);
},
});
```
---
## L'objet ctx dans setup()
`setup(ctx)` reçoit un objet qui donne accès aux services du CMS. Chaque propriété retourne une promesse vers le module correspondant.
```js
async setup(ctx) {
// Base de données PostgreSQL
const { query, queryOne, queryAll } = await ctx.db;
const rows = await query('SELECT * FROM zen_auth_users LIMIT 1');
// Envoi de courriels (Resend)
const { sendEmail } = await ctx.email;
await sendEmail({ to: 'test@example.com', subject: 'Test', html: '<p>ok</p>' });
// Stockage de fichiers (Cloudflare R2)
const { uploadFile, deleteFile } = await ctx.storage;
// Stripe
const { stripe } = await ctx.payments;
// Variables d'environnement
const apiKey = ctx.config.get('STRIPE_SECRET_KEY');
}
```
---
## package.json du module externe
```json
{
"name": "@zen/core-invoice",
"version": "1.0.0",
"type": "module",
"main": "./index.js",
"exports": {
".": "./index.js"
},
"peerDependencies": {
"@zen/core": ">=1.0.0",
"react": ">=19.0.0"
}
}
```
---
## Intégration dans l'app consommatrice
### 1. Installer le package
```bash
npm install @zen/core-invoice
```
### 2. Créer zen.config.js à la racine de l'app
```js
// zen.config.js
import invoiceModule from '@zen/core-invoice';
export default {
modules: [invoiceModule],
};
```
### 3. Passer la config à initializeZen()
```js
// instrumentation.js
import zenConfig from './zen.config.js';
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initializeZen } = await import('@zen/core');
await initializeZen(zenConfig);
}
}
```
### 4. Passer les modules à ZenProvider
```jsx
// app/layout.js
import zenConfig from './zen.config.js';
import { ZenProvider } from '@zen/core/provider';
export default function RootLayout({ children }) {
return (
<html>
<body>
<ZenProvider modules={zenConfig.modules}>
{children}
</ZenProvider>
</body>
</html>
);
}
```
### 5. Activer le module via variable d'environnement
```bash
# .env.local
ZEN_MODULE_INVOICE=true
```
La convention est la même que pour les modules internes : tirets en underscores, tout en majuscules.
---
## Initialiser la base de données
Le module déclare ses tables dans `db.createTables`. Deux façons de les créer.
**Au démarrage du serveur** (si `skipDb: false`) :
```js
await initializeZen({ modules: zenConfig.modules, skipDb: false });
```
**Via la CLI** :
```bash
npx zen-db init
```
---
## Vérification rapide
1. Démarrer avec `ZEN_MODULE_INVOICE=true`.
2. Ouvrir `/admin`. La section "Facturation" doit apparaître dans la navigation.
3. Naviguer vers `/admin/invoice/list`. La page du module doit se charger.
4. Appeler `getModuleActions('invoice')` côté serveur. Les actions du module doivent être retournées.
-218
View File
@@ -1,218 +0,0 @@
# Créer un module interne
Un module interne vit dans `src/modules/` et fait partie du package `@zen/core`. Il a accès direct aux services du CMS sans passer par un contexte injecté.
---
## Structure d'un module
```
src/modules/mon-module/
├── module.config.js # Obligatoire — navigation, pages, routes, cron, etc.
├── db.js # Optionnel — createTables() et dropTables()
├── api.js # Optionnel — routes REST
├── cron.config.js # Optionnel — tâches planifiées
├── crud.js # Optionnel — accès aux données
├── admin/ # Composants React pour l'admin
└── .env.example # Variables d'environnement requises
```
---
## module.config.js
C'est la source de vérité du module. On utilise `defineModule()` pour déclarer la configuration.
```js
import { lazy } from 'react';
import { defineModule } from '../../core/modules/defineModule.js';
const ListPage = lazy(() => import('./admin/ListPage.js'));
const CreatePage = lazy(() => import('./admin/CreatePage.js'));
const EditPage = lazy(() => import('./admin/EditPage.js'));
export default defineModule({
name: 'mon-module',
displayName: 'Mon module',
version: '1.0.0',
description: 'Description courte.',
// Modules dont celui-ci dépend (vérification au démarrage)
dependencies: [],
// Variables d'environnement que ce module lit
envVars: ['ZEN_MON_MODULE_OPTION'],
// Navigation admin — un ou plusieurs objets de section
navigation: [
{
id: 'mon-module',
title: 'Mon module',
icon: 'SomeIcon',
items: [
{ name: 'Liste', href: '/admin/mon-module/list', icon: 'SomeIcon' },
{ name: 'Nouveau', href: '/admin/mon-module/new', icon: 'AddIcon' },
],
},
],
// Pages admin — chemin exact vers composant lazy
adminPages: {
'/admin/mon-module/list': ListPage,
'/admin/mon-module/new': CreatePage,
'/admin/mon-module/edit': EditPage,
},
// Résolveur pour les routes dynamiques (non connues à la compilation)
// Retourner null si le chemin ne correspond pas
pageResolver(path) {
const parts = path.split('/').filter(Boolean);
if (parts[0] !== 'admin' || parts[1] !== 'mon-module') return null;
if (parts[2] === 'list') return ListPage;
if (parts[2] === 'new') return CreatePage;
if (parts[2] === 'edit') return EditPage;
return null;
},
// Pages publiques accessibles sans authentification (/zen/mon-module/...)
publicPages: {},
publicRoutes: [],
// Widgets affichés sur le dashboard admin
dashboardWidgets: [],
});
```
---
## db.js
Si le module crée des tables, on exporte `createTables` et `dropTables`.
```js
import { query } from '../../core/database/index.js';
import { tableExists } from '../../core/database/helpers.js';
export async function createTables() {
const created = [];
const skipped = [];
if (!(await tableExists('zen_mon_module'))) {
await query(`
CREATE TABLE zen_mon_module (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
titre TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
)
`);
created.push('zen_mon_module');
} else {
skipped.push('zen_mon_module');
}
return { created, skipped };
}
export async function dropTables() {
await query('DROP TABLE IF EXISTS zen_mon_module CASCADE');
}
```
---
## api.js
Les routes API sont montées automatiquement sous `/api/zen/mon-module/...`.
```js
async function handleList(request) {
// ...
return Response.json({ items });
}
export default {
routes: [
{ path: '/admin/mon-module/list', method: 'GET', handler: handleList, auth: 'admin' },
{ path: '/mon-module/list', method: 'GET', handler: handleList, auth: 'public' },
],
};
```
Valeurs acceptées pour `auth` : `'admin'` (JWT admin requis), `'user'` (JWT utilisateur requis), `'public'` (aucune auth).
---
## cron.config.js
```js
import { maFonction } from './crud.js';
export default {
jobs: [
{
name: 'mon-module:nettoyage',
schedule: '0 3 * * *', // Tous les jours à 3h
handler: maFonction,
timezone: 'America/Toronto',
},
],
};
```
---
## Enregistrement — 2 étapes
Après avoir créé les fichiers, on enregistre le module dans deux endroits.
### 1. `src/modules/modules.registry.js`
Ajouter le nom du module à `AVAILABLE_MODULES` :
```js
export const AVAILABLE_MODULES = [
'posts',
'mon-module', // ajout
];
```
### 2. `src/modules/modules.pages.js`
Importer la config et l'ajouter à `MODULE_CONFIGS` :
```js
import postsConfig from './posts/module.config.js';
import monModuleConfig from './mon-module/module.config.js'; // ajout
const MODULE_CONFIGS = {
posts: postsConfig,
'mon-module': monModuleConfig, // ajout
};
```
---
## Activation
Le module est ignoré au démarrage tant que sa variable d'environnement n'est pas définie :
```bash
# .env.local
ZEN_MODULE_MON_MODULE=true
```
La règle de conversion : tirets et espaces deviennent des underscores, tout en majuscules.
```
mon-module → ZEN_MODULE_MON_MODULE
post-types → ZEN_MODULE_POST_TYPES
```
---
## Vérification rapide
1. Démarrer le serveur avec `ZEN_MODULE_MON_MODULE=true`.
2. Ouvrir `/admin`. La section de navigation du module doit apparaître.
3. Naviguer vers `/admin/mon-module/list`. La page doit se charger.
4. Lancer `npx zen-db init`. La table `zen_mon_module` doit être créée.
+37 -48
View File
@@ -1,25 +1,26 @@
{ {
"name": "@zen/core", "name": "@zen/core",
"version": "1.3.12", "version": "1.4.210",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "@zen/core", "name": "@zen/core",
"version": "1.3.12", "version": "1.4.210",
"license": "GPL-3.0-only", "license": "GPL-3.0-only",
"dependencies": { "dependencies": {
"@headlessui/react": "^2.0.0", "@headlessui/react": "^2.0.0",
"@react-email/components": "^0.5.6", "@react-email/components": "^0.5.6",
"@react-pdf/renderer": "^4.3.1", "@react-pdf/renderer": "^4.3.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"node-cron": "^3.0.3", "node-cron": "^4.2.1",
"pg": "^8.11.3", "pg": "^8.11.3",
"resend": "^3.2.0", "resend": "^3.2.0",
"stripe": "^14.0.0" "stripe": "^14.0.0"
}, },
"bin": { "bin": {
"zen-db": "dist/cli/database.js" "zen-db": "dist/core/database/cli.js",
"zen-modules": "dist/core/modules/cli.js"
}, },
"devDependencies": { "devDependencies": {
"react": "^19.0.0", "react": "^19.0.0",
@@ -3653,43 +3654,11 @@
"tslib": "^2.8.0" "tslib": "^2.8.0"
} }
}, },
"node_modules/next/node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.6",
"picocolors": "^1.0.0",
"source-map-js": "^1.0.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/node-cron": { "node_modules/node-cron": {
"version": "3.0.3", "version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
"integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", "integrity": "sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==",
"license": "ISC", "license": "ISC",
"dependencies": {
"uuid": "8.3.2"
},
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
} }
@@ -3913,6 +3882,35 @@
"node": ">= 6" "node": ">= 6"
} }
}, },
"node_modules/postcss": {
"version": "8.5.10",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz",
"integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.11",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-load-config": { "node_modules/postcss-load-config": {
"version": "6.0.1", "version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
@@ -5355,15 +5353,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/uuid": {
"version": "8.3.2",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz",
"integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/vite-compatible-readable-stream": { "node_modules/vite-compatible-readable-stream": {
"version": "3.6.1", "version": "3.6.1",
"resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz", "resolved": "https://registry.npmjs.org/vite-compatible-readable-stream/-/vite-compatible-readable-stream-3.6.1.tgz",
+81 -63
View File
@@ -1,7 +1,7 @@
{ {
"name": "@zen/core", "name": "@zen/core",
"version": "1.3.12", "version": "1.4.210",
"description": "Un CMS construit sur l'essentiel, rien de plus, rien de moins.", "description": "Une plateforme multi-usage construite sur l'essentiel, rien de plus, rien de moins.",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.hyko.cx/zen/core.git" "url": "https://git.hyko.cx/zen/core.git"
@@ -15,22 +15,25 @@
"main": "./dist/index.js", "main": "./dist/index.js",
"module": "./dist/index.js", "module": "./dist/index.js",
"files": [ "files": [
"dist" "dist",
".env.example"
], ],
"scripts": { "scripts": {
"build": "tsup && npm run build:css", "build": "tsup && npm run build:css",
"build:css": "mkdir -p ./dist/shared/styles && cp ./src/shared/styles/zen.css ./dist/shared/styles/zen.css", "build:css": "mkdir -p ./dist/shared/styles ./dist/shared/fonts && cp ./src/shared/styles/zen.css ./dist/shared/styles/zen.css && cp -r ./src/shared/fonts/. ./dist/shared/fonts/",
"prepublishOnly": "npm run build" "prepublishOnly": "npm run build",
"release": "npm version patch --no-git-tag-version && npm i && git add package.json package-lock.json && git commit -m \"chore: bump version to $(node -p \"require('./package.json').version\")\" && git push && npm publish"
}, },
"bin": { "bin": {
"zen-db": "./dist/cli/database.js" "zen-db": "./dist/core/database/cli.js",
"zen-modules": "./dist/core/modules/cli.js"
}, },
"dependencies": { "dependencies": {
"@headlessui/react": "^2.0.0", "@headlessui/react": "^2.0.0",
"@react-email/components": "^0.5.6", "@react-email/components": "^0.5.6",
"@react-pdf/renderer": "^4.3.1", "@react-pdf/renderer": "^4.3.1",
"dotenv": "^16.4.5", "dotenv": "^16.4.5",
"node-cron": "^3.0.3", "node-cron": "^4.2.1",
"pg": "^8.11.3", "pg": "^8.11.3",
"resend": "^3.2.0", "resend": "^3.2.0",
"stripe": "^14.0.0" "stripe": "^14.0.0"
@@ -45,44 +48,77 @@
"next": ">=14.0.0", "next": ">=14.0.0",
"react": "^19.0.0" "react": "^19.0.0"
}, },
"overrides": {
"postcss": "^8.5.10"
},
"exports": { "exports": {
".": { ".": {
"import": "./dist/index.js" "import": "./dist/index.js"
}, },
"./auth": { "./features/auth": {
"import": "./dist/features/auth/index.js" "import": "./dist/features/auth/index.js"
}, },
"./auth/actions": { "./features/auth/actions": {
"import": "./dist/features/auth/actions.js" "import": "./dist/features/auth/actions.js"
}, },
"./auth/pages": { "./features/auth/server": {
"import": "./dist/features/auth/pages.js" "import": "./dist/features/auth/AuthPage.server.js"
}, },
"./auth/page": { "./features/auth/client": {
"import": "./dist/features/auth/page.js" "import": "./dist/features/auth/AuthPage.client.js"
}, },
"./auth/components": { "./features/auth/pages": {
"import": "./dist/features/auth/components/index.js" "import": "./dist/features/auth/pages/index.js"
}, },
"./admin": { "./features/admin": {
"import": "./dist/features/admin/index.js" "import": "./dist/features/admin/index.js"
}, },
"./admin/actions": { "./features/admin/protect": {
"import": "./dist/features/admin/actions.js" "import": "./dist/features/admin/protect.js"
}, },
"./admin/navigation": { "./features/admin/server": {
"import": "./dist/features/admin/navigation.server.js" "import": "./dist/features/admin/AdminPage.server.js"
}, },
"./admin/pages": { "./features/admin/layout": {
"import": "./dist/features/admin/pages.js" "import": "./dist/features/admin/AdminLayout.server.js"
}, },
"./admin/page": { "./features/admin/client": {
"import": "./dist/features/admin/page.js" "import": "./dist/features/admin/AdminPage.client.js"
},
"./features/admin/components": {
"import": "./dist/features/admin/components/index.js"
},
"./features/media": {
"import": "./dist/features/media/index.js"
},
"./features/media/picker": {
"import": "./dist/features/media/components/MediaPicker.client.js"
},
"./features/media/details-modal": {
"import": "./dist/features/media/components/MediaDetailsModal.client.js"
},
"./features/provider": {
"import": "./dist/features/provider/index.js"
},
"./users": {
"import": "./dist/core/users/index.js"
},
"./users/constants": {
"import": "./dist/core/users/constants.js"
},
"./modules": {
"import": "./dist/core/modules/index.js"
},
"./public-pages": {
"import": "./dist/core/public-pages/index.js"
},
"./public-pages/server": {
"import": "./dist/core/public-pages/PublicModulePage.server.js"
}, },
"./api": { "./api": {
"import": "./dist/core/api/index.js" "import": "./dist/core/api/index.js"
}, },
"./zen/api": { "./api/handler": {
"import": "./dist/core/api/route-handler.js" "import": "./dist/core/api/route-handler.js"
}, },
"./database": { "./database": {
@@ -112,47 +148,29 @@
"./toast": { "./toast": {
"import": "./dist/core/toast/index.js" "import": "./dist/core/toast/index.js"
}, },
"./provider": { "./themes": {
"import": "./dist/features/provider/index.js" "import": "./dist/core/themes/index.js"
}, },
"./core/modules": { "./shared/components": {
"import": "./dist/core/modules/index.js"
},
"./core/modules/client": {
"import": "./dist/core/modules/client.js"
},
"./modules": {
"import": "./dist/modules/index.js"
},
"./modules/define": {
"import": "./dist/core/modules/defineModule.js"
},
"./modules/pages": {
"import": "./dist/modules/pages.js"
},
"./modules/actions": {
"import": "./dist/modules/modules.actions.js"
},
"./modules/storage": {
"import": "./dist/modules/modules.storage.js"
},
"./modules/posts/crud": {
"import": "./dist/modules/posts/crud.js"
},
"./modules/metadata": {
"import": "./dist/modules/modules.metadata.js"
},
"./modules/page": {
"import": "./dist/modules/page.js"
},
"./lib/metadata": {
"import": "./dist/shared/lib/metadata/index.js"
},
"./components": {
"import": "./dist/shared/components/index.js" "import": "./dist/shared/components/index.js"
}, },
"./icons": { "./shared/components/BlockEditor/mediaLink": {
"import": "./dist/shared/Icons.js" "import": "./dist/shared/components/BlockEditor/mediaLink.server.js"
},
"./shared/icons": {
"import": "./dist/shared/icons/index.js"
},
"./shared/metadata": {
"import": "./dist/shared/lib/metadata/index.js"
},
"./shared/logger": {
"import": "./dist/shared/lib/logger.js"
},
"./shared/config": {
"import": "./dist/shared/lib/appConfig.js"
},
"./shared/rate-limit": {
"import": "./dist/shared/lib/rateLimit.js"
}, },
"./styles/zen.css": { "./styles/zen.css": {
"default": "./dist/shared/styles/zen.css" "default": "./dist/shared/styles/zen.css"
+20 -12
View File
@@ -10,10 +10,12 @@ Ce répertoire est un **framework d'API générique**. Il ne connaît aucune fea
src/core/api/ src/core/api/
├── index.js Exports publics (routeRequest, requireAuth, apiSuccess, defineApiRoutes…) ├── index.js Exports publics (routeRequest, requireAuth, apiSuccess, defineApiRoutes…)
├── router.js Orchestration : rate limit, CSRF, auth, dispatch ├── router.js Orchestration : rate limit, CSRF, auth, dispatch
├── runtime.js État global persisté : resolver de session + registre des feature routes
├── route-handler.js Intégration Next.js App Router (GET/POST/PUT/DELETE/PATCH) ├── route-handler.js Intégration Next.js App Router (GET/POST/PUT/DELETE/PATCH)
├── define.js defineApiRoutes() — validateur de définitions de routes ├── define.js defineApiRoutes() — validateur de définitions de routes
├── respond.js apiSuccess() / apiError() — utilitaires de réponse ├── respond.js apiSuccess() / apiError() / getStatusCode() — utilitaires de réponse
├── core-routes.js Index des routes built-in (seul fichier à toucher pour un nouveau handler core) ├── core-routes.js Index des routes built-in (seul fichier à toucher pour un nouveau handler core)
├── file-response.js Réponse streaming pour les fichiers (GET /zen/api/storage/**)
└── health.js GET /zen/api/health └── health.js GET /zen/api/health
src/features/auth/api.js Routes /zen/api/users/* (vivent avec la feature auth) src/features/auth/api.js Routes /zen/api/users/* (vivent avec la feature auth)
@@ -35,6 +37,7 @@ route-handler.js (GET / POST / PUT / DELETE / PATCH)
├─ matchRoute(pattern, path) — exact, :param, /** ├─ matchRoute(pattern, path) — exact, :param, /**
├─ Auth enforcement (depuis la définition de la route) ├─ Auth enforcement (depuis la définition de la route)
│ 'admin' → requireAdmin() — session dans context.session │ 'admin' → requireAdmin() — session dans context.session
│ │ si `permission` est défini → hasPermission() → 403 si refusé
│ 'user' → requireAuth() — session dans context.session │ 'user' → requireAuth() — session dans context.session
│ 'public'→ aucun — context.session = undefined │ 'public'→ aucun — context.session = undefined
└─ handler(request, params, context) └─ handler(request, params, context)
@@ -54,9 +57,11 @@ L'auth est **toujours déclarée dans la définition de route**, jamais dans le
--- ---
## Ajouter des routes à une feature existante ## Ajouter des routes à une feature core
Créer un fichier `api.js` dans le répertoire de la feature et exporter `routes` : Deux étapes :
**1. Créer un fichier `api.js` dans le répertoire de la feature :**
```js ```js
// src/features/myfeature/api.js // src/features/myfeature/api.js
@@ -79,23 +84,19 @@ export const routes = defineApiRoutes([
]); ]);
``` ```
Puis enregistrer dans `core-routes.js` (la seule ligne à ajouter) : **2. L'enregistrer dans `initializeZen()` (src/shared/lib/init.js) :**
```js ```js
import { registerFeatureRoutes } from '../../core/api/index.js';
import { routes as settingsRoutes } from '../../features/myfeature/api.js'; import { routes as settingsRoutes } from '../../features/myfeature/api.js';
export function getCoreRoutes() { registerFeatureRoutes(settingsRoutes);
return [
...healthRoutes,
...usersRoutes,
...storageRoutes,
...settingsRoutes, // ← ajout
];
}
``` ```
**C'est tout.** Les routes sont disponibles à `GET /zen/api/settings` et `PUT /zen/api/settings`. **C'est tout.** Les routes sont disponibles à `GET /zen/api/settings` et `PUT /zen/api/settings`.
> Note : `core-routes.js` n'est utilisé que pour les routes built-in de l'infrastructure (health, storage). Les routes de features passent par `registerFeatureRoutes()` dans `initializeZen()`.
--- ---
## Ajouter des routes depuis un module ## Ajouter des routes depuis un module
@@ -175,6 +176,13 @@ Champs requis par route :
| `handler` | `Function` | Signature : `(request, params, context) => Promise<Object>` | | `handler` | `Function` | Signature : `(request, params, context) => Promise<Object>` |
| `auth` | `string` | `'public'` \| `'user'` \| `'admin'` | | `auth` | `string` | `'public'` \| `'user'` \| `'admin'` |
Champs optionnels :
| Champ | Type | Description |
|-------|------|-------------|
| `skipRateLimit` | `boolean` | Exempte la route du rate limiting par IP (ex. health checks) |
| `permission` | `string` | Sur une route `auth: 'admin'`, exige en plus cette clé de permission granulaire (ex. `'users.manage'`). Retourne 403 si l'utilisateur ne la possède pas. Voir `PERMISSIONS` dans `src/core/users/constants.js` |
--- ---
## Note — handler storage ## Note — handler storage
+9 -8
View File
@@ -1,29 +1,30 @@
/** /**
* Core Route Index * Core Route Index
* *
* This is the ONLY file to edit when adding a new built-in handler. * Contains only routes that are part of the core API infrastructure itself:
* Each feature manages its own route definitions via defineApiRoutes(). * the health check and the storage file-serving endpoint. Both live under
* Do NOT put route logic here — only import and spread. * src/core/ and have no feature-level dependencies.
* *
* To add a new built-in handler: * Feature routes (e.g. /users/*) are registered separately by each feature
* 1. Create the handler in its natural location (e.g. src/features/myfeature/api.js) * during initializeZen() via registerFeatureRoutes().
*
* To add a new core infrastructure handler:
* 1. Create the handler in its natural location under src/core/
* 2. Export `routes` from it using defineApiRoutes() * 2. Export `routes` from it using defineApiRoutes()
* 3. Add one line here: import + spread * 3. Add one line here: import + spread
* 4. Done — never touch router.js * 4. Done — never touch router.js
*/ */
import { routes as healthRoutes } from './health.js'; import { routes as healthRoutes } from './health.js';
import { routes as usersRoutes } from '../../features/auth/api.js';
import { routes as storageRoutes } from '../storage/api.js'; import { routes as storageRoutes } from '../storage/api.js';
/** /**
* Return all registered core API routes. * Return all registered core infrastructure API routes.
* @returns {ReadonlyArray} * @returns {ReadonlyArray}
*/ */
export function getCoreRoutes() { export function getCoreRoutes() {
return [ return [
...healthRoutes, ...healthRoutes,
...usersRoutes,
...storageRoutes, ...storageRoutes,
]; ];
} }
+24 -4
View File
@@ -13,10 +13,20 @@
* ]); * ]);
* *
* Required fields per route: * Required fields per route:
* path {string} Must start with '/'. Supports ':param' and trailing '/**'. * path {string} Must start with '/'. Supports ':param' and trailing '/**'.
* method {string} One of: GET | POST | PUT | PATCH | DELETE * method {string} One of: GET | POST | PUT | PATCH | DELETE
* handler {Function} Async function — signature: (request, params, context) * handler {Function} Async function — signature: (request, params, context)
* auth {string} One of: 'public' | 'user' | 'admin' * auth {string} One of: 'public' | 'user' | 'admin'
*
* Optional fields per route:
* skipRateLimit {boolean} When true, the router skips the per-IP rate limit
* check for this route. Use sparingly — only for routes
* that must remain accessible under high probe frequency
* (e.g. health checks from monitoring systems).
* permission {string} When set on an 'admin' route, the router additionally
* verifies that the authenticated user holds this granular
* permission key (e.g. 'users.manage'). If the user lacks
* the permission, the request is rejected with 403 Forbidden.
* *
* Auth levels: * Auth levels:
* 'public' Anyone can call this route. context.session is undefined. * 'public' Anyone can call this route. context.session is undefined.
@@ -66,6 +76,16 @@ export function defineApiRoutes(routes) {
`${at} (${route.method} ${route.path}) — "auth" must be one of "public" | "user" | "admin", got: ${JSON.stringify(route.auth)}` `${at} (${route.method} ${route.path}) — "auth" must be one of "public" | "user" | "admin", got: ${JSON.stringify(route.auth)}`
); );
} }
if (route.skipRateLimit !== undefined && typeof route.skipRateLimit !== 'boolean') {
throw new TypeError(
`${at} (${route.method} ${route.path}) — "skipRateLimit" must be a boolean when provided, got: ${JSON.stringify(route.skipRateLimit)}`
);
}
if (route.permission !== undefined && typeof route.permission !== 'string') {
throw new TypeError(
`${at} (${route.method} ${route.path}) — "permission" must be a string when provided, got: ${JSON.stringify(route.permission)}`
);
}
} }
// Freeze to prevent accidental mutation of route definitions at runtime. // Freeze to prevent accidental mutation of route definitions at runtime.
+57
View File
@@ -0,0 +1,57 @@
/**
* File Response Builder
*
* Builds a NextResponse for streaming a file from storage.
* Encapsulates all file-serving semantics — MIME type rendering policy,
* Content-Disposition rules, security headers — in one place so the
* generic route handler stays agnostic about storage concerns.
*
* Policy:
* - Image MIME types are served inline (required for <img> tags).
* - All other types force a download to prevent in-browser rendering
* of potentially dangerous content.
* - Content-Disposition is always emitted explicitly; omitting it leaves
* rendering decisions to browser heuristics, which vary by content-type
* and browser version.
*/
import { NextResponse } from 'next/server';
// MIME types that are safe to render inline (used in <img> tags).
// All other types are forced to download.
const INLINE_MIME_TYPES = new Set([
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
]);
/**
* Build a NextResponse that streams a file from a storage result envelope.
*
* @param {{ body: *, contentType?: string, contentLength?: number, lastModified?: Date, filename?: string }} file
* @returns {NextResponse}
*/
export function buildFileResponse(file) {
const contentType = file.contentType || 'application/octet-stream';
const headers = {
'Content-Type': contentType,
'Cache-Control': 'private, max-age=3600',
'Last-Modified': file.lastModified?.toUTCString() ?? new Date().toUTCString(),
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
};
if (file.contentLength != null) {
headers['Content-Length'] = String(file.contentLength);
}
if (INLINE_MIME_TYPES.has(contentType)) {
headers['Content-Disposition'] = 'inline';
} else if (file.filename) {
const encoded = encodeURIComponent(file.filename);
headers['Content-Disposition'] = `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`;
} else {
headers['Content-Disposition'] = 'attachment';
}
return new NextResponse(file.body, { status: 200, headers });
}
+1 -1
View File
@@ -16,5 +16,5 @@ async function handleHealth() {
} }
export const routes = defineApiRoutes([ export const routes = defineApiRoutes([
{ path: '/health', method: 'GET', handler: handleHealth, auth: 'public' } { path: '/health', method: 'GET', handler: handleHealth, auth: 'public', skipRateLimit: true }
]); ]);
+6 -3
View File
@@ -2,14 +2,17 @@
* Zen API — Public Surface * Zen API — Public Surface
* *
* Exports the router entry point, auth helpers, response utilities, * Exports the router entry point, auth helpers, response utilities,
* and the route definition helper for use across the application. * the route definition helper, and the feature routes registry.
*/ */
// Router // Router
export { routeRequest, getStatusCode, requireAuth, requireAdmin } from './router.js'; export { routeRequest, requireAuth, requireAdmin } from './router.js';
// Runtime state — session resolver + feature routes registry
export { configureRouter, getSessionResolver, clearRouterConfig, registerApiRoutes, registerFeatureRoutes, getFeatureRoutes, clearFeatureRoutes } from './runtime.js';
// Response utilities — use in all handlers (core and modules) // Response utilities — use in all handlers (core and modules)
export { apiSuccess, apiError } from './respond.js'; export { apiSuccess, apiError, getStatusCode } from './respond.js';
// Route definition helper — use in handler files and module api.js files // Route definition helper — use in handler files and module api.js files
export { defineApiRoutes } from './define.js'; export { defineApiRoutes } from './define.js';
+30 -3
View File
@@ -29,9 +29,8 @@ export function apiSuccess(payload) {
/** /**
* Create an error API response payload. * Create an error API response payload.
* *
* The `code` field is read by getStatusCode() in router.js to derive the * The `code` field is read by getStatusCode() to derive the HTTP status.
* HTTP status. Always use one of the recognised codes below — any other * Always use one of the recognised codes below — any other value maps to 500.
* value maps to 500.
* *
* Valid codes → HTTP status: * Valid codes → HTTP status:
* 'Unauthorized' → 401 * 'Unauthorized' → 401
@@ -49,3 +48,31 @@ export function apiSuccess(payload) {
export function apiError(code, message) { export function apiError(code, message) {
return { error: code, message }; return { error: code, message };
} }
/**
* Derive an HTTP status code from a response payload.
* Reads the `error` field set by apiError().
*
* @param {Object} response
* @returns {number}
*/
export function getStatusCode(response) {
if (response.error) {
switch (response.error) {
case 'Unauthorized':
return 401;
case 'Forbidden':
case 'Admin access required':
return 403;
case 'Not Found':
return 404;
case 'Bad Request':
return 400;
case 'Too Many Requests':
return 429;
default:
return 500;
}
}
return 200;
}
+37 -170
View File
@@ -1,184 +1,51 @@
/** /**
* ZEN API Route Handler * ZEN API Route Handler
* *
* This is the main catch-all route handler for the ZEN API under /zen/api/. * Catch-all Next.js App Router handler for all routes under /zen/api/.
* It should be used in a Next.js App Router route at: app/zen/api/[...path]/route.js * Place this file at: app/zen/api/[...path]/route.js
*
* All HTTP methods are handled by a single factory. GET additionally
* supports file streaming responses from the storage endpoint.
*/ */
import { NextResponse } from 'next/server'; import { NextResponse } from 'next/server';
import { routeRequest, getStatusCode } from './router.js'; import { routeRequest } from './router.js';
import { fail } from '../../shared/lib/logger.js'; import { apiError, getStatusCode } from './respond.js';
import { buildFileResponse } from './file-response.js';
import { fail } from '@zen/core/shared/logger';
const GENERIC_ERROR_MSG = 'An unexpected error occurred. Please try again later.';
/** /**
* Handle GET requests * Create a Next.js route handler for a given HTTP method.
*
* @param {boolean} [serveFiles=false] - When true, file streaming responses are
* returned directly instead of being wrapped in JSON. Only GET needs this.
* @returns {Function} Next.js App Router handler
*/ */
export async function GET(request, { params }) { function makeHandler(serveFiles = false) {
try { return async function handler(request, { params }) {
const resolvedParams = await params; try {
const path = resolvedParams.path || []; const path = (await params).path ?? [];
const response = await routeRequest(request, path); const response = await routeRequest(request, path);
// Check if this is a file response (from storage endpoint)
if (response.success && response.file) {
const contentType = response.file.contentType || 'application/octet-stream';
const headers = {
'Content-Type': contentType,
'Content-Length': response.file.contentLength?.toString() || '',
'Cache-Control': 'private, max-age=3600',
'Last-Modified': response.file.lastModified?.toUTCString() || new Date().toUTCString(),
'X-Content-Type-Options': 'nosniff',
'X-Frame-Options': 'DENY',
};
// Always emit an explicit Content-Disposition header — omitting it leaves if (serveFiles && response.success && response.file) {
// rendering decisions to browser heuristics, which varies by content-type return buildFileResponse(response.file);
// and browser version. Image MIME types are served inline (required for
// <img> tags); every other type forces a download to prevent in-browser
// rendering of potentially dangerous content.
const INLINE_MIME_TYPES = new Set([
'image/jpeg', 'image/png', 'image/gif', 'image/webp',
]);
if (INLINE_MIME_TYPES.has(contentType)) {
headers['Content-Disposition'] = 'inline';
} else if (response.file.filename) {
const encoded = encodeURIComponent(response.file.filename);
headers['Content-Disposition'] = `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`;
} else {
headers['Content-Disposition'] = 'attachment';
} }
return new NextResponse(response.file.body, { status: 200, headers }); return NextResponse.json(response, { status: getStatusCode(response) });
} catch (error) {
fail(`API error: ${error.message}`);
return NextResponse.json(
apiError('Internal Server Error', GENERIC_ERROR_MSG),
{ status: 500 }
);
} }
};
// Regular JSON response
const statusCode = getStatusCode(response);
return NextResponse.json(response, {
status: statusCode,
headers: {
'Content-Type': 'application/json'
}
});
} catch (error) {
fail(`API error: ${error.message}`);
return NextResponse.json(
{
error: 'Internal Server Error',
message: 'An unexpected error occurred. Please try again later.'
},
{ status: 500 }
);
}
}
/**
* Handle POST requests
*/
export async function POST(request, { params }) {
try {
const resolvedParams = await params;
const path = resolvedParams.path || [];
const response = await routeRequest(request, path);
const statusCode = getStatusCode(response);
return NextResponse.json(response, {
status: statusCode,
headers: {
'Content-Type': 'application/json'
}
});
} catch (error) {
fail(`API error: ${error.message}`);
return NextResponse.json(
{
error: 'Internal Server Error',
message: 'An unexpected error occurred. Please try again later.'
},
{ status: 500 }
);
}
}
/**
* Handle PUT requests
*/
export async function PUT(request, { params }) {
try {
const resolvedParams = await params;
const path = resolvedParams.path || [];
const response = await routeRequest(request, path);
const statusCode = getStatusCode(response);
return NextResponse.json(response, {
status: statusCode,
headers: {
'Content-Type': 'application/json'
}
});
} catch (error) {
fail(`API error: ${error.message}`);
return NextResponse.json(
{
error: 'Internal Server Error',
message: 'An unexpected error occurred. Please try again later.'
},
{ status: 500 }
);
}
}
/**
* Handle DELETE requests
*/
export async function DELETE(request, { params }) {
try {
const resolvedParams = await params;
const path = resolvedParams.path || [];
const response = await routeRequest(request, path);
const statusCode = getStatusCode(response);
return NextResponse.json(response, {
status: statusCode,
headers: {
'Content-Type': 'application/json'
}
});
} catch (error) {
fail(`API error: ${error.message}`);
return NextResponse.json(
{
error: 'Internal Server Error',
message: 'An unexpected error occurred. Please try again later.'
},
{ status: 500 }
);
}
}
/**
* Handle PATCH requests
*/
export async function PATCH(request, { params }) {
try {
const resolvedParams = await params;
const path = resolvedParams.path || [];
const response = await routeRequest(request, path);
const statusCode = getStatusCode(response);
return NextResponse.json(response, {
status: statusCode,
headers: {
'Content-Type': 'application/json'
}
});
} catch (error) {
fail(`API error: ${error.message}`);
return NextResponse.json(
{
error: 'Internal Server Error',
message: 'An unexpected error occurred. Please try again later.'
},
{ status: 500 }
);
}
} }
export const GET = makeHandler(true);
export const POST = makeHandler();
export const PUT = makeHandler();
export const PATCH = makeHandler();
export const DELETE = makeHandler();
+102 -123
View File
@@ -1,28 +1,33 @@
/** /**
* API Router * API Router
* *
* Generic request router — has no knowledge of specific features. * Orchestre rate limiting, CSRF, enforcement d'auth et dispatch vers les handlers.
* Core handlers and modules self-register their routes; this file * N'a aucune connaissance des features spécifiques — les routes s'auto-enregistrent.
* only orchestrates rate limiting, CSRF, auth enforcement, and dispatch.
* *
* Request lifecycle: * Initialisation requise :
* Appeler configureRouter({ resolveSession }) une fois au démarrage (dans initializeZen)
* avant toute requête. Le resolver est fourni par la feature auth, ce qui garde
* core/api/ libre de tout import feature.
*
* Cycle de vie d'une requête :
* route-handler.js → routeRequest() * route-handler.js → routeRequest()
* → rate limit check (health GET exempt) * → rate limit (routes skipRateLimit exemptées)
* → CSRF origin validation (state-mutating methods only) * → validation CSRF (méthodes state-mutating uniquement)
* → unified route match (core routes first, then module routes) * → matching sur toutes les routes (core en premier, puis features, puis modules)
* → auth enforcement from route definition * → enforcement auth depuis la définition de route
* → handler(request, params, context) * → handler(request, params, context)
*/ */
import { validateSession } from '../../features/auth/lib/session.js'; import { getSessionCookieName } from '@zen/core/shared/config';
import { cookies } from 'next/headers'; import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '@zen/core/shared/rate-limit';
import { getSessionCookieName } from '../../shared/lib/appConfig.js'; import { fail } from '@zen/core/shared/logger';
import { getAllApiRoutes } from '../modules/index.js'; import { hasPermission, PERMISSIONS } from '@zen/core/users';
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../features/auth/lib/rateLimit.js';
import { fail } from '../../shared/lib/logger.js';
import { getCoreRoutes } from './core-routes.js'; import { getCoreRoutes } from './core-routes.js';
import { getFeatureRoutes, getSessionResolver } from './runtime.js';
import { apiError } from './respond.js'; import { apiError } from './respond.js';
export { configureRouter, getSessionResolver, clearRouterConfig } from './runtime.js';
const COOKIE_NAME = getSessionCookieName(); const COOKIE_NAME = getSessionCookieName();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -30,11 +35,11 @@ const COOKIE_NAME = getSessionCookieName();
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Require a valid session. Throws if the request carries no valid cookie. * Exige une session valide. Lève une erreur si aucun cookie valide n'est présent.
* @param {Request} request
* @returns {Promise<Object>} session * @returns {Promise<Object>} session
*/ */
export async function requireAuth(_request) { export async function requireAuth() {
const { cookies } = await import('next/headers');
const cookieStore = await cookies(); const cookieStore = await cookies();
const sessionToken = cookieStore.get(COOKIE_NAME)?.value; const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
@@ -42,7 +47,7 @@ export async function requireAuth(_request) {
throw new Error('Unauthorized'); throw new Error('Unauthorized');
} }
const session = await validateSession(sessionToken); const session = await getSessionResolver()(sessionToken);
if (!session || !session.user) { if (!session || !session.user) {
throw new Error('Unauthorized'); throw new Error('Unauthorized');
@@ -52,14 +57,14 @@ export async function requireAuth(_request) {
} }
/** /**
* Require a valid admin session. Throws if not authenticated or not admin. * Exige une session avec la permission admin.access.
* @param {Request} request
* @returns {Promise<Object>} session * @returns {Promise<Object>} session
*/ */
export async function requireAdmin(_request) { export async function requireAdmin() {
const session = await requireAuth(_request); const session = await requireAuth();
if (session.user.role !== 'admin') { const allowed = await hasPermission(session.user.id, PERMISSIONS.ADMIN_ACCESS);
if (!allowed) {
throw new Error('Admin access required'); throw new Error('Admin access required');
} }
@@ -71,8 +76,8 @@ export async function requireAdmin(_request) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Resolve the canonical application URL from environment variables. * Résout l'URL canonique de l'application depuis les variables d'environnement.
* Priority: NEXT_PUBLIC_URL_DEV (development) → NEXT_PUBLIC_URL (production). * Priorité : NEXT_PUBLIC_URL_DEV (développement) → NEXT_PUBLIC_URL (production).
*/ */
function resolveAppUrl() { function resolveAppUrl() {
if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_URL_DEV) { if (process.env.NODE_ENV === 'development' && process.env.NEXT_PUBLIC_URL_DEV) {
@@ -82,8 +87,8 @@ function resolveAppUrl() {
} }
/** /**
* Verify that state-mutating requests originate from the expected application * Vérifie que les requêtes state-mutating proviennent de l'origine attendue.
* origin. GET, HEAD, and OPTIONS are exempt per RFC 7231. * GET, HEAD et OPTIONS sont exemptés (RFC 7231).
* @param {Request} request * @param {Request} request
* @returns {boolean} * @returns {boolean}
*/ */
@@ -110,7 +115,7 @@ function passesCsrfCheck(request) {
return origin === expectedOrigin; return origin === expectedOrigin;
} }
// No Origin header: fall back to Referer (some older browsers). // Pas d'en-tête Origin : repli sur Referer (anciens navigateurs).
const referer = request.headers.get('referer'); const referer = request.headers.get('referer');
if (referer) { if (referer) {
try { try {
@@ -120,7 +125,7 @@ function passesCsrfCheck(request) {
} }
} }
// Neither Origin nor Referer — deny to be safe. // Ni Origin ni Referer — refus par sécurité.
return false; return false;
} }
@@ -129,15 +134,15 @@ function passesCsrfCheck(request) {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** /**
* Match a route pattern against a request path. * Teste un pattern de route contre un chemin de requête.
* *
* Supports: * Supporte :
* - Exact segments: '/health' * - Segments exacts : '/health'
* - Named params: '/users/:id' * - Paramètres nommés : '/users/:id'
* - Greedy wildcard (end only): '/storage/**' * - Wildcard greedy (fin uniquement) : '/storage/**'
* *
* @param {string} pattern - Route pattern * @param {string} pattern
* @param {string} path - Actual request path (e.g. '/users/42') * @param {string} path
* @returns {boolean} * @returns {boolean}
*/ */
function matchRoute(pattern, path) { function matchRoute(pattern, path) {
@@ -167,11 +172,11 @@ function matchRoute(pattern, path) {
} }
/** /**
* Extract named path parameters (and wildcard) from a matched route. * Extrait les paramètres de chemin nommés (et le wildcard) d'une route matchée.
* *
* @param {string} pattern - Route pattern (e.g. '/users/:id') * @param {string} pattern - Ex. '/users/:id'
* @param {string} path - Actual path (e.g. '/users/42') * @param {string} path - Ex. '/users/42'
* @returns {Object} params — named params + optional `wildcard` string * @returns {Object} params — paramètres nommés + `wildcard` optionnel
*/ */
function extractPathParams(pattern, path) { function extractPathParams(pattern, path) {
const params = {}; const params = {};
@@ -196,37 +201,42 @@ function extractPathParams(pattern, path) {
// Main router // Main router
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Messages safe to surface to clients verbatim. // Messages sûrs à exposer verbatim au client.
const SAFE_AUTH_MESSAGES = new Set(['Unauthorized', 'Admin access required']); const SAFE_AUTH_MESSAGES = new Set(['Unauthorized', 'Admin access required']);
// Emitted at most once per process lifetime to avoid log flooding while still // Émis au plus une fois par lifetime de process pour éviter le log flooding.
// alerting operators that per-IP rate limiting is inactive.
let _rateLimitUnavailableWarned = false; let _rateLimitUnavailableWarned = false;
/** /**
* Route an API request to the appropriate handler. * Route une requête API vers le handler approprié.
* *
* @param {Request} request - Incoming Next.js request * @param {Request} request - Requête Next.js entrante
* @param {string[]} path - Path segments after /zen/api/ * @param {string[]} path - Segments de chemin après /zen/api/
* @returns {Promise<Object>} Response payload (serialised to JSON by route-handler.js) * @returns {Promise<Object>} Payload de réponse (sérialisé en JSON par route-handler.js)
*/ */
export async function routeRequest(request, path) { export async function routeRequest(request, path) {
const method = request.method; const method = request.method;
const pathString = '/' + path.join('/'); const pathString = '/' + path.join('/');
// IP-based rate limit for all API calls. The health endpoint is exempt so // Fusion de toutes les routes — core en premier pour que les built-ins aient priorité.
// that monitoring probes do not consume quota. // Le rate limit est différé après le matching pour pouvoir honorer skipRateLimit
const isHealthCheck = path[0] === 'health' && method === 'GET'; // sans hardcoder de chemins dans le router.
if (!isHealthCheck) { const allRoutes = [...getCoreRoutes(), ...getFeatureRoutes()];
const matchedRoute = allRoutes.find(
route => matchRoute(route.path, pathString) && route.method === method
);
// Rate limit par IP. Les routes avec skipRateLimit: true sont exemptées
// (ex. GET /health pour que les sondes de monitoring ne consomment pas de quota).
if (!matchedRoute?.skipRateLimit) {
const ip = getIpFromRequest(request); const ip = getIpFromRequest(request);
if (ip === 'unknown') { if (ip === 'unknown') {
// Client IP cannot be resolved — applying rate limiting against the // L'IP client ne peut pas être résolue — appliquer le rate limit sur la clé
// shared 'unknown' key would collapse every user's traffic into one // 'unknown' partagée effondrerait tout le trafic dans un seul bucket, permettant
// bucket, allowing a single attacker to exhaust it and deny service to // à un seul attaquant d'épuiser le quota et de dénier le service à tous les autres.
// all other users (global DoS). Rate limiting is therefore suspended // Le rate limiting est donc suspendu jusqu'à ce qu'un reverse proxy de confiance
// until a trusted reverse proxy is configured. // soit configuré avec ZEN_TRUST_PROXY=true.
// Operators must set ZEN_TRUST_PROXY=true once a verified proxy
// (Nginx, Cloudflare, AWS ALB, …) strips and rewrites forwarding headers.
if (!_rateLimitUnavailableWarned) { if (!_rateLimitUnavailableWarned) {
_rateLimitUnavailableWarned = true; _rateLimitUnavailableWarned = true;
fail( fail(
@@ -245,77 +255,46 @@ export async function routeRequest(request, path) {
} }
} }
// CSRF origin validation for state-mutating requests. // Validation CSRF pour les requêtes state-mutating.
if (!passesCsrfCheck(request)) { if (!passesCsrfCheck(request)) {
return apiError('Forbidden', 'CSRF validation failed'); return apiError('Forbidden', 'CSRF validation failed');
} }
// Merge all routes — core first so built-ins take precedence over modules. if (!matchedRoute) {
const allRoutes = [...getCoreRoutes(), ...getAllApiRoutes()]; // Aucune route matchée — message générique sans refléter la méthode ou le chemin
// pour éviter l'énumération de routes.
for (const route of allRoutes) { return apiError('Not Found', 'The requested resource does not exist');
if (!matchRoute(route.path, pathString) || route.method !== method) {
continue;
}
// Enforce auth from the route definition before calling the handler.
const context = {};
try {
if (route.auth === 'admin') {
context.session = await requireAdmin(request);
} else if (route.auth === 'user') {
context.session = await requireAuth(request);
}
// 'public' — context.session remains undefined
} catch (err) {
const code = SAFE_AUTH_MESSAGES.has(err.message) ? err.message : 'Internal Server Error';
if (!SAFE_AUTH_MESSAGES.has(err.message)) {
fail(`Auth error: ${err.message}`);
}
return apiError(code, code);
}
const params = extractPathParams(route.path, pathString);
try {
return await route.handler(request, params, context);
} catch (err) {
fail(`Route handler error [${method} ${route.path}]: ${err.message}`);
return apiError('Internal Server Error', 'An unexpected error occurred. Please try again later.');
}
} }
// No route matched — return a generic message without reflecting the method // Enforcement auth depuis la définition de route, avant d'appeler le handler.
// or path back to the caller to avoid route enumeration. const context = {};
return apiError('Not Found', 'The requested resource does not exist'); try {
} if (matchedRoute.auth === 'admin') {
context.session = await requireAdmin();
// --------------------------------------------------------------------------- if (matchedRoute.permission) {
// HTTP status mapping const allowed = await hasPermission(context.session.user.id, matchedRoute.permission);
// --------------------------------------------------------------------------- if (!allowed) {
return apiError('Forbidden', 'Permission insuffisante');
/** }
* Derive an HTTP status code from the response payload. }
* @param {Object} response } else if (matchedRoute.auth === 'user') {
* @returns {number} context.session = await requireAuth();
*/
export function getStatusCode(response) {
if (response.error) {
switch (response.error) {
case 'Unauthorized':
return 401;
case 'Forbidden':
case 'Admin access required':
return 403;
case 'Not Found':
return 404;
case 'Bad Request':
return 400;
case 'Too Many Requests':
return 429;
default:
return 500;
} }
// 'public' — context.session reste undefined
} catch (err) {
const code = SAFE_AUTH_MESSAGES.has(err.message) ? err.message : 'Internal Server Error';
if (!SAFE_AUTH_MESSAGES.has(err.message)) {
fail(`Auth error: ${err.message}`);
}
return apiError(code, code);
}
const params = extractPathParams(matchedRoute.path, pathString);
try {
return await matchedRoute.handler(request, params, context);
} catch (err) {
fail(`Route handler error [${method} ${matchedRoute.path}]: ${err.message}`);
return apiError('Internal Server Error', 'An unexpected error occurred. Please try again later.');
} }
return 200;
} }
+106
View File
@@ -0,0 +1,106 @@
/**
* API Runtime State
*
* Centralise tout l'état global persisté de l'infrastructure API :
* - Le resolver de session (injecté par la feature auth via configureRouter)
* - Le registre des routes de features (peuplé via registerFeatureRoutes)
*
* Les deux utilisent le même pattern Symbol.for() + globalThis pour survivre
* aux hot-reloads Next.js sans réinitialiser l'état entre les recharges de modules.
*
* LIMITATION CONNUE : l'état est local au process. Dans un déploiement multi-worker
* ou serverless, chaque instance maintient son propre état. Pour les routes, cela
* implique que initializeZen() doit être appelé une fois par worker — ce qui est
* déjà le cas via instrumentation.js.
*/
// ---------------------------------------------------------------------------
// Session resolver
// ---------------------------------------------------------------------------
const RESOLVER_KEY = Symbol.for('__ZEN_SESSION_RESOLVER__');
/**
* Configure le router avec les dépendances de la feature auth.
* Doit être appelé une fois au démarrage avant toute requête.
*
* @param {{ resolveSession: (token: string) => Promise<Object|null> }} config
*/
export function configureRouter({ resolveSession }) {
if (typeof resolveSession !== 'function') {
throw new TypeError('configureRouter: resolveSession must be a function');
}
globalThis[RESOLVER_KEY] = resolveSession;
}
/**
* Retourne le resolver de session configuré.
* Utilisé par router.js et core/storage/api.js.
*
* @returns {(token: string) => Promise<Object|null>}
* @throws {Error} Si configureRouter n'a pas encore été appelé
*/
export function getSessionResolver() {
const resolver = globalThis[RESOLVER_KEY];
if (!resolver) {
throw new Error(
'Router not configured: call configureRouter({ resolveSession }) during initializeZen() before handling requests.'
);
}
return resolver;
}
/**
* Efface le resolver injecté.
* Destiné aux tests ou à la réinitialisation manuelle.
*/
export function clearRouterConfig() {
globalThis[RESOLVER_KEY] = undefined;
}
// ---------------------------------------------------------------------------
// Feature routes registry
// ---------------------------------------------------------------------------
const REGISTRY_KEY = Symbol.for('__ZEN_FEATURE_ROUTES__');
if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = [];
/** @type {Array} */
const _featureRoutes = globalThis[REGISTRY_KEY];
/**
* Enregistre des routes API.
* Appelé une fois par feature core ou module externe pendant initializeZen()
* ou depuis le hook register() d'un module.
*
* @param {ReadonlyArray} routes - Définitions produites par defineApiRoutes()
*/
export function registerApiRoutes(routes) {
if (!Array.isArray(routes)) {
throw new TypeError('registerApiRoutes: routes must be an array');
}
_featureRoutes.push(...routes);
}
/**
* Alias rétro-compatible de registerApiRoutes.
* @deprecated Utiliser registerApiRoutes.
*/
export const registerFeatureRoutes = registerApiRoutes;
/**
* Retourne toutes les routes de features enregistrées.
* Appelé à chaque requête par le router pour construire la liste complète.
*
* @returns {ReadonlyArray}
*/
export function getFeatureRoutes() {
return _featureRoutes;
}
/**
* Vide toutes les routes de features enregistrées.
* Destiné aux tests ou à la réinitialisation de l'état ZEN.
*/
export function clearFeatureRoutes() {
_featureRoutes.length = 0;
}
+132
View File
@@ -0,0 +1,132 @@
# Cron Framework
Ce répertoire est un **wrapper générique autour de `node-cron`**. Il ne connaît aucune tâche spécifique — les modules et features enregistrent leurs propres jobs. Ajouter un nouveau job ne nécessite jamais de modifier `src/core/cron/`.
---
## Structure
```
src/core/cron/
└── index.js schedule, stop, stopAll, trigger, validate, isRunning, getJobs, getStatus
```
---
## Import
```js
import { schedule, stop, trigger } from '@zen/core/cron';
```
---
## API
### `schedule(name, cronSchedule, handler, options?)`
Enregistre un job. Si un job du même nom existe déjà, il est stoppé et remplacé.
```js
schedule('daily-report', '0 9 * * *', async () => {
await sendReport();
});
schedule('every-5min', '*/5 * * * *', async () => {
await syncData();
}, { timezone: 'America/New_York', runOnInit: true });
```
| Paramètre | Type | Description |
|-----------|------|-------------|
| `name` | `string` | Nom unique du job |
| `cronSchedule` | `string` | Expression cron (5 ou 6 champs) |
| `handler` | `async Function` | Fonction exécutée à chaque déclenchement |
| `options.timezone` | `string` | Timezone IANA (défaut : `ZEN_TIMEZONE` ou `America/Toronto`) |
| `options.runOnInit` | `boolean` | Exécuter immédiatement à l'enregistrement (défaut : `false`) |
Retourne l'instance `node-cron` task.
---
### `stop(name)`
Stoppe et supprime un job par son nom. Retourne `true` si le job existait, `false` sinon.
### `stopAll()`
Stoppe et supprime tous les jobs enregistrés.
### `trigger(name)`
Déclenche manuellement un job sans attendre son prochain tick. Lève une `Error` si le job n'existe pas.
```js
await trigger('daily-report');
```
### `validate(expression)`
Valide une expression cron. Retourne `boolean`.
### `isRunning(name)`
Vérifie si un job est actuellement enregistré. Retourne `boolean`.
### `getJobs()`
Retourne la liste des noms de tous les jobs enregistrés (`string[]`).
### `getStatus()`
Retourne les métadonnées de tous les jobs enregistrés.
```js
{
'daily-report': {
schedule: '0 9 * * *',
timezone: 'America/Toronto',
registeredAt: '2026-04-24T09:00:00.000Z'
}
}
```
---
## Enregistrer un job depuis un module
Les jobs vivent **avec leur feature ou module**, pas dans le framework. Enregistrer un job dans `initializeZen()` (`src/shared/lib/init.js`) :
```js
// src/modules/mymodule/cron.js
import { schedule } from '@zen/core/cron';
export function registerCronJobs() {
schedule('mymodule-sync', '*/15 * * * *', async () => {
await syncMyModule();
});
}
```
```js
// src/shared/lib/init.js
import { registerCronJobs } from '../../modules/mymodule/cron.js';
registerCronJobs();
```
---
## Comportement Hot-Reload
Les jobs sont stockés dans `globalThis[Symbol.for('__ZEN_CRON_JOBS__')]` — un store partagé qui survit aux invalidations de cache de modules de Next.js. Un job enregistré deux fois (hot-reload) remplace silencieusement l'ancien plutôt que de créer un doublon.
---
## Gestion des erreurs
Les erreurs levées par un handler sont interceptées et loguées via `fail()` — elles ne font jamais crasher le processus.
```
✗ Cron daily-report: Connection timeout
```
+118 -90
View File
@@ -1,19 +1,28 @@
/** /**
* Cron Utility * Cron Utility
* Wrapper around node-cron for scheduling tasks * Wrapper around node-cron for scheduling tasks
* *
* Usage in modules: * Usage in modules:
* import { schedule, validate } from '@zen/core/cron'; * import { schedule, validate } from '@zen/core/cron';
*/ */
import cron from 'node-cron'; import cron from 'node-cron';
import { done, fail, info } from '../../shared/lib/logger.js'; import { done, fail, info } from '@zen/core/shared/logger';
// Store for all scheduled cron jobs // Shared store — survives Next.js hot-reload and module-cache invalidation
const CRON_JOBS_KEY = Symbol.for('__ZEN_CRON_JOBS__'); const CRON_JOBS_KEY = Symbol.for('__ZEN_CRON_JOBS__');
/** /**
* Initialize cron jobs storage * @typedef {Object} CronEntry
* @property {Object} job - node-cron task instance
* @property {Function} handler - original handler function
* @property {string} schedule - cron expression
* @property {string} timezone - timezone used
* @property {string} registeredAt - ISO timestamp of registration
*/
/**
* @returns {Map<string, CronEntry>}
*/ */
function getJobsStorage() { function getJobsStorage() {
if (!globalThis[CRON_JOBS_KEY]) { if (!globalThis[CRON_JOBS_KEY]) {
@@ -23,161 +32,180 @@ function getJobsStorage() {
} }
/** /**
* Schedule a cron job * Schedule a cron job.
* @param {string} name - Unique name for the job *
* @param {string} schedule - Cron schedule expression * If a job with the same name already exists it is stopped and replaced.
* @param {Function} handler - Handler function to execute *
* @param {Object} options - Options * @param {string} name - Unique name for the job
* @param {string} options.timezone - Timezone (default: from env or America/Toronto) * @param {string} cronSchedule - Cron expression (5 or 6 fields)
* @param {boolean} options.runOnInit - Run immediately on schedule (default: false) * @param {Function} handler - Async function to execute
* @returns {Object} Cron job instance * @param {Object} [options]
* * @param {string} [options.timezone] - IANA timezone (default: ZEN_TIMEZONE env or America/Toronto)
* @param {boolean} [options.runOnInit] - Run immediately when scheduled (default: false)
* @returns {Object} node-cron task instance
*
* @example * @example
* schedule('my-task', '0 9 * * *', async () => { * schedule('daily-report', '0 9 * * *', async () => {
* console.log('Running every day at 9 AM'); * await sendReport();
* }); * });
* *
* @example * @example
* schedule('reminder', ''\''*\/5 5-17 * * *'\'', async () => { * schedule('every-5min', '*\/5 * * * *', async () => {
* console.log('Every 5 minutes between 5 AM and 5 PM'); * await syncData();
* }, { timezone: 'America/New_York' }); * }, { timezone: 'America/New_York' });
*/ */
export function schedule(name, cronSchedule, handler, options = {}) { export function schedule(name, cronSchedule, handler, options = {}) {
if (!name || typeof name !== 'string') {
throw new Error('Cron job name must be a non-empty string');
}
if (!validate(cronSchedule)) {
throw new Error(`Invalid cron expression: "${cronSchedule}"`);
}
if (typeof handler !== 'function') {
throw new Error('Cron job handler must be a function');
}
const jobs = getJobsStorage(); const jobs = getJobsStorage();
// Stop existing job with same name // Replace existing job with same name
if (jobs.has(name)) { if (jobs.has(name)) {
jobs.get(name).stop(); jobs.get(name).job.stop();
info(`Cron replaced: ${name}`); info(`Cron replaced: ${name}`);
} }
const timezone = options.timezone || process.env.ZEN_TIMEZONE || 'America/Toronto'; const timezone = options.timezone || process.env.ZEN_TIMEZONE || 'America/Toronto';
const job = cron.schedule(cronSchedule, async () => { const job = cron.schedule(cronSchedule, async () => {
info(`Cron ${name} running at ${new Date().toISOString()}`);
try { try {
await handler(); await handler();
info(`Cron ${name} completed`);
} catch (error) { } catch (error) {
fail(`Cron ${name}: ${error.message}`); fail(`Cron ${name}: ${error.message}`);
} }
}, { }, { timezone });
scheduled: true,
if (options.runOnInit) {
handler().catch((error) => fail(`Cron ${name}: ${error.message}`));
}
jobs.set(name, {
job,
handler,
schedule: cronSchedule,
timezone, timezone,
runOnInit: options.runOnInit || false registeredAt: new Date().toISOString()
}); });
jobs.set(name, job);
done(`Cron scheduled: ${name} (${cronSchedule})`); done(`Cron scheduled: ${name} (${cronSchedule})`);
return job; return job;
} }
/** /**
* Stop a scheduled cron job * Stop and remove a scheduled cron job.
*
* @param {string} name - Job name * @param {string} name - Job name
* @returns {boolean} True if job was stopped * @returns {boolean} True if the job existed and was stopped
*/ */
export function stop(name) { export function stop(name) {
const jobs = getJobsStorage(); const jobs = getJobsStorage();
if (jobs.has(name)) { if (jobs.has(name)) {
jobs.get(name).stop(); jobs.get(name).job.stop();
jobs.delete(name); jobs.delete(name);
info(`Cron stopped: ${name}`); info(`Cron stopped: ${name}`);
return true; return true;
} }
return false; return false;
} }
/** /**
* Stop all cron jobs * Stop and remove all scheduled cron jobs.
*/ */
export function stopAll() { export function stopAll() {
const jobs = getJobsStorage(); const jobs = getJobsStorage();
for (const [name, job] of jobs.entries()) { for (const [name, entry] of jobs.entries()) {
job.stop(); entry.job.stop();
info(`Cron stopped: ${name}`); info(`Cron stopped: ${name}`);
} }
jobs.clear(); jobs.clear();
done('All cron jobs stopped');
} }
/** /**
* Get status of all cron jobs * Manually trigger a job by name, bypassing its schedule.
* @returns {Object} Status of all jobs *
*/
export function getStatus() {
const jobs = getJobsStorage();
const status = {};
for (const [name] of jobs.entries()) {
status[name] = { running: true };
}
return status;
}
/**
* Check if a cron job is running
* @param {string} name - Job name * @param {string} name - Job name
* @returns {boolean} * @returns {Promise<void>}
* @throws {Error} If the job does not exist
*/ */
export function isRunning(name) { export async function trigger(name) {
const jobs = getJobsStorage(); const jobs = getJobsStorage();
return jobs.has(name);
if (!jobs.has(name)) {
throw new Error(`Cron job '${name}' not found`);
}
info(`Cron manual trigger: ${name}`);
await jobs.get(name).handler();
} }
/** /**
* Validate a cron expression * Validate a cron expression.
* @param {string} expression - Cron expression to validate *
* @returns {boolean} True if valid * @param {string} expression
* @returns {boolean}
*/ */
export function validate(expression) { export function validate(expression) {
return cron.validate(expression); return cron.validate(expression);
} }
/** /**
* Get list of all scheduled job names * Check whether a job is currently scheduled.
* @returns {string[]} Array of job names *
* @param {string} name - Job name
* @returns {boolean}
*/ */
export function getJobs() { export function isRunning(name) {
const jobs = getJobsStorage(); return getJobsStorage().has(name);
return Array.from(jobs.keys());
} }
/** /**
* Manually trigger a job by name * Return the names of all scheduled jobs.
* @param {string} name - Job name *
* @returns {Promise<void>} * @returns {string[]}
*/ */
export async function trigger(name) { export function getJobs() {
const jobs = getJobsStorage(); return Array.from(getJobsStorage().keys());
if (!jobs.has(name)) {
throw new Error(`Cron job '${name}' not found`);
}
info(`Cron manual trigger: ${name}`);
// Note: node-cron doesn't expose the handler directly,
// so modules should keep their handler function accessible
} }
// Re-export the raw cron module for advanced usage /**
export { cron }; * Return metadata for all scheduled jobs.
*
* @returns {Object.<string, { schedule: string, timezone: string, registeredAt: string }>}
*/
export function getStatus() {
const status = {};
for (const [name, entry] of getJobsStorage().entries()) {
status[name] = {
schedule: entry.schedule,
timezone: entry.timezone,
registeredAt: entry.registeredAt
};
}
return status;
}
// Default export for convenience
export default { export default {
schedule, schedule,
stop, stop,
stopAll, stopAll,
getStatus,
isRunning,
validate,
getJobs,
trigger, trigger,
cron validate,
isRunning,
getJobs,
getStatus
}; };
+292
View File
@@ -0,0 +1,292 @@
# Database
Ce répertoire est le **module de base de données**. Il fournit une couche d'accès PostgreSQL générique : connexion, requêtes paramétrées, transactions et helpers CRUD. Il ne connaît aucune feature spécifique — les features l'importent pour leurs propres besoins.
---
## Structure
```
src/core/database/
├── index.js Exports publics (query, transaction, create, find, update…)
├── db.js Pool de connexion, DatabaseError, fonctions de requête bas niveau
└── crud.js Helpers CRUD (create, find, update, delete, count, exists…)
```
---
## Variables d'environnement
| Variable | Rôle |
|----------|------|
| `ZEN_DATABASE_URL` | URL de connexion PostgreSQL (tous environnements) |
| `ZEN_DATABASE_URL_DEV` | URL de connexion en développement (prioritaire sur `ZEN_DATABASE_URL` si `NODE_ENV=development`) |
| `ZEN_DB_SSL_DISABLED` | Mettre à `true` pour désactiver TLS entièrement (loopback local uniquement) |
### Politique TLS
| Environnement | Comportement |
|---------------|-------------|
| `production` | TLS activé, vérification du certificat serveur (`rejectUnauthorized: true`) |
| Autres (`development`, `test`…) | TLS activé, vérification désactivée (accepte les certificats auto-signés) |
| `ZEN_DB_SSL_DISABLED=true` | TLS désactivé (usage local uniquement) |
---
## DatabaseError
Toutes les opérations base de données lèvent une `DatabaseError` en cas d'échec. Elle expose uniquement un message générique et le code d'erreur PostgreSQL (SQLSTATE), sans jamais retourner de nom de table, de contrainte ou de fragment SQL.
```js
import { DatabaseError } from '@zen/core/database';
try {
await create('users', { email });
} catch (error) {
if (error instanceof DatabaseError && error.code === '23505') {
// Violation de contrainte unique (ex: email déjà utilisé)
}
}
```
Codes SQLSTATE courants :
| Code | Signification |
|------|--------------|
| `23505` | Violation de contrainte unique (`UNIQUE`) |
| `23503` | Violation de contrainte de clé étrangère (`FOREIGN KEY`) |
| `23502` | Violation de contrainte `NOT NULL` |
---
## Fonctions de requête bas niveau (`db.js`)
### `query(sql, params?)`
Exécute une requête SQL paramétrée et retourne l'objet résultat complet `pg`.
```js
const result = await query('SELECT * FROM users WHERE id = $1', [userId]);
// result.rows, result.rowCount…
```
### `queryOne(sql, params?)`
Retourne la première ligne ou `null`.
```js
const user = await queryOne('SELECT * FROM users WHERE email = $1', [email]);
```
### `queryAll(sql, params?)`
Retourne toutes les lignes sous forme de tableau.
```js
const posts = await queryAll('SELECT * FROM posts WHERE published = true');
```
### `transaction(callback)`
Exécute plusieurs requêtes dans une transaction atomique. Rollback automatique en cas d'erreur.
```js
const result = await transaction(async (client) => {
const post = await client.query(
'INSERT INTO posts (title) VALUES ($1) RETURNING *',
['Mon article']
);
await client.query(
'INSERT INTO post_tags (post_id, tag) VALUES ($1, $2)',
[post.rows[0].id, 'actu']
);
return post.rows[0];
});
```
### `testConnection()`
Vérifie que la connexion à la base est établie. Retourne `true` ou `false`.
### `tableExists(tableName)`
Vérifie si une table existe dans le schéma `public`. Retourne `true` ou `false`.
```js
if (!(await tableExists('users'))) {
// table absente — migration non appliquée
}
```
---
## Helpers CRUD (`crud.js`)
Toutes les fonctions CRUD construisent du SQL **entièrement paramétré**. Les noms de tables et de colonnes passent par `safeIdentifier()` — injection SQL impossible.
### `create(tableName, data, options?)`
Insère un enregistrement et retourne la ligne créée (via `RETURNING *`).
```js
const user = await create('users', { email, name, role: 'user' });
```
Option `allowedColumns` (recommandée) : liste blanche explicite des colonnes autorisées. Toute clé absente de cette liste lève immédiatement une erreur.
```js
const user = await create('users', body, {
allowedColumns: ['email', 'name'],
// 'role' absent → une tentative de mass-assignment lèvera une erreur
});
```
### `findById(tableName, id, idColumn?)`
Retourne l'enregistrement correspondant à l'identifiant, ou `null`.
```js
const post = await findById('posts', 42);
const user = await findById('users', slug, 'slug');
```
### `find(tableName, conditions?, options?)`
Retourne un tableau d'enregistrements correspondant aux conditions.
```js
const published = await find('posts', { published: true }, {
orderBy: 'created_at DESC',
limit: 10,
offset: 0,
});
```
Options disponibles :
| Option | Type | Contrainte |
|--------|------|-----------|
| `orderBy` | `string` | `"colonne"` ou `"colonne ASC\|DESC"` |
| `limit` | `number` | Entier entre 1 et 10 000 |
| `offset` | `number` | Entier ≥ 0 |
### `findOne(tableName, conditions)`
Retourne le premier enregistrement correspondant, ou `null`.
```js
const user = await findOne('users', { email });
```
### `updateById(tableName, id, data, idColumn?, options?)`
Met à jour un enregistrement par identifiant et retourne la ligne mise à jour, ou `null` si introuvable.
```js
const updated = await updateById('users', userId, { name: 'Alice' });
// Avec whitelist
const updated = await updateById('users', userId, body, 'id', {
allowedColumns: ['name', 'avatar_url'],
});
```
### `update(tableName, conditions, data, options?)`
Met à jour tous les enregistrements correspondant aux conditions. **Au moins une condition est obligatoire** — une mise à jour sans condition lève immédiatement une erreur.
```js
const rows = await update('posts', { author_id: userId }, { published: false });
```
### `deleteById(tableName, id, idColumn?)`
Supprime un enregistrement par identifiant. Retourne `true` si supprimé, `false` sinon.
```js
const deleted = await deleteById('posts', postId);
```
### `deleteWhere(tableName, conditions)`
Supprime tous les enregistrements correspondant aux conditions. **Au moins une condition est obligatoire** — une suppression sans condition lève immédiatement une erreur. Retourne le nombre de lignes supprimées.
```js
const count = await deleteWhere('sessions', { user_id: userId });
```
### `count(tableName, conditions?)`
Retourne le nombre d'enregistrements correspondant aux conditions (ou le total si aucune condition).
```js
const total = await count('users');
const admins = await count('users', { role: 'admin' });
```
### `exists(tableName, conditions)`
Retourne `true` si au moins un enregistrement correspond aux conditions.
```js
const taken = await exists('users', { email });
```
---
## Utilitaires de sécurité
### `safeIdentifier(name)`
Valide et encadre de guillemets doubles un identifiant SQL (nom de table ou de colonne). Autorise uniquement `[A-Za-z_][A-Za-z0-9_]*`, longueur max 63. Lève une erreur pour tout identifiant invalide.
```js
safeIdentifier('users') // → '"users"'
safeIdentifier('my-table') // → Error: SQL identifier contains disallowed characters
```
### `safeOrderBy(orderBy)`
Valide une expression `ORDER BY` : `"colonne"` ou `"colonne ASC|DESC"`. Utilise `safeIdentifier` en interne.
```js
safeOrderBy('created_at DESC') // → '"created_at" DESC'
safeOrderBy('1=1; DROP TABLE') // → Error
```
### `filterAllowedColumns(data, allowedColumns?)`
Filtre un objet de données selon une liste blanche de colonnes. Sans liste blanche, retourne l'objet tel quel. Avec liste blanche, toute clé absente lève immédiatement une erreur.
```js
filterAllowedColumns({ name: 'Alice', role: 'admin' }, ['name'])
// → Error: Column "role" is not in the permitted columns list
```
---
## Usage depuis une feature
```js
// src/features/myfeature/db.js
import { create, find, updateById, deleteById, exists } from '../../core/database/index.js';
export async function createItem(data) {
return create('items', data, { allowedColumns: ['title', 'content', 'author_id'] });
}
export async function getPublishedItems() {
return find('items', { published: true }, { orderBy: 'created_at DESC', limit: 50 });
}
```
## Usage depuis un module
```js
// src/modules/mymodule/crud.js
import { create, findById } from '@zen/core/database';
export async function createEntry(data) {
return create('mymodule_entries', data, { allowedColumns: ['title', 'slug'] });
}
```
@@ -16,15 +16,12 @@ dotenv.config({ path: resolve(process.cwd(), '.env.local') });
// The CLI always runs locally, so default to development to use ZEN_DATABASE_URL_DEV if set // The CLI always runs locally, so default to development to use ZEN_DATABASE_URL_DEV if set
process.env.NODE_ENV = process.env.NODE_ENV || 'development'; process.env.NODE_ENV = process.env.NODE_ENV || 'development';
import { initDatabase, dropAuthTables, testConnection, closePool } from '../core/database/index.js'; import { testConnection, closePool } from './index.js';
import readline from 'readline'; import readline from 'readline';
import { step, done, warn, fail } from '../shared/lib/logger.js'; import { step, done, warn, fail } from '@zen/core/shared/logger';
async function runCLI() { function printHelp() {
const command = process.argv[2]; console.log(`
if (!command) {
console.log(`
Zen Database CLI Zen Database CLI
Usage: Usage:
@@ -33,22 +30,48 @@ Usage:
Commands: Commands:
init Initialize database (create all required tables) init Initialize database (create all required tables)
test Test database connection test Test database connection
drop Drop all authentication tables (DANGER!) drop Drop all tables (DANGER!)
help Show this help message help Show this help message
Example: Example:
npx zen-db init npx zen-db init
`); `);
}
/**
* Prompt the user for a confirmation answer.
* @param {string} question
* @returns {Promise<string>} The trimmed, lowercased answer
*/
function askConfirmation(question) {
return new Promise((resolve) => {
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
rl.question(question, (answer) => {
rl.close();
resolve(answer.trim().toLowerCase());
});
});
}
async function runCLI() {
const command = process.argv[2];
if (!command) {
printHelp();
process.exit(0); process.exit(0);
} }
try { try {
switch (command) { switch (command) {
case 'init': case 'init': {
step('Initializing database...'); step('Initializing database...');
const result = await initDatabase();
done(`Created ${result.created.length} tables, skipped ${result.skipped.length} existing tables`); const { initFeatures } = await import('../../features/init.js');
const featuresResult = await initFeatures();
done(`DB ready — ${featuresResult.created.length} tables created, ${featuresResult.skipped.length} skipped`);
break; break;
}
case 'test': case 'test':
step('Testing database connection...'); step('Testing database connection...');
@@ -61,40 +84,22 @@ Example:
} }
break; break;
case 'drop': case 'drop': {
warn('This will delete all authentication tables!'); warn('This will delete all tables!');
process.stdout.write(' Type "yes" to confirm or Ctrl+C to cancel...\n'); process.stdout.write(' Type "yes" to confirm or Ctrl+C to cancel...\n');
const answer = await askConfirmation('Confirm (yes/no): ');
const rl = readline.createInterface({ if (answer === 'yes') {
input: process.stdin, const { dropFeatures } = await import('../../features/init.js');
output: process.stdout await dropFeatures();
}); done('Tables dropped successfully');
} else {
rl.question('Confirm (yes/no): ', async (answer) => { warn('Operation cancelled');
if (answer.toLowerCase() === 'yes') { }
await dropAuthTables(); break;
done('Tables dropped successfully'); }
} else {
warn('Operation cancelled');
}
rl.close();
process.exit(0);
});
return; // Don't close the process yet
case 'help': case 'help':
console.log(` printHelp();
Zen Database CLI
Commands:
init Initialize database (create all required tables)
test Test database connection
drop Drop all authentication tables (DANGER!)
help Show this help message
Usage:
npx zen-db <command>
`);
break; break;
default: default:
@@ -103,12 +108,12 @@ Usage:
process.exit(1); process.exit(1);
} }
// Close the database connection pool
await closePool(); await closePool();
process.exit(0); process.exit(0);
} catch (error) { } catch (error) {
fail(`Error: ${error.message}`); fail(`Error: ${error.message}`);
await closePool();
process.exit(1); process.exit(1);
} }
} }
+87 -36
View File
@@ -3,7 +3,31 @@
* Provides convenient methods for Create, Read, Update, Delete operations * Provides convenient methods for Create, Read, Update, Delete operations
*/ */
import { query, queryOne, queryAll } from './db.js'; import { query, queryOne, queryAll } from './db.server.js';
/**
* Filter a data object to only the columns present in allowedColumns.
* If allowedColumns is omitted or empty the original object is returned unchanged
* (backward-compatible default). When a whitelist IS provided, any key not in the
* list causes an immediate throw — mass-assignment of privileged columns (e.g.
* role, email_verified) is therefore impossible when callers supply a whitelist.
* @param {Object} data - Raw data object to filter
* @param {string[]|undefined} allowedColumns - Explicit column whitelist
* @returns {Object} Filtered data object
* @throws {Error} If data contains a column not present in allowedColumns
*/
function filterAllowedColumns(data, allowedColumns) {
if (!allowedColumns || allowedColumns.length === 0) return data;
const allowed = new Set(allowedColumns);
const filtered = {};
for (const key of Object.keys(data)) {
if (!allowed.has(key)) {
throw new Error(`Column "${key}" is not in the permitted columns list`);
}
filtered[key] = data[key];
}
return filtered;
}
/** /**
* Validate and safely double-quote a single SQL identifier (table name, column name). * Validate and safely double-quote a single SQL identifier (table name, column name).
@@ -49,16 +73,39 @@ function safeOrderBy(orderBy) {
return col; return col;
} }
/**
* Build a parameterized WHERE clause from a conditions object.
* @param {Object} conditions - Column/value pairs to match
* @param {number} startIndex - First $N placeholder index (default: 1)
* @returns {{ clause: string, values: Array }} SQL fragment and bound values
*/
function buildWhere(conditions, startIndex = 1) {
const values = [];
const clauses = Object.keys(conditions).map((key, index) => {
values.push(conditions[key]);
return `${safeIdentifier(key)} = $${startIndex + index}`;
});
return { clause: clauses.join(' AND '), values };
}
/** /**
* Insert a new record into a table * Insert a new record into a table
* @param {string} tableName - Name of the table * @param {string} tableName - Name of the table
* @param {Object} data - Object with column names as keys and values to insert * @param {Object} data - Object with column names as keys and values to insert
* @param {Object} [options={}] - Options
* @param {string[]} [options.allowedColumns] - Whitelist of permitted column names;
* any key in data not present in this list throws immediately. Omit only when the
* data object is already fully trusted and caller-constructed.
* @returns {Promise<Object>} Inserted record with all fields * @returns {Promise<Object>} Inserted record with all fields
*/ */
async function create(tableName, data) { async function create(tableName, data, { allowedColumns } = {}) {
if (!data || Object.keys(data).length === 0) {
throw new Error('create() requires at least one data field');
}
const safeData = filterAllowedColumns(data, allowedColumns);
const safeTable = safeIdentifier(tableName); const safeTable = safeIdentifier(tableName);
const columns = Object.keys(data).map(safeIdentifier); const columns = Object.keys(safeData).map(safeIdentifier);
const values = Object.values(data); const values = Object.values(safeData);
const placeholders = values.map((_, index) => `$${index + 1}`).join(', '); const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
const sql = ` const sql = `
@@ -98,11 +145,9 @@ async function find(tableName, conditions = {}, options = {}) {
// Build WHERE clause — column names are validated via safeIdentifier // Build WHERE clause — column names are validated via safeIdentifier
if (Object.keys(conditions).length > 0) { if (Object.keys(conditions).length > 0) {
const whereConditions = Object.keys(conditions).map((key, index) => { const { clause, values: whereValues } = buildWhere(conditions);
values.push(conditions[key]); values.push(...whereValues);
return `${safeIdentifier(key)} = $${index + 1}`; sql += ` WHERE ${clause}`;
});
sql += ` WHERE ${whereConditions.join(' AND ')}`;
} }
// Add ORDER BY — validated and quoted via safeOrderBy // Add ORDER BY — validated and quoted via safeOrderBy
@@ -149,14 +194,21 @@ async function findOne(tableName, conditions) {
* @param {string} tableName - Name of the table * @param {string} tableName - Name of the table
* @param {number|string} id - ID of the record * @param {number|string} id - ID of the record
* @param {Object} data - Object with column names as keys and new values * @param {Object} data - Object with column names as keys and new values
* @param {string} idColumn - Name of the ID column (default: 'id') * @param {string} [idColumn='id'] - Name of the ID column
* @param {Object} [options={}] - Options
* @param {string[]} [options.allowedColumns] - Whitelist of permitted column names;
* any key in data not in this list throws immediately.
* @returns {Promise<Object|null>} Updated record or null if not found * @returns {Promise<Object|null>} Updated record or null if not found
*/ */
async function updateById(tableName, id, data, idColumn = 'id') { async function updateById(tableName, id, data, idColumn = 'id', { allowedColumns } = {}) {
if (!data || Object.keys(data).length === 0) {
throw new Error('updateById() requires at least one data field');
}
const safeData = filterAllowedColumns(data, allowedColumns);
const safeTable = safeIdentifier(tableName); const safeTable = safeIdentifier(tableName);
const safeIdCol = safeIdentifier(idColumn); const safeIdCol = safeIdentifier(idColumn);
const columns = Object.keys(data).map(safeIdentifier); const columns = Object.keys(safeData).map(safeIdentifier);
const values = Object.values(data); const values = Object.values(safeData);
const setClause = columns.map((col, index) => `${col} = $${index + 1}`).join(', '); const setClause = columns.map((col, index) => `${col} = $${index + 1}`).join(', ');
@@ -176,34 +228,37 @@ async function updateById(tableName, id, data, idColumn = 'id') {
* @param {string} tableName - Name of the table * @param {string} tableName - Name of the table
* @param {Object} conditions - Object with column names as keys and values to match * @param {Object} conditions - Object with column names as keys and values to match
* @param {Object} data - Object with column names as keys and new values * @param {Object} data - Object with column names as keys and new values
* @param {Object} [options={}] - Options
* @param {string[]} [options.allowedColumns] - Whitelist of permitted column names;
* any key in data not in this list throws immediately.
* @returns {Promise<Array>} Array of updated records * @returns {Promise<Array>} Array of updated records
*/ */
async function update(tableName, conditions, data) { async function update(tableName, conditions, data, { allowedColumns } = {}) {
// Reject unconditional updates — a missing WHERE clause would silently mutate // Reject unconditional updates — a missing WHERE clause would silently mutate
// every row in the table. Callers must always supply at least one condition. // every row in the table. Callers must always supply at least one condition.
if (!conditions || Object.keys(conditions).length === 0) { if (!conditions || Object.keys(conditions).length === 0) {
throw new Error('update() requires at least one condition to prevent full-table mutation'); throw new Error('update() requires at least one condition to prevent full-table mutation');
} }
if (!data || Object.keys(data).length === 0) {
throw new Error('update() requires at least one data field');
}
const safeData = filterAllowedColumns(data, allowedColumns);
const safeTable = safeIdentifier(tableName); const safeTable = safeIdentifier(tableName);
const dataColumns = Object.keys(data).map(safeIdentifier); const dataColumns = Object.keys(safeData).map(safeIdentifier);
const dataValues = Object.values(data); const dataValues = Object.values(safeData);
const setClause = dataColumns.map((col, index) => `${col} = $${index + 1}`).join(', '); const setClause = dataColumns.map((col, index) => `${col} = $${index + 1}`).join(', ');
let paramIndex = dataValues.length + 1; const { clause: whereClause, values: whereValues } = buildWhere(conditions, dataValues.length + 1);
const whereConditions = Object.keys(conditions).map((key) => {
dataValues.push(conditions[key]);
return `${safeIdentifier(key)} = $${paramIndex++}`;
});
const sql = ` const sql = `
UPDATE ${safeTable} UPDATE ${safeTable}
SET ${setClause} SET ${setClause}
WHERE ${whereConditions.join(' AND ')} WHERE ${whereClause}
RETURNING * RETURNING *
`; `;
const result = await query(sql, dataValues); const result = await query(sql, [...dataValues, ...whereValues]);
return result.rows; return result.rows;
} }
@@ -232,13 +287,8 @@ async function deleteWhere(tableName, conditions) {
if (!conditions || Object.keys(conditions).length === 0) { if (!conditions || Object.keys(conditions).length === 0) {
throw new Error('deleteWhere() requires at least one condition to prevent full-table deletion'); throw new Error('deleteWhere() requires at least one condition to prevent full-table deletion');
} }
const values = []; const { clause, values } = buildWhere(conditions);
const whereConditions = Object.keys(conditions).map((key, index) => { const sql = `DELETE FROM ${safeIdentifier(tableName)} WHERE ${clause} RETURNING *`;
values.push(conditions[key]);
return `${safeIdentifier(key)} = $${index + 1}`;
});
const sql = `DELETE FROM ${safeIdentifier(tableName)} WHERE ${whereConditions.join(' AND ')} RETURNING *`;
const result = await query(sql, values); const result = await query(sql, values);
return result.rowCount; return result.rowCount;
} }
@@ -251,14 +301,12 @@ async function deleteWhere(tableName, conditions) {
*/ */
async function count(tableName, conditions = {}) { async function count(tableName, conditions = {}) {
let sql = `SELECT COUNT(*) as count FROM ${safeIdentifier(tableName)}`; let sql = `SELECT COUNT(*) as count FROM ${safeIdentifier(tableName)}`;
const values = []; let values = [];
if (Object.keys(conditions).length > 0) { if (Object.keys(conditions).length > 0) {
const whereConditions = Object.keys(conditions).map((key, index) => { const { clause, values: whereValues } = buildWhere(conditions);
values.push(conditions[key]); values = whereValues;
return `${safeIdentifier(key)} = $${index + 1}`; sql += ` WHERE ${clause}`;
});
sql += ` WHERE ${whereConditions.join(' AND ')}`;
} }
const result = await queryOne(sql, values); const result = await queryOne(sql, values);
@@ -277,6 +325,9 @@ async function exists(tableName, conditions) {
} }
export { export {
filterAllowedColumns,
safeIdentifier,
safeOrderBy,
create, create,
findById, findById,
find, find,
@@ -5,7 +5,23 @@
import pkg from 'pg'; import pkg from 'pg';
const { Pool } = pkg; const { Pool } = pkg;
import { fail } from '../../shared/lib/logger.js'; import { fail } from '@zen/core/shared/logger';
/**
* Opaque error type thrown by all database operations.
* Exposes only a generic message and the pg error code (e.g. '23505') so
* callers can branch on well-known codes without receiving internal details
* such as table names, constraint names, or query fragments.
*/
export class DatabaseError extends Error {
/** @param {string} message - Safe, generic message */
/** @param {string|undefined} code - PostgreSQL error code (SQLSTATE), if any */
constructor(message, code) {
super(message);
this.name = 'DatabaseError';
if (code !== undefined) this.code = code;
}
}
let pool = null; let pool = null;
@@ -35,9 +51,16 @@ function getPool() {
pool = new Pool({ pool = new Pool({
connectionString: databaseUrl, connectionString: databaseUrl,
// rejectUnauthorized MUST remain true in production to validate the server's // TLS policy:
// TLS certificate chain and prevent man-in-the-middle attacks. // production → full TLS with server certificate verification
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: true } : false, // all other environments → TLS encryption on, certificate verification off
// (prevents eavesdropping; allows self-signed certs in dev/ci)
// ZEN_DB_SSL_DISABLED=true → opt-out of TLS entirely (local loopback only)
ssl: process.env.NODE_ENV === 'production'
? { rejectUnauthorized: true }
: (process.env.ZEN_DB_SSL_DISABLED === 'true'
? false
: { rejectUnauthorized: false }),
max: 20, // Maximum number of clients in the pool max: 20, // Maximum number of clients in the pool
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
connectionTimeoutMillis: 2000, // Return an error after 2 seconds if connection could not be established connectionTimeoutMillis: 2000, // Return an error after 2 seconds if connection could not be established
@@ -59,14 +82,14 @@ function getPool() {
* @returns {Promise<Object>} Query result * @returns {Promise<Object>} Query result
*/ */
async function query(sql, params = []) { async function query(sql, params = []) {
const client = getPool(); const dbPool = getPool(); // renamed — avoids shadowing module-level `pool`
try { try {
const result = await client.query(sql, params); const result = await dbPool.query(sql, params);
return result; return result;
} catch (error) { } catch (error) {
fail(`DB query error: ${error.message}`); fail(`DB query error: ${error.message}`);
throw error; throw new DatabaseError('A database error occurred', error.code);
} }
} }
@@ -108,7 +131,7 @@ async function transaction(callback) {
} catch (error) { } catch (error) {
await client.query('ROLLBACK'); await client.query('ROLLBACK');
fail(`DB transaction error: ${error.message}`); fail(`DB transaction error: ${error.message}`);
throw error; throw new DatabaseError('A database transaction error occurred', error.code);
} finally { } finally {
client.release(); client.release();
} }
@@ -139,6 +162,23 @@ async function testConnection() {
} }
} }
/**
* Check if a table exists in the database
* @param {string} tableName - Name of the table to check
* @returns {Promise<boolean>} True if table exists, false otherwise
*/
async function tableExists(tableName) {
const result = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)`,
[tableName]
);
return result.rows[0].exists;
}
export { export {
query, query,
queryOne, queryOne,
@@ -146,6 +186,7 @@ export {
transaction, transaction,
getPool, getPool,
closePool, closePool,
testConnection testConnection,
tableExists
}; };
+7 -11
View File
@@ -5,17 +5,22 @@
// Core database functions // Core database functions
export { export {
DatabaseError,
query, query,
queryOne, queryOne,
queryAll, queryAll,
transaction, transaction,
getPool, getPool,
closePool, closePool,
testConnection testConnection,
} from './db.js'; tableExists
} from './db.server.js';
// CRUD helper functions // CRUD helper functions
export { export {
filterAllowedColumns,
safeIdentifier,
safeOrderBy,
create, create,
findById, findById,
find, find,
@@ -27,12 +32,3 @@ export {
count, count,
exists exists
} from './crud.js'; } from './crud.js';
// Database initialization
export {
initDatabase,
createAuthTables,
tableExists,
dropAuthTables
} from './init.js';
-185
View File
@@ -1,185 +0,0 @@
/**
* Database Initialization
* Creates required tables if they don't exist
*/
import { query } from './db.js';
import { step, done, warn, fail, info } from '../../shared/lib/logger.js';
/**
* Check if a table exists in the database
* @param {string} tableName - Name of the table to check
* @returns {Promise<boolean>} True if table exists, false otherwise
*/
async function tableExists(tableName) {
const result = await query(
`SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = $1
)`,
[tableName]
);
return result.rows[0].exists;
}
/**
* Create authentication tables
* @returns {Promise<void>}
*/
async function createAuthTables() {
const tables = [
{
name: 'zen_auth_users',
sql: `
CREATE TABLE zen_auth_users (
id text NOT NULL PRIMARY KEY,
name text NOT NULL,
email text NOT NULL UNIQUE,
email_verified boolean NOT NULL DEFAULT false,
image text,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
role text DEFAULT 'user' CHECK (role IN ('admin', 'user'))
)
`
},
{
name: 'zen_auth_sessions',
sql: `
CREATE TABLE zen_auth_sessions (
id text NOT NULL PRIMARY KEY,
expires_at timestamptz NOT NULL,
token text NOT NULL UNIQUE,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamptz NOT NULL,
ip_address text,
user_agent text,
user_id text NOT NULL REFERENCES zen_auth_users (id) ON DELETE CASCADE
)
`
},
{
name: 'zen_auth_accounts',
sql: `
CREATE TABLE zen_auth_accounts (
id text NOT NULL PRIMARY KEY,
account_id text NOT NULL,
provider_id text NOT NULL,
user_id text NOT NULL REFERENCES zen_auth_users (id) ON DELETE CASCADE,
access_token text,
refresh_token text,
id_token text,
access_token_expires_at timestamptz,
refresh_token_expires_at timestamptz,
scope text,
password text,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamptz NOT NULL
)
`
},
{
name: 'zen_auth_verifications',
sql: `
CREATE TABLE zen_auth_verifications (
id text NOT NULL PRIMARY KEY,
identifier text NOT NULL,
value text NOT NULL,
token text NOT NULL,
expires_at timestamptz NOT NULL,
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL
)
`
}
];
const created = [];
const skipped = [];
for (const table of tables) {
const exists = await tableExists(table.name);
if (!exists) {
await query(table.sql);
created.push(table.name);
done(`Created table: ${table.name}`);
} else {
skipped.push(table.name);
info(`Table already exists: ${table.name}`);
}
}
return {
created,
skipped,
success: true
};
}
/**
* Initialize the database with all required tables
* @returns {Promise<Object>} Result object with created and skipped tables
*/
async function initDatabase() {
step('Initializing Zen database...');
try {
const authResult = await createAuthTables();
// Initialize modules
let modulesResult = { created: [], skipped: [] };
try {
const { initModules } = await import('../../modules/init.js');
modulesResult = await initModules();
} catch (error) {
// Modules might not be available or enabled
info('No modules to initialize or modules not available');
}
done(`DB ready — auth: ${authResult.created.length} created, modules: ${modulesResult.created.length} created, ${authResult.skipped.length + modulesResult.skipped.length} skipped`);
return {
created: [...authResult.created, ...modulesResult.created],
skipped: [...authResult.skipped, ...modulesResult.skipped],
success: true
};
} catch (error) {
fail(`DB initialization failed: ${error.message}`);
throw error;
}
}
/**
* Drop all Zen authentication tables (use with caution!)
* @returns {Promise<void>}
*/
async function dropAuthTables() {
const tables = [
'zen_auth_verifications',
'zen_auth_accounts',
'zen_auth_sessions',
'zen_auth_users'
];
warn('Dropping all Zen authentication tables...');
for (const tableName of tables) {
const exists = await tableExists(tableName);
if (exists) {
await query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`);
done(`Dropped table: ${tableName}`);
}
}
done('All authentication tables dropped');
}
export {
initDatabase,
createAuthTables,
tableExists,
dropAuthTables
};
+155
View File
@@ -0,0 +1,155 @@
# Email Framework
Ce répertoire fournit un **wrapper autour de [Resend](https://resend.com)** pour l'envoi d'emails, ainsi qu'un composant de mise en page React Email réutilisable. Il ne connaît aucun template métier — les features créent leurs propres templates et utilisent ce module pour l'envoi.
---
## Structure
```
src/core/email/
├── index.js sendEmail, sendBatchEmails
└── templates/
├── index.js re-export
└── BaseLayout.js composant de mise en page React Email
```
---
## Import
```js
import { sendEmail, sendBatchEmails } from '@zen/core/email';
import { BaseLayout } from '@zen/core/email/templates';
```
---
## Variables d'environnement
| Variable | Obligatoire | Description |
|----------|-------------|-------------|
| `ZEN_EMAIL_RESEND_APIKEY` | Oui | Clé API Resend |
| `ZEN_EMAIL_FROM_ADDRESS` | Oui | Adresse expéditeur par défaut |
| `ZEN_EMAIL_FROM_NAME` | Non | Nom affiché de l'expéditeur |
| `ZEN_EMAIL_LOGO` | Non | URL du logo affiché dans `BaseLayout` |
| `ZEN_EMAIL_LOGO_URL` | Non | URL de destination du lien autour du logo |
| `ZEN_SUPPORT_EMAIL` | Non | Email affiché dans le footer si `supportSection` est activé |
| `ZEN_NAME` | Non | Nom de l'application (fallback du nom affiché dans `BaseLayout`) |
---
## API
### `sendEmail(email)`
Envoie un email via Resend. Retourne `{ success, data, error }`.
```js
const result = await sendEmail({
to: 'user@example.com',
subject: 'Bienvenue',
html: '<p>Bonjour !</p>',
});
if (!result.success) {
console.error(result.error);
}
```
| Paramètre | Type | Description |
|-----------|------|-------------|
| `to` | `string \| string[]` | Destinataire(s) |
| `subject` | `string` | Objet de l'email |
| `html` | `string` | Corps HTML |
| `text` | `string` | Corps texte brut (optionnel) |
| `from` | `string` | Adresse expéditeur (défaut : `ZEN_EMAIL_FROM_ADDRESS`) |
| `fromName` | `string` | Nom expéditeur (défaut : `ZEN_EMAIL_FROM_NAME`) |
| `replyTo` | `string` | Adresse de réponse (optionnel) |
| `attachments` | `object[]` | Pièces jointes Resend (optionnel) |
| `tags` | `object[]` | Tags Resend (optionnel) |
---
### `sendBatchEmails(emails)`
Envoie plusieurs emails en une seule requête batch Resend. Retourne `{ success, data, error }`.
```js
await sendBatchEmails([
{ to: 'a@example.com', subject: 'Sujet A', html: '<p>A</p>' },
{ to: 'b@example.com', subject: 'Sujet B', html: '<p>B</p>' },
]);
```
Chaque objet du tableau accepte les mêmes paramètres que `sendEmail`.
---
## BaseLayout
Composant React Email (`@react-email/components`) qui fournit une structure cohérente : logo ou nom de l'app, titre optionnel, contenu, footer avec copyright et lien support.
```jsx
import { render } from '@react-email/render';
import { BaseLayout } from '@zen/core/email/templates';
const html = await render(
<BaseLayout
preview="Votre commande est confirmée"
title="Commande confirmée"
supportSection
>
<Text>Merci pour votre achat.</Text>
</BaseLayout>
);
await sendEmail({ to: 'user@example.com', subject: 'Commande confirmée', html });
```
| Prop | Type | Description |
|------|------|-------------|
| `preview` | `string` | Texte de prévisualisation (snippet email) |
| `title` | `string` | Titre affiché en haut du corps |
| `children` | `ReactNode` | Contenu de l'email |
| `companyName` | `string` | Nom affiché si pas de logo (défaut : `ZEN_NAME` ou `ZEN`) |
| `logoURL` | `string` | URL du logo (défaut : `ZEN_EMAIL_LOGO`) |
| `supportSection` | `boolean` | Afficher le lien support dans le footer (défaut : `false`) |
| `supportEmail` | `string` | Email support (défaut : `ZEN_SUPPORT_EMAIL`) |
---
## Créer un template depuis une feature
Les templates vivent **avec leur feature**, pas dans ce répertoire.
```jsx
// src/features/auth/emails/WelcomeEmail.js
import { BaseLayout } from '@zen/core/email/templates';
import { Text, Button } from '@react-email/components';
export const WelcomeEmail = ({ name, loginUrl }) => (
<BaseLayout preview={`Bienvenue, ${name}`} title="Bienvenue !">
<Text>Bonjour {name}, votre compte est prêt.</Text>
<Button href={loginUrl}>Se connecter</Button>
</BaseLayout>
);
```
```js
// src/features/auth/emails/sendWelcome.js
import { render } from '@react-email/render';
import { sendEmail } from '@zen/core/email';
import { WelcomeEmail } from './WelcomeEmail.js';
export async function sendWelcomeEmail({ to, name, loginUrl }) {
const html = await render(<WelcomeEmail name={name} loginUrl={loginUrl} />);
return sendEmail({ to, subject: 'Bienvenue !', html });
}
```
---
## Gestion des erreurs
`sendEmail` et `sendBatchEmails` ne lèvent jamais d'exception — toute erreur est capturée, loguée via `fail()`, et retournée dans `{ success: false, error }`. L'appelant vérifie `result.success`.
+31 -177
View File
@@ -1,211 +1,65 @@
/**
* Email Utility using Resend
* Centralized email sending functionality for the entire package
*/
import { Resend } from 'resend'; import { Resend } from 'resend';
import { done, fail, info } from '../../shared/lib/logger.js'; import { done, fail, info } from '@zen/core/shared/logger';
/**
* Initialize Resend client
*/
let resendClient = null; let resendClient = null;
function getResendClient() { function getResendClient() {
if (!resendClient) { if (!resendClient) {
const apiKey = process.env.ZEN_EMAIL_RESEND_APIKEY; const apiKey = process.env.ZEN_EMAIL_RESEND_APIKEY;
if (!apiKey) { if (!apiKey) throw new Error('ZEN_EMAIL_RESEND_APIKEY environment variable is not set');
throw new Error('ZEN_EMAIL_RESEND_APIKEY environment variable is not set');
}
resendClient = new Resend(apiKey); resendClient = new Resend(apiKey);
} }
return resendClient; return resendClient;
} }
/** function resolveFrom(from, fromName) {
* Format sender address with name if available const address = from || process.env.ZEN_EMAIL_FROM_ADDRESS;
* @param {string} email - Email address if (!address) throw new Error('ZEN_EMAIL_FROM_ADDRESS environment variable is not set');
* @param {string} name - Sender name (optional) const name = fromName || process.env.ZEN_EMAIL_FROM_NAME;
* @returns {string} Formatted sender address return name?.trim() ? `${name.trim()} <${address}>` : address;
*/
function formatSenderAddress(email, name) {
if (name && name.trim()) {
return `${name.trim()} <${email}>`;
}
return email;
} }
/** function buildPayload({ to, subject, html, text, from, fromName, replyTo, attachments, tags }) {
* Send an email using Resend return {
* @param {Object} options - Email options from: resolveFrom(from, fromName),
* @param {string} options.to - Recipient email address to,
* @param {string} options.subject - Email subject subject,
* @param {string} options.html - HTML content of the email html,
* @param {string} options.text - Plain text content of the email (optional) ...(text && { text }),
* @param {string} options.from - Sender email address (optional, defaults to ZEN_EMAIL_FROM_ADDRESS) ...(replyTo && { reply_to: replyTo }),
* @param {string} options.fromName - Sender name (optional, defaults to ZEN_EMAIL_FROM_NAME) ...(attachments && { attachments }),
* @param {string} options.replyTo - Reply-to email address (optional) ...(tags && { tags })
* @param {Array} options.attachments - Email attachments (optional) };
* @param {Object} options.tags - Email tags for tracking (optional) }
* @returns {Promise<Object>} Resend response
*/ async function sendEmail(email) {
async function sendEmail({ to, subject, html, text, from, fromName, replyTo, attachments, tags }) {
try { try {
const resend = getResendClient(); const response = await getResendClient().emails.send(buildPayload(email));
// Default from address and name
const fromAddress = from || process.env.ZEN_EMAIL_FROM_ADDRESS || 'noreply@example.com';
const senderName = fromName || process.env.ZEN_EMAIL_FROM_NAME;
// Format sender with name if available
const formattedFrom = formatSenderAddress(fromAddress, senderName);
const emailData = {
from: formattedFrom,
to,
subject,
html,
...(text && { text }),
...(replyTo && { reply_to: replyTo }),
...(attachments && { attachments }),
...(tags && { tags })
};
const response = await resend.emails.send(emailData);
// Resend returns { data: { id: "..." }, error: null } or { data: null, error: { message: "..." } }
if (response.error) { if (response.error) {
fail(`Email Resend error: ${response.error.message || response.error}`); fail(`Email Resend error: ${response.error.message || response.error}`);
return { return { success: false, data: null, error: response.error.message || 'Failed to send email' };
success: false,
data: null,
error: response.error.message || 'Failed to send email'
};
} }
info(`Email sent to ${email.to} — ID: ${response.data?.id}`);
const emailId = response.data?.id || response.id; return { success: true, data: response.data, error: null };
info(`Email sent to ${to} — ID: ${emailId}`);
return {
success: true,
data: response.data || response,
error: null
};
} catch (error) { } catch (error) {
fail(`Email send failed: ${error.message}`); fail(`Email send failed: ${error.message}`);
return { return { success: false, data: null, error: error.message };
success: false,
data: null,
error: error.message
};
} }
} }
/**
* Send an authentication-related email
* Uses default ZEN_EMAIL_FROM_ADDRESS and ZEN_EMAIL_FROM_NAME
* @param {Object} options - Email options
* @param {string} options.to - Recipient email address
* @param {string} options.subject - Email subject
* @param {string} options.html - HTML content of the email
* @param {string} options.text - Plain text content of the email (optional)
* @param {string} options.replyTo - Reply-to email address (optional)
* @returns {Promise<Object>} Resend response
*/
async function sendAuthEmail({ to, subject, html, text, replyTo }) {
return sendEmail({
to,
subject,
html,
text,
replyTo
});
}
/**
* Send an application-related email
* Uses default ZEN_EMAIL_FROM_ADDRESS and ZEN_EMAIL_FROM_NAME
* @param {Object} options - Email options
* @param {string} options.to - Recipient email address
* @param {string} options.subject - Email subject
* @param {string} options.html - HTML content of the email
* @param {string} options.text - Plain text content of the email (optional)
* @param {string} options.replyTo - Reply-to email address (optional)
* @param {Array} options.attachments - Email attachments (optional)
* @param {Object} options.tags - Email tags for tracking (optional)
* @returns {Promise<Object>} Resend response
*/
async function sendAppEmail({ to, subject, html, text, replyTo, attachments, tags }) {
return sendEmail({
to,
subject,
html,
text,
replyTo,
attachments,
tags
});
}
/**
* Send a batch of emails
* @param {Array<Object>} emails - Array of email objects
* @returns {Promise<Array<Object>>} Array of Resend responses
*/
async function sendBatchEmails(emails) { async function sendBatchEmails(emails) {
try { try {
const resend = getResendClient(); const response = await getResendClient().batch.send(emails.map(buildPayload));
const emailsData = emails.map(email => {
const fromAddress = email.from || process.env.ZEN_EMAIL_FROM_ADDRESS || 'noreply@example.com';
const fromName = email.fromName || process.env.ZEN_EMAIL_FROM_NAME;
const formattedFrom = formatSenderAddress(fromAddress, fromName);
return {
from: formattedFrom,
to: email.to,
subject: email.subject,
html: email.html,
...(email.text && { text: email.text }),
...(email.replyTo && { reply_to: email.replyTo }),
...(email.attachments && { attachments: email.attachments }),
...(email.tags && { tags: email.tags })
};
});
const response = await resend.batch.send(emailsData);
// Handle Resend error response
if (response.error) { if (response.error) {
fail(`Email batch Resend error: ${response.error.message || response.error}`); fail(`Email batch Resend error: ${response.error.message || response.error}`);
return { return { success: false, data: null, error: response.error.message || 'Failed to send batch emails' };
success: false,
data: null,
error: response.error.message || 'Failed to send batch emails'
};
} }
done(`Email batch of ${emails.length} sent`); done(`Email batch of ${emails.length} sent`);
return { success: true, data: response.data, error: null };
return {
success: true,
data: response.data || response,
error: null
};
} catch (error) { } catch (error) {
fail(`Email batch send failed: ${error.message}`); fail(`Email batch send failed: ${error.message}`);
return { return { success: false, data: null, error: error.message };
success: false,
data: null,
error: error.message
};
} }
} }
export { export { sendEmail, sendBatchEmails };
sendEmail,
sendAuthEmail,
sendAppEmail,
sendBatchEmails
};
@@ -18,18 +18,19 @@ import {
Link, Link,
} from "@react-email/components"; } from "@react-email/components";
export const BaseLayout = ({ export const BaseLayout = ({
preview, preview,
title, title,
children, children,
companyName, companyName,
logoURL, logoURL,
supportSection = false, supportSection = false,
supportEmail = 'support@zenya.test' supportEmail
}) => { }) => {
const appName = companyName || process.env.ZEN_NAME || 'ZEN'; const appName = companyName || process.env.ZEN_NAME || 'ZEN';
const logoSrc = logoURL || process.env.ZEN_EMAIL_LOGO || null; const logoSrc = logoURL || process.env.ZEN_EMAIL_LOGO || null;
const logoHref = process.env.ZEN_EMAIL_LOGO_URL || null; const logoHref = process.env.ZEN_EMAIL_LOGO_URL || null;
const resolvedSupportEmail = supportEmail || process.env.ZEN_SUPPORT_EMAIL;
const currentYear = new Date().getFullYear(); const currentYear = new Date().getFullYear();
return ( return (
@@ -68,11 +69,11 @@ export const BaseLayout = ({
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0"> <Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
© {currentYear} {appName}. Tous droits réservés. © {currentYear} {appName}. Tous droits réservés.
{supportSection && ( {supportSection && resolvedSupportEmail && (
<> <>
{' · '} {' · '}
<Link href={`mailto:${supportEmail}`} className="text-neutral-400 underline"> <Link href={`mailto:${resolvedSupportEmail}`} className="text-neutral-400 underline">
{supportEmail} {resolvedSupportEmail}
</Link> </Link>
</> </>
)} )}
@@ -1,41 +0,0 @@
/**
* Password Changed Confirmation Email Template
*/
import { Section, Text } from "@react-email/components";
import { BaseLayout } from "./BaseLayout";
export const PasswordChangedEmail = ({
email,
companyName
}) => {
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
return (
<BaseLayout
preview={`Votre mot de passe a été modifié ${appName}`}
title="Mot de passe modifié"
companyName={companyName}
supportSection={true}
supportEmail={supportEmail}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Ceci confirme que le mot de passe de votre compte <span className="font-medium text-neutral-900">{appName}</span> a bien été modifié.
</Text>
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[4px] uppercase tracking-wider">
Compte
</Text>
<Text className="text-[14px] font-medium text-neutral-900 m-0">
{email}
</Text>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Si vous n'êtes pas à l'origine de cette modification, contactez immédiatement notre équipe de support.
</Text>
</BaseLayout>
);
};
@@ -1,49 +0,0 @@
/**
* Password Reset Email Template
*/
import { Button, Section, Text, Link } from "@react-email/components";
import { BaseLayout } from "./BaseLayout";
export const PasswordResetEmail = ({
email,
resetUrl,
companyName
}) => {
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
return (
<BaseLayout
preview={`Réinitialisez votre mot de passe pour ${appName}`}
title="Réinitialisation du mot de passe"
companyName={companyName}
supportSection={true}
supportEmail={supportEmail}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Nous avons reçu une demande de réinitialisation du mot de passe pour votre compte <span className="font-medium text-neutral-900">{appName}</span>. Cliquez sur le bouton ci-dessous pour en choisir un nouveau.
</Text>
<Section className="mt-[28px] mb-[32px]">
<Button
href={resetUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
Réinitialiser le mot de passe
</Button>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Ce lien expire dans 1 heure. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message votre mot de passe ne sera pas modifié.
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
Lien :{' '}
<Link href={resetUrl} className="text-neutral-400 underline break-all">
{resetUrl}
</Link>
</Text>
</BaseLayout>
);
};
@@ -1,49 +0,0 @@
/**
* Email Verification Template
*/
import { Button, Section, Text, Link } from "@react-email/components";
import { BaseLayout } from "./BaseLayout";
export const VerificationEmail = ({
email,
verificationUrl,
companyName
}) => {
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
return (
<BaseLayout
preview={`Confirmez votre adresse courriel pour ${appName}`}
title="Confirmez votre adresse courriel"
companyName={companyName}
supportSection={true}
supportEmail={supportEmail}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Merci de vous être inscrit sur <span className="font-medium text-neutral-900">{appName}</span>. Cliquez sur le bouton ci-dessous pour confirmer votre adresse courriel.
</Text>
<Section className="mt-[28px] mb-[32px]">
<Button
href={verificationUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
Confirmer mon courriel
</Button>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Ce lien expire dans 24 heures. Si vous n'avez pas créé de compte, ignorez ce message.
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
Lien :{' '}
<Link href={verificationUrl} className="text-neutral-400 underline break-all">
{verificationUrl}
</Link>
</Text>
</BaseLayout>
);
};
+1 -71
View File
@@ -1,71 +1 @@
/** export { BaseLayout } from './BaseLayout.js';
* Email Templates
* Export all email templates and render functions
*/
import { render } from '@react-email/components';
import { VerificationEmail } from './VerificationEmail.jsx';
import { PasswordResetEmail } from './PasswordResetEmail.jsx';
import { PasswordChangedEmail } from './PasswordChangedEmail.jsx';
// Export JSX components
export { VerificationEmail } from './VerificationEmail.jsx';
export { PasswordResetEmail } from './PasswordResetEmail.jsx';
export { PasswordChangedEmail } from './PasswordChangedEmail.jsx';
export { BaseLayout } from './BaseLayout.jsx';
/**
* Render verification email to HTML
* @param {string} verificationUrl - The verification URL
* @param {string} email - User's email address
* @param {string} companyName - Company name (optional)
* @returns {Promise<string>} Rendered HTML
*/
export async function renderVerificationEmail(verificationUrl, email, companyName) {
return await render(
<VerificationEmail
email={email}
verificationUrl={verificationUrl}
companyName={companyName}
/>
);
}
/**
* Render password reset email to HTML
* @param {string} resetUrl - The password reset URL
* @param {string} email - User's email address
* @param {string} companyName - Company name (optional)
* @returns {Promise<string>} Rendered HTML
*/
export async function renderPasswordResetEmail(resetUrl, email, companyName) {
return await render(
<PasswordResetEmail
email={email}
resetUrl={resetUrl}
companyName={companyName}
/>
);
}
/**
* Render password changed email to HTML
* @param {string} email - User's email address
* @param {string} companyName - Company name (optional)
* @returns {Promise<string>} Rendered HTML
*/
export async function renderPasswordChangedEmail(email, companyName) {
return await render(
<PasswordChangedEmail
email={email}
companyName={companyName}
/>
);
}
// Legacy exports for backward compatibility
export const getVerificationEmailTemplate = renderVerificationEmail;
export const getPasswordResetTemplate = renderPasswordResetEmail;
export const getPasswordChangedTemplate = renderPasswordChangedEmail;
+47
View File
@@ -0,0 +1,47 @@
# Modules
Registre runtime des modules `@zen/module-*` activés dans le projet consommateur. Voir [docs/MODULES.md](../../../docs/MODULES.md) pour le guide complet de création d'un module.
## Architecture
Les modules sont activés via un **manifeste statique** généré dans le projet consommateur (`app/.zen/modules.generated.js`). Le manifeste fait des `import * as ...` statiques pour chaque package et appelle `register()` au top level. Importé par `instrumentation.js` (serveur) et `app/layout.js` (client), il rend l'arbre d'imports du module visible aux deux bundles Next.js — Turbopack et Webpack le bundlent comme n'importe quel autre fichier source.
Le manifeste est régénéré par `npx zen-modules sync` (typiquement depuis le `postinstall` + les scripts `dev` / `build` du projet).
## API
```js
import {
registerModules,
registerModule,
getRegisteredModules,
getRegisteredModule,
findInstalledModuleNames,
validateModuleEnvVars,
} from '@zen/core/modules';
```
| Fonction | Usage |
|----------|-------|
| `registerModules(modules)` | Appelée par `initializeZen({ modules })`. Peuple le registre interne à partir du manifeste. |
| `registerModule(mod)` | Bas niveau — utilisé par `registerModules`. |
| `getRegisteredModules()` | Retourne tous les modules connus (utilisé par `zen-db init` et l'env validation). |
| `findInstalledModuleNames({ cwd })` | Scan readonly du `package.json` du projet — utilisé par le CLI `zen-modules sync`. |
| `validateModuleEnvVars(modules)` | Logge un warning par variable d'env requise absente. |
## Forme attendue d'un module
Le point d'entrée d'un package `@zen/module-X` doit exporter :
| Export | Type | Obligatoire |
|--------|------|-------------|
| `manifest` | `{ name, version, permissions?, envVars? }` | oui |
| `register` | `() => void \| Promise<void>` | oui |
| `createTables` | `async () => { created?, skipped? }` | si le module a des tables |
| `dropTables` | `async () => void` | si le module a des tables |
Le code du module doit être pré-compilé avant publication (transformation JSX, voir [docs/MODULES.md](../../../docs/MODULES.md)).
## CLI
`npx zen-modules sync` — régénère `app/.zen/modules.generated.js`. Idempotent : pas d'écriture si le contenu est inchangé.
+174
View File
@@ -0,0 +1,174 @@
#!/usr/bin/env node
/**
* Zen Modules CLI
*
* Génère `app/.zen/modules.generated.js` à partir des dépendances `@zen/module-*`
* détectées dans le `package.json` du projet consommateur. Le fichier généré est
* ensuite importé par `instrumentation.js` (côté serveur) et par `app/layout.js`
* (côté client) pour rendre les modules visibles aux deux bundles Next.js.
*
* Usage : `npx zen-modules sync`
*
* Conçu pour être appelé depuis postinstall + dev/build du projet consommateur.
* Idempotent : si le contenu généré est identique au fichier existant, aucune
* écriture n'est effectuée.
*/
import { writeFile, mkdir, readFile } from 'node:fs/promises';
import { resolve, dirname } from 'node:path';
import { step, done, warn, fail } from '@zen/core/shared/logger';
import { findInstalledModuleNames, moduleHasClientEntry } from './discover.server.js';
const OUTPUT_SERVER = 'app/.zen/modules.generated.js';
const OUTPUT_CLIENT = 'app/.zen/modules.client.js';
function safeIdentifier(name, idx) {
// `@zen/module-posts` → `m0_zen_module_posts`
const cleaned = name.replace(/[^a-zA-Z0-9]/g, '_');
return `m${idx}_${cleaned}`;
}
function renderServerManifest(names) {
const header = '// AUTO-GÉNÉRÉ par `npx zen-modules sync` — ne pas modifier à la main.\n';
if (names.length === 0) {
return header + '\nexport const modules = [];\n';
}
const imports = names
.map((name, i) => `import * as ${safeIdentifier(name, i)} from '${name}';`)
.join('\n');
const entries = names
.map((name, i) => ` { name: '${name}', exports: ${safeIdentifier(name, i)} },`)
.join('\n');
return [
header,
imports,
'',
'export const modules = [',
entries,
'];',
'',
'// Top-level await : déclenche register() de chaque module au moment de l\'import',
'// côté serveur. instrumentation.js importe ce fichier au boot.',
'await Promise.all(modules.map(m => m.exports.register?.()));',
'',
].join('\n');
}
function renderClientManifest(clientNames) {
// Exporte un Component React `ZenModulesClient` que le layout consommateur doit
// RENDRE dans son tree (`<ZenModulesClient />`). C'est le seul moyen fiable
// sous Next.js 15+/Turbopack pour garantir que les side-effects top-level
// (registerPage, registerWidget) s'exécutent côté browser. Un simple
// `import './.zen/modules.client.js'` dans un Server Component met bien le
// fichier dans le bundle client mais n'en exécute jamais le code top-level
// — la transformation 'use client' s'applique aux Components, pas aux
// side-effect imports orphelins.
//
// Importe `@zen/module-X/client` (sous-entrée 'use client') et NON le main
// entry du module — le main entry tire createTables/registerApiRoutes/etc.
// qui dépendent de pg/fs/net et ne peuvent pas être bundlés pour le browser.
const header = [
'// AUTO-GÉNÉRÉ par `npx zen-modules sync` — ne pas modifier à la main.',
"'use client';",
'',
].join('\n');
const body = [
'export default function ZenModulesClient() {',
' return null;',
'}',
'',
].join('\n');
if (clientNames.length === 0) return header + '\n' + body;
const imports = clientNames.map(name => `import '${name}/client';`).join('\n');
return [header, imports, '', body].join('\n');
}
async function readIfExists(path) {
try {
return await readFile(path, 'utf-8');
} catch {
return null;
}
}
async function writeIfChanged(path, contents) {
const prev = await readIfExists(path);
if (prev === contents) return false;
await mkdir(dirname(path), { recursive: true });
await writeFile(path, contents, 'utf-8');
return true;
}
async function syncCommand({ cwd = process.cwd() } = {}) {
const names = await findInstalledModuleNames({ cwd });
const clientNames = [];
for (const name of names) {
if (await moduleHasClientEntry(name, cwd)) clientNames.push(name);
}
const serverCount = `(${names.length} module${names.length === 1 ? '' : 's'})`;
const clientCount = `(${clientNames.length} client entr${clientNames.length === 1 ? 'y' : 'ies'})`;
const serverPath = resolve(cwd, OUTPUT_SERVER);
const clientPath = resolve(cwd, OUTPUT_CLIENT);
const wroteServer = await writeIfChanged(serverPath, renderServerManifest(names));
const wroteClient = await writeIfChanged(clientPath, renderClientManifest(clientNames));
if (!wroteServer && !wroteClient) {
step(`zen-modules: manifests already up to date ${serverCount} ${clientCount}`);
return;
}
if (wroteServer) done(`zen-modules: wrote ${OUTPUT_SERVER} ${serverCount}`);
if (wroteClient) done(`zen-modules: wrote ${OUTPUT_CLIENT} ${clientCount}`);
for (const name of names) {
const hasClient = clientNames.includes(name);
step(`${name}${hasClient ? ' (+ /client)' : ''}`);
}
}
function printHelp() {
console.log(`
Zen Modules CLI
Usage:
npx zen-modules <command>
Commands:
sync Régénère app/.zen/modules.generated.js à partir des @zen/module-*
déclarés dans le package.json du projet courant.
help Affiche cette aide.
`);
}
const [, , command] = process.argv;
try {
switch (command) {
case 'sync':
await syncCommand();
break;
case 'help':
case '--help':
case '-h':
case undefined:
printHelp();
break;
default:
warn(`Unknown command: ${command}`);
printHelp();
process.exit(1);
}
} catch (err) {
fail(`zen-modules: ${err.message}`);
process.exit(1);
}
-32
View File
@@ -1,32 +0,0 @@
/**
* Client-Safe Module Registry Access
*
* This file ONLY exports functions that are safe to use in client components.
* It does NOT export discovery, loader, or initialization functions that
* might import server-only modules like database code.
*
* NOTE: Most registry functions return empty results on the client because
* the registry is populated on the server during discovery. For client-side
* module page loading, use the loaders from modules.pages.js instead.
*/
// Only export registry getter functions (no discovery/loader functions)
export {
getModule,
getAllModules,
getEnabledModules,
isModuleRegistered,
isModuleEnabled,
getAllApiRoutes,
getAllAdminNavigation,
getAdminPage,
getAllCronJobs,
getAllPublicRoutes,
getAllDatabaseSchemas,
getModuleMetadataGenerator,
getAllModuleMetadata,
} from './registry.js';
// NOTE: getModulePublicPages is NOT exported here because it relies on the
// server-side registry which is empty on the client. Use getModulePublicPageLoader()
// from '@zen/core/modules/pages' instead for client-side public page loading.
-59
View File
@@ -1,59 +0,0 @@
/**
* defineModule — helper to declare a ZEN module.
*
* Used for both internal modules (src/modules/) and external npm packages.
*
* @param {Object} config - Module configuration
* @returns {Object} Normalized module configuration
*/
export function defineModule(config) {
if (!config || typeof config !== 'object') {
throw new Error('[defineModule] Config must be an object.');
}
if (!config.name || typeof config.name !== 'string') {
throw new Error('[defineModule] Field "name" is required (e.g. "invoice").');
}
return {
// Identity
version: '1.0.0',
displayName: config.name.charAt(0).toUpperCase() + config.name.slice(1),
description: '',
// Dependencies and environment variables
dependencies: [],
envVars: [],
// Admin UI
navigation: null,
adminPages: {},
pageResolver: null,
// Public pages
publicPages: {},
publicRoutes: [],
dashboardWidgets: [],
// Server actions for public pages
actions: {},
// SEO metadata generators
metadata: {},
// Storage prefixes served publicly (no auth). Format: 'posts/blogue', 'assets/docs'
storagePublicPrefixes: [],
// Database (optional) — { createTables, dropTables }
db: null,
// Initialization callback (optional) — setup(ctx)
setup: null,
// Spread last so all fields above can be overridden
...config,
// Internal marker — do not override
__isZenModule: true,
};
}
+206
View File
@@ -0,0 +1,206 @@
import { readFile } from 'node:fs/promises';
import { readFileSync } from 'node:fs';
import { resolve, join, dirname } from 'node:path';
import { pathToFileURL } from 'node:url';
import { info, warn } from '@zen/core/shared/logger';
import { registerModule } from './registry.js';
/**
* Sources de modules `@zen/module-*` activés dans le projet consommateur.
*
* Le projet consommateur fournit un manifeste statique (généré par
* `npx zen-modules sync`) et le passe à `initializeZen({ modules })`. Le
* manifeste a la forme :
*
* import * as posts from '@zen/module-posts';
* export const modules = [{ name: '@zen/module-posts', exports: posts }];
* await Promise.all(modules.map(m => m.exports.register?.()));
*
* Le `import *` rend l'arbre d'imports du module visible aux deux bundles
* Next.js (server + client) ; Turbopack/Webpack le bundlent comme n'importe
* quel autre fichier source. C'est ce qui permet au module de référencer du
* JSX, `next/headers`, `next/navigation`, etc. — chaque côté reçoit la bonne
* condition.
*
* Cette fonction ne fait QUE peupler le registre interne du core (pour que
* `getRegisteredModules()` retourne les bons objets côté serveur). Le top-level
* await dans le manifeste a déjà appelé `register()` au moment de son import.
*/
export function registerModules(modules) {
if (!Array.isArray(modules)) return;
for (const entry of modules) {
const ex = entry?.exports;
const name = entry?.name ?? ex?.manifest?.name ?? '<unknown>';
if (!ex?.manifest || typeof ex.register !== 'function') {
warn(`zen-modules: "${name}" missing manifest/register — skipping`);
continue;
}
registerModule({
manifest: ex.manifest,
register: ex.register,
createTables: ex.createTables,
dropTables: ex.dropTables,
});
info(`zen-modules: registered ${ex.manifest.name}@${ex.manifest.version ?? '?'}`);
}
}
// ---------------------------------------------------------------------------
// Helpers de scan (utilisés par le CLI `zen-modules sync` et l'env validation).
// ---------------------------------------------------------------------------
const NAME_PREFIX = /^(@zen\/module-|zen-module-)/;
export function isCandidateName(name) {
return NAME_PREFIX.test(name);
}
export async function readJson(path) {
try {
return JSON.parse(await readFile(path, 'utf-8'));
} catch {
return null;
}
}
/**
* Résout le chemin du `package.json` d'un module installé. Ne dépend PAS de
* `require.resolve(name)` (qui peut échouer si la sous-entrée `./package.json`
* n'est pas exposée par `exports`) ni de `import.meta.resolve` (variable selon
* la version Node). On remonte simplement depuis `projectCwd` en cherchant
* `node_modules/<name>/package.json` à chaque niveau — c'est le mécanisme de
* résolution npm standard.
*/
function resolveModulePackageJson(name, projectCwd) {
let dir = projectCwd;
while (true) {
const candidate = join(dir, 'node_modules', name, 'package.json');
try {
const pkg = JSON.parse(readFileSync(candidate, 'utf-8'));
return { path: candidate, pkg };
} catch {
// pas trouvé à ce niveau, remonter
}
const parent = dirname(dir);
if (parent === dir) return null;
dir = parent;
}
}
export async function isThirdPartyModule(name, projectCwd) {
const found = resolveModulePackageJson(name, projectCwd);
return Array.isArray(found?.pkg?.keywords) && found.pkg.keywords.includes('zen-module');
}
/**
* Vérifie si un module installé expose une sous-entrée `./client` dans son
* `package.json#exports`. Utilisé par le CLI `zen-modules sync` pour décider
* si le manifeste client doit l'importer. Les modules sans partie cliente
* (modules purement back-end / API / DB) ne sont pas inclus dans le manifeste
* client — ça évite d'embarquer leur graphe d'imports serveur dans le bundle
* browser.
*/
export async function moduleHasClientEntry(name, projectCwd) {
const found = resolveModulePackageJson(name, projectCwd);
return Boolean(found?.pkg?.exports?.['./client']);
}
/**
* Scanne `package.json` du projet consommateur et retourne la liste des noms
* de packages `@zen/module-*` (ou compatibles). N'effectue AUCUN import — le
* CLI `zen-modules sync` consomme cette liste pour générer le manifeste
* statique.
*/
export async function findInstalledModuleNames({ cwd = process.cwd() } = {}) {
const pkg = await readJson(resolve(cwd, 'package.json'));
if (!pkg) return [];
const allDeps = {
...(pkg.dependencies ?? {}),
...(pkg.devDependencies ?? {}),
};
const out = [];
for (const name of Object.keys(allDeps)) {
if (isCandidateName(name) || (await isThirdPartyModule(name, cwd))) {
out.push(name);
}
}
return out.sort();
}
/**
* Résout l'URL ESM du main entry d'un module à partir de son `package.json`.
* On lit `exports['.'].import` puis `main`, sinon `./index.js` par défaut.
* Pas de `require.resolve` : un module `@zen/module-*` peut déclarer uniquement
* la condition `"import"` (ESM-only) — la résolution CJS échouerait alors avec
* "No exports main defined". Comme on a déjà localisé le `package.json` via
* `resolveModulePackageJson`, on construit l'URL nous-mêmes.
*/
function resolveModuleEntryUrl(found) {
const pkgDir = dirname(found.path);
const main = found.pkg?.exports?.['.']?.import ?? found.pkg?.main ?? './index.js';
return pathToFileURL(resolve(pkgDir, main)).href;
}
/**
* Variante "Node-only" du chargement de modules — utilisée par le CLI
* `zen-db init` qui ne passe jamais par un bundler. Charge dynamiquement
* chaque module depuis `node_modules/` du projet, pour que le CLI ait accès
* à `manifest`, `createTables`, `dropTables`. Ne déclenche PAS `register()`
* (la chaîne register-server tirerait des imports Next.js incompatibles avec
* le contexte CLI).
*
* À ne PAS utiliser depuis le runtime Next.js — utiliser le manifeste statique
* via `initializeZen({ modules })`.
*/
export async function loadModulesForCli({ cwd = process.cwd() } = {}) {
const names = await findInstalledModuleNames({ cwd });
for (const name of names) {
const found = resolveModulePackageJson(name, cwd);
if (!found) {
warn(`zen-modules: cannot find package "${name}" in node_modules`);
continue;
}
let mod;
try {
mod = await import(resolveModuleEntryUrl(found));
} catch (err) {
warn(`zen-modules: failed to import "${name}" — ${err.message}`);
continue;
}
if (!mod.manifest || typeof mod.register !== 'function') {
warn(`zen-modules: "${name}" missing manifest/register — skipping`);
continue;
}
registerModule({
manifest: mod.manifest,
register: mod.register,
createTables: mod.createTables,
dropTables: mod.dropTables,
});
info(`zen-modules: loaded ${mod.manifest.name}@${mod.manifest.version ?? '?'}`);
}
}
/**
* Valide les variables d'environnement requises par chaque module.
* Ne lance pas — log un warning pour chaque variable absente.
*/
export function validateModuleEnvVars(modules) {
for (const mod of modules) {
const envVars = mod.manifest?.envVars ?? [];
for (const v of envVars) {
if (v.required && !process.env[v.key]) {
warn(`zen-modules: ${mod.manifest.name} requires env var "${v.key}" — ${v.description ?? ''}`);
}
}
}
}
-310
View File
@@ -1,310 +0,0 @@
/**
* Module Discovery System
* Auto-discovers and registers modules from the modules directory.
* Also handles registration of external modules passed via zen.config.js.
*/
import { registerModule, clearRegistry } from './registry.js';
import { getAvailableModules } from '../../modules/modules.registry.js';
import { step, done, warn, fail, info } from '../../shared/lib/logger.js';
/**
* Check if a module is enabled via environment variable
* @param {string} moduleName - Module name
* @returns {boolean}
*/
export function isModuleEnabledInEnv(moduleName) {
const envVar = `ZEN_MODULE_${moduleName.toUpperCase().replace(/-/g, '_')}`;
return process.env[envVar] === 'true';
}
/**
* Discover and register all modules
* @param {Object} options - Discovery options
* @param {boolean} options.force - Force re-discovery
* @returns {Promise<Object>} Discovery result
*/
export async function discoverModules(options = {}) {
const { force = false } = options;
const DISCOVERY_KEY = Symbol.for('__ZEN_MODULES_DISCOVERED__');
if (globalThis[DISCOVERY_KEY] && !force) {
warn('modules already discovered, skipping');
return { alreadyDiscovered: true };
}
if (force) {
clearRegistry();
}
step('Discovering modules...');
const discovered = [];
const enabled = [];
const skipped = [];
const errors = [];
const knownModules = getAvailableModules();
for (const moduleName of knownModules) {
try {
const isEnabled = isModuleEnabledInEnv(moduleName);
if (!isEnabled) {
skipped.push(moduleName);
continue;
}
// Load module configuration
const moduleConfig = await loadModuleConfig(moduleName);
if (moduleConfig) {
// Load additional components (db, cron, api)
const components = await loadModuleComponents(moduleName);
// Register the module
registerModule(moduleName, {
...moduleConfig,
...components,
enabled: true
});
discovered.push(moduleName);
enabled.push(moduleName);
info(`Registered ${moduleName}`);
}
} catch (error) {
errors.push({ module: moduleName, error: error.message });
fail(`Error loading ${moduleName}: ${error.message}`);
}
}
globalThis[DISCOVERY_KEY] = true;
return { discovered, enabled, skipped, errors };
}
/**
* Load module configuration from module.config.js
* @param {string} moduleName - Module name
* @returns {Promise<Object|null>} Module configuration
*/
async function loadModuleConfig(moduleName) {
try {
const config = await import(`../../modules/${moduleName}/module.config.js`);
const moduleConfig = config.default || config;
return {
name: moduleConfig.name || moduleName,
displayName: moduleConfig.displayName || moduleName.charAt(0).toUpperCase() + moduleName.slice(1),
version: moduleConfig.version || '1.0.0',
description: moduleConfig.description || `${moduleName} module`,
dependencies: moduleConfig.dependencies || [],
envVars: moduleConfig.envVars || [],
// Admin config: navigation + page path markers (components loaded client-side)
admin: buildAdminConfig(moduleConfig),
// Public routes metadata (components loaded client-side)
public: moduleConfig.publicRoutes?.length
? { routes: moduleConfig.publicRoutes }
: undefined,
};
} catch (error) {
// No module.config.js — use defaults silently
return {
name: moduleName,
displayName: moduleName.charAt(0).toUpperCase() + moduleName.slice(1),
version: '1.0.0',
description: `${moduleName} module`
};
}
}
/**
* Load additional module components (db, cron, api)
* Note: Metadata is loaded from modules.metadata.js (static registry)
* @param {string} moduleName - Module name
* @returns {Promise<Object>} Module components
*/
async function loadModuleComponents(moduleName) {
const components = {};
// Load API routes
try {
const api = await import(`../../modules/${moduleName}/api.js`);
components.api = api.default || api;
} catch (error) {
// API is optional
}
// Load cron configuration
try {
const cron = await import(`../../modules/${moduleName}/cron.config.js`);
components.cron = cron.default || cron;
} catch (error) {
// Cron is optional
}
// Load database configuration
try {
const db = await import(`../../modules/${moduleName}/db.js`);
if (db.createTables) {
components.db = {
init: db.createTables,
drop: db.dropTables
};
}
} catch (error) {
// DB is optional
}
return components;
}
/**
* Register external modules provided via zen.config.js.
* Skips any module whose ZEN_MODULE_<NAME>=true env var is not set.
*
* @param {Array} modules - Array of module configs created with defineModule()
* @returns {Promise<Object>} { registered, skipped, errors }
*/
export async function registerExternalModules(modules = []) {
const registered = [];
const skipped = [];
const errors = [];
for (const moduleConfig of modules) {
const moduleName = moduleConfig?.name;
if (!moduleName) {
errors.push({ module: '(unknown)', error: 'Missing "name" field.' });
continue;
}
try {
if (!isModuleEnabledInEnv(moduleName)) {
skipped.push(moduleName);
continue;
}
// Build registry entry from the external module config
const adminConfig = buildAdminConfig(moduleConfig);
const moduleData = {
name: moduleName,
displayName: moduleConfig.displayName || moduleName,
version: moduleConfig.version || '1.0.0',
description: moduleConfig.description || '',
dependencies: moduleConfig.dependencies || [],
envVars: moduleConfig.envVars || [],
admin: adminConfig,
public: moduleConfig.publicRoutes?.length
? { routes: moduleConfig.publicRoutes }
: undefined,
actions: moduleConfig.actions || {},
metadata: moduleConfig.metadata || {},
db: moduleConfig.db
? { init: moduleConfig.db.createTables, drop: moduleConfig.db.dropTables }
: undefined,
cron: moduleConfig.cron || undefined,
api: moduleConfig.api || undefined,
enabled: true,
external: true,
};
registerModule(moduleName, moduleData);
// Call setup(ctx) if provided.
// Pass the module's declared envVars so the restricted config.get()
// enforces least-privilege access to environment variables.
if (typeof moduleConfig.setup === 'function') {
const ctx = await buildModuleContext(moduleConfig.envVars || []);
await moduleConfig.setup(ctx);
}
registered.push(moduleName);
info(`Registered external ${moduleName}`);
} catch (error) {
errors.push({ module: moduleName, error: error.message });
fail(`Error registering external ${moduleName}: ${error.message}`);
}
}
return { registered, skipped, errors };
}
/**
* Build admin config object from a module config (shared with loadModuleConfig).
* @param {Object} moduleConfig
* @returns {Object|undefined}
*/
function buildAdminConfig(moduleConfig) {
if (!moduleConfig.navigation && !moduleConfig.adminPages) return undefined;
const adminConfig = {};
if (moduleConfig.navigation) {
adminConfig.navigation = moduleConfig.navigation;
}
if (moduleConfig.adminPages) {
adminConfig.pages = {};
for (const path of Object.keys(moduleConfig.adminPages)) {
adminConfig.pages[path] = true;
}
}
return adminConfig;
}
/**
* Build the context object injected into module setup() callbacks.
* All services are lazy-initialized internally — importing them is safe.
*
* The config.get accessor is intentionally restricted: a module may only read
* environment variables that it has declared in its own envVars list. This
* prevents a malicious or compromised third-party module from reading unrelated
* secrets (e.g. STRIPE_SECRET_KEY, ZEN_DATABASE_URL) via ctx.config.get().
*
* @param {string[]} [allowedKeys=[]] - env var names this module declared
* @returns {Promise<Object>} ctx
*/
async function buildModuleContext(allowedKeys = []) {
const [db, email, storage, payments] = await Promise.all([
import('../../core/database/index.js'),
import('../../core/email/index.js'),
import('../../core/storage/index.js'),
import('../../core/payments/index.js'),
]);
const allowedSet = new Set(allowedKeys);
return {
db,
email,
storage,
payments,
config: {
/**
* Read an env var — only variables declared in the module's envVars list
* are accessible. Any other key returns undefined and logs a violation.
*/
get: (key) => {
if (!allowedSet.has(key)) {
fail(`[Security] Module attempted to read undeclared env var "${key}" — access denied`);
return undefined;
}
return process.env[key];
},
},
};
}
/**
* Reset module discovery (useful for testing)
*/
export function resetDiscovery() {
const DISCOVERY_KEY = Symbol.for('__ZEN_MODULES_DISCOVERED__');
globalThis[DISCOVERY_KEY] = false;
clearRegistry();
}
+2 -43
View File
@@ -1,43 +1,2 @@
/** export { registerModule, getRegisteredModules, getRegisteredModule, clearRegisteredModules } from './registry.js';
* Module System Entry Point export { registerModules, findInstalledModuleNames, moduleHasClientEntry, loadModulesForCli, validateModuleEnvVars } from './discover.server.js';
* Exports all module-related functionality
*/
// Discovery
export {
discoverModules,
registerExternalModules,
isModuleEnabledInEnv,
resetDiscovery
} from './discovery.js';
// Registry (server-side only - these functions rely on the registry populated during discovery)
export {
registerModule,
getModule,
getAllModules,
getEnabledModules,
isModuleRegistered,
isModuleEnabled,
clearRegistry,
getAllApiRoutes,
getAllAdminNavigation,
getAdminPage,
getAllCronJobs,
getAllPublicRoutes,
getAllDatabaseSchemas,
getModuleMetadataGenerator,
getAllModuleMetadata,
getModulePublicPages // returns route metadata only, use modules.pages.js for components
} from './registry.js';
// Loader
export {
initializeModules,
initializeModuleDatabases,
startModuleCronJobs,
stopModuleCronJobs,
getCronJobStatus,
resetModuleLoader,
getModuleStatus
} from './loader.js';
-244
View File
@@ -1,244 +0,0 @@
/**
* Module Loader
* Handles loading and initializing modules
*/
import { discoverModules, resetDiscovery } from './discovery.js';
import {
getAllModules,
getEnabledModules,
getAllCronJobs,
getAllDatabaseSchemas,
isModuleEnabled
} from './registry.js';
import { step, done, warn, fail, info } from '../../shared/lib/logger.js';
// Use globalThis to track initialization state
const INIT_KEY = Symbol.for('__ZEN_MODULES_INITIALIZED__');
const CRON_JOBS_KEY = Symbol.for('__ZEN_MODULE_CRON_JOBS__');
/**
* Initialize all modules
* Discovers modules, initializes databases, and starts cron jobs
* @param {Object} options - Initialization options
* @param {boolean} options.skipCron - Skip starting cron jobs
* @param {boolean} options.skipDb - Skip database initialization
* @param {boolean} options.force - Force re-initialization
* @returns {Promise<Object>} Initialization result
*/
export async function initializeModules(options = {}) {
const { skipCron = false, skipDb = false, force = false } = options;
// Prevent multiple initializations
if (globalThis[INIT_KEY] && !force) {
warn('modules already initialized, skipping');
return { alreadyInitialized: true };
}
step('Initializing modules...');
const result = {
discovery: null,
database: { created: [], skipped: [], errors: [] },
cron: { started: [], errors: [] }
};
try {
// Step 1: Discover modules
result.discovery = await discoverModules({ force });
// Step 2: Initialize databases
if (!skipDb) {
result.database = await initializeModuleDatabases();
}
// Step 3: Start cron jobs
if (!skipCron) {
result.cron = await startModuleCronJobs();
}
globalThis[INIT_KEY] = true;
done('Modules initialized');
} catch (error) {
fail(`Module initialization failed: ${error.message}`);
result.error = error.message;
}
return result;
}
/**
* Initialize databases for all enabled modules
* @returns {Promise<Object>} Database initialization result
*/
export async function initializeModuleDatabases() {
step('Initializing module databases...');
const schemas = getAllDatabaseSchemas();
const result = {
created: [],
skipped: [],
errors: []
};
for (const schema of schemas) {
try {
if (schema.init && typeof schema.init === 'function') {
const initResult = await schema.init();
if (initResult?.created) {
result.created.push(...initResult.created);
}
if (initResult?.skipped) {
result.skipped.push(...initResult.skipped);
}
info(`DB ready: ${schema.module}`);
}
} catch (error) {
result.errors.push({
module: schema.module,
error: error.message
});
fail(`DB init error for ${schema.module}: ${error.message}`);
}
}
return result;
}
/**
* Start cron jobs for all enabled modules
* @returns {Promise<Object>} Cron job start result
*/
export async function startModuleCronJobs() {
step('Starting cron jobs...');
// Stop existing cron jobs first
stopModuleCronJobs();
const jobs = getAllCronJobs();
const result = {
started: [],
errors: []
};
// Initialize cron jobs storage
if (!globalThis[CRON_JOBS_KEY]) {
globalThis[CRON_JOBS_KEY] = new Map();
}
for (const job of jobs) {
try {
if (job.handler && typeof job.handler === 'function') {
// Dynamic import of node-cron
const cron = (await import('node-cron')).default;
const cronJob = cron.schedule(job.schedule, async () => {
info(`Cron ${job.name} running at ${new Date().toISOString()}`);
try {
await job.handler();
info(`Cron ${job.name} completed`);
} catch (error) {
fail(`Cron ${job.name}: ${error.message}`);
}
}, {
scheduled: true,
timezone: job.timezone || process.env.ZEN_TIMEZONE || 'America/Toronto'
});
globalThis[CRON_JOBS_KEY].set(job.name, cronJob);
result.started.push(job.name);
info(`Cron ready: ${job.name} (${job.schedule})`);
}
} catch (error) {
result.errors.push({
job: job.name,
module: job.module,
error: error.message
});
fail(`Cron error for ${job.name}: ${error.message}`);
}
}
return result;
}
/**
* Stop all module cron jobs
*/
export function stopModuleCronJobs() {
if (globalThis[CRON_JOBS_KEY]) {
for (const [name, job] of globalThis[CRON_JOBS_KEY].entries()) {
try {
job.stop();
info(`Cron stopped: ${name}`);
} catch (error) {
fail(`Error stopping cron ${name}: ${error.message}`);
}
}
globalThis[CRON_JOBS_KEY].clear();
}
}
/**
* Get status of all cron jobs
* @returns {Object} Cron job status
*/
export function getCronJobStatus() {
const status = {};
if (globalThis[CRON_JOBS_KEY]) {
for (const [name, job] of globalThis[CRON_JOBS_KEY].entries()) {
status[name] = {
running: true // node-cron doesn't expose a running state easily
};
}
}
return status;
}
/**
* Reset module loader (useful for testing)
*/
export function resetModuleLoader() {
stopModuleCronJobs();
resetDiscovery();
globalThis[INIT_KEY] = false;
}
/**
* Get module status
* @returns {Object} Status of all modules
*/
export function getModuleStatus() {
const modules = getAllModules();
const enabled = getEnabledModules();
const cronStatus = getCronJobStatus();
return {
totalModules: modules.size,
enabledModules: enabled.length,
modules: Array.from(modules.entries()).map(([name, data]) => ({
name,
enabled: data.enabled,
displayName: data.displayName,
version: data.version,
hasApi: !!data.api,
hasAdmin: !!data.admin,
hasCron: !!data.cron,
hasDb: !!data.db,
hasPublic: !!data.public
})),
cronJobs: cronStatus
};
}
// Re-export useful functions from registry
export {
isModuleEnabled,
getAllModules,
getEnabledModules
} from './registry.js';
+31 -279
View File
@@ -1,293 +1,45 @@
/** /**
* Module Registry * Registre des modules `@zen/module-*` chargés.
* Stores and manages all discovered modules *
* Un module est un package npm exportant :
* - manifest : { name, version, permissions?, envVars? }
* - register : () => void | Promise<void>
* - createTables : async () => { created?: string[], skipped?: string[] }
* - dropTables : async () => void
*
* La découverte (`discover.server.js`) lit le package.json du projet
* consommateur et appelle registerModule() pour chaque dépendance détectée.
*
* Persisté via Symbol.for sur globalThis pour survivre aux hot-reloads.
*/ */
// Use globalThis to persist registry across module reloads const REGISTRY_KEY = Symbol.for('__ZEN_MODULES_REGISTRY__');
const REGISTRY_KEY = Symbol.for('__ZEN_MODULE_REGISTRY__'); if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = new Map();
/** @type {Map<string, object>} */
const registry = globalThis[REGISTRY_KEY];
/** export function registerModule(mod) {
* Initialize or get the module registry if (!mod || typeof mod !== 'object') {
* @returns {Map} Module registry map throw new TypeError('registerModule: argument must be an object');
*/
function getRegistry() {
if (!globalThis[REGISTRY_KEY]) {
globalThis[REGISTRY_KEY] = new Map();
} }
return globalThis[REGISTRY_KEY]; const { manifest } = mod;
} if (!manifest || typeof manifest.name !== 'string' || !manifest.name) {
throw new TypeError('registerModule: module.manifest.name must be a non-empty string');
/**
* Register a module in the registry
* @param {string} name - Module name
* @param {Object} moduleData - Module configuration and components
*/
export function registerModule(name, moduleData) {
const registry = getRegistry();
registry.set(name, {
...moduleData,
registeredAt: new Date().toISOString()
});
}
/**
* Get a registered module by name
* @param {string} name - Module name
* @returns {Object|null} Module data or null
*/
export function getModule(name) {
const registry = getRegistry();
return registry.get(name) || null;
}
/**
* Get all registered modules
* @returns {Map} All registered modules
*/
export function getAllModules() {
return getRegistry();
}
/**
* Get all enabled modules
* @returns {Array} Array of enabled module data
*/
export function getEnabledModules() {
const registry = getRegistry();
const enabled = [];
for (const [name, data] of registry.entries()) {
if (data.enabled) {
enabled.push({ name, ...data });
}
} }
if (typeof mod.register !== 'function') {
return enabled; throw new TypeError(`registerModule(${manifest.name}): module.register must be a function`);
}
registry.set(manifest.name, mod);
} }
/** export function getRegisteredModules() {
* Check if a module is registered return [...registry.values()];
* @param {string} name - Module name
* @returns {boolean}
*/
export function isModuleRegistered(name) {
const registry = getRegistry();
return registry.has(name);
} }
/** export function getRegisteredModule(name) {
* Check if a module is enabled return registry.get(name);
* @param {string} name - Module name
* @returns {boolean}
*/
export function isModuleEnabled(name) {
const module = getModule(name);
return module?.enabled === true;
} }
/** export function clearRegisteredModules() {
* Clear the module registry (useful for testing)
*/
export function clearRegistry() {
const registry = getRegistry();
registry.clear(); registry.clear();
} }
/**
* Get all API routes from enabled modules
* @returns {Array} Array of route definitions
*/
export function getAllApiRoutes() {
const routes = [];
const registry = getRegistry();
for (const [name, data] of registry.entries()) {
if (data.enabled && data.api?.routes) {
routes.push(...data.api.routes.map(route => ({
...route,
module: name
})));
}
}
return routes;
}
/**
* Get all admin navigation sections from enabled modules
* @param {string} pathname - Current pathname for active state
* @returns {Array} Array of navigation sections
*/
export function getAllAdminNavigation(pathname) {
const sections = [];
const registry = getRegistry();
for (const [name, data] of registry.entries()) {
if (data.enabled && data.admin?.navigation) {
const nav = data.admin.navigation;
// Handle function or object navigation
const section = typeof nav === 'function' ? nav(pathname) : nav;
if (section) {
// Support array of sections (e.g. one per post type)
const sectionList = Array.isArray(section) ? section : [section];
for (const s of sectionList) {
if (s.items) {
s.items = s.items.map(item => ({
...item,
current: pathname.startsWith(item.href)
}));
}
sections.push({ ...s, module: name });
}
}
}
}
return sections;
}
/**
* Get admin page info for a given path
*
* Returns module info if the path is registered as an admin page.
* The actual component is loaded client-side via modules.pages.js
*
* @param {string} path - Page path (e.g., '/admin/invoice/invoices')
* @returns {Object|null} Object with { module, path } or null
*/
export function getAdminPage(path) {
const registry = getRegistry();
for (const [name, data] of registry.entries()) {
if (data.enabled && data.admin?.pages) {
if (data.admin.pages[path]) {
return { module: name, path };
}
}
}
return null;
}
/**
* Get all cron jobs from enabled modules
* @returns {Array} Array of cron job definitions
*/
export function getAllCronJobs() {
const jobs = [];
const registry = getRegistry();
for (const [name, data] of registry.entries()) {
if (data.enabled && data.cron?.jobs) {
jobs.push(...data.cron.jobs.map(job => ({
...job,
module: name
})));
}
}
return jobs;
}
/**
* Get public routes from enabled modules
* @returns {Array} Array of public route definitions
*/
export function getAllPublicRoutes() {
const routes = [];
const registry = getRegistry();
for (const [name, data] of registry.entries()) {
if (data.enabled && data.public?.routes) {
routes.push(...data.public.routes.map(route => ({
...route,
module: name
})));
}
}
return routes;
}
/**
* Get database schemas from all enabled modules
* @returns {Array} Array of database schema definitions
*/
export function getAllDatabaseSchemas() {
const schemas = [];
const registry = getRegistry();
for (const [name, data] of registry.entries()) {
if (data.enabled && data.db) {
schemas.push({
module: name,
...data.db
});
}
}
return schemas;
}
/**
* Get a specific metadata generator function from a module.
* Use this when you need to call a generator directly (e.g. for Next.js generateMetadata).
*
* To get the full metadata object for a module, use getModuleMetadata() from modules.metadata.js.
*
* @param {string} moduleName - Module name (e.g., 'invoice')
* @param {string} type - Metadata type key (e.g., 'payment', 'pdf', 'receipt')
* @returns {Function|null} Metadata generator function or null if not found
*/
export function getModuleMetadataGenerator(moduleName, type) {
const module = getModule(moduleName);
if (module?.enabled && module?.metadata) {
// If type is specified, return the specific generator
if (type && module.metadata[type]) {
return module.metadata[type];
}
// If no type, return the default (first one or 'payment')
return module.metadata.payment || module.metadata[Object.keys(module.metadata)[0]] || null;
}
return null;
}
/**
* Get all metadata configurations from enabled modules
* @returns {Object} Object mapping module names to their metadata configs
*/
export function getAllModuleMetadata() {
const metadata = {};
const registry = getRegistry();
for (const [name, data] of registry.entries()) {
if (data.enabled && data.metadata) {
metadata[name] = data.metadata;
}
}
return metadata;
}
/**
* Get public routes configuration from a module
*
* NOTE: This function only returns route metadata, not components.
* For loading public page components, use getModulePublicPageLoader() from modules.pages.js
*
* @param {string} moduleName - Module name
* @returns {Object|null} Public routes config or null
*/
export function getModulePublicPages(moduleName) {
const module = getModule(moduleName);
if (module?.enabled && module?.public) {
return module.public;
}
return null;
}
+225
View File
@@ -0,0 +1,225 @@
# Payments Framework
Ce répertoire fournit un **wrapper autour de [Stripe](https://stripe.com)** pour la gestion des paiements : sessions de checkout, intents, clients, remboursements et webhooks.
---
## Structure
```
src/core/payments/
├── index.js re-export
└── stripe.js wrapper Stripe
```
---
## Import
```js
import {
isEnabled,
getPublishableKey,
createCheckoutSession,
createPaymentIntent,
getCheckoutSession,
getPaymentIntent,
verifyWebhookSignature,
createCustomer,
getOrCreateCustomer,
listPaymentMethods,
createRefund,
} from '@zen/core/payments';
```
---
## Variables d'environnement
| Variable | Obligatoire | Description |
|----------|-------------|-------------|
| `STRIPE_SECRET_KEY` | Oui | Clé secrète Stripe (côté serveur) |
| `STRIPE_PUBLISHABLE_KEY` | Oui | Clé publique Stripe (côté client) |
| `STRIPE_WEBHOOK_SECRET` | Pour les webhooks | Secret de signature des webhooks Stripe |
| `ZEN_CURRENCY` | Non | Devise par défaut pour les payment intents (défaut : `cad`) |
---
## API
### `isEnabled()`
Retourne `true` si `STRIPE_SECRET_KEY` et `STRIPE_PUBLISHABLE_KEY` sont définis. Utiliser pour conditionner l'affichage des fonctionnalités de paiement.
```js
if (isEnabled()) {
// afficher le bouton de paiement
}
```
---
### `getPublishableKey()`
Retourne la clé publique Stripe, ou `null` si absente. Passer au client pour initialiser Stripe.js ou `@stripe/react-stripe-js`.
```js
const key = getPublishableKey();
```
---
### `createCheckoutSession(options)`
Crée une session Stripe Checkout. Retourne la session Stripe.
```js
const session = await createCheckoutSession({
lineItems: [{ price: 'price_xxx', quantity: 1 }],
successUrl: 'https://example.com/success',
cancelUrl: 'https://example.com/cancel',
customerEmail: 'user@example.com',
mode: 'payment',
});
// Rediriger l'utilisateur vers session.url
```
| Paramètre | Type | Description |
|-----------|------|-------------|
| `lineItems` | `object[]` | Lignes de commande Stripe |
| `successUrl` | `string` | URL de retour après paiement réussi |
| `cancelUrl` | `string` | URL de retour après annulation |
| `customerEmail` | `string` | Email pré-rempli dans le formulaire (optionnel) |
| `clientReferenceId` | `string` | Identifiant interne pour rapprochement (optionnel) |
| `metadata` | `object` | Métadonnées Stripe (optionnel) |
| `mode` | `string` | `'payment'`, `'subscription'` ou `'setup'` (défaut : `'payment'`) |
---
### `createPaymentIntent(options)`
Crée un PaymentIntent Stripe. Retourne le PaymentIntent.
```js
const intent = await createPaymentIntent({
amount: 4999, // en centimes
currency: 'eur',
metadata: { orderId: '123' },
});
```
| Paramètre | Type | Description |
|-----------|------|-------------|
| `amount` | `number` | Montant en centimes |
| `currency` | `string` | Devise ISO (défaut : `ZEN_CURRENCY` ou `cad`) |
| `metadata` | `object` | Métadonnées Stripe (optionnel) |
| `automaticPaymentMethods` | `object` | Config des méthodes de paiement (défaut : `{ enabled: true }`) |
---
### `getCheckoutSession(sessionId)`
Récupère une session Checkout par son identifiant. À utiliser dans la route `successUrl` pour confirmer le paiement.
```js
const session = await getCheckoutSession(sessionId);
```
---
### `getPaymentIntent(paymentIntentId)`
Récupère un PaymentIntent par son identifiant.
```js
const intent = await getPaymentIntent(paymentIntentId);
```
---
### `verifyWebhookSignature(payload, signature)`
Vérifie la signature d'un webhook Stripe et retourne l'événement. Lève une erreur si la signature est invalide ou si `STRIPE_WEBHOOK_SECRET` est absent.
```js
// Next.js Route Handler
export async function POST(req) {
const payload = await req.text();
const signature = req.headers.get('stripe-signature');
let event;
try {
event = await verifyWebhookSignature(payload, signature);
} catch (err) {
return new Response('Signature invalide', { status: 400 });
}
if (event.type === 'checkout.session.completed') {
// traiter la commande
}
return new Response('OK');
}
```
Le `payload` doit être le corps brut de la requête (non parsé).
---
### `createCustomer(options)`
Crée un client Stripe. Retourne le client.
```js
const customer = await createCustomer({
email: 'user@example.com',
name: 'Jean Dupont',
metadata: { userId: '42' },
});
```
---
### `getOrCreateCustomer(email, defaultData)`
Retourne le client Stripe existant pour cet email, ou en crée un nouveau. Utilise une clé d'idempotence dérivée de l'email pour limiter les doublons en cas d'appels concurrents.
```js
const customer = await getOrCreateCustomer('user@example.com', {
name: 'Jean Dupont',
metadata: { userId: '42' },
});
```
---
### `listPaymentMethods(customerId, type)`
Retourne la liste des méthodes de paiement d'un client.
```js
const methods = await listPaymentMethods(customer.id, 'card');
```
Le paramètre `type` est optionnel (défaut : `'card'`).
---
### `createRefund(options)`
Crée un remboursement. Retourne le remboursement Stripe.
```js
const refund = await createRefund({
paymentIntentId: 'pi_xxx',
amount: 1000, // partiel, en centimes (optionnel — total si absent)
reason: 'requested_by_customer', // optionnel
});
```
| Paramètre | Type | Description |
|-----------|------|-------------|
| `paymentIntentId` | `string` | Identifiant du PaymentIntent à rembourser |
| `amount` | `number` | Montant en centimes (optionnel, remboursement total si absent) |
| `reason` | `string` | Raison Stripe : `duplicate`, `fraudulent`, `requested_by_customer` (optionnel) |
-6
View File
@@ -1,7 +1 @@
/**
* Payments Module Entry Point
* Re-exports all payment utilities
*/
export * from './stripe.js'; export * from './stripe.js';
export { default as stripe } from './stripe.js';
+44 -198
View File
@@ -1,72 +1,33 @@
/** import { createHash } from 'crypto';
* Stripe Payment Utilities import Stripe from 'stripe';
* Generic Stripe integration for payment processing
*
* Usage in modules:
* import { createCheckoutSession, isEnabled } from '@zen/core/stripe';
*/
/** let _stripe = null;
* Get Stripe instance
* @returns {Promise<Object>} Stripe instance export function getStripe() {
*/
export async function getStripe() {
const secretKey = process.env.STRIPE_SECRET_KEY; const secretKey = process.env.STRIPE_SECRET_KEY;
if (!secretKey) { if (!secretKey) {
throw new Error('Stripe is not configured. Please set STRIPE_SECRET_KEY environment variable.'); throw new Error('Stripe is not configured. Please set STRIPE_SECRET_KEY environment variable.');
} }
const Stripe = (await import('stripe')).default; if (!_stripe) {
return new Stripe(secretKey, { _stripe = new Stripe(secretKey, { apiVersion: '2023-10-16' });
apiVersion: '2023-10-16', }
});
return _stripe;
} }
/**
* Check if Stripe is enabled
* @returns {boolean}
*/
export function isEnabled() { export function isEnabled() {
return !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PUBLISHABLE_KEY); return !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PUBLISHABLE_KEY);
} }
/**
* Get Stripe publishable key (for client-side)
* @returns {string|null}
*/
export function getPublishableKey() { export function getPublishableKey() {
return process.env.STRIPE_PUBLISHABLE_KEY || null; return process.env.STRIPE_PUBLISHABLE_KEY || null;
} }
/**
* Create a checkout session
* @param {Object} options - Checkout options
* @param {Array} options.lineItems - Line items for checkout
* @param {string} options.successUrl - Success redirect URL
* @param {string} options.cancelUrl - Cancel redirect URL
* @param {string} options.customerEmail - Customer email
* @param {Object} options.metadata - Additional metadata
* @param {string} options.mode - Payment mode (default: 'payment')
* @returns {Promise<Object>} Stripe session object
*
* @example
* const session = await createCheckoutSession({
* lineItems: [{
* price_data: {
* currency: 'usd',
* product_data: { name: 'Product' },
* unit_amount: 1000,
* },
* quantity: 1,
* }],
* successUrl: 'https://example.com/success',
* cancelUrl: 'https://example.com/cancel',
* });
*/
export async function createCheckoutSession(options) { export async function createCheckoutSession(options) {
const stripe = await getStripe(); const stripe = getStripe();
const { const {
lineItems, lineItems,
successUrl, successUrl,
@@ -74,49 +35,34 @@ export async function createCheckoutSession(options) {
customerEmail, customerEmail,
metadata = {}, metadata = {},
mode = 'payment', mode = 'payment',
paymentMethodTypes = ['card'],
clientReferenceId, clientReferenceId,
} = options; } = options;
const sessionConfig = { const sessionConfig = {
payment_method_types: paymentMethodTypes,
line_items: lineItems, line_items: lineItems,
mode, mode,
success_url: successUrl, success_url: successUrl,
cancel_url: cancelUrl, cancel_url: cancelUrl,
metadata, metadata,
}; };
if (customerEmail) { if (customerEmail) sessionConfig.customer_email = customerEmail;
sessionConfig.customer_email = customerEmail; if (clientReferenceId) sessionConfig.client_reference_id = clientReferenceId;
}
return stripe.checkout.sessions.create(sessionConfig);
if (clientReferenceId) {
sessionConfig.client_reference_id = clientReferenceId;
}
return await stripe.checkout.sessions.create(sessionConfig);
} }
/**
* Create a payment intent
* @param {Object} options - Payment options
* @param {number} options.amount - Amount in cents
* @param {string} options.currency - Currency code
* @param {Object} options.metadata - Additional metadata
* @returns {Promise<Object>} Stripe payment intent
*/
export async function createPaymentIntent(options) { export async function createPaymentIntent(options) {
const stripe = await getStripe(); const stripe = getStripe();
const { const {
amount, amount,
currency = process.env.ZEN_CURRENCY?.toLowerCase() || 'cad', currency = process.env.ZEN_CURRENCY?.toLowerCase() || 'cad',
metadata = {}, metadata = {},
automaticPaymentMethods = { enabled: true }, automaticPaymentMethods = { enabled: true },
} = options; } = options;
return await stripe.paymentIntents.create({ return stripe.paymentIntents.create({
amount, amount,
currency, currency,
metadata, metadata,
@@ -124,159 +70,59 @@ export async function createPaymentIntent(options) {
}); });
} }
/**
* Retrieve a checkout session
* @param {string} sessionId - Session ID
* @returns {Promise<Object>} Stripe session
*/
export async function getCheckoutSession(sessionId) { export async function getCheckoutSession(sessionId) {
const stripe = await getStripe(); return getStripe().checkout.sessions.retrieve(sessionId);
return await stripe.checkout.sessions.retrieve(sessionId);
} }
/**
* Retrieve a payment intent
* @param {string} paymentIntentId - Payment intent ID
* @returns {Promise<Object>} Stripe payment intent
*/
export async function getPaymentIntent(paymentIntentId) { export async function getPaymentIntent(paymentIntentId) {
const stripe = await getStripe(); return getStripe().paymentIntents.retrieve(paymentIntentId);
return await stripe.paymentIntents.retrieve(paymentIntentId);
} }
/** export async function verifyWebhookSignature(payload, signature) {
* Verify webhook signature const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
* @param {string} payload - Raw request body
* @param {string} signature - Stripe-Signature header
* @param {string} secret - Webhook secret (optional, uses env if not provided)
* @returns {Promise<Object>} Verified event
*/
export async function verifyWebhookSignature(payload, signature, secret = null) {
const stripe = await getStripe();
const webhookSecret = secret || process.env.STRIPE_WEBHOOK_SECRET;
if (!webhookSecret) { if (!webhookSecret) {
throw new Error('Stripe webhook secret is not configured'); throw new Error('Stripe webhook secret is not configured');
} }
return stripe.webhooks.constructEvent(payload, signature, webhookSecret); return getStripe().webhooks.constructEvent(payload, signature, webhookSecret);
} }
/**
* Create a customer
* @param {Object} options - Customer options
* @param {string} options.email - Customer email
* @param {string} options.name - Customer name
* @param {Object} options.metadata - Additional metadata
* @returns {Promise<Object>} Stripe customer
*/
export async function createCustomer(options) { export async function createCustomer(options) {
const stripe = await getStripe();
const { email, name, metadata = {} } = options; const { email, name, metadata = {} } = options;
return getStripe().customers.create({ email, name, metadata });
return await stripe.customers.create({
email,
name,
metadata,
});
} }
/** /**
* Get or create a customer by email. * Get or create a customer by email.
* *
* TOCTOU note: Stripe does not provide a native atomic upsert. To minimise * TOCTOU note: Stripe does not provide a native atomic upsert. To minimise
* the race-condition window where two concurrent calls both create a customer * the race-condition window where two concurrent calls both create a customer
* for the same email, we use an idempotency key derived from the email address. * for the same email, we use an idempotency key derived from the email address.
* If a duplicate is created despite this guard (extremely unlikely), operators * If a duplicate is created despite this guard (extremely unlikely), operators
* should merge duplicates via the Stripe dashboard or a reconciliation job. * should merge duplicates via the Stripe dashboard or a reconciliation job.
*
* @param {string} email - Customer email
* @param {Object} defaultData - Default data if creating new customer
* @returns {Promise<Object>} Stripe customer
*/ */
export async function getOrCreateCustomer(email, defaultData = {}) { export async function getOrCreateCustomer(email, defaultData = {}) {
const stripe = await getStripe(); const stripe = getStripe();
// Search for existing customer first. const existing = await stripe.customers.list({ email, limit: 1 });
const existing = await stripe.customers.list({ if (existing.data.length > 0) return existing.data[0];
email,
limit: 1,
});
if (existing.data.length > 0) {
return existing.data[0];
}
// Use a deterministic idempotency key so that concurrent requests for the
// same email address result in a single Stripe customer even if both pass
// the list-check above before either create completes.
const { createHash } = await import('crypto');
const idempotencyKey = 'get-or-create-customer-' + createHash('sha256').update(email).digest('hex'); const idempotencyKey = 'get-or-create-customer-' + createHash('sha256').update(email).digest('hex');
return stripe.customers.create({ email, ...defaultData }, { idempotencyKey });
return await stripe.customers.create(
{ email, ...defaultData },
{ idempotencyKey }
);
} }
/**
* List customer's payment methods
* @param {string} customerId - Customer ID
* @param {string} type - Payment method type (default: 'card')
* @returns {Promise<Array>} List of payment methods
*/
export async function listPaymentMethods(customerId, type = 'card') { export async function listPaymentMethods(customerId, type = 'card') {
const stripe = await getStripe(); const methods = await getStripe().paymentMethods.list({ customer: customerId, type });
const methods = await stripe.paymentMethods.list({
customer: customerId,
type,
});
return methods.data; return methods.data;
} }
/**
* Create a refund
* @param {Object} options - Refund options
* @param {string} options.paymentIntentId - Payment intent to refund
* @param {number} options.amount - Amount to refund in cents (optional, full refund if not specified)
* @param {string} options.reason - Reason for refund
* @returns {Promise<Object>} Stripe refund
*/
export async function createRefund(options) { export async function createRefund(options) {
const stripe = await getStripe();
const { paymentIntentId, amount, reason } = options; const { paymentIntentId, amount, reason } = options;
const refundConfig = {
payment_intent: paymentIntentId,
};
if (amount) {
refundConfig.amount = amount;
}
if (reason) {
refundConfig.reason = reason;
}
return await stripe.refunds.create(refundConfig);
}
// Default export for convenience const refundConfig = { payment_intent: paymentIntentId };
export default { if (amount) refundConfig.amount = amount;
getStripe, if (reason) refundConfig.reason = reason;
isEnabled,
getPublishableKey, return getStripe().refunds.create(refundConfig);
createCheckoutSession, }
createPaymentIntent,
getCheckoutSession,
getPaymentIntent,
verifyWebhookSignature,
createCustomer,
getOrCreateCustomer,
listPaymentMethods,
createRefund,
};
+142
View File
@@ -0,0 +1,142 @@
# PDF Framework
Ce répertoire re-exporte les primitives de [`@react-pdf/renderer`](https://react-pdf.org) et fournit un utilitaire de nommage de fichiers. Il ne contient aucun template métier — les features créent leurs propres documents et utilisent ce module pour le rendu.
---
## Structure
```
src/core/pdf/
└── index.js re-exports @react-pdf/renderer + getFilename
```
---
## Import
```js
import {
renderToBuffer,
Document,
Page,
View,
Text,
Image,
Link,
StyleSheet,
Font,
getFilename,
} from '@zen/core/pdf';
```
---
## API
### `renderToBuffer(element)`
Rend un document React PDF en `Buffer`. Retourne une `Promise<Buffer>`.
```js
import { renderToBuffer, Document, Page, Text } from '@zen/core/pdf';
const buffer = await renderToBuffer(
<Document>
<Page>
<Text>Bonjour</Text>
</Page>
</Document>
);
```
Utiliser ce buffer pour servir le PDF en réponse HTTP ou l'écrire sur disque.
---
### `getFilename(prefix, identifier, date?)`
Retourne un nom de fichier normalisé pour un PDF.
```js
getFilename('invoice', '12345')
// 'invoice-12345-2024-01-15.pdf'
getFilename('receipt', 'ORD-99', new Date('2024-06-01'))
// 'receipt-ORD-99-2024-06-01.pdf'
```
| Paramètre | Type | Description |
|-----------|------|-------------|
| `prefix` | `string` | Type de document (`invoice`, `receipt`, etc.) |
| `identifier` | `string` | Identifiant unique (numéro de commande, ID, etc.) |
| `date` | `Date` | Date du document (défaut : aujourd'hui) |
---
### Primitives re-exportées
Toutes les primitives de `@react-pdf/renderer` sont disponibles directement depuis `@zen/core/pdf` :
| Export | Description |
|--------|-------------|
| `Document` | Racine d'un document PDF |
| `Page` | Page du document |
| `View` | Conteneur (équivalent `div`) |
| `Text` | Bloc de texte |
| `Image` | Image (URL ou base64) |
| `Link` | Lien hypertexte |
| `StyleSheet` | Création de styles (similaire à `StyleSheet.create` React Native) |
| `Font` | Enregistrement de polices personnalisées |
---
## Créer un template depuis une feature
Les templates vivent **avec leur feature**, pas dans ce répertoire.
```jsx
// src/features/orders/pdf/InvoiceDocument.js
import { Document, Page, View, Text, StyleSheet } from '@zen/core/pdf';
const styles = StyleSheet.create({
page: { padding: 40 },
title: { fontSize: 20, marginBottom: 16 },
});
export const InvoiceDocument = ({ order }) => (
<Document>
<Page style={styles.page}>
<Text style={styles.title}>Facture #{order.number}</Text>
<Text>{order.customerName}</Text>
</Page>
</Document>
);
```
```js
// src/features/orders/pdf/sendInvoice.js
import { renderToBuffer, getFilename } from '@zen/core/pdf';
import { InvoiceDocument } from './InvoiceDocument.js';
export async function generateInvoicePdf(order) {
const buffer = await renderToBuffer(<InvoiceDocument order={order} />);
const filename = getFilename('invoice', order.number);
return { buffer, filename };
}
```
```js
// Next.js Route Handler
export async function GET(req, { params }) {
const order = await getOrder(params.id);
const { buffer, filename } = await generateInvoicePdf(order);
return new Response(buffer, {
headers: {
'Content-Type': 'application/pdf',
'Content-Disposition': `attachment; filename="${filename}"`,
},
});
}
```
+4 -116
View File
@@ -1,121 +1,9 @@
/** export { renderToBuffer, Document, Page, View, Text, Image, Link, StyleSheet, Font } from '@react-pdf/renderer';
* PDF Generation Utilities
* Wrapper around @react-pdf/renderer for PDF generation
*
* Usage in modules:
* import { renderToBuffer } from '@zen/core/pdf';
*/
import { renderToBuffer as reactPdfRenderToBuffer } from '@react-pdf/renderer';
import React from 'react';
/** /**
* Render a React PDF document to a buffer * Get a suggested filename for a PDF.
* @param {React.Element} document - React PDF document element * @example getFilename('invoice', '12345') // 'invoice-12345-2024-01-15.pdf'
* @returns {Promise<Buffer>} PDF buffer
*
* @example
* import { Document, Page, Text } from '@react-pdf/renderer';
*
* const MyDoc = () => (
* <Document>
* <Page>
* <Text>Hello World</Text>
* </Page>
* </Document>
* );
*
* const buffer = await renderToBuffer(<MyDoc />);
*/
export async function renderToBuffer(document) {
return await reactPdfRenderToBuffer(document);
}
/**
* Create a React element for PDF rendering
* @param {Function} Component - React component
* @param {Object} props - Component props
* @returns {React.Element}
*/
export function createElement(Component, props) {
return React.createElement(Component, props);
}
/**
* Get a suggested filename for a PDF
* @param {string} prefix - Filename prefix
* @param {string|number} identifier - Unique identifier
* @param {Date} date - Date for the filename (default: today)
* @returns {string} Suggested filename
*
* @example
* getFilename('invoice', '12345'); // 'invoice-12345-2024-01-15.pdf'
*/ */
export function getFilename(prefix, identifier, date = new Date()) { export function getFilename(prefix, identifier, date = new Date()) {
const dateStr = date.toISOString().split('T')[0]; return `${prefix}-${identifier}-${date.toISOString().split('T')[0]}.pdf`;
return `${prefix}-${identifier}-${dateStr}.pdf`;
} }
/**
* Convert centimeters to points (for PDF dimensions)
* @param {number} cm - Centimeters
* @returns {number} Points
*/
export function cmToPoints(cm) {
return cm * 28.3465;
}
/**
* Convert inches to points (for PDF dimensions)
* @param {number} inches - Inches
* @returns {number} Points
*/
export function inchesToPoints(inches) {
return inches * 72;
}
/**
* Convert millimeters to points (for PDF dimensions)
* @param {number} mm - Millimeters
* @returns {number} Points
*/
export function mmToPoints(mm) {
return mm * 2.83465;
}
/**
* Common page sizes in points
*/
export const PAGE_SIZES = {
A4: { width: 595.28, height: 841.89 },
LETTER: { width: 612, height: 792 },
LEGAL: { width: 612, height: 1008 },
A3: { width: 841.89, height: 1190.55 },
A5: { width: 419.53, height: 595.28 },
};
// Re-export react-pdf components for convenience
export {
Document,
Page,
View,
Text,
Image,
Link,
StyleSheet,
Font,
PDFViewer,
BlobProvider,
PDFDownloadLink,
} from '@react-pdf/renderer';
// Default export
export default {
renderToBuffer,
createElement,
getFilename,
cmToPoints,
inchesToPoints,
mmToPoints,
PAGE_SIZES,
};
@@ -0,0 +1,28 @@
import { notFound } from 'next/navigation';
import { getPublicModulePage } from './registry.js';
/**
* Composant serveur RSC catch-all pour `/zen/<module>/<...>`.
*
* Next.js route ce composant via un segment `[...path]`. Le premier segment
* identifie le module ; le reste est passé au composant enregistré qui fait
* son propre routage interne.
*
* `/zen/api/...` est intercepté en amont par la route API (`route.js`) qui
* est plus spécifique pour Next.js — ce composant ne le verra jamais en
* pratique, mais on garde le filtre par sûreté.
*/
export default async function PublicModulePage({ params }) {
const resolved = await params;
const path = Array.isArray(resolved?.path) ? resolved.path : [];
if (path.length === 0) notFound();
const [moduleName, ...rest] = path;
if (moduleName === 'api') notFound();
const entry = getPublicModulePage(moduleName);
if (!entry) notFound();
const { Component } = entry;
return <Component params={resolved} segments={rest} />;
}
+37
View File
@@ -0,0 +1,37 @@
# Public Module Pages
Registre runtime pour les pages publiques `/zen/<module>/<...>` ajoutées par les modules externes.
## Concept
Tout chemin `/zen/<segment>/...` (sauf `/zen/api/...` réservé aux routes API) est résolu vers le composant enregistré sous `<segment>`. Le module gère son routage interne.
## API
```js
import { registerPublicModulePage } from '@zen/core/public-pages';
registerPublicModulePage({
moduleName: 'billing',
Component: BillingRouter,
title: 'Facturation',
});
```
Le composant reçoit `{ params, segments }` :
| Prop | Type | Description |
|------|------|-------------|
| `params` | `object` | Paramètres Next.js résolus (incluant `path`). |
| `segments` | `string[]` | Segments d'URL après `/zen/<moduleName>/`. Le module fait son propre routage. |
Exemple : `/zen/billing/invoice/abc-123``segments = ['invoice', 'abc-123']`.
## Câblage côté projet consommateur
Le scaffolder `@zen/start` génère automatiquement `app/zen/[...path]/page.js` qui ré-exporte le composant serveur. Aucune action manuelle requise.
## Restrictions
- Le moduleName `api` est réservé et lève une exception à l'enregistrement.
- Un seul composant par moduleName ; un appel ultérieur écrase le précédent.
+1
View File
@@ -0,0 +1 @@
export { registerPublicModulePage, getPublicModulePage, getPublicModulePages } from './registry.js';
+36
View File
@@ -0,0 +1,36 @@
/**
* Registre runtime des pages publiques `/zen/<module>/<...>`.
*
* Chaque module externe enregistre un composant racine pour son namespace.
* Le composant reçoit `{ params, segments }` où `segments` est le tableau
* de chemins après `/zen/<module>/` ; le module fait son propre routage interne.
*
* Le préfixe `api` est réservé : tout enregistrement sous moduleName === 'api'
* est rejeté pour éviter les collisions avec les routes API.
*/
const REGISTRY_KEY = Symbol.for('__ZEN_PUBLIC_MODULE_PAGES__');
if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = new Map();
/** @type {Map<string, { moduleName: string, Component: any, title?: string }>} */
const registry = globalThis[REGISTRY_KEY];
export function registerPublicModulePage({ moduleName, Component, title }) {
if (typeof moduleName !== 'string' || !moduleName) {
throw new TypeError('registerPublicModulePage: "moduleName" must be a non-empty string');
}
if (moduleName === 'api') {
throw new Error('registerPublicModulePage: "api" is a reserved namespace under /zen/');
}
if (typeof Component !== 'function' && typeof Component !== 'object') {
throw new TypeError(`registerPublicModulePage(${moduleName}): "Component" must be a React component`);
}
registry.set(moduleName, { moduleName, Component, title });
}
export function getPublicModulePage(moduleName) {
return registry.get(moduleName);
}
export function getPublicModulePages() {
return [...registry.values()];
}
+283
View File
@@ -0,0 +1,283 @@
# Storage
Ce répertoire est le **module de stockage de fichiers**. Il fournit une couche d'accès générique aux providers S3-compatibles (Cloudflare R2, Backblaze B2) : upload, téléchargement, suppression, listage, URL présignées et validation de fichiers. Il gère également le système de contrôle d'accès aux fichiers servis via HTTP.
Il ne connaît aucune feature spécifique — les features l'importent pour leurs propres besoins et enregistrent leurs propres policies d'accès.
---
## Structure
```
src/core/storage/
├── index.js Exports publics (uploadFile, deleteFile, validateUpload…)
├── api.js Route HTTP /zen/api/storage/** avec contrôle d'accès par chemin
├── storage-config.js Enregistrement des policies et préfixes publics
├── utils.js Validation de fichiers, MIME types, magic bytes
├── signing.js Signature AWS Signature V4 pour requêtes S3-compatibles
├── cloudflare-r2.js Configuration provider Cloudflare R2
└── backblaze.js Configuration provider Backblaze B2
```
---
## Variables d'environnement
| Variable | Rôle |
|----------|------|
| `ZEN_STORAGE_PROVIDER` | Provider à utiliser : `r2` (défaut) ou `backblaze` |
| `ZEN_R2_ACCOUNT_ID` | ID de compte Cloudflare (R2) |
| `ZEN_R2_ACCESS_KEY_ID` | Clé d'accès R2 |
| `ZEN_R2_SECRET_ACCESS_KEY` | Secret R2 |
| `ZEN_R2_BUCKET_NAME` | Nom du bucket R2 |
| `ZEN_B2_KEY_ID` | ID de clé Backblaze B2 |
| `ZEN_B2_APPLICATION_KEY` | Clé d'application Backblaze B2 |
| `ZEN_B2_BUCKET_NAME` | Nom du bucket Backblaze B2 |
| `ZEN_B2_ENDPOINT` | Endpoint S3 Backblaze (ex: `s3.us-west-004.backblazeb2.com`) |
---
## Système de contrôle d'accès
### Principe : tout est privé par défaut
Aucun fichier n'est accessible sans configuration explicite. Pour qu'un fichier soit servi via `/zen/api/storage/**`, son chemin doit correspondre à l'un des deux mécanismes déclarés pendant `initializeZen()` : un préfixe public ou une policy d'accès.
### Préfixes publics
Les fichiers dont le chemin commence par un préfixe public sont servis **sans authentification**. Utile pour les assets publics (logos, images de produits…).
Le chemin doit avoir au minimum la forme `{prefixe}/{id}/{fichier}` — un préfixe seul ne suffit pas à exposer des fichiers.
```js
// src/features/myfeature/storage-policies.js
export const storagePublicPrefixes = ['products'];
// products/abc123/photo.webp → accessible sans session
```
### Policies d'accès (chemins privés)
Pour les chemins privés, une policy doit correspondre au premier segment du chemin (`pathParts[0]`). Deux types sont disponibles :
| Type | Règle |
|------|-------|
| `owner` | `pathParts[1]` doit être l'ID de l'utilisateur connecté. Les admins ont accès à tout. |
| `admin` | L'utilisateur doit avoir le rôle `admin`. |
```js
// src/features/myfeature/storage-policies.js
export const storageAccessPolicies = [
{ prefix: 'users', type: 'owner' }, // users/{userId}/...
{ prefix: 'invoices', type: 'admin' }, // invoices/...
];
```
### Enregistrement dans initializeZen()
```js
// src/shared/lib/init.js
import { registerStoragePolicies, registerStoragePublicPrefixes } from '@zen/core/storage';
import { storageAccessPolicies, storagePublicPrefixes } from '../../features/myfeature/storage-policies.js';
registerStoragePolicies(storageAccessPolicies);
registerStoragePublicPrefixes(storagePublicPrefixes); // si nécessaire
```
L'enregistrement est **additif** — plusieurs features peuvent appeler `registerStoragePolicies` indépendamment.
---
## Fonctions d'opération
Toutes les fonctions retournent un objet `{ success, data, error }`. Elles ne lèvent jamais d'exception — les erreurs sont journalisées côté serveur et encapsulées dans `error`.
### `uploadFile({ key, body, contentType, metadata?, cacheControl? })`
Upload générique.
```js
const result = await uploadFile({
key: 'users/abc123/profile/avatar_xxx.webp',
body: buffer,
contentType: 'image/webp',
metadata: { userId: 'abc123' },
});
if (!result.success) throw new Error(result.error);
```
### `uploadImage({ key, body, contentType, metadata? })`
Équivalent à `uploadFile` avec `Cache-Control: public, max-age=31536000` (1 an).
### `deleteFile(key)`
Supprime un fichier.
```js
await deleteFile('users/abc123/profile/avatar_xxx.webp');
```
### `deleteFiles(keys[])`
Suppression en lot (S3 DeleteObjects). Retourne les clés supprimées et les erreurs éventuelles.
```js
const { data } = await deleteFiles(['a.jpg', 'b.jpg']);
// data.deleted → [{ Key: 'a.jpg' }, …]
// data.errors → []
```
### `getFile(key)`
Récupère le contenu et les métadonnées d'un fichier.
```js
const { data } = await getFile('users/abc123/profile/avatar.webp');
// data.body, data.contentType, data.contentLength, data.lastModified, data.metadata
```
### `getFileMetadata(key)`
Requête HEAD : métadonnées uniquement, sans télécharger le contenu.
### `fileExists(key)`
Retourne `true` si le fichier existe.
```js
if (await fileExists('users/abc123/profile/avatar.webp')) { }
```
### `listFiles({ prefix?, maxKeys?, continuationToken? })`
Liste les fichiers avec pagination (max 1 000 par appel).
```js
const { data } = await listFiles({ prefix: 'users/abc123/', maxKeys: 100 });
// data.files → [{ key, size, lastModified, etag }, …]
// data.isTruncated, data.nextContinuationToken
```
### `getPresignedUrl({ key, expiresIn?, operation? })`
Génère une URL signée temporaire. `expiresIn` en secondes (défaut 3600, max 604 800 = 7 jours). `operation` : `'get'` (défaut) ou `'put'`.
```js
const { data } = await getPresignedUrl({ key: 'docs/contrat.pdf', expiresIn: 300 });
// data.url → URL signée valide 5 minutes
```
### `copyFile({ sourceKey, destinationKey })`
Copie un fichier dans le même bucket.
### `moveFile({ sourceKey, destinationKey })`
Copie puis supprime la source. Si la suppression échoue, le fichier copié est conservé (non-fatal).
### `proxyFile(key, { filename? })`
Récupère un fichier pour le streamer directement au client.
---
## Validation de fichiers
### `validateUpload({ filename, size, allowedTypes, maxSize, buffer? })`
Validation complète avant upload. Retourne `{ valid, errors[] }`.
```js
const { valid, errors } = validateUpload({
filename: file.name,
size: buffer.byteLength,
allowedTypes: FILE_TYPE_PRESETS.IMAGES,
maxSize: FILE_SIZE_LIMITS.AVATAR,
buffer,
});
if (!valid) return apiError('Bad Request', errors[0]);
```
Couches de validation appliquées dans l'ordre :
1. Nom de fichier requis
2. SVG explicitement interdit (risque d'exécution de scripts)
3. Extension contre la liste blanche `allowedTypes`
4. Taille contre `maxSize`
5. *(si buffer fourni)* Magic bytes — assertion positive que l'extension correspond au contenu réel
6. *(si buffer fourni)* Scan des 512 premiers octets pour HTML/SVG/XML (défense contre les fichiers polyglots)
### `FILE_TYPE_PRESETS`
| Constante | Extensions |
|-----------|-----------|
| `IMAGES` | `.jpg` `.jpeg` `.png` `.gif` `.webp` |
| `IMAGES_NO_GIF` | `.jpg` `.jpeg` `.png` `.webp` |
| `DOCUMENTS` | `.pdf` `.doc` `.docx` `.xls` `.xlsx` `.ppt` `.pptx` `.txt` `.csv` |
| `PDF_ONLY` | `.pdf` |
| `VIDEOS` | `.mp4` `.avi` `.mov` `.wmv` |
| `AUDIO` | `.mp3` `.wav` |
| `ARCHIVES` | `.zip` `.rar` `.7z` `.tar` `.gz` |
### `FILE_SIZE_LIMITS`
| Constante | Limite |
|-----------|--------|
| `AVATAR` | 5 MB |
| `IMAGE` | 10 MB |
| `DOCUMENT` | 50 MB |
| `VIDEO` | 500 MB |
| `LARGE_FILE` | 1 GB |
### Fonctions utilitaires
| Fonction | Description |
|----------|-------------|
| `generateUniqueFilename(originalName, prefix?)` | Génère `{prefix}_{timestamp}_{hash}{ext}` |
| `getFileExtension(filename)` | Retourne l'extension avec le point (`.jpg`) ou `''` |
| `getMimeType(filename)` | Retourne le MIME type depuis l'extension |
| `validateFileType(filename, allowedTypes)` | Vérifie l'extension contre une liste |
| `validateFileSize(size, maxSize)` | Vérifie la taille |
| `formatFileSize(bytes)` | Formate en lisible humain (`1.5 MB`) |
| `sanitizeFilename(filename)` | Supprime les caractères spéciaux |
---
## Usage depuis une feature
```js
// src/features/media/api.js
import { uploadImage, deleteFile, validateUpload, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, generateUniqueFilename } from '../../core/storage/index.js';
export async function uploadAvatar(userId, file, buffer) {
const { valid, errors } = validateUpload({
filename: file.name,
size: buffer.byteLength,
allowedTypes: FILE_TYPE_PRESETS.IMAGES,
maxSize: FILE_SIZE_LIMITS.AVATAR,
buffer,
});
if (!valid) throw new Error(errors[0]);
const filename = generateUniqueFilename(file.name, 'avatar');
const key = `users/${userId}/profile/${filename}`;
const result = await uploadImage({ key, body: buffer, contentType: getMimeType(file.name) });
if (!result.success) throw new Error('Upload failed');
return key;
}
```
```js
// src/features/media/storage-policies.js ← la feature déclare ses propres policies
export const storageAccessPolicies = [
{ prefix: 'users', type: 'owner' },
];
```
## Usage depuis un module
```js
// src/modules/mymodule/actions.js
import { uploadFile, deleteFile, generateUniqueFilename } from '@zen/core/storage';
```
+36 -49
View File
@@ -7,31 +7,27 @@
* because access policy depends on the file path, not a single role. * because access policy depends on the file path, not a single role.
* The handler enforces its own rules: * The handler enforces its own rules:
* - Public prefix paths → no session required * - Public prefix paths → no session required
* - User files → session required; users can only access their own files * - All other paths → session required; access governed by registered policies
* - Organisation files → admin session required
* - Post files (private) → admin session required
* - Unknown paths → denied * - Unknown paths → denied
*
* Call registerStoragePolicies() and registerStoragePublicPrefixes() during
* initializeZen before the first request, following the same pattern as
* registerFeatureRoutes in core/api/runtime.js.
*/ */
import { validateSession } from '../../features/auth/lib/session.js'; import { getSessionCookieName } from '@zen/core/shared/config';
import { cookies } from 'next/headers'; import { getSessionResolver } from '../api/router.js';
import { getSessionCookieName } from '../../shared/lib/appConfig.js'; import { getFile } from './index.js';
import { getAllStoragePublicPrefixes } from '@zen/core/modules/storage'; import { fail } from '@zen/core/shared/logger';
import { getFile } from '@zen/core/storage';
import { fail } from '../../shared/lib/logger.js';
import { defineApiRoutes } from '../api/define.js'; import { defineApiRoutes } from '../api/define.js';
import { apiError } from '../api/respond.js'; import { apiError } from '../api/respond.js';
import { getStoragePublicPrefixes, getStorageAccessPolicies } from './storage-config.js';
const COOKIE_NAME = getSessionCookieName(); const COOKIE_NAME = getSessionCookieName();
/** // ─── Handlers ─────────────────────────────────────────────────────────────────
* Serve a file from storage with path-based security validation.
* async function handleGetFile(_request, { wildcard: fileKey }) {
* @param {Request} request
* @param {{ wildcard: string }} params - wildcard contains the full file key
* @returns {Promise<{ success: true, file: Object }|{ error: string, message: string }>}
*/
async function handleGetFile(request, { wildcard: fileKey }) {
try { try {
if (!fileKey) { if (!fileKey) {
return apiError('Bad Request', 'File path is required'); return apiError('Bad Request', 'File path is required');
@@ -40,22 +36,20 @@ async function handleGetFile(request, { wildcard: fileKey }) {
// Reject path traversal sequences, empty segments, and null bytes before // Reject path traversal sequences, empty segments, and null bytes before
// passing the key to the storage backend. Next.js decodes percent-encoding // passing the key to the storage backend. Next.js decodes percent-encoding
// before populating [...path], so '..' and '.' arrive as literal values. // before populating [...path], so '..' and '.' arrive as literal values.
const rawSegments = fileKey.split('/'); const pathParts = fileKey.split('/');
if ( if (
rawSegments.some(seg => seg === '..' || seg === '.' || seg === '') || pathParts.some(seg => seg === '..' || seg === '.' || seg === '') ||
fileKey.includes('\0') fileKey.includes('\0')
) { ) {
return apiError('Bad Request', 'Invalid file path'); return apiError('Bad Request', 'Invalid file path');
} }
const pathParts = rawSegments;
// Public prefixes: declared by each module via defineModule() storagePublicPrefixes. // Public prefixes: declared by each module via defineModule() storagePublicPrefixes.
// Files whose path starts with a declared prefix are served without authentication. // Files whose path starts with a declared prefix are served without authentication.
// The path must have at least two segments beyond the prefix // The path must have at least two segments beyond the prefix
// ({...prefix}/{id}/{filename}) to prevent unintentional root-level exposure. // ({...prefix}/{id}/{filename}) to prevent unintentional root-level exposure.
const publicPrefixes = getAllStoragePublicPrefixes(); const publicPrefixes = getStoragePublicPrefixes();
const matchedPrefix = publicPrefixes.find(prefix => fileKey.startsWith(prefix + '/')); const matchedPrefix = publicPrefixes.find(prefix => fileKey.startsWith(prefix + '/'));
if (matchedPrefix) { if (matchedPrefix) {
const prefixDepth = matchedPrefix.split('/').length; const prefixDepth = matchedPrefix.split('/').length;
if (pathParts.length < prefixDepth + 2) { if (pathParts.length < prefixDepth + 2) {
@@ -65,40 +59,37 @@ async function handleGetFile(request, { wildcard: fileKey }) {
} }
// Require authentication for all other paths. // Require authentication for all other paths.
const cookieStore = await cookies(); const { cookies } = await import('next/headers');
const cookieStore = await cookies();
const sessionToken = cookieStore.get(COOKIE_NAME)?.value; const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
if (!sessionToken) { if (!sessionToken) {
return apiError('Unauthorized', 'Authentication required to access files'); return apiError('Unauthorized', 'Authentication required to access files');
} }
const session = await validateSession(sessionToken); const session = await getSessionResolver()(sessionToken);
if (!session) { if (!session) {
return apiError('Unauthorized', 'Invalid or expired session'); return apiError('Unauthorized', 'Invalid or expired session');
} }
// Path-based access control for authenticated users. // Path-based access control driven by policies declared in each module.
if (pathParts[0] === 'users') { const policies = getStorageAccessPolicies();
// User files: users/{userId}/{category}/{filename} const policy = policies.find(p => pathParts[0] === p.prefix);
// Users can only access their own files, unless they are admin.
const userId = pathParts[1]; if (!policy) {
if (session.user.id !== userId && session.user.role !== 'admin') { return apiError('Forbidden', 'Invalid file path');
}
if (policy.type === 'owner') {
// Owner-scoped: pathParts[1] is the resource owner ID (e.g. users/{userId}/...)
if (session.user.id !== pathParts[1] && session.user.role !== 'admin') {
return apiError('Forbidden', 'You do not have permission to access this file'); return apiError('Forbidden', 'You do not have permission to access this file');
} }
} else if (pathParts[0] === 'organizations') { } else if (policy.type === 'admin') {
// Organisation files: admin only.
if (session.user.role !== 'admin') {
return apiError('Forbidden', 'Admin access required for organisation files');
}
} else if (pathParts[0] === 'posts') {
// Post files not covered by a public prefix: admin only.
if (session.user.role !== 'admin') { if (session.user.role !== 'admin') {
return apiError('Forbidden', 'Admin access required for this file'); return apiError('Forbidden', 'Admin access required for this file');
} }
} else {
// Unknown path pattern — deny by default.
return apiError('Forbidden', 'Invalid file path');
} }
return await fetchFile(fileKey); return await fetchFile(fileKey);
@@ -109,10 +100,6 @@ async function handleGetFile(request, { wildcard: fileKey }) {
} }
} }
/**
* Retrieve a file from the storage backend and return the response envelope.
* @param {string} fileKey
*/
async function fetchFile(fileKey) { async function fetchFile(fileKey) {
const result = await getFile(fileKey); const result = await getFile(fileKey);
@@ -128,11 +115,11 @@ async function fetchFile(fileKey) {
return { return {
success: true, success: true,
file: { file: {
body: result.data.body, body: result.data.body,
contentType: result.data.contentType, contentType: result.data.contentType,
contentLength: result.data.contentLength, contentLength: result.data.contentLength,
lastModified: result.data.lastModified lastModified: result.data.lastModified,
} },
}; };
} }
+28
View File
@@ -0,0 +1,28 @@
/**
* Backblaze B2 provider config (S3-compatible API).
* Reads ZEN_STORAGE_* environment variables.
*
* Endpoint format: s3.<region>.backblazeb2.com (e.g. s3.us-west-004.backblazeb2.com)
*/
export function getConfig() {
const host = process.env.ZEN_STORAGE_ENDPOINT;
const region = process.env.ZEN_STORAGE_REGION;
const accessKeyId = process.env.ZEN_STORAGE_ACCESS_KEY;
const secretAccessKey = process.env.ZEN_STORAGE_SECRET_KEY;
const bucket = process.env.ZEN_STORAGE_BUCKET;
if (!host || !accessKeyId || !secretAccessKey) {
throw new Error(
'Backblaze B2 credentials are not configured. Please set ZEN_STORAGE_ENDPOINT, ZEN_STORAGE_ACCESS_KEY, and ZEN_STORAGE_SECRET_KEY.'
);
}
if (!bucket) {
throw new Error('ZEN_STORAGE_BUCKET environment variable is not set.');
}
if (!region) {
throw new Error('ZEN_STORAGE_REGION environment variable is not set.');
}
return { accessKeyId, secretAccessKey, bucket, host, region };
}
+23
View File
@@ -0,0 +1,23 @@
/**
* Cloudflare R2 provider config.
* Reads ZEN_STORAGE_* environment variables.
*/
export function getConfig() {
const host = process.env.ZEN_STORAGE_ENDPOINT;
const region = process.env.ZEN_STORAGE_REGION ?? 'auto';
const accessKeyId = process.env.ZEN_STORAGE_ACCESS_KEY;
const secretAccessKey = process.env.ZEN_STORAGE_SECRET_KEY;
const bucket = process.env.ZEN_STORAGE_BUCKET;
if (!host || !accessKeyId || !secretAccessKey) {
throw new Error(
'Storage credentials are not configured. Please set ZEN_STORAGE_ENDPOINT, ZEN_STORAGE_ACCESS_KEY, and ZEN_STORAGE_SECRET_KEY.'
);
}
if (!bucket) {
throw new Error('ZEN_STORAGE_BUCKET environment variable is not set.');
}
return { accessKeyId, secretAccessKey, bucket, host, region };
}
+71 -388
View File
@@ -1,265 +1,33 @@
/** import { createHash } from 'crypto';
* Zen Storage Module - Cloudflare R2 import { fail, warn, info } from '@zen/core/shared/logger';
* Provides file upload, download, deletion, and management functionality import {
* Uses native fetch + crypto (AWS Signature V4) — no external dependencies signRequest,
*/ buildPresignedUrl,
toBuffer,
xmlFirst,
xmlAll,
sanitizeHeaderValue,
escapeXml,
metaToHeaders,
headersToMeta,
encodePath,
} from './signing.js';
import { createHmac, createHash } from 'crypto'; // ─── Provider selection ───────────────────────────────────────────────────────
import { fail, warn, info } from '../../shared/lib/logger.js';
// ─── AWS Signature V4 ──────────────────────────────────────────────────────── async function getConfig() {
const provider = process.env.ZEN_STORAGE_PROVIDER ?? 'r2';
function sha256hex(data) { const { getConfig: providerGetConfig } = provider === 'backblaze'
return createHash('sha256').update(data).digest('hex'); ? await import('./backblaze.js')
: await import('./cloudflare-r2.js');
return providerGetConfig();
} }
function hmac(key, data) { // ─── Storage functions ────────────────────────────────────────────────────────
return createHmac('sha256', Buffer.isBuffer(key) ? key : Buffer.from(key)).update(data).digest();
}
function hmacHex(key, data) {
return createHmac('sha256', Buffer.isBuffer(key) ? key : Buffer.from(key)).update(data).digest('hex');
}
function amzDate(date) {
return date.toISOString().replace(/[:\-]|\.\d{3}/g, '');
}
function dateStamp(date) {
return date.toISOString().slice(0, 10).replace(/-/g, '');
}
/**
* Encode a string per AWS Signature V4 spec (encodes everything except A-Z a-z 0-9 - _ . ~)
*/
function encodeS3(str) {
return encodeURIComponent(str)
.replace(/!/g, '%21')
.replace(/'/g, '%27')
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/\*/g, '%2A');
}
/**
* Encode a URI path, encoding each segment individually (preserving slashes)
*/
function encodePath(path) {
return path
.split('/')
.map(segment => (segment ? encodeS3(segment) : ''))
.join('/');
}
function signingKey(secret, ds, region, service) {
const kDate = hmac('AWS4' + secret, ds);
const kRegion = hmac(kDate, region);
const kService = hmac(kRegion, service);
return hmac(kService, 'aws4_request');
}
/**
* Sign an S3 request using AWS Signature V4.
* Returns the full URL and the headers object to pass to fetch.
*/
function signRequest({ method, host, path, query = {}, extraHeaders = {}, bodyBuffer, config, date }) {
const { accessKeyId, secretAccessKey } = config;
const region = 'auto';
const service = 's3';
const ts = amzDate(date);
const ds = dateStamp(date);
const bodyHash = sha256hex(bodyBuffer ?? Buffer.alloc(0));
const headers = {
host,
'x-amz-date': ts,
'x-amz-content-sha256': bodyHash,
...extraHeaders,
};
const sortedHeaderKeys = Object.keys(headers).sort();
const canonicalHeaders = sortedHeaderKeys.map(k => `${k}:${headers[k]}\n`).join('');
const signedHeaders = sortedHeaderKeys.join(';');
const canonicalQueryString = Object.keys(query)
.sort()
.map(k => `${encodeS3(k)}=${encodeS3(query[k])}`)
.join('&');
const canonicalRequest = [
method,
encodePath(path),
canonicalQueryString,
canonicalHeaders,
signedHeaders,
bodyHash,
].join('\n');
const scope = `${ds}/${region}/${service}/aws4_request`;
const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n');
const sig = hmacHex(signingKey(secretAccessKey, ds, region, service), stringToSign);
const auth = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${sig}`;
const requestHeaders = { ...headers, Authorization: auth };
delete requestHeaders.host;
const url = canonicalQueryString
? `https://${host}${path}?${canonicalQueryString}`
: `https://${host}${path}`;
return { url, headers: requestHeaders };
}
/**
* Build a presigned URL (signature embedded in query string, no Authorization header).
* The payload is marked UNSIGNED-PAYLOAD so no body hash is needed at signing time.
*/
function buildPresignedUrl({ method, host, path, expiresIn, config, date }) {
const { accessKeyId, secretAccessKey } = config;
const region = 'auto';
const service = 's3';
const ts = amzDate(date);
const ds = dateStamp(date);
const scope = `${ds}/${region}/${service}/aws4_request`;
const query = {
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${accessKeyId}/${scope}`,
'X-Amz-Date': ts,
'X-Amz-Expires': String(expiresIn),
'X-Amz-SignedHeaders': 'host',
};
const canonicalQueryString = Object.keys(query)
.sort()
.map(k => `${encodeS3(k)}=${encodeS3(query[k])}`)
.join('&');
const canonicalRequest = [
method,
encodePath(path),
canonicalQueryString,
`host:${host}\n`,
'host',
'UNSIGNED-PAYLOAD',
].join('\n');
const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n');
const sig = hmacHex(signingKey(secretAccessKey, ds, region, service), stringToSign);
return `https://${host}${path}?${canonicalQueryString}&X-Amz-Signature=${sig}`;
}
// ─── Config ──────────────────────────────────────────────────────────────────
function getConfig() {
const region = process.env.ZEN_STORAGE_REGION;
const accessKeyId = process.env.ZEN_STORAGE_ACCESS_KEY;
const secretAccessKey = process.env.ZEN_STORAGE_SECRET_KEY;
const bucket = process.env.ZEN_STORAGE_BUCKET;
if (!region || !accessKeyId || !secretAccessKey) {
throw new Error(
'Storage credentials are not configured. Please set ZEN_STORAGE_REGION, ZEN_STORAGE_ACCESS_KEY, and ZEN_STORAGE_SECRET_KEY.'
);
}
if (!bucket) {
throw new Error('ZEN_STORAGE_BUCKET environment variable is not set');
}
return {
accessKeyId,
secretAccessKey,
bucket,
host: `${region}.r2.cloudflarestorage.com`,
};
}
// ─── Minimal XML helpers ─────────────────────────────────────────────────────
function xmlFirst(xml, tag) {
const m = xml.match(new RegExp(`<${tag}[^>]*>(.*?)</${tag}>`, 's'));
return m ? m[1] : null;
}
function xmlAll(xml, tag) {
const re = new RegExp(`<${tag}[^>]*>(.*?)</${tag}>`, 'gs');
const results = [];
let m;
while ((m = re.exec(xml)) !== null) results.push(m[1]);
return results;
}
// ─── Body normalizer ─────────────────────────────────────────────────────────
async function toBuffer(body) {
if (Buffer.isBuffer(body)) return body;
if (body instanceof Uint8Array) return Buffer.from(body);
if (typeof body === 'string') return Buffer.from(body, 'utf8');
if (body instanceof Blob) return Buffer.from(await body.arrayBuffer());
return Buffer.from(body);
}
// ─── Sanitization helpers ─────────────────────────────────────────────────────
/**
* Strip HTTP header injection characters (\r, \n, \0) from a header value.
* A value containing these characters would break the canonical request format
* and could allow an attacker to inject arbitrary signed headers.
*/
function sanitizeHeaderValue(value) {
return String(value).replace(/[\r\n\0]/g, '');
}
/**
* Escape XML special characters to prevent injection into the DeleteObjects payload.
*/
function escapeXml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// ─── Metadata header helpers ─────────────────────────────────────────────────
function metaToHeaders(metadata) {
return Object.fromEntries(
Object.entries(metadata).map(([k, v]) => [
`x-amz-meta-${sanitizeHeaderValue(k).toLowerCase()}`,
sanitizeHeaderValue(v),
])
);
}
function headersToMeta(headers) {
return Object.fromEntries(
[...headers.entries()]
.filter(([k]) => k.startsWith('x-amz-meta-'))
.map(([k, v]) => [k.replace('x-amz-meta-', ''), v])
);
}
// ─── Storage functions ───────────────────────────────────────────────────────
/**
* Upload a file to storage
* @param {Object} options
* @param {string} options.key - File path/key in the bucket
* @param {Buffer|string|Uint8Array|Blob} options.body - File content
* @param {string} options.contentType - MIME type
* @param {Object} options.metadata - Optional metadata key-value pairs
* @param {string} options.cacheControl - Optional cache control header
* @returns {Promise<Object>}
*/
async function uploadFile({ key, body, contentType, metadata = {}, cacheControl }) { async function uploadFile({ key, body, contentType, metadata = {}, cacheControl }) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}/${key}`; const path = `/${config.bucket}/${key}`;
const date = new Date(); const date = new Date();
const bodyBuffer = await toBuffer(body); const bodyBuffer = await toBuffer(body);
@@ -270,16 +38,7 @@ async function uploadFile({ key, body, contentType, metadata = {}, cacheControl
...(cacheControl && { 'cache-control': sanitizeHeaderValue(cacheControl) }), ...(cacheControl && { 'cache-control': sanitizeHeaderValue(cacheControl) }),
}; };
const { url, headers } = signRequest({ const { url, headers } = signRequest({ method: 'PUT', host: config.host, path, extraHeaders, bodyBuffer, config, date });
method: 'PUT',
host: config.host,
path,
extraHeaders,
bodyBuffer,
config,
date,
});
const response = await fetch(url, { method: 'PUT', headers, body: bodyBuffer }); const response = await fetch(url, { method: 'PUT', headers, body: bodyBuffer });
if (!response.ok) { if (!response.ok) {
@@ -294,27 +53,13 @@ async function uploadFile({ key, body, contentType, metadata = {}, cacheControl
} }
} }
/**
* Upload an image with optimized settings
* @param {Object} options
* @param {string} options.key - File path/key in the bucket
* @param {Buffer|Blob} options.body - Image content
* @param {string} options.contentType - Image MIME type
* @param {Object} options.metadata - Optional metadata
* @returns {Promise<Object>}
*/
async function uploadImage({ key, body, contentType, metadata = {} }) { async function uploadImage({ key, body, contentType, metadata = {} }) {
return uploadFile({ key, body, contentType, metadata, cacheControl: 'public, max-age=31536000' }); return uploadFile({ key, body, contentType, metadata, cacheControl: 'public, max-age=31536000' });
} }
/**
* Delete a file from storage
* @param {string} key - File path/key to delete
* @returns {Promise<Object>}
*/
async function deleteFile(key) { async function deleteFile(key) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}/${key}`; const path = `/${config.bucket}/${key}`;
const date = new Date(); const date = new Date();
@@ -333,14 +78,9 @@ async function deleteFile(key) {
} }
} }
/**
* Delete multiple files from storage
* @param {string[]} keys - Array of file paths/keys to delete
* @returns {Promise<Object>}
*/
async function deleteFiles(keys) { async function deleteFiles(keys) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}`; const path = `/${config.bucket}`;
const date = new Date(); const date = new Date();
@@ -371,9 +111,9 @@ async function deleteFiles(keys) {
const xml = await response.text(); const xml = await response.text();
const deleted = xmlAll(xml, 'Deleted').map(b => ({ Key: xmlFirst(b, 'Key') })); const deleted = xmlAll(xml, 'Deleted').map(b => ({ Key: xmlFirst(b, 'Key') }));
const errors = xmlAll(xml, 'Error').map(b => ({ const errors = xmlAll(xml, 'Error').map(b => ({
Key: xmlFirst(b, 'Key'), Key: xmlFirst(b, 'Key'),
Code: xmlFirst(b, 'Code'), Code: xmlFirst(b, 'Code'),
Message: xmlFirst(b, 'Message'), Message: xmlFirst(b, 'Message'),
})); }));
@@ -384,14 +124,9 @@ async function deleteFiles(keys) {
} }
} }
/**
* Get a file from storage
* @param {string} key - File path/key to retrieve
* @returns {Promise<Object>} File data with metadata
*/
async function getFile(key) { async function getFile(key) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}/${key}`; const path = `/${config.bucket}/${key}`;
const date = new Date(); const date = new Date();
@@ -410,9 +145,9 @@ async function getFile(key) {
data: { data: {
key, key,
body: buffer, body: buffer,
contentType: response.headers.get('content-type'), contentType: response.headers.get('content-type'),
contentLength: Number(response.headers.get('content-length')), contentLength: Number(response.headers.get('content-length')),
lastModified: response.headers.get('last-modified') lastModified: response.headers.get('last-modified')
? new Date(response.headers.get('last-modified')) ? new Date(response.headers.get('last-modified'))
: null, : null,
metadata: headersToMeta(response.headers), metadata: headersToMeta(response.headers),
@@ -425,14 +160,9 @@ async function getFile(key) {
} }
} }
/**
* Get file metadata without downloading the file
* @param {string} key - File path/key
* @returns {Promise<Object>} File metadata
*/
async function getFileMetadata(key) { async function getFileMetadata(key) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}/${key}`; const path = `/${config.bucket}/${key}`;
const date = new Date(); const date = new Date();
@@ -447,13 +177,13 @@ async function getFileMetadata(key) {
success: true, success: true,
data: { data: {
key, key,
contentType: response.headers.get('content-type'), contentType: response.headers.get('content-type'),
contentLength: Number(response.headers.get('content-length')), contentLength: Number(response.headers.get('content-length')),
lastModified: response.headers.get('last-modified') lastModified: response.headers.get('last-modified')
? new Date(response.headers.get('last-modified')) ? new Date(response.headers.get('last-modified'))
: null, : null,
metadata: headersToMeta(response.headers), metadata: headersToMeta(response.headers),
etag: response.headers.get('etag'), etag: response.headers.get('etag'),
}, },
error: null, error: null,
}; };
@@ -463,27 +193,14 @@ async function getFileMetadata(key) {
} }
} }
/**
* Check if a file exists in storage
* @param {string} key - File path/key to check
* @returns {Promise<boolean>}
*/
async function fileExists(key) { async function fileExists(key) {
const result = await getFileMetadata(key); const result = await getFileMetadata(key);
return result.success; return result.success;
} }
/**
* List files in a directory/prefix
* @param {Object} options
* @param {string} options.prefix - Directory prefix (e.g., 'users/123/')
* @param {number} options.maxKeys - Maximum number of keys to return (default: 1000)
* @param {string} options.continuationToken - Token for pagination
* @returns {Promise<Object>}
*/
async function listFiles({ prefix = '', maxKeys = 1000, continuationToken } = {}) { async function listFiles({ prefix = '', maxKeys = 1000, continuationToken } = {}) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}`; const path = `/${config.bucket}`;
const date = new Date(); const date = new Date();
@@ -492,9 +209,9 @@ async function listFiles({ prefix = '', maxKeys = 1000, continuationToken } = {}
const query = { const query = {
'list-type': '2', 'list-type': '2',
'max-keys': String(validMaxKeys), 'max-keys': String(validMaxKeys),
...(prefix && { prefix }), ...(prefix && { prefix }),
...(continuationToken && { 'continuation-token': continuationToken }), ...(continuationToken && { 'continuation-token': continuationToken }),
}; };
const { url, headers } = signRequest({ method: 'GET', host: config.host, path, query, config, date }); const { url, headers } = signRequest({ method: 'GET', host: config.host, path, query, config, date });
@@ -506,47 +223,31 @@ async function listFiles({ prefix = '', maxKeys = 1000, continuationToken } = {}
} }
const xml = await response.text(); const xml = await response.text();
const isTruncated = xmlFirst(xml, 'IsTruncated') === 'true'; const isTruncated = xmlFirst(xml, 'IsTruncated') === 'true';
const nextContinuationToken = xmlFirst(xml, 'NextContinuationToken'); const nextContinuationToken = xmlFirst(xml, 'NextContinuationToken');
const files = xmlAll(xml, 'Contents').map(block => ({ const files = xmlAll(xml, 'Contents').map(block => ({
key: xmlFirst(block, 'Key'), key: xmlFirst(block, 'Key'),
size: parseInt(xmlFirst(block, 'Size') || '0', 10), size: parseInt(xmlFirst(block, 'Size') || '0', 10),
lastModified: xmlFirst(block, 'LastModified') ? new Date(xmlFirst(block, 'LastModified')) : null, lastModified: xmlFirst(block, 'LastModified') ? new Date(xmlFirst(block, 'LastModified')) : null,
etag: (xmlFirst(block, 'ETag') || '').replace(/"/g, ''), etag: (xmlFirst(block, 'ETag') || '').replace(/"/g, ''),
})); }));
return { return { success: true, data: { files, isTruncated, nextContinuationToken, count: files.length }, error: null };
success: true,
data: { files, isTruncated, nextContinuationToken, count: files.length },
error: null,
};
} catch (error) { } catch (error) {
fail(`Storage list files failed: ${error.message}`); fail(`Storage list files failed: ${error.message}`);
return { success: false, data: null, error: error.message }; return { success: false, data: null, error: error.message };
} }
} }
/**
* Generate a presigned URL for temporary access to a file
* @param {Object} options
* @param {string} options.key - File path/key
* @param {number} options.expiresIn - URL expiration time in seconds (default: 3600)
* @param {string} options.operation - 'get' or 'put' (default: 'get')
* @returns {Promise<Object>}
*/
async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) { async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) {
try { try {
const config = getConfig(); const config = await getConfig();
const path = `/${config.bucket}/${key}`; const path = `/${config.bucket}/${key}`;
const date = new Date(); const date = new Date();
const method = operation === 'put' ? 'PUT' : 'GET'; const method = operation === 'put' ? 'PUT' : 'GET';
// R2/S3 max presigned URL lifetime is 7 days (604800 seconds) // R2/S3 max presigned URL lifetime is 7 days (604800 seconds)
const validExpiresIn = Math.min(Math.max(Math.floor(Number(expiresIn)), 1), 604800); const validExpiresIn = Math.min(Math.max(Math.floor(Number(expiresIn)), 1), 604800);
if (!Number.isFinite(validExpiresIn)) {
throw new Error('expiresIn must be a finite positive number');
}
const url = buildPresignedUrl({ method, host: config.host, path, expiresIn: validExpiresIn, config, date }); const url = buildPresignedUrl({ method, host: config.host, path, expiresIn: validExpiresIn, config, date });
return { success: true, data: { key, url, expiresIn: validExpiresIn, operation }, error: null }; return { success: true, data: { key, url, expiresIn: validExpiresIn, operation }, error: null };
@@ -556,66 +257,50 @@ async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) {
} }
} }
/**
* Copy a file within the same bucket
* @param {Object} options
* @param {string} options.sourceKey - Source file path/key
* @param {string} options.destinationKey - Destination file path/key
* @returns {Promise<Object>}
*/
async function copyFile({ sourceKey, destinationKey }) { async function copyFile({ sourceKey, destinationKey }) {
try { try {
const getResult = await getFile(sourceKey); const config = await getConfig();
if (!getResult.success) return getResult; const path = `/${config.bucket}/${destinationKey}`;
const date = new Date();
const uploadResult = await uploadFile({ const { url, headers } = signRequest({
key: destinationKey, method: 'PUT',
body: getResult.data.body, host: config.host,
contentType: getResult.data.contentType, path,
metadata: getResult.data.metadata, extraHeaders: { 'x-amz-copy-source': `/${config.bucket}/${encodePath(sourceKey)}` },
config,
date,
}); });
if (uploadResult.success) { const response = await fetch(url, { method: 'PUT', headers });
info(`Storage: copied ${sourceKey}${destinationKey}`);
if (!response.ok) {
const text = await response.text();
throw new Error(`Copy failed (${response.status}): ${text}`);
} }
return uploadResult; info(`Storage: copied ${sourceKey}${destinationKey}`);
return { success: true, data: { key: destinationKey, bucket: config.bucket }, error: null };
} catch (error) { } catch (error) {
fail(`Storage copy failed: ${error.message}`); fail(`Storage copy failed: ${error.message}`);
return { success: false, data: null, error: error.message }; return { success: false, data: null, error: error.message };
} }
} }
/**
* Proxy a file from storage, returning a handler-ready response object.
* Use this instead of presigned URLs to avoid exposing storage URLs to clients.
* The returned object is consumed directly by the API router to stream the file.
* @param {string} key - File path/key to retrieve
* @param {Object} options
* @param {string} [options.filename] - Optional download filename (Content-Disposition)
* @returns {Promise<Object>}
*/
async function proxyFile(key, { filename } = {}) { async function proxyFile(key, { filename } = {}) {
const result = await getFile(key); const result = await getFile(key);
if (!result.success) return { success: false, error: result.error }; if (!result.success) return { success: false, error: result.error };
return { return {
success: true, success: true,
file: { file: {
body: result.data.body, body: result.data.body,
contentType: result.data.contentType, contentType: result.data.contentType,
contentLength: result.data.contentLength, contentLength: result.data.contentLength,
...(filename && { filename }), ...(filename && { filename }),
}, },
}; };
} }
/**
* Move a file (copy + delete source)
* @param {Object} options
* @param {string} options.sourceKey - Source file path/key
* @param {string} options.destinationKey - Destination file path/key
* @returns {Promise<Object>}
*/
async function moveFile({ sourceKey, destinationKey }) { async function moveFile({ sourceKey, destinationKey }) {
try { try {
const copyResult = await copyFile({ sourceKey, destinationKey }); const copyResult = await copyFile({ sourceKey, destinationKey });
@@ -635,7 +320,8 @@ async function moveFile({ sourceKey, destinationKey }) {
} }
} }
// Export utility functions // ─── Exports ──────────────────────────────────────────────────────────────────
export { export {
generateUniqueFilename, generateUniqueFilename,
getFileExtension, getFileExtension,
@@ -643,17 +329,12 @@ export {
validateFileType, validateFileType,
validateFileSize, validateFileSize,
formatFileSize, formatFileSize,
generateUserFilePath,
generateOrgFilePath,
generatePostFilePath,
sanitizeFilename, sanitizeFilename,
validateImageDimensions,
validateUpload, validateUpload,
FILE_TYPE_PRESETS, FILE_TYPE_PRESETS,
FILE_SIZE_LIMITS, FILE_SIZE_LIMITS,
} from './utils.js'; } from './utils.js';
// Export storage functions
export { export {
uploadFile, uploadFile,
uploadImage, uploadImage,
@@ -668,3 +349,5 @@ export {
copyFile, copyFile,
moveFile, moveFile,
}; };
export { registerStoragePolicies, registerStoragePublicPrefixes, clearStorageConfig } from './storage-config.js';
+198
View File
@@ -0,0 +1,198 @@
/**
* AWS Signature V4 — internal helpers for S3-compatible storage providers.
* Not exported from package.json; only imported by provider implementations.
*/
import { createHmac, createHash } from 'crypto';
// ─── Crypto primitives ───────────────────────────────────────────────────────
export function sha256hex(data) {
return createHash('sha256').update(data).digest('hex');
}
/**
* HMAC-SHA256. Pass encoding='hex' for a hex string; omit for a Buffer.
*/
export function hmac(key, data, encoding) {
return createHmac('sha256', Buffer.isBuffer(key) ? key : Buffer.from(key))
.update(data)
.digest(encoding);
}
export function amzDate(date) {
return date.toISOString().replace(/[:\-]|\.\d{3}/g, '');
}
export function dateStamp(date) {
return date.toISOString().slice(0, 10).replace(/-/g, '');
}
// ─── URI encoding ────────────────────────────────────────────────────────────
/**
* Encode a string per AWS Signature V4 spec (encodes everything except A-Z a-z 0-9 - _ . ~)
*/
export function encodeS3(str) {
return encodeURIComponent(str)
.replace(/!/g, '%21')
.replace(/'/g, '%27')
.replace(/\(/g, '%28')
.replace(/\)/g, '%29')
.replace(/\*/g, '%2A');
}
/**
* Encode a URI path, encoding each segment individually (preserving slashes)
*/
export function encodePath(path) {
return path.split('/').map(seg => (seg ? encodeS3(seg) : '')).join('/');
}
// ─── Signing key ─────────────────────────────────────────────────────────────
function signingKey(secret, ds, region, service) {
const kDate = hmac('AWS4' + secret, ds);
const kRegion = hmac(kDate, region);
const kService = hmac(kRegion, service);
return hmac(kService, 'aws4_request');
}
// ─── Request signing ─────────────────────────────────────────────────────────
/**
* Sign an S3 request using AWS Signature V4.
* Returns the full URL and the headers object to pass to fetch.
*/
export function signRequest({ method, host, path, query = {}, extraHeaders = {}, bodyBuffer, config, date }) {
const { accessKeyId, secretAccessKey, region } = config;
const service = 's3';
const ts = amzDate(date);
const ds = dateStamp(date);
const bodyHash = sha256hex(bodyBuffer ?? Buffer.alloc(0));
const headers = { host, 'x-amz-date': ts, 'x-amz-content-sha256': bodyHash, ...extraHeaders };
const sortedKeys = Object.keys(headers).sort();
const canonicalHeaders = sortedKeys.map(k => `${k}:${headers[k]}\n`).join('');
const signedHeaders = sortedKeys.join(';');
const canonicalQueryString = Object.keys(query)
.sort()
.map(k => `${encodeS3(k)}=${encodeS3(query[k])}`)
.join('&');
const canonicalRequest = [method, encodePath(path), canonicalQueryString, canonicalHeaders, signedHeaders, bodyHash].join('\n');
const scope = `${ds}/${region}/${service}/aws4_request`;
const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n');
const sig = hmac(signingKey(secretAccessKey, ds, region, service), stringToSign, 'hex');
const auth = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${sig}`;
const requestHeaders = { ...headers, Authorization: auth };
delete requestHeaders.host;
const url = canonicalQueryString
? `https://${host}${path}?${canonicalQueryString}`
: `https://${host}${path}`;
return { url, headers: requestHeaders };
}
/**
* Build a presigned URL (signature embedded in query string, no Authorization header).
* The payload is marked UNSIGNED-PAYLOAD so no body hash is needed at signing time.
*/
export function buildPresignedUrl({ method, host, path, expiresIn, config, date }) {
const { accessKeyId, secretAccessKey, region } = config;
const service = 's3';
const ts = amzDate(date);
const ds = dateStamp(date);
const scope = `${ds}/${region}/${service}/aws4_request`;
const query = {
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
'X-Amz-Credential': `${accessKeyId}/${scope}`,
'X-Amz-Date': ts,
'X-Amz-Expires': String(expiresIn),
'X-Amz-SignedHeaders': 'host',
};
const canonicalQueryString = Object.keys(query)
.sort()
.map(k => `${encodeS3(k)}=${encodeS3(query[k])}`)
.join('&');
const canonicalRequest = [method, encodePath(path), canonicalQueryString, `host:${host}\n`, 'host', 'UNSIGNED-PAYLOAD'].join('\n');
const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n');
const sig = hmac(signingKey(secretAccessKey, ds, region, service), stringToSign, 'hex');
return `https://${host}${path}?${canonicalQueryString}&X-Amz-Signature=${sig}`;
}
// ─── XML helpers ─────────────────────────────────────────────────────────────
export function xmlFirst(xml, tag) {
const m = xml.match(new RegExp(`<${tag}[^>]*>(.*?)</${tag}>`, 's'));
return m ? m[1] : null;
}
export function xmlAll(xml, tag) {
const re = new RegExp(`<${tag}[^>]*>(.*?)</${tag}>`, 'gs');
const results = [];
let m;
while ((m = re.exec(xml)) !== null) results.push(m[1]);
return results;
}
// ─── Body normalizer ─────────────────────────────────────────────────────────
export async function toBuffer(body) {
if (Buffer.isBuffer(body)) return body;
if (body instanceof Uint8Array) return Buffer.from(body);
if (typeof body === 'string') return Buffer.from(body, 'utf8');
if (body instanceof Blob) return Buffer.from(await body.arrayBuffer());
return Buffer.from(body);
}
// ─── Security helpers ─────────────────────────────────────────────────────────
/**
* Strip HTTP header injection characters (\r, \n, \0) from a header value.
*/
export function sanitizeHeaderValue(value) {
return String(value).replace(/[\r\n\0]/g, '');
}
/**
* Escape XML special characters to prevent injection into the DeleteObjects payload.
*/
export function escapeXml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
// ─── Metadata helpers ─────────────────────────────────────────────────────────
export function metaToHeaders(metadata) {
return Object.fromEntries(
Object.entries(metadata).map(([k, v]) => [
`x-amz-meta-${sanitizeHeaderValue(k).toLowerCase()}`,
sanitizeHeaderValue(v),
])
);
}
export function headersToMeta(headers) {
return Object.fromEntries(
[...headers.entries()]
.filter(([k]) => k.startsWith('x-amz-meta-'))
.map(([k, v]) => [k.replace('x-amz-meta-', ''), v])
);
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Storage API runtime configuration.
*
* Additive registration — mirrors core/api/runtime.js:
* registerStoragePolicies(policies) called by features during initializeZen()
* registerStoragePublicPrefixes(prefixes) called by features during initializeZen()
* clearStorageConfig() called by resetZenInitialization() / tests
*
* getStorageAccessPolicies() and getStoragePublicPrefixes() are read-only readers
* used internally by api.js — they are not re-exported from index.js.
*/
const POLICIES_KEY = Symbol.for('__ZEN_STORAGE_POLICIES__');
const PREFIXES_KEY = Symbol.for('__ZEN_STORAGE_PUBLIC_PREFIXES__');
if (!globalThis[POLICIES_KEY]) globalThis[POLICIES_KEY] = [];
if (!globalThis[PREFIXES_KEY]) globalThis[PREFIXES_KEY] = [];
export function registerStoragePolicies(policies) {
if (!Array.isArray(policies)) {
throw new TypeError('registerStoragePolicies: policies must be an array');
}
globalThis[POLICIES_KEY].push(...policies);
}
export function registerStoragePublicPrefixes(prefixes) {
if (!Array.isArray(prefixes)) {
throw new TypeError('registerStoragePublicPrefixes: prefixes must be an array');
}
globalThis[PREFIXES_KEY].push(...prefixes);
}
export function clearStorageConfig() {
globalThis[POLICIES_KEY].length = 0;
globalThis[PREFIXES_KEY].length = 0;
}
export function getStoragePublicPrefixes() {
return globalThis[PREFIXES_KEY];
}
export function getStorageAccessPolicies() {
return globalThis[POLICIES_KEY];
}
+48 -116
View File
@@ -4,7 +4,6 @@
*/ */
import crypto from 'crypto'; import crypto from 'crypto';
import { warn } from '../../shared/lib/logger.js';
/** /**
* Generate a unique filename with timestamp and random hash * Generate a unique filename with timestamp and random hash
@@ -31,6 +30,51 @@ export function getFileExtension(filename) {
return lastDot === -1 ? '' : filename.substring(lastDot).toLowerCase(); return lastDot === -1 ? '' : filename.substring(lastDot).toLowerCase();
} }
const MIME_TYPES = {
// Images
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.bmp': 'image/bmp',
// Documents
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.txt': 'text/plain',
'.csv': 'text/csv',
// Archives
'.zip': 'application/zip',
'.rar': 'application/x-rar-compressed',
'.7z': 'application/x-7z-compressed',
'.tar': 'application/x-tar',
'.gz': 'application/gzip',
// Media
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.mp4': 'video/mp4',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.wmv': 'video/x-ms-wmv',
// Code
'.js': 'application/javascript',
'.json': 'application/json',
'.xml': 'application/xml',
'.html': 'text/html',
'.css': 'text/css',
};
/** /**
* Get MIME type from file extension * Get MIME type from file extension
* @param {string} filename - Filename or extension * @param {string} filename - Filename or extension
@@ -38,53 +82,7 @@ export function getFileExtension(filename) {
*/ */
export function getMimeType(filename) { export function getMimeType(filename) {
const ext = getFileExtension(filename).toLowerCase(); const ext = getFileExtension(filename).toLowerCase();
return MIME_TYPES[ext] || 'application/octet-stream';
const mimeTypes = {
// Images
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.ico': 'image/x-icon',
'.bmp': 'image/bmp',
// Documents
'.pdf': 'application/pdf',
'.doc': 'application/msword',
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'.xls': 'application/vnd.ms-excel',
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'.ppt': 'application/vnd.ms-powerpoint',
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'.txt': 'text/plain',
'.csv': 'text/csv',
// Archives
'.zip': 'application/zip',
'.rar': 'application/x-rar-compressed',
'.7z': 'application/x-7z-compressed',
'.tar': 'application/x-tar',
'.gz': 'application/gzip',
// Media
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.mp4': 'video/mp4',
'.avi': 'video/x-msvideo',
'.mov': 'video/quicktime',
'.wmv': 'video/x-ms-wmv',
// Code
'.js': 'application/javascript',
'.json': 'application/json',
'.xml': 'application/xml',
'.html': 'text/html',
'.css': 'text/css',
};
return mimeTypes[ext] || 'application/octet-stream';
} }
/** /**
@@ -136,39 +134,6 @@ export function formatFileSize(bytes, decimals = 2) {
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
} }
/**
* Generate a storage path for a user's file
* @param {string|number} userId - User ID
* @param {string} category - File category (e.g., 'profile', 'documents')
* @param {string} filename - Filename
* @returns {string} Storage path (e.g., 'users/123/profile/filename.jpg')
*/
export function generateUserFilePath(userId, category, filename) {
return `users/${userId}/${category}/${filename}`;
}
/**
* Generate a storage path for organization/tenant files
* @param {string|number} orgId - Organization/tenant ID
* @param {string} category - File category
* @param {string} filename - Filename
* @returns {string} Storage path
*/
export function generateOrgFilePath(orgId, category, filename) {
return `organizations/${orgId}/${category}/${filename}`;
}
/**
* Generate a storage path for a post image, scoped by post type.
* @param {string} typeKey - Post type key (e.g. 'blogue', 'cve')
* @param {string|number} postIdOrSlug - Post ID or slug (use timestamp for pre-creation uploads)
* @param {string} filename - Filename
* @returns {string} Storage path (e.g., 'posts/blogue/123/filename.jpg')
*/
export function generatePostFilePath(typeKey, postIdOrSlug, filename) {
return `posts/${typeKey}/${postIdOrSlug}/${filename}`;
}
/** /**
* Sanitize filename by removing special characters * Sanitize filename by removing special characters
* @param {string} filename - Original filename * @param {string} filename - Original filename
@@ -187,36 +152,6 @@ export function sanitizeFilename(filename) {
return sanitized + ext; return sanitized + ext;
} }
/**
* Validate image dimensions from buffer
* Note: This is a basic implementation. For production, consider using a library like 'sharp'
* @param {Buffer} buffer - Image buffer
* @param {Object} constraints - Dimension constraints
* @param {number} constraints.maxWidth - Maximum width
* @param {number} constraints.maxHeight - Maximum height
* @param {number} constraints.minWidth - Minimum width
* @param {number} constraints.minHeight - Minimum height
* @returns {Promise<Object>} Validation result with dimensions
*/
export async function validateImageDimensions(buffer, constraints = {}) {
// SECURITY: This function previously returned { valid: true } unconditionally,
// silently bypassing all dimension constraints. That behaviour is unsafe —
// callers that invoke this function expect enforcement, not a no-op.
//
// Returning valid=false with a clear diagnostic forces callers to either
// install 'sharp' (the recommended path) or explicitly handle the
// unvalidated case themselves. Never silently approve what cannot be checked.
warn('Storage: validateImageDimensions — dimension enforcement unavailable. Install "sharp" to enable pixel-level validation.');
return {
valid: false,
width: null,
height: null,
message:
'Image dimension validation is not configured. ' +
'Install "sharp" and implement validateImageDimensions before enforcing size constraints.',
};
}
/** /**
* Common file type presets * Common file type presets
*/ */
@@ -336,11 +271,8 @@ export function validateUpload({ filename, size, allowedTypes, maxSize, buffer }
} }
// Explicitly reject SVG regardless of allowedTypes — SVG can carry JavaScript. // Explicitly reject SVG regardless of allowedTypes — SVG can carry JavaScript.
if (filename) { if (filename && getFileExtension(filename) === '.svg') {
const ext = filename.split('.').pop()?.toLowerCase(); errors.push('SVG files are not permitted due to script execution risk');
if (ext === 'svg') {
errors.push('SVG files are not permitted due to script execution risk');
}
} }
if (allowedTypes && !validateFileType(filename, allowedTypes)) { if (allowedTypes && !validateFileType(filename, allowedTypes)) {
+153
View File
@@ -0,0 +1,153 @@
# Themes
Ce répertoire gère le thème clair/sombre de l'interface. Il expose des utilitaires client pour lire, appliquer et réagir au thème, ainsi qu'un script d'initialisation à injecter dans `<head>` pour éviter le flash au chargement.
---
## Structure
```
src/core/themes/
└── index.js THEME_INIT_SCRIPT, getStoredTheme, applyTheme, getThemeIcon, ThemeWatcher, useTheme
```
---
## Import
```js
import {
THEME_INIT_SCRIPT,
getStoredTheme,
applyTheme,
getThemeIcon,
ThemeWatcher,
useTheme,
} from '@zen/core/themes';
```
Tous les exports sont marqués `'use client'`.
---
## API
### `THEME_INIT_SCRIPT`
Script inline à injecter dans `<head>` avant le premier rendu. Il lit `localStorage` et applique la classe `dark` sur `<html>` immédiatement, ce qui évite le flash de thème (FOUC).
```jsx
// app/layout.js
import { THEME_INIT_SCRIPT } from '@zen/core/themes';
export default function RootLayout({ children }) {
return (
<html>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_INIT_SCRIPT }} />
</head>
<body>{children}</body>
</html>
);
}
```
---
### `getStoredTheme()`
Lit le thème enregistré dans `localStorage`. Retourne `'light'`, `'dark'` ou `'auto'`.
```js
const theme = getStoredTheme(); // 'light' | 'dark' | 'auto'
```
---
### `applyTheme(theme)`
Applique un thème en modifiant `document.documentElement` et en mettant à jour `localStorage`. En mode `'auto'`, retire la préférence stockée et suit le système.
| Valeur | Comportement |
|--------|-------------|
| `'light'` | Retire la classe `dark`, stocke `'light'` |
| `'dark'` | Ajoute la classe `dark`, stocke `'dark'` |
| `'auto'` | Retire la valeur stockée, suit `prefers-color-scheme` |
```js
applyTheme('dark');
applyTheme('auto');
```
---
### `getThemeIcon(theme, systemIsDark)`
Retourne le composant icône correspondant au thème actuel.
| Thème | `systemIsDark` | Icône retournée |
|-------|----------------|-----------------|
| `'light'` | - | `Sun01Icon` |
| `'dark'` | - | `Moon02Icon` |
| `'auto'` | `true` | `MoonCloudIcon` |
| `'auto'` | `false` | `SunCloud01Icon` |
```jsx
const Icon = getThemeIcon(theme, systemIsDark);
return <Icon />;
```
---
### `ThemeWatcher`
Composant sans rendu qui écoute les changements de `prefers-color-scheme`. Si aucune préférence n'est stockée dans `localStorage`, il met à jour la classe `dark` automatiquement quand le système change.
```jsx
// Placer une fois dans le layout racine, après THEME_INIT_SCRIPT.
import { ThemeWatcher } from '@zen/core/themes';
export default function RootLayout({ children }) {
return (
<html>
<body>
<ThemeWatcher />
{children}
</body>
</html>
);
}
```
---
### `useTheme()`
Hook qui expose le thème actuel et une fonction de basculement cyclique. Synchronise l'état avec `localStorage` et le système au montage.
Retourne `{ theme, toggle, systemIsDark }`.
| Propriété | Type | Description |
|-----------|------|-------------|
| `theme` | `'light' \| 'dark' \| 'auto'` | Thème actif |
| `toggle` | `() => void` | Passe au thème suivant dans le cycle |
| `systemIsDark` | `boolean` | Indique si le système est en mode sombre |
Le cycle de basculement dépend de la préférence système :
- Système clair : `auto` -> `dark` -> `light` -> `auto`
- Système sombre : `auto` -> `light` -> `dark` -> `auto`
```jsx
import { useTheme, getThemeIcon } from '@zen/core/themes';
export function ThemeToggle() {
const { theme, toggle, systemIsDark } = useTheme();
const Icon = getThemeIcon(theme, systemIsDark);
return (
<button onClick={toggle}>
<Icon />
</button>
);
}
```
+83
View File
@@ -0,0 +1,83 @@
'use client';
import { useState, useEffect } from 'react';
import { Sun01Icon, Moon02Icon, SunCloud01Icon, MoonCloudIcon } from '@zen/core/shared/icons';
// Script à injecter dans <head> pour appliquer le thème avant le premier rendu (anti-FOUC).
export const THEME_INIT_SCRIPT = `(function(){try{var t=localStorage.getItem('theme'),d=window.matchMedia('(prefers-color-scheme: dark)').matches;if(t==='dark'||(!t&&d))document.documentElement.classList.add('dark');}catch(e){}})();`;
const THEME_ICONS = {
light: Sun01Icon,
dark: Moon02Icon,
};
export function getStoredTheme() {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') return stored;
return 'auto';
}
export function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else if (theme === 'light') {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
localStorage.removeItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', prefersDark);
}
}
export function getThemeIcon(theme, systemIsDark) {
if (theme === 'auto') return systemIsDark ? MoonCloudIcon : SunCloud01Icon;
return THEME_ICONS[theme];
}
function getNextTheme(current) {
const systemIsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (current === 'auto') return systemIsDark ? 'light' : 'dark';
if (current === 'dark') return systemIsDark ? 'auto' : 'light';
return systemIsDark ? 'dark' : 'auto';
}
export function ThemeWatcher() {
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
function onSystemChange(e) {
if (localStorage.getItem('theme')) return;
document.documentElement.classList.toggle('dark', e.matches);
}
mq.addEventListener('change', onSystemChange);
return () => mq.removeEventListener('change', onSystemChange);
}, []);
return null;
}
export function useTheme() {
const [theme, setTheme] = useState('auto');
const [systemIsDark, setSystemIsDark] = useState(false);
useEffect(() => {
setTheme(getStoredTheme());
setSystemIsDark(window.matchMedia('(prefers-color-scheme: dark)').matches);
const mq = window.matchMedia('(prefers-color-scheme: dark)');
function onSystemChange(e) {
setSystemIsDark(e.matches);
}
mq.addEventListener('change', onSystemChange);
return () => mq.removeEventListener('change', onSystemChange);
}, []);
function toggle() {
const next = getNextTheme(theme);
setTheme(next);
applyTheme(next);
}
return { theme, toggle, systemIsDark };
}
+145
View File
@@ -0,0 +1,145 @@
# Toast
Ce répertoire fournit un **système de notifications toast** basé sur un contexte React. Il expose un provider, un hook, et un conteneur à placer dans le layout. Les features utilisent le hook pour déclencher des notifications.
---
## Structure
```
src/core/toast/
├── index.js Toast, ToastProvider, useToast, ToastContainer
├── ToastContext.js contexte, provider, hook useToast
├── ToastContainer.js conteneur à monter dans le layout
└── Toast.js composant d'affichage individuel
```
---
## Import
```js
import { ToastProvider, useToast, ToastContainer } from '@zen/core/toast';
```
---
## Mise en place
Entourer le layout avec `ToastProvider` et y placer `ToastContainer`.
```jsx
import { ToastProvider, ToastContainer } from '@zen/core/toast';
export default function RootLayout({ children }) {
return (
<ToastProvider>
{children}
<ToastContainer />
</ToastProvider>
);
}
```
`useToast` lève une erreur si appelé hors du `ToastProvider`.
---
## API
### `useToast()`
Hook qui expose les méthodes et l'état courant des toasts.
```js
const { success, error, warning, info, addToast, removeToast, clearAllToasts } = useToast();
```
**Méthodes de raccourci**
```js
success('Modifications enregistrées.');
error('La connexion a échoué.');
warning('Session sur le point d'expirer.');
info('Une mise à jour est disponible.');
```
Chaque méthode accepte un message et un objet `options` optionnel pour surcharger les paramètres par défaut.
```js
success('Fichier importé.', { duration: 3000, dismissible: false });
```
Toutes retournent l'`id` du toast créé.
**`addToast(toast)`**
Crée un toast à partir d'un objet complet.
```js
const id = addToast({
type: 'success',
message: 'Profil mis à jour.',
title: 'Enregistré',
duration: 4000,
dismissible: true,
});
```
| Paramètre | Type | Défaut | Description |
|-----------|------|--------|-------------|
| `type` | `'success' \| 'error' \| 'warning' \| 'info'` | `'info'` | Variante visuelle |
| `message` | `string` | — | Corps du toast |
| `title` | `string` | Selon `type` | Titre affiché (optionnel) |
| `duration` | `number` | `5000` | Durée en ms avant disparition automatique. `0` pour désactiver |
| `dismissible` | `boolean` | `true` | Afficher le bouton de fermeture |
Durées par défaut selon le type : `error` → 7000 ms, `warning` → 6000 ms, `success` / `info` → 5000 ms.
**`removeToast(id)`**
Supprime un toast immédiatement par son `id`.
**`clearAllToasts()`**
Supprime tous les toasts actifs.
---
## ToastContainer
Composant à placer une seule fois dans le layout. Affiche les toasts en bas à droite de l'écran, empilés avec une animation de survol.
```jsx
<ToastContainer maxToasts={5} />
```
| Prop | Type | Défaut | Description |
|------|------|--------|-------------|
| `maxToasts` | `number` | `5` | Nombre maximum de toasts visibles simultanément |
Au survol du toast le plus récent, la pile se déploie pour afficher tous les toasts à leur taille réelle.
---
## Déclencher un toast depuis une feature
```js
// src/features/auth/actions/login.js
import { useToast } from '@zen/core/toast';
export function useLoginActions() {
const { success, error } = useToast();
async function login(credentials) {
const result = await loginRequest(credentials);
if (result.success) {
success('Connexion réussie.');
} else {
error('Identifiants incorrects.');
}
}
return { login };
}
```
+1 -1
View File
@@ -7,7 +7,7 @@ import {
AlertCircleIcon, AlertCircleIcon,
InformationCircleIcon, InformationCircleIcon,
CancelCircleIcon CancelCircleIcon
} from '../../shared/Icons.js'; } from '@zen/core/shared/icons';
const Toast = ({ const Toast = ({
id, id,
+3 -3
View File
@@ -1,6 +1,6 @@
'use client'; 'use client';
import React, { createContext, useContext, useState, useCallback } from 'react'; import React, { createContext, useContext, useState, useCallback, useMemo } from 'react';
const ToastContext = createContext(); const ToastContext = createContext();
@@ -88,7 +88,7 @@ export const ToastProvider = ({ children }) => {
}); });
}, [addToast]); }, [addToast]);
const value = { const value = useMemo(() => ({
toasts, toasts,
addToast, addToast,
removeToast, removeToast,
@@ -97,7 +97,7 @@ export const ToastProvider = ({ children }) => {
error, error,
warning, warning,
info, info,
}; }), [toasts, addToast, removeToast, clearAllToasts, success, error, warning, info]);
return ( return (
<ToastContext.Provider value={value}> <ToastContext.Provider value={value}>
+274
View File
@@ -0,0 +1,274 @@
# Users
Ce répertoire gère les utilisateurs, l'authentification par identifiants, les sessions, les rôles et les permissions. Il constitue la couche de données auth du projet : les features l'appellent, il ne connaît pas les features.
---
## Structure
```
src/core/users/
├── index.js re-exports publics
├── auth.js register, login, mot de passe, vérification email
├── session.js création, validation, suppression de sessions
├── queries.js lecture et mise à jour des utilisateurs
├── roles.js CRUD des rôles, assignation aux utilisateurs
├── permissions.js hasPermission, getUserPermissions
├── constants.js PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups
├── verifications.js tokens de vérification email et de réinitialisation
├── emailChange.js tokens de changement d'adresse email
├── password.js hashPassword, verifyPassword, generateToken, generateId
└── db.js helpers internes
```
---
## Import
```js
import {
register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser,
createSession, validateSession, deleteSession, deleteUserSessions, refreshSession,
getUserById, getUserByEmail, countUsers, listUsers, updateUserById,
createRole, updateRole, deleteRole, listRoles, getRoleById,
getUserRoles, assignUserRole, revokeUserRole,
hasPermission, getUserPermissions,
PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups,
hashPassword, verifyPassword, generateToken, generateId,
} from '@zen/core/users';
```
---
## API
### `register(userData, options?)`
Crée un compte utilisateur avec vérification des contraintes mot de passe. Le premier utilisateur enregistré reçoit le rôle `admin`. Retourne `{ user, verificationToken }`.
```js
const { user, verificationToken } = await register(
{ email: 'alice@example.com', password: 'Secret1', name: 'Alice' },
{
onEmailVerification: async (email, token) => {
await sendVerificationEmail({ to: email, token });
},
}
);
```
| Paramètre | Type | Description |
|-----------|------|-------------|
| `email` | `string` | Adresse email (max 254 caractères) |
| `password` | `string` | Mot de passe (8-128 caractères, au moins 1 majuscule, 1 minuscule, 1 chiffre) |
| `name` | `string` | Nom affiché (max 100 caractères) |
| `onEmailVerification` | `async (email, token) => void` | Callback pour envoyer le token de vérification |
---
### `login(credentials, sessionOptions?)`
Vérifie les identifiants et crée une session. Retourne `{ user, session }`. Lève une erreur si les identifiants sont incorrects.
```js
const { user, session } = await login(
{ email: 'alice@example.com', password: 'Secret1' },
{ ipAddress: req.ip, userAgent: req.headers['user-agent'] }
);
```
---
### `requestPasswordReset(email)`
Génère un token de réinitialisation (expire dans 1 heure). Retourne `{ success: true, token }` même si l'email est inconnu, pour éviter l'énumération.
```js
const { token } = await requestPasswordReset('alice@example.com');
```
---
### `resetPassword(data, options?)`
Valide le token et met à jour le mot de passe. Retourne `{ success: true }`.
```js
await resetPassword(
{ email: 'alice@example.com', token, newPassword: 'NewSecret1' },
{ onPasswordChanged: async (email) => { /* envoyer confirmation */ } }
);
```
---
### `verifyUserEmail(userId)`
Marque l'email de l'utilisateur comme vérifié.
```js
await verifyUserEmail(user.id);
```
---
### `updateUser(userId, data)`
Met à jour les champs autorisés du profil : `name`, `image`, `language`.
```js
await updateUser(user.id, { name: 'Alice Martin' });
```
---
### `createSession(userId, options?)`
Crée une session valide 30 jours. Retourne l'objet session.
```js
const session = await createSession(user.id, { ipAddress: '127.0.0.1', userAgent: '...' });
```
---
### `validateSession(token)`
Valide un token de session. Renouvelle automatiquement la session si elle expire dans moins de 20 jours. Retourne `{ session, user, sessionRefreshed }` ou `null`.
```js
const result = await validateSession(token);
if (!result) {
// session expirée ou invalide
}
```
---
### `deleteSession(token)` / `deleteUserSessions(userId)`
Supprime une session ou toutes les sessions d'un utilisateur.
```js
await deleteSession(token);
await deleteUserSessions(user.id);
```
---
### `getUserById(id)` / `getUserByEmail(email)`
Récupère un utilisateur par son id ou son email.
```js
const user = await getUserById('abc123');
const user = await getUserByEmail('alice@example.com');
```
---
### `listUsers(options?)`
Liste les utilisateurs avec pagination et tri. Retourne `{ users, pagination }`.
```js
const { users, pagination } = await listUsers({ page: 1, limit: 20, sortBy: 'created_at', sortOrder: 'desc' });
```
| Paramètre | Défaut | Description |
|-----------|--------|-------------|
| `page` | `1` | Page courante |
| `limit` | `10` | Résultats par page (max 100) |
| `sortBy` | `'created_at'` | Colonne de tri (`id`, `email`, `name`, `role`, `email_verified`, `created_at`) |
| `sortOrder` | `'desc'` | `'asc'` ou `'desc'` |
---
### `updateUserById(id, fields)`
Met à jour les champs autorisés d'un utilisateur : `name`, `role`, `email_verified`, `image`, `language`.
```js
await updateUserById(user.id, { role: 'editor', email_verified: true });
```
---
### Rôles
```js
const roles = await listRoles();
const role = await getRoleById(id);
const role = await createRole({ name: 'Modérateur', description: 'Peut gérer les utilisateurs', color: '#3b82f6' });
await updateRole(roleId, {
name: 'Modérateur',
permissionKeys: [PERMISSIONS.USERS_VIEW, PERMISSIONS.USERS_MANAGE],
});
await deleteRole(roleId); // impossible sur les rôles système
const userRoles = await getUserRoles(userId);
await assignUserRole(userId, roleId);
await revokeUserRole(userId, roleId);
```
Les rôles système (`is_system = true`) peuvent être renommés mais leurs permissions ne peuvent pas être modifiées. Ils ne peuvent pas être supprimés.
L'endpoint `DELETE /zen/api/users/:id/roles/:roleId` applique une règle de sécurité supplémentaire : un utilisateur ne peut pas se retirer un rôle qui lui accorde `users.manage` s'il n'en a pas d'autre. Cela évite qu'un administrateur se retrouve dans l'impossibilité de se redonner la permission. Cette vérification est faite au niveau du handler API et ne concerne pas la fonction `revokeUserRole` elle-même.
---
### Permissions
```js
const canManageRoles = await hasPermission(userId, PERMISSIONS.ROLES_MANAGE);
const keys = await getUserPermissions(userId);
```
`PERMISSIONS` contient toutes les clés disponibles. `PERMISSION_DEFINITIONS` expose le label, la description et le groupe de chaque permission. `getPermissionGroups()` retourne les permissions regroupées par `group_name`.
| Groupe | Clés |
|--------|------|
| Administration | `admin.access` |
| Utilisateurs | `users.view`, `users.manage` |
| Rôles | `roles.view`, `roles.manage` |
---
### Changement d'email
```js
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange } from '@zen/core/users/emailChange';
const token = await createEmailChangeToken(userId, 'new@example.com');
// Plus tard, lors de la confirmation :
const result = await verifyEmailChangeToken(token);
if (result) {
await applyEmailChange(result.userId, result.newEmail);
}
```
Le token expire dans 24 heures. `verifyEmailChangeToken` retourne `null` si le token est invalide ou expiré.
---
## Gestion des erreurs
`register`, `login`, `resetPassword` lèvent des erreurs typées (`Error`) avec des messages en français. Les fonctions de requête (`getUserById`, etc.) retournent `null` si l'entrée n'existe pas. Les callbacks `onEmailVerification` et `onPasswordChanged` sont exécutés sans bloquer le flux principal : une erreur dans le callback est loguée mais n'interrompt pas l'opération.
---
## Tables utilisées
| Table | Description |
|-------|-------------|
| `zen_auth_users` | Comptes utilisateurs |
| `zen_auth_accounts` | Identifiants par provider (`credential`) |
| `zen_auth_sessions` | Sessions actives |
| `zen_auth_verifications` | Tokens de vérification email, reset mot de passe, changement email |
| `zen_auth_roles` | Rôles |
| `zen_auth_role_permissions` | Permissions associées aux rôles |
| `zen_auth_user_roles` | Rôles assignés aux utilisateurs |
@@ -1,78 +1,55 @@
/** import { query, create, findOne, updateById, count } from '@zen/core/database';
* Authentication Logic
* Main authentication functions for user registration, login, and password management
*/
import { create, findOne, updateById, count } from '../../../core/database/crud.js';
import { hashPassword, verifyPassword, generateId } from './password.js'; import { hashPassword, verifyPassword, generateId } from './password.js';
import { createSession } from './session.js'; import { createSession } from './session.js';
import { fail } from '../../../shared/lib/logger.js'; import { fail } from '@zen/core/shared/logger';
import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken, sendPasswordChangedEmail } from './email.js'; import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken, verifyAccountSetupToken, deleteAccountSetupToken } from './verifications.js';
/** async function register(userData, { onEmailVerification } = {}) {
* Register a new user
* @param {Object} userData - User registration data
* @param {string} userData.email - User email
* @param {string} userData.password - User password
* @param {string} userData.name - User name
* @returns {Promise<Object>} Created user and session
*/
async function register(userData) {
const { email, password, name } = userData; const { email, password, name } = userData;
// Validate required fields
if (!email || !password || !name) { if (!email || !password || !name) {
throw new Error('L\'e-mail, le mot de passe et le nom sont requis'); throw new Error('L\'e-mail, le mot de passe et le nom sont requis');
} }
// Validate email length (maximum 254 characters - RFC standard)
if (email.length > 254) { if (email.length > 254) {
throw new Error('L\'e-mail doit contenir 254 caractères ou moins'); throw new Error('L\'e-mail doit contenir 254 caractères ou moins');
} }
// Validate password length (minimum 8, maximum 128 characters)
if (password.length < 8) { if (password.length < 8) {
throw new Error('Le mot de passe doit contenir au moins 8 caractères'); throw new Error('Le mot de passe doit contenir au moins 8 caractères');
} }
if (password.length > 128) { if (password.length > 128) {
throw new Error('Le mot de passe doit contenir 128 caractères ou moins'); throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
} }
// Validate password complexity (1 uppercase, 1 lowercase, 1 number)
const hasUppercase = /[A-Z]/.test(password); const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password); const hasLowercase = /[a-z]/.test(password);
const hasNumber = /\d/.test(password); const hasNumber = /\d/.test(password);
if (!hasUppercase || !hasLowercase || !hasNumber) { if (!hasUppercase || !hasLowercase || !hasNumber) {
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre'); throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
} }
// Validate name length (maximum 100 characters)
if (name.length > 100) { if (name.length > 100) {
throw new Error('Le nom doit contenir 100 caractères ou moins'); throw new Error('Le nom doit contenir 100 caractères ou moins');
} }
// Validate name is not empty after trimming
if (name.trim().length === 0) { if (name.trim().length === 0) {
throw new Error('Le nom ne peut pas être vide'); throw new Error('Le nom ne peut pas être vide');
} }
// Check if user already exists
const existingUser = await findOne('zen_auth_users', { email }); const existingUser = await findOne('zen_auth_users', { email });
if (existingUser) { if (existingUser) {
throw new Error('Un utilisateur avec cet e-mail existe déjà'); throw new Error('Un utilisateur avec cet e-mail existe déjà');
} }
// Check if this is the first user - if so, make them admin
const userCount = await count('zen_auth_users'); const userCount = await count('zen_auth_users');
const role = userCount === 0 ? 'admin' : 'user'; const role = userCount === 0 ? 'admin' : 'user';
// Hash password
const hashedPassword = await hashPassword(password); const hashedPassword = await hashPassword(password);
// Create user
const userId = generateId(); const userId = generateId();
const user = await create('zen_auth_users', { const user = await create('zen_auth_users', {
id: userId, id: userId,
email, email,
@@ -82,8 +59,7 @@ async function register(userData) {
role, role,
updated_at: new Date() updated_at: new Date()
}); });
// Create account with password
const accountId = generateId(); const accountId = generateId();
await create('zen_auth_accounts', { await create('zen_auth_accounts', {
id: accountId, id: accountId,
@@ -93,176 +69,143 @@ async function register(userData) {
password: hashedPassword, password: hashedPassword,
updated_at: new Date() updated_at: new Date()
}); });
// Create email verification token // Assign admin role to first user via the new roles system
if (role === 'admin') {
try {
const adminRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'admin'`);
if (adminRole.rows.length > 0) {
await query(
`INSERT INTO zen_auth_user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[user.id, adminRole.rows[0].id]
);
}
} catch (err) {
fail(`register: failed to assign admin role to first user: ${err.message}`);
}
}
const verification = await createEmailVerification(email); const verification = await createEmailVerification(email);
return { if (onEmailVerification) {
user, try {
verificationToken: verification.token await onEmailVerification(email, verification.token);
}; } catch (err) {
fail(`register: failed to send verification email: ${err.message}`);
}
}
return { user, verificationToken: verification.token };
} }
/**
* Login a user
* @param {Object} credentials - Login credentials
* @param {string} credentials.email - User email
* @param {string} credentials.password - User password
* @param {Object} sessionOptions - Session options (ipAddress, userAgent)
* @returns {Promise<Object>} User and session
*/
async function login(credentials, sessionOptions = {}) { async function login(credentials, sessionOptions = {}) {
const { email, password } = credentials; const { email, password } = credentials;
// Validate required fields
if (!email || !password) { if (!email || !password) {
throw new Error('L\'e-mail et le mot de passe sont requis'); throw new Error('L\'e-mail et le mot de passe sont requis');
} }
// Find user
const user = await findOne('zen_auth_users', { email }); const user = await findOne('zen_auth_users', { email });
if (!user) { if (!user) {
throw new Error('E-mail ou mot de passe incorrect'); throw new Error('E-mail ou mot de passe incorrect');
} }
// Find account with password
const account = await findOne('zen_auth_accounts', { const account = await findOne('zen_auth_accounts', {
user_id: user.id, user_id: user.id,
provider_id: 'credential' provider_id: 'credential'
}); });
if (!account || !account.password) { if (!account || !account.password) {
throw new Error('E-mail ou mot de passe incorrect'); throw new Error('E-mail ou mot de passe incorrect');
} }
// Verify password
const isValid = await verifyPassword(password, account.password); const isValid = await verifyPassword(password, account.password);
if (!isValid) { if (!isValid) {
throw new Error('E-mail ou mot de passe incorrect'); throw new Error('E-mail ou mot de passe incorrect');
} }
// Create session
const session = await createSession(user.id, sessionOptions); const session = await createSession(user.id, sessionOptions);
return { return { user, session };
user,
session
};
} }
/**
* Request a password reset
* @param {string} email - User email
* @returns {Promise<Object>} Reset token
*/
async function requestPasswordReset(email) { async function requestPasswordReset(email) {
// Validate email
if (!email) { if (!email) {
throw new Error('L\'e-mail est requis'); throw new Error('L\'e-mail est requis');
} }
// Check if user exists
const user = await findOne('zen_auth_users', { email }); const user = await findOne('zen_auth_users', { email });
if (!user) { if (!user) {
// Don't reveal if user exists or not
return { success: true }; return { success: true };
} }
// Create password reset token
const reset = await createPasswordReset(email); const reset = await createPasswordReset(email);
return { return { success: true, token: reset.token };
success: true,
token: reset.token
};
} }
/** async function resetPassword(resetData, { onPasswordChanged } = {}) {
* Reset password with token
* @param {Object} resetData - Reset data
* @param {string} resetData.email - User email
* @param {string} resetData.token - Reset token
* @param {string} resetData.newPassword - New password
* @returns {Promise<Object>} Success status
*/
async function resetPassword(resetData) {
const { email, token, newPassword } = resetData; const { email, token, newPassword } = resetData;
// Validate required fields
if (!email || !token || !newPassword) { if (!email || !token || !newPassword) {
throw new Error('L\'e-mail, le jeton et le nouveau mot de passe sont requis'); throw new Error('L\'e-mail, le jeton et le nouveau mot de passe sont requis');
} }
// Validate password length (minimum 8, maximum 128 characters)
if (newPassword.length < 8) { if (newPassword.length < 8) {
throw new Error('Le mot de passe doit contenir au moins 8 caractères'); throw new Error('Le mot de passe doit contenir au moins 8 caractères');
} }
if (newPassword.length > 128) { if (newPassword.length > 128) {
throw new Error('Le mot de passe doit contenir 128 caractères ou moins'); throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
} }
// Validate password complexity (1 uppercase, 1 lowercase, 1 number)
const hasUppercase = /[A-Z]/.test(newPassword); const hasUppercase = /[A-Z]/.test(newPassword);
const hasLowercase = /[a-z]/.test(newPassword); const hasLowercase = /[a-z]/.test(newPassword);
const hasNumber = /\d/.test(newPassword); const hasNumber = /\d/.test(newPassword);
if (!hasUppercase || !hasLowercase || !hasNumber) { if (!hasUppercase || !hasLowercase || !hasNumber) {
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre'); throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
} }
// Authoritative token verification — this check must live here so that any
// caller that imports resetPassword() directly (bypassing the server-action
// layer) cannot reset a password with an arbitrary or omitted token.
const tokenValid = await verifyResetToken(email, token); const tokenValid = await verifyResetToken(email, token);
if (!tokenValid) { if (!tokenValid) {
throw new Error('Jeton de réinitialisation invalide ou expiré'); throw new Error('Jeton de réinitialisation invalide ou expiré');
} }
// Find user
const user = await findOne('zen_auth_users', { email }); const user = await findOne('zen_auth_users', { email });
if (!user) { if (!user) {
throw new Error('Jeton de réinitialisation invalide'); throw new Error('Jeton de réinitialisation invalide');
} }
// Find account
const account = await findOne('zen_auth_accounts', { const account = await findOne('zen_auth_accounts', {
user_id: user.id, user_id: user.id,
provider_id: 'credential' provider_id: 'credential'
}); });
if (!account) { if (!account) {
throw new Error('Compte introuvable'); throw new Error('Compte introuvable');
} }
// Hash new password
const hashedPassword = await hashPassword(newPassword); const hashedPassword = await hashPassword(newPassword);
// Update password
await updateById('zen_auth_accounts', account.id, { await updateById('zen_auth_accounts', account.id, {
password: hashedPassword, password: hashedPassword,
updated_at: new Date() updated_at: new Date()
}); });
// Delete reset token
await deleteResetToken(email); await deleteResetToken(email);
// Send password changed confirmation email if (onPasswordChanged) {
try { try {
await sendPasswordChangedEmail(email); await onPasswordChanged(email);
} catch (error) { } catch (error) {
// Log error but don't fail the password reset process fail(`Auth: failed to send password changed email to ${email}: ${error.message}`);
fail(`Auth: failed to send password changed email to ${email}: ${error.message}`); }
} }
return { success: true }; return { success: true };
} }
/**
* Verify user email
* @param {string} userId - User ID
* @returns {Promise<Object>} Updated user
*/
async function verifyUserEmail(userId) { async function verifyUserEmail(userId) {
return await updateById('zen_auth_users', userId, { return await updateById('zen_auth_users', userId, {
email_verified: true, email_verified: true,
@@ -270,32 +213,83 @@ async function verifyUserEmail(userId) {
}); });
} }
/**
* Update user profile
* @param {string} userId - User ID
* @param {Object} updateData - Data to update
* @returns {Promise<Object>} Updated user
*/
async function updateUser(userId, updateData) { async function updateUser(userId, updateData) {
const allowedFields = ['name', 'image', 'language']; const allowedFields = ['name', 'image', 'language'];
const filteredData = {}; const filteredData = {};
for (const field of allowedFields) { for (const field of allowedFields) {
if (updateData[field] !== undefined) { if (updateData[field] !== undefined) {
filteredData[field] = updateData[field]; filteredData[field] = updateData[field];
} }
} }
filteredData.updated_at = new Date(); filteredData.updated_at = new Date();
return await updateById('zen_auth_users', userId, filteredData); return await updateById('zen_auth_users', userId, filteredData);
} }
export { async function completeAccountSetup({ email, token, password }) {
register, if (!email || !token || !password) {
login, throw new Error('L\'e-mail, le jeton et le mot de passe sont requis');
requestPasswordReset, }
resetPassword,
verifyUserEmail, if (password.length < 8) {
updateUser throw new Error('Le mot de passe doit contenir au moins 8 caractères');
}; }
if (password.length > 128) {
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
}
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /\d/.test(password);
if (!hasUppercase || !hasLowercase || !hasNumber) {
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
}
const tokenValid = await verifyAccountSetupToken(email, token);
if (!tokenValid) {
throw new Error('Lien d\'invitation invalide ou expiré');
}
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('Lien d\'invitation invalide');
}
const hashedPassword = await hashPassword(password);
const existingAccount = await findOne('zen_auth_accounts', {
user_id: user.id,
provider_id: 'credential'
});
if (existingAccount) {
await updateById('zen_auth_accounts', existingAccount.id, {
password: hashedPassword,
updated_at: new Date()
});
} else {
await create('zen_auth_accounts', {
id: generateId(),
account_id: email,
provider_id: 'credential',
user_id: user.id,
password: hashedPassword,
updated_at: new Date()
});
}
await updateById('zen_auth_users', user.id, {
email_verified: true,
updated_at: new Date()
});
await deleteAccountSetupToken(email);
return { success: true };
}
export { register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser, completeAccountSetup };
+32
View File
@@ -0,0 +1,32 @@
/**
* Static permission definitions — single source of truth.
* No server-side imports: safe to use in both client and server code.
*/
export const PERMISSIONS = {
ADMIN_ACCESS: 'admin.access',
USERS_VIEW: 'users.view',
USERS_MANAGE: 'users.manage',
ROLES_VIEW: 'roles.view',
ROLES_MANAGE: 'roles.manage',
};
export const PERMISSION_DEFINITIONS = [
{ key: 'admin.access', name: 'Accès au panneau admin', description: "Permet d'accéder à l'interface d'administration.", group_name: 'Administration' },
{ key: 'users.view', name: 'Voir les utilisateurs', description: 'Permet de consulter la liste des membres et leurs profils.', group_name: 'Utilisateurs' },
{ key: 'users.manage', name: 'Gérer les utilisateurs', description: 'Permet de créer, modifier et supprimer des comptes membres.', group_name: 'Utilisateurs' },
{ key: 'roles.view', name: 'Voir les rôles', description: 'Permet de consulter la liste des rôles et leurs permissions.', group_name: 'Rôles' },
{ key: 'roles.manage', name: 'Gérer les rôles', description: 'Permet de créer, modifier et supprimer des rôles.', group_name: 'Rôles' },
];
/**
* Returns permissions grouped by group_name.
* @returns {Object.<string, Array>}
*/
export function getPermissionGroups() {
return PERMISSION_DEFINITIONS.reduce((acc, perm) => {
if (!acc[perm.group_name]) acc[perm.group_name] = [];
acc[perm.group_name].push(perm);
return acc;
}, {});
}
+182
View File
@@ -0,0 +1,182 @@
import { query, tableExists } from '@zen/core/database';
import { generateId } from './password.js';
import { done, warn } from '@zen/core/shared/logger';
import { PERMISSION_DEFINITIONS } from './constants.js';
import { registerPermissions, getRegisteredPermissions } from './permissions-registry.js';
const USER_ROLE_PERMISSIONS = [];
const ROLE_TABLES = [
{
name: 'zen_auth_roles',
sql: `
CREATE TABLE zen_auth_roles (
id text NOT NULL PRIMARY KEY,
name text NOT NULL UNIQUE,
description text,
color text DEFAULT '#6b7280',
is_system boolean NOT NULL DEFAULT false,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`
},
{
name: 'zen_auth_permissions',
sql: `
CREATE TABLE zen_auth_permissions (
key text NOT NULL PRIMARY KEY,
name text NOT NULL,
description text,
group_name text NOT NULL
)
`
},
{
name: 'zen_auth_role_permissions',
sql: `
CREATE TABLE zen_auth_role_permissions (
role_id text NOT NULL REFERENCES zen_auth_roles(id) ON DELETE CASCADE,
permission_key text NOT NULL REFERENCES zen_auth_permissions(key) ON DELETE CASCADE,
PRIMARY KEY (role_id, permission_key)
)
`
},
{
name: 'zen_auth_user_roles',
sql: `
CREATE TABLE zen_auth_user_roles (
user_id text NOT NULL REFERENCES zen_auth_users(id) ON DELETE CASCADE,
role_id text NOT NULL REFERENCES zen_auth_roles(id) ON DELETE CASCADE,
PRIMARY KEY (user_id, role_id)
)
`
}
];
async function dropRoleCheckConstraint() {
await query(`
DO $$ DECLARE cname text;
BEGIN
SELECT conname INTO cname FROM pg_constraint
WHERE conrelid = 'zen_auth_users'::regclass AND contype = 'c' AND conname LIKE '%role%';
IF cname IS NOT NULL THEN
EXECUTE 'ALTER TABLE zen_auth_users DROP CONSTRAINT ' || quote_ident(cname);
END IF;
END$$;
`);
}
async function migratePermissions() {
// Migrate users.edit / users.delete → users.manage
await query(`
INSERT INTO zen_auth_role_permissions (role_id, permission_key)
SELECT DISTINCT role_id, 'users.manage'
FROM zen_auth_role_permissions
WHERE permission_key IN ('users.edit', 'users.delete')
AND EXISTS (SELECT 1 FROM zen_auth_permissions WHERE key = 'users.manage')
ON CONFLICT DO NOTHING
`);
await query(`DELETE FROM zen_auth_role_permissions WHERE permission_key IN ('users.edit', 'users.delete')`);
await query(`DELETE FROM zen_auth_permissions WHERE key IN ('users.edit', 'users.delete')`);
}
async function seedDefaultRolesAndPermissions() {
// S'assure que les permissions core sont dans le registre, puis seed depuis
// le registre — qui contient core + permissions enregistrées par les modules.
registerPermissions(PERMISSION_DEFINITIONS);
const allPermissions = getRegisteredPermissions();
for (const perm of allPermissions) {
await query(
`INSERT INTO zen_auth_permissions (key, name, description, group_name)
VALUES ($1, $2, $3, $4)
ON CONFLICT (key) DO UPDATE SET name = EXCLUDED.name, description = EXCLUDED.description, group_name = EXCLUDED.group_name`,
[perm.key, perm.name, perm.description, perm.group_name]
);
}
await migratePermissions();
// Admin role
const adminRoleId = generateId();
await query(
`INSERT INTO zen_auth_roles (id, name, description, color, is_system) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (name) DO NOTHING`,
[adminRoleId, 'admin', 'Accès complet à toutes les fonctionnalités', '#ef4444', true]
);
const adminRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'admin'`);
const adminId = adminRole.rows[0].id;
// Toute permission présente dans le catalogue est attribuée au rôle admin —
// y compris les permissions ajoutées par les modules après le premier init.
await query(
`INSERT INTO zen_auth_role_permissions (role_id, permission_key)
SELECT $1, key FROM zen_auth_permissions
ON CONFLICT DO NOTHING`,
[adminId]
);
// User role
const userRoleId = generateId();
await query(
`INSERT INTO zen_auth_roles (id, name, description, color, is_system) VALUES ($1, $2, $3, $4, $5) ON CONFLICT (name) DO NOTHING`,
[userRoleId, 'user', 'Accès de base en lecture', '#6b7280', true]
);
const userRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'user'`);
const userId = userRole.rows[0].id;
for (const permKey of USER_ROLE_PERMISSIONS) {
await query(
`INSERT INTO zen_auth_role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[userId, permKey]
);
}
// Assign admin role to the first user if no user-roles exist yet
const userRolesCount = await query(`SELECT COUNT(*) FROM zen_auth_user_roles`);
if (parseInt(userRolesCount.rows[0].count) === 0) {
const firstUser = await query(`SELECT id FROM zen_auth_users ORDER BY created_at ASC LIMIT 1`);
if (firstUser.rows.length > 0) {
await query(
`INSERT INTO zen_auth_user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[firstUser.rows[0].id, adminId]
);
}
}
}
export async function createTables() {
const created = [];
const skipped = [];
// Drop legacy CHECK constraint on zen_auth_users.role if it exists
await dropRoleCheckConstraint();
for (const table of ROLE_TABLES) {
const exists = await tableExists(table.name);
if (!exists) {
await query(table.sql);
created.push(table.name);
done(`Created table: ${table.name}`);
} else {
skipped.push(table.name);
}
}
await seedDefaultRolesAndPermissions();
return { created, skipped };
}
export async function dropTables() {
const dropOrder = [...ROLE_TABLES].reverse().map(t => t.name);
warn('Dropping all Zen role/permission tables...');
for (const tableName of dropOrder) {
const exists = await tableExists(tableName);
if (exists) {
await query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`);
done(`Dropped table: ${tableName}`);
}
}
done('All role/permission tables dropped');
}
+62
View File
@@ -0,0 +1,62 @@
import crypto from 'crypto';
import { query, create, deleteWhere, updateById } from '@zen/core/database';
import { generateToken, generateId } from './password.js';
export async function createEmailChangeToken(userId, newEmail) {
const token = generateToken(32);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
await deleteWhere('zen_auth_verifications', { identifier: 'email_change:' + userId });
await create('zen_auth_verifications', {
id: generateId(),
identifier: 'email_change:' + userId,
value: newEmail,
token,
expires_at: expiresAt,
updated_at: new Date(),
});
return token;
}
export async function verifyEmailChangeToken(token) {
const result = await query(
"SELECT * FROM zen_auth_verifications WHERE identifier LIKE 'email_change:%' AND token = $1",
[token]
);
if (result.rows.length === 0) return null;
const record = result.rows[0];
// Timing-safe comparison — same-length buffer padding prevents length-based timing leaks.
const storedBuf = Buffer.from(record.token, 'utf8');
const providedBuf = Buffer.from(
token.length === record.token.length ? token : record.token,
'utf8'
);
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf) && token.length === record.token.length;
if (!tokensMatch) return null;
if (new Date(record.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: record.id });
return null;
}
const userId = record.identifier.slice('email_change:'.length);
const newEmail = record.value;
await deleteWhere('zen_auth_verifications', { id: record.id });
return { userId, newEmail };
}
export async function applyEmailChange(userId, newEmail) {
await updateById('zen_auth_users', userId, { email: newEmail, updated_at: new Date() });
await query(
'UPDATE zen_auth_accounts SET account_id = $1, updated_at = $2 WHERE user_id = $3 AND provider_id = $4',
[newEmail, new Date(), userId, 'credential']
);
}
+17
View File
@@ -0,0 +1,17 @@
export { getUserById, getUserByEmail, countUsers, listUsers, updateUserById } from './queries.js';
export { register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser } from './auth.js';
export { createSession, validateSession, deleteSession, deleteUserSessions, refreshSession } from './session.js';
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken } from './verifications.js';
export { hashPassword, verifyPassword, generateToken, generateId } from './password.js';
export { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } from './roles.js';
export {
PERMISSIONS,
PERMISSION_DEFINITIONS,
getPermissionGroups,
hasPermission,
getUserPermissions,
registerPermission,
registerPermissions,
getRegisteredPermissions,
getRegisteredPermissionKeys,
} from './permissions.js';
@@ -1,21 +1,8 @@
/**
* Password Hashing and Verification
* Provides secure password hashing using bcrypt
*/
import crypto from 'crypto'; import crypto from 'crypto';
/**
* Hash a password using scrypt (Node.js native)
* @param {string} password - Plain text password
* @returns {Promise<string>} Hashed password
*/
async function hashPassword(password) { async function hashPassword(password) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
// Generate a salt
const salt = crypto.randomBytes(16).toString('hex'); const salt = crypto.randomBytes(16).toString('hex');
// Hash password with salt using scrypt
crypto.scrypt(password, salt, 64, (err, derivedKey) => { crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err); if (err) reject(err);
resolve(salt + ':' + derivedKey.toString('hex')); resolve(salt + ':' + derivedKey.toString('hex'));
@@ -23,22 +10,13 @@ async function hashPassword(password) {
}); });
} }
/**
* Verify a password against a hash
* @param {string} password - Plain text password
* @param {string} hash - Hashed password
* @returns {Promise<boolean>} True if password matches, false otherwise
*/
async function verifyPassword(password, hash) { async function verifyPassword(password, hash) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const [salt, key] = hash.split(':'); const [salt, key] = hash.split(':');
crypto.scrypt(password, salt, 64, (err, derivedKey) => { crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) { reject(err); return; } if (err) { reject(err); return; }
try { try {
const storedKey = Buffer.from(key, 'hex'); const storedKey = Buffer.from(key, 'hex');
// timingSafeEqual requires identical lengths; if the stored hash is
// malformed the lengths will differ and we reject without leaking timing.
if (storedKey.length !== derivedKey.length) { resolve(false); return; } if (storedKey.length !== derivedKey.length) { resolve(false); return; }
resolve(crypto.timingSafeEqual(storedKey, derivedKey)); resolve(crypto.timingSafeEqual(storedKey, derivedKey));
} catch { } catch {
@@ -48,26 +26,12 @@ async function verifyPassword(password, hash) {
}); });
} }
/**
* Generate a random token
* @param {number} length - Token length in bytes (default: 32)
* @returns {string} Random token
*/
function generateToken(length = 32) { function generateToken(length = 32) {
return crypto.randomBytes(length).toString('hex'); return crypto.randomBytes(length).toString('hex');
} }
/**
* Generate a random ID
* @returns {string} Random ID
*/
function generateId() { function generateId() {
return crypto.randomUUID(); return crypto.randomUUID();
} }
export { export { hashPassword, verifyPassword, generateToken, generateId };
hashPassword,
verifyPassword,
generateToken,
generateId
};
+47
View File
@@ -0,0 +1,47 @@
/**
* Registre runtime des permissions.
*
* Le core enregistre ses permissions au boot (initializeZen) ; chaque module
* externe enregistre les siennes via son hook register(). Le registre alimente
* à la fois le seed BD (zen-db init) et la validation runtime (updateRole).
*
* Le registre est un singleton process-local persisté via Symbol.for sur
* globalThis pour survivre aux hot-reloads Next.js.
*/
const REGISTRY_KEY = Symbol.for('__ZEN_PERMISSIONS_REGISTRY__');
if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = new Map();
/** @type {Map<string, { key: string, name: string, description?: string, group_name: string }>} */
const registry = globalThis[REGISTRY_KEY];
export function registerPermission({ key, name, description, group_name }) {
if (typeof key !== 'string' || !key) {
throw new TypeError('registerPermission: "key" must be a non-empty string');
}
if (typeof name !== 'string' || !name) {
throw new TypeError(`registerPermission(${key}): "name" must be a non-empty string`);
}
if (typeof group_name !== 'string' || !group_name) {
throw new TypeError(`registerPermission(${key}): "group_name" must be a non-empty string`);
}
registry.set(key, { key, name, description: description ?? null, group_name });
}
export function registerPermissions(list) {
if (!Array.isArray(list)) {
throw new TypeError('registerPermissions: argument must be an array');
}
for (const perm of list) registerPermission(perm);
}
export function getRegisteredPermissions() {
return [...registry.values()];
}
export function getRegisteredPermissionKeys() {
return new Set(registry.keys());
}
export function clearRegisteredPermissions() {
registry.clear();
}
+32
View File
@@ -0,0 +1,32 @@
import { query } from '@zen/core/database';
export { PERMISSIONS, PERMISSION_DEFINITIONS, getPermissionGroups } from './constants.js';
export {
registerPermission,
registerPermissions,
getRegisteredPermissions,
getRegisteredPermissionKeys,
} from './permissions-registry.js';
export async function hasPermission(userId, permissionKey) {
const result = await query(
`SELECT 1
FROM zen_auth_user_roles ur
JOIN zen_auth_role_permissions rp ON rp.role_id = ur.role_id
WHERE ur.user_id = $1
AND rp.permission_key = $2
LIMIT 1`,
[userId, permissionKey]
);
return result.rows.length > 0;
}
export async function getUserPermissions(userId) {
const result = await query(
`SELECT DISTINCT rp.permission_key
FROM zen_auth_user_roles ur
JOIN zen_auth_role_permissions rp ON rp.role_id = ur.role_id
WHERE ur.user_id = $1`,
[userId]
);
return result.rows.map(r => r.permission_key);
}
+46
View File
@@ -0,0 +1,46 @@
import { query, findOne, updateById, count } from '@zen/core/database';
const MAX_PAGE_LIMIT = 100;
const ALLOWED_SORT_COLUMNS = ['id', 'email', 'name', 'role', 'email_verified', 'created_at'];
async function getUserById(id) {
return findOne('zen_auth_users', { id });
}
async function getUserByEmail(email) {
return findOne('zen_auth_users', { email });
}
async function countUsers() {
return count('zen_auth_users');
}
async function listUsers({ page = 1, limit = 10, sortBy = 'created_at', sortOrder = 'desc' } = {}) {
const safePage = Math.max(1, page);
const safeLimit = Math.min(Math.max(1, limit), MAX_PAGE_LIMIT);
const offset = (safePage - 1) * safeLimit;
const sortColumn = ALLOWED_SORT_COLUMNS.includes(sortBy) ? sortBy : 'created_at';
const order = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
const result = await query(
`SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users ORDER BY "${sortColumn}" ${order} LIMIT $1 OFFSET $2`,
[safeLimit, offset]
);
const total = await countUsers();
return {
users: result.rows,
pagination: { page: safePage, limit: safeLimit, total, totalPages: Math.ceil(total / safeLimit) }
};
}
async function updateUserById(id, fields) {
const allowed = ['name', 'role', 'email_verified', 'image', 'language', 'updated_at'];
const data = { updated_at: new Date() };
for (const key of allowed) {
if (fields[key] !== undefined) data[key] = fields[key];
}
return updateById('zen_auth_users', id, data);
}
export { getUserById, getUserByEmail, countUsers, listUsers, updateUserById };
+138
View File
@@ -0,0 +1,138 @@
import { query, transaction } from '@zen/core/database';
import { generateId } from './password.js';
import { getRegisteredPermissionKeys } from './permissions-registry.js';
export async function listRoles() {
const result = await query(
`SELECT r.*,
COUNT(DISTINCT rp.permission_key)::int AS permission_count,
COUNT(DISTINCT ur.user_id)::int AS user_count
FROM zen_auth_roles r
LEFT JOIN zen_auth_role_permissions rp ON rp.role_id = r.id
LEFT JOIN zen_auth_user_roles ur ON ur.role_id = r.id
GROUP BY r.id
ORDER BY r.created_at ASC`
);
return result.rows;
}
export async function getRoleById(id) {
const roleResult = await query(
`SELECT * FROM zen_auth_roles WHERE id = $1`,
[id]
);
if (roleResult.rows.length === 0) return null;
const permsResult = await query(
`SELECT permission_key FROM zen_auth_role_permissions WHERE role_id = $1`,
[id]
);
return {
...roleResult.rows[0],
permission_keys: permsResult.rows.map(r => r.permission_key)
};
}
export async function createRole({ name, description = null, color = '#6b7280' }) {
if (!name || !name.trim()) throw new Error('Role name is required');
const id = generateId();
const result = await query(
`INSERT INTO zen_auth_roles (id, name, description, color, is_system)
VALUES ($1, $2, $3, $4, false)
RETURNING *`,
[id, name.trim(), description || null, color]
);
return result.rows[0];
}
export async function updateRole(roleId, { name, description, color, permissionKeys }) {
const role = await query(`SELECT is_system FROM zen_auth_roles WHERE id = $1`, [roleId]);
if (role.rows.length === 0) throw new Error('Role not found');
const isSystem = role.rows[0].is_system;
return transaction(async (client) => {
const updateFields = [];
const values = [];
let idx = 1;
if (name !== undefined) {
if (!name.trim()) throw new Error('Role name cannot be empty');
updateFields.push(`name = $${idx++}`);
values.push(name.trim());
}
if (description !== undefined) {
updateFields.push(`description = $${idx++}`);
values.push(description || null);
}
if (color !== undefined) {
updateFields.push(`color = $${idx++}`);
values.push(color);
}
updateFields.push(`updated_at = $${idx++}`);
values.push(new Date());
values.push(roleId);
const updated = await client.query(
`UPDATE zen_auth_roles SET ${updateFields.join(', ')} WHERE id = $${idx} RETURNING *`,
values
);
if (!isSystem && permissionKeys !== undefined) {
const validKeys = getRegisteredPermissionKeys();
const safeKeys = [...new Set(permissionKeys)].filter(k => validKeys.has(k));
await client.query(`DELETE FROM zen_auth_role_permissions WHERE role_id = $1`, [roleId]);
for (const key of safeKeys) {
await client.query(
`INSERT INTO zen_auth_role_permissions (role_id, permission_key) VALUES ($1, $2)`,
[roleId, key]
);
}
}
const perms = await client.query(
`SELECT permission_key FROM zen_auth_role_permissions WHERE role_id = $1`,
[roleId]
);
return {
...updated.rows[0],
permission_keys: perms.rows.map(r => r.permission_key)
};
});
}
export async function deleteRole(roleId) {
const result = await query(`SELECT is_system FROM zen_auth_roles WHERE id = $1`, [roleId]);
if (result.rows.length === 0) throw new Error('Role not found');
if (result.rows[0].is_system) throw new Error('Cannot delete a system role');
await query(`DELETE FROM zen_auth_roles WHERE id = $1`, [roleId]);
}
export async function getUserRoles(userId) {
const result = await query(
`SELECT r.* FROM zen_auth_roles r
JOIN zen_auth_user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = $1
ORDER BY r.created_at ASC`,
[userId]
);
return result.rows;
}
export async function assignUserRole(userId, roleId) {
await query(
`INSERT INTO zen_auth_user_roles (user_id, role_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[userId, roleId]
);
}
export async function revokeUserRole(userId, roleId) {
await query(
`DELETE FROM zen_auth_user_roles WHERE user_id = $1 AND role_id = $2`,
[userId, roleId]
);
}
@@ -1,28 +1,13 @@
/** import { create, findOne, deleteWhere, updateById } from '@zen/core/database';
* Session Management
* Handles user session creation, validation, and deletion
*/
import { create, findOne, deleteWhere, updateById } from '../../../core/database/crud.js';
import { generateToken, generateId } from './password.js'; import { generateToken, generateId } from './password.js';
/**
* Create a new session for a user
* @param {string} userId - User ID
* @param {Object} options - Session options (ipAddress, userAgent)
* @returns {Promise<Object>} Session object with token
*/
async function createSession(userId, options = {}) { async function createSession(userId, options = {}) {
const { ipAddress, userAgent } = options; const { ipAddress, userAgent } = options;
// Generate session token
const token = generateToken(32); const token = generateToken(32);
const sessionId = generateId(); const sessionId = generateId();
// Session expires in 30 days
const expiresAt = new Date(); const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30); expiresAt.setDate(expiresAt.getDate() + 30);
const session = await create('zen_auth_sessions', { const session = await create('zen_auth_sessions', {
id: sessionId, id: sessionId,
user_id: userId, user_id: userId,
@@ -32,107 +17,63 @@ async function createSession(userId, options = {}) {
user_agent: userAgent || null, user_agent: userAgent || null,
updated_at: new Date() updated_at: new Date()
}); });
return session; return session;
} }
/**
* Validate a session token
* @param {string} token - Session token
* @returns {Promise<Object|null>} Session object with user data or null if invalid
*/
async function validateSession(token) { async function validateSession(token) {
if (!token) return null; if (!token) return null;
const session = await findOne('zen_auth_sessions', { token }); const session = await findOne('zen_auth_sessions', { token });
if (!session) return null; if (!session) return null;
// Check if session is expired
if (new Date(session.expires_at) < new Date()) { if (new Date(session.expires_at) < new Date()) {
await deleteSession(token); await deleteSession(token);
return null; return null;
} }
// Get user data
const user = await findOne('zen_auth_users', { id: session.user_id }); const user = await findOne('zen_auth_users', { id: session.user_id });
if (!user) { if (!user) {
await deleteSession(token); await deleteSession(token);
return null; return null;
} }
// Auto-refresh session if it expires in less than 20 days
const now = new Date(); const now = new Date();
const expiresAt = new Date(session.expires_at); const expiresAt = new Date(session.expires_at);
const daysUntilExpiry = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24)); const daysUntilExpiry = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
let sessionRefreshed = false; let sessionRefreshed = false;
if (daysUntilExpiry < 20) { if (daysUntilExpiry < 20) {
// Extend session to 30 days from now
const newExpiresAt = new Date(); const newExpiresAt = new Date();
newExpiresAt.setDate(newExpiresAt.getDate() + 30); newExpiresAt.setDate(newExpiresAt.getDate() + 30);
await updateById('zen_auth_sessions', session.id, { await updateById('zen_auth_sessions', session.id, {
expires_at: newExpiresAt, expires_at: newExpiresAt,
updated_at: new Date() updated_at: new Date()
}); });
// Update the session object with new expiration
session.expires_at = newExpiresAt; session.expires_at = newExpiresAt;
sessionRefreshed = true; sessionRefreshed = true;
} }
return { return { session, user, sessionRefreshed };
session,
user,
sessionRefreshed
};
} }
/**
* Delete a session
* @param {string} token - Session token
* @returns {Promise<number>} Number of deleted sessions
*/
async function deleteSession(token) { async function deleteSession(token) {
return await deleteWhere('zen_auth_sessions', { token }); return await deleteWhere('zen_auth_sessions', { token });
} }
/**
* Delete all sessions for a user
* @param {string} userId - User ID
* @returns {Promise<number>} Number of deleted sessions
*/
async function deleteUserSessions(userId) { async function deleteUserSessions(userId) {
return await deleteWhere('zen_auth_sessions', { user_id: userId }); return await deleteWhere('zen_auth_sessions', { user_id: userId });
} }
/**
* Refresh a session (extend expiration)
* @param {string} token - Session token
* @returns {Promise<Object|null>} Updated session or null
*/
async function refreshSession(token) { async function refreshSession(token) {
const session = await findOne('zen_auth_sessions', { token }); const session = await findOne('zen_auth_sessions', { token });
if (!session) return null; if (!session) return null;
// Extend session by 30 days
const expiresAt = new Date(); const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30); expiresAt.setDate(expiresAt.getDate() + 30);
return await updateById('zen_auth_sessions', session.id, { return await updateById('zen_auth_sessions', session.id, {
expires_at: expiresAt, expires_at: expiresAt,
updated_at: new Date() updated_at: new Date()
}); });
} }
export { export { createSession, validateSession, deleteSession, deleteUserSessions, refreshSession };
createSession,
validateSession,
deleteSession,
deleteUserSessions,
refreshSession
};
+149
View File
@@ -0,0 +1,149 @@
import crypto from 'crypto';
import { create, findOne, deleteWhere } from '@zen/core/database';
import { generateToken, generateId } from './password.js';
async function createEmailVerification(email) {
const token = generateToken(32);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
await deleteWhere('zen_auth_verifications', { identifier: 'email_verification', value: email });
const verification = await create('zen_auth_verifications', {
id: generateId(),
identifier: 'email_verification',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return { ...verification, token };
}
async function verifyEmailToken(email, token) {
const verification = await findOne('zen_auth_verifications', {
identifier: 'email_verification',
value: email
});
if (!verification) return false;
// Timing-safe comparison — always operate on same-length buffers so that a
// wrong-length guess yields no measurable timing difference from a wrong-value guess.
const storedBuf = Buffer.from(verification.token, 'utf8');
const providedBuf = Buffer.from(
token.length === verification.token.length ? token : verification.token,
'utf8'
);
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf)
&& token.length === verification.token.length;
if (!tokensMatch) return false;
if (new Date(verification.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: verification.id });
return false;
}
await deleteWhere('zen_auth_verifications', { id: verification.id });
return true;
}
async function createPasswordReset(email) {
const token = generateToken(32);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1);
await deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
const reset = await create('zen_auth_verifications', {
id: generateId(),
identifier: 'password_reset',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return { ...reset, token };
}
async function verifyResetToken(email, token) {
const reset = await findOne('zen_auth_verifications', {
identifier: 'password_reset',
value: email
});
if (!reset) return false;
// Timing-safe comparison — same rationale as verifyEmailToken above.
const storedBuf = Buffer.from(reset.token, 'utf8');
const providedBuf = Buffer.from(
token.length === reset.token.length ? token : reset.token,
'utf8'
);
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf)
&& token.length === reset.token.length;
if (!tokensMatch) return false;
if (new Date(reset.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: reset.id });
return false;
}
return true;
}
function deleteResetToken(email) {
return deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
}
async function createAccountSetup(email) {
const token = generateToken(32);
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 48);
await deleteWhere('zen_auth_verifications', { identifier: 'account_setup', value: email });
const setup = await create('zen_auth_verifications', {
id: generateId(),
identifier: 'account_setup',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return { ...setup, token };
}
async function verifyAccountSetupToken(email, token) {
const setup = await findOne('zen_auth_verifications', {
identifier: 'account_setup',
value: email
});
if (!setup) return false;
const storedBuf = Buffer.from(setup.token, 'utf8');
const providedBuf = Buffer.from(
token.length === setup.token.length ? token : setup.token,
'utf8'
);
const tokensMatch = crypto.timingSafeEqual(storedBuf, providedBuf)
&& token.length === setup.token.length;
if (!tokensMatch) return false;
if (new Date(setup.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: setup.id });
return false;
}
return true;
}
function deleteAccountSetupToken(email) {
return deleteWhere('zen_auth_verifications', { identifier: 'account_setup', value: email });
}
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken, createAccountSetup, verifyAccountSetupToken, deleteAccountSetupToken };
+27
View File
@@ -0,0 +1,27 @@
import AdminShell from './components/AdminShell.js';
import { protectAdmin } from './protect.js';
import { buildNavigationSections, buildBottomNavItems } from './navigation.js';
import { logoutAction } from '@zen/core/features/auth/actions';
import { getAppName } from '@zen/core';
import { getUserPermissions } from '@zen/core/users';
import './widgets/index.server.js';
export default async function AdminLayout({ children }) {
const session = await protectAdmin();
const appName = getAppName();
const permissions = await getUserPermissions(session.user.id);
const navigationSections = buildNavigationSections('/', permissions);
const bottomNavItems = buildBottomNavItems('/');
return (
<AdminShell
user={session.user}
onLogout={logoutAction}
appName={appName}
navigationSections={navigationSections}
bottomNavItems={bottomNavItems}
>
{children}
</AdminShell>
);
}
+34
View File
@@ -0,0 +1,34 @@
'use client';
import { getPage } from './registry.js';
import './pages/DashboardPage.client.js';
import './pages/UsersPage.client.js';
import './pages/RolesPage.client.js';
import './pages/ProfilePage.client.js';
import './pages/SettingsPage.client.js';
import './pages/ConfirmEmailChangePage.client.js';
import './widgets/index.client.js';
import './devkit/DevkitPage.client.js';
import '../media/pages/MediaPage.client.js';
export default function AdminPageClient({ params, user, widgetData, appConfig, devkitEnabled }) {
const parts = params?.admin || [];
const [first] = parts;
const slug = first || 'dashboard';
const page = getPage(slug) || getPage('dashboard');
if (!page) return null;
const { Component } = page;
if (slug === 'dashboard') {
return <Component user={user} stats={widgetData} />;
}
if (slug === 'settings') {
return <Component user={user} appConfig={appConfig} />;
}
if (slug === 'devkit') {
return <Component user={user} params={parts} devkitEnabled={devkitEnabled} />;
}
return <Component user={user} params={parts} />;
}
+28
View File
@@ -0,0 +1,28 @@
import AdminPageClient from './AdminPage.client.js';
import { protectAdmin } from './protect.js';
import { collectWidgetData } from './registry.js';
import { getAppConfig, getPublicBaseUrl } from '@zen/core';
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
import { getUserPermissions } from '@zen/core/users';
export default async function AdminPage({ params }) {
const resolvedParams = await params;
const session = await protectAdmin();
const [widgetData, permissions] = await Promise.all([
collectWidgetData(),
getUserPermissions(session.user.id),
]);
const appConfig = { ...getAppConfig(), siteUrl: getPublicBaseUrl() };
const devkitEnabled = isDevkitEnabled();
const user = { ...session.user, permissions };
return (
<AdminPageClient
params={resolvedParams}
user={user}
widgetData={widgetData}
appConfig={appConfig}
devkitEnabled={devkitEnabled}
/>
);
}
+324
View File
@@ -0,0 +1,324 @@
# Admin
Ce répertoire fournit l'interface d'administration complète : layout, navigation, tableau de bord, gestion des utilisateurs et des rôles. Il expose un **registre runtime** pour permettre aux projets consommateurs d'ajouter des widgets, des entrées de navigation et des pages sans modifier le core.
> Le pattern `zen.extensions.js` documenté ici reste valide pour les extensions in-projet (extensions ad hoc spécifiques à une app). Pour distribuer une extension réutilisable comme un package npm, consulter [docs/MODULES.md](../../../docs/MODULES.md) — la même API d'enregistrement s'utilise mais le module est auto-découvert via les `dependencies` du projet consommateur.
---
## Structure
```
src/features/admin/
├── index.js buildNavigationSections, registre (Next.js-free)
├── protect.js gardes d'accès
├── navigation.js buildNavigationSections, buildBottomNavItems, getNavItemBasePath, findActiveNavContext
├── registry.js registre runtime d'extensions
├── AdminLayout.server.js layout RSC de l'admin
├── AdminPage.server.js page RSC racine (protège + collecte les données widgets)
├── AdminPage.client.js shell client
├── components/
│ ├── index.js re-export
│ ├── AdminHeader.js
│ ├── AdminShell.js
│ ├── AdminSidebar.js
│ ├── AdminTop.js
│ ├── RoleEditModal.client.js
│ ├── ThemeToggle.js
│ ├── UserCreateModal.client.js
│ └── UserEditModal.client.js
├── devkit/
│ ├── ComponentsPage.client.js
│ ├── DevkitPage.client.js
│ └── IconsPage.client.js
├── pages/
│ ├── ConfirmEmailChangePage.client.js
│ ├── DashboardPage.client.js
│ ├── ProfilePage.client.js
│ ├── RolesPage.client.js
│ ├── SettingsPage.client.js
│ └── UsersPage.client.js
└── widgets/
├── index.client.js auto-registration des widgets core (côté client)
├── index.server.js auto-registration des widgets core (côté serveur)
├── users.client.js widget Utilisateurs (composant)
└── users.server.js widget Utilisateurs (fetcher)
```
---
## Import
```js
// Gardes RSC — chemin dédié (next/navigation + next/headers, non compatible turbopackIgnore)
import { protectAdmin, isAdmin } from '@zen/core/features/admin/protect';
// Registre et navigation — compatible modules externes
import { buildNavigationSections } from '@zen/core/features/admin';
import {
registerWidget,
registerWidgetFetcher,
registerNavItem,
registerNavSection,
registerPage,
} from '@zen/core/features/admin';
```
---
## Pages intégrées
| Route | Page |
|-------|------|
| `/admin/dashboard` | Tableau de bord avec widgets |
| `/admin/users` | Liste, création et gestion des utilisateurs |
| `/admin/roles` | Gestion des rôles et permissions |
| `/admin/settings` | Paramètres de l'application |
| `/admin/profile` | Profil de l'utilisateur connecté |
| `/admin/confirm-email-change` | Confirmation de changement d'email |
---
## API
### `protectAdmin(options?)`
Garde serveur. Redirige si l'utilisateur n'est pas connecté ou n'a pas la permission `ADMIN_ACCESS`. Retourne la session courante.
```js
const session = await protectAdmin();
// session.user est disponible
```
| Option | Type | Défaut | Description |
|--------|------|--------|-------------|
| `redirectTo` | `string` | `'/auth/login'` | Redirection si non authentifié |
| `forbiddenRedirect` | `string` | `'/'` | Redirection si non autorisé |
---
### `isAdmin()`
Vérifie si l'utilisateur courant a la permission `ADMIN_ACCESS`. Retourne `boolean`.
```js
const admin = await isAdmin();
if (!admin) return null;
```
---
### `buildNavigationSections(pathname, userPermissions?)`
Construit les sections de navigation pour la sidebar à partir du registre. Marque l'entrée active selon `pathname`. Les items dont le champ `permission` n'est pas présent dans `userPermissions` sont automatiquement exclus ; si tous les items d'une section sont exclus, la section disparaît également.
```js
const permissions = await getUserPermissions(session.user.id);
const sections = buildNavigationSections('/admin/users', permissions);
// [{ id, title, icon, items: [{ name, href, icon, current }] }]
```
---
## Registre d'extensions
Le registre permet d'ajouter des widgets, des entrées de navigation et des pages sans toucher au core. Les enregistrements se font via des imports à effet de bord dans le layout racine du projet consommateur.
### Ajouter un widget
Un widget est composé de deux parties : un fetcher serveur qui collecte les données, et un composant client qui les affiche.
```js
// app/admin/orders/ordersWidget.server.js
import { registerWidgetFetcher } from '@zen/core/features/admin';
import { countOrders } from './orders.server.js';
registerWidgetFetcher('orders', async () => ({
total: await countOrders(),
}));
```
```js
// app/admin/orders/ordersWidget.client.js
'use client';
import { registerWidget } from '@zen/core/features/admin';
import { StatCard } from '@zen/core/shared/components';
function OrdersWidget({ data, loading }) {
return (
<StatCard
title="Commandes"
value={loading ? '-' : String(data?.total ?? 0)}
loading={loading}
/>
);
}
registerWidget({ id: 'orders', Component: OrdersWidget, order: 20 });
```
Le composant reçoit `data` (retour du fetcher) et `loading` (booléen). Si le fetcher échoue, `data` est `null` et `loading` reste `false`.
**`registerWidgetFetcher(id, fetcher)`**
| Paramètre | Type | Description |
|-----------|------|-------------|
| `id` | `string` | Identifiant unique du widget |
| `fetcher` | `async () => object` | Fonction serveur qui retourne les données |
**`registerWidget({ id, Component, order?, permission? })`**
| Paramètre | Type | Description |
|-----------|------|-------------|
| `id` | `string` | Identifiant unique (doit correspondre au fetcher) |
| `Component` | `ReactComponent` | Composant client affiché dans le tableau de bord |
| `order` | `number` | Position dans la grille (défaut : `0`) |
| `permission` | `string` | Clé de permission requise pour voir ce widget (ex. `'users.view'`). Le widget est masqué si l'utilisateur ne possède pas cette permission. |
---
### Ajouter une entrée de navigation
```js
import { registerNavSection, registerNavItem } from '@zen/core/features/admin';
registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'ShoppingBag03Icon', order: 30 });
registerNavItem({
id: 'orders',
label: 'Commandes',
icon: 'ShoppingBag03Icon',
href: '/admin/orders',
sectionId: 'commerce',
order: 10,
permission: 'orders.view', // optionnel — masqué si l'utilisateur n'a pas cette permission
});
```
**`registerNavSection({ id, title, icon, order? })`**
| Paramètre | Type | Description |
|-----------|------|-------------|
| `id` | `string` | Identifiant unique de la section |
| `title` | `string` | Titre affiché dans la sidebar |
| `icon` | `string` | Nom d'icône Hugeicons |
| `order` | `number` | Ordre d'affichage (défaut : `0`) |
**`registerNavItem({ id, label, icon, href, basePath?, sectionId?, order?, position?, permission? })`**
| Paramètre | Type | Description |
|-----------|------|-------------|
| `id` | `string` | Identifiant unique de l'entrée |
| `label` | `string` | Texte affiché |
| `icon` | `string` | Nom d'icône Hugeicons |
| `href` | `string` | URL de destination (cible du clic dans la sidebar) |
| `basePath` | `string` | Préfixe de routes considérées « sous » cette entrée — utilisé pour souligner l'item actif et construire le fil d'Ariane sur les sous-routes (`/edit/:id`, `/new`...). Auto-déduit : si `href` finit par `/list`, on retire le `/list` ; sinon `basePath = href`. À spécifier explicitement uniquement quand l'auto-déduction ne suffit pas. |
| `sectionId` | `string` | Section parente (défaut : `'main'`) |
| `order` | `number` | Ordre d'affichage (défaut : `0`) |
| `position` | `string` | `'bottom'` pour épingler en bas de la sidebar |
| `permission` | `string` | Clé de permission requise pour voir cette entrée (ex. `'orders.view'`). L'entrée est masquée si l'utilisateur ne possède pas cette permission. |
#### Item actif et fil d'Ariane
L'item « actif » dans la sidebar est celui dont le `basePath` matche le préfixe **le plus long** du pathname courant. Sur `/admin/posts/blogue/edit/1`, l'item « Posts » (basePath `/admin/posts/blogue`) est souligné ; sur `/admin/posts/blogue/taxonomies/categories`, c'est l'item « Catégories » qui gagne car son basePath est plus spécifique.
Le fil d'Ariane se construit automatiquement à partir de cet item :
- `[icon] > <section.title> > <item.label>` — si la section contient plusieurs items
- `[icon] > <item.label>` — si la section ne contient que cet item et qu'ils portent le même nom (ex : section "Utilisateurs" + item "Utilisateurs")
- Suivi de `> Nouveau` ou `> Modification` quand l'URL contient `/new` ou `/edit/:id` après le basePath.
Pour personnaliser les labels d'action, enregistrer une page avec un slug `<root>:new` ou `<root>:edit` :
```js
registerPage({ slug: 'posts:edit', breadcrumbLabel: "Modifier l'article" });
registerPage({ slug: 'posts:new', breadcrumbLabel: 'Nouvel article' });
```
(`<root>` = premier segment d'URL après `/admin/`, ex : `posts` pour `/admin/posts/blogue/edit/1`.)
---
### Ajouter une page
```js
import { registerPage } from '@zen/core/features/admin';
import OrdersPage from './OrdersPage.js';
registerPage({
slug: 'orders',
Component: OrdersPage,
title: 'Commandes',
});
```
La page est rendue sous `/admin/<slug>`. `AdminPage.client.js` résout le composant à partir du slug dans les paramètres de route.
**`registerPage({ slug, Component, title?, breadcrumbLabel? })`**
| Paramètre | Type | Description |
|-----------|------|-------------|
| `slug` | `string` | Segment d'URL sous `/admin/` |
| `Component` | `ReactComponent` | Composant client rendu pour cette route |
| `title` | `string` | Titre de la page (optionnel) |
| `breadcrumbLabel` | `string` | Label du fil d'Ariane (optionnel, défaut : `title`) |
---
## Câbler les extensions dans le projet consommateur
Regrouper tous les enregistrements dans un fichier de point d'entrée unique, puis l'importer une seule fois depuis le layout racine.
```js
// app/zen.extensions.js
import './admin/orders/ordersWidget.server.js';
import './admin/orders/ordersWidget.client.js';
import { registerNavSection, registerNavItem, registerPage } from '@zen/core/features/admin';
import OrdersPage from './admin/orders/OrdersPage.js';
registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'ShoppingBag03Icon', order: 30 });
registerNavItem({ id: 'orders', label: 'Commandes', icon: 'ShoppingBag03Icon', href: '/admin/orders', sectionId: 'commerce', permission: 'orders.view' });
registerPage({ slug: 'orders', Component: OrdersPage, title: 'Commandes' });
```
```js
// app/layout.js
import './zen.extensions'; // les side effects enregistrent tout
```
---
## DevKit
Le DevKit est une section de l'admin réservée au développement. Il expose une galerie de composants et un catalogue d'icônes. Il s'active via la variable d'environnement `ZEN_DEVKIT_ENABLED=true` et n'est jamais rendu en production.
| Route | Contenu |
|-------|---------|
| `/admin/devkit/components` | Galerie des composants partagés |
| `/admin/devkit/icons` | Catalogue d'icônes Hugeicons |
---
## Ajouter un widget core
Les widgets intégrés au core suivent le même pattern que les widgets consommateurs, avec une étape supplémentaire : déclarer les fichiers dans les index d'auto-registration.
```js
// src/features/admin/widgets/myWidget.server.js
import { registerWidgetFetcher } from '../registry.js';
registerWidgetFetcher('myWidget', async () => ({ ... }));
// src/features/admin/widgets/index.server.js
import './myWidget.server.js'; // ajouter cette ligne
```
```js
// src/features/admin/widgets/myWidget.client.js
'use client';
import { registerWidget } from '../registry.js';
// ...
registerWidget({ id: 'myWidget', Component: MyWidget, order: 20 });
// src/features/admin/widgets/index.client.js
import './myWidget.client.js'; // ajouter cette ligne
```
-12
View File
@@ -1,12 +0,0 @@
/**
* Admin Server Actions
*
* These are exported separately from admin/index.js to avoid bundling
* server-side code (which includes database imports) into client components.
*
* Usage:
* import { getDashboardStats, getModuleDashboardStats } from '@zen/core/admin/actions';
*/
export { getDashboardStats } from './actions/statsActions.js';
export { getAllModuleDashboardStats as getModuleDashboardStats } from '@zen/core/modules/actions';
@@ -1,80 +0,0 @@
/**
* Admin Stats Actions
* Server-side actions for core dashboard statistics
*
* Module-specific stats are handled by each module's dashboard actions.
* See src/modules/{module}/dashboard/statsActions.js
*
* Usage in your Next.js app:
*
* ```javascript
* // app/(admin)/admin/[...admin]/page.js
* import { protectAdmin } from '@zen/core/admin';
* import { getDashboardStats, getModuleDashboardStats } from '@zen/core/admin/actions';
* import { AdminPagesClient } from '@zen/core/admin/pages';
*
* export default async function AdminPage({ params }) {
* const { user } = await protectAdmin();
*
* // Fetch core dashboard stats
* const statsResult = await getDashboardStats();
* const dashboardStats = statsResult.success ? statsResult.stats : null;
*
* // Fetch module dashboard stats (for dynamic widgets)
* const moduleStats = await getModuleDashboardStats();
*
* return (
* <AdminPagesClient
* params={params}
* user={user}
* dashboardStats={dashboardStats}
* moduleStats={moduleStats}
* />
* );
* }
* ```
*/
'use server';
import { query } from '@zen/core/database';
import { fail } from '../../../shared/lib/logger.js';
/**
* Get total number of users
* @returns {Promise<number>}
*/
async function getTotalUsersCount() {
try {
const result = await query(
`SELECT COUNT(*) as count FROM zen_auth_users`
);
return parseInt(result.rows[0].count) || 0;
} catch (error) {
fail(`Error getting users count: ${error.message}`);
return 0;
}
}
/**
* Get core dashboard statistics
* @returns {Promise<Object>}
*/
export async function getDashboardStats() {
try {
const totalUsers = await getTotalUsersCount();
return {
success: true,
stats: {
totalUsers,
}
};
} catch (error) {
fail(`Error getting dashboard stats: ${error.message}`);
return {
success: false,
error: error.message || 'Failed to get dashboard statistics'
};
}
}
+23 -206
View File
@@ -1,214 +1,31 @@
'use client'; 'use client';
import React from 'react';
import { Menu, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { ChevronDownIcon } from '../../../shared/Icons.js';
import { useRouter } from 'next/navigation'; import { useRouter } from 'next/navigation';
import ThemeToggle from './ThemeToggle'; import { Button } from '@zen/core/shared/components';
const AdminHeader = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appName = 'ZEN' }) => { const AdminHeader = ({ title, description, backHref, backLabel = '← Retour', action }) => {
const router = useRouter(); const router = useRouter();
const getImageUrl = (imageKey) => {
if (!imageKey) return null;
return `/zen/api/storage/${imageKey}`;
};
const handleLogout = async () => {
try {
if (onLogout) {
const result = await onLogout();
if (result && result.success) {
router.push('/auth/login');
} else {
console.error('Logout failed:', result?.error);
router.push('/auth/login');
}
} else {
router.push('/auth/login');
}
} catch (error) {
console.error('Logout error:', error);
router.push('/auth/login');
}
};
const getUserInitials = (name) => { return (
if (!name) return 'U'; <div className="flex items-center justify-between">
return name <div className="flex items-center gap-3">
.split(' ') <div>
.map(n => n[0]) <h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">{title}</h1>
.join('') {description && (
.toUpperCase() <p className="mt-1 text-[13px] text-neutral-500 dark:text-neutral-400">{description}</p>
.slice(0, 2); )}
}; </div>
</div>
const quickLinks = []; <div className='flex gap-2'>
{backHref && (
const userInitials = getUserInitials(user?.name); <Button variant="secondary" onClick={() => router.push(backHref)}>
{backLabel}
return ( </Button>
<header className="bg-white dark:bg-black border-b border-neutral-200 dark:border-neutral-800/70 sticky top-0 z-30 h-14 flex items-center w-full"> )}
<div className="flex items-center justify-between lg:justify-end px-4 lg:px-6 py-2 w-full"> {action}
{/* Left section - Mobile menu button + Logo (hidden on desktop) */} </div>
<div className="flex items-center space-x-3 lg:hidden"> </div>
<button );
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="p-2 rounded-lg bg-neutral-100 dark:bg-neutral-900/50 border border-neutral-200 dark:border-neutral-700/50 text-neutral-900 dark:text-white hover:bg-neutral-200 dark:hover:bg-neutral-800 transition-colors duration-200"
aria-label="Toggle menu"
>
<svg className={`h-5 w-5 transition-transform duration-200 ${isMobileMenuOpen ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={isMobileMenuOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
</svg>
</button>
<h1 className="text-neutral-900 dark:text-white font-semibold text-lg">{appName}</h1>
</div>
{/* Right Section - Theme Toggle + Quick Links + Profile */}
<div className="flex items-center space-x-3 sm:space-x-4">
{/* Quick Links - Hidden on very small screens */}
<nav className="hidden sm:flex items-center space-x-4 lg:space-x-6">
{quickLinks.map((link) => (
<a
key={link.name}
href={link.href}
className="text-sm text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white transition-colors duration-200"
>
{link.name}
</a>
))}
</nav>
{/* Theme Toggle */}
<ThemeToggle />
{/* User Profile Menu */}
<Menu as="div" className="relative">
<Menu.Button className="cursor-pointer flex items-center space-x-2 sm:space-x-3 px-2 sm:px-3 py-2 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-all duration-200 group ui-open:bg-neutral-100 dark:ui-open:bg-neutral-800/50 outline-none">
{/* Avatar for desktop - hidden on mobile */}
<div className="hidden sm:flex items-center space-x-3">
{getImageUrl(user?.image) && (
<img
src={getImageUrl(user.image)}
alt={user?.name || 'User'}
className="w-8 h-8 rounded-lg object-cover"
/>
)}
<div className="text-left">
<p className="text-sm font-medium text-neutral-900 dark:text-white">{user?.name || 'User'}</p>
</div>
</div>
{/* Avatar for mobile - visible on mobile only */}
<div className="sm:hidden">
{getImageUrl(user?.image) && (
<img
src={getImageUrl(user.image)}
alt={user?.name || 'User'}
className="w-8 h-8 rounded-lg object-cover"
/>
)}
</div>
<ChevronDownIcon className="h-4 w-4 text-neutral-400 transition-transform duration-200 ui-open:rotate-180" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 -translate-y-2"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-2"
>
<Menu.Items className="absolute right-0 mt-2 w-56 sm:w-64 bg-white dark:bg-neutral-900/95 backdrop-blur-sm border border-neutral-200 dark:border-neutral-700/50 rounded-xl shadow-xl z-50 outline-none">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700/50">
<div className="flex items-center space-x-3">
{getImageUrl(user?.image) && (
<img
src={getImageUrl(user.image)}
alt={user?.name || 'User'}
className="w-10 h-10 rounded-lg object-cover"
/>
)}
<div>
<p className="text-sm font-medium text-neutral-900 dark:text-white">{user?.name || 'User'}</p>
<p className="text-xs text-neutral-400">{user?.email || 'email@example.com'}</p>
</div>
</div>
</div>
{/* Quick Links for mobile */}
{quickLinks.length > 0 && (
<div className="sm:hidden py-2 border-b border-neutral-200 dark:border-neutral-700/50">
{quickLinks.map((link) => (
<Menu.Item key={link.name}>
{({ active }) => (
<a
href={link.href}
className={`flex items-center px-4 py-3 text-sm transition-all duration-200 ${
active
? 'bg-neutral-100 dark:bg-neutral-800/50 text-neutral-900 dark:text-white'
: 'text-neutral-600 dark:text-neutral-300'
}`}
>
<svg className="mr-3 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{link.name}
</a>
)}
</Menu.Item>
))}
</div>
)}
<div className="py-2">
<Menu.Item>
{({ active }) => (
<a
href="/admin/profile"
className={`flex items-center px-4 py-3 text-sm transition-all duration-200 group ${
active
? 'bg-neutral-100 dark:bg-neutral-800/50 text-neutral-900 dark:text-white'
: 'text-neutral-600 dark:text-neutral-300'
}`}
>
<svg className="mr-3 h-4 w-4 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Mon profil
</a>
)}
</Menu.Item>
<div className="border-t border-neutral-200 dark:border-neutral-700/50 mt-2 pt-2">
<Menu.Item>
{({ active }) => (
<button
onClick={handleLogout}
className={`w-full flex items-center px-4 py-3 text-sm transition-all duration-200 group text-left ${
active
? 'bg-red-500/10 text-red-300'
: 'text-red-400'
}`}
>
<svg className="mr-3 h-4 w-4 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Se déconnecter
</button>
)}
</Menu.Item>
</div>
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
</header>
);
}; };
export default AdminHeader; export default AdminHeader;
@@ -1,85 +0,0 @@
'use client';
/**
* Admin Pages Component
*
* This component handles both core admin pages and module pages.
* Module pages are loaded dynamically on the client where hooks work properly.
*/
import { Suspense } from 'react';
import DashboardPage from './pages/DashboardPage.js';
import UsersPage from './pages/UsersPage.js';
import UserEditPage from './pages/UserEditPage.js';
import ProfilePage from './pages/ProfilePage.js';
import { getModulePageLoader } from '../../../modules/modules.pages.js';
// Loading component for suspense
function PageLoading() {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div>
);
}
export default function AdminPagesClient({
params,
user,
dashboardStats = null,
moduleStats = {},
modulePageInfo = null,
routeInfo = null,
enabledModules = {}
}) {
// If this is a module page, render it with lazy loading
if (modulePageInfo && routeInfo) {
const LazyComponent = getModulePageLoader(modulePageInfo.module, modulePageInfo.path);
if (LazyComponent) {
// Build props for the page
const pageProps = { user };
if (routeInfo.action === 'edit' && routeInfo.id) {
// Add ID props for edit pages (modules may use different prop names)
pageProps.id = routeInfo.id;
pageProps.invoiceId = routeInfo.id;
pageProps.clientId = routeInfo.id;
pageProps.itemId = routeInfo.id;
pageProps.categoryId = routeInfo.id;
pageProps.transactionId = routeInfo.id;
pageProps.recurrenceId = routeInfo.id;
pageProps.templateId = routeInfo.id;
pageProps.postId = routeInfo.id;
}
return (
<Suspense fallback={<PageLoading />}>
<LazyComponent {...pageProps} />
</Suspense>
);
}
}
// Determine core page from routeInfo or params
let currentPage = 'dashboard';
if (routeInfo?.path) {
const parts = routeInfo.path.split('/').filter(Boolean);
currentPage = parts[1] || 'dashboard'; // /admin/[page]
} else if (params?.admin) {
currentPage = params.admin[0] || 'dashboard';
}
// Core page components mapping (non-module pages)
const usersPageComponent = routeInfo?.action === 'edit' && routeInfo?.id
? () => <UserEditPage userId={routeInfo.id} user={user} enabledModules={enabledModules} />
: () => <UsersPage user={user} />;
const corePages = {
dashboard: () => <DashboardPage user={user} stats={dashboardStats} moduleStats={moduleStats} enabledModules={enabledModules} />,
users: usersPageComponent,
profile: () => <ProfilePage user={user} />,
};
// Render the appropriate core page or default to dashboard
const CorePageComponent = corePages[currentPage];
return CorePageComponent ? <CorePageComponent /> : <DashboardPage user={user} stats={dashboardStats} moduleStats={moduleStats} enabledModules={enabledModules} />;
}
@@ -1,25 +1,32 @@
'use client'; 'use client';
import AdminSidebar from './AdminSidebar';
import { useState } from 'react'; import { useState } from 'react';
import AdminHeader from './AdminHeader'; import AdminSidebar from './AdminSidebar.js';
import AdminTop from './AdminTop.js';
export default function AdminPagesLayout({ children, user, onLogout, appName, enabledModules, navigationSections }) { export default function AdminShell({ children, user, onLogout, appName, navigationSections, bottomNavItems }) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
return ( return (
<div className="flex h-screen overflow-hidden bg-white dark:bg-black"> <div className="flex h-screen overflow-hidden bg-white dark:bg-black font-ibm-plex-sans">
<AdminSidebar <AdminSidebar
isMobileMenuOpen={isMobileMenuOpen} isMobileMenuOpen={isMobileMenuOpen}
setIsMobileMenuOpen={setIsMobileMenuOpen} setIsMobileMenuOpen={setIsMobileMenuOpen}
appName={appName} appName={appName}
enabledModules={enabledModules}
navigationSections={navigationSections} navigationSections={navigationSections}
bottomNavItems={bottomNavItems}
/> />
<div className="flex-1 flex flex-col min-w-0"> <div className="flex-1 flex flex-col min-w-0">
<AdminHeader isMobileMenuOpen={isMobileMenuOpen} setIsMobileMenuOpen={setIsMobileMenuOpen} user={user} onLogout={onLogout} appName={appName} /> <AdminTop
isMobileMenuOpen={isMobileMenuOpen}
setIsMobileMenuOpen={setIsMobileMenuOpen}
user={user}
onLogout={onLogout}
appName={appName}
navigationSections={navigationSections}
/>
<main className="flex-1 overflow-y-auto bg-neutral-50 dark:bg-black"> <main className="flex-1 overflow-y-auto bg-neutral-50 dark:bg-black">
<div className="px-4 sm:px-6 lg:px-8 xl:px-12 pt-4 sm:pt-6 lg:pt-8 pb-32 max-w-[1400px] mx-auto"> <div className="px-8 py-7 pb-32 max-w-[1920px] mx-auto">
{children} {children}
</div> </div>
</main> </main>
+108 -93
View File
@@ -3,62 +3,59 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import Link from 'next/link'; import Link from 'next/link';
import { usePathname } from 'next/navigation'; import { usePathname } from 'next/navigation';
import * as Icons from '../../../shared/Icons.js'; import * as Icons from '@zen/core/shared/icons';
import { ChevronDownIcon } from '../../../shared/Icons.js'; import { ArrowDown01Icon } from '@zen/core/shared/icons';
/** /**
* Resolve icon name (string) to icon component * Resolve icon name (string) to icon component
* Icons are passed as strings from server to avoid serialization issues * Icons are passed as strings from server to avoid serialization issues
*/ */
function resolveIcon(iconNameOrComponent) { function resolveIcon(iconNameOrComponent) {
// If it's already a component (function), return it
if (typeof iconNameOrComponent === 'function') { if (typeof iconNameOrComponent === 'function') {
return iconNameOrComponent; return iconNameOrComponent;
} }
// If it's a string, look up in Icons
if (typeof iconNameOrComponent === 'string') { if (typeof iconNameOrComponent === 'string') {
return Icons[iconNameOrComponent] || Icons.DashboardSquare03Icon; return Icons[iconNameOrComponent] || Icons.DashboardSquare03Icon;
} }
// Default fallback
return Icons.DashboardSquare03Icon; return Icons.DashboardSquare03Icon;
} }
const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledModules, navigationSections: serverNavigationSections }) => { const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledModules, navigationSections: serverNavigationSections, bottomNavItems = [] }) => {
const pathname = usePathname(); const pathname = usePathname();
// State to manage collapsed sections (all open by default)
const [collapsedSections, setCollapsedSections] = useState(new Set());
// Function to toggle a section's state const isItemActive = (item) => {
const basePath = item.basePath || item.href;
return pathname === item.href || pathname === basePath || pathname.startsWith(basePath + '/');
};
const [collapsedSections, setCollapsedSections] = useState(() => {
const initial = new Set();
serverNavigationSections.forEach(section => {
const isActive = section.items.some(isItemActive);
if (!isActive) initial.add(section.id);
});
return initial;
});
const toggleSection = (sectionId) => { const toggleSection = (sectionId) => {
// Find the section to check if it has active items
const section = navigationSections.find(s => s.id === sectionId);
// Don't allow collapsing sections with active items
if (section && isSectionActive(section)) {
return;
}
setCollapsedSections(prev => { setCollapsedSections(prev => {
const newCollapsed = new Set(prev); const next = new Set(prev);
if (newCollapsed.has(sectionId)) { if (next.has(sectionId)) {
newCollapsed.delete(sectionId); next.delete(sectionId);
} else { } else {
newCollapsed.add(sectionId); next.add(sectionId);
} }
return newCollapsed; return next;
}); });
}; };
// Handle mobile menu closure when clicking on a link
const handleMobileLinkClick = () => { const handleMobileLinkClick = () => {
setIsMobileMenuOpen(false); setIsMobileMenuOpen(false);
}; };
// Close mobile menu on screen size change
useEffect(() => { useEffect(() => {
const handleResize = () => { const handleResize = () => {
if (window.innerWidth >= 1024) { // lg breakpoint if (window.innerWidth >= 1024) {
setIsMobileMenuOpen(false); setIsMobileMenuOpen(false);
} }
}; };
@@ -67,49 +64,59 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
return () => window.removeEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize);
}, [setIsMobileMenuOpen]); }, [setIsMobileMenuOpen]);
// Function to check if any item in a section is currently active
const isSectionActive = (section) => { const isSectionActive = (section) => {
return section.items.some(item => item.current); return section.items.some(item => item.current);
}; };
// Function to check if a section should be rendered as a direct link
const shouldRenderAsDirectLink = (section) => { const shouldRenderAsDirectLink = (section) => {
// Check if there's only one item and it has the same name as the section return section.items.length === 1 &&
return section.items.length === 1 &&
section.items[0].name.toLowerCase() === section.title.toLowerCase(); section.items[0].name.toLowerCase() === section.title.toLowerCase();
}; };
// Update collapsed sections when pathname changes to ensure active sections are open // Recalcule `current` côté client pour suivre les navigations Link sans
useEffect(() => { // dépendre d'un re-render serveur. Règle alignée sur navigation.js :
setCollapsedSections(prev => { // match le plus long (href exact > basePath préfixe), un seul item actif.
const newSet = new Set(prev); const matchLen = (item) => {
// Add any sections that have active items to ensure they stay open if (pathname === item.href) return item.href.length;
navigationSections.forEach(section => { const basePath = item.basePath || item.href;
if (isSectionActive(section)) { if (pathname === basePath) return basePath.length;
newSet.add(section.id); if (pathname.startsWith(basePath + '/')) return basePath.length;
} return 0;
}); };
return newSet;
}); let activeItemRef = null;
// eslint-disable-next-line react-hooks/exhaustive-deps let activeItemLen = 0;
}, [pathname]); for (const section of serverNavigationSections) {
for (const item of section.items) {
const len = matchLen(item);
if (len > activeItemLen) {
activeItemRef = item;
activeItemLen = len;
}
}
}
// Use server-provided navigation sections if available, otherwise use core-only fallback
// Server navigation includes module navigation, fallback only has core pages
// Update the 'current' property based on the actual pathname (client-side)
const navigationSections = serverNavigationSections.map(section => ({ const navigationSections = serverNavigationSections.map(section => ({
...section, ...section,
items: section.items.map(item => ({ items: section.items.map(item => ({
...item, ...item,
current: pathname === item.href || pathname.startsWith(item.href + '/') current: item === activeItemRef,
})) }))
})); }));
// Function to render a complete navigation section const base = 'w-full flex items-center justify-between px-[10px] py-[7px] rounded-lg text-[13px] transition-colors duration-[120ms] ease-out';
const inactive = 'font-normal text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-900 hover:text-neutral-900 dark:hover:text-white';
const parentBase = `${base}`;
const parentActif = 'bg-neutral-100 dark:bg-neutral-900 text-black dark:text-white font-medium';
const parentActifOuvert = 'text-black dark:text-white font-medium hover:bg-neutral-100 dark:hover:bg-neutral-900';
const subItemBase = base;
const subItemActif = 'bg-neutral-100 dark:bg-neutral-900 text-black dark:text-white font-medium';
const renderNavSection = (section) => { const renderNavSection = (section) => {
const Icon = resolveIcon(section.icon); const Icon = resolveIcon(section.icon);
// If section should be rendered as a direct link
if (shouldRenderAsDirectLink(section)) { if (shouldRenderAsDirectLink(section)) {
const item = section.items[0]; const item = section.items[0];
return ( return (
@@ -117,18 +124,14 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
<Link <Link
href={item.href} href={item.href}
onClick={handleMobileLinkClick} onClick={handleMobileLinkClick}
className={`${ className={`${parentBase} ${item.current ? parentActif : inactive}`}
item.current
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-white'
: 'text-neutral-900 dark:text-white hover:text-neutral-500 dark:hover:text-neutral-300'
} w-full flex items-center justify-between border-y border-neutral-200 dark:border-neutral-800/70 px-4 py-2.5 -mb-[1px] text-[13px] tracking-wide transition-colorsduration-0`}
> >
<div className="flex items-center"> <div className="flex items-center gap-2">
<Icon className="mr-3 h-4 w-4 flex-shrink-0" /> <Icon className="h-[15px] w-[15px] flex-shrink-0" />
<span>{section.title}</span> <span>{section.title}</span>
</div> </div>
{item.badge && ( {item.badge && (
<span className="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full font-medium"> <span className="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-lg font-medium">
{item.badge} {item.badge}
</span> </span>
)} )}
@@ -137,33 +140,33 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
); );
} }
// Regular section with expandable sub-items const isCollapsed = collapsedSections.has(section.id);
const isCollapsed = !collapsedSections.has(section.id); const isActive = isSectionActive(section);
return ( return (
<div key={section.id}> <div key={section.id}>
<button <button
onClick={() => toggleSection(section.id)} onClick={() => toggleSection(section.id)}
className="cursor-pointer w-full flex items-center justify-between border-y border-neutral-200 dark:border-neutral-800/70 px-4 py-2.5 -mb-[1px] text-[13px] text-neutral-900 dark:text-white tracking-wide hover:text-neutral-500 dark:hover:text-neutral-300 transition-colorsduration-0" className={`cursor-pointer ${parentBase} ${isActive && isCollapsed ? parentActif : isActive ? parentActifOuvert : inactive}`}
> >
<div className="flex items-center"> <div className="flex items-center gap-2">
<Icon className="mr-3 h-4 w-4 flex-shrink-0" /> <Icon className="h-[15px] w-[15px] flex-shrink-0" />
<span>{section.title}</span> <span>{section.title}</span>
</div> </div>
<ChevronDownIcon <ArrowDown01Icon
className={`h-3 w-3 ${ className={`h-3 w-3 transition-transform duration-[120ms] ease-out ${
isCollapsed ? '-rotate-90' : 'rotate-0' isCollapsed ? '-rotate-90' : 'rotate-0'
}`} }`}
/> />
</button> </button>
<div <div
className={`overflow-hidden ${ className={`overflow-hidden transition-all duration-[120ms] ease-out ${
isCollapsed isCollapsed
? 'max-h-0 opacity-0' ? 'max-h-0 opacity-0'
: 'max-h-[1000px] opacity-100' : 'max-h-[1000px] opacity-100'
}`} }`}
> >
<ul className="flex flex-col gap-0"> <ul className="flex flex-col">
{section.items.map(renderNavItem)} {section.items.map(renderNavItem)}
</ul> </ul>
</div> </div>
@@ -171,26 +174,19 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
); );
}; };
// Function to render a navigation item
const renderNavItem = (item) => { const renderNavItem = (item) => {
const Icon = resolveIcon(item.icon);
return ( return (
<li key={item.name}> <li key={item.name}>
<Link <Link
href={item.href} href={item.href}
onClick={handleMobileLinkClick} onClick={handleMobileLinkClick}
className={`${ className={`${subItemBase} ${item.current ? subItemActif : inactive}`}
item.current
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-white'
: 'text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/50 hover:text-neutral-900 dark:hover:text-white'
} group flex items-center justify-between px-4 py-1.5 text-[12px] font-medium transition-allduration-0`}
> >
<div className="flex items-center"> <div className="flex items-center">
<Icon className="mr-3 h-3.5 w-3.5 flex-shrink-0" /> <span className="pl-[25px]">{item.name}</span>
{item.name}
</div> </div>
{item.badge && ( {item.badge && (
<span className="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full font-medium"> <span className="bg-red-500 text-white text-[10px] px-1.5 py-0.5 rounded-lg font-medium">
{item.badge} {item.badge}
</span> </span>
)} )}
@@ -203,8 +199,8 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
<> <>
{/* Mobile overlay */} {/* Mobile overlay */}
{isMobileMenuOpen && ( {isMobileMenuOpen && (
<div <div
className="lg:hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-40" className="lg:hidden fixed inset-0 bg-black/50 z-40"
onClick={() => setIsMobileMenuOpen(false)} onClick={() => setIsMobileMenuOpen(false)}
/> />
)} )}
@@ -212,23 +208,42 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
{/* Sidebar */} {/* Sidebar */}
<div className={` <div className={`
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'} ${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
fixed lg:static inset-y-0 left-0 z-40 w-64 bg-white dark:bg-black border-r border-neutral-200 dark:border-neutral-800/70 flex flex-col h-screen transition-transform duration-300 ease-in-out fixed lg:static inset-y-0 left-0 z-40 w-[230px] bg-white dark:bg-black border-r border-neutral-200 dark:border-neutral-800/70 flex flex-col h-screen transition-transform duration-[120ms] ease-out
`}> `}>
{/* Logo Section */} {/* Logo */}
<Link href="/admin" className="px-4 h-14 flex items-center justify-start gap-2 border-b border-neutral-200 dark:border-neutral-800/70"> <Link href="/admin/dashboard" className="px-4 h-12 flex items-center justify-start gap-2 border-b border-neutral-200 dark:border-neutral-800/70">
<h1 className="text-neutral-900 dark:text-white font-semibold">{appName}</h1> <h1 className="text-neutral-900 dark:text-white font-semibold text-sm">{appName}</h1>
<span className="bg-red-700/10 border border-red-600/20 text-red-600 uppercase text-[10px] leading-none px-2 py-1 rounded-full font-semibold">
Admin
</span>
</Link> </Link>
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 px-0 overflow-y-auto flex flex-col gap-0 pb-12 -mt-[1px]"> <nav className="flex-1 px-2 py-2 overflow-y-auto flex flex-col">
{navigationSections.map(renderNavSection)} {navigationSections.map(renderNavSection)}
</nav> </nav>
{/* Bottom pinned items */}
{bottomNavItems.length > 0 && (
<div className="px-2 py-2 border-t border-neutral-200 dark:border-neutral-800/70 shrink-0">
{bottomNavItems.map((item) => {
const Icon = resolveIcon(item.icon);
return (
<Link
key={item.href}
href={item.href}
onClick={handleMobileLinkClick}
className={`${base} ${item.current ? parentActif : inactive}`}
>
<div className="flex items-center gap-2">
<Icon className="h-[15px] w-[15px] flex-shrink-0" />
<span>{item.name}</span>
</div>
</Link>
);
})}
</div>
)}
</div> </div>
</> </>
); );
}; };
export default AdminSidebar; export default AdminSidebar;
+225
View File
@@ -0,0 +1,225 @@
'use client';
import { Fragment, useState, useEffect } from 'react';
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { ArrowDown01Icon, ArrowRight01Icon, Menu01Icon, User03Icon, DashboardSquare03Icon, Logout02Icon } from '@zen/core/shared/icons';
import { UserAvatar } from '@zen/core/shared/components';
import { useRouter, usePathname } from 'next/navigation';
import { getPage } from '../registry.js';
import { useTheme, getThemeIcon } from '@zen/core/themes';
import Link from 'next/link';
const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appName = 'ZEN', navigationSections = [] }) => {
const router = useRouter();
const pathname = usePathname();
const [pageTitle, setPageTitle] = useState('');
useEffect(() => {
const segments = pathname.replace(/^\/admin\/?/, '').split('/').filter(Boolean);
const slug = segments[0] || 'dashboard';
setPageTitle(getPage(slug)?.title || '');
}, [pathname]);
const handleLogout = async () => {
try {
if (onLogout) {
const result = await onLogout();
if (result && result.success) {
router.push('/auth/login');
} else {
console.error('Logout failed:', result?.error);
router.push('/auth/login');
}
} else {
router.push('/auth/login');
}
} catch (error) {
console.error('Logout error:', error);
router.push('/auth/login');
}
};
const { theme, toggle, systemIsDark } = useTheme();
const ThemeIcon = getThemeIcon(theme, systemIsDark);
const themeLabel = theme === 'light' ? 'Mode clair' : theme === 'dark' ? 'Mode sombre' : 'Thème système';
const buildBreadcrumbs = () => {
const crumbs = [{ icon: DashboardSquare03Icon, href: '/admin/dashboard' }];
const after = pathname.replace(/^\/admin\/?/, '');
const segments = after.split('/').filter(Boolean);
if (!after || !segments.length || (segments[0] === 'dashboard' && segments.length === 1)) {
crumbs.push({ label: pageTitle });
return crumbs;
}
// Localise l'item actif via match le plus long (href exact > basePath préfixe).
// On recalcule côté client pour rester aligné sur AdminSidebar et suivre les
// navigations Link sans dépendre d'un re-render serveur du layout.
const matchLen = (item) => {
if (pathname === item.href) return item.href.length;
const basePath = item.basePath || item.href;
if (pathname === basePath) return basePath.length;
if (pathname.startsWith(basePath + '/')) return basePath.length;
return 0;
};
let activeSection = null;
let activeItem = null;
let activeLen = 0;
for (const section of navigationSections) {
for (const item of section.items) {
const len = matchLen(item);
if (len > activeLen) {
activeSection = section;
activeItem = item;
activeLen = len;
}
}
}
if (!activeItem) {
// Page enregistrée hors navigation (ex : /admin/profile).
crumbs.push({ label: pageTitle });
return crumbs;
}
// Préfixer la section uniquement si elle ne se résume pas à un item unique
// du même nom (cas « direct link » dans la sidebar — section.title === item.name,
// ex : section "Utilisateurs" + item "Utilisateurs").
if (activeSection.title !== activeItem.name) {
crumbs.push({ label: activeSection.title });
}
const isExactPage = pathname === activeItem.href;
crumbs.push({ label: activeItem.name, href: isExactPage ? undefined : activeItem.href });
// Action de sous-route : segment juste après le basePath (/edit/:id, /new).
const basePath = activeItem.basePath || activeItem.href;
const trail = pathname.startsWith(basePath)
? pathname.slice(basePath.length).split('/').filter(Boolean)
: [];
const action = trail[0];
if (action === 'new') {
const page = getPage(`${segments[0]}:new`);
crumbs.push({ label: page?.breadcrumbLabel || page?.title || 'Nouveau' });
} else if (action === 'edit') {
const page = getPage(`${segments[0]}:edit`);
crumbs.push({ label: page?.breadcrumbLabel || page?.title || 'Modification' });
}
return crumbs;
};
const breadcrumbs = buildBreadcrumbs();
return (
<header className="bg-white dark:bg-black border-b border-neutral-200 dark:border-neutral-800/70 sticky top-0 z-30 h-12 flex items-center w-full">
<div className="flex items-center justify-between px-4 lg:px-6 py-2 w-full">
{/* Left section — Mobile menu button + Logo / Desktop breadcrumb */}
<div className="flex items-center space-x-3 lg:hidden">
<button
onClick={() => setIsMobileMenuOpen(!isMobileMenuOpen)}
className="p-1 rounded-md text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors duration-150"
aria-label="Toggle menu"
>
<Menu01Icon className="h-5 w-5 transition-transform duration-200" />
</button>
<h1 className="text-neutral-900 dark:text-white font-semibold text-sm">{appName}</h1>
</div>
{/* Desktop breadcrumb — always rendered to keep user menu pinned right */}
<div className="hidden lg:flex items-center gap-1.5 text-[13px]">
{breadcrumbs.length > 0 && breadcrumbs.map((crumb, i) => (
<Fragment key={i}>
{i > 0 && (
<ArrowRight01Icon className="w-3 h-3 text-neutral-400 dark:text-neutral-600 flex-shrink-0" />
)}
{crumb.icon ? (
<button
onClick={() => router.push(crumb.href)}
className="text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors cursor-pointer"
>
<crumb.icon className="w-4 h-4" />
</button>
) : crumb.href ? (
<button
onClick={() => router.push(crumb.href)}
className="text-neutral-500 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-white transition-colors cursor-pointer"
>
{crumb.label}
</button>
) : (
<span className="text-neutral-900 dark:text-white font-medium">{crumb.label}</span>
)}
</Fragment>
))}
</div>
{/* Right section — Profile */}
<div className="flex items-center gap-3 sm:gap-4">
{/* User Profile Menu */}
<Menu as="div" className="relative">
<MenuButton className="cursor-pointer flex items-center gap-2.5 px-2.5 py-1.5 rounded-xl hover:bg-black/5 dark:hover:bg-white/5 transition-colors duration-200 outline-none group">
<UserAvatar user={user} size="sm" />
{/* Name — desktop only */}
<span className="hidden sm:block text-[13px] leading-none font-medium text-neutral-800 dark:text-white">
{user?.name || 'User'}
</span>
<ArrowDown01Icon className="w-3.5 h-3.5 shrink-0 text-neutral-400 dark:text-neutral-600 transition-transform duration-200 group-data-open:rotate-180" />
</MenuButton>
<Transition
as={Fragment}
enter="transition ease-out duration-150"
enterFrom="opacity-0 translate-y-1"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-100"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 translate-y-1"
>
<MenuItems className="absolute right-0 mt-4 w-48 outline-none rounded-xl border border-black/8 dark:border-white/8 bg-white dark:bg-[#0B0B0B] shadow-lg overflow-hidden z-50">
<div className="p-1.5 flex flex-col gap-0.5">
{/* Profile */}
<MenuItem>
<Link
href="/admin/profile"
className="cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-neutral-500 dark:text-neutral-400 transition-colors duration-[120ms] ease-out data-focus:bg-neutral-100 dark:data-focus:bg-white/5 data-focus:text-neutral-900 dark:data-focus:text-white"
>
<User03Icon className="w-4 h-4 shrink-0" />
Mon profil
</Link>
</MenuItem>
{/* Theme — pas de MenuItem pour ne pas fermer le menu au clic */}
<button
onClick={toggle}
className="cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-neutral-500 dark:text-neutral-400 hover:bg-amber-50 dark:hover:bg-amber-500/10 hover:text-amber-500 dark:hover:text-amber-400 transition-colors duration-150"
>
<ThemeIcon className="w-4 h-4 shrink-0" />
{themeLabel}
</button>
<div className="h-px bg-black/6 dark:bg-white/6 my-0.5" />
{/* Logout */}
<MenuItem>
<button
onClick={handleLogout}
className="cursor-pointer w-full flex items-center gap-2 px-[7px] py-[10px] rounded-lg text-[13px] leading-none text-red-700 dark:text-red-600 transition-colors duration-150 text-left data-focus:bg-red-700/10 dark:data-focus:bg-red-700/20"
>
<Logout02Icon className="w-4 h-4 shrink-0" />
Se déconnecter
</button>
</MenuItem>
</div>
</MenuItems>
</Transition>
</Menu>
</div>
</div>
</header>
);
};
export default AdminTop;
@@ -0,0 +1,189 @@
'use client';
import { useState, useEffect } from 'react';
import { Input, Textarea, Switch, Modal, ColorPicker } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
const toast = useToast();
const isNew = !roleId || roleId === 'new';
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [isSystem, setIsSystem] = useState(false);
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [color, setColor] = useState('#6b7280');
const [selectedPerms, setSelectedPerms] = useState([]);
// Catalogue dynamique des permissions (core + modules), récupéré via l'API.
const [permissionGroups, setPermissionGroups] = useState({});
useEffect(() => {
if (!isOpen) return;
fetchPermissions();
if (isNew) {
setName('');
setDescription('');
setColor('#6b7280');
setSelectedPerms([]);
setIsSystem(false);
return;
}
fetchRole();
}, [isOpen, roleId]);
const fetchPermissions = async () => {
try {
const response = await fetch('/zen/api/permissions', { credentials: 'include' });
if (!response.ok) return;
const data = await response.json();
setPermissionGroups(data.groups || {});
} catch {
// Si le catalogue n'est pas joignable, on laisse l'utilisateur sauvegarder
// ses changements ; les permissions invalides sont filtrées côté serveur.
}
};
const fetchRole = async () => {
try {
setLoading(true);
const response = await fetch(`/zen/api/roles/${roleId}`, { credentials: 'include' });
if (!response.ok) {
toast.error('Rôle introuvable');
onClose();
return;
}
const data = await response.json();
const role = data.role;
setName(role.name || '');
setDescription(role.description || '');
setColor(role.color || '#6b7280');
setSelectedPerms(role.permission_keys || []);
setIsSystem(role.is_system || false);
} catch {
toast.error('Impossible de charger ce rôle');
onClose();
} finally {
setLoading(false);
}
};
const togglePerm = (key) => {
setSelectedPerms(prev =>
prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key]
);
};
const handleSubmit = async () => {
if (!name.trim()) {
toast.error('Le nom du rôle est requis');
return;
}
try {
setSaving(true);
const url = isNew ? '/zen/api/roles' : `/zen/api/roles/${roleId}`;
const method = isNew ? 'POST' : 'PUT';
const response = await fetch(url, {
method,
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: name.trim(),
description: description.trim() || null,
color,
permissionKeys: selectedPerms,
}),
});
const data = await response.json();
if (!response.ok) {
toast.error(data.message || 'Impossible de sauvegarder ce rôle');
return;
}
toast.success(isNew ? 'Rôle créé' : 'Rôle mis à jour');
onSaved?.();
onClose();
} catch {
toast.error('Impossible de sauvegarder ce rôle');
} finally {
setSaving(false);
}
};
const title = isNew ? 'Nouveau rôle' : `Modifier "${name}"`;
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title={title}
onSubmit={handleSubmit}
submitLabel={isNew ? 'Créer le rôle' : 'Sauvegarder'}
loading={saving}
disabled={loading}
size="lg"
>
{loading ? (
<div className="flex flex-col gap-4 animate-pulse">
<div className="h-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
<div className="h-20 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
<div className="h-40 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
</div>
) : (
<div className="flex flex-col gap-6">
<div className="flex flex-col gap-4">
<Input
label="Nom du rôle"
value={name}
onChange={setName}
placeholder="Éditeur, Modérateur..."
required
/>
<Textarea
label="Description"
value={description}
onChange={setDescription}
rows={2}
placeholder="Description optionnelle..."
/>
<ColorPicker
label="Couleur du rôle"
description="Les membres utilisent la couleur du rôle le plus élevé qu'ils possèdent."
value={color}
onChange={setColor}
required
/>
</div>
<div className="flex flex-col gap-3">
<p className="text-xs font-semibold uppercase tracking-wide text-neutral-500 dark:text-neutral-400">Permissions</p>
{Object.entries(permissionGroups).map(([group, perms]) => (
<div key={group} className="rounded-xl border border-neutral-200 dark:border-neutral-700/60 overflow-hidden">
<div className="px-4 py-2.5 bg-neutral-50 dark:bg-neutral-800/60 border-b border-neutral-200 dark:border-neutral-700/60">
<p className="text-[11px] font-medium font-ibm-plex-mono text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">
{group}
</p>
</div>
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-700/40">
{perms.map((perm) => (
<Switch
key={perm.key}
checked={selectedPerms.includes(perm.key)}
onChange={() => togglePerm(perm.key)}
label={perm.name}
description={perm.description}
disabled={isSystem}
/>
))}
</div>
</div>
))}
</div>
</div>
)}
</Modal>
);
};
export default RoleEditModal;
+2 -66
View File
@@ -1,74 +1,10 @@
'use client'; 'use client';
import { useState, useEffect } from 'react'; import { useTheme, getThemeIcon } from '@zen/core/themes';
import { Sun01Icon, Moon02Icon, SunCloud02Icon, MoonCloudIcon } from '../../../shared/Icons.js';
function getNextTheme(current) {
const systemIsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (current === 'auto') return systemIsDark ? 'light' : 'dark';
if (current === 'dark') return systemIsDark ? 'auto' : 'light';
return systemIsDark ? 'dark' : 'auto';
}
function getAutoIcon(systemIsDark) {
return systemIsDark ? MoonCloudIcon : SunCloud02Icon;
}
const THEME_ICONS = {
light: Sun01Icon,
dark: Moon02Icon,
};
function getStoredTheme() {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') return stored;
return 'auto';
}
function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else if (theme === 'light') {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
localStorage.removeItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', prefersDark);
}
}
function useTheme() {
const [theme, setTheme] = useState('auto');
const [systemIsDark, setSystemIsDark] = useState(false);
useEffect(() => {
setTheme(getStoredTheme());
setSystemIsDark(window.matchMedia('(prefers-color-scheme: dark)').matches);
const mq = window.matchMedia('(prefers-color-scheme: dark)');
function onSystemChange(e) {
setSystemIsDark(e.matches);
if (localStorage.getItem('theme')) return;
document.documentElement.classList.toggle('dark', e.matches);
}
mq.addEventListener('change', onSystemChange);
return () => mq.removeEventListener('change', onSystemChange);
}, []);
function toggle() {
const next = getNextTheme(theme);
setTheme(next);
applyTheme(next);
}
return { theme, toggle, systemIsDark };
}
export default function ThemeToggle() { export default function ThemeToggle() {
const { theme, toggle, systemIsDark } = useTheme(); const { theme, toggle, systemIsDark } = useTheme();
const Icon = theme === 'auto' ? getAutoIcon(systemIsDark) : THEME_ICONS[theme]; const Icon = getThemeIcon(theme, systemIsDark);
return ( return (
<button <button
@@ -0,0 +1,157 @@
'use client';
import { useState, useEffect } from 'react';
import { Input, TagInput, Modal, Badge } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
const UserCreateModal = ({ isOpen, onClose, onSaved }) => {
const toast = useToast();
const [saving, setSaving] = useState(false);
const [allRoles, setAllRoles] = useState([]);
const [selectedRoleIds, setSelectedRoleIds] = useState([]);
const [formData, setFormData] = useState({ name: '', email: '', password: '' });
const [errors, setErrors] = useState({});
const [error, setError] = useState('');
useEffect(() => {
if (!isOpen) return;
setFormData({ name: '', email: '', password: '' });
setSelectedRoleIds([]);
setErrors({});
setError('');
fetchRoles();
}, [isOpen]);
const fetchRoles = async () => {
try {
const res = await fetch('/zen/api/roles', { credentials: 'include' });
const data = await res.json();
setAllRoles(data.roles || []);
} catch {
toast.error('Impossible de charger les rôles');
}
};
const handleInputChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
if (error) setError('');
};
const validate = () => {
const newErrors = {};
if (!formData.name.trim()) newErrors.name = 'Le nom est requis';
if (!formData.email.trim()) newErrors.email = 'Le courriel est requis';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
setSaving(true);
setError('');
try {
const res = await fetch('/zen/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: formData.name.trim(),
email: formData.email.trim(),
password: formData.password || undefined,
roleIds: selectedRoleIds,
}),
});
const data = await res.json();
if (!res.ok) {
setError(data.message || data.error || "Impossible de créer l'utilisateur");
return;
}
if (data.invited) {
toast.success('Utilisateur créé — invitation envoyée par courriel');
} else {
toast.success('Utilisateur créé');
}
onSaved?.();
onClose();
} catch {
setError("Impossible de créer l'utilisateur");
} finally {
setSaving(false);
}
};
const roleOptions = allRoles.map(r => ({
value: r.id,
label: r.name,
color: r.color || '#6b7280',
description: r.description || undefined,
}));
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Nouvel utilisateur"
onSubmit={handleSubmit}
submitLabel="Créer"
loading={saving}
size="md"
>
<div className="flex flex-col gap-4">
{error && (
<div className="p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center gap-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0" />
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Nom complet *"
value={formData.name}
onChange={(value) => handleInputChange('name', value)}
placeholder="Prénom Nom"
error={errors.name}
/>
<Input
label="Courriel *"
type="email"
value={formData.email}
onChange={(value) => handleInputChange('email', value)}
placeholder="utilisateur@exemple.com"
error={errors.email}
/>
</div>
<TagInput
label="Rôles"
options={roleOptions}
value={selectedRoleIds}
onChange={setSelectedRoleIds}
placeholder="Rechercher un rôle..."
renderTag={(opt, onRemove) => (
<Badge key={opt.value} color={opt.color} dot onRemove={onRemove}>{opt.label}</Badge>
)}
/>
<div className="flex flex-col gap-1">
<Input
label="Mot de passe"
type="password"
value={formData.password}
onChange={(value) => handleInputChange('password', value)}
placeholder="Laisser vide pour envoyer une invitation"
/>
<p className="text-xs text-neutral-500 dark:text-neutral-400">
Si vide, un courriel d'invitation sera envoyé pour que l'utilisateur crée son mot de passe.
</p>
</div>
</div>
</Modal>
);
};
export default UserCreateModal;
@@ -0,0 +1,311 @@
'use client';
import { useState, useEffect } from 'react';
import { Input, TagInput, Modal, Badge } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
const UserEditModal = ({ userId, currentUserId, isOpen, onClose, onSaved }) => {
const toast = useToast();
const isSelf = userId && currentUserId && userId === currentUserId;
const [userData, setUserData] = useState(null);
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [formData, setFormData] = useState({ name: '', email: '', currentPassword: '', newPassword: '' });
const [errors, setErrors] = useState({});
const [sendingReset, setSendingReset] = useState(false);
const [allRoles, setAllRoles] = useState([]);
const [selectedRoleIds, setSelectedRoleIds] = useState([]);
const [initialRoleIds, setInitialRoleIds] = useState([]);
useEffect(() => {
if (!isOpen || !userId) return;
loadAll();
}, [isOpen, userId]);
const loadAll = async () => {
try {
setLoading(true);
setErrors({});
const [userRes, rolesRes, userRolesRes] = await Promise.all([
fetch(`/zen/api/users/${userId}`, { credentials: 'include' }),
fetch('/zen/api/roles', { credentials: 'include' }),
fetch(`/zen/api/users/${userId}/roles`, { credentials: 'include' }),
]);
const [userJson, rolesJson, userRolesJson] = await Promise.all([
userRes.json(),
rolesRes.json(),
userRolesRes.json(),
]);
if (userJson.user) {
setUserData(userJson.user);
setFormData({
name: userJson.user.name || '',
email: userJson.user.email || '',
currentPassword: '',
newPassword: '',
});
} else {
toast.error(userJson.message || 'Utilisateur introuvable');
onClose();
return;
}
setAllRoles(rolesJson.roles || []);
const ids = (userRolesJson.roles || []).map(r => r.id);
setSelectedRoleIds(ids);
setInitialRoleIds(ids);
} catch {
toast.error("Impossible de charger l'utilisateur");
onClose();
} finally {
setLoading(false);
}
};
const handleInputChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (errors[field]) setErrors(prev => ({ ...prev, [field]: null }));
};
const handleSendPasswordReset = async () => {
setSendingReset(true);
try {
const res = await fetch(`/zen/api/users/${userId}/send-password-reset`, {
method: 'POST',
credentials: 'include',
});
const data = await res.json();
if (!res.ok) throw new Error(data.error || data.message || 'Impossible d\'envoyer le lien');
toast.success(data.message || 'Lien de réinitialisation envoyé');
} catch {
toast.error('Impossible d\'envoyer le lien de réinitialisation');
} finally {
setSendingReset(false);
}
};
const emailChanged = userData && formData.email !== userData.email;
const needsCurrentPassword = isSelf && (emailChanged || !!formData.newPassword);
const validate = () => {
const newErrors = {};
if (!formData.name?.trim()) newErrors.name = 'Le nom est requis';
if (needsCurrentPassword && !formData.currentPassword) newErrors.currentPassword = 'Le mot de passe actuel est requis';
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async () => {
if (!validate()) return;
try {
setSaving(true);
const userRes = await fetch(`/zen/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: formData.name.trim(),
}),
});
const userResData = await userRes.json();
if (!userRes.ok) {
toast.error(userResData.message || userResData.error || "Impossible de mettre à jour l'utilisateur");
return;
}
const toAdd = selectedRoleIds.filter(id => !initialRoleIds.includes(id));
const toRemove = initialRoleIds.filter(id => !selectedRoleIds.includes(id));
await Promise.all(
toAdd.map(roleId =>
fetch(`/zen/api/users/${userId}/roles`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ roleId }),
})
)
);
const removeResults = await Promise.all(
toRemove.map(roleId =>
fetch(`/zen/api/users/${userId}/roles/${roleId}`, {
method: 'DELETE',
credentials: 'include',
}).then(async res => ({ res, data: await res.json() }))
)
);
const failedRemove = removeResults.find(({ res }) => !res.ok);
if (failedRemove) {
toast.error(failedRemove.data?.message || failedRemove.data?.error || 'Impossible de retirer ce rôle');
onSaved?.();
onClose();
return;
}
if (emailChanged) {
if (isSelf) {
const emailRes = await fetch('/zen/api/users/profile/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ newEmail: formData.email.trim(), password: formData.currentPassword }),
});
const emailData = await emailRes.json();
if (!emailRes.ok) {
toast.error(emailData.error || emailData.message || 'Impossible de changer le courriel');
onSaved?.();
onClose();
return;
}
toast.success('Utilisateur mis à jour');
toast.info(emailData.message || 'Un courriel de confirmation a été envoyé');
} else {
const emailRes = await fetch(`/zen/api/users/${userId}/email`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ newEmail: formData.email.trim() }),
});
const emailData = await emailRes.json();
if (!emailRes.ok) {
toast.error(emailData.error || emailData.message || 'Impossible de changer le courriel');
onSaved?.();
onClose();
return;
}
toast.success('Utilisateur mis à jour');
}
} else {
toast.success('Utilisateur mis à jour');
}
if (formData.newPassword) {
const pwdUrl = isSelf ? '/zen/api/users/profile/password' : `/zen/api/users/${userId}/password`;
const pwdMethod = isSelf ? 'POST' : 'PUT';
const pwdBody = isSelf
? { currentPassword: formData.currentPassword, newPassword: formData.newPassword }
: { newPassword: formData.newPassword };
const pwdRes = await fetch(pwdUrl, {
method: pwdMethod,
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify(pwdBody),
});
const pwdData = await pwdRes.json();
if (!pwdRes.ok) {
toast.error(pwdData.error || pwdData.message || 'Impossible de changer le mot de passe');
onSaved?.();
onClose();
return;
}
toast.success('Mot de passe mis à jour');
}
onSaved?.();
onClose();
} catch {
toast.error("Impossible de mettre à jour l'utilisateur");
} finally {
setSaving(false);
}
};
const roleOptions = allRoles.map(r => ({
value: r.id,
label: r.name,
color: r.color || '#6b7280',
description: r.description || undefined,
}));
return (
<Modal
isOpen={isOpen}
onClose={onClose}
title="Modifier l'utilisateur"
onSubmit={handleSubmit}
submitLabel="Mettre à jour"
loading={saving}
disabled={loading}
size="md"
>
{loading ? (
<div className="flex flex-col gap-4 animate-pulse">
<div className="h-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
<div className="h-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
<div className="h-10 bg-neutral-100 dark:bg-neutral-800 rounded-lg" />
</div>
) : (
<div className="flex flex-col gap-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="Nom *"
value={formData.name}
onChange={(value) => handleInputChange('name', value)}
placeholder="Nom de l'utilisateur"
error={errors.name}
/>
<Input
label="Courriel"
type="email"
value={formData.email}
onChange={(value) => handleInputChange('email', value)}
placeholder="courriel@exemple.com"
/>
</div>
<TagInput
label="Rôles attribués"
options={roleOptions}
value={selectedRoleIds}
onChange={setSelectedRoleIds}
placeholder="Rechercher un rôle..."
renderTag={(opt, onRemove) => (
<Badge key={opt.value} color={opt.color} dot onRemove={onRemove}>{opt.label}</Badge>
)}
/>
<div className="flex flex-col gap-2">
<Input
label="Nouveau mot de passe (optionnel)"
type="password"
value={formData.newPassword}
onChange={(value) => handleInputChange('newPassword', value)}
placeholder="Laisser vide pour ne pas modifier"
/>
<button
type="button"
onClick={handleSendPasswordReset}
disabled={sendingReset}
className="cursor-pointer text-left text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200 underline self-start transition-colors"
>
{sendingReset ? 'Envoi en cours…' : 'Envoyer un lien de réinitialisation par courriel'}
</button>
</div>
{needsCurrentPassword && (
<Input
label="Mot de passe actuel *"
type="password"
value={formData.currentPassword}
onChange={(value) => handleInputChange('currentPassword', value)}
placeholder="Votre mot de passe"
error={errors.currentPassword}
description={emailChanged && formData.newPassword ? 'Requis pour confirmer le changement de courriel et de mot de passe' : emailChanged ? 'Requis pour confirmer le changement de courriel' : 'Requis pour confirmer le changement de mot de passe'}
/>
)}
</div>
)}
</Modal>
);
};
export default UserEditModal;
+9 -5
View File
@@ -1,6 +1,10 @@
/** 'use client';
* Admin Components Exports
*/
export { default as AdminPagesClient } from './AdminPages.js'; export { default as AdminShell } from './AdminShell.js';
export { default as AdminPagesLayout } from './AdminPagesLayout.js'; export { default as AdminSidebar } from './AdminSidebar.js';
export { default as AdminTop } from './AdminTop.js';
export { default as AdminHeader } from './AdminHeader.js';
export { default as ThemeToggle } from './ThemeToggle.js';
export { default as UserEditModal } from './UserEditModal.client.js';
export { default as RoleEditModal } from './RoleEditModal.client.js';
export { default as UserCreateModal } from './UserCreateModal.client.js';
@@ -1,64 +0,0 @@
'use client';
/**
* Admin Dashboard Page
* Displays core stats and dynamically loads module dashboard widgets
*/
import { Suspense } from 'react';
import { StatCard } from '../../../../shared/components';
import { UserMultiple02Icon } from '../../../../shared/Icons.js';
import { getModuleDashboardWidgets } from '../../../../modules/modules.pages.js';
/**
* Loading placeholder for widgets
*/
function WidgetLoading() {
return (
<div className="animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-lg h-32"></div>
);
}
export default function DashboardPage({ user, stats, moduleStats = {}, enabledModules = {} }) {
const loading = !stats;
// Get only enabled module dashboard widgets
const allModuleWidgets = getModuleDashboardWidgets();
const moduleWidgets = Object.fromEntries(
Object.entries(allModuleWidgets).filter(([moduleName]) => enabledModules[moduleName])
);
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">
Tableau de bord
</h1>
<p className="mt-1 text-xs text-neutral-400">Vue d'ensemble de votre application</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{/* Module dashboard widgets (dynamically loaded) */}
{Object.entries(moduleWidgets).map(([moduleName, widgets]) => (
widgets.map((Widget, index) => (
<Suspense key={`${moduleName}-widget-${index}`} fallback={<WidgetLoading />}>
<Widget stats={moduleStats[moduleName]} />
</Suspense>
))
))}
{/* Core stats - always shown */}
<StatCard
title="Nombre d'utilisateurs"
value={loading ? '-' : String(stats?.totalUsers || 0)}
icon={UserMultiple02Icon}
color="text-purple-400"
bgColor="bg-purple-500/10"
loading={loading}
/>
</div>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More