refactor: reorganize feature modules with consistent naming conventions and flattened structure
This commit is contained in:
@@ -1,10 +0,0 @@
|
|||||||
/**
|
|
||||||
* Admin Server Actions
|
|
||||||
*
|
|
||||||
* Exported separately from admin/index.js to avoid bundling
|
|
||||||
* server-side code (which includes database imports) into client components.
|
|
||||||
*
|
|
||||||
* Usage: import { getDashboardStats } from '@zen/core/features/admin/actions';
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { getDashboardStats } from './actions/statsActions.js';
|
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
/**
|
|
||||||
* Admin Stats Actions
|
|
||||||
*
|
|
||||||
* @deprecated Utiliser collectAllDashboardData() de serverRegistry à la place.
|
|
||||||
* Ce fichier est conservé pour la rétrocompatibilité avec les projets qui
|
|
||||||
* appellent getDashboardStats() directement depuis leur page admin.
|
|
||||||
*/
|
|
||||||
|
|
||||||
'use server';
|
|
||||||
|
|
||||||
import { query } from '@zen/core/database';
|
|
||||||
import { fail } from '@zen/core/shared/logger';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @deprecated Utiliser collectAllDashboardData() de serverRegistry à la place.
|
|
||||||
* @returns {Promise<{ success: boolean, stats?: { totalUsers: number }, error?: string }>}
|
|
||||||
*/
|
|
||||||
export async function getDashboardStats() {
|
|
||||||
try {
|
|
||||||
const result = await query(`SELECT COUNT(*) as count FROM zen_auth_users`);
|
|
||||||
const totalUsers = parseInt(result.rows[0].count) || 0;
|
|
||||||
return { success: true, stats: { totalUsers } };
|
|
||||||
} catch (error) {
|
|
||||||
fail(`Error getting dashboard stats: ${error.message}`);
|
|
||||||
return { success: false, error: error.message || 'Failed to get dashboard statistics' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
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 RolesPage from './pages/RolesPage.js';
|
|
||||||
import RoleEditPage from './pages/RoleEditPage.js';
|
|
||||||
|
|
||||||
export default function AdminPagesClient({ params, user, dashboardStats = null }) {
|
|
||||||
const parts = params?.admin || [];
|
|
||||||
const page = parts[0] || 'dashboard';
|
|
||||||
|
|
||||||
if (page === 'users' && parts[1] === 'edit' && parts[2]) {
|
|
||||||
return <UserEditPage userId={parts[2]} user={user} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page === 'roles' && parts[1] === 'edit' && parts[2]) {
|
|
||||||
return <RoleEditPage roleId={parts[2]} user={user} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (page === 'roles' && parts[1] === 'new') {
|
|
||||||
return <RoleEditPage roleId="new" user={user} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
const corePages = {
|
|
||||||
dashboard: () => <DashboardPage user={user} stats={dashboardStats} />,
|
|
||||||
users: () => <UsersPage user={user} />,
|
|
||||||
profile: () => <ProfilePage user={user} />,
|
|
||||||
roles: () => <RolesPage user={user} />,
|
|
||||||
};
|
|
||||||
|
|
||||||
const CorePageComponent = corePages[page];
|
|
||||||
return CorePageComponent ? <CorePageComponent /> : <DashboardPage user={user} stats={dashboardStats} />;
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Dashboard Client Registry — API générique
|
|
||||||
*
|
|
||||||
* Les features s'enregistrent ici via registerClientWidget().
|
|
||||||
* Ce fichier ne doit jamais importer une feature directement.
|
|
||||||
* Les features dépendent de ce fichier, pas l'inverse.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const widgets = [];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enregistre un composant React pour le tableau de bord.
|
|
||||||
* Appelé en side effect par chaque feature (ex: auth/dashboard.widget.js).
|
|
||||||
* @param {string} id - Identifiant unique de la feature (doit correspondre à l'id serveur)
|
|
||||||
* @param {React.ComponentType} Component - Composant avec props { data, loading }
|
|
||||||
* @param {number} order - Ordre d'affichage (croissant). Utiliser des intervalles de 10.
|
|
||||||
*/
|
|
||||||
export function registerClientWidget(id, Component, order) {
|
|
||||||
widgets.push({ id, Component, order });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Retourne tous les widgets enregistrés, triés par order croissant.
|
|
||||||
* @returns {Array<{ id: string, Component: React.ComponentType, order: number }>}
|
|
||||||
*/
|
|
||||||
export function getClientWidgets() {
|
|
||||||
return [...widgets].sort((a, b) => a.order - b.order);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dashboard Server Registry — API générique
|
|
||||||
*
|
|
||||||
* Les features s'enregistrent ici via registerServerWidget().
|
|
||||||
* Ce fichier ne doit jamais importer une feature directement.
|
|
||||||
* Les features dépendent de ce fichier, pas l'inverse.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const widgets = new Map(); // id → fetcher
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Enregistre une fonction de fetch pour le tableau de bord.
|
|
||||||
* Appelé en side effect par chaque feature (ex: auth/dashboard.server.js).
|
|
||||||
* @param {string} id - Identifiant unique de la feature
|
|
||||||
* @param {() => Promise<any>} fetcher - Fonction async retournant des données sérialisables
|
|
||||||
*/
|
|
||||||
export function registerServerWidget(id, fetcher) {
|
|
||||||
widgets.set(id, fetcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Exécute tous les fetchers enregistrés en parallèle.
|
|
||||||
* Un fetcher qui échoue n'empêche pas les autres (Promise.allSettled).
|
|
||||||
* @returns {Promise<Record<string, any>>} Map featureId → données
|
|
||||||
*/
|
|
||||||
export async function collectAllDashboardData() {
|
|
||||||
const results = await Promise.allSettled(
|
|
||||||
Array.from(widgets.entries()).map(([id, fetcher]) =>
|
|
||||||
fetcher().then(data => ({ id, data }))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
return results.reduce((acc, result) => {
|
|
||||||
if (result.status === 'fulfilled') {
|
|
||||||
acc[result.value.id] = result.value.data;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {});
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
/**
|
|
||||||
* Dashboard Server Registry — Point d'entrée
|
|
||||||
*
|
|
||||||
* Ré-exporte l'API du registre pur et déclenche le câblage des features
|
|
||||||
* via un import side-effect de features/dashboard.server.js.
|
|
||||||
*
|
|
||||||
* Ne jamais importer une feature directement ici.
|
|
||||||
* Pour ajouter une feature : éditer src/features/dashboard.server.js.
|
|
||||||
*/
|
|
||||||
|
|
||||||
export { collectAllDashboardData } from './registry.js';
|
|
||||||
|
|
||||||
// Side effect : initialise le registre, enregistre les widgets core, puis les features
|
|
||||||
import './registry.js';
|
|
||||||
import './widgets/index.server.js';
|
|
||||||
import '../../dashboard.server.js';
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Widgets core — Câblage client
|
|
||||||
*
|
|
||||||
* Ce fichier est le SEUL à modifier pour ajouter un widget core côté client.
|
|
||||||
* Les widgets core sont auto-enregistrés au démarrage de l'admin, sans module externe.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import './users.widget.js';
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* Widgets core — Câblage serveur
|
|
||||||
*
|
|
||||||
* Ce fichier est le SEUL à modifier pour ajouter un widget core côté serveur.
|
|
||||||
* Les widgets core sont auto-enregistrés au démarrage de l'admin, sans module externe.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import './users.server.js';
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
/**
|
|
||||||
* Admin Navigation Builder (Server-Only)
|
|
||||||
*
|
|
||||||
* IMPORTANT: Navigation data must be serializable (no functions/components).
|
|
||||||
* Icons are passed as string names and resolved on the client.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build complete navigation sections
|
|
||||||
* @param {string} pathname - Current pathname
|
|
||||||
* @returns {Array} Navigation sections (serializable, icons as strings)
|
|
||||||
*/
|
|
||||||
export function buildNavigationSections(pathname) {
|
|
||||||
const coreNavigation = [
|
|
||||||
{
|
|
||||||
id: 'Dashboard',
|
|
||||||
title: 'Tableau de bord',
|
|
||||||
icon: 'DashboardSquare03Icon',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Tableau de bord',
|
|
||||||
href: '/admin/dashboard',
|
|
||||||
icon: 'DashboardSquare03Icon',
|
|
||||||
current: pathname === '/admin/dashboard'
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const systemNavigation = [
|
|
||||||
{
|
|
||||||
id: 'users',
|
|
||||||
title: 'Utilisateurs',
|
|
||||||
icon: 'UserMultiple02Icon',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
name: 'Utilisateurs',
|
|
||||||
href: '/admin/users',
|
|
||||||
icon: 'UserMultiple02Icon',
|
|
||||||
current: pathname.startsWith('/admin/users')
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'Rôles',
|
|
||||||
href: '/admin/roles',
|
|
||||||
icon: 'Crown03Icon',
|
|
||||||
current: pathname.startsWith('/admin/roles')
|
|
||||||
},
|
|
||||||
]
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return [...coreNavigation, ...systemNavigation];
|
|
||||||
}
|
|
||||||
@@ -1,434 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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 { fail } from '@zen/core/shared/logger';
|
|
||||||
import { cookies, headers } from 'next/headers';
|
|
||||||
import { getSessionCookieName, getPublicBaseUrl } from '@zen/core/shared/config';
|
|
||||||
import { checkRateLimit, getIpFromHeaders, formatRetryAfter } from '@zen/core/shared/rate-limit';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Errors that are safe to surface verbatim to the client (e.g. "token expired").
|
|
||||||
* All other errors — including library-layer and database errors — must be caught,
|
|
||||||
* logged server-side only, and replaced with a generic message to prevent internal
|
|
||||||
* detail disclosure.
|
|
||||||
*/
|
|
||||||
class UserFacingError extends Error {
|
|
||||||
constructor(message) {
|
|
||||||
super(message);
|
|
||||||
this.name = 'UserFacingError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the client IP from the current server action context.
|
|
||||||
*/
|
|
||||||
async function getClientIp() {
|
|
||||||
const h = await headers();
|
|
||||||
return getIpFromHeaders(h);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Emitted at most once per process lifetime to avoid log flooding while still
|
|
||||||
// alerting operators that per-IP rate limiting is inactive for server actions.
|
|
||||||
let _rateLimitUnavailableWarned = false;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Apply per-IP rate limiting only when a real IP address is available.
|
|
||||||
*
|
|
||||||
* When IP resolves to 'unknown' (no trusted proxy configured), every caller
|
|
||||||
* shares the single bucket keyed '<action>:unknown'. A single attacker can
|
|
||||||
* exhaust that bucket in 5 requests and impose a 30-minute denial-of-service
|
|
||||||
* on every legitimate user. Rate limiting is therefore suspended for the
|
|
||||||
* 'unknown' case and a one-time operator warning is emitted instead,
|
|
||||||
* mirroring the identical policy applied to API routes in router.js.
|
|
||||||
*
|
|
||||||
* Set ZEN_TRUST_PROXY=true only behind a verified reverse proxy (Nginx,
|
|
||||||
* Cloudflare, AWS ALB, …) that strips and rewrites forwarding headers.
|
|
||||||
*
|
|
||||||
* @param {string} ip
|
|
||||||
* @param {string} action
|
|
||||||
* @returns {{ allowed: boolean, retryAfterMs?: number } | null} null = suspended
|
|
||||||
*/
|
|
||||||
function enforceRateLimit(ip, action) {
|
|
||||||
if (ip === 'unknown') {
|
|
||||||
if (!_rateLimitUnavailableWarned) {
|
|
||||||
_rateLimitUnavailableWarned = true;
|
|
||||||
fail(
|
|
||||||
'Rate limiting inactive (server actions): client IP cannot be determined. ' +
|
|
||||||
'Set ZEN_TRUST_PROXY=true behind a verified reverse proxy to enable per-IP rate limiting.'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return checkRateLimit(ip, action);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 AND no more than MAX_FORM_AGE_MS in the past. Both a lower bound
|
|
||||||
* (prevents instant automated submission) and an upper bound (prevents the
|
|
||||||
* trivial bypass of supplying an arbitrary past timestamp such as _t=1) are
|
|
||||||
* enforced. Future timestamps are also rejected.
|
|
||||||
*
|
|
||||||
* @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 MIN_ELAPSED_MS = 1_500;
|
|
||||||
const MAX_FORM_AGE_MS = 10 * 60 * 1_000; // 10 minutes — rejects epoch-era timestamps
|
|
||||||
const now = Date.now();
|
|
||||||
const t = parseInt(formData.get('_t') || '0', 10);
|
|
||||||
const elapsed = now - t;
|
|
||||||
|
|
||||||
if (t === 0 || t > now || elapsed < MIN_ELAPSED_MS || elapsed > MAX_FORM_AGE_MS) {
|
|
||||||
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 = enforceRateLimit(ip, 'register');
|
|
||||||
if (rl && !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) {
|
|
||||||
// Never return raw error.message to the client — library and database errors
|
|
||||||
// (e.g. unique-constraint violations) expose internal table names and schema.
|
|
||||||
fail(`Auth: registerAction error: ${error.message}`);
|
|
||||||
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 = enforceRateLimit(ip, 'login');
|
|
||||||
if (rl && !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 });
|
|
||||||
|
|
||||||
// Set the session cookie directly inside this server action so the token
|
|
||||||
// never travels through JavaScript-readable response payload.
|
|
||||||
// An HttpOnly cookie is the only safe transport for session tokens.
|
|
||||||
const cookieStore = await cookies();
|
|
||||||
cookieStore.set(COOKIE_NAME, result.session.token, {
|
|
||||||
httpOnly: true,
|
|
||||||
secure: process.env.NODE_ENV === 'production',
|
|
||||||
sameSite: 'lax',
|
|
||||||
maxAge: 60 * 60 * 24 * 30,
|
|
||||||
path: '/'
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'Connexion réussie',
|
|
||||||
user: result.user
|
|
||||||
// sessionToken intentionally omitted — it must never appear in a
|
|
||||||
// JavaScript-accessible response body.
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
fail(`Auth: loginAction error: ${error.message}`);
|
|
||||||
return { success: false, error: 'Identifiants invalides ou erreur interne. Veuillez réessayer.' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set session cookie after verifying the token is a genuine live session.
|
|
||||||
*
|
|
||||||
* This server action is client-callable. Without server-side token validation an
|
|
||||||
* attacker could supply any arbitrary string (including a stolen token for another
|
|
||||||
* user) and have it written as the HttpOnly session cookie, completely bypassing
|
|
||||||
* the protection that HttpOnly is intended to provide. The token is therefore
|
|
||||||
* validated against the session store before the cookie is written.
|
|
||||||
*
|
|
||||||
* @param {string} token - Session token obtained from a trusted server-side flow
|
|
||||||
* @returns {Promise<Object>} Result object
|
|
||||||
*/
|
|
||||||
export async function setSessionCookie(token) {
|
|
||||||
try {
|
|
||||||
if (!token || typeof token !== 'string' || token.trim() === '') {
|
|
||||||
return { success: false, error: 'Jeton de session invalide' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await validateSession(token);
|
|
||||||
if (!session) {
|
|
||||||
return { success: false, error: 'Session invalide ou expirée' };
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
fail(`Auth: setSessionCookie error: ${error.message}`);
|
|
||||||
return { success: false, error: 'Une erreur interne est survenue' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Refresh session cookie (extend expiration).
|
|
||||||
*
|
|
||||||
* Re-validates the token before extending its cookie lifetime so that expired
|
|
||||||
* or revoked tokens cannot have their cookie window reopened by replaying this
|
|
||||||
* server action.
|
|
||||||
*
|
|
||||||
* @param {string} token - Session token
|
|
||||||
* @returns {Promise<Object>} Result object
|
|
||||||
*/
|
|
||||||
export async function refreshSessionCookie(token) {
|
|
||||||
try {
|
|
||||||
if (!token || typeof token !== 'string' || token.trim() === '') {
|
|
||||||
return { success: false, error: 'Jeton de session invalide' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const session = await validateSession(token);
|
|
||||||
if (!session) {
|
|
||||||
return { success: false, error: 'Session invalide ou expirée' };
|
|
||||||
}
|
|
||||||
|
|
||||||
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) {
|
|
||||||
fail(`Auth: refreshSessionCookie error: ${error.message}`);
|
|
||||||
return { success: false, error: 'Une erreur interne est survenue' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
|
||||||
fail(`Auth: logoutAction error: ${error.message}`);
|
|
||||||
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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) {
|
|
||||||
fail(`Auth: session validation error: ${error.message}`);
|
|
||||||
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 = enforceRateLimit(ip, 'forgot_password');
|
|
||||||
if (rl && !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) {
|
|
||||||
fail(`Auth: forgotPasswordAction error: ${error.message}`);
|
|
||||||
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 = enforceRateLimit(ip, 'reset_password');
|
|
||||||
if (rl && !rl.allowed) {
|
|
||||||
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = formData.get('email');
|
|
||||||
const token = formData.get('token');
|
|
||||||
const newPassword = formData.get('newPassword');
|
|
||||||
|
|
||||||
// Verify token first — throw UserFacingError so the specific message reaches
|
|
||||||
// the client while unexpected system errors are sanitized in the catch below.
|
|
||||||
const isValid = await verifyResetToken(email, token);
|
|
||||||
if (!isValid) {
|
|
||||||
throw new UserFacingError('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) {
|
|
||||||
if (error instanceof UserFacingError) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
fail(`Auth: resetPasswordAction error: ${error.message}`);
|
|
||||||
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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 = enforceRateLimit(ip, 'verify_email');
|
|
||||||
if (rl && !rl.allowed) {
|
|
||||||
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
|
|
||||||
}
|
|
||||||
|
|
||||||
const email = formData.get('email');
|
|
||||||
const token = formData.get('token');
|
|
||||||
|
|
||||||
// Verify token — throw UserFacingError so validation messages surface to the
|
|
||||||
// client while unexpected system errors remain sanitized in the catch below.
|
|
||||||
const isValid = await verifyEmailToken(email, token);
|
|
||||||
if (!isValid) {
|
|
||||||
throw new UserFacingError('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 UserFacingError('Utilisateur introuvable');
|
|
||||||
}
|
|
||||||
|
|
||||||
await verifyUserEmail(user.id);
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
message: 'E-mail vérifié avec succès. Vous pouvez maintenant vous connecter.'
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof UserFacingError) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
fail(`Auth: verifyEmailAction error: ${error.message}`);
|
|
||||||
return { success: false, error: 'Une erreur interne est survenue. Veuillez réessayer.' };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
'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} />;
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Câblage client des widgets du tableau de bord — Features externes
|
|
||||||
*
|
|
||||||
* Ce fichier est le SEUL à modifier pour ajouter le widget client
|
|
||||||
* d'une nouvelle feature au tableau de bord.
|
|
||||||
*
|
|
||||||
* L'import suffit : chaque module s'enregistre lui-même en side effect
|
|
||||||
* via registerClientWidget() de admin/dashboard/clientRegistry.js.
|
|
||||||
*
|
|
||||||
* Exemple pour une feature 'blog' :
|
|
||||||
* import './blog/dashboard.widget.js';
|
|
||||||
*/
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/**
|
|
||||||
* Câblage serveur des widgets du tableau de bord — Features externes
|
|
||||||
*
|
|
||||||
* Ce fichier est le SEUL à modifier pour ajouter la contribution serveur
|
|
||||||
* d'une nouvelle feature au tableau de bord.
|
|
||||||
*
|
|
||||||
* L'import suffit : chaque module s'enregistre lui-même en side effect
|
|
||||||
* via registerServerWidget() de admin/dashboard/registry.js.
|
|
||||||
*
|
|
||||||
* Exemple pour une feature 'blog' :
|
|
||||||
* import './blog/dashboard.server.js';
|
|
||||||
*/
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
/**
|
|
||||||
* Core Features Registry
|
|
||||||
*
|
|
||||||
* Lists all built-in features that are always initialized when running `zen-db init`.
|
|
||||||
* Unlike optional modules (src/modules), core features are not gated by env vars —
|
|
||||||
* they are required for the application to function.
|
|
||||||
*
|
|
||||||
* Each name must correspond to a directory under src/features/ that exposes a db.js
|
|
||||||
* with createTables() and optionally dropTables().
|
|
||||||
*/
|
|
||||||
export const CORE_FEATURES = [
|
|
||||||
'auth',
|
|
||||||
];
|
|
||||||
Reference in New Issue
Block a user