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