Merge pull request #117 from IliesAllali/feat/inscription-fermee

Auto-hébergement : fermer la création de compte (ALLOW_REGISTRATION)
This commit is contained in:
IliesAllali authored and GitHub committed 2026-10-20 14:14:44 +02:00
commit 4dd0db9699
20 files changed
+385 -33

No files matched your search

+7
View File
@@ -34,6 +34,13 @@ CORS_ORIGIN=http://localhost
# MEDIA_QUOTA_PER_TREE_MB=5120
# MEDIA_PENDING_QUOTA_PER_TREE_MB=500
# ── Comptes ───────────────────────────────────────────────────────────────────
# À false, plus personne ne peut créer de compte : les comptes existants se connectent et
# créent leurs arbres, la famille ouvre toujours les arbres partagés avec le lien et le mot
# de passe. Une instance sans aucun compte accepte quand même le premier (le vôtre) :
# créez-le juste après le premier démarrage, avant de donner l'adresse.
# ALLOW_REGISTRATION=true
# ── Immich (reconnaissance des visages, désactivé par défaut) ────────────────
# Instances Immich autorisées sur ce serveur, séparées par des virgules. Chaque entrée
# peut recevoir un libellé, « Libellé=https://adresse » ; sans libellé, c'est le nom
+4 -2
View File
@@ -57,6 +57,8 @@ Trois conteneurs tournent : `db` (PostgreSQL 16), `api` (l'API Node) et `web` (n
Pour ouvrir l'instance à la famille depuis Internet, placez un reverse proxy HTTPS devant le port `web` (Caddy, Traefik, nginx) et mettez l'adresse publique dans `CORS_ORIGIN`, par exemple `https://arbre.votre-domaine.fr`.
Pour qu'une personne qui tombe sur l'adresse ne puisse pas s'inscrire et y créer ses arbres, mettez `ALLOW_REGISTRATION=false` dans `.env`, puis lancez `docker compose up -d`. Les comptes existants se connectent et créent leurs arbres comme avant, la famille ouvre toujours les arbres partagés avec le lien et le mot de passe, et l'application ne propose plus de créer de compte. Une instance sans aucun compte accepte quand même le premier, la variable peut donc être posée avant le tout premier démarrage : créez votre compte tout de suite, avant de donner l'adresse.
Une instance auto-hébergée n'envoie aucune mesure d'audience : le suivi d'usage de parenthese.io ne s'active que si une clé PostHog est fournie au build, ce que cette configuration ne fait pas.
Les liens d'aide et de vie privée de l'application mènent à parenthese.io. Pour pointer vers vos propres pages, renseignez `VITE_HELP_URL` et `VITE_PRIVACY_URL` quand vous construisez vous-même le front (voir `frontend/.env.example`).
@@ -69,7 +71,7 @@ docker compose ps # état des conteneurs
docker compose down # arrêt, les données sont conservées
```
Pour vérifier qu'une installation fonctionne de bout en bout, lancez `bash tools/docker-smoke.sh http://localhost` (avec le port si `WEB_PORT` n'est pas 80). Le script a besoin de `curl` et `jq`. Il passe par le port web comme un navigateur : page d'accueil, santé de l'API, création d'un compte, connexion, création puis suppression d'un arbre de test. Le compte de test (`smoke-...@example.com`) reste en base.
Pour vérifier qu'une installation fonctionne de bout en bout, lancez `bash tools/docker-smoke.sh http://localhost` (avec le port si `WEB_PORT` n'est pas 80). Le script a besoin de `curl` et `jq`. Il passe par le port web comme un navigateur : page d'accueil, santé de l'API, création d'un compte, connexion, création puis suppression d'un arbre de test. Le compte de test (`smoke-...@example.com`) reste en base. Avec `ALLOW_REGISTRATION=false`, le script s'arrête à la création du compte, c'est le refus attendu.
### Un seul conteneur (Unraid, NAS)
@@ -79,7 +81,7 @@ Pour les systèmes qui installent un conteneur par application, `ghcr.io/iliesal
docker run -d --name parenthese --restart unless-stopped -p 8080:80 -v /chemin/vers/appdata/parenthese:/data ghcr.io/iliesallali/parenthese-aio
```
Rien à remplir. Au premier démarrage, le conteneur crée dans `/data` la base, son mot de passe et la clé des sessions, puis les réutilise. Les photos et vidéos vont dans `/data/media`. Sauvegardez tout le dossier `/data` conteneur arrêté, ou exportez la base pendant qu'il tourne avec `docker exec parenthese runuser -u postgres -- pg_dump parenthese > parenthese.sql`. La base est ici PostgreSQL 15, l'installation compose utilise la 16.
Rien à remplir. Au premier démarrage, le conteneur crée dans `/data` la base, son mot de passe et la clé des sessions, puis les réutilise. Les photos et vidéos vont dans `/data/media`. Pour fermer la création de compte, ajoutez `-e ALLOW_REGISTRATION=false` (sur Unraid, « Allow registration » à `false`). Sauvegardez tout le dossier `/data` conteneur arrêté, ou exportez la base pendant qu'il tourne avec `docker exec parenthese runuser -u postgres -- pg_dump parenthese > parenthese.sql`. La base est ici PostgreSQL 15, l'installation compose utilise la 16.
### Où sont les données
+4 -2
View File
@@ -63,6 +63,8 @@ Three containers run: `db` (PostgreSQL 16), `api` (the Node API) and `web` (ngin
To open the instance to your family over the Internet, put an HTTPS reverse proxy in front of the `web` port (Caddy, Traefik, nginx) and set the public address in `CORS_ORIGIN`, for example `https://tree.your-domain.com`.
So that people who come across the address can't sign up and start their own trees, set `ALLOW_REGISTRATION=false` in `.env`, then run `docker compose up -d`. Existing accounts keep signing in and creating trees, relatives still open shared trees with the link and password, and the app no longer offers to create an account. An instance with no account at all still accepts the first one, so the variable can be set before the very first start: create your own account right away, before you give out the address.
A self-hosted instance sends no analytics. Usage tracking on parenthese.io only turns on when a PostHog key is provided at build time, which this setup does not do.
The help and privacy links in the app point to parenthese.io. To use your own pages, set `VITE_HELP_URL` and `VITE_PRIVACY_URL` when you build the front end yourself (see `frontend/.env.example`).
@@ -75,7 +77,7 @@ docker compose ps # container status
docker compose down # stop, data is kept
```
To check that an installation works end to end, run `bash tools/docker-smoke.sh http://localhost` (add the port if `WEB_PORT` is not 80). The script needs `curl` and `jq`. It goes through the web port like a browser: home page, API health, account creation, sign-in, creating then deleting a test tree. The test account (`smoke-...@example.com`) stays in the database.
To check that an installation works end to end, run `bash tools/docker-smoke.sh http://localhost` (add the port if `WEB_PORT` is not 80). The script needs `curl` and `jq`. It goes through the web port like a browser: home page, API health, account creation, sign-in, creating then deleting a test tree. The test account (`smoke-...@example.com`) stays in the database. With `ALLOW_REGISTRATION=false` the script stops at account creation, which is the expected refusal.
### Single container (Unraid, NAS)
@@ -85,7 +87,7 @@ For systems that install one container per app, `ghcr.io/iliesallali/parenthese-
docker run -d --name parenthese --restart unless-stopped -p 8080:80 -v /path/to/appdata/parenthese:/data ghcr.io/iliesallali/parenthese-aio
```
There is nothing to fill in. On first start the container creates the database, its password and the session key in `/data`, and reuses them afterwards. Photos and videos go to `/data/media`. Back up the whole `/data` folder with the container stopped, or dump the database while it runs with `docker exec parenthese runuser -u postgres -- pg_dump parenthese > parenthese.sql`. The database is PostgreSQL 15 here, the compose setup uses 16.
There is nothing to fill in. On first start the container creates the database, its password and the session key in `/data`, and reuses them afterwards. Photos and videos go to `/data/media`. To close account creation, add `-e ALLOW_REGISTRATION=false` (on Unraid, set "Allow registration" to `false`). Back up the whole `/data` folder with the container stopped, or dump the database while it runs with `docker exec parenthese runuser -u postgres -- pg_dump parenthese > parenthese.sql`. The database is PostgreSQL 15 here, the compose setup uses 16.
### Where the data lives
+3
View File
@@ -11,3 +11,6 @@ MEDIA_STORAGE_PATH="./storage/media"
# donc un libellé si deux instances partagent un nom). Les administrateurs de chaque arbre choisissent
# l'instance par son nom et donnent leur propre clé d'API. Sans cette liste, la fonction reste éteinte.
IMMICH_ALLOWED_API_URLS=""
# Création de compte. À false, seuls les comptes existants se connectent ; une instance sans aucun
# compte accepte quand même le premier, celui du propriétaire.
ALLOW_REGISTRATION=true
+4
View File
@@ -29,3 +29,7 @@ MEDIA_STORAGE_PATH="/var/lib/parenthese/media"
# donc un libellé si deux instances partagent un nom). Les administrateurs de chaque arbre choisissent
# l'instance par son nom et donnent leur propre clé d'API. Sans cette liste, la fonction reste éteinte.
IMMICH_ALLOWED_API_URLS=""
# Création de compte. À false, seuls les comptes existants se connectent ; une instance sans aucun
# compte accepte quand même le premier, celui du propriétaire.
ALLOW_REGISTRATION=true
+23
View File
@@ -538,6 +538,23 @@ components:
required: [decision]
paths:
/auth/options:
get:
tags: [Auth]
summary: What the sign-in screen can offer on this instance
responses:
'200':
description: Instance options
content:
application/json:
schema:
type: object
properties:
registration:
type: boolean
description: false when ALLOW_REGISTRATION=false and the instance already has an account
required: [registration]
/auth/register:
post:
tags: [Auth]
@@ -561,6 +578,12 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'403':
description: Registration closed on this instance (registration_closed)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'409':
description: Email already exists
content:
+14
View File
@@ -28,6 +28,16 @@ function parseTrustProxy(value: string): boolean | string {
return value
}
// « true » ou « false », sans tenir compte de la casse. z.coerce.boolean lirait « false » comme vrai.
function parseBooleanFlag(name: string) {
return (value: string): boolean => {
const normalized = value.trim().toLowerCase()
if (normalized === 'true') return true
if (normalized === 'false') return false
throw new Error(`${name} must be "true" or "false"`)
}
}
const envSchema = z.object({
NODE_ENV: z.enum(['development', 'test', 'production']).default('development'),
PORT: z.coerce.number().int().positive().default(4000),
@@ -54,6 +64,10 @@ const envSchema = z.object({
// d'un arbre choisissent leur connexion parmi cette liste : sans elle, la reconnaissance
// des visages reste éteinte et le serveur ne peut pas servir de proxy vers un service interne.
IMMICH_ALLOWED_API_URLS: z.string().default(''),
// Création de compte. À « false », seuls les comptes existants se connectent : une instance ouverte sur
// Internet ne laisse pas un inconnu qui trouve l'adresse créer ses arbres. Une instance sans aucun compte
// accepte quand même le premier, celui du propriétaire (voir lib/registration.ts).
ALLOW_REGISTRATION: z.string().default('true').transform(parseBooleanFlag('ALLOW_REGISTRATION')),
})
export const env = envSchema.parse(process.env)
+27
View File
@@ -0,0 +1,27 @@
import type { Prisma, PrismaClient } from '@prisma/client'
import { env } from '../config/env.js'
// Inscription fermée (ALLOW_REGISTRATION=false) : une instance sans aucun compte accepte quand même le premier,
// sinon personne ne pourrait jamais s'y connecter. Dès qu'un compte existe, plus aucun ne se crée.
export async function isRegistrationOpen(prisma: PrismaClient): Promise<boolean> {
if (env.ALLOW_REGISTRATION) return true
return (await prisma.user.count()) === 0
}
// Seul chemin de création d'un compte. Inscription fermée : le compte n'est créé que si la base n'en a aucun,
// sous un verrou de transaction pour que deux inscriptions simultanées sur une instance neuve n'en créent pas
// deux. Renvoie null quand la création est refusée.
export async function createAccountIfAllowed<T>(
prisma: PrismaClient,
create: (tx: Prisma.TransactionClient) => Promise<T>,
): Promise<T | null> {
if (env.ALLOW_REGISTRATION) return create(prisma)
return prisma.$transaction(async (tx) => {
// Clé arbitraire, propre à la création de compte. pg_advisory_xact_lock renvoie void : $executeRaw, pas $queryRaw.
await tx.$executeRaw`SELECT pg_advisory_xact_lock(113001)`
if ((await tx.user.count()) > 0) return null
return create(tx)
})
}
+19 -2
View File
@@ -4,6 +4,7 @@ import { z } from 'zod'
import { env } from '../config/env.js'
import { hashPassword, verifyPassword } from '../lib/auth.js'
import { createAccountIfAllowed, isRegistrationOpen } from '../lib/registration.js'
import { revokeUserToken } from '../lib/token-revocation.js'
import { deletedAccountVisitorKey } from '../lib/tree-visits.js'
import { deleteTreeRecords, removeTreeMediaDirectories } from '../lib/tree-purge.js'
@@ -94,6 +95,13 @@ async function resolveAccessibleLastTree(
}
export const authRoutes: FastifyPluginAsync = async (app) => {
// Ce que l'écran d'accueil propose : l'app cache « Créer un compte » quand l'instance n'en accepte plus
app.get(
'/options',
{ config: { rateLimit: { max: 60, timeWindow: '1 minute' } } },
async (_request, reply) => reply.send({ registration: await isRegistrationOpen(app.prisma) }),
)
app.post(
'/register',
{
@@ -110,6 +118,11 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() })
}
// Inscription fermée : refusée avant toute lecture, la réponse ne dit pas si l'adresse a un compte
if (!(await isRegistrationOpen(app.prisma))) {
return reply.code(403).send({ error: 'registration_closed' })
}
const email = parsed.data.email.trim().toLowerCase()
const existing = await app.prisma.user.findUnique({ where: { email } })
if (existing) {
@@ -117,7 +130,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
}
const passwordHash = await hashPassword(parsed.data.password)
const user = await app.prisma.user.create({
const user = await createAccountIfAllowed(app.prisma, (tx) => tx.user.create({
data: {
email,
passwordHash,
@@ -128,7 +141,11 @@ export const authRoutes: FastifyPluginAsync = async (app) => {
email: true,
firstName: true,
},
})
}))
// Un autre compte est né pendant le calcul du hash, sur une instance fermée
if (!user) {
return reply.code(403).send({ error: 'registration_closed' })
}
const token = app.jwt.sign(
{
+141
View File
@@ -0,0 +1,141 @@
import type { FastifyInstance } from 'fastify'
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
// Inscription fermée (ALLOW_REGISTRATION=false, issue #113) : plus aucun compte ne naît, les comptes existants
// se connectent, et une instance neuve accepte quand même son premier compte.
const { prismaMock } = vi.hoisted(() => ({
prismaMock: {
user: {
findUnique: vi.fn(),
create: vi.fn(),
count: vi.fn(),
},
$transaction: vi.fn(),
$executeRaw: vi.fn(),
$disconnect: vi.fn(),
},
}))
vi.mock('../src/lib/prisma.js', () => ({
prisma: prismaMock,
}))
let app: FastifyInstance
let env: { ALLOW_REGISTRATION: boolean }
const newAccount = { firstName: 'Camille', email: 'camille@example.com', password: 'securepass123' }
beforeAll(async () => {
process.env.NODE_ENV = 'test'
process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy'
process.env.JWT_SECRET = 'test-secret-with-sufficient-length'
process.env.CORS_ORIGIN = 'http://localhost:5173'
process.env.ALLOW_REGISTRATION = ' False '
;({ env } = await import('../src/config/env.js'))
const { createApp } = await import('../src/app.js')
app = createApp()
})
afterAll(async () => {
delete process.env.ALLOW_REGISTRATION
await app?.close()
})
beforeEach(() => {
vi.clearAllMocks()
env.ALLOW_REGISTRATION = false
prismaMock.user.findUnique.mockResolvedValue(null)
prismaMock.user.count.mockResolvedValue(3)
prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: newAccount.email, firstName: 'Camille' })
prismaMock.$transaction.mockImplementation(async (run: (tx: typeof prismaMock) => unknown) => run(prismaMock))
prismaMock.$executeRaw.mockResolvedValue(1)
})
describe('inscription fermée', () => {
it('lit la variable : « false » ferme, sans tenir compte de la casse', () => {
expect(env.ALLOW_REGISTRATION).toBe(false)
})
it("refuse l'inscription quand des comptes existent, sans rien créer", async () => {
const res = await app.inject({ method: 'POST', url: '/auth/register', payload: newAccount })
expect(res.statusCode).toBe(403)
expect(res.json()).toEqual({ error: 'registration_closed' })
expect(prismaMock.user.create).not.toHaveBeenCalled()
})
it("répond pareil pour une adresse qui a déjà un compte (pas d'énumération)", async () => {
prismaMock.user.findUnique.mockResolvedValue({ id: 'user-9', email: newAccount.email })
const res = await app.inject({ method: 'POST', url: '/auth/register', payload: newAccount })
expect(res.statusCode).toBe(403)
expect(res.json()).toEqual({ error: 'registration_closed' })
})
it('garde la connexion des comptes existants', async () => {
const { hashPassword } = await import('../src/lib/auth.js')
prismaMock.user.findUnique.mockResolvedValue({
id: 'user-9',
email: newAccount.email,
firstName: 'Camille',
passwordHash: await hashPassword(newAccount.password),
})
const res = await app.inject({
method: 'POST',
url: '/auth/login',
payload: { email: newAccount.email, password: newAccount.password },
})
expect(res.statusCode).toBe(200)
expect(res.json().token).toEqual(expect.any(String))
}, 15000)
it("annonce l'inscription fermée à l'écran d'accueil", async () => {
const res = await app.inject({ method: 'GET', url: '/auth/options' })
expect(res.statusCode).toBe(200)
expect(res.json()).toEqual({ registration: false })
})
it('instance neuve : le premier compte se crée, sous verrou', async () => {
prismaMock.user.count.mockResolvedValue(0)
const options = await app.inject({ method: 'GET', url: '/auth/options' })
expect(options.json()).toEqual({ registration: true })
const res = await app.inject({ method: 'POST', url: '/auth/register', payload: newAccount })
expect(res.statusCode).toBe(201)
expect(prismaMock.$transaction).toHaveBeenCalledTimes(1)
expect(prismaMock.$executeRaw).toHaveBeenCalledTimes(1)
expect(prismaMock.user.create).toHaveBeenCalledTimes(1)
})
it("instance neuve : une seconde inscription simultanée est refusée une fois le verrou pris", async () => {
// Base vide au premier contrôle, puis le compte concurrent est là une fois le verrou obtenu
prismaMock.user.count.mockResolvedValueOnce(0).mockResolvedValueOnce(1)
const res = await app.inject({ method: 'POST', url: '/auth/register', payload: newAccount })
expect(res.statusCode).toBe(403)
expect(res.json()).toEqual({ error: 'registration_closed' })
expect(prismaMock.user.create).not.toHaveBeenCalled()
})
})
describe('inscription ouverte (par défaut)', () => {
it('crée le compte sans transaction ni verrou, comme avant', async () => {
env.ALLOW_REGISTRATION = true
const options = await app.inject({ method: 'GET', url: '/auth/options' })
expect(options.json()).toEqual({ registration: true })
const res = await app.inject({ method: 'POST', url: '/auth/register', payload: newAccount })
expect(res.statusCode).toBe(201)
expect(prismaMock.$transaction).not.toHaveBeenCalled()
expect(prismaMock.user.count).not.toHaveBeenCalled()
})
})
+23 -4
View File
@@ -52,6 +52,7 @@ import {
uploadPersonMedia,
} from './api/treeApi'
import { fetchImmichConfig, getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js'
import { fetchAuthOptions } from './api/authApi'
import { useAuth } from './hooks/useAuth'
import { useTreeAccess, getTreeTokenStorageKey, getSlugFromPathname, readTreeAccessRole, TREE_ID_FROM_ENV } from './hooks/useTreeAccess'
import { useMediaManager } from './hooks/useMediaManager'
@@ -529,6 +530,8 @@ function App() {
})
const [treeNotFound, setTreeNotFound] = useState(false)
const [accountEntryMode, setAccountEntryMode] = useState('login')
// Inscription fermée sur l'instance (ALLOW_REGISTRATION=false, #113) : plus de « Créer un compte » nulle part
const [registrationOpen, setRegistrationOpen] = useState(true)
const [treeWizardVisible, setTreeWizardVisible] = useState(false)
const [treeWizardLoading, setTreeWizardLoading] = useState(false)
const [treeWelcomeVisible, setTreeWelcomeVisible] = useState(false)
@@ -2178,6 +2181,15 @@ function App() {
return
}
// Lue en parallèle, attendue avant le premier écran sans session pour ne pas montrer un bouton qui disparaît
let registrationAllowed = true
const authOptionsReady = fetchAuthOptions()
.then((options) => {
registrationAllowed = options?.registration !== false
if (active) setRegistrationOpen(registrationAllowed)
})
.catch(() => {})
const slug = getSlugFromPathname()
let resolvedTreeId = TREE_ID_FROM_ENV
let resolvedTreeName = ''
@@ -2191,6 +2203,8 @@ function App() {
if (!slug && !TREE_ID_FROM_ENV && !query.has('account')) {
const userToken = auth.restoreUserToken()
if (!userToken) {
await authOptionsReady
if (!active) return
tree.setBootState('welcome')
return
}
@@ -2318,6 +2332,7 @@ function App() {
}
}
await authOptionsReady
if (!active) return
if (resolvedTreeId) {
@@ -2334,7 +2349,7 @@ function App() {
}
// ?account=register (CTA de la landing) : écran compte directement en création
if (query.get('account') === 'register') {
if (query.get('account') === 'register' && registrationAllowed) {
setAccountEntryMode('register')
trackAppEvent('account_screen_viewed', { mode: 'register', from_shared_tree: false, source: getSource() })
}
@@ -2395,8 +2410,9 @@ function App() {
tree.setGateError('')
auth.setAccountError('')
setAccountPanelVisible(false)
setAccountEntryMode(mode === 'register' ? 'register' : 'login')
trackAppEvent('account_screen_viewed', { mode: mode === 'register' ? 'register' : 'login', from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId) })
const entryMode = mode === 'register' && registrationOpen ? 'register' : 'login'
setAccountEntryMode(entryMode)
trackAppEvent('account_screen_viewed', { mode: entryMode, from_shared_tree: Boolean(pendingSharedTreeRef.current?.treeId) })
media.resetMediaManager()
setAdminPanelVisible(false)
contrib.resetContribState()
@@ -2944,7 +2960,7 @@ function App() {
<EntryChoice
onFamilyLink={handleFamilyLinkChoice}
onLogin={() => handleUseAccount('login')}
onRegister={() => handleUseAccount('register')}
onRegister={registrationOpen ? () => handleUseAccount('register') : undefined}
/>
</div>
)
@@ -2968,6 +2984,7 @@ function App() {
onSubmit={handleGateUnlock}
onUseDemo={handleUseDemo}
onUseAccount={handleUseAccount}
canRegister={registrationOpen}
/>
</div>
)
@@ -2983,6 +3000,7 @@ function App() {
trees={auth.accountTrees}
activeTreeId={tree.treeContext.treeId}
defaultMode={accountEntryMode}
canRegister={registrationOpen}
pendingTreeName={pendingSharedTreeName}
onLogin={handleLogin}
onRegister={handleRegister}
@@ -3149,6 +3167,7 @@ function App() {
onTreeSelect={handleOpenTreeFromAccount}
onCreateTree={handleOpenTreeWizard}
onProfileClick={handleUseAccount}
canRegister={registrationOpen}
onAccountClick={() => setAccountPanelVisible(true)}
onSettingsClick={() => setAdminPanelVisible(true)}
onLogout={handleLogout}
+5
View File
@@ -75,3 +75,8 @@ export async function updateLastOpenedTree(token, payload) {
return data?.lastTree || null
}
// Options de l'instance : inscription ouverte ou fermée (ALLOW_REGISTRATION côté API)
export async function fetchAuthOptions() {
return apiRequest('/auth/options', { method: 'GET' })
}
+14 -4
View File
@@ -27,6 +27,7 @@ const AccessGate = ({
connectedName = '',
onSubmit,
onUseAccount,
canRegister = true,
onNotMyAccount,
}) => {
const [treeId, setTreeId] = useState(initialTreeId || '')
@@ -159,11 +160,18 @@ const AccessGate = ({
</button>
)}
{treeNotFound && onUseAccount && (
{/* Inscription fermée : un compte existant crée encore ses arbres, un visiteur ne peut que se connecter */}
{treeNotFound && onUseAccount && (canRegister || connected) && (
<button type="button" className="pz-btn pz-btn--primary pz-btn--block" onClick={() => onUseAccount('register')} disabled={loading}>
{t('Créer mon propre arbre')}
</button>
)}
{treeNotFound && onUseAccount && !canRegister && !connected && (
<button type="button" className="pz-btn pz-btn--primary pz-btn--block" onClick={() => onUseAccount('login')} disabled={loading}>
{t('Se connecter')}
</button>
)}
</form>
{connected && onNotMyAccount && !treeNotFound && (
@@ -179,9 +187,11 @@ const AccessGate = ({
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onUseAccount('login')} disabled={loading}>
{t("J'ai déjà un compte")}
</button>
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onUseAccount('register')} disabled={loading}>
{t("Je n'ai pas de compte")}
</button>
{canRegister && (
<button type="button" className="pz-btn pz-btn--ghost pz-btn--sm" onClick={() => onUseAccount('register')} disabled={loading}>
{t("Je n'ai pas de compte")}
</button>
)}
</div>
)}
+16 -12
View File
@@ -23,6 +23,7 @@ const AccountDashboard = ({
trees,
activeTreeId,
defaultMode = 'login',
canRegister = true,
pendingTreeName = '',
onLogin,
onRegister,
@@ -78,7 +79,8 @@ const AccountDashboard = ({
setMode(defaultMode === 'register' ? 'register' : 'login')
}, [defaultMode])
const isRegister = mode === 'register'
// Inscription fermée sur l'instance : seul le formulaire de connexion reste
const isRegister = mode === 'register' && canRegister
const canSubmitAuth = useMemo(() => {
if (loading) return false
@@ -207,17 +209,19 @@ const AccountDashboard = ({
</button>
</div>
<p className="pz-auth-switch pz-small">
{isRegister ? t('Déjà inscrit ?') : t('Pas encore de compte ?')}{' '}
<button
type="button"
className="pz-link account-signup-toggle"
onClick={() => setMode(isRegister ? 'login' : 'register')}
disabled={loading}
>
{isRegister ? t('Se connecter') : t('Créer un compte')}
</button>
</p>
{canRegister && (
<p className="pz-auth-switch pz-small">
{isRegister ? t('Déjà inscrit ?') : t('Pas encore de compte ?')}{' '}
<button
type="button"
className="pz-link account-signup-toggle"
onClick={() => setMode(isRegister ? 'login' : 'register')}
disabled={loading}
>
{isRegister ? t('Se connecter') : t('Créer un compte')}
</button>
</p>
)}
<LanguageSwitch className="pz-lang--screen" />
</div>
+8 -5
View File
@@ -27,11 +27,14 @@ const EntryChoice = ({ onFamilyLink, onLogin, onRegister }) => (
</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>
{/* Absent quand l'instance n'accepte plus de compte (ALLOW_REGISTRATION=false) */}
{onRegister && (
<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>
@@ -59,7 +59,7 @@ function AvatarMenu({
const body = guest ? (
<>
<NavMenuItem icon={<LogIn {...ICON} />} label={t('Se connecter')} onSelect={() => { onClose(); onLogin?.() }} />
<NavMenuItem icon={<UserPlus {...ICON} />} label={t('Créer mon arbre')} onSelect={() => { onClose(); onRegister?.() }} />
{onRegister && <NavMenuItem icon={<UserPlus {...ICON} />} label={t('Créer mon arbre')} onSelect={() => { onClose(); onRegister() }} />}
<NavMenuSep />
{helpRow}
</>
@@ -50,6 +50,7 @@ function ContextualNavbar({
onTreeSelect,
onCreateTree,
onProfileClick,
canRegister = true,
onAccountClick,
onSettingsClick,
onLogout,
@@ -408,7 +409,7 @@ function ContextualNavbar({
onCreateTree={onCreateTree}
onSettings={onSettingsClick}
onLogin={() => onProfileClick?.('login')}
onRegister={handleCreateAccountClick}
onRegister={canRegister ? handleCreateAccountClick : undefined}
onLogout={onLogout}
/>
)}
@@ -0,0 +1,65 @@
import { renderToStaticMarkup } from 'react-dom/server'
import { describe, expect, it } from 'vitest'
import AccessGate from './AccessGate.jsx'
import AccountDashboard from './AccountDashboard.jsx'
import EntryChoice from './EntryChoice.jsx'
import AvatarMenu from './navbar/AvatarMenu.jsx'
import { getAccountErrorMessage } from '../utils/errorMessages.js'
// Inscription fermée sur l'instance (ALLOW_REGISTRATION=false, #113) : aucun écran ne propose de créer un compte,
// la connexion reste partout où elle était.
const noop = () => {}
// React échappe l'apostrophe en &#x27;
const renderToStaticMarkupText = (element) => renderToStaticMarkup(element).replaceAll('&#x27;', "'")
describe('inscription fermée, écrans sans session', () => {
it("l'accueil garde le lien de famille et la connexion, sans « Créer mon arbre »", () => {
const open = renderToStaticMarkupText(<EntryChoice onFamilyLink={noop} onLogin={noop} onRegister={noop} />)
const closed = renderToStaticMarkupText(<EntryChoice onFamilyLink={noop} onLogin={noop} />)
expect(open).toContain('Créer mon arbre')
expect(closed).not.toContain('Créer mon arbre')
expect(closed).toContain('Se connecter')
expect(closed).toContain("J'ai reçu un lien de famille")
})
it("l'écran de connexion n'a plus de bascule vers la création, même ouvert en mode création", () => {
const props = { authenticated: false, trees: [], onLogin: noop, onRegister: noop, onBackToAccess: noop }
const open = renderToStaticMarkupText(<AccountDashboard {...props} defaultMode="register" />)
const closed = renderToStaticMarkupText(<AccountDashboard {...props} defaultMode="register" canRegister={false} />)
expect(open).toContain('Créer mon compte')
expect(closed).not.toContain('Créer mon compte')
expect(closed).not.toContain('Créer un compte')
expect(closed).toContain('Se connecter')
})
it("la porte d'un arbre partagé ne propose plus « Je n'ai pas de compte »", () => {
const props = { initialTreeId: 'tree-1', treeName: 'Famille Test', onSubmit: noop, onUseAccount: noop }
const open = renderToStaticMarkupText(<AccessGate {...props} />)
const closed = renderToStaticMarkupText(<AccessGate {...props} canRegister={false} />)
expect(open).toContain("Je n'ai pas de compte")
expect(closed).not.toContain("Je n'ai pas de compte")
expect(closed).toContain("J'ai déjà un compte")
})
it('lien mort : un visiteur se connecte, un compte connecté crée encore son arbre', () => {
const props = { treeNotFound: true, onSubmit: noop, onUseAccount: noop, canRegister: false }
const visitor = renderToStaticMarkupText(<AccessGate {...props} />)
const member = renderToStaticMarkupText(<AccessGate {...props} connected />)
expect(visitor).not.toContain('Créer mon propre arbre')
expect(visitor).toContain('Se connecter')
expect(member).toContain('Créer mon propre arbre')
})
it("le menu d'un visiteur sur un arbre garde « Se connecter » seul", () => {
const closed = renderToStaticMarkupText(<AvatarMenu guest onClose={noop} onLogin={noop} />)
const open = renderToStaticMarkupText(<AvatarMenu guest onClose={noop} onLogin={noop} onRegister={noop} />)
expect(open).toContain('Créer mon arbre')
expect(closed).not.toContain('Créer mon arbre')
expect(closed).toContain('Se connecter')
})
it("le refus du serveur s'explique", () => {
expect(getAccountErrorMessage({ status: 403, payload: { error: 'registration_closed' } }))
.toBe("Cette instance n'accepte plus de nouveaux comptes. Demandez à la personne qui l'héberge.")
})
})
+1
View File
@@ -43,6 +43,7 @@ export default {
"Ouvrir un arbre partagé avec moi": "Open a tree shared with me",
"Vous avez déjà un compte ?": "Already have an account?",
"Pas encore de compte ?": "Don't have an account yet?",
"Cette instance n'accepte plus de nouveaux comptes. Demandez à la personne qui l'héberge.": "This instance no longer accepts new accounts. Ask the person who hosts it.",
"Me connecter": "Sign in",
"Vos *arbres*": "Your *trees*",
"Ouvrez un arbre, ou commencez celui d'une autre branche de la famille.": "Open a tree, or start one for another branch of the family.",
+4
View File
@@ -59,6 +59,10 @@ export function getAccountErrorMessage(error) {
return t("L'arbre n'a plus de place pour de nouveaux souvenirs. Son propriétaire peut en supprimer.")
}
if (errorCode === 'registration_closed') {
return t("Cette instance n'accepte plus de nouveaux comptes. Demandez à la personne qui l'héberge.")
}
if (errorCode === 'citation_text_required') {
return t('Ajoutez le texte de la citation.')
}