mirror of
https://github.com/IliesAllali/parenthese.git
synced 2026-11-06 23:32:08 +00:00
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:
20 files changed
+385
-33
No files matched your search
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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' })
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 '
|
||||
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(<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.")
|
||||
})
|
||||
})
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.')
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user