refactor(auth): remove GET /users/me endpoint and related exports
This commit is contained in:
@@ -63,9 +63,6 @@
|
|||||||
"./features/auth/client": {
|
"./features/auth/client": {
|
||||||
"import": "./dist/features/auth/AuthPage.client.js"
|
"import": "./dist/features/auth/AuthPage.client.js"
|
||||||
},
|
},
|
||||||
"./features/auth/components": {
|
|
||||||
"import": "./dist/features/auth/components/index.js"
|
|
||||||
},
|
|
||||||
"./features/admin": {
|
"./features/admin": {
|
||||||
"import": "./dist/features/admin/index.js"
|
"import": "./dist/features/admin/index.js"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<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 '@zen/core/auth/actions';
|
|
||||||
import { UserMenu } from '@zen/core/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 '@zen/core/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 '@zen/core/auth';
|
|
||||||
import { AccountSection } from '@zen/core/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 `@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 ? <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 '@zen/core/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 '@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 (
|
|
||||||
<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 '@zen/core/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 '@zen/core/auth';
|
|
||||||
import { AccountSection } from '@zen/core/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 `@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).
|
|
||||||
@@ -37,24 +37,6 @@ function logAndObscureError(error, fallback) {
|
|||||||
return 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)
|
// GET /zen/api/users/:id (admin only)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -481,12 +463,11 @@ async function handleDeleteRole(_request, { id: roleId }) {
|
|||||||
// Route definitions
|
// 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.
|
// parameterised paths (/users/:id) so they match first.
|
||||||
|
|
||||||
export const routes = defineApiRoutes([
|
export const routes = defineApiRoutes([
|
||||||
{ path: '/users', method: 'GET', handler: handleListUsers, auth: 'admin' },
|
{ 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', method: 'PUT', handler: handleUpdateProfile, auth: 'user' },
|
||||||
{ path: '/users/profile/picture', method: 'POST', handler: handleUploadProfilePicture, auth: 'user' },
|
{ path: '/users/profile/picture', method: 'POST', handler: handleUploadProfilePicture, auth: 'user' },
|
||||||
{ path: '/users/profile/picture', method: 'DELETE', handler: handleDeleteProfilePicture, auth: 'user' },
|
{ path: '/users/profile/picture', method: 'DELETE', handler: handleDeleteProfilePicture, auth: 'user' },
|
||||||
|
|||||||
@@ -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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 (
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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';
|
|
||||||
@@ -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 <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 };
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Zen Authentication — server barrel.
|
* Zen Authentication — server barrel.
|
||||||
* Client components live in @zen/core/features/auth/components.
|
|
||||||
* Server actions live in @zen/core/features/auth/actions.
|
* Server actions live in @zen/core/features/auth/actions.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -39,8 +38,6 @@ export {
|
|||||||
generateId
|
generateId
|
||||||
} from './password.js';
|
} from './password.js';
|
||||||
|
|
||||||
export { protect, checkAuth, requireRole } from './protect.js';
|
|
||||||
|
|
||||||
export {
|
export {
|
||||||
registerAction,
|
registerAction,
|
||||||
loginAction,
|
loginAction,
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user