chore: import codes
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
@@ -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
|
||||
};
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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 site’s 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 Zen’s 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 don’t 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.
|
||||
@@ -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 user’s 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:
|
||||
|
||||
- **User–client 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 user’s linked client and that client’s 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).
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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' : ''}`;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { ToastProvider, ToastContainer } from '@hykocx/zen/toast';
|
||||
|
||||
export function ZenProvider({ children }) {
|
||||
return (
|
||||
<ToastProvider>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</ToastProvider>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
'use client';
|
||||
|
||||
export { ZenProvider } from './ZenProvider.js';
|
||||
@@ -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 };
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Zen Setup Module
|
||||
* Utilities for setting up Zen in a Next.js project
|
||||
*/
|
||||
|
||||
export { setupZen } from './cli.js';
|
||||
Reference in New Issue
Block a user