chore: import codes

This commit is contained in:
2026-04-12 12:50:14 -04:00
parent 4bcb4898e8
commit 65ae3c6788
241 changed files with 48834 additions and 1 deletions
+12
View File
@@ -0,0 +1,12 @@
/**
* Admin Server Actions
*
* These are exported separately from admin/index.js to avoid bundling
* server-side code (which includes database imports) into client components.
*
* Usage:
* import { getDashboardStats, getModuleDashboardStats } from '@hykocx/zen/admin/actions';
*/
export { getDashboardStats } from './actions/statsActions.js';
export { getAllModuleDashboardStats as getModuleDashboardStats } from '@hykocx/zen/modules/actions';
@@ -0,0 +1,79 @@
/**
* Admin Stats Actions
* Server-side actions for core dashboard statistics
*
* Module-specific stats are handled by each module's dashboard actions.
* See src/modules/{module}/dashboard/statsActions.js
*
* Usage in your Next.js app:
*
* ```javascript
* // app/(admin)/admin/[...admin]/page.js
* import { protectAdmin } from '@hykocx/zen/admin';
* import { getDashboardStats, getModuleDashboardStats } from '@hykocx/zen/admin/actions';
* import { AdminPagesClient } from '@hykocx/zen/admin/pages';
*
* export default async function AdminPage({ params }) {
* const { user } = await protectAdmin();
*
* // Fetch core dashboard stats
* const statsResult = await getDashboardStats();
* const dashboardStats = statsResult.success ? statsResult.stats : null;
*
* // Fetch module dashboard stats (for dynamic widgets)
* const moduleStats = await getModuleDashboardStats();
*
* return (
* <AdminPagesClient
* params={params}
* user={user}
* dashboardStats={dashboardStats}
* moduleStats={moduleStats}
* />
* );
* }
* ```
*/
'use server';
import { query } from '@hykocx/zen/database';
/**
* Get total number of users
* @returns {Promise<number>}
*/
async function getTotalUsersCount() {
try {
const result = await query(
`SELECT COUNT(*) as count FROM zen_auth_users`
);
return parseInt(result.rows[0].count) || 0;
} catch (error) {
console.error('Error getting users count:', error);
return 0;
}
}
/**
* Get core dashboard statistics
* @returns {Promise<Object>}
*/
export async function getDashboardStats() {
try {
const totalUsers = await getTotalUsersCount();
return {
success: true,
stats: {
totalUsers,
}
};
} catch (error) {
console.error('Error getting dashboard stats:', error);
return {
success: false,
error: error.message || 'Failed to get dashboard statistics'
};
}
}
@@ -0,0 +1,214 @@
'use client';
import React from 'react';
import { Menu, Transition } from '@headlessui/react';
import { Fragment } from 'react';
import { ChevronDownIcon } from '../../../shared/Icons.js';
import { useRouter } from 'next/navigation';
import ThemeToggle from './ThemeToggle';
const AdminHeader = ({ isMobileMenuOpen, setIsMobileMenuOpen, user, onLogout, appName = 'ZEN' }) => {
const router = useRouter();
const getImageUrl = (imageKey) => {
if (!imageKey) return null;
return `/zen/api/storage/${imageKey}`;
};
const handleLogout = async () => {
try {
if (onLogout) {
const result = await onLogout();
if (result && result.success) {
router.push('/auth/login');
} else {
console.error('Logout failed:', result?.error);
router.push('/auth/login');
}
} else {
router.push('/auth/login');
}
} catch (error) {
console.error('Logout error:', error);
router.push('/auth/login');
}
};
const getUserInitials = (name) => {
if (!name) return 'U';
return name
.split(' ')
.map(n => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
};
const quickLinks = [];
const userInitials = getUserInitials(user?.name);
return (
<header className="bg-white dark:bg-black border-b border-neutral-200 dark:border-neutral-800/70 sticky top-0 z-30 h-14 flex items-center w-full">
<div className="flex items-center justify-between lg:justify-end px-4 lg:px-6 py-2 w-full">
{/* Left section - Mobile menu button + Logo (hidden on desktop) */}
<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"
aria-label="Toggle menu"
>
<svg className={`h-5 w-5 transition-transform duration-200 ${isMobileMenuOpen ? 'rotate-90' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d={isMobileMenuOpen ? "M6 18L18 6M6 6l12 12" : "M4 6h16M4 12h16M4 18h16"} />
</svg>
</button>
<h1 className="text-neutral-900 dark:text-white font-semibold text-lg">{appName}</h1>
</div>
{/* Right Section - Theme Toggle + Quick Links + Profile */}
<div className="flex items-center space-x-3 sm:space-x-4">
{/* Quick Links - Hidden on very small screens */}
<nav className="hidden sm:flex items-center space-x-4 lg:space-x-6">
{quickLinks.map((link) => (
<a
key={link.name}
href={link.href}
className="text-sm text-neutral-600 dark:text-neutral-300 hover:text-neutral-900 dark:hover:text-white transition-colors duration-200"
>
{link.name}
</a>
))}
</nav>
{/* Theme Toggle */}
<ThemeToggle />
{/* User Profile Menu */}
<Menu as="div" className="relative">
<Menu.Button className="cursor-pointer flex items-center space-x-2 sm:space-x-3 px-2 sm:px-3 py-2 rounded-xl hover:bg-neutral-100 dark:hover:bg-neutral-800/50 transition-all duration-200 group ui-open:bg-neutral-100 dark:ui-open:bg-neutral-800/50 outline-none">
{/* Avatar for desktop - hidden on mobile */}
<div className="hidden sm:flex items-center space-x-3">
{getImageUrl(user?.image) && (
<img
src={getImageUrl(user.image)}
alt={user?.name || 'User'}
className="w-8 h-8 rounded-lg object-cover"
/>
)}
<div className="text-left">
<p className="text-sm font-medium text-neutral-900 dark:text-white">{user?.name || 'User'}</p>
</div>
</div>
{/* Avatar for mobile - visible on mobile only */}
<div className="sm:hidden">
{getImageUrl(user?.image) && (
<img
src={getImageUrl(user.image)}
alt={user?.name || 'User'}
className="w-8 h-8 rounded-lg object-cover"
/>
)}
</div>
<ChevronDownIcon className="h-4 w-4 text-neutral-400 transition-transform duration-200 ui-open:rotate-180" />
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-200"
enterFrom="opacity-0 -translate-y-2"
enterTo="opacity-100 translate-y-0"
leave="transition ease-in duration-150"
leaveFrom="opacity-100 translate-y-0"
leaveTo="opacity-0 -translate-y-2"
>
<Menu.Items className="absolute right-0 mt-2 w-56 sm:w-64 bg-white dark:bg-neutral-900/95 backdrop-blur-sm border border-neutral-200 dark:border-neutral-700/50 rounded-xl shadow-xl z-50 outline-none">
<div className="p-4 border-b border-neutral-200 dark:border-neutral-700/50">
<div className="flex items-center space-x-3">
{getImageUrl(user?.image) && (
<img
src={getImageUrl(user.image)}
alt={user?.name || 'User'}
className="w-10 h-10 rounded-lg object-cover"
/>
)}
<div>
<p className="text-sm font-medium text-neutral-900 dark:text-white">{user?.name || 'User'}</p>
<p className="text-xs text-neutral-400">{user?.email || 'email@example.com'}</p>
</div>
</div>
</div>
{/* Quick Links for mobile */}
{quickLinks.length > 0 && (
<div className="sm:hidden py-2 border-b border-neutral-200 dark:border-neutral-700/50">
{quickLinks.map((link) => (
<Menu.Item key={link.name}>
{({ active }) => (
<a
href={link.href}
className={`flex items-center px-4 py-3 text-sm transition-all duration-200 ${
active
? 'bg-neutral-100 dark:bg-neutral-800/50 text-neutral-900 dark:text-white'
: 'text-neutral-600 dark:text-neutral-300'
}`}
>
<svg className="mr-3 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
{link.name}
</a>
)}
</Menu.Item>
))}
</div>
)}
<div className="py-2">
<Menu.Item>
{({ active }) => (
<a
href="/admin/profile"
className={`flex items-center px-4 py-3 text-sm transition-all duration-200 group ${
active
? 'bg-neutral-100 dark:bg-neutral-800/50 text-neutral-900 dark:text-white'
: 'text-neutral-600 dark:text-neutral-300'
}`}
>
<svg className="mr-3 h-4 w-4 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
Mon profil
</a>
)}
</Menu.Item>
<div className="border-t border-neutral-200 dark:border-neutral-700/50 mt-2 pt-2">
<Menu.Item>
{({ active }) => (
<button
onClick={handleLogout}
className={`w-full flex items-center px-4 py-3 text-sm transition-all duration-200 group text-left ${
active
? 'bg-red-500/10 text-red-300'
: 'text-red-400'
}`}
>
<svg className="mr-3 h-4 w-4 group-hover:scale-110 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Se déconnecter
</button>
)}
</Menu.Item>
</div>
</div>
</Menu.Items>
</Transition>
</Menu>
</div>
</div>
</header>
);
};
export default AdminHeader;
@@ -0,0 +1,85 @@
'use client';
/**
* Admin Pages Component
*
* This component handles both core admin pages and module pages.
* Module pages are loaded dynamically on the client where hooks work properly.
*/
import { Suspense } from 'react';
import DashboardPage from './pages/DashboardPage.js';
import UsersPage from './pages/UsersPage.js';
import UserEditPage from './pages/UserEditPage.js';
import ProfilePage from './pages/ProfilePage.js';
import { getModulePageLoader } from '../../../modules/modules.pages.js';
// Loading component for suspense
function PageLoading() {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div>
</div>
);
}
export default function AdminPagesClient({
params,
user,
dashboardStats = null,
moduleStats = {},
modulePageInfo = null,
routeInfo = null,
enabledModules = {}
}) {
// If this is a module page, render it with lazy loading
if (modulePageInfo && routeInfo) {
const LazyComponent = getModulePageLoader(modulePageInfo.module, modulePageInfo.path);
if (LazyComponent) {
// Build props for the page
const pageProps = { user };
if (routeInfo.action === 'edit' && routeInfo.id) {
// Add ID props for edit pages (modules may use different prop names)
pageProps.id = routeInfo.id;
pageProps.invoiceId = routeInfo.id;
pageProps.clientId = routeInfo.id;
pageProps.itemId = routeInfo.id;
pageProps.categoryId = routeInfo.id;
pageProps.transactionId = routeInfo.id;
pageProps.recurrenceId = routeInfo.id;
pageProps.templateId = routeInfo.id;
pageProps.postId = routeInfo.id;
}
return (
<Suspense fallback={<PageLoading />}>
<LazyComponent {...pageProps} />
</Suspense>
);
}
}
// Determine core page from routeInfo or params
let currentPage = 'dashboard';
if (routeInfo?.path) {
const parts = routeInfo.path.split('/').filter(Boolean);
currentPage = parts[1] || 'dashboard'; // /admin/[page]
} else if (params?.admin) {
currentPage = params.admin[0] || 'dashboard';
}
// Core page components mapping (non-module pages)
const usersPageComponent = routeInfo?.action === 'edit' && routeInfo?.id
? () => <UserEditPage userId={routeInfo.id} user={user} enabledModules={enabledModules} />
: () => <UsersPage user={user} />;
const corePages = {
dashboard: () => <DashboardPage user={user} stats={dashboardStats} moduleStats={moduleStats} enabledModules={enabledModules} />,
users: usersPageComponent,
profile: () => <ProfilePage user={user} />,
};
// Render the appropriate core page or default to dashboard
const CorePageComponent = corePages[currentPage];
return CorePageComponent ? <CorePageComponent /> : <DashboardPage user={user} stats={dashboardStats} moduleStats={moduleStats} enabledModules={enabledModules} />;
}
@@ -0,0 +1,29 @@
'use client';
import AdminSidebar from './AdminSidebar';
import { useState } from 'react';
import AdminHeader from './AdminHeader';
export default function AdminPagesLayout({ children, user, onLogout, appName, enabledModules, navigationSections }) {
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
return (
<div className="flex h-screen overflow-hidden bg-white dark:bg-black">
<AdminSidebar
isMobileMenuOpen={isMobileMenuOpen}
setIsMobileMenuOpen={setIsMobileMenuOpen}
appName={appName}
enabledModules={enabledModules}
navigationSections={navigationSections}
/>
<div className="flex-1 flex flex-col min-w-0">
<AdminHeader isMobileMenuOpen={isMobileMenuOpen} setIsMobileMenuOpen={setIsMobileMenuOpen} user={user} onLogout={onLogout} appName={appName} />
<main className="flex-1 overflow-y-auto bg-neutral-50 dark:bg-black">
<div className="px-4 sm:px-6 lg:px-8 xl:px-12 pt-4 sm:pt-6 lg:pt-8 pb-32 max-w-[1400px] mx-auto">
{children}
</div>
</main>
</div>
</div>
);
}
@@ -0,0 +1,234 @@
'use client';
import React, { useState, useEffect } from 'react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import * as Icons from '../../../shared/Icons.js';
import { ChevronDownIcon } from '../../../shared/Icons.js';
/**
* Resolve icon name (string) to icon component
* Icons are passed as strings from server to avoid serialization issues
*/
function resolveIcon(iconNameOrComponent) {
// If it's already a component (function), return it
if (typeof iconNameOrComponent === 'function') {
return iconNameOrComponent;
}
// If it's a string, look up in Icons
if (typeof iconNameOrComponent === 'string') {
return Icons[iconNameOrComponent] || Icons.DashboardSquare03Icon;
}
// Default fallback
return Icons.DashboardSquare03Icon;
}
const AdminSidebar = ({ isMobileMenuOpen, setIsMobileMenuOpen, appName, enabledModules, navigationSections: serverNavigationSections }) => {
const pathname = usePathname();
// State to manage collapsed sections (all open by default)
const [collapsedSections, setCollapsedSections] = useState(new Set());
// Function to toggle a section's state
const toggleSection = (sectionId) => {
// Find the section to check if it has active items
const section = navigationSections.find(s => s.id === sectionId);
// Don't allow collapsing sections with active items
if (section && isSectionActive(section)) {
return;
}
setCollapsedSections(prev => {
const newCollapsed = new Set(prev);
if (newCollapsed.has(sectionId)) {
newCollapsed.delete(sectionId);
} else {
newCollapsed.add(sectionId);
}
return newCollapsed;
});
};
// Handle mobile menu closure when clicking on a link
const handleMobileLinkClick = () => {
setIsMobileMenuOpen(false);
};
// Close mobile menu on screen size change
useEffect(() => {
const handleResize = () => {
if (window.innerWidth >= 1024) { // lg breakpoint
setIsMobileMenuOpen(false);
}
};
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [setIsMobileMenuOpen]);
// Function to check if any item in a section is currently active
const isSectionActive = (section) => {
return section.items.some(item => item.current);
};
// Function to check if a section should be rendered as a direct link
const shouldRenderAsDirectLink = (section) => {
// Check if there's only one item and it has the same name as the section
return section.items.length === 1 &&
section.items[0].name.toLowerCase() === section.title.toLowerCase();
};
// Update collapsed sections when pathname changes to ensure active sections are open
useEffect(() => {
setCollapsedSections(prev => {
const newSet = new Set(prev);
// Add any sections that have active items to ensure they stay open
navigationSections.forEach(section => {
if (isSectionActive(section)) {
newSet.add(section.id);
}
});
return newSet;
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pathname]);
// Use server-provided navigation sections if available, otherwise use core-only fallback
// Server navigation includes module navigation, fallback only has core pages
// Update the 'current' property based on the actual pathname (client-side)
const navigationSections = serverNavigationSections.map(section => ({
...section,
items: section.items.map(item => ({
...item,
current: pathname === item.href || pathname.startsWith(item.href + '/')
}))
}));
// Function to render a complete navigation section
const renderNavSection = (section) => {
const Icon = resolveIcon(section.icon);
// If section should be rendered as a direct link
if (shouldRenderAsDirectLink(section)) {
const item = section.items[0];
return (
<div key={section.id}>
<Link
href={item.href}
onClick={handleMobileLinkClick}
className={`${
item.current
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-white'
: 'text-neutral-900 dark:text-white hover:text-neutral-500 dark:hover:text-neutral-300'
} w-full flex items-center justify-between border-y border-neutral-200 dark:border-neutral-800/70 px-4 py-2.5 -mb-[1px] text-[13px] tracking-wide transition-colorsduration-0`}
>
<div className="flex items-center">
<Icon className="mr-3 h-4 w-4 flex-shrink-0" />
<span>{section.title}</span>
</div>
{item.badge && (
<span className="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full font-medium">
{item.badge}
</span>
)}
</Link>
</div>
);
}
// Regular section with expandable sub-items
const isCollapsed = !collapsedSections.has(section.id);
return (
<div key={section.id}>
<button
onClick={() => toggleSection(section.id)}
className="cursor-pointer w-full flex items-center justify-between border-y border-neutral-200 dark:border-neutral-800/70 px-4 py-2.5 -mb-[1px] text-[13px] text-neutral-900 dark:text-white tracking-wide hover:text-neutral-500 dark:hover:text-neutral-300 transition-colorsduration-0"
>
<div className="flex items-center">
<Icon className="mr-3 h-4 w-4 flex-shrink-0" />
<span>{section.title}</span>
</div>
<ChevronDownIcon
className={`h-3 w-3 ${
isCollapsed ? '-rotate-90' : 'rotate-0'
}`}
/>
</button>
<div
className={`overflow-hidden ${
isCollapsed
? 'max-h-0 opacity-0'
: 'max-h-[1000px] opacity-100'
}`}
>
<ul className="flex flex-col gap-0">
{section.items.map(renderNavItem)}
</ul>
</div>
</div>
);
};
// Function to render a navigation item
const renderNavItem = (item) => {
const Icon = resolveIcon(item.icon);
return (
<li key={item.name}>
<Link
href={item.href}
onClick={handleMobileLinkClick}
className={`${
item.current
? 'bg-neutral-100 dark:bg-neutral-800 text-neutral-900 dark:text-white'
: 'text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/50 hover:text-neutral-900 dark:hover:text-white'
} group flex items-center justify-between px-4 py-1.5 text-[12px] font-medium transition-allduration-0`}
>
<div className="flex items-center">
<Icon className="mr-3 h-3.5 w-3.5 flex-shrink-0" />
{item.name}
</div>
{item.badge && (
<span className="bg-red-500 text-white text-xs px-1.5 py-0.5 rounded-full font-medium">
{item.badge}
</span>
)}
</Link>
</li>
);
};
return (
<>
{/* Mobile overlay */}
{isMobileMenuOpen && (
<div
className="lg:hidden fixed inset-0 bg-black/50 backdrop-blur-sm z-40"
onClick={() => setIsMobileMenuOpen(false)}
/>
)}
{/* Sidebar */}
<div className={`
${isMobileMenuOpen ? 'translate-x-0' : '-translate-x-full lg:translate-x-0'}
fixed lg:static inset-y-0 left-0 z-40 w-64 bg-white dark:bg-black border-r border-neutral-200 dark:border-neutral-800/70 flex flex-col h-screen transition-transform duration-300 ease-in-out
`}>
{/* Logo Section */}
<Link href="/admin" className="px-4 h-14 flex items-center justify-start gap-2 border-b border-neutral-200 dark:border-neutral-800/70">
<h1 className="text-neutral-900 dark:text-white font-semibold">{appName}</h1>
<span className="bg-red-700/10 border border-red-600/20 text-red-600 uppercase text-[10px] leading-none px-2 py-1 rounded-full font-semibold">
Admin
</span>
</Link>
{/* Navigation */}
<nav className="flex-1 px-0 overflow-y-auto flex flex-col gap-0 pb-12 -mt-[1px]">
{navigationSections.map(renderNavSection)}
</nav>
</div>
</>
);
};
export default AdminSidebar;
@@ -0,0 +1,83 @@
'use client';
import { useState, useEffect } from 'react';
import { Sun01Icon, Moon02Icon, SunCloud02Icon, MoonCloudIcon } from '../../../shared/Icons.js';
function getNextTheme(current) {
const systemIsDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
if (current === 'auto') return systemIsDark ? 'light' : 'dark';
if (current === 'dark') return systemIsDark ? 'auto' : 'light';
return systemIsDark ? 'dark' : 'auto';
}
function getAutoIcon(systemIsDark) {
return systemIsDark ? MoonCloudIcon : SunCloud02Icon;
}
const THEME_ICONS = {
light: Sun01Icon,
dark: Moon02Icon,
};
function getStoredTheme() {
const stored = localStorage.getItem('theme');
if (stored === 'light' || stored === 'dark') return stored;
return 'auto';
}
function applyTheme(theme) {
if (theme === 'dark') {
document.documentElement.classList.add('dark');
localStorage.setItem('theme', 'dark');
} else if (theme === 'light') {
document.documentElement.classList.remove('dark');
localStorage.setItem('theme', 'light');
} else {
localStorage.removeItem('theme');
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
document.documentElement.classList.toggle('dark', prefersDark);
}
}
function useTheme() {
const [theme, setTheme] = useState('auto');
const [systemIsDark, setSystemIsDark] = useState(false);
useEffect(() => {
setTheme(getStoredTheme());
setSystemIsDark(window.matchMedia('(prefers-color-scheme: dark)').matches);
const mq = window.matchMedia('(prefers-color-scheme: dark)');
function onSystemChange(e) {
setSystemIsDark(e.matches);
if (localStorage.getItem('theme')) return;
document.documentElement.classList.toggle('dark', e.matches);
}
mq.addEventListener('change', onSystemChange);
return () => mq.removeEventListener('change', onSystemChange);
}, []);
function toggle() {
const next = getNextTheme(theme);
setTheme(next);
applyTheme(next);
}
return { theme, toggle, systemIsDark };
}
export default function ThemeToggle() {
const { theme, toggle, systemIsDark } = useTheme();
const Icon = theme === 'auto' ? getAutoIcon(systemIsDark) : THEME_ICONS[theme];
return (
<button
onClick={toggle}
aria-label="Changer le thème"
title="Changer le thème"
className="cursor-pointer p-2 rounded-lg text-neutral-500 dark:text-neutral-400 hover:bg-neutral-100 dark:hover:bg-neutral-800/50 hover:text-neutral-900 dark:hover:text-white transition-colors duration-200"
>
<Icon className="h-4 w-4" />
</button>
);
}
+6
View File
@@ -0,0 +1,6 @@
/**
* Admin Components Exports
*/
export { default as AdminPagesClient } from './AdminPages.js';
export { default as AdminPagesLayout } from './AdminPagesLayout.js';
@@ -0,0 +1,64 @@
'use client';
/**
* Admin Dashboard Page
* Displays core stats and dynamically loads module dashboard widgets
*/
import { Suspense } from 'react';
import { StatCard } from '../../../../shared/components';
import { UserMultiple02Icon } from '../../../../shared/Icons.js';
import { getModuleDashboardWidgets } from '../../../../modules/modules.pages.js';
/**
* Loading placeholder for widgets
*/
function WidgetLoading() {
return (
<div className="animate-pulse bg-neutral-200 dark:bg-neutral-800 rounded-lg h-32"></div>
);
}
export default function DashboardPage({ user, stats, moduleStats = {}, enabledModules = {} }) {
const loading = !stats;
// Get only enabled module dashboard widgets
const allModuleWidgets = getModuleDashboardWidgets();
const moduleWidgets = Object.fromEntries(
Object.entries(allModuleWidgets).filter(([moduleName]) => enabledModules[moduleName])
);
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">
Tableau de bord
</h1>
<p className="mt-1 text-xs text-neutral-400">Vue d'ensemble de votre application</p>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 sm:gap-6">
{/* Module dashboard widgets (dynamically loaded) */}
{Object.entries(moduleWidgets).map(([moduleName, widgets]) => (
widgets.map((Widget, index) => (
<Suspense key={`${moduleName}-widget-${index}`} fallback={<WidgetLoading />}>
<Widget stats={moduleStats[moduleName]} />
</Suspense>
))
))}
{/* Core stats - always shown */}
<StatCard
title="Nombre d'utilisateurs"
value={loading ? '-' : String(stats?.totalUsers || 0)}
icon={UserMultiple02Icon}
color="text-purple-400"
bgColor="bg-purple-500/10"
loading={loading}
/>
</div>
</div>
);
}
@@ -0,0 +1,331 @@
'use client';
import React, { useState, useEffect, useRef } from 'react';
import { Card, Input, Button } from '../../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
const ProfilePage = ({ user: initialUser }) => {
const toast = useToast();
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 || ''
});
// Helper function to get image URL from storage key
const getImageUrl = (imageKey) => {
if (!imageKey) return null;
return `/zen/api/storage/${imageKey}`;
};
useEffect(() => {
if (initialUser) {
setFormData({
name: initialUser.name || ''
});
setImagePreview(getImageUrl(initialUser.image));
}
}, [initialUser]);
const handleChange = (value) => {
setFormData(prev => ({
...prev,
name: value
}));
};
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',
},
credentials: 'include',
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');
}
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);
} 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 response = await fetch('/zen/api/users/profile/picture', {
method: 'POST',
credentials: 'include',
body: formData
});
const data = await response.json();
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));
} finally {
setUploadingImage(false);
}
};
const handleRemoveImage = async () => {
if (!user?.image) return;
setUploadingImage(true);
try {
const response = await fetch('/zen/api/users/profile/picture', {
method: 'DELETE',
credentials: 'include'
});
const data = await response.json();
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');
} 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">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">
Mon profil
</h1>
<p className="mt-1 text-xs text-neutral-400">
Gérez les informations de votre compte
</p>
</div>
</div>
{/* 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="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>
)}
</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">
<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>
<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>
</div>
</div>
);
};
export default ProfilePage;
@@ -0,0 +1,254 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Button, Card, Input, Select, Loading } from '../../../../shared/components';
import { useToast } from '@hykocx/zen/toast';
/**
* User Edit Page Component
* Page for editing an existing user (admin only)
*/
const UserEditPage = ({ userId, user, enabledModules = {} }) => {
const router = useRouter();
const toast = useToast();
const clientsModuleActive = Boolean(enabledModules?.clients);
const [userData, setUserData] = useState(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [clients, setClients] = useState([]);
const [formData, setFormData] = useState({
name: '',
role: 'user',
email_verified: 'false',
client_id: ''
});
const [errors, setErrors] = useState({});
const roleOptions = [
{ value: 'user', label: 'Utilisateur' },
{ value: 'admin', label: 'Admin' }
];
const emailVerifiedOptions = [
{ value: 'false', label: 'Non vérifié' },
{ value: 'true', label: 'Vérifié' }
];
useEffect(() => {
loadUser();
}, [userId]);
useEffect(() => {
if (clientsModuleActive) {
fetch('/zen/api/admin/clients?limit=500', { credentials: 'include' })
.then(res => res.json())
.then(data => data.clients ? setClients(data.clients) : setClients([]))
.catch(() => setClients([]));
}
}, [clientsModuleActive]);
const loadUser = async () => {
try {
setLoading(true);
const response = await fetch(`/zen/api/users/${userId}`, {
credentials: 'include'
});
const data = await response.json();
if (data.user) {
setUserData(data.user);
setFormData(prev => ({
...prev,
name: data.user.name || '',
role: data.user.role || 'user',
email_verified: data.user.email_verified ? 'true' : 'false',
client_id: data.linkedClient ? String(data.linkedClient.id) : ''
}));
} else {
toast.error(data.message || 'Utilisateur introuvable');
}
} catch (error) {
console.error('Error loading user:', error);
toast.error('Impossible de charger l\'utilisateur');
} finally {
setLoading(false);
}
};
const handleInputChange = (field, value) => {
setFormData(prev => ({ ...prev, [field]: value }));
if (errors[field]) {
setErrors(prev => ({ ...prev, [field]: null }));
}
};
const validateForm = () => {
const newErrors = {};
if (!formData.name || !formData.name.trim()) {
newErrors.name = 'Le nom est requis';
}
setErrors(newErrors);
return Object.keys(newErrors).length === 0;
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!validateForm()) return;
try {
setSaving(true);
const response = await fetch(`/zen/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
name: formData.name.trim(),
role: formData.role,
email_verified: formData.email_verified === 'true',
...(clientsModuleActive && { client_id: formData.client_id ? parseInt(formData.client_id, 10) : null })
})
});
const data = await response.json();
if (data.success) {
toast.success('Utilisateur mis à jour avec succès');
router.push('/admin/users');
} else {
toast.error(data.message || data.error || 'Impossible de mettre à jour l\'utilisateur');
}
} catch (error) {
console.error('Error updating user:', error);
toast.error('Impossible de mettre à jour l\'utilisateur');
} finally {
setSaving(false);
}
};
if (loading) {
return (
<div className="min-h-64 flex items-center justify-center">
<Loading />
</div>
);
}
if (!userData) {
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier l'utilisateur</h1>
<p className="mt-1 text-xs text-neutral-400">Utilisateur introuvable</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/users')}
>
← Retour aux utilisateurs
</Button>
</div>
<Card>
<div className="bg-red-500/10 border border-red-500/20 text-red-400 px-4 py-3 rounded-lg">
<p className="font-medium">Utilisateur introuvable</p>
<p className="text-sm mt-1">L'utilisateur que vous recherchez n'existe pas ou a été supprimé.</p>
</div>
</Card>
</div>
);
}
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier l'utilisateur</h1>
<p className="mt-1 text-xs text-neutral-400">{userData.email}</p>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => router.push('/admin/users')}
>
Retour aux utilisateurs
</Button>
</div>
<form onSubmit={handleSubmit} className="space-y-6">
<Card>
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de l'utilisateur</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Nom *"
value={formData.name}
onChange={(value) => handleInputChange('name', value)}
placeholder="Nom de l'utilisateur"
error={errors.name}
/>
<Input
label="Email"
value={userData.email}
disabled
/>
<Select
label="Rôle"
value={formData.role}
onChange={(value) => handleInputChange('role', value)}
options={roleOptions}
/>
<Select
label="Email vérifié"
value={formData.email_verified}
onChange={(value) => handleInputChange('email_verified', value)}
options={emailVerifiedOptions}
/>
{clientsModuleActive && (
<Select
label="Client associé"
value={formData.client_id}
onChange={(value) => handleInputChange('client_id', value)}
options={[
{ value: '', label: 'Aucun' },
...clients.map(c => ({
value: String(c.id),
label: [c.client_number, c.company_name || [c.first_name, c.last_name].filter(Boolean).join(' ') || c.email].filter(Boolean).join(' ')
}))
]}
/>
)}
</div>
</div>
</Card>
<div className="flex items-center justify-end gap-3">
<Button
type="button"
variant="secondary"
onClick={() => router.push('/admin/users')}
disabled={saving}
>
Annuler
</Button>
<Button
type="submit"
variant="success"
loading={saving}
disabled={saving}
>
{saving ? 'Enregistrement...' : 'Mettre à jour'}
</Button>
</div>
</form>
</div>
);
};
export default UserEditPage;
@@ -0,0 +1,220 @@
'use client';
import React, { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Card, Table, StatusBadge, Pagination, Button } from '../../../../shared/components';
import { PencilEdit01Icon } from '../../../../shared/Icons.js';
import { useToast } from '@hykocx/zen/toast';
const UsersPageClient = () => {
const router = useRouter();
const toast = useToast();
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
// Pagination state
const [pagination, setPagination] = useState({
page: 1,
limit: 20,
total: 0,
totalPages: 0
});
// Sort state
const [sortBy, setSortBy] = useState('created_at');
const [sortOrder, setSortOrder] = useState('desc');
// Table columns configuration
const columns = [
{
key: 'name',
label: 'Nom',
sortable: true,
render: (user) => (
<div>
<div className="text-sm font-medium text-neutral-900 dark:text-white">{user.name}</div>
<div className="text-xs text-neutral-500 dark:text-gray-400">ID: {user.id.slice(0, 8)}...</div>
</div>
),
skeleton: {
height: 'h-4', width: '60%',
secondary: { height: 'h-3', width: '40%' }
}
},
{
key: 'email',
label: 'Email',
sortable: true,
render: (user) => <div className="text-sm font-medium text-neutral-900 dark:text-white">{user.email}</div>,
skeleton: {
height: 'h-4',
width: '60%',
}
},
{
key: 'role',
label: 'Rôle',
sortable: true,
render: (user) => <StatusBadge status={user.role} />,
skeleton: { height: 'h-6', width: '80px', className: 'rounded-full' }
},
{
key: 'email_verified',
label: 'Statut',
sortable: true,
render: (user) => <StatusBadge status={user.email_verified ? 'verified' : 'unverified'} />,
skeleton: { height: 'h-6', width: '90px', className: 'rounded-full' }
},
{
key: 'created_at',
label: 'Créé le',
sortable: true,
render: (user) => (
<span className="text-sm text-neutral-600 dark:text-gray-300">
{formatDate(user.created_at)}
</span>
),
skeleton: { height: 'h-4', width: '70%' }
},
{
key: 'actions',
label: '',
sortable: false,
noWrap: true,
render: (user) => (
<Button
variant="secondary"
size="sm"
onClick={() => router.push(`/admin/users/edit/${user.id}`)}
icon={<PencilEdit01Icon className="w-4 h-4" />}
>
Modifier
</Button>
),
skeleton: { height: 'h-8', width: '80px', className: 'rounded-lg' }
}
];
// Fetch users function
const fetchUsers = async () => {
setLoading(true);
try {
const searchParams = new URLSearchParams({
page: pagination.page.toString(),
limit: pagination.limit.toString(),
sortBy,
sortOrder
});
const response = await fetch(`/zen/api/users?${searchParams}`, {
credentials: 'include'
});
if (!response.ok) {
throw new Error(`Error: ${response.status}`);
}
const data = await response.json();
setUsers(data.users);
setPagination(prev => ({
...prev,
total: data.pagination.total,
totalPages: data.pagination.totalPages,
page: data.pagination.page
}));
} catch (err) {
toast.error(err.message || 'Impossible de charger les utilisateurs');
console.error('Error fetching users:', err);
} finally {
setLoading(false);
}
};
// Effect to fetch users when sort or pagination change
useEffect(() => {
fetchUsers();
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
// Handle pagination
const handlePageChange = (newPage) => {
setPagination(prev => ({ ...prev, page: newPage }));
};
const handleLimitChange = (newLimit) => {
setPagination(prev => ({
...prev,
limit: newLimit,
page: 1 // Reset to first page when changing limit
}));
};
// Handle sorting
const handleSort = (newSortBy) => {
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
setSortBy(newSortBy);
setSortOrder(newSortOrder);
};
// Format date helper
const formatDate = (dateString) => {
if (!dateString) return 'N/A';
try {
return new Date(dateString).toLocaleDateString('fr-FR', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return 'Date invalide';
}
};
return (
<div className="flex flex-col gap-6">
{/* Users Table */}
<Card variant="default" padding="none">
<Table
columns={columns}
data={users}
loading={loading}
sortBy={sortBy}
sortOrder={sortOrder}
onSort={handleSort}
emptyMessage="Aucun utilisateur trouvé"
emptyDescription="La base de données est vide"
/>
<Pagination
currentPage={pagination.page}
totalPages={pagination.totalPages}
onPageChange={handlePageChange}
onLimitChange={handleLimitChange}
limit={pagination.limit}
total={pagination.total}
loading={loading}
showPerPage={true}
showStats={true}
/>
</Card>
</div>
);
};
const UsersPage = () => {
return (
<div className="flex flex-col gap-4 sm:gap-6 lg:gap-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Utilisateurs</h1>
<p className="mt-1 text-xs text-neutral-400">Gérez les comptes utilisateurs</p>
</div>
</div>
<UsersPageClient />
</div>
);
};
export default UsersPage;
+16
View File
@@ -0,0 +1,16 @@
/**
* Zen Admin Module
* Admin panel functionality with role-based access control
*/
// Middleware exports
export { protectAdmin, isAdmin } from './middleware/protect.js';
// Component exports (for catch-all routes)
export { AdminPagesClient, AdminPagesLayout } from './pages.js';
// NOTE: Server-only navigation builder is in '@hykocx/zen/admin/navigation'
// Do NOT import from this file to avoid bundling database code into client
// NOTE: Admin server actions are exported separately to avoid bundling issues
// Import them from '@hykocx/zen/admin/actions' instead
+65
View File
@@ -0,0 +1,65 @@
/**
* Admin Route Protection Middleware
* Utilities to protect admin routes and require admin role
*/
import { getSession } from '../../auth/actions/authActions.js';
import { redirect } from 'next/navigation';
/**
* Protect an admin page - requires authentication and admin role
* Use this in server components to require admin access
*
* @param {Object} options - Protection options
* @param {string} options.redirectTo - Where to redirect if not authenticated (default: '/auth/login')
* @param {string} options.forbiddenRedirect - Where to redirect if not admin (default: '/')
* @returns {Promise<Object>} Session object with user data
*
* @example
* // In a server component:
* import { protectAdmin } from '@hykocx/zen/admin';
*
* export default async function AdminPage() {
* const session = await protectAdmin();
* return <div>Welcome Admin, {session.user.name}!</div>;
* }
*/
async function protectAdmin(options = {}) {
const { redirectTo = '/auth/login', forbiddenRedirect = '/' } = options;
const session = await getSession();
if (!session) {
redirect(redirectTo);
}
if (session.user.role !== 'admin') {
redirect(forbiddenRedirect);
}
return session;
}
/**
* Check if user is admin
* Use this when you want to check admin status without forcing a redirect
*
* @returns {Promise<boolean>} True if user is admin
*
* @example
* import { isAdmin } from '@hykocx/zen/admin';
*
* export default async function Page() {
* const admin = await isAdmin();
* return admin ? <div>Admin panel</div> : <div>Access denied</div>;
* }
*/
async function isAdmin() {
const session = await getSession();
return session && session.user.role === 'admin';
}
export {
protectAdmin,
isAdmin
};
+69
View File
@@ -0,0 +1,69 @@
/**
* Admin Navigation Builder (Server-Only)
*
* This file imports from the module registry and should ONLY be used on the server.
* It builds the complete navigation including dynamic module navigation.
*
* IMPORTANT: This file is NOT bundled to ensure it shares the same registry instance
* that was populated during module discovery.
*
* IMPORTANT: We import from '@hykocx/zen' (main package) to use the same registry
* instance that was populated during initializeZen(). DO NOT import from
* '@hykocx/zen/core/modules' as that's a separate bundle with its own registry.
*
* IMPORTANT: Navigation data must be serializable (no functions/components).
* Icons are passed as string names and resolved on the client.
*/
// Import from the main package to use the same registry as discovery
import { moduleSystem } from '@hykocx/zen';
const { getAllAdminNavigation } = moduleSystem;
/**
* Build complete navigation sections including modules
* This should ONLY be called on the server (in page.js)
* @param {string} pathname - Current pathname
* @param {Object} enabledModules - Object with module names as keys (for compatibility)
* @returns {Array} Complete navigation sections (serializable, icons as strings)
*/
export function buildNavigationSections(pathname, enabledModules = null) {
// Core navigation sections (always available)
// Use icon NAMES (strings) for serialization across server/client boundary
const coreNavigation = [
{
id: 'Dashboard',
title: 'Tableau de bord',
icon: 'DashboardSquare03Icon',
items: [
{
name: 'Tableau de bord',
href: '/admin/dashboard',
icon: 'DashboardSquare03Icon',
current: pathname === '/admin/dashboard'
},
]
}
];
// Get module navigation from registry (only works on server)
const moduleNavigation = getAllAdminNavigation(pathname);
// System navigation (always at the end)
const systemNavigation = [
{
id: 'users',
title: 'Utilisateurs',
icon: 'UserMultiple02Icon',
items: [
{
name: 'Utilisateurs',
href: '/admin/users',
icon: 'UserMultiple02Icon',
current: pathname.startsWith('/admin/users')
},
]
}
];
return [...coreNavigation, ...moduleNavigation, ...systemNavigation];
}
+135
View File
@@ -0,0 +1,135 @@
/**
* Admin Page - Server Component Wrapper for Next.js App Router
*
* This is a complete server component that handles all admin routes.
* Users can simply re-export this in their app/admin/[...admin]/page.js:
*
* ```javascript
* export { default } from '@hykocx/zen/admin/page';
* ```
*
* This eliminates the need to manually import and pass all actions and props.
*/
import { AdminPagesLayout, AdminPagesClient } from '@hykocx/zen/admin/pages';
import { protectAdmin } from '@hykocx/zen/admin';
import { buildNavigationSections } from '@hykocx/zen/admin/navigation';
import { getDashboardStats, getModuleDashboardStats } from '@hykocx/zen/admin/actions';
import { logoutAction } from '@hykocx/zen/auth/actions';
import { getAppName, getModulesConfig, getAppConfig, moduleSystem } from '@hykocx/zen';
const { getAdminPage } = moduleSystem;
/**
* Parse admin route params and build the module path
* Handles nested paths like /admin/invoice/clients/edit/123
*
* @param {Object} params - Next.js route params
* @returns {Object} Parsed info with path, action, and id
*/
function parseAdminRoute(params) {
const parts = params?.admin || [];
if (parts.length === 0) {
return { path: '/admin/dashboard', action: null, id: null, isCorePage: true };
}
// Check for core pages first
const corePages = ['dashboard', 'users', 'profile'];
if (corePages.includes(parts[0])) {
// Users: support /admin/users/edit/:id
if (parts[0] === 'users' && parts[1] === 'edit' && parts[2]) {
return { path: '/admin/users', action: 'edit', id: parts[2], isCorePage: true };
}
return { path: `/admin/${parts[0]}`, action: null, id: null, isCorePage: true };
}
// Build module path
// Look for 'new', 'create', or 'edit' to determine action
const actionKeywords = ['new', 'create', 'edit'];
let pathParts = [];
let action = null;
let id = null;
for (let i = 0; i < parts.length; i++) {
const part = parts[i];
if (actionKeywords.includes(part)) {
action = part === 'create' ? 'new' : part;
// If it's 'edit', the next part is the ID
if (action === 'edit' && i + 1 < parts.length) {
id = parts[i + 1];
}
break;
}
pathParts.push(part);
}
// Build the full path
let fullPath = '/admin/' + pathParts.join('/');
if (action) {
fullPath += '/' + action;
}
return { path: fullPath, action, id, isCorePage: false };
}
/**
* Check if a path is a module page
* @param {string} fullPath - Full admin path
* @returns {Object|null} Module info if it's a module page, null otherwise
*/
function getModulePageInfo(fullPath) {
const modulePage = getAdminPage(fullPath);
if (modulePage) {
return {
module: modulePage.module,
path: fullPath
};
}
return null;
}
export default async function AdminPage({ params }) {
const resolvedParams = await params;
const session = await protectAdmin();
const appName = getAppName();
const enabledModules = getModulesConfig();
const config = getAppConfig();
const statsResult = await getDashboardStats();
const dashboardStats = statsResult.success ? statsResult.stats : null;
// Fetch module dashboard stats for widgets
const moduleStats = await getModuleDashboardStats();
// Build navigation on server where module registry is available
const navigationSections = buildNavigationSections('/', enabledModules);
// Parse route and build path
const { path, action, id, isCorePage } = parseAdminRoute(resolvedParams);
// Check if this is a module page (just check existence, don't load)
const modulePageInfo = isCorePage ? null : getModulePageInfo(path);
return (
<AdminPagesLayout
user={session.user}
onLogout={logoutAction}
appName={appName}
enabledModules={enabledModules}
navigationSections={navigationSections}
>
<AdminPagesClient
params={resolvedParams}
user={session.user}
dashboardStats={dashboardStats}
moduleStats={moduleStats}
modulePageInfo={modulePageInfo}
routeInfo={{ path, action, id }}
enabledModules={enabledModules}
/>
</AdminPagesLayout>
);
}
+11
View File
@@ -0,0 +1,11 @@
'use client';
/**
* Admin Pages Export for Next.js App Router
*
* This exports the admin client components.
* Users must create their own server component wrapper that uses protectAdmin.
*/
export { default as AdminPagesClient } from './components/AdminPages.js';
export { default as AdminPagesLayout } from './components/AdminPagesLayout.js';
+347
View File
@@ -0,0 +1,347 @@
# 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 `@hykocx/zen/auth/components`
- **Actions**: from `@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/auth/components';
export function LogoutPageWrapper({ logoutAction, setSessionCookie }) {
return (
<LogoutPage
onLogout={logoutAction}
onSetSessionCookie={setSessionCookie}
/>
);
}
```
---
## Protecting routes
Use `protect()` from `@hykocx/zen/auth` and set `redirectTo` to your custom login path:
```js
import { protect } from '@hykocx/zen/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 '@hykocx/zen/auth/page';
```
This serves login, register, forgot, reset, confirm, and logout under `/auth/*` with the default styling.
+274
View File
@@ -0,0 +1,274 @@
# 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 `@hykocx/zen/auth/actions` |
| Check auth without redirect | `checkAuth()` from `@hykocx/zen/auth` |
| Require a role | `requireRole(['admin', 'manager'])` from `@hykocx/zen/auth` |
| Show user in client (header/nav) | `UserMenu` or `UserAvatar` + `useCurrentUser` from `@hykocx/zen/auth/components` |
| Edit account (name + avatar) | `AccountSection` from `@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/auth/actions';
import { UserMenu } from '@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/auth';
import { AccountSection } from '@hykocx/zen/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 `@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/auth';
import { UserMenu } from '@hykocx/zen/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 '@hykocx/zen/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 '@hykocx/zen/auth';
import { AccountSection } from '@hykocx/zen/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 `@hykocx/zen/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).
+19
View File
@@ -0,0 +1,19 @@
/**
* Server Actions Export
* This file ONLY exports server actions - no client components
*/
'use server';
export {
registerAction,
loginAction,
logoutAction,
getSession,
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
setSessionCookie,
refreshSessionCookie
} from './actions/authActions.js';
+341
View File
@@ -0,0 +1,341 @@
/**
* Server Actions for Next.js
* Authentication actions for login, register, password reset, etc.
*/
'use server';
import { register, login, requestPasswordReset, resetPassword, verifyUserEmail } from '../lib/auth.js';
import { validateSession, deleteSession } from '../lib/session.js';
import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPasswordResetEmail } from '../lib/email.js';
import { cookies, headers } from 'next/headers';
import { getSessionCookieName, getPublicBaseUrl } from '../../../shared/lib/appConfig.js';
import { checkRateLimit, getIpFromHeaders, formatRetryAfter } from '../lib/rateLimit.js';
/**
* Get the client IP from the current server action context.
*/
async function getClientIp() {
const h = await headers();
return getIpFromHeaders(h);
}
/**
* Validate anti-bot fields submitted with forms.
* - _hp : honeypot field — must be empty
* - _t : form load timestamp (ms) — submission must be at least 1.5 s after page load
*
* @param {FormData} formData
* @returns {{ valid: boolean, error?: string }}
*/
function validateAntiBotFields(formData) {
const honeypot = formData.get('_hp');
if (honeypot && honeypot.length > 0) {
return { valid: false, error: 'Requête invalide' };
}
const t = parseInt(formData.get('_t') || '0', 10);
if (t === 0 || Date.now() - t < 1500) {
return { valid: false, error: 'Requête invalide' };
}
return { valid: true };
}
// Get cookie name from environment or use default
export const COOKIE_NAME = getSessionCookieName();
/**
* Register a new user
* @param {FormData} formData - Form data with email, password, name
* @returns {Promise<Object>} Result object
*/
export async function registerAction(formData) {
try {
const botCheck = validateAntiBotFields(formData);
if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'register');
if (!rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const password = formData.get('password');
const name = formData.get('name');
const result = await register({ email, password, name });
// Send verification email
await sendVerificationEmail(result.user.email, result.verificationToken, getPublicBaseUrl());
return {
success: true,
message: 'Compte créé avec succès. Consultez votre e-mail pour vérifier votre compte.',
user: result.user
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Login a user
* @param {FormData} formData - Form data with email and password
* @returns {Promise<Object>} Result object
*/
export async function loginAction(formData) {
try {
const botCheck = validateAntiBotFields(formData);
if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'login');
if (!rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const password = formData.get('password');
const result = await login({ email, password });
// Return the token to be set by the client to avoid page refresh
// The client will call setSessionCookie after displaying the success message
return {
success: true,
message: 'Connexion réussie',
user: result.user,
sessionToken: result.session.token
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Set session cookie (called by client after showing success message)
* @param {string} token - Session token
* @returns {Promise<Object>} Result object
*/
export async function setSessionCookie(token) {
try {
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/'
});
return { success: true };
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Refresh session cookie (extend expiration)
* @param {string} token - Session token
* @returns {Promise<Object>} Result object
*/
export async function refreshSessionCookie(token) {
try {
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/'
});
return { success: true };
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Logout a user
* @returns {Promise<Object>} Result object
*/
export async function logoutAction() {
try {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (token) {
await deleteSession(token);
}
cookieStore.delete(COOKIE_NAME);
return {
success: true,
message: 'Déconnexion réussie'
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Get current user session
* @returns {Promise<Object|null>} Session and user data or null
*/
export async function getSession() {
try {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return null;
const result = await validateSession(token);
// If session was refreshed, also refresh the cookie
if (result && result.sessionRefreshed) {
await refreshSessionCookie(token);
}
return result;
} catch (error) {
console.error('Session validation error:', error);
return null;
}
}
/**
* Request password reset
* @param {FormData} formData - Form data with email
* @returns {Promise<Object>} Result object
*/
export async function forgotPasswordAction(formData) {
try {
const botCheck = validateAntiBotFields(formData);
if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'forgot_password');
if (!rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const result = await requestPasswordReset(email);
if (result.token) {
await sendPasswordResetEmail(email, result.token, getPublicBaseUrl());
}
return {
success: true,
message: 'Si un compte existe avec cet e-mail, vous recevrez un lien pour réinitialiser votre mot de passe.'
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Reset password with token
* @param {FormData} formData - Form data with email, token, and newPassword
* @returns {Promise<Object>} Result object
*/
export async function resetPasswordAction(formData) {
try {
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'reset_password');
if (!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');
// Verify token first
const isValid = await verifyResetToken(email, token);
if (!isValid) {
throw new Error('Jeton de réinitialisation invalide ou expiré');
}
await resetPassword({ email, token, newPassword });
return {
success: true,
message: 'Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.'
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Verify email with token
* @param {FormData} formData - Form data with email and token
* @returns {Promise<Object>} Result object
*/
export async function verifyEmailAction(formData) {
try {
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'verify_email');
if (!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');
// Verify token
const isValid = await verifyEmailToken(email, token);
if (!isValid) {
throw new Error('Jeton de vérification invalide ou expiré');
}
// Find user and verify
const { findOne } = await import('../../../core/database/crud.js');
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('Utilisateur introuvable');
}
await verifyUserEmail(user.id);
return {
success: true,
message: 'E-mail vérifié avec succès. Vous pouvez maintenant vous connecter.'
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
@@ -0,0 +1,279 @@
'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 '../../../shared/components/index.js';
import { useToast } from '@hykocx/zen/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>
);
}
+104
View File
@@ -0,0 +1,104 @@
'use client';
/**
* Auth Pages Component - Catch-all route for Next.js App Router
* This component handles all authentication routes: login, register, forgot, reset, confirm
*/
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import LoginPage from './pages/LoginPage.js';
import RegisterPage from './pages/RegisterPage.js';
import ForgotPasswordPage from './pages/ForgotPasswordPage.js';
import ResetPasswordPage from './pages/ResetPasswordPage.js';
import ConfirmEmailPage from './pages/ConfirmEmailPage.js';
import LogoutPage from './pages/LogoutPage.js';
export default function AuthPagesClient({
params,
searchParams,
registerAction,
loginAction,
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
logoutAction,
setSessionCookieAction,
redirectAfterLogin = '/',
currentUser = null
}) {
const router = useRouter();
const [currentPage, setCurrentPage] = useState(null); // null = loading
const [isLoading, setIsLoading] = useState(true);
const [email, setEmail] = useState('');
const [token, setToken] = useState('');
useEffect(() => {
// Get page from params or URL
const getPageFromParams = () => {
if (params?.auth?.[0]) {
return params.auth[0];
}
// Fallback: read from URL
if (typeof window !== 'undefined') {
const pathname = window.location.pathname;
const match = pathname.match(/\/auth\/([^\/\?]+)/);
return match ? match[1] : 'login';
}
return 'login';
};
const page = getPageFromParams();
setCurrentPage(page);
setIsLoading(false);
}, [params]);
// Extract email and token from searchParams (handles both Promise and regular object)
useEffect(() => {
const extractSearchParams = async () => {
let resolvedParams = searchParams;
// Check if searchParams is a Promise (Next.js 15+)
if (searchParams && typeof searchParams.then === 'function') {
resolvedParams = await searchParams;
}
// Extract email and token from URL if not in searchParams
if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
setEmail(resolvedParams?.email || urlParams.get('email') || '');
setToken(resolvedParams?.token || urlParams.get('token') || '');
} else {
setEmail(resolvedParams?.email || '');
setToken(resolvedParams?.token || '');
}
};
extractSearchParams();
}, [searchParams]);
const navigate = (page) => {
router.push(`/auth/${page}`);
};
// Don't render anything while determining the correct page
if (isLoading || !currentPage) {
return null;
}
// Page components mapping
const pageComponents = {
login: () => <LoginPage onSubmit={loginAction} onNavigate={navigate} onSetSessionCookie={setSessionCookieAction} redirectAfterLogin={redirectAfterLogin} currentUser={currentUser} />,
register: () => <RegisterPage onSubmit={registerAction} onNavigate={navigate} currentUser={currentUser} />,
forgot: () => <ForgotPasswordPage onSubmit={forgotPasswordAction} onNavigate={navigate} currentUser={currentUser} />,
reset: () => <ResetPasswordPage onSubmit={resetPasswordAction} onNavigate={navigate} email={email} token={token} />,
confirm: () => <ConfirmEmailPage onSubmit={verifyEmailAction} onNavigate={navigate} email={email} token={token} />,
logout: () => <LogoutPage onLogout={logoutAction} onSetSessionCookie={setSessionCookieAction} />
};
// Render the appropriate page
const PageComponent = pageComponents[currentPage];
return PageComponent ? <PageComponent /> : <LoginPage onSubmit={loginAction} onNavigate={navigate} onSetSessionCookie={setSessionCookieAction} redirectAfterLogin={redirectAfterLogin} currentUser={currentUser} />;
}
@@ -0,0 +1,19 @@
/**
* Auth Pages Layout - Server Component
* Provides the layout structure for authentication pages
*
* Usage:
* <AuthPagesLayout>
* <AuthPagesClient {...props} />
* </AuthPagesLayout>
*/
export default function AuthPagesLayout({ children }) {
return (
<div className="min-h-screen flex items-center justify-center p-4 md:p-8">
<div className="max-w-md w-full">
{children}
</div>
</div>
);
}
@@ -0,0 +1,55 @@
'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
@@ -0,0 +1,90 @@
'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>
);
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Auth Components Export
*
* Use these components to build custom auth pages for every flow (login, register, forgot,
* reset, confirm, logout) so they match your site's style.
* For a ready-made catch-all auth UI, use AuthPagesClient from '@hykocx/zen/auth/pages'.
* For the default full-page auth (no custom layout), re-export from '@hykocx/zen/auth/page'.
*
* --- Custom auth pages (all types) ---
*
* Pattern: server component loads session/searchParams and passes actions to a client wrapper;
* client wrapper uses useRouter for onNavigate and renders the Zen component.
*
* 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)
*
* onNavigate receives 'login' | 'register' | 'forgot' | 'reset'. Map to your routes (e.g. /auth/${page}).
* For reset/confirm, pass email and token from searchParams. Full guide: see README-custom-login.md in this package.
* Protect routes with protect() from '@hykocx/zen/auth', redirectTo your login path.
*
* --- Dashboard / user display ---
*
* UserAvatar, UserMenu, AccountSection, useCurrentUser: see README-dashboard.md.
*/
export { default as AuthPagesLayout } from './AuthPagesLayout.js';
export { default as AuthPagesClient } from './AuthPages.js';
export { default as LoginPage } from './pages/LoginPage.js';
export { default as RegisterPage } from './pages/RegisterPage.js';
export { default as ForgotPasswordPage } from './pages/ForgotPasswordPage.js';
export { default as ResetPasswordPage } from './pages/ResetPasswordPage.js';
export { default as ConfirmEmailPage } from './pages/ConfirmEmailPage.js';
export { default as LogoutPage } from './pages/LogoutPage.js';
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';
@@ -0,0 +1,162 @@
'use client';
/**
* Confirm Email Page Component
*/
import { useState, useEffect, useRef } from 'react';
export default function ConfirmEmailPage({ onSubmit, onNavigate, email, token }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [success, setSuccess] = useState('');
const [hasVerified, setHasVerified] = useState(false);
const isVerifyingRef = useRef(false);
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
sessionStorage.removeItem('emailVerificationSuccess');
// Redirect after showing the message
setTimeout(() => {
onNavigate('login');
}, 3000);
return;
}
// Auto-verify on mount, but only once
if (email && token && !hasVerified && !isVerifyingRef.current) {
console.log('Starting email verification');
verifyEmail();
} else if (!email || !token) {
console.log('Invalid email or token');
setError('Lien de vérification invalide');
setIsLoading(false);
}
}, [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);
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);
} else {
console.log('Verification failed:', result.error);
setError(result.error || 'Échec de la vérification de l\'e-mail');
setIsLoading(false);
}
} catch (err) {
console.error('Email verification error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
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>
</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>
</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">
<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 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>
)}
{/* 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>
);
}
@@ -0,0 +1,174 @@
'use client';
/**
* Forgot Password Page Component
*/
import { useState, useEffect } from 'react';
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 [honeypot, setHoneypot] = useState('');
const [formLoadedAt, setFormLoadedAt] = useState(0);
useEffect(() => {
setFormLoadedAt(Date.now());
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
async function handleSubmit(e) {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
const submitData = new FormData();
submitData.append('email', formData.email);
submitData.append('_hp', honeypot);
submitData.append('_t', String(formLoadedAt));
try {
const result = await onSubmit(submitData);
if (result.success) {
setSuccess(result.message);
setIsLoading(false);
} else {
setError(result.error || 'Échec de l\'envoi de l\'e-mail de réinitialisation');
setIsLoading(false);
}
} catch (err) {
console.error('Forgot password error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
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>
</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>
)}
{/* 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>
)}
{/* 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>
);
}
@@ -0,0 +1,228 @@
'use client';
/**
* Login Page Component
*/
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
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 [honeypot, setHoneypot] = useState('');
const [formLoadedAt, setFormLoadedAt] = useState(0);
const router = useRouter();
useEffect(() => {
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();
}
};
const handleSubmit = async () => {
setError('');
setSuccess('');
setIsLoading(true);
const submitData = new FormData();
submitData.append('email', formData.email);
submitData.append('password', formData.password);
submitData.append('_hp', honeypot);
submitData.append('_t', String(formLoadedAt));
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 {
setError(result.error || 'Échec de la connexion');
setIsLoading(false);
}
} catch (err) {
console.error('Login error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
};
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">
Connexion
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Veuillez vous connecter pour continuer.
</p>
</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>
)}
{/* 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>
)}
{/* 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-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>Connexion en cours...</span>
</div>
) : (
'Se connecter'
)}
</button>
</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>
</div>
);
}
@@ -0,0 +1,117 @@
'use client';
/**
* Logout Page Component
*/
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function LogoutPage({ onLogout, onSetSessionCookie }) {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleLogout = async () => {
setError('');
setSuccess('');
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);
return;
}
}
// 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);
} catch (err) {
console.error('Logout error:', err);
setError('Une erreur inattendue s\'est produite lors de la déconnexion');
setIsLoading(false);
}
};
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>
{/* 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>
)}
{/* 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>
)}
{/* 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>
{/* 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>
);
}
@@ -0,0 +1,337 @@
'use client';
/**
* Register Page Component
*/
import { useState, useEffect } from 'react';
import { PasswordStrengthIndicator } from '../../../../shared/components';
export default function RegisterPage({ onSubmit, onNavigate, currentUser = null }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [success, setSuccess] = useState('');
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: ''
});
const [honeypot, setHoneypot] = useState('');
const [formLoadedAt, setFormLoadedAt] = useState(0);
useEffect(() => {
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');
}
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');
}
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');
}
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 &&
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 (formData.password !== formData.confirmPassword) {
setError('Les mots de passe ne correspondent pas');
setIsLoading(false);
return;
}
const submitData = new FormData();
submitData.append('name', formData.name);
submitData.append('email', formData.email);
submitData.append('password', formData.password);
submitData.append('confirmPassword', formData.confirmPassword);
submitData.append('_hp', honeypot);
submitData.append('_t', String(formLoadedAt));
try {
const result = await onSubmit(submitData);
if (result.success) {
setSuccess(result.message);
setIsLoading(false);
} else {
setError(result.error || 'Échec de l\'inscription');
setIsLoading(false);
}
} catch (err) {
console.error('Registration error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
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>
</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>
)}
{/* 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>
)}
{/* 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>
</div>
);
}
@@ -0,0 +1,222 @@
'use client';
/**
* Reset Password Page Component
*/
import { useState } from 'react';
import { PasswordStrengthIndicator } from '../../../../shared/components';
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: ''
});
// 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');
}
return errors;
};
const isFormValid = () => {
const passwordErrors = validatePassword(formData.newPassword);
return passwordErrors.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 (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);
// Redirect to login after 2 seconds
setTimeout(() => {
onNavigate('login');
}, 2000);
} else {
setError(result.error || 'Échec de la réinitialisation du mot de passe');
setIsLoading(false);
}
} catch (err) {
console.error('Reset password error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
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>
</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>
)}
{/* 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>
)}
{/* 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>
);
}
@@ -0,0 +1,66 @@
'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 };
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Zen Authentication Module - Server-side utilities
*
* For client components, use '@hykocx/zen/auth/pages'
* For server actions, use '@hykocx/zen/auth/actions'
*/
// Authentication library (server-side only)
export {
register,
login,
requestPasswordReset,
resetPassword,
verifyUserEmail,
updateUser
} from './lib/auth.js';
// Session management (server-side only)
export {
createSession,
validateSession,
deleteSession,
deleteUserSessions,
refreshSession
} from './lib/session.js';
// Email utilities (server-side only)
export {
createEmailVerification,
verifyEmailToken,
createPasswordReset,
verifyResetToken,
deleteResetToken,
sendVerificationEmail,
sendPasswordResetEmail,
sendPasswordChangedEmail
} from './lib/email.js';
// Password utilities (server-side only)
export {
hashPassword,
verifyPassword,
generateToken,
generateId
} from './lib/password.js';
// Middleware (server-side only)
export {
protect,
checkAuth,
requireRole
} from './middleware/protect.js';
// Server Actions (server-side only)
export {
registerAction,
loginAction,
logoutAction,
getSession,
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
setSessionCookie,
refreshSessionCookie
} from './actions/authActions.js';
+295
View File
@@ -0,0 +1,295 @@
/**
* Authentication Logic
* Main authentication functions for user registration, login, and password management
*/
import { create, findOne, updateById, count } from '../../../core/database/crud.js';
import { hashPassword, verifyPassword, generateId } from './password.js';
import { createSession } from './session.js';
import { createEmailVerification, createPasswordReset, deleteResetToken, sendPasswordChangedEmail } from './email.js';
/**
* Register a new user
* @param {Object} userData - User registration data
* @param {string} userData.email - User email
* @param {string} userData.password - User password
* @param {string} userData.name - User name
* @returns {Promise<Object>} Created user and session
*/
async function register(userData) {
const { email, password, name } = userData;
// Validate required fields
if (!email || !password || !name) {
throw new Error('L\'e-mail, le mot de passe et le nom sont requis');
}
// Validate email length (maximum 254 characters - RFC standard)
if (email.length > 254) {
throw new Error('L\'e-mail doit contenir 254 caractères ou moins');
}
// Validate password length (minimum 8, maximum 128 characters)
if (password.length < 8) {
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');
}
// Validate password complexity (1 uppercase, 1 lowercase, 1 number)
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');
}
// Validate name length (maximum 100 characters)
if (name.length > 100) {
throw new Error('Le nom doit contenir 100 caractères ou moins');
}
// Validate name is not empty after trimming
if (name.trim().length === 0) {
throw new Error('Le nom ne peut pas être vide');
}
// Check if user already exists
const existingUser = await findOne('zen_auth_users', { email });
if (existingUser) {
throw new Error('Un utilisateur avec cet e-mail existe déjà');
}
// Check if this is the first user - if so, make them admin
const userCount = await count('zen_auth_users');
const role = userCount === 0 ? 'admin' : 'user';
// Hash password
const hashedPassword = await hashPassword(password);
// Create user
const userId = generateId();
const user = await create('zen_auth_users', {
id: userId,
email,
name,
email_verified: false,
image: null,
role,
updated_at: new Date()
});
// Create account with password
const accountId = generateId();
await create('zen_auth_accounts', {
id: accountId,
account_id: email,
provider_id: 'credential',
user_id: user.id,
password: hashedPassword,
updated_at: new Date()
});
// Create email verification token
const verification = await createEmailVerification(email);
return {
user,
verificationToken: verification.token
};
}
/**
* Login a user
* @param {Object} credentials - Login credentials
* @param {string} credentials.email - User email
* @param {string} credentials.password - User password
* @param {Object} sessionOptions - Session options (ipAddress, userAgent)
* @returns {Promise<Object>} User and session
*/
async function login(credentials, sessionOptions = {}) {
const { email, password } = credentials;
// Validate required fields
if (!email || !password) {
throw new Error('L\'e-mail et le mot de passe sont requis');
}
// Find user
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('E-mail ou mot de passe incorrect');
}
// Find account with password
const account = await findOne('zen_auth_accounts', {
user_id: user.id,
provider_id: 'credential'
});
if (!account || !account.password) {
throw new Error('E-mail ou mot de passe incorrect');
}
// Verify password
const isValid = await verifyPassword(password, account.password);
if (!isValid) {
throw new Error('E-mail ou mot de passe incorrect');
}
// Create session
const session = await createSession(user.id, sessionOptions);
return {
user,
session
};
}
/**
* Request a password reset
* @param {string} email - User email
* @returns {Promise<Object>} Reset token
*/
async function requestPasswordReset(email) {
// Validate email
if (!email) {
throw new Error('L\'e-mail est requis');
}
// Check if user exists
const user = await findOne('zen_auth_users', { email });
if (!user) {
// Don't reveal if user exists or not
return { success: true };
}
// Create password reset token
const reset = await createPasswordReset(email);
return {
success: true,
token: reset.token
};
}
/**
* Reset password with token
* @param {Object} resetData - Reset data
* @param {string} resetData.email - User email
* @param {string} resetData.token - Reset token
* @param {string} resetData.newPassword - New password
* @returns {Promise<Object>} Success status
*/
async function resetPassword(resetData) {
const { email, token, newPassword } = resetData;
// Validate required fields
if (!email || !token || !newPassword) {
throw new Error('L\'e-mail, le jeton et le nouveau mot de passe sont requis');
}
// Validate password length (minimum 8, maximum 128 characters)
if (newPassword.length < 8) {
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
}
if (newPassword.length > 128) {
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
}
// Validate password complexity (1 uppercase, 1 lowercase, 1 number)
const hasUppercase = /[A-Z]/.test(newPassword);
const hasLowercase = /[a-z]/.test(newPassword);
const hasNumber = /\d/.test(newPassword);
if (!hasUppercase || !hasLowercase || !hasNumber) {
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
}
// Verify token is handled in the email module
// For now, we'll assume token is valid if it exists in the database
// Find user
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('Jeton de réinitialisation invalide');
}
// Find account
const account = await findOne('zen_auth_accounts', {
user_id: user.id,
provider_id: 'credential'
});
if (!account) {
throw new Error('Compte introuvable');
}
// Hash new password
const hashedPassword = await hashPassword(newPassword);
// Update password
await updateById('zen_auth_accounts', account.id, {
password: hashedPassword,
updated_at: new Date()
});
// Delete reset token
await deleteResetToken(email);
// Send password changed confirmation email
try {
await sendPasswordChangedEmail(email);
} catch (error) {
// Log error but don't fail the password reset process
console.error(`[ZEN AUTH] Failed to send password changed email to ${email}:`, error.message);
}
return { success: true };
}
/**
* Verify user email
* @param {string} userId - User ID
* @returns {Promise<Object>} Updated user
*/
async function verifyUserEmail(userId) {
return await updateById('zen_auth_users', userId, {
email_verified: true,
updated_at: new Date()
});
}
/**
* Update user profile
* @param {string} userId - User ID
* @param {Object} updateData - Data to update
* @returns {Promise<Object>} Updated user
*/
async function updateUser(userId, updateData) {
const allowedFields = ['name', 'image', 'language'];
const filteredData = {};
for (const field of allowedFields) {
if (updateData[field] !== undefined) {
filteredData[field] = updateData[field];
}
}
filteredData.updated_at = new Date();
return await updateById('zen_auth_users', userId, filteredData);
}
export {
register,
login,
requestPasswordReset,
resetPassword,
verifyUserEmail,
updateUser
};
+233
View File
@@ -0,0 +1,233 @@
/**
* Email Verification and Password Reset
* Handles email verification tokens and password reset tokens
*/
import { create, findOne, deleteWhere } from '../../../core/database/crud.js';
import { generateToken, generateId } from './password.js';
import { sendAuthEmail } from '../../../core/email/index.js';
import { renderVerificationEmail, renderPasswordResetEmail, renderPasswordChangedEmail } from '../../../core/email/templates/index.js';
/**
* Create an email verification token
* @param {string} email - User email
* @returns {Promise<Object>} Verification object with token
*/
async function createEmailVerification(email) {
const token = generateToken(32);
const verificationId = generateId();
// Token expires in 24 hours
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
// Delete any existing verification tokens for this email
await deleteWhere('zen_auth_verifications', {
identifier: 'email_verification',
value: email
});
const verification = await create('zen_auth_verifications', {
id: verificationId,
identifier: 'email_verification',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return {
...verification,
token
};
}
/**
* Verify an email token
* @param {string} email - User email
* @param {string} token - Verification token
* @returns {Promise<boolean>} True if valid, false otherwise
*/
async function verifyEmailToken(email, token) {
const verification = await findOne('zen_auth_verifications', {
identifier: 'email_verification',
value: email
});
if (!verification) return false;
// Verify token matches
if (verification.token !== token) return false;
// Check if token is expired
if (new Date(verification.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: verification.id });
return false;
}
// Delete the verification token after use
await deleteWhere('zen_auth_verifications', { id: verification.id });
return true;
}
/**
* Create a password reset token
* @param {string} email - User email
* @returns {Promise<Object>} Reset object with token
*/
async function createPasswordReset(email) {
const token = generateToken(32);
const resetId = generateId();
// Token expires in 1 hour
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1);
// Delete any existing reset tokens for this email
await deleteWhere('zen_auth_verifications', {
identifier: 'password_reset',
value: email
});
const reset = await create('zen_auth_verifications', {
id: resetId,
identifier: 'password_reset',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return {
...reset,
token
};
}
/**
* Verify a password reset token
* @param {string} email - User email
* @param {string} token - Reset token
* @returns {Promise<boolean>} True if valid, false otherwise
*/
async function verifyResetToken(email, token) {
const reset = await findOne('zen_auth_verifications', {
identifier: 'password_reset',
value: email
});
if (!reset) return false;
// Verify token matches
if (reset.token !== token) return false;
// Check if token is expired
if (new Date(reset.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: reset.id });
return false;
}
return true;
}
/**
* Delete a password reset token
* @param {string} email - User email
* @returns {Promise<number>} Number of deleted tokens
*/
async function deleteResetToken(email) {
return await deleteWhere('zen_auth_verifications', {
identifier: 'password_reset',
value: email
});
}
/**
* Send verification email using Resend
* @param {string} email - User email
* @param {string} token - Verification token
* @param {string} baseUrl - Base URL of the application
*/
async function sendVerificationEmail(email, token, baseUrl) {
const verificationUrl = `${baseUrl}/auth/confirm?email=${encodeURIComponent(email)}&token=${token}`;
const appName = process.env.ZEN_NAME || 'ZEN';
const html = await renderVerificationEmail(verificationUrl, email, appName);
const result = await sendAuthEmail({
to: email,
subject: `Confirmez votre adresse courriel ${appName}`,
html
});
if (!result.success) {
console.error(`[ZEN AUTH] Failed to send verification email to ${email}:`, result.error);
throw new Error('Failed to send verification email');
}
console.log(`[ZEN AUTH] Verification email sent to ${email}`);
return result;
}
/**
* Send password reset email using Resend
* @param {string} email - User email
* @param {string} token - Reset token
* @param {string} baseUrl - Base URL of the application
*/
async function sendPasswordResetEmail(email, token, baseUrl) {
const resetUrl = `${baseUrl}/auth/reset?email=${encodeURIComponent(email)}&token=${token}`;
const appName = process.env.ZEN_NAME || 'ZEN';
const html = await renderPasswordResetEmail(resetUrl, email, appName);
const result = await sendAuthEmail({
to: email,
subject: `Réinitialisation du mot de passe ${appName}`,
html
});
if (!result.success) {
console.error(`[ZEN AUTH] Failed to send password reset email to ${email}:`, result.error);
throw new Error('Failed to send password reset email');
}
console.log(`[ZEN AUTH] Password reset email sent to ${email}`);
return result;
}
/**
* Send password changed confirmation email using Resend
* @param {string} email - User email
*/
async function sendPasswordChangedEmail(email) {
const appName = process.env.ZEN_NAME || 'ZEN';
const html = await renderPasswordChangedEmail(email, appName);
const result = await sendAuthEmail({
to: email,
subject: `Mot de passe modifié ${appName}`,
html
});
if (!result.success) {
console.error(`[ZEN AUTH] Failed to send password changed email to ${email}:`, result.error);
throw new Error('Failed to send password changed email');
}
console.log(`[ZEN AUTH] Password changed email sent to ${email}`);
return result;
}
export {
createEmailVerification,
verifyEmailToken,
createPasswordReset,
verifyResetToken,
deleteResetToken,
sendVerificationEmail,
sendPasswordResetEmail,
sendPasswordChangedEmail
};
+65
View File
@@ -0,0 +1,65 @@
/**
* Password Hashing and Verification
* Provides secure password hashing using bcrypt
*/
import crypto from 'crypto';
/**
* Hash a password using scrypt (Node.js native)
* @param {string} password - Plain text password
* @returns {Promise<string>} Hashed password
*/
async function hashPassword(password) {
return new Promise((resolve, reject) => {
// Generate a salt
const salt = crypto.randomBytes(16).toString('hex');
// Hash password with salt using scrypt
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
resolve(salt + ':' + derivedKey.toString('hex'));
});
});
}
/**
* Verify a password against a hash
* @param {string} password - Plain text password
* @param {string} hash - Hashed password
* @returns {Promise<boolean>} True if password matches, false otherwise
*/
async function verifyPassword(password, hash) {
return new Promise((resolve, reject) => {
const [salt, key] = hash.split(':');
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
resolve(key === derivedKey.toString('hex'));
});
});
}
/**
* Generate a random token
* @param {number} length - Token length in bytes (default: 32)
* @returns {string} Random token
*/
function generateToken(length = 32) {
return crypto.randomBytes(length).toString('hex');
}
/**
* Generate a random ID
* @returns {string} Random ID
*/
function generateId() {
return crypto.randomUUID();
}
export {
hashPassword,
verifyPassword,
generateToken,
generateId
};
+116
View File
@@ -0,0 +1,116 @@
/**
* In-memory rate limiter
* Stores counters in a Map — resets on server restart, no DB required.
*/
/** @type {Map<string, { count: number, windowStart: number, windowMs: number, blockedUntil: number|null }>} */
const store = new Map();
// Purge expired entries every 10 minutes to avoid memory leak
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of store.entries()) {
const windowExpired = now > entry.windowStart + entry.windowMs;
const blockExpired = !entry.blockedUntil || now > entry.blockedUntil;
if (windowExpired && blockExpired) {
store.delete(key);
}
}
}, 10 * 60 * 1000);
// Allow garbage collection in test/serverless environments
if (cleanup.unref) cleanup.unref();
/**
* Rate limit presets per action.
* maxAttempts : number of requests allowed in the window
* windowMs : rolling window duration
* blockMs : how long to block once the limit is exceeded
*/
export const RATE_LIMITS = {
login: { maxAttempts: 5, windowMs: 15 * 60 * 1000, blockMs: 30 * 60 * 1000 },
register: { maxAttempts: 5, windowMs: 60 * 60 * 1000, blockMs: 60 * 60 * 1000 },
forgot_password: { maxAttempts: 3, windowMs: 60 * 60 * 1000, blockMs: 60 * 60 * 1000 },
reset_password: { maxAttempts: 5, windowMs: 15 * 60 * 1000, blockMs: 30 * 60 * 1000 },
verify_email: { maxAttempts: 10, windowMs: 60 * 60 * 1000, blockMs: 30 * 60 * 1000 },
api: { maxAttempts: 120, windowMs: 60 * 1000, blockMs: 5 * 60 * 1000 },
};
/**
* Check whether a given identifier is allowed for an action, and record the attempt.
*
* @param {string} identifier - IP address or user ID
* @param {string} action - Key from RATE_LIMITS (e.g. 'login')
* @returns {{ allowed: boolean, retryAfterMs?: number, remaining?: number }}
*/
export function checkRateLimit(identifier, action) {
const config = RATE_LIMITS[action];
if (!config) return { allowed: true };
const key = `${action}:${identifier}`;
const now = Date.now();
let entry = store.get(key);
// Still blocked
if (entry?.blockedUntil && now < entry.blockedUntil) {
return { allowed: false, retryAfterMs: entry.blockedUntil - now };
}
// Start a fresh window (first request, or previous window has expired)
if (!entry || now > entry.windowStart + entry.windowMs) {
store.set(key, { count: 1, windowStart: now, windowMs: config.windowMs, blockedUntil: null });
return { allowed: true, remaining: config.maxAttempts - 1 };
}
// Increment counter in the current window
entry.count += 1;
if (entry.count > config.maxAttempts) {
entry.blockedUntil = now + config.blockMs;
store.set(key, entry);
return { allowed: false, retryAfterMs: config.blockMs };
}
store.set(key, entry);
return { allowed: true, remaining: config.maxAttempts - entry.count };
}
/**
* Extract the best-effort client IP from Next.js headers() (server actions).
* @param {import('next/headers').ReadonlyHeaders} headersList
* @returns {string}
*/
export function getIpFromHeaders(headersList) {
return (
headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ||
headersList.get('x-real-ip') ||
'unknown'
);
}
/**
* Extract the best-effort client IP from a Next.js Request object (API routes).
* @param {Request} request
* @returns {string}
*/
export function getIpFromRequest(request) {
return (
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'unknown'
);
}
/**
* Format a block duration in human-readable French.
* @param {number} ms
* @returns {string}
*/
export function formatRetryAfter(ms) {
const seconds = Math.ceil(ms / 1000);
if (seconds < 60) return `${seconds} secondes`;
const minutes = Math.ceil(seconds / 60);
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''}`;
const hours = Math.ceil(minutes / 60);
return `${hours} heure${hours > 1 ? 's' : ''}`;
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Session Management
* Handles user session creation, validation, and deletion
*/
import { create, findOne, deleteWhere, updateById } from '../../../core/database/crud.js';
import { generateToken, generateId } from './password.js';
/**
* Create a new session for a user
* @param {string} userId - User ID
* @param {Object} options - Session options (ipAddress, userAgent)
* @returns {Promise<Object>} Session object with token
*/
async function createSession(userId, options = {}) {
const { ipAddress, userAgent } = options;
// Generate session token
const token = generateToken(32);
const sessionId = generateId();
// Session expires in 30 days
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
const session = await create('zen_auth_sessions', {
id: sessionId,
user_id: userId,
token,
expires_at: expiresAt,
ip_address: ipAddress || null,
user_agent: userAgent || null,
updated_at: new Date()
});
return session;
}
/**
* Validate a session token
* @param {string} token - Session token
* @returns {Promise<Object|null>} Session object with user data or null if invalid
*/
async function validateSession(token) {
if (!token) return null;
const session = await findOne('zen_auth_sessions', { token });
if (!session) return null;
// Check if session is expired
if (new Date(session.expires_at) < new Date()) {
await deleteSession(token);
return null;
}
// Get user data
const user = await findOne('zen_auth_users', { id: session.user_id });
if (!user) {
await deleteSession(token);
return null;
}
// Auto-refresh session if it expires in less than 20 days
const now = new Date();
const expiresAt = new Date(session.expires_at);
const daysUntilExpiry = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
let sessionRefreshed = false;
if (daysUntilExpiry < 20) {
// Extend session to 30 days from now
const newExpiresAt = new Date();
newExpiresAt.setDate(newExpiresAt.getDate() + 30);
await updateById('zen_auth_sessions', session.id, {
expires_at: newExpiresAt,
updated_at: new Date()
});
// Update the session object with new expiration
session.expires_at = newExpiresAt;
sessionRefreshed = true;
}
return {
session,
user,
sessionRefreshed
};
}
/**
* Delete a session
* @param {string} token - Session token
* @returns {Promise<number>} Number of deleted sessions
*/
async function deleteSession(token) {
return await deleteWhere('zen_auth_sessions', { token });
}
/**
* Delete all sessions for a user
* @param {string} userId - User ID
* @returns {Promise<number>} Number of deleted sessions
*/
async function deleteUserSessions(userId) {
return await deleteWhere('zen_auth_sessions', { user_id: userId });
}
/**
* Refresh a session (extend expiration)
* @param {string} token - Session token
* @returns {Promise<Object|null>} Updated session or null
*/
async function refreshSession(token) {
const session = await findOne('zen_auth_sessions', { token });
if (!session) return null;
// Extend session by 30 days
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
return await updateById('zen_auth_sessions', session.id, {
expires_at: expiresAt,
updated_at: new Date()
});
}
export {
createSession,
validateSession,
deleteSession,
deleteUserSessions,
refreshSession
};
+83
View File
@@ -0,0 +1,83 @@
/**
* Route Protection Middleware
* Utilities to protect routes and check authentication
*/
import { getSession } from '../actions/authActions.js';
import { redirect } from 'next/navigation';
/**
* Protect a page - requires authentication
* Use this in server components to require authentication
*
* @param {Object} options - Protection options
* @param {string} options.redirectTo - Where to redirect if not authenticated (default: '/auth/login')
* @returns {Promise<Object>} Session object with user data
*
* @example
* // In a server component:
* import { protect } from '@hykocx/zen/auth';
*
* export default async function ProtectedPage() {
* const session = await protect();
* return <div>Welcome, {session.user.name}!</div>;
* }
*/
async function protect(options = {}) {
const { redirectTo = '/auth/login' } = options;
const session = await getSession();
if (!session) {
redirect(redirectTo);
}
return session;
}
/**
* Check if user is authenticated
* Use this when you want to check authentication without forcing a redirect
*
* @returns {Promise<Object|null>} Session object or null if not authenticated
*
* @example
* import { checkAuth } from '@hykocx/zen/auth';
*
* export default async function Page() {
* const session = await checkAuth();
* return session ? <div>Logged in</div> : <div>Not logged in</div>;
* }
*/
async function checkAuth() {
return await getSession();
}
/**
* Require a specific role
* @param {Array<string>} allowedRoles - Array of allowed roles
* @param {Object} options - Options
* @returns {Promise<Object>} Session object
*/
async function requireRole(allowedRoles = [], options = {}) {
const { redirectTo = '/auth/login', forbiddenRedirect = '/' } = options;
const session = await getSession();
if (!session) {
redirect(redirectTo);
}
if (!allowedRoles.includes(session.user.role)) {
redirect(forbiddenRedirect);
}
return session;
}
export {
protect,
checkAuth,
requireRole
};
+46
View File
@@ -0,0 +1,46 @@
/**
* Auth Page - Server Component Wrapper for Next.js App Router
*
* Default auth UI: login, register, forgot, reset, confirm, logout at /auth/[...auth].
* Re-export in your app: export { default } from '@hykocx/zen/auth/page';
*
* For custom auth pages (all flows) that match your site style, use components from
* '@hykocx/zen/auth/components' and actions from '@hykocx/zen/auth/actions'.
* See README-custom-login.md in this package. Basic sites can keep using this default page.
*/
import { AuthPagesClient } from '@hykocx/zen/auth/pages';
import {
registerAction,
loginAction,
logoutAction,
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
setSessionCookie,
getSession
} from '@hykocx/zen/auth/actions';
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="max-w-md w-full">
<AuthPagesClient
params={params}
searchParams={searchParams}
registerAction={registerAction}
loginAction={loginAction}
logoutAction={logoutAction}
forgotPasswordAction={forgotPasswordAction}
resetPasswordAction={resetPasswordAction}
verifyEmailAction={verifyEmailAction}
setSessionCookieAction={setSessionCookie}
redirectAfterLogin={process.env.ZEN_AUTH_REDIRECT_AFTER_LOGIN || '/'}
currentUser={session?.user || null}
/>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
'use client';
/**
* Auth Pages Export for Next.js App Router
*
* This exports the auth client components.
* Users must create their own server component wrapper that imports the actions.
*/
export { default as AuthPagesClient } from './components/AuthPages.js';
export { default as AuthPagesLayout } from './components/AuthPagesLayout.js';
+12
View File
@@ -0,0 +1,12 @@
'use client';
import { ToastProvider, ToastContainer } from '@hykocx/zen/toast';
export function ZenProvider({ children }) {
return (
<ToastProvider>
{children}
<ToastContainer />
</ToastProvider>
);
}
+3
View File
@@ -0,0 +1,3 @@
'use client';
export { ZenProvider } from './ZenProvider.js';
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env node
/**
* Zen Setup CLI
* Command-line tool for setting up Zen in a Next.js project
*/
import { mkdir, writeFile } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { resolve, dirname } from 'node:path';
import readline from 'readline';
// File templates
const templates = {
instrumentation: `// instrumentation.js
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { initializeZen } = await import('@hykocx/zen');
await initializeZen();
}
}
`,
authRedirect: `import { redirect } from 'next/navigation';
export default function Redirect() {
redirect('/auth/login/');
}
`,
authCatchAll: `export { default } from '@hykocx/zen/auth/page';
`,
adminRedirect: `import { redirect } from 'next/navigation';
export default function Redirect() {
redirect('/admin/dashboard');
}
`,
adminCatchAll: `export { default } from '@hykocx/zen/admin/page';
`,
zenApiRoute: `export { GET, POST, PUT, DELETE, PATCH } from '@hykocx/zen/zen/api';
`,
zenPageRoute: `export { default, generateMetadata } from '@hykocx/zen/modules/page';
`,
nextConfig: `// next.config.js
module.exports = {
experimental: {
instrumentationHook: true,
},
};
`,
};
// File definitions
const files = [
{
path: 'instrumentation.js',
template: 'instrumentation',
description: 'Instrumentation file (initialize Zen)',
},
{
path: 'app/(auth)/auth/page.js',
template: 'authRedirect',
description: 'Auth redirect page',
},
{
path: 'app/(auth)/auth/[...auth]/page.js',
template: 'authCatchAll',
description: 'Auth catch-all route',
},
{
path: 'app/(admin)/admin/page.js',
template: 'adminRedirect',
description: 'Admin redirect page',
},
{
path: 'app/(admin)/admin/[...admin]/page.js',
template: 'adminCatchAll',
description: 'Admin catch-all route',
},
{
path: 'app/zen/api/[...path]/route.js',
template: 'zenApiRoute',
description: 'Zen API catch-all route',
},
{
path: 'app/zen/[...zen]/page.js',
template: 'zenPageRoute',
description: 'Zen public pages catch-all route',
},
];
async function createFile(filePath, content, force = false) {
const fullPath = resolve(process.cwd(), filePath);
// Check if file already exists
if (existsSync(fullPath) && !force) {
console.log(`⏭️ Skipped (already exists): ${filePath}`);
return { created: false, skipped: true };
}
// Create directory if it doesn't exist
const dir = dirname(fullPath);
await mkdir(dir, { recursive: true });
// Write the file
await writeFile(fullPath, content, 'utf-8');
console.log(`✅ Created: ${filePath}`);
return { created: true, skipped: false };
}
async function setupZen(options = {}) {
const { force = false } = options;
console.log('🚀 Setting up Zen for your Next.js project...\n');
let created = 0;
let skipped = 0;
for (const file of files) {
const result = await createFile(
file.path,
templates[file.template],
force
);
if (result.created) created++;
if (result.skipped) skipped++;
}
console.log('\n📝 Summary:');
console.log(` ✅ Created: ${created} file${created !== 1 ? 's' : ''}`);
console.log(` ⏭️ Skipped: ${skipped} file${skipped !== 1 ? 's' : ''}`);
// Check if next.config.js needs updating
const nextConfigPath = resolve(process.cwd(), 'next.config.js');
const nextConfigExists = existsSync(nextConfigPath);
if (!nextConfigExists) {
console.log('\n⚠️ Note: next.config.js not found.');
console.log(' Make sure to enable instrumentation in your Next.js config:');
console.log(' experimental: { instrumentationHook: true }');
}
console.log('\n🎉 Setup complete!');
console.log('\nNext steps:');
console.log(' 1. Add Zen styles to your globals.css:');
console.log(' @import \'@hykocx/zen/styles/zen.css\';');
console.log(' 2. Configure environment variables (see .env.example)');
console.log(' 3. Initialize the database:');
console.log(' npx zen-db init');
console.log('\nFor more information, check the INSTALL.md file.');
}
async function listFiles() {
console.log('📋 Files that will be created:\n');
for (const file of files) {
const exists = existsSync(resolve(process.cwd(), file.path));
const status = exists ? '✓ exists' : '✗ missing';
console.log(` ${status} ${file.path}`);
console.log(` ${file.description}`);
}
console.log('\nRun "npx zen-setup init" to create missing files.');
}
async function runCLI() {
const command = process.argv[2];
const flags = process.argv.slice(3);
const force = flags.includes('--force') || flags.includes('-f');
if (!command || command === 'help') {
console.log(`
Zen Setup CLI
Usage:
npx zen-setup <command> [options]
Commands:
init Create all required files for Zen setup
list List all files that will be created
help Show this help message
Options:
--force, -f Force overwrite existing files
Examples:
npx zen-setup init # Create missing files
npx zen-setup init --force # Overwrite all files
npx zen-setup list # List all files
`);
process.exit(0);
}
try {
switch (command) {
case 'init':
if (force) {
console.log('⚠️ WARNING: --force flag will overwrite existing files!\n');
console.log('Type "yes" to confirm or Ctrl+C to cancel...');
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
rl.question('Confirm (yes/no): ', async (answer) => {
if (answer.toLowerCase() === 'yes') {
await setupZen({ force: true });
} else {
console.log('❌ Operation cancelled.');
}
rl.close();
process.exit(0);
});
return; // Don't exit yet
} else {
await setupZen({ force: false });
}
break;
case 'list':
await listFiles();
break;
default:
console.log(`❌ Unknown command: ${command}`);
console.log('Run "npx zen-setup help" for usage information.');
process.exit(1);
}
process.exit(0);
} catch (error) {
console.error('❌ Error:', error.message);
console.error(error.stack);
process.exit(1);
}
}
// Run CLI if called directly
import { fileURLToPath } from 'url';
import { realpathSync } from 'node:fs';
const __filename = realpathSync(fileURLToPath(import.meta.url));
const isMainModule = process.argv[1] && realpathSync(process.argv[1]) === __filename;
if (isMainModule) {
runCLI();
}
export { runCLI, setupZen };
+6
View File
@@ -0,0 +1,6 @@
/**
* Zen Setup Module
* Utilities for setting up Zen in a Next.js project
*/
export { setupZen } from './cli.js';