From 1b2d8ed6e23542664aa4ff75d01666a7c60ce079 Mon Sep 17 00:00:00 2001 From: IliesAllali Date: Tue, 20 Oct 2026 14:12:18 +0200 Subject: [PATCH] =?UTF-8?q?feat(comptes):=20ALLOW=5FREGISTRATION=20ferme?= =?UTF-8?q?=20la=20cr=C3=A9ation=20de=20compte=20(#113)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit À false, POST /auth/register répond 403 registration_closed et l'app ne propose plus de créer un compte (accueil, connexion, porte d'un arbre partagé, menu du visiteur). Les comptes existants se connectent et créent leurs arbres, le partage par lien et mot de passe ne change pas. Une instance sans aucun compte accepte quand même le premier, sous un verrou de transaction PostgreSQL (deux inscriptions simultanées n'en créent qu'un). GET /auth/options expose l'état à l'écran d'accueil. Défaut true : rien ne change pour les instances existantes. Co-Authored-By: Claude Opus 5.5 --- .env.example | 7 + README.fr.md | 6 +- README.md | 6 +- backend/.env.example | 3 + backend/.env.production.example | 4 + backend/openapi/v1.yaml | 23 +++ backend/src/config/env.ts | 14 ++ backend/src/lib/registration.ts | 27 ++++ backend/src/routes/auth.ts | 21 ++- backend/test/registration-closed.test.ts | 141 ++++++++++++++++++ frontend/src/App.jsx | 27 +++- frontend/src/api/authApi.js | 5 + frontend/src/components/AccessGate.jsx | 18 ++- frontend/src/components/AccountDashboard.jsx | 28 ++-- frontend/src/components/EntryChoice.jsx | 13 +- frontend/src/components/navbar/AvatarMenu.jsx | 2 +- .../components/navbar/ContextualNavbar.jsx | 3 +- .../components/registrationClosed.test.jsx | 65 ++++++++ frontend/src/i18n/en/compte.js | 1 + frontend/src/utils/errorMessages.js | 4 + 20 files changed, 385 insertions(+), 33 deletions(-) create mode 100644 backend/src/lib/registration.ts create mode 100644 backend/test/registration-closed.test.ts create mode 100644 frontend/src/components/registrationClosed.test.jsx diff --git a/.env.example b/.env.example index 4c005af..ddbe48b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/README.fr.md b/README.fr.md index 8533931..7d45ef0 100644 --- a/README.fr.md +++ b/README.fr.md @@ -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 diff --git a/README.md b/README.md index 707f57a..4a851b6 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/backend/.env.example b/backend/.env.example index 02584f4..73872f9 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -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 diff --git a/backend/.env.production.example b/backend/.env.production.example index 239b528..62a360f 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -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 diff --git a/backend/openapi/v1.yaml b/backend/openapi/v1.yaml index 9ae9c27..0fb333f 100644 --- a/backend/openapi/v1.yaml +++ b/backend/openapi/v1.yaml @@ -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: diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 1419f0d..14dde3e 100644 --- a/backend/src/config/env.ts +++ b/backend/src/config/env.ts @@ -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) diff --git a/backend/src/lib/registration.ts b/backend/src/lib/registration.ts new file mode 100644 index 0000000..dc98371 --- /dev/null +++ b/backend/src/lib/registration.ts @@ -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 { + 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( + prisma: PrismaClient, + create: (tx: Prisma.TransactionClient) => Promise, +): Promise { + 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) + }) +} diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 8b0adeb..1beb3c1 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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( { diff --git a/backend/test/registration-closed.test.ts b/backend/test/registration-closed.test.ts new file mode 100644 index 0000000..fa51c7b --- /dev/null +++ b/backend/test/registration-closed.test.ts @@ -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() + }) +}) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8a3b67e..f33f4ee 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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' @@ -526,6 +527,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) @@ -2175,6 +2178,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 = '' @@ -2188,6 +2200,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 } @@ -2315,6 +2329,7 @@ function App() { } } + await authOptionsReady if (!active) return if (resolvedTreeId) { @@ -2331,7 +2346,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() }) } @@ -2392,8 +2407,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() @@ -2941,7 +2957,7 @@ function App() { handleUseAccount('login')} - onRegister={() => handleUseAccount('register')} + onRegister={registrationOpen ? () => handleUseAccount('register') : undefined} /> ) @@ -2965,6 +2981,7 @@ function App() { onSubmit={handleGateUnlock} onUseDemo={handleUseDemo} onUseAccount={handleUseAccount} + canRegister={registrationOpen} /> ) @@ -2980,6 +2997,7 @@ function App() { trees={auth.accountTrees} activeTreeId={tree.treeContext.treeId} defaultMode={accountEntryMode} + canRegister={registrationOpen} pendingTreeName={pendingSharedTreeName} onLogin={handleLogin} onRegister={handleRegister} @@ -3146,6 +3164,7 @@ function App() { onTreeSelect={handleOpenTreeFromAccount} onCreateTree={handleOpenTreeWizard} onProfileClick={handleUseAccount} + canRegister={registrationOpen} onAccountClick={() => setAccountPanelVisible(true)} onSettingsClick={() => setAdminPanelVisible(true)} onLogout={handleLogout} diff --git a/frontend/src/api/authApi.js b/frontend/src/api/authApi.js index d8cc165..43999fe 100644 --- a/frontend/src/api/authApi.js +++ b/frontend/src/api/authApi.js @@ -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' }) +} diff --git a/frontend/src/components/AccessGate.jsx b/frontend/src/components/AccessGate.jsx index 37d1c39..ed565e6 100644 --- a/frontend/src/components/AccessGate.jsx +++ b/frontend/src/components/AccessGate.jsx @@ -27,6 +27,7 @@ const AccessGate = ({ connectedName = '', onSubmit, onUseAccount, + canRegister = true, onNotMyAccount, }) => { const [treeId, setTreeId] = useState(initialTreeId || '') @@ -159,11 +160,18 @@ const AccessGate = ({ )} - {treeNotFound && onUseAccount && ( + {/* Inscription fermée : un compte existant crée encore ses arbres, un visiteur ne peut que se connecter */} + {treeNotFound && onUseAccount && (canRegister || connected) && ( )} + + {treeNotFound && onUseAccount && !canRegister && !connected && ( + + )} {connected && onNotMyAccount && !treeNotFound && ( @@ -179,9 +187,11 @@ const AccessGate = ({ - + {canRegister && ( + + )} )} diff --git a/frontend/src/components/AccountDashboard.jsx b/frontend/src/components/AccountDashboard.jsx index e1ee503..db6dbe3 100644 --- a/frontend/src/components/AccountDashboard.jsx +++ b/frontend/src/components/AccountDashboard.jsx @@ -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 = ({ -

- {isRegister ? t('Déjà inscrit ?') : t('Pas encore de compte ?')}{' '} - -

+ {canRegister && ( +

+ {isRegister ? t('Déjà inscrit ?') : t('Pas encore de compte ?')}{' '} + +

+ )} diff --git a/frontend/src/components/EntryChoice.jsx b/frontend/src/components/EntryChoice.jsx index 1695f8c..11c3295 100644 --- a/frontend/src/components/EntryChoice.jsx +++ b/frontend/src/components/EntryChoice.jsx @@ -27,11 +27,14 @@ const EntryChoice = ({ onFamilyLink, onLogin, onRegister }) => ( -
- -
+ {/* Absent quand l'instance n'accepte plus de compte (ALLOW_REGISTRATION=false) */} + {onRegister && ( +
+ +
+ )} diff --git a/frontend/src/components/navbar/AvatarMenu.jsx b/frontend/src/components/navbar/AvatarMenu.jsx index 20e1e13..8bea988 100644 --- a/frontend/src/components/navbar/AvatarMenu.jsx +++ b/frontend/src/components/navbar/AvatarMenu.jsx @@ -59,7 +59,7 @@ function AvatarMenu({ const body = guest ? ( <> } label={t('Se connecter')} onSelect={() => { onClose(); onLogin?.() }} /> - } label={t('Créer mon arbre')} onSelect={() => { onClose(); onRegister?.() }} /> + {onRegister && } label={t('Créer mon arbre')} onSelect={() => { onClose(); onRegister() }} />} {helpRow} diff --git a/frontend/src/components/navbar/ContextualNavbar.jsx b/frontend/src/components/navbar/ContextualNavbar.jsx index 06c9c3c..d76517f 100644 --- a/frontend/src/components/navbar/ContextualNavbar.jsx +++ b/frontend/src/components/navbar/ContextualNavbar.jsx @@ -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} /> )} diff --git a/frontend/src/components/registrationClosed.test.jsx b/frontend/src/components/registrationClosed.test.jsx new file mode 100644 index 0000000..47b1222 --- /dev/null +++ b/frontend/src/components/registrationClosed.test.jsx @@ -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 ' +const renderToStaticMarkupText = (element) => renderToStaticMarkup(element).replaceAll(''', "'") + +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() + const closed = renderToStaticMarkupText() + 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() + const closed = renderToStaticMarkupText() + 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() + const closed = renderToStaticMarkupText() + 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() + const member = renderToStaticMarkupText() + 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() + const open = renderToStaticMarkupText() + 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.") + }) +}) diff --git a/frontend/src/i18n/en/compte.js b/frontend/src/i18n/en/compte.js index 356c50f..e78dc04 100644 --- a/frontend/src/i18n/en/compte.js +++ b/frontend/src/i18n/en/compte.js @@ -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.", diff --git a/frontend/src/utils/errorMessages.js b/frontend/src/utils/errorMessages.js index 10467fb..810f5f0 100644 --- a/frontend/src/utils/errorMessages.js +++ b/frontend/src/utils/errorMessages.js @@ -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.') }