diff --git a/backend/prisma/migrations/20261018_001_prenom_compte/migration.sql b/backend/prisma/migrations/20261018_001_prenom_compte/migration.sql new file mode 100644 index 0000000..2608a8a --- /dev/null +++ b/backend/prisma/migrations/20261018_001_prenom_compte/migration.sql @@ -0,0 +1,2 @@ +-- Prénom du compte, facultatif (comptes existants : vide). Migration additive. +ALTER TABLE "users" ADD COLUMN "firstName" TEXT; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 098b07e..d01eb53 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -81,6 +81,7 @@ enum SharedAccessRole { model User { id String @id @default(cuid()) email String @unique + firstName String? passwordHash String lastOpenedTreeId String? lastOpenedAccessMode String? diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 0701a78..5ede013 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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' }) diff --git a/backend/test/auth-prenom.test.ts b/backend/test/auth-prenom.test.ts new file mode 100644 index 0000000..98e9d34 --- /dev/null +++ b/backend/test/auth-prenom.test.ts @@ -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) + }) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index ec7b175..d912456 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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 ( +
+ handleUseAccount('login')} + onRegister={() => handleUseAccount('register')} + /> +
+ ) + } + if (tree.bootState === 'need-access') { return (
@@ -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} diff --git a/frontend/src/api/authApi.js b/frontend/src/api/authApi.js index 12b2039..d8cc165 100644 --- a/frontend/src/api/authApi.js +++ b/frontend/src/api/authApi.js @@ -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', diff --git a/frontend/src/components/AccessGate.jsx b/frontend/src/components/AccessGate.jsx index 623bb03..37d1c39 100644 --- a/frontend/src/components/AccessGate.jsx +++ b/frontend/src/components/AccessGate.jsx @@ -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 = ({
-

{t('Arbre partagé')}

{treeNotFound ? (

{richText(t('Cet arbre est *introuvable*'))}

) : ( @@ -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.')}

@@ -119,20 +119,6 @@ const AccessGate = ({
)} -
- - setFirstName(event.target.value)} - disabled={loading} - autoComplete="given-name" - maxLength={60} - /> -

{t("Il sert à savoir qui est venu voir l'arbre.")}

-
-
+ + {/* Compte connecté : son prénom sert déjà, on ne le redemande pas */} + {!connected && ( +
+ + setFirstName(event.target.value)} + disabled={loading} + autoComplete="given-name" + maxLength={60} + /> +

{t("Il sert à savoir qui est venu voir l'arbre.")}

+
+ )} )} @@ -163,7 +166,15 @@ const AccessGate = ({ )} - {onUseAccount && !treeNotFound && ( + {connected && onNotMyAccount && !treeNotFound && ( +
+ +
+ )} + + {!connected && onUseAccount && !treeNotFound && (
+
+ +
+

{t("J'ai déjà un compte")}

+

{t('Pour retrouver vos arbres et ajouter des souvenirs.')}

+ +
+ +
+ +
+ + + + +) + +export default EntryChoice diff --git a/frontend/src/hooks/useAuth.js b/frontend/src/hooks/useAuth.js index 8358e6a..ac3528c 100644 --- a/frontend/src/hooks/useAuth.js +++ b/frontend/src/hooks/useAuth.js @@ -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) diff --git a/frontend/src/i18n/en/compte.js b/frontend/src/i18n/en/compte.js index 38ed6cc..2614bad 100644 --- a/frontend/src/i18n/en/compte.js +++ b/frontend/src/i18n/en/compte.js @@ -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", }