feat(entree): accueil à deux choix, porte unique, prénom du compte

- Backend : prénom du compte facultatif à l'inscription (colonne users.firstName,
  migration additive), renvoyé à l'inscription, à la connexion et sur GET /auth/me
- Accueil sans session : deux choix (J'ai reçu un lien, Se connecter) et
  Créer mon arbre en lien, à la place de la démo (variante B validée)
- Porte : mot de passe puis prénom facultatif pour un nouveau venu ; compte
  connecté non rattaché : bandeau avec son prénom, sans champ prénom, et
  « Ce n'est pas mon compte » (déconnexion, retour à la porte du même arbre)
- Sur-titre « Arbre partagé » retiré de la porte
- Compte déjà rattaché : inchangé, la racine ouvre l'arbre directement

Vérifié : vitest front 154/154, back 228/228 (sans .env local, le test CORS
attend localhost:5173), tsc back, eslint front, vite build, landing test et build.

Co-Authored-By: Claude Haiku 5.5 <noreply@anthropic.com>
This commit is contained in:
IliesAllaliandClaude Haiku 5.5 committed 2026-10-18 20:45:58 +02:00
1 parent f1ecb3951c
commit 92ceea6565
11 files changed
+293 -21

No files matched your search

@@ -0,0 +1,2 @@
-- Prénom du compte, facultatif (comptes existants : vide). Migration additive.
ALTER TABLE "users" ADD COLUMN "firstName" TEXT;
+1
View File
@@ -81,6 +81,7 @@ enum SharedAccessRole {
model User {
id String @id @default(cuid())
email String @unique
firstName String?
passwordHash String
lastOpenedTreeId String?
lastOpenedAccessMode String?
+32 -1
View File
@@ -20,6 +20,16 @@ const credentialsSchema = z.object({
password: z.string().min(8).max(128),
})
// Prénom du compte, facultatif : seulement à l'inscription (la connexion ne le demande pas)
const registerSchema = credentialsSchema.extend({
firstName: z.string().max(60).optional().nullable(),
})
function trimmedFirstName(value: string | null | undefined): string | null {
const trimmed = (value ?? '').trim()
return trimmed ? trimmed : null
}
const deleteAccountSchema = z.object({
password: z.string().min(1).max(128),
})
@@ -94,7 +104,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
},
},
async (request, reply) => {
const parsed = credentialsSchema.safeParse(request.body)
const parsed = registerSchema.safeParse(request.body)
if (!parsed.success) {
return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() })
}
@@ -110,10 +120,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
data: {
email,
passwordHash,
firstName: trimmedFirstName(parsed.data.firstName),
},
select: {
id: true,
email: true,
firstName: true,
},
})
@@ -154,6 +166,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
select: {
id: true,
email: true,
firstName: true,
passwordHash: true,
},
})
@@ -185,11 +198,29 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
user: {
id: user.id,
email: user.email,
firstName: user.firstName,
},
})
},
)
// Profil du compte connecté (prénom compris) : la session restaurée sur un autre appareil le retrouve
app.get('/me', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
}
const user = await app.prisma.user.findUnique({
where: { id: request.actor.userId },
select: { id: true, email: true, firstName: true },
})
if (!user) {
return reply.code(401).send({ error: 'authentication_required' })
}
return reply.send({ user })
})
app.get('/me/last-tree', async (request, reply) => {
if (!request.actor || request.actor.kind !== 'user') {
return reply.code(401).send({ error: 'authentication_required' })
+119
View File
@@ -0,0 +1,119 @@
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
// Prénom du compte : renvoyé à l'inscription, à la connexion et sur /me.
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
count: vi.fn(),
},
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
let app: FastifyInstance
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.PORT = '4000'
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.JWT_EXPIRES_IN = '7d'
process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h'
process.env.CORS_ORIGIN = 'http://localhost:5173'
const { createApp } = await import('../src/app.js')
app = createApp()
})
afterAll(async () => {
await app?.close()
})
beforeEach(() => {
vi.clearAllMocks()
prismaMock.user.findUnique.mockResolvedValue(null)
prismaMock.user.count.mockResolvedValue(1)
})
describe('prénom du compte', () => {
it("l'inscription enregistre le prénom (sans espaces autour) et le renvoie", async () => {
prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs' })
const res = await app.inject({
method: 'POST',
url: '/auth/register',
payload: { firstName: ' Anaïs ', email: 'anais@example.com', password: 'securepass123' },
})
expect(res.statusCode).toBe(201)
expect(prismaMock.user.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ firstName: 'Anaïs' }),
}))
expect(res.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs' })
})
it("l'inscription sans prénom le garde vide, sans erreur", async () => {
prismaMock.user.create.mockResolvedValue({ id: 'user-2', email: 'sans@example.com', firstName: null })
const res = await app.inject({
method: 'POST',
url: '/auth/register',
payload: { email: 'sans@example.com', password: 'securepass123' },
})
expect(res.statusCode).toBe(201)
expect(prismaMock.user.create).toHaveBeenCalledWith(expect.objectContaining({
data: expect.objectContaining({ firstName: null }),
}))
expect(res.json().user.firstName).toBeNull()
})
it('la connexion renvoie le prénom du compte', async () => {
const { hashPassword } = await import('../src/lib/auth.js')
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
email: 'anais@example.com',
firstName: 'Anaïs',
passwordHash: await hashPassword('securepass123'),
})
const res = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email: 'anais@example.com', password: 'securepass123' },
})
expect(res.statusCode).toBe(200)
expect(res.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs' })
})
it('/me renvoie le profil du compte connecté, et refuse sans session', async () => {
const { hashPassword } = await import('../src/lib/auth.js')
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-1',
email: 'anais@example.com',
firstName: 'Anaïs',
passwordHash: await hashPassword('securepass123'),
})
const login = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email: 'anais@example.com', password: 'securepass123' },
})
const token = login.json().token
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs' })
const me = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${token}` } })
expect(me.statusCode).toBe(200)
expect(me.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs' })
const anonymous = await app.inject({ method: 'GET', url: '/auth/me' })
expect(anonymous.statusCode).toBe(401)
})
})
+31 -2
View File
@@ -9,6 +9,7 @@ import MediaViewer from './components/MediaViewer'
import MediaManagerPanel from './components/MediaManagerPanel'
import AdminPanel from './components/AdminPanel'
import AccessGate from './components/AccessGate'
import EntryChoice from './components/EntryChoice'
import AccountDashboard from './components/AccountDashboard'
import AccountPanel from './components/AccountPanel'
import TreeCreationWizard from './components/TreeCreationWizard'
@@ -2002,12 +2003,12 @@ function App() {
const resolvedTreeOwnerEmail = ''
// Root app:
// - not connected => demo by default
// - not connected => accueil à deux choix (variante B, 18/10)
// - connected => continue bootstrap to open the user's tree
if (!slug && !TREE_ID_FROM_ENV && !query.has('account')) {
const userToken = auth.restoreUserToken()
if (!userToken) {
tree.handleUseDemo()
tree.setBootState('welcome')
return
}
}
@@ -2517,6 +2518,21 @@ function App() {
resetSignedOutState()
}
// Accueil « J'ai reçu un lien de famille » : la porte demande le code de l'arbre et son mot de passe
const handleFamilyLinkChoice = () => {
tree.setTreeContext(buildEmptyTreeContext())
tree.setGateError('')
tree.setBootState('need-access')
}
// « Ce n'est pas mon compte » : déconnexion, puis la porte du même arbre
const handleNotMyAccount = async () => {
setTreeNotFound(false)
await auth.handleLogout()
tree.setGateError('')
tree.setBootState('need-access')
}
// Suppression du compte : même remise à zéro que la déconnexion une fois la session effacée.
const handleDeleteAccount = async (password) => {
const errorMessage = await auth.handleDeleteAccount(password)
@@ -2739,6 +2755,18 @@ function App() {
)
}
if (tree.bootState === 'welcome') {
return (
<div className="app">
<EntryChoice
onFamilyLink={handleFamilyLinkChoice}
onLogin={() => handleUseAccount('login')}
onRegister={() => handleUseAccount('register')}
/>
</div>
)
}
if (tree.bootState === 'need-access') {
return (
<div className="app">
@@ -2753,6 +2781,7 @@ function App() {
errorMessage={tree.gateError}
connected={Boolean(auth.userAuth.token)}
connectedName={accountUser?.firstName || ''}
onNotMyAccount={handleNotMyAccount}
onSubmit={handleGateUnlock}
onUseDemo={handleUseDemo}
onUseAccount={handleUseAccount}
+12
View File
@@ -41,6 +41,18 @@ export async function deleteAccount(token, password) {
return data
}
// Profil du compte connecté (prénom compris), pour une session restaurée sur un autre appareil
export async function fetchAccountProfile(token) {
const data = await apiRequest('/auth/me', {
method: 'GET',
headers: {
Authorization: `Bearer ${token}`,
},
})
return data
}
export async function fetchLastOpenedTree(token) {
const data = await apiRequest('/auth/me/last-tree', {
method: 'GET',
+28 -17
View File
@@ -27,6 +27,7 @@ const AccessGate = ({
connectedName = '',
onSubmit,
onUseAccount,
onNotMyAccount,
}) => {
const [treeId, setTreeId] = useState(initialTreeId || '')
const [password, setPassword] = useState('')
@@ -57,7 +58,6 @@ const AccessGate = ({
<form className="pz-card pz-screen-card" onSubmit={handleSubmit}>
<div className="pz-auth-head">
<p className="pz-eyebrow">{t('Arbre partagé')}</p>
{treeNotFound ? (
<h1 className="pz-title">{richText(t('Cet arbre est *introuvable*'))}</h1>
) : (
@@ -69,7 +69,7 @@ const AccessGate = ({
{treeNotFound
? t("Le lien est peut-être incomplet, ou l'arbre a été supprimé. Demandez un nouveau lien à la personne qui vous l'a envoyé.")
: treeName
? t('Entrez le mot de passe reçu avec le lien. Pas besoin de compte pour regarder.')
? t('Tapez le mot de passe reçu avec le lien. Pas besoin de compte pour regarder.')
: t('Entrez le code et le mot de passe reçus avec votre lien de partage.')}
</p>
</div>
@@ -119,20 +119,6 @@ const AccessGate = ({
</div>
)}
<div className="pz-field">
<label htmlFor="visitorFirstName">{t('Votre prénom (facultatif)')}</label>
<input
id="visitorFirstName"
type="text"
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
disabled={loading}
autoComplete="given-name"
maxLength={60}
/>
<p className="pz-hint">{t("Il sert à savoir qui est venu voir l'arbre.")}</p>
</div>
<div className="pz-field">
<label htmlFor="sharePassword">{t('Mot de passe reçu avec le lien')}</label>
<input
@@ -145,6 +131,23 @@ const AccessGate = ({
required
/>
</div>
{/* Compte connecté : son prénom sert déjà, on ne le redemande pas */}
{!connected && (
<div className="pz-field">
<label htmlFor="visitorFirstName">{t('Votre prénom (facultatif)')}</label>
<input
id="visitorFirstName"
type="text"
value={firstName}
onChange={(event) => setFirstName(event.target.value)}
disabled={loading}
autoComplete="given-name"
maxLength={60}
/>
<p className="pz-hint">{t("Il sert à savoir qui est venu voir l'arbre.")}</p>
</div>
)}
</div>
)}
@@ -163,7 +166,15 @@ const AccessGate = ({
)}
</form>
{onUseAccount && !treeNotFound && (
{connected && onNotMyAccount && !treeNotFound && (
<div className="pz-account-foot">
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={onNotMyAccount} disabled={loading}>
{t("Ce n'est pas mon compte")}
</button>
</div>
)}
{!connected && onUseAccount && !treeNotFound && (
<div className="pz-account-foot">
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onUseAccount('login')} disabled={loading}>
{t("J'ai déjà un compte")}
@@ -45,6 +45,12 @@
.access-gate-session { display: grid; gap: 4px; padding: 12px 14px; border-radius: var(--pz-r-m); background: var(--pz-a50); border: 1px solid var(--pz-a100); color: var(--pz-ink); font: 400 14px/1.45 var(--pz-sans); }
.access-gate-session-step { color: var(--pz-ink-2); }
/* Accueil sans session (variante B) */
.pz-btn.pz-btn--line { background: transparent; border: 1px solid var(--pz-ink); color: var(--pz-ink); }
.entry-choice-title { text-align: center; }
.entry-choice-card { padding: 18px; display: grid; gap: 10px; }
.entry-choice-name { font: 400 20px/1.2 var(--pz-serif); color: var(--pz-ink); margin: 0; }
/* Fenêtre « Mon compte » (personne liée) */
.ap-linked { display: flex; gap: 12px; align-items: center; padding: 12px 14px; border-radius: var(--pz-r-m); background: var(--pz-a50); border: 1px solid var(--pz-a100); }
.ap-avatar { width: 44px; height: 44px; flex: none; display: grid; place-items: center; overflow: hidden; border-radius: 50%; background: var(--pz-a100); color: var(--pz-a700); font: 400 20px/1 var(--pz-serif); }
+41
View File
@@ -0,0 +1,41 @@
import logoUrl from '../assets/parenthese-logo.svg?url'
import './AccountScreens.css'
import LanguageSwitch from './navbar/LanguageSwitch'
import { t } from '../i18n/index.js'
// Accueil sans session (variante B validée le 18/10) : deux choix, rien d'autre au premier regard.
const EntryChoice = ({ onFamilyLink, onLogin, onRegister }) => (
<div className="entry-choice pz-screen">
<div className="pz-screen-inner">
<img src={logoUrl} alt="Parenthèse" className="pz-screen-logo" />
<h1 className="pz-title entry-choice-title">{t('Que voulez-vous faire ?')}</h1>
<div className="entry-choice-card pz-card">
<h2 className="entry-choice-name">{t("J'ai reçu un lien")}</h2>
<p className="pz-sub">{t("Pas besoin de compte pour regarder l'arbre.")}</p>
<button type="button" className="pz-btn pz-btn--primary pz-btn--block" onClick={onFamilyLink}>
{t("J'ai reçu un lien de famille")}
</button>
</div>
<div className="entry-choice-card pz-card">
<h2 className="entry-choice-name">{t("J'ai déjà un compte")}</h2>
<p className="pz-sub">{t('Pour retrouver vos arbres et ajouter des souvenirs.')}</p>
<button type="button" className="pz-btn pz-btn--line pz-btn--block" onClick={onLogin}>
{t('Se connecter')}
</button>
</div>
<div className="pz-account-foot">
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={onRegister}>
{t('Créer mon arbre')}
</button>
</div>
<LanguageSwitch className="pz-lang--screen" />
</div>
</div>
)
export default EntryChoice
+15 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react'
import {
deleteAccount,
fetchAccountProfile,
fetchLastOpenedTree,
loginUser,
logoutUser,
@@ -49,8 +50,21 @@ export function useAuth() {
const authenticated = Boolean(userAuth.token)
// Prénom du compte : lu sur le serveur, gardé dans le profil local pour le bandeau de la porte
const refreshAccountProfile = async (token) => {
try {
const response = await fetchAccountProfile(token)
if (!response?.user) return
const merged = { ...(loadUserProfile() || {}), ...response.user }
saveUserProfile(merged)
setUserAuth((current) => (current.token === token ? { ...current, user: merged } : current))
} catch {
// Profil non lu : le bandeau reste neutre, la session n'est pas touchée
}
}
const loadAccountTrees = async (token) => {
const response = await listTrees(token)
const [response] = await Promise.all([listTrees(token), refreshAccountProfile(token)])
const trees = Array.isArray(response?.trees) ? response.trees : []
const lastTree = response?.lastOpenedTree || null
setAccountTrees(trees)
+6
View File
@@ -217,4 +217,10 @@ export default {
"Vous êtes sur le compte de {name}": "You are signed in as {name}",
"Vous êtes sur votre compte.": "You are signed in to your account.",
"Tapez une fois le mot de passe reçu avec le lien. Ensuite, l'arbre s'ouvrira directement.": "Enter the password you received with the link once. After that, the tree opens directly.",
"Tapez le mot de passe reçu avec le lien. Pas besoin de compte pour regarder.": "Type the password you received with the link. No account needed to look around.",
"Que voulez-vous faire ?": "What would you like to do?",
"J'ai reçu un lien": "I received a link",
"Pas besoin de compte pour regarder l'arbre.": "No account needed to look at the tree.",
"Pour retrouver vos arbres et ajouter des souvenirs.": "To find your trees again and add memories.",
"Ce n'est pas mon compte": "This is not my account",
}