chore: import codes

This commit is contained in:
2026-04-12 12:50:14 -04:00
parent 4bcb4898e8
commit 65ae3c6788
241 changed files with 48834 additions and 1 deletions
+347
View File
@@ -0,0 +1,347 @@
# Custom auth pages
This guide explains how to build your own auth pages (login, register, forgot password, reset password, confirm email, logout) so they match your sites layout and style. For a basic site you can keep using the [default auth page](#default-auth-page).
## Overview
You can use a **custom page for every auth flow**:
| Page | Component | Server action(s) |
|-----------------|-----------------------|-------------------------------------|
| Login | `LoginPage` | `loginAction`, `setSessionCookie` |
| Register | `RegisterPage` | `registerAction` |
| Forgot password | `ForgotPasswordPage` | `forgotPasswordAction` |
| Reset password | `ResetPasswordPage` | `resetPasswordAction` |
| Confirm email | `ConfirmEmailPage` | `verifyEmailAction` |
| Logout | `LogoutPage` | `logoutAction`, `setSessionCookie` |
- **Components**: from `@hykocx/zen/auth/components`
- **Actions**: from `@hykocx/zen/auth/actions`
Create your own routes (e.g. `/login`, `/register`, `/auth/forgot`) and wrap Zens components in your layout. Each page follows the same pattern: a **server component** that loads data and passes actions, and a **client wrapper** that handles navigation and renders the Zen component.
---
## Route structure
Choose a URL scheme and use it consistently. Two common options:
**Option A All under `/auth/*` (like the default)**
`/auth/login`, `/auth/register`, `/auth/forgot`, `/auth/reset`, `/auth/confirm`, `/auth/logout`
**Option B Top-level routes**
`/login`, `/register`, `/forgot`, `/reset`, `/confirm`, `/logout`
The `onNavigate` callback receives one of: `'login' | 'register' | 'forgot' | 'reset'`. Map each to your chosen path, e.g. `router.push(\`/auth/${page}\`)` or `router.push(\`/${page}\`)`.
Reset and confirm pages need `email` and `token` from the URL (e.g. `/auth/reset?email=...&token=...`). Your server page can read `searchParams` and pass them to the component.
---
## Component reference (props)
Use this when wiring each custom page.
| Component | Props |
|-----------------------|--------|
| **LoginPage** | `onSubmit` (loginAction), `onSetSessionCookie`, `onNavigate`, `redirectAfterLogin`, `currentUser` |
| **RegisterPage** | `onSubmit` (registerAction), `onNavigate`, `currentUser` |
| **ForgotPasswordPage**| `onSubmit` (forgotPasswordAction), `onNavigate`, `currentUser` |
| **ResetPasswordPage** | `onSubmit` (resetPasswordAction), `onNavigate`, `email`, `token` (from URL) |
| **ConfirmEmailPage** | `onSubmit` (verifyEmailAction), `onNavigate`, `email`, `token` (from URL) |
| **LogoutPage** | `onLogout` (logoutAction), `onSetSessionCookie` (optional) |
---
## 1. Login
**Server:** `app/login/page.js` (or `app/auth/login/page.js`)
```js
import { getSession, loginAction, setSessionCookie } from '@hykocx/zen/auth/actions';
import { LoginPageWrapper } from './LoginPageWrapper';
export default async function LoginRoute() {
const session = await getSession();
return (
<YourSiteLayout>
<LoginPageWrapper
loginAction={loginAction}
setSessionCookie={setSessionCookie}
currentUser={session?.user ?? null}
/>
</YourSiteLayout>
);
}
```
**Client:** `app/login/LoginPageWrapper.js`
```js
'use client';
import { useRouter } from 'next/navigation';
import { LoginPage } from '@hykocx/zen/auth/components';
export function LoginPageWrapper({ loginAction, setSessionCookie, currentUser }) {
const router = useRouter();
return (
<LoginPage
onSubmit={loginAction}
onSetSessionCookie={setSessionCookie}
onNavigate={(page) => router.push(`/auth/${page}`)}
redirectAfterLogin="/"
currentUser={currentUser}
/>
);
}
```
---
## 2. Register
**Server:** `app/register/page.js`
```js
import { getSession, registerAction } from '@hykocx/zen/auth/actions';
import { RegisterPageWrapper } from './RegisterPageWrapper';
export default async function RegisterRoute() {
const session = await getSession();
return (
<YourSiteLayout>
<RegisterPageWrapper
registerAction={registerAction}
currentUser={session?.user ?? null}
/>
</YourSiteLayout>
);
}
```
**Client:** `app/register/RegisterPageWrapper.js`
```js
'use client';
import { useRouter } from 'next/navigation';
import { RegisterPage } from '@hykocx/zen/auth/components';
export function RegisterPageWrapper({ registerAction, currentUser }) {
const router = useRouter();
return (
<RegisterPage
onSubmit={registerAction}
onNavigate={(page) => router.push(`/auth/${page}`)}
currentUser={currentUser}
/>
);
}
```
---
## 3. Forgot password
**Server:** `app/forgot/page.js`
```js
import { getSession, forgotPasswordAction } from '@hykocx/zen/auth/actions';
import { ForgotPasswordPageWrapper } from './ForgotPasswordPageWrapper';
export default async function ForgotRoute() {
const session = await getSession();
return (
<YourSiteLayout>
<ForgotPasswordPageWrapper
forgotPasswordAction={forgotPasswordAction}
currentUser={session?.user ?? null}
/>
</YourSiteLayout>
);
}
```
**Client:** `app/forgot/ForgotPasswordPageWrapper.js`
```js
'use client';
import { useRouter } from 'next/navigation';
import { ForgotPasswordPage } from '@hykocx/zen/auth/components';
export function ForgotPasswordPageWrapper({ forgotPasswordAction, currentUser }) {
const router = useRouter();
return (
<ForgotPasswordPage
onSubmit={forgotPasswordAction}
onNavigate={(page) => router.push(`/auth/${page}`)}
currentUser={currentUser}
/>
);
}
```
---
## 4. Reset password
Requires `email` and `token` from the reset link (e.g. `/auth/reset?email=...&token=...`). Read `searchParams` in the server component and pass them to the client.
**Server:** `app/auth/reset/page.js` (or `app/reset/page.js` with dynamic segment if needed)
```js
import { resetPasswordAction } from '@hykocx/zen/auth/actions';
import { ResetPasswordPageWrapper } from './ResetPasswordPageWrapper';
export default async function ResetRoute({ searchParams }) {
const params = typeof searchParams?.then === 'function' ? await searchParams : searchParams ?? {};
const email = params.email ?? '';
const token = params.token ?? '';
return (
<YourSiteLayout>
<ResetPasswordPageWrapper
resetPasswordAction={resetPasswordAction}
email={email}
token={token}
/>
</YourSiteLayout>
);
}
```
**Client:** `app/auth/reset/ResetPasswordPageWrapper.js`
```js
'use client';
import { useRouter } from 'next/navigation';
import { ResetPasswordPage } from '@hykocx/zen/auth/components';
export function ResetPasswordPageWrapper({ resetPasswordAction, email, token }) {
const router = useRouter();
return (
<ResetPasswordPage
onSubmit={resetPasswordAction}
onNavigate={(page) => router.push(`/auth/${page}`)}
email={email}
token={token}
/>
);
}
```
---
## 5. Confirm email
Requires `email` and `token` from the verification link (e.g. `/auth/confirm?email=...&token=...`).
**Server:** `app/auth/confirm/page.js`
```js
import { verifyEmailAction } from '@hykocx/zen/auth/actions';
import { ConfirmEmailPageWrapper } from './ConfirmEmailPageWrapper';
export default async function ConfirmRoute({ searchParams }) {
const params = typeof searchParams?.then === 'function' ? await searchParams : searchParams ?? {};
const email = params.email ?? '';
const token = params.token ?? '';
return (
<YourSiteLayout>
<ConfirmEmailPageWrapper
verifyEmailAction={verifyEmailAction}
email={email}
token={token}
/>
</YourSiteLayout>
);
}
```
**Client:** `app/auth/confirm/ConfirmEmailPageWrapper.js`
```js
'use client';
import { useRouter } from 'next/navigation';
import { ConfirmEmailPage } from '@hykocx/zen/auth/components';
export function ConfirmEmailPageWrapper({ verifyEmailAction, email, token }) {
const router = useRouter();
return (
<ConfirmEmailPage
onSubmit={verifyEmailAction}
onNavigate={(page) => router.push(`/auth/${page}`)}
email={email}
token={token}
/>
);
}
```
---
## 6. Logout
**Server:** `app/auth/logout/page.js`
```js
import { logoutAction, setSessionCookie } from '@hykocx/zen/auth/actions';
import { LogoutPageWrapper } from './LogoutPageWrapper';
export default function LogoutRoute() {
return (
<YourSiteLayout>
<LogoutPageWrapper
logoutAction={logoutAction}
setSessionCookie={setSessionCookie}
/>
</YourSiteLayout>
);
}
```
**Client:** `app/auth/logout/LogoutPageWrapper.js`
```js
'use client';
import { LogoutPage } from '@hykocx/zen/auth/components';
export function LogoutPageWrapper({ logoutAction, setSessionCookie }) {
return (
<LogoutPage
onLogout={logoutAction}
onSetSessionCookie={setSessionCookie}
/>
);
}
```
---
## Protecting routes
Use `protect()` from `@hykocx/zen/auth` and set `redirectTo` to your custom login path:
```js
import { protect } from '@hykocx/zen/auth';
export const middleware = protect({ redirectTo: '/login' });
```
So unauthenticated users are sent to your custom login page.
---
## Default auth page
If you dont need a custom layout, keep using the built-in auth UI. In `app/auth/[...auth]/page.js`:
```js
export { default } from '@hykocx/zen/auth/page';
```
This serves login, register, forgot, reset, confirm, and logout under `/auth/*` with the default styling.
+274
View File
@@ -0,0 +1,274 @@
# Client dashboard and user features
This guide explains how to build a **client dashboard** in your Next.js app using Zen auth: protect routes, show the current user (name, avatar), add an account section to edit profile and avatar, and redirect to login when the user is not connected.
## What is available
| Need | Solution |
|------|----------|
| Require login on a page | `protect()` in a **server component** redirects to login if not authenticated |
| Get current user on server | `getSession()` from `@hykocx/zen/auth/actions` |
| Check auth without redirect | `checkAuth()` from `@hykocx/zen/auth` |
| Require a role | `requireRole(['admin', 'manager'])` from `@hykocx/zen/auth` |
| Show user in client (header/nav) | `UserMenu` or `UserAvatar` + `useCurrentUser` from `@hykocx/zen/auth/components` |
| Edit account (name + avatar) | `AccountSection` from `@hykocx/zen/auth/components` |
| Call user API from client | `GET /zen/api/users/me`, `PUT /zen/api/users/profile`, `POST/DELETE /zen/api/users/profile/picture` (with `credentials: 'include'`) |
All user APIs are **session-based**: the session cookie is read on the server. No token in client code. Avatar and profile updates are scoped to the current user; the API validates the session on every request.
---
## 1. Protect a dashboard page (redirect if not logged in)
Use `protect()` in a **server component**. If there is no valid session, the user is redirected to the login page.
```js
// app/dashboard/page.js (Server Component)
import { protect } from '@hykocx/zen/auth';
import { DashboardClient } from './DashboardClient';
export default async function DashboardPage() {
const session = await protect({ redirectTo: '/auth/login' });
return (
<div>
<h1>Dashboard</h1>
<DashboardClient initialUser={session.user} />
</div>
);
}
```
- `redirectTo`: where to send the user if not authenticated (default: `'/auth/login'`).
- `protect()` returns the **session** (with `session.user`: `id`, `email`, `name`, `role`, `image`, etc.).
---
## 2. Display the current user in the layout (name, avatar)
**Option A Server: pass user into a client component**
In your layout or header (server component), get the session and pass `user` to a client component that shows avatar and name:
```js
// app/layout.js or app/dashboard/layout.js
import { getSession } from '@hykocx/zen/auth/actions';
import { UserMenu } from '@hykocx/zen/auth/components';
export default async function Layout({ children }) {
const session = await getSession();
return (
<div>
<header>
{session?.user ? (
<UserMenu user={session.user} accountHref="/dashboard/account" logoutHref="/auth/logout" />
) : (
<a href="/auth/login">Log in</a>
)}
</header>
{children}
</div>
);
}
```
**Option B Client only: fetch user with `useCurrentUser`**
If you prefer not to pass user from the server, use the hook in a client component. It calls `GET /zen/api/users/me` with the session cookie:
```js
'use client';
import { UserMenu } from '@hykocx/zen/auth/components';
export function Header() {
return (
<UserMenu
accountHref="/dashboard/account"
logoutHref="/auth/logout"
/>
);
}
```
`UserMenu` with no `user` prop will call `useCurrentUser()` itself and show a loading state until the request finishes. If the user is not logged in, it renders nothing (you can show a “Log in” link elsewhere).
**Components:**
- **`UserMenu`** Avatar + name + dropdown with “My account” and “Log out”. Props: `user` (optional), `accountHref`, `logoutHref`, `className`.
- **`UserAvatar`** Only the avatar (image or initials). Props: `user`, `size` (`'sm' | 'md' | 'lg'`), `className`.
- **`useCurrentUser()`** Returns `{ user, loading, error, refetch }`. Use when you need the current user in a client component without receiving it from the server.
---
## 3. Account page (edit profile and avatar)
Use **`AccountSection`** on a page that is already protected (e.g. `/dashboard/account`). It shows:
- Profile picture (upload / remove)
- Full name (editable)
- Email (read-only)
- Optional “Account created” date
**Server page:**
```js
// app/dashboard/account/page.js
import { protect } from '@hykocx/zen/auth';
import { AccountSection } from '@hykocx/zen/auth/components';
export default async function AccountPage() {
const session = await protect({ redirectTo: '/auth/login' });
return (
<div>
<h1>My account</h1>
<AccountSection initialUser={session.user} />
</div>
);
}
```
- **`initialUser`** Optional. If you pass `session.user`, the section uses it immediately and does not need an extra API call on load.
- **`onUpdate`** Optional callback after profile or avatar update; you can use it to refresh parent state or revalidate.
`AccountSection` uses:
- `PUT /zen/api/users/profile` for name
- `POST /zen/api/users/profile/picture` for upload
- `DELETE /zen/api/users/profile/picture` for remove
All with `credentials: 'include'` (session cookie). Ensure your app uses **ToastProvider** (from `@hykocx/zen/toast`) if you want toasts.
---
## 4. Check if the user is connected (without redirect)
Use **`checkAuth()`** in a server component when you only need to know whether someone is logged in:
```js
import { checkAuth } from '@hykocx/zen/auth';
export default async function Page() {
const session = await checkAuth();
return session ? <div>Hello, {session.user.name}</div> : <div>Please log in</div>;
}
```
Use **`requireRole()`** when a page is only for certain roles:
```js
import { requireRole } from '@hykocx/zen/auth';
export default async function ManagerPage() {
const session = await requireRole(['admin', 'manager'], {
redirectTo: '/auth/login',
forbiddenRedirect: '/dashboard',
});
return <div>Manager content</div>;
}
```
---
## 5. Security summary
- **Session cookie**: HttpOnly, validated on the server for every protected API call.
- **User APIs**:
- `GET /zen/api/users/me` current user only.
- `PUT /zen/api/users/profile` update only the authenticated users name.
- Profile picture upload/delete scoped to the current user; storage path includes `users/{userId}/...`.
- **Storage**: User files under `users/{userId}/...` are only served if the request session matches that `userId` (or admin).
- **Protection**: Use `protect()` or `requireRole()` in server components so unauthenticated or unauthorized users never see sensitive dashboard content.
---
## 6. Minimal dashboard example
```text
app/
layout.js # Root layout with ToastProvider if you use it
auth/
[...auth]/page.js # Zen default auth page (login, register, logout, etc.)
dashboard/
layout.js # Optional: layout that shows UserMenu and requires login
page.js # Protected dashboard home
account/
page.js # Protected account page with AccountSection
```
**dashboard/layout.js:**
```js
import { protect } from '@hykocx/zen/auth';
import { UserMenu } from '@hykocx/zen/auth/components';
import Link from 'next/link';
export default async function DashboardLayout({ children }) {
const session = await protect({ redirectTo: '/auth/login' });
return (
<div>
<header className="flex justify-between items-center p-4 border-b">
<Link href="/dashboard">Dashboard</Link>
<UserMenu
user={session.user}
accountHref="/dashboard/account"
logoutHref="/auth/logout"
/>
</header>
<main className="p-4">{children}</main>
</div>
);
}
```
**dashboard/page.js:**
```js
import { protect } from '@hykocx/zen/auth';
export default async function DashboardPage() {
const session = await protect({ redirectTo: '/auth/login' });
return (
<div>
<h1>Welcome, {session.user.name}</h1>
<p><a href="/dashboard/account">Edit my account</a></p>
</div>
);
}
```
**dashboard/account/page.js:**
```js
import { protect } from '@hykocx/zen/auth';
import { AccountSection } from '@hykocx/zen/auth/components';
export default async function AccountPage() {
const session = await protect({ redirectTo: '/auth/login' });
return (
<div>
<h1>My account</h1>
<AccountSection initialUser={session.user} />
</div>
);
}
```
This gives you a protected dashboard, user display in the header, and a dedicated account page to modify profile and avatar, with redirect to login when the user is not connected.
---
## 7. Facturation (invoices) section
If you use the **Invoice** module and want logged-in users to see their own invoices in the dashboard:
- **Userclient link**: In the admin, link a user to a client (User edit → Client). Only invoices for that client are shown.
- **API**: `GET /zen/api/invoices/me` (session required) returns the current users linked client and that clients invoices.
- **Component**: Use `ClientInvoicesSection` from `@hykocx/zen/invoice/dashboard` on a protected page (e.g. `/dashboard/invoices`).
See the [Invoice module dashboard guide](../../modules/invoice/README-dashboard.md) for the full setup (API details, page example, linking users to clients, and security).
+19
View File
@@ -0,0 +1,19 @@
/**
* Server Actions Export
* This file ONLY exports server actions - no client components
*/
'use server';
export {
registerAction,
loginAction,
logoutAction,
getSession,
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
setSessionCookie,
refreshSessionCookie
} from './actions/authActions.js';
+341
View File
@@ -0,0 +1,341 @@
/**
* Server Actions for Next.js
* Authentication actions for login, register, password reset, etc.
*/
'use server';
import { register, login, requestPasswordReset, resetPassword, verifyUserEmail } from '../lib/auth.js';
import { validateSession, deleteSession } from '../lib/session.js';
import { verifyEmailToken, verifyResetToken, sendVerificationEmail, sendPasswordResetEmail } from '../lib/email.js';
import { cookies, headers } from 'next/headers';
import { getSessionCookieName, getPublicBaseUrl } from '../../../shared/lib/appConfig.js';
import { checkRateLimit, getIpFromHeaders, formatRetryAfter } from '../lib/rateLimit.js';
/**
* Get the client IP from the current server action context.
*/
async function getClientIp() {
const h = await headers();
return getIpFromHeaders(h);
}
/**
* Validate anti-bot fields submitted with forms.
* - _hp : honeypot field — must be empty
* - _t : form load timestamp (ms) — submission must be at least 1.5 s after page load
*
* @param {FormData} formData
* @returns {{ valid: boolean, error?: string }}
*/
function validateAntiBotFields(formData) {
const honeypot = formData.get('_hp');
if (honeypot && honeypot.length > 0) {
return { valid: false, error: 'Requête invalide' };
}
const t = parseInt(formData.get('_t') || '0', 10);
if (t === 0 || Date.now() - t < 1500) {
return { valid: false, error: 'Requête invalide' };
}
return { valid: true };
}
// Get cookie name from environment or use default
export const COOKIE_NAME = getSessionCookieName();
/**
* Register a new user
* @param {FormData} formData - Form data with email, password, name
* @returns {Promise<Object>} Result object
*/
export async function registerAction(formData) {
try {
const botCheck = validateAntiBotFields(formData);
if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'register');
if (!rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const password = formData.get('password');
const name = formData.get('name');
const result = await register({ email, password, name });
// Send verification email
await sendVerificationEmail(result.user.email, result.verificationToken, getPublicBaseUrl());
return {
success: true,
message: 'Compte créé avec succès. Consultez votre e-mail pour vérifier votre compte.',
user: result.user
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Login a user
* @param {FormData} formData - Form data with email and password
* @returns {Promise<Object>} Result object
*/
export async function loginAction(formData) {
try {
const botCheck = validateAntiBotFields(formData);
if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'login');
if (!rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const password = formData.get('password');
const result = await login({ email, password });
// Return the token to be set by the client to avoid page refresh
// The client will call setSessionCookie after displaying the success message
return {
success: true,
message: 'Connexion réussie',
user: result.user,
sessionToken: result.session.token
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Set session cookie (called by client after showing success message)
* @param {string} token - Session token
* @returns {Promise<Object>} Result object
*/
export async function setSessionCookie(token) {
try {
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/'
});
return { success: true };
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Refresh session cookie (extend expiration)
* @param {string} token - Session token
* @returns {Promise<Object>} Result object
*/
export async function refreshSessionCookie(token) {
try {
const cookieStore = await cookies();
cookieStore.set(COOKIE_NAME, token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 30, // 30 days
path: '/'
});
return { success: true };
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Logout a user
* @returns {Promise<Object>} Result object
*/
export async function logoutAction() {
try {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (token) {
await deleteSession(token);
}
cookieStore.delete(COOKIE_NAME);
return {
success: true,
message: 'Déconnexion réussie'
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Get current user session
* @returns {Promise<Object|null>} Session and user data or null
*/
export async function getSession() {
try {
const cookieStore = await cookies();
const token = cookieStore.get(COOKIE_NAME)?.value;
if (!token) return null;
const result = await validateSession(token);
// If session was refreshed, also refresh the cookie
if (result && result.sessionRefreshed) {
await refreshSessionCookie(token);
}
return result;
} catch (error) {
console.error('Session validation error:', error);
return null;
}
}
/**
* Request password reset
* @param {FormData} formData - Form data with email
* @returns {Promise<Object>} Result object
*/
export async function forgotPasswordAction(formData) {
try {
const botCheck = validateAntiBotFields(formData);
if (!botCheck.valid) return { success: false, error: botCheck.error };
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'forgot_password');
if (!rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const result = await requestPasswordReset(email);
if (result.token) {
await sendPasswordResetEmail(email, result.token, getPublicBaseUrl());
}
return {
success: true,
message: 'Si un compte existe avec cet e-mail, vous recevrez un lien pour réinitialiser votre mot de passe.'
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Reset password with token
* @param {FormData} formData - Form data with email, token, and newPassword
* @returns {Promise<Object>} Result object
*/
export async function resetPasswordAction(formData) {
try {
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'reset_password');
if (!rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const token = formData.get('token');
const newPassword = formData.get('newPassword');
// Verify token first
const isValid = await verifyResetToken(email, token);
if (!isValid) {
throw new Error('Jeton de réinitialisation invalide ou expiré');
}
await resetPassword({ email, token, newPassword });
return {
success: true,
message: 'Mot de passe réinitialisé avec succès. Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.'
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
/**
* Verify email with token
* @param {FormData} formData - Form data with email and token
* @returns {Promise<Object>} Result object
*/
export async function verifyEmailAction(formData) {
try {
const ip = await getClientIp();
const rl = checkRateLimit(ip, 'verify_email');
if (!rl.allowed) {
return { success: false, error: `Trop de tentatives. Réessayez dans ${formatRetryAfter(rl.retryAfterMs)}.` };
}
const email = formData.get('email');
const token = formData.get('token');
// Verify token
const isValid = await verifyEmailToken(email, token);
if (!isValid) {
throw new Error('Jeton de vérification invalide ou expiré');
}
// Find user and verify
const { findOne } = await import('../../../core/database/crud.js');
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('Utilisateur introuvable');
}
await verifyUserEmail(user.id);
return {
success: true,
message: 'E-mail vérifié avec succès. Vous pouvez maintenant vous connecter.'
};
} catch (error) {
return {
success: false,
error: error.message
};
}
}
@@ -0,0 +1,279 @@
'use client';
/**
* Reusable "Edit account" section: display name, email (read-only), avatar upload/remove.
* Use on a protected dashboard page. Requires session cookie (user must be logged in).
*
* @param {Object} props
* @param {Object} [props.initialUser] - Initial user from server (e.g. getSession().user). If omitted, fetches from API.
* @param {function} [props.onUpdate] - Called after profile or avatar update with the new user object (e.g. to refresh layout)
*/
import React, { useState, useEffect, useRef } from 'react';
import { Card, Input, Button } from '../../../shared/components/index.js';
import { useToast } from '@hykocx/zen/toast';
import { useCurrentUser } from './useCurrentUser.js';
import UserAvatar from './UserAvatar.js';
const API_BASE = '/zen/api';
function getImageUrl(imageKey) {
if (!imageKey) return null;
return `/zen/api/storage/${imageKey}`;
}
export default function AccountSection({ initialUser, onUpdate }) {
const toast = useToast();
const { user: fetchedUser, loading: fetchLoading, refetch } = useCurrentUser();
const user = initialUser ?? fetchedUser;
const [formData, setFormData] = useState({ name: user?.name ?? '' });
const [saving, setSaving] = useState(false);
const [uploadingImage, setUploadingImage] = useState(false);
const [imagePreview, setImagePreview] = useState(null);
const fileInputRef = useRef(null);
useEffect(() => {
if (user) {
setFormData((prev) => ({ ...prev, name: user.name ?? '' }));
setImagePreview(user.image ? getImageUrl(user.image) : null);
}
}, [user]);
const handleNameChange = (value) => {
setFormData((prev) => ({ ...prev, name: value }));
};
const handleSubmit = async (e) => {
e.preventDefault();
if (!formData.name?.trim()) {
toast.error('Le nom est requis');
return;
}
setSaving(true);
try {
const res = await fetch(`${API_BASE}/users/profile`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name: formData.name.trim() }),
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.message || data.error || 'Échec de la mise à jour du profil');
}
toast.success('Profil mis à jour avec succès');
onUpdate?.(data.user);
refetch();
} catch (err) {
toast.error(err.message || 'Échec de la mise à jour du profil');
} finally {
setSaving(false);
}
};
const handleReset = () => {
setFormData({ name: user?.name ?? '' });
};
const handleImageSelect = async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
toast.error('Veuillez sélectionner un fichier image');
return;
}
if (file.size > 5 * 1024 * 1024) {
toast.error("L'image doit faire moins de 5MB");
return;
}
const reader = new FileReader();
reader.onloadend = () => setImagePreview(reader.result);
reader.readAsDataURL(file);
setUploadingImage(true);
try {
const fd = new FormData();
fd.append('file', file);
const res = await fetch(`${API_BASE}/users/profile/picture`, {
method: 'POST',
credentials: 'include',
body: fd,
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.message || data.error || 'Upload failed');
}
setImagePreview(getImageUrl(data.user?.image));
toast.success('Photo de profil mise à jour avec succès');
onUpdate?.(data.user);
refetch();
} catch (err) {
toast.error(err.message || 'Upload failed');
setImagePreview(user?.image ? getImageUrl(user.image) : null);
} finally {
setUploadingImage(false);
}
};
const handleRemoveImage = async () => {
if (!user?.image) return;
setUploadingImage(true);
try {
const res = await fetch(`${API_BASE}/users/profile/picture`, {
method: 'DELETE',
credentials: 'include',
});
const data = await res.json();
if (!res.ok || !data.success) {
throw new Error(data.message || data.error || 'Remove failed');
}
setImagePreview(null);
toast.success('Photo de profil supprimée avec succès');
onUpdate?.(data.user);
refetch();
} catch (err) {
toast.error(err.message || 'Remove failed');
} finally {
setUploadingImage(false);
}
};
const created_at = user?.created_at ?? user?.createdAt;
const hasChanges = formData.name?.trim() !== (user?.name ?? '');
if (fetchLoading && !initialUser) {
return (
<Card variant="lightDark">
<div className="animate-pulse space-y-4">
<div className="h-24 rounded-xl bg-neutral-200 dark:bg-neutral-700" />
<div className="h-32 rounded-xl bg-neutral-200 dark:bg-neutral-700" />
</div>
</Card>
);
}
if (!user) {
return null;
}
return (
<div className="space-y-6">
<Card variant="lightDark">
<div className="space-y-4">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
Photo de profil
</h2>
<div className="flex flex-wrap items-center gap-6">
<div className="relative">
{imagePreview ? (
<img
src={imagePreview}
alt="Profile"
className="w-24 h-24 rounded-full object-cover border-2 border-neutral-200 dark:border-neutral-700"
/>
) : (
<UserAvatar user={user} size="lg" className="w-24 h-24" />
)}
{uploadingImage && (
<div className="absolute inset-0 rounded-full bg-black/50 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-white border-t-transparent rounded-full animate-spin" />
</div>
)}
</div>
<div className="flex flex-col gap-2">
<p className="text-sm text-neutral-500 dark:text-neutral-400">
Téléchargez une nouvelle photo de profil. Taille max 5MB.
</p>
<div className="flex gap-2">
<input
ref={fileInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
className="hidden"
/>
<Button
type="button"
variant="secondary"
onClick={() => fileInputRef.current?.click()}
disabled={uploadingImage}
>
{uploadingImage ? 'Téléchargement...' : 'Télécharger une image'}
</Button>
{imagePreview && (
<Button
type="button"
variant="secondary"
onClick={handleRemoveImage}
disabled={uploadingImage}
>
Supprimer
</Button>
)}
</div>
</div>
</div>
</div>
</Card>
<Card variant="lightDark">
<form onSubmit={handleSubmit} className="space-y-6">
<h2 className="text-lg font-semibold text-neutral-900 dark:text-white">
Informations personnelles
</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<Input
label="Nom complet"
type="text"
value={formData.name}
onChange={handleNameChange}
placeholder="Entrez votre nom complet"
required
disabled={saving}
/>
<Input
label="Courriel"
type="email"
value={user.email ?? ''}
disabled
readOnly
description="L'email ne peut pas être modifié"
/>
</div>
{created_at && (
<Input
label="Compte créé"
type="text"
value={new Date(created_at).toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'long',
day: 'numeric',
})}
disabled
readOnly
/>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-neutral-200 dark:border-neutral-700">
<Button
type="button"
variant="secondary"
onClick={handleReset}
disabled={saving || !hasChanges}
>
Réinitialiser
</Button>
<Button
type="submit"
variant="primary"
disabled={saving || !hasChanges}
loading={saving}
>
{saving ? 'Enregistrement...' : 'Enregistrer les modifications'}
</Button>
</div>
</form>
</Card>
</div>
);
}
+104
View File
@@ -0,0 +1,104 @@
'use client';
/**
* Auth Pages Component - Catch-all route for Next.js App Router
* This component handles all authentication routes: login, register, forgot, reset, confirm
*/
import { useRouter } from 'next/navigation';
import { useEffect, useState } from 'react';
import LoginPage from './pages/LoginPage.js';
import RegisterPage from './pages/RegisterPage.js';
import ForgotPasswordPage from './pages/ForgotPasswordPage.js';
import ResetPasswordPage from './pages/ResetPasswordPage.js';
import ConfirmEmailPage from './pages/ConfirmEmailPage.js';
import LogoutPage from './pages/LogoutPage.js';
export default function AuthPagesClient({
params,
searchParams,
registerAction,
loginAction,
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
logoutAction,
setSessionCookieAction,
redirectAfterLogin = '/',
currentUser = null
}) {
const router = useRouter();
const [currentPage, setCurrentPage] = useState(null); // null = loading
const [isLoading, setIsLoading] = useState(true);
const [email, setEmail] = useState('');
const [token, setToken] = useState('');
useEffect(() => {
// Get page from params or URL
const getPageFromParams = () => {
if (params?.auth?.[0]) {
return params.auth[0];
}
// Fallback: read from URL
if (typeof window !== 'undefined') {
const pathname = window.location.pathname;
const match = pathname.match(/\/auth\/([^\/\?]+)/);
return match ? match[1] : 'login';
}
return 'login';
};
const page = getPageFromParams();
setCurrentPage(page);
setIsLoading(false);
}, [params]);
// Extract email and token from searchParams (handles both Promise and regular object)
useEffect(() => {
const extractSearchParams = async () => {
let resolvedParams = searchParams;
// Check if searchParams is a Promise (Next.js 15+)
if (searchParams && typeof searchParams.then === 'function') {
resolvedParams = await searchParams;
}
// Extract email and token from URL if not in searchParams
if (typeof window !== 'undefined') {
const urlParams = new URLSearchParams(window.location.search);
setEmail(resolvedParams?.email || urlParams.get('email') || '');
setToken(resolvedParams?.token || urlParams.get('token') || '');
} else {
setEmail(resolvedParams?.email || '');
setToken(resolvedParams?.token || '');
}
};
extractSearchParams();
}, [searchParams]);
const navigate = (page) => {
router.push(`/auth/${page}`);
};
// Don't render anything while determining the correct page
if (isLoading || !currentPage) {
return null;
}
// Page components mapping
const pageComponents = {
login: () => <LoginPage onSubmit={loginAction} onNavigate={navigate} onSetSessionCookie={setSessionCookieAction} redirectAfterLogin={redirectAfterLogin} currentUser={currentUser} />,
register: () => <RegisterPage onSubmit={registerAction} onNavigate={navigate} currentUser={currentUser} />,
forgot: () => <ForgotPasswordPage onSubmit={forgotPasswordAction} onNavigate={navigate} currentUser={currentUser} />,
reset: () => <ResetPasswordPage onSubmit={resetPasswordAction} onNavigate={navigate} email={email} token={token} />,
confirm: () => <ConfirmEmailPage onSubmit={verifyEmailAction} onNavigate={navigate} email={email} token={token} />,
logout: () => <LogoutPage onLogout={logoutAction} onSetSessionCookie={setSessionCookieAction} />
};
// Render the appropriate page
const PageComponent = pageComponents[currentPage];
return PageComponent ? <PageComponent /> : <LoginPage onSubmit={loginAction} onNavigate={navigate} onSetSessionCookie={setSessionCookieAction} redirectAfterLogin={redirectAfterLogin} currentUser={currentUser} />;
}
@@ -0,0 +1,19 @@
/**
* Auth Pages Layout - Server Component
* Provides the layout structure for authentication pages
*
* Usage:
* <AuthPagesLayout>
* <AuthPagesClient {...props} />
* </AuthPagesLayout>
*/
export default function AuthPagesLayout({ children }) {
return (
<div className="min-h-screen flex items-center justify-center p-4 md:p-8">
<div className="max-w-md w-full">
{children}
</div>
</div>
);
}
@@ -0,0 +1,55 @@
'use client';
/**
* Displays the current user's avatar (image or initials fallback).
* Image is loaded from /zen/api/storage/{key}; session cookie is sent automatically.
*
* @param {Object} props
* @param {Object} props.user - User object with optional image (storage key) and name
* @param {'sm'|'md'|'lg'} [props.size='md'] - Size of the avatar
* @param {string} [props.className] - Additional CSS classes for the wrapper
*/
function getImageUrl(imageKey) {
if (!imageKey) return null;
return `/zen/api/storage/${imageKey}`;
}
function getInitials(name) {
if (!name || !name.trim()) return '?';
return name
.trim()
.split(/\s+/)
.map((n) => n[0])
.join('')
.toUpperCase()
.slice(0, 2);
}
const sizeClasses = {
sm: 'w-8 h-8 text-xs',
md: 'w-10 h-10 text-sm',
lg: 'w-12 h-12 text-base',
};
export default function UserAvatar({ user, size = 'md', className = '' }) {
const sizeClass = sizeClasses[size] || sizeClasses.md;
const imageUrl = user?.image ? getImageUrl(user.image) : null;
return (
<div
className={`rounded-full overflow-hidden flex items-center justify-center bg-neutral-700 text-white font-medium shrink-0 ${sizeClass} ${className}`}
aria-hidden
>
{imageUrl ? (
<img
src={imageUrl}
alt={user?.name ? `${user.name} avatar` : 'Avatar'}
className="w-full h-full object-cover"
/>
) : (
<span>{getInitials(user?.name)}</span>
)}
</div>
);
}
+90
View File
@@ -0,0 +1,90 @@
'use client';
/**
* User menu: avatar + name with optional dropdown (account link, logout).
* Can receive user from server (e.g. from getSession()) or use useCurrentUser() on client.
*
* @param {Object} props
* @param {Object} [props.user] - User from server (getSession().user). If not provided, useCurrentUser() is used.
* @param {string} [props.accountHref='/dashboard/account'] - Link for "My account"
* @param {string} [props.logoutHref='/auth/logout'] - Link for logout
* @param {string} [props.className] - Extra classes for the menu wrapper
*/
import { Fragment } from 'react';
import { Menu, Transition } from '@headlessui/react';
import UserAvatar from './UserAvatar.js';
import { useCurrentUser } from './useCurrentUser.js';
export default function UserMenu({
user: userProp,
accountHref = '/dashboard/account',
logoutHref = '/auth/logout',
className = '',
}) {
const { user: userFromHook, loading } = useCurrentUser();
const user = userProp ?? userFromHook;
if (loading && !userProp) {
return (
<div className={`flex items-center gap-2 ${className}`}>
<div className="w-10 h-10 rounded-full bg-neutral-700 animate-pulse" />
<div className="h-4 w-24 bg-neutral-700 rounded animate-pulse" />
</div>
);
}
if (!user) {
return null;
}
return (
<Menu as="div" className={`relative ${className}`}>
<Menu.Button className="flex items-center gap-2 sm:gap-3 px-2 py-1.5 rounded-lg hover:bg-black/10 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-neutral-400 transition-colors">
<UserAvatar user={user} size="md" />
<span className="text-sm font-medium text-inherit truncate max-w-[120px] sm:max-w-[160px]">
{user.name || user.email || 'Account'}
</span>
<svg className="w-4 h-4 text-neutral-500 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</Menu.Button>
<Transition
as={Fragment}
enter="transition ease-out duration-100"
enterFrom="opacity-0 scale-95"
enterTo="opacity-100 scale-100"
leave="transition ease-in duration-75"
leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95"
>
<Menu.Items className="absolute right-0 mt-2 w-56 origin-top-right rounded-lg bg-white dark:bg-neutral-900 shadow-lg border border-neutral-200 dark:border-neutral-700 py-1 focus:outline-none z-50">
<div className="px-4 py-3 border-b border-neutral-200 dark:border-neutral-700">
<p className="text-sm font-medium text-neutral-900 dark:text-white truncate">{user.name || 'User'}</p>
<p className="text-xs text-neutral-500 dark:text-neutral-400 truncate">{user.email}</p>
</div>
<Menu.Item>
{({ active }) => (
<a
href={accountHref}
className={`block px-4 py-2 text-sm ${active ? 'bg-neutral-100 dark:bg-neutral-800' : ''} text-neutral-700 dark:text-neutral-300`}
>
My account
</a>
)}
</Menu.Item>
<Menu.Item>
{({ active }) => (
<a
href={logoutHref}
className={`block px-4 py-2 text-sm ${active ? 'bg-neutral-100 dark:bg-neutral-800' : ''} text-neutral-700 dark:text-neutral-300`}
>
Log out
</a>
)}
</Menu.Item>
</Menu.Items>
</Transition>
</Menu>
);
}
+43
View File
@@ -0,0 +1,43 @@
/**
* Auth Components Export
*
* Use these components to build custom auth pages for every flow (login, register, forgot,
* reset, confirm, logout) so they match your site's style.
* For a ready-made catch-all auth UI, use AuthPagesClient from '@hykocx/zen/auth/pages'.
* For the default full-page auth (no custom layout), re-export from '@hykocx/zen/auth/page'.
*
* --- Custom auth pages (all types) ---
*
* Pattern: server component loads session/searchParams and passes actions to a client wrapper;
* client wrapper uses useRouter for onNavigate and renders the Zen component.
*
* Component props:
* - LoginPage: onSubmit (loginAction), onSetSessionCookie, onNavigate, redirectAfterLogin, currentUser
* - RegisterPage: onSubmit (registerAction), onNavigate, currentUser
* - ForgotPasswordPage: onSubmit (forgotPasswordAction), onNavigate, currentUser
* - ResetPasswordPage: onSubmit (resetPasswordAction), onNavigate, email, token (from URL)
* - ConfirmEmailPage: onSubmit (verifyEmailAction), onNavigate, email, token (from URL)
* - LogoutPage: onLogout (logoutAction), onSetSessionCookie (optional)
*
* onNavigate receives 'login' | 'register' | 'forgot' | 'reset'. Map to your routes (e.g. /auth/${page}).
* For reset/confirm, pass email and token from searchParams. Full guide: see README-custom-login.md in this package.
* Protect routes with protect() from '@hykocx/zen/auth', redirectTo your login path.
*
* --- Dashboard / user display ---
*
* UserAvatar, UserMenu, AccountSection, useCurrentUser: see README-dashboard.md.
*/
export { default as AuthPagesLayout } from './AuthPagesLayout.js';
export { default as AuthPagesClient } from './AuthPages.js';
export { default as LoginPage } from './pages/LoginPage.js';
export { default as RegisterPage } from './pages/RegisterPage.js';
export { default as ForgotPasswordPage } from './pages/ForgotPasswordPage.js';
export { default as ResetPasswordPage } from './pages/ResetPasswordPage.js';
export { default as ConfirmEmailPage } from './pages/ConfirmEmailPage.js';
export { default as LogoutPage } from './pages/LogoutPage.js';
export { default as UserAvatar } from './UserAvatar.js';
export { default as UserMenu } from './UserMenu.js';
export { default as AccountSection } from './AccountSection.js';
export { useCurrentUser } from './useCurrentUser.js';
@@ -0,0 +1,162 @@
'use client';
/**
* Confirm Email Page Component
*/
import { useState, useEffect, useRef } from 'react';
export default function ConfirmEmailPage({ onSubmit, onNavigate, email, token }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(true);
const [success, setSuccess] = useState('');
const [hasVerified, setHasVerified] = useState(false);
const isVerifyingRef = useRef(false);
useEffect(() => {
console.log('ConfirmEmailPage useEffect triggered', { email, token, hasVerified });
// Check for persisted success message on mount
const persistedSuccess = sessionStorage.getItem('emailVerificationSuccess');
console.log('Persisted success message:', persistedSuccess);
if (persistedSuccess) {
console.log('Restoring persisted success message');
setSuccess(persistedSuccess);
setIsLoading(false);
setHasVerified(true); // Mark as verified to prevent re-verification
// Clear the persisted message after showing it
sessionStorage.removeItem('emailVerificationSuccess');
// Redirect after showing the message
setTimeout(() => {
onNavigate('login');
}, 3000);
return;
}
// Auto-verify on mount, but only once
if (email && token && !hasVerified && !isVerifyingRef.current) {
console.log('Starting email verification');
verifyEmail();
} else if (!email || !token) {
console.log('Invalid email or token');
setError('Lien de vérification invalide');
setIsLoading(false);
}
}, [email, token, hasVerified, onNavigate]);
async function verifyEmail() {
// Prevent multiple calls
if (hasVerified || isVerifyingRef.current) {
console.log('Email verification already attempted or in progress');
return;
}
// Set flags IMMEDIATELY to prevent multiple calls
isVerifyingRef.current = true;
setHasVerified(true);
// Clear any existing states at the start
setError('');
setSuccess('');
console.log('Starting email verification for:', email);
const formData = new FormData();
formData.set('email', email);
formData.set('token', token);
try {
const result = await onSubmit(formData);
console.log('Verification result:', result);
if (result.success) {
console.log('Verification successful');
const successMessage = result.message || 'E-mail vérifié avec succès. Vous pouvez maintenant vous connecter.';
// Persist success message in sessionStorage
sessionStorage.setItem('emailVerificationSuccess', successMessage);
setSuccess(successMessage);
setIsLoading(false);
// Redirect to login after 3 seconds
setTimeout(() => {
onNavigate('login');
}, 3000);
} else {
console.log('Verification failed:', result.error);
setError(result.error || 'Échec de la vérification de l\'e-mail');
setIsLoading(false);
}
} catch (err) {
console.error('Email verification error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
console.log('ConfirmEmailPage render', { success, error, isLoading, hasVerified });
return (
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Vérification de l'e-mail
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Nous vérifions votre adresse e-mail...
</p>
</div>
{isLoading && (
<div className="flex flex-col items-center py-10">
<div className="w-12 h-12 border-4 border-neutral-300 border-t-neutral-900 rounded-full animate-spin dark:border-neutral-700/50 dark:border-t-white"></div>
<p className="mt-5 text-neutral-600 dark:text-neutral-400 text-sm">Vérification de votre e-mail en cours...</p>
</div>
)}
{/* Success Message - Only show if success and no error */}
{success && !error && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{/* Error Message - Only show if error and no success */}
{error && !success && (
<div>
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-800/50 text-center">
<a
href="#"
onClick={(e) => {
e.preventDefault();
onNavigate('login');
}}
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
>
Retour à la connexion
</a>
</div>
</div>
)}
{/* Redirect message - Only show if success and no error */}
{success && !error && (
<p className="text-center text-neutral-600 dark:text-neutral-400 text-sm mt-3">Redirection vers la connexion...</p>
)}
</div>
);
}
@@ -0,0 +1,174 @@
'use client';
/**
* Forgot Password Page Component
*/
import { useState, useEffect } from 'react';
export default function ForgotPasswordPage({ onSubmit, onNavigate, currentUser = null }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [success, setSuccess] = useState('');
const [formData, setFormData] = useState({
email: ''
});
const [honeypot, setHoneypot] = useState('');
const [formLoadedAt, setFormLoadedAt] = useState(0);
useEffect(() => {
setFormLoadedAt(Date.now());
}, []);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
async function handleSubmit(e) {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
const submitData = new FormData();
submitData.append('email', formData.email);
submitData.append('_hp', honeypot);
submitData.append('_t', String(formLoadedAt));
try {
const result = await onSubmit(submitData);
if (result.success) {
setSuccess(result.message);
setIsLoading(false);
} else {
setError(result.error || 'Échec de l\'envoi de l\'e-mail de réinitialisation');
setIsLoading(false);
}
} catch (err) {
console.error('Forgot password error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
return (
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Mot de passe oublié
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Entrez votre adresse e-mail et nous vous enverrons un lien pour réinitialiser votre mot de passe.
</p>
</div>
{/* Already Connected Message */}
{currentUser && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-blue-700 dark:text-blue-400">
Vous êtes connecté en tant que <span className="font-medium">{currentUser.name}</span>.{' '}
<a
href="/auth/logout"
className="underline hover:text-blue-600 dark:hover:text-blue-300 transition-colors duration-200"
>
Se déconnecter ?
</a>
</span>
</div>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{/* Forgot Password Form */}
<form onSubmit={handleSubmit} className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
{/* Honeypot — invisible to humans, filled by bots */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
<label htmlFor="_hp_forgot">Website</label>
<input id="_hp_forgot" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
</div>
<div>
<label htmlFor="email" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
E-mail
</label>
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="your@email.com"
autoComplete="email"
/>
</div>
<button
type="submit"
disabled={isLoading || success || currentUser}
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
<span>Envoi en cours...</span>
</div>
) : (
'Envoyer le lien de réinitialisation'
)}
</button>
</form>
{/* Back to Login Link */}
<div className={`mt-6 text-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<a
href="#"
onClick={(e) => {
e.preventDefault();
if (!currentUser) {
onNavigate('login');
}
}}
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
>
Retour à la connexion
</a>
</div>
</div>
);
}
@@ -0,0 +1,228 @@
'use client';
/**
* Login Page Component
*/
import { useState, useEffect } from 'react';
import { useRouter } from 'next/navigation';
export default function LoginPage({ onSubmit, onNavigate, onSetSessionCookie, redirectAfterLogin = '/', currentUser = null }) {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [formData, setFormData] = useState({
email: '',
password: ''
});
const [honeypot, setHoneypot] = useState('');
const [formLoadedAt, setFormLoadedAt] = useState(0);
const router = useRouter();
useEffect(() => {
setFormLoadedAt(Date.now());
}, []);
// If already logged in, redirect to redirectAfterLogin
useEffect(() => {
if (currentUser) {
router.replace(redirectAfterLogin);
}
}, [currentUser, redirectAfterLogin, router]);
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
const handleKeyPress = (e) => {
if (e.key === 'Enter' && !isLoading && !success) {
handleSubmit();
}
};
const handleSubmit = async () => {
setError('');
setSuccess('');
setIsLoading(true);
const submitData = new FormData();
submitData.append('email', formData.email);
submitData.append('password', formData.password);
submitData.append('_hp', honeypot);
submitData.append('_t', String(formLoadedAt));
try {
const result = await onSubmit(submitData);
if (result.success) {
const successMsg = result.message || 'Connexion réussie ! Redirection...';
// Display success message immediately (no page refresh because we didn't set cookie yet)
setSuccess(successMsg);
setIsLoading(false);
// Wait for user to see the success message
setTimeout(async () => {
// Now set the session cookie (this might cause a refresh, but we're redirecting anyway)
if (result.sessionToken && onSetSessionCookie) {
await onSetSessionCookie(result.sessionToken);
}
// Then navigate
router.push(redirectAfterLogin);
}, 1500);
} else {
setError(result.error || 'Échec de la connexion');
setIsLoading(false);
}
} catch (err) {
console.error('Login error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
};
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
return (
<div className="bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Connexion
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Veuillez vous connecter pour continuer.
</p>
</div>
{/* Already logged in: redirecting (brief message while redirect runs) */}
{currentUser && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
<div className="flex items-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-blue-400/50 border-t-blue-600 rounded-full animate-spin flex-shrink-0 dark:border-blue-500/30 dark:border-t-blue-500"></div>
<span className="text-xs text-blue-700 dark:text-blue-400">Redirection...</span>
</div>
</div>
)}
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{/* Login Form */}
<div className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
{/* Honeypot — invisible to humans, filled by bots */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
<label htmlFor="_hp_login">Website</label>
<input id="_hp_login" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
</div>
<div>
<label htmlFor="email" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
E-mail
</label>
<input
id="email"
name="email"
type="email"
required
value={formData.email}
onChange={handleChange}
onKeyDown={handleKeyPress}
disabled={success || currentUser}
className={inputClasses}
placeholder="your@email.com"
/>
</div>
<div>
<div className="flex items-center justify-between mb-1.5">
<label htmlFor="password" className="block text-xs font-medium text-neutral-700 dark:text-white">
Mot de passe
</label>
<a
href="#"
onClick={(e) => {
e.preventDefault();
if (!currentUser) {
onNavigate('forgot');
}
}}
className="text-xs text-neutral-600 hover:text-neutral-900 transition-colors duration-200 dark:text-neutral-300 dark:hover:text-white"
>
Mot de passe oublié ?
</a>
</div>
<input
id="password"
name="password"
type="password"
required
value={formData.password}
onChange={handleChange}
onKeyDown={handleKeyPress}
disabled={success || currentUser}
className={inputClasses}
placeholder="••••••••"
/>
</div>
<button
type="button"
onClick={handleSubmit}
disabled={isLoading || success || currentUser}
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
<span>Connexion en cours...</span>
</div>
) : (
'Se connecter'
)}
</button>
</div>
{/* Register Link */}
<div className={`mt-6 text-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<a
href="#"
onClick={(e) => {
e.preventDefault();
if (!currentUser) {
onNavigate('register');
}
}}
className="group flex items-center justify-center gap-2"
>
<span className="text-sm text-neutral-600 dark:text-neutral-400">Pas de compte ? </span>
<span className="text-sm text-neutral-900 group-hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:group-hover:text-neutral-300">S'inscrire</span>
</a>
</div>
</div>
);
}
@@ -0,0 +1,117 @@
'use client';
/**
* Logout Page Component
*/
import { useState } from 'react';
import { useRouter } from 'next/navigation';
export default function LogoutPage({ onLogout, onSetSessionCookie }) {
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const handleLogout = async () => {
setError('');
setSuccess('');
setIsLoading(true);
try {
// Call the logout action if provided
if (onLogout) {
const result = await onLogout();
if (result && !result.success) {
setError(result.error || 'Échec de la déconnexion');
setIsLoading(false);
return;
}
}
// Clear session cookie if provided
if (onSetSessionCookie) {
await onSetSessionCookie('', { expires: new Date(0) });
}
// Show success message
setSuccess('Vous avez été déconnecté. Redirection...');
setIsLoading(false);
// Wait for user to see the success message, then redirect
setTimeout(() => {
router.push('/');
}, 100);
} catch (err) {
console.error('Logout error:', err);
setError('Une erreur inattendue s\'est produite lors de la déconnexion');
setIsLoading(false);
}
};
return (
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Prêt à vous déconnecter ?
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Cela mettra fin à votre session et vous déconnectera de votre compte.
</p>
</div>
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{/* Logout Button */}
<div className="flex flex-col gap-4">
<button
type="button"
onClick={handleLogout}
disabled={isLoading || success}
className="cursor-pointer w-full bg-red-600 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-red-700 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-red-500/20 dark:focus:ring-red-400/30"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
<span>Déconnexion en cours...</span>
</div>
) : (
'Se déconnecter'
)}
</button>
</div>
{/* Cancel Link */}
<div className="mt-6 text-center">
<span className="text-sm text-neutral-600 dark:text-neutral-400">Vous avez changé d'avis ? </span>
<a
href="/"
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
>
Retour
</a>
</div>
</div>
);
}
@@ -0,0 +1,337 @@
'use client';
/**
* Register Page Component
*/
import { useState, useEffect } from 'react';
import { PasswordStrengthIndicator } from '../../../../shared/components';
export default function RegisterPage({ onSubmit, onNavigate, currentUser = null }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [success, setSuccess] = useState('');
const [formData, setFormData] = useState({
name: '',
email: '',
password: '',
confirmPassword: ''
});
const [honeypot, setHoneypot] = useState('');
const [formLoadedAt, setFormLoadedAt] = useState(0);
useEffect(() => {
setFormLoadedAt(Date.now());
}, []);
// Validation functions
const validateEmail = (email) => {
const errors = [];
if (email.length > 254) {
errors.push('L\'e-mail doit contenir 254 caractères ou moins');
}
return errors;
};
const validatePassword = (password) => {
const errors = [];
if (password.length < 8) {
errors.push('Le mot de passe doit contenir au moins 8 caractères');
}
if (password.length > 128) {
errors.push('Le mot de passe doit contenir 128 caractères ou moins');
}
if (!/[A-Z]/.test(password)) {
errors.push('Le mot de passe doit contenir au moins une majuscule');
}
if (!/[a-z]/.test(password)) {
errors.push('Le mot de passe doit contenir au moins une minuscule');
}
if (!/\d/.test(password)) {
errors.push('Le mot de passe doit contenir au moins un chiffre');
}
return errors;
};
const validateName = (name) => {
const errors = [];
if (name.trim().length === 0) {
errors.push('Le nom ne peut pas être vide');
}
if (name.length > 100) {
errors.push('Le nom doit contenir 100 caractères ou moins');
}
return errors;
};
const isFormValid = () => {
const emailErrors = validateEmail(formData.email);
const passwordErrors = validatePassword(formData.password);
const nameErrors = validateName(formData.name);
return emailErrors.length === 0 &&
passwordErrors.length === 0 &&
nameErrors.length === 0 &&
formData.password === formData.confirmPassword &&
formData.email.trim().length > 0;
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
async function handleSubmit(e) {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
// Frontend validation
const emailErrors = validateEmail(formData.email);
const passwordErrors = validatePassword(formData.password);
const nameErrors = validateName(formData.name);
if (emailErrors.length > 0) {
setError(emailErrors[0]); // Show first error
setIsLoading(false);
return;
}
if (passwordErrors.length > 0) {
setError(passwordErrors[0]); // Show first error
setIsLoading(false);
return;
}
if (nameErrors.length > 0) {
setError(nameErrors[0]); // Show first error
setIsLoading(false);
return;
}
// Validate password match
if (formData.password !== formData.confirmPassword) {
setError('Les mots de passe ne correspondent pas');
setIsLoading(false);
return;
}
const submitData = new FormData();
submitData.append('name', formData.name);
submitData.append('email', formData.email);
submitData.append('password', formData.password);
submitData.append('confirmPassword', formData.confirmPassword);
submitData.append('_hp', honeypot);
submitData.append('_t', String(formLoadedAt));
try {
const result = await onSubmit(submitData);
if (result.success) {
setSuccess(result.message);
setIsLoading(false);
} else {
setError(result.error || 'Échec de l\'inscription');
setIsLoading(false);
}
} catch (err) {
console.error('Registration error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
return (
<div className="bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Créer un compte
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Inscrivez-vous pour commencer.
</p>
</div>
{/* Already Connected Message */}
{currentUser && (
<div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg dark:bg-blue-500/10 dark:border-blue-500/20">
<div className="flex items-center justify-between">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-blue-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-blue-700 dark:text-blue-400">
Vous êtes connecté en tant que <span className="font-medium">{currentUser.name}</span>.{' '}
<a
href="/auth/logout"
className="underline hover:text-blue-600 dark:hover:text-blue-300 transition-colors duration-200"
>
Se déconnecter ?
</a>
</span>
</div>
</div>
</div>
)}
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{/* Registration Form */}
<form onSubmit={handleSubmit} className={`flex flex-col gap-4 transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
{/* Honeypot — invisible to humans, filled by bots */}
<div aria-hidden="true" style={{ position: 'absolute', left: '-9999px', top: '-9999px', width: 0, height: 0, overflow: 'hidden' }}>
<label htmlFor="_hp_register">Website</label>
<input id="_hp_register" name="_hp" type="text" tabIndex={-1} autoComplete="off" value={honeypot} onChange={(e) => setHoneypot(e.target.value)} />
</div>
<div>
<label htmlFor="name" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Nom complet
</label>
<input
id="name"
name="name"
type="text"
required
maxLength="100"
value={formData.name}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="John Doe"
autoComplete="name"
/>
</div>
<div>
<label htmlFor="email" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
E-mail
</label>
<input
id="email"
name="email"
type="email"
required
maxLength="254"
value={formData.email}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="your@email.com"
autoComplete="email"
/>
</div>
<div>
<label htmlFor="password" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Mot de passe
</label>
<input
id="password"
name="password"
type="password"
required
minLength="8"
maxLength="128"
value={formData.password}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="••••••••"
autoComplete="new-password"
/>
<PasswordStrengthIndicator password={formData.password} showRequirements={true} />
</div>
<div>
<label htmlFor="confirmPassword" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Confirmer le mot de passe
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength="8"
maxLength="128"
value={formData.confirmPassword}
onChange={handleChange}
disabled={success || currentUser}
className={inputClasses}
placeholder="••••••••"
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={isLoading || success || currentUser || !isFormValid()}
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
<span>Création du compte en cours...</span>
</div>
) : (
'Créer un compte'
)}
</button>
</form>
{/* Login Link */}
<div className={`mt-6 text-center transition-opacity duration-200 ${currentUser ? 'opacity-50 pointer-events-none' : ''}`}>
<a
href="#"
onClick={(e) => {
e.preventDefault();
if (!currentUser) {
onNavigate('login');
}
}}
className="group flex items-center justify-center gap-2"
>
<span className="text-sm text-neutral-600 dark:text-neutral-400">Vous avez déjà un compte ? </span>
<span className="text-sm text-neutral-900 group-hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:group-hover:text-neutral-300">Se connecter</span>
</a>
</div>
</div>
);
}
@@ -0,0 +1,222 @@
'use client';
/**
* Reset Password Page Component
*/
import { useState } from 'react';
import { PasswordStrengthIndicator } from '../../../../shared/components';
export default function ResetPasswordPage({ onSubmit, onNavigate, email, token }) {
const [error, setError] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [success, setSuccess] = useState('');
const [formData, setFormData] = useState({
newPassword: '',
confirmPassword: ''
});
// Validation functions
const validatePassword = (password) => {
const errors = [];
if (password.length < 8) {
errors.push('Le mot de passe doit contenir au moins 8 caractères');
}
if (password.length > 128) {
errors.push('Le mot de passe doit contenir 128 caractères ou moins');
}
if (!/[A-Z]/.test(password)) {
errors.push('Le mot de passe doit contenir au moins une majuscule');
}
if (!/[a-z]/.test(password)) {
errors.push('Le mot de passe doit contenir au moins une minuscule');
}
if (!/\d/.test(password)) {
errors.push('Le mot de passe doit contenir au moins un chiffre');
}
return errors;
};
const isFormValid = () => {
const passwordErrors = validatePassword(formData.newPassword);
return passwordErrors.length === 0 &&
formData.newPassword === formData.confirmPassword &&
formData.newPassword.length > 0;
};
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({
...prev,
[name]: value
}));
};
async function handleSubmit(e) {
e.preventDefault();
setError('');
setSuccess('');
setIsLoading(true);
// Frontend validation
const passwordErrors = validatePassword(formData.newPassword);
if (passwordErrors.length > 0) {
setError(passwordErrors[0]); // Show first error
setIsLoading(false);
return;
}
// Validate password match
if (formData.newPassword !== formData.confirmPassword) {
setError('Les mots de passe ne correspondent pas');
setIsLoading(false);
return;
}
const submitData = new FormData();
submitData.append('newPassword', formData.newPassword);
submitData.append('confirmPassword', formData.confirmPassword);
submitData.append('email', email);
submitData.append('token', token);
try {
const result = await onSubmit(submitData);
if (result.success) {
setSuccess(result.message);
setIsLoading(false);
// Redirect to login after 2 seconds
setTimeout(() => {
onNavigate('login');
}, 2000);
} else {
setError(result.error || 'Échec de la réinitialisation du mot de passe');
setIsLoading(false);
}
} catch (err) {
console.error('Reset password error:', err);
setError('Une erreur inattendue s\'est produite');
setIsLoading(false);
}
}
const inputClasses = 'w-full px-3 py-2.5 rounded-lg text-sm focus:outline-none transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed bg-white border border-neutral-300 text-neutral-900 placeholder-neutral-400 focus:border-neutral-500 focus:ring-1 focus:ring-neutral-500/20 dark:bg-neutral-900 dark:border-neutral-700/50 dark:text-white dark:placeholder-neutral-500 dark:focus:border-neutral-600 dark:focus:ring-neutral-600/20';
return (
<div className="group bg-white/80 dark:bg-neutral-900/40 backdrop-blur-sm border border-neutral-200/80 dark:border-neutral-800/50 rounded-2xl px-4 py-6 md:px-6 md:py-8 hover:bg-neutral-50/90 hover:border-neutral-300/80 dark:hover:bg-neutral-900/50 dark:hover:border-neutral-700/50 transition-all duration-300 w-full max-w-md">
{/* Header */}
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-neutral-900 dark:text-white mb-2">
Réinitialiser le mot de passe
</h1>
<p className="text-sm text-neutral-600 dark:text-neutral-400">
Saisissez votre nouveau mot de passe ci-dessous.
</p>
</div>
{/* Error Message */}
{error && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg dark:bg-red-500/10 dark:border-red-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-red-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-red-700 dark:text-red-400">{error}</span>
</div>
</div>
)}
{/* Success Message */}
{success && (
<div className="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg dark:bg-green-500/10 dark:border-green-500/20">
<div className="flex items-center space-x-2">
<div className="w-1.5 h-1.5 bg-green-500 rounded-full flex-shrink-0"></div>
<span className="text-xs text-green-700 dark:text-green-400">{success}</span>
</div>
</div>
)}
{/* Reset Password Form */}
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<label htmlFor="newPassword" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Nouveau mot de passe
</label>
<input
id="newPassword"
name="newPassword"
type="password"
required
minLength="8"
maxLength="128"
value={formData.newPassword}
onChange={handleChange}
disabled={success}
className={inputClasses}
placeholder="••••••••"
autoComplete="new-password"
/>
<PasswordStrengthIndicator password={formData.newPassword} showRequirements={true} />
</div>
<div>
<label htmlFor="confirmPassword" className="block text-xs font-medium text-neutral-700 dark:text-white mb-1.5">
Confirmer le mot de passe
</label>
<input
id="confirmPassword"
name="confirmPassword"
type="password"
required
minLength="8"
maxLength="128"
value={formData.confirmPassword}
onChange={handleChange}
disabled={success}
className={inputClasses}
placeholder="••••••••"
autoComplete="new-password"
/>
</div>
<button
type="submit"
disabled={isLoading || success || !isFormValid()}
className="cursor-pointer w-full bg-neutral-900 text-white mt-2 py-2.5 px-4 rounded-lg text-sm font-medium hover:bg-neutral-800 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-200 focus:outline-none focus:ring-1 focus:ring-neutral-900/20 dark:bg-white dark:text-black dark:hover:bg-neutral-100 dark:focus:ring-white/20"
>
{isLoading ? (
<div className="flex items-center justify-center space-x-2">
<div className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin dark:border-black/20 dark:border-t-black"></div>
<span>Réinitialisation...</span>
</div>
) : (
'Réinitialiser le mot de passe'
)}
</button>
</form>
{/* Back to Login Link */}
<div className="mt-6 text-center">
<a
href="#"
onClick={(e) => {
e.preventDefault();
onNavigate('login');
}}
className="text-sm text-neutral-900 hover:text-neutral-600 font-medium transition-colors duration-200 dark:text-white dark:hover:text-neutral-300"
>
Retour à la connexion
</a>
</div>
</div>
);
}
@@ -0,0 +1,66 @@
'use client';
/**
* Client hook to fetch the current user from the API.
* Uses session cookie (credentials: 'include'); safe to use in client components.
*
* @returns {{ user: Object|null, loading: boolean, error: string|null, refetch: function }}
*
* @example
* const { user, loading, error, refetch } = useCurrentUser();
* if (loading) return <Spinner />;
* if (error) return <div>Error: {error}</div>;
* if (!user) return <Link href="/auth/login">Log in</Link>;
* return <span>Hello, {user.name}</span>;
*/
import { useState, useEffect, useCallback } from 'react';
const API_BASE = '/zen/api';
export function useCurrentUser() {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const fetchUser = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await fetch(`${API_BASE}/users/me`, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
});
const data = await res.json();
if (!res.ok) {
if (res.status === 401) {
setUser(null);
return;
}
setError(data.message || data.error || 'Failed to load user');
setUser(null);
return;
}
if (data.user) {
setUser(data.user);
} else {
setUser(null);
}
} catch (err) {
console.error('[useCurrentUser]', err);
setError(err.message || 'Failed to load user');
setUser(null);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchUser();
}, [fetchUser]);
return { user, loading, error, refetch: fetchUser };
}
+66
View File
@@ -0,0 +1,66 @@
/**
* Zen Authentication Module - Server-side utilities
*
* For client components, use '@hykocx/zen/auth/pages'
* For server actions, use '@hykocx/zen/auth/actions'
*/
// Authentication library (server-side only)
export {
register,
login,
requestPasswordReset,
resetPassword,
verifyUserEmail,
updateUser
} from './lib/auth.js';
// Session management (server-side only)
export {
createSession,
validateSession,
deleteSession,
deleteUserSessions,
refreshSession
} from './lib/session.js';
// Email utilities (server-side only)
export {
createEmailVerification,
verifyEmailToken,
createPasswordReset,
verifyResetToken,
deleteResetToken,
sendVerificationEmail,
sendPasswordResetEmail,
sendPasswordChangedEmail
} from './lib/email.js';
// Password utilities (server-side only)
export {
hashPassword,
verifyPassword,
generateToken,
generateId
} from './lib/password.js';
// Middleware (server-side only)
export {
protect,
checkAuth,
requireRole
} from './middleware/protect.js';
// Server Actions (server-side only)
export {
registerAction,
loginAction,
logoutAction,
getSession,
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
setSessionCookie,
refreshSessionCookie
} from './actions/authActions.js';
+295
View File
@@ -0,0 +1,295 @@
/**
* Authentication Logic
* Main authentication functions for user registration, login, and password management
*/
import { create, findOne, updateById, count } from '../../../core/database/crud.js';
import { hashPassword, verifyPassword, generateId } from './password.js';
import { createSession } from './session.js';
import { createEmailVerification, createPasswordReset, deleteResetToken, sendPasswordChangedEmail } from './email.js';
/**
* Register a new user
* @param {Object} userData - User registration data
* @param {string} userData.email - User email
* @param {string} userData.password - User password
* @param {string} userData.name - User name
* @returns {Promise<Object>} Created user and session
*/
async function register(userData) {
const { email, password, name } = userData;
// Validate required fields
if (!email || !password || !name) {
throw new Error('L\'e-mail, le mot de passe et le nom sont requis');
}
// Validate email length (maximum 254 characters - RFC standard)
if (email.length > 254) {
throw new Error('L\'e-mail doit contenir 254 caractères ou moins');
}
// Validate password length (minimum 8, maximum 128 characters)
if (password.length < 8) {
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
}
if (password.length > 128) {
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
}
// Validate password complexity (1 uppercase, 1 lowercase, 1 number)
const hasUppercase = /[A-Z]/.test(password);
const hasLowercase = /[a-z]/.test(password);
const hasNumber = /\d/.test(password);
if (!hasUppercase || !hasLowercase || !hasNumber) {
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
}
// Validate name length (maximum 100 characters)
if (name.length > 100) {
throw new Error('Le nom doit contenir 100 caractères ou moins');
}
// Validate name is not empty after trimming
if (name.trim().length === 0) {
throw new Error('Le nom ne peut pas être vide');
}
// Check if user already exists
const existingUser = await findOne('zen_auth_users', { email });
if (existingUser) {
throw new Error('Un utilisateur avec cet e-mail existe déjà');
}
// Check if this is the first user - if so, make them admin
const userCount = await count('zen_auth_users');
const role = userCount === 0 ? 'admin' : 'user';
// Hash password
const hashedPassword = await hashPassword(password);
// Create user
const userId = generateId();
const user = await create('zen_auth_users', {
id: userId,
email,
name,
email_verified: false,
image: null,
role,
updated_at: new Date()
});
// Create account with password
const accountId = generateId();
await create('zen_auth_accounts', {
id: accountId,
account_id: email,
provider_id: 'credential',
user_id: user.id,
password: hashedPassword,
updated_at: new Date()
});
// Create email verification token
const verification = await createEmailVerification(email);
return {
user,
verificationToken: verification.token
};
}
/**
* Login a user
* @param {Object} credentials - Login credentials
* @param {string} credentials.email - User email
* @param {string} credentials.password - User password
* @param {Object} sessionOptions - Session options (ipAddress, userAgent)
* @returns {Promise<Object>} User and session
*/
async function login(credentials, sessionOptions = {}) {
const { email, password } = credentials;
// Validate required fields
if (!email || !password) {
throw new Error('L\'e-mail et le mot de passe sont requis');
}
// Find user
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('E-mail ou mot de passe incorrect');
}
// Find account with password
const account = await findOne('zen_auth_accounts', {
user_id: user.id,
provider_id: 'credential'
});
if (!account || !account.password) {
throw new Error('E-mail ou mot de passe incorrect');
}
// Verify password
const isValid = await verifyPassword(password, account.password);
if (!isValid) {
throw new Error('E-mail ou mot de passe incorrect');
}
// Create session
const session = await createSession(user.id, sessionOptions);
return {
user,
session
};
}
/**
* Request a password reset
* @param {string} email - User email
* @returns {Promise<Object>} Reset token
*/
async function requestPasswordReset(email) {
// Validate email
if (!email) {
throw new Error('L\'e-mail est requis');
}
// Check if user exists
const user = await findOne('zen_auth_users', { email });
if (!user) {
// Don't reveal if user exists or not
return { success: true };
}
// Create password reset token
const reset = await createPasswordReset(email);
return {
success: true,
token: reset.token
};
}
/**
* Reset password with token
* @param {Object} resetData - Reset data
* @param {string} resetData.email - User email
* @param {string} resetData.token - Reset token
* @param {string} resetData.newPassword - New password
* @returns {Promise<Object>} Success status
*/
async function resetPassword(resetData) {
const { email, token, newPassword } = resetData;
// Validate required fields
if (!email || !token || !newPassword) {
throw new Error('L\'e-mail, le jeton et le nouveau mot de passe sont requis');
}
// Validate password length (minimum 8, maximum 128 characters)
if (newPassword.length < 8) {
throw new Error('Le mot de passe doit contenir au moins 8 caractères');
}
if (newPassword.length > 128) {
throw new Error('Le mot de passe doit contenir 128 caractères ou moins');
}
// Validate password complexity (1 uppercase, 1 lowercase, 1 number)
const hasUppercase = /[A-Z]/.test(newPassword);
const hasLowercase = /[a-z]/.test(newPassword);
const hasNumber = /\d/.test(newPassword);
if (!hasUppercase || !hasLowercase || !hasNumber) {
throw new Error('Le mot de passe doit contenir au moins une majuscule, une minuscule et un chiffre');
}
// Verify token is handled in the email module
// For now, we'll assume token is valid if it exists in the database
// Find user
const user = await findOne('zen_auth_users', { email });
if (!user) {
throw new Error('Jeton de réinitialisation invalide');
}
// Find account
const account = await findOne('zen_auth_accounts', {
user_id: user.id,
provider_id: 'credential'
});
if (!account) {
throw new Error('Compte introuvable');
}
// Hash new password
const hashedPassword = await hashPassword(newPassword);
// Update password
await updateById('zen_auth_accounts', account.id, {
password: hashedPassword,
updated_at: new Date()
});
// Delete reset token
await deleteResetToken(email);
// Send password changed confirmation email
try {
await sendPasswordChangedEmail(email);
} catch (error) {
// Log error but don't fail the password reset process
console.error(`[ZEN AUTH] Failed to send password changed email to ${email}:`, error.message);
}
return { success: true };
}
/**
* Verify user email
* @param {string} userId - User ID
* @returns {Promise<Object>} Updated user
*/
async function verifyUserEmail(userId) {
return await updateById('zen_auth_users', userId, {
email_verified: true,
updated_at: new Date()
});
}
/**
* Update user profile
* @param {string} userId - User ID
* @param {Object} updateData - Data to update
* @returns {Promise<Object>} Updated user
*/
async function updateUser(userId, updateData) {
const allowedFields = ['name', 'image', 'language'];
const filteredData = {};
for (const field of allowedFields) {
if (updateData[field] !== undefined) {
filteredData[field] = updateData[field];
}
}
filteredData.updated_at = new Date();
return await updateById('zen_auth_users', userId, filteredData);
}
export {
register,
login,
requestPasswordReset,
resetPassword,
verifyUserEmail,
updateUser
};
+233
View File
@@ -0,0 +1,233 @@
/**
* Email Verification and Password Reset
* Handles email verification tokens and password reset tokens
*/
import { create, findOne, deleteWhere } from '../../../core/database/crud.js';
import { generateToken, generateId } from './password.js';
import { sendAuthEmail } from '../../../core/email/index.js';
import { renderVerificationEmail, renderPasswordResetEmail, renderPasswordChangedEmail } from '../../../core/email/templates/index.js';
/**
* Create an email verification token
* @param {string} email - User email
* @returns {Promise<Object>} Verification object with token
*/
async function createEmailVerification(email) {
const token = generateToken(32);
const verificationId = generateId();
// Token expires in 24 hours
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 24);
// Delete any existing verification tokens for this email
await deleteWhere('zen_auth_verifications', {
identifier: 'email_verification',
value: email
});
const verification = await create('zen_auth_verifications', {
id: verificationId,
identifier: 'email_verification',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return {
...verification,
token
};
}
/**
* Verify an email token
* @param {string} email - User email
* @param {string} token - Verification token
* @returns {Promise<boolean>} True if valid, false otherwise
*/
async function verifyEmailToken(email, token) {
const verification = await findOne('zen_auth_verifications', {
identifier: 'email_verification',
value: email
});
if (!verification) return false;
// Verify token matches
if (verification.token !== token) return false;
// Check if token is expired
if (new Date(verification.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: verification.id });
return false;
}
// Delete the verification token after use
await deleteWhere('zen_auth_verifications', { id: verification.id });
return true;
}
/**
* Create a password reset token
* @param {string} email - User email
* @returns {Promise<Object>} Reset object with token
*/
async function createPasswordReset(email) {
const token = generateToken(32);
const resetId = generateId();
// Token expires in 1 hour
const expiresAt = new Date();
expiresAt.setHours(expiresAt.getHours() + 1);
// Delete any existing reset tokens for this email
await deleteWhere('zen_auth_verifications', {
identifier: 'password_reset',
value: email
});
const reset = await create('zen_auth_verifications', {
id: resetId,
identifier: 'password_reset',
value: email,
token,
expires_at: expiresAt,
updated_at: new Date()
});
return {
...reset,
token
};
}
/**
* Verify a password reset token
* @param {string} email - User email
* @param {string} token - Reset token
* @returns {Promise<boolean>} True if valid, false otherwise
*/
async function verifyResetToken(email, token) {
const reset = await findOne('zen_auth_verifications', {
identifier: 'password_reset',
value: email
});
if (!reset) return false;
// Verify token matches
if (reset.token !== token) return false;
// Check if token is expired
if (new Date(reset.expires_at) < new Date()) {
await deleteWhere('zen_auth_verifications', { id: reset.id });
return false;
}
return true;
}
/**
* Delete a password reset token
* @param {string} email - User email
* @returns {Promise<number>} Number of deleted tokens
*/
async function deleteResetToken(email) {
return await deleteWhere('zen_auth_verifications', {
identifier: 'password_reset',
value: email
});
}
/**
* Send verification email using Resend
* @param {string} email - User email
* @param {string} token - Verification token
* @param {string} baseUrl - Base URL of the application
*/
async function sendVerificationEmail(email, token, baseUrl) {
const verificationUrl = `${baseUrl}/auth/confirm?email=${encodeURIComponent(email)}&token=${token}`;
const appName = process.env.ZEN_NAME || 'ZEN';
const html = await renderVerificationEmail(verificationUrl, email, appName);
const result = await sendAuthEmail({
to: email,
subject: `Confirmez votre adresse courriel ${appName}`,
html
});
if (!result.success) {
console.error(`[ZEN AUTH] Failed to send verification email to ${email}:`, result.error);
throw new Error('Failed to send verification email');
}
console.log(`[ZEN AUTH] Verification email sent to ${email}`);
return result;
}
/**
* Send password reset email using Resend
* @param {string} email - User email
* @param {string} token - Reset token
* @param {string} baseUrl - Base URL of the application
*/
async function sendPasswordResetEmail(email, token, baseUrl) {
const resetUrl = `${baseUrl}/auth/reset?email=${encodeURIComponent(email)}&token=${token}`;
const appName = process.env.ZEN_NAME || 'ZEN';
const html = await renderPasswordResetEmail(resetUrl, email, appName);
const result = await sendAuthEmail({
to: email,
subject: `Réinitialisation du mot de passe ${appName}`,
html
});
if (!result.success) {
console.error(`[ZEN AUTH] Failed to send password reset email to ${email}:`, result.error);
throw new Error('Failed to send password reset email');
}
console.log(`[ZEN AUTH] Password reset email sent to ${email}`);
return result;
}
/**
* Send password changed confirmation email using Resend
* @param {string} email - User email
*/
async function sendPasswordChangedEmail(email) {
const appName = process.env.ZEN_NAME || 'ZEN';
const html = await renderPasswordChangedEmail(email, appName);
const result = await sendAuthEmail({
to: email,
subject: `Mot de passe modifié ${appName}`,
html
});
if (!result.success) {
console.error(`[ZEN AUTH] Failed to send password changed email to ${email}:`, result.error);
throw new Error('Failed to send password changed email');
}
console.log(`[ZEN AUTH] Password changed email sent to ${email}`);
return result;
}
export {
createEmailVerification,
verifyEmailToken,
createPasswordReset,
verifyResetToken,
deleteResetToken,
sendVerificationEmail,
sendPasswordResetEmail,
sendPasswordChangedEmail
};
+65
View File
@@ -0,0 +1,65 @@
/**
* Password Hashing and Verification
* Provides secure password hashing using bcrypt
*/
import crypto from 'crypto';
/**
* Hash a password using scrypt (Node.js native)
* @param {string} password - Plain text password
* @returns {Promise<string>} Hashed password
*/
async function hashPassword(password) {
return new Promise((resolve, reject) => {
// Generate a salt
const salt = crypto.randomBytes(16).toString('hex');
// Hash password with salt using scrypt
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
resolve(salt + ':' + derivedKey.toString('hex'));
});
});
}
/**
* Verify a password against a hash
* @param {string} password - Plain text password
* @param {string} hash - Hashed password
* @returns {Promise<boolean>} True if password matches, false otherwise
*/
async function verifyPassword(password, hash) {
return new Promise((resolve, reject) => {
const [salt, key] = hash.split(':');
crypto.scrypt(password, salt, 64, (err, derivedKey) => {
if (err) reject(err);
resolve(key === derivedKey.toString('hex'));
});
});
}
/**
* Generate a random token
* @param {number} length - Token length in bytes (default: 32)
* @returns {string} Random token
*/
function generateToken(length = 32) {
return crypto.randomBytes(length).toString('hex');
}
/**
* Generate a random ID
* @returns {string} Random ID
*/
function generateId() {
return crypto.randomUUID();
}
export {
hashPassword,
verifyPassword,
generateToken,
generateId
};
+116
View File
@@ -0,0 +1,116 @@
/**
* In-memory rate limiter
* Stores counters in a Map — resets on server restart, no DB required.
*/
/** @type {Map<string, { count: number, windowStart: number, windowMs: number, blockedUntil: number|null }>} */
const store = new Map();
// Purge expired entries every 10 minutes to avoid memory leak
const cleanup = setInterval(() => {
const now = Date.now();
for (const [key, entry] of store.entries()) {
const windowExpired = now > entry.windowStart + entry.windowMs;
const blockExpired = !entry.blockedUntil || now > entry.blockedUntil;
if (windowExpired && blockExpired) {
store.delete(key);
}
}
}, 10 * 60 * 1000);
// Allow garbage collection in test/serverless environments
if (cleanup.unref) cleanup.unref();
/**
* Rate limit presets per action.
* maxAttempts : number of requests allowed in the window
* windowMs : rolling window duration
* blockMs : how long to block once the limit is exceeded
*/
export const RATE_LIMITS = {
login: { maxAttempts: 5, windowMs: 15 * 60 * 1000, blockMs: 30 * 60 * 1000 },
register: { maxAttempts: 5, windowMs: 60 * 60 * 1000, blockMs: 60 * 60 * 1000 },
forgot_password: { maxAttempts: 3, windowMs: 60 * 60 * 1000, blockMs: 60 * 60 * 1000 },
reset_password: { maxAttempts: 5, windowMs: 15 * 60 * 1000, blockMs: 30 * 60 * 1000 },
verify_email: { maxAttempts: 10, windowMs: 60 * 60 * 1000, blockMs: 30 * 60 * 1000 },
api: { maxAttempts: 120, windowMs: 60 * 1000, blockMs: 5 * 60 * 1000 },
};
/**
* Check whether a given identifier is allowed for an action, and record the attempt.
*
* @param {string} identifier - IP address or user ID
* @param {string} action - Key from RATE_LIMITS (e.g. 'login')
* @returns {{ allowed: boolean, retryAfterMs?: number, remaining?: number }}
*/
export function checkRateLimit(identifier, action) {
const config = RATE_LIMITS[action];
if (!config) return { allowed: true };
const key = `${action}:${identifier}`;
const now = Date.now();
let entry = store.get(key);
// Still blocked
if (entry?.blockedUntil && now < entry.blockedUntil) {
return { allowed: false, retryAfterMs: entry.blockedUntil - now };
}
// Start a fresh window (first request, or previous window has expired)
if (!entry || now > entry.windowStart + entry.windowMs) {
store.set(key, { count: 1, windowStart: now, windowMs: config.windowMs, blockedUntil: null });
return { allowed: true, remaining: config.maxAttempts - 1 };
}
// Increment counter in the current window
entry.count += 1;
if (entry.count > config.maxAttempts) {
entry.blockedUntil = now + config.blockMs;
store.set(key, entry);
return { allowed: false, retryAfterMs: config.blockMs };
}
store.set(key, entry);
return { allowed: true, remaining: config.maxAttempts - entry.count };
}
/**
* Extract the best-effort client IP from Next.js headers() (server actions).
* @param {import('next/headers').ReadonlyHeaders} headersList
* @returns {string}
*/
export function getIpFromHeaders(headersList) {
return (
headersList.get('x-forwarded-for')?.split(',')[0]?.trim() ||
headersList.get('x-real-ip') ||
'unknown'
);
}
/**
* Extract the best-effort client IP from a Next.js Request object (API routes).
* @param {Request} request
* @returns {string}
*/
export function getIpFromRequest(request) {
return (
request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ||
request.headers.get('x-real-ip') ||
'unknown'
);
}
/**
* Format a block duration in human-readable French.
* @param {number} ms
* @returns {string}
*/
export function formatRetryAfter(ms) {
const seconds = Math.ceil(ms / 1000);
if (seconds < 60) return `${seconds} secondes`;
const minutes = Math.ceil(seconds / 60);
if (minutes < 60) return `${minutes} minute${minutes > 1 ? 's' : ''}`;
const hours = Math.ceil(minutes / 60);
return `${hours} heure${hours > 1 ? 's' : ''}`;
}
+138
View File
@@ -0,0 +1,138 @@
/**
* Session Management
* Handles user session creation, validation, and deletion
*/
import { create, findOne, deleteWhere, updateById } from '../../../core/database/crud.js';
import { generateToken, generateId } from './password.js';
/**
* Create a new session for a user
* @param {string} userId - User ID
* @param {Object} options - Session options (ipAddress, userAgent)
* @returns {Promise<Object>} Session object with token
*/
async function createSession(userId, options = {}) {
const { ipAddress, userAgent } = options;
// Generate session token
const token = generateToken(32);
const sessionId = generateId();
// Session expires in 30 days
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
const session = await create('zen_auth_sessions', {
id: sessionId,
user_id: userId,
token,
expires_at: expiresAt,
ip_address: ipAddress || null,
user_agent: userAgent || null,
updated_at: new Date()
});
return session;
}
/**
* Validate a session token
* @param {string} token - Session token
* @returns {Promise<Object|null>} Session object with user data or null if invalid
*/
async function validateSession(token) {
if (!token) return null;
const session = await findOne('zen_auth_sessions', { token });
if (!session) return null;
// Check if session is expired
if (new Date(session.expires_at) < new Date()) {
await deleteSession(token);
return null;
}
// Get user data
const user = await findOne('zen_auth_users', { id: session.user_id });
if (!user) {
await deleteSession(token);
return null;
}
// Auto-refresh session if it expires in less than 20 days
const now = new Date();
const expiresAt = new Date(session.expires_at);
const daysUntilExpiry = Math.ceil((expiresAt - now) / (1000 * 60 * 60 * 24));
let sessionRefreshed = false;
if (daysUntilExpiry < 20) {
// Extend session to 30 days from now
const newExpiresAt = new Date();
newExpiresAt.setDate(newExpiresAt.getDate() + 30);
await updateById('zen_auth_sessions', session.id, {
expires_at: newExpiresAt,
updated_at: new Date()
});
// Update the session object with new expiration
session.expires_at = newExpiresAt;
sessionRefreshed = true;
}
return {
session,
user,
sessionRefreshed
};
}
/**
* Delete a session
* @param {string} token - Session token
* @returns {Promise<number>} Number of deleted sessions
*/
async function deleteSession(token) {
return await deleteWhere('zen_auth_sessions', { token });
}
/**
* Delete all sessions for a user
* @param {string} userId - User ID
* @returns {Promise<number>} Number of deleted sessions
*/
async function deleteUserSessions(userId) {
return await deleteWhere('zen_auth_sessions', { user_id: userId });
}
/**
* Refresh a session (extend expiration)
* @param {string} token - Session token
* @returns {Promise<Object|null>} Updated session or null
*/
async function refreshSession(token) {
const session = await findOne('zen_auth_sessions', { token });
if (!session) return null;
// Extend session by 30 days
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 30);
return await updateById('zen_auth_sessions', session.id, {
expires_at: expiresAt,
updated_at: new Date()
});
}
export {
createSession,
validateSession,
deleteSession,
deleteUserSessions,
refreshSession
};
+83
View File
@@ -0,0 +1,83 @@
/**
* Route Protection Middleware
* Utilities to protect routes and check authentication
*/
import { getSession } from '../actions/authActions.js';
import { redirect } from 'next/navigation';
/**
* Protect a page - requires authentication
* Use this in server components to require authentication
*
* @param {Object} options - Protection options
* @param {string} options.redirectTo - Where to redirect if not authenticated (default: '/auth/login')
* @returns {Promise<Object>} Session object with user data
*
* @example
* // In a server component:
* import { protect } from '@hykocx/zen/auth';
*
* export default async function ProtectedPage() {
* const session = await protect();
* return <div>Welcome, {session.user.name}!</div>;
* }
*/
async function protect(options = {}) {
const { redirectTo = '/auth/login' } = options;
const session = await getSession();
if (!session) {
redirect(redirectTo);
}
return session;
}
/**
* Check if user is authenticated
* Use this when you want to check authentication without forcing a redirect
*
* @returns {Promise<Object|null>} Session object or null if not authenticated
*
* @example
* import { checkAuth } from '@hykocx/zen/auth';
*
* export default async function Page() {
* const session = await checkAuth();
* return session ? <div>Logged in</div> : <div>Not logged in</div>;
* }
*/
async function checkAuth() {
return await getSession();
}
/**
* Require a specific role
* @param {Array<string>} allowedRoles - Array of allowed roles
* @param {Object} options - Options
* @returns {Promise<Object>} Session object
*/
async function requireRole(allowedRoles = [], options = {}) {
const { redirectTo = '/auth/login', forbiddenRedirect = '/' } = options;
const session = await getSession();
if (!session) {
redirect(redirectTo);
}
if (!allowedRoles.includes(session.user.role)) {
redirect(forbiddenRedirect);
}
return session;
}
export {
protect,
checkAuth,
requireRole
};
+46
View File
@@ -0,0 +1,46 @@
/**
* Auth Page - Server Component Wrapper for Next.js App Router
*
* Default auth UI: login, register, forgot, reset, confirm, logout at /auth/[...auth].
* Re-export in your app: export { default } from '@hykocx/zen/auth/page';
*
* For custom auth pages (all flows) that match your site style, use components from
* '@hykocx/zen/auth/components' and actions from '@hykocx/zen/auth/actions'.
* See README-custom-login.md in this package. Basic sites can keep using this default page.
*/
import { AuthPagesClient } from '@hykocx/zen/auth/pages';
import {
registerAction,
loginAction,
logoutAction,
forgotPasswordAction,
resetPasswordAction,
verifyEmailAction,
setSessionCookie,
getSession
} from '@hykocx/zen/auth/actions';
export default async function AuthPage({ params, searchParams }) {
const session = await getSession();
return (
<div className="min-h-screen flex items-center justify-center p-4 md:p-8">
<div className="max-w-md w-full">
<AuthPagesClient
params={params}
searchParams={searchParams}
registerAction={registerAction}
loginAction={loginAction}
logoutAction={logoutAction}
forgotPasswordAction={forgotPasswordAction}
resetPasswordAction={resetPasswordAction}
verifyEmailAction={verifyEmailAction}
setSessionCookieAction={setSessionCookie}
redirectAfterLogin={process.env.ZEN_AUTH_REDIRECT_AFTER_LOGIN || '/'}
currentUser={session?.user || null}
/>
</div>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
'use client';
/**
* Auth Pages Export for Next.js App Router
*
* This exports the auth client components.
* Users must create their own server component wrapper that imports the actions.
*/
export { default as AuthPagesClient } from './components/AuthPages.js';
export { default as AuthPagesLayout } from './components/AuthPagesLayout.js';