Compare commits

...

313 Commits

Author SHA1 Message Date
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
147 changed files with 15012 additions and 3512 deletions
+7 -1
View File
@@ -10,6 +10,9 @@ ZEN_CURRENCY=CAD
ZEN_CURRENCY_SYMBOL=$
ZEN_SUPPORT_EMAIL=support@exemple.com
# PROXY (activer si derrière un reverse proxy)
ZEN_TRUST_PROXY=false
# DATABASE
ZEN_DATABASE_URL=postgres://USER:PASSWORD@HOST:PORT/postgres
ZEN_DATABASE_URL_DEV=postgres://USER:PASSWORD@HOST:PORT/postgres_dev
@@ -49,4 +52,7 @@ ZEN_PUBLIC_LOGO_BLACK=
ZEN_PUBLIC_LOGO_URL=
# 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
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
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)
+94 -41
View File
@@ -20,17 +20,6 @@ Chaque couleur, bordure, espace ou texte supplémentaire doit justifier sa prés
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.
| Rôle | Clair | Sombre |
|---|---|---|
| Fond de page | `#ffffff` | `#0c0c0b` |
| Fond hover | `#f5f5f4` | `#161614` |
| Fond actif / sélectionné | `#ebebea` | `#1f1f1d` |
| Bordure standard | `#e4e4e2` | `#272724` |
| Bordure forte | `#c8c8c4` | `#3a3a36` |
| Texte principal | `#0c0c0b` | `#f0f0ed` |
| Texte secondaire | `#6b6b68` | `#6b6b68` |
| Texte discret | `#a3a39e` | `#3d3d3a` |
### 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.
@@ -61,10 +50,10 @@ Chaque couleur existe en trois déclinaisons : la couleur elle-même (pour le te
| Rôle | Taille | Graisse | Notes |
|---|---|---|---|
| Titre de page | 20px | 600 | Lettre-espacement -0.01em |
| Titre de section | 1620px | 600 | |
| Corps de texte | 14px | 400 | |
| Texte secondaire | 13px | 400 | Dans les tableaux, les listes |
| 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 |
@@ -104,9 +93,9 @@ La densité est **moyenne**. Pas étouffant, pas aéré. Un admin est un outil q
| Contexte | Rayon |
|---|---|
| Badges, étiquettes, petits éléments | 8px |
| Boutons, champs de saisie, cartes | 12px |
| Modales, panneaux flottants importants | 16px |
| 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.
@@ -119,7 +108,7 @@ Les ombres existent uniquement pour les éléments qui **flottent au-dessus** de
---
## Structure de la page
## Structure de l'admin
```
┌─────────────────────────────────────────────┐
@@ -136,35 +125,102 @@ Les ombres existent uniquement pour les éléments qui **flottent au-dessus** de
- **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.
- **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
## Navigation sidebar admin
- Les items inactifs sont en texte secondaire (`#6b6b68`).
- Au survol : fond légèrement grisé, texte principal.
- L'item actif : fond légèrement grisé plus prononcé, texte principal gras.
- Les icônes sont toujours à gauche du texte. Taille 15×15px.
- Les sous-menus se révèlent par accordéon (chevron qui tourne).
- Rayon des items : 8px.
- Padding interne : 7px vertical, 10px horizontal.
### 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
Quatre variantes seulement :
Sept variantes :
| Variante | Apparence | Usage |
|---|---|---|
| **Primaire** | Fond noir, texte blanc | Action principale de la page |
| **Secondaire** | Fond transparent, bordure, texte sombre | Actions secondaires, annulation |
| **Fantôme** | Fond transparent, pas de bordure, texte sombre | Actions tertiaires |
| **Danger** | Fond rouge très pâle, texte rouge, bordure rouge pâle | Suppression uniquement |
| **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 |
**Taille standard : petite.** Padding 6px vertical / 12px horizontal, texte 12px.
Il ne doit jamais y avoir plus d'un bouton primaire par section ou en-tête de page.
**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.
---
@@ -172,7 +228,7 @@ Il ne doit jamais y avoir plus d'un bouton primaire par section ou en-tête de p
- Fond blanc.
- Bordure 1px.
- Rayon 12px.
- Rayon 12px (rounded-xl).
- Padding interne 1620px.
- **Pas d'ombre.**
- En-tête interne séparé du contenu par une bordure horizontale.
@@ -195,10 +251,7 @@ Il ne doit jamais y avoir plus d'un bouton primaire par section ou en-tête de p
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.
Les statuts "vivants" (actif, en attente, en retard) ont un point coloré à gauche du texte.
Les statuts terminaux (archivé, publié) n'ont pas de point.
Rayon : 8px. Taille du texte : 11px.
Rayon : full. Taille du texte : 11px.
---
@@ -229,4 +282,4 @@ Toutes les transitions durent **120ms**, courbe `ease-out`. Aucune animation dé
## Thème sombre
Le thème sombre est une inversion calibrée : les fonds passent au quasi-noir, les textes s'éclaircissent, les accents gagnent légèrement en luminosité pour rester lisibles. La structure, les espaces et les proportions restent identiques. Détection automatique via la préférence système — pas de toggle manuel.
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.
+23 -42
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 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 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
### 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.
@@ -24,14 +32,10 @@ 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 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.
**ESLint passe sans avertissement.** Un warning ignoré aujourd'hui est un bug non détecté demain.
**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é. Le code et sa documentation vieillissent ensemble.
**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.
---
@@ -52,19 +56,20 @@ Tout fichier épinglé à une frontière Next.js porte le suffixe dans son nom :
- 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é**. Tout fichier `*.server.js` ou `*.client.js` est automatiquement ajouté à la config tsup non-bundlée pour préserver la frontière RSC.
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
### Source de vérité
`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.
`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 avec `bundle: true` dans un barrel créerait une copie inline distincte et casserait le partage d'état entre les pages et les widgets.
- **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.
---
@@ -72,6 +77,8 @@ Ces suffixes ne sont pas cosmétiques : **le build les utilise comme source de v
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 {
@@ -85,15 +92,12 @@ import OrdersWidget from './admin/OrdersWidget';
import OrdersPage from './admin/OrdersPage';
import { countOrders } from './admin/orders.server';
// Widget dashboard — fetcher serveur + composant client partagent un même id.
registerWidgetFetcher('orders', async () => ({ total: await countOrders() }));
registerWidget({ id: 'orders', Component: OrdersWidget, order: 20 });
// Sidebar
registerNavSection({ id: 'commerce', title: 'Commerce', icon: 'ShoppingBag03Icon', order: 30 });
registerNavItem({ id: 'orders', label: 'Commandes', icon: 'ShoppingBag03Icon', href: '/admin/orders', sectionId: 'commerce' });
registerNavItem({ id: 'orders', label: 'Commandes', icon: 'ShoppingBag03Icon', href: '/admin/orders', sectionId: 'commerce', permission: 'orders.view' });
// Page — le slug correspond au segment sous /admin/.
registerPage({ slug: 'orders', Component: OrdersPage, title: 'Commandes' });
```
@@ -106,33 +110,10 @@ import './zen.extensions';
## 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 :
```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.
**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.
+465
View File
@@ -0,0 +1,465 @@
# 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.). |
### 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/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 |
+28 -345
View File
@@ -1,14 +1,16 @@
# ZEN — Plan du projet
> 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.
ZEN est un système de gestion de contenu (CMS) pour Next.js. Il s'installe automatiquement dans n'importe quel projet Next.js via :
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 nécessaire pour gérer un site : 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, mais le reste du CMS s'appuie sur chacun d'eux.
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.
---
@@ -29,350 +31,9 @@ Le package principal est `@zen/core`. Il fournit toute l'infrastructure nécessa
```
src/
core/ # Infrastructure fondamentale — la base de tout
features/ # Fonctionnalités du CMS utilisant les cores
features/ # Fonctionnalités de la palteforme utilisant les cores
shared/ # Utilitaires, composants et styles partagés
```
---
## `src/core` — Les piliers du CMS
Chaque core est une brique indépendante. Il n'existe qu'une seule façon de faire chaque chose dans ZEN : passer par le core concerné. L'ensemble du CMS, des features aux modules, repose sur ces cores.
---
### API
**`@zen/core/api`**
L'API est le point d'entrée unique de toutes les requêtes HTTP du CMS. Que ce soit depuis l'Admin, le front-end du site ou un module tiers, tout passe par là. Il n'existe aucune autre route API dans ZEN.
Toutes les requêtes arrivent via le catch-all Next.js `app/zen/api/[...path]/route.js`. Le router les dispatche vers le bon handler selon le chemin et la méthode HTTP.
**Ce qu'il fait :**
- Routage dynamique avec paramètres nommés (`:id`) et wildcards (`/**`)
- Protection CSRF automatique sur toutes les requêtes mutantes (POST, PUT, PATCH, DELETE)
- Limitation de débit par IP avec des préréglages par action (login, register, api)
- Injection de session dans le contexte de chaque handler
- Trois niveaux d'accès : `public`, `user` (session requise), `admin` (rôle admin requis)
- Enregistrement dynamique des routes par les features
- Réponses standardisées via `apiSuccess()` et `apiError()`
**Règle absolue :** Toute route API du CMS doit être enregistrée dans ce router. Jamais de `route.js` parallèle.
---
### Database
**`@zen/core/database`**
La couche d'accès à la base de données PostgreSQL. Toute l'application communique avec la base de données uniquement via ce core — jamais directement.
**Ce qu'il fait :**
- Pool de connexions PostgreSQL (max 20 clients, reconnexion automatique)
- Fonctions de requête de bas niveau : `query()`, `queryOne()`, `queryAll()`, `transaction()`
- Helpers CRUD complets : `create()`, `find()`, `findOne()`, `findById()`, `update()`, `updateById()`, `delete()`, `deleteWhere()`, `count()`, `exists()`
- Protection contre l'injection SQL : requêtes paramétrées `$1, $2, ...`, jamais de concaténation
- Contrôle d'accès aux colonnes via liste blanche (`allowedColumns`) — empêche le mass assignment
- Validation et échappement des identifiants SQL (noms de tables et colonnes)
- Gestion SSL configurable : vérification complète en production, souple en développement
- Erreurs opaques : seul le code SQLSTATE est exposé, jamais les détails internes
**Règle absolue :** Aucun code ailleurs dans le CMS ne communique directement avec la base de données. Tout passe par ce core.
---
### Email
**`@zen/core/email`**
L'unique système d'envoi de courriels du CMS. Alimenté par Resend. Toute l'application envoie ses courriels via ce core.
**Ce qu'il fait :**
- Envoi de courriels simples : `sendEmail({ to, subject, html })`
- Envoi en lot : `sendBatchEmails(emailArray)`
- Expéditeur configurable (adresse, nom d'affichage)
- Template de base React Email (`BaseLayout`) : logo, marque, pied de page, lien de support
- Réponses standardisées `{ success, data, error }`
**Templates fournis :**
- `BaseLayout` — Enveloppe visuelle commune à tous les courriels du CMS (logo, couleurs, pied de page)
**Règle absolue :** Tout courriel envoyé par le CMS passe par ce core.
---
### Cron
**`@zen/core/cron`**
Le registre central de toutes les tâches planifiées. Chaque tâche récurrente de l'application s'enregistre ici — jamais en dehors.
**Ce qu'il fait :**
- Planification de tâches avec expressions cron standard (`schedule(name, expression, handler)`)
- Support des fuseaux horaires (défaut : `ZEN_TIMEZONE`)
- Survie aux hot reloads Next.js via stockage global (`Symbol.for`)
- Remplacement automatique si une tâche du même nom est enregistrée deux fois
- Déclenchement manuel : `trigger(name)` pour exécuter une tâche immédiatement
- Introspection : `getJobs()`, `getStatus()`, `isRunning(name)`
- Gestion des erreurs : un échec de tâche ne plante pas le scheduler
**Règle absolue :** Toutes les tâches cron de l'application sont enregistrées via ce core.
---
### Storage
**`@zen/core/storage`**
La gestion complète du stockage de fichiers, compatible S3 (Cloudflare R2 ou Backblaze B2). Toute l'application lit et écrit des fichiers via ce core.
**Ce qu'il fait :**
- Upload, téléchargement, suppression, copie et déplacement de fichiers
- Upload d'images avec cache longue durée (`max-age=31536000`)
- Suppression en lot optimisée (S3 batch delete)
- URLs pré-signées pour accès direct (GET ou PUT)
- Liste paginée des fichiers avec préfixe et continuation token
- Signature AWS Signature V4 pour toutes les requêtes
- Protection contre le path traversal (`..`, `.`, segments vides, null bytes)
- Contrôle d'accès via policies : préfixes publics vs chemins protégés (session + rôle)
- Headers de sécurité : `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`
- Téléchargement forcé pour les fichiers non-images (prévient l'exécution en navigateur)
- Utilitaires : validation de type, validation de taille, nommage unique, extension, MIME type
**Règle absolue :** Tout accès fichier passe par ce core.
---
### Payments
**`@zen/core/payments`**
L'intégration Stripe pour les paiements. Tout ce qui touche à la facturation ou aux transactions dans l'application passe par ce core.
**Ce qu'il fait :**
- Sessions de checkout Stripe (`createCheckoutSession`)
- PaymentIntents pour paiements personnalisés (`createPaymentIntent`)
- Gestion des clients Stripe (`createCustomer`, `getOrCreateCustomer`)
- Récupération des sessions et intentions de paiement
- Listing des moyens de paiement d'un client
- Vérification des webhooks Stripe (`verifyWebhookSignature`)
- Remboursements (`createRefund`)
- Initialisation paresseuse du client Stripe (seulement si configuré)
- Clé publiable exposée pour le front-end (`getPublishableKey`)
---
### PDF
**`@zen/core/pdf`**
La génération de fichiers PDF à partir de composants React. Tout PDF produit par l'application passe par ce core.
**Ce qu'il fait :**
- Rendu de documents PDF depuis des composants React (`renderToBuffer`)
- Réexporte l'API complète de `@react-pdf/renderer` : `Document`, `Page`, `View`, `Text`, `Image`, `Link`, `StyleSheet`, `Font`
- Utilitaire de nommage : `getFilename(prefix, identifier, date?)``"invoice-12345-2024-01-15.pdf"`
---
### Toast
**`@zen/core/toast`**
Le système de notifications visuelles de l'application. Que ce soit dans l'Admin ou sur le front-end, c'est l'unique système de toast à utiliser.
**Ce qu'il fait :**
- Contexte React via `<ToastProvider>` et `useToast()`
- Quatre types de notifications : `success`, `error`, `warning`, `info`
- Disparition automatique avec durées configurables par type
- Animation de sortie (fade-out 300ms)
- Flag `dismissible` par notification
- `<ToastContainer>` pour le rendu dans l'arbre React
**Règle absolue :** Un seul système de toast dans toute l'application — celui-ci.
---
## `src/features` — Les fonctionnalités du CMS
Les features utilisent les cores pour implémenter les fonctionnalités centrales du CMS. Elles ont accès à l'API, à la base de données, aux courriels, etc.
---
### Auth
**`@zen/core/auth`**
Le système d'authentification du CMS. Toute authentification d'utilisateur dans le site passe par ici.
**Ce qu'il fait :**
- Inscription et connexion d'utilisateurs
- Hachage des mots de passe avec `scrypt` (natif Node.js) + sel aléatoire + comparaison résistante aux timing attacks
- Gestion de sessions : création, validation, suppression, rafraîchissement automatique (<20 jours → 30 jours)
- Vérification d'adresse courriel par token
- Réinitialisation de mot de passe par lien sécurisé
- Middleware de protection de routes : `protect()`, `checkAuth()`, `requireRole()`
- Server Actions : `loginAction`, `registerAction`, `logoutAction`, `forgotPasswordAction`, `resetPasswordAction`, `verifyEmailAction`
- Tables gérées : `zen_auth_users`, `zen_auth_sessions`, `zen_auth_email_verifications`, `zen_auth_password_resets`
**Règle absolue :** Toute authentification de site passe par cette feature.
---
### Admin
**`@zen/core/admin`**
L'interface d'administration centrale. Tableau de bord visuel pour gérer le site et ses modules.
**Ce qu'il fait :**
- Protection des routes admin : `protectAdmin()`, `isAdmin()`
- Pages catch-all pour l'interface admin (`AdminPage.server.js`, `AdminPage.client.js`, `AdminShell`)
- Navigation construite côté serveur (`buildNavigationSections`)
- Registre d'extensions runtime pour widgets, nav items et pages (`registerWidget`, `registerNavItem`, `registerPage`)
- Gestion des utilisateurs depuis l'interface
**Navigation :**
- Tableau de bord → `/admin/dashboard`
- Utilisateurs → `/admin/users`
---
### Provider
**`@zen/core/provider`**
Le provider React racine du CMS. Il s'insère dans le layout du site et active tout ce dont le CMS a besoin côté client.
**Ce qu'il fait :**
- Enveloppe l'application dans `<ToastProvider>` avec son `<ToastContainer>`
- Un seul composant à poser dans le layout : `<ZenProvider>`
---
## `src/shared` — Utilitaires partagés
Tout ce qui est utile à travers le CMS sans appartenir à un core ou une feature.
### Composants UI (`src/shared/components/`)
Bibliothèque de composants React stylisés, utilisés dans l'Admin et les pages du CMS :
`Badge`, `StatusBadge`, `TypeBadge`, `Button`, `Card`, `Input`, `Loading`, `LoadingState`, `Modal`, `Pagination`, `Select`, `StatCard`, `Table`, `Textarea`, `MarkdownEditor`, `PasswordStrengthIndicator`, `FilterTabs`, `Breadcrumb`
### Utilitaires (`src/shared/lib/`, `src/shared/utils/`)
- **`appConfig`** — Lecture centralisée de la configuration (`getAppName`, `getAppConfig`, `getPublicBaseUrl`)
- **`logger`** — Console stylisée pour les logs (`step`, `done`, `warn`, `fail`, `info`)
- **`dates`** — Manipulation de dates en UTC (`formatDateForDisplay`, `getDaysBetween`, `isOverdue`, etc.)
- **`metadata`** — Génération de métadonnées Next.js (`generateMetadata`, `generateTitle`, `generateRobots`)
- **`rateLimit`** — Limitation de débit partagée (`checkRateLimit`) avec préréglages par action
- **`currency`** — Formatage monétaire (`formatCurrency`, `getCurrencySymbol`)
### Icons (`src/shared/Icons.js`)
Bibliothèque de plus de 1000 icônes (style Untitled UI).
### Styles (`src/shared/styles/zen.css`)
Feuille de style CSS de base du CMS.
---
## Initialisation
Dans `instrumentation.js` du projet Next.js :
```js
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initializeZen } = await import('@zen/core');
await initializeZen();
}
}
```
Dans `app/layout.js` :
```jsx
import { ZenProvider } from '@zen/core/provider';
export default function RootLayout({ children }) {
return (
<html>
<body>
<ZenProvider>
{children}
</ZenProvider>
</body>
</html>
);
}
```
Dans `app/zen/api/[...path]/route.js` :
```js
export { GET, POST, PUT, PATCH, DELETE } from '@zen/core/zen/api';
```
---
## Variables d'environnement
| Variable | Description |
|---|---|
| `ZEN_NAME` | Nom de l'application |
| `ZEN_TIMEZONE` | Fuseau horaire IANA (défaut : `America/Toronto`) |
| `ZEN_CURRENCY` | Code monétaire (défaut : `CAD`) |
| `ZEN_CURRENCY_SYMBOL` | Symbole monétaire (défaut : `$`) |
| `NEXT_PUBLIC_URL` | URL de base (production) |
| `NEXT_PUBLIC_URL_DEV` | URL de base (développement) |
| `ZEN_DATABASE_URL` | Chaîne de connexion PostgreSQL (production) |
| `ZEN_DATABASE_URL_DEV` | Chaîne de connexion PostgreSQL (développement) |
| `ZEN_DB_SSL_DISABLED` | Désactiver TLS (local uniquement) |
| `ZEN_EMAIL_RESEND_APIKEY` | Clé API Resend |
| `ZEN_EMAIL_FROM_ADDRESS` | Adresse d'expédition |
| `ZEN_EMAIL_FROM_NAME` | Nom d'affichage de l'expéditeur |
| `ZEN_STORAGE_PROVIDER` | `r2` ou `backblaze` |
| `ZEN_STORAGE_ENDPOINT` | Endpoint S3-compatible |
| `ZEN_STORAGE_ACCESS_KEY` | Clé d'accès stockage |
| `ZEN_STORAGE_SECRET_KEY` | Clé secrète stockage |
| `ZEN_STORAGE_BUCKET` | Nom du bucket |
| `STRIPE_SECRET_KEY` | Clé secrète Stripe |
| `STRIPE_PUBLISHABLE_KEY` | Clé publiable Stripe |
| `STRIPE_WEBHOOK_SECRET` | Secret webhook Stripe |
---
## CLI base de données
```bash
npx zen-db init # Créer toutes les tables
npx zen-db test # Tester la connexion
npx zen-db drop # Supprimer toutes les tables (confirmation requise)
```
---
## Flux d'une requête
```
Navigateur / Client
app/zen/api/[...path]/route.js ← catch-all Next.js
core/api — router.js ← CSRF, rate limit, auth
Handler (feature) ← logique métier
core/database, core/storage, ← accès aux ressources
core/email, core/payments…
Réponse standardisée ← apiSuccess() / apiError()
```
---
## Résumé des règles absolues
@@ -386,3 +47,25 @@ Réponse standardisée ← apiSuccess() / apiError()
| 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.
+103 -127
View File
@@ -1,163 +1,139 @@
# RÉDACTION
Dernière modification : 2026-04-12
# Rédaction
## La voix de l'entreprise
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 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.
---
## Ce qu'on évite absolument
## Deux contextes, deux voix
**Les superlatifs vides**
*de premier plan, leader, de pointe, best-in-class, incontournable*
**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.
**Les promesses floues**
*solutions sur mesure, accompagnement personnalisé, approche holistique*
**Documentation** : on s'adresse directement au développeur qui lit. Phrases courtes, verbes actifs, ton direct.
---
## Ce qu'on évite partout
**Superlatifs et formules vides**
*de premier plan, robuste, puissant, tout-en-un, intuitif, seamless*
**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**
Si on ne peut pas le garantir, on ne l'écrit pas. Jamais.
**Les métaphores usées**
*pont entre, clé en main, écosystème, synergies, à 360°*
**Chevilles inutiles**
*Ainsi, En effet, Il convient de noter que, N'hésitez pas à, Veuillez noter que*
**Le tiret long (—)**
Reformuler la phrase plutôt que d'insérer une incise.
Reformuler la phrase à la place.
**Les chevilles inutiles**
*Ainsi, En effet, Il convient de noter que, N'hésitez pas à*
**Le passif sans raison**
Préférer la forme active. "On a livré le projet" plutôt que "le projet a été livré".
**La sur-promesse**
Si on ne peut pas le garantir, on ne l'écrit pas.
---
## Formules qui marchent
## Interface
**Plutôt ça :**
> "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."
### Voix de l'interface
**Pas ça :**
> "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 plateforme rapporte. Elle ne dit pas "on a fait" — elle dit ce qui s'est passé.
**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
Courts, affirmatifs, sans point. Une idée, pas une liste. Pas de question rhétorique.
> ✓ "Zéro raccourci. Zéro compromis."
> ✓ "On livre. On reste."
> "Une approche rigoureuse pour des résultats durables"
> ✗ "Pourquoi nous choisir ?"
Descriptifs, pas génériques.
> "Ajouter un widget à l'admin"
> ✗ "Configuration"
### 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
Concrets et à l'infinitif. Pas d'exclamation.
> ✓ "Discuter de votre projet"
> ✓ "Voir comment on travaille"
> ✗ "Contactez-nous pour en savoir plus !"
> ✗ "Passez à l'action"
Pas de listes à puces pour des explications. Les listes servent pour les étapes, les options, les éléments techniques.
---
## Selon le format
## Révision avant de livrer
### Site web
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.
Pas de jargon en page d'accueil. Le jargon technique appartient aux pages de service, là où le lecteur s'y attend.
### Courriels
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.
- [ ] La première phrase dit déjà l'essentiel.
- [ ] Chaque phrase dit quelque chose. Les phrases qui meublent sont supprimées.
- [ ] L'interface utilise l'impersonnel. La doc s'adresse directement au lecteur.
- [ ] Il n'y a pas de tiret long (—).
- [ ] Les fautes sont corrigées.
- [ ] L'utilisateur sait quoi faire ou quoi retenir après avoir lu.
---
## Parler de ce qu'on fait
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.*
*Ce guide évolue. Si une formule sonne faux ou si un cas n'est pas couvert, l'ajuster.*
+33 -32
View File
@@ -1,12 +1,12 @@
{
"name": "@zen/core",
"version": "1.4.39",
"version": "1.4.186",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@zen/core",
"version": "1.4.39",
"version": "1.4.186",
"license": "GPL-3.0-only",
"dependencies": {
"@headlessui/react": "^2.0.0",
@@ -19,7 +19,8 @@
"stripe": "^14.0.0"
},
"bin": {
"zen-db": "dist/core/database/cli.js"
"zen-db": "dist/core/database/cli.js",
"zen-modules": "dist/core/modules/cli.js"
},
"devDependencies": {
"react": "^19.0.0",
@@ -3653,35 +3654,6 @@
"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": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.2.1.tgz",
@@ -3910,6 +3882,35 @@
"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": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
+24 -5
View File
@@ -1,7 +1,7 @@
{
"name": "@zen/core",
"version": "1.4.39",
"description": "Un CMS Next.js construit sur l'essentiel, rien de plus, rien de moins.",
"version": "1.4.186",
"description": "Une plateforme multi-usage construite sur l'essentiel, rien de plus, rien de moins.",
"repository": {
"type": "git",
"url": "https://git.hyko.cx/zen/core.git"
@@ -25,7 +25,8 @@
"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": {
"zen-db": "./dist/core/database/cli.js"
"zen-db": "./dist/core/database/cli.js",
"zen-modules": "./dist/core/modules/cli.js"
},
"dependencies": {
"@headlessui/react": "^2.0.0",
@@ -47,6 +48,9 @@
"next": ">=14.0.0",
"react": "^19.0.0"
},
"overrides": {
"postcss": "^8.5.10"
},
"exports": {
".": {
"import": "./dist/index.js"
@@ -63,15 +67,21 @@
"./features/auth/client": {
"import": "./dist/features/auth/AuthPage.client.js"
},
"./features/auth/components": {
"import": "./dist/features/auth/components/index.js"
"./features/auth/pages": {
"import": "./dist/features/auth/pages/index.js"
},
"./features/admin": {
"import": "./dist/features/admin/index.js"
},
"./features/admin/protect": {
"import": "./dist/features/admin/protect.js"
},
"./features/admin/server": {
"import": "./dist/features/admin/AdminPage.server.js"
},
"./features/admin/layout": {
"import": "./dist/features/admin/AdminLayout.server.js"
},
"./features/admin/client": {
"import": "./dist/features/admin/AdminPage.client.js"
},
@@ -87,6 +97,15 @@
"./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": {
"import": "./dist/core/api/index.js"
},
+8
View File
@@ -37,6 +37,7 @@ route-handler.js (GET / POST / PUT / DELETE / PATCH)
├─ matchRoute(pattern, path) — exact, :param, /**
├─ Auth enforcement (depuis la définition de la route)
│ 'admin' → requireAdmin() — session dans context.session
│ │ si `permission` est défini → hasPermission() → 403 si refusé
│ 'user' → requireAuth() — session dans context.session
│ 'public'→ aucun — context.session = undefined
└─ handler(request, params, context)
@@ -175,6 +176,13 @@ Champs requis par route :
| `handler` | `Function` | Signature : `(request, params, context) => Promise<Object>` |
| `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
+9
View File
@@ -23,6 +23,10 @@
* 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:
* 'public' Anyone can call this route. context.session is undefined.
@@ -77,6 +81,11 @@ export function defineApiRoutes(routes) {
`${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.
+1 -1
View File
@@ -9,7 +9,7 @@
export { routeRequest, requireAuth, requireAdmin } from './router.js';
// Runtime state — session resolver + feature routes registry
export { configureRouter, getSessionResolver, clearRouterConfig, registerFeatureRoutes, getFeatureRoutes, clearFeatureRoutes } from './runtime.js';
export { configureRouter, getSessionResolver, clearRouterConfig, registerApiRoutes, registerFeatureRoutes, getFeatureRoutes, clearFeatureRoutes } from './runtime.js';
// Response utilities — use in all handlers (core and modules)
export { apiSuccess, apiError, getStatusCode } from './respond.js';
+7 -1
View File
@@ -18,7 +18,6 @@
* → handler(request, params, context)
*/
import { cookies } from 'next/headers';
import { getSessionCookieName } from '@zen/core/shared/config';
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '@zen/core/shared/rate-limit';
import { fail } from '@zen/core/shared/logger';
@@ -40,6 +39,7 @@ const COOKIE_NAME = getSessionCookieName();
* @returns {Promise<Object>} session
*/
export async function requireAuth() {
const { cookies } = await import('next/headers');
const cookieStore = await cookies();
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
@@ -271,6 +271,12 @@ export async function routeRequest(request, path) {
try {
if (matchedRoute.auth === 'admin') {
context.session = await requireAdmin();
if (matchedRoute.permission) {
const allowed = await hasPermission(context.session.user.id, matchedRoute.permission);
if (!allowed) {
return apiError('Forbidden', 'Permission insuffisante');
}
}
} else if (matchedRoute.auth === 'user') {
context.session = await requireAuth();
}
+11 -4
View File
@@ -68,18 +68,25 @@ if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = [];
const _featureRoutes = globalThis[REGISTRY_KEY];
/**
* Enregistre les routes d'une feature core.
* Appelé une fois par feature pendant initializeZen().
* 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 registerFeatureRoutes(routes) {
export function registerApiRoutes(routes) {
if (!Array.isArray(routes)) {
throw new TypeError('registerFeatureRoutes: routes must be an array');
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.
+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
```
+1 -1
View File
@@ -3,7 +3,7 @@
* 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.
+1 -1
View File
@@ -14,7 +14,7 @@ export {
closePool,
testConnection,
tableExists
} from './db.js';
} from './db.server.js';
// CRUD helper functions
export {
+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`.
+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);
}
+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 ?? ''}`);
}
}
}
}
+2
View File
@@ -0,0 +1,2 @@
export { registerModule, getRegisteredModules, getRegisteredModule, clearRegisteredModules } from './registry.js';
export { registerModules, findInstalledModuleNames, moduleHasClientEntry, loadModulesForCli, validateModuleEnvVars } from './discover.server.js';
+45
View File
@@ -0,0 +1,45 @@
/**
* Registre des modules `@zen/module-*` chargés.
*
* 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.
*/
const REGISTRY_KEY = Symbol.for('__ZEN_MODULES_REGISTRY__');
if (!globalThis[REGISTRY_KEY]) globalThis[REGISTRY_KEY] = new Map();
/** @type {Map<string, object>} */
const registry = globalThis[REGISTRY_KEY];
export function registerModule(mod) {
if (!mod || typeof mod !== 'object') {
throw new TypeError('registerModule: argument must be an object');
}
const { manifest } = mod;
if (!manifest || typeof manifest.name !== 'string' || !manifest.name) {
throw new TypeError('registerModule: module.manifest.name must be a non-empty string');
}
if (typeof mod.register !== 'function') {
throw new TypeError(`registerModule(${manifest.name}): module.register must be a function`);
}
registry.set(manifest.name, mod);
}
export function getRegisteredModules() {
return [...registry.values()];
}
export function getRegisteredModule(name) {
return registry.get(name);
}
export function clearRegisteredModules() {
registry.clear();
}
+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) |
+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}"`,
},
});
}
```
@@ -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()];
}
+1 -1
View File
@@ -15,7 +15,6 @@
* registerFeatureRoutes in core/api/runtime.js.
*/
import { cookies } from 'next/headers';
import { getSessionCookieName } from '@zen/core/shared/config';
import { getSessionResolver } from '../api/router.js';
import { getFile } from './index.js';
@@ -60,6 +59,7 @@ async function handleGetFile(_request, { wildcard: fileKey }) {
}
// Require authentication for all other paths.
const { cookies } = await import('next/headers');
const cookieStore = await cookies();
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
+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>
);
}
```
+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 };
}
```
+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 |
+66 -2
View File
@@ -2,7 +2,7 @@ import { query, create, findOne, updateById, count } from '@zen/core/database';
import { hashPassword, verifyPassword, generateId } from './password.js';
import { createSession } from './session.js';
import { fail } from '@zen/core/shared/logger';
import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken } from './verifications.js';
import { createEmailVerification, createPasswordReset, verifyResetToken, deleteResetToken, verifyAccountSetupToken, deleteAccountSetupToken } from './verifications.js';
async function register(userData, { onEmailVerification } = {}) {
const { email, password, name } = userData;
@@ -228,4 +228,68 @@ async function updateUser(userId, updateData) {
return await updateById('zen_auth_users', userId, filteredData);
}
export { register, login, requestPasswordReset, resetPassword, verifyUserEmail, updateUser };
async function completeAccountSetup({ email, token, password }) {
if (!email || !token || !password) {
throw new Error('L\'e-mail, le jeton et le mot de passe sont requis');
}
if (password.length < 8) {
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 };
+10 -32
View File
@@ -4,41 +4,19 @@
*/
export const PERMISSIONS = {
ADMIN_ACCESS: 'admin.access',
CONTENT_VIEW: 'content.view',
CONTENT_CREATE: 'content.create',
CONTENT_EDIT: 'content.edit',
CONTENT_DELETE: 'content.delete',
CONTENT_PUBLISH: 'content.publish',
MEDIA_VIEW: 'media.view',
MEDIA_UPLOAD: 'media.upload',
MEDIA_DELETE: 'media.delete',
USERS_VIEW: 'users.view',
USERS_EDIT: 'users.edit',
USERS_DELETE: 'users.delete',
ROLES_VIEW: 'roles.view',
ROLES_MANAGE: 'roles.manage',
SETTINGS_VIEW: 'settings.view',
SETTINGS_MANAGE: 'settings.manage',
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: 'content.view', name: 'Voir le contenu', description: 'Permet de consulter les articles, pages et autres contenus.', group_name: 'Contenu' },
{ key: 'content.create', name: 'Créer du contenu', description: 'Permet de rédiger et soumettre de nouveaux contenus.', group_name: 'Contenu' },
{ key: 'content.edit', name: 'Modifier le contenu', description: 'Permet de mettre à jour des contenus existants.', group_name: 'Contenu' },
{ key: 'content.delete', name: 'Supprimer le contenu', description: 'Permet de supprimer définitivement des contenus.', group_name: 'Contenu' },
{ key: 'content.publish', name: 'Publier le contenu', description: 'Permet de rendre des contenus visibles publiquement.', group_name: 'Contenu' },
{ key: 'media.view', name: 'Voir les médias', description: 'Permet de parcourir la médiathèque.', group_name: 'Médias' },
{ key: 'media.upload', name: 'Téléverser des médias', description: 'Permet d\'uploader des images, vidéos et fichiers.', group_name: 'Médias' },
{ key: 'media.delete', name: 'Supprimer des médias', description: 'Permet de supprimer des fichiers de la médiathèque.', group_name: 'Médias' },
{ key: 'users.view', name: 'Voir les utilisateurs', description: 'Permet de consulter la liste des membres et leurs profils.', group_name: 'Utilisateurs' },
{ key: 'users.edit', name: 'Modifier les utilisateurs', description: 'Permet de changer les informations et les rôles des membres.', group_name: 'Utilisateurs' },
{ key: 'users.delete', name: 'Supprimer des utilisateurs', description: 'Permet de 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' },
{ key: 'settings.view', name: 'Voir les paramètres', description: 'Permet de consulter la configuration du site.', group_name: 'Paramètres' },
{ key: 'settings.manage', name: 'Gérer les paramètres', description: 'Permet de modifier la configuration et les réglages du site.', group_name: 'Paramètres' },
{ 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 cer, 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' },
];
/**
+36 -11
View File
@@ -2,8 +2,9 @@ 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 = ['content.view', 'media.view'];
const USER_ROLE_PERMISSIONS = [];
const ROLE_TABLES = [
{
@@ -66,15 +67,37 @@ async function dropRoleCheckConstraint() {
`);
}
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() {
// Permissions
for (const perm of PERMISSION_DEFINITIONS) {
// 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, group_name) VALUES ($1, $2, $3) ON CONFLICT DO NOTHING`,
[perm.key, perm.name, perm.group_name]
`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(
@@ -84,12 +107,14 @@ async function seedDefaultRolesAndPermissions() {
const adminRole = await query(`SELECT id FROM zen_auth_roles WHERE name = 'admin'`);
const adminId = adminRole.rows[0].id;
for (const perm of PERMISSION_DEFINITIONS) {
await query(
`INSERT INTO zen_auth_role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING`,
[adminId, perm.key]
);
}
// 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();
+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']
);
}
+11 -1
View File
@@ -4,4 +4,14 @@ export { createSession, validateSession, deleteSession, deleteUserSessions, refr
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 } from './permissions.js';
export {
PERMISSIONS,
PERMISSION_DEFINITIONS,
getPermissionGroups,
hasPermission,
getUserPermissions,
registerPermission,
registerPermissions,
getRegisteredPermissions,
getRegisteredPermissionKeys,
} from './permissions.js';
+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();
}
+6
View File
@@ -1,5 +1,11 @@
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(
+5 -7
View File
@@ -1,8 +1,6 @@
import { query, transaction } from '@zen/core/database';
import { generateId } from './password.js';
import { PERMISSIONS } from './permissions.js';
const VALID_PERMISSION_KEYS = new Set(Object.values(PERMISSIONS));
import { getRegisteredPermissionKeys } from './permissions-registry.js';
export async function listRoles() {
const result = await query(
@@ -60,8 +58,7 @@ export async function updateRole(roleId, { name, description, color, permissionK
const values = [];
let idx = 1;
// System roles cannot be renamed
if (!isSystem && name !== undefined) {
if (name !== undefined) {
if (!name.trim()) throw new Error('Role name cannot be empty');
updateFields.push(`name = $${idx++}`);
values.push(name.trim());
@@ -83,8 +80,9 @@ export async function updateRole(roleId, { name, description, color, permissionK
values
);
if (permissionKeys !== undefined) {
const safeKeys = [...new Set(permissionKeys)].filter(k => VALID_PERMISSION_KEYS.has(k));
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(
+49 -1
View File
@@ -98,4 +98,52 @@ function deleteResetToken(email) {
return deleteWhere('zen_auth_verifications', { identifier: 'password_reset', value: email });
}
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken };
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>
);
}
+10 -10
View File
@@ -1,33 +1,33 @@
'use client';
import { useEffect } from 'react';
import { getPage } from './registry.js';
import { useAdminPageTitle } from './components/AdminPageTitleContext.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';
export default function AdminPageClient({ params, user, widgetData }) {
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');
const { setTitle } = useAdminPageTitle();
useEffect(() => {
if (page?.title) setTitle(page.title);
}, [page?.title]);
if (!page) return null;
const { Component } = page;
// Le tableau de bord reçoit les données collectées côté serveur ; les
// autres pages ne connaissent pas le widget data.
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} />;
}
+17 -21
View File
@@ -1,32 +1,28 @@
import AdminShell from './components/AdminShell.js';
import AdminPageClient from './AdminPage.client.js';
import { protectAdmin } from './protect.js';
import { buildNavigationSections } from './navigation.js';
import { collectWidgetData } from './registry.js';
import { logoutAction } from '@zen/core/features/auth/actions';
import { getAppName } from '@zen/core';
import './widgets/index.server.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 appName = getAppName();
const widgetData = await collectWidgetData();
const navigationSections = buildNavigationSections('/');
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 (
<AdminShell
user={session.user}
onLogout={logoutAction}
appName={appName}
navigationSections={navigationSections}
>
<AdminPageClient
params={resolvedParams}
user={session.user}
widgetData={widgetData}
/>
</AdminShell>
<AdminPageClient
params={resolvedParams}
user={user}
widgetData={widgetData}
appConfig={appConfig}
devkitEnabled={devkitEnabled}
/>
);
}
+304
View File
@@ -0,0 +1,304 @@
# 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
├── 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, 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 |
| `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. |
---
### 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
```
+1 -1
View File
@@ -18,7 +18,7 @@ const AdminHeader = ({ title, description, backHref, backLabel = '← Retour', a
</div>
<div className='flex gap-2'>
{backHref && (
<Button variant="secondary" size="sm" onClick={() => router.push(backHref)}>
<Button variant="secondary" onClick={() => router.push(backHref)}>
{backLabel}
</Button>
)}
@@ -1,18 +0,0 @@
'use client';
import { createContext, useContext, useState } from 'react';
const AdminPageTitleContext = createContext({ title: '', setTitle: () => {} });
export function AdminPageTitleProvider({ children }) {
const [title, setTitle] = useState('');
return (
<AdminPageTitleContext.Provider value={{ title, setTitle }}>
{children}
</AdminPageTitleContext.Provider>
);
}
export function useAdminPageTitle() {
return useContext(AdminPageTitleContext);
}
+19 -21
View File
@@ -3,36 +3,34 @@
import { useState } from 'react';
import AdminSidebar from './AdminSidebar.js';
import AdminTop from './AdminTop.js';
import { AdminPageTitleProvider } from './AdminPageTitleContext.js';
export default function AdminShell({ children, user, onLogout, appName, navigationSections }) {
export default function AdminShell({ children, user, onLogout, appName, navigationSections, bottomNavItems }) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
return (
<AdminPageTitleProvider>
<div className="flex h-screen overflow-hidden bg-white dark:bg-black font-ibm-plex-sans">
<AdminSidebar
<div className="flex h-screen overflow-hidden bg-white dark:bg-black font-ibm-plex-sans">
<AdminSidebar
isMobileMenuOpen={isMobileMenuOpen}
setIsMobileMenuOpen={setIsMobileMenuOpen}
appName={appName}
navigationSections={navigationSections}
bottomNavItems={bottomNavItems}
/>
<div className="flex-1 flex flex-col min-w-0">
<AdminTop
isMobileMenuOpen={isMobileMenuOpen}
setIsMobileMenuOpen={setIsMobileMenuOpen}
user={user}
onLogout={onLogout}
appName={appName}
navigationSections={navigationSections}
/>
<div className="flex-1 flex flex-col min-w-0">
<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">
<div className="px-8 py-7 pb-32 max-w-[1920px] mx-auto">
{children}
</div>
</main>
</div>
<main className="flex-1 overflow-y-auto bg-neutral-50 dark:bg-black">
<div className="px-8 py-7 pb-32 max-w-[1920px] mx-auto">
{children}
</div>
</main>
</div>
</AdminPageTitleProvider>
</div>
);
}
+42 -24
View File
@@ -4,7 +4,7 @@ import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import * as Icons from '@zen/core/shared/icons';
import { ChevronDownIcon } from '@zen/core/shared/icons';
import { ArrowDown01Icon } from '@zen/core/shared/icons';
/**
* Resolve icon name (string) to icon component
@@ -20,20 +20,29 @@ function resolveIcon(iconNameOrComponent) {
return Icons.DashboardSquare03Icon;
}
const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledModules, navigationSections: serverNavigationSections }) => {
const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledModules, navigationSections: serverNavigationSections, bottomNavItems = [] }) => {
const pathname = usePathname();
const [collapsedSections, setCollapsedSections] = useState(new Set());
const [collapsedSections, setCollapsedSections] = useState(() => {
const initial = new Set();
serverNavigationSections.forEach(section => {
const isActive = section.items.some(item =>
pathname === item.href || pathname.startsWith(item.href + '/')
);
if (!isActive) initial.add(section.id);
});
return initial;
});
const toggleSection = (sectionId) => {
setCollapsedSections(prev => {
const newCollapsed = new Set(prev);
if (newCollapsed.has(sectionId)) {
newCollapsed.delete(sectionId);
const next = new Set(prev);
if (next.has(sectionId)) {
next.delete(sectionId);
} else {
newCollapsed.add(sectionId);
next.add(sectionId);
}
return newCollapsed;
return next;
});
};
@@ -61,19 +70,6 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
section.items[0].name.toLowerCase() === section.title.toLowerCase();
};
useEffect(() => {
setCollapsedSections(prev => {
const newSet = new Set(prev);
navigationSections.forEach(section => {
if (isSectionActive(section)) {
newSet.add(section.id);
}
});
return newSet;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
const navigationSections = serverNavigationSections.map(section => ({
...section,
items: section.items.map(item => ({
@@ -118,7 +114,7 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
);
}
const isCollapsed = !collapsedSections.has(section.id);
const isCollapsed = collapsedSections.has(section.id);
const isActive = isSectionActive(section);
return (
@@ -131,7 +127,7 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
<Icon className="h-[15px] w-[15px] flex-shrink-0" />
<span>{section.title}</span>
</div>
<ChevronDownIcon
<ArrowDown01Icon
className={`h-3 w-3 transition-transform duration-[120ms] ease-out ${
isCollapsed ? '-rotate-90' : 'rotate-0'
}`}
@@ -194,9 +190,31 @@ const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledM
</Link>
{/* Navigation */}
<nav className="flex-1 px-2 py-2 overflow-y-auto flex flex-col pb-12">
<nav className="flex-1 px-2 py-2 overflow-y-auto flex flex-col">
{navigationSections.map(renderNavSection)}
</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>
</>
);
+40 -74
View File
@@ -1,18 +1,24 @@
'use client';
import { Fragment } from 'react';
import { Fragment, useState, useEffect } from 'react';
import { Menu, MenuButton, MenuItem, MenuItems, Transition } from '@headlessui/react';
import { ChevronDownIcon, User03Icon, DashboardSquare03Icon } from '@zen/core/shared/icons';
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 { getPages } from '../registry.js';
import { useAdminPageTitle } from './AdminPageTitleContext.js';
import { getPage, getPages } 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 { title: currentPageTitle } = useAdminPageTitle();
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 {
@@ -40,36 +46,36 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
const buildBreadcrumbs = () => {
const crumbs = [{ icon: DashboardSquare03Icon, href: '/admin/dashboard' }];
const after = pathname.replace(/^\/admin\/?/, '');
if (!after) {
crumbs.push({ label: currentPageTitle });
return crumbs;
}
const segments = after.split('/').filter(Boolean);
if (!segments.length || (segments[0] === 'dashboard' && segments.length === 1)) {
crumbs.push({ label: currentPageTitle });
return crumbs;
}
const [first, second] = segments;
if (first === 'profile') {
crumbs.push({ label: currentPageTitle });
if (!after || !segments.length || (segments[0] === 'dashboard' && segments.length === 1)) {
crumbs.push({ label: pageTitle });
return crumbs;
}
const allItems = navigationSections.flatMap(s => s.items);
const navItem = allItems.find(item => item.href.replace('/admin/', '').split('/')[0] === first);
const hasSubPage = segments.length > 1;
const navItem = allItems.find(item => {
const itemSegs = item.href.replace('/admin/', '').split('/').filter(Boolean);
return itemSegs.length <= segments.length && itemSegs.every((seg, i) => segments[i] === seg);
});
const itemSegCount = navItem
? navItem.href.replace('/admin/', '').split('/').filter(Boolean).length
: 1;
const hasSubPage = segments.length > itemSegCount;
if (navItem) {
crumbs.push({ label: navItem.name, href: hasSubPage ? navItem.href : undefined });
} else if (!hasSubPage) {
crumbs.push({ label: pageTitle });
return crumbs;
}
if (second === 'new') {
const subSegment = segments[itemSegCount];
if (subSegment === 'new') {
crumbs.push({ label: 'Nouveau' });
} else if (second === 'edit') {
const page = getPages().find(p => p.slug === `${first}:edit`);
} else if (subSegment === 'edit') {
const page = getPages().find(p => p.slug === `${segments[0]}:edit`);
crumbs.push({ label: page?.breadcrumbLabel || page?.title || 'Modifier' });
}
@@ -78,8 +84,6 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
const breadcrumbs = buildBreadcrumbs();
const quickLinks = [];
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">
@@ -87,14 +91,12 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
<div className="flex items-center space-x-3 lg:hidden">
<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"
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"
>
<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>
<Menu01Icon className="h-5 w-5 transition-transform duration-200" />
</button>
<h1 className="text-neutral-900 dark:text-white font-semibold text-lg">{appName}</h1>
<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 */}
@@ -102,9 +104,7 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
{breadcrumbs.length > 0 && breadcrumbs.map((crumb, i) => (
<Fragment key={i}>
{i > 0 && (
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-neutral-400 dark:text-neutral-600 flex-shrink-0">
<polyline points="9 18 15 12 9 6" />
</svg>
<ArrowRight01Icon className="w-3 h-3 text-neutral-400 dark:text-neutral-600 flex-shrink-0" />
)}
{crumb.icon ? (
<button
@@ -127,20 +127,8 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
))}
</div>
{/* Right section — Quick links + Profile */}
{/* Right section — Profile */}
<div className="flex items-center gap-3 sm:gap-4">
<nav className="hidden sm:flex items-center gap-4 lg:gap-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>
{/* 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">
@@ -149,7 +137,7 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
<span className="hidden sm:block text-[13px] leading-none font-medium text-neutral-800 dark:text-white">
{user?.name || 'User'}
</span>
<ChevronDownIcon 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" />
<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
@@ -161,37 +149,17 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
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-neutral-50 dark:bg-black shadow-lg overflow-hidden z-50">
<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">
{/* Quick links — mobile only */}
{quickLinks.length > 0 && (
<>
{quickLinks.map((link) => (
<MenuItem key={link.name}>
<a
href={link.href}
className="sm:hidden flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm text-neutral-500 dark:text-neutral-400 transition-colors duration-150 data-focus:bg-neutral-100 dark:data-focus:bg-white/5 data-focus:text-neutral-900 dark:data-focus:text-white"
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{link.name}
</a>
</MenuItem>
))}
<div className="h-px bg-black/6 dark:bg-white/6 my-0.5" />
</>
)}
{/* Profile */}
<MenuItem>
<a
<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
</a>
</Link>
</MenuItem>
{/* Theme — pas de MenuItem pour ne pas fermer le menu au clic */}
@@ -209,11 +177,9 @@ const AdminTop = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appNa
<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-500 dark:text-red-400 transition-colors duration-150 text-left data-focus:bg-red-50 dark:data-focus:bg-red-500/10"
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"
>
<svg className="w-4 h-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.75} 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>
<Logout02Icon className="w-4 h-4 shrink-0" />
Se déconnecter
</button>
</MenuItem>
@@ -3,9 +3,6 @@
import { useState, useEffect } from 'react';
import { Input, Textarea, Switch, Modal, ColorPicker } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
import { getPermissionGroups } from '@zen/core/users/constants';
const PERMISSION_GROUPS = getPermissionGroups();
const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
const toast = useToast();
@@ -19,9 +16,12 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
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('');
@@ -33,6 +33,18 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
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);
@@ -125,7 +137,6 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
label="Nom du rôle"
value={name}
onChange={setName}
disabled={isSystem}
placeholder="Éditeur, Modérateur..."
required
/>
@@ -147,7 +158,7 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
<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(PERMISSION_GROUPS).map(([group, perms]) => (
{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">
@@ -162,6 +173,7 @@ const RoleEditModal = ({ roleId, isOpen, onClose, onSaved }) => {
onChange={() => togglePerm(perm.key)}
label={perm.name}
description={perm.description}
disabled={isSystem}
/>
))}
</div>
@@ -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;
@@ -1,28 +1,25 @@
'use client';
import { useState, useEffect } from 'react';
import { Input, Select, TagInput, Modal } from '@zen/core/shared/components';
import { Input, TagInput, Modal, Badge } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => {
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_verified: '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([]);
const emailVerifiedOptions = [
{ value: 'false', label: 'Non vérifié' },
{ value: 'true', label: 'Vérifié' },
];
useEffect(() => {
if (!isOpen || !userId) return;
loadAll();
@@ -47,7 +44,9 @@ const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => {
setUserData(userJson.user);
setFormData({
name: userJson.user.name || '',
email_verified: userJson.user.email_verified ? 'true' : 'false',
email: userJson.user.email || '',
currentPassword: '',
newPassword: '',
});
} else {
toast.error(userJson.message || 'Utilisateur introuvable');
@@ -73,9 +72,31 @@ const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => {
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;
};
@@ -91,7 +112,6 @@ const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => {
credentials: 'include',
body: JSON.stringify({
name: formData.name.trim(),
email_verified: formData.email_verified === 'true',
}),
});
const userResData = await userRes.json();
@@ -103,24 +123,93 @@ const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => {
const toAdd = selectedRoleIds.filter(id => !initialRoleIds.includes(id));
const toRemove = initialRoleIds.filter(id => !selectedRoleIds.includes(id));
await Promise.all([
...toAdd.map(roleId =>
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 }),
})
),
...toRemove.map(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');
}
toast.success('Utilisateur mis à jour');
onSaved?.();
onClose();
} catch {
@@ -165,26 +254,54 @@ const UserEditModal = ({ userId, isOpen, onClose, onSaved }) => {
error={errors.name}
/>
<Input
label="Email"
value={userData?.email || ''}
disabled
label="Courriel"
type="email"
value={formData.email}
onChange={(value) => handleInputChange('email', value)}
placeholder="courriel@exemple.com"
/>
</div>
<Select
label="Email vérifié"
value={formData.email_verified}
onChange={(value) => handleInputChange('email_verified', value)}
options={emailVerifiedOptions}
/>
<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>
+1
View File
@@ -7,3 +7,4 @@ 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';
@@ -0,0 +1,258 @@
'use client';
import { useState } from 'react';
import {
Button,
Card,
Badge,
StatusBadge,
Input,
Select,
Textarea,
Switch,
TagInput,
StatCard,
Loading,
BlockEditor,
} from '@zen/core/shared/components';
import { UserCircle02Icon } from '@zen/core/shared/icons';
import AdminHeader from '../components/AdminHeader.js';
const ROLE_OPTIONS = [
{ value: 'admin', label: 'Admin', color: '#6366f1' },
{ value: 'editor', label: 'Éditeur', color: '#f59e0b' },
{ value: 'viewer', label: 'Lecteur', color: '#10b981' },
{ value: 'support', label: 'Support', color: '#3b82f6' },
{ value: 'billing', label: 'Facturation', color: '#ec4899' },
];
function TagInputDemo({ label, description, error, renderTag }) {
const [value, setValue] = useState([]);
return (
<TagInput
label={label}
description={description}
error={error}
placeholder="Ajouter un rôle..."
options={ROLE_OPTIONS}
value={value}
onChange={setValue}
renderTag={renderTag}
/>
);
}
function PreviewBlock({ title, children }) {
return (
<div className="flex flex-col gap-3">
<h3 className="text-[11px] font-semibold uppercase tracking-widest text-neutral-500 dark:text-neutral-400">{title}</h3>
<div className="rounded-xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 p-6 flex flex-wrap gap-3 items-center">
{children}
</div>
</div>
);
}
export default function ComponentsPage() {
return (
<div className="flex flex-col gap-8">
<AdminHeader title="Composants" description="Catalogue visuel des composants partagés" />
<PreviewBlock title="Button — variants">
<Button variant="primary" size="md">Primary</Button>
<Button variant="secondary" size="md">Secondary</Button>
<Button variant="success" size="md">Success</Button>
<Button variant="danger" size="md">Danger</Button>
<Button variant="warning" size="md">Warning</Button>
<Button variant="ghost" size="md">Ghost</Button>
<Button variant="fullghost" size="md">Full Ghost</Button>
</PreviewBlock>
<PreviewBlock title="Button — tailles">
<Button variant="primary" size="sm">Small</Button>
<Button variant="primary" size="md">Medium</Button>
<Button variant="primary" size="lg">Large</Button>
</PreviewBlock>
<PreviewBlock title="Button — états">
<Button variant="primary" disabled>Désactivé</Button>
<Button variant="primary" loading>Chargement</Button>
</PreviewBlock>
<PreviewBlock title="Badge — variants">
<Badge variant="default">Default</Badge>
<Badge variant="primary">Primary</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="danger">Danger</Badge>
<Badge variant="info">Info</Badge>
<Badge variant="purple">Purple</Badge>
<Badge variant="pink">Pink</Badge>
<Badge variant="orange">Orange</Badge>
</PreviewBlock>
<PreviewBlock title="Badge — pastilles (dot)">
<Badge color="#6366f1" dot>Indigo</Badge>
<Badge color="#f59e0b" dot>Ambre</Badge>
<Badge color="#10b981" dot>Émeraude</Badge>
<Badge color="#3b82f6" dot>Bleu</Badge>
<Badge color="#ec4899" dot>Rose</Badge>
<Badge color="#ef4444" dot>Rouge</Badge>
<Badge color="#8b5cf6" dot>Violet</Badge>
<Badge color="#14b8a6" dot>Teal</Badge>
</PreviewBlock>
<PreviewBlock title="StatusBadge">
<StatusBadge status="active" />
<StatusBadge status="inactive" />
<StatusBadge status="pending" />
<StatusBadge status="verified" />
<StatusBadge status="unverified" />
<StatusBadge status="admin" />
<StatusBadge status="user" />
</PreviewBlock>
<PreviewBlock title="Card — variants">
{['default', 'elevated', 'outline', 'success', 'info', 'warning', 'danger'].map(v => (
<Card key={v} variant={v} padding="md" className="min-w-[140px]">
<span className="text-sm font-medium text-black dark:text-white">{v}</span>
</Card>
))}
</PreviewBlock>
<PreviewBlock title="StatCard">
<StatCard
title="Utilisateurs"
value="1 234"
change="+42 ce mois"
changeType="increase"
icon={UserCircle02Icon}
color="text-blue-700"
bgColor="bg-blue-700/10"
className="w-56"
/>
<StatCard
title="Revenus"
value="8 400 $"
change="-120 ce mois"
changeType="decrease"
icon={UserCircle02Icon}
color="text-red-700"
bgColor="bg-red-700/10"
className="w-56"
/>
<StatCard
title="Chargement"
value="..."
icon={UserCircle02Icon}
color="text-neutral-400"
bgColor="bg-neutral-400/10"
loading
className="w-56"
/>
</PreviewBlock>
<PreviewBlock title="Input">
<div className="w-72 flex flex-col gap-3">
<Input label="Champ normal" placeholder="Valeur..." value="" onChange={() => {}} />
<Input label="Avec description" placeholder="Valeur..." value="" description="Texte d'aide sous le champ." onChange={() => {}} />
<Input label="Avec erreur" placeholder="Valeur..." value="" error="Ce champ est invalide." onChange={() => {}} />
<Input label="Désactivé" placeholder="Valeur..." value="Valeur fixe" disabled onChange={() => {}} />
<Input label="Requis" placeholder="Valeur..." value="" required onChange={() => {}} />
</div>
</PreviewBlock>
<PreviewBlock title="Select">
<div className="w-72 flex flex-col gap-3">
<Select
label="Sélection normale"
value="option1"
options={[{ value: 'option1', label: 'Option 1' }, { value: 'option2', label: 'Option 2' }]}
onChange={() => {}}
/>
<Select
label="Avec erreur"
value=""
options={[{ value: 'option1', label: 'Option 1' }]}
error="Veuillez choisir une option."
onChange={() => {}}
/>
<Select
label="Désactivé"
value="option1"
options={[{ value: 'option1', label: 'Option 1' }]}
disabled
onChange={() => {}}
/>
</div>
</PreviewBlock>
<PreviewBlock title="Textarea">
<div className="w-72 flex flex-col gap-3">
<Textarea label="Zone de texte" placeholder="Entrer du texte..." value="" rows={3} onChange={() => {}} />
<Textarea label="Avec erreur" placeholder="..." value="" error="Ce champ est requis." rows={2} onChange={() => {}} />
<Textarea label="Désactivé" value="Texte fixe" disabled rows={2} onChange={() => {}} />
</div>
</PreviewBlock>
<PreviewBlock title="Switch">
<div className="w-72 flex flex-col gap-1 border border-neutral-200 dark:border-neutral-800 rounded-xl overflow-hidden">
<Switch label="Désactivé" description="Ce switch est off" checked={false} onChange={() => {}} />
<Switch label="Activé" description="Ce switch est on" checked={true} onChange={() => {}} />
<Switch label="Désactivé (disabled)" checked={false} disabled onChange={() => {}} />
</div>
</PreviewBlock>
<PreviewBlock title="TagInput">
<div className="w-72 flex flex-col gap-3">
<TagInputDemo label="Rôles" description="Sélectionnez un ou plusieurs rôles." />
<TagInputDemo
label="Avec pastilles colorées"
renderTag={(opt, onRemove) => (
<Badge key={opt.value} color={opt.color} dot onRemove={onRemove}>{opt.label}</Badge>
)}
/>
<TagInputDemo label="Avec erreur" error="Ce champ est requis." />
</div>
</PreviewBlock>
<PreviewBlock title="Loading">
<Loading />
</PreviewBlock>
<PreviewBlock title="BlockEditor">
<BlockEditorDemo />
</PreviewBlock>
</div>
);
}
function BlockEditorDemo() {
const [blocks, setBlocks] = useState([
{ id: 'demo-1', type: 'heading_1', content: [{ type: 'text', text: 'Bienvenue dans BlockEditor' }] },
{ id: 'demo-2', type: 'paragraph', content: [
{ type: 'text', text: "Tapez " },
{ type: 'text', text: "'/'", marks: [{ type: 'code' }] },
{ type: 'text', text: ' pour ouvrir le menu, ou ' },
{ type: 'text', text: 'sélectionnez', marks: [{ type: 'bold' }] },
{ type: 'text', text: ' pour ' },
{ type: 'text', text: 'mettre en forme', marks: [{ type: 'italic' }, { type: 'color', color: 'blue' }] },
{ type: 'text', text: '.' },
] },
{ id: 'demo-3', type: 'checklist', checked: true, content: [{ type: 'text', text: 'Format inline (gras, italique, couleur, lien)' }] },
{ id: 'demo-4', type: 'checklist', checked: false, content: [{ type: 'text', text: 'Bloc image (URL uniquement en Phase 2)' }] },
{ id: 'demo-5', type: 'bullet_item', content: [{ type: 'text', text: 'Glissez la poignée ⋮⋮ pour réordonner' }] },
{ id: 'demo-6', type: 'paragraph', content: [] },
]);
return (
<div className="w-full flex flex-col gap-4">
<BlockEditor value={blocks} onChange={setBlocks} />
<details className="text-xs text-neutral-500 dark:text-neutral-400">
<summary className="cursor-pointer select-none">Aperçu JSON</summary>
<pre className="mt-2 p-3 rounded-lg bg-neutral-100 dark:bg-neutral-800/60 overflow-x-auto text-[11px] leading-relaxed">
{JSON.stringify(blocks, null, 2)}
</pre>
</details>
</div>
);
}
@@ -0,0 +1,23 @@
'use client';
import { registerPage } from '../registry.js';
import ComponentsPage from './ComponentsPage.client.js';
import IconsPage from './IconsPage.client.js';
function DevkitPage({ params, devkitEnabled }) {
if (!devkitEnabled) {
return (
<div className="flex items-center justify-center py-24 text-neutral-400 dark:text-neutral-600 text-sm">
DevKit désactivé. Définir <code className="mx-1 font-mono bg-neutral-100 dark:bg-neutral-800 px-1 rounded">ZEN_DEVKIT=true</code> pour activer.
</div>
);
}
const subPage = params?.[1] || 'components';
if (subPage === 'icons') return <IconsPage />;
return <ComponentsPage />;
}
export default DevkitPage;
registerPage({ slug: 'devkit', title: 'DevKit', Component: DevkitPage });
@@ -0,0 +1,151 @@
'use client';
import { useState, useMemo } from 'react';
import * as Icons from '@zen/core/shared/icons';
import { useToast } from '@zen/core/toast';
import AdminHeader from '../components/AdminHeader.js';
const ALL_ICONS = Object.entries(Icons);
export default function IconsPage() {
const [query, setQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState(null);
const toast = useToast();
const categories = useMemo(() => {
const map = new Map();
for (const [, Icon] of ALL_ICONS) {
const cat = Icon.category;
if (!cat) continue;
if (!map.has(cat)) map.set(cat, { count: 0, FirstIcon: Icon });
const entry = map.get(cat);
entry.count += 1;
if (Icon.categoryIcon) entry.FirstIcon = Icon;
}
return Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
}, []);
const filtered = useMemo(() => {
let list = ALL_ICONS;
if (selectedCategory) list = list.filter(([, Icon]) => Icon.category === selectedCategory);
if (!query.trim()) return list;
const q = query.trim().toLowerCase();
return list.filter(([name, Icon]) =>
name.toLowerCase().includes(q) ||
Icon.keywords?.some(k => k.toLowerCase().includes(q))
);
}, [query, selectedCategory]);
const handleCopy = (name, e) => {
if (e.shiftKey) {
navigator.clipboard.writeText(`<${name} className="w-5 h-5" />`);
toast.success(`JSX de ${name} copié`);
} else {
navigator.clipboard.writeText(name);
toast.success(`${name} copié`);
}
};
const hasSidebar = categories.length > 0;
return (
<div className="flex flex-col gap-6">
<AdminHeader
title="Icônes"
description={`${ALL_ICONS.length} icônes disponibles`}
/>
<div className={`flex gap-4 items-start ${hasSidebar ? 'flex-col sm:flex-row' : ''}`}>
<div className="flex-1 min-w-0 flex flex-col gap-6">
<div className="relative">
<input
type="text"
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Rechercher une icône..."
className="w-full rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 px-4 py-2.5 text-sm text-neutral-900 dark:text-white placeholder-neutral-400 dark:placeholder-neutral-500 focus:outline-none focus:ring-2 focus:ring-blue-700/40"
/>
{query && (
<button
onClick={() => setQuery('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-200 text-lg leading-none"
>
×
</button>
)}
</div>
{filtered.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 text-center py-12">
Aucune icône trouvée pour &ldquo;{query}&rdquo;
</p>
) : (
<div className={`grid gap-2 ${
hasSidebar
? 'grid-cols-4 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 2xl:grid-cols-[repeat(13,minmax(0,1fr))]'
: 'grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 xl:grid-cols-12 2xl:grid-cols-[repeat(16,minmax(0,1fr))]'
}`}>
{filtered.map(([name, IconComponent]) => (
<button
key={name}
onClick={(e) => handleCopy(name, e)}
title={`${name.replace('Icon', '')} · Shift: JSX`}
className="aspect-square flex flex-col items-center justify-between rounded-lg border border-neutral-200 dark:border-neutral-800 bg-neutral-100 dark:bg-neutral-900 hover:border-blue-500 dark:hover:border-blue-500 transition-colors duration-100 group cursor-pointer px-1 py-2 overflow-hidden"
>
<div className="flex-1 flex items-center justify-center w-full">
<IconComponent className="w-7 h-7 text-black dark:text-white" />
</div>
<span className="shrink-0 text-[9px] text-neutral-500 dark:text-neutral-400 leading-none text-center truncate group-hover:text-neutral-600 dark:group-hover:text-neutral-300 w-full px-1">
{name.replace('Icon', '')}
</span>
</button>
))}
</div>
)}
</div>
{hasSidebar && (
<div className="w-full sm:w-[250px] sm:shrink-0 sm:sticky sm:top-4 rounded-lg border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 overflow-hidden order-first sm:order-none">
<div className="px-3 py-2.5 border-b border-neutral-200 dark:border-neutral-800">
<span className="text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wide">Catégories</span>
</div>
<div className="py-1 flex sm:flex-col flex-row flex-wrap sm:flex-nowrap gap-0">
<button
onClick={() => setSelectedCategory(null)}
className={`cursor-pointer flex items-center justify-between px-3 py-1.5 text-sm rounded-md m-1 sm:mx-1 sm:my-0 sm:w-[calc(100%-8px)] transition-colors ${
selectedCategory === null
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-white'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white'
}`}
>
<span className="truncate">Tout</span>
<span className="text-xs tabular-nums shrink-0 ml-2 text-neutral-400 dark:text-neutral-500">
{ALL_ICONS.length}
</span>
</button>
{categories.map(([cat, { count, FirstIcon }]) => (
<button
key={cat}
onClick={() => setSelectedCategory(cat)}
className={`cursor-pointer flex items-center justify-between px-3 py-1.5 text-sm rounded-md m-1 sm:mx-1 sm:my-0 sm:w-[calc(100%-8px)] transition-colors ${
selectedCategory === cat
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-white'
: 'text-neutral-600 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800 hover:text-neutral-900 dark:hover:text-white'
}`}
>
<span className="flex items-center gap-2 min-w-0">
<FirstIcon className="w-4 h-4 shrink-0 text-neutral-500 dark:text-neutral-400" />
<span className="truncate">{cat}</span>
</span>
<span className="text-xs tabular-nums shrink-0 ml-2 text-neutral-400 dark:text-neutral-500">
{count}
</span>
</button>
))}
</div>
</div>
)}
</div>
</div>
);
}
+7 -3
View File
@@ -1,16 +1,20 @@
/**
* Zen Admin — barrel serveur.
* Zen Admin — barrel serveur (Next.js-free).
*
* - Gardes d'accès : protectAdmin, isAdmin.
* - Navigation : buildNavigationSections.
* - Registre d'extensions : registerWidget, registerWidgetFetcher, registerNavItem,
* registerNavSection, registerPage (import une seule fois depuis le layout
* racine de l'app consommatrice pour que les side effects s'exécutent).
*
* Ne re-exporte PAS protect.js — ce fichier importe `next/navigation` et
* `@zen/core/features/auth/actions` (qui importe `next/headers`) au niveau
* top-level. Ce barrel est importé par des modules externes pendant leur
* register(), avant que Next.js ait activé ses alias de modules.
* Importer les gardes explicitement via @zen/core/features/admin/protect.
*
* Client components sous @zen/core/features/admin/components.
*/
export { protectAdmin, isAdmin } from './protect.js';
export { buildNavigationSections } from './navigation.js';
export {
registerWidget,
+36 -5
View File
@@ -4,22 +4,38 @@ import {
getNavSections,
getNavItems,
} from './registry.js';
import { isDevkitEnabled } from '../../shared/lib/appConfig.js';
import { PERMISSIONS } from '@zen/core/users/constants';
// Sections et items core — enregistrés à l'import de ce module.
registerNavSection({ id: 'dashboard', title: 'Tableau de bord', icon: 'DashboardSquare03Icon', order: 10 });
registerNavSection({ id: 'system', title: 'Utilisateurs', icon: 'UserMultiple02Icon', order: 20 });
registerNavSection({ id: 'system', title: 'Utilisateurs', icon: 'UserMultiple02Icon', order: 800 });
registerNavItem({ id: 'dashboard', label: 'Tableau de bord', icon: 'DashboardSquare03Icon', href: '/admin/dashboard', sectionId: 'dashboard', order: 10 });
registerNavItem({ id: 'users', label: 'Utilisateurs', icon: 'UserMultiple02Icon', href: '/admin/users', sectionId: 'system', order: 10 });
registerNavItem({ id: 'roles', label: 'Rôles', icon: 'Crown03Icon', href: '/admin/roles', sectionId: 'system', order: 20 });
registerNavItem({ id: 'users', label: 'Utilisateurs', icon: 'UserMultiple02Icon', href: '/admin/users', sectionId: 'system', order: 10, permission: PERMISSIONS.USERS_VIEW });
registerNavItem({ id: 'roles', label: 'Rôles', icon: 'Crown03Icon', href: '/admin/roles', sectionId: 'system', order: 20, permission: PERMISSIONS.ROLES_VIEW });
registerNavItem({ id: 'settings', label: 'Paramètres', icon: 'Settings02Icon', href: '/admin/settings', position: 'bottom', order: 10 });
if (isDevkitEnabled()) {
registerNavSection({ id: 'devkit', title: 'DevKit', icon: 'Wrench01Icon', order: 900 });
registerNavItem({ id: 'devkit-components', label: 'Composants', icon: 'Layers01Icon', href: '/admin/devkit/components', sectionId: 'devkit', order: 10 });
registerNavItem({ id: 'devkit-icons', label: 'Icônes', icon: 'Image01Icon', href: '/admin/devkit/icons', sectionId: 'devkit', order: 20 });
}
/**
* Build sections for AdminSidebar. Items are sérialisables (pas de composants),
* icônes en chaînes résolues côté client.
* @param {string} pathname
* @param {string[]} [userPermissions] - Permissions de l'utilisateur connecté ; les items
* avec un champ `permission` sont masqués si la permission n'est pas présente.
*/
export function buildNavigationSections(pathname) {
export function buildNavigationSections(pathname, userPermissions = []) {
const sections = getNavSections();
const items = getNavItems();
const items = getNavItems().filter(item => {
if (item.position === 'bottom') return false;
if (item.permission && !userPermissions.includes(item.permission)) return false;
return true;
});
const bySection = new Map();
for (const item of items) {
@@ -37,3 +53,18 @@ export function buildNavigationSections(pathname) {
.filter(s => bySection.has(s.id))
.map(s => ({ id: s.id, title: s.title, icon: s.icon, items: bySection.get(s.id) }));
}
/**
* Build the list of bottom-pinned nav items for AdminSidebar.
*/
export function buildBottomNavItems(pathname) {
return getNavItems()
.filter(item => item.position === 'bottom')
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0))
.map(item => ({
name: item.label,
href: item.href,
icon: item.icon,
current: pathname === item.href || pathname.startsWith(item.href + '/'),
}));
}
@@ -0,0 +1,87 @@
'use client';
import { registerPage } from '../registry.js';
import { useState, useEffect, useRef } from 'react';
import { useSearchParams } from 'next/navigation';
import { Card } from '@zen/core/shared/components';
const ConfirmEmailChangePage = () => {
const searchParams = useSearchParams();
const token = searchParams.get('token');
const [isLoading, setIsLoading] = useState(true);
const [success, setSuccess] = useState('');
const [error, setError] = useState('');
const hasConfirmedRef = useRef(false);
useEffect(() => {
if (!token) {
setError('Lien de confirmation invalide.');
setIsLoading(false);
return;
}
if (hasConfirmedRef.current) return;
hasConfirmedRef.current = true;
fetch(`/zen/api/users/email/confirm?token=${encodeURIComponent(token)}`, { credentials: 'include' })
.then(res => res.json().then(data => ({ ok: res.ok, data })))
.then(({ ok, data }) => {
if (ok && data.success) {
setSuccess('Votre adresse courriel a été mise à jour avec succès.');
setTimeout(() => { window.location.href = '/admin/profile'; }, 3000);
} else {
setError(data.error || data.message || 'Lien de confirmation invalide ou expiré.');
}
})
.catch(() => setError('Une erreur inattendue est survenue.'))
.finally(() => setIsLoading(false));
}, [token]);
return (
<div className="flex items-center justify-center min-h-64">
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Confirmation du courriel
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Validation de votre nouvelle adresse courriel...
</p>
</div>
{isLoading && (
<div className="flex flex-col items-center py-10">
<div className="w-12 h-12 border-4 border-neutral-300 border-t-neutral-900 rounded-full animate-spin dark:border-neutral-700/50 dark:border-t-white" />
<p className="mt-5 text-neutral-600 dark:text-neutral-400 text-sm">Confirmation en cours...</p>
</div>
)}
{success && (
<>
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0" />
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
<p className="text-center text-neutral-600 dark:text-neutral-400 text-sm">
Redirection vers votre profil...
</p>
</>
)}
{error && (
<div className="mb-4 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 space-x-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>
)}
</Card>
</div>
);
};
export default ConfirmEmailChangePage;
registerPage({ slug: 'confirm-email-change', title: 'Confirmation du courriel', Component: ConfirmEmailChangePage });
@@ -3,9 +3,10 @@
import { getWidgets, registerPage } from '../registry.js';
import AdminHeader from '../components/AdminHeader.js';
export default function DashboardPage({ stats }) {
export default function DashboardPage({ user, stats }) {
const loading = stats === null || stats === undefined;
const widgets = getWidgets();
const permissions = user?.permissions ?? [];
const widgets = getWidgets().filter(w => !w.permission || permissions.includes(w.permission));
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
+403 -204
View File
@@ -1,142 +1,210 @@
'use client';
import { registerPage } from '../registry.js';
import React, { useState, useEffect, useRef } from 'react';
import { Card, Input, Button } from '@zen/core/shared/components';
import { useState, useEffect, useRef } from 'react';
import { Card, Input, Button, TabNav, UserAvatar, Modal, PasswordStrengthIndicator } from '@zen/core/shared/components';
import { SmartPhone01Icon, ComputerIcon } from '@zen/core/shared/icons';
import { useToast } from '@zen/core/toast';
import AdminHeader from '../components/AdminHeader.js';
const TABS = [
{ id: 'informations', label: 'Informations' },
{ id: 'photo', label: 'Photo de profil' },
{ id: 'securite', label: 'Sécurité' },
{ id: 'sessions', label: 'Sessions' },
];
const ProfilePage = ({ user: initialUser }) => {
const toast = useToast();
const fileInputRef = useRef(null);
const [activeTab, setActiveTab] = useState('informations');
const [user, setUser] = useState(initialUser);
const [loading, setLoading] = useState(false);
const [uploadingImage, setUploadingImage] = useState(false);
const [imagePreview, setImagePreview] = useState(null);
const fileInputRef = useRef(null);
const [formData, setFormData] = useState({
name: initialUser?.name || ''
});
const [formData, setFormData] = useState({ name: initialUser?.name || '' });
// Helper function to get image URL from storage key
const getImageUrl = (imageKey) => {
if (!imageKey) return null;
return `/zen/api/storage/${imageKey}`;
};
const [emailModalOpen, setEmailModalOpen] = useState(false);
const [emailFormData, setEmailFormData] = useState({ newEmail: '', password: '' });
const [emailLoading, setEmailLoading] = useState(false);
const [pendingEmailMessage, setPendingEmailMessage] = useState('');
const [passwordForm, setPasswordForm] = useState({ currentPassword: '', newPassword: '', confirmPassword: '' });
const [passwordLoading, setPasswordLoading] = useState(false);
const [sessions, setSessions] = useState([]);
const [sessionsLoading, setSessionsLoading] = useState(false);
const [currentSessionId, setCurrentSessionId] = useState(null);
useEffect(() => {
if (initialUser) {
setFormData({
name: initialUser.name || ''
});
setImagePreview(getImageUrl(initialUser.image));
}
if (initialUser) setFormData({ name: initialUser.name || '' });
}, [initialUser]);
const handleChange = (value) => {
setFormData(prev => ({
...prev,
name: value
}));
useEffect(() => {
if (activeTab !== 'sessions') return;
setSessionsLoading(true);
fetch('/zen/api/users/profile/sessions', { credentials: 'include' })
.then(r => r.json())
.then(data => {
if (data.sessions) {
setSessions(data.sessions);
setCurrentSessionId(data.currentSessionId);
}
})
.catch(() => toast.error('Impossible de charger les sessions'))
.finally(() => setSessionsLoading(false));
}, [activeTab]);
const handleRevokeSession = async (sessionId) => {
try {
const response = await fetch(`/zen/api/users/profile/sessions/${sessionId}`, {
method: 'DELETE',
credentials: 'include',
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || 'Impossible de révoquer la session');
if (data.isCurrent) {
window.location.href = '/admin/login';
} else {
setSessions(prev => prev.filter(s => s.id !== sessionId));
}
} catch (error) {
toast.error(error.message || 'Impossible de révoquer la session');
}
};
const handleRevokeAllSessions = async () => {
if (!confirm('Révoquer toutes les sessions ? Vous serez déconnecté de tous les appareils.')) return;
try {
const response = await fetch('/zen/api/users/profile/sessions', {
method: 'DELETE',
credentials: 'include',
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || 'Impossible de révoquer les sessions');
window.location.href = '/admin/login';
} catch (error) {
toast.error(error.message || 'Impossible de révoquer les sessions');
}
};
const hasChanges = formData.name !== user?.name;
const validatePassword = (password) => {
if (!password || password.length < 8) return 'Le mot de passe doit contenir au moins 8 caractères';
if (password.length > 128) return 'Le mot de passe doit contenir 128 caractères ou moins';
if (!/[A-Z]/.test(password)) return 'Le mot de passe doit contenir au moins une majuscule';
if (!/[a-z]/.test(password)) return 'Le mot de passe doit contenir au moins une minuscule';
if (!/\d/.test(password)) return 'Le mot de passe doit contenir au moins un chiffre';
return null;
};
const handlePasswordSubmit = async (e) => {
e.preventDefault();
if (!passwordForm.currentPassword) { toast.error('Le mot de passe actuel est requis'); return; }
const pwdError = validatePassword(passwordForm.newPassword);
if (pwdError) { toast.error(pwdError); return; }
if (passwordForm.newPassword !== passwordForm.confirmPassword) { toast.error('Les mots de passe ne correspondent pas'); return; }
setPasswordLoading(true);
try {
const response = await fetch('/zen/api/users/profile/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ currentPassword: passwordForm.currentPassword, newPassword: passwordForm.newPassword }),
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || data.message || 'Échec de la mise à jour du mot de passe');
toast.success(data.message);
setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' });
} catch (error) {
toast.error(error.message || 'Échec de la mise à jour du mot de passe');
} finally {
setPasswordLoading(false);
}
};
const handleEmailSubmit = async () => {
if (!emailFormData.newEmail.trim()) {
toast.error('Le nouveau courriel est requis');
return;
}
if (!emailFormData.password) {
toast.error('Le mot de passe est requis');
return;
}
setEmailLoading(true);
try {
const response = await fetch('/zen/api/users/profile/email', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ newEmail: emailFormData.newEmail.trim(), password: emailFormData.password }),
});
const data = await response.json();
if (!response.ok || !data.success) throw new Error(data.error || data.message || 'Échec de la demande de changement de courriel');
toast.success(data.message);
setPendingEmailMessage(data.message);
setEmailModalOpen(false);
setEmailFormData({ newEmail: '', password: '' });
} catch (error) {
toast.error(error.message || 'Échec de la demande de changement de courriel');
} finally {
setEmailLoading(false);
}
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.name.trim()) {
toast.error('Le nom est requis');
return;
}
setLoading(true);
try {
const response = await fetch('/zen/api/users/profile', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: formData.name.trim()
})
body: JSON.stringify({ name: formData.name.trim() }),
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.error || 'Échec de la mise à jour du profil');
}
if (!response.ok || !data.success) throw new Error(data.error || 'Échec de la mise à jour du profil');
setUser(data.user);
toast.success('Profil mis à jour avec succès');
// Refresh the page to update the user data in the header
setTimeout(() => {
window.location.reload();
}, 1000);
setTimeout(() => window.location.reload(), 1000);
} catch (error) {
console.error('Error updating profile:', error);
toast.error(error.message || 'Échec de la mise à jour du profil');
} finally {
setLoading(false);
}
};
const handleReset = () => {
setFormData({
name: user?.name || ''
});
};
const handleImageSelect = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
// Validate file type
if (!file.type.startsWith('image/')) {
toast.error('Veuillez sélectionner un fichier image');
return;
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
toast.error("L'image doit faire moins de 5MB");
return;
}
// Show preview
const reader = new FileReader();
reader.onloadend = () => {
setImagePreview(reader.result);
};
reader.readAsDataURL(file);
// Upload image
setUploadingImage(true);
try {
const formData = new FormData();
formData.append('file', file);
const body = new FormData();
body.append('file', file);
const response = await fetch('/zen/api/users/profile/picture', {
method: 'POST',
credentials: 'include',
body: formData
body,
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.message || 'Échec du téléchargement de l\'image');
}
if (!response.ok || !data.success) throw new Error(data.message || "Échec du téléchargement de l'image");
setUser(data.user);
setImagePreview(getImageUrl(data.user.image));
toast.success('Photo de profil mise à jour avec succès');
} catch (error) {
console.error('Error uploading image:', error);
toast.error(error.message || 'Échec du téléchargement de l\'image');
// Revert preview on error
setImagePreview(getImageUrl(user?.image));
toast.error(error.message || "Échec du téléchargement de l'image");
} finally {
setUploadingImage(false);
}
@@ -144,80 +212,248 @@ const ProfilePage = ({ user: initialUser }) => {
const handleRemoveImage = async () => {
if (!user?.image) return;
setUploadingImage(true);
try {
const response = await fetch('/zen/api/users/profile/picture', {
method: 'DELETE',
credentials: 'include'
credentials: 'include',
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.message || 'Échec de la suppression de l\'image');
}
if (!response.ok || !data.success) throw new Error(data.message || "Échec de la suppression de l'image");
setUser(data.user);
setImagePreview(null);
toast.success('Photo de profil supprimée avec succès');
} catch (error) {
console.error('Error removing image:', error);
toast.error(error.message || 'Échec de la suppression de l\'image');
toast.error(error.message || "Échec de la suppression de l'image");
} finally {
setUploadingImage(false);
}
};
const getUserInitials = (name) => {
if (!name) return 'U';
return name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
const hasChanges = formData.name !== user?.name;
return (
<>
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<AdminHeader title="Mon profil" description="Gérez les informations de votre compte" />
{/* Content */}
<div className="flex flex-col gap-6">
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
Photo de profil
</h2>
<div className="flex items-center gap-6">
<div className="relative">
{imagePreview ? (
<img
src={imagePreview}
alt="Profile"
className="w-24 h-24 rounded-full object-cover border-2 border-neutral-300 dark:border-neutral-700"
<div className="flex flex-col gap-6 items-start">
<TabNav tabs={TABS} activeTab={activeTab} onTabChange={setActiveTab} />
{activeTab === 'informations' && (
<Card
title="Informations personnelles"
className="w-full lg:max-w-4/5"
footer={
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => setFormData({ name: user?.name || '' })}
disabled={loading || !hasChanges}
>
Réinitialiser
</Button>
<Button
type="submit"
variant="primary"
disabled={loading || !hasChanges}
loading={loading}
onClick={handleSubmit}
>
Enregistrer les modifications
</Button>
</div>
}
>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Nom complet"
type="text"
value={formData.name}
onChange={(value) => setFormData(prev => ({ ...prev, name: value }))}
placeholder="Entrez votre nom complet"
required
disabled={loading}
/>
<div className="flex flex-col gap-1">
<Input
label="Courriel"
type="email"
value={user?.email || ''}
disabled
readOnly
description={pendingEmailMessage || undefined}
/>
) : (
<div className="w-24 h-24 bg-gradient-to-br from-neutral-200 to-neutral-300 dark:from-neutral-800 dark:to-neutral-700 rounded-full flex items-center justify-center border-2 border-neutral-300 dark:border-neutral-700">
<span className="text-neutral-700 dark:text-white font-semibold text-2xl">
{getUserInitials(user?.name)}
</span>
</div>
)}
{uploadingImage && (
<div className="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center">
<div className="w-8 h-8 border-2 border-white border-t-transparent rounded-full animate-spin"></div>
</div>
)}
{!pendingEmailMessage && (
<button
type="button"
onClick={() => setEmailModalOpen(true)}
className="text-xs text-neutral-500 hover:text-neutral-800 dark:hover:text-neutral-200 underline self-start transition-colors"
>
Modifier le courriel
</button>
)}
</div>
<Input
label="Compte créé"
type="text"
value={user?.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
}) : 'N/D'}
disabled
readOnly
/>
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-neutral-400">
Téléchargez une nouvelle photo de profil. Taille max 5MB.
</p>
<div className="flex gap-2">
</Card>
)}
{activeTab === 'securite' && (
<Card
title="Changer le mot de passe"
className="w-full lg:max-w-4/5"
footer={
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => setPasswordForm({ currentPassword: '', newPassword: '', confirmPassword: '' })}
disabled={passwordLoading}
>
Réinitialiser
</Button>
<Button
type="submit"
form="password-form"
variant="primary"
disabled={passwordLoading}
loading={passwordLoading}
>
Enregistrer le mot de passe
</Button>
</div>
}
>
<form id="password-form" onSubmit={handlePasswordSubmit} className="flex flex-col gap-4">
<Input
label="Mot de passe actuel"
type="password"
value={passwordForm.currentPassword}
onChange={(value) => setPasswordForm(prev => ({ ...prev, currentPassword: value }))}
placeholder="••••••••"
autoComplete="current-password"
required
disabled={passwordLoading}
/>
<div>
<Input
label="Nouveau mot de passe"
type="password"
value={passwordForm.newPassword}
onChange={(value) => setPasswordForm(prev => ({ ...prev, newPassword: value }))}
placeholder="••••••••"
autoComplete="new-password"
minLength="8"
maxLength="128"
required
disabled={passwordLoading}
/>
<PasswordStrengthIndicator password={passwordForm.newPassword} showRequirements={true} />
</div>
<Input
label="Confirmer le nouveau mot de passe"
type="password"
value={passwordForm.confirmPassword}
onChange={(value) => setPasswordForm(prev => ({ ...prev, confirmPassword: value }))}
placeholder="••••••••"
autoComplete="new-password"
minLength="8"
maxLength="128"
required
disabled={passwordLoading}
/>
</form>
</Card>
)}
{activeTab === 'sessions' && (
<Card
title="Sessions actives"
className="w-full lg:max-w-4/5"
footer={
<div className="flex justify-end">
<Button
type="button"
variant="danger"
onClick={handleRevokeAllSessions}
disabled={sessionsLoading || sessions.length === 0}
>
Révoquer toutes les sessions
</Button>
</div>
}
>
{sessionsLoading ? (
<div className="flex justify-center py-8">
<div className="w-6 h-6 border-2 border-neutral-300 dark:border-neutral-700 border-t-neutral-900 dark:border-t-neutral-100 rounded-full animate-spin" />
</div>
) : sessions.length === 0 ? (
<p className="text-sm text-neutral-500 dark:text-neutral-400 py-4 text-center">Aucune session active</p>
) : (
<div className="flex flex-col divide-y divide-neutral-100 dark:divide-neutral-800">
{sessions.map(session => (
<div key={session.id} className="flex items-center justify-between gap-4 py-3 first:pt-0 last:pb-0">
<div className="flex items-center gap-3">
{session.device === 'mobile' ? (
<SmartPhone01Icon className="w-8 h-8 text-neutral-500 dark:text-neutral-400 shrink-0" />
) : (
<ComputerIcon className="w-8 h-8 text-neutral-500 dark:text-neutral-400 shrink-0" />
)}
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
{session.browser} · {session.os}
</span>
{session.id === currentSessionId && (
<span className="text-xs bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400 px-2 py-0.5 rounded-full font-medium">
Session actuelle
</span>
)}
</div>
<span className="text-xs text-neutral-500 dark:text-neutral-400">
{session.ip_address || 'IP inconnue'} · {new Date(session.created_at).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })}
</span>
</div>
</div>
<Button
type="button"
variant="danger"
size="sm"
onClick={() => handleRevokeSession(session.id)}
>
Révoquer
</Button>
</div>
))}
</div>
)}
</Card>
)}
{activeTab === 'photo' && (
<Card title="Photo de profil" className="w-full lg:max-w-4/5">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Téléchargez une nouvelle photo de profil. Taille max 5MB.
</p>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-4 sm:gap-6">
<div className="relative shrink-0">
<UserAvatar user={user} size="xl" />
{uploadingImage && (
<div className="absolute inset-0 bg-black/50 rounded-full flex items-center justify-center">
<div className="w-8 h-8 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
<div className="flex flex-wrap gap-2">
<input
ref={fileInputRef}
type="file"
@@ -233,10 +469,10 @@ const ProfilePage = ({ user: initialUser }) => {
>
{uploadingImage ? 'Téléchargement...' : 'Télécharger une image'}
</Button>
{imagePreview && (
{user?.image && (
<Button
type="button"
variant="secondary"
variant="danger"
onClick={handleRemoveImage}
disabled={uploadingImage}
>
@@ -245,78 +481,41 @@ const ProfilePage = ({ user: initialUser }) => {
)}
</div>
</div>
</div>
</div>
</Card>
<Card>
<form onSubmit={handleSubmit} className="space-y-6">
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
Informations personnelles
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Nom complet"
type="text"
value={formData.name}
onChange={handleChange}
placeholder="Entrez votre nom complet"
required
disabled={loading}
/>
<Input
label="Courriel"
name="email"
type="email"
value={user?.email || ''}
disabled
readOnly
description="L'email ne peut pas être modifié"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Compte créé"
name="createdAt"
type="text"
value={user?.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric'
}) : 'N/D'}
disabled
readOnly
/>
</div>
</div>
{/* Action Buttons */}
<div className="flex items-center justify-end gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700/50">
<Button
type="button"
variant="secondary"
onClick={handleReset}
disabled={loading || !hasChanges}
>
Réinitialiser
</Button>
<Button
type="submit"
variant="primary"
disabled={loading || !hasChanges}
loading={loading}
>
{loading ? 'Enregistrement...' : 'Enregistrer les modifications'}
</Button>
</div>
</form>
</Card>
</Card>
)}
</div>
</div>
<Modal
isOpen={emailModalOpen}
onClose={() => { setEmailModalOpen(false); setEmailFormData({ newEmail: '', password: '' }); }}
title="Modifier le courriel"
onSubmit={handleEmailSubmit}
submitLabel="Envoyer la confirmation"
loading={emailLoading}
size="sm"
>
<div className="flex flex-col gap-4">
<Input
label="Nouveau courriel"
type="email"
value={emailFormData.newEmail}
onChange={(value) => setEmailFormData(prev => ({ ...prev, newEmail: value }))}
placeholder="nouvelle@adresse.com"
required
disabled={emailLoading}
/>
<Input
label="Mot de passe actuel"
type="password"
value={emailFormData.password}
onChange={(value) => setEmailFormData(prev => ({ ...prev, password: value }))}
placeholder="Votre mot de passe"
required
disabled={emailLoading}
/>
</div>
</Modal>
</>
);
};
+27 -24
View File
@@ -8,7 +8,7 @@ import { useToast } from '@zen/core/toast';
import AdminHeader from '../components/AdminHeader.js';
import RoleEditModal from '../components/RoleEditModal.client.js';
const RolesPageClient = () => {
const RolesPageClient = ({ canManage }) => {
const toast = useToast();
const [roles, setRoles] = useState([]);
const [loading, setLoading] = useState(true);
@@ -73,35 +73,33 @@ const RolesPageClient = () => {
render: (role) => role.is_system ? <Badge variant="default" size="sm">système</Badge> : null,
skeleton: { height: 'h-4', width: '60px' },
},
{
...(canManage ? [{
key: 'actions',
label: '',
sortable: false,
noWrap: true,
align: 'right',
render: (role) => (
<div className="flex items-center gap-2">
<div className="flex items-center justify-end gap-2">
<Button
variant="secondary"
size="sm"
onClick={() => openEdit(role.id)}
icon={<PencilEdit01Icon className="w-4 h-4" />}
icon={PencilEdit01Icon}
>
Modifier
</Button>
{!role.is_system && (
<Button
variant="danger"
size="sm"
onClick={() => handleDelete(role)}
icon={<Cancel01Icon className="w-4 h-4" />}
icon={Cancel01Icon}
>
Supprimer
</Button>
)}
</div>
),
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
},
}] : []),
];
const fetchRoles = async () => {
@@ -163,14 +161,17 @@ const RolesPageClient = () => {
);
};
const RolesPage = () => (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<RolesPageHeader />
<RolesPageClient />
</div>
);
const RolesPage = ({ user }) => {
const canManage = user?.permissions?.includes('roles.manage');
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<RolesPageHeader canManage={canManage} />
<RolesPageClient canManage={canManage} />
</div>
);
};
const RolesPageHeader = () => {
const RolesPageHeader = ({ canManage }) => {
const [modalOpen, setModalOpen] = useState(false);
return (
@@ -178,17 +179,19 @@ const RolesPageHeader = () => {
<AdminHeader
title="Rôles"
description="Gérez les rôles et leurs permissions"
action={
<Button variant="primary" size="sm" onClick={() => setModalOpen(true)}>
action={canManage && (
<Button variant="primary" onClick={() => setModalOpen(true)}>
Nouveau rôle
</Button>
}
/>
<RoleEditModal
roleId="new"
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
)}
/>
{canManage && (
<RoleEditModal
roleId="new"
isOpen={modalOpen}
onClose={() => setModalOpen(false)}
/>
)}
</>
);
};
@@ -0,0 +1,92 @@
'use client';
import { useState, useEffect } from 'react';
import { registerPage } from '../registry.js';
import AdminHeader from '../components/AdminHeader.js';
import { Card, Input, Select, TabNav } from '@zen/core/shared/components';
import { applyTheme, getStoredTheme } from '@zen/core/themes';
const TABS = [
{ id: 'general', label: 'Général' },
{ id: 'appearance', label: 'Apparence' },
];
const THEME_OPTIONS = [
{ value: 'light', label: 'Mode clair' },
{ value: 'dark', label: 'Mode sombre' },
{ value: 'auto', label: 'Thème système' },
];
const SettingsPage = ({ appConfig = {} }) => {
const [activeTab, setActiveTab] = useState('general');
const [theme, setTheme] = useState('auto');
useEffect(() => {
setTheme(getStoredTheme());
}, []);
const handleThemeChange = (value) => {
setTheme(value);
applyTheme(value);
};
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<AdminHeader title="Paramètres" description="Configuration de votre espace ZEN" />
<div className="flex flex-col gap-6 items-start">
<TabNav tabs={TABS} activeTab={activeTab} onTabChange={setActiveTab} />
{activeTab === 'general' && (
<Card title="Informations générales" className='w-full lg:max-w-4/5'>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Nom du site"
value={appConfig.name || ''}
readOnly
disabled
/>
<Input
label="URL du site"
value={appConfig.siteUrl || ''}
readOnly
disabled
description="URL publique de votre site"
/>
<Input
label="Fuseau horaire"
value={appConfig.timezone || ''}
readOnly
disabled
/>
<Input
label="Format de date"
value={appConfig.dateFormat || ''}
readOnly
disabled
/>
</div>
</Card>
)}
{activeTab === 'appearance' && (
<Card title="Thème" className='w-full lg:max-w-4/5'>
<div className="max-w-xs">
<Select
label="Thème de l'interface"
value={theme}
onChange={handleThemeChange}
options={THEME_OPTIONS}
description="S'applique immédiatement et persiste entre les sessions"
/>
</div>
</Card>
)}
</div>
</div>
);
};
export default SettingsPage;
registerPage({ slug: 'settings', title: 'Paramètres', Component: SettingsPage });
+58 -38
View File
@@ -7,12 +7,12 @@ import { PencilEdit01Icon } from '@zen/core/shared/icons';
import { useToast } from '@zen/core/toast';
import AdminHeader from '../components/AdminHeader.js';
import UserEditModal from '../components/UserEditModal.client.js';
import UserCreateModal from '../components/UserCreateModal.client.js';
const UsersPageClient = () => {
const UsersPageClient = ({ currentUserId, refreshKey, canEdit }) => {
const toast = useToast();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [roleColorMap, setRoleColorMap] = useState({});
const [editingUserId, setEditingUserId] = useState(null);
const [pagination, setPagination] = useState({
@@ -48,12 +48,21 @@ const UsersPageClient = () => {
key: 'role',
label: 'Rôle',
sortable: true,
render: (user) => (
<Badge color={roleColorMap[user.role?.toLowerCase()]}>
{user.role}
</Badge>
),
skeleton: { height: 'h-6', width: '80px', className: 'rounded-full' },
render: (user) => {
const roles = user.roles || [];
const visible = roles.slice(0, 3);
const overflow = roles.length - 3;
return (
<div className="flex flex-wrap gap-1">
{visible.map(role => (
<Badge key={role.id} color={role.color} dot>{role.name}</Badge>
))}
{overflow > 0 && <Badge>+{overflow}</Badge>}
{roles.length === 0 && <span className="text-xs text-neutral-400"></span>}
</div>
);
},
skeleton: { height: 'h-6', width: '140px', className: 'rounded-full' },
},
{
key: 'email_verified',
@@ -69,23 +78,23 @@ const UsersPageClient = () => {
render: (user) => <RelativeDate date={user.created_at} />,
skeleton: { height: 'h-4', width: '70%' },
},
{
...(canEdit ? [{
key: 'actions',
label: '',
sortable: false,
noWrap: true,
align: 'right',
render: (user) => (
<Button
variant="secondary"
size="sm"
onClick={() => setEditingUserId(user.id)}
icon={<PencilEdit01Icon className="w-4 h-4" />}
icon={PencilEdit01Icon}
>
Modifier
</Button>
),
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' },
},
}] : []),
];
const fetchUsers = async () => {
@@ -116,22 +125,9 @@ const UsersPageClient = () => {
}
};
useEffect(() => {
fetch('/zen/api/roles', { credentials: 'include' })
.then(r => r.json())
.then(data => {
const map = {};
for (const role of data.roles || []) {
if (role.color) map[role.name.toLowerCase()] = role.color;
}
setRoleColorMap(map);
})
.catch(() => {});
}, []);
useEffect(() => {
fetchUsers();
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
}, [sortBy, sortOrder, pagination.page, pagination.limit, refreshKey]);
const handlePageChange = (newPage) => setPagination(prev => ({ ...prev, page: newPage }));
const handleLimitChange = (newLimit) => setPagination(prev => ({ ...prev, limit: newLimit, page: 1 }));
@@ -162,22 +158,46 @@ const UsersPageClient = () => {
/>
</Card>
<UserEditModal
userId={editingUserId}
isOpen={!!editingUserId}
onClose={() => setEditingUserId(null)}
onSaved={fetchUsers}
/>
{canEdit && (
<UserEditModal
userId={editingUserId}
currentUserId={currentUserId}
isOpen={!!editingUserId}
onClose={() => setEditingUserId(null)}
onSaved={fetchUsers}
/>
)}
</>
);
};
const UsersPage = () => (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<AdminHeader title="Utilisateurs" description="Gérez les comptes utilisateurs" />
<UsersPageClient />
</div>
);
const UsersPage = ({ user }) => {
const [createModalOpen, setCreateModalOpen] = useState(false);
const [refreshKey, setRefreshKey] = useState(0);
const canEdit = user?.permissions?.includes('users.manage');
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<AdminHeader
title="Utilisateurs"
description="Gérez les comptes utilisateurs"
action={canEdit && (
<Button variant="primary" onClick={() => setCreateModalOpen(true)}>
Nouvel utilisateur
</Button>
)}
/>
<UsersPageClient currentUserId={user?.id} refreshKey={refreshKey} canEdit={canEdit} />
{canEdit && (
<UserCreateModal
isOpen={createModalOpen}
onClose={() => setCreateModalOpen(false)}
onSaved={() => setRefreshKey(k => k + 1)}
/>
)}
</div>
);
};
export default UsersPage;
+22 -13
View File
@@ -7,17 +7,26 @@
* - navItem : une entrée de la sidebar admin (section optionnelle pour grouper).
* - page : un composant rendu sous /admin/<slug>.
*
* Les instances de module sont séparées entre le bundle serveur et le bundle
* client de Next.js ; c'est attendu : les fetchers vivent côté serveur, les
* Composants côté client. Les navItems et les pages sont enregistrés côté
* neutre et visibles des deux côtés.
* Les Maps sont stockées sur `globalThis` via `Symbol.for` pour survivre :
* 1. au hot-reload de Next.js dev (sinon les enregistrements disparaissent).
* 2. à la double-instanciation du fichier — l'instrumentation hook tourne en
* Node natif (require ESM), tandis que les Server Components passent par
* le bundle Turbopack/Webpack. Sans `globalThis`, les nav items poussés
* par `register-server.js` au boot ne seraient pas visibles côté Server
* Component qui rend la sidebar — la sidebar resterait vide.
*/
const widgetFetchers = new Map(); // id -> async () => data
const widgetComponents = new Map(); // id -> { Component, order }
const navItems = new Map(); // id -> { id, label, icon, href, order, sectionId }
const navSections = new Map(); // id -> { id, title, icon, order }
const pages = new Map(); // slug -> { slug, Component, title? }
const REGISTRY_KEY = Symbol.for('__ZEN_ADMIN_REGISTRY__');
if (!globalThis[REGISTRY_KEY]) {
globalThis[REGISTRY_KEY] = {
widgetFetchers: new Map(), // id -> async () => data
widgetComponents: new Map(), // id -> { Component, order, permission }
navItems: new Map(), // id -> { id, label, icon, href, order, sectionId, position, permission }
navSections: new Map(), // id -> { id, title, icon, order }
pages: new Map(), // slug -> { slug, Component, title?, breadcrumbLabel? }
};
}
const { widgetFetchers, widgetComponents, navItems, navSections, pages } = globalThis[REGISTRY_KEY];
// ---- Widgets ---------------------------------------------------------------
@@ -25,8 +34,8 @@ export function registerWidgetFetcher(id, fetcher) {
widgetFetchers.set(id, fetcher);
}
export function registerWidget({ id, Component, order = 0 }) {
widgetComponents.set(id, { Component, order });
export function registerWidget({ id, Component, order = 0, permission }) {
widgetComponents.set(id, { Component, order, permission });
}
export function getWidgets() {
@@ -57,8 +66,8 @@ export function registerNavSection({ id, title, icon, order = 0 }) {
navSections.set(id, { id, title, icon, order });
}
export function registerNavItem({ id, label, icon, href, order = 0, sectionId = 'main' }) {
navItems.set(id, { id, label, icon, href, order, sectionId });
export function registerNavItem({ id, label, icon, href, order = 0, sectionId = 'main', position, permission }) {
navItems.set(id, { id, label, icon, href, order, sectionId, position, permission });
}
export function getNavSections() {
+1 -1
View File
@@ -20,4 +20,4 @@ function UsersWidget({ data, loading }) {
);
}
registerWidget({ id: 'users', Component: UsersWidget, order: 10 });
registerWidget({ id: 'users', Component: UsersWidget, order: 10, permission: 'users.view' });
+5
View File
@@ -8,6 +8,7 @@ import ForgotPasswordPage from './pages/ForgotPasswordPage.client.js';
import ResetPasswordPage from './pages/ResetPasswordPage.client.js';
import ConfirmEmailPage from './pages/ConfirmEmailPage.client.js';
import LogoutPage from './pages/LogoutPage.client.js';
import SetupAccountPage from './pages/SetupAccountPage.client.js';
const PAGE_COMPONENTS = {
login: LoginPage,
@@ -16,6 +17,7 @@ const PAGE_COMPONENTS = {
reset: ResetPasswordPage,
confirm: ConfirmEmailPage,
logout: LogoutPage,
setup: SetupAccountPage,
};
export default function AuthPage({
@@ -26,6 +28,7 @@ export default function AuthPage({
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
setupAccountAction,
logoutAction,
setSessionCookieAction,
redirectAfterLogin = '/',
@@ -81,6 +84,8 @@ export default function AuthPage({
return <Page {...common} onSubmit={resetPasswordAction} email={email} token={token} />;
case ConfirmEmailPage:
return <Page {...common} onSubmit={verifyEmailAction} email={email} token={token} />;
case SetupAccountPage:
return <Page {...common} onSubmit={setupAccountAction} email={email} token={token} />;
case LogoutPage:
return <Page onLogout={logoutAction} onSetSessionCookie={setSessionCookieAction} />;
default:
+3 -1
View File
@@ -6,6 +6,7 @@ import {
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
setupAccountAction,
setSessionCookie,
getSession,
} from './actions.js';
@@ -14,7 +15,7 @@ export default async function AuthPage({ params, searchParams }) {
const session = await getSession();
return (
<div className="min-h-screen flex items-center justify-center p-4 md:p-8">
<div className="min-h-screen flex flex-col items-center justify-start sm:justify-center px-4 py-10 sm:py-8 md:p-8 bg-neutral-50 dark:bg-black">
<div className="max-w-md w-full">
<AuthPageClient
params={params}
@@ -25,6 +26,7 @@ export default async function AuthPage({ params, searchParams }) {
forgotPasswordAction={forgotPasswordAction}
resetPasswordAction={resetPasswordAction}
verifyEmailAction={verifyEmailAction}
setupAccountAction={setupAccountAction}
setSessionCookieAction={setSessionCookie}
redirectAfterLogin={process.env.ZEN_AUTH_REDIRECT_AFTER_LOGIN || '/'}
currentUser={session?.user || null}
+102
View File
@@ -0,0 +1,102 @@
# Pages d'authentification personnalisées
Pour utiliser sa propre mise en page, on crée des routes Next.js qui enveloppent les composants Zen dans un layout.
Chaque page suit le même patron : un **composant serveur** qui charge la session et passe les actions, et un **wrapper client** qui gère la navigation.
- Composants : `@zen/core/features/auth/pages`
- Actions : `@zen/core/features/auth/actions`
---
## Composants disponibles
| Page | Composant | Props principales |
|-----------------------|------------------------|-------------------|
| Connexion | `LoginPage` | `onSubmit`, `onSetSessionCookie`, `onNavigate`, `redirectAfterLogin`, `currentUser` |
| Inscription | `RegisterPage` | `onSubmit`, `onNavigate`, `currentUser` |
| Mot de passe oublié | `ForgotPasswordPage` | `onSubmit`, `onNavigate`, `currentUser` |
| Réinitialisation | `ResetPasswordPage` | `onSubmit`, `onNavigate`, `email`, `token` |
| Confirmation courriel | `ConfirmEmailPage` | `onSubmit`, `onNavigate`, `email`, `token` |
| Déconnexion | `LogoutPage` | `onLogout`, `onSetSessionCookie` |
`onNavigate` reçoit une valeur parmi : `'login' | 'register' | 'forgot' | 'reset'`.
---
## Exemple : page de connexion
**Serveur**`app/auth/login/page.js`
```js
import { getSession, loginAction, setSessionCookie } from '@zen/core/features/auth/actions';
import { LoginPageWrapper } from './LoginPageWrapper';
export default async function LoginRoute() {
const session = await getSession();
return (
<MonLayout>
<LoginPageWrapper
loginAction={loginAction}
setSessionCookie={setSessionCookie}
currentUser={session?.user ?? null}
/>
</MonLayout>
);
}
```
**Client**`app/auth/login/LoginPageWrapper.js`
```js
'use client';
import { useRouter } from 'next/navigation';
import { LoginPage } from '@zen/core/features/auth/pages';
export function LoginPageWrapper({ loginAction, setSessionCookie, currentUser }) {
const router = useRouter();
return (
<LoginPage
onSubmit={loginAction}
onSetSessionCookie={setSessionCookie}
onNavigate={(page) => router.push(`/auth/${page}`)}
redirectAfterLogin="/"
currentUser={currentUser}
/>
);
}
```
Le même patron s'applique à toutes les autres pages. Les différences :
- **RegisterPage** : passer `registerAction` comme `onSubmit`
- **ForgotPasswordPage** : passer `forgotPasswordAction` comme `onSubmit`
- **ResetPasswordPage** / **ConfirmEmailPage** : lire `email` et `token` dans `searchParams` et les passer en props
- **LogoutPage** : utiliser `onLogout` au lieu de `onSubmit`, pas de `onNavigate` requis
---
## Protection de route
```js
import { getSession } from '@zen/core/features/auth/actions';
import { redirect } from 'next/navigation';
export default async function PageProtégée() {
const session = await getSession();
if (!session?.user) redirect('/auth/login');
// ...
}
```
---
## Page par défaut (sans personnalisation)
Si une mise en page personnalisée n'est pas nécessaire, on garde simplement :
```js
// app/auth/[...auth]/page.js
export { default } from '@zen/core/features/auth/server';
```
-347
View File
@@ -1,347 +0,0 @@
# Custom auth pages
This guide explains how to build your own auth pages (login, register, forgot password, reset password, confirm email, logout) so they match your sites layout and style. For a basic site you can keep using the [default auth page](#default-auth-page).
## Overview
You can use a **custom page for every auth flow**:
| Page | Component | Server action(s) |
|-----------------|-----------------------|-------------------------------------|
| Login | `LoginPage` | `loginAction`, `setSessionCookie` |
| Register | `RegisterPage` | `registerAction` |
| Forgot password | `ForgotPasswordPage` | `forgotPasswordAction` |
| Reset password | `ResetPasswordPage` | `resetPasswordAction` |
| Confirm email | `ConfirmEmailPage` | `verifyEmailAction` |
| Logout | `LogoutPage` | `logoutAction`, `setSessionCookie` |
- **Components**: from `@zen/core/auth/components`
- **Actions**: from `@zen/core/auth/actions`
Create your own routes (e.g. `/login`, `/register`, `/auth/forgot`) and wrap Zens components in your layout. Each page follows the same pattern: a **server component** that loads data and passes actions, and a **client wrapper** that handles navigation and renders the Zen component.
---
## Route structure
Choose a URL scheme and use it consistently. Two common options:
**Option A All under `/auth/*` (like the default)**
`/auth/login`, `/auth/register`, `/auth/forgot`, `/auth/reset`, `/auth/confirm`, `/auth/logout`
**Option B Top-level routes**
`/login`, `/register`, `/forgot`, `/reset`, `/confirm`, `/logout`
The `onNavigate` callback receives one of: `'login' | 'register' | 'forgot' | 'reset'`. Map each to your chosen path, e.g. `router.push(\`/auth/${page}\`)` or `router.push(\`/${page}\`)`.
Reset and confirm pages need `email` and `token` from the URL (e.g. `/auth/reset?email=...&token=...`). Your server page can read `searchParams` and pass them to the component.
---
## Component reference (props)
Use this when wiring each custom page.
| Component | Props |
|-----------------------|--------|
| **LoginPage** | `onSubmit` (loginAction), `onSetSessionCookie`, `onNavigate`, `redirectAfterLogin`, `currentUser` |
| **RegisterPage** | `onSubmit` (registerAction), `onNavigate`, `currentUser` |
| **ForgotPasswordPage**| `onSubmit` (forgotPasswordAction), `onNavigate`, `currentUser` |
| **ResetPasswordPage** | `onSubmit` (resetPasswordAction), `onNavigate`, `email`, `token` (from URL) |
| **ConfirmEmailPage** | `onSubmit` (verifyEmailAction), `onNavigate`, `email`, `token` (from URL) |
| **LogoutPage** | `onLogout` (logoutAction), `onSetSessionCookie` (optional) |
---
## 1. Login
**Server:** `app/login/page.js` (or `app/auth/login/page.js`)
```js
import { getSession, loginAction, setSessionCookie } from '@zen/core/auth/actions';
import { LoginPageWrapper } from './LoginPageWrapper';
export default async function LoginRoute() {
const session = await getSession();
return (
<YourSiteLayout>
<LoginPageWrapper
loginAction={loginAction}
setSessionCookie={setSessionCookie}
currentUser={session?.user ?? null}
/>
</YourSiteLayout>
);
}
```
**Client:** `app/login/LoginPageWrapper.js`
```js
'use client';
import { useRouter } from 'next/navigation';
import { LoginPage } from '@zen/core/auth/components';
export function LoginPageWrapper({ loginAction, setSessionCookie, currentUser }) {
const router = useRouter();
return (
<LoginPage
onSubmit={loginAction}
onSetSessionCookie={setSessionCookie}
onNavigate={(page) => router.push(`/auth/${page}`)}
redirectAfterLogin="/"
currentUser={currentUser}
/>
);
}
```
---
## 2. Register
**Server:** `app/register/page.js`
```js
import { getSession, registerAction } from '@zen/core/auth/actions';
import { RegisterPageWrapper } from './RegisterPageWrapper';
export default async function RegisterRoute() {
const session = await getSession();
return (
<YourSiteLayout>
<RegisterPageWrapper
registerAction={registerAction}
currentUser={session?.user ?? null}
/>
</YourSiteLayout>
);
}
```
**Client:** `app/register/RegisterPageWrapper.js`
```js
'use client';
import { useRouter } from 'next/navigation';
import { RegisterPage } from '@zen/core/auth/components';
export function RegisterPageWrapper({ registerAction, currentUser }) {
const router = useRouter();
return (
<RegisterPage
onSubmit={registerAction}
onNavigate={(page) => router.push(`/auth/${page}`)}
currentUser={currentUser}
/>
);
}
```
---
## 3. Forgot password
**Server:** `app/forgot/page.js`
```js
import { getSession, forgotPasswordAction } from '@zen/core/auth/actions';
import { ForgotPasswordPageWrapper } from './ForgotPasswordPageWrapper';
export default async function ForgotRoute() {
const session = await getSession();
return (
<YourSiteLayout>
<ForgotPasswordPageWrapper
forgotPasswordAction={forgotPasswordAction}
currentUser={session?.user ?? null}
/>
</YourSiteLayout>
);
}
```
**Client:** `app/forgot/ForgotPasswordPageWrapper.js`
```js
'use client';
import { useRouter } from 'next/navigation';
import { ForgotPasswordPage } from '@zen/core/auth/components';
export function ForgotPasswordPageWrapper({ forgotPasswordAction, currentUser }) {
const router = useRouter();
return (
<ForgotPasswordPage
onSubmit={forgotPasswordAction}
onNavigate={(page) => router.push(`/auth/${page}`)}
currentUser={currentUser}
/>
);
}
```
---
## 4. Reset password
Requires `email` and `token` from the reset link (e.g. `/auth/reset?email=...&token=...`). Read `searchParams` in the server component and pass them to the client.
**Server:** `app/auth/reset/page.js` (or `app/reset/page.js` with dynamic segment if needed)
```js
import { resetPasswordAction } from '@zen/core/auth/actions';
import { ResetPasswordPageWrapper } from './ResetPasswordPageWrapper';
export default async function ResetRoute({ searchParams }) {
const params = typeof searchParams?.then === 'function' ? await searchParams : searchParams ?? {};
const email = params.email ?? '';
const token = params.token ?? '';
return (
<YourSiteLayout>
<ResetPasswordPageWrapper
resetPasswordAction={resetPasswordAction}
email={email}
token={token}
/>
</YourSiteLayout>
);
}
```
**Client:** `app/auth/reset/ResetPasswordPageWrapper.js`
```js
'use client';
import { useRouter } from 'next/navigation';
import { ResetPasswordPage } from '@zen/core/auth/components';
export function ResetPasswordPageWrapper({ resetPasswordAction, email, token }) {
const router = useRouter();
return (
<ResetPasswordPage
onSubmit={resetPasswordAction}
onNavigate={(page) => router.push(`/auth/${page}`)}
email={email}
token={token}
/>
);
}
```
---
## 5. Confirm email
Requires `email` and `token` from the verification link (e.g. `/auth/confirm?email=...&token=...`).
**Server:** `app/auth/confirm/page.js`
```js
import { verifyEmailAction } from '@zen/core/auth/actions';
import { ConfirmEmailPageWrapper } from './ConfirmEmailPageWrapper';
export default async function ConfirmRoute({ searchParams }) {
const params = typeof searchParams?.then === 'function' ? await searchParams : searchParams ?? {};
const email = params.email ?? '';
const token = params.token ?? '';
return (
<YourSiteLayout>
<ConfirmEmailPageWrapper
verifyEmailAction={verifyEmailAction}
email={email}
token={token}
/>
</YourSiteLayout>
);
}
```
**Client:** `app/auth/confirm/ConfirmEmailPageWrapper.js`
```js
'use client';
import { useRouter } from 'next/navigation';
import { ConfirmEmailPage } from '@zen/core/auth/components';
export function ConfirmEmailPageWrapper({ verifyEmailAction, email, token }) {
const router = useRouter();
return (
<ConfirmEmailPage
onSubmit={verifyEmailAction}
onNavigate={(page) => router.push(`/auth/${page}`)}
email={email}
token={token}
/>
);
}
```
---
## 6. Logout
**Server:** `app/auth/logout/page.js`
```js
import { logoutAction, setSessionCookie } from '@zen/core/auth/actions';
import { LogoutPageWrapper } from './LogoutPageWrapper';
export default function LogoutRoute() {
return (
<YourSiteLayout>
<LogoutPageWrapper
logoutAction={logoutAction}
setSessionCookie={setSessionCookie}
/>
</YourSiteLayout>
);
}
```
**Client:** `app/auth/logout/LogoutPageWrapper.js`
```js
'use client';
import { LogoutPage } from '@zen/core/auth/components';
export function LogoutPageWrapper({ logoutAction, setSessionCookie }) {
return (
<LogoutPage
onLogout={logoutAction}
onSetSessionCookie={setSessionCookie}
/>
);
}
```
---
## Protecting routes
Use `protect()` from `@zen/core/auth` and set `redirectTo` to your custom login path:
```js
import { protect } from '@zen/core/auth';
export const middleware = protect({ redirectTo: '/login' });
```
So unauthenticated users are sent to your custom login page.
---
## Default auth page
If you dont need a custom layout, keep using the built-in auth UI. In `app/auth/[...auth]/page.js`:
```js
export { default } from '@zen/core/auth/page';
```
This serves login, register, forgot, reset, confirm, and logout under `/auth/*` with the default styling.
-274
View File
@@ -1,274 +0,0 @@
# Client dashboard and user features
This guide explains how to build a **client dashboard** in your Next.js app using Zen auth: protect routes, show the current user (name, avatar), add an account section to edit profile and avatar, and redirect to login when the user is not connected.
## What is available
| Need | Solution |
|------|----------|
| Require login on a page | `protect()` in a **server component** redirects to login if not authenticated |
| Get current user on server | `getSession()` from `@zen/core/auth/actions` |
| Check auth without redirect | `checkAuth()` from `@zen/core/auth` |
| Require a role | `requireRole(['admin', 'manager'])` from `@zen/core/auth` |
| Show user in client (header/nav) | `UserMenu` or `UserAvatar` + `useCurrentUser` from `@zen/core/auth/components` |
| Edit account (name + avatar) | `AccountSection` from `@zen/core/auth/components` |
| Call user API from client | `GET /zen/api/users/me`, `PUT /zen/api/users/profile`, `POST/DELETE /zen/api/users/profile/picture` (with `credentials: 'include'`) |
All user APIs are **session-based**: the session cookie is read on the server. No token in client code. Avatar and profile updates are scoped to the current user; the API validates the session on every request.
---
## 1. Protect a dashboard page (redirect if not logged in)
Use `protect()` in a **server component**. If there is no valid session, the user is redirected to the login page.
```js
// app/dashboard/page.js (Server Component)
import { protect } from '@zen/core/auth';
import { DashboardClient } from './DashboardClient';
export default async function DashboardPage() {
const session = await protect({ redirectTo: '/auth/login' });
return (
<div>
<h1>Dashboard</h1>
<DashboardClient initialUser={session.user} />
</div>
);
}
```
- `redirectTo`: where to send the user if not authenticated (default: `'/auth/login'`).
- `protect()` returns the **session** (with `session.user`: `id`, `email`, `name`, `role`, `image`, etc.).
---
## 2. Display the current user in the layout (name, avatar)
**Option A Server: pass user into a client component**
In your layout or header (server component), get the session and pass `user` to a client component that shows avatar and name:
```js
// app/layout.js or app/dashboard/layout.js
import { getSession } from '@zen/core/auth/actions';
import { UserMenu } from '@zen/core/auth/components';
export default async function Layout({ children }) {
const session = await getSession();
return (
<div>
<header>
{session?.user ? (
<UserMenu user={session.user} accountHref="/dashboard/account" logoutHref="/auth/logout" />
) : (
<a href="/auth/login">Log in</a>
)}
</header>
{children}
</div>
);
}
```
**Option B Client only: fetch user with `useCurrentUser`**
If you prefer not to pass user from the server, use the hook in a client component. It calls `GET /zen/api/users/me` with the session cookie:
```js
'use client';
import { UserMenu } from '@zen/core/auth/components';
export function Header() {
return (
<UserMenu
accountHref="/dashboard/account"
logoutHref="/auth/logout"
/>
);
}
```
`UserMenu` with no `user` prop will call `useCurrentUser()` itself and show a loading state until the request finishes. If the user is not logged in, it renders nothing (you can show a “Log in” link elsewhere).
**Components:**
- **`UserMenu`** Avatar + name + dropdown with “My account” and “Log out”. Props: `user` (optional), `accountHref`, `logoutHref`, `className`.
- **`UserAvatar`** Only the avatar (image or initials). Props: `user`, `size` (`'sm' | 'md' | 'lg'`), `className`.
- **`useCurrentUser()`** Returns `{ user, loading, error, refetch }`. Use when you need the current user in a client component without receiving it from the server.
---
## 3. Account page (edit profile and avatar)
Use **`AccountSection`** on a page that is already protected (e.g. `/dashboard/account`). It shows:
- Profile picture (upload / remove)
- Full name (editable)
- Email (read-only)
- Optional “Account created” date
**Server page:**
```js
// app/dashboard/account/page.js
import { protect } from '@zen/core/auth';
import { AccountSection } from '@zen/core/auth/components';
export default async function AccountPage() {
const session = await protect({ redirectTo: '/auth/login' });
return (
<div>
<h1>My account</h1>
<AccountSection initialUser={session.user} />
</div>
);
}
```
- **`initialUser`** Optional. If you pass `session.user`, the section uses it immediately and does not need an extra API call on load.
- **`onUpdate`** Optional callback after profile or avatar update; you can use it to refresh parent state or revalidate.
`AccountSection` uses:
- `PUT /zen/api/users/profile` for name
- `POST /zen/api/users/profile/picture` for upload
- `DELETE /zen/api/users/profile/picture` for remove
All with `credentials: 'include'` (session cookie). Ensure your app uses **ToastProvider** (from `@zen/core/toast`) if you want toasts.
---
## 4. Check if the user is connected (without redirect)
Use **`checkAuth()`** in a server component when you only need to know whether someone is logged in:
```js
import { checkAuth } from '@zen/core/auth';
export default async function Page() {
const session = await checkAuth();
return session ? <div>Hello, {session.user.name}</div> : <div>Please log in</div>;
}
```
Use **`requireRole()`** when a page is only for certain roles:
```js
import { requireRole } from '@zen/core/auth';
export default async function ManagerPage() {
const session = await requireRole(['admin', 'manager'], {
redirectTo: '/auth/login',
forbiddenRedirect: '/dashboard',
});
return <div>Manager content</div>;
}
```
---
## 5. Security summary
- **Session cookie**: HttpOnly, validated on the server for every protected API call.
- **User APIs**:
- `GET /zen/api/users/me` current user only.
- `PUT /zen/api/users/profile` update only the authenticated users name.
- Profile picture upload/delete scoped to the current user; storage path includes `users/{userId}/...`.
- **Storage**: User files under `users/{userId}/...` are only served if the request session matches that `userId` (or admin).
- **Protection**: Use `protect()` or `requireRole()` in server components so unauthenticated or unauthorized users never see sensitive dashboard content.
---
## 6. Minimal dashboard example
```text
app/
layout.js # Root layout with ToastProvider if you use it
auth/
[...auth]/page.js # Zen default auth page (login, register, logout, etc.)
dashboard/
layout.js # Optional: layout that shows UserMenu and requires login
page.js # Protected dashboard home
account/
page.js # Protected account page with AccountSection
```
**dashboard/layout.js:**
```js
import { protect } from '@zen/core/auth';
import { UserMenu } from '@zen/core/auth/components';
import Link from 'next/link';
export default async function DashboardLayout({ children }) {
const session = await protect({ redirectTo: '/auth/login' });
return (
<div>
<header className="flex justify-between items-center p-4 border-b">
<Link href="/dashboard">Dashboard</Link>
<UserMenu
user={session.user}
accountHref="/dashboard/account"
logoutHref="/auth/logout"
/>
</header>
<main className="p-4">{children}</main>
</div>
);
}
```
**dashboard/page.js:**
```js
import { protect } from '@zen/core/auth';
export default async function DashboardPage() {
const session = await protect({ redirectTo: '/auth/login' });
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p><a href="/dashboard/account">Edit my account</a></p>
</div>
);
}
```
**dashboard/account/page.js:**
```js
import { protect } from '@zen/core/auth';
import { AccountSection } from '@zen/core/auth/components';
export default async function AccountPage() {
const session = await protect({ redirectTo: '/auth/login' });
return (
<div>
<h1>My account</h1>
<AccountSection initialUser={session.user} />
</div>
);
}
```
This gives you a protected dashboard, user display in the header, and a dedicated account page to modify profile and avatar, with redirect to login when the user is not connected.
---
## 7. Facturation (invoices) section
If you use the **Invoice** module and want logged-in users to see their own invoices in the dashboard:
- **Userclient link**: In the admin, link a user to a client (User edit → Client). Only invoices for that client are shown.
- **API**: `GET /zen/api/invoices/me` (session required) returns the current users linked client and that clients invoices.
- **Component**: Use `ClientInvoicesSection` from `@zen/core/invoice/dashboard` on a protected page (e.g. `/dashboard/invoices`).
See the [Invoice module dashboard guide](../../modules/invoice/README-dashboard.md) for the full setup (API details, page example, linking users to clients, and security).
+273
View File
@@ -0,0 +1,273 @@
# Auth
Ce répertoire gère l'authentification : inscription, connexion, sessions, réinitialisation de mot de passe, vérification d'adresse courriel et gestion du profil. Il expose des server actions Next.js, des routes API REST et des composants de pages prêts à l'emploi.
---
## Structure
```
src/features/auth/
├── index.js barrel serveur
├── actions.js server actions Next.js ('use server')
├── api.js routes API REST (users, roles)
├── auth.js register, login, resetPassword, updateUser, completeAccountSetup
├── session.js createSession, validateSession, deleteSession
├── email.js tokens de vérification + envoi des e-mails
├── password.js hashPassword, verifyPassword, generateToken
├── db.js createTables, dropTables
├── storage-policies.js politiques d'accès au stockage
├── AuthPage.server.js page RSC racine (route catch-all)
├── AuthPage.client.js shell client
├── GUIDE-custom-login.md guide pour les pages personnalisées
├── components/
│ └── AuthPageHeader.js
├── pages/
│ ├── index.js re-export
│ ├── LoginPage.client.js
│ ├── RegisterPage.client.js
│ ├── ForgotPasswordPage.client.js
│ ├── ResetPasswordPage.client.js
│ ├── ConfirmEmailPage.client.js
│ ├── SetupAccountPage.client.js
│ └── LogoutPage.client.js
└── templates/
├── VerificationEmail.js
├── PasswordResetEmail.js
├── PasswordChangedEmail.js
├── EmailChangeConfirmEmail.js
├── EmailChangeNotifyEmail.js
└── InvitationEmail.js
```
---
## Import
```js
import { getSession, loginAction, logoutAction } from '@zen/core/features/auth/actions';
import { LoginPage, RegisterPage } from '@zen/core/features/auth/pages';
import { validateSession, createSession } from '@zen/core/features/auth';
```
---
## Pages intégrées
La route catch-all `app/auth/[...auth]/page.js` suffit pour exposer toutes les pages sans configuration supplémentaire.
```js
// app/auth/[...auth]/page.js
export { default } from '@zen/core/features/auth/server';
```
| Route | Page |
|-------|------|
| `/auth/login` | Connexion |
| `/auth/register` | Inscription |
| `/auth/forgot` | Mot de passe oublié |
| `/auth/reset` | Réinitialisation du mot de passe |
| `/auth/confirm` | Vérification de l'adresse courriel |
| `/auth/setup` | Configuration du compte après invitation admin |
| `/auth/logout` | Déconnexion |
---
## Server actions
Toutes les actions sont dans `@zen/core/features/auth/actions`. Elles attendent un `FormData` sauf `getSession`, `setSessionCookie` et `refreshSessionCookie`.
### `getSession()`
Lit le cookie de session et retourne la session courante, ou `null` si l'utilisateur n'est pas connecté. Renouvelle automatiquement le cookie si la session a été rafraîchie.
```js
const session = await getSession();
if (!session?.user) redirect('/auth/login');
// session.user, session.session disponibles
```
---
### `loginAction(formData)`
Authentifie l'utilisateur et pose un cookie `HttpOnly`. Applique le rate limiting par IP et les vérifications anti-bot.
```js
const result = await loginAction(formData);
// { success: true, user } ou { success: false, error }
```
---
### `registerAction(formData)`
Crée un compte et envoie l'e-mail de vérification.
| Champ | Description |
|-------|-------------|
| `email` | Adresse courriel |
| `password` | Mot de passe |
| `name` | Nom d'affichage |
---
### `logoutAction()`
Invalide la session en base et supprime le cookie.
---
### `forgotPasswordAction(formData)`
Envoie un lien de réinitialisation si un compte existe pour l'adresse fournie. La réponse ne révèle pas si le compte existe.
---
### `resetPasswordAction(formData)`
Vérifie le token puis met à jour le mot de passe.
| Champ | Description |
|-------|-------------|
| `email` | Adresse courriel |
| `token` | Token reçu par e-mail |
| `newPassword` | Nouveau mot de passe |
---
### `verifyEmailAction(formData)`
Vérifie le token de confirmation et marque l'adresse comme vérifiée.
| Champ | Description |
|-------|-------------|
| `email` | Adresse courriel |
| `token` | Token reçu par e-mail |
---
### `setupAccountAction(formData)`
Vérifie le token d'invitation, crée le compte credential et marque l'adresse comme vérifiée. Appelée depuis `/auth/setup` après qu'un admin a créé le compte sans mot de passe.
| Champ | Description |
|-------|-------------|
| `email` | Adresse courriel |
| `token` | Token reçu dans le courriel d'invitation |
| `newPassword` | Mot de passe choisi |
| `confirmPassword` | Confirmation du mot de passe |
---
### `setSessionCookie(token)`
Valide le token contre la base avant de l'écrire dans le cookie `HttpOnly`. À utiliser après une authentification externe ou OAuth.
---
### `refreshSessionCookie(token)`
Revalide le token et prolonge la durée de vie du cookie (30 jours).
---
## Routes API REST
Les routes sont enregistrées automatiquement sous le préfixe `/zen/api`. L'authentification est appliquée par le routeur avant chaque handler.
### Utilisateurs
| Méthode | Route | Auth | Description |
|---------|-------|------|-------------|
| `GET` | `/zen/api/users` | admin | Liste paginée des utilisateurs |
| `POST` | `/zen/api/users` | admin | Créer un utilisateur (avec ou sans invitation) |
| `GET` | `/zen/api/users/:id` | admin | Détail d'un utilisateur |
| `PUT` | `/zen/api/users/:id` | admin | Modifier `name`, `role`, `email_verified` |
| `PUT` | `/zen/api/users/:id/email` | admin | Changer l'adresse courriel |
| `PUT` | `/zen/api/users/:id/password` | admin | Définir un mot de passe |
| `POST` | `/zen/api/users/:id/send-password-reset` | admin | Envoyer un lien de réinitialisation |
| `GET` | `/zen/api/users/:id/roles` | admin | Lister les rôles de l'utilisateur |
| `POST` | `/zen/api/users/:id/roles` | admin | Assigner un rôle |
| `DELETE` | `/zen/api/users/:id/roles/:roleId` | admin | Révoquer un rôle |
### Profil (utilisateur connecté)
| Méthode | Route | Description |
|---------|-------|-------------|
| `PUT` | `/zen/api/users/profile` | Modifier le nom |
| `POST` | `/zen/api/users/profile/email` | Initier un changement d'adresse courriel |
| `GET` | `/zen/api/users/email/confirm` | Confirmer le changement d'adresse |
| `POST` | `/zen/api/users/profile/password` | Changer le mot de passe |
| `POST` | `/zen/api/users/profile/picture` | Téléverser une photo de profil |
| `DELETE` | `/zen/api/users/profile/picture` | Supprimer la photo de profil |
| `GET` | `/zen/api/users/profile/sessions` | Lister les sessions actives |
| `DELETE` | `/zen/api/users/profile/sessions` | Révoquer toutes les sessions |
| `DELETE` | `/zen/api/users/profile/sessions/:sessionId` | Révoquer une session |
### Rôles
| Méthode | Route | Description |
|---------|-------|-------------|
| `GET` | `/zen/api/roles` | Lister les rôles |
| `POST` | `/zen/api/roles` | Créer un rôle |
| `GET` | `/zen/api/roles/:id` | Détail d'un rôle |
| `PUT` | `/zen/api/roles/:id` | Modifier un rôle |
| `DELETE` | `/zen/api/roles/:id` | Supprimer un rôle |
---
## Invitation par l'admin
Un administrateur peut créer un utilisateur depuis `/admin/users → Nouvel utilisateur`. Deux flux selon si un mot de passe est fourni :
**Avec mot de passe :** l'utilisateur est créé avec `email_verified = true` et un compte credential. Il peut se connecter immédiatement.
**Sans mot de passe :** l'utilisateur est créé avec `email_verified = false` et aucun compte credential. Un token `account_setup` (48 h) est généré et un courriel d'invitation est envoyé. L'utilisateur clique sur le lien `/auth/setup?email=X&token=Y`, choisit son mot de passe, et le compte est activé (`email_verified = true`) en une seule étape — le passage par le lien vaut confirmation du courriel.
```
Admin crée l'utilisateur (sans mdp)
→ POST /zen/api/users
→ zen_auth_users créé (email_verified: false)
→ token account_setup enregistré dans zen_auth_verifications (48 h)
→ courriel InvitationEmail envoyé
Utilisateur clique sur le lien /auth/setup
→ SetupAccountPage (setupAccountAction)
→ token vérifié
→ zen_auth_accounts créé avec mot de passe haché
→ email_verified = true
→ token supprimé
→ redirection vers /auth/login
```
---
## Sécurité
**Rate limiting par IP.** Les actions `register`, `login`, `forgot_password`, `reset_password` et `verify_email` sont limitées par adresse IP. Quand l'IP est inconnue (pas de proxy configuré), le rate limiting est suspendu et un avertissement opérateur est émis une seule fois. Activer avec `ZEN_TRUST_PROXY=true` derrière un reverse proxy vérifié.
**Champs anti-bot.** Chaque formulaire embarque un champ honeypot (`_hp`) et un timestamp de chargement (`_t`). Une soumission trop rapide (moins de 1,5 s), trop ancienne (plus de 10 min) ou avec un honeypot rempli est rejetée.
**Cookie HttpOnly.** Le token de session n'est jamais exposé à JavaScript. `setSessionCookie` et `refreshSessionCookie` valident le token en base avant d'écrire le cookie pour éviter qu'un token arbitraire soit accepté.
**Erreurs opaques.** Les erreurs internes sont loguées côté serveur et remplacées par un message générique côté client. Seules les `UserFacingError` (token expiré, etc.) remontent verbatim.
---
## Base de données
`db.js` expose `createTables()` et `dropTables()`, appelés par `initializeZen()`.
| Table | Contenu |
|-------|---------|
| `zen_auth_users` | Utilisateurs (`id`, `email`, `name`, `role`, `email_verified`, `image`) |
| `zen_auth_sessions` | Sessions actives avec IP et user-agent |
| `zen_auth_accounts` | Comptes liés à un provider (credential, OAuth) |
| `zen_auth_verifications` | Tokens de vérification d'e-mail et de réinitialisation |
---
## Pages personnalisées
Pour envelopper les pages auth dans un layout existant, voir [GUIDE-custom-login.md](./GUIDE-custom-login.md). Le guide couvre le pattern serveur/client, les props de chaque composant et la protection de route.
+41 -4
View File
@@ -1,6 +1,6 @@
'use server';
import { register, login, requestPasswordReset, resetPassword, verifyUserEmail } from './auth.js';
import { register, login, requestPasswordReset, resetPassword, verifyUserEmail, completeAccountSetup } from './auth.js';
import { validateSession, deleteSession } from './session.js';
import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPasswordResetEmail } from './email.js';
import { fail } from '@zen/core/shared/logger';
@@ -121,7 +121,8 @@ export async function loginAction(formData) {
const botCheck = validateAntiBotFields(formData);
if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp();
const h = await headers();
const ip = getIpFromHeaders(h);
const rl = enforceRateLimit(ip, 'login');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
@@ -129,8 +130,8 @@ export async function loginAction(formData) {
const email = formData.get('email');
const password = formData.get('password');
const result = await login({ email, password });
const userAgent = h.get('user-agent') || null;
const result = await login({ email, password }, { ipAddress: ip !== 'unknown' ? ip : null, userAgent });
// An HttpOnly cookie is the only safe transport for session tokens; setting it
// here keeps the token out of any JavaScript-readable response payload.
@@ -322,6 +323,42 @@ export async function resetPasswordAction(formData) {
}
}
export async function setupAccountAction(formData) {
try {
const ip = await getClientIp();
const rl = enforceRateLimit(ip, 'setup_account');
if (rl && !rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const token = formData.get('token');
const newPassword = formData.get('newPassword');
const confirmPassword = formData.get('confirmPassword');
if (!newPassword || !confirmPassword) {
throw new UserFacingError('Les deux champs de mot de passe sont requis');
}
if (newPassword !== confirmPassword) {
throw new UserFacingError('Les mots de passe ne correspondent pas');
}
await completeAccountSetup({ email, token, password: newPassword });
return {
success: true,
message: 'Mot de passe créé avec succès. Vous pouvez maintenant vous connecter.'
};
} catch (error) {
if (error instanceof UserFacingError) {
return { success: false, error: error.message };
}
fail(`Auth: setupAccountAction error: ${error.message}`);
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
}
}
export async function verifyEmailAction(formData) {
try {
const ip = await getClientIp();
+490 -36
View File
@@ -7,10 +7,14 @@
* the context argument: (request, params, { session }).
*/
import { query, updateById } from '@zen/core/database';
import { updateUser } from './auth.js';
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole } from '@zen/core/users';
import { query, create, updateById, findOne } from '@zen/core/database';
import { updateUser, requestPasswordReset } from './auth.js';
import { hashPassword, verifyPassword, generateId } from '../../core/users/password.js';
import { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendPasswordChangedEmail, sendPasswordResetEmail, sendInvitationEmail } from './email.js';
import { createAccountSetup } from '../../core/users/verifications.js';
import { listRoles, getRoleById, createRole, updateRole, deleteRole, getUserRoles, assignUserRole, revokeUserRole, deleteUserSessions, PERMISSIONS, getRegisteredPermissions } from '@zen/core/users';
import { uploadImage, deleteFile, generateUniqueFilename, getFileExtension, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@zen/core/storage';
import { getPublicBaseUrl } from '@zen/core/shared/config';
const generateUserFilePath = (userId, category, filename) => `users/${userId}/${category}/${filename}`;
import { fail, info } from '@zen/core/shared/logger';
@@ -37,24 +41,6 @@ function logAndObscureError(error, fallback) {
return fallback;
}
// ---------------------------------------------------------------------------
// GET /zen/api/users/me
// ---------------------------------------------------------------------------
async function handleGetCurrentUser(_request, _params, { session }) {
return apiSuccess({
user: {
id: session.user.id,
email: session.user.email,
name: session.user.name,
role: session.user.role,
image: session.user.image,
emailVerified: session.user.email_verified,
createdAt: session.user.created_at
}
});
}
// ---------------------------------------------------------------------------
// GET /zen/api/users/:id (admin only)
// ---------------------------------------------------------------------------
@@ -144,7 +130,15 @@ async function handleListUsers(request) {
const quotedSortColumn = `"${sortColumn}"`;
const result = await query(
`SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users ORDER BY ${quotedSortColumn} ${order} LIMIT $1 OFFSET $2`,
`SELECT u.id, u.email, u.name, u.role, u.image, u.email_verified, u.created_at,
COALESCE(
(SELECT json_agg(json_build_object('id', r.id, 'name', r.name, 'color', r.color) ORDER BY r.created_at ASC)
FROM zen_auth_roles r
JOIN zen_auth_user_roles ur ON ur.role_id = r.id
WHERE ur.user_id = u.id),
'[]'::json
) AS roles
FROM zen_auth_users u ORDER BY u.${quotedSortColumn} ${order} LIMIT $1 OFFSET $2`,
[limit, offset]
);
@@ -157,6 +151,163 @@ async function handleListUsers(request) {
});
}
// ---------------------------------------------------------------------------
// POST /zen/api/users/profile/email
// ---------------------------------------------------------------------------
const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
async function handleInitiateEmailChange(request, _params, { session }) {
try {
const body = await request.json();
const { newEmail, password } = body;
if (!newEmail || !EMAIL_REGEX.test(newEmail) || newEmail.length > 254) {
return apiError('Bad Request', 'Adresse courriel invalide');
}
if (!password) {
return apiError('Bad Request', 'Le mot de passe est requis');
}
const normalizedEmail = newEmail.trim().toLowerCase();
if (normalizedEmail === session.user.email.toLowerCase()) {
return apiError('Bad Request', 'Cette adresse courriel est déjà la vôtre');
}
const existing = await findOne('zen_auth_users', { email: normalizedEmail });
if (existing) {
return apiError('Conflict', 'Cette adresse courriel est déjà utilisée');
}
const account = await findOne('zen_auth_accounts', { user_id: session.user.id, provider_id: 'credential' });
if (!account || !account.password) {
return apiError('Bad Request', 'Impossible de vérifier le mot de passe');
}
const passwordValid = await verifyPassword(password, account.password);
if (!passwordValid) {
return apiError('Unauthorized', 'Mot de passe incorrect');
}
const token = await createEmailChangeToken(session.user.id, normalizedEmail);
const baseUrl = getPublicBaseUrl();
try {
await sendEmailChangeConfirmEmail(normalizedEmail, token, baseUrl);
} catch (emailError) {
fail(`handleInitiateEmailChange: failed to send confirmation email: ${emailError.message}`);
return apiError('Internal Server Error', 'Impossible d\'envoyer le courriel de confirmation');
}
try {
await sendEmailChangeOldNotifyEmail(session.user.email, normalizedEmail, 'pending');
} catch (emailError) {
fail(`handleInitiateEmailChange: failed to send notification email: ${emailError.message}`);
}
return apiSuccess({ success: true, message: `Un courriel de confirmation a été envoyé à ${normalizedEmail}` });
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Impossible d\'initier le changement de courriel');
}
}
// ---------------------------------------------------------------------------
// GET /zen/api/users/email/confirm
// ---------------------------------------------------------------------------
async function handleConfirmEmailChange(request, _params, { session }) {
try {
const url = new URL(request.url);
const token = url.searchParams.get('token');
if (!token) {
return apiError('Bad Request', 'Jeton de confirmation manquant');
}
const result = await verifyEmailChangeToken(token);
if (!result) {
return apiError('Bad Request', 'Lien de confirmation invalide ou expiré');
}
const { userId, newEmail } = result;
if (userId !== session.user.id) {
return apiError('Forbidden', 'Ce lien ne vous appartient pas');
}
const existing = await findOne('zen_auth_users', { email: newEmail });
if (existing) {
return apiError('Conflict', 'Cette adresse courriel est déjà utilisée');
}
await applyEmailChange(userId, newEmail);
return apiSuccess({ success: true, message: 'Adresse courriel mise à jour avec succès' });
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Impossible de confirmer le changement de courriel');
}
}
// ---------------------------------------------------------------------------
// PUT /zen/api/users/:id/email (admin only)
// ---------------------------------------------------------------------------
async function handleAdminUpdateUserEmail(request, { id: userId }) {
try {
const body = await request.json();
const { newEmail } = body;
if (!newEmail || !EMAIL_REGEX.test(newEmail) || newEmail.length > 254) {
return apiError('Bad Request', 'Adresse courriel invalide');
}
const normalizedEmail = newEmail.trim().toLowerCase();
const targetUser = await findOne('zen_auth_users', { id: userId });
if (!targetUser) {
return apiError('Not Found', 'Utilisateur introuvable');
}
if (normalizedEmail === targetUser.email.toLowerCase()) {
return apiError('Bad Request', 'Cette adresse courriel est déjà celle de l\'utilisateur');
}
const existing = await findOne('zen_auth_users', { email: normalizedEmail });
if (existing) {
return apiError('Conflict', 'Cette adresse courriel est déjà utilisée');
}
const oldEmail = targetUser.email;
await applyEmailChange(userId, normalizedEmail);
try {
await sendEmailChangeOldNotifyEmail(oldEmail, normalizedEmail, 'changed');
} catch (emailError) {
fail(`handleAdminUpdateUserEmail: failed to notify old email ${oldEmail}: ${emailError.message}`);
}
try {
await sendEmailChangeNewNotifyEmail(normalizedEmail, oldEmail);
} catch (emailError) {
fail(`handleAdminUpdateUserEmail: failed to notify new email ${normalizedEmail}: ${emailError.message}`);
}
const updated = await query(
'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1',
[userId]
);
return apiSuccess({ success: true, user: updated.rows[0], message: 'Courriel mis à jour avec succès' });
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Impossible de mettre à jour le courriel');
}
}
// ---------------------------------------------------------------------------
// PUT /zen/api/users/profile
// ---------------------------------------------------------------------------
@@ -375,8 +526,26 @@ async function handleAssignUserRole(request, { id: userId }) {
// DELETE /zen/api/users/:id/roles/:roleId (admin only)
// ---------------------------------------------------------------------------
async function handleRevokeUserRole(_request, { id: userId, roleId }) {
async function handleRevokeUserRole(_request, { id: userId, roleId }, context) {
try {
if (context.session.user.id === userId) {
const roleHasPerm = await query(
`SELECT 1 FROM zen_auth_role_permissions WHERE role_id = $1 AND permission_key = $2`,
[roleId, PERMISSIONS.USERS_MANAGE]
);
if (roleHasPerm.rows.length > 0) {
const otherRoles = 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 AND ur.role_id != $3
LIMIT 1`,
[userId, PERMISSIONS.USERS_MANAGE, roleId]
);
if (otherRoles.rows.length === 0) {
return apiError('Forbidden', "Vous ne pouvez pas retirer ce rôle car c'est votre seule source de la permission de gestion des utilisateurs.");
}
}
}
await revokeUserRole(userId, roleId);
return apiSuccess({ success: true });
} catch (error) {
@@ -394,6 +563,21 @@ async function handleListRoles() {
return apiSuccess({ roles });
}
// ---------------------------------------------------------------------------
// GET /zen/api/permissions (admin only)
// Catalogue dynamique : core + permissions enregistrées par les modules.
// ---------------------------------------------------------------------------
async function handleListPermissions() {
const permissions = getRegisteredPermissions();
const groups = permissions.reduce((acc, perm) => {
if (!acc[perm.group_name]) acc[perm.group_name] = [];
acc[perm.group_name].push(perm);
return acc;
}, {});
return apiSuccess({ permissions, groups });
}
// ---------------------------------------------------------------------------
// POST /zen/api/roles (admin only)
// ---------------------------------------------------------------------------
@@ -477,27 +661,297 @@ async function handleDeleteRole(_request, { id: roleId }) {
}
}
// ---------------------------------------------------------------------------
// GET /zen/api/users/profile/sessions (user — list own sessions)
// ---------------------------------------------------------------------------
function parseUserAgent(ua) {
if (!ua) return { browser: 'Navigateur inconnu', os: 'Système inconnu', device: 'desktop' };
const device = /Mobile|Android|iPhone|iPad/i.test(ua) ? 'mobile' : 'desktop';
let os = 'Système inconnu';
if (/Windows/i.test(ua)) os = 'Windows';
else if (/Android/i.test(ua)) os = 'Android';
else if (/iPhone|iPad/i.test(ua)) os = 'iOS';
else if (/Mac OS X/i.test(ua)) os = 'macOS';
else if (/Linux/i.test(ua)) os = 'Linux';
let browser = 'Navigateur inconnu';
if (/Edg\//i.test(ua)) browser = 'Edge';
else if (/Chrome\//i.test(ua)) browser = 'Chrome';
else if (/Firefox\//i.test(ua)) browser = 'Firefox';
else if (/Safari\//i.test(ua)) browser = 'Safari';
return { browser, os, device };
}
async function handleListSessions(_request, _params, { session }) {
try {
const result = await query(
'SELECT id, ip_address, user_agent, created_at, expires_at FROM zen_auth_sessions WHERE user_id = $1 ORDER BY created_at DESC',
[session.user.id]
);
const sessions = result.rows.map(s => ({
id: s.id,
ip_address: s.ip_address,
created_at: s.created_at,
expires_at: s.expires_at,
...parseUserAgent(s.user_agent),
}));
return apiSuccess({ sessions, currentSessionId: session.session.id });
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Impossible de récupérer les sessions');
}
}
// ---------------------------------------------------------------------------
// DELETE /zen/api/users/profile/sessions (user — revoke all own sessions)
// ---------------------------------------------------------------------------
async function handleDeleteAllSessions(_request, _params, { session }) {
try {
await deleteUserSessions(session.user.id);
return apiSuccess({ success: true });
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Impossible de révoquer les sessions');
}
}
// ---------------------------------------------------------------------------
// DELETE /zen/api/users/profile/sessions/:sessionId (user — revoke one session)
// ---------------------------------------------------------------------------
async function handleDeleteSession(_request, { sessionId }, { session }) {
try {
const result = await query(
'DELETE FROM zen_auth_sessions WHERE id = $1 AND user_id = $2 RETURNING id',
[sessionId, session.user.id]
);
if (result.rows.length === 0) {
return apiError('Not Found', 'Session introuvable');
}
return apiSuccess({ success: true, isCurrent: sessionId === session.session.id });
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Impossible de révoquer la session');
}
}
// ---------------------------------------------------------------------------
// POST /zen/api/users/profile/password (user — change own password)
// ---------------------------------------------------------------------------
const PASSWORD_REGEX_UPPER = /[A-Z]/;
const PASSWORD_REGEX_LOWER = /[a-z]/;
const PASSWORD_REGEX_DIGIT = /\d/;
function validateNewPassword(password) {
if (!password || password.length < 8) return 'Le mot de passe doit contenir au moins 8 caractères';
if (password.length > 128) return 'Le mot de passe doit contenir 128 caractères ou moins';
if (!PASSWORD_REGEX_UPPER.test(password)) return 'Le mot de passe doit contenir au moins une majuscule';
if (!PASSWORD_REGEX_LOWER.test(password)) return 'Le mot de passe doit contenir au moins une minuscule';
if (!PASSWORD_REGEX_DIGIT.test(password)) return 'Le mot de passe doit contenir au moins un chiffre';
return null;
}
async function handleChangeOwnPassword(request, _params, { session }) {
try {
const body = await request.json();
const { currentPassword, newPassword } = body;
if (!currentPassword) return apiError('Bad Request', 'Le mot de passe actuel est requis');
const passwordError = validateNewPassword(newPassword);
if (passwordError) return apiError('Bad Request', passwordError);
const account = await findOne('zen_auth_accounts', { user_id: session.user.id, provider_id: 'credential' });
if (!account || !account.password) return apiError('Bad Request', 'Impossible de vérifier le mot de passe');
const valid = await verifyPassword(currentPassword, account.password);
if (!valid) return apiError('Unauthorized', 'Mot de passe actuel incorrect');
const hashed = await hashPassword(newPassword);
await updateById('zen_auth_accounts', account.id, { password: hashed, updated_at: new Date() });
try {
await sendPasswordChangedEmail(session.user.email);
} catch (emailError) {
fail(`handleChangeOwnPassword: failed to send notification: ${emailError.message}`);
}
return apiSuccess({ success: true, message: 'Mot de passe mis à jour avec succès' });
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Impossible de mettre à jour le mot de passe');
}
}
// ---------------------------------------------------------------------------
// PUT /zen/api/users/:id/password (admin — set any user's password)
// ---------------------------------------------------------------------------
async function handleAdminSetUserPassword(request, { id: userId }) {
try {
const body = await request.json();
const { newPassword } = body;
const passwordError = validateNewPassword(newPassword);
if (passwordError) return apiError('Bad Request', passwordError);
const targetUser = await findOne('zen_auth_users', { id: userId });
if (!targetUser) return apiError('Not Found', 'Utilisateur introuvable');
const account = await findOne('zen_auth_accounts', { user_id: userId, provider_id: 'credential' });
if (!account) return apiError('Not Found', 'Compte introuvable');
const hashed = await hashPassword(newPassword);
await updateById('zen_auth_accounts', account.id, { password: hashed, updated_at: new Date() });
try {
await sendPasswordChangedEmail(targetUser.email);
} catch (emailError) {
fail(`handleAdminSetUserPassword: failed to send notification: ${emailError.message}`);
}
return apiSuccess({ success: true, message: 'Mot de passe mis à jour' });
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Impossible de mettre à jour le mot de passe');
}
}
// ---------------------------------------------------------------------------
// POST /zen/api/users/:id/send-password-reset (admin — send reset link)
// ---------------------------------------------------------------------------
async function handleAdminSendPasswordReset(_request, { id: userId }) {
try {
const targetUser = await findOne('zen_auth_users', { id: userId });
if (!targetUser) return apiError('Not Found', 'Utilisateur introuvable');
const result = await requestPasswordReset(targetUser.email);
if (result.token) {
await sendPasswordResetEmail(targetUser.email, result.token, getPublicBaseUrl());
}
return apiSuccess({ success: true, message: 'Lien de réinitialisation envoyé' });
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Impossible d\'envoyer le lien de réinitialisation');
}
}
// ---------------------------------------------------------------------------
// POST /zen/api/users (admin only)
// ---------------------------------------------------------------------------
async function handleAdminCreateUser(request) {
try {
const body = await request.json();
const { name, email, password, roleIds } = body;
if (!name || typeof name !== 'string' || name.trim().length === 0) {
return apiError('Bad Request', 'Le nom est requis');
}
if (name.trim().length > 100) {
return apiError('Bad Request', 'Le nom doit contenir 100 caractères ou moins');
}
if (!email || !EMAIL_REGEX.test(email) || email.length > 254) {
return apiError('Bad Request', 'Adresse courriel invalide');
}
const normalizedEmail = email.trim().toLowerCase();
const existing = await findOne('zen_auth_users', { email: normalizedEmail });
if (existing) {
return apiError('Conflict', 'Cette adresse courriel est déjà utilisée');
}
const userId = generateId();
const hasPassword = typeof password === 'string' && password.length > 0;
const user = await create('zen_auth_users', {
id: userId,
email: normalizedEmail,
name: name.trim(),
email_verified: hasPassword,
image: null,
role: 'user',
updated_at: new Date()
});
if (hasPassword) {
const hashedPassword = await hashPassword(password);
await create('zen_auth_accounts', {
id: generateId(),
account_id: normalizedEmail,
provider_id: 'credential',
user_id: user.id,
password: hashedPassword,
updated_at: new Date()
});
} else {
const setup = await createAccountSetup(normalizedEmail);
const baseUrl = getPublicBaseUrl();
try {
await sendInvitationEmail(normalizedEmail, setup.token, baseUrl);
} catch (emailError) {
fail(`handleAdminCreateUser: failed to send invitation email to ${normalizedEmail}: ${emailError.message}`);
}
}
if (Array.isArray(roleIds) && roleIds.length > 0) {
for (const roleId of roleIds) {
if (typeof roleId === 'string' && roleId.length > 0) {
try {
await assignUserRole(user.id, roleId);
} catch (err) {
fail(`handleAdminCreateUser: failed to assign role ${roleId} to user ${user.id}: ${err.message}`);
}
}
}
}
return apiSuccess({ user, invited: !hasPassword });
} catch (error) {
logAndObscureError(error, null);
return apiError('Internal Server Error', 'Impossible de créer l\'utilisateur');
}
}
// ---------------------------------------------------------------------------
// Route definitions
// ---------------------------------------------------------------------------
//
// Order matters: specific paths (/users/me, /users/profile) must come before
// Order matters: specific paths (/users/profile) must come before
// parameterised paths (/users/:id) so they match first.
export const routes = defineApiRoutes([
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
{ path: '/users/me', method: 'GET', handler: handleGetCurrentUser, auth: 'user' },
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin', permission: PERMISSIONS.USERS_VIEW },
{ path: '/users', method: 'POST', handler: handleAdminCreateUser, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
{ path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
{ path: '/users/profile/email', method: 'POST', handler: handleInitiateEmailChange, auth: 'user' },
{ path: '/users/profile/password', method: 'POST', handler: handleChangeOwnPassword, auth: 'user' },
{ path: '/users/profile/picture', method: 'POST', handler: handleUploadProfilePicture, auth: 'user' },
{ path: '/users/profile/picture', method: 'DELETE', handler: handleDeleteProfilePicture, auth: 'user' },
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin' },
{ path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin' },
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin' },
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin' },
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin' },
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin' },
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin' },
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin' },
{ path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin' },
{ path: '/roles/:id', method: 'DELETE', handler: handleDeleteRole, auth: 'admin' },
{ path: '/users/profile/sessions', method: 'GET', handler: handleListSessions, auth: 'user' },
{ path: '/users/profile/sessions', method: 'DELETE', handler: handleDeleteAllSessions, auth: 'user' },
{ path: '/users/profile/sessions/:sessionId', method: 'DELETE', handler: handleDeleteSession, auth: 'user' },
{ path: '/users/email/confirm', method: 'GET', handler: handleConfirmEmailChange, auth: 'user' },
{ path: '/users/:id/roles', method: 'GET', handler: handleGetUserRoles, auth: 'admin', permission: PERMISSIONS.USERS_VIEW },
{ path: '/users/:id/roles', method: 'POST', handler: handleAssignUserRole, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
{ path: '/users/:id/roles/:roleId', method: 'DELETE', handler: handleRevokeUserRole, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
{ path: '/users/:id', method: 'GET', handler: handleGetUserById, auth: 'admin', permission: PERMISSIONS.USERS_VIEW },
{ path: '/users/:id', method: 'PUT', handler: handleUpdateUserById, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
{ path: '/users/:id/email', method: 'PUT', handler: handleAdminUpdateUserEmail, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
{ path: '/users/:id/password', method: 'PUT', handler: handleAdminSetUserPassword, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
{ path: '/users/:id/send-password-reset', method: 'POST', handler: handleAdminSendPasswordReset, auth: 'admin', permission: PERMISSIONS.USERS_MANAGE },
{ path: '/roles', method: 'GET', handler: handleListRoles, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
{ path: '/permissions', method: 'GET', handler: handleListPermissions, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
{ path: '/roles', method: 'POST', handler: handleCreateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
{ path: '/roles/:id', method: 'GET', handler: handleGetRole, auth: 'admin', permission: PERMISSIONS.ROLES_VIEW },
{ path: '/roles/:id', method: 'PUT', handler: handleUpdateRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
{ path: '/roles/:id', method: 'DELETE', handler: handleDeleteRole, auth: 'admin', permission: PERMISSIONS.ROLES_MANAGE },
]);
+3 -2
View File
@@ -4,7 +4,8 @@ import {
login,
requestPasswordReset,
verifyUserEmail,
updateUser
updateUser,
completeAccountSetup
} from '../../core/users/auth.js';
import { sendPasswordChangedEmail } from './email.js';
@@ -19,4 +20,4 @@ export function resetPassword(resetData) {
return _resetPassword(resetData, { onPasswordChanged: sendPasswordChangedEmail });
}
export { login, requestPasswordReset, verifyUserEmail, updateUser };
export { login, requestPasswordReset, verifyUserEmail, updateUser, completeAccountSetup };
@@ -1,279 +0,0 @@
'use client';
/**
* Reusable "Edit account" section: display name, email (read-only), avatar upload/remove.
* Use on a protected dashboard page. Requires session cookie (user must be logged in).
*
* @param {Object} props
* @param {Object} [props.initialUser] - Initial user from server (e.g. getSession().user). If omitted, fetches from API.
* @param {function} [props.onUpdate] - Called after profile or avatar update with the new user object (e.g. to refresh layout)
*/
import React, { useState, useEffect, useRef } from 'react';
import { Card, Input, Button } from '@zen/core/shared/components';
import { useToast } from '@zen/core/toast';
import { useCurrentUser } from './useCurrentUser.js';
import UserAvatar from './UserAvatar.js';
const API_BASE = '/zen/api';
function getImageUrl(imageKey) {
if (!imageKey) return null;
return `/zen/api/storage/${imageKey}`;
}
export default function AccountSection({ initialUser, onUpdate }) {
const toast = useToast();
const { user: fetchedUser, loading: fetchLoading, refetch } = useCurrentUser();
const user = initialUser ?? fetchedUser;
const [formData, setFormData] = useState({ name: user?.name ?? '' });
const [saving, setSaving] = useState(false);
const [uploadingImage, setUploadingImage] = useState(false);
const [imagePreview, setImagePreview] = useState(null);
const fileInputRef = useRef(null);
useEffect(() => {
if (user) {
setFormData((prev) => ({ ...prev, name: user.name ?? '' }));
setImagePreview(user.image ? getImageUrl(user.image) : null);
}
}, [user]);
const handleNameChange = (value) => {
setFormData((prev) => ({ ...prev, name: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.name?.trim()) {
toast.error('Le nom est requis');
return;
}
setSaving(true);
try {
const res = await fetch(`${API_BASE}/users/profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name: formData.name.trim() }),
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.message || data.error || 'Échec de la mise à jour du profil');
}
toast.success('Profil mis à jour avec succès');
onUpdate?.(data.user);
refetch();
} catch (err) {
toast.error(err.message || 'Échec de la mise à jour du profil');
} finally {
setSaving(false);
}
};
const handleReset = () => {
setFormData({ name: user?.name ?? '' });
};
const handleImageSelect = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Veuillez sélectionner un fichier image');
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error("L'image doit faire moins de 5MB");
return;
}
const reader = new FileReader();
reader.onloadend = () => setImagePreview(reader.result);
reader.readAsDataURL(file);
setUploadingImage(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`${API_BASE}/users/profile/picture`, {
method: 'POST',
credentials: 'include',
body: fd,
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.message || data.error || 'Upload failed');
}
setImagePreview(getImageUrl(data.user?.image));
toast.success('Photo de profil mise à jour avec succès');
onUpdate?.(data.user);
refetch();
} catch (err) {
toast.error(err.message || 'Upload failed');
setImagePreview(user?.image ? getImageUrl(user.image) : null);
} finally {
setUploadingImage(false);
}
};
const handleRemoveImage = async () => {
if (!user?.image) return;
setUploadingImage(true);
try {
const res = await fetch(`${API_BASE}/users/profile/picture`, {
method: 'DELETE',
credentials: 'include',
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.message || data.error || 'Remove failed');
}
setImagePreview(null);
toast.success('Photo de profil supprimée avec succès');
onUpdate?.(data.user);
refetch();
} catch (err) {
toast.error(err.message || 'Remove failed');
} finally {
setUploadingImage(false);
}
};
const created_at = user?.created_at ?? user?.createdAt;
const hasChanges = formData.name?.trim() !== (user?.name ?? '');
if (fetchLoading && !initialUser) {
return (
<Card variant="lightDark">
<div className="animate-pulse space-y-4">
<div className="h-24 rounded-xl bg-neutral-200 dark:bg-neutral-700" />
<div className="h-32 rounded-xl bg-neutral-200 dark:bg-neutral-700" />
</div>
</Card>
);
}
if (!user) {
return null;
}
return (
<div className="space-y-6">
<Card variant="lightDark">
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
Photo de profil
</h2>
<div className="flex flex-wrap items-center gap-6">
<div className="relative">
{imagePreview ? (
<img
src={imagePreview}
alt="Profile"
className="w-24 h-24 rounded-full object-cover border-2 border-neutral-200 dark:border-neutral-700"
/>
) : (
<UserAvatar user={user} size="lg" className="w-24 h-24" />
)}
{uploadingImage && (
<div className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Téléchargez une nouvelle photo de profil. Taille max 5MB.
</p>
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
className="hidden"
/>
<Button
type="button"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingImage}
>
{uploadingImage ? 'Téléchargement...' : 'Télécharger une image'}
</Button>
{imagePreview && (
<Button
type="button"
variant="secondary"
onClick={handleRemoveImage}
disabled={uploadingImage}
>
Supprimer
</Button>
)}
</div>
</div>
</div>
</div>
</Card>
<Card variant="lightDark">
<form onSubmit={handleSubmit} className="space-y-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
Informations personnelles
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Nom complet"
type="text"
value={formData.name}
onChange={handleNameChange}
placeholder="Entrez votre nom complet"
required
disabled={saving}
/>
<Input
label="Courriel"
type="email"
value={user.email ?? ''}
disabled
readOnly
description="L'email ne peut pas être modifié"
/>
</div>
{created_at && (
<Input
label="Compte créé"
type="text"
value={new Date(created_at).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
disabled
readOnly
/>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<Button
type="button"
variant="secondary"
onClick={handleReset}
disabled={saving || !hasChanges}
>
Réinitialiser
</Button>
<Button
type="submit"
variant="primary"
disabled={saving || !hasChanges}
loading={saving}
>
{saving ? 'Enregistrement...' : 'Enregistrer les modifications'}
</Button>
</div>
</form>
</Card>
</div>
);
}
@@ -0,0 +1,10 @@
export default function AuthPageHeader({ title, description }) {
return (
<div className="text-center mb-6 flex flex-col gap-1">
<h1 className="text-[22px] font-bold text-neutral-900 dark:text-white">{title}</h1>
{description && (
<p className="text-sm text-neutral-600 dark:text-neutral-400">{description}</p>
)}
</div>
);
}
@@ -1,55 +0,0 @@
'use client';
/**
* Displays the current user's avatar (image or initials fallback).
* Image is loaded from /zen/api/storage/{key}; session cookie is sent automatically.
*
* @param {Object} props
* @param {Object} props.user - User object with optional image (storage key) and name
* @param {'sm'|'md'|'lg'} [props.size='md'] - Size of the avatar
* @param {string} [props.className] - Additional CSS classes for the wrapper
*/
function getImageUrl(imageKey) {
if (!imageKey) return null;
return `/zen/api/storage/${imageKey}`;
}
function getInitials(name) {
if (!name || !name.trim()) return '?';
return name
.trim()
.split(/\s+/)
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
const sizeClasses = {
sm: 'w-8 h-8 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-12 h-12 text-base',
};
export default function UserAvatar({ user, size = 'md', className = '' }) {
const sizeClass = sizeClasses[size] || sizeClasses.md;
const imageUrl = user?.image ? getImageUrl(user.image) : null;
return (
<div
className={`rounded-full overflow-hidden flex items-center justify-center bg-neutral-700 text-white font-medium shrink-0 ${sizeClass} ${className}`}
aria-hidden
>
{imageUrl ? (
<img
src={imageUrl}
alt={user?.name ? `${user.name} avatar` : 'Avatar'}
className="w-full h-full object-cover"
/>
) : (
<span>{getInitials(user?.name)}</span>
)}
</div>
);
}
-90
View File
@@ -1,90 +0,0 @@
'use client';
/**
* User menu: avatar + name with optional dropdown (account link, logout).
* Can receive user from server (e.g. from getSession()) or use useCurrentUser() on client.
*
* @param {Object} props
* @param {Object} [props.user] - User from server (getSession().user). If not provided, useCurrentUser() is used.
* @param {string} [props.accountHref='/dashboard/account'] - Link for "My account"
* @param {string} [props.logoutHref='/auth/logout'] - Link for logout
* @param {string} [props.className] - Extra classes for the menu wrapper
*/
import { Fragment } from 'react';
import { Menu, Transition } from '@headlessui/react';
import UserAvatar from './UserAvatar.js';
import { useCurrentUser } from './useCurrentUser.js';
export default function UserMenu({
user: userProp,
accountHref = '/dashboard/account',
logoutHref = '/auth/logout',
className = '',
}) {
const { user: userFromHook, loading } = useCurrentUser();
const user = userProp ?? userFromHook;
if (loading && !userProp) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className="w-10 h-10 rounded-full bg-neutral-700 animate-pulse" />
<div className="h-4 w-24 bg-neutral-700 rounded animate-pulse" />
</div>
);
}
if (!user) {
return null;
}
return (
<Menu as="div" className={`relative ${className}`}>
<Menu.Button className="flex items-center gap-2 sm:gap-3 px-2 py-1.5 rounded-lg hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-400 transition-colors">
<UserAvatar user={user} size="md" />
<span className="text-sm font-medium text-inherit truncate max-w-[120px] sm:max-w-[160px]">
{user.name || user.email || 'Account'}
</span>
<svg className="w-4 h-4 text-neutral-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 mt-2 w-56 origin-top-right rounded-lg bg-white dark:bg-neutral-900 shadow-lg border border-neutral-200 dark:border-neutral-700 py-1 focus:outline-none z-50">
<div className="px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
<p className="text-sm font-medium text-neutral-900 dark:text-white truncate">{user.name || 'User'}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 truncate">{user.email}</p>
</div>
<Menu.Item>
{({ active }) => (
<a
href={accountHref}
className={`block px-4 py-2 text-sm ${active ? 'bg-neutral-100 dark:bg-neutral-800' : ''} text-neutral-700 dark:text-neutral-300`}
>
My account
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
href={logoutHref}
className={`block px-4 py-2 text-sm ${active ? 'bg-neutral-100 dark:bg-neutral-800' : ''} text-neutral-700 dark:text-neutral-300`}
>
Log out
</a>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
);
}
-6
View File
@@ -1,6 +0,0 @@
'use client';
export { default as UserAvatar } from './UserAvatar.js';
export { default as UserMenu } from './UserMenu.js';
export { default as AccountSection } from './AccountSection.js';
export { useCurrentUser } from './useCurrentUser.js';
@@ -1,66 +0,0 @@
'use client';
/**
* Client hook to fetch the current user from the API.
* Uses session cookie (credentials: 'include'); safe to use in client components.
*
* @returns {{ user: Object|null, loading: boolean, error: string|null, refetch: function }}
*
* @example
* const { user, loading, error, refetch } = useCurrentUser();
* if (loading) return <Spinner />;
* if (error) return <div>Error: {error}</div>;
* if (!user) return <Link href="/auth/login">Log in</Link>;
* return <span>Hello, {user.name}</span>;
*/
import { useState, useEffect, useCallback } from 'react';
const API_BASE = '/zen/api';
export function useCurrentUser() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchUser = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/users/me`, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
});
const data = await res.json();
if (!res.ok) {
if (res.status === 401) {
setUser(null);
return;
}
setError(data.message || data.error || 'Failed to load user');
setUser(null);
return;
}
if (data.user) {
setUser(data.user);
} else {
setUser(null);
}
} catch (err) {
console.error('[useCurrentUser]', err);
setError(err.message || 'Failed to load user');
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchUser();
}, [fetchUser]);
return { user, loading, error, refetch: fetchUser };
}
+62 -1
View File
@@ -4,10 +4,16 @@ import { sendEmail } from '@zen/core/email';
import { VerificationEmail } from './templates/VerificationEmail.js';
import { PasswordResetEmail } from './templates/PasswordResetEmail.js';
import { PasswordChangedEmail } from './templates/PasswordChangedEmail.js';
import { EmailChangeConfirmEmail } from './templates/EmailChangeConfirmEmail.js';
import { EmailChangeNotifyEmail } from './templates/EmailChangeNotifyEmail.js';
import { InvitationEmail } from './templates/InvitationEmail.js';
export { createEmailVerification, verifyEmailToken, createPasswordReset, verifyResetToken, deleteResetToken }
from '../../core/users/verifications.js';
export { createEmailChangeToken, verifyEmailChangeToken, applyEmailChange }
from '../../core/users/emailChange.js';
async function sendVerificationEmail(email, token, baseUrl) {
const appName = process.env.ZEN_NAME || 'ZEN';
const verificationUrl = `${baseUrl}/auth/confirm?email=${encodeURIComponent(email)}&token=${token}`;
@@ -46,4 +52,59 @@ async function sendPasswordChangedEmail(email) {
return result;
}
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail };
async function sendEmailChangeConfirmEmail(newEmail, token, baseUrl) {
const appName = process.env.ZEN_NAME || 'ZEN';
const confirmUrl = `${baseUrl}/admin/confirm-email-change?token=${encodeURIComponent(token)}`;
const html = await render(<EmailChangeConfirmEmail confirmUrl={confirmUrl} newEmail={newEmail} companyName={appName} />);
const result = await sendEmail({ to: newEmail, subject: `Confirmez votre nouvelle adresse courriel ${appName}`, html });
if (!result.success) {
fail(`Auth: failed to send email change confirmation to ${newEmail}: ${result.error}`);
throw new Error('Failed to send email change confirmation');
}
info(`Auth: email change confirmation sent to ${newEmail}`);
return result;
}
async function sendEmailChangeOldNotifyEmail(oldEmail, newEmail, variant) {
const appName = process.env.ZEN_NAME || 'ZEN';
const subjects = {
pending: `Demande de modification de courriel ${appName}`,
changed: `Votre adresse courriel a été modifiée ${appName}`,
};
const subject = subjects[variant] || subjects.changed;
const html = await render(<EmailChangeNotifyEmail oldEmail={oldEmail} newEmail={newEmail} variant={variant} companyName={appName} />);
const result = await sendEmail({ to: oldEmail, subject, html });
if (!result.success) {
fail(`Auth: failed to send email change notification to ${oldEmail}: ${result.error}`);
throw new Error('Failed to send email change notification');
}
info(`Auth: email change notification (${variant}) sent to ${oldEmail}`);
return result;
}
async function sendEmailChangeNewNotifyEmail(newEmail, oldEmail) {
const appName = process.env.ZEN_NAME || 'ZEN';
const html = await render(<EmailChangeNotifyEmail oldEmail={oldEmail} newEmail={newEmail} variant="admin_new" companyName={appName} />);
const result = await sendEmail({ to: newEmail, subject: `Votre compte est maintenant associé à cette adresse ${appName}`, html });
if (!result.success) {
fail(`Auth: failed to send email change welcome to ${newEmail}: ${result.error}`);
throw new Error('Failed to send email change welcome');
}
info(`Auth: email change welcome sent to ${newEmail}`);
return result;
}
async function sendInvitationEmail(email, token, baseUrl) {
const appName = process.env.ZEN_NAME || 'ZEN';
const setupUrl = `${baseUrl}/auth/setup?email=${encodeURIComponent(email)}&token=${token}`;
const html = await render(<InvitationEmail setupUrl={setupUrl} companyName={appName} />);
const result = await sendEmail({ to: email, subject: `Terminez la création de votre compte ${appName}`, html });
if (!result.success) {
fail(`Auth: failed to send invitation email to ${email}: ${result.error}`);
throw new Error('Failed to send invitation email');
}
info(`Auth: invitation email sent to ${email}`);
return result;
}
export { sendVerificationEmail, sendPasswordResetEmail, sendPasswordChangedEmail, sendEmailChangeConfirmEmail, sendEmailChangeOldNotifyEmail, sendEmailChangeNewNotifyEmail, sendInvitationEmail };
+11 -19
View File
@@ -1,7 +1,11 @@
/**
* Zen Authentication — server barrel.
* Client components live in @zen/core/features/auth/components.
* Server actions live in @zen/core/features/auth/actions.
* Zen Authentication — server barrel (Next.js-free).
*
* Ne re-exporte PAS actions.js — ce fichier importe `next/headers` au niveau
* top-level et ne peut pas être tiré via ce barrel (qui peut être importé par
* des modules externes pendant leur register(), avant que Next.js ait activé
* ses alias de modules). Importer les server actions via
* @zen/core/features/auth/actions.
*/
export {
@@ -10,7 +14,8 @@ export {
requestPasswordReset,
resetPassword,
verifyUserEmail,
updateUser
updateUser,
completeAccountSetup
} from './auth.js';
export {
@@ -29,7 +34,8 @@ export {
deleteResetToken,
sendVerificationEmail,
sendPasswordResetEmail,
sendPasswordChangedEmail
sendPasswordChangedEmail,
sendInvitationEmail
} from './email.js';
export {
@@ -38,17 +44,3 @@ export {
generateToken,
generateId
} from './password.js';
export { protect, checkAuth, requireRole } from './protect.js';
export {
registerAction,
loginAction,
logoutAction,
getSession,
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
setSessionCookie,
refreshSessionCookie
} from './actions.js';
@@ -1,10 +1,8 @@
'use client';
/**
* Confirm Email Page Component
*/
import { useState, useEffect, useRef } from 'react';
import { Card } from '@zen/core/shared/components';
import AuthPageHeader from '../components/AuthPageHeader.js';
export default function ConfirmEmailPage({ onSubmit, onNavigate, email, token }) {
const [error, setError] = useState('');
@@ -15,26 +13,20 @@ export default function ConfirmEmailPage({ onSubmit, onNavigate, email, token })
useEffect(() => {
console.log('ConfirmEmailPage useEffect triggered', { email, token, hasVerified });
// Check for persisted success message on mount
const persistedSuccess = sessionStorage.getItem('emailVerificationSuccess');
console.log('Persisted success message:', persistedSuccess);
if (persistedSuccess) {
console.log('Restoring persisted success message');
setSuccess(persistedSuccess);
setIsLoading(false);
setHasVerified(true); // Mark as verified to prevent re-verification
// Clear the persisted message after showing it
setHasVerified(true);
sessionStorage.removeItem('emailVerificationSuccess');
// Redirect after showing the message
setTimeout(() => {
onNavigate('login');
}, 3000);
setTimeout(() => onNavigate('login'), 3000);
return;
}
// Auto-verify on mount, but only once
if (email && token && !hasVerified && !isVerifyingRef.current) {
console.log('Starting email verification');
verifyEmail();
@@ -46,22 +38,18 @@ export default function ConfirmEmailPage({ onSubmit, onNavigate, email, token })
}, [email, token, hasVerified, onNavigate]);
async function verifyEmail() {
// Prevent multiple calls
if (hasVerified || isVerifyingRef.current) {
console.log('Email verification already attempted or in progress');
return;
}
// Set flags IMMEDIATELY to prevent multiple calls
isVerifyingRef.current = true;
setHasVerified(true);
// Clear any existing states at the start
setError('');
setSuccess('');
console.log('Starting email verification for:', email);
const formData = new FormData();
formData.set('email', email);
formData.set('token', token);
@@ -69,20 +57,14 @@ export default function ConfirmEmailPage({ onSubmit, onNavigate, email, token })
try {
const result = await onSubmit(formData);
console.log('Verification result:', result);
if (result.success) {
console.log('Verification successful');
const successMessage = result.message || 'E-mail vérifié avec succès. Vous pouvez maintenant vous connecter.';
// Persist success message in sessionStorage
sessionStorage.setItem('emailVerificationSuccess', successMessage);
setSuccess(successMessage);
setIsLoading(false);
// Redirect to login after 3 seconds
setTimeout(() => {
onNavigate('login');
}, 3000);
setTimeout(() => onNavigate('login'), 3000);
} else {
console.log('Verification failed:', result.error);
setError(result.error || 'Échec de la vérification de l\'e-mail');
@@ -98,65 +80,48 @@ export default function ConfirmEmailPage({ onSubmit, onNavigate, email, token })
console.log('ConfirmEmailPage render', { success, error, isLoading, hasVerified });
return (
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Vérification de l'e-mail
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Nous vérifions votre adresse e-mail...
</p>
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader title="Vérification de l'e-mail" description="Nous vérifions votre adresse e-mail..." />
{isLoading && (
<div className="flex flex-col items-center py-10">
<div className="w-12 h-12 border-4 border-neutral-300 border-t-neutral-900 rounded-full animate-spin dark:border-neutral-700/50 dark:border-t-white"></div>
<p className="mt-5 text-neutral-600 dark:text-neutral-400 text-sm">Vérification de votre e-mail en cours...</p>
</div>
)}
{isLoading && (
<div className="flex flex-col items-center py-10">
<div className="w-12 h-12 border-4 border-neutral-300 border-t-neutral-900 rounded-full animate-spin dark:border-neutral-700/50 dark:border-t-white"></div>
<p className="mt-5 text-neutral-600 dark:text-neutral-400 text-sm">Vérification de votre e-mail en cours...</p>
{success && !error && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
)}
</div>
)}
{/* Success Message - Only show if success and no error */}
{success && !error && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
{error && !success && (
<div>
<div className="mb-4 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 space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{/* Error Message - Only show if error and no success */}
{error && !success && (
<div>
<div className="mb-4 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 space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800/50 text-center">
<a
href="#"
onClick={(e) => {
e.preventDefault();
onNavigate('login');
}}
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
>
Retour à la connexion
</a>
</div>
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800/50 text-center">
<a
href="#"
onClick={(e) => { e.preventDefault(); onNavigate('login'); }}
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
>
Retour à la connexion
</a>
</div>
)}
</div>
)}
{/* Redirect message - Only show if success and no error */}
{success && !error && (
<p className="text-center text-neutral-600 dark:text-neutral-400 text-sm mt-3">Redirection vers la connexion...</p>
)}
</div>
{success && !error && (
<p className="text-center text-neutral-600 dark:text-neutral-400 text-sm mt-3">Redirection vers la connexion...</p>
)}
</Card>
);
}
@@ -1,18 +1,14 @@
'use client';
/**
* Forgot Password Page Component
*/
import { useState, useEffect } from 'react';
import { Card, Input, Button } from '@zen/core/shared/components';
import AuthPageHeader from '../components/AuthPageHeader.js';
export default function ForgotPasswordPage({ onSubmit, onNavigate, currentUser = null }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [success, setSuccess] = useState('');
const [formData, setFormData] = useState({
email: ''
});
const [email, setEmail] = useState('');
const [honeypot, setHoneypot] = useState('');
const [formLoadedAt, setFormLoadedAt] = useState(0);
@@ -20,14 +16,6 @@ export default function ForgotPasswordPage({ onSubmit, onNavigate, currentUser =
setFormLoadedAt(Date.now());
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
async function handleSubmit(e) {
e.preventDefault();
setError('');
@@ -35,13 +23,13 @@ export default function ForgotPasswordPage({ onSubmit, onNavigate, currentUser =
setIsLoading(true);
const submitData = new FormData();
submitData.append('email', formData.email);
submitData.append('email', email);
submitData.append('_hp', honeypot);
submitData.append('_t', String(formLoadedAt));
try {
const result = await onSubmit(submitData);
if (result.success) {
setSuccess(result.message);
setIsLoading(false);
@@ -56,119 +44,83 @@ export default function ForgotPasswordPage({ onSubmit, onNavigate, currentUser =
}
}
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
return (
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Mot de passe oublié
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.
</p>
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader title="Mot de passe oublié" description="Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe." />
{currentUser && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-blue-700 dark:text-blue-400">
Vous êtes connecté en tant que <span className="font-medium">{currentUser.name}</span>.{' '}
<a href="/auth/logout" className="underline hover:text-blue-600 dark:hover:text-blue-300 transition-colors duration-200">
Se déconnecter ?
</a>
</span>
</div>
</div>
)}
{error && !currentUser && !success && (
<div className="mb-4 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 space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{success && !currentUser && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
<label htmlFor="_hp_forgot">Website</label>
<input id="_hp_forgot" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
</div>
{/* Already Connected Message */}
{currentUser && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-blue-700 dark:text-blue-400">
Vous êtes connecté en tant que <span className="font-medium">{currentUser.name}</span>.{' '}
<a
href="/auth/logout"
className="underline hover:text-blue-600 dark:hover:text-blue-300 transition-colors duration-200"
>
Se déconnecter ?
</a>
</span>
</div>
</div>
</div>
)}
<Input
id="email"
name="email"
type="email"
label="Courriel"
value={email}
onChange={setEmail}
placeholder="votre@courriel.com"
disabled={!!success || !!currentUser}
autoComplete="email"
required
/>
{/* Error Message */}
{error && (
<div className="mb-4 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 space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
<Button
type="submit"
variant="primary"
size="lg"
loading={isLoading}
disabled={!!success || !!currentUser}
className="w-full mt-2"
>
Envoyer le lien de réinitialisation
</Button>
</form>
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{/* Forgot Password Form */}
<form onSubmit={handleSubmit} className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
{/* Honeypot — invisible to humans, filled by bots */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
<label htmlFor="_hp_forgot">Website</label>
<input id="_hp_forgot" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
</div>
<div>
<label htmlFor="email" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
E-mail
</label>
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="your@email.com"
autoComplete="email"
/>
</div>
<button
type="submit"
disabled={isLoading || success || currentUser}
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
<span>Envoi en cours...</span>
</div>
) : (
'Envoyer le lien de réinitialisation'
)}
</button>
</form>
{/* Back to Login Link */}
<div className={`mt-6 text-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<a
href="#"
onClick={(e) => {
e.preventDefault();
if (!currentUser) {
onNavigate('login');
}
}}
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
>
Retour à la connexion
</a>
</div>
<div className={`mt-6 flex justify-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<Button
type="button"
variant="fullghost"
disabled={!!currentUser}
onClick={() => { if (!currentUser) onNavigate('login'); }}
>
Retour à la connexion
</Button>
</div>
</Card>
);
}
+97 -155
View File
@@ -1,20 +1,15 @@
'use client';
/**
* Login Page Component
*/
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Card, Input, Button } from '@zen/core/shared/components';
import AuthPageHeader from '../components/AuthPageHeader.js';
export default function LoginPage({ onSubmit, onNavigate, onSetSessionCookie, redirectAfterLogin = '/', currentUser = null }) {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
email: '',
password: ''
});
const [formData, setFormData] = useState({ email: '', password: '' });
const [honeypot, setHoneypot] = useState('');
const [formLoadedAt, setFormLoadedAt] = useState(0);
const router = useRouter();
@@ -23,21 +18,12 @@ export default function LoginPage({ onSubmit, onNavigate, onSetSessionCookie, re
setFormLoadedAt(Date.now());
}, []);
// If already logged in, redirect to redirectAfterLogin
useEffect(() => {
if (currentUser) {
router.replace(redirectAfterLogin);
}
}, [currentUser, redirectAfterLogin, router]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !isLoading && !success) {
handleSubmit();
@@ -57,21 +43,16 @@ export default function LoginPage({ onSubmit, onNavigate, onSetSessionCookie, re
try {
const result = await onSubmit(submitData);
if (result.success) {
const successMsg = result.message || 'Connexion réussie ! Redirection...';
// Display success message immediately (no page refresh because we didn't set cookie yet)
setSuccess(successMsg);
setIsLoading(false);
// Wait for user to see the success message
setTimeout(async () => {
// Now set the session cookie (this might cause a refresh, but we're redirecting anyway)
if (result.sessionToken && onSetSessionCookie) {
await onSetSessionCookie(result.sessionToken);
}
// Then navigate
router.push(redirectAfterLogin);
}, 1500);
} else {
@@ -85,144 +66,105 @@ export default function LoginPage({ onSubmit, onNavigate, onSetSessionCookie, re
}
};
const inputClasses = 'w-full px-[10px] py-[7px] rounded text-[13px] focus:outline-none transition-all duration-[120ms] ease-out disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
return (
<div className="bg-white dark:bg-neutral-900/40 border border-neutral-200 dark:border-neutral-800/50 rounded-md px-4 py-6 md:px-6 md:py-8 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Connexion
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Veuillez vous connecter pour continuer.
</p>
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader title="Connexion" description="Veuillez vous connecter pour continuer." />
{currentUser && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
<div className="flex items-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-blue-400/50 border-t-blue-600 rounded-full animate-spin flex-shrink-0 dark:border-blue-500/30 dark:border-t-blue-500"></div>
<span className="text-xs text-blue-700 dark:text-blue-400">Redirection...</span>
</div>
</div>
)}
{success && !currentUser && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{error && !currentUser && !success && (
<div className="mb-4 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 space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
<div className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
<label htmlFor="_hp_login">Website</label>
<input id="_hp_login" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
</div>
{/* Already logged in: redirecting (brief message while redirect runs) */}
{currentUser && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
<div className="flex items-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-blue-400/50 border-t-blue-600 rounded-full animate-spin flex-shrink-0 dark:border-blue-500/30 dark:border-t-blue-500"></div>
<span className="text-xs text-blue-700 dark:text-blue-400">Redirection...</span>
</div>
</div>
)}
<Input
id="email"
name="email"
type="email"
label="Courriel"
value={formData.email}
onChange={(value) => setFormData(prev => ({ ...prev, email: value }))}
onKeyDown={handleKeyPress}
placeholder="votre@courriel.com"
disabled={!!success || !!currentUser}
required
/>
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
<div className="flex flex-col">
<div className="flex items-center justify-between mb-1.5">
<span className="text-xs font-medium text-neutral-700 dark:text-white">Mot de passe</span>
<a
href="#"
onClick={(e) => {
e.preventDefault();
if (!currentUser) onNavigate('forgot');
}}
className="text-xs text-neutral-600 hover:text-neutral-900 transition-colors duration-200 dark:text-neutral-300 dark:hover:text-white"
>
Mot de passe oublié ?
</a>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-4 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 space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{/* Login Form */}
<div className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
{/* Honeypot — invisible to humans, filled by bots */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
<label htmlFor="_hp_login">Website</label>
<input id="_hp_login" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
</div>
<div>
<label htmlFor="email" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
E-mail
</label>
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
onKeyDown={handleKeyPress}
disabled={success || currentUser}
className={inputClasses}
placeholder="your@email.com"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label htmlFor="password" className="block text-xs font-medium text-neutral-700 dark:text-white">
Mot de passe
</label>
<a
href="#"
onClick={(e) => {
e.preventDefault();
if (!currentUser) {
onNavigate('forgot');
}
}}
className="text-xs text-neutral-600 hover:text-neutral-900 transition-colors duration-200 dark:text-neutral-300 dark:hover:text-white"
>
Mot de passe oublié ?
</a>
</div>
<input
id="password"
name="password"
type="password"
required
value={formData.password}
onChange={handleChange}
onKeyDown={handleKeyPress}
disabled={success || currentUser}
className={inputClasses}
placeholder="••••••••"
/>
</div>
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || success || currentUser}
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-[7px] px-4 rounded text-[13px] font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-[120ms] ease-out focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
<span>Connexion en cours...</span>
</div>
) : (
'Se connecter'
)}
</button>
<Input
id="password"
name="password"
type="password"
value={formData.password}
onChange={(value) => setFormData(prev => ({ ...prev, password: value }))}
onKeyDown={handleKeyPress}
placeholder="••••••••"
disabled={!!success || !!currentUser}
/>
</div>
{/* Register Link */}
<div className={`mt-6 text-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<a
href="#"
onClick={(e) => {
e.preventDefault();
if (!currentUser) {
onNavigate('register');
}
}}
className="group flex items-center justify-center gap-2"
>
<span className="text-sm text-neutral-600 dark:text-neutral-400">Pas de compte ? </span>
<span className="text-sm text-neutral-900 group-hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:group-hover:text-neutral-300">S'inscrire</span>
</a>
</div>
<Button
type="button"
variant="primary"
size="lg"
loading={isLoading}
disabled={!!success || !!currentUser}
onClick={handleSubmit}
className="w-full mt-2"
>
Se connecter
</Button>
</div>
<div className={`mt-6 flex justify-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<Button
type="button"
variant="fullghost"
disabled={!!currentUser}
onClick={() => { if (!currentUser) onNavigate('register'); }}
>
Pas de compte ? S'inscrire
</Button>
</div>
</Card>
);
}
+26 -55
View File
@@ -1,11 +1,9 @@
'use client';
/**
* Logout Page Component
*/
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Card, Button } from '@zen/core/shared/components';
import AuthPageHeader from '../components/AuthPageHeader.js';
export default function LogoutPage({ onLogout, onSetSessionCookie }) {
const [error, setError] = useState('');
@@ -19,10 +17,8 @@ export default function LogoutPage({ onLogout, onSetSessionCookie }) {
setIsLoading(true);
try {
// Call the logout action if provided
if (onLogout) {
const result = await onLogout();
if (result && !result.success) {
setError(result.error || 'Échec de la déconnexion');
setIsLoading(false);
@@ -30,20 +26,14 @@ export default function LogoutPage({ onLogout, onSetSessionCookie }) {
}
}
// Clear session cookie if provided
if (onSetSessionCookie) {
await onSetSessionCookie('', { expires: new Date(0) });
}
// Show success message
setSuccess('Vous avez été déconnecté. Redirection...');
setIsLoading(false);
// Wait for user to see the success message, then redirect
setTimeout(() => {
router.push('/');
}, 100);
setTimeout(() => router.push('/'), 100);
} catch (err) {
console.error('Logout error:', err);
setError('Une erreur inattendue s\'est produite lors de la déconnexion');
@@ -52,18 +42,9 @@ export default function LogoutPage({ onLogout, onSetSessionCookie }) {
};
return (
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Prêt à vous déconnecter ?
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Cela mettra fin à votre session et vous déconnectera de votre compte.
</p>
</div>
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader title="Prêt à vous déconnecter ?" description="Cela mettra fin à votre session et vous déconnectera de votre compte." />
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
@@ -73,8 +54,7 @@ export default function LogoutPage({ onLogout, onSetSessionCookie }) {
</div>
)}
{/* Error Message */}
{error && (
{error && !success && (
<div className="mb-4 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 space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
@@ -83,35 +63,26 @@ export default function LogoutPage({ onLogout, onSetSessionCookie }) {
</div>
)}
{/* Logout Button */}
<div className="flex flex-col gap-4">
<button
type="button"
onClick={handleLogout}
disabled={isLoading || success}
className="cursor-pointer w-full bg-red-600 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-red-500/20 dark:focus:ring-red-400/30"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
<span>Déconnexion en cours...</span>
</div>
) : (
'Se déconnecter'
)}
</button>
</div>
<Button
type="button"
variant="danger"
loading={isLoading}
disabled={!!success}
onClick={handleLogout}
className="w-full mt-2"
>
Se déconnecter
</Button>
{/* Cancel Link */}
<div className="mt-6 text-center">
<span className="text-sm text-neutral-600 dark:text-neutral-400">Vous avez changé d'avis ? </span>
<a
href="/"
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
>
Retour
</a>
</div>
</div>
<span className="text-sm text-neutral-600 dark:text-neutral-400">Vous avez changé d'avis ? </span>
<a
href="/"
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
>
Retour
</a>
</div>
</Card>
);
}
+136 -247
View File
@@ -1,11 +1,8 @@
'use client';
/**
* Register Page Component
*/
import { useState, useEffect } from 'react';
import { PasswordStrengthIndicator } from '@zen/core/shared/components';
import { Card, Input, Button, PasswordStrengthIndicator } from '@zen/core/shared/components';
import AuthPageHeader from '../components/AuthPageHeader.js';
export default function RegisterPage({ onSubmit, onNavigate, currentUser = null }) {
const [error, setError] = useState('');
@@ -24,107 +21,50 @@ export default function RegisterPage({ onSubmit, onNavigate, currentUser = null
setFormLoadedAt(Date.now());
}, []);
// Validation functions
const validateEmail = (email) => {
const errors = [];
if (email.length > 254) {
errors.push('L\'e-mail doit contenir 254 caractères ou moins');
}
if (email.length > 254) errors.push('L\'e-mail doit contenir 254 caractères ou moins');
return errors;
};
const validatePassword = (password) => {
const errors = [];
if (password.length < 8) {
errors.push('Le mot de passe doit contenir au moins 8 caractères');
}
if (password.length > 128) {
errors.push('Le mot de passe doit contenir 128 caractères ou moins');
}
if (!/[A-Z]/.test(password)) {
errors.push('Le mot de passe doit contenir au moins une majuscule');
}
if (!/[a-z]/.test(password)) {
errors.push('Le mot de passe doit contenir au moins une minuscule');
}
if (!/\d/.test(password)) {
errors.push('Le mot de passe doit contenir au moins un chiffre');
}
if (password.length < 8) errors.push('Le mot de passe doit contenir au moins 8 caractères');
if (password.length > 128) errors.push('Le mot de passe doit contenir 128 caractères ou moins');
if (!/[A-Z]/.test(password)) errors.push('Le mot de passe doit contenir au moins une majuscule');
if (!/[a-z]/.test(password)) errors.push('Le mot de passe doit contenir au moins une minuscule');
if (!/\d/.test(password)) errors.push('Le mot de passe doit contenir au moins un chiffre');
return errors;
};
const validateName = (name) => {
const errors = [];
if (name.trim().length === 0) {
errors.push('Le nom ne peut pas être vide');
}
if (name.length > 100) {
errors.push('Le nom doit contenir 100 caractères ou moins');
}
if (name.trim().length === 0) errors.push('Le nom ne peut pas être vide');
if (name.length > 100) errors.push('Le nom doit contenir 100 caractères ou moins');
return errors;
};
const isFormValid = () => {
const emailErrors = validateEmail(formData.email);
const passwordErrors = validatePassword(formData.password);
const nameErrors = validateName(formData.name);
return emailErrors.length === 0 &&
passwordErrors.length === 0 &&
nameErrors.length === 0 &&
return validateEmail(formData.email).length === 0 &&
validatePassword(formData.password).length === 0 &&
validateName(formData.name).length === 0 &&
formData.password === formData.confirmPassword &&
formData.email.trim().length > 0;
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
async function handleSubmit(e) {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
// Frontend validation
const emailErrors = validateEmail(formData.email);
const passwordErrors = validatePassword(formData.password);
const nameErrors = validateName(formData.name);
if (emailErrors.length > 0) {
setError(emailErrors[0]); // Show first error
setIsLoading(false);
return;
}
if (passwordErrors.length > 0) {
setError(passwordErrors[0]); // Show first error
setIsLoading(false);
return;
}
if (nameErrors.length > 0) {
setError(nameErrors[0]); // Show first error
setIsLoading(false);
return;
}
// Validate password match
if (emailErrors.length > 0) { setError(emailErrors[0]); setIsLoading(false); return; }
if (passwordErrors.length > 0) { setError(passwordErrors[0]); setIsLoading(false); return; }
if (nameErrors.length > 0) { setError(nameErrors[0]); setIsLoading(false); return; }
if (formData.password !== formData.confirmPassword) {
setError('Les mots de passe ne correspondent pas');
setIsLoading(false);
@@ -141,7 +81,7 @@ export default function RegisterPage({ onSubmit, onNavigate, currentUser = null
try {
const result = await onSubmit(submitData);
if (result.success) {
setSuccess(result.message);
setIsLoading(false);
@@ -156,182 +96,131 @@ export default function RegisterPage({ onSubmit, onNavigate, currentUser = null
}
}
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
return (
<div className="bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Créer un compte
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Inscrivez-vous pour commencer.
</p>
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader title="Créer un compte" description="Inscrivez-vous pour commencer." />
{currentUser && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-blue-700 dark:text-blue-400">
Vous êtes connecté en tant que <span className="font-medium">{currentUser.name}</span>.{' '}
<a href="/auth/logout" className="underline hover:text-blue-600 dark:hover:text-blue-300 transition-colors duration-200">
Se déconnecter ?
</a>
</span>
</div>
</div>
)}
{error && !currentUser && !success && (
<div className="mb-4 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 space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{success && !currentUser && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
<label htmlFor="_hp_register">Website</label>
<input id="_hp_register" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
</div>
{/* Already Connected Message */}
{currentUser && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-blue-700 dark:text-blue-400">
Vous êtes connecté en tant que <span className="font-medium">{currentUser.name}</span>.{' '}
<a
href="/auth/logout"
className="underline hover:text-blue-600 dark:hover:text-blue-300 transition-colors duration-200"
>
Se déconnecter ?
</a>
</span>
</div>
</div>
</div>
)}
<Input
id="name"
name="name"
type="text"
label="Nom complet"
value={formData.name}
onChange={(value) => setFormData(prev => ({ ...prev, name: value }))}
placeholder="Lou Doe"
disabled={!!success || !!currentUser}
maxLength="100"
autoComplete="name"
required
/>
{/* Error Message */}
{error && (
<div className="mb-4 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 space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
<Input
id="email"
name="email"
type="email"
label="Courriel"
value={formData.email}
onChange={(value) => setFormData(prev => ({ ...prev, email: value }))}
placeholder="votre@courriel.com"
disabled={!!success || !!currentUser}
maxLength="254"
autoComplete="email"
required
/>
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{/* Registration Form */}
<form onSubmit={handleSubmit} className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
{/* Honeypot — invisible to humans, filled by bots */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
<label htmlFor="_hp_register">Website</label>
<input id="_hp_register" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
</div>
<div>
<label htmlFor="name" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Nom complet
</label>
<input
id="name"
name="name"
type="text"
required
maxLength="100"
value={formData.name}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="John Doe"
autoComplete="name"
/>
</div>
<div>
<label htmlFor="email" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
E-mail
</label>
<input
id="email"
name="email"
type="email"
required
maxLength="254"
value={formData.email}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="your@email.com"
autoComplete="email"
/>
</div>
<div>
<label htmlFor="password" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Mot de passe
</label>
<input
id="password"
name="password"
type="password"
required
minLength="8"
maxLength="128"
value={formData.password}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="••••••••"
autoComplete="new-password"
/>
<PasswordStrengthIndicator password={formData.password} showRequirements={true} />
</div>
<div>
<label htmlFor="confirmPassword" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Confirmer le mot de passe
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength="8"
maxLength="128"
value={formData.confirmPassword}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="••••••••"
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={isLoading || success || currentUser || !isFormValid()}
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
<span>Création du compte en cours...</span>
</div>
) : (
'Créer un compte'
)}
</button>
</form>
{/* Login Link */}
<div className={`mt-6 text-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<a
href="#"
onClick={(e) => {
e.preventDefault();
if (!currentUser) {
onNavigate('login');
}
}}
className="group flex items-center justify-center gap-2"
>
<span className="text-sm text-neutral-600 dark:text-neutral-400">Vous avez déjà un compte ? </span>
<span className="text-sm text-neutral-900 group-hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:group-hover:text-neutral-300">Se connecter</span>
</a>
<div>
<Input
id="password"
name="password"
type="password"
label="Mot de passe"
value={formData.password}
onChange={(value) => setFormData(prev => ({ ...prev, password: value }))}
placeholder="••••••••"
disabled={!!success || !!currentUser}
minLength="8"
maxLength="128"
autoComplete="new-password"
required
/>
<PasswordStrengthIndicator password={formData.password} showRequirements={true} />
</div>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
label="Confirmer le mot de passe"
value={formData.confirmPassword}
onChange={(value) => setFormData(prev => ({ ...prev, confirmPassword: value }))}
placeholder="••••••••"
disabled={!!success || !!currentUser}
minLength="8"
maxLength="128"
autoComplete="new-password"
required
/>
<Button
type="submit"
variant="primary"
size="lg"
loading={isLoading}
disabled={!!success || !!currentUser || !isFormValid()}
className="w-full mt-2"
>
Créer un compte
</Button>
</form>
<div className={`mt-6 flex justify-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<Button
type="button"
variant="fullghost"
disabled={!!currentUser}
onClick={() => { if (!currentUser) onNavigate('login'); }}
>
Vous avez déjà un compte ? Se connecter
</Button>
</div>
</Card>
);
}
@@ -1,80 +1,39 @@
'use client';
/**
* Reset Password Page Component
*/
import { useState } from 'react';
import { PasswordStrengthIndicator } from '@zen/core/shared/components';
import { Card, Input, Button, PasswordStrengthIndicator } from '@zen/core/shared/components';
import AuthPageHeader from '../components/AuthPageHeader.js';
export default function ResetPasswordPage({ onSubmit, onNavigate, email, token }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [success, setSuccess] = useState('');
const [formData, setFormData] = useState({
newPassword: '',
confirmPassword: ''
});
const [formData, setFormData] = useState({ newPassword: '', confirmPassword: '' });
// Validation functions
const validatePassword = (password) => {
const errors = [];
if (password.length < 8) {
errors.push('Le mot de passe doit contenir au moins 8 caractères');
}
if (password.length > 128) {
errors.push('Le mot de passe doit contenir 128 caractères ou moins');
}
if (!/[A-Z]/.test(password)) {
errors.push('Le mot de passe doit contenir au moins une majuscule');
}
if (!/[a-z]/.test(password)) {
errors.push('Le mot de passe doit contenir au moins une minuscule');
}
if (!/\d/.test(password)) {
errors.push('Le mot de passe doit contenir au moins un chiffre');
}
if (password.length < 8) errors.push('Le mot de passe doit contenir au moins 8 caractères');
if (password.length > 128) errors.push('Le mot de passe doit contenir 128 caractères ou moins');
if (!/[A-Z]/.test(password)) errors.push('Le mot de passe doit contenir au moins une majuscule');
if (!/[a-z]/.test(password)) errors.push('Le mot de passe doit contenir au moins une minuscule');
if (!/\d/.test(password)) errors.push('Le mot de passe doit contenir au moins un chiffre');
return errors;
};
const isFormValid = () => {
const passwordErrors = validatePassword(formData.newPassword);
return passwordErrors.length === 0 &&
return validatePassword(formData.newPassword).length === 0 &&
formData.newPassword === formData.confirmPassword &&
formData.newPassword.length > 0;
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
async function handleSubmit(e) {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
// Frontend validation
const passwordErrors = validatePassword(formData.newPassword);
if (passwordErrors.length > 0) {
setError(passwordErrors[0]); // Show first error
setIsLoading(false);
return;
}
// Validate password match
if (passwordErrors.length > 0) { setError(passwordErrors[0]); setIsLoading(false); return; }
if (formData.newPassword !== formData.confirmPassword) {
setError('Les mots de passe ne correspondent pas');
setIsLoading(false);
@@ -89,14 +48,11 @@ export default function ResetPasswordPage({ onSubmit, onNavigate, email, token }
try {
const result = await onSubmit(submitData);
if (result.success) {
setSuccess(result.message);
setIsLoading(false);
// Redirect to login after 2 seconds
setTimeout(() => {
onNavigate('login');
}, 2000);
setTimeout(() => onNavigate('login'), 2000);
} else {
setError(result.error || 'Échec de la réinitialisation du mot de passe');
setIsLoading(false);
@@ -108,115 +64,83 @@ export default function ResetPasswordPage({ onSubmit, onNavigate, email, token }
}
}
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
return (
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Réinitialiser le mot de passe
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Saisissez votre nouveau mot de passe ci-dessous.
</p>
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader title="Réinitialiser le mot de passe" description="Saisissez votre nouveau mot de passe ci-dessous." />
{error && !success && (
<div className="mb-4 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 space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<Input
id="newPassword"
name="newPassword"
type="password"
label="Nouveau mot de passe"
value={formData.newPassword}
onChange={(value) => setFormData(prev => ({ ...prev, newPassword: value }))}
placeholder="••••••••"
disabled={!!success}
minLength="8"
maxLength="128"
autoComplete="new-password"
required
/>
<PasswordStrengthIndicator password={formData.newPassword} showRequirements={true} />
</div>
{/* Error Message */}
{error && (
<div className="mb-4 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 space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
label="Confirmer le mot de passe"
value={formData.confirmPassword}
onChange={(value) => setFormData(prev => ({ ...prev, confirmPassword: value }))}
placeholder="••••••••"
disabled={!!success}
minLength="8"
maxLength="128"
autoComplete="new-password"
required
/>
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
<Button
type="submit"
variant="primary"
size="lg"
loading={isLoading}
disabled={!!success || !isFormValid()}
className="w-full mt-2"
>
Réinitialiser le mot de passe
</Button>
</form>
{/* Reset Password Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label htmlFor="newPassword" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Nouveau mot de passe
</label>
<input
id="newPassword"
name="newPassword"
type="password"
required
minLength="8"
maxLength="128"
value={formData.newPassword}
onChange={handleChange}
disabled={success}
className={inputClasses}
placeholder="••••••••"
autoComplete="new-password"
/>
<PasswordStrengthIndicator password={formData.newPassword} showRequirements={true} />
</div>
<div>
<label htmlFor="confirmPassword" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Confirmer le mot de passe
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength="8"
maxLength="128"
value={formData.confirmPassword}
onChange={handleChange}
disabled={success}
className={inputClasses}
placeholder="••••••••"
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={isLoading || success || !isFormValid()}
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
<span>Réinitialisation...</span>
</div>
) : (
'Réinitialiser le mot de passe'
)}
</button>
</form>
{/* Back to Login Link */}
<div className="mt-6 text-center">
<a
href="#"
onClick={(e) => {
e.preventDefault();
onNavigate('login');
}}
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
>
Retour à la connexion
</a>
</div>
<div className="mt-6 flex justify-center">
<Button
type="button"
variant="fullghost"
onClick={() => onNavigate('login')}
>
Retour à la connexion
</Button>
</div>
</Card>
);
}
@@ -0,0 +1,149 @@
'use client';
import { useState } from 'react';
import { Card, Input, Button, PasswordStrengthIndicator } from '@zen/core/shared/components';
import AuthPageHeader from '../components/AuthPageHeader.js';
export default function SetupAccountPage({ onSubmit, onNavigate, email, token }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [success, setSuccess] = useState('');
const [formData, setFormData] = useState({ newPassword: '', confirmPassword: '' });
const validatePassword = (password) => {
const errors = [];
if (password.length < 8) errors.push('Le mot de passe doit contenir au moins 8 caractères');
if (password.length > 128) errors.push('Le mot de passe doit contenir 128 caractères ou moins');
if (!/[A-Z]/.test(password)) errors.push('Le mot de passe doit contenir au moins une majuscule');
if (!/[a-z]/.test(password)) errors.push('Le mot de passe doit contenir au moins une minuscule');
if (!/\d/.test(password)) errors.push('Le mot de passe doit contenir au moins un chiffre');
return errors;
};
const isFormValid = () => {
return validatePassword(formData.newPassword).length === 0 &&
formData.newPassword === formData.confirmPassword &&
formData.newPassword.length > 0;
};
async function handleSubmit(e) {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
const passwordErrors = validatePassword(formData.newPassword);
if (passwordErrors.length > 0) { setError(passwordErrors[0]); setIsLoading(false); return; }
if (formData.newPassword !== formData.confirmPassword) {
setError('Les mots de passe ne correspondent pas');
setIsLoading(false);
return;
}
const submitData = new FormData();
submitData.append('newPassword', formData.newPassword);
submitData.append('confirmPassword', formData.confirmPassword);
submitData.append('email', email);
submitData.append('token', token);
try {
const result = await onSubmit(submitData);
if (result.success) {
setSuccess(result.message);
setIsLoading(false);
setTimeout(() => onNavigate('login'), 2000);
} else {
setError(result.error || 'Impossible de créer le mot de passe');
setIsLoading(false);
}
} catch (err) {
console.error('Setup account error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
return (
<Card variant="default" padding="md" spacing="none" className="w-full max-w-md mx-auto">
<AuthPageHeader
title="Créez votre mot de passe"
description="Un administrateur a créé votre compte. Choisissez un mot de passe pour y accéder."
/>
{error && !success && (
<div className="mb-4 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 space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<Input
id="newPassword"
name="newPassword"
type="password"
label="Mot de passe"
value={formData.newPassword}
onChange={(value) => setFormData(prev => ({ ...prev, newPassword: value }))}
placeholder="••••••••"
disabled={!!success}
minLength="8"
maxLength="128"
autoComplete="new-password"
required
/>
<PasswordStrengthIndicator password={formData.newPassword} showRequirements={true} />
</div>
<Input
id="confirmPassword"
name="confirmPassword"
type="password"
label="Confirmer le mot de passe"
value={formData.confirmPassword}
onChange={(value) => setFormData(prev => ({ ...prev, confirmPassword: value }))}
placeholder="••••••••"
disabled={!!success}
minLength="8"
maxLength="128"
autoComplete="new-password"
required
/>
<Button
type="submit"
variant="primary"
size="lg"
loading={isLoading}
disabled={!!success || !isFormValid()}
className="w-full mt-2"
>
Créer mon mot de passe
</Button>
</form>
<div className="mt-6 flex justify-center">
<Button
type="button"
variant="fullghost"
onClick={() => onNavigate('login')}
>
Retour à la connexion
</Button>
</div>
</Card>
);
}
+7
View File
@@ -0,0 +1,7 @@
export { default as LoginPage } from './LoginPage.client.js';
export { default as RegisterPage } from './RegisterPage.client.js';
export { default as ForgotPasswordPage } from './ForgotPasswordPage.client.js';
export { default as ResetPasswordPage } from './ResetPasswordPage.client.js';
export { default as ConfirmEmailPage } from './ConfirmEmailPage.client.js';
export { default as LogoutPage } from './LogoutPage.client.js';
export { default as SetupAccountPage } from './SetupAccountPage.client.js';
-19
View File
@@ -1,19 +0,0 @@
import { getSession } from './actions.js';
import { redirect } from 'next/navigation';
export async function protect({ redirectTo = '/auth/login' } = {}) {
const session = await getSession();
if (!session) redirect(redirectTo);
return session;
}
export async function checkAuth() {
return getSession();
}
export async function requireRole(allowedRoles = [], { redirectTo = '/auth/login', forbiddenRedirect = '/' } = {}) {
const session = await getSession();
if (!session) redirect(redirectTo);
if (!allowedRoles.includes(session.user.role)) redirect(forbiddenRedirect);
return session;
}
@@ -0,0 +1,45 @@
import { Button, Section, Text, Link } from "@react-email/components";
import { BaseLayout } from "@zen/core/email/templates";
export const EmailChangeConfirmEmail = ({ confirmUrl, newEmail, companyName }) => (
<BaseLayout
preview={`Confirmez votre nouvelle adresse courriel ${companyName}`}
title="Confirmez votre nouvelle adresse courriel"
companyName={companyName}
supportSection={true}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Une demande de modification d'adresse courriel a été effectuée sur votre compte{' '}
<span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton ci-dessous pour confirmer votre nouvelle adresse.
</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">
Nouvelle adresse
</Text>
<Text className="text-[14px] font-medium text-neutral-900 m-0">
{newEmail}
</Text>
</Section>
<Section className="mt-[28px] mb-[32px]">
<Button
href={confirmUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
Confirmer mon adresse courriel
</Button>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Ce lien expire dans 24 heures. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message. Votre adresse actuelle reste inchangée.
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
Lien :{' '}
<Link href={confirmUrl} className="text-neutral-400 underline break-all">
{confirmUrl}
</Link>
</Text>
</BaseLayout>
);
@@ -0,0 +1,63 @@
import { Section, Text } from "@react-email/components";
import { BaseLayout } from "@zen/core/email/templates";
const VARIANTS = {
pending: {
preview: (name) => `Demande de modification de courriel ${name}`,
title: 'Demande de modification de courriel',
body: (name) => `Une demande de modification de l'adresse courriel associée à votre compte ${name} a été initiée.`,
note: "Si vous n'êtes pas à l'origine de cette demande, contactez le support immédiatement. Votre adresse actuelle reste active jusqu'à confirmation.",
},
changed: {
preview: (name) => `Votre adresse courriel a été modifiée ${name}`,
title: 'Adresse courriel modifiée',
body: (name) => `L'adresse courriel de votre compte ${name} a été modifiée par un administrateur.`,
note: "Si vous n'êtes pas à l'origine de cette modification, contactez le support immédiatement.",
},
admin_new: {
preview: (name) => `Votre compte est maintenant associé à cette adresse ${name}`,
title: 'Adresse courriel associée à votre compte',
body: (name) => `Votre adresse courriel est maintenant associée à un compte ${name}. Cette modification a été effectuée par un administrateur.`,
note: "Si vous n'avez pas été informé de cette modification, contactez le support.",
},
};
export const EmailChangeNotifyEmail = ({ oldEmail, newEmail, variant = 'changed', companyName }) => {
const msg = VARIANTS[variant] || VARIANTS.changed;
return (
<BaseLayout
preview={msg.preview(companyName)}
title={msg.title}
companyName={companyName}
supportSection={true}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
{msg.body(companyName)}
</Text>
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
{oldEmail && variant !== 'admin_new' && (
<>
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[4px] uppercase tracking-wider">
Ancienne adresse
</Text>
<Text className="text-[14px] font-medium text-neutral-900 m-0 mb-[12px]">
{oldEmail}
</Text>
</>
)}
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[4px] uppercase tracking-wider">
Nouvelle adresse
</Text>
<Text className="text-[14px] font-medium text-neutral-900 m-0">
{newEmail}
</Text>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
{msg.note}
</Text>
</BaseLayout>
);
};
@@ -0,0 +1,35 @@
import { Button, Section, Text, Link } from "@react-email/components";
import { BaseLayout } from "@zen/core/email/templates";
export const InvitationEmail = ({ setupUrl, companyName }) => (
<BaseLayout
preview={`Terminez la création de votre compte ${companyName}`}
title="Créez votre mot de passe"
companyName={companyName}
supportSection={true}
>
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
Un administrateur a créé un compte pour vous sur <span className="font-medium text-neutral-900">{companyName}</span>. Cliquez sur le bouton ci-dessous pour choisir votre mot de passe et accéder à votre compte.
</Text>
<Section className="mt-[28px] mb-[32px]">
<Button
href={setupUrl}
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
>
Créer mon mot de passe
</Button>
</Section>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
Ce lien expire dans 48 heures. Si vous n'attendiez pas cette invitation, ignorez ce message.
</Text>
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
Lien :{' '}
<Link href={setupUrl} className="text-neutral-400 underline break-all">
{setupUrl}
</Link>
</Text>
</BaseLayout>
);
@@ -9,7 +9,7 @@ export const PasswordChangedEmail = ({ email, companyName }) => (
supportSection={true}
>
<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">{companyName}</span> a bien été modifié.
Le mot de passe associé au compte <span className="font-medium text-neutral-900">{companyName}</span> a été modifié.
</Text>
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
@@ -22,7 +22,7 @@ export const PasswordChangedEmail = ({ email, companyName }) => (
</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.
Si vous n'êtes pas à l'origine de cette modification, contactez le support immédiatement.
</Text>
</BaseLayout>
);

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