mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-09 07:50:58 +00:00
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:
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;
|
||||
@@ -81,6 +81,7 @@ enum SharedAccessRole {
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
firstName String?
|
||||
passwordHash String
|
||||
lastOpenedTreeId String?
|
||||
lastOpenedAccessMode String?
|
||||
|
||||
@@ -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' })
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
Reference in new issue
Block a user