diff --git a/package.json b/package.json index 7c803d5..09761ce 100644 --- a/package.json +++ b/package.json @@ -63,9 +63,6 @@ "./features/auth/client": { "import": "./dist/features/auth/AuthPage.client.js" }, - "./features/auth/components": { - "import": "./dist/features/auth/components/index.js" - }, "./features/admin": { "import": "./dist/features/admin/index.js" }, diff --git a/src/features/auth/README-dashboard.md b/src/features/auth/README-dashboard.md deleted file mode 100644 index f8c869e..0000000 --- a/src/features/auth/README-dashboard.md +++ /dev/null @@ -1,274 +0,0 @@ -# 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 `@zen/core/auth/actions` | -| Check auth without redirect | `checkAuth()` from `@zen/core/auth` | -| Require a role | `requireRole(['admin', 'manager'])` from `@zen/core/auth` | -| Show user in client (header/nav) | `UserMenu` or `UserAvatar` + `useCurrentUser` from `@zen/core/auth/components` | -| Edit account (name + avatar) | `AccountSection` from `@zen/core/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 '@zen/core/auth'; -import { DashboardClient } from './DashboardClient'; - -export default async function DashboardPage() { - const session = await protect({ redirectTo: '/auth/login' }); - - return ( -
-

Dashboard

