refactor(auth): remove GET /users/me endpoint and related exports
This commit is contained in:
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 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' },
|
||||
|
||||
@@ -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.
|
||||
* 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,
|
||||
|
||||
@@ -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