chore: import codes
This commit is contained in:
@@ -0,0 +1,303 @@
|
||||
/**
|
||||
* Dynamic API Router
|
||||
* Routes incoming requests to appropriate handlers
|
||||
* Supports both core routes and dynamically discovered module routes
|
||||
*/
|
||||
|
||||
import { validateSession } from '../../features/auth/lib/session.js';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getSessionCookieName } from '../../shared/lib/appConfig.js';
|
||||
|
||||
// Core handlers
|
||||
import { handleHealth } from './handlers/health.js';
|
||||
import { handleVersion } from './handlers/version.js';
|
||||
import {
|
||||
handleGetCurrentUser,
|
||||
handleGetUserById,
|
||||
handleListUsers,
|
||||
handleUpdateProfile,
|
||||
handleUpdateUserById,
|
||||
handleUploadProfilePicture,
|
||||
handleDeleteProfilePicture
|
||||
} from './handlers/users.js';
|
||||
import { handleGetFile } from './handlers/storage.js';
|
||||
import updatesHandler from './handlers/updates.js';
|
||||
|
||||
// Get cookie name from environment or use default
|
||||
const COOKIE_NAME = getSessionCookieName();
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
* @param {Request} request - The request object
|
||||
* @returns {Promise<Object>} Session object if authenticated
|
||||
* @throws {Error} If not authenticated
|
||||
*/
|
||||
async function requireAuth(request) {
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session || !session.user) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated and is admin
|
||||
* @param {Request} request - The request object
|
||||
* @returns {Promise<Object>} Session object if authenticated and admin
|
||||
* @throws {Error} If not authenticated or not admin
|
||||
*/
|
||||
async function requireAdmin(request) {
|
||||
const session = await requireAuth(request);
|
||||
|
||||
if (session.user.role !== 'admin') {
|
||||
throw new Error('Admin access required');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route an API request to the appropriate handler
|
||||
* @param {Request} request - The incoming request
|
||||
* @param {string[]} path - The path segments after /zen/api/
|
||||
* @returns {Promise<Object>} - The response data
|
||||
*/
|
||||
export async function routeRequest(request, path) {
|
||||
const method = request.method;
|
||||
|
||||
// Try core routes first
|
||||
const coreResult = await routeCoreRequest(request, path, method);
|
||||
if (coreResult !== null) {
|
||||
return coreResult;
|
||||
}
|
||||
|
||||
// Try module routes
|
||||
const moduleResult = await routeModuleRequest(request, path, method);
|
||||
if (moduleResult !== null) {
|
||||
return moduleResult;
|
||||
}
|
||||
|
||||
// No matching route
|
||||
return {
|
||||
error: 'Not Found',
|
||||
message: `No handler found for ${method} ${path.join('/')}`,
|
||||
path: path
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route core (non-module) requests
|
||||
* @param {Request} request - The incoming request
|
||||
* @param {string[]} path - The path segments
|
||||
* @param {string} method - HTTP method
|
||||
* @returns {Promise<Object|null>} Response or null if no match
|
||||
*/
|
||||
async function routeCoreRequest(request, path, method) {
|
||||
// Health check endpoint
|
||||
if (path[0] === 'health' && method === 'GET') {
|
||||
return await handleHealth();
|
||||
}
|
||||
|
||||
// Version endpoint
|
||||
if (path[0] === 'version' && method === 'GET') {
|
||||
return await handleVersion();
|
||||
}
|
||||
|
||||
// Updates endpoint
|
||||
if (path[0] === 'updates' && method === 'GET') {
|
||||
return await updatesHandler(request);
|
||||
}
|
||||
|
||||
// Storage endpoint - serve files securely
|
||||
if (path[0] === 'storage' && method === 'GET') {
|
||||
const fileKey = path.slice(1).join('/');
|
||||
if (!fileKey) {
|
||||
return {
|
||||
error: 'Bad Request',
|
||||
message: 'File path is required'
|
||||
};
|
||||
}
|
||||
return await handleGetFile(request, fileKey);
|
||||
}
|
||||
|
||||
// User endpoints
|
||||
if (path[0] === 'users') {
|
||||
// GET /zen/api/users - List all users (admin only)
|
||||
if (path.length === 1 && method === 'GET') {
|
||||
return await handleListUsers(request);
|
||||
}
|
||||
|
||||
// GET /zen/api/users/me - Get current user
|
||||
if (path[1] === 'me' && method === 'GET') {
|
||||
return await handleGetCurrentUser(request);
|
||||
}
|
||||
|
||||
// PUT /zen/api/users/profile - Update current user profile
|
||||
if (path[1] === 'profile' && method === 'PUT') {
|
||||
return await handleUpdateProfile(request);
|
||||
}
|
||||
|
||||
// POST /zen/api/users/profile/picture - Upload profile picture
|
||||
if (path[1] === 'profile' && path[2] === 'picture' && method === 'POST') {
|
||||
return await handleUploadProfilePicture(request);
|
||||
}
|
||||
|
||||
// DELETE /zen/api/users/profile/picture - Delete profile picture
|
||||
if (path[1] === 'profile' && path[2] === 'picture' && method === 'DELETE') {
|
||||
return await handleDeleteProfilePicture(request);
|
||||
}
|
||||
|
||||
// GET /zen/api/users/:id - Get user by ID (admin only)
|
||||
if (path[1] && path[1] !== 'me' && path[1] !== 'profile' && method === 'GET') {
|
||||
return await handleGetUserById(request, path[1]);
|
||||
}
|
||||
|
||||
// PUT /zen/api/users/:id - Update user by ID (admin only)
|
||||
if (path[1] && path[1] !== 'me' && path[1] !== 'profile' && method === 'PUT') {
|
||||
return await handleUpdateUserById(request, path[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route module requests
|
||||
* @param {Request} request - The incoming request
|
||||
* @param {string[]} path - The path segments
|
||||
* @param {string} method - HTTP method
|
||||
* @returns {Promise<Object|null>} Response or null if no match
|
||||
*/
|
||||
async function routeModuleRequest(request, path, method) {
|
||||
try {
|
||||
// Import module registry
|
||||
const { getAllApiRoutes } = await import('../modules/registry.js');
|
||||
const routes = getAllApiRoutes();
|
||||
|
||||
// Convert path array to path string
|
||||
const pathString = '/' + path.join('/');
|
||||
|
||||
// Find matching route
|
||||
for (const route of routes) {
|
||||
if (matchRoute(route.path, pathString) && route.method === method) {
|
||||
// Check authentication
|
||||
try {
|
||||
if (route.auth === 'admin') {
|
||||
await requireAdmin(request);
|
||||
} else if (route.auth === 'user' || route.auth === 'auth') {
|
||||
await requireAuth(request);
|
||||
}
|
||||
// 'public' or undefined means no auth required
|
||||
|
||||
// Call the handler
|
||||
if (typeof route.handler === 'function') {
|
||||
// Extract path parameters
|
||||
const params = extractPathParams(route.path, pathString);
|
||||
return await route.handler(request, params);
|
||||
}
|
||||
} catch (authError) {
|
||||
return {
|
||||
success: false,
|
||||
error: authError.message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[Dynamic Router] Error routing module request:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a route pattern against a path
|
||||
* Supports path parameters like :id
|
||||
* @param {string} pattern - Route pattern (e.g., '/admin/invoices/:id')
|
||||
* @param {string} path - Actual path (e.g., '/admin/invoices/123')
|
||||
* @returns {boolean} True if matches
|
||||
*/
|
||||
function matchRoute(pattern, path) {
|
||||
const patternParts = pattern.split('/').filter(Boolean);
|
||||
const pathParts = path.split('/').filter(Boolean);
|
||||
|
||||
if (patternParts.length !== pathParts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
const patternPart = patternParts[i];
|
||||
const pathPart = pathParts[i];
|
||||
|
||||
// Skip parameter parts (they match anything)
|
||||
if (patternPart.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patternPart !== pathPart) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path parameters from a path
|
||||
* @param {string} pattern - Route pattern (e.g., '/admin/invoices/:id')
|
||||
* @param {string} path - Actual path (e.g., '/admin/invoices/123')
|
||||
* @returns {Object} Path parameters
|
||||
*/
|
||||
function extractPathParams(pattern, path) {
|
||||
const params = {};
|
||||
const patternParts = pattern.split('/').filter(Boolean);
|
||||
const pathParts = path.split('/').filter(Boolean);
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
const patternPart = patternParts[i];
|
||||
|
||||
if (patternPart.startsWith(':')) {
|
||||
const paramName = patternPart.slice(1);
|
||||
params[paramName] = pathParts[i];
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTTP status code based on the response
|
||||
* @param {Object} response - The response object
|
||||
* @returns {number} - HTTP status code
|
||||
*/
|
||||
export function getStatusCode(response) {
|
||||
if (response.error) {
|
||||
switch (response.error) {
|
||||
case 'Unauthorized':
|
||||
return 401;
|
||||
case 'Forbidden':
|
||||
case 'Admin access required':
|
||||
return 403;
|
||||
case 'Not Found':
|
||||
return 404;
|
||||
case 'Bad Request':
|
||||
return 400;
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
return 200;
|
||||
}
|
||||
|
||||
// Export auth helpers for use in module handlers
|
||||
export { requireAuth, requireAdmin };
|
||||
@@ -0,0 +1,13 @@
|
||||
/**
|
||||
* Health Check Handler
|
||||
* Returns the status of the API and basic system information
|
||||
*/
|
||||
|
||||
export async function handleHealth() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
version: process.env.npm_package_version || '0.1.0'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Storage API Handlers
|
||||
* Handles secure file access
|
||||
*/
|
||||
|
||||
import { validateSession } from '../../../features/auth/lib/session.js';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getSessionCookieName } from '../../../shared/lib/appConfig.js';
|
||||
import { getFile } from '@hykocx/zen/storage';
|
||||
|
||||
// Get cookie name from environment or use default
|
||||
const COOKIE_NAME = getSessionCookieName();
|
||||
|
||||
/**
|
||||
* Serve a file from storage with security validation
|
||||
* @param {Request} request - The request object
|
||||
* @param {string} fileKey - The file key/path in storage
|
||||
* @returns {Promise<Response|Object>} File response or error object
|
||||
*/
|
||||
export async function handleGetFile(request, fileKey) {
|
||||
try {
|
||||
const pathParts = fileKey.split('/');
|
||||
|
||||
// Blog images: public read (no auth) for site integration
|
||||
if (pathParts[0] === 'blog') {
|
||||
const result = await getFile(fileKey);
|
||||
if (!result.success) {
|
||||
if (result.error.includes('NoSuchKey') || result.error.includes('not found')) {
|
||||
return { error: 'Not Found', message: 'File not found' };
|
||||
}
|
||||
return { error: 'Internal Server Error', message: result.error || 'Failed to retrieve file' };
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
file: {
|
||||
body: result.data.body,
|
||||
contentType: result.data.contentType,
|
||||
contentLength: result.data.contentLength,
|
||||
lastModified: result.data.lastModified
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Require auth for other paths
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'Authentication required to access files'
|
||||
};
|
||||
}
|
||||
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired session'
|
||||
};
|
||||
}
|
||||
|
||||
// Security validation based on file path
|
||||
if (pathParts[0] === 'users') {
|
||||
// User files: users/{userId}/{category}/{filename}
|
||||
const userId = pathParts[1];
|
||||
|
||||
// Users can only access their own files, unless they're admin
|
||||
if (session.user.id !== userId && session.user.role !== 'admin') {
|
||||
return {
|
||||
error: 'Forbidden',
|
||||
message: 'You do not have permission to access this file'
|
||||
};
|
||||
}
|
||||
} else if (pathParts[0] === 'organizations') {
|
||||
// Organization files: organizations/{orgId}/{category}/{filename}
|
||||
// For now, only admins can access organization files
|
||||
if (session.user.role !== 'admin') {
|
||||
return {
|
||||
error: 'Forbidden',
|
||||
message: 'Admin access required for organization files'
|
||||
};
|
||||
}
|
||||
} else {
|
||||
// Unknown file path pattern - deny by default
|
||||
return {
|
||||
error: 'Forbidden',
|
||||
message: 'Invalid file path'
|
||||
};
|
||||
}
|
||||
|
||||
// Get file from storage
|
||||
const result = await getFile(fileKey);
|
||||
|
||||
if (!result.success) {
|
||||
if (result.error.includes('NoSuchKey') || result.error.includes('not found')) {
|
||||
return {
|
||||
error: 'Not Found',
|
||||
message: 'File not found'
|
||||
};
|
||||
}
|
||||
return {
|
||||
error: 'Internal Server Error',
|
||||
message: result.error || 'Failed to retrieve file'
|
||||
};
|
||||
}
|
||||
|
||||
// Return the file data with proper headers
|
||||
return {
|
||||
success: true,
|
||||
file: {
|
||||
body: result.data.body,
|
||||
contentType: result.data.contentType,
|
||||
contentLength: result.data.contentLength,
|
||||
lastModified: result.data.lastModified
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error serving file:', error);
|
||||
return {
|
||||
error: 'Internal Server Error',
|
||||
message: error.message || 'Failed to retrieve file'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,568 @@
|
||||
/**
|
||||
* Users API Handlers
|
||||
* Handles user-related API endpoints
|
||||
*/
|
||||
|
||||
import { validateSession } from '../../../features/auth/lib/session.js';
|
||||
import { cookies } from 'next/headers';
|
||||
import { query, updateById } from '@hykocx/zen/database';
|
||||
import { getSessionCookieName, getModulesConfig } from '../../../shared/lib/appConfig.js';
|
||||
import { updateUser } from '../../../features/auth/lib/auth.js';
|
||||
import { uploadImage, deleteFile, generateUniqueFilename, generateUserFilePath, FILE_TYPE_PRESETS, FILE_SIZE_LIMITS, validateUpload } from '@hykocx/zen/storage';
|
||||
|
||||
// Get cookie name from environment or use default
|
||||
const COOKIE_NAME = getSessionCookieName();
|
||||
|
||||
/**
|
||||
* Get current user information
|
||||
*/
|
||||
export async function handleGetCurrentUser(request) {
|
||||
// Get session token from cookies
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'No session token provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired session'
|
||||
};
|
||||
}
|
||||
|
||||
// Return user data (without sensitive information)
|
||||
return {
|
||||
user: {
|
||||
id: session.user.id,
|
||||
email: session.user.email,
|
||||
name: session.user.name,
|
||||
role: session.user.role,
|
||||
image: session.user.image,
|
||||
emailVerified: session.user.email_verified,
|
||||
createdAt: session.user.created_at
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user by ID (admin only)
|
||||
*/
|
||||
export async function handleGetUserById(request, userId) {
|
||||
// Get session token from cookies
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'No session token provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired session'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
if (session.user.role !== 'admin') {
|
||||
return {
|
||||
error: 'Forbidden',
|
||||
message: 'Admin access required'
|
||||
};
|
||||
}
|
||||
|
||||
// Get user from database
|
||||
const result = await query(
|
||||
'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return {
|
||||
error: 'Not Found',
|
||||
message: 'User not found'
|
||||
};
|
||||
}
|
||||
|
||||
const response = { user: result.rows[0] };
|
||||
|
||||
// When clients module is active, include the client linked to this user (if any)
|
||||
const modules = getModulesConfig();
|
||||
if (modules.clients) {
|
||||
const clientResult = await query(
|
||||
`SELECT id, client_number, company_name, first_name, last_name, email
|
||||
FROM zen_clients WHERE user_id = $1 LIMIT 1`,
|
||||
[userId]
|
||||
);
|
||||
response.linkedClient = clientResult.rows[0] || null;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update user by ID (admin only)
|
||||
*/
|
||||
export async function handleUpdateUserById(request, userId) {
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return { success: false, error: 'Unauthorized', message: 'No session token provided' };
|
||||
}
|
||||
|
||||
const session = await validateSession(sessionToken);
|
||||
if (!session) {
|
||||
return { success: false, error: 'Unauthorized', message: 'Invalid or expired session' };
|
||||
}
|
||||
if (session.user.role !== 'admin') {
|
||||
return { success: false, error: 'Forbidden', message: 'Admin access required' };
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const allowedFields = ['name', 'role', 'email_verified'];
|
||||
const updateData = { updated_at: new Date() };
|
||||
|
||||
for (const field of allowedFields) {
|
||||
if (body[field] !== undefined) {
|
||||
if (field === 'email_verified') {
|
||||
updateData[field] = Boolean(body[field]);
|
||||
} else if (field === 'role') {
|
||||
const role = String(body[field]).toLowerCase();
|
||||
if (['admin', 'user'].includes(role)) {
|
||||
updateData[field] = role;
|
||||
}
|
||||
} else if (field === 'name' && body[field] != null) {
|
||||
updateData[field] = String(body[field]).trim() || null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateById('zen_auth_users', userId, updateData);
|
||||
if (!updated) {
|
||||
return { success: false, error: 'Not Found', message: 'User not found' };
|
||||
}
|
||||
|
||||
// When clients module is active, update client association (one user = one client)
|
||||
const modules = getModulesConfig();
|
||||
if (modules.clients && body.client_id !== undefined) {
|
||||
const clientId = body.client_id === null || body.client_id === '' ? null : parseInt(body.client_id, 10);
|
||||
// Unlink all clients currently linked to this user
|
||||
await query(
|
||||
'UPDATE zen_clients SET user_id = NULL, updated_at = CURRENT_TIMESTAMP WHERE user_id = $1',
|
||||
[userId]
|
||||
);
|
||||
// Link the selected client to this user if provided
|
||||
if (clientId != null && !Number.isNaN(clientId)) {
|
||||
await query(
|
||||
'UPDATE zen_clients SET user_id = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2',
|
||||
[userId, clientId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
'SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users WHERE id = $1',
|
||||
[userId]
|
||||
);
|
||||
return {
|
||||
success: true,
|
||||
user: result.rows[0],
|
||||
message: 'User updated successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating user:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
message: error.message || 'Failed to update user'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* List all users (admin only)
|
||||
*/
|
||||
export async function handleListUsers(request) {
|
||||
// Get session token from cookies
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'No session token provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired session'
|
||||
};
|
||||
}
|
||||
|
||||
// Check if user is admin
|
||||
if (session.user.role !== 'admin') {
|
||||
return {
|
||||
error: 'Forbidden',
|
||||
message: 'Admin access required'
|
||||
};
|
||||
}
|
||||
|
||||
// Get URL params for pagination and sorting
|
||||
const url = new URL(request.url);
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
const limit = parseInt(url.searchParams.get('limit') || '10');
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Get sorting parameters
|
||||
const sortBy = url.searchParams.get('sortBy') || 'created_at';
|
||||
const sortOrder = url.searchParams.get('sortOrder') || 'desc';
|
||||
|
||||
// Whitelist allowed sort columns to prevent SQL injection
|
||||
const allowedSortColumns = ['id', 'email', 'name', 'role', 'email_verified', 'created_at'];
|
||||
const sortColumn = allowedSortColumns.includes(sortBy) ? sortBy : 'created_at';
|
||||
|
||||
// Validate sort order
|
||||
const order = sortOrder.toLowerCase() === 'asc' ? 'ASC' : 'DESC';
|
||||
|
||||
// Get users from database with dynamic sorting
|
||||
const result = await query(
|
||||
`SELECT id, email, name, role, image, email_verified, created_at FROM zen_auth_users ORDER BY ${sortColumn} ${order} LIMIT $1 OFFSET $2`,
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
// Get total count
|
||||
const countResult = await query('SELECT COUNT(*) FROM zen_auth_users');
|
||||
const total = parseInt(countResult.rows[0].count);
|
||||
|
||||
return {
|
||||
users: result.rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current user profile
|
||||
*/
|
||||
export async function handleUpdateProfile(request) {
|
||||
// Get session token from cookies
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
message: 'No session token provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired session'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get update data from request body
|
||||
const body = await request.json();
|
||||
const { name } = body;
|
||||
|
||||
// Validate input
|
||||
if (!name || !name.trim()) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Bad Request',
|
||||
message: 'Name is required'
|
||||
};
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {
|
||||
name: name.trim()
|
||||
};
|
||||
|
||||
// Update user profile
|
||||
const updatedUser = await updateUser(session.user.id, updateData);
|
||||
|
||||
// Return updated user data (without sensitive information)
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
name: updatedUser.name,
|
||||
role: updatedUser.role,
|
||||
image: updatedUser.image,
|
||||
email_verified: updatedUser.email_verified,
|
||||
created_at: updatedUser.created_at
|
||||
},
|
||||
message: 'Profile updated successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
message: error.message || 'Failed to update profile'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload profile picture
|
||||
*/
|
||||
export async function handleUploadProfilePicture(request) {
|
||||
// Get session token from cookies
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
message: 'No session token provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired session'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get form data from request
|
||||
const formData = await request.formData();
|
||||
const file = formData.get('file');
|
||||
|
||||
if (!file) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Bad Request',
|
||||
message: 'No file provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate file
|
||||
const validation = validateUpload({
|
||||
filename: file.name,
|
||||
size: file.size,
|
||||
allowedTypes: FILE_TYPE_PRESETS.IMAGES,
|
||||
maxSize: FILE_SIZE_LIMITS.AVATAR
|
||||
});
|
||||
|
||||
if (!validation.valid) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Bad Request',
|
||||
message: validation.errors.join(', ')
|
||||
};
|
||||
}
|
||||
|
||||
// Get current user to check for existing profile picture
|
||||
const currentUser = await query(
|
||||
'SELECT image FROM zen_auth_users WHERE id = $1',
|
||||
[session.user.id]
|
||||
);
|
||||
|
||||
let oldImageKey = null;
|
||||
if (currentUser.rows.length > 0 && currentUser.rows[0].image) {
|
||||
// The image field now contains the storage key directly
|
||||
oldImageKey = currentUser.rows[0].image;
|
||||
}
|
||||
|
||||
// Generate unique filename
|
||||
const uniqueFilename = generateUniqueFilename(file.name, 'avatar');
|
||||
|
||||
// Generate storage path
|
||||
const key = generateUserFilePath(session.user.id, 'profile', uniqueFilename);
|
||||
|
||||
// Convert file to buffer
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// Upload to storage
|
||||
const uploadResult = await uploadImage({
|
||||
key,
|
||||
body: buffer,
|
||||
contentType: file.type,
|
||||
metadata: {
|
||||
userId: session.user.id,
|
||||
originalName: file.name
|
||||
}
|
||||
});
|
||||
|
||||
if (!uploadResult.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Upload Failed',
|
||||
message: uploadResult.error || 'Failed to upload image'
|
||||
};
|
||||
}
|
||||
|
||||
// Update user profile with storage key (not URL)
|
||||
const updatedUser = await updateUser(session.user.id, {
|
||||
image: key
|
||||
});
|
||||
|
||||
// Delete old image if it exists (after successful upload)
|
||||
if (oldImageKey) {
|
||||
try {
|
||||
await deleteFile(oldImageKey);
|
||||
console.log(`[ZEN] Deleted old profile picture: ${oldImageKey}`);
|
||||
} catch (deleteError) {
|
||||
// Log error but don't fail the upload
|
||||
console.error('[ZEN] Failed to delete old profile picture:', deleteError);
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated user data
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
name: updatedUser.name,
|
||||
role: updatedUser.role,
|
||||
image: updatedUser.image,
|
||||
email_verified: updatedUser.email_verified,
|
||||
created_at: updatedUser.created_at
|
||||
},
|
||||
message: 'Profile picture uploaded successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error uploading profile picture:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
message: error.message || 'Failed to upload profile picture'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete profile picture
|
||||
*/
|
||||
export async function handleDeleteProfilePicture(request) {
|
||||
// Get session token from cookies
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
message: 'No session token provided'
|
||||
};
|
||||
}
|
||||
|
||||
// Validate session
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
message: 'Invalid or expired session'
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// Get current user to check for existing profile picture
|
||||
const currentUser = await query(
|
||||
'SELECT image FROM zen_auth_users WHERE id = $1',
|
||||
[session.user.id]
|
||||
);
|
||||
|
||||
if (currentUser.rows.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Not Found',
|
||||
message: 'User not found'
|
||||
};
|
||||
}
|
||||
|
||||
const imageKey = currentUser.rows[0].image;
|
||||
if (!imageKey) {
|
||||
return {
|
||||
success: false,
|
||||
error: 'Bad Request',
|
||||
message: 'No profile picture to delete'
|
||||
};
|
||||
}
|
||||
|
||||
// Update user profile to remove image URL
|
||||
const updatedUser = await updateUser(session.user.id, {
|
||||
image: null
|
||||
});
|
||||
|
||||
// Delete image from storage
|
||||
if (imageKey) {
|
||||
try {
|
||||
await deleteFile(imageKey);
|
||||
console.log(`[ZEN] Deleted profile picture: ${imageKey}`);
|
||||
} catch (deleteError) {
|
||||
// Log error but don't fail the update
|
||||
console.error('[ZEN] Failed to delete profile picture from storage:', deleteError);
|
||||
}
|
||||
}
|
||||
|
||||
// Return updated user data
|
||||
return {
|
||||
success: true,
|
||||
user: {
|
||||
id: updatedUser.id,
|
||||
email: updatedUser.email,
|
||||
name: updatedUser.name,
|
||||
role: updatedUser.role,
|
||||
image: updatedUser.image,
|
||||
email_verified: updatedUser.email_verified,
|
||||
created_at: updatedUser.created_at
|
||||
},
|
||||
message: 'Profile picture deleted successfully'
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error deleting profile picture:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: 'Internal Server Error',
|
||||
message: error.message || 'Failed to delete profile picture'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Version Handler
|
||||
* Returns version information about the ZEN API
|
||||
*/
|
||||
|
||||
import { getAppName } from '../../../shared/lib/appConfig.js';
|
||||
|
||||
export async function handleVersion() {
|
||||
return {
|
||||
name: 'ZEN API',
|
||||
appName: getAppName(),
|
||||
version: '0.1.0',
|
||||
apiVersion: '1.0',
|
||||
description: 'ZEN API - Complete modular web platform'
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Zen API Module
|
||||
*
|
||||
* This module exports API utilities for custom handlers
|
||||
* For route setup, import from '@hykocx/zen/zen/api'
|
||||
*/
|
||||
|
||||
// Export router utilities (for custom handlers)
|
||||
export { routeRequest, getStatusCode, requireAuth, requireAdmin } from './router.js';
|
||||
|
||||
// Export individual handlers (for custom usage)
|
||||
export { handleHealth } from './handlers/health.js';
|
||||
export { handleVersion } from './handlers/version.js';
|
||||
export {
|
||||
handleGetCurrentUser,
|
||||
handleGetUserById,
|
||||
handleListUsers
|
||||
} from './handlers/users.js';
|
||||
|
||||
// Module API handlers are now self-contained in their respective modules
|
||||
// e.g., invoice handlers are in @hykocx/zen/modules/invoice/api
|
||||
@@ -0,0 +1,166 @@
|
||||
/**
|
||||
* ZEN API Route Handler
|
||||
*
|
||||
* This is the main catch-all route handler for the ZEN API under /zen/api/.
|
||||
* It should be used in a Next.js App Router route at: app/zen/api/[...path]/route.js
|
||||
*/
|
||||
|
||||
import { NextResponse } from 'next/server';
|
||||
import { routeRequest, getStatusCode } from './router.js';
|
||||
|
||||
/**
|
||||
* Handle GET requests
|
||||
*/
|
||||
export async function GET(request, { params }) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const path = resolvedParams.path || [];
|
||||
const response = await routeRequest(request, path);
|
||||
|
||||
// Check if this is a file response (from storage endpoint)
|
||||
if (response.success && response.file) {
|
||||
const headers = {
|
||||
'Content-Type': response.file.contentType || 'application/octet-stream',
|
||||
'Content-Length': response.file.contentLength?.toString() || '',
|
||||
'Cache-Control': 'private, max-age=3600',
|
||||
'Last-Modified': response.file.lastModified?.toUTCString() || new Date().toUTCString(),
|
||||
};
|
||||
if (response.file.filename) {
|
||||
const encoded = encodeURIComponent(response.file.filename);
|
||||
headers['Content-Disposition'] = `attachment; filename="${encoded}"; filename*=UTF-8''${encoded}`;
|
||||
}
|
||||
return new NextResponse(response.file.body, { status: 200, headers });
|
||||
}
|
||||
|
||||
// Regular JSON response
|
||||
const statusCode = getStatusCode(response);
|
||||
return NextResponse.json(response, {
|
||||
status: statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal Server Error',
|
||||
message: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle POST requests
|
||||
*/
|
||||
export async function POST(request, { params }) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const path = resolvedParams.path || [];
|
||||
const response = await routeRequest(request, path);
|
||||
const statusCode = getStatusCode(response);
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal Server Error',
|
||||
message: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PUT requests
|
||||
*/
|
||||
export async function PUT(request, { params }) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const path = resolvedParams.path || [];
|
||||
const response = await routeRequest(request, path);
|
||||
const statusCode = getStatusCode(response);
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal Server Error',
|
||||
message: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle DELETE requests
|
||||
*/
|
||||
export async function DELETE(request, { params }) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const path = resolvedParams.path || [];
|
||||
const response = await routeRequest(request, path);
|
||||
const statusCode = getStatusCode(response);
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal Server Error',
|
||||
message: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle PATCH requests
|
||||
*/
|
||||
export async function PATCH(request, { params }) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const path = resolvedParams.path || [];
|
||||
const response = await routeRequest(request, path);
|
||||
const statusCode = getStatusCode(response);
|
||||
|
||||
return NextResponse.json(response, {
|
||||
status: statusCode,
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('API Error:', error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'Internal Server Error',
|
||||
message: error.message
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
/**
|
||||
* API Router
|
||||
* Routes incoming requests to appropriate handlers
|
||||
*
|
||||
* This router supports both:
|
||||
* - Core routes (health, version, users, storage)
|
||||
* - Module routes (imported directly from module api.js files)
|
||||
*/
|
||||
|
||||
import { validateSession } from '../../features/auth/lib/session.js';
|
||||
import { cookies } from 'next/headers';
|
||||
import { getSessionCookieName } from '../../shared/lib/appConfig.js';
|
||||
import { getAllApiRoutes } from '../modules/index.js';
|
||||
import { checkRateLimit, getIpFromRequest, formatRetryAfter } from '../../features/auth/lib/rateLimit.js';
|
||||
|
||||
// Core handlers
|
||||
import { handleHealth } from './handlers/health.js';
|
||||
import { handleVersion } from './handlers/version.js';
|
||||
import {
|
||||
handleGetCurrentUser,
|
||||
handleGetUserById,
|
||||
handleListUsers,
|
||||
handleUpdateProfile,
|
||||
handleUpdateUserById,
|
||||
handleUploadProfilePicture,
|
||||
handleDeleteProfilePicture
|
||||
} from './handlers/users.js';
|
||||
import { handleGetFile } from './handlers/storage.js';
|
||||
|
||||
// Get cookie name from environment or use default
|
||||
const COOKIE_NAME = getSessionCookieName();
|
||||
|
||||
/**
|
||||
* Get all module routes from the dynamic module registry
|
||||
* @returns {Array} Array of route definitions
|
||||
*/
|
||||
function getModuleRoutes() {
|
||||
// Use the dynamic module registry to get all routes
|
||||
return getAllApiRoutes();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
* @param {Request} request - The request object
|
||||
* @returns {Promise<Object>} Session object if authenticated
|
||||
* @throws {Error} If not authenticated
|
||||
*/
|
||||
async function requireAuth(request) {
|
||||
const cookieStore = await cookies();
|
||||
const sessionToken = cookieStore.get(COOKIE_NAME)?.value;
|
||||
|
||||
if (!sessionToken) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
const session = await validateSession(sessionToken);
|
||||
|
||||
if (!session || !session.user) {
|
||||
throw new Error('Unauthorized');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated and is admin
|
||||
* @param {Request} request - The request object
|
||||
* @returns {Promise<Object>} Session object if authenticated and admin
|
||||
* @throws {Error} If not authenticated or not admin
|
||||
*/
|
||||
async function requireAdmin(request) {
|
||||
const session = await requireAuth(request);
|
||||
|
||||
if (session.user.role !== 'admin') {
|
||||
throw new Error('Admin access required');
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route an API request to the appropriate handler
|
||||
* @param {Request} request - The incoming request
|
||||
* @param {string[]} path - The path segments after /zen/api/
|
||||
* @returns {Promise<Object>} - The response data
|
||||
*/
|
||||
export async function routeRequest(request, path) {
|
||||
const method = request.method;
|
||||
|
||||
// Global IP-based rate limit for all API calls (health/version are exempt)
|
||||
const isExempt = (path[0] === 'health' || path[0] === 'version') && method === 'GET';
|
||||
if (!isExempt) {
|
||||
const ip = getIpFromRequest(request);
|
||||
const rl = checkRateLimit(ip, 'api');
|
||||
if (!rl.allowed) {
|
||||
return {
|
||||
error: 'Too Many Requests',
|
||||
message: `Trop de requêtes. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Try core routes first
|
||||
const coreResult = await routeCoreRequest(request, path, method);
|
||||
if (coreResult !== null) {
|
||||
return coreResult;
|
||||
}
|
||||
|
||||
// Try module routes (dynamically discovered)
|
||||
const moduleResult = await routeModuleRequest(request, path, method);
|
||||
if (moduleResult !== null) {
|
||||
return moduleResult;
|
||||
}
|
||||
|
||||
// No matching route
|
||||
return {
|
||||
error: 'Not Found',
|
||||
message: `No handler found for ${method} ${path.join('/')}`,
|
||||
path: path
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Route core (non-module) requests
|
||||
* @param {Request} request - The incoming request
|
||||
* @param {string[]} path - The path segments
|
||||
* @param {string} method - HTTP method
|
||||
* @returns {Promise<Object|null>} Response or null if no match
|
||||
*/
|
||||
async function routeCoreRequest(request, path, method) {
|
||||
// Health check endpoint
|
||||
if (path[0] === 'health' && method === 'GET') {
|
||||
return await handleHealth();
|
||||
}
|
||||
|
||||
// Version endpoint
|
||||
if (path[0] === 'version' && method === 'GET') {
|
||||
return await handleVersion();
|
||||
}
|
||||
|
||||
// Storage endpoint - serve files securely
|
||||
if (path[0] === 'storage' && method === 'GET') {
|
||||
const fileKey = path.slice(1).join('/');
|
||||
if (!fileKey) {
|
||||
return {
|
||||
error: 'Bad Request',
|
||||
message: 'File path is required'
|
||||
};
|
||||
}
|
||||
return await handleGetFile(request, fileKey);
|
||||
}
|
||||
|
||||
// User endpoints
|
||||
if (path[0] === 'users') {
|
||||
// GET /zen/api/users - List all users (admin only)
|
||||
if (path.length === 1 && method === 'GET') {
|
||||
return await handleListUsers(request);
|
||||
}
|
||||
|
||||
// GET /zen/api/users/me - Get current user
|
||||
if (path[1] === 'me' && method === 'GET') {
|
||||
return await handleGetCurrentUser(request);
|
||||
}
|
||||
|
||||
// PUT /zen/api/users/profile - Update current user profile
|
||||
if (path[1] === 'profile' && method === 'PUT') {
|
||||
return await handleUpdateProfile(request);
|
||||
}
|
||||
|
||||
// POST /zen/api/users/profile/picture - Upload profile picture
|
||||
if (path[1] === 'profile' && path[2] === 'picture' && method === 'POST') {
|
||||
return await handleUploadProfilePicture(request);
|
||||
}
|
||||
|
||||
// DELETE /zen/api/users/profile/picture - Delete profile picture
|
||||
if (path[1] === 'profile' && path[2] === 'picture' && method === 'DELETE') {
|
||||
return await handleDeleteProfilePicture(request);
|
||||
}
|
||||
|
||||
// GET /zen/api/users/:id - Get user by ID (admin only)
|
||||
if (path[1] && path[1] !== 'me' && path[1] !== 'profile' && method === 'GET') {
|
||||
return await handleGetUserById(request, path[1]);
|
||||
}
|
||||
|
||||
// PUT /zen/api/users/:id - Update user by ID (admin only)
|
||||
if (path[1] && path[1] !== 'me' && path[1] !== 'profile' && method === 'PUT') {
|
||||
return await handleUpdateUserById(request, path[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Route module requests
|
||||
* @param {Request} request - The incoming request
|
||||
* @param {string[]} path - The path segments
|
||||
* @param {string} method - HTTP method
|
||||
* @returns {Promise<Object|null>} Response or null if no match
|
||||
*/
|
||||
async function routeModuleRequest(request, path, method) {
|
||||
// Get routes from enabled modules
|
||||
const routes = getModuleRoutes();
|
||||
|
||||
// Convert path array to path string
|
||||
const pathString = '/' + path.join('/');
|
||||
|
||||
// Find matching route
|
||||
for (const route of routes) {
|
||||
if (matchRoute(route.path, pathString) && route.method === method) {
|
||||
// Check authentication
|
||||
try {
|
||||
if (route.auth === 'admin') {
|
||||
await requireAdmin(request);
|
||||
} else if (route.auth === 'user' || route.auth === 'auth') {
|
||||
await requireAuth(request);
|
||||
}
|
||||
// 'public' or undefined means no auth required
|
||||
|
||||
// Call the handler
|
||||
if (typeof route.handler === 'function') {
|
||||
// Extract path parameters
|
||||
const params = extractPathParams(route.path, pathString);
|
||||
return await route.handler(request, params);
|
||||
}
|
||||
} catch (authError) {
|
||||
return {
|
||||
success: false,
|
||||
error: authError.message
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a route pattern against a path
|
||||
* Supports path parameters like :id
|
||||
* @param {string} pattern - Route pattern (e.g., '/admin/invoices/:id')
|
||||
* @param {string} path - Actual path (e.g., '/admin/invoices/123')
|
||||
* @returns {boolean} True if matches
|
||||
*/
|
||||
function matchRoute(pattern, path) {
|
||||
const patternParts = pattern.split('/').filter(Boolean);
|
||||
const pathParts = path.split('/').filter(Boolean);
|
||||
|
||||
if (patternParts.length !== pathParts.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
const patternPart = patternParts[i];
|
||||
const pathPart = pathParts[i];
|
||||
|
||||
// Skip parameter parts (they match anything)
|
||||
if (patternPart.startsWith(':')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (patternPart !== pathPart) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract path parameters from a path
|
||||
* @param {string} pattern - Route pattern (e.g., '/admin/invoices/:id')
|
||||
* @param {string} path - Actual path (e.g., '/admin/invoices/123')
|
||||
* @returns {Object} Path parameters
|
||||
*/
|
||||
function extractPathParams(pattern, path) {
|
||||
const params = {};
|
||||
const patternParts = pattern.split('/').filter(Boolean);
|
||||
const pathParts = path.split('/').filter(Boolean);
|
||||
|
||||
for (let i = 0; i < patternParts.length; i++) {
|
||||
const patternPart = patternParts[i];
|
||||
|
||||
if (patternPart.startsWith(':')) {
|
||||
const paramName = patternPart.slice(1);
|
||||
params[paramName] = pathParts[i];
|
||||
}
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the HTTP status code based on the response
|
||||
* @param {Object} response - The response object
|
||||
* @returns {number} - HTTP status code
|
||||
*/
|
||||
export function getStatusCode(response) {
|
||||
if (response.error) {
|
||||
switch (response.error) {
|
||||
case 'Unauthorized':
|
||||
return 401;
|
||||
case 'Forbidden':
|
||||
case 'Admin access required':
|
||||
return 403;
|
||||
case 'Not Found':
|
||||
return 404;
|
||||
case 'Bad Request':
|
||||
return 400;
|
||||
case 'Too Many Requests':
|
||||
return 429;
|
||||
default:
|
||||
return 500;
|
||||
}
|
||||
}
|
||||
return 200;
|
||||
}
|
||||
|
||||
// Export auth helpers for use in module handlers
|
||||
export { requireAuth, requireAdmin };
|
||||
@@ -0,0 +1,183 @@
|
||||
/**
|
||||
* Cron Utility
|
||||
* Wrapper around node-cron for scheduling tasks
|
||||
*
|
||||
* Usage in modules:
|
||||
* import { schedule, validate } from '@hykocx/zen/cron';
|
||||
*/
|
||||
|
||||
import cron from 'node-cron';
|
||||
|
||||
// Store for all scheduled cron jobs
|
||||
const CRON_JOBS_KEY = Symbol.for('__ZEN_CRON_JOBS__');
|
||||
|
||||
/**
|
||||
* Initialize cron jobs storage
|
||||
*/
|
||||
function getJobsStorage() {
|
||||
if (!globalThis[CRON_JOBS_KEY]) {
|
||||
globalThis[CRON_JOBS_KEY] = new Map();
|
||||
}
|
||||
return globalThis[CRON_JOBS_KEY];
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a cron job
|
||||
* @param {string} name - Unique name for the job
|
||||
* @param {string} schedule - Cron schedule expression
|
||||
* @param {Function} handler - Handler function to execute
|
||||
* @param {Object} options - Options
|
||||
* @param {string} options.timezone - Timezone (default: from env or America/Toronto)
|
||||
* @param {boolean} options.runOnInit - Run immediately on schedule (default: false)
|
||||
* @returns {Object} Cron job instance
|
||||
*
|
||||
* @example
|
||||
* schedule('my-task', '0 9 * * *', async () => {
|
||||
* console.log('Running every day at 9 AM');
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* schedule('reminder', ''\''*\/5 5-17 * * *'\'', async () => {
|
||||
* console.log('Every 5 minutes between 5 AM and 5 PM');
|
||||
* }, { timezone: 'America/New_York' });
|
||||
*/
|
||||
export function schedule(name, cronSchedule, handler, options = {}) {
|
||||
const jobs = getJobsStorage();
|
||||
|
||||
// Stop existing job with same name
|
||||
if (jobs.has(name)) {
|
||||
jobs.get(name).stop();
|
||||
console.log(`[Cron] Stopped existing job: ${name}`);
|
||||
}
|
||||
|
||||
const timezone = options.timezone || process.env.ZEN_TIMEZONE || 'America/Toronto';
|
||||
|
||||
const job = cron.schedule(cronSchedule, async () => {
|
||||
console.log(`[Cron: ${name}] Running at:`, new Date().toISOString());
|
||||
|
||||
try {
|
||||
await handler();
|
||||
console.log(`[Cron: ${name}] Completed`);
|
||||
} catch (error) {
|
||||
console.error(`[Cron: ${name}] Error:`, error);
|
||||
}
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone,
|
||||
runOnInit: options.runOnInit || false
|
||||
});
|
||||
|
||||
jobs.set(name, job);
|
||||
console.log(`[Cron] Scheduled job: ${name} (${cronSchedule})`);
|
||||
|
||||
return job;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop a scheduled cron job
|
||||
* @param {string} name - Job name
|
||||
* @returns {boolean} True if job was stopped
|
||||
*/
|
||||
export function stop(name) {
|
||||
const jobs = getJobsStorage();
|
||||
|
||||
if (jobs.has(name)) {
|
||||
jobs.get(name).stop();
|
||||
jobs.delete(name);
|
||||
console.log(`[Cron] Stopped job: ${name}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all cron jobs
|
||||
*/
|
||||
export function stopAll() {
|
||||
const jobs = getJobsStorage();
|
||||
|
||||
for (const [name, job] of jobs.entries()) {
|
||||
job.stop();
|
||||
console.log(`[Cron] Stopped job: ${name}`);
|
||||
}
|
||||
|
||||
jobs.clear();
|
||||
console.log('[Cron] All jobs stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all cron jobs
|
||||
* @returns {Object} Status of all jobs
|
||||
*/
|
||||
export function getStatus() {
|
||||
const jobs = getJobsStorage();
|
||||
const status = {};
|
||||
|
||||
for (const [name] of jobs.entries()) {
|
||||
status[name] = { running: true };
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a cron job is running
|
||||
* @param {string} name - Job name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isRunning(name) {
|
||||
const jobs = getJobsStorage();
|
||||
return jobs.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a cron expression
|
||||
* @param {string} expression - Cron expression to validate
|
||||
* @returns {boolean} True if valid
|
||||
*/
|
||||
export function validate(expression) {
|
||||
return cron.validate(expression);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of all scheduled job names
|
||||
* @returns {string[]} Array of job names
|
||||
*/
|
||||
export function getJobs() {
|
||||
const jobs = getJobsStorage();
|
||||
return Array.from(jobs.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually trigger a job by name
|
||||
* @param {string} name - Job name
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function trigger(name) {
|
||||
const jobs = getJobsStorage();
|
||||
|
||||
if (!jobs.has(name)) {
|
||||
throw new Error(`Cron job '${name}' not found`);
|
||||
}
|
||||
|
||||
console.log(`[Cron] Manual trigger for: ${name}`);
|
||||
// Note: node-cron doesn't expose the handler directly,
|
||||
// so modules should keep their handler function accessible
|
||||
}
|
||||
|
||||
// Re-export the raw cron module for advanced usage
|
||||
export { cron };
|
||||
|
||||
// Default export for convenience
|
||||
export default {
|
||||
schedule,
|
||||
stop,
|
||||
stopAll,
|
||||
getStatus,
|
||||
isRunning,
|
||||
validate,
|
||||
getJobs,
|
||||
trigger,
|
||||
cron
|
||||
};
|
||||
@@ -0,0 +1,127 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Zen Database CLI
|
||||
* Command-line tool for database management
|
||||
*/
|
||||
|
||||
// Load environment variables from the project's .env file
|
||||
import dotenv from 'dotenv';
|
||||
import { resolve } from 'node:path';
|
||||
|
||||
// Load .env from the current working directory (user's project)
|
||||
dotenv.config({ path: resolve(process.cwd(), '.env') });
|
||||
dotenv.config({ path: resolve(process.cwd(), '.env.local') });
|
||||
|
||||
// The CLI always runs locally, so default to development to use ZEN_DATABASE_URL_DEV if set
|
||||
process.env.NODE_ENV = process.env.NODE_ENV || 'development';
|
||||
|
||||
import { initDatabase, dropAuthTables, testConnection, closePool } from './index.js';
|
||||
import readline from 'readline';
|
||||
|
||||
async function runCLI() {
|
||||
const command = process.argv[2];
|
||||
|
||||
if (!command) {
|
||||
console.log(`
|
||||
Zen Database CLI
|
||||
|
||||
Usage:
|
||||
npx zen-db <command>
|
||||
|
||||
Commands:
|
||||
init Initialize database (create all required tables)
|
||||
test Test database connection
|
||||
drop Drop all authentication tables (DANGER!)
|
||||
help Show this help message
|
||||
|
||||
Example:
|
||||
npx zen-db init
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
try {
|
||||
switch (command) {
|
||||
case 'init':
|
||||
console.log('🔧 Initializing database...\n');
|
||||
const result = await initDatabase();
|
||||
console.log(`\n✅ Success! Created ${result.created.length} tables, skipped ${result.skipped.length} existing tables.`);
|
||||
break;
|
||||
|
||||
case 'test':
|
||||
console.log('🔌 Testing database connection...\n');
|
||||
const isConnected = await testConnection();
|
||||
if (isConnected) {
|
||||
console.log('✅ Database connection successful!');
|
||||
} else {
|
||||
console.log('❌ Database connection failed!');
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'drop':
|
||||
console.log('⚠️ WARNING: This will delete all authentication tables!\n');
|
||||
console.log('Type "yes" to confirm or Ctrl+C to cancel...');
|
||||
|
||||
// Simple confirmation (in production, you'd use a proper readline)
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout
|
||||
});
|
||||
|
||||
rl.question('Confirm (yes/no): ', async (answer) => {
|
||||
if (answer.toLowerCase() === 'yes') {
|
||||
await dropAuthTables();
|
||||
console.log('✅ Tables dropped successfully.');
|
||||
} else {
|
||||
console.log('❌ Operation cancelled.');
|
||||
}
|
||||
rl.close();
|
||||
process.exit(0);
|
||||
});
|
||||
return; // Don't close the process yet
|
||||
|
||||
case 'help':
|
||||
console.log(`
|
||||
Zen Database CLI
|
||||
|
||||
Commands:
|
||||
init Initialize database (create all required tables)
|
||||
test Test database connection
|
||||
drop Drop all authentication tables (DANGER!)
|
||||
help Show this help message
|
||||
|
||||
Usage:
|
||||
npx zen-db <command>
|
||||
`);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log(`❌ Unknown command: ${command}`);
|
||||
console.log('Run "npx zen-db help" for usage information.');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Close the database connection pool
|
||||
await closePool();
|
||||
process.exit(0);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error:', error.message);
|
||||
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 };
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* CRUD Helper Functions
|
||||
* Provides convenient methods for Create, Read, Update, Delete operations
|
||||
*/
|
||||
|
||||
import { query, queryOne, queryAll } from './db.js';
|
||||
|
||||
/**
|
||||
* Insert a new record into a table
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {Object} data - Object with column names as keys and values to insert
|
||||
* @returns {Promise<Object>} Inserted record with all fields
|
||||
*/
|
||||
async function create(tableName, data) {
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
const placeholders = values.map((_, index) => `$${index + 1}`).join(', ');
|
||||
|
||||
const sql = `
|
||||
INSERT INTO ${tableName} (${columns.join(', ')})
|
||||
VALUES (${placeholders})
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await query(sql, values);
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single record by ID
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {number|string} id - ID of the record
|
||||
* @param {string} idColumn - Name of the ID column (default: 'id')
|
||||
* @returns {Promise<Object|null>} Found record or null
|
||||
*/
|
||||
async function findById(tableName, id, idColumn = 'id') {
|
||||
const sql = `SELECT * FROM ${tableName} WHERE ${idColumn} = $1`;
|
||||
return await queryOne(sql, [id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find records matching conditions
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {Object} conditions - Object with column names as keys and values to match
|
||||
* @param {Object} options - Query options (limit, offset, orderBy)
|
||||
* @returns {Promise<Array>} Array of matching records
|
||||
*/
|
||||
async function find(tableName, conditions = {}, options = {}) {
|
||||
const { limit, offset, orderBy } = options;
|
||||
|
||||
let sql = `SELECT * FROM ${tableName}`;
|
||||
const values = [];
|
||||
|
||||
// Build WHERE clause
|
||||
if (Object.keys(conditions).length > 0) {
|
||||
const whereConditions = Object.keys(conditions).map((key, index) => {
|
||||
values.push(conditions[key]);
|
||||
return `${key} = $${index + 1}`;
|
||||
});
|
||||
sql += ` WHERE ${whereConditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
// Add ORDER BY
|
||||
if (orderBy) {
|
||||
sql += ` ORDER BY ${orderBy}`;
|
||||
}
|
||||
|
||||
// Add LIMIT
|
||||
if (limit) {
|
||||
sql += ` LIMIT ${parseInt(limit)}`;
|
||||
}
|
||||
|
||||
// Add OFFSET
|
||||
if (offset) {
|
||||
sql += ` OFFSET ${parseInt(offset)}`;
|
||||
}
|
||||
|
||||
return await queryAll(sql, values);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a single record matching conditions
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {Object} conditions - Object with column names as keys and values to match
|
||||
* @returns {Promise<Object|null>} Found record or null
|
||||
*/
|
||||
async function findOne(tableName, conditions) {
|
||||
const results = await find(tableName, conditions, { limit: 1 });
|
||||
return results.length > 0 ? results[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update a record by ID
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {number|string} id - ID of the record
|
||||
* @param {Object} data - Object with column names as keys and new values
|
||||
* @param {string} idColumn - Name of the ID column (default: 'id')
|
||||
* @returns {Promise<Object|null>} Updated record or null if not found
|
||||
*/
|
||||
async function updateById(tableName, id, data, idColumn = 'id') {
|
||||
const columns = Object.keys(data);
|
||||
const values = Object.values(data);
|
||||
|
||||
const setClause = columns.map((col, index) => `${col} = $${index + 1}`).join(', ');
|
||||
|
||||
const sql = `
|
||||
UPDATE ${tableName}
|
||||
SET ${setClause}
|
||||
WHERE ${idColumn} = $${values.length + 1}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await query(sql, [...values, id]);
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update records matching conditions
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {Object} conditions - Object with column names as keys and values to match
|
||||
* @param {Object} data - Object with column names as keys and new values
|
||||
* @returns {Promise<Array>} Array of updated records
|
||||
*/
|
||||
async function update(tableName, conditions, data) {
|
||||
const dataColumns = Object.keys(data);
|
||||
const dataValues = Object.values(data);
|
||||
|
||||
const setClause = dataColumns.map((col, index) => `${col} = $${index + 1}`).join(', ');
|
||||
|
||||
let paramIndex = dataValues.length + 1;
|
||||
const whereConditions = Object.keys(conditions).map((key) => {
|
||||
dataValues.push(conditions[key]);
|
||||
return `${key} = $${paramIndex++}`;
|
||||
});
|
||||
|
||||
const sql = `
|
||||
UPDATE ${tableName}
|
||||
SET ${setClause}
|
||||
WHERE ${whereConditions.join(' AND ')}
|
||||
RETURNING *
|
||||
`;
|
||||
|
||||
const result = await query(sql, dataValues);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a record by ID
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {number|string} id - ID of the record
|
||||
* @param {string} idColumn - Name of the ID column (default: 'id')
|
||||
* @returns {Promise<boolean>} True if record was deleted, false otherwise
|
||||
*/
|
||||
async function deleteById(tableName, id, idColumn = 'id') {
|
||||
const sql = `DELETE FROM ${tableName} WHERE ${idColumn} = $1 RETURNING *`;
|
||||
const result = await query(sql, [id]);
|
||||
return result.rows.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete records matching conditions
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {Object} conditions - Object with column names as keys and values to match
|
||||
* @returns {Promise<number>} Number of deleted records
|
||||
*/
|
||||
async function deleteWhere(tableName, conditions) {
|
||||
const values = [];
|
||||
const whereConditions = Object.keys(conditions).map((key, index) => {
|
||||
values.push(conditions[key]);
|
||||
return `${key} = $${index + 1}`;
|
||||
});
|
||||
|
||||
const sql = `DELETE FROM ${tableName} WHERE ${whereConditions.join(' AND ')} RETURNING *`;
|
||||
const result = await query(sql, values);
|
||||
return result.rowCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* Count records in a table
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {Object} conditions - Object with column names as keys and values to match (optional)
|
||||
* @returns {Promise<number>} Number of records
|
||||
*/
|
||||
async function count(tableName, conditions = {}) {
|
||||
let sql = `SELECT COUNT(*) as count FROM ${tableName}`;
|
||||
const values = [];
|
||||
|
||||
if (Object.keys(conditions).length > 0) {
|
||||
const whereConditions = Object.keys(conditions).map((key, index) => {
|
||||
values.push(conditions[key]);
|
||||
return `${key} = $${index + 1}`;
|
||||
});
|
||||
sql += ` WHERE ${whereConditions.join(' AND ')}`;
|
||||
}
|
||||
|
||||
const result = await queryOne(sql, values);
|
||||
return parseInt(result.count);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a record exists
|
||||
* @param {string} tableName - Name of the table
|
||||
* @param {Object} conditions - Object with column names as keys and values to match
|
||||
* @returns {Promise<boolean>} True if record exists, false otherwise
|
||||
*/
|
||||
async function exists(tableName, conditions) {
|
||||
const recordCount = await count(tableName, conditions);
|
||||
return recordCount > 0;
|
||||
}
|
||||
|
||||
export {
|
||||
create,
|
||||
findById,
|
||||
find,
|
||||
findOne,
|
||||
updateById,
|
||||
update,
|
||||
deleteById,
|
||||
deleteWhere,
|
||||
count,
|
||||
exists
|
||||
};
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Database Connection and Query Utilities
|
||||
* Provides PostgreSQL database connection and query execution functions
|
||||
*/
|
||||
|
||||
import pkg from 'pg';
|
||||
const { Pool } = pkg;
|
||||
|
||||
let pool = null;
|
||||
|
||||
function resolveDatabaseUrl() {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
if (isDev && process.env.ZEN_DATABASE_URL_DEV) {
|
||||
return process.env.ZEN_DATABASE_URL_DEV;
|
||||
}
|
||||
return process.env.ZEN_DATABASE_URL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a database connection pool
|
||||
* @returns {Pool} PostgreSQL connection pool
|
||||
*/
|
||||
function getPool() {
|
||||
if (!pool) {
|
||||
const databaseUrl = resolveDatabaseUrl();
|
||||
|
||||
if (!databaseUrl) {
|
||||
throw new Error(
|
||||
process.env.NODE_ENV === 'development'
|
||||
? 'ZEN_DATABASE_URL or ZEN_DATABASE_URL_DEV must be defined in environment variables'
|
||||
: 'ZEN_DATABASE_URL is not defined in environment variables'
|
||||
);
|
||||
}
|
||||
|
||||
pool = new Pool({
|
||||
connectionString: databaseUrl,
|
||||
ssl: process.env.NODE_ENV === 'production' ? { rejectUnauthorized: false } : false,
|
||||
max: 20, // Maximum number of clients in the pool
|
||||
idleTimeoutMillis: 30000, // Close idle clients after 30 seconds
|
||||
connectionTimeoutMillis: 2000, // Return an error after 2 seconds if connection could not be established
|
||||
});
|
||||
|
||||
// Handle pool errors
|
||||
pool.on('error', (err) => {
|
||||
console.error('Unexpected error on idle client', err);
|
||||
});
|
||||
}
|
||||
|
||||
return pool;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a SQL query
|
||||
* @param {string} sql - SQL query string
|
||||
* @param {Array} params - Query parameters (optional)
|
||||
* @returns {Promise<Object>} Query result
|
||||
*/
|
||||
async function query(sql, params = []) {
|
||||
const client = getPool();
|
||||
|
||||
try {
|
||||
const result = await client.query(sql, params);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Database query error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and return the first row
|
||||
* @param {string} sql - SQL query string
|
||||
* @param {Array} params - Query parameters (optional)
|
||||
* @returns {Promise<Object|null>} First row or null if no results
|
||||
*/
|
||||
async function queryOne(sql, params = []) {
|
||||
const result = await query(sql, params);
|
||||
return result.rows.length > 0 ? result.rows[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a query and return all rows
|
||||
* @param {string} sql - SQL query string
|
||||
* @param {Array} params - Query parameters (optional)
|
||||
* @returns {Promise<Array>} Array of rows
|
||||
*/
|
||||
async function queryAll(sql, params = []) {
|
||||
const result = await query(sql, params);
|
||||
return result.rows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute multiple queries in a transaction
|
||||
* @param {Function} callback - Async function that receives a client and executes queries
|
||||
* @returns {Promise<any>} Result of the callback function
|
||||
*/
|
||||
async function transaction(callback) {
|
||||
const client = await getPool().connect();
|
||||
|
||||
try {
|
||||
await client.query('BEGIN');
|
||||
const result = await callback(client);
|
||||
await client.query('COMMIT');
|
||||
return result;
|
||||
} catch (error) {
|
||||
await client.query('ROLLBACK');
|
||||
console.error('Transaction error:', error);
|
||||
throw error;
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the database connection pool
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function closePool() {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Test database connection
|
||||
* @returns {Promise<boolean>} True if connection successful
|
||||
*/
|
||||
async function testConnection() {
|
||||
try {
|
||||
await query('SELECT NOW()');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Database connection test failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
query,
|
||||
queryOne,
|
||||
queryAll,
|
||||
transaction,
|
||||
getPool,
|
||||
closePool,
|
||||
testConnection
|
||||
};
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Zen Database Module
|
||||
* Complete database utilities for PostgreSQL
|
||||
*/
|
||||
|
||||
// Core database functions
|
||||
export {
|
||||
query,
|
||||
queryOne,
|
||||
queryAll,
|
||||
transaction,
|
||||
getPool,
|
||||
closePool,
|
||||
testConnection
|
||||
} from './db.js';
|
||||
|
||||
// CRUD helper functions
|
||||
export {
|
||||
create,
|
||||
findById,
|
||||
find,
|
||||
findOne,
|
||||
updateById,
|
||||
update,
|
||||
deleteById,
|
||||
deleteWhere,
|
||||
count,
|
||||
exists
|
||||
} from './crud.js';
|
||||
|
||||
// Database initialization
|
||||
export {
|
||||
initDatabase,
|
||||
createAuthTables,
|
||||
tableExists,
|
||||
dropAuthTables
|
||||
} from './init.js';
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* Database Initialization
|
||||
* Creates required tables if they don't exist
|
||||
*/
|
||||
|
||||
import { query } from './db.js';
|
||||
|
||||
/**
|
||||
* Check if a table exists in the database
|
||||
* @param {string} tableName - Name of the table to check
|
||||
* @returns {Promise<boolean>} True if table exists, false otherwise
|
||||
*/
|
||||
async function tableExists(tableName) {
|
||||
const result = await query(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
)`,
|
||||
[tableName]
|
||||
);
|
||||
return result.rows[0].exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create authentication tables
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function createAuthTables() {
|
||||
const tables = [
|
||||
{
|
||||
name: 'zen_auth_users',
|
||||
sql: `
|
||||
CREATE TABLE zen_auth_users (
|
||||
id text NOT NULL PRIMARY KEY,
|
||||
name text NOT NULL,
|
||||
email text NOT NULL UNIQUE,
|
||||
email_verified boolean NOT NULL DEFAULT false,
|
||||
image text,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
role text DEFAULT 'user'
|
||||
)
|
||||
`
|
||||
},
|
||||
{
|
||||
name: 'zen_auth_sessions',
|
||||
sql: `
|
||||
CREATE TABLE zen_auth_sessions (
|
||||
id text NOT NULL PRIMARY KEY,
|
||||
expires_at timestamptz NOT NULL,
|
||||
token text NOT NULL UNIQUE,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at timestamptz NOT NULL,
|
||||
ip_address text,
|
||||
user_agent text,
|
||||
user_id text NOT NULL REFERENCES zen_auth_users (id) ON DELETE CASCADE
|
||||
)
|
||||
`
|
||||
},
|
||||
{
|
||||
name: 'zen_auth_accounts',
|
||||
sql: `
|
||||
CREATE TABLE zen_auth_accounts (
|
||||
id text NOT NULL PRIMARY KEY,
|
||||
account_id text NOT NULL,
|
||||
provider_id text NOT NULL,
|
||||
user_id text NOT NULL REFERENCES zen_auth_users (id) ON DELETE CASCADE,
|
||||
access_token text,
|
||||
refresh_token text,
|
||||
id_token text,
|
||||
access_token_expires_at timestamptz,
|
||||
refresh_token_expires_at timestamptz,
|
||||
scope text,
|
||||
password text,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at timestamptz NOT NULL
|
||||
)
|
||||
`
|
||||
},
|
||||
{
|
||||
name: 'zen_auth_verifications',
|
||||
sql: `
|
||||
CREATE TABLE zen_auth_verifications (
|
||||
id text NOT NULL PRIMARY KEY,
|
||||
identifier text NOT NULL,
|
||||
value text NOT NULL,
|
||||
token text NOT NULL,
|
||||
expires_at timestamptz NOT NULL,
|
||||
created_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)
|
||||
`
|
||||
}
|
||||
];
|
||||
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
|
||||
for (const table of tables) {
|
||||
const exists = await tableExists(table.name);
|
||||
|
||||
if (!exists) {
|
||||
await query(table.sql);
|
||||
created.push(table.name);
|
||||
console.log(`✓ Created table: ${table.name}`);
|
||||
} else {
|
||||
skipped.push(table.name);
|
||||
console.log(`- Table already exists: ${table.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
created,
|
||||
skipped,
|
||||
success: true
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the database with all required tables
|
||||
* @returns {Promise<Object>} Result object with created and skipped tables
|
||||
*/
|
||||
async function initDatabase() {
|
||||
console.log('Initializing Zen database...');
|
||||
|
||||
try {
|
||||
const authResult = await createAuthTables();
|
||||
|
||||
// Initialize modules
|
||||
let modulesResult = { created: [], skipped: [] };
|
||||
try {
|
||||
const { initModules } = await import('../../modules/init.js');
|
||||
modulesResult = await initModules();
|
||||
} catch (error) {
|
||||
// Modules might not be available or enabled
|
||||
console.log('\nNo modules to initialize or modules not available.');
|
||||
}
|
||||
|
||||
console.log('\nDatabase initialization completed!');
|
||||
console.log(`Auth tables created: ${authResult.created.length}`);
|
||||
console.log(`Module tables created: ${modulesResult.created.length}`);
|
||||
console.log(`Total tables skipped: ${authResult.skipped.length + modulesResult.skipped.length}`);
|
||||
|
||||
return {
|
||||
created: [...authResult.created, ...modulesResult.created],
|
||||
skipped: [...authResult.skipped, ...modulesResult.skipped],
|
||||
success: true
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Database initialization failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all Zen authentication tables (use with caution!)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
async function dropAuthTables() {
|
||||
const tables = [
|
||||
'zen_auth_verifications',
|
||||
'zen_auth_accounts',
|
||||
'zen_auth_sessions',
|
||||
'zen_auth_users'
|
||||
];
|
||||
|
||||
console.log('WARNING: Dropping all Zen authentication tables...');
|
||||
|
||||
for (const tableName of tables) {
|
||||
const exists = await tableExists(tableName);
|
||||
if (exists) {
|
||||
await query(`DROP TABLE IF EXISTS "${tableName}" CASCADE`);
|
||||
console.log(`✓ Dropped table: ${tableName}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('All authentication tables dropped.');
|
||||
}
|
||||
|
||||
export {
|
||||
initDatabase,
|
||||
createAuthTables,
|
||||
tableExists,
|
||||
dropAuthTables
|
||||
};
|
||||
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Email Utility using Resend
|
||||
* Centralized email sending functionality for the entire package
|
||||
*/
|
||||
|
||||
import { Resend } from 'resend';
|
||||
|
||||
/**
|
||||
* Initialize Resend client
|
||||
*/
|
||||
let resendClient = null;
|
||||
|
||||
function getResendClient() {
|
||||
if (!resendClient) {
|
||||
const apiKey = process.env.ZEN_EMAIL_RESEND_APIKEY;
|
||||
if (!apiKey) {
|
||||
throw new Error('ZEN_EMAIL_RESEND_APIKEY environment variable is not set');
|
||||
}
|
||||
resendClient = new Resend(apiKey);
|
||||
}
|
||||
return resendClient;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format sender address with name if available
|
||||
* @param {string} email - Email address
|
||||
* @param {string} name - Sender name (optional)
|
||||
* @returns {string} Formatted sender address
|
||||
*/
|
||||
function formatSenderAddress(email, name) {
|
||||
if (name && name.trim()) {
|
||||
return `${name.trim()} <${email}>`;
|
||||
}
|
||||
return email;
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an email using Resend
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.to - Recipient email address
|
||||
* @param {string} options.subject - Email subject
|
||||
* @param {string} options.html - HTML content of the email
|
||||
* @param {string} options.text - Plain text content of the email (optional)
|
||||
* @param {string} options.from - Sender email address (optional, defaults to ZEN_EMAIL_FROM_ADDRESS)
|
||||
* @param {string} options.fromName - Sender name (optional, defaults to ZEN_EMAIL_FROM_NAME)
|
||||
* @param {string} options.replyTo - Reply-to email address (optional)
|
||||
* @param {Array} options.attachments - Email attachments (optional)
|
||||
* @param {Object} options.tags - Email tags for tracking (optional)
|
||||
* @returns {Promise<Object>} Resend response
|
||||
*/
|
||||
async function sendEmail({ to, subject, html, text, from, fromName, replyTo, attachments, tags }) {
|
||||
try {
|
||||
const resend = getResendClient();
|
||||
|
||||
// Default from address and name
|
||||
const fromAddress = from || process.env.ZEN_EMAIL_FROM_ADDRESS || 'noreply@example.com';
|
||||
const senderName = fromName || process.env.ZEN_EMAIL_FROM_NAME;
|
||||
|
||||
// Format sender with name if available
|
||||
const formattedFrom = formatSenderAddress(fromAddress, senderName);
|
||||
|
||||
const emailData = {
|
||||
from: formattedFrom,
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
...(text && { text }),
|
||||
...(replyTo && { reply_to: replyTo }),
|
||||
...(attachments && { attachments }),
|
||||
...(tags && { tags })
|
||||
};
|
||||
|
||||
const response = await resend.emails.send(emailData);
|
||||
|
||||
// Resend returns { data: { id: "..." }, error: null } or { data: null, error: { message: "..." } }
|
||||
if (response.error) {
|
||||
console.error('[ZEN EMAIL] Resend error:', response.error);
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: response.error.message || 'Failed to send email'
|
||||
};
|
||||
}
|
||||
|
||||
const emailId = response.data?.id || response.id;
|
||||
console.log(`[ZEN EMAIL] Email sent to ${to} - ID: ${emailId}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data || response,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ZEN EMAIL] Error sending email:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an authentication-related email
|
||||
* Uses default ZEN_EMAIL_FROM_ADDRESS and ZEN_EMAIL_FROM_NAME
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.to - Recipient email address
|
||||
* @param {string} options.subject - Email subject
|
||||
* @param {string} options.html - HTML content of the email
|
||||
* @param {string} options.text - Plain text content of the email (optional)
|
||||
* @param {string} options.replyTo - Reply-to email address (optional)
|
||||
* @returns {Promise<Object>} Resend response
|
||||
*/
|
||||
async function sendAuthEmail({ to, subject, html, text, replyTo }) {
|
||||
return sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
replyTo
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send an application-related email
|
||||
* Uses default ZEN_EMAIL_FROM_ADDRESS and ZEN_EMAIL_FROM_NAME
|
||||
* @param {Object} options - Email options
|
||||
* @param {string} options.to - Recipient email address
|
||||
* @param {string} options.subject - Email subject
|
||||
* @param {string} options.html - HTML content of the email
|
||||
* @param {string} options.text - Plain text content of the email (optional)
|
||||
* @param {string} options.replyTo - Reply-to email address (optional)
|
||||
* @param {Array} options.attachments - Email attachments (optional)
|
||||
* @param {Object} options.tags - Email tags for tracking (optional)
|
||||
* @returns {Promise<Object>} Resend response
|
||||
*/
|
||||
async function sendAppEmail({ to, subject, html, text, replyTo, attachments, tags }) {
|
||||
return sendEmail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
text,
|
||||
replyTo,
|
||||
attachments,
|
||||
tags
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a batch of emails
|
||||
* @param {Array<Object>} emails - Array of email objects
|
||||
* @returns {Promise<Array<Object>>} Array of Resend responses
|
||||
*/
|
||||
async function sendBatchEmails(emails) {
|
||||
try {
|
||||
const resend = getResendClient();
|
||||
|
||||
const emailsData = emails.map(email => {
|
||||
const fromAddress = email.from || process.env.ZEN_EMAIL_FROM_ADDRESS || 'noreply@example.com';
|
||||
const fromName = email.fromName || process.env.ZEN_EMAIL_FROM_NAME;
|
||||
const formattedFrom = formatSenderAddress(fromAddress, fromName);
|
||||
|
||||
return {
|
||||
from: formattedFrom,
|
||||
to: email.to,
|
||||
subject: email.subject,
|
||||
html: email.html,
|
||||
...(email.text && { text: email.text }),
|
||||
...(email.replyTo && { reply_to: email.replyTo }),
|
||||
...(email.attachments && { attachments: email.attachments }),
|
||||
...(email.tags && { tags: email.tags })
|
||||
};
|
||||
});
|
||||
|
||||
const response = await resend.batch.send(emailsData);
|
||||
|
||||
// Handle Resend error response
|
||||
if (response.error) {
|
||||
console.error('[ZEN EMAIL] Resend batch error:', response.error);
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: response.error.message || 'Failed to send batch emails'
|
||||
};
|
||||
}
|
||||
|
||||
console.log(`[ZEN EMAIL] Batch of ${emails.length} emails sent`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: response.data || response,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ZEN EMAIL] Error sending batch emails:', error);
|
||||
return {
|
||||
success: false,
|
||||
data: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export {
|
||||
sendEmail,
|
||||
sendAuthEmail,
|
||||
sendAppEmail,
|
||||
sendBatchEmails
|
||||
};
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Base Email Layout Component
|
||||
* Provides consistent structure for all ZEN emails
|
||||
*/
|
||||
|
||||
import {
|
||||
Body,
|
||||
Container,
|
||||
Head,
|
||||
Heading,
|
||||
Html,
|
||||
Preview,
|
||||
Section,
|
||||
Text,
|
||||
Img,
|
||||
Tailwind,
|
||||
Hr,
|
||||
Link,
|
||||
} from "@react-email/components";
|
||||
|
||||
export const BaseLayout = ({
|
||||
preview,
|
||||
title,
|
||||
children,
|
||||
companyName,
|
||||
logoURL,
|
||||
supportSection = false,
|
||||
supportEmail = 'support@zenya.test'
|
||||
}) => {
|
||||
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
|
||||
const logoSrc = logoURL || process.env.ZEN_EMAIL_LOGO || null;
|
||||
const logoHref = process.env.ZEN_EMAIL_LOGO_URL || null;
|
||||
const currentYear = new Date().getFullYear();
|
||||
|
||||
return (
|
||||
<Html lang="fr">
|
||||
<Head />
|
||||
{preview && <Preview>{preview}</Preview>}
|
||||
<Tailwind>
|
||||
<Body className="bg-white font-sans">
|
||||
<Container className="mx-auto px-[15px] py-[20px] max-w-[525px]">
|
||||
|
||||
<Section className="mb-[40px]">
|
||||
{logoSrc ? (
|
||||
logoHref ? (
|
||||
<Link href={logoHref}>
|
||||
<Img src={logoSrc} alt={appName} className="w-auto max-h-[64px]" />
|
||||
</Link>
|
||||
) : (
|
||||
<Img src={logoSrc} alt={appName} className="w-auto max-h-[64px]" />
|
||||
)
|
||||
) : (
|
||||
<Heading className="text-[16px] font-semibold text-neutral-900 m-0 tracking-tight">
|
||||
{appName}
|
||||
</Heading>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{title && (
|
||||
<Heading className="text-[22px] font-semibold text-neutral-900 mt-0 mb-[8px] tracking-tight">
|
||||
{title}
|
||||
</Heading>
|
||||
)}
|
||||
|
||||
{children}
|
||||
|
||||
<Hr className="border-neutral-100 mt-[48px] mb-[20px]" />
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
© {currentYear} — {appName}. Tous droits réservés.
|
||||
{supportSection && (
|
||||
<>
|
||||
{' · '}
|
||||
<Link href={`mailto:${supportEmail}`} className="text-neutral-400 underline">
|
||||
{supportEmail}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</Text>
|
||||
|
||||
</Container>
|
||||
</Body>
|
||||
</Tailwind>
|
||||
</Html>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Password Changed Confirmation Email Template
|
||||
*/
|
||||
|
||||
import { Section, Text } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
export const PasswordChangedEmail = ({
|
||||
email,
|
||||
companyName
|
||||
}) => {
|
||||
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
|
||||
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
preview={`Votre mot de passe a été modifié – ${appName}`}
|
||||
title="Mot de passe modifié"
|
||||
companyName={companyName}
|
||||
supportSection={true}
|
||||
supportEmail={supportEmail}
|
||||
>
|
||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
||||
Ceci confirme que le mot de passe de votre compte <span className="font-medium text-neutral-900">{appName}</span> a bien été modifié.
|
||||
</Text>
|
||||
|
||||
<Section style={{ border: '1px solid #E5E5E5' }} className="bg-neutral-100 rounded-[12px] p-[20px] my-[24px]">
|
||||
<Text className="text-[12px] font-medium text-neutral-400 m-0 mb-[4px] uppercase tracking-wider">
|
||||
Compte
|
||||
</Text>
|
||||
<Text className="text-[14px] font-medium text-neutral-900 m-0">
|
||||
{email}
|
||||
</Text>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
Si vous n'êtes pas à l'origine de cette modification, contactez immédiatement notre équipe de support.
|
||||
</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Password Reset Email Template
|
||||
*/
|
||||
|
||||
import { Button, Section, Text, Link } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
export const PasswordResetEmail = ({
|
||||
email,
|
||||
resetUrl,
|
||||
companyName
|
||||
}) => {
|
||||
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
|
||||
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
preview={`Réinitialisez votre mot de passe pour ${appName}`}
|
||||
title="Réinitialisation du mot de passe"
|
||||
companyName={companyName}
|
||||
supportSection={true}
|
||||
supportEmail={supportEmail}
|
||||
>
|
||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
||||
Nous avons reçu une demande de réinitialisation du mot de passe pour votre compte <span className="font-medium text-neutral-900">{appName}</span>. Cliquez sur le bouton ci-dessous pour en choisir un nouveau.
|
||||
</Text>
|
||||
|
||||
<Section className="mt-[28px] mb-[32px]">
|
||||
<Button
|
||||
href={resetUrl}
|
||||
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
|
||||
>
|
||||
Réinitialiser le mot de passe
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
Ce lien expire dans 1 heure. Si vous n'êtes pas à l'origine de cette demande, ignorez ce message — votre mot de passe ne sera pas modifié.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
|
||||
Lien :{' '}
|
||||
<Link href={resetUrl} className="text-neutral-400 underline break-all">
|
||||
{resetUrl}
|
||||
</Link>
|
||||
</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Email Verification Template
|
||||
*/
|
||||
|
||||
import { Button, Section, Text, Link } from "@react-email/components";
|
||||
import { BaseLayout } from "./BaseLayout";
|
||||
|
||||
export const VerificationEmail = ({
|
||||
email,
|
||||
verificationUrl,
|
||||
companyName
|
||||
}) => {
|
||||
const appName = companyName || process.env.ZEN_NAME || 'ZEN';
|
||||
const supportEmail = process.env.ZEN_SUPPORT_EMAIL || 'support@zenya.test';
|
||||
|
||||
return (
|
||||
<BaseLayout
|
||||
preview={`Confirmez votre adresse courriel pour ${appName}`}
|
||||
title="Confirmez votre adresse courriel"
|
||||
companyName={companyName}
|
||||
supportSection={true}
|
||||
supportEmail={supportEmail}
|
||||
>
|
||||
<Text className="text-[14px] leading-[24px] text-neutral-600 mt-[4px] mb-[24px]">
|
||||
Merci de vous être inscrit sur <span className="font-medium text-neutral-900">{appName}</span>. Cliquez sur le bouton ci-dessous pour confirmer votre adresse courriel.
|
||||
</Text>
|
||||
|
||||
<Section className="mt-[28px] mb-[32px]">
|
||||
<Button
|
||||
href={verificationUrl}
|
||||
className="bg-neutral-900 rounded-[8px] text-white font-medium px-[20px] py-[11px] no-underline text-[13px]"
|
||||
>
|
||||
Confirmer mon courriel
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0">
|
||||
Ce lien expire dans 24 heures. Si vous n'avez pas créé de compte, ignorez ce message.
|
||||
</Text>
|
||||
|
||||
<Text className="text-[12px] leading-[20px] text-neutral-400 m-0 mt-[8px]">
|
||||
Lien :{' '}
|
||||
<Link href={verificationUrl} className="text-neutral-400 underline break-all">
|
||||
{verificationUrl}
|
||||
</Link>
|
||||
</Text>
|
||||
</BaseLayout>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Email Templates
|
||||
* Export all email templates and render functions
|
||||
*/
|
||||
|
||||
import { render } from '@react-email/components';
|
||||
import { VerificationEmail } from './VerificationEmail.jsx';
|
||||
import { PasswordResetEmail } from './PasswordResetEmail.jsx';
|
||||
import { PasswordChangedEmail } from './PasswordChangedEmail.jsx';
|
||||
|
||||
// Export JSX components
|
||||
export { VerificationEmail } from './VerificationEmail.jsx';
|
||||
export { PasswordResetEmail } from './PasswordResetEmail.jsx';
|
||||
export { PasswordChangedEmail } from './PasswordChangedEmail.jsx';
|
||||
export { BaseLayout } from './BaseLayout.jsx';
|
||||
|
||||
/**
|
||||
* Render verification email to HTML
|
||||
* @param {string} verificationUrl - The verification URL
|
||||
* @param {string} email - User's email address
|
||||
* @param {string} companyName - Company name (optional)
|
||||
* @returns {Promise<string>} Rendered HTML
|
||||
*/
|
||||
export async function renderVerificationEmail(verificationUrl, email, companyName) {
|
||||
return await render(
|
||||
<VerificationEmail
|
||||
email={email}
|
||||
verificationUrl={verificationUrl}
|
||||
companyName={companyName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render password reset email to HTML
|
||||
* @param {string} resetUrl - The password reset URL
|
||||
* @param {string} email - User's email address
|
||||
* @param {string} companyName - Company name (optional)
|
||||
* @returns {Promise<string>} Rendered HTML
|
||||
*/
|
||||
export async function renderPasswordResetEmail(resetUrl, email, companyName) {
|
||||
return await render(
|
||||
<PasswordResetEmail
|
||||
email={email}
|
||||
resetUrl={resetUrl}
|
||||
companyName={companyName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Render password changed email to HTML
|
||||
* @param {string} email - User's email address
|
||||
* @param {string} companyName - Company name (optional)
|
||||
* @returns {Promise<string>} Rendered HTML
|
||||
*/
|
||||
export async function renderPasswordChangedEmail(email, companyName) {
|
||||
return await render(
|
||||
<PasswordChangedEmail
|
||||
email={email}
|
||||
companyName={companyName}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Legacy exports for backward compatibility
|
||||
export const getVerificationEmailTemplate = renderVerificationEmail;
|
||||
export const getPasswordResetTemplate = renderPasswordResetEmail;
|
||||
export const getPasswordChangedTemplate = renderPasswordChangedEmail;
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Client-Safe Module Registry Access
|
||||
*
|
||||
* This file ONLY exports functions that are safe to use in client components.
|
||||
* It does NOT export discovery, loader, or initialization functions that
|
||||
* might import server-only modules like database code.
|
||||
*
|
||||
* NOTE: Most registry functions return empty results on the client because
|
||||
* the registry is populated on the server during discovery. For client-side
|
||||
* module page loading, use the loaders from modules.pages.js instead.
|
||||
*/
|
||||
|
||||
// Only export registry getter functions (no discovery/loader functions)
|
||||
export {
|
||||
getModule,
|
||||
getAllModules,
|
||||
getEnabledModules,
|
||||
isModuleRegistered,
|
||||
isModuleEnabled,
|
||||
getAllApiRoutes,
|
||||
getAllAdminNavigation,
|
||||
getAdminPage,
|
||||
getAllCronJobs,
|
||||
getAllPublicRoutes,
|
||||
getAllDatabaseSchemas,
|
||||
getModuleMetadata,
|
||||
getAllModuleMetadata,
|
||||
} from './registry.js';
|
||||
|
||||
// NOTE: getModulePublicPages is NOT exported here because it relies on the
|
||||
// server-side registry which is empty on the client. Use getModulePublicPageLoader()
|
||||
// from '@hykocx/zen/modules/pages' instead for client-side public page loading.
|
||||
@@ -0,0 +1,192 @@
|
||||
/**
|
||||
* Module Discovery System
|
||||
* Auto-discovers and registers modules from the modules directory
|
||||
*/
|
||||
|
||||
import { registerModule, clearRegistry } from './registry.js';
|
||||
import { getAvailableModules } from '../../modules/modules.registry.js';
|
||||
|
||||
/**
|
||||
* Check if a module is enabled via environment variable
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isModuleEnabledInEnv(moduleName) {
|
||||
const envVar = `ZEN_MODULE_${moduleName.toUpperCase()}`;
|
||||
return process.env[envVar] === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover and register all modules
|
||||
* @param {Object} options - Discovery options
|
||||
* @param {boolean} options.force - Force re-discovery
|
||||
* @returns {Promise<Object>} Discovery result
|
||||
*/
|
||||
export async function discoverModules(options = {}) {
|
||||
const { force = false } = options;
|
||||
|
||||
const DISCOVERY_KEY = Symbol.for('__ZEN_MODULES_DISCOVERED__');
|
||||
|
||||
if (globalThis[DISCOVERY_KEY] && !force) {
|
||||
console.log('[Module Discovery] Already discovered, skipping...');
|
||||
return { alreadyDiscovered: true };
|
||||
}
|
||||
|
||||
if (force) {
|
||||
clearRegistry();
|
||||
}
|
||||
|
||||
console.log('[Module Discovery] Starting module discovery...');
|
||||
|
||||
const discovered = [];
|
||||
const enabled = [];
|
||||
const skipped = [];
|
||||
const errors = [];
|
||||
|
||||
const knownModules = getAvailableModules();
|
||||
|
||||
for (const moduleName of knownModules) {
|
||||
try {
|
||||
const isEnabled = isModuleEnabledInEnv(moduleName);
|
||||
|
||||
if (!isEnabled) {
|
||||
skipped.push(moduleName);
|
||||
console.log(`[Module Discovery] Skipped ${moduleName} (not enabled)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Load module configuration
|
||||
const moduleConfig = await loadModuleConfig(moduleName);
|
||||
|
||||
if (moduleConfig) {
|
||||
// Load additional components (db, cron, api)
|
||||
const components = await loadModuleComponents(moduleName);
|
||||
|
||||
// Register the module
|
||||
registerModule(moduleName, {
|
||||
...moduleConfig,
|
||||
...components,
|
||||
enabled: true
|
||||
});
|
||||
|
||||
discovered.push(moduleName);
|
||||
enabled.push(moduleName);
|
||||
console.log(`[Module Discovery] Registered ${moduleName}`);
|
||||
}
|
||||
} catch (error) {
|
||||
errors.push({ module: moduleName, error: error.message });
|
||||
console.error(`[Module Discovery] Error loading ${moduleName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
globalThis[DISCOVERY_KEY] = true;
|
||||
|
||||
console.log(`[Module Discovery] Complete. Enabled: ${enabled.length}, Skipped: ${skipped.length}, Errors: ${errors.length}`);
|
||||
|
||||
return { discovered, enabled, skipped, errors };
|
||||
}
|
||||
|
||||
/**
|
||||
* Load module configuration from module.config.js
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {Promise<Object|null>} Module configuration
|
||||
*/
|
||||
async function loadModuleConfig(moduleName) {
|
||||
try {
|
||||
const config = await import(`../../modules/${moduleName}/module.config.js`);
|
||||
const moduleConfig = config.default || config;
|
||||
|
||||
// Build admin config with navigation and pages
|
||||
let adminConfig = undefined;
|
||||
if (moduleConfig.navigation || moduleConfig.adminPages) {
|
||||
adminConfig = {};
|
||||
if (moduleConfig.navigation) {
|
||||
adminConfig.navigation = moduleConfig.navigation;
|
||||
}
|
||||
// Extract admin page paths (keys only, not the lazy components)
|
||||
// This allows getAdminPage() to know which paths belong to this module
|
||||
if (moduleConfig.adminPages) {
|
||||
adminConfig.pages = {};
|
||||
for (const path of Object.keys(moduleConfig.adminPages)) {
|
||||
// Store true as a marker that this path exists
|
||||
// The actual component is loaded client-side via modules.pages.js
|
||||
adminConfig.pages[path] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extract server-side relevant data
|
||||
return {
|
||||
name: moduleConfig.name || moduleName,
|
||||
displayName: moduleConfig.displayName || moduleName.charAt(0).toUpperCase() + moduleName.slice(1),
|
||||
version: moduleConfig.version || '1.0.0',
|
||||
description: moduleConfig.description || `${moduleName} module`,
|
||||
dependencies: moduleConfig.dependencies || [],
|
||||
envVars: moduleConfig.envVars || [],
|
||||
// Admin configuration (navigation + page paths)
|
||||
admin: adminConfig,
|
||||
// Public routes metadata (not components)
|
||||
public: moduleConfig.publicRoutes ? {
|
||||
routes: moduleConfig.publicRoutes
|
||||
} : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.log(`[Module Discovery] No module.config.js for ${moduleName}, using defaults`);
|
||||
return {
|
||||
name: moduleName,
|
||||
displayName: moduleName.charAt(0).toUpperCase() + moduleName.slice(1),
|
||||
version: '1.0.0',
|
||||
description: `${moduleName} module`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load additional module components (db, cron, api)
|
||||
* Note: Metadata is loaded from modules.metadata.js (static registry)
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {Promise<Object>} Module components
|
||||
*/
|
||||
async function loadModuleComponents(moduleName) {
|
||||
const components = {};
|
||||
|
||||
// Load API routes
|
||||
try {
|
||||
const api = await import(`../../modules/${moduleName}/api.js`);
|
||||
components.api = api.default || api;
|
||||
} catch (error) {
|
||||
// API is optional
|
||||
}
|
||||
|
||||
// Load cron configuration
|
||||
try {
|
||||
const cron = await import(`../../modules/${moduleName}/cron.config.js`);
|
||||
components.cron = cron.default || cron;
|
||||
} catch (error) {
|
||||
// Cron is optional
|
||||
}
|
||||
|
||||
// Load database configuration
|
||||
try {
|
||||
const db = await import(`../../modules/${moduleName}/db.js`);
|
||||
if (db.createTables) {
|
||||
components.db = {
|
||||
init: db.createTables,
|
||||
drop: db.dropTables
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// DB is optional
|
||||
}
|
||||
|
||||
return components;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset module discovery (useful for testing)
|
||||
*/
|
||||
export function resetDiscovery() {
|
||||
const DISCOVERY_KEY = Symbol.for('__ZEN_MODULES_DISCOVERED__');
|
||||
globalThis[DISCOVERY_KEY] = false;
|
||||
clearRegistry();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Module System Entry Point
|
||||
* Exports all module-related functionality
|
||||
*/
|
||||
|
||||
// Discovery
|
||||
export {
|
||||
discoverModules,
|
||||
isModuleEnabledInEnv,
|
||||
resetDiscovery
|
||||
} from './discovery.js';
|
||||
|
||||
// Registry (server-side only - these functions rely on the registry populated during discovery)
|
||||
export {
|
||||
registerModule,
|
||||
getModule,
|
||||
getAllModules,
|
||||
getEnabledModules,
|
||||
isModuleRegistered,
|
||||
isModuleEnabled,
|
||||
clearRegistry,
|
||||
getAllApiRoutes,
|
||||
getAllAdminNavigation,
|
||||
getAdminPage,
|
||||
getAllCronJobs,
|
||||
getAllPublicRoutes,
|
||||
getAllDatabaseSchemas,
|
||||
getModuleMetadata,
|
||||
getAllModuleMetadata,
|
||||
getModulePublicPages // returns route metadata only, use modules.pages.js for components
|
||||
} from './registry.js';
|
||||
|
||||
// Loader
|
||||
export {
|
||||
initializeModules,
|
||||
initializeModuleDatabases,
|
||||
startModuleCronJobs,
|
||||
stopModuleCronJobs,
|
||||
getCronJobStatus,
|
||||
resetModuleLoader,
|
||||
getModuleStatus
|
||||
} from './loader.js';
|
||||
@@ -0,0 +1,244 @@
|
||||
/**
|
||||
* Module Loader
|
||||
* Handles loading and initializing modules
|
||||
*/
|
||||
|
||||
import { discoverModules, resetDiscovery } from './discovery.js';
|
||||
import {
|
||||
getAllModules,
|
||||
getEnabledModules,
|
||||
getAllCronJobs,
|
||||
getAllDatabaseSchemas,
|
||||
isModuleEnabled
|
||||
} from './registry.js';
|
||||
|
||||
// Use globalThis to track initialization state
|
||||
const INIT_KEY = Symbol.for('__ZEN_MODULES_INITIALIZED__');
|
||||
const CRON_JOBS_KEY = Symbol.for('__ZEN_MODULE_CRON_JOBS__');
|
||||
|
||||
/**
|
||||
* Initialize all modules
|
||||
* Discovers modules, initializes databases, and starts cron jobs
|
||||
* @param {Object} options - Initialization options
|
||||
* @param {boolean} options.skipCron - Skip starting cron jobs
|
||||
* @param {boolean} options.skipDb - Skip database initialization
|
||||
* @param {boolean} options.force - Force re-initialization
|
||||
* @returns {Promise<Object>} Initialization result
|
||||
*/
|
||||
export async function initializeModules(options = {}) {
|
||||
const { skipCron = false, skipDb = false, force = false } = options;
|
||||
|
||||
// Prevent multiple initializations
|
||||
if (globalThis[INIT_KEY] && !force) {
|
||||
console.log('[Module Loader] Already initialized, skipping...');
|
||||
return { alreadyInitialized: true };
|
||||
}
|
||||
|
||||
console.log('[Module Loader] Starting module initialization...');
|
||||
|
||||
const result = {
|
||||
discovery: null,
|
||||
database: { created: [], skipped: [], errors: [] },
|
||||
cron: { started: [], errors: [] }
|
||||
};
|
||||
|
||||
try {
|
||||
// Step 1: Discover modules
|
||||
result.discovery = await discoverModules({ force });
|
||||
|
||||
// Step 2: Initialize databases
|
||||
if (!skipDb) {
|
||||
result.database = await initializeModuleDatabases();
|
||||
}
|
||||
|
||||
// Step 3: Start cron jobs
|
||||
if (!skipCron) {
|
||||
result.cron = await startModuleCronJobs();
|
||||
}
|
||||
|
||||
globalThis[INIT_KEY] = true;
|
||||
console.log('[Module Loader] Module initialization complete');
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Module Loader] Initialization failed:', error);
|
||||
result.error = error.message;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize databases for all enabled modules
|
||||
* @returns {Promise<Object>} Database initialization result
|
||||
*/
|
||||
export async function initializeModuleDatabases() {
|
||||
console.log('[Module Loader] Initializing module databases...');
|
||||
|
||||
const schemas = getAllDatabaseSchemas();
|
||||
const result = {
|
||||
created: [],
|
||||
skipped: [],
|
||||
errors: []
|
||||
};
|
||||
|
||||
for (const schema of schemas) {
|
||||
try {
|
||||
if (schema.init && typeof schema.init === 'function') {
|
||||
const initResult = await schema.init();
|
||||
|
||||
if (initResult?.created) {
|
||||
result.created.push(...initResult.created);
|
||||
}
|
||||
if (initResult?.skipped) {
|
||||
result.skipped.push(...initResult.skipped);
|
||||
}
|
||||
|
||||
console.log(`[Module Loader] Database initialized for ${schema.module}`);
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
module: schema.module,
|
||||
error: error.message
|
||||
});
|
||||
console.error(`[Module Loader] Database init error for ${schema.module}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start cron jobs for all enabled modules
|
||||
* @returns {Promise<Object>} Cron job start result
|
||||
*/
|
||||
export async function startModuleCronJobs() {
|
||||
console.log('[Module Loader] Starting module cron jobs...');
|
||||
|
||||
// Stop existing cron jobs first
|
||||
stopModuleCronJobs();
|
||||
|
||||
const jobs = getAllCronJobs();
|
||||
const result = {
|
||||
started: [],
|
||||
errors: []
|
||||
};
|
||||
|
||||
// Initialize cron jobs storage
|
||||
if (!globalThis[CRON_JOBS_KEY]) {
|
||||
globalThis[CRON_JOBS_KEY] = new Map();
|
||||
}
|
||||
|
||||
for (const job of jobs) {
|
||||
try {
|
||||
if (job.handler && typeof job.handler === 'function') {
|
||||
// Dynamic import of node-cron
|
||||
const cron = (await import('node-cron')).default;
|
||||
|
||||
const cronJob = cron.schedule(job.schedule, async () => {
|
||||
console.log(`[Cron: ${job.name}] Running at:`, new Date().toISOString());
|
||||
try {
|
||||
await job.handler();
|
||||
console.log(`[Cron: ${job.name}] Completed`);
|
||||
} catch (error) {
|
||||
console.error(`[Cron: ${job.name}] Error:`, error);
|
||||
}
|
||||
}, {
|
||||
scheduled: true,
|
||||
timezone: job.timezone || process.env.ZEN_TIMEZONE || 'America/Toronto'
|
||||
});
|
||||
|
||||
globalThis[CRON_JOBS_KEY].set(job.name, cronJob);
|
||||
result.started.push(job.name);
|
||||
|
||||
console.log(`[Module Loader] Started cron job: ${job.name} (${job.schedule})`);
|
||||
}
|
||||
} catch (error) {
|
||||
result.errors.push({
|
||||
job: job.name,
|
||||
module: job.module,
|
||||
error: error.message
|
||||
});
|
||||
console.error(`[Module Loader] Cron job error for ${job.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop all module cron jobs
|
||||
*/
|
||||
export function stopModuleCronJobs() {
|
||||
if (globalThis[CRON_JOBS_KEY]) {
|
||||
for (const [name, job] of globalThis[CRON_JOBS_KEY].entries()) {
|
||||
try {
|
||||
job.stop();
|
||||
console.log(`[Module Loader] Stopped cron job: ${name}`);
|
||||
} catch (error) {
|
||||
console.error(`[Module Loader] Error stopping cron job ${name}:`, error);
|
||||
}
|
||||
}
|
||||
globalThis[CRON_JOBS_KEY].clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of all cron jobs
|
||||
* @returns {Object} Cron job status
|
||||
*/
|
||||
export function getCronJobStatus() {
|
||||
const status = {};
|
||||
|
||||
if (globalThis[CRON_JOBS_KEY]) {
|
||||
for (const [name, job] of globalThis[CRON_JOBS_KEY].entries()) {
|
||||
status[name] = {
|
||||
running: true // node-cron doesn't expose a running state easily
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset module loader (useful for testing)
|
||||
*/
|
||||
export function resetModuleLoader() {
|
||||
stopModuleCronJobs();
|
||||
resetDiscovery();
|
||||
globalThis[INIT_KEY] = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get module status
|
||||
* @returns {Object} Status of all modules
|
||||
*/
|
||||
export function getModuleStatus() {
|
||||
const modules = getAllModules();
|
||||
const enabled = getEnabledModules();
|
||||
const cronStatus = getCronJobStatus();
|
||||
|
||||
return {
|
||||
totalModules: modules.size,
|
||||
enabledModules: enabled.length,
|
||||
modules: Array.from(modules.entries()).map(([name, data]) => ({
|
||||
name,
|
||||
enabled: data.enabled,
|
||||
displayName: data.displayName,
|
||||
version: data.version,
|
||||
hasApi: !!data.api,
|
||||
hasAdmin: !!data.admin,
|
||||
hasCron: !!data.cron,
|
||||
hasDb: !!data.db,
|
||||
hasPublic: !!data.public
|
||||
})),
|
||||
cronJobs: cronStatus
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export useful functions from registry
|
||||
export {
|
||||
isModuleEnabled,
|
||||
getAllModules,
|
||||
getEnabledModules
|
||||
} from './registry.js';
|
||||
@@ -0,0 +1,289 @@
|
||||
/**
|
||||
* Module Registry
|
||||
* Stores and manages all discovered modules
|
||||
*/
|
||||
|
||||
// Use globalThis to persist registry across module reloads
|
||||
const REGISTRY_KEY = Symbol.for('__ZEN_MODULE_REGISTRY__');
|
||||
|
||||
/**
|
||||
* Initialize or get the module registry
|
||||
* @returns {Map} Module registry map
|
||||
*/
|
||||
function getRegistry() {
|
||||
if (!globalThis[REGISTRY_KEY]) {
|
||||
globalThis[REGISTRY_KEY] = new Map();
|
||||
}
|
||||
return globalThis[REGISTRY_KEY];
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a module in the registry
|
||||
* @param {string} name - Module name
|
||||
* @param {Object} moduleData - Module configuration and components
|
||||
*/
|
||||
export function registerModule(name, moduleData) {
|
||||
const registry = getRegistry();
|
||||
registry.set(name, {
|
||||
...moduleData,
|
||||
registeredAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a registered module by name
|
||||
* @param {string} name - Module name
|
||||
* @returns {Object|null} Module data or null
|
||||
*/
|
||||
export function getModule(name) {
|
||||
const registry = getRegistry();
|
||||
return registry.get(name) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all registered modules
|
||||
* @returns {Map} All registered modules
|
||||
*/
|
||||
export function getAllModules() {
|
||||
return getRegistry();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all enabled modules
|
||||
* @returns {Array} Array of enabled module data
|
||||
*/
|
||||
export function getEnabledModules() {
|
||||
const registry = getRegistry();
|
||||
const enabled = [];
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled) {
|
||||
enabled.push({ name, ...data });
|
||||
}
|
||||
}
|
||||
|
||||
return enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a module is registered
|
||||
* @param {string} name - Module name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isModuleRegistered(name) {
|
||||
const registry = getRegistry();
|
||||
return registry.has(name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a module is enabled
|
||||
* @param {string} name - Module name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isModuleEnabled(name) {
|
||||
const module = getModule(name);
|
||||
return module?.enabled === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the module registry (useful for testing)
|
||||
*/
|
||||
export function clearRegistry() {
|
||||
const registry = getRegistry();
|
||||
registry.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all API routes from enabled modules
|
||||
* @returns {Array} Array of route definitions
|
||||
*/
|
||||
export function getAllApiRoutes() {
|
||||
const routes = [];
|
||||
const registry = getRegistry();
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled && data.api?.routes) {
|
||||
routes.push(...data.api.routes.map(route => ({
|
||||
...route,
|
||||
module: name
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all admin navigation sections from enabled modules
|
||||
* @param {string} pathname - Current pathname for active state
|
||||
* @returns {Array} Array of navigation sections
|
||||
*/
|
||||
export function getAllAdminNavigation(pathname) {
|
||||
const sections = [];
|
||||
const registry = getRegistry();
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled && data.admin?.navigation) {
|
||||
const nav = data.admin.navigation;
|
||||
|
||||
// Handle function or object navigation
|
||||
const section = typeof nav === 'function' ? nav(pathname) : nav;
|
||||
|
||||
if (section) {
|
||||
// Support array of sections (e.g. one per post type)
|
||||
const sectionList = Array.isArray(section) ? section : [section];
|
||||
for (const s of sectionList) {
|
||||
if (s.items) {
|
||||
s.items = s.items.map(item => ({
|
||||
...item,
|
||||
current: pathname.startsWith(item.href)
|
||||
}));
|
||||
}
|
||||
sections.push({ ...s, module: name });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get admin page info for a given path
|
||||
*
|
||||
* Returns module info if the path is registered as an admin page.
|
||||
* The actual component is loaded client-side via modules.pages.js
|
||||
*
|
||||
* @param {string} path - Page path (e.g., '/admin/invoice/invoices')
|
||||
* @returns {Object|null} Object with { module, path } or null
|
||||
*/
|
||||
export function getAdminPage(path) {
|
||||
const registry = getRegistry();
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled && data.admin?.pages) {
|
||||
if (data.admin.pages[path]) {
|
||||
return { module: name, path };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all cron jobs from enabled modules
|
||||
* @returns {Array} Array of cron job definitions
|
||||
*/
|
||||
export function getAllCronJobs() {
|
||||
const jobs = [];
|
||||
const registry = getRegistry();
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled && data.cron?.jobs) {
|
||||
jobs.push(...data.cron.jobs.map(job => ({
|
||||
...job,
|
||||
module: name
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
return jobs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public routes from enabled modules
|
||||
* @returns {Array} Array of public route definitions
|
||||
*/
|
||||
export function getAllPublicRoutes() {
|
||||
const routes = [];
|
||||
const registry = getRegistry();
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled && data.public?.routes) {
|
||||
routes.push(...data.public.routes.map(route => ({
|
||||
...route,
|
||||
module: name
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
return routes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get database schemas from all enabled modules
|
||||
* @returns {Array} Array of database schema definitions
|
||||
*/
|
||||
export function getAllDatabaseSchemas() {
|
||||
const schemas = [];
|
||||
const registry = getRegistry();
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled && data.db) {
|
||||
schemas.push({
|
||||
module: name,
|
||||
...data.db
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return schemas;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get metadata generator function from a module
|
||||
* @param {string} moduleName - Module name (e.g., 'invoice')
|
||||
* @param {string} type - Metadata type (e.g., 'payment', 'pdf', 'receipt')
|
||||
* @returns {Function|null} Metadata generator function or null if not found
|
||||
*/
|
||||
export function getModuleMetadata(moduleName, type) {
|
||||
const module = getModule(moduleName);
|
||||
|
||||
if (module?.enabled && module?.metadata) {
|
||||
// If type is specified, return the specific generator
|
||||
if (type && module.metadata[type]) {
|
||||
return module.metadata[type];
|
||||
}
|
||||
// If no type, return the default (first one or 'payment')
|
||||
return module.metadata.payment || module.metadata[Object.keys(module.metadata)[0]] || null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all metadata configurations from enabled modules
|
||||
* @returns {Object} Object mapping module names to their metadata configs
|
||||
*/
|
||||
export function getAllModuleMetadata() {
|
||||
const metadata = {};
|
||||
const registry = getRegistry();
|
||||
|
||||
for (const [name, data] of registry.entries()) {
|
||||
if (data.enabled && data.metadata) {
|
||||
metadata[name] = data.metadata;
|
||||
}
|
||||
}
|
||||
|
||||
return metadata;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get public routes configuration from a module
|
||||
*
|
||||
* NOTE: This function only returns route metadata, not components.
|
||||
* For loading public page components, use getModulePublicPageLoader() from modules.pages.js
|
||||
*
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {Object|null} Public routes config or null
|
||||
*/
|
||||
export function getModulePublicPages(moduleName) {
|
||||
const module = getModule(moduleName);
|
||||
|
||||
if (module?.enabled && module?.public) {
|
||||
return module.public;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Payments Module Entry Point
|
||||
* Re-exports all payment utilities
|
||||
*/
|
||||
|
||||
export * from './stripe.js';
|
||||
export { default as stripe } from './stripe.js';
|
||||
@@ -0,0 +1,270 @@
|
||||
/**
|
||||
* Stripe Payment Utilities
|
||||
* Generic Stripe integration for payment processing
|
||||
*
|
||||
* Usage in modules:
|
||||
* import { createCheckoutSession, isEnabled } from '@hykocx/zen/stripe';
|
||||
*/
|
||||
|
||||
/**
|
||||
* Get Stripe instance
|
||||
* @returns {Promise<Object>} Stripe instance
|
||||
*/
|
||||
export async function getStripe() {
|
||||
const secretKey = process.env.STRIPE_SECRET_KEY;
|
||||
|
||||
if (!secretKey) {
|
||||
throw new Error('Stripe is not configured. Please set STRIPE_SECRET_KEY environment variable.');
|
||||
}
|
||||
|
||||
const Stripe = (await import('stripe')).default;
|
||||
return new Stripe(secretKey, {
|
||||
apiVersion: '2023-10-16',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if Stripe is enabled
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isEnabled() {
|
||||
return !!(process.env.STRIPE_SECRET_KEY && process.env.STRIPE_PUBLISHABLE_KEY);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get Stripe publishable key (for client-side)
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function getPublishableKey() {
|
||||
return process.env.STRIPE_PUBLISHABLE_KEY || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a checkout session
|
||||
* @param {Object} options - Checkout options
|
||||
* @param {Array} options.lineItems - Line items for checkout
|
||||
* @param {string} options.successUrl - Success redirect URL
|
||||
* @param {string} options.cancelUrl - Cancel redirect URL
|
||||
* @param {string} options.customerEmail - Customer email
|
||||
* @param {Object} options.metadata - Additional metadata
|
||||
* @param {string} options.mode - Payment mode (default: 'payment')
|
||||
* @returns {Promise<Object>} Stripe session object
|
||||
*
|
||||
* @example
|
||||
* const session = await createCheckoutSession({
|
||||
* lineItems: [{
|
||||
* price_data: {
|
||||
* currency: 'usd',
|
||||
* product_data: { name: 'Product' },
|
||||
* unit_amount: 1000,
|
||||
* },
|
||||
* quantity: 1,
|
||||
* }],
|
||||
* successUrl: 'https://example.com/success',
|
||||
* cancelUrl: 'https://example.com/cancel',
|
||||
* });
|
||||
*/
|
||||
export async function createCheckoutSession(options) {
|
||||
const stripe = await getStripe();
|
||||
|
||||
const {
|
||||
lineItems,
|
||||
successUrl,
|
||||
cancelUrl,
|
||||
customerEmail,
|
||||
metadata = {},
|
||||
mode = 'payment',
|
||||
paymentMethodTypes = ['card'],
|
||||
clientReferenceId,
|
||||
} = options;
|
||||
|
||||
const sessionConfig = {
|
||||
payment_method_types: paymentMethodTypes,
|
||||
line_items: lineItems,
|
||||
mode,
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
metadata,
|
||||
};
|
||||
|
||||
if (customerEmail) {
|
||||
sessionConfig.customer_email = customerEmail;
|
||||
}
|
||||
|
||||
if (clientReferenceId) {
|
||||
sessionConfig.client_reference_id = clientReferenceId;
|
||||
}
|
||||
|
||||
return await stripe.checkout.sessions.create(sessionConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a payment intent
|
||||
* @param {Object} options - Payment options
|
||||
* @param {number} options.amount - Amount in cents
|
||||
* @param {string} options.currency - Currency code
|
||||
* @param {Object} options.metadata - Additional metadata
|
||||
* @returns {Promise<Object>} Stripe payment intent
|
||||
*/
|
||||
export async function createPaymentIntent(options) {
|
||||
const stripe = await getStripe();
|
||||
|
||||
const {
|
||||
amount,
|
||||
currency = process.env.ZEN_CURRENCY?.toLowerCase() || 'cad',
|
||||
metadata = {},
|
||||
automaticPaymentMethods = { enabled: true },
|
||||
} = options;
|
||||
|
||||
return await stripe.paymentIntents.create({
|
||||
amount,
|
||||
currency,
|
||||
metadata,
|
||||
automatic_payment_methods: automaticPaymentMethods,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a checkout session
|
||||
* @param {string} sessionId - Session ID
|
||||
* @returns {Promise<Object>} Stripe session
|
||||
*/
|
||||
export async function getCheckoutSession(sessionId) {
|
||||
const stripe = await getStripe();
|
||||
return await stripe.checkout.sessions.retrieve(sessionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a payment intent
|
||||
* @param {string} paymentIntentId - Payment intent ID
|
||||
* @returns {Promise<Object>} Stripe payment intent
|
||||
*/
|
||||
export async function getPaymentIntent(paymentIntentId) {
|
||||
const stripe = await getStripe();
|
||||
return await stripe.paymentIntents.retrieve(paymentIntentId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify webhook signature
|
||||
* @param {string} payload - Raw request body
|
||||
* @param {string} signature - Stripe-Signature header
|
||||
* @param {string} secret - Webhook secret (optional, uses env if not provided)
|
||||
* @returns {Promise<Object>} Verified event
|
||||
*/
|
||||
export async function verifyWebhookSignature(payload, signature, secret = null) {
|
||||
const stripe = await getStripe();
|
||||
const webhookSecret = secret || process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
if (!webhookSecret) {
|
||||
throw new Error('Stripe webhook secret is not configured');
|
||||
}
|
||||
|
||||
return stripe.webhooks.constructEvent(payload, signature, webhookSecret);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a customer
|
||||
* @param {Object} options - Customer options
|
||||
* @param {string} options.email - Customer email
|
||||
* @param {string} options.name - Customer name
|
||||
* @param {Object} options.metadata - Additional metadata
|
||||
* @returns {Promise<Object>} Stripe customer
|
||||
*/
|
||||
export async function createCustomer(options) {
|
||||
const stripe = await getStripe();
|
||||
|
||||
const { email, name, metadata = {} } = options;
|
||||
|
||||
return await stripe.customers.create({
|
||||
email,
|
||||
name,
|
||||
metadata,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get or create a customer by email
|
||||
* @param {string} email - Customer email
|
||||
* @param {Object} defaultData - Default data if creating new customer
|
||||
* @returns {Promise<Object>} Stripe customer
|
||||
*/
|
||||
export async function getOrCreateCustomer(email, defaultData = {}) {
|
||||
const stripe = await getStripe();
|
||||
|
||||
// Search for existing customer
|
||||
const existing = await stripe.customers.list({
|
||||
email,
|
||||
limit: 1,
|
||||
});
|
||||
|
||||
if (existing.data.length > 0) {
|
||||
return existing.data[0];
|
||||
}
|
||||
|
||||
// Create new customer
|
||||
return await stripe.customers.create({
|
||||
email,
|
||||
...defaultData,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* List customer's payment methods
|
||||
* @param {string} customerId - Customer ID
|
||||
* @param {string} type - Payment method type (default: 'card')
|
||||
* @returns {Promise<Array>} List of payment methods
|
||||
*/
|
||||
export async function listPaymentMethods(customerId, type = 'card') {
|
||||
const stripe = await getStripe();
|
||||
|
||||
const methods = await stripe.paymentMethods.list({
|
||||
customer: customerId,
|
||||
type,
|
||||
});
|
||||
|
||||
return methods.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a refund
|
||||
* @param {Object} options - Refund options
|
||||
* @param {string} options.paymentIntentId - Payment intent to refund
|
||||
* @param {number} options.amount - Amount to refund in cents (optional, full refund if not specified)
|
||||
* @param {string} options.reason - Reason for refund
|
||||
* @returns {Promise<Object>} Stripe refund
|
||||
*/
|
||||
export async function createRefund(options) {
|
||||
const stripe = await getStripe();
|
||||
|
||||
const { paymentIntentId, amount, reason } = options;
|
||||
|
||||
const refundConfig = {
|
||||
payment_intent: paymentIntentId,
|
||||
};
|
||||
|
||||
if (amount) {
|
||||
refundConfig.amount = amount;
|
||||
}
|
||||
|
||||
if (reason) {
|
||||
refundConfig.reason = reason;
|
||||
}
|
||||
|
||||
return await stripe.refunds.create(refundConfig);
|
||||
}
|
||||
|
||||
// Default export for convenience
|
||||
export default {
|
||||
getStripe,
|
||||
isEnabled,
|
||||
getPublishableKey,
|
||||
createCheckoutSession,
|
||||
createPaymentIntent,
|
||||
getCheckoutSession,
|
||||
getPaymentIntent,
|
||||
verifyWebhookSignature,
|
||||
createCustomer,
|
||||
getOrCreateCustomer,
|
||||
listPaymentMethods,
|
||||
createRefund,
|
||||
};
|
||||
@@ -0,0 +1,121 @@
|
||||
/**
|
||||
* PDF Generation Utilities
|
||||
* Wrapper around @react-pdf/renderer for PDF generation
|
||||
*
|
||||
* Usage in modules:
|
||||
* import { renderToBuffer } from '@hykocx/zen/pdf';
|
||||
*/
|
||||
|
||||
import { renderToBuffer as reactPdfRenderToBuffer } from '@react-pdf/renderer';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Render a React PDF document to a buffer
|
||||
* @param {React.Element} document - React PDF document element
|
||||
* @returns {Promise<Buffer>} PDF buffer
|
||||
*
|
||||
* @example
|
||||
* import { Document, Page, Text } from '@react-pdf/renderer';
|
||||
*
|
||||
* const MyDoc = () => (
|
||||
* <Document>
|
||||
* <Page>
|
||||
* <Text>Hello World</Text>
|
||||
* </Page>
|
||||
* </Document>
|
||||
* );
|
||||
*
|
||||
* const buffer = await renderToBuffer(<MyDoc />);
|
||||
*/
|
||||
export async function renderToBuffer(document) {
|
||||
return await reactPdfRenderToBuffer(document);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a React element for PDF rendering
|
||||
* @param {Function} Component - React component
|
||||
* @param {Object} props - Component props
|
||||
* @returns {React.Element}
|
||||
*/
|
||||
export function createElement(Component, props) {
|
||||
return React.createElement(Component, props);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a suggested filename for a PDF
|
||||
* @param {string} prefix - Filename prefix
|
||||
* @param {string|number} identifier - Unique identifier
|
||||
* @param {Date} date - Date for the filename (default: today)
|
||||
* @returns {string} Suggested filename
|
||||
*
|
||||
* @example
|
||||
* getFilename('invoice', '12345'); // 'invoice-12345-2024-01-15.pdf'
|
||||
*/
|
||||
export function getFilename(prefix, identifier, date = new Date()) {
|
||||
const dateStr = date.toISOString().split('T')[0];
|
||||
return `${prefix}-${identifier}-${dateStr}.pdf`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert centimeters to points (for PDF dimensions)
|
||||
* @param {number} cm - Centimeters
|
||||
* @returns {number} Points
|
||||
*/
|
||||
export function cmToPoints(cm) {
|
||||
return cm * 28.3465;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert inches to points (for PDF dimensions)
|
||||
* @param {number} inches - Inches
|
||||
* @returns {number} Points
|
||||
*/
|
||||
export function inchesToPoints(inches) {
|
||||
return inches * 72;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert millimeters to points (for PDF dimensions)
|
||||
* @param {number} mm - Millimeters
|
||||
* @returns {number} Points
|
||||
*/
|
||||
export function mmToPoints(mm) {
|
||||
return mm * 2.83465;
|
||||
}
|
||||
|
||||
/**
|
||||
* Common page sizes in points
|
||||
*/
|
||||
export const PAGE_SIZES = {
|
||||
A4: { width: 595.28, height: 841.89 },
|
||||
LETTER: { width: 612, height: 792 },
|
||||
LEGAL: { width: 612, height: 1008 },
|
||||
A3: { width: 841.89, height: 1190.55 },
|
||||
A5: { width: 419.53, height: 595.28 },
|
||||
};
|
||||
|
||||
// Re-export react-pdf components for convenience
|
||||
export {
|
||||
Document,
|
||||
Page,
|
||||
View,
|
||||
Text,
|
||||
Image,
|
||||
Link,
|
||||
StyleSheet,
|
||||
Font,
|
||||
PDFViewer,
|
||||
BlobProvider,
|
||||
PDFDownloadLink,
|
||||
} from '@react-pdf/renderer';
|
||||
|
||||
// Default export
|
||||
export default {
|
||||
renderToBuffer,
|
||||
createElement,
|
||||
getFilename,
|
||||
cmToPoints,
|
||||
inchesToPoints,
|
||||
mmToPoints,
|
||||
PAGE_SIZES,
|
||||
};
|
||||
@@ -0,0 +1,671 @@
|
||||
/**
|
||||
* Zen Storage Module - Cloudflare R2
|
||||
* Provides file upload, download, deletion, and management functionality
|
||||
* Uses native fetch + crypto (AWS Signature V4) — no external dependencies
|
||||
*/
|
||||
|
||||
import { createHmac, createHash } from 'crypto';
|
||||
|
||||
// ─── AWS Signature V4 ────────────────────────────────────────────────────────
|
||||
|
||||
function sha256hex(data) {
|
||||
return createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
function hmac(key, data) {
|
||||
return createHmac('sha256', Buffer.isBuffer(key) ? key : Buffer.from(key)).update(data).digest();
|
||||
}
|
||||
|
||||
function hmacHex(key, data) {
|
||||
return createHmac('sha256', Buffer.isBuffer(key) ? key : Buffer.from(key)).update(data).digest('hex');
|
||||
}
|
||||
|
||||
function amzDate(date) {
|
||||
return date.toISOString().replace(/[:\-]|\.\d{3}/g, '');
|
||||
}
|
||||
|
||||
function dateStamp(date) {
|
||||
return date.toISOString().slice(0, 10).replace(/-/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a string per AWS Signature V4 spec (encodes everything except A-Z a-z 0-9 - _ . ~)
|
||||
*/
|
||||
function encodeS3(str) {
|
||||
return encodeURIComponent(str)
|
||||
.replace(/!/g, '%21')
|
||||
.replace(/'/g, '%27')
|
||||
.replace(/\(/g, '%28')
|
||||
.replace(/\)/g, '%29')
|
||||
.replace(/\*/g, '%2A');
|
||||
}
|
||||
|
||||
/**
|
||||
* Encode a URI path, encoding each segment individually (preserving slashes)
|
||||
*/
|
||||
function encodePath(path) {
|
||||
return path
|
||||
.split('/')
|
||||
.map(segment => (segment ? encodeS3(segment) : ''))
|
||||
.join('/');
|
||||
}
|
||||
|
||||
function signingKey(secret, ds, region, service) {
|
||||
const kDate = hmac('AWS4' + secret, ds);
|
||||
const kRegion = hmac(kDate, region);
|
||||
const kService = hmac(kRegion, service);
|
||||
return hmac(kService, 'aws4_request');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sign an S3 request using AWS Signature V4.
|
||||
* Returns the full URL and the headers object to pass to fetch.
|
||||
*/
|
||||
function signRequest({ method, host, path, query = {}, extraHeaders = {}, bodyBuffer, config, date }) {
|
||||
const { accessKeyId, secretAccessKey } = config;
|
||||
const region = 'auto';
|
||||
const service = 's3';
|
||||
|
||||
const ts = amzDate(date);
|
||||
const ds = dateStamp(date);
|
||||
const bodyHash = sha256hex(bodyBuffer ?? Buffer.alloc(0));
|
||||
|
||||
const headers = {
|
||||
host,
|
||||
'x-amz-date': ts,
|
||||
'x-amz-content-sha256': bodyHash,
|
||||
...extraHeaders,
|
||||
};
|
||||
|
||||
const sortedHeaderKeys = Object.keys(headers).sort();
|
||||
const canonicalHeaders = sortedHeaderKeys.map(k => `${k}:${headers[k]}\n`).join('');
|
||||
const signedHeaders = sortedHeaderKeys.join(';');
|
||||
|
||||
const canonicalQueryString = Object.keys(query)
|
||||
.sort()
|
||||
.map(k => `${encodeS3(k)}=${encodeS3(query[k])}`)
|
||||
.join('&');
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
encodePath(path),
|
||||
canonicalQueryString,
|
||||
canonicalHeaders,
|
||||
signedHeaders,
|
||||
bodyHash,
|
||||
].join('\n');
|
||||
|
||||
const scope = `${ds}/${region}/${service}/aws4_request`;
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n');
|
||||
|
||||
const sig = hmacHex(signingKey(secretAccessKey, ds, region, service), stringToSign);
|
||||
const auth = `AWS4-HMAC-SHA256 Credential=${accessKeyId}/${scope}, SignedHeaders=${signedHeaders}, Signature=${sig}`;
|
||||
|
||||
const requestHeaders = { ...headers, Authorization: auth };
|
||||
delete requestHeaders.host;
|
||||
|
||||
const url = canonicalQueryString
|
||||
? `https://${host}${path}?${canonicalQueryString}`
|
||||
: `https://${host}${path}`;
|
||||
|
||||
return { url, headers: requestHeaders };
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a presigned URL (signature embedded in query string, no Authorization header).
|
||||
* The payload is marked UNSIGNED-PAYLOAD so no body hash is needed at signing time.
|
||||
*/
|
||||
function buildPresignedUrl({ method, host, path, expiresIn, config, date }) {
|
||||
const { accessKeyId, secretAccessKey } = config;
|
||||
const region = 'auto';
|
||||
const service = 's3';
|
||||
|
||||
const ts = amzDate(date);
|
||||
const ds = dateStamp(date);
|
||||
const scope = `${ds}/${region}/${service}/aws4_request`;
|
||||
|
||||
const query = {
|
||||
'X-Amz-Algorithm': 'AWS4-HMAC-SHA256',
|
||||
'X-Amz-Credential': `${accessKeyId}/${scope}`,
|
||||
'X-Amz-Date': ts,
|
||||
'X-Amz-Expires': String(expiresIn),
|
||||
'X-Amz-SignedHeaders': 'host',
|
||||
};
|
||||
|
||||
const canonicalQueryString = Object.keys(query)
|
||||
.sort()
|
||||
.map(k => `${encodeS3(k)}=${encodeS3(query[k])}`)
|
||||
.join('&');
|
||||
|
||||
const canonicalRequest = [
|
||||
method,
|
||||
encodePath(path),
|
||||
canonicalQueryString,
|
||||
`host:${host}\n`,
|
||||
'host',
|
||||
'UNSIGNED-PAYLOAD',
|
||||
].join('\n');
|
||||
|
||||
const stringToSign = ['AWS4-HMAC-SHA256', ts, scope, sha256hex(canonicalRequest)].join('\n');
|
||||
const sig = hmacHex(signingKey(secretAccessKey, ds, region, service), stringToSign);
|
||||
|
||||
return `https://${host}${path}?${canonicalQueryString}&X-Amz-Signature=${sig}`;
|
||||
}
|
||||
|
||||
// ─── Config ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function getConfig() {
|
||||
const region = process.env.ZEN_STORAGE_REGION;
|
||||
const accessKeyId = process.env.ZEN_STORAGE_ACCESS_KEY;
|
||||
const secretAccessKey = process.env.ZEN_STORAGE_SECRET_KEY;
|
||||
const bucket = process.env.ZEN_STORAGE_BUCKET;
|
||||
|
||||
if (!region || !accessKeyId || !secretAccessKey) {
|
||||
throw new Error(
|
||||
'Storage credentials are not configured. Please set ZEN_STORAGE_REGION, ZEN_STORAGE_ACCESS_KEY, and ZEN_STORAGE_SECRET_KEY.'
|
||||
);
|
||||
}
|
||||
if (!bucket) {
|
||||
throw new Error('ZEN_STORAGE_BUCKET environment variable is not set');
|
||||
}
|
||||
|
||||
return {
|
||||
accessKeyId,
|
||||
secretAccessKey,
|
||||
bucket,
|
||||
host: `${region}.r2.cloudflarestorage.com`,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Minimal XML helpers ─────────────────────────────────────────────────────
|
||||
|
||||
function xmlFirst(xml, tag) {
|
||||
const m = xml.match(new RegExp(`<${tag}[^>]*>(.*?)</${tag}>`, 's'));
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function xmlAll(xml, tag) {
|
||||
const re = new RegExp(`<${tag}[^>]*>(.*?)</${tag}>`, 'gs');
|
||||
const results = [];
|
||||
let m;
|
||||
while ((m = re.exec(xml)) !== null) results.push(m[1]);
|
||||
return results;
|
||||
}
|
||||
|
||||
// ─── Body normalizer ─────────────────────────────────────────────────────────
|
||||
|
||||
async function toBuffer(body) {
|
||||
if (Buffer.isBuffer(body)) return body;
|
||||
if (body instanceof Uint8Array) return Buffer.from(body);
|
||||
if (typeof body === 'string') return Buffer.from(body, 'utf8');
|
||||
if (body instanceof Blob) return Buffer.from(await body.arrayBuffer());
|
||||
return Buffer.from(body);
|
||||
}
|
||||
|
||||
// ─── Sanitization helpers ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Strip HTTP header injection characters (\r, \n, \0) from a header value.
|
||||
* A value containing these characters would break the canonical request format
|
||||
* and could allow an attacker to inject arbitrary signed headers.
|
||||
*/
|
||||
function sanitizeHeaderValue(value) {
|
||||
return String(value).replace(/[\r\n\0]/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Escape XML special characters to prevent injection into the DeleteObjects payload.
|
||||
*/
|
||||
function escapeXml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
// ─── Metadata header helpers ─────────────────────────────────────────────────
|
||||
|
||||
function metaToHeaders(metadata) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(metadata).map(([k, v]) => [
|
||||
`x-amz-meta-${sanitizeHeaderValue(k).toLowerCase()}`,
|
||||
sanitizeHeaderValue(v),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
function headersToMeta(headers) {
|
||||
return Object.fromEntries(
|
||||
[...headers.entries()]
|
||||
.filter(([k]) => k.startsWith('x-amz-meta-'))
|
||||
.map(([k, v]) => [k.replace('x-amz-meta-', ''), v])
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Storage functions ───────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Upload a file to storage
|
||||
* @param {Object} options
|
||||
* @param {string} options.key - File path/key in the bucket
|
||||
* @param {Buffer|string|Uint8Array|Blob} options.body - File content
|
||||
* @param {string} options.contentType - MIME type
|
||||
* @param {Object} options.metadata - Optional metadata key-value pairs
|
||||
* @param {string} options.cacheControl - Optional cache control header
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function uploadFile({ key, body, contentType, metadata = {}, cacheControl }) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
const bodyBuffer = await toBuffer(body);
|
||||
|
||||
const extraHeaders = {
|
||||
'content-type': sanitizeHeaderValue(contentType),
|
||||
...metaToHeaders(metadata),
|
||||
...(cacheControl && { 'cache-control': sanitizeHeaderValue(cacheControl) }),
|
||||
};
|
||||
|
||||
const { url, headers } = signRequest({
|
||||
method: 'PUT',
|
||||
host: config.host,
|
||||
path,
|
||||
extraHeaders,
|
||||
bodyBuffer,
|
||||
config,
|
||||
date,
|
||||
});
|
||||
|
||||
const response = await fetch(url, { method: 'PUT', headers, body: bodyBuffer });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Upload failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
return { success: true, data: { key, bucket: config.bucket, contentType }, error: null };
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error uploading file:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload an image with optimized settings
|
||||
* @param {Object} options
|
||||
* @param {string} options.key - File path/key in the bucket
|
||||
* @param {Buffer|Blob} options.body - Image content
|
||||
* @param {string} options.contentType - Image MIME type
|
||||
* @param {Object} options.metadata - Optional metadata
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function uploadImage({ key, body, contentType, metadata = {} }) {
|
||||
return uploadFile({ key, body, contentType, metadata, cacheControl: 'public, max-age=31536000' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file from storage
|
||||
* @param {string} key - File path/key to delete
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function deleteFile(key) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
|
||||
const { url, headers } = signRequest({ method: 'DELETE', host: config.host, path, config, date });
|
||||
const response = await fetch(url, { method: 'DELETE', headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Delete failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
return { success: true, data: { key }, error: null };
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error deleting file:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple files from storage
|
||||
* @param {string[]} keys - Array of file paths/keys to delete
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function deleteFiles(keys) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const path = `/${config.bucket}`;
|
||||
const date = new Date();
|
||||
|
||||
const xmlBody =
|
||||
`<?xml version="1.0" encoding="UTF-8"?><Delete>` +
|
||||
keys.map(k => `<Object><Key>${escapeXml(k)}</Key></Object>`).join('') +
|
||||
`</Delete>`;
|
||||
const bodyBuffer = Buffer.from(xmlBody, 'utf8');
|
||||
const contentMd5 = createHash('md5').update(bodyBuffer).digest('base64');
|
||||
|
||||
const { url, headers } = signRequest({
|
||||
method: 'POST',
|
||||
host: config.host,
|
||||
path,
|
||||
query: { delete: '' },
|
||||
extraHeaders: { 'content-type': 'application/xml', 'content-md5': contentMd5 },
|
||||
bodyBuffer,
|
||||
config,
|
||||
date,
|
||||
});
|
||||
|
||||
const response = await fetch(url, { method: 'POST', headers, body: bodyBuffer });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Delete failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const deleted = xmlAll(xml, 'Deleted').map(b => ({ Key: xmlFirst(b, 'Key') }));
|
||||
const errors = xmlAll(xml, 'Error').map(b => ({
|
||||
Key: xmlFirst(b, 'Key'),
|
||||
Code: xmlFirst(b, 'Code'),
|
||||
Message: xmlFirst(b, 'Message'),
|
||||
}));
|
||||
|
||||
return { success: true, data: { deleted, errors }, error: null };
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error deleting files:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a file from storage
|
||||
* @param {string} key - File path/key to retrieve
|
||||
* @returns {Promise<Object>} File data with metadata
|
||||
*/
|
||||
async function getFile(key) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
|
||||
const { url, headers } = signRequest({ method: 'GET', host: config.host, path, config, date });
|
||||
const response = await fetch(url, { method: 'GET', headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`Get failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
key,
|
||||
body: buffer,
|
||||
contentType: response.headers.get('content-type'),
|
||||
contentLength: Number(response.headers.get('content-length')),
|
||||
lastModified: response.headers.get('last-modified')
|
||||
? new Date(response.headers.get('last-modified'))
|
||||
: null,
|
||||
metadata: headersToMeta(response.headers),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error getting file:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file metadata without downloading the file
|
||||
* @param {string} key - File path/key
|
||||
* @returns {Promise<Object>} File metadata
|
||||
*/
|
||||
async function getFileMetadata(key) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
|
||||
const { url, headers } = signRequest({ method: 'HEAD', host: config.host, path, config, date });
|
||||
const response = await fetch(url, { method: 'HEAD', headers });
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Head failed (${response.status})`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
key,
|
||||
contentType: response.headers.get('content-type'),
|
||||
contentLength: Number(response.headers.get('content-length')),
|
||||
lastModified: response.headers.get('last-modified')
|
||||
? new Date(response.headers.get('last-modified'))
|
||||
: null,
|
||||
metadata: headersToMeta(response.headers),
|
||||
etag: response.headers.get('etag'),
|
||||
},
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error getting file metadata:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists in storage
|
||||
* @param {string} key - File path/key to check
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function fileExists(key) {
|
||||
const result = await getFileMetadata(key);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* List files in a directory/prefix
|
||||
* @param {Object} options
|
||||
* @param {string} options.prefix - Directory prefix (e.g., 'users/123/')
|
||||
* @param {number} options.maxKeys - Maximum number of keys to return (default: 1000)
|
||||
* @param {string} options.continuationToken - Token for pagination
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function listFiles({ prefix = '', maxKeys = 1000, continuationToken } = {}) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const path = `/${config.bucket}`;
|
||||
const date = new Date();
|
||||
|
||||
// R2/S3 max is 1000 keys per list request
|
||||
const validMaxKeys = Math.min(Math.max(Math.floor(Number(maxKeys)), 1), 1000);
|
||||
|
||||
const query = {
|
||||
'list-type': '2',
|
||||
'max-keys': String(validMaxKeys),
|
||||
...(prefix && { prefix }),
|
||||
...(continuationToken && { 'continuation-token': continuationToken }),
|
||||
};
|
||||
|
||||
const { url, headers } = signRequest({ method: 'GET', host: config.host, path, query, config, date });
|
||||
const response = await fetch(url, { method: 'GET', headers });
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
throw new Error(`List failed (${response.status}): ${text}`);
|
||||
}
|
||||
|
||||
const xml = await response.text();
|
||||
const isTruncated = xmlFirst(xml, 'IsTruncated') === 'true';
|
||||
const nextContinuationToken = xmlFirst(xml, 'NextContinuationToken');
|
||||
const files = xmlAll(xml, 'Contents').map(block => ({
|
||||
key: xmlFirst(block, 'Key'),
|
||||
size: parseInt(xmlFirst(block, 'Size') || '0', 10),
|
||||
lastModified: xmlFirst(block, 'LastModified') ? new Date(xmlFirst(block, 'LastModified')) : null,
|
||||
etag: (xmlFirst(block, 'ETag') || '').replace(/"/g, ''),
|
||||
}));
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { files, isTruncated, nextContinuationToken, count: files.length },
|
||||
error: null,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error listing files:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a presigned URL for temporary access to a file
|
||||
* @param {Object} options
|
||||
* @param {string} options.key - File path/key
|
||||
* @param {number} options.expiresIn - URL expiration time in seconds (default: 3600)
|
||||
* @param {string} options.operation - 'get' or 'put' (default: 'get')
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function getPresignedUrl({ key, expiresIn = 3600, operation = 'get' }) {
|
||||
try {
|
||||
const config = getConfig();
|
||||
const path = `/${config.bucket}/${key}`;
|
||||
const date = new Date();
|
||||
const method = operation === 'put' ? 'PUT' : 'GET';
|
||||
|
||||
// R2/S3 max presigned URL lifetime is 7 days (604800 seconds)
|
||||
const validExpiresIn = Math.min(Math.max(Math.floor(Number(expiresIn)), 1), 604800);
|
||||
if (!Number.isFinite(validExpiresIn)) {
|
||||
throw new Error('expiresIn must be a finite positive number');
|
||||
}
|
||||
|
||||
const url = buildPresignedUrl({ method, host: config.host, path, expiresIn: validExpiresIn, config, date });
|
||||
|
||||
return { success: true, data: { key, url, expiresIn: validExpiresIn, operation }, error: null };
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error generating presigned URL:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a file within the same bucket
|
||||
* @param {Object} options
|
||||
* @param {string} options.sourceKey - Source file path/key
|
||||
* @param {string} options.destinationKey - Destination file path/key
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function copyFile({ sourceKey, destinationKey }) {
|
||||
try {
|
||||
const getResult = await getFile(sourceKey);
|
||||
if (!getResult.success) return getResult;
|
||||
|
||||
const uploadResult = await uploadFile({
|
||||
key: destinationKey,
|
||||
body: getResult.data.body,
|
||||
contentType: getResult.data.contentType,
|
||||
metadata: getResult.data.metadata,
|
||||
});
|
||||
|
||||
if (uploadResult.success) {
|
||||
console.log(`[ZEN STORAGE] File copied from ${sourceKey} to ${destinationKey}`);
|
||||
}
|
||||
|
||||
return uploadResult;
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error copying file:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Proxy a file from storage, returning a handler-ready response object.
|
||||
* Use this instead of presigned URLs to avoid exposing storage URLs to clients.
|
||||
* The returned object is consumed directly by the API router to stream the file.
|
||||
* @param {string} key - File path/key to retrieve
|
||||
* @param {Object} options
|
||||
* @param {string} [options.filename] - Optional download filename (Content-Disposition)
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function proxyFile(key, { filename } = {}) {
|
||||
const result = await getFile(key);
|
||||
if (!result.success) return { success: false, error: result.error };
|
||||
return {
|
||||
success: true,
|
||||
file: {
|
||||
body: result.data.body,
|
||||
contentType: result.data.contentType,
|
||||
contentLength: result.data.contentLength,
|
||||
...(filename && { filename }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Move a file (copy + delete source)
|
||||
* @param {Object} options
|
||||
* @param {string} options.sourceKey - Source file path/key
|
||||
* @param {string} options.destinationKey - Destination file path/key
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
async function moveFile({ sourceKey, destinationKey }) {
|
||||
try {
|
||||
const copyResult = await copyFile({ sourceKey, destinationKey });
|
||||
if (!copyResult.success) return copyResult;
|
||||
|
||||
const deleteResult = await deleteFile(sourceKey);
|
||||
if (!deleteResult.success) {
|
||||
console.warn(
|
||||
`[ZEN STORAGE] File copied to ${destinationKey} but failed to delete source ${sourceKey}`
|
||||
);
|
||||
} else {
|
||||
console.log(`[ZEN STORAGE] File moved from ${sourceKey} to ${destinationKey}`);
|
||||
}
|
||||
|
||||
return copyResult;
|
||||
} catch (error) {
|
||||
console.error('[ZEN STORAGE] Error moving file:', error);
|
||||
return { success: false, data: null, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
// Export utility functions
|
||||
export {
|
||||
generateUniqueFilename,
|
||||
getFileExtension,
|
||||
getMimeType,
|
||||
validateFileType,
|
||||
validateFileSize,
|
||||
formatFileSize,
|
||||
generateUserFilePath,
|
||||
generateOrgFilePath,
|
||||
generateBlogFilePath,
|
||||
sanitizeFilename,
|
||||
validateImageDimensions,
|
||||
validateUpload,
|
||||
FILE_TYPE_PRESETS,
|
||||
FILE_SIZE_LIMITS,
|
||||
} from './utils.js';
|
||||
|
||||
// Export storage functions
|
||||
export {
|
||||
uploadFile,
|
||||
uploadImage,
|
||||
deleteFile,
|
||||
deleteFiles,
|
||||
getFile,
|
||||
getFileMetadata,
|
||||
fileExists,
|
||||
listFiles,
|
||||
getPresignedUrl,
|
||||
proxyFile,
|
||||
copyFile,
|
||||
moveFile,
|
||||
};
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Storage utility functions
|
||||
* Helper functions for file handling, validation, and naming
|
||||
*/
|
||||
|
||||
import crypto from 'crypto';
|
||||
|
||||
/**
|
||||
* Generate a unique filename with timestamp and random hash
|
||||
* @param {string} originalName - Original filename
|
||||
* @param {string} prefix - Optional prefix for the filename
|
||||
* @returns {string} Unique filename
|
||||
*/
|
||||
export function generateUniqueFilename(originalName, prefix = '') {
|
||||
const timestamp = Date.now();
|
||||
const randomHash = crypto.randomBytes(8).toString('hex');
|
||||
const extension = getFileExtension(originalName);
|
||||
const basePrefix = prefix ? `${prefix}_` : '';
|
||||
return `${basePrefix}${timestamp}_${randomHash}${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get file extension from filename
|
||||
* @param {string} filename - Filename
|
||||
* @returns {string} File extension with dot (e.g., '.jpg') or empty string
|
||||
*/
|
||||
export function getFileExtension(filename) {
|
||||
if (!filename) return '';
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
return lastDot === -1 ? '' : filename.substring(lastDot).toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get MIME type from file extension
|
||||
* @param {string} filename - Filename or extension
|
||||
* @returns {string} MIME type
|
||||
*/
|
||||
export function getMimeType(filename) {
|
||||
const ext = getFileExtension(filename).toLowerCase();
|
||||
|
||||
const mimeTypes = {
|
||||
// Images
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.png': 'image/png',
|
||||
'.gif': 'image/gif',
|
||||
'.webp': 'image/webp',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
'.bmp': 'image/bmp',
|
||||
|
||||
// Documents
|
||||
'.pdf': 'application/pdf',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.ppt': 'application/vnd.ms-powerpoint',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.txt': 'text/plain',
|
||||
'.csv': 'text/csv',
|
||||
|
||||
// Archives
|
||||
'.zip': 'application/zip',
|
||||
'.rar': 'application/x-rar-compressed',
|
||||
'.7z': 'application/x-7z-compressed',
|
||||
'.tar': 'application/x-tar',
|
||||
'.gz': 'application/gzip',
|
||||
|
||||
// Media
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.mp4': 'video/mp4',
|
||||
'.avi': 'video/x-msvideo',
|
||||
'.mov': 'video/quicktime',
|
||||
'.wmv': 'video/x-ms-wmv',
|
||||
|
||||
// Code
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.xml': 'application/xml',
|
||||
'.html': 'text/html',
|
||||
'.css': 'text/css',
|
||||
};
|
||||
|
||||
return mimeTypes[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file type against allowed types
|
||||
* @param {string} filename - Filename or extension
|
||||
* @param {string[]} allowedTypes - Array of allowed extensions (e.g., ['.jpg', '.png']) or MIME types
|
||||
* @returns {boolean} True if file type is allowed
|
||||
*/
|
||||
export function validateFileType(filename, allowedTypes) {
|
||||
if (!allowedTypes || allowedTypes.length === 0) return true;
|
||||
|
||||
const ext = getFileExtension(filename).toLowerCase();
|
||||
const mimeType = getMimeType(filename);
|
||||
|
||||
return allowedTypes.some(type => {
|
||||
if (type.startsWith('.')) {
|
||||
return ext === type.toLowerCase();
|
||||
}
|
||||
return mimeType === type;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate file size
|
||||
* @param {number} size - File size in bytes
|
||||
* @param {number} maxSize - Maximum allowed size in bytes
|
||||
* @returns {boolean} True if file size is valid
|
||||
*/
|
||||
export function validateFileSize(size, maxSize) {
|
||||
if (!maxSize) return true;
|
||||
return size <= maxSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format file size to human-readable format
|
||||
* @param {number} bytes - Size in bytes
|
||||
* @param {number} decimals - Number of decimal places (default: 2)
|
||||
* @returns {string} Formatted size (e.g., '1.5 MB')
|
||||
*/
|
||||
export function formatFileSize(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB', 'PB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a storage path for a user's file
|
||||
* @param {string|number} userId - User ID
|
||||
* @param {string} category - File category (e.g., 'profile', 'documents')
|
||||
* @param {string} filename - Filename
|
||||
* @returns {string} Storage path (e.g., 'users/123/profile/filename.jpg')
|
||||
*/
|
||||
export function generateUserFilePath(userId, category, filename) {
|
||||
return `users/${userId}/${category}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a storage path for organization/tenant files
|
||||
* @param {string|number} orgId - Organization/tenant ID
|
||||
* @param {string} category - File category
|
||||
* @param {string} filename - Filename
|
||||
* @returns {string} Storage path
|
||||
*/
|
||||
export function generateOrgFilePath(orgId, category, filename) {
|
||||
return `organizations/${orgId}/${category}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a storage path for blog post images
|
||||
* @param {string|number} postIdOrSlug - Post ID or slug (e.g. for temp uploads use timestamp)
|
||||
* @param {string} filename - Filename
|
||||
* @returns {string} Storage path (e.g., 'blog/123/filename.jpg')
|
||||
*/
|
||||
export function generateBlogFilePath(postIdOrSlug, filename) {
|
||||
return `blog/${postIdOrSlug}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize filename by removing special characters
|
||||
* @param {string} filename - Original filename
|
||||
* @returns {string} Sanitized filename
|
||||
*/
|
||||
export function sanitizeFilename(filename) {
|
||||
const ext = getFileExtension(filename);
|
||||
const nameWithoutExt = filename.substring(0, filename.length - ext.length);
|
||||
|
||||
// Remove special characters and replace spaces with underscores
|
||||
const sanitized = nameWithoutExt
|
||||
.replace(/[^a-zA-Z0-9_-]/g, '_')
|
||||
.replace(/_{2,}/g, '_')
|
||||
.replace(/^_+|_+$/g, '');
|
||||
|
||||
return sanitized + ext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate image dimensions from buffer
|
||||
* Note: This is a basic implementation. For production, consider using a library like 'sharp'
|
||||
* @param {Buffer} buffer - Image buffer
|
||||
* @param {Object} constraints - Dimension constraints
|
||||
* @param {number} constraints.maxWidth - Maximum width
|
||||
* @param {number} constraints.maxHeight - Maximum height
|
||||
* @param {number} constraints.minWidth - Minimum width
|
||||
* @param {number} constraints.minHeight - Minimum height
|
||||
* @returns {Promise<Object>} Validation result with dimensions
|
||||
*/
|
||||
export async function validateImageDimensions(buffer, constraints = {}) {
|
||||
// This is a placeholder - in production, use a library like 'sharp'
|
||||
// For now, we'll return a basic structure
|
||||
return {
|
||||
valid: true,
|
||||
width: null,
|
||||
height: null,
|
||||
message: 'Image dimension validation requires additional setup',
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Common file type presets
|
||||
*/
|
||||
export const FILE_TYPE_PRESETS = {
|
||||
IMAGES: ['.jpg', '.jpeg', '.png', '.gif', '.webp'],
|
||||
IMAGES_NO_GIF: ['.jpg', '.jpeg', '.png', '.webp'],
|
||||
DOCUMENTS: ['.pdf', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.txt', '.csv'],
|
||||
PDF_ONLY: ['.pdf'],
|
||||
VIDEOS: ['.mp4', '.avi', '.mov', '.wmv'],
|
||||
AUDIO: ['.mp3', '.wav'],
|
||||
ARCHIVES: ['.zip', '.rar', '.7z', '.tar', '.gz'],
|
||||
};
|
||||
|
||||
/**
|
||||
* Common file size limits (in bytes)
|
||||
*/
|
||||
export const FILE_SIZE_LIMITS = {
|
||||
AVATAR: 5 * 1024 * 1024, // 5 MB
|
||||
IMAGE: 10 * 1024 * 1024, // 10 MB
|
||||
DOCUMENT: 50 * 1024 * 1024, // 50 MB
|
||||
VIDEO: 500 * 1024 * 1024, // 500 MB
|
||||
LARGE_FILE: 1024 * 1024 * 1024, // 1 GB
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate upload file
|
||||
* @param {Object} options - Validation options
|
||||
* @param {string} options.filename - Filename
|
||||
* @param {number} options.size - File size in bytes
|
||||
* @param {string[]} options.allowedTypes - Allowed file types
|
||||
* @param {number} options.maxSize - Maximum file size
|
||||
* @returns {Object} Validation result
|
||||
*/
|
||||
export function validateUpload({ filename, size, allowedTypes, maxSize }) {
|
||||
const errors = [];
|
||||
|
||||
if (!filename) {
|
||||
errors.push('Filename is required');
|
||||
}
|
||||
|
||||
if (allowedTypes && !validateFileType(filename, allowedTypes)) {
|
||||
const typesList = allowedTypes.join(', ');
|
||||
errors.push(`File type not allowed. Allowed types: ${typesList}`);
|
||||
}
|
||||
|
||||
if (maxSize && !validateFileSize(size, maxSize)) {
|
||||
errors.push(`File size exceeds limit of ${formatFileSize(maxSize)}`);
|
||||
}
|
||||
|
||||
return {
|
||||
valid: errors.length === 0,
|
||||
errors,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Tick02Icon,
|
||||
Cancel01Icon,
|
||||
AlertCircleIcon,
|
||||
InformationCircleIcon,
|
||||
CancelCircleIcon
|
||||
} from '../../shared/Icons.js';
|
||||
|
||||
const Toast = ({
|
||||
id,
|
||||
type = 'info',
|
||||
message,
|
||||
title,
|
||||
duration = 5000,
|
||||
dismissible = true,
|
||||
isAutoRemoving = false,
|
||||
onDismiss
|
||||
}) => {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
const [isLeaving, setIsLeaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Trigger entrance animation
|
||||
const timer = setTimeout(() => setIsVisible(true), 10);
|
||||
return () => clearTimeout(timer);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// Trigger exit animation on auto-remove
|
||||
if (isAutoRemoving && !isLeaving) {
|
||||
setIsLeaving(true);
|
||||
}
|
||||
}, [isAutoRemoving, isLeaving]);
|
||||
|
||||
const handleDismiss = () => {
|
||||
if (!dismissible) return;
|
||||
|
||||
setIsLeaving(true);
|
||||
setTimeout(() => {
|
||||
onDismiss(id);
|
||||
}, 300); // Match animation duration
|
||||
};
|
||||
|
||||
const getIcon = () => {
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <Tick02Icon className="h-5 w-5 flex-shrink-0" />;
|
||||
case 'error':
|
||||
return <Cancel01Icon className="h-5 w-5 flex-shrink-0" />;
|
||||
case 'warning':
|
||||
return <AlertCircleIcon className="h-5 w-5 flex-shrink-0" />;
|
||||
case 'info':
|
||||
default:
|
||||
return <InformationCircleIcon className="h-5 w-5 flex-shrink-0" />;
|
||||
}
|
||||
};
|
||||
|
||||
const getStyles = () => {
|
||||
const base = 'backdrop-blur-sm shadow-lg transition-colors duration-200';
|
||||
const shadow = 'shadow-neutral-900/10 dark:shadow-black/20';
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return `${base} ${shadow} bg-green-50 border border-green-200 text-green-700 hover:bg-green-100/80 dark:bg-green-500/10 dark:border-green-500/20 dark:text-green-400 dark:hover:bg-green-500/15`;
|
||||
case 'error':
|
||||
return `${base} ${shadow} bg-red-50 border border-red-200 text-red-700 hover:bg-red-100/80 dark:bg-red-500/10 dark:border-red-500/20 dark:text-red-400 dark:hover:bg-red-500/15`;
|
||||
case 'warning':
|
||||
return `${base} ${shadow} bg-yellow-50 border border-yellow-200 text-yellow-800 hover:bg-yellow-100/80 dark:bg-yellow-500/10 dark:border-yellow-500/20 dark:text-yellow-400 dark:hover:bg-yellow-500/15`;
|
||||
case 'info':
|
||||
default:
|
||||
return `${base} ${shadow} bg-blue-50 border border-blue-200 text-blue-700 hover:bg-blue-100/80 dark:bg-blue-500/10 dark:border-blue-500/20 dark:text-blue-400 dark:hover:bg-blue-500/15`;
|
||||
}
|
||||
};
|
||||
|
||||
const getTitle = () => {
|
||||
if (title) return title;
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return 'Success';
|
||||
case 'error':
|
||||
return 'Error';
|
||||
case 'warning':
|
||||
return 'Warning';
|
||||
case 'info':
|
||||
default:
|
||||
return 'Information';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
${getStyles()}
|
||||
px-4 py-3 min-w-[300px] max-w-[400px]
|
||||
rounded-xl flex items-start gap-3
|
||||
transition-all duration-300 ease-in-out
|
||||
transform
|
||||
${isVisible && !isLeaving
|
||||
? 'translate-x-0 opacity-100 scale-100'
|
||||
: 'translate-x-8 opacity-0 scale-95'
|
||||
}
|
||||
${isLeaving ? 'translate-x-8 opacity-0 scale-95' : ''}
|
||||
`}
|
||||
>
|
||||
{/* Icon */}
|
||||
<div className="mt-0.5">
|
||||
{getIcon()}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-medium text-sm">{getTitle()}</p>
|
||||
<p className="text-xs opacity-90 dark:opacity-95 mt-0.5 break-words">{message}</p>
|
||||
</div>
|
||||
|
||||
{/* Dismiss button */}
|
||||
{dismissible && (
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="flex-shrink-0 mt-0.5 opacity-60 hover:opacity-100 dark:opacity-70 dark:hover:opacity-100 transition-opacity duration-200 text-current"
|
||||
aria-label="Dismiss notification"
|
||||
>
|
||||
<CancelCircleIcon className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Toast;
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { useToast } from './ToastContext';
|
||||
import Toast from './Toast';
|
||||
|
||||
const ToastContainer = ({ maxToasts = 5 }) => {
|
||||
const { toasts, removeToast } = useToast();
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const [toastHeights, setToastHeights] = useState({});
|
||||
const hoverTimeoutRef = useRef(null);
|
||||
const toastRefs = useRef({});
|
||||
|
||||
// Limit the number of visible toasts
|
||||
const visibleToasts = toasts.slice(-maxToasts);
|
||||
|
||||
// Measure toast heights
|
||||
useEffect(() => {
|
||||
const newHeights = {};
|
||||
visibleToasts.forEach((toast) => {
|
||||
const element = toastRefs.current[toast.id];
|
||||
if (element) {
|
||||
newHeights[toast.id] = element.offsetHeight;
|
||||
}
|
||||
});
|
||||
|
||||
// Update only if heights have changed
|
||||
const hasChanged = visibleToasts.some(toast =>
|
||||
newHeights[toast.id] !== toastHeights[toast.id]
|
||||
);
|
||||
|
||||
if (hasChanged) {
|
||||
setToastHeights(newHeights);
|
||||
}
|
||||
}, [visibleToasts, toastHeights]);
|
||||
|
||||
const handleMouseEnter = (isLastToast) => {
|
||||
if (hoverTimeoutRef.current) {
|
||||
clearTimeout(hoverTimeoutRef.current);
|
||||
hoverTimeoutRef.current = null;
|
||||
}
|
||||
|
||||
// Trigger hover only on most recent toast OR if already in hover mode
|
||||
if (isLastToast || isHovered) {
|
||||
setIsHovered(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMouseLeave = () => {
|
||||
// Delay before closing to avoid flickering
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
setIsHovered(false);
|
||||
}, 150);
|
||||
};
|
||||
|
||||
// Calculate position of each toast based on actual heights
|
||||
const calculatePosition = (index) => {
|
||||
const isRecent = index === visibleToasts.length - 1;
|
||||
const distanceFromRecent = visibleToasts.length - 1 - index;
|
||||
|
||||
if (isRecent) {
|
||||
return 0; // Most recent stays at 0
|
||||
}
|
||||
|
||||
if (isHovered) {
|
||||
// In hover mode: add heights of more recent toasts + margin
|
||||
let totalOffset = 0;
|
||||
for (let i = index + 1; i < visibleToasts.length; i++) {
|
||||
const toastId = visibleToasts[i].id;
|
||||
const height = toastHeights[toastId] || 60; // default height
|
||||
totalOffset += height + 10; // height + 10px margin
|
||||
}
|
||||
return -totalOffset;
|
||||
} else {
|
||||
// In stack mode: reduced spacing based on height
|
||||
const recentToastHeight = toastHeights[visibleToasts[visibleToasts.length - 1].id] || 60;
|
||||
return -(distanceFromRecent * Math.min(recentToastHeight * 0.15, 12)); // Maximum 12px per level
|
||||
}
|
||||
};
|
||||
|
||||
if (visibleToasts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
fixed bottom-4 right-4
|
||||
z-50 pointer-events-none
|
||||
transition-all duration-300 ease-in-out
|
||||
`}
|
||||
>
|
||||
<div className="relative">
|
||||
{visibleToasts.map((toast, index) => {
|
||||
// The last toast (index length-1) is always the most recent
|
||||
const isRecent = index === visibleToasts.length - 1;
|
||||
const distanceFromRecent = visibleToasts.length - 1 - index;
|
||||
|
||||
// Calculations for both modes
|
||||
const scale = isHovered ? 1 : (isRecent ? 1 : Math.max(0.7, 1 - (distanceFromRecent * 0.08)));
|
||||
const translateY = calculatePosition(index);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={toast.id}
|
||||
ref={el => toastRefs.current[toast.id] = el}
|
||||
className={`
|
||||
absolute bottom-0 right-0
|
||||
pointer-events-auto
|
||||
transition-all duration-500 ease-out
|
||||
`}
|
||||
style={{
|
||||
transform: `scale(${scale}) translateY(${translateY}px)`,
|
||||
zIndex: isRecent ? 10 : (10 - distanceFromRecent),
|
||||
}}
|
||||
onMouseEnter={() => handleMouseEnter(isRecent)}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<Toast
|
||||
{...toast}
|
||||
onDismiss={removeToast}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastContainer;
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback } from 'react';
|
||||
|
||||
const ToastContext = createContext();
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within a ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export const ToastProvider = ({ children }) => {
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const addToast = useCallback((toast) => {
|
||||
const id = Date.now() + Math.random();
|
||||
const newToast = {
|
||||
id,
|
||||
type: 'info',
|
||||
duration: 5000,
|
||||
dismissible: true,
|
||||
isAutoRemoving: false,
|
||||
...toast,
|
||||
};
|
||||
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
|
||||
if (newToast.duration > 0) {
|
||||
setTimeout(() => {
|
||||
// First mark the toast for auto-removal
|
||||
setToasts(prev => prev.map(t =>
|
||||
t.id === id ? { ...t, isAutoRemoving: true } : t
|
||||
));
|
||||
|
||||
// Then remove it after animation (300ms)
|
||||
setTimeout(() => {
|
||||
removeToast(id);
|
||||
}, 300);
|
||||
}, newToast.duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearAllToasts = useCallback(() => {
|
||||
setToasts([]);
|
||||
}, []);
|
||||
|
||||
// Convenience methods for different toast types
|
||||
const success = useCallback((message, options = {}) => {
|
||||
return addToast({
|
||||
type: 'success',
|
||||
message,
|
||||
...options,
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const error = useCallback((message, options = {}) => {
|
||||
return addToast({
|
||||
type: 'error',
|
||||
message,
|
||||
duration: 7000, // Longer duration for errors
|
||||
...options,
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const warning = useCallback((message, options = {}) => {
|
||||
return addToast({
|
||||
type: 'warning',
|
||||
message,
|
||||
duration: 6000,
|
||||
...options,
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const info = useCallback((message, options = {}) => {
|
||||
return addToast({
|
||||
type: 'info',
|
||||
message,
|
||||
...options,
|
||||
});
|
||||
}, [addToast]);
|
||||
|
||||
const value = {
|
||||
toasts,
|
||||
addToast,
|
||||
removeToast,
|
||||
clearAllToasts,
|
||||
success,
|
||||
error,
|
||||
warning,
|
||||
info,
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToastContext;
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
'use client';
|
||||
|
||||
export { default as Toast } from './Toast';
|
||||
export { ToastProvider, useToast } from './ToastContext';
|
||||
export { default as ToastContainer } from './ToastContainer';
|
||||
|
||||
@@ -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';
|
||||
@@ -0,0 +1,48 @@
|
||||
// Export database utilities as namespace
|
||||
export * as db from "./core/database/index.js";
|
||||
|
||||
// Export authentication module as namespace
|
||||
export * as auth from "./features/auth/index.js";
|
||||
|
||||
// Export admin module as namespace
|
||||
export * as admin from "./features/admin/index.js";
|
||||
|
||||
// Export API module as namespace
|
||||
export * as api from "./core/api/index.js";
|
||||
|
||||
// Export email utilities as namespace
|
||||
export * as email from "./core/email/index.js";
|
||||
|
||||
// Export cron utilities as namespace
|
||||
export * as cron from "./core/cron/index.js";
|
||||
|
||||
// Export payment utilities as namespace
|
||||
export * as payments from "./core/payments/index.js";
|
||||
export * as stripe from "./core/payments/stripe.js";
|
||||
|
||||
// Export PDF utilities as namespace
|
||||
export * as pdf from "./core/pdf/index.js";
|
||||
|
||||
// Export module system as namespace
|
||||
export * as moduleSystem from "./core/modules/index.js";
|
||||
|
||||
// NOTE: Toast components are CLIENT ONLY - import from '@hykocx/zen/toast'
|
||||
// Do not export here to avoid mixing client/server boundaries
|
||||
|
||||
// Export modules system as namespace (legacy, includes invoice module)
|
||||
export * as modules from "./modules/index.js";
|
||||
|
||||
// Export public pages (Zen routes)
|
||||
export { PublicPagesLayout, PublicPagesClient } from "./modules/pages.js";
|
||||
|
||||
// Export app configuration utilities
|
||||
export { getAppName, getAppConfig, getSessionCookieName, getModulesConfig, getPublicBaseUrl } from "./shared/lib/appConfig.js";
|
||||
|
||||
// Export initialization utilities
|
||||
export { initializeZen, resetZenInitialization } from "./shared/lib/init.js";
|
||||
|
||||
// Export date utilities
|
||||
export * as dates from "./shared/lib/dates.js";
|
||||
|
||||
// Export currency utilities
|
||||
export * as currency from "./shared/utils/currency.js";
|
||||
@@ -0,0 +1,54 @@
|
||||
'use client';
|
||||
|
||||
import React, { Suspense } from 'react';
|
||||
import { getModulePublicPageLoader } from './modules.pages.js';
|
||||
import { Loading } from '../shared/components';
|
||||
|
||||
/**
|
||||
* Not Found Message Component
|
||||
*/
|
||||
function NotFoundMessage() {
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4">
|
||||
<div className="bg-neutral-900 border border-neutral-800 rounded-lg p-8 max-w-md w-full text-center">
|
||||
<h1 className="text-2xl font-semibold text-white mb-4">Page non trouvée</h1>
|
||||
<p className="text-neutral-400">
|
||||
La page que vous recherchez n'existe pas.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Public Module Pages Router
|
||||
* Handles routing for all public module pages dynamically
|
||||
*
|
||||
* Uses the client-side page loader from modules.pages.js instead of
|
||||
* the server-side registry (which is empty on the client).
|
||||
*/
|
||||
const PublicPagesClient = ({
|
||||
path = [],
|
||||
moduleActions = {},
|
||||
...additionalProps
|
||||
}) => {
|
||||
const moduleName = path[0];
|
||||
const PublicPage = getModulePublicPageLoader(moduleName);
|
||||
|
||||
if (PublicPage) {
|
||||
return (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<PublicPage
|
||||
path={path}
|
||||
{...moduleActions}
|
||||
{...additionalProps}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
// Module not found or no public pages
|
||||
return <NotFoundMessage />;
|
||||
};
|
||||
|
||||
export default PublicPagesClient;
|
||||
@@ -0,0 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Public Module Pages Layout
|
||||
* Simple layout for public module pages like invoice payment
|
||||
*/
|
||||
const PublicPagesLayout = ({ children }) => {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default PublicPagesLayout;
|
||||
@@ -0,0 +1,284 @@
|
||||
# Module System
|
||||
|
||||
Modules are self-contained features that can be enabled/disabled via environment variables.
|
||||
|
||||
## File Structure
|
||||
|
||||
```
|
||||
src/modules/your-module/
|
||||
├── module.config.js # Required — navigation, pages, widgets
|
||||
├── db.js # Database schema (createTables / dropTables)
|
||||
├── crud.js # CRUD operations
|
||||
├── actions.js # Server actions (for public pages)
|
||||
├── metadata.js # SEO metadata generators
|
||||
├── api.js # API route handlers
|
||||
├── cron.config.js # Scheduled tasks
|
||||
├── index.js # Public API re-exports
|
||||
├── .env.example # Environment variable documentation
|
||||
├── admin/ # Admin pages (lazy-loaded)
|
||||
│ └── index.js # Re-exports admin components
|
||||
├── pages/ # Public pages (lazy-loaded)
|
||||
│ └── index.js
|
||||
├── dashboard/ # Dashboard widgets
|
||||
│ ├── statsActions.js
|
||||
│ └── Widget.js
|
||||
└── sub-feature/ # Optional sub-modules (e.g. items/, categories/)
|
||||
├── db.js
|
||||
├── crud.js
|
||||
└── admin/
|
||||
```
|
||||
|
||||
> Not all files are required. Only create what the module actually needs.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Create `module.config.js`
|
||||
|
||||
```javascript
|
||||
import { lazy } from 'react';
|
||||
|
||||
export default {
|
||||
// Module identity
|
||||
name: 'your-module',
|
||||
displayName: 'Your Module',
|
||||
version: '1.0.0',
|
||||
description: 'Description of your module',
|
||||
|
||||
// Other modules this one depends on (must be enabled too)
|
||||
dependencies: ['clients'],
|
||||
|
||||
// Environment variables this module uses (documentation only)
|
||||
envVars: [
|
||||
'YOUR_MODULE_API_KEY',
|
||||
],
|
||||
|
||||
// Admin navigation — single section object or array of section objects
|
||||
navigation: {
|
||||
id: 'your-module',
|
||||
title: 'Your Module',
|
||||
icon: 'SomeIcon', // String icon name from shared/Icons.js
|
||||
items: [
|
||||
{ name: 'Items', href: '/admin/your-module/items', icon: 'PackageIcon' },
|
||||
{ name: 'New', href: '/admin/your-module/items/new', icon: 'PlusSignIcon' },
|
||||
],
|
||||
},
|
||||
|
||||
// Admin pages — path → lazy component
|
||||
adminPages: {
|
||||
'/admin/your-module/items': lazy(() => import('./admin/ItemsListPage.js')),
|
||||
'/admin/your-module/items/new': lazy(() => import('./admin/ItemCreatePage.js')),
|
||||
'/admin/your-module/items/edit': lazy(() => import('./admin/ItemEditPage.js')),
|
||||
},
|
||||
|
||||
// (Optional) Custom resolver for dynamic paths not known at build time.
|
||||
// Called before the adminPages map. Return the lazy component or null.
|
||||
pageResolver(path) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
// example: /admin/your-module/{type}/list
|
||||
if (parts[2] === 'list') return lazy(() => import('./admin/ItemsListPage.js'));
|
||||
return null;
|
||||
},
|
||||
|
||||
// Public pages — keyed by 'default' (one component handles all public routes)
|
||||
publicPages: {
|
||||
default: lazy(() => import('./pages/YourModulePublicPages.js')),
|
||||
},
|
||||
|
||||
// Public route patterns for SEO/route matching (relative to /zen/your-module/)
|
||||
publicRoutes: [
|
||||
{ pattern: ':id', description: 'View item' },
|
||||
{ pattern: ':id/pdf', description: 'PDF viewer' },
|
||||
],
|
||||
|
||||
// Dashboard widgets (lazy-loaded, rendered on the admin dashboard)
|
||||
dashboardWidgets: [
|
||||
lazy(() => import('./dashboard/Widget.js')),
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
### Navigation as multiple sections
|
||||
|
||||
When a module provides several distinct sections (like the `posts` module with one section per post type), set `navigation` to an array:
|
||||
|
||||
```javascript
|
||||
navigation: [
|
||||
{ id: 'your-module-foo', title: 'Foo', icon: 'Book02Icon', items: [...] },
|
||||
{ id: 'your-module-bar', title: 'Bar', icon: 'Layers01Icon', items: [...] },
|
||||
],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Create `db.js`
|
||||
|
||||
Every module that uses a database must expose a `createTables` function:
|
||||
|
||||
```javascript
|
||||
import { query } from '@hykocx/zen/database';
|
||||
|
||||
export async function createTables() {
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
|
||||
const exists = await query(`
|
||||
SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = $1
|
||||
)`, ['zen_your_module']);
|
||||
|
||||
if (!exists.rows[0].exists) {
|
||||
await query(`
|
||||
CREATE TABLE zen_your_module (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
created.push('zen_your_module');
|
||||
} else {
|
||||
skipped.push('zen_your_module');
|
||||
}
|
||||
|
||||
return { created, skipped };
|
||||
}
|
||||
|
||||
export async function dropTables() {
|
||||
await query(`DROP TABLE IF EXISTS zen_your_module CASCADE`);
|
||||
}
|
||||
```
|
||||
|
||||
> **Never create migrations.** Instead, provide the SQL to the user so they can run it manually.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Create `.env.example`
|
||||
|
||||
Document every environment variable the module reads:
|
||||
|
||||
```bash
|
||||
#################################
|
||||
# MODULE YOUR-MODULE
|
||||
ZEN_MODULE_YOUR_MODULE=false
|
||||
|
||||
ZEN_MODULE_YOUR_MODULE_API_KEY=
|
||||
ZEN_MODULE_YOUR_MODULE_SOME_OPTION=default_value
|
||||
#################################
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Create `cron.config.js` (optional)
|
||||
|
||||
Only needed if the module requires scheduled tasks:
|
||||
|
||||
```javascript
|
||||
import { doSomething } from './reminders.js';
|
||||
|
||||
export default {
|
||||
jobs: [
|
||||
{
|
||||
name: 'your-module-task',
|
||||
description: 'Description of what this job does',
|
||||
schedule: '*/5 * * * *', // cron expression
|
||||
handler: doSomething,
|
||||
timezone: process.env.ZEN_TIMEZONE || 'America/Toronto',
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Register the module in 5 files
|
||||
|
||||
### `modules/modules.registry.js` — add the module name
|
||||
|
||||
```javascript
|
||||
export const AVAILABLE_MODULES = [
|
||||
'clients',
|
||||
'invoice',
|
||||
'your-module',
|
||||
];
|
||||
```
|
||||
|
||||
### `modules/modules.pages.js` — import the config
|
||||
|
||||
```javascript
|
||||
'use client';
|
||||
|
||||
import yourModuleConfig from './your-module/module.config.js';
|
||||
|
||||
const MODULE_CONFIGS = {
|
||||
// ...existing modules...
|
||||
'your-module': yourModuleConfig,
|
||||
};
|
||||
```
|
||||
|
||||
### `modules/modules.actions.js` — import server actions (if public pages or dashboard widgets)
|
||||
|
||||
```javascript
|
||||
import { yourPublicAction } from './your-module/actions.js';
|
||||
import { getYourModuleDashboardStats } from './your-module/dashboard/statsActions.js';
|
||||
|
||||
export const MODULE_ACTIONS = {
|
||||
// ...existing modules...
|
||||
'your-module': { yourPublicAction },
|
||||
};
|
||||
|
||||
export const MODULE_DASHBOARD_ACTIONS = {
|
||||
// ...existing modules...
|
||||
'your-module': getYourModuleDashboardStats,
|
||||
};
|
||||
```
|
||||
|
||||
### `modules/modules.metadata.js` — import metadata generators (if SEO needed)
|
||||
|
||||
```javascript
|
||||
import * as yourModuleMetadata from './your-module/metadata.js';
|
||||
|
||||
export const MODULE_METADATA = {
|
||||
// ...existing modules...
|
||||
'your-module': yourModuleMetadata,
|
||||
};
|
||||
```
|
||||
|
||||
### `modules/init.js` — register the database initializer
|
||||
|
||||
```javascript
|
||||
import { createTables as createYourModuleTables } from './your-module/db.js';
|
||||
|
||||
const MODULE_DB_INITIALIZERS = {
|
||||
// ...existing modules...
|
||||
'your-module': createYourModuleTables,
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — Enable the module
|
||||
|
||||
```bash
|
||||
ZEN_MODULE_YOUR_MODULE=true
|
||||
```
|
||||
|
||||
The environment variable is derived from the module name: `ZEN_MODULE_` + module name uppercased (hyphens become underscores).
|
||||
|
||||
---
|
||||
|
||||
## Sub-modules
|
||||
|
||||
For complex modules, group related features into sub-directories. Each sub-module follows the same pattern (`db.js`, `crud.js`, `admin/`). The parent `module.config.js` registers all sub-module admin pages, and the parent `index.js` re-exports everything publicly.
|
||||
|
||||
See `src/modules/invoice/` for a complete example with `items/`, `categories/`, `transactions/`, and `recurrences/` sub-modules.
|
||||
|
||||
---
|
||||
|
||||
## Reference implementations
|
||||
|
||||
| Module | Features demonstrated |
|
||||
|--------|-----------------------|
|
||||
| `src/modules/invoice/` | Sub-modules, public pages, cron jobs, Stripe, email, PDF, dashboard widgets, metadata |
|
||||
| `src/modules/posts/` | Dynamic config from env vars, `pageResolver`, multiple navigation sections |
|
||||
| `src/modules/clients/` | Simple module, dependencies, no public pages |
|
||||
@@ -0,0 +1,4 @@
|
||||
#################################
|
||||
# MODULE CLIENTS
|
||||
ZEN_MODULE_CLIENTS=false
|
||||
#################################
|
||||
@@ -0,0 +1,37 @@
|
||||
# Clients Module Installation
|
||||
|
||||
## 1. Configure Environment Variables
|
||||
|
||||
Copy all variables from [`.env.example`](.env.example) and add them to your `.env` file.
|
||||
|
||||
## 2. Activate the Module
|
||||
|
||||
In your `.env` file, set:
|
||||
|
||||
```env
|
||||
ZEN_MODULE_CLIENTS=true
|
||||
```
|
||||
|
||||
## 3. Database Setup
|
||||
|
||||
Run the database initialization to create the required tables:
|
||||
|
||||
```bash
|
||||
npx zen-db init
|
||||
```
|
||||
|
||||
This will create the following table:
|
||||
- `zen_clients` - Stores client information
|
||||
|
||||
## 4. Features
|
||||
|
||||
### Client Management
|
||||
- Create, edit, and delete clients
|
||||
- Store contact information (name, email, phone, address)
|
||||
- Link clients to user accounts (optional)
|
||||
- Unique client numbers (auto-generated)
|
||||
|
||||
### Used By Other Modules
|
||||
The clients module is a dependency for:
|
||||
- **Quote Module**: Assign quotes to clients
|
||||
- **Invoice Module**: Assign invoices to clients
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button } from '../../../shared/components';
|
||||
import ClientForm from './ClientForm.js';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
|
||||
/**
|
||||
* Client Create Page Component
|
||||
* Page for creating a new client
|
||||
*/
|
||||
const ClientCreatePage = ({ user }) => {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const handleSubmit = async (formData) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
const response = await fetch('/zen/api/admin/clients', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ client: formData })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Client créé avec succès');
|
||||
router.push('/admin/clients/list');
|
||||
} else {
|
||||
toast.error(data.message || 'Échec de la création du client');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error creating client:', error);
|
||||
toast.error('Échec de la création du client');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">Créer un client</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">Remplissez les détails pour créer un nouveau client</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push('/admin/clients/list')}
|
||||
>
|
||||
← Retour aux clients
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<ClientForm
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => router.push('/admin/clients/list')}
|
||||
isEdit={false}
|
||||
saving={saving}
|
||||
users={[]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientCreatePage;
|
||||
@@ -0,0 +1,139 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Button, Card, Loading } from '../../../shared/components';
|
||||
import ClientForm from './ClientForm.js';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
|
||||
/**
|
||||
* Client Edit Page Component
|
||||
* Page for editing an existing client
|
||||
*/
|
||||
const ClientEditPage = ({ clientId, user }) => {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const [client, setClient] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
loadClient();
|
||||
}, [clientId]);
|
||||
|
||||
const loadClient = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const response = await fetch(`/zen/api/admin/clients?id=${clientId}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setClient(data.client);
|
||||
} else {
|
||||
toast.error(data.error || 'Échec du chargement du client');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading client:', error);
|
||||
toast.error('Échec du chargement du client');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (formData) => {
|
||||
try {
|
||||
setSaving(true);
|
||||
|
||||
const response = await fetch(`/zen/api/admin/clients`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ id: clientId, client: formData })
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Client mis à jour avec succès');
|
||||
router.push('/admin/clients/list');
|
||||
} else {
|
||||
toast.error(data.message || 'Échec de la mise à jour du client');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating client:', error);
|
||||
toast.error('Échec de la mise à jour du client');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="min-h-64 flex items-center justify-center">
|
||||
<Loading />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!client) {
|
||||
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 le client</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">Client non trouvé</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push('/admin/clients/list')}
|
||||
>
|
||||
← Retour aux clients
|
||||
</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">Client non trouvé</p>
|
||||
<p className="text-sm mt-1">Le client 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">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg sm:text-xl font-semibold text-neutral-900 dark:text-white">Modifier le client</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">Client : {client.first_name} {client.last_name}</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => router.push('/admin/clients/list')}
|
||||
>
|
||||
← Retour aux clients
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<ClientForm
|
||||
initialData={client}
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={() => router.push('/admin/clients/list')}
|
||||
isEdit={true}
|
||||
saving={saving}
|
||||
users={[]}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientEditPage;
|
||||
@@ -0,0 +1,245 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Button, Card, Input, Select, Textarea } from '../../../shared/components';
|
||||
|
||||
/**
|
||||
* Client Form Component
|
||||
* Form for creating and editing clients
|
||||
*/
|
||||
const ClientForm = ({
|
||||
initialData = null,
|
||||
users = [],
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isEdit = false,
|
||||
saving = false
|
||||
}) => {
|
||||
const [formData, setFormData] = useState({
|
||||
user_id: initialData?.user_id || '',
|
||||
company_name: initialData?.company_name || '',
|
||||
first_name: initialData?.first_name || '',
|
||||
last_name: initialData?.last_name || '',
|
||||
email: initialData?.email || '',
|
||||
phone: initialData?.phone || '',
|
||||
address: initialData?.address || '',
|
||||
city: initialData?.city || '',
|
||||
province: initialData?.province || '',
|
||||
postal_code: initialData?.postal_code || '',
|
||||
country: initialData?.country || 'Canada',
|
||||
notes: initialData?.notes || '',
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState({});
|
||||
|
||||
const handleInputChange = (field, value) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[field]: value
|
||||
}));
|
||||
|
||||
if (errors[field]) {
|
||||
setErrors(prev => ({
|
||||
...prev,
|
||||
[field]: null
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = () => {
|
||||
const newErrors = {};
|
||||
|
||||
if (!formData.first_name.trim()) {
|
||||
newErrors.first_name = "Le prénom est requis";
|
||||
}
|
||||
|
||||
if (!formData.last_name.trim()) {
|
||||
newErrors.last_name = "Le nom de famille est requis";
|
||||
}
|
||||
|
||||
if (!formData.email.trim()) {
|
||||
newErrors.email = "L'email est requis";
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) {
|
||||
newErrors.email = "Format d'email invalide";
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (onSubmit) {
|
||||
onSubmit(formData);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Basic Information */}
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Informations de base</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<Input
|
||||
label="Nom de la société"
|
||||
value={formData.company_name}
|
||||
onChange={(value) => handleInputChange('company_name', value)}
|
||||
placeholder="Entrez le nom de la société..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Prénom *"
|
||||
value={formData.first_name}
|
||||
onChange={(value) => handleInputChange('first_name', value)}
|
||||
placeholder="Entrez le prénom..."
|
||||
error={errors.first_name}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Nom de famille *"
|
||||
value={formData.last_name}
|
||||
onChange={(value) => handleInputChange('last_name', value)}
|
||||
placeholder="Entrez le nom de famille..."
|
||||
error={errors.last_name}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Courriel *"
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(value) => handleInputChange('email', value)}
|
||||
placeholder="Entrez le courriel..."
|
||||
error={errors.email}
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Téléphone"
|
||||
type="tel"
|
||||
value={formData.phone}
|
||||
onChange={(value) => handleInputChange('phone', value)}
|
||||
placeholder="Entrez le numéro de téléphone..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Address */}
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Adresse</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="md:col-span-2">
|
||||
<Input
|
||||
label="Adresse"
|
||||
value={formData.address}
|
||||
onChange={(value) => handleInputChange('address', value)}
|
||||
placeholder="Entrez l'adresse..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Input
|
||||
label="Ville"
|
||||
value={formData.city}
|
||||
onChange={(value) => handleInputChange('city', value)}
|
||||
placeholder="Entrez la ville..."
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Province / État"
|
||||
value={formData.province}
|
||||
onChange={(value) => handleInputChange('province', value)}
|
||||
placeholder="Entrez la province ou l'état..."
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Code postal"
|
||||
value={formData.postal_code}
|
||||
onChange={(value) => handleInputChange('postal_code', value)}
|
||||
placeholder="Entrez le code postal..."
|
||||
/>
|
||||
|
||||
<Input
|
||||
label="Pays"
|
||||
value={formData.country}
|
||||
onChange={(value) => handleInputChange('country', value)}
|
||||
placeholder="Entrez le pays..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* User Link */}
|
||||
{users.length > 0 && (
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Lien compte utilisateur</h2>
|
||||
<p className="text-sm text-neutral-400">
|
||||
Vous pouvez associer ce client à un compte utilisateur sur la plateforme
|
||||
</p>
|
||||
|
||||
<Select
|
||||
label="Compte utilisateur"
|
||||
value={formData.user_id || ''}
|
||||
onChange={(value) => handleInputChange('user_id', value)}
|
||||
options={[
|
||||
{ value: '', label: 'Aucun compte utilisateur associé' },
|
||||
...users.map(user => ({
|
||||
value: user.id,
|
||||
label: `${user.name} (${user.email})`
|
||||
}))
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Notes */}
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">Notes</h2>
|
||||
<Textarea
|
||||
value={formData.notes}
|
||||
onChange={(value) => handleInputChange('notes', value)}
|
||||
rows={4}
|
||||
placeholder="Notes supplémentaires sur ce client..."
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center justify-end gap-3">
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onCancel}
|
||||
disabled={saving}
|
||||
>
|
||||
Annuler
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
variant="success"
|
||||
loading={saving}
|
||||
disabled={saving}
|
||||
>
|
||||
{saving
|
||||
? (isEdit ? 'Mise à jour...' : 'Création...')
|
||||
: (isEdit ? 'Mettre à jour le client' : 'Créer le client')
|
||||
}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientForm;
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { PlusSignCircleIcon, PencilEdit01Icon, Delete02Icon } from '../../../shared/Icons.js';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
Card,
|
||||
Pagination
|
||||
} from '../../../shared/components';
|
||||
import { useToast } from '@hykocx/zen/toast';
|
||||
|
||||
/**
|
||||
* Clients List Page Component
|
||||
* Displays list of clients with pagination and sorting
|
||||
*/
|
||||
const ClientsListPage = ({ user }) => {
|
||||
const router = useRouter();
|
||||
const toast = useToast();
|
||||
const [clients, setClients] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
|
||||
// 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: 'client_number',
|
||||
label: 'N° client',
|
||||
sortable: true,
|
||||
render: (client) => (
|
||||
<div>
|
||||
<div className="text-sm font-mono font-semibold text-neutral-900 dark:text-white">{client.client_number}</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-gray-400">
|
||||
ID: {client.id}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
skeleton: {
|
||||
height: 'h-4',
|
||||
width: '30%',
|
||||
secondary: { height: 'h-3', width: '25%' }
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Nom / Société',
|
||||
sortable: true,
|
||||
render: (client) => (
|
||||
<div>
|
||||
<div className="text-sm text-neutral-900 dark:text-white font-medium">
|
||||
{client.company_name || `${client.first_name} ${client.last_name}`}
|
||||
</div>
|
||||
{client.company_name && (
|
||||
<div className="text-xs text-neutral-500 dark:text-gray-400">
|
||||
{client.first_name} {client.last_name}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
skeleton: { height: 'h-4', width: '60%' }
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Contact',
|
||||
sortable: true,
|
||||
render: (client) => (
|
||||
<div>
|
||||
<div className="text-sm text-neutral-900 dark:text-white">{client.email}</div>
|
||||
{client.phone && (
|
||||
<div className="text-xs text-neutral-500 dark:text-gray-400">{client.phone}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
skeleton: { height: 'h-4', width: '50%' }
|
||||
},
|
||||
{
|
||||
key: 'location',
|
||||
label: 'Lieu',
|
||||
sortable: false,
|
||||
render: (client) => (
|
||||
<div className="text-sm text-neutral-600 dark:text-gray-300">
|
||||
{client.city && client.province ? (
|
||||
`${client.city}, ${client.province}`
|
||||
) : (
|
||||
<span className="text-neutral-400 dark:text-gray-500">-</span>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
skeleton: { height: 'h-4', width: '40%' }
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
label: 'Créé',
|
||||
sortable: true,
|
||||
render: (client) => (
|
||||
<div className="text-sm text-neutral-600 dark:text-gray-300">
|
||||
{new Date(client.created_at).toLocaleDateString('fr-FR')}
|
||||
</div>
|
||||
),
|
||||
skeleton: { height: 'h-4', width: '35%' }
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: 'Actions',
|
||||
render: (client) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEditClient(client)}
|
||||
disabled={deleting}
|
||||
icon={<PencilEdit01Icon className="w-4 h-4" />}
|
||||
className="p-2"
|
||||
/>
|
||||
<Button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteClient(client)}
|
||||
disabled={deleting}
|
||||
icon={<Delete02Icon className="w-4 h-4" />}
|
||||
className="p-2"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
skeleton: { height: 'h-8', width: '80px' }
|
||||
}
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
loadClients();
|
||||
}, [sortBy, sortOrder, pagination.page, pagination.limit]);
|
||||
|
||||
const loadClients = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
sortBy,
|
||||
sortOrder
|
||||
});
|
||||
|
||||
const response = await fetch(`/zen/api/admin/clients?${searchParams}`, {
|
||||
credentials: 'include'
|
||||
});
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
setClients(data.clients || []);
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
total: data.total || 0,
|
||||
totalPages: data.totalPages || 0,
|
||||
page: data.page || 1
|
||||
}));
|
||||
} else {
|
||||
toast.error(data.error || 'Échec du chargement des clients');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading clients:', error);
|
||||
toast.error('Échec du chargement des clients');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageChange = (newPage) => {
|
||||
setPagination(prev => ({ ...prev, page: newPage }));
|
||||
};
|
||||
|
||||
const handleLimitChange = (newLimit) => {
|
||||
setPagination(prev => ({
|
||||
...prev,
|
||||
limit: newLimit,
|
||||
page: 1
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSort = (newSortBy) => {
|
||||
const newSortOrder = sortBy === newSortBy && sortOrder === 'asc' ? 'desc' : 'asc';
|
||||
setSortBy(newSortBy);
|
||||
setSortOrder(newSortOrder);
|
||||
};
|
||||
|
||||
const handleEditClient = (client) => {
|
||||
router.push(`/admin/clients/edit/${client.id}`);
|
||||
};
|
||||
|
||||
const handleDeleteClient = async (client) => {
|
||||
const clientName = client.company_name || `${client.first_name} ${client.last_name}`;
|
||||
if (!confirm(`Êtes-vous sûr de vouloir supprimer le client « ${clientName} » ?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setDeleting(true);
|
||||
|
||||
const response = await fetch(`/zen/api/admin/clients?id=${client.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
toast.success('Client supprimé avec succès');
|
||||
loadClients();
|
||||
} else {
|
||||
toast.error(data.error || 'Échec de la suppression du client');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error deleting client:', error);
|
||||
toast.error('Échec de la suppression du client');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
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">Clients</h1>
|
||||
<p className="mt-1 text-xs text-neutral-400">Gérez vos clients</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => router.push('/admin/clients/new')}
|
||||
icon={<PlusSignCircleIcon className="w-4 h-4" />}
|
||||
>
|
||||
Créer un client
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Clients Table */}
|
||||
<Card variant="default" padding="none">
|
||||
<Table
|
||||
columns={columns}
|
||||
data={clients}
|
||||
loading={loading}
|
||||
sortBy={sortBy}
|
||||
sortOrder={sortOrder}
|
||||
onSort={handleSort}
|
||||
emptyMessage="Aucun client trouvé"
|
||||
emptyDescription="Créez votre premier client pour commencer"
|
||||
/>
|
||||
|
||||
<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>
|
||||
);
|
||||
};
|
||||
|
||||
export default ClientsListPage;
|
||||
@@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Clients Admin Components
|
||||
*/
|
||||
|
||||
export { default as ClientsListPage } from './ClientsListPage.js';
|
||||
export { default as ClientCreatePage } from './ClientCreatePage.js';
|
||||
export { default as ClientEditPage } from './ClientEditPage.js';
|
||||
export { default as ClientForm } from './ClientForm.js';
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* Clients Module API Routes
|
||||
* All API endpoints for the clients module
|
||||
*/
|
||||
|
||||
// Client CRUD
|
||||
import {
|
||||
createClient,
|
||||
getClientById,
|
||||
getClients,
|
||||
updateClient,
|
||||
deleteClient,
|
||||
} from './crud.js';
|
||||
|
||||
// ============================================================================
|
||||
// Client Handlers
|
||||
// ============================================================================
|
||||
|
||||
async function handleGetClients(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get('id');
|
||||
|
||||
if (id) {
|
||||
const client = await getClientById(parseInt(id));
|
||||
if (!client) {
|
||||
return { success: false, error: 'Client not found' };
|
||||
}
|
||||
return { success: true, client };
|
||||
}
|
||||
|
||||
const page = parseInt(url.searchParams.get('page')) || 1;
|
||||
const limit = parseInt(url.searchParams.get('limit')) || 20;
|
||||
const search = url.searchParams.get('search') || '';
|
||||
const sortBy = url.searchParams.get('sortBy') || 'created_at';
|
||||
const sortOrder = url.searchParams.get('sortOrder') || 'DESC';
|
||||
|
||||
const result = await getClients({ page, limit, search, sortBy, sortOrder });
|
||||
|
||||
return {
|
||||
success: true,
|
||||
clients: result.clients,
|
||||
total: result.pagination.total,
|
||||
totalPages: result.pagination.totalPages,
|
||||
page: result.pagination.page,
|
||||
limit: result.pagination.limit
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error handling GET clients:', error);
|
||||
return { success: false, error: 'Failed to fetch clients' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleCreateClient(request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
// Accept both { client: {...} } and direct {...} format
|
||||
const clientData = body.client || body;
|
||||
|
||||
if (!clientData || Object.keys(clientData).length === 0) {
|
||||
return { success: false, error: 'Client data is required' };
|
||||
}
|
||||
|
||||
const client = await createClient(clientData);
|
||||
return { success: true, client, message: 'Client created successfully' };
|
||||
} catch (error) {
|
||||
console.error('Error creating client:', error);
|
||||
return { success: false, error: error.message || 'Failed to create client' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleUpdateClient(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const body = await request.json();
|
||||
// Accept ID from query param or body
|
||||
const id = url.searchParams.get('id') || body.id;
|
||||
// Accept both { client: {...} } and direct {...} format
|
||||
const updates = body.client || (({ id, ...rest }) => rest)(body);
|
||||
|
||||
if (!id) {
|
||||
return { success: false, error: 'Client ID is required' };
|
||||
}
|
||||
|
||||
if (!updates || Object.keys(updates).length === 0) {
|
||||
return { success: false, error: 'Update data is required' };
|
||||
}
|
||||
|
||||
const existingClient = await getClientById(parseInt(id));
|
||||
if (!existingClient) {
|
||||
return { success: false, error: 'Client not found' };
|
||||
}
|
||||
|
||||
const client = await updateClient(parseInt(id), updates);
|
||||
return { success: true, client, message: 'Client updated successfully' };
|
||||
} catch (error) {
|
||||
console.error('Error updating client:', error);
|
||||
return { success: false, error: error.message || 'Failed to update client' };
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDeleteClient(request) {
|
||||
try {
|
||||
const url = new URL(request.url);
|
||||
const id = url.searchParams.get('id');
|
||||
|
||||
if (!id) {
|
||||
return { success: false, error: 'Client ID is required' };
|
||||
}
|
||||
|
||||
const existingClient = await getClientById(parseInt(id));
|
||||
if (!existingClient) {
|
||||
return { success: false, error: 'Client not found' };
|
||||
}
|
||||
|
||||
const deleted = await deleteClient(parseInt(id));
|
||||
if (!deleted) {
|
||||
return { success: false, error: 'Failed to delete client' };
|
||||
}
|
||||
|
||||
return { success: true, message: 'Client deleted successfully' };
|
||||
} catch (error) {
|
||||
console.error('Error deleting client:', error);
|
||||
return { success: false, error: 'Failed to delete client' };
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Route Definitions
|
||||
// ============================================================================
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
// Clients (admin)
|
||||
{ path: '/admin/clients', method: 'GET', handler: handleGetClients, auth: 'admin' },
|
||||
{ path: '/admin/clients', method: 'POST', handler: handleCreateClient, auth: 'admin' },
|
||||
{ path: '/admin/clients', method: 'PUT', handler: handleUpdateClient, auth: 'admin' },
|
||||
{ path: '/admin/clients', method: 'DELETE', handler: handleDeleteClient, auth: 'admin' },
|
||||
]
|
||||
};
|
||||
|
||||
// Export individual handlers for direct use if needed
|
||||
export {
|
||||
handleGetClients,
|
||||
handleCreateClient,
|
||||
handleUpdateClient,
|
||||
handleDeleteClient,
|
||||
};
|
||||
@@ -0,0 +1,269 @@
|
||||
/**
|
||||
* Clients Module - CRUD Operations
|
||||
* Create, Read, Update, Delete operations for clients
|
||||
*/
|
||||
|
||||
import { query } from '@hykocx/zen/database';
|
||||
|
||||
/**
|
||||
* Generate a unique client number
|
||||
* Format: 2-digit sequential number (01, 02, 03, etc.)
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
async function generateClientNumber() {
|
||||
const result = await query(
|
||||
`SELECT client_number FROM zen_clients ORDER BY id DESC LIMIT 1`
|
||||
);
|
||||
|
||||
if (result.rows.length === 0) {
|
||||
return '01'; // First client
|
||||
}
|
||||
|
||||
const lastNumber = parseInt(result.rows[0].client_number);
|
||||
const nextNumber = lastNumber + 1;
|
||||
return nextNumber.toString().padStart(2, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new client
|
||||
* @param {Object} clientData - Client data
|
||||
* @returns {Promise<Object>} Created client
|
||||
*/
|
||||
export async function createClient(clientData) {
|
||||
const {
|
||||
user_id = null,
|
||||
company_name = null,
|
||||
first_name,
|
||||
last_name,
|
||||
email,
|
||||
phone = null,
|
||||
address = null,
|
||||
city = null,
|
||||
province = null,
|
||||
postal_code = null,
|
||||
country = 'Canada',
|
||||
notes = null,
|
||||
} = clientData;
|
||||
|
||||
// Validate required fields
|
||||
if (!first_name || !last_name || !email) {
|
||||
throw new Error('First name, last name, and email are required');
|
||||
}
|
||||
|
||||
// Convert empty strings to null for foreign key fields
|
||||
const cleanUserId = user_id === '' ? null : user_id;
|
||||
|
||||
// Generate client number
|
||||
const client_number = await generateClientNumber();
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO zen_clients (
|
||||
client_number, user_id, company_name, first_name, last_name, email,
|
||||
phone, address, city, province, postal_code, country, notes
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
client_number, cleanUserId, company_name, first_name, last_name, email,
|
||||
phone, address, city, province, postal_code, country, notes
|
||||
]
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client by ID
|
||||
* @param {number} id - Client ID
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
export async function getClientById(id) {
|
||||
const result = await query(
|
||||
`SELECT c.*, u.name as user_name, u.email as user_email
|
||||
FROM zen_clients c
|
||||
LEFT JOIN zen_auth_users u ON c.user_id = u.id
|
||||
WHERE c.id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client by client number
|
||||
* @param {string} clientNumber - Client number
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
export async function getClientByNumber(clientNumber) {
|
||||
const result = await query(
|
||||
`SELECT c.*, u.name as user_name, u.email as user_email
|
||||
FROM zen_clients c
|
||||
LEFT JOIN zen_auth_users u ON c.user_id = u.id
|
||||
WHERE c.client_number = $1`,
|
||||
[clientNumber]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get client by user ID
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
export async function getClientByUserId(userId) {
|
||||
const result = await query(
|
||||
`SELECT c.*, u.name as user_name, u.email as user_email
|
||||
FROM zen_clients c
|
||||
LEFT JOIN zen_auth_users u ON c.user_id = u.id
|
||||
WHERE c.user_id = $1`,
|
||||
[userId]
|
||||
);
|
||||
|
||||
return result.rows[0] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all clients with pagination
|
||||
* @param {Object} options - Query options
|
||||
* @returns {Promise<Object>} Clients and metadata
|
||||
*/
|
||||
export async function getClients(options = {}) {
|
||||
const {
|
||||
page = 1,
|
||||
limit = 20,
|
||||
search = '',
|
||||
sortBy = 'created_at',
|
||||
sortOrder = 'DESC'
|
||||
} = options;
|
||||
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
// Build search condition
|
||||
let searchCondition = '';
|
||||
const params = [];
|
||||
|
||||
if (search) {
|
||||
searchCondition = `WHERE (
|
||||
c.first_name ILIKE $1 OR
|
||||
c.last_name ILIKE $1 OR
|
||||
c.email ILIKE $1 OR
|
||||
c.company_name ILIKE $1 OR
|
||||
c.client_number ILIKE $1
|
||||
)`;
|
||||
params.push(`%${search}%`);
|
||||
}
|
||||
|
||||
// Get total count
|
||||
const countResult = await query(
|
||||
`SELECT COUNT(*) FROM zen_clients c ${searchCondition}`,
|
||||
params
|
||||
);
|
||||
const total = parseInt(countResult.rows[0].count);
|
||||
|
||||
// Get clients
|
||||
const clientsResult = await query(
|
||||
`SELECT c.*, u.name as user_name, u.email as user_email
|
||||
FROM zen_clients c
|
||||
LEFT JOIN zen_auth_users u ON c.user_id = u.id
|
||||
${searchCondition}
|
||||
ORDER BY c.${sortBy} ${sortOrder}
|
||||
LIMIT $${params.length + 1} OFFSET $${params.length + 2}`,
|
||||
[...params, limit, offset]
|
||||
);
|
||||
|
||||
return {
|
||||
clients: clientsResult.rows,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Update client
|
||||
* @param {number} id - Client ID
|
||||
* @param {Object} updates - Fields to update
|
||||
* @returns {Promise<Object>} Updated client
|
||||
*/
|
||||
export async function updateClient(id, updates) {
|
||||
const allowedFields = [
|
||||
'user_id', 'company_name', 'first_name', 'last_name', 'email',
|
||||
'phone', 'address', 'city', 'province', 'postal_code', 'country', 'notes'
|
||||
];
|
||||
|
||||
const setFields = [];
|
||||
const values = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (allowedFields.includes(key)) {
|
||||
// Convert empty strings to null for foreign key fields
|
||||
const cleanValue = (key === 'user_id' && value === '') ? null : value;
|
||||
setFields.push(`${key} = $${paramIndex}`);
|
||||
values.push(cleanValue);
|
||||
paramIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
if (setFields.length === 0) {
|
||||
throw new Error('No valid fields to update');
|
||||
}
|
||||
|
||||
// Add updated_at
|
||||
setFields.push(`updated_at = CURRENT_TIMESTAMP`);
|
||||
|
||||
// Add ID parameter
|
||||
values.push(id);
|
||||
|
||||
const result = await query(
|
||||
`UPDATE zen_clients
|
||||
SET ${setFields.join(', ')}
|
||||
WHERE id = $${paramIndex}
|
||||
RETURNING *`,
|
||||
values
|
||||
);
|
||||
|
||||
return result.rows[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete client
|
||||
* @param {number} id - Client ID
|
||||
* @returns {Promise<boolean>} Success status
|
||||
*/
|
||||
export async function deleteClient(id) {
|
||||
const result = await query(
|
||||
`DELETE FROM zen_clients WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
return result.rowCount > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Link client to user
|
||||
* @param {number} clientId - Client ID
|
||||
* @param {string} userId - User ID
|
||||
* @returns {Promise<Object>} Updated client
|
||||
*/
|
||||
export async function linkClientToUser(clientId, userId) {
|
||||
// Check if user is already linked to another client
|
||||
const existingLink = await getClientByUserId(userId);
|
||||
if (existingLink && existingLink.id !== clientId) {
|
||||
throw new Error('User is already linked to another client');
|
||||
}
|
||||
|
||||
return await updateClient(clientId, { user_id: userId });
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink client from user
|
||||
* @param {number} clientId - Client ID
|
||||
* @returns {Promise<Object>} Updated client
|
||||
*/
|
||||
export async function unlinkClientFromUser(clientId) {
|
||||
return await updateClient(clientId, { user_id: null });
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* Clients Module - Database
|
||||
* Database initialization and tables for clients
|
||||
*/
|
||||
|
||||
import { query } from '@hykocx/zen/database';
|
||||
|
||||
/**
|
||||
* Check if a table exists in the database
|
||||
* @param {string} tableName - Name of the table to check
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
async function tableExists(tableName) {
|
||||
const result = await query(
|
||||
`SELECT EXISTS (
|
||||
SELECT FROM information_schema.tables
|
||||
WHERE table_schema = 'public'
|
||||
AND table_name = $1
|
||||
)`,
|
||||
[tableName]
|
||||
);
|
||||
return result.rows[0].exists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create clients table
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function createClientsTable() {
|
||||
const tableName = 'zen_clients';
|
||||
const exists = await tableExists(tableName);
|
||||
|
||||
if (exists) {
|
||||
console.log(`- Table already exists: ${tableName}`);
|
||||
return { created: false, tableName };
|
||||
}
|
||||
|
||||
await query(`
|
||||
CREATE TABLE zen_clients (
|
||||
id SERIAL PRIMARY KEY,
|
||||
client_number VARCHAR(10) UNIQUE NOT NULL,
|
||||
user_id TEXT REFERENCES zen_auth_users(id) ON DELETE SET NULL,
|
||||
company_name VARCHAR(255),
|
||||
first_name VARCHAR(100),
|
||||
last_name VARCHAR(100),
|
||||
email VARCHAR(255) NOT NULL,
|
||||
phone VARCHAR(50),
|
||||
address TEXT,
|
||||
city VARCHAR(100),
|
||||
province VARCHAR(100),
|
||||
postal_code VARCHAR(20),
|
||||
country VARCHAR(100) DEFAULT 'Canada',
|
||||
notes TEXT,
|
||||
created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Create index on client_number for fast lookups
|
||||
await query(`
|
||||
CREATE INDEX idx_zen_clients_client_number ON zen_clients(client_number)
|
||||
`);
|
||||
|
||||
// Create index on user_id for fast lookups
|
||||
await query(`
|
||||
CREATE INDEX idx_zen_clients_user_id ON zen_clients(user_id)
|
||||
`);
|
||||
|
||||
// Create index on email for fast lookups
|
||||
await query(`
|
||||
CREATE INDEX idx_zen_clients_email ON zen_clients(email)
|
||||
`);
|
||||
|
||||
console.log(`✓ Created table: ${tableName}`);
|
||||
return { created: true, tableName };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop clients table (use with caution!)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function dropClientsTable() {
|
||||
const tableName = 'zen_clients';
|
||||
const exists = await tableExists(tableName);
|
||||
|
||||
if (exists) {
|
||||
await query(`DROP TABLE IF EXISTS ${tableName} CASCADE`);
|
||||
console.log(`✓ Dropped table: ${tableName}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create all client-related tables
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function createTables() {
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
|
||||
console.log('\n--- Clients Module ---');
|
||||
const clientsResult = await createClientsTable();
|
||||
if (clientsResult.created) created.push(clientsResult.tableName);
|
||||
else skipped.push(clientsResult.tableName);
|
||||
|
||||
return { created, skipped };
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop all client-related tables
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function dropTables() {
|
||||
await dropClientsTable();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Clients Module Entry Point
|
||||
* Standalone clients module for use across other modules
|
||||
*/
|
||||
|
||||
// Database
|
||||
export {
|
||||
createTables,
|
||||
dropTables,
|
||||
createClientsTable,
|
||||
dropClientsTable,
|
||||
} from './db.js';
|
||||
|
||||
// CRUD Operations
|
||||
export {
|
||||
createClient,
|
||||
getClientById,
|
||||
getClientByNumber,
|
||||
getClientByUserId,
|
||||
getClients,
|
||||
updateClient,
|
||||
deleteClient,
|
||||
linkClientToUser,
|
||||
unlinkClientFromUser,
|
||||
} from './crud.js';
|
||||
|
||||
// Admin Components
|
||||
export * from './admin/index.js';
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Clients Module Configuration
|
||||
* Standalone clients module for use across other modules
|
||||
*/
|
||||
|
||||
import { lazy } from 'react';
|
||||
|
||||
export default {
|
||||
// Basic module info
|
||||
name: 'clients',
|
||||
displayName: 'Clients',
|
||||
version: '1.0.0',
|
||||
description: 'Client management module - reusable across other modules',
|
||||
|
||||
// Module dependencies
|
||||
dependencies: [],
|
||||
|
||||
// Environment variables this module uses
|
||||
envVars: [],
|
||||
|
||||
// Admin navigation section
|
||||
navigation: {
|
||||
id: 'clients',
|
||||
title: 'Clients',
|
||||
icon: 'UserGroupIcon',
|
||||
items: [
|
||||
{
|
||||
name: 'Clients',
|
||||
href: '/admin/clients/list',
|
||||
icon: 'UserGroupIcon',
|
||||
},
|
||||
]
|
||||
},
|
||||
|
||||
// Admin pages (lazy-loaded for client-side rendering)
|
||||
adminPages: {
|
||||
'/admin/clients/list': lazy(() => import('./admin/ClientsListPage.js')),
|
||||
'/admin/clients/new': lazy(() => import('./admin/ClientCreatePage.js')),
|
||||
'/admin/clients/edit': lazy(() => import('./admin/ClientEditPage.js')),
|
||||
},
|
||||
|
||||
// No public pages for clients module
|
||||
publicPages: {},
|
||||
|
||||
// No public routes
|
||||
publicRoutes: [],
|
||||
|
||||
// No dashboard widgets
|
||||
dashboardWidgets: [],
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Modules Entry Point
|
||||
* Export all module-related functionality
|
||||
*
|
||||
* NOTE: Individual modules (like invoice) should NOT be exported from here.
|
||||
* Access module functionality directly via:
|
||||
* - import { ... } from '@hykocx/zen/modules/invoice'
|
||||
* - Or use the dynamic registry functions from @hykocx/zen/core/modules
|
||||
*/
|
||||
|
||||
// Module registry
|
||||
export {
|
||||
getAvailableModules,
|
||||
AVAILABLE_MODULES
|
||||
} from './modules.registry.js';
|
||||
|
||||
// Dynamic module system exports
|
||||
export {
|
||||
// Discovery & initialization
|
||||
discoverModules,
|
||||
initializeModules,
|
||||
initializeModuleDatabases,
|
||||
startModuleCronJobs,
|
||||
stopModuleCronJobs,
|
||||
getModuleStatus,
|
||||
|
||||
// Registry getters
|
||||
getAllApiRoutes,
|
||||
getAllAdminNavigation,
|
||||
getAdminPage,
|
||||
getAllCronJobs,
|
||||
getAllPublicRoutes,
|
||||
getAllDatabaseSchemas,
|
||||
getModule,
|
||||
getAllModules,
|
||||
getEnabledModules,
|
||||
|
||||
// Module-specific getters
|
||||
getModuleMetadata,
|
||||
getAllModuleMetadata,
|
||||
getModulePublicPages,
|
||||
|
||||
// Status & checks
|
||||
isModuleEnabled,
|
||||
isModuleRegistered,
|
||||
} from '../core/modules/index.js';
|
||||
|
||||
// Public pages system
|
||||
export * from './pages.js';
|
||||
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Module Database Initialization
|
||||
* Initializes enabled module database tables
|
||||
*
|
||||
* IMPORTANT: When creating a new module, add its createTables import below
|
||||
* and add it to MODULE_DB_INITIALIZERS.
|
||||
*/
|
||||
|
||||
// Import createTables functions from each module
|
||||
// These are bundled together so they're available at runtime
|
||||
import { createTables as createClientsTables } from './clients/db.js';
|
||||
import { createTables as createInvoiceTables } from './invoice/db.js';
|
||||
import { createTables as createPostsTables } from './posts/db.js';
|
||||
import { createTables as createNuageTables } from './nuage/db.js';
|
||||
|
||||
/**
|
||||
* Module database initializers
|
||||
* Maps module names to their createTables functions
|
||||
*
|
||||
* Add new modules here:
|
||||
*/
|
||||
const MODULE_DB_INITIALIZERS = {
|
||||
clients: createClientsTables,
|
||||
invoice: createInvoiceTables,
|
||||
posts: createPostsTables,
|
||||
nuage: createNuageTables,
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a module is enabled in the environment
|
||||
* @param {string} moduleName - Module name
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isModuleEnabled(moduleName) {
|
||||
const envKey = `ZEN_MODULE_${moduleName.toUpperCase()}`;
|
||||
return process.env[envKey] === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize all enabled module databases
|
||||
* @returns {Promise<Object>} Result with created and skipped tables
|
||||
*/
|
||||
export async function initModules() {
|
||||
const created = [];
|
||||
const skipped = [];
|
||||
|
||||
console.log('\nInitializing module databases...');
|
||||
|
||||
for (const [moduleName, createTables] of Object.entries(MODULE_DB_INITIALIZERS)) {
|
||||
if (!isModuleEnabled(moduleName)) {
|
||||
console.log(`- Skipped ${moduleName} (not enabled)`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (typeof createTables === 'function') {
|
||||
console.log(`\nInitializing ${moduleName} module tables...`);
|
||||
const result = await createTables();
|
||||
|
||||
if (result?.created) {
|
||||
created.push(...result.created);
|
||||
}
|
||||
if (result?.skipped) {
|
||||
skipped.push(...result.skipped);
|
||||
}
|
||||
|
||||
console.log(`✓ ${moduleName} module initialized`);
|
||||
} else {
|
||||
console.log(`- ${moduleName} has no createTables function`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error initializing ${moduleName}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return { created, skipped };
|
||||
}
|
||||
|
||||
export default initModules;
|
||||
@@ -0,0 +1,19 @@
|
||||
#################################
|
||||
# MODULE INVOICE
|
||||
ZEN_MODULE_INVOICE=false
|
||||
ZEN_MODULE_INVOICE_INTEREST_ENABLED=true
|
||||
ZEN_MODULE_INVOICE_INTEREST_RATE=1
|
||||
ZEN_MODULE_INVOICE_INTEREST_GRACE_DAYS=3
|
||||
ZEN_MODULE_INVOICE_OVERDUE_EMAIL=support@hyko.cx
|
||||
ZEN_MODULE_INVOICE_INTERAC=false
|
||||
ZEN_MODULE_INVOICE_INTERAC_EMAIL=
|
||||
|
||||
ZEN_MODULE_INVOICE_COMPANY_NAME=
|
||||
ZEN_MODULE_INVOICE_COMPANY_ADDRESS=
|
||||
ZEN_MODULE_INVOICE_COMPANY_CITY=
|
||||
ZEN_MODULE_INVOICE_COMPANY_PROVINCE=Québec
|
||||
ZEN_MODULE_INVOICE_COMPANY_POSTAL_CODE=
|
||||
ZEN_MODULE_INVOICE_COMPANY_COUNTRY=Canada
|
||||
ZEN_MODULE_INVOICE_COMPANY_PHONE=
|
||||
ZEN_MODULE_INVOICE_COMPANY_EMAIL=
|
||||
#################################
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user