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 ( +
{t("Il sert à savoir qui est venu voir l'arbre.")}
-{t("Il sert à savoir qui est venu voir l'arbre.")}
+