- -
- ); -} -``` - -- `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 '@zen/core/auth/actions'; -import { UserMenu } from '@zen/core/auth/components'; - -export default async function Layout({ children }) { - const session = await getSession(); - - return ( -
-
- {session?.user ? ( - - ) : ( - Log in - )} -
- {children} -
- ); -} -``` - -**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 '@zen/core/auth/components'; - -export function Header() { - return ( - - ); -} -``` - -`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 '@zen/core/auth'; -import { AccountSection } from '@zen/core/auth/components'; - -export default async function AccountPage() { - const session = await protect({ redirectTo: '/auth/login' }); - - return ( -
-

My account

- -
- ); -} -``` - -- **`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 `@zen/core/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 '@zen/core/auth'; - -export default async function Page() { - const session = await checkAuth(); - return session ?
Hello, {session.user.name}
:
Please log in
; -} -``` - -Use **`requireRole()`** when a page is only for certain roles: - -```js -import { requireRole } from '@zen/core/auth'; - -export default async function ManagerPage() { - const session = await requireRole(['admin', 'manager'], { - redirectTo: '/auth/login', - forbiddenRedirect: '/dashboard', - }); - return
Manager content
; -} -``` - ---- - -## 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 '@zen/core/auth'; -import { UserMenu } from '@zen/core/auth/components'; -import Link from 'next/link'; - -export default async function DashboardLayout({ children }) { - const session = await protect({ redirectTo: '/auth/login' }); - - return ( -
-
- Dashboard - -
-
{children}
-
- ); -} -``` - -**dashboard/page.js:** - -```js -import { protect } from '@zen/core/auth'; - -export default async function DashboardPage() { - const session = await protect({ redirectTo: '/auth/login' }); - - return ( -
-

Welcome, {session.user.name}

-

Edit my account

-
- ); -} -``` - -**dashboard/account/page.js:** - -```js -import { protect } from '@zen/core/auth'; -import { AccountSection } from '@zen/core/auth/components'; - -export default async function AccountPage() { - const session = await protect({ redirectTo: '/auth/login' }); - - return ( -
-

My account

- -
- ); -} -``` - -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 `@zen/core/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). diff --git a/src/features/auth/api.js b/src/features/auth/api.js index 59af20a..f146f42 100644 --- a/src/features/auth/api.js +++ b/src/features/auth/api.js @@ -37,24 +37,6 @@ function logAndObscureError(error, fallback) { return fallback; } -// --------------------------------------------------------------------------- -// GET /zen/api/users/me -// --------------------------------------------------------------------------- - -async function handleGetCurrentUser(_request, _params, { session }) { - return apiSuccess({ - user: { - id: session.user.id, - email: session.user.email, - name: session.user.name, - role: session.user.role, - image: session.user.image, - emailVerified: session.user.email_verified, - createdAt: session.user.created_at - } - }); -} - // --------------------------------------------------------------------------- // GET /zen/api/users/:id (admin only) // --------------------------------------------------------------------------- @@ -481,12 +463,11 @@ async function handleDeleteRole(_request, { id: roleId }) { // Route definitions // --------------------------------------------------------------------------- // -// Order matters: specific paths (/users/me, /users/profile) must come before +// Order matters: specific paths (/users/profile) must come before // parameterised paths (/users/:id) so they match first. export const routes = defineApiRoutes([ { path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' }, - { path: '/users/me', method: 'GET', handler: handleGetCurrentUser, auth: 'user' }, { path: '/users/profile', method: 'PUT', handler: handleUpdateProfile, auth: 'user' }, { path: '/users/profile/picture', method: 'POST', handler: handleUploadProfilePicture, auth: 'user' }, { path: '/users/profile/picture', method: 'DELETE', handler: handleDeleteProfilePicture, auth: 'user' }, diff --git a/src/features/auth/components/AccountSection.js b/src/features/auth/components/AccountSection.js deleted file mode 100644 index caa9ebd..0000000 --- a/src/features/auth/components/AccountSection.js +++ /dev/null @@ -1,279 +0,0 @@ -'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 '@zen/core/shared/components'; -import { useToast } from '@zen/core/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 ( - -
-
-
-
- - ); - } - - if (!user) { - return null; - } - - return ( -
- -
-

- Photo de profil -

-
-
- {imagePreview ? ( - Profile - ) : ( - - )} - {uploadingImage && ( -
-
-
- )} -
-
-

- Téléchargez une nouvelle photo de profil. Taille max 5MB. -

-
- - - {imagePreview && ( - - )} -
-
-
-
- - - -
-

- Informations personnelles -

-
- - -
- {created_at && ( - - )} -
- - -
-
-
-
- ); -} diff --git a/src/features/auth/components/UserAvatar.js b/src/features/auth/components/UserAvatar.js deleted file mode 100644 index b261743..0000000 --- a/src/features/auth/components/UserAvatar.js +++ /dev/null @@ -1,55 +0,0 @@ -'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 ( -
- {imageUrl ? ( - {user?.name - ) : ( - {getInitials(user?.name)} - )} -
- ); -} diff --git a/src/features/auth/components/UserMenu.js b/src/features/auth/components/UserMenu.js deleted file mode 100644 index 3174f0d..0000000 --- a/src/features/auth/components/UserMenu.js +++ /dev/null @@ -1,90 +0,0 @@ -'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 ( -
-
-
-
- ); - } - - if (!user) { - return null; - } - - return ( - - - - - {user.name || user.email || 'Account'} - - - - - - - -
-

{user.name || 'User'}

-

{user.email}

-
- - {({ active }) => ( - - My account - - )} - - - {({ active }) => ( - - Log out - - )} - -
-
-
- ); -} diff --git a/src/features/auth/components/index.js b/src/features/auth/components/index.js deleted file mode 100644 index b73c2d4..0000000 --- a/src/features/auth/components/index.js +++ /dev/null @@ -1,6 +0,0 @@ -'use client'; - -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'; diff --git a/src/features/auth/components/useCurrentUser.js b/src/features/auth/components/useCurrentUser.js deleted file mode 100644 index 5d83bf7..0000000 --- a/src/features/auth/components/useCurrentUser.js +++ /dev/null @@ -1,66 +0,0 @@ -'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 ; - * if (error) return
Error: {error}
; - * if (!user) return Log in; - * return Hello, {user.name}; - */ - -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 }; -} diff --git a/src/features/auth/index.js b/src/features/auth/index.js index bd7f139..5259fea 100644 --- a/src/features/auth/index.js +++ b/src/features/auth/index.js @@ -1,6 +1,5 @@ /** * Zen Authentication — server barrel. - * Client components live in @zen/core/features/auth/components. * Server actions live in @zen/core/features/auth/actions. */ @@ -39,8 +38,6 @@ export { generateId } from './password.js'; -export { protect, checkAuth, requireRole } from './protect.js'; - export { registerAction, loginAction, diff --git a/src/features/auth/protect.js b/src/features/auth/protect.js deleted file mode 100644 index 3a5c237..0000000 --- a/src/features/auth/protect.js +++ /dev/null @@ -1,19 +0,0 @@ -import { getSession } from './actions.js'; -import { redirect } from 'next/navigation'; - -export async function protect({ redirectTo = '/auth/login' } = {}) { - const session = await getSession(); - if (!session) redirect(redirectTo); - return session; -} - -export async function checkAuth() { - return getSession(); -} - -export async function requireRole(allowedRoles = [], { redirectTo = '/auth/login', forbiddenRedirect = '/' } = {}) { - const session = await getSession(); - if (!session) redirect(redirectTo); - if (!allowedRoles.includes(session.user.role)) redirect(forbiddenRedirect); - return session; -}