diff --git a/.env.example b/.env.example index 4c005af..278b324 100644 --- a/.env.example +++ b/.env.example @@ -34,6 +34,26 @@ 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 + +# ── Connexion par un fournisseur d'identité (OIDC : Authentik, Authelia, Keycloak…) ── +# Éteinte sans OIDC_ISSUER. Chez le fournisseur, adresse de retour = adresse publique + /auth/oidc/callback. +# Détails dans le README, section « Connexion par votre fournisseur d'identité ». +# OIDC_ISSUER=https://auth.votre-domaine.fr/application/o/parenthese/ +# OIDC_CLIENT_ID=parenthese +# OIDC_CLIENT_SECRET= +# OIDC_REDIRECT_URI=https://arbre.votre-domaine.fr/auth/oidc/callback +# OIDC_PROVIDER_NAME=Authentik +# Email accepté seulement s'il est vérifié chez le fournisseur (email_verified). +# OIDC_REQUIRE_VERIFIED_EMAIL=true +# À false, plus de mot de passe Parenthèse : connexion par le fournisseur seulement. +# PASSWORD_LOGIN=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/.gitattributes b/.gitattributes index 0176cb2..96dbd58 100644 --- a/.gitattributes +++ b/.gitattributes @@ -2,3 +2,5 @@ * text=auto # Scripts lancés sous Linux (serveur, conteneurs, CI) : fins de ligne LF même sur un clone Windows *.sh text eol=lf +# Témoins texte du placement : comparés octet pour octet par vitest, toujours en LF +frontend/src/components/galaxy/__snapshots__/** text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75a3631..59eac7e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -182,6 +182,13 @@ jobs: - name: Test de fumée par le port web run: bash tools/docker-smoke.sh http://localhost:8080 + # Les commandes de sauvegarde et de restauration du README, jouées pour de vrai + - name: Sauvegarde puis restauration + run: | + set -euo pipefail + bash tools/docker-restore-check.sh http://localhost:8080 + bash tools/docker-smoke.sh http://localhost:8080 + # Un second front qui joint l'API par le nom de son conteneur (API_UPSTREAM), comme sur Umbrel. - name: Front avec une autre adresse d'API run: | @@ -233,6 +240,9 @@ jobs: docker restart aio bash tools/docker-smoke.sh http://localhost:8080 docker logs aio 2>&1 | grep -c "première installation" | grep -qx 1 + # Base à jour au redémarrage : pas de sauvegarde avant migration, mais pg_dump marche dans l'image + [ -z "$(docker exec aio ls -A /data/backups 2>/dev/null)" ] + [ "$(docker exec aio runuser -u postgres -- pg_dump -Fc parenthese | wc -c)" -gt 1000 ] - name: Journaux en cas d'échec if: failure() diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f72fbc..897a354 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,52 @@ Notable changes to Parenthèse. Dates are in YYYY-MM-DD format. +## 1.2.1 (2026-10-21) + +### Fixes + +- Clicking a person without a photo opens their profile again. The file picker only opens from "+ photo" in edit mode (#124, reported by @coutadeurf, #125). +- GEDCOM export stays valid 5.5.1. Sex "other" is written as `SEX U`, and `SEX X` is still read on import (#126). + +### Self-hosting + +- With `PASSWORD_LOGIN=false`, an account that had a password and was linked to OIDC can delete itself by retyping its email after a recent sign-in (#126). +- An email already linked to an OIDC identity can't be claimed by a different subject at the provider (#126). +- HEIC and AVIF files are recognised as images instead of video, and are refused cleanly until they can be converted (#126). + +## 1.2.0 (2026-10-20) + +### Tree layout + +- Rows follow the kinship generation from the tree's starting person: spouses on the same row, parents one row up, children one row down. A shorter branch married into a deeper one no longer sits several rows too high (#67, reported by @SpaceOptimist, #111). +- One tree, never islands. An in-law family attaches to its child at the end of the sibling row, never between two siblings, and each union of a remarriage sits above its own children (#105, #116). +- In-law families wider than a screen open in their own tab, the same tree drawn from the spouse. A pill above the spouse leads there and back, and tabs with the same name show the couple's first names. Search opens the tab holding the person. Tree settings can turn tabs off (#116, #81 reported by @coutadeurf). +- Lines between families are full lines when they cross no other family, with one trunk per sibling row (#83, #90). + +### People + +- Sex can be set to female, male or other, and is unspecified by default. Relationship names follow it in French and English, with neutral or double forms when unknown. GEDCOM `SEX X` is read and written (#114, suggested by @Arc676, #118). +- Nicknames show under the name in the tree and the profile (#85). The last name is optional and an unknown first name can be ticked (#87, #89). +- Profiles: pills for facts, place of death, adoption, autosave when leaving, a cleared field stays cleared, close by swiping down on phones (#91, #93, #98, #101, #106). +- Portraits can be cropped with the full photo kept, in a round or square frame, and "+ photo" opens the picker right away (#95, #108, #109). Admins can edit a memory (#94). + +### Sharing and family + +- Guests can propose changes with the pencil, and the owner sees pending proposals on their avatar as soon as the tree opens (#96, #121). +- A simpler start page with two choices, one door for shared trees, and a clearer login (#86, #88, #92). +- The bottom bar is one pill with Add or correct and the account menu (#100, #104, #107). Search results can be tapped in edit mode on phones (#110). + +### Self-hosting + +- `ALLOW_REGISTRATION=false` closes sign-up. Existing accounts log in and the first account of an empty instance is still allowed (#113, suggested by @milan616, #117). +- OIDC login (experimental) with `OIDC_ISSUER`, `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` and `OIDC_REDIRECT_URI`. An existing account is linked by verified email only, and never an account that has a password (sign-up does not verify the address). `PASSWORD_LOGIN=false` turns passwords off (#115, suggested by @Arc676, #122). + +### Fixes and maintenance + +- GEDCOM import in transactions and much faster on large files, backups restore tested in CI, and other points from the 18/10 audit (#97). A union without a date sits at its logical place in the Life tab (#102). +- Mobile: separated parents drawn as a dotted line, lighter edit bar, dates on the number pad (#82). +- Eleven unused interface modules removed (#119). + ## 1.1.0 (2026-10-17) ### Life tab diff --git a/README.fr.md b/README.fr.md index 910b661..6b5f741 100644 --- a/README.fr.md +++ b/README.fr.md @@ -57,6 +57,26 @@ 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. + +#### Connexion par votre fournisseur d'identité (OIDC) + +*Expérimental dans la 1.2.0 : testé contre un fournisseur OpenID certifié, vos retours sur de vraies installations sont les bienvenus dans les issues.* + +Parenthèse peut confier la connexion à un fournisseur OpenID Connect que vous faites déjà tourner (Authentik, Authelia, Keycloak, Pocket ID, Zitadel…) : la famille garde un seul compte et sa double authentification. Créez chez lui une application OIDC (client confidentiel, flux « authorization code ») dont l'adresse de retour est l'adresse publique de Parenthèse suivie de `/auth/oidc/callback`, puis renseignez dans `.env` : + +```bash +OIDC_ISSUER=https://auth.votre-domaine.fr/application/o/parenthese/ # exactement comme le fournisseur l'annonce +OIDC_CLIENT_ID=parenthese +OIDC_CLIENT_SECRET=... # vide pour un client public (PKCE seul) +OIDC_REDIRECT_URI=https://arbre.votre-domaine.fr/auth/oidc/callback +OIDC_PROVIDER_NAME=Authentik # nom affiché sur le bouton +``` + +L'écran de connexion affiche alors « Se connecter avec Authentik ». À la première connexion, Parenthèse relie le compte du fournisseur au compte Parenthèse qui a le même email, ou en crée un si `ALLOW_REGISTRATION` le permet. Inscription fermée, le fournisseur ne connecte que les personnes qui ont déjà un compte (le premier compte d'une instance vide reste accepté). Un email n'est pris en compte que si le fournisseur le dit vérifié (`email_verified`) ; `OIDC_REQUIRE_VERIFIED_EMAIL=false` lève ce contrôle, pour un fournisseur qui ne l'envoie pas et où personne ne choisit lui-même son adresse. Une fois relié, un compte suit l'identifiant de l'utilisateur chez le fournisseur, pas son email. + +`PASSWORD_LOGIN=false` retire les mots de passe Parenthèse : la connexion passe par le fournisseur seulement (l'API refuse de démarrer sans `OIDC_ISSUER`). Un compte créé par le fournisseur n'a pas de mot de passe ; pour le supprimer, on retape son email. `OIDC_SCOPES` (par défaut `openid email profile`) et `OIDC_APP_URL` (où l'app s'ouvre après la connexion, par défaut l'origine de `OIDC_REDIRECT_URI`) se changent rarement. + 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 +89,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 +99,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 @@ -99,7 +119,7 @@ docker run --rm -v parenthese_media:/data -v "$PWD":/backup alpine tar -czf /bac La première commande sauvegarde la base. Elle contient les personnes, leurs liens, les comptes et les contributions, et elle sait à qui appartient chaque photo, vidéo ou enregistrement. La seconde sauvegarde les fichiers eux-mêmes. Il faut les deux. Restaurez la base seule et les souvenirs pointent vers des fichiers disparus. Restaurez l'archive seule et vous avez un dossier de fichiers rattachés à personne. L'export GEDCOM n'est pas une sauvegarde non plus. Il sert à emmener l'arbre vers un autre logiciel de généalogie et laisse de côté les médias et les annotations. -Adaptez `-U parenthese` et le nom de base si vous avez changé `POSTGRES_USER` ou `POSTGRES_DB`. Pour restaurer la base : `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean < parenthese-db-AAAA-MM-JJ.dump`, puis décompressez l'archive des médias dans le volume avec la même commande `docker run`, `tar -xzf` à la place de `-czf`. +Adaptez `-U parenthese` et le nom de base si vous avez changé `POSTGRES_USER` ou `POSTGRES_DB`. Pour restaurer la base : `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean --if-exists < parenthese-db-AAAA-MM-JJ.dump`, puis décompressez l'archive des médias dans le volume avec la même commande `docker run`, `tar -xzf` à la place de `-czf`. Le script `deploy/backup.sh` fait la même chose (dump PostgreSQL, archive des médias, sommes de contrôle, rotation, envoi S3 optionnel) pour une installation sans Docker : il lit `backend/.env` et a besoin de `pg_dump` sur la machine. Ses réglages passent par les variables `BACKUP_ROOT`, `BACKUP_RETENTION_DAYS` et `BACKUP_S3_BUCKET`. @@ -111,7 +131,7 @@ docker compose pull && docker compose up -d `latest` suit la branche main. Pour rester sur une version, renseignez `PARENTHESE_VERSION` dans `.env` (par exemple `1.0`). Depuis un clone, `git pull && docker compose up -d --build` reconstruit depuis les sources. -Les migrations de base de données s'appliquent toutes seules au démarrage de l'API. Faites une sauvegarde avant, par habitude. +Les migrations de base de données s'appliquent toutes seules au démarrage de l'API, et une migration ne se défait pas. Lancez les deux commandes de sauvegarde ci-dessus avant chaque mise à jour. L'image tout-en-un le fait seule : avant d'appliquer une migration, elle copie la base dans `/data/backups` et garde les cinq dernières copies. ## Développer diff --git a/README.md b/README.md index afedadb..868a97d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A family tree you explore like a map, with the photos, videos and voices of each person. Share it with one link and a password: relatives look around without an account and add what they know. -Website: [parenthese.io/en](https://parenthese.io/en/) · Demo, no account needed: [app.parenthese.io](https://app.parenthese.io/?lang=en) · [Version française](README.fr.md) +Website: [parenthese.io/en](https://parenthese.io/en/) · Demo, no account needed: [app.parenthese.io](https://app.parenthese.io/?demo&lang=en) · [Version française](README.fr.md) ![The demo tree, with Jean Dupont's profile open and his photos](docs/demo-tree.png) @@ -63,6 +63,26 @@ 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. + +#### Sign in with your identity provider (OIDC) + +*Experimental in 1.2.0: tested against a certified OpenID provider, feedback from real setups is welcome in the issues.* + +Parenthèse can hand sign-in to an OpenID Connect provider you already run (Authentik, Authelia, Keycloak, Pocket ID, Zitadel…), so your family reuses one account and its two-factor settings. Create an OIDC application (confidential client, authorization code flow) whose redirect URI is the public address of Parenthèse followed by `/auth/oidc/callback`, then set in `.env`: + +```bash +OIDC_ISSUER=https://auth.your-domain.com/application/o/parenthese/ # exactly as the provider announces it +OIDC_CLIENT_ID=parenthese +OIDC_CLIENT_SECRET=... # empty for a public client (PKCE only) +OIDC_REDIRECT_URI=https://tree.your-domain.com/auth/oidc/callback +OIDC_PROVIDER_NAME=Authentik # shown on the button +``` + +The sign-in screen then shows "Sign in with Authentik". On a first sign-in, Parenthèse links the provider account to the Parenthèse account with the same email, or creates one when `ALLOW_REGISTRATION` allows it. With registration closed, the provider only signs in people who already have an account (the first account of an empty instance is still accepted). Emails are only trusted when the provider marks them verified (`email_verified`); `OIDC_REQUIRE_VERIFIED_EMAIL=false` lifts that check, for a provider that doesn't send the claim and where nobody picks their own address. Once linked, an account stays tied to the provider's user id, not to the email. + +`PASSWORD_LOGIN=false` removes Parenthèse passwords altogether: sign-in goes through the provider only (the API refuses to start without `OIDC_ISSUER`). Accounts created through the provider have no password; to delete one, its owner types their email instead. `OIDC_SCOPES` (default `openid email profile`) and `OIDC_APP_URL` (where the app opens after sign-in, by default the origin of `OIDC_REDIRECT_URI`) rarely need changing. + 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 +95,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 +105,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 @@ -105,7 +125,7 @@ docker run --rm -v parenthese_media:/data -v "$PWD":/backup alpine tar -czf /bac The first command saves the database. It holds the people, their links, the accounts and the contributions, plus the record of which photo, video or recording belongs to whom. The second saves the files themselves. You need both. Restore only the database and the memories point to files that are gone. Restore only the archive and you get a folder of files with no names attached. The GEDCOM export is not a backup either. It moves the tree to another genealogy program and leaves the media and the annotations behind. -Adjust `-U parenthese` and the database name if you changed `POSTGRES_USER` or `POSTGRES_DB`. To restore the database, run `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean < parenthese-db-YYYY-MM-DD.dump`, then extract the media archive into the volume with the same `docker run` command, using `tar -xzf` instead of `-czf`. +Adjust `-U parenthese` and the database name if you changed `POSTGRES_USER` or `POSTGRES_DB`. To restore the database, run `docker compose exec -T db pg_restore -U parenthese -d parenthese --clean --if-exists < parenthese-db-YYYY-MM-DD.dump`, then extract the media archive into the volume with the same `docker run` command, using `tar -xzf` instead of `-czf`. The `deploy/backup.sh` script does the same (PostgreSQL dump, media archive, checksums, rotation, optional S3 upload) for an installation without Docker. It reads `backend/.env` and needs `pg_dump` on the machine. It is configured through the `BACKUP_ROOT`, `BACKUP_RETENTION_DAYS` and `BACKUP_S3_BUCKET` variables. @@ -117,7 +137,7 @@ docker compose pull && docker compose up -d `latest` follows the main branch. To stay on a version, set `PARENTHESE_VERSION` in `.env` (for example `1.0`). From a clone, `git pull && docker compose up -d --build` builds from the source instead. -Database migrations apply on their own when the API starts. Make a backup first, as a habit. +Database migrations apply on their own when the API starts, and a migration cannot be undone. Run the two backup commands above before each update. The all-in-one image does it by itself: before applying a migration, it copies the database to `/data/backups` and keeps the last five copies. ## Development diff --git a/backend/.env.example b/backend/.env.example index 02584f4..254e824 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,3 +11,15 @@ 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 +# Connexion par un fournisseur OIDC, éteinte sans OIDC_ISSUER (voir le README). PASSWORD_LOGIN=false retire +# les mots de passe Parenthèse. +OIDC_ISSUER="" +OIDC_CLIENT_ID="" +OIDC_CLIENT_SECRET="" +OIDC_REDIRECT_URI="" +OIDC_PROVIDER_NAME="SSO" +OIDC_REQUIRE_VERIFIED_EMAIL=true +PASSWORD_LOGIN=true diff --git a/backend/.env.production.example b/backend/.env.production.example index 239b528..de6e4b4 100644 --- a/backend/.env.production.example +++ b/backend/.env.production.example @@ -29,3 +29,16 @@ 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 +# Connexion par un fournisseur OIDC, éteinte sans OIDC_ISSUER (voir le README). PASSWORD_LOGIN=false retire +# les mots de passe Parenthèse. +OIDC_ISSUER="" +OIDC_CLIENT_ID="" +OIDC_CLIENT_SECRET="" +OIDC_REDIRECT_URI="" +OIDC_PROVIDER_NAME="SSO" +OIDC_REQUIRE_VERIFIED_EMAIL=true +PASSWORD_LOGIN=true diff --git a/backend/openapi/v1.yaml b/backend/openapi/v1.yaml index 9ae9c27..54c67fa 100644 --- a/backend/openapi/v1.yaml +++ b/backend/openapi/v1.yaml @@ -538,6 +538,86 @@ 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 + passwordLogin: + type: boolean + description: false when PASSWORD_LOGIN=false (sign-in through the OIDC provider only) + oidc: + type: object + nullable: true + description: OIDC provider shown on the sign-in screen, null when OIDC_ISSUER is not set + properties: + name: + type: string + required: [registration, passwordLogin, oidc] + + /auth/oidc/start: + get: + tags: [Auth] + summary: Start an OIDC sign-in (browser navigation) + description: Sets a signed, httpOnly cookie holding state, nonce and PKCE verifier, then redirects to the provider. Only exists when OIDC_ISSUER is set. + parameters: + - name: returnTo + in: query + required: false + schema: + type: string + description: Either / or /arbre/, anything else falls back to / + responses: + '302': + description: Redirect to the provider, or back to the app with "#oidc_error=oidc_unavailable" + + /auth/oidc/callback: + get: + tags: [Auth] + summary: Provider redirect target (browser navigation) + description: Checks the cookie, then lets openid-client exchange the code and verify the ID token (signature, issuer, audience, expiry, nonce, state, PKCE). Finds the account by provider subject, links a verified email, or creates an account when registration allows it. + responses: + '302': + description: Back to the app with "#oidc=" or "#oidc_error=oidc_failed|oidc_email_unverified|oidc_email_missing|registration_closed" + + /auth/oidc/exchange: + post: + tags: [Auth] + summary: Trade the one-time handoff for a session + requestBody: + required: true + content: + application/json: + schema: + type: object + properties: + code: + type: string + required: [code] + responses: + '200': + description: Session + content: + application/json: + schema: + $ref: '#/components/schemas/AuthResponse' + '401': + description: Handoff invalid, expired or already used (invalid_handoff) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + /auth/register: post: tags: [Auth] @@ -561,6 +641,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '403': + description: Registration closed on this instance (registration_closed), or PASSWORD_LOGIN=false (password_login_disabled) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '409': description: Email already exists content: @@ -597,6 +683,12 @@ paths: application/json: schema: $ref: '#/components/schemas/ErrorResponse' + '403': + description: PASSWORD_LOGIN=false (password_login_disabled) + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' '401': description: Invalid credentials content: diff --git a/backend/package-lock.json b/backend/package-lock.json index 042e877..596dc44 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -8,6 +8,7 @@ "name": "backend", "version": "0.1.0", "dependencies": { + "@fastify/cookie": "^11.1.3", "@fastify/cors": "^11.3.0", "@fastify/jwt": "^10.2.2", "@fastify/rate-limit": "^11.2.0", @@ -15,6 +16,7 @@ "bcryptjs": "^3.0.2", "dotenv": "^18.0.7", "fastify": "^5.12.7", + "openid-client": "^6.8.8", "sharp": "^0.35.5", "zod": "^4.6.7" }, @@ -499,6 +501,39 @@ "fast-uri": "^4.0.0" } }, + "node_modules/@fastify/cookie": { + "version": "11.1.3", + "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.3.tgz", + "integrity": "sha512-RBhlMNsmSG6C5KGiTkhSXBF75xIrUMSytDDBcpnVewhJ8XVYnJTu+SV6FYKXClIRsBuRjOfKbBSULvnPD3X/mg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "cookie": "^2.0.0", + "fastify-plugin": "^6.0.0" + } + }, + "node_modules/@fastify/cookie/node_modules/cookie": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-2.0.1.tgz", + "integrity": "sha512-yuToqVvRrj6pfDXREyQAAv8SkAEk/8GS3jQRTiUMm66TVtBYmqQeoEjL2Lmq8Rpo6271vH76InTChTitEAm65w==", + "license": "MIT", + "engines": { + "node": ">=22" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/@fastify/cors": { "version": "11.3.1", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.3.1.tgz", @@ -1409,9 +1444,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1430,9 +1462,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -1451,9 +1480,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1472,9 +1498,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1493,9 +1516,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -1514,9 +1534,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2670,6 +2687,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/jose": { + "version": "6.2.13", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.13.tgz", + "integrity": "sha512-7TIg10ZE1x82YediN+lcrPNvGMKge9yFHQD+Qho/b8y2adfugU3AsvvHfxV0fzyFllT6l9xCjf4RWY9g/Z9PJQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", @@ -2881,9 +2907,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2906,9 +2929,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2931,9 +2951,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -2956,9 +2973,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3094,6 +3108,15 @@ "devOptional": true, "license": "MIT" }, + "node_modules/oauth4webapi": { + "version": "3.8.8", + "resolved": "https://registry.npmjs.org/oauth4webapi/-/oauth4webapi-3.8.8.tgz", + "integrity": "sha512-8N28E+a/oxfXWBgOMt+ZP/JUf/XR+IFbvkAEPP3gznXOMv9BpAAwiIj0TFNz3tGTPc0ZQ8zmWBNgN1nAys0gng==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/obliterator": { "version": "2.0.5", "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", @@ -3130,6 +3153,19 @@ "node": ">=14.0.0" } }, + "node_modules/openid-client": { + "version": "6.8.8", + "resolved": "https://registry.npmjs.org/openid-client/-/openid-client-6.8.8.tgz", + "integrity": "sha512-ZsucJA5Ad04Uv7YN4ql+s4GXNmb9uAYQwyJTsJx7CH/MX3JZioLE2pVKi1SC+YrbSLA4Px3Gi30dMjgOZtb6pA==", + "license": "MIT", + "dependencies": { + "jose": "^6.2.12", + "oauth4webapi": "^3.8.8" + }, + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", diff --git a/backend/package.json b/backend/package.json index 13188b4..28f1ce4 100644 --- a/backend/package.json +++ b/backend/package.json @@ -17,6 +17,7 @@ "prisma:deploy": "prisma migrate deploy" }, "dependencies": { + "@fastify/cookie": "^11.1.3", "@fastify/cors": "^11.3.0", "@fastify/jwt": "^10.2.2", "@fastify/rate-limit": "^11.2.0", @@ -24,6 +25,7 @@ "bcryptjs": "^3.0.2", "dotenv": "^18.0.7", "fastify": "^5.12.7", + "openid-client": "^6.8.8", "sharp": "^0.35.5", "zod": "^4.6.7" }, diff --git a/backend/prisma/migrations/20261018_001_prenom_compte/migration.sql b/backend/prisma/migrations/20261018_001_prenom_compte/migration.sql new file mode 100644 index 0000000..2608a8a --- /dev/null +++ b/backend/prisma/migrations/20261018_001_prenom_compte/migration.sql @@ -0,0 +1,2 @@ +-- Prénom du compte, facultatif (comptes existants : vide). Migration additive. +ALTER TABLE "users" ADD COLUMN "firstName" TEXT; diff --git a/backend/prisma/migrations/20261018_002_avatar_crop/migration.sql b/backend/prisma/migrations/20261018_002_avatar_crop/migration.sql new file mode 100644 index 0000000..5882f11 --- /dev/null +++ b/backend/prisma/migrations/20261018_002_avatar_crop/migration.sql @@ -0,0 +1,3 @@ +-- Cadrage du portrait (feat/recadrage-portrait). Migration additive : colonne nullable, sans défaut. +-- AlterTable +ALTER TABLE "persons" ADD COLUMN "avatarCrop" JSONB; diff --git a/backend/prisma/migrations/20261020_001_identites_oidc/migration.sql b/backend/prisma/migrations/20261020_001_identites_oidc/migration.sql new file mode 100644 index 0000000..40c5c9c --- /dev/null +++ b/backend/prisma/migrations/20261020_001_identites_oidc/migration.sql @@ -0,0 +1,25 @@ +-- Connexion OIDC (#115) : un compte peut naître sans mot de passe, et ses identités chez un fournisseur sont gardées +-- à part (émetteur + sub). Migration additive : les comptes existants gardent leur mot de passe. +-- AlterTable +ALTER TABLE "users" ALTER COLUMN "passwordHash" DROP NOT NULL; + +-- CreateTable +CREATE TABLE "user_identities" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "issuer" TEXT NOT NULL, + "subject" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "user_identities_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "user_identities_userId_idx" ON "user_identities"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "user_identities_issuer_subject_key" ON "user_identities"("issuer", "subject"); + +-- AddForeignKey +ALTER TABLE "user_identities" ADD CONSTRAINT "user_identities_userId_fkey" FOREIGN KEY ("userId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE; + diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index 098b07e..86d0b3d 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -81,7 +81,9 @@ enum SharedAccessRole { model User { id String @id @default(cuid()) email String @unique - passwordHash String + firstName String? + // Vide pour un compte né d'une connexion OIDC : il n'a pas de mot de passe Parenthèse + passwordHash String? lastOpenedTreeId String? lastOpenedAccessMode String? lastOpenedRole String? @@ -93,12 +95,28 @@ model User { submittedContributionSessions ContributionSession[] @relation("ContributionSubmittedBy") reviewedContributionSessions ContributionSession[] @relation("ContributionReviewedBy") treeVisits TreeVisit[] + identities UserIdentity[] lastOpenedTree Tree? @relation("UserLastOpenedTree", fields: [lastOpenedTreeId], references: [id], onDelete: SetNull) @@index([lastOpenedAt]) @@map("users") } +// Compte d'un fournisseur OIDC relié à un compte Parenthèse : l'émetteur et le « sub » de l'ID token, jamais l'email, +// qui peut changer chez le fournisseur. +model UserIdentity { + id String @id @default(cuid()) + userId String + issuer String + subject String + createdAt DateTime @default(now()) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@unique([issuer, subject]) + @@index([userId]) + @@map("user_identities") +} + model Tree { id String @id @default(cuid()) ownerUserId String @@ -229,6 +247,10 @@ model Person { nationality String? avatarPath String? avatarMimeType String? + // Cadrage du portrait, appliqué à l'affichage sur l'original (jamais de fichier dérivé) : + // { "x": 0..1, "y": 0..1, "zoom": 1..4 }, centre du cadrage en proportion de l'image, zoom 1 = image entière + // recouvrant le cadre. Null = cadrage par défaut (centré, zoom 1). + avatarCrop Json? sex String? notes String? createdBy String? diff --git a/backend/src/app.ts b/backend/src/app.ts index 61272f1..b47a640 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -1,4 +1,6 @@ +import { createHmac } from 'node:crypto' import Fastify, { type FastifyError } from 'fastify' +import cookie from '@fastify/cookie' import cors from '@fastify/cors' import jwt from '@fastify/jwt' import rateLimit from '@fastify/rate-limit' @@ -12,6 +14,7 @@ import { contributionRoutes } from './routes/contributions.js' import { graphRoutes } from './routes/graph.js' import { immichRoutes } from './routes/immich.js' import { mediaRoutes } from './routes/media.js' +import { oidcRoutes } from './routes/oidc.js' import { publicRoutes } from './routes/public.js' import { annotationRoutes } from './routes/annotations.js' import { gedcomRoutes } from './routes/gedcom.js' @@ -19,9 +22,10 @@ import { treeRoutes } from './routes/trees.js' import { visitRoutes } from './routes/visits.js' import type { AnyJwtPayload } from './types/auth.js' -// Les adresses des médias portent un jeton ( n'envoie pas d'en-tête) : jamais dans les journaux +// Les adresses des médias portent un jeton ( n'envoie pas d'en-tête), le retour OIDC un code +// d'autorisation : jamais dans les journaux export function redactUrl(url: string | undefined): string | undefined { - return url?.replace(/([?&]token=)[^&#]*/g, '$1[masqué]') + return url?.replace(/([?&](?:token|code)=)[^&#]*/g, '$1[masqué]') } export function createApp() { @@ -68,6 +72,11 @@ export function createApp() { secret: env.JWT_SECRET, }) + // Cookie signé du parcours OIDC seulement (state, nonce, PKCE entre /auth/oidc/start et le retour) : les sessions + // restent des jetons Bearer + // Clé propre au cookie, dérivée de JWT_SECRET : une signature de cookie ne vaut jamais signature de jeton + app.register(cookie, { secret: createHmac('sha256', env.JWT_SECRET).update('parenthese-oidc-cookie').digest('hex') }) + app.addHook('preHandler', async (request) => { request.actor = null @@ -140,6 +149,7 @@ export function createApp() { app.get('/health', async () => ({ ok: true })) app.register(authRoutes, { prefix: '/auth' }) + app.register(oidcRoutes, { prefix: '/auth/oidc' }) app.register(publicRoutes) app.register(treeRoutes) app.register(mediaRoutes) diff --git a/backend/src/config/env.ts b/backend/src/config/env.ts index 1419f0d..6e391ef 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,10 +64,58 @@ 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')), + // Connexion par un fournisseur d'identité OIDC (#115 ; Authentik, Authelia, Keycloak, Pocket ID…). Éteinte sans + // OIDC_ISSUER. Le fournisseur renvoie vers OIDC_REDIRECT_URI, l'adresse publique de /auth/oidc/callback. + OIDC_ISSUER: z.string().trim().default(''), + OIDC_CLIENT_ID: z.string().trim().default(''), + // Vide : client public, protégé par PKCE seul + OIDC_CLIENT_SECRET: z.string().default(''), + OIDC_REDIRECT_URI: z.string().trim().default(''), + // Où l'app s'ouvre après la connexion. Par défaut l'origine de OIDC_REDIRECT_URI (Docker : front et API sur la + // même adresse) ; à renseigner quand le front a sa propre adresse (développement). + OIDC_APP_URL: z.string().trim().default(''), + OIDC_SCOPES: z.string().default('openid email profile'), + // Nom du bouton « Se connecter avec … » + OIDC_PROVIDER_NAME: z.string().trim().max(40).default('SSO'), + // Un compte n'est créé ou relié par son email que si le fournisseur le dit vérifié (email_verified). À false + // seulement pour un fournisseur qui ne l'envoie pas et où personne ne choisit lui-même son adresse. + OIDC_REQUIRE_VERIFIED_EMAIL: z.string().default('true').transform(parseBooleanFlag('OIDC_REQUIRE_VERIFIED_EMAIL')), + // À false, plus de mot de passe Parenthèse : connexion par le fournisseur OIDC seulement + PASSWORD_LOGIN: z.string().default('true').transform(parseBooleanFlag('PASSWORD_LOGIN')), }) export const env = envSchema.parse(process.env) +function assertHttpUrl(name: string, value: string) { + let url: URL + try { + url = new URL(value) + } catch { + throw new Error(`${name} must be a full URL`) + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw new Error(`${name} must be an http(s) URL`) + } +} + +if (env.OIDC_ISSUER) { + assertHttpUrl('OIDC_ISSUER', env.OIDC_ISSUER) + if (!env.OIDC_CLIENT_ID) throw new Error('OIDC_CLIENT_ID is required with OIDC_ISSUER') + if (!env.OIDC_REDIRECT_URI) throw new Error('OIDC_REDIRECT_URI is required with OIDC_ISSUER (public address of /auth/oidc/callback)') + assertHttpUrl('OIDC_REDIRECT_URI', env.OIDC_REDIRECT_URI) + if (env.OIDC_APP_URL) assertHttpUrl('OIDC_APP_URL', env.OIDC_APP_URL) + if (!env.OIDC_SCOPES.split(/\s+/).includes('openid')) throw new Error('OIDC_SCOPES must include openid') +} + +// Sans mot de passe ni fournisseur, personne ne pourrait se connecter +if (!env.PASSWORD_LOGIN && !env.OIDC_ISSUER) { + throw new Error('PASSWORD_LOGIN=false needs OIDC_ISSUER: otherwise nobody can sign in') +} + // Corps maximal des routes qui reçoivent un fichier en base64 ou un gros envoi (le reste de l'API : 1 Mo) export const UPLOAD_BODY_LIMIT = 12 * 1024 * 1024 diff --git a/backend/src/lib/auth.ts b/backend/src/lib/auth.ts index edc534b..60b5737 100644 --- a/backend/src/lib/auth.ts +++ b/backend/src/lib/auth.ts @@ -1,5 +1,7 @@ import bcrypt from 'bcryptjs' +import { env } from '../config/env.js' + const HASH_ROUNDS = 12 export async function hashPassword(value: string): Promise { @@ -8,4 +10,11 @@ export async function hashPassword(value: string): Promise { export async function verifyPassword(value: string, hash: string): Promise { return bcrypt.compare(value, hash) -} \ No newline at end of file +} + +// Un compte à mot de passe se confirme avec son mot de passe seulement si la connexion par mot de passe est ouverte. +// Avec PASSWORD_LOGIN=false, un compte relié à un fournisseur OIDC (et qui avait un mot de passe) ne peut plus le +// redonner : il se confirme par son email, comme un compte né du fournisseur, après une connexion récente. +export function confirmsWithPassword(passwordHash: string | null | undefined): boolean { + return Boolean(passwordHash) && env.PASSWORD_LOGIN +} diff --git a/backend/src/lib/avatar-crop.ts b/backend/src/lib/avatar-crop.ts new file mode 100644 index 0000000..d386377 --- /dev/null +++ b/backend/src/lib/avatar-crop.ts @@ -0,0 +1,37 @@ +import { z } from 'zod' + +// Cadrage d'un portrait : centre (proportions de l'image), zoom, et forme du cadre si on l'a choisie. +// Zoom 1 = l'image entière recouvre le cadre. Les bornes qui empêchent le vide sont appliquées à +// l'affichage (frontend, portraitCrop.js), le serveur vérifie seulement la forme et les plages. +export const FRAME_SHAPES = ['round', 'square', 'polaroid', 'portrait', 'landscape'] as const +export type AvatarCrop = { x: number; y: number; zoom: number; shape?: (typeof FRAME_SHAPES)[number] } + +export const AVATAR_CROP_MAX_ZOOM = 4 + +export const avatarCropInputSchema = z.object({ + crop: z + .object({ + x: z.number().min(0).max(1), + y: z.number().min(0).max(1), + zoom: z.number().min(1).max(AVATAR_CROP_MAX_ZOOM), + shape: z.enum(FRAME_SHAPES).optional(), + }) + .nullable(), +}) + +const round4 = (value: number) => Math.round(value * 10000) / 10000 + +// Forme à écrire en base. Le cadrage par défaut, sans forme choisie, se stocke comme absence de cadrage (null). +export function toStoredAvatarCrop(crop: AvatarCrop | null): AvatarCrop | null { + if (!crop) return null + const normalized = { x: round4(crop.x), y: round4(crop.y), zoom: round4(crop.zoom) } + if (crop.shape) return { ...normalized, shape: crop.shape } + if (normalized.x === 0.5 && normalized.y === 0.5 && normalized.zoom === 1) return null + return normalized +} + +// Lecture de la colonne JSON : tout ce qui ne respecte pas la forme est ignoré (portrait affiché tel quel). +export function readAvatarCrop(value: unknown): AvatarCrop | null { + const parsed = avatarCropInputSchema.shape.crop.safeParse(value) + return parsed.success ? parsed.data : null +} diff --git a/backend/src/lib/entity-deletion.ts b/backend/src/lib/entity-deletion.ts index 2078dee..e9b688c 100644 --- a/backend/src/lib/entity-deletion.ts +++ b/backend/src/lib/entity-deletion.ts @@ -1,4 +1,4 @@ -import type { Prisma, PrismaClient } from '@prisma/client' +import { Prisma, type PrismaClient } from '@prisma/client' type Db = PrismaClient | Prisma.TransactionClient @@ -32,7 +32,7 @@ export async function softDeletePersonCascade( await db.person.updateMany({ where: { id: personId, treeId, deletedAt: null }, - data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, immichPersonId: null }, + data: { deletedAt: now, updatedBy: actorUserId, avatarPath: null, avatarMimeType: null, avatarCrop: Prisma.DbNull, immichPersonId: null }, }) const unions = await db.union.findMany({ diff --git a/backend/src/lib/gedcom-photos.ts b/backend/src/lib/gedcom-photos.ts index d790a82..9388577 100644 --- a/backend/src/lib/gedcom-photos.ts +++ b/backend/src/lib/gedcom-photos.ts @@ -148,10 +148,51 @@ export function plainNoteText(raw: string): string { return text.replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim() } +// Une note ou une source partagée est citée par des milliers de fiches : son texte se calcule une fois +// par import (les nœuds sont neufs à chaque import, la WeakMap se vide avec eux) +const noteTextCache = new WeakMap() +const recordTitleCache = new WeakMap() +const hasMediaCache = new WeakMap() +const MAX_RECORD_DEPTH = 200 +const MAX_WALKED_NODES_PER_PERSON = 5000 + export function noteText(node: GedcomPhotoNode | undefined, records: GedcomRecords): string { if (!node) return '' const pointer = pointerOf(node.value) - return plainNoteText(joinedText(pointer ? records.note.get(pointer) : node)) + const target = pointer ? records.note.get(pointer) : node + if (!target) return '' + let text = noteTextCache.get(target) + if (text === undefined) { + text = plainNoteText(joinedText(target)) + noteTextCache.set(target, text) + } + return text +} + +function recordTitle(record: GedcomPhotoNode): string { + let title = recordTitleCache.get(record) + if (title === undefined) { + title = joinedText(child(record, 'TITL')) || joinedText(child(record, 'ABBR')) + recordTitleCache.set(record, title) + } + return title +} + +// Un nœud mène-t-il à un fichier (OBJE), directement ou par une source ou une union citée ? +// Un renvoi en boucle compte pour non pendant le calcul. +function hasMedia(node: GedcomPhotoNode, records: GedcomRecords, depth = 0): boolean { + const known = hasMediaCache.get(node) + if (known !== undefined) return known + if (depth > MAX_RECORD_DEPTH) return false + hasMediaCache.set(node, false) + let found = false + for (const c of node.children) { + const pointer = pointerOf(c.value) + const linked = !pointer ? undefined : c.tag === 'SOUR' ? records.sour.get(pointer) : c.tag === 'FAMS' ? records.fam.get(pointer) : undefined + if (c.tag === 'OBJE' || hasMedia(c, records, depth + 1) || (linked && hasMedia(linked, records, depth + 1))) { found = true; break } + } + hasMediaCache.set(node, found) + return found } function clipText(value: string, max: number): string | null { @@ -170,7 +211,7 @@ function baseNameWithoutExtension(path: string): string { export function sourceLabel(citation: GedcomPhotoNode, records: GedcomRecords): string | null { const pointer = pointerOf(citation.value) const record = pointer ? records.sour.get(pointer) : null - const title = joinedText(child(record ?? undefined, 'TITL')) || joinedText(child(record ?? undefined, 'ABBR')) || (pointer ? '' : citation.value) + const title = (record ? recordTitle(record) : '') || (pointer ? '' : citation.value) const page = joinedText(child(citation, 'PAGE')) return clipText([title, page].filter(Boolean).join(', '), MAX_SOURCE_LENGTH) } @@ -203,32 +244,37 @@ export function mediaRefsForPerson(indi: GedcomPhotoNode, records: GedcomRecords } } - const walk = (node: GedcomPhotoNode, source: string | null) => { + let budget = MAX_WALKED_NODES_PER_PERSON + const walk = (node: GedcomPhotoNode, source: string | null, depth: number) => { + if (depth > MAX_RECORD_DEPTH) return for (const c of node.children) { + if (--budget < 0 || refs.length >= MAX_MEDIA_PER_PERSON) return if (c.tag === 'OBJE') { addObje(c, source) } else if (c.tag === 'SOUR') { - const label = sourceLabel(c, records) ?? source - walk(c, label) const pointer = pointerOf(c.value) const record = pointer ? records.sour.get(pointer) : null - if (record && !visitedRecords.has(record)) { - visitedRecords.add(record) - walk(record, label) + const recordToWalk = record && !visitedRecords.has(record) && hasMedia(record, records) ? record : null + if (!hasMedia(c, records) && !recordToWalk) continue + const label = sourceLabel(c, records) ?? source + walk(c, label, depth + 1) + if (recordToWalk) { + visitedRecords.add(recordToWalk) + walk(recordToWalk, label, depth + 1) } } else if (c.tag === 'FAMS') { const pointer = pointerOf(c.value) const fam = pointer ? records.fam.get(pointer) : null - if (fam && !visitedRecords.has(fam)) { + if (fam && !visitedRecords.has(fam) && hasMedia(fam, records)) { visitedRecords.add(fam) - walk({ ...fam, children: fam.children.filter((f) => !['HUSB', 'WIFE', 'CHIL'].includes(f.tag)) }, source) + walk({ ...fam, children: fam.children.filter((f) => !['HUSB', 'WIFE', 'CHIL'].includes(f.tag)) }, source, depth + 1) } - } else if (!pointerOf(c.value) && c.children.length > 0) { - walk(c, source) + } else if (!pointerOf(c.value) && c.children.length > 0 && hasMedia(c, records)) { + walk(c, source, depth + 1) } } } - walk(indi, null) + walk(indi, null, 0) return refs } diff --git a/backend/src/lib/media-files.ts b/backend/src/lib/media-files.ts index 70ce049..c0900b4 100644 --- a/backend/src/lib/media-files.ts +++ b/backend/src/lib/media-files.ts @@ -59,6 +59,9 @@ const EXTENSION_BY_MIME: Record = { 'application/gpx+xml': '.gpx', } +// Marques ISO BMFF des images HEIF/HEIC (ISO/IEC 23008-12) +const HEIC_BRANDS = new Set(['heic', 'heix', 'mif1', 'msf1', 'heim', 'heis']) + function startsWith(buf: Buffer, bytes: number[], offset = 0): boolean { if (buf.length < offset + bytes.length) return false return bytes.every((byte, index) => buf[offset + index] === byte) @@ -89,6 +92,10 @@ export function sniffMimeType(buf: Buffer, declaredType: BinaryMediaType, fileNa if (ascii(buf, 4, 8) === 'ftyp') { const brand = ascii(buf, 8, 12) if (brand === 'qt ') return 'video/quicktime' + // HEIF (photos iPhone et Android) : pas une vidéo, et refusée à l'envoi faute de conversion (sharp ne décode + // pas le HEVC). « hevc » peut aussi désigner une vidéo HEVC en MP4 : photo seulement si l'envoi se déclare photo. + if (HEIC_BRANDS.has(brand) || (brand === 'hevc' && declaredType === 'photo')) return 'image/heic' + if (brand === 'avif' || brand === 'avis') return 'image/avif' if (declaredType === 'audio' || brand === 'M4A ' || brand === 'M4B ') return 'audio/mp4' return 'video/mp4' } diff --git a/backend/src/lib/oidc.ts b/backend/src/lib/oidc.ts new file mode 100644 index 0000000..12a7798 --- /dev/null +++ b/backend/src/lib/oidc.ts @@ -0,0 +1,125 @@ +import * as client from 'openid-client' + +import { env } from '../config/env.js' + +// Connexion par un fournisseur OIDC (#115). Tout le protocole passe par openid-client : découverte, PKCE (S256), +// state et nonce, échange du code, puis vérification de l'ID token (signature par les clés publiées du +// fournisseur, émetteur, audience = notre client, expiration, nonce). Aucun jeton n'est décodé à la main ici. + +export type OidcPending = { + state: string + nonce: string + codeVerifier: string + returnTo: string +} + +export type OidcIdentity = { + issuer: string + subject: string + email: string | null + emailVerified: boolean + firstName: string | null +} + +export function isOidcEnabled(): boolean { + return Boolean(env.OIDC_ISSUER) +} + +let configuration: Promise | null = null + +// Découverte faite au premier besoin puis gardée ; un échec (fournisseur injoignable) sera retenté à la connexion suivante +export function getOidcConfiguration(): Promise { + configuration ??= discover().catch((error: unknown) => { + configuration = null + throw error + }) + return configuration +} + +function discover(): Promise { + const issuer = new URL(env.OIDC_ISSUER) + // Sans secret : client public, PKCE seul. Avec : client_secret_basic, la méthode par défaut d'OIDC. + const authentication = env.OIDC_CLIENT_SECRET ? client.ClientSecretBasic(env.OIDC_CLIENT_SECRET) : client.None() + // Un fournisseur en http (réseau local) doit être autorisé explicitement par openid-client + const options = issuer.protocol === 'http:' ? { execute: [client.allowInsecureRequests] } : undefined + return client.discovery(issuer, env.OIDC_CLIENT_ID, undefined, authentication, options) +} + +// Adresse de l'app où revenir : OIDC_APP_URL, sinon l'origine de l'adresse de retour (front et API sur la même adresse) +export function appBaseUrl(): string { + return (env.OIDC_APP_URL || new URL(env.OIDC_REDIRECT_URI).origin).replace(/\/$/, '') +} + +// Seules la racine et l'adresse d'un arbre partagé sont acceptées comme retour : pas de redirection ouverte +export function safeReturnTo(value: unknown): string { + return typeof value === 'string' && /^\/(arbre\/[A-Za-z0-9-]{3,64})?$/.test(value) ? value : '/' +} + +export async function startAuthorization(returnTo: string): Promise<{ url: URL; pending: OidcPending }> { + const config = await getOidcConfiguration() + const codeVerifier = client.randomPKCECodeVerifier() + const pending: OidcPending = { + state: client.randomState(), + nonce: client.randomNonce(), + codeVerifier, + returnTo, + } + const url = client.buildAuthorizationUrl(config, { + redirect_uri: env.OIDC_REDIRECT_URI, + scope: env.OIDC_SCOPES, + code_challenge: await client.calculatePKCECodeChallenge(codeVerifier), + code_challenge_method: 'S256', + state: pending.state, + nonce: pending.nonce, + }) + return { url, pending } +} + +function stringClaim(value: unknown): string | null { + return typeof value === 'string' && value.trim() ? value.trim() : null +} + +// Retour du fournisseur. L'adresse vérifiée est OIDC_REDIRECT_URI plus les paramètres reçus, jamais l'en-tête Host. +export async function finishAuthorization(callbackQuery: string, pending: OidcPending): Promise { + const config = await getOidcConfiguration() + const currentUrl = new URL(env.OIDC_REDIRECT_URI) + currentUrl.search = callbackQuery + + const tokens = await client.authorizationCodeGrant(config, currentUrl, { + pkceCodeVerifier: pending.codeVerifier, + expectedState: pending.state, + expectedNonce: pending.nonce, + idTokenExpected: true, + }) + + const claims = tokens.claims() + if (!claims) { + throw new Error('oidc_missing_id_token') + } + + let email = stringClaim(claims.email) + let emailVerified = claims.email_verified === true + let firstName = stringClaim(claims.given_name) ?? stringClaim(claims.name) + + // Certains fournisseurs (Authelia par défaut) ne mettent l'email que dans userinfo. Le sujet attendu est + // celui de l'ID token : une réponse userinfo pour un autre compte est refusée par openid-client. + if (!email && tokens.access_token) { + const info = await client.fetchUserInfo(config, tokens.access_token, claims.sub) + email = stringClaim(info.email) + emailVerified = info.email_verified === true + firstName ??= stringClaim(info.given_name) ?? stringClaim(info.name) + } + + return { + issuer: claims.iss, + subject: claims.sub, + email: email?.toLowerCase() ?? null, + emailVerified, + firstName: firstName?.slice(0, 60) ?? null, + } +} + +// Pour les tests : oublie la configuration découverte +export function resetOidcConfiguration() { + configuration = null +} diff --git a/backend/src/lib/person-names.ts b/backend/src/lib/person-names.ts new file mode 100644 index 0000000..679cf9f --- /dev/null +++ b/backend/src/lib/person-names.ts @@ -0,0 +1,4 @@ +// Nom de famille facultatif, prénom facultatif (prénom inconnu = vide) : une personne garde au moins un des deux +export function hasAnyName(firstName: string, lastName: string): boolean { + return Boolean(firstName.trim() || lastName.trim()) +} diff --git a/backend/src/lib/person-sex.ts b/backend/src/lib/person-sex.ts new file mode 100644 index 0000000..20a9a45 --- /dev/null +++ b/backend/src/lib/person-sex.ts @@ -0,0 +1,36 @@ +import { z } from 'zod' + +// Sexe d'une personne : 'male', 'female', 'other' (autre), ou null (non précisé). L'import GEDCOM écrit +// 'male'/'female'/'other' (SEX X de GEDCOM 7), d'anciens clients de l'API ont pu écrire 'M'/'F' : tout est +// ramené à ces valeurs, à l'écriture comme à la lecture, sans migration des lignes existantes. +export type PersonSex = 'male' | 'female' | 'other' + +const MALE = new Set(['male', 'm', 'homme', 'h']) +const FEMALE = new Set(['female', 'f', 'femme']) +const OTHER = new Set(['other', 'x', 'autre']) +const UNSPECIFIED = new Set(['', 'u', 'unknown', 'unspecified']) + +// undefined : valeur que l'on ne sait pas lire (refusée à l'écriture) +export function parseSex(value: string | null): PersonSex | null | undefined { + if (value === null) return null + const key = value.trim().toLowerCase() + if (MALE.has(key)) return 'male' + if (FEMALE.has(key)) return 'female' + if (OTHER.has(key)) return 'other' + if (UNSPECIFIED.has(key)) return null + return undefined +} + +// Lecture d'une valeur déjà en base : ce qu'on ne sait pas lire vaut « non précisé » +export function sexOrNull(value: string | null | undefined): PersonSex | null { + return parseSex(value ?? null) ?? null +} + +// Champ « sex » des schémas d'écriture : facultatif, null pour « non précisé », valeur ramenée à male/female/other +export const sexSchema = z + .string() + .max(20) + .nullable() + .refine((value) => parseSex(value) !== undefined, { message: 'invalid_sex' }) + .transform((value) => parseSex(value) ?? null) + .optional() 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/lib/tree-visits.ts b/backend/src/lib/tree-visits.ts index e2a1098..84dd2d7 100644 --- a/backend/src/lib/tree-visits.ts +++ b/backend/src/lib/tree-visits.ts @@ -6,8 +6,11 @@ import { env } from '../config/env.js' // Une ouverture d'arbre = une visite. Les rechargements du même visiteur dans cette fenêtre // (graphe rechargé après une modification, onglet rouvert) ne comptent qu'une fois. export const VISIT_DEDUP_WINDOW_MS = 30 * 60 * 1000 -// Au-delà, les visites sont effacées (lecture de l'onglet Visites) +// Au-delà, les visites sont effacées : à l'enregistrement d'une visite (au plus une fois par heure, +// pour tous les arbres) et à la lecture de l'onglet Visites export const VISIT_RETENTION_DAYS = 365 +const PURGE_INTERVAL_MS = 60 * 60 * 1000 +let lastPurgeAt = 0 export type VisitAccessKind = 'share' | 'member' | 'shared_account' @@ -34,6 +37,18 @@ export function visitorKeyOf(treeId: string, ip: string, userAgent: string): str return createHash('sha256').update(`${env.JWT_SECRET}|${treeId}|${ip}|${userAgent}`).digest('hex').slice(0, 32) } +// Compte supprimé : ses visites restent comptées, sans son identifiant +export function deletedAccountVisitorKey(userId: string): string { + return createHash('sha256').update(`${env.JWT_SECRET}|deleted|${userId}`).digest('hex').slice(0, 32) +} + +async function purgeExpiredVisits(app: FastifyInstance): Promise { + const now = Date.now() + if (now - lastPurgeAt < PURGE_INTERVAL_MS) return + lastPurgeAt = now + await app.prisma.treeVisit.deleteMany({ where: { createdAt: { lt: new Date(now - VISIT_RETENTION_DAYS * 24 * 60 * 60 * 1000) } } }) +} + // N'échoue jamais : un journal qui casse ne doit pas empêcher d'ouvrir l'arbre export async function recordTreeVisit( app: FastifyInstance, @@ -69,6 +84,7 @@ export async function recordTreeVisit( device: deviceFromUserAgent(userAgent), }, }) + await purgeExpiredVisits(app) } catch (error) { request.log.warn({ err: error, treeId }, 'tree_visit_record_failed') } diff --git a/backend/src/lib/user-session.ts b/backend/src/lib/user-session.ts new file mode 100644 index 0000000..428d281 --- /dev/null +++ b/backend/src/lib/user-session.ts @@ -0,0 +1,18 @@ +import type { FastifyInstance } from 'fastify' +import { randomUUID } from 'node:crypto' + +import { env } from '../config/env.js' + +// Session d'un compte, la même quelle que soit la façon de se connecter (mot de passe ou fournisseur OIDC) +export function signUserSession(app: FastifyInstance, user: { id: string; email: string }): string { + return app.jwt.sign( + { + kind: 'user', + sub: user.id, + jti: randomUUID(), + userId: user.id, + email: user.email, + }, + { expiresIn: env.JWT_EXPIRES_IN }, + ) +} diff --git a/backend/src/routes/annotations.ts b/backend/src/routes/annotations.ts index 8429a91..21f547d 100644 --- a/backend/src/routes/annotations.ts +++ b/backend/src/routes/annotations.ts @@ -306,64 +306,67 @@ export const annotationRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_annotation_photo_path' }) } - // Creates - for (const [index, item] of payload.data.create.entries()) { - await app.prisma.annotation.create({ + // Créations, modifications, suppressions et trace : tout ou rien, un nouvel essai ne double rien + await app.prisma.$transaction(async (tx) => { + // Creates + for (const [index, item] of payload.data.create.entries()) { + await tx.annotation.create({ + data: { + treeId, + type: item.type, + x: item.x, + y: item.y, + content: createContents[index] as string, + style: (item.style ?? {}) as Prisma.InputJsonValue, + zIndex: item.zIndex ?? 0, + createdBy: user.userId, + }, + }) + results.created++ + } + + // Updates + for (const [index, item] of payload.data.update.entries()) { + const data: Record = {} + if (item.x !== undefined) data.x = item.x + if (item.y !== undefined) data.y = item.y + if (item.content !== undefined) data.content = updateContents[index] + if (item.style !== undefined) data.style = item.style + if (item.zIndex !== undefined) data.zIndex = item.zIndex + + if (Object.keys(data).length > 0) { + const updated = await tx.annotation.updateMany({ + where: { id: item.id, treeId, deletedAt: null }, + data, + }) + if (updated.count > 0) results.updated++ + } + } + + // Deletes + if (payload.data.delete.length > 0) { + const deleted = await tx.annotation.updateMany({ + where: { + id: { in: payload.data.delete }, + treeId, + deletedAt: null, + }, + data: { deletedAt: new Date() }, + }) + results.deleted = deleted.count + } + + await tx.auditLog.create({ data: { treeId, - type: item.type, - x: item.x, - y: item.y, - content: createContents[index] as string, - style: (item.style ?? {}) as Prisma.InputJsonValue, - zIndex: item.zIndex ?? 0, - createdBy: user.userId, + actorType: 'user', + actorId: user.userId, + action: 'annotations_batch', + entityType: 'annotation', + entityId: treeId, + payloadJson: results, }, }) - results.created++ - } - - // Updates - for (const [index, item] of payload.data.update.entries()) { - const data: Record = {} - if (item.x !== undefined) data.x = item.x - if (item.y !== undefined) data.y = item.y - if (item.content !== undefined) data.content = updateContents[index] - if (item.style !== undefined) data.style = item.style - if (item.zIndex !== undefined) data.zIndex = item.zIndex - - if (Object.keys(data).length > 0) { - const updated = await app.prisma.annotation.updateMany({ - where: { id: item.id, treeId, deletedAt: null }, - data, - }) - if (updated.count > 0) results.updated++ - } - } - - // Deletes - if (payload.data.delete.length > 0) { - const deleted = await app.prisma.annotation.updateMany({ - where: { - id: { in: payload.data.delete }, - treeId, - deletedAt: null, - }, - data: { deletedAt: new Date() }, - }) - results.deleted = deleted.count - } - - await app.prisma.auditLog.create({ - data: { - treeId, - actorType: 'user', - actorId: user.userId, - action: 'annotations_batch', - entityType: 'annotation', - entityId: treeId, - payloadJson: results, - }, }) return reply.send({ ok: true, ...results }) diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 0701a78..1499d9c 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -3,8 +3,12 @@ import { randomUUID } from 'node:crypto' import { z } from 'zod' import { env } from '../config/env.js' -import { hashPassword, verifyPassword } from '../lib/auth.js' +import { confirmsWithPassword, hashPassword, verifyPassword } from '../lib/auth.js' +import { isOidcEnabled } from '../lib/oidc.js' +import { createAccountIfAllowed, isRegistrationOpen } from '../lib/registration.js' +import { signUserSession } from '../lib/user-session.js' import { revokeUserToken } from '../lib/token-revocation.js' +import { deletedAccountVisitorKey } from '../lib/tree-visits.js' import { deleteTreeRecords, removeTreeMediaDirectories } from '../lib/tree-purge.js' import type { AnyJwtPayload } from '../types/auth.js' @@ -20,10 +24,25 @@ const credentialsSchema = z.object({ password: z.string().min(8).max(128), }) -const deleteAccountSchema = z.object({ - password: z.string().min(1).max(128), +// Prénom du compte, facultatif : seulement à l'inscription (la connexion ne le demande pas) +const registerSchema = credentialsSchema.extend({ + firstName: z.string().max(60).optional().nullable(), }) +function trimmedFirstName(value: string | null | undefined): string | null { + const trimmed = (value ?? '').trim() + return trimmed ? trimmed : null +} + +// Suppression d'un compte sans mot de passe : connexion chez le fournisseur de moins de 15 minutes +const RECENT_SIGN_IN_MS = 15 * 60 * 1000 + +// Un compte avec mot de passe le redonne ; un compte né d'une connexion OIDC, sans mot de passe, retape son email +const deleteAccountSchema = z.object({ + password: z.string().min(1).max(128).optional(), + confirmEmail: z.string().min(1).max(320).optional(), +}).refine((value) => value.password || value.confirmEmail, { message: 'password or confirmEmail required' }) + const updateLastTreeSchema = z.object({ treeId: z.string().min(1), accessMode: z.enum(['member', 'share']), @@ -83,6 +102,17 @@ 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), + passwordLogin: env.PASSWORD_LOGIN, + oidc: isOidcEnabled() ? { name: env.OIDC_PROVIDER_NAME } : null, + }), + ) + app.post( '/register', { @@ -94,11 +124,21 @@ export const authRoutes: FastifyPluginAsync = async (app) => { }, }, async (request, reply) => { - const parsed = credentialsSchema.safeParse(request.body) + const parsed = registerSchema.safeParse(request.body) if (!parsed.success) { return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() }) } + // Connexion par le fournisseur seulement (PASSWORD_LOGIN=false) : pas de compte à mot de passe + if (!env.PASSWORD_LOGIN) { + return reply.code(403).send({ error: 'password_login_disabled' }) + } + + // 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) { @@ -106,29 +146,24 @@ 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, + firstName: trimmedFirstName(parsed.data.firstName), }, select: { id: true, 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( - { - kind: 'user', - sub: user.id, - jti: randomUUID(), - userId: user.id, - email: user.email, - }, - { expiresIn: env.JWT_EXPIRES_IN }, - ) - - return reply.code(201).send({ token, user }) + return reply.code(201).send({ token: signUserSession(app, user), user: { ...user, hasPassword: true } }) }, ) @@ -143,6 +178,10 @@ export const authRoutes: FastifyPluginAsync = async (app) => { }, }, async (request, reply) => { + if (!env.PASSWORD_LOGIN) { + return reply.code(403).send({ error: 'password_login_disabled' }) + } + const parsed = credentialsSchema.safeParse(request.body) if (!parsed.success) { return reply.code(400).send({ error: 'invalid_payload', details: parsed.error.flatten() }) @@ -154,12 +193,14 @@ export const authRoutes: FastifyPluginAsync = async (app) => { select: { id: true, email: true, + firstName: true, passwordHash: true, }, }) - if (!user) { - // Même durée qu'un mauvais mot de passe : la réponse ne dit pas si l'adresse a un compte + // Adresse inconnue, ou compte né d'une connexion OIDC (sans mot de passe) : même durée qu'un mauvais mot de + // passe, la réponse ne dit pas si l'adresse a un compte + if (!user?.passwordHash) { await verifyPassword(parsed.data.password, await getDummyHash()) return reply.code(401).send({ error: 'invalid_credentials' }) } @@ -169,27 +210,36 @@ export const authRoutes: FastifyPluginAsync = async (app) => { return reply.code(401).send({ error: 'invalid_credentials' }) } - const token = app.jwt.sign( - { - kind: 'user', - sub: user.id, - jti: randomUUID(), - userId: user.id, - email: user.email, - }, - { expiresIn: env.JWT_EXPIRES_IN }, - ) - return reply.send({ - token, + token: signUserSession(app, user), user: { id: user.id, email: user.email, + firstName: user.firstName, + hasPassword: true, }, }) }, ) + // Profil du compte connecté (prénom compris) : la session restaurée sur un autre appareil le retrouve + app.get('/me', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const user = await app.prisma.user.findUnique({ + where: { id: request.actor.userId }, + select: { id: true, email: true, firstName: true, passwordHash: true }, + }) + if (!user) { + return reply.code(401).send({ error: 'authentication_required' }) + } + + // hasPassword : l'écran de suppression demande le mot de passe, ou l'email pour un compte sans mot de passe + return reply.send({ user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash) } }) + }) + app.get('/me/last-tree', async (request, reply) => { if (!request.actor || request.actor.kind !== 'user') { return reply.code(401).send({ error: 'authentication_required' }) @@ -318,6 +368,7 @@ export const authRoutes: FastifyPluginAsync = async (app) => { where: { id: userId }, select: { id: true, + email: true, passwordHash: true, }, }) @@ -326,12 +377,26 @@ export const authRoutes: FastifyPluginAsync = async (app) => { return reply.code(401).send({ error: 'authentication_required' }) } - const isValid = await verifyPassword(parsed.data.password, user.passwordHash) + const payload = await request.jwtVerify() + + const withPassword = confirmsWithPassword(user.passwordHash) + if (!withPassword) { + // Compte né d'une connexion OIDC, ou dont la connexion par mot de passe est fermée : pas de mot de passe à + // redonner. Il retape son email, et la session doit venir d'une connexion récente chez le fournisseur + // (une session volée ou oubliée ouverte ne suffit pas). + const sessionAgeMs = typeof payload.iat === 'number' ? Date.now() - payload.iat * 1000 : Infinity + if (sessionAgeMs > RECENT_SIGN_IN_MS) { + return reply.code(403).send({ error: 'recent_sign_in_required' }) + } + } + + const isValid = withPassword + ? Boolean(parsed.data.password) && await verifyPassword(parsed.data.password as string, user.passwordHash as string) + : (parsed.data.confirmEmail ?? '').trim().toLowerCase() === user.email if (!isValid) { return reply.code(403).send({ error: 'invalid_password' }) } - const payload = await request.jwtVerify() // Arbres possédés (y compris ceux marqués supprimés) puis compte, dans une seule transaction : // la relation propriétaire est en Restrict, les arbres doivent partir avant l'utilisateur. @@ -350,6 +415,12 @@ export const authRoutes: FastifyPluginAsync = async (app) => { data: { submittedByLabel: 'Contributeur' }, }) + // Ses visites des autres arbres restent comptées, sans son identifiant + await tx.treeVisit.updateMany({ + where: { visitorKey: `user:${userId}` }, + data: { visitorKey: deletedAccountVisitorKey(userId) }, + }) + await tx.user.delete({ where: { id: userId } }) return treeIds }) diff --git a/backend/src/routes/contributions.ts b/backend/src/routes/contributions.ts index 126c63f..4dcd8bb 100644 --- a/backend/src/routes/contributions.ts +++ b/backend/src/routes/contributions.ts @@ -13,6 +13,8 @@ import type { Actor, MembershipRole } from '../types/auth.js' import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js' import { nextSiblingOrder } from '../lib/sibling-order.js' import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js' +import { hasAnyName } from '../lib/person-names.js' +import { sexOrNull, sexSchema } from '../lib/person-sex.js' const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/ @@ -68,33 +70,37 @@ const reviewSessionSchema = z.object({ }) const personCreateSchema = z.object({ - firstName: z.string().min(1).max(120), - lastName: z.string().min(1).max(120), + firstName: z.string().trim().max(120), + lastName: z.string().trim().max(120), birthName: z.string().max(120).optional().nullable(), + nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), + deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), - sex: z.string().max(20).optional().nullable(), + sex: sexSchema, notes: z.string().max(2000).optional().nullable(), -}) +}).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] }) const personUpdateSchema = z .object({ - firstName: z.string().min(1).max(120).optional(), - lastName: z.string().min(1).max(120).optional(), + firstName: z.string().trim().max(120).optional(), + lastName: z.string().trim().max(120).optional(), birthName: z.string().max(120).optional().nullable(), + nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), + deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), - sex: z.string().max(20).optional().nullable(), + sex: sexSchema, notes: z.string().max(2000).optional().nullable(), }) .refine((value) => Object.keys(value).length > 0, { @@ -378,10 +384,12 @@ async function applyContributionChange( firstName: parsed.data.firstName.trim(), lastName: parsed.data.lastName.trim(), birthName: toTrimmedStringOrNull(parsed.data.birthName), + nickname: toTrimmedStringOrNull(parsed.data.nickname), birthDate: toDateOrNull(parsed.data.birthDate), deathDate: toDateOrNull(parsed.data.deathDate), isDeceased: !parsed.data.deathDate && Boolean(parsed.data.isDeceased), birthPlace: toTrimmedStringOrNull(parsed.data.birthPlace), + deathPlace: toTrimmedStringOrNull(parsed.data.deathPlace), profession: toTrimmedStringOrNull(parsed.data.profession), region: toTrimmedStringOrNull(parsed.data.region), nationality: toTrimmedStringOrNull(parsed.data.nationality), @@ -424,6 +432,16 @@ async function applyContributionChange( }) : null + if (parsed.data.firstName !== undefined || parsed.data.lastName !== undefined) { + const before = await tx.person.findFirst({ + where: { id: change.entityId, treeId, deletedAt: null }, + select: { firstName: true, lastName: true }, + }) + if (before && !hasAnyName(parsed.data.firstName ?? before.firstName, parsed.data.lastName ?? before.lastName)) { + throw new ContributionRouteError(400, 'invalid_change_after') + } + } + const result = await tx.person.updateMany({ where: { id: change.entityId, @@ -435,10 +453,12 @@ async function applyContributionChange( firstName: parsed.data.firstName !== undefined ? parsed.data.firstName.trim() : undefined, lastName: parsed.data.lastName !== undefined ? parsed.data.lastName.trim() : undefined, birthName: parsed.data.birthName !== undefined ? toTrimmedStringOrNull(parsed.data.birthName) : undefined, + nickname: parsed.data.nickname !== undefined ? toTrimmedStringOrNull(parsed.data.nickname) : undefined, birthDate: nextDates.birthDate, deathDate: nextDates.deathDate, isDeceased: nextDates.deathDate ? false : parsed.data.isDeceased, birthPlace: parsed.data.birthPlace !== undefined ? toTrimmedStringOrNull(parsed.data.birthPlace) : undefined, + deathPlace: parsed.data.deathPlace !== undefined ? toTrimmedStringOrNull(parsed.data.deathPlace) : undefined, profession: parsed.data.profession !== undefined ? toTrimmedStringOrNull(parsed.data.profession) : undefined, region: parsed.data.region !== undefined ? toTrimmedStringOrNull(parsed.data.region) : undefined, nationality: parsed.data.nationality !== undefined ? toTrimmedStringOrNull(parsed.data.nationality) : undefined, @@ -832,10 +852,12 @@ async function snapshotEntity( firstName: true, lastName: true, birthName: true, + nickname: true, birthDate: true, deathDate: true, isDeceased: true, birthPlace: true, + deathPlace: true, profession: true, region: true, nationality: true, @@ -848,6 +870,7 @@ async function snapshotEntity( ...current, birthDate: current.birthDate?.toISOString().slice(0, 10) ?? null, deathDate: current.deathDate?.toISOString().slice(0, 10) ?? null, + sex: sexOrNull(current.sex), } } diff --git a/backend/src/routes/gedcom.ts b/backend/src/routes/gedcom.ts index ed675a9..563581e 100644 --- a/backend/src/routes/gedcom.ts +++ b/backend/src/routes/gedcom.ts @@ -4,10 +4,11 @@ import { z } from 'zod' import { UPLOAD_BODY_LIMIT } from '../config/env.js' import { parseGedcomDate } from '../lib/gedcom-dates.js' import { adoptedFamilies, FAMILY_EVENT_TYPES, familyExtras, PERSON_EVENT_TYPES, personExtras } from '../lib/gedcom-events.js' -import { indexGedcomRecords, mediaRefsForPerson, photoPathForPerson, plainNoteText } from '../lib/gedcom-photos.js' +import { indexGedcomRecords, mediaRefsForPerson, noteText, photoPathForPerson } from '../lib/gedcom-photos.js' import type { GedcomRecords } from '../lib/gedcom-photos.js' import type { MembershipRole } from '../types/auth.js' import { nextSiblingOrder } from '../lib/sibling-order.js' +import { sexOrNull, type PersonSex } from '../lib/person-sex.js' const treeIdParamSchema = z.object({ id: z.string().min(1), @@ -20,6 +21,9 @@ const importBodySchema = z.object({ // Un fichier GEDCOM vient de l'extérieur : longueur des lignes, nombre de fiches et taille des champs // plafonnés comme partout ailleurs (sinon un seul fichier bloque l'API ou remplit la base) const MAX_LINE_LENGTH = 4096 +const MAX_GEDCOM_LEVEL = 99 +// Remontées d'ascendance permises sur tout l'import : au-delà, les liens ne sont plus contrôlés (fichier hors norme) +const MAX_ANCESTRY_STEPS = 5_000_000 const MAX_IMPORTED_PERSONS = 20000 const MAX_IMPORTED_FAMILIES = 20000 // Le parseur construit un objet par ligne : 10 Mo de lignes courtes montaient à 370 Mo de mémoire, et le @@ -94,6 +98,8 @@ export function parseGedcom(text: string): GedcomNode[] { if (!match) continue const level = parseInt(match[1], 10) + // La norme s'arrête à 99 niveaux : au-delà, la lecture récursive de l'import déborderait la pile + if (level > MAX_GEDCOM_LEVEL) continue const xref = match[2] ? match[2].slice(1, -1) : undefined const tag = match[3] const rawValue = match[4] ?? '' @@ -137,19 +143,7 @@ function gedcomDateToIso(value: string): string | null { // Notes d'une personne : écrites sous elle ou partagées (« 1 NOTE @N1@ »), lignes recollées (CONT, CONC), // RTF de Heredis Mac nettoyé. Plusieurs notes sont séparées par une ligne vide. export function personNotes(indi: GedcomNode, records: GedcomRecords): string | null { - const texts: string[] = [] - for (const note of findAllChildren(indi, 'NOTE')) { - const pointer = note.value.match(/^@([^@]+)@$/)?.[1] - const source = pointer ? records.note.get(pointer) : note - if (!source) continue - let text = (pointer ? source.rawValue ?? source.value : note.rawValue ?? note.value) - for (const part of source.children) { - if (part.tag === 'CONT') text += `\n${part.rawValue ?? part.value}` - else if (part.tag === 'CONC') text += part.rawValue ?? part.value - } - const clean = plainNoteText(text) - if (clean) texts.push(clean) - } + const texts = findAllChildren(indi, 'NOTE').map((note) => noteText(note, records)).filter(Boolean) return texts.length ? texts.join('\n\n') : null } @@ -258,8 +252,14 @@ function pushLifeEvent(lines: string[], event: ExportedEvent, tags: Record= 0 ? value.indexOf('/', open + 1) : -1 if (open >= 0 && close > open) { @@ -285,6 +285,15 @@ function pairKey(a: string, b: string | null): string { return b ? [a, b].sort().join('|') : `${a}|` } +// GEDCOM 5.5.1 n'admet que M, F et U (inconnu, autre). « X » n'existe qu'en 7.0 : un sexe « autre » s'écrit U, +// et l'import relit X comme avant. +export function gedcomSexValue(sex: PersonSex | null): 'M' | 'F' | 'U' | null { + if (sex === 'male') return 'M' + if (sex === 'female') return 'F' + if (sex === 'other') return 'U' + return null +} + function sanitizeXref(id: string): string { return id.replace(/[^A-Za-z0-9_]/g, '_') } @@ -453,11 +462,11 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { for (const person of persons) { const xref = sanitizeXref(person.id) lines.push(`0 @I${xref}@ INDI`) - lines.push(`1 NAME ${oneLine(person.firstName)} /${oneLine(person.lastName).replace(/\//g, '')}/`) + lines.push(`1 NAME ${gedcomNameValue(person.firstName, person.lastName)}`) if (person.nickname) lines.push(`2 NICK ${oneLine(person.nickname)}`) - if (person.sex === 'male' || person.sex === 'M') lines.push('1 SEX M') - else if (person.sex === 'female' || person.sex === 'F') lines.push('1 SEX F') + const sexValue = gedcomSexValue(sexOrNull(person.sex)) + if (sexValue) lines.push(`1 SEX ${sexValue}`) const personEvents = eventsByPerson.get(person.id) ?? [] const birthDetails = personEvents.find((event) => event.type === 'birth') @@ -519,9 +528,9 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { const p1 = union.partner1PersonId ? personById.get(union.partner1PersonId) : null const p2 = union.partner2PersonId ? personById.get(union.partner2PersonId) : null - const p1IsFemale = p1?.sex === 'female' || p1?.sex === 'F' - const p1IsMale = p1?.sex === 'male' || p1?.sex === 'M' - const p2IsMale = p2?.sex === 'male' || p2?.sex === 'M' + const p1IsFemale = sexOrNull(p1?.sex) === 'female' + const p1IsMale = sexOrNull(p1?.sex) === 'male' + const p2IsMale = sexOrNull(p2?.sex) === 'male' // Swap to HUSB=male if we know p1 is female and p2 is male (or unknown) const shouldSwap = p1IsFemale && !p2IsMale === false ? false : p1IsFemale && (p2IsMale || !p1IsMale) @@ -639,12 +648,30 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { } const unionByPair = new Map(existingUnions.map((u) => [pairKey(u.partner1PersonId, u.partner2PersonId), u.id])) const linkKeys = new Set(existingLinks.map((l) => `${l.parentPersonId}:${l.childPersonId}`)) + // Parents de chaque personne, pour refuser un lien qui ferait de quelqu'un son propre ancêtre + // (les validateurs de l'API relisent tout l'arbre à chaque lien : trop lent pour un import) + const parentsOf = new Map() + for (const link of existingLinks) parentsOf.set(link.childPersonId, [...(parentsOf.get(link.childPersonId) ?? []), link.parentPersonId]) + let ancestryBudget = MAX_ANCESTRY_STEPS + const isAncestorOf = (candidate: string, personId: string): boolean => { + const seen = new Set() + const stack = [personId] + while (stack.length) { + const current = stack.pop()! + if (current === candidate) return true + if (seen.has(current) || --ancestryBudget < 0) continue + seen.add(current) + stack.push(...(parentsOf.get(current) ?? [])) + } + return false + } let personsCreated = 0 let personsReused = 0 let unionsCreated = 0 let linksCreated = 0 let eventsCreated = 0 + let relationsSkipped = 0 // Adoptions déclarées sous l'enfant (« 2 PEDI adopted ») : enfant → familles où il est adopté const adoptedByChild = new Map(indiNodes.map((indi) => [indi.xref!, adoptedFamilies(indi)])) @@ -655,10 +682,10 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { const nameNode = findChild(indi, 'NAME') const { firstName, lastName } = nameNode ? parseGedcomName(nameNode.value) - : { firstName: '?', lastName: '' } + : { firstName: '', lastName: '' } const sexNode = findChild(indi, 'SEX') - const sex = sexNode?.value === 'M' ? 'male' : sexNode?.value === 'F' ? 'female' : null + const sex = sexOrNull(sexNode?.value) const birtNode = findChild(indi, 'BIRT') const birthDateIso = gedcomDateToIso(findChild(birtNode, 'DATE')?.value ?? '') @@ -669,9 +696,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { const profession = clip(findChild(indi, 'OCCU')?.value ?? null, 200) - const notes = clip(personNotes(indi, records), 2000) - - const personFirstName = (firstName || '?').slice(0, 120) + // Prénom absent du fichier : chaîne vide, comme un prénom inconnu dans l'arbre + const personFirstName = (firstName || '').slice(0, 120) const personLastName = (lastName || '').slice(0, 120) const birthDate = isoDateToDbDate(birthDateIso) const deathDate = isoDateToDbDate(deathDateIso) @@ -686,6 +712,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { continue } + const notes = clip(personNotes(indi, records), 2000) const { events, ...extras } = personExtras(indi, records) const person = await tx.person.create({ data: { @@ -713,7 +740,8 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { xrefToPersonId.set(indi.xref!, person.id) personsCreated++ if (photoPath) photoRefs.push({ personId: person.id, file: photoPath }) - for (const ref of mediaRefsForPerson(indi, records, photoPath)) { + const personMedia = mediaRefs.length < MAX_IMPORTED_MEDIA_REFS ? mediaRefsForPerson(indi, records, photoPath) : [] + for (const ref of personMedia) { if (mediaRefs.length >= MAX_IMPORTED_MEDIA_REFS) break mediaRefs.push({ personId: person.id, ...ref }) } @@ -724,7 +752,12 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { const wifeXref = findChild(fam, 'WIFE')?.value?.replace(/@/g, '').trim() const partner1Id = husbXref ? xrefToPersonId.get(husbXref) : undefined - const partner2Id = wifeXref ? xrefToPersonId.get(wifeXref) : undefined + let partner2Id = wifeXref ? xrefToPersonId.get(wifeXref) : undefined + // Une même personne des deux côtés d'une union : un seul partenaire + if (partner2Id && partner2Id === partner1Id) { + partner2Id = undefined + relationsSkipped++ + } if (!partner1Id && !partner2Id) continue @@ -770,6 +803,10 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { for (const parentId of parentIds) { const linkKey = `${parentId}:${childId}` if (linkKeys.has(linkKey)) continue + if (parentId === childId || isAncestorOf(childId, parentId)) { + relationsSkipped++ + continue + } await tx.parentChildLink.create({ data: { treeId, @@ -781,6 +818,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { }, }) linkKeys.add(linkKey) + parentsOf.set(childId, [...(parentsOf.get(childId) ?? []), parentId]) linksCreated++ } } @@ -794,7 +832,7 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { action: 'gedcom_imported', entityType: 'tree', entityId: treeId, - payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, photoRefs: photoRefs.length, mediaRefs: mediaRefs.length }, + payloadJson: { personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, relationsSkipped, photoRefs: photoRefs.length, mediaRefs: mediaRefs.length }, }, }) }, { timeout: 60000 }) @@ -804,6 +842,6 @@ export const gedcomRoutes: FastifyPluginAsync = async (app) => { // photoRefs : chemin de la photo que le logiciel d'origine associait à chaque personne ; mediaRefs : les // autres fichiers cités, avec leur titre et leur source (rien n'est gardé en base) - return reply.send({ personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, photoRefs, mediaRefs }) + return reply.send({ personsCreated, personsReused, unionsCreated, linksCreated, eventsCreated, relationsSkipped, photoRefs, mediaRefs }) }) } diff --git a/backend/src/routes/immich.ts b/backend/src/routes/immich.ts index 26ad66f..9b8c227 100644 --- a/backend/src/routes/immich.ts +++ b/backend/src/routes/immich.ts @@ -1,3 +1,4 @@ +import { Prisma } from '@prisma/client' import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify' import path from 'node:path' import { randomUUID } from 'node:crypto' @@ -16,7 +17,7 @@ import { type ImmichPersonSummary, } from '../lib/immich.js' import { decryptImmichApiKey, encryptImmichApiKey } from '../lib/immich-secret.js' -import { InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js' +import { IMAGE_MIME_TYPES, InvalidMediaError, isImmichAvatarPath, removeStoredFile, sniffMimeType } from '../lib/media-files.js' import { exceedsStorageQuota, getStorageRoot, prepareUploadedFile, requireAdminMembership } from './media.js' // Reconnaissance des visages : associer une personne de l'arbre à une personne Immich, dont la @@ -357,7 +358,7 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { // Le type se lit dans les octets, pas dans ce qu'Immich déclare : la vignette finit dans une // balise . Vide, trop grande ou pas une image : rien à montrer. const mimeType = thumbnail?.length ? sniffMimeType(thumbnail, 'photo') : null - if (!thumbnail || !mimeType?.startsWith('image/')) { + if (!thumbnail || !mimeType || !(IMAGE_MIME_TYPES as readonly string[]).includes(mimeType)) { request.log.warn('Vignette Immich vide, trop grande ou qui n’est pas une image') return reply.code(404).send({ error: 'thumbnail_not_found' }) } @@ -515,6 +516,8 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { data: { avatarPath: relativePath, avatarMimeType: prepared.mimeType, + // Nouveau portrait, nouveau cadrage à faire + avatarCrop: Prisma.DbNull, immichPersonId, updatedBy: request.actor.userId, }, @@ -594,10 +597,12 @@ export const immichRoutes: FastifyPluginAsync = async (app) => { updatedBy: string avatarPath?: null avatarMimeType?: null + avatarCrop?: typeof Prisma.DbNull } = { immichPersonId: null, updatedBy: request.actor.userId } if (avatarFromImmich) { updateData.avatarPath = null updateData.avatarMimeType = null + updateData.avatarCrop = Prisma.DbNull } // Base d'abord, fichier ensuite : le portrait disparaît seulement une fois la base à jour. diff --git a/backend/src/routes/media.ts b/backend/src/routes/media.ts index 9129744..99cee28 100644 --- a/backend/src/routes/media.ts +++ b/backend/src/routes/media.ts @@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises' import path from 'node:path' +import { Prisma } from '@prisma/client' import type { FastifyPluginAsync, FastifyRequest } from 'fastify' import { z } from 'zod' @@ -10,6 +11,7 @@ import { applyMediaResponseHeaders, extensionForMime, InvalidMediaError, + isImmichAvatarPath, isMimeAllowedForType, removeStoredFile, sanitizeImage, @@ -17,6 +19,7 @@ import { sniffMimeType, type BinaryMediaType, } from '../lib/media-files.js' +import { avatarCropInputSchema, readAvatarCrop, toStoredAvatarCrop } from '../lib/avatar-crop.js' import { syncPersonMediaOrder } from '../lib/media-order.js' import { resolveTreeMediaDirectory } from '../lib/tree-purge.js' import { canReadTree, canReadTreeWithMediaToken } from '../lib/tree-access.js' @@ -94,6 +97,13 @@ const reorderMediaSchema = z.object({ orderedMediaIds: z.array(z.string().min(1)).min(1), }) +const MAX_CAPTION_LENGTH = 280 +const MAX_CITATION_LENGTH = 2000 +const updateMediaSchema = z.object({ + caption: z.string().trim().min(1).max(MAX_CITATION_LENGTH), + lifeEventId: z.string().min(1).max(64).nullable().optional(), +}) + function hasAdminRight(role: MembershipRole): boolean { return role === 'owner' || role === 'admin' } @@ -273,6 +283,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { select: { id: true, firstName: true, + lastName: true, }, }) @@ -362,7 +373,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { submittedByLabel: payload.data.submittedByLabel?.trim() || contribution.submittedByLabel, mode: contribution.mode, status: contribution.autoStatus, - title: `Souvenir pour ${person.firstName}`.slice(0, 120), + title: `Souvenir pour ${person.firstName || person.lastName}`.slice(0, 120), reviewedAt: contribution.autoStatus === 'approved' ? now : undefined, reviewedBy: contribution.autoStatus === 'approved' ? contribution.submittedByUserId : undefined, }, @@ -592,6 +603,8 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { data: { avatarPath: relativePath, avatarMimeType: prepared.mimeType, + // Nouveau portrait : le cadrage de l'ancien ne s'applique pas à celui-ci + avatarCrop: Prisma.DbNull, updatedBy: request.actor.userId, // Un portrait déposé à la main remplace aussi le lien Immich : sans cela, l'association // survivrait invisible en base (le graphe ne publie que l'origine du portrait) et ne @@ -647,6 +660,7 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { data: { avatarPath: null, avatarMimeType: null, + avatarCrop: Prisma.DbNull, // Retirer le portrait retire aussi le lien Immich : l'association ne survit pas au portrait. immichPersonId: null, updatedBy: request.actor.userId, @@ -673,6 +687,69 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.send({ ok: true }) }) + // Cadrage du portrait : l'original reste en place, seul le cadrage est enregistré. Même droit que changer la photo. + app.put('/trees/:id/persons/:personId/avatar/crop', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) + } + + const params = treePersonParamsSchema.safeParse(request.params) + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const payload = avatarCropInputSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload', details: payload.error.flatten() }) + } + + const membership = await requireAdminMembership(app, request.actor.userId, params.data.id) + if (!membership) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const person = await app.prisma.person.findFirst({ + where: { id: params.data.personId, treeId: params.data.id, deletedAt: null }, + select: { avatarPath: true }, + }) + + if (!person) { + return reply.code(404).send({ error: 'person_not_found' }) + } + + if (!person.avatarPath) { + return reply.code(404).send({ error: 'avatar_not_found' }) + } + + // Hors périmètre pour l'instant : les portraits venus d'Immich ne se recadrent pas + if (isImmichAvatarPath(person.avatarPath)) { + return reply.code(409).send({ error: 'avatar_from_immich' }) + } + + const avatarCrop = toStoredAvatarCrop(payload.data.crop) + + await app.prisma.person.updateMany({ + where: { id: params.data.personId, treeId: params.data.id, deletedAt: null }, + data: { + avatarCrop: avatarCrop ?? Prisma.DbNull, + updatedBy: request.actor.userId, + }, + }) + + await app.prisma.auditLog.create({ + data: { + treeId: params.data.id, + actorType: 'user', + actorId: request.actor.userId, + action: 'person_avatar_cropped', + entityType: 'person', + entityId: params.data.personId, + }, + }) + + return reply.send({ ok: true, avatarCrop: readAvatarCrop(avatarCrop) }) + }) + app.patch('/trees/:id/persons/:personId/media/reorder', async (request, reply) => { if (!request.actor || request.actor.kind !== 'user') { return reply.code(401).send({ error: 'authentication_required' }) @@ -718,38 +795,148 @@ export const mediaRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_media_order_payload' }) } - for (let index = 0; index < orderedIds.length; index += 1) { - await app.prisma.mediaItem.updateMany({ - where: { - id: orderedIds[index], - treeId: params.data.id, - personId: params.data.personId, - deletedAt: null, - }, + const actorId = request.actor.userId + await app.prisma.$transaction(async (tx) => { + for (let index = 0; index < orderedIds.length; index += 1) { + await tx.mediaItem.updateMany({ + where: { + id: orderedIds[index], + treeId: params.data.id, + personId: params.data.personId, + deletedAt: null, + }, + data: { + displayOrder: index + 1, + }, + }) + } + + await syncPersonMediaOrder(tx, params.data.id, params.data.personId) + + await tx.auditLog.create({ data: { - displayOrder: index + 1, + treeId: params.data.id, + actorType: 'user', + actorId, + action: 'media_reordered', + entityType: 'media_item', + entityId: params.data.personId, + payloadJson: { + personId: params.data.personId, + orderedMediaIds: payload.data.orderedMediaIds, + }, }, }) + }) + + return reply.send({ ok: true }) + }) + + // Modifier un souvenir : titre (caption) et moment de vie. Pour une citation, caption est aussi le texte + // du fichier citation.txt, réécrit ici pour que la lecture et le téléchargement restent identiques. + app.patch('/trees/:id/persons/:personId/media/:mediaId', async (request, reply) => { + if (!request.actor || request.actor.kind !== 'user') { + return reply.code(401).send({ error: 'authentication_required' }) } - await syncPersonMediaOrder(app.prisma, params.data.id, params.data.personId) + const params = z + .object({ + id: z.string().min(1), + personId: z.string().min(1), + mediaId: z.string().min(1), + }) + .safeParse(request.params) + + if (!params.success) { + return reply.code(400).send({ error: 'invalid_params' }) + } + + const membership = await requireAdminMembership(app, request.actor.userId, params.data.id) + if (!membership) { + return reply.code(403).send({ error: 'forbidden' }) + } + + const payload = updateMediaSchema.safeParse(request.body) + if (!payload.success) { + return reply.code(400).send({ error: 'invalid_payload' }) + } + + const existingMedia = await app.prisma.mediaItem.findFirst({ + where: { id: params.data.mediaId, treeId: params.data.id, personId: params.data.personId, deletedAt: null }, + select: { id: true, type: true, filePath: true }, + }) + if (!existingMedia) { + return reply.code(404).send({ error: 'media_not_found' }) + } + + const isCitation = existingMedia.type === 'citation' + if (!isCitation && payload.data.caption.length > MAX_CAPTION_LENGTH) { + return reply.code(400).send({ error: 'caption_too_long' }) + } + + const lifeEventId = payload.data.lifeEventId + if (lifeEventId) { + const event = await app.prisma.lifeEvent.findFirst({ + where: { id: lifeEventId, treeId: params.data.id, deletedAt: null }, + select: { personId: true, unionId: true, status: true }, + }) + let ownsEvent = event?.personId === params.data.personId + if (event && !ownsEvent && event.unionId) { + const union = await app.prisma.union.findFirst({ + where: { id: event.unionId, treeId: params.data.id }, + select: { partner1PersonId: true, partner2PersonId: true }, + }) + ownsEvent = union?.partner1PersonId === params.data.personId || union?.partner2PersonId === params.data.personId + } + if (!event || !ownsEvent || event.status !== 'approved') { + return reply.code(400).send({ error: 'invalid_life_event_reference' }) + } + } + + const data: { caption: string; lifeEventId?: string | null; sizeBytes?: number } = { + caption: payload.data.caption, + } + if (lifeEventId !== undefined) data.lifeEventId = lifeEventId + + if (isCitation) { + const buffer = Buffer.from(payload.data.caption, 'utf8') + const storageRoot = getStorageRoot() + const absolutePath = path.resolve(storageRoot, existingMedia.filePath) + if (!absolutePath.startsWith(storageRoot + path.sep)) { + return reply.code(400).send({ error: 'invalid_media_path' }) + } + await writeFile(absolutePath, buffer) + data.sizeBytes = buffer.length + } + + await app.prisma.mediaItem.updateMany({ + where: { id: params.data.mediaId, treeId: params.data.id, personId: params.data.personId, deletedAt: null }, + data, + }) await app.prisma.auditLog.create({ data: { treeId: params.data.id, actorType: 'user', actorId: request.actor.userId, - action: 'media_reordered', + action: 'media_updated', entityType: 'media_item', - entityId: params.data.personId, + entityId: params.data.mediaId, payloadJson: { personId: params.data.personId, - orderedMediaIds: payload.data.orderedMediaIds, + lifeEventChanged: lifeEventId !== undefined, }, }, }) - return reply.send({ ok: true }) + return reply.send({ + ok: true, + media: { + id: params.data.mediaId, + caption: payload.data.caption, + lifeEventId, + }, + }) }) app.delete('/trees/:id/persons/:personId/media/:mediaId', async (request, reply) => { diff --git a/backend/src/routes/oidc.ts b/backend/src/routes/oidc.ts new file mode 100644 index 0000000..ef12b63 --- /dev/null +++ b/backend/src/routes/oidc.ts @@ -0,0 +1,224 @@ +import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from 'fastify' +import { randomUUID } from 'node:crypto' +import { Prisma } from '@prisma/client' +import { z } from 'zod' + +import { env } from '../config/env.js' +import { + appBaseUrl, + finishAuthorization, + isOidcEnabled, + safeReturnTo, + startAuthorization, + type OidcIdentity, + type OidcPending, +} from '../lib/oidc.js' +import { confirmsWithPassword } from '../lib/auth.js' +import { createAccountIfAllowed } from '../lib/registration.js' +import { signUserSession } from '../lib/user-session.js' + +// Parcours : /auth/oidc/start pose un cookie signé (state, nonce, vérificateur PKCE) et part chez le fournisseur ; +// /auth/oidc/callback vérifie tout, trouve ou crée le compte, et renvoie vers l'app avec un jeton de passage dans le +// fragment (#oidc=…, jamais envoyé à un serveur ni écrit dans un journal) ; l'app l'échange une seule fois contre +// une session sur /auth/oidc/exchange. Le cookie lie le retour au navigateur qui a commencé la connexion. + +const PENDING_COOKIE = 'parenthese_oidc' +const PENDING_MAX_AGE_S = 10 * 60 +const HANDOFF_TTL_S = 60 + +type HandoffPayload = { kind: 'oidc_handoff'; sub: string; jti: string; exp?: number } + +// Jetons de passage déjà échangés, jusqu'à leur expiration (une seule instance de l'API, comme les sessions révoquées) +const usedHandoffs = new Map() +function consumeHandoff(jti: string, expSeconds: number): boolean { + const now = Date.now() + for (const [key, expiresAt] of usedHandoffs) { + if (expiresAt < now) usedHandoffs.delete(key) + } + if (usedHandoffs.has(jti)) return false + usedHandoffs.set(jti, expSeconds * 1000) + return true +} + +const pendingSchema = z.object({ + state: z.string().min(1), + nonce: z.string().min(1), + codeVerifier: z.string().min(43), + returnTo: z.string(), +}) + +const exchangeSchema = z.object({ + code: z.string().min(1).max(4096), +}) + +type AccountResult = { userId: string } | { error: 'registration_closed' | 'oidc_email_missing' | 'oidc_email_unverified' | 'oidc_email_taken' } + +// Compte relié à cette identité ; sinon le compte qui a cet email, si le fournisseur le garantit ; sinon un compte +// neuf, seulement si l'instance en accepte (ALLOW_REGISTRATION, ou instance encore vide). +async function resolveAccount(app: FastifyInstance, identity: OidcIdentity): Promise { + const linked = await app.prisma.userIdentity.findUnique({ + where: { issuer_subject: { issuer: identity.issuer, subject: identity.subject } }, + select: { userId: true }, + }) + if (linked) return { userId: linked.userId } + + if (!identity.email) return { error: 'oidc_email_missing' } + if (env.OIDC_REQUIRE_VERIFIED_EMAIL && !identity.emailVerified) return { error: 'oidc_email_unverified' } + + const existing = await app.prisma.user.findUnique({ + where: { email: identity.email }, + select: { id: true, passwordHash: true, identities: { select: { id: true }, take: 1 } }, + }) + if (existing) { + // Relier un compte existant demande toujours un email vérifié, même avec OIDC_REQUIRE_VERIFIED_EMAIL=false : + // sinon quiconque déclare l'adresse d'un autre chez le fournisseur prendrait son compte + if (!identity.emailVerified) return { error: 'oidc_email_unverified' } + // Jamais vers un compte à mot de passe : l'inscription ne vérifie pas l'adresse, un tiers aurait pu créer ce + // compte avec l'email de la victime, et sa connexion par le fournisseur y serait reliée, lisible par le tiers + if (existing.passwordHash) return { error: 'oidc_email_taken' } + // Un compte déjà relié à une autre identité (un autre sujet) n'est jamais repris par un nouveau sujet qui + // présente le même email : le fournisseur a pu réattribuer l'adresse. Le compte reste à celui qui l'a relié. + if (existing.identities?.length) return { error: 'oidc_email_taken' } + + await app.prisma.userIdentity.create({ + data: { userId: existing.id, issuer: identity.issuer, subject: identity.subject }, + }) + return { userId: existing.id } + } + + const created = await createAccountIfAllowed(app.prisma, (tx) => tx.user.create({ + data: { + email: identity.email as string, + firstName: identity.firstName, + passwordHash: null, + identities: { create: { issuer: identity.issuer, subject: identity.subject } }, + }, + select: { id: true }, + })) + return created ? { userId: created.id } : { error: 'registration_closed' } +} + +function redirectToApp(reply: FastifyReply, returnTo: string, fragment: string) { + reply.clearCookie(PENDING_COOKIE, { path: '/' }) + return reply.redirect(`${appBaseUrl()}${returnTo}#${fragment}`, 302) +} + +export const oidcRoutes: FastifyPluginAsync = async (app) => { + if (!isOidcEnabled()) return + + const secureCookie = new URL(env.OIDC_REDIRECT_URI).protocol === 'https:' + + app.get( + '/start', + { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } }, + async (request, reply) => { + const returnTo = safeReturnTo((request.query as Record | undefined)?.returnTo) + let start: Awaited> + try { + start = await startAuthorization(returnTo) + } catch (error) { + request.log.error({ err: error }, 'OIDC discovery failed') + return redirectToApp(reply, returnTo, 'oidc_error=oidc_unavailable') + } + + reply.setCookie(PENDING_COOKIE, JSON.stringify(start.pending), { + path: '/', + httpOnly: true, + sameSite: 'lax', // envoyé au retour du fournisseur, qui est une navigation de premier niveau + secure: secureCookie, + signed: true, + maxAge: PENDING_MAX_AGE_S, + }) + return reply.redirect(start.url.href, 302) + }, + ) + + app.get( + '/callback', + { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } }, + async (request, reply) => { + const raw = request.cookies[PENDING_COOKIE] + const unsigned = raw ? request.unsignCookie(raw) : null + let pending: OidcPending | null = null + if (unsigned?.valid && unsigned.value) { + try { + const parsed = pendingSchema.safeParse(JSON.parse(unsigned.value)) + pending = parsed.success ? parsed.data : null + } catch { + pending = null + } + } + if (!pending) { + // Pas de connexion commencée dans ce navigateur (cookie absent, expiré ou modifié) + return redirectToApp(reply, '/', 'oidc_error=oidc_failed') + } + + const returnTo = safeReturnTo(pending.returnTo) + const query = request.url.includes('?') ? request.url.slice(request.url.indexOf('?')) : '' + + let identity: OidcIdentity + try { + identity = await finishAuthorization(query, pending) + } catch (error) { + // Refus chez le fournisseur, state ou nonce faux, ID token invalide (signature, émetteur, audience, date) + request.log.warn({ err: error }, 'OIDC callback rejected') + return redirectToApp(reply, returnTo, 'oidc_error=oidc_failed') + } + + let result: AccountResult + try { + result = await resolveAccount(app, identity) + } catch (error) { + // Deux retours simultanés pour la même identité : le second retrouve le lien créé par le premier + if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') { + result = await resolveAccount(app, identity) + } else { + throw error + } + } + if ('error' in result) { + return redirectToApp(reply, returnTo, `oidc_error=${result.error}`) + } + + const handoff = app.jwt.sign( + { kind: 'oidc_handoff', sub: result.userId, jti: randomUUID() }, + { expiresIn: `${HANDOFF_TTL_S}s` }, + ) + return redirectToApp(reply, returnTo, `oidc=${encodeURIComponent(handoff)}`) + }, + ) + + app.post( + '/exchange', + { config: { rateLimit: { max: 20, timeWindow: '1 minute' } } }, + async (request, reply) => { + const parsed = exchangeSchema.safeParse(request.body) + if (!parsed.success) { + return reply.code(400).send({ error: 'invalid_payload' }) + } + + let payload: HandoffPayload + try { + payload = app.jwt.verify(parsed.data.code) + } catch { + return reply.code(401).send({ error: 'invalid_handoff' }) + } + if (payload.kind !== 'oidc_handoff' || typeof payload.exp !== 'number' || !consumeHandoff(payload.jti, payload.exp)) { + return reply.code(401).send({ error: 'invalid_handoff' }) + } + + const user = await app.prisma.user.findUnique({ + where: { id: payload.sub }, + select: { id: true, email: true, firstName: true, passwordHash: true }, + }) + if (!user) { + return reply.code(401).send({ error: 'invalid_handoff' }) + } + + return reply.send({ + token: signUserSession(app, user), + user: { id: user.id, email: user.email, firstName: user.firstName, hasPassword: confirmsWithPassword(user.passwordHash) }, + }) + }, + ) +} diff --git a/backend/src/routes/trees.ts b/backend/src/routes/trees.ts index de1805b..2035c78 100644 --- a/backend/src/routes/trees.ts +++ b/backend/src/routes/trees.ts @@ -16,6 +16,8 @@ import { reserveTreeUnlockAttempt, serializePasswordCheck } from '../lib/unlock- import type { MembershipRole } from '../types/auth.js' import { findParentChildValidationError, findUnionValidationError } from '../utils/relationship-guards.js' import { personPrecisionResets, unionPrecisionResets } from '../lib/date-precision.js' +import { hasAnyName } from '../lib/person-names.js' +import { sexSchema } from '../lib/person-sex.js' import { ContributionRouteError, resolveSubmissionContext } from './contributions.js' const MAX_TREES_PER_ACCOUNT = 10 @@ -84,33 +86,37 @@ const listAuditQuerySchema = z.object({ const isoDateRegex = /^\d{4}-\d{2}-\d{2}$/ const createPersonSchema = z.object({ - firstName: z.string().min(1).max(120), - lastName: z.string().min(1).max(120), + firstName: z.string().trim().max(120), + lastName: z.string().trim().max(120), birthName: z.string().max(120).optional().nullable(), + nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), + deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), - sex: z.string().max(20).optional().nullable(), + sex: sexSchema, notes: z.string().max(2000).optional().nullable(), -}) +}).refine((value) => hasAnyName(value.firstName, value.lastName), { message: 'name_required', path: ['lastName'] }) const patchPersonSchema = z .object({ - firstName: z.string().min(1).max(120).optional(), - lastName: z.string().min(1).max(120).optional(), + firstName: z.string().trim().max(120).optional(), + lastName: z.string().trim().max(120).optional(), birthName: z.string().max(120).optional().nullable(), + nickname: z.string().max(120).optional().nullable(), birthDate: z.string().regex(isoDateRegex).optional().nullable(), deathDate: z.string().regex(isoDateRegex).optional().nullable(), isDeceased: z.boolean().optional(), birthPlace: z.string().max(200).optional().nullable(), + deathPlace: z.string().max(200).optional().nullable(), profession: z.string().max(200).optional().nullable(), region: z.string().max(120).optional().nullable(), nationality: z.string().max(120).optional().nullable(), - sex: z.string().max(20).optional().nullable(), + sex: sexSchema, notes: z.string().max(2000).optional().nullable(), }) .refine((value) => Object.keys(value).length > 0, { @@ -1310,11 +1316,13 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { firstName: payload.data.firstName.trim(), lastName: payload.data.lastName.trim(), birthName: toTrimmedStringOrNull(payload.data.birthName), + nickname: toTrimmedStringOrNull(payload.data.nickname), birthDate: toDateOrNull(payload.data.birthDate), deathDate: toDateOrNull(payload.data.deathDate), // Une date de décès dit déjà que la personne est décédée : le drapeau ne sert que sans date isDeceased: !payload.data.deathDate && Boolean(payload.data.isDeceased), birthPlace: toTrimmedStringOrNull(payload.data.birthPlace), + deathPlace: toTrimmedStringOrNull(payload.data.deathPlace), profession: toTrimmedStringOrNull(payload.data.profession), region: toTrimmedStringOrNull(payload.data.region), nationality: toTrimmedStringOrNull(payload.data.nationality), @@ -1364,14 +1372,27 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { return reply.code(403).send({ error: 'forbidden' }) } + // Au moins un nom : on regarde le résultat de la modification, pas seulement ce qu'elle envoie + if (payload.data.firstName !== undefined || payload.data.lastName !== undefined) { + const before = await app.prisma.person.findFirst({ + where: { id: params.data.personId, treeId: params.data.id, deletedAt: null }, + select: { firstName: true, lastName: true }, + }) + if (before && !hasAnyName(payload.data.firstName ?? before.firstName, payload.data.lastName ?? before.lastName)) { + return reply.code(400).send({ error: 'invalid_payload', details: 'name_required' }) + } + } + const data: { firstName?: string lastName?: string birthName?: string | null + nickname?: string | null birthDate?: Date | null deathDate?: Date | null isDeceased?: boolean birthPlace?: string | null + deathPlace?: string | null profession?: string | null region?: string | null nationality?: string | null @@ -1392,6 +1413,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { data.birthName = toTrimmedStringOrNull(payload.data.birthName) } + if (payload.data.nickname !== undefined) { + data.nickname = toTrimmedStringOrNull(payload.data.nickname) + } + if (payload.data.birthDate !== undefined) { data.birthDate = toDateOrNull(payload.data.birthDate) } @@ -1412,6 +1437,10 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { data.birthPlace = toTrimmedStringOrNull(payload.data.birthPlace) } + if (payload.data.deathPlace !== undefined) { + data.deathPlace = toTrimmedStringOrNull(payload.data.deathPlace) + } + if (payload.data.profession !== undefined) { data.profession = toTrimmedStringOrNull(payload.data.profession) } @@ -2067,21 +2096,23 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { return reply.send({ link }) }) - // Réordonner une fratrie : displayOrder des liens parent-enfant réécrit en une transaction - // Souvenirs rattachables : approuvés, d'une de ces personnes, dans cet arbre - const validLifeEventMedia = async (treeId: string, ownerIds: string[], mediaIds: string[] | undefined) => { + // Souvenirs rattachables : approuvés, d'une de ces personnes, dans cet arbre. La famille ne prend + // que des souvenirs libres : elle ne détache pas un souvenir du moment d'un autre. + const validLifeEventMedia = async (treeId: string, ownerIds: string[], mediaIds: string[] | undefined, onlyFree = false) => { if (!mediaIds?.length) return true const unique = [...new Set(mediaIds)] - const count = await app.prisma.mediaItem.count({ where: { id: { in: unique }, treeId, personId: { in: ownerIds }, status: 'approved', deletedAt: null } }) + const count = await app.prisma.mediaItem.count({ + where: { id: { in: unique }, treeId, personId: { in: ownerIds }, status: 'approved', deletedAt: null, ...(onlyFree ? { lifeEventId: null } : {}) }, + }) return count === unique.length } // Rattache exactement ces souvenirs au moment (et détache ceux qui ne sont plus dans la liste) - const setLifeEventMedia = async (treeId: string, eventId: string, mediaIds: string[] | undefined) => { + const setLifeEventMedia = async (db: Prisma.TransactionClient, treeId: string, eventId: string, mediaIds: string[] | undefined) => { if (mediaIds === undefined) return const unique = [...new Set(mediaIds)] - await app.prisma.mediaItem.updateMany({ where: { treeId, lifeEventId: eventId, id: { notIn: unique } }, data: { lifeEventId: null } }) - if (unique.length) await app.prisma.mediaItem.updateMany({ where: { treeId, id: { in: unique } }, data: { lifeEventId: eventId } }) + await db.mediaItem.updateMany({ where: { treeId, lifeEventId: eventId, id: { notIn: unique } }, data: { lifeEventId: null } }) + if (unique.length) await db.mediaItem.updateMany({ where: { treeId, id: { in: unique } }, data: { lifeEventId: eventId } }) } app.post('/trees/:id/persons/:personId/life/events', async (request, reply) => { @@ -2117,7 +2148,7 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { const person = await app.prisma.person.findFirst({ where: { id: params.data.personId, treeId: params.data.id, deletedAt: null }, - select: { id: true, firstName: true }, + select: { id: true, firstName: true, lastName: true }, }) if (!person) return reply.code(404).send({ error: 'person_not_found' }) @@ -2142,55 +2173,59 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { }) if (existing) return reply.code(409).send({ error: 'event_exists', eventId: existing.id }) } - if (!(await validLifeEventMedia(params.data.id, ownerIds, payload.data.mediaIds))) { + if (!(await validLifeEventMedia(params.data.id, ownerIds, payload.data.mediaIds, !isAdmin))) { return reply.code(400).send({ error: 'invalid_media_reference' }) } const isPending = contribution?.autoStatus === 'pending' const title = payload.data.label?.trim() || payload.data.description?.trim() || payload.data.type - const session = contribution - ? await app.prisma.contributionSession.create({ + // Session, moment, souvenirs et trace de la contribution : tout ou rien + const event = await app.prisma.$transaction(async (tx) => { + const session = contribution + ? await tx.contributionSession.create({ + data: { + treeId: params.data.id, + submittedByUserId: contribution.submittedByUserId, + submittedByLabel: payload.data.submittedByLabel?.trim() || contribution.submittedByLabel, + mode: contribution.mode, + status: contribution.autoStatus, + title: `Moment pour ${person.firstName || person.lastName}`.slice(0, 120), + reviewedAt: isPending ? undefined : new Date(), + reviewedBy: isPending ? undefined : contribution.submittedByUserId, + }, + }) + : null + + const event = await tx.lifeEvent.create({ data: { treeId: params.data.id, - submittedByUserId: contribution.submittedByUserId, - submittedByLabel: payload.data.submittedByLabel?.trim() || contribution.submittedByLabel, - mode: contribution.mode, - status: contribution.autoStatus, - title: `Moment pour ${person.firstName}`.slice(0, 120), - reviewedAt: isPending ? undefined : new Date(), - reviewedBy: isPending ? undefined : contribution.submittedByUserId, + personId: unionId ? null : person.id, + unionId, + type: payload.data.type, + ...lifeEventData(payload.data, reserved), + status: isPending ? 'pending' : 'approved', + contributionSessionId: session?.id ?? null, }, }) - : null + await setLifeEventMedia(tx, params.data.id, event.id, payload.data.mediaIds) - const event = await app.prisma.lifeEvent.create({ - data: { - treeId: params.data.id, - personId: unionId ? null : person.id, - unionId, - type: payload.data.type, - ...lifeEventData(payload.data, reserved), - status: isPending ? 'pending' : 'approved', - contributionSessionId: session?.id ?? null, - }, - }) - await setLifeEventMedia(params.data.id, event.id, payload.data.mediaIds) - - if (session) { - await app.prisma.contributionChange.create({ - data: { - sessionId: session.id, - entityType: 'life_event', - entityId: event.id, - action: 'create', - afterJson: { - personId: person.id, type: event.type, title, date: payload.data.date ?? null, dateEnd: payload.data.dateEnd ?? null, - datePrecision: payload.data.datePrecision ?? null, place: event.place, notes: event.notes, sources: event.sources, + if (session) { + await tx.contributionChange.create({ + data: { + sessionId: session.id, + entityType: 'life_event', + entityId: event.id, + action: 'create', + afterJson: { + personId: person.id, type: event.type, title, date: payload.data.date ?? null, dateEnd: payload.data.dateEnd ?? null, + datePrecision: payload.data.datePrecision ?? null, place: event.place, notes: event.notes, sources: event.sources, + }, + decision: isPending ? undefined : 'approved', }, - decision: isPending ? undefined : 'approved', - }, - }) - } + }) + } + return event + }) return reply.code(201).send({ event: { id: event.id, status: event.status } }) }) @@ -2221,11 +2256,13 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { return reply.code(400).send({ error: 'invalid_media_reference' }) } - await app.prisma.lifeEvent.update({ - where: { id: params.data.eventId }, - data: lifeEventData(payload.data, RESERVED_LIFE_EVENT_TYPES.has(existing.type)), + await app.prisma.$transaction(async (tx) => { + await tx.lifeEvent.update({ + where: { id: params.data.eventId }, + data: lifeEventData(payload.data, RESERVED_LIFE_EVENT_TYPES.has(existing.type)), + }) + await setLifeEventMedia(tx, params.data.id, params.data.eventId, payload.data.mediaIds) }) - await setLifeEventMedia(params.data.id, params.data.eventId, payload.data.mediaIds) return reply.send({ ok: true }) }) @@ -2238,13 +2275,16 @@ export const treeRoutes: FastifyPluginAsync = async (app) => { const membership = await getAdminMembership(app, user.userId, params.data.id) if (!hasActiveAdminMembership(membership)) return reply.code(403).send({ error: 'forbidden' }) - const deleted = await app.prisma.lifeEvent.updateMany({ - where: { id: params.data.eventId, treeId: params.data.id, deletedAt: null, personId: { not: null }, type: { notIn: [...RESERVED_LIFE_EVENT_TYPES] } }, - data: { deletedAt: new Date() }, + const deletedCount = await app.prisma.$transaction(async (tx) => { + const deleted = await tx.lifeEvent.updateMany({ + where: { id: params.data.eventId, treeId: params.data.id, deletedAt: null, personId: { not: null }, type: { notIn: [...RESERVED_LIFE_EVENT_TYPES] } }, + data: { deletedAt: new Date() }, + }) + // Les souvenirs restent dans la fiche, simplement détachés du moment + if (deleted.count > 0) await tx.mediaItem.updateMany({ where: { treeId: params.data.id, lifeEventId: params.data.eventId }, data: { lifeEventId: null } }) + return deleted.count }) - if (deleted.count === 0) return reply.code(404).send({ error: 'event_not_found' }) - // Les souvenirs restent dans la fiche, simplement détachés du moment - await app.prisma.mediaItem.updateMany({ where: { treeId: params.data.id, lifeEventId: params.data.eventId }, data: { lifeEventId: null } }) + if (deletedCount === 0) return reply.code(404).send({ error: 'event_not_found' }) return reply.code(204).send() }) diff --git a/backend/src/types/auth.ts b/backend/src/types/auth.ts index 0a830b7..b92e7eb 100644 --- a/backend/src/types/auth.ts +++ b/backend/src/types/auth.ts @@ -28,7 +28,14 @@ export type MediaJwtPayload = { accessVersion?: number } -export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload +// Passage d'une connexion OIDC à l'app (60 s, à usage unique, échangé sur /auth/oidc/exchange) : jamais une session +export type OidcHandoffJwtPayload = { + kind: 'oidc_handoff' + sub: string + jti: string +} + +export type AnyJwtPayload = UserJwtPayload | TreeAccessJwtPayload | MediaJwtPayload | OidcHandoffJwtPayload export type UserActor = { kind: 'user' diff --git a/backend/src/utils/graph-payload.ts b/backend/src/utils/graph-payload.ts index 78a4a04..745919c 100644 --- a/backend/src/utils/graph-payload.ts +++ b/backend/src/utils/graph-payload.ts @@ -1,7 +1,9 @@ import type { Annotation, MediaItem, ParentChildLink, Person, Union } from '@prisma/client' import { sanitizeAnnotationContentForRead } from '../lib/annotation-content.js' +import { readAvatarCrop, type AvatarCrop } from '../lib/avatar-crop.js' import { isImmichAvatarPath } from '../lib/media-files.js' +import { sexOrNull } from '../lib/person-sex.js' export type GraphPayload = { persons: Array<{ @@ -9,10 +11,12 @@ export type GraphPayload = { firstName: string lastName: string birthName: string | null + nickname: string | null birthDate: string | null deathDate: string | null isDeceased: boolean birthPlace: string | null + deathPlace: string | null profession: string | null region: string | null nationality: string | null @@ -20,6 +24,7 @@ export type GraphPayload = { sex: string | null notes: string | null avatarFromImmich: boolean + avatarCrop: AvatarCrop | null }> unions: Array<{ id: string @@ -84,17 +89,20 @@ export function buildGraphPayload( firstName: person.firstName, lastName: person.lastName, birthName: person.birthName, + nickname: person.nickname, birthDate: formatDate(person.birthDate), deathDate: formatDate(person.deathDate), isDeceased: Boolean(person.isDeceased) && !person.deathDate, birthPlace: person.birthPlace, + deathPlace: person.deathPlace, profession: person.profession, region: person.region, nationality: person.nationality, avatarUrlPath: person.avatarPath ? `/trees/${treeId}/persons/${person.id}/avatar` : null, - sex: person.sex, + sex: sexOrNull(person.sex), notes: person.notes, avatarFromImmich: isImmichAvatarPath(person.avatarPath), + avatarCrop: person.avatarPath ? readAvatarCrop(person.avatarCrop) : null, })), unions: unions.map((union) => ({ id: union.id, diff --git a/backend/test/api-routes.test.ts b/backend/test/api-routes.test.ts index 5f9b606..53faa60 100644 --- a/backend/test/api-routes.test.ts +++ b/backend/test/api-routes.test.ts @@ -80,6 +80,9 @@ const { prismaMock } = vi.hoisted(() => ({ }, annotation: { findMany: vi.fn(), + count: vi.fn().mockResolvedValue(0), + create: vi.fn(), + updateMany: vi.fn().mockResolvedValue({ count: 1 }), }, contributionSession: { create: vi.fn(), @@ -488,7 +491,7 @@ describe('Graph routes', () => { prismaMock.person.findMany.mockResolvedValue([ { ...base, id: 'p-1', firstName: 'Louis', lastName: 'Ragot', sex: 'male', birthDate: new Date('1879-01-01T00:00:00Z'), birthDatePrecision: 'about', birthPlace: 'La Roche-sur-Yon', deathDate: new Date('1950-05-02T00:00:00Z'), deathPlace: 'Nantes', nickname: 'Loulou', profession: 'Instituteur' }, { ...base, id: 'p-2', firstName: 'Marie', lastName: 'Bodin', sex: 'female', birthDate: null, birthDatePrecision: null, birthPlace: null, isDeceased: true }, - { ...base, id: 'p-3', firstName: 'Jean', lastName: 'Ragot', sex: 'male', birthDate: null, birthDatePrecision: null, birthPlace: null }, + { ...base, id: 'p-3', firstName: 'Jean', lastName: 'Ragot', sex: 'M', birthDate: null, birthDatePrecision: null, birthPlace: null }, ]) prismaMock.union.findMany.mockResolvedValue([ { id: 'u-1', partner1PersonId: 'p-1', partner2PersonId: 'p-2', startDate: new Date('1905-01-01T00:00:00Z'), startDatePrecision: 'estimated', startPlace: 'Limoges', endDate: null }, @@ -515,6 +518,8 @@ describe('Graph routes', () => { expect(text).toContain('2 PEDI adopted') // Décédée sans date (issue #77) : la forme courte de la norme expect(text).toContain('1 NAME Marie /Bodin/\n1 SEX F\n1 DEAT Y\n') + // Ancienne écriture « M » en base : exportée comme un homme (issue #114) + expect(text).toContain('1 NAME Jean /Ragot/\n1 SEX M\n') const roots = parseGedcom(text) const records = indexGedcomRecords(roots) @@ -753,6 +758,198 @@ describe('Moments de l’onglet Vie', () => { }) }) +describe('Écritures en plusieurs temps : tout passe par une transaction', () => { + // Le client de la transaction est un autre objet que app.prisma : une écriture faite hors transaction se voit + const txClient = () => ({ + annotation: { create: vi.fn().mockResolvedValue({ id: 'a' }), updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, + mediaItem: { updateMany: vi.fn().mockResolvedValue({ count: 1 }), findMany: vi.fn().mockResolvedValue([]), update: vi.fn() }, + lifeEvent: { create: vi.fn().mockResolvedValue({ id: 'e-1', status: 'approved' }), update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }) }, + contributionSession: { create: vi.fn() }, + contributionChange: { create: vi.fn() }, + auditLog: { create: vi.fn() }, + }) + + it('lot d’annotations : créations, modifications, suppressions et trace dans la même transaction', async () => { + const app = createApp() + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + const tx = txClient() + prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise) => callback(tx)) + + const response = await app.inject({ + method: 'POST', url: '/trees/tree-1/annotations/batch', headers: auth, + payload: { create: [{ type: 'text', x: 0, y: 0, content: 'Été 1952' }, { type: 'text', x: 1, y: 1, content: 'Noël' }], update: [{ id: 'a-1', x: 5 }], delete: ['a-2'] }, + }) + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ ok: true, created: 2, updated: 1, deleted: 1 }) + expect(tx.annotation.create).toHaveBeenCalledTimes(2) + expect(tx.annotation.updateMany).toHaveBeenCalledTimes(2) + expect(tx.auditLog.create).toHaveBeenCalledTimes(1) + expect(prismaMock.annotation.create).not.toHaveBeenCalled() + expect(prismaMock.annotation.updateMany).not.toHaveBeenCalled() + await app.close() + }) + + it('lot d’annotations : une erreur au milieu fait échouer toute la transaction', async () => { + const app = createApp() + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + const tx = txClient() + tx.annotation.create.mockResolvedValueOnce({ id: 'a' }).mockRejectedValueOnce(new Error('base indisponible')) + prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise) => callback(tx)) + + const response = await app.inject({ + method: 'POST', url: '/trees/tree-1/annotations/batch', headers: auth, + payload: { create: [{ type: 'text', x: 0, y: 0, content: 'a' }, { type: 'text', x: 1, y: 1, content: 'b' }] }, + }) + expect(response.statusCode).toBe(500) + expect(tx.auditLog.create).not.toHaveBeenCalled() + await app.close() + }) + + it('réordonner les souvenirs : positions, renumérotation et trace dans la même transaction', async () => { + const app = createApp() + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + const tx = txClient() + prismaMock.mediaItem.findMany.mockResolvedValueOnce([{ id: 'm-1' }, { id: 'm-2' }]) + prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise) => callback(tx)) + + const response = await app.inject({ + method: 'PATCH', url: '/trees/tree-1/persons/person-1/media/reorder', headers: auth, + payload: { orderedMediaIds: ['m-2', 'm-1'] }, + }) + expect(response.statusCode).toBe(200) + expect(tx.mediaItem.updateMany).toHaveBeenCalledTimes(2) + expect(tx.auditLog.create).toHaveBeenCalledTimes(1) + expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled() + await app.close() + }) + + it('moment : création et rattachement des souvenirs dans la même transaction', async () => { + const app = createApp() + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + const tx = txClient() + prismaMock.mediaItem.count.mockResolvedValue(1) + prismaMock.$transaction.mockImplementationOnce(async (callback: (client: unknown) => Promise) => callback(tx)) + + const response = await app.inject({ + method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth, + payload: { label: 'Départ pour Paris', mediaIds: ['m-1'] }, + }) + expect(response.statusCode).toBe(201) + expect(tx.lifeEvent.create).toHaveBeenCalledTimes(1) + expect(tx.mediaItem.updateMany).toHaveBeenCalledWith({ where: { treeId: 'tree-1', id: { in: ['m-1'] } }, data: { lifeEventId: 'e-1' } }) + expect(prismaMock.lifeEvent.create).not.toHaveBeenCalled() + await app.close() + }) + + it('la famille ne prend que des souvenirs libres, l’administrateur peut déplacer un souvenir d’un moment à l’autre', async () => { + const app = createApp() + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + const post = () => app.inject({ method: 'POST', url: '/trees/tree-1/persons/person-1/life/events', headers: auth, payload: { label: 'Communion', mediaIds: ['m-1'] } }) + prismaMock.mediaItem.count.mockResolvedValue(1) + prismaMock.lifeEvent.create.mockResolvedValue({ id: 'e-1', status: 'approved' }) + + expect((await post()).statusCode).toBe(201) + expect(prismaMock.mediaItem.count.mock.calls.at(-1)[0].where).not.toHaveProperty('lifeEventId') + + prismaMock.treeMembership.findUnique.mockResolvedValue({ role: 'member', tree: { deletedAt: null, settings: null } }) + prismaMock.contributionSession.create.mockResolvedValue({ id: 's-1' }) + expect((await post()).statusCode).toBe(201) + expect(prismaMock.mediaItem.count.mock.calls.at(-1)[0].where).toMatchObject({ lifeEventId: null }) + await app.close() + }) +}) + +describe('Import GEDCOM', () => { + it('une personne des deux côtés d’une union garde un seul partenaire, une boucle parent-enfant est refusée', async () => { + const app = createApp() + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + prismaMock.person.findMany.mockResolvedValue([]) + prismaMock.union.findMany.mockResolvedValue([]) + prismaMock.parentChildLink.findMany.mockResolvedValue([]) + prismaMock.parentChildLink.findFirst.mockResolvedValue(null) + prismaMock.person.create.mockImplementation(async ({ data }: { data: { firstName: string } }) => ({ id: `p-${data.firstName}` })) + let unionCount = 0 + prismaMock.union.create.mockImplementation(async () => ({ id: `u-${++unionCount}` })) + + const content = [ + '0 HEAD', + '0 @I1@ INDI', '1 NAME Jean /Martin/', + '0 @I2@ INDI', '1 NAME Paul /Martin/', + // Jean marié à lui-même (fichier abîmé) + '0 @F1@ FAM', '1 HUSB @I1@', '1 WIFE @I1@', + // Paul enfant de Jean, puis Jean enfant de Paul : la seconde famille ferait une boucle + '0 @F2@ FAM', '1 HUSB @I1@', '1 CHIL @I2@', + '0 @F3@ FAM', '1 HUSB @I2@', '1 CHIL @I1@', + '0 TRLR', + ].join('\n') + const response = await app.inject({ method: 'POST', url: '/trees/tree-1/import/gedcom', headers: auth, payload: { content } }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toMatchObject({ personsCreated: 2, linksCreated: 1, relationsSkipped: 2 }) + expect(prismaMock.union.create.mock.calls[0][0].data).toMatchObject({ partner1PersonId: 'p-Jean', partner2PersonId: null }) + expect(prismaMock.parentChildLink.create).toHaveBeenCalledTimes(1) + expect(prismaMock.parentChildLink.create.mock.calls[0][0].data).toMatchObject({ parentPersonId: 'p-Jean', childPersonId: 'p-Paul' }) + await app.close() + }) +}) + +describe('Sexe dans le GEDCOM (issue #114)', () => { + const importer = async (app: FastifyInstance, content: string) => { + const auth = { authorization: `Bearer ${await createUserToken(app)}` } + prismaMock.person.findMany.mockResolvedValue([]) + prismaMock.union.findMany.mockResolvedValue([]) + prismaMock.parentChildLink.findMany.mockResolvedValue([]) + prismaMock.person.create.mockImplementation(async ({ data }: { data: { firstName: string } }) => ({ id: `p-${data.firstName}` })) + prismaMock.person.create.mockClear() + const response = await app.inject({ method: 'POST', url: '/trees/tree-1/import/gedcom', headers: auth, payload: { content } }) + expect(response.statusCode).toBe(200) + return Object.fromEntries(prismaMock.person.create.mock.calls.map((call: [{ data: { firstName: string; sex: string | null } }]) => [call[0].data.firstName, call[0].data.sex])) + } + + it('import : M, F, X (GEDCOM 7), U et absent donnent male, female, other, null, null', async () => { + const app = createApp() + const content = [ + '0 HEAD', + '0 @I1@ INDI', '1 NAME Jean /Martin/', '1 SEX M', + '0 @I2@ INDI', '1 NAME Marie /Martin/', '1 SEX F', + '0 @I3@ INDI', '1 NAME Camille /Martin/', '1 SEX X', + '0 @I4@ INDI', '1 NAME Dominique /Martin/', '1 SEX U', + '0 @I5@ INDI', '1 NAME Claude /Martin/', + '0 TRLR', + ].join('\n') + expect(await importer(app, content)).toEqual({ Jean: 'male', Marie: 'female', Camille: 'other', Dominique: null, Claude: null }) + await app.close() + }) + + it('aller-retour : other sort en SEX U (GEDCOM 5.5.1), non précisé sans ligne SEX, le reste se relit à l’identique', async () => { + const app = createApp() + await app.ready() + const token = (app as any).jwt.sign({ kind: 'tree_access', sub: 'tree:tree-1:visitor', treeId: 'tree-1', role: 'visitor', accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime() }) + const base = { lastName: 'Martin', birthDate: null, birthDatePrecision: null, birthPlace: null, birthDateEnd: null, deathDateEnd: null, deathDatePrecision: null, deathPlace: null, nickname: null, notes: null, profession: null, deathDate: null } + prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', name: 'Martin', rootPersonId: null }) + prismaMock.person.findMany.mockResolvedValue([ + { ...base, id: 'p-1', firstName: 'Jean', sex: 'male' }, + { ...base, id: 'p-2', firstName: 'Marie', sex: 'female' }, + { ...base, id: 'p-3', firstName: 'Camille', sex: 'other' }, + { ...base, id: 'p-4', firstName: 'Claude', sex: null }, + ]) + prismaMock.union.findMany.mockResolvedValue([]) + prismaMock.parentChildLink.findMany.mockResolvedValue([]) + prismaMock.lifeEvent.findMany.mockResolvedValue([]) + + const response = await app.inject({ method: 'GET', url: '/trees/tree-1/export/gedcom', headers: { authorization: `Bearer ${token}` } }) + expect(response.statusCode).toBe(200) + const text = response.body.replace(/\r\n/g, '\n') + expect(text).toContain('1 NAME Camille /Martin/\n1 SEX U\n') + expect(text).not.toContain('SEX X') + expect(text).toMatch(/1 NAME Claude \/Martin\/\n(?!1 SEX)/) + + // SEX U se relit comme non précisé : 5.5.1 n'a pas de valeur pour « autre ». SEX X reste lu à l'import (ligne 915). + expect(await importer(app, text)).toEqual({ Jean: 'male', Marie: 'female', Camille: null, Claude: null }) + await app.close() + }) +}) + describe('Visit log', () => { it('refuses the visit log to a non-admin member', async () => { const app = createApp() @@ -985,3 +1182,77 @@ describe('Relationship guardrails', () => { await app.close() }) }) + +describe('Surnom (nickname) en écriture directe', () => { + it('PATCH accepte le surnom, le coupe, et vide = null', async () => { + const app = createApp() + const token = await createUserToken(app) + const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload }) + + expect((await patch({ nickname: ' Loulou ' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: 'Loulou' }) + + expect((await patch({ nickname: ' ' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: null }) + await app.close() + }) + + it('refuse un surnom de plus de 120 caractères', async () => { + const app = createApp() + const token = await createUserToken(app) + const res = await app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload: { nickname: 'x'.repeat(121) } }) + expect(res.statusCode).toBe(400) + await app.close() + }) +}) + +describe('Sexe d’une personne en écriture directe (issue #114)', () => { + it('PATCH ramène le sexe à male / female / other, null = non précisé, refuse une valeur inconnue', async () => { + const app = createApp() + const token = await createUserToken(app) + const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload }) + + expect((await patch({ sex: 'F' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'female' }) + + expect((await patch({ sex: 'male' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' }) + + expect((await patch({ sex: 'other' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'other' }) + + expect((await patch({ sex: null })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: null }) + + const calls = prismaMock.person.updateMany.mock.calls.length + expect((await patch({ sex: 'robot' })).statusCode).toBe(400) + expect(prismaMock.person.updateMany.mock.calls.length).toBe(calls) + await app.close() + }) +}) + +describe('Lieu de décès et filiation adoptive (écriture)', () => { + it('PATCH accepte le lieu de décès, le coupe, et vide = null', async () => { + const app = createApp() + const token = await createUserToken(app) + const patch = (payload: object) => app.inject({ method: 'PATCH', url: '/trees/tree-1/persons/person-1', headers: { authorization: `Bearer ${token}` }, payload }) + + expect((await patch({ deathPlace: ' Nantes, France ' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ deathPlace: 'Nantes, France' }) + + expect((await patch({ deathPlace: ' ' })).statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ deathPlace: null }) + await app.close() + }) + + it('PATCH d’un lien accepte le type de filiation (naissance ou adoption)', async () => { + const app = createApp() + const token = await createUserToken(app) + prismaMock.parentChildLink.findFirst.mockResolvedValue({ id: 'link-1', parentPersonId: 'person-2', childPersonId: 'person-1', viaUnionId: null, parentageType: 'biologique' }) + prismaMock.parentChildLink.updateMany.mockResolvedValueOnce({ count: 1 }) + const res = await app.inject({ method: 'PATCH', url: '/trees/tree-1/links/link-1', headers: { authorization: `Bearer ${token}` }, payload: { parentageType: 'adoption' } }) + expect(res.statusCode).toBe(200) + expect(prismaMock.parentChildLink.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ parentageType: 'adoption' }) + await app.close() + }) +}) diff --git a/backend/test/api-security.integration.test.ts b/backend/test/api-security.integration.test.ts index ebaa8a0..5bd9ac4 100644 --- a/backend/test/api-security.integration.test.ts +++ b/backend/test/api-security.integration.test.ts @@ -290,7 +290,8 @@ describe('API security and authz', () => { expect(response.statusCode).toBe(429) await app.close() - }) + // Onze connexions, chacune avec une vérification bcrypt : plus de 5 s sur les machines lentes de la CI + }, 20_000) it('rate-limits register endpoint', async () => { const app = createApp() diff --git a/backend/test/auth-prenom.test.ts b/backend/test/auth-prenom.test.ts new file mode 100644 index 0000000..f2ff437 --- /dev/null +++ b/backend/test/auth-prenom.test.ts @@ -0,0 +1,119 @@ +import type { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +// Prénom du compte : renvoyé à l'inscription, à la connexion et sur /me. +const { prismaMock } = vi.hoisted(() => ({ + prismaMock: { + user: { + findUnique: vi.fn(), + create: vi.fn(), + count: vi.fn(), + }, + $disconnect: vi.fn(), + }, +})) + +vi.mock('../src/lib/prisma.js', () => ({ + prisma: prismaMock, +})) + +let app: FastifyInstance + +beforeAll(async () => { + process.env.NODE_ENV = 'test' + process.env.PORT = '4000' + process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy' + process.env.JWT_SECRET = 'test-secret-with-sufficient-length' + process.env.JWT_EXPIRES_IN = '7d' + process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h' + process.env.CORS_ORIGIN = 'http://localhost:5173' + const { createApp } = await import('../src/app.js') + app = createApp() +}) + +afterAll(async () => { + await app?.close() +}) + +beforeEach(() => { + vi.clearAllMocks() + prismaMock.user.findUnique.mockResolvedValue(null) + prismaMock.user.count.mockResolvedValue(1) +}) + +describe('prénom du compte', () => { + it("l'inscription enregistre le prénom (sans espaces autour) et le renvoie", async () => { + prismaMock.user.create.mockResolvedValue({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs' }) + + const res = await app.inject({ + method: 'POST', + url: '/auth/register', + payload: { firstName: ' Anaïs ', email: 'anais@example.com', password: 'securepass123' }, + }) + + expect(res.statusCode).toBe(201) + expect(prismaMock.user.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ firstName: 'Anaïs' }), + })) + expect(res.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true }) + }) + + it("l'inscription sans prénom le garde vide, sans erreur", async () => { + prismaMock.user.create.mockResolvedValue({ id: 'user-2', email: 'sans@example.com', firstName: null }) + + const res = await app.inject({ + method: 'POST', + url: '/auth/register', + payload: { email: 'sans@example.com', password: 'securepass123' }, + }) + + expect(res.statusCode).toBe(201) + expect(prismaMock.user.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ firstName: null }), + })) + expect(res.json().user.firstName).toBeNull() + }) + + it('la connexion renvoie le prénom du compte', async () => { + const { hashPassword } = await import('../src/lib/auth.js') + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: 'anais@example.com', + firstName: 'Anaïs', + passwordHash: await hashPassword('securepass123'), + }) + + const res = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email: 'anais@example.com', password: 'securepass123' }, + }) + + expect(res.statusCode).toBe(200) + expect(res.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true }) + }) + + it('/me renvoie le profil du compte connecté, et refuse sans session', async () => { + const { hashPassword } = await import('../src/lib/auth.js') + prismaMock.user.findUnique.mockResolvedValue({ + id: 'user-1', + email: 'anais@example.com', + firstName: 'Anaïs', + passwordHash: await hashPassword('securepass123'), + }) + const login = await app.inject({ + method: 'POST', + url: '/auth/login', + payload: { email: 'anais@example.com', password: 'securepass123' }, + }) + const token = login.json().token + + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', passwordHash: 'hash' }) + const me = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${token}` } }) + expect(me.statusCode).toBe(200) + expect(me.json().user).toEqual({ id: 'user-1', email: 'anais@example.com', firstName: 'Anaïs', hasPassword: true }) + + const anonymous = await app.inject({ method: 'GET', url: '/auth/me' }) + expect(anonymous.statusCode).toBe(401) + }) +}) diff --git a/backend/test/avatar-crop.test.ts b/backend/test/avatar-crop.test.ts new file mode 100644 index 0000000..5a1a2d9 --- /dev/null +++ b/backend/test/avatar-crop.test.ts @@ -0,0 +1,230 @@ +import { randomUUID } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' +import { Prisma } from '@prisma/client' + +import type { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { prismaMock } = vi.hoisted(() => ({ + prismaMock: { + user: { + count: vi.fn(), + }, + person: { + findFirst: vi.fn(), + updateMany: vi.fn(), + }, + treeMembership: { + findUnique: vi.fn(), + }, + auditLog: { + create: vi.fn(), + }, + $transaction: vi.fn(), + $disconnect: vi.fn(), + }, +})) + +vi.mock('../src/lib/prisma.js', () => ({ + prisma: prismaMock, +})) + +let createApp: () => FastifyInstance +let storageRoot: string + +const TREE = 'tree-1' +const PERSON = 'person-1' +const url = `/trees/${TREE}/persons/${PERSON}/avatar/crop` + +async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise { + await app.ready() + return (app as any).jwt.sign( + { kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' }, + { expiresIn: '1h' }, + ) +} + +function asAdmin(role: 'owner' | 'admin' | 'member' = 'owner') { + prismaMock.treeMembership.findUnique.mockResolvedValue({ role, tree: { deletedAt: null } }) +} + +beforeAll(async () => { + process.env.NODE_ENV = 'test' + process.env.PORT = '4000' + process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy' + process.env.JWT_SECRET = 'test-secret-with-sufficient-length' + process.env.JWT_EXPIRES_IN = '7d' + process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h' + process.env.CORS_ORIGIN = 'http://localhost:5173' + + storageRoot = await mkdtemp(path.join(os.tmpdir(), 'parenthese-crop-')) + process.env.MEDIA_STORAGE_PATH = storageRoot + + const module = await import('../src/app.js') + createApp = module.createApp +}) + +afterAll(async () => { + await rm(storageRoot, { recursive: true, force: true }) +}) + +beforeEach(() => { + vi.clearAllMocks() + prismaMock.user.count.mockResolvedValue(1) + prismaMock.person.updateMany.mockResolvedValue({ count: 1 }) + prismaMock.person.findFirst.mockResolvedValue({ avatarPath: `${TREE}/${PERSON}/avatar-a.jpg` }) + asAdmin('owner') +}) + +describe('PUT /trees/:id/persons/:personId/avatar/crop', () => { + it('refuse sans session', async () => { + const app = createApp() + const response = await app.inject({ method: 'PUT', url, payload: { crop: null } }) + expect(response.statusCode).toBe(401) + expect(prismaMock.person.updateMany).not.toHaveBeenCalled() + }) + + it('réserve le cadrage aux administrateurs de l arbre, comme le changement de photo', async () => { + const app = createApp() + const token = await createUserToken(app) + asAdmin('member') + + const response = await app.inject({ + method: 'PUT', + url, + headers: { authorization: `Bearer ${token}` }, + payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } }, + }) + + expect(response.statusCode).toBe(403) + expect(prismaMock.person.updateMany).not.toHaveBeenCalled() + }) + + it('refuse un cadrage hors plage ou mal formé', async () => { + const app = createApp() + const token = await createUserToken(app) + const headers = { authorization: `Bearer ${token}` } + + const tooZoomed = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.5, y: 0.5, zoom: 5 } } }) + const outside = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 1.2, y: 0.5, zoom: 1 } } }) + const missing = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.5 } } }) + const noBody = await app.inject({ method: 'PUT', url, headers, payload: {} }) + + expect(tooZoomed.statusCode).toBe(400) + expect(outside.statusCode).toBe(400) + expect(missing.statusCode).toBe(400) + expect(noBody.statusCode).toBe(400) + expect(prismaMock.person.updateMany).not.toHaveBeenCalled() + }) + + it('enregistre le cadrage arrondi et audite le changement', async () => { + const app = createApp() + const token = await createUserToken(app) + + const response = await app.inject({ + method: 'PUT', + url, + headers: { authorization: `Bearer ${token}` }, + payload: { crop: { x: 0.123456, y: 0.7, zoom: 1.8 } }, + }) + + expect(response.statusCode).toBe(200) + expect(response.json()).toEqual({ ok: true, avatarCrop: { x: 0.1235, y: 0.7, zoom: 1.8 } }) + + const call = prismaMock.person.updateMany.mock.calls[0][0] + expect(call.where).toEqual({ id: PERSON, treeId: TREE, deletedAt: null }) + expect(call.data.avatarCrop).toEqual({ x: 0.1235, y: 0.7, zoom: 1.8 }) + // La photo elle-même n'est jamais touchée + expect(call.data).not.toHaveProperty('avatarPath') + expect(prismaMock.auditLog.create).toHaveBeenCalledWith({ + data: expect.objectContaining({ action: 'person_avatar_cropped', entityId: PERSON, treeId: TREE }), + }) + }) + + it('remet le cadrage par défaut à zéro (null) plutôt que de le stocker', async () => { + const app = createApp() + const token = await createUserToken(app) + + const reset = await app.inject({ + method: 'PUT', + url, + headers: { authorization: `Bearer ${token}` }, + payload: { crop: null }, + }) + expect(reset.statusCode).toBe(200) + expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toBe(Prisma.DbNull) + + prismaMock.person.updateMany.mockClear() + await app.inject({ + method: 'PUT', + url, + headers: { authorization: `Bearer ${token}` }, + payload: { crop: { x: 0.5, y: 0.5, zoom: 1 } }, + }) + expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toBe(Prisma.DbNull) + }) + + it('garde la forme choisie, même avec un cadrage centré, et refuse une forme inconnue', async () => { + const app = createApp() + const token = await createUserToken(app) + const put = (crop: unknown) => app.inject({ method: 'PUT', url, headers: { authorization: `Bearer ${token}` }, payload: { crop } }) + + const saved = await put({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' }) + expect(saved.statusCode).toBe(200) + expect(saved.json()).toEqual({ ok: true, avatarCrop: { x: 0.5, y: 0.5, zoom: 1, shape: 'round' } }) + expect(prismaMock.person.updateMany.mock.calls[0][0].data.avatarCrop).toEqual({ x: 0.5, y: 0.5, zoom: 1, shape: 'round' }) + + expect((await put({ x: 0.5, y: 0.5, zoom: 1, shape: 'hexagone' })).statusCode).toBe(400) + }) + + it('répond 404 sans portrait, et 409 pour un portrait venu d Immich', async () => { + const app = createApp() + const token = await createUserToken(app) + const headers = { authorization: `Bearer ${token}` } + + prismaMock.person.findFirst.mockResolvedValueOnce({ avatarPath: null }) + const noAvatar = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } } }) + expect(noAvatar.statusCode).toBe(404) + + prismaMock.person.findFirst.mockResolvedValueOnce({ avatarPath: `${TREE}/${PERSON}/immich-abc.jpg` }) + const fromImmich = await app.inject({ method: 'PUT', url, headers, payload: { crop: { x: 0.4, y: 0.4, zoom: 2 } } }) + expect(fromImmich.statusCode).toBe(409) + expect(fromImmich.json()).toEqual({ error: 'avatar_from_immich' }) + expect(prismaMock.person.updateMany).not.toHaveBeenCalled() + }) +}) + +describe('DELETE avatar : le cadrage disparaît avec la photo', () => { + it('remet avatarCrop à null en même temps que le portrait', async () => { + const app = createApp() + const token = await createUserToken(app) + + const response = await app.inject({ + method: 'DELETE', + url: `/trees/${TREE}/persons/${PERSON}/avatar`, + headers: { authorization: `Bearer ${token}` }, + }) + + expect(response.statusCode).toBe(200) + const data = prismaMock.person.updateMany.mock.calls[0][0].data + expect(data.avatarPath).toBeNull() + expect(data.avatarCrop).toBe(Prisma.DbNull) + }) +}) + +describe('lecture du cadrage stocké', () => { + it('ignore toute valeur mal formée au lieu de déformer le portrait', async () => { + const { readAvatarCrop, toStoredAvatarCrop } = await import('../src/lib/avatar-crop.js') + + expect(readAvatarCrop(null)).toBeNull() + expect(readAvatarCrop('oui')).toBeNull() + expect(readAvatarCrop({ x: 0.2, y: 0.3 })).toBeNull() + expect(readAvatarCrop({ x: 0.2, y: 0.3, zoom: 9 })).toBeNull() + expect(readAvatarCrop({ x: 0.2, y: 0.3, zoom: 2 })).toEqual({ x: 0.2, y: 0.3, zoom: 2 }) + + expect(toStoredAvatarCrop({ x: 0.5, y: 0.5, zoom: 1 })).toBeNull() + expect(toStoredAvatarCrop(null)).toBeNull() + }) +}) diff --git a/backend/test/data-deletion.test.ts b/backend/test/data-deletion.test.ts index b3ea86c..0919f42 100644 --- a/backend/test/data-deletion.test.ts +++ b/backend/test/data-deletion.test.ts @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import path from 'node:path' import type { FastifyInstance } from 'fastify' -import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' const { prismaMock } = vi.hoisted(() => ({ prismaMock: { @@ -28,6 +28,9 @@ const { prismaMock } = vi.hoisted(() => ({ contributionSession: { updateMany: vi.fn(), }, + treeVisit: { + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + }, $transaction: vi.fn(), $disconnect: vi.fn(), }, @@ -50,6 +53,7 @@ let storageRoot = '' let passwordHash = '' let createApp: () => FastifyInstance let resolveTreeMediaDirectory: (treeId: string, root?: string) => string | null +let env: { PASSWORD_LOGIN: boolean } function applyTestEnv(): void { process.env.NODE_ENV = 'test' @@ -94,6 +98,7 @@ beforeAll(async () => { createApp = (await import('../src/app.js')).createApp resolveTreeMediaDirectory = (await import('../src/lib/tree-purge.js')).resolveTreeMediaDirectory + ;({ env } = await import('../src/config/env.js')) }) afterAll(async () => { @@ -152,6 +157,138 @@ describe('DELETE /auth/me', () => { await app.close() }) + describe("compte sans mot de passe (né d'une connexion OIDC)", () => { + const signedAt = async (app: FastifyInstance, minutesAgo: number) => { + await app.ready() + return (app as any).jwt.sign({ + kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com', + iat: Math.floor(Date.now() / 1000) - minutesAgo * 60, + }, { expiresIn: '1h' }) + } + + beforeEach(() => { + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash: null }) + }) + + it('se supprime avec son email retapé, juste après une connexion', async () => { + const app = createApp() + const response = await app.inject({ + method: 'DELETE', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 2)}` }, + payload: { confirmEmail: ' User@Example.com ' }, + }) + expect(response.statusCode).toBe(200) + expect(prismaMock.user.delete).toHaveBeenCalled() + await app.close() + }) + + it('refuse un autre email', async () => { + const app = createApp() + const response = await app.inject({ + method: 'DELETE', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 2)}` }, + payload: { confirmEmail: 'autre@example.com' }, + }) + expect(response.statusCode).toBe(403) + expect(response.json()).toEqual({ error: 'invalid_password' }) + expect(prismaMock.user.delete).not.toHaveBeenCalled() + await app.close() + }) + + it('refuse une session de plus de 15 minutes, même avec le bon email', async () => { + const app = createApp() + const response = await app.inject({ + method: 'DELETE', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 20)}` }, + payload: { confirmEmail: 'user@example.com' }, + }) + expect(response.statusCode).toBe(403) + expect(response.json()).toEqual({ error: 'recent_sign_in_required' }) + expect(prismaMock.user.delete).not.toHaveBeenCalled() + await app.close() + }) + + it("n'accepte pas un email à la place du mot de passe pour un compte qui en a un", async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash }) + const app = createApp() + const response = await app.inject({ + method: 'DELETE', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 2)}` }, + payload: { confirmEmail: 'user@example.com' }, + }) + expect(response.statusCode).toBe(403) + expect(prismaMock.user.delete).not.toHaveBeenCalled() + await app.close() + }, 15000) + }) + + describe('compte à mot de passe relié à OIDC, connexion par mot de passe fermée (PASSWORD_LOGIN=false)', () => { + const signedAt = async (app: FastifyInstance, minutesAgo: number) => { + await app.ready() + return (app as any).jwt.sign({ + kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'user@example.com', + iat: Math.floor(Date.now() / 1000) - minutesAgo * 60, + }, { expiresIn: '1h' }) + } + + beforeEach(() => { + env.PASSWORD_LOGIN = false + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-1', email: 'user@example.com', passwordHash }) + }) + + afterEach(() => { + env.PASSWORD_LOGIN = true + }) + + it('se supprime avec son email retapé, juste après une connexion (le mot de passe ne peut plus servir)', async () => { + const app = createApp() + const response = await app.inject({ + method: 'DELETE', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 2)}` }, + payload: { confirmEmail: 'user@example.com' }, + }) + expect(response.statusCode).toBe(200) + expect(prismaMock.user.delete).toHaveBeenCalled() + await app.close() + }) + + it('refuse une session de plus de 15 minutes, même avec le bon email', async () => { + const app = createApp() + const response = await app.inject({ + method: 'DELETE', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 20)}` }, + payload: { confirmEmail: 'user@example.com' }, + }) + expect(response.statusCode).toBe(403) + expect(response.json()).toEqual({ error: 'recent_sign_in_required' }) + expect(prismaMock.user.delete).not.toHaveBeenCalled() + await app.close() + }) + + it('refuse un autre email', async () => { + const app = createApp() + const response = await app.inject({ + method: 'DELETE', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 2)}` }, + payload: { confirmEmail: 'autre@example.com' }, + }) + expect(response.statusCode).toBe(403) + expect(prismaMock.user.delete).not.toHaveBeenCalled() + await app.close() + }) + + it('dit à l’écran de demander l’email, pas un mot de passe (hasPassword=false)', async () => { + const app = createApp() + const response = await app.inject({ + method: 'GET', url: '/auth/me', + headers: { authorization: `Bearer ${await signedAt(app, 2)}` }, + }) + expect(response.statusCode).toBe(200) + expect(response.json().user.hasPassword).toBe(false) + await app.close() + }) + }) + it('rejects a wrong password without deleting anything', async () => { const ownedFile = await writeMediaFile('tree-owned', 'person-1/photo.jpg') const app = createApp() @@ -214,6 +351,10 @@ describe('DELETE /auth/me', () => { where: { submittedByUserId: 'user-1' }, data: { submittedByLabel: 'Contributeur' }, }) + // Ses visites d'autres arbres restent comptées, sans son identifiant + const visitUpdate = prismaMock.treeVisit.updateMany.mock.calls[0][0] + expect(visitUpdate.where).toEqual({ visitorKey: 'user:user-1' }) + expect(visitUpdate.data.visitorKey).toMatch(/^[0-9a-f]{32}$/) expect(prismaMock.user.delete).toHaveBeenCalledWith({ where: { id: 'user-1' } }) // Les arbres doivent partir avant l'utilisateur (relation propriétaire en Restrict). diff --git a/backend/test/e2e-journeys.test.ts b/backend/test/e2e-journeys.test.ts index f09bdb6..cbf4a3c 100644 --- a/backend/test/e2e-journeys.test.ts +++ b/backend/test/e2e-journeys.test.ts @@ -278,6 +278,7 @@ describe('Journey 1 — Owner creates account, tree, and adds family', () => { }, }) expect(addSelfRes.statusCode).toBe(201) + expect(prismaMock.person.create.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' }) // Step 4: Add parent prismaMock.person.create.mockResolvedValue({ id: 'person-2', firstName: 'Jean', lastName: 'Martin' }) @@ -734,3 +735,127 @@ describe('Journey 4 — Admin reviews a contribution session', () => { await app.close() }) }) + +describe('Surnom (nickname) proposé par un contributeur', () => { + it('soumis en attente, puis appliqué par le propriétaire avec la valeur nettoyée', async () => { + const app = createApp() + await app.ready() + + const contributorToken = (app as any).jwt.sign({ + kind: 'tree_access', + sub: 'tree:tree-1:contributor', + treeId: 'tree-1', + role: 'contributor', + accessVersion: new Date('2026-02-11T10:00:00.000Z').getTime(), + }) + prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', deletedAt: null, settings: { contributorPolicy: 'pending' } }) + + const submitRes = await app.inject({ + method: 'POST', + url: '/trees/tree-1/contributions/sessions', + headers: { authorization: `Bearer ${contributorToken}` }, + payload: { + title: 'Surnom de Robert', + submittedByLabel: 'Jeanette Martin', + changes: [ + { + entityType: 'person', + action: 'update', + entityId: 'person-1', + before: { nickname: null }, + after: { nickname: ' Loulou ' }, + }, + ], + }, + }) + expect(submitRes.statusCode).toBe(201) + expect(submitRes.json().session.status).toBe('pending') + + const adminToken = (app as any).jwt.sign( + { kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'owner@example.com' }, + { expiresIn: '1h' }, + ) + prismaMock.contributionSession.findFirst.mockResolvedValue({ + id: 'session-n', + treeId: 'tree-1', + status: 'pending', + title: 'Surnom de Robert', + changes: [ + { + id: 'change-n', + entityType: 'person', + action: 'update', + entityId: 'person-1', + beforeJson: { nickname: null }, + afterJson: { nickname: ' Loulou ' }, + conflictState: 'none', + }, + ], + }) + prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', treeId: 'tree-1', firstName: 'Robert', lastName: 'Dupont', nickname: null, deletedAt: null }) + + const reviewRes = await app.inject({ + method: 'POST', + url: '/trees/tree-1/contributions/sessions/session-n/review', + headers: { authorization: `Bearer ${adminToken}` }, + payload: { decision: 'approved' }, + }) + expect(reviewRes.statusCode).toBe(200) + expect(reviewRes.json().approvedCount).toBe(1) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ nickname: 'Loulou' }) + + await app.close() + }) +}) + +describe('Sexe proposé par un contributeur (issue #114)', () => { + it('appliqué par le propriétaire avec la valeur normalisée', async () => { + const app = createApp() + await app.ready() + + const adminToken = (app as any).jwt.sign( + { kind: 'user', sub: 'user-1', jti: randomUUID(), userId: 'user-1', email: 'owner@example.com' }, + { expiresIn: '1h' }, + ) + prismaMock.contributionSession.findFirst.mockResolvedValue({ + id: 'session-s', + treeId: 'tree-1', + status: 'pending', + title: 'Sexe de Camille', + changes: [ + { + id: 'change-s1', + entityType: 'person', + action: 'update', + entityId: 'person-1', + beforeJson: { sex: null }, + afterJson: { sex: 'F' }, + conflictState: 'none', + }, + { + id: 'change-s2', + entityType: 'person', + action: 'create', + entityId: null, + beforeJson: null, + afterJson: { firstName: 'Léon', lastName: 'Martin', sex: 'male' }, + conflictState: 'none', + }, + ], + }) + prismaMock.person.findFirst.mockResolvedValue({ id: 'person-1', treeId: 'tree-1', firstName: 'Camille', lastName: 'Martin', sex: null, deletedAt: null }) + + const reviewRes = await app.inject({ + method: 'POST', + url: '/trees/tree-1/contributions/sessions/session-s/review', + headers: { authorization: `Bearer ${adminToken}` }, + payload: { decision: 'approved' }, + }) + expect(reviewRes.statusCode).toBe(200) + expect(reviewRes.json().approvedCount).toBe(2) + expect(prismaMock.person.updateMany.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'female' }) + expect(prismaMock.person.create.mock.calls.at(-1)[0].data).toMatchObject({ sex: 'male' }) + + await app.close() + }) +}) diff --git a/backend/test/gedcom-media.test.ts b/backend/test/gedcom-media.test.ts index 86923e0..5b1aa26 100644 --- a/backend/test/gedcom-media.test.ts +++ b/backend/test/gedcom-media.test.ts @@ -220,4 +220,13 @@ describe('localMediaPath', () => { expect(localMediaPath('C:\\Photos\\arbre.ged')).toBeNull() expect(localMediaPath('')).toBeNull() }) + + it('fichier piégé : sources en chaîne et niveaux trop profonds, sans débordement ni lenteur', () => { + const chain = Array.from({ length: 20000 }, (_, i) => [`0 @S${i}@ SOUR`, `1 SOUR @S${i + 1}@`]).flat() + const deep = Array.from({ length: 5000 }, (_, i) => `${i + 2} NOTE niveau`) + const text = ['0 @I1@ INDI', '1 NAME A /B/', '1 SOUR @S0@', '1 BIRT', ...deep, ...chain, '0 @S20000@ SOUR', '1 OBJE', '2 FILE C:/fin.jpg'].join('\n') + const started = Date.now() + expect(() => mediaOf(text)).not.toThrow() + expect(Date.now() - started).toBeLessThan(2000) + }) }) diff --git a/backend/test/gedcom-sex.test.ts b/backend/test/gedcom-sex.test.ts new file mode 100644 index 0000000..3943b2f --- /dev/null +++ b/backend/test/gedcom-sex.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest' + +import { parseSex } from '../src/lib/person-sex' +import { gedcomSexValue, parseGedcom } from '../src/routes/gedcom' + +// Export en GEDCOM 5.5.1 : SEX ne vaut que M, F ou U (#118). « X » n'existe qu'en GEDCOM 7. +describe('export GEDCOM 5.5.1 : valeur SEX', () => { + it('écrit M, F, et U pour un sexe « autre »', () => { + expect(gedcomSexValue('male')).toBe('M') + expect(gedcomSexValue('female')).toBe('F') + expect(gedcomSexValue('other')).toBe('U') + }) + + it("n'écrit jamais X, et ne déclare rien pour un sexe non précisé", () => { + for (const sex of ['male', 'female', 'other', null] as const) { + expect(gedcomSexValue(sex)).not.toBe('X') + } + expect(gedcomSexValue(null)).toBeNull() + }) + + it('l’import relit toujours SEX X comme « autre », et U comme non précisé', () => { + const sexes = parseGedcom(['0 @I1@ INDI', '1 SEX X', '0 @I2@ INDI', '1 SEX U'].join('\n')) + .filter((node) => node.tag === 'INDI') + .map((indi) => parseSex(indi.children.find((child) => child.tag === 'SEX')?.value ?? null)) + expect(sexes).toEqual(['other', null]) + }) +}) diff --git a/backend/test/graph-payload.test.ts b/backend/test/graph-payload.test.ts index bb18e36..e534aec 100644 --- a/backend/test/graph-payload.test.ts +++ b/backend/test/graph-payload.test.ts @@ -13,6 +13,7 @@ describe('buildGraphPayload', () => { firstName: 'Zoe', lastName: 'Martin', birthName: 'Durand', + nickname: 'Zozo', birthDate: new Date('1994-06-02T00:00:00.000Z'), deathDate: null, sex: null, @@ -115,6 +116,7 @@ describe('buildGraphPayload', () => { expect(graph.persons[0].id).toBe('p-2') expect(graph.persons[0].birthDate).toBe('1994-06-02') expect(graph.persons[0].birthName).toBe('Durand') + expect(graph.persons[0].nickname).toBe('Zozo') expect(graph.persons[0].avatarUrlPath).toBe('/trees/t-1/persons/p-2/avatar') // L'identifiant Immich ne part jamais dans le graphe : il ne sert qu'aux administrateurs. // Seule l'origine du portrait (fichier préfixé « immich- ») est publiée. diff --git a/backend/test/media-update.test.ts b/backend/test/media-update.test.ts new file mode 100644 index 0000000..9cd7d04 --- /dev/null +++ b/backend/test/media-update.test.ts @@ -0,0 +1,279 @@ +import { randomUUID } from 'node:crypto' +import type { FastifyInstance } from 'fastify' +import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +const { prismaMock } = vi.hoisted(() => ({ + prismaMock: { + user: { + findUnique: vi.fn(), + count: vi.fn().mockResolvedValue(1), + create: vi.fn(), + update: vi.fn(), + }, + tree: { + findFirst: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + updateMany: vi.fn(), + }, + treeMembership: { + count: vi.fn(async () => 0), + findMany: vi.fn(), + findUnique: vi.fn(), + create: vi.fn(), + }, + treeSettings: { + create: vi.fn(), + findUnique: vi.fn(), + upsert: vi.fn(), + }, + treeAccessPasswords: { + findUnique: vi.fn(), + create: vi.fn(), + update: vi.fn(), + }, + treeSharePassword: { + findUnique: vi.fn(), + upsert: vi.fn(), + deleteMany: vi.fn(), + }, + userTreeAccess: { + findMany: vi.fn(), + findUnique: vi.fn(), + upsert: vi.fn(), + updateMany: vi.fn(), deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + person: { + findMany: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + count: vi.fn(), + updateMany: vi.fn(), + }, + union: { + findMany: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + count: vi.fn(), + updateMany: vi.fn(), + }, + parentChildLink: { + findMany: vi.fn(), + findFirst: vi.fn(), + create: vi.fn(), + count: vi.fn(), + updateMany: vi.fn(), + }, + mediaItem: { + count: vi.fn(), aggregate: vi.fn().mockResolvedValue({ _sum: { sizeBytes: 0 } }), + create: vi.fn(), + findMany: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), + updateMany: vi.fn(), + }, + lifeEvent: { + findFirst: vi.fn(), + findMany: vi.fn().mockResolvedValue([]), + createMany: vi.fn().mockResolvedValue({ count: 0 }), + updateMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + annotation: { + findMany: vi.fn(), + }, + contributionSession: { + create: vi.fn(), + findMany: vi.fn(), + findFirst: vi.fn(), + update: vi.fn(), updateMany: vi.fn().mockResolvedValue({ count: 1 }), + count: vi.fn(), + }, + contributionChange: { + create: vi.fn(), + update: vi.fn(), + }, + auditLog: { + create: vi.fn(), + findMany: vi.fn(), + }, + $transaction: vi.fn(), + $disconnect: vi.fn(), + }, +})) + +vi.mock('../src/lib/prisma.js', () => ({ + prisma: prismaMock, +})) + +let createApp: () => FastifyInstance + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, writeFile: vi.fn(async () => undefined), mkdir: vi.fn(async () => undefined) } +}) + +function applyTestEnv(): void { + process.env.NODE_ENV = 'test' + process.env.PORT = '4000' + process.env.DATABASE_URL = 'postgresql://postgres:postgres@localhost:5432/genealogy' + process.env.JWT_SECRET = 'test-secret-with-sufficient-length' + process.env.JWT_EXPIRES_IN = '7d' + process.env.JWT_TREE_ACCESS_EXPIRES_IN = '24h' + process.env.CORS_ORIGIN = 'http://localhost:5173' +} + +async function createUserToken(app: FastifyInstance, userId = 'user-1'): Promise { + await app.ready() + return (app as any).jwt.sign( + { kind: 'user', sub: userId, jti: randomUUID(), userId, email: 'user@example.com' }, + { expiresIn: '1h' }, + ) +} + +const URL_MEDIA = '/trees/tree-1/persons/person-1/media/media-1' + +beforeAll(async () => { + applyTestEnv() + const module = await import('../src/app.js') + createApp = module.createApp +}) + +beforeEach(() => { + vi.clearAllMocks() + prismaMock.user.findUnique.mockResolvedValue(null) + prismaMock.tree.findFirst.mockResolvedValue({ id: 'tree-1', rootPersonId: null }) + prismaMock.treeMembership.findUnique.mockResolvedValue({ + treeId: 'tree-1', + userId: 'user-1', + role: 'owner', + tree: { deletedAt: null, rootPersonId: null }, + }) + prismaMock.mediaItem.findFirst.mockResolvedValue({ id: 'media-1', type: 'photo', filePath: 'tree-1/person-1/media-1.jpg' }) + prismaMock.mediaItem.updateMany.mockResolvedValue({ count: 1 }) + prismaMock.lifeEvent.findFirst.mockResolvedValue({ personId: 'person-1', unionId: null, status: 'approved' }) + prismaMock.auditLog.create.mockResolvedValue({ id: 'audit-1' }) +}) + +describe('PATCH media (modifier un souvenir)', () => { + it('refuses a visitor', async () => { + prismaMock.treeMembership.findUnique.mockResolvedValueOnce({ treeId: 'tree-1', userId: 'user-1', role: 'visitor', tree: { deletedAt: null } }) + const app = createApp() + const token = await createUserToken(app) + const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Titre' } }) + expect(response.statusCode).toBe(403) + expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled() + await app.close() + }) + + it('refuses a contributor', async () => { + prismaMock.treeMembership.findUnique.mockResolvedValueOnce({ treeId: 'tree-1', userId: 'user-1', role: 'contributor', tree: { deletedAt: null } }) + const app = createApp() + const token = await createUserToken(app) + const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Titre' } }) + expect(response.statusCode).toBe(403) + expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled() + await app.close() + }) + + it('accepts an admin and updates title and life event', async () => { + const app = createApp() + const token = await createUserToken(app) + const response = await app.inject({ + method: 'PATCH', + url: URL_MEDIA, + headers: { authorization: `Bearer ${token}` }, + payload: { caption: 'Mariage à Caen', lifeEventId: 'event-1' }, + }) + expect(response.statusCode).toBe(200) + expect(prismaMock.mediaItem.updateMany).toHaveBeenCalledWith(expect.objectContaining({ + where: expect.objectContaining({ id: 'media-1', treeId: 'tree-1', personId: 'person-1' }), + data: { caption: 'Mariage à Caen', lifeEventId: 'event-1' }, + })) + expect(response.json().media).toEqual({ id: 'media-1', caption: 'Mariage à Caen', lifeEventId: 'event-1' }) + await app.close() + }) + + it('clears the life event with null and keeps it when the key is absent', async () => { + const app = createApp() + const token = await createUserToken(app) + await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: null } }) + expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({ caption: 'Photo', lifeEventId: null }) + await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo' } }) + expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({ caption: 'Photo' }) + await app.close() + }) + + it('rejects an empty title and a photo title above 280 characters', async () => { + const app = createApp() + const token = await createUserToken(app) + const empty = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: ' ' } }) + expect(empty.statusCode).toBe(400) + const long = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'a'.repeat(281) } }) + expect(long.statusCode).toBe(400) + expect(long.json().error).toBe('caption_too_long') + expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled() + await app.close() + }) + + it('refuses a life event from another tree or another person', async () => { + prismaMock.lifeEvent.findFirst.mockResolvedValueOnce(null) + const app = createApp() + const token = await createUserToken(app) + const otherTree = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-other-tree' } }) + expect(otherTree.statusCode).toBe(400) + expect(otherTree.json().error).toBe('invalid_life_event_reference') + + prismaMock.lifeEvent.findFirst.mockResolvedValueOnce({ personId: 'person-2', unionId: null, status: 'approved' }) + const otherPerson = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-2' } }) + expect(otherPerson.statusCode).toBe(400) + expect(prismaMock.mediaItem.updateMany).not.toHaveBeenCalled() + await app.close() + }) + + it('accepts a life event of a union the person belongs to', async () => { + prismaMock.lifeEvent.findFirst.mockResolvedValueOnce({ personId: null, unionId: 'union-1', status: 'approved' }) + prismaMock.union.findFirst.mockResolvedValueOnce({ partner1PersonId: 'person-1', partner2PersonId: 'person-9' }) + const app = createApp() + const token = await createUserToken(app) + const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo', lifeEventId: 'event-union' } }) + expect(response.statusCode).toBe(200) + await app.close() + }) + + it('returns 404 for a media that does not belong to the person', async () => { + prismaMock.mediaItem.findFirst.mockResolvedValueOnce(null) + const app = createApp() + const token = await createUserToken(app) + const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'Photo' } }) + expect(response.statusCode).toBe(404) + await app.close() + }) + + it('rewrites the citation file with the same text as caption', async () => { + prismaMock.mediaItem.findFirst.mockResolvedValueOnce({ id: 'media-1', type: 'citation', filePath: 'tree-1/person-1/media-1.txt' }) + const { writeFile } = await import('node:fs/promises') + const app = createApp() + const token = await createUserToken(app) + const text = 'Il aimait la mer, « toujours »' + const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: text } }) + expect(response.statusCode).toBe(200) + const writes = (writeFile as unknown as ReturnType).mock.calls + expect(writes.length).toBe(1) + expect(String(writes[0][0])).toMatch(/media-1\.txt$/) + expect(Buffer.from(writes[0][1] as Buffer).toString('utf8')).toBe(text) + expect(prismaMock.mediaItem.updateMany.mock.calls.at(-1)?.[0].data).toEqual({ + caption: text, + sizeBytes: Buffer.byteLength(text, 'utf8'), + }) + await app.close() + }) + + it('accepts a citation longer than 280 characters', async () => { + prismaMock.mediaItem.findFirst.mockResolvedValueOnce({ id: 'media-1', type: 'citation', filePath: 'tree-1/person-1/media-1.txt' }) + const app = createApp() + const token = await createUserToken(app) + const response = await app.inject({ method: 'PATCH', url: URL_MEDIA, headers: { authorization: `Bearer ${token}` }, payload: { caption: 'b'.repeat(500) } }) + expect(response.statusCode).toBe(200) + await app.close() + }) +}) diff --git a/backend/test/oidc.test.ts b/backend/test/oidc.test.ts new file mode 100644 index 0000000..ca4c795 --- /dev/null +++ b/backend/test/oidc.test.ts @@ -0,0 +1,339 @@ +import type { FastifyInstance } from 'fastify' +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' + +// Connexion OIDC (#115), routes et règles de compte. Le protocole lui-même (PKCE, state, nonce, signature, émetteur, +// audience de l'ID token) est celui d'openid-client, simulé ici : il est testé de bout en bout contre un vrai +// fournisseur à part. Ce fichier vérifie ce que Parenthèse décide avec ce que le fournisseur renvoie. +const { prismaMock, oidcMock } = vi.hoisted(() => ({ + prismaMock: { + user: { + findUnique: vi.fn(), + create: vi.fn(), + count: vi.fn(), + }, + userIdentity: { + findUnique: vi.fn(), + create: vi.fn(), + }, + $transaction: vi.fn(), + $executeRaw: vi.fn(), + $disconnect: vi.fn(), + }, + oidcMock: { + startAuthorization: vi.fn(), + finishAuthorization: vi.fn(), + }, +})) + +vi.mock('../src/lib/prisma.js', () => ({ prisma: prismaMock })) +vi.mock('../src/lib/oidc.js', async (importOriginal) => ({ + ...(await importOriginal()), + ...oidcMock, +})) + +const ISSUER = 'https://id.example.org' +const pending = { state: 'state-1', nonce: 'nonce-1', codeVerifier: 'v'.repeat(43), returnTo: '/' } +const identity = { issuer: ISSUER, subject: 'sub-1', email: 'camille@example.com', emailVerified: true, firstName: 'Camille' } + +let app: FastifyInstance +let env: { ALLOW_REGISTRATION: boolean; PASSWORD_LOGIN: boolean; OIDC_REQUIRE_VERIFIED_EMAIL: boolean } + +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.OIDC_ISSUER = ISSUER + process.env.OIDC_CLIENT_ID = 'parenthese' + process.env.OIDC_CLIENT_SECRET = 'secret' + process.env.OIDC_REDIRECT_URI = 'https://arbre.example.org/auth/oidc/callback' + process.env.OIDC_PROVIDER_NAME = 'Authentik' + ;({ env } = await import('../src/config/env.js')) + const { createApp } = await import('../src/app.js') + app = createApp() +}) + +afterAll(async () => { + for (const key of ['OIDC_ISSUER', 'OIDC_CLIENT_ID', 'OIDC_CLIENT_SECRET', 'OIDC_REDIRECT_URI', 'OIDC_PROVIDER_NAME']) delete process.env[key] + await app?.close() +}) + +beforeEach(() => { + vi.clearAllMocks() + env.ALLOW_REGISTRATION = true + env.PASSWORD_LOGIN = true + env.OIDC_REQUIRE_VERIFIED_EMAIL = true + oidcMock.startAuthorization.mockImplementation(async (returnTo: string) => ({ + url: new URL(`${ISSUER}/authorize?state=state-1`), + pending: { ...pending, returnTo }, + })) + oidcMock.finishAuthorization.mockResolvedValue(identity) + prismaMock.user.count.mockResolvedValue(1) + prismaMock.user.findUnique.mockResolvedValue(null) + prismaMock.userIdentity.findUnique.mockResolvedValue(null) + prismaMock.userIdentity.create.mockResolvedValue({}) + prismaMock.user.create.mockResolvedValue({ id: 'user-new' }) + prismaMock.$transaction.mockImplementation(async (run: (tx: typeof prismaMock) => unknown) => run(prismaMock)) + prismaMock.$executeRaw.mockResolvedValue(1) +}) + +// Chaque connexion simulée vient de sa propre adresse : les routes OIDC sont limitées à 20 appels par minute et +// par adresse, et le fichier en fait plus +let visitor = 0 +const nextAddress = () => `10.0.${Math.floor(++visitor / 250)}.${visitor % 250 + 1}` +let currentAddress = nextAddress() + +// Début de connexion : renvoie le cookie signé que le navigateur rapportera au retour +async function start(returnTo = '/') { + currentAddress = nextAddress() + const res = await app.inject({ method: 'GET', url: `/auth/oidc/start?returnTo=${encodeURIComponent(returnTo)}`, remoteAddress: currentAddress }) + const setCookie = String(res.headers['set-cookie']) + return { res, cookie: setCookie.split(';')[0] } +} + +async function callback(cookie?: string, query = '?code=abc&state=state-1') { + return app.inject({ method: 'GET', url: `/auth/oidc/callback${query}`, headers: cookie ? { cookie } : {}, remoteAddress: currentAddress }) +} + +function fragmentOf(location: string | undefined) { + return new URLSearchParams(String(location).split('#')[1] ?? '') +} + +describe('départ et retour', () => { + it("annonce le fournisseur à l'écran de connexion", async () => { + const res = await app.inject({ method: 'GET', url: '/auth/options' }) + expect(res.json()).toEqual({ registration: true, passwordLogin: true, oidc: { name: 'Authentik' } }) + }) + + it('part chez le fournisseur avec un cookie signé, httpOnly, lax, sécurisé en https', async () => { + const { res } = await start() + expect(res.statusCode).toBe(302) + expect(res.headers.location).toBe(`${ISSUER}/authorize?state=state-1`) + const setCookie = String(res.headers['set-cookie']) + expect(setCookie).toMatch(/^parenthese_oidc=/) + expect(setCookie).toMatch(/HttpOnly/) + expect(setCookie).toMatch(/SameSite=Lax/) + expect(setCookie).toMatch(/Secure/) + }) + + it("n'accepte comme retour que la racine ou un arbre partagé (pas de redirection ouverte)", async () => { + await start('/arbre/famille-exemple') + expect(oidcMock.startAuthorization).toHaveBeenLastCalledWith('/arbre/famille-exemple') + for (const bad of ['https://evil.example', '//evil.example', '/arbre/../../x', '/\\evil.example']) { + await start(bad) + expect(oidcMock.startAuthorization).toHaveBeenLastCalledWith('/') + } + }) + + it('refuse un retour sans connexion commencée dans ce navigateur', async () => { + const res = await callback() + expect(res.statusCode).toBe(302) + expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_failed') + expect(oidcMock.finishAuthorization).not.toHaveBeenCalled() + }) + + it('refuse un cookie modifié', async () => { + const { cookie } = await start() + const forged = cookie.replace(/state-1/, 'state-2').replace(/%22state%22%3A%22state-1/, '%22state%22%3A%22state-2') + const tampered = forged === cookie ? `${cookie}x` : forged + const res = await callback(tampered) + expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_failed') + expect(oidcMock.finishAuthorization).not.toHaveBeenCalled() + }) + + it("passe à openid-client l'état gardé dans le cookie, et refuse quand la vérification échoue", async () => { + const { cookie } = await start() + oidcMock.finishAuthorization.mockRejectedValueOnce(new Error('unexpected ID Token "aud" (audience) claim value')) + const res = await callback(cookie) + expect(oidcMock.finishAuthorization).toHaveBeenCalledWith('?code=abc&state=state-1', expect.objectContaining({ state: 'state-1', nonce: 'nonce-1' })) + expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_failed') + expect(String(res.headers['set-cookie'])).toMatch(/parenthese_oidc=;/) + }) + + it('revient sur l’arbre partagé demandé au départ', async () => { + prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' }) + const { cookie } = await start('/arbre/famille-exemple') + const res = await callback(cookie) + expect(String(res.headers.location)).toMatch(/^https:\/\/arbre\.example\.org\/arbre\/famille-exemple#oidc=/) + }) +}) + +describe('compte relié, créé ou refusé', () => { + it('identité déjà reliée : connexion, même si le fournisseur ne dit plus l’email vérifié', async () => { + prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' }) + oidcMock.finishAuthorization.mockResolvedValue({ ...identity, emailVerified: false, email: null }) + const { cookie } = await start() + const res = await callback(cookie) + expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy() + expect(prismaMock.user.create).not.toHaveBeenCalled() + }) + + it('identité déjà reliée : retrouvée par émetteur et sujet, jamais par email (le fournisseur a pu le réattribuer)', async () => { + prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' }) + oidcMock.finishAuthorization.mockResolvedValue({ ...identity, email: 'reattribue@example.com' }) + const { cookie } = await start() + const res = await callback(cookie) + expect(prismaMock.userIdentity.findUnique).toHaveBeenCalledWith({ + where: { issuer_subject: { issuer: ISSUER, subject: 'sub-1' } }, + select: { userId: true }, + }) + expect(prismaMock.user.findUnique).not.toHaveBeenCalled() + expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy() + }) + + it("nouveau sujet avec un email vérifié déjà relié à un autre sujet : refusé, le compte ne change pas de mains", async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: null, identities: [{ id: 'identity-1' }] }) + oidcMock.finishAuthorization.mockResolvedValue({ ...identity, subject: 'sub-2' }) + const { cookie } = await start() + const res = await callback(cookie) + expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_taken') + expect(prismaMock.userIdentity.create).not.toHaveBeenCalled() + expect(prismaMock.user.create).not.toHaveBeenCalled() + }) + + it("email vérifié d'un compte à mot de passe : refusé, ni relié ni dupliqué (le compte a pu être créé par un tiers)", async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: 'hash' }) + const { cookie } = await start() + const res = await callback(cookie) + expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_taken') + expect(prismaMock.userIdentity.create).not.toHaveBeenCalled() + expect(prismaMock.user.create).not.toHaveBeenCalled() + }) + + it('email vérifié qui a déjà un compte sans mot de passe : relie le compte, ne le duplique pas', async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7', passwordHash: null }) + const { cookie } = await start() + const res = await callback(cookie) + expect(prismaMock.userIdentity.create).toHaveBeenCalledWith({ data: { userId: 'user-7', issuer: ISSUER, subject: 'sub-1' } }) + expect(prismaMock.user.create).not.toHaveBeenCalled() + expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy() + }) + + it("email non vérifié : ni relié ni créé (sinon n'importe qui prendrait le compte de cette adresse)", async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7' }) + oidcMock.finishAuthorization.mockResolvedValue({ ...identity, emailVerified: false }) + const { cookie } = await start() + const res = await callback(cookie) + expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_unverified') + expect(prismaMock.userIdentity.create).not.toHaveBeenCalled() + expect(prismaMock.user.create).not.toHaveBeenCalled() + }) + + it('OIDC_REQUIRE_VERIFIED_EMAIL=false accepte un email non vérifié', async () => { + env.OIDC_REQUIRE_VERIFIED_EMAIL = false + oidcMock.finishAuthorization.mockResolvedValue({ ...identity, emailVerified: false }) + const { cookie } = await start() + const res = await callback(cookie) + expect(prismaMock.user.create).toHaveBeenCalled() + expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy() + }) + + it('OIDC_REQUIRE_VERIFIED_EMAIL=false ne relie jamais un compte existant sur un email non vérifié', async () => { + env.OIDC_REQUIRE_VERIFIED_EMAIL = false + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7' }) + oidcMock.finishAuthorization.mockResolvedValue({ ...identity, emailVerified: false }) + const { cookie } = await start() + const res = await callback(cookie) + expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_unverified') + expect(prismaMock.userIdentity.create).not.toHaveBeenCalled() + }) + + it('sans email du tout : refus clair', async () => { + oidcMock.finishAuthorization.mockResolvedValue({ ...identity, email: null }) + const { cookie } = await start() + const res = await callback(cookie) + expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('oidc_email_missing') + }) + + it('compte neuf sans mot de passe, identité reliée, quand l’instance en accepte', async () => { + const { cookie } = await start() + await callback(cookie) + expect(prismaMock.user.create).toHaveBeenCalledWith(expect.objectContaining({ + data: expect.objectContaining({ + email: 'camille@example.com', + passwordHash: null, + identities: { create: { issuer: ISSUER, subject: 'sub-1' } }, + }), + })) + }) + + it('inscription fermée : le fournisseur ne crée pas de compte (#113)', async () => { + env.ALLOW_REGISTRATION = false + const { cookie } = await start() + const res = await callback(cookie) + expect(fragmentOf(res.headers.location).get('oidc_error')).toBe('registration_closed') + expect(prismaMock.user.create).not.toHaveBeenCalled() + }) + + it('inscription fermée : un compte existant se connecte toujours par le fournisseur', async () => { + env.ALLOW_REGISTRATION = false + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-7' }) + const { cookie } = await start() + const res = await callback(cookie) + expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy() + }) + + it('inscription fermée sur une instance vide : le premier compte naît par le fournisseur', async () => { + env.ALLOW_REGISTRATION = false + prismaMock.user.count.mockResolvedValue(0) + const { cookie } = await start() + const res = await callback(cookie) + expect(prismaMock.$executeRaw).toHaveBeenCalled() + expect(fragmentOf(res.headers.location).get('oidc')).toBeTruthy() + }) +}) + +describe('échange du jeton de passage', () => { + async function handoff() { + prismaMock.userIdentity.findUnique.mockResolvedValue({ userId: 'user-9' }) + const { cookie } = await start() + const res = await callback(cookie) + return fragmentOf(res.headers.location).get('oidc') as string + } + + it('donne une session une seule fois', async () => { + const code = await handoff() + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-9', email: 'camille@example.com', firstName: 'Camille', passwordHash: null }) + const first = await app.inject({ method: 'POST', url: '/auth/oidc/exchange', payload: { code } }) + expect(first.statusCode).toBe(200) + expect(first.json().user).toEqual({ id: 'user-9', email: 'camille@example.com', firstName: 'Camille', hasPassword: false }) + + const second = await app.inject({ method: 'POST', url: '/auth/oidc/exchange', payload: { code } }) + expect(second.statusCode).toBe(401) + }) + + it("le jeton de passage n'ouvre aucune session, et une session n'est pas un jeton de passage", async () => { + const code = await handoff() + const asBearer = await app.inject({ method: 'GET', url: '/auth/me', headers: { authorization: `Bearer ${code}` } }) + expect(asBearer.statusCode).toBe(401) + + const session = app.jwt.sign({ kind: 'user', sub: 'user-9', jti: 'j', userId: 'user-9', email: 'camille@example.com' }) + const asCode = await app.inject({ method: 'POST', url: '/auth/oidc/exchange', payload: { code: session } }) + expect(asCode.statusCode).toBe(401) + }) +}) + +describe('connexion par le fournisseur seulement (PASSWORD_LOGIN=false)', () => { + it('refuse connexion et inscription par mot de passe, et le dit à l’écran', async () => { + env.PASSWORD_LOGIN = false + const login = await app.inject({ method: 'POST', url: '/auth/login', payload: { email: 'a@example.com', password: 'motdepasse1' } }) + const register = await app.inject({ method: 'POST', url: '/auth/register', payload: { email: 'a@example.com', password: 'motdepasse1' } }) + expect(login.statusCode).toBe(403) + expect(login.json()).toEqual({ error: 'password_login_disabled' }) + expect(register.statusCode).toBe(403) + expect((await app.inject({ method: 'GET', url: '/auth/options' })).json().passwordLogin).toBe(false) + }) + + it('un compte sans mot de passe ne se connecte jamais par mot de passe', async () => { + prismaMock.user.findUnique.mockResolvedValue({ id: 'user-9', email: 'camille@example.com', firstName: null, passwordHash: null }) + const res = await app.inject({ method: 'POST', url: '/auth/login', payload: { email: 'camille@example.com', password: 'nimportequoi' } }) + expect(res.statusCode).toBe(401) + }, 15000) +}) + +describe('journaux', () => { + it("le code d'autorisation du retour n'est pas journalisé", async () => { + const { redactUrl } = await import('../src/app.js') + expect(redactUrl('/auth/oidc/callback?code=secret-code&state=s1')).toBe('/auth/oidc/callback?code=[masqué]&state=s1') + }) +}) diff --git a/backend/test/person-names.test.ts b/backend/test/person-names.test.ts new file mode 100644 index 0000000..3d96e2b --- /dev/null +++ b/backend/test/person-names.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest' + +import { hasAnyName } from '../src/lib/person-names' +import { gedcomNameValue } from '../src/routes/gedcom' + +// Nom de famille facultatif : une personne garde au moins un prénom ou un nom +describe('au moins un nom', () => { + it('un prénom ou un nom suffit', () => { + expect(hasAnyName('Léa', '')).toBe(true) + expect(hasAnyName('', 'Lambert')).toBe(true) + expect(hasAnyName(' ', ' Lambert ')).toBe(true) + }) + + it('ni prénom ni nom : refusé', () => { + expect(hasAnyName('', '')).toBe(false) + expect(hasAnyName(' ', ' ')).toBe(false) + }) + + it('export GEDCOM sans nom de famille : « Léa // », valide', () => { + expect(gedcomNameValue('Léa', '')).toBe('Léa //') + }) +}) diff --git a/backend/test/person-sex.test.ts b/backend/test/person-sex.test.ts new file mode 100644 index 0000000..51c27bd --- /dev/null +++ b/backend/test/person-sex.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { parseSex, sexOrNull, sexSchema } from '../src/lib/person-sex' + +// Sexe d'une personne (issue #114) : male, female, other ou null, anciennes valeurs M/F relues +describe('sexe d’une personne', () => { + it('ramène les écritures connues à male / female / null', () => { + expect(parseSex('male')).toBe('male') + expect(parseSex('M')).toBe('male') + expect(parseSex(' homme ')).toBe('male') + expect(parseSex('female')).toBe('female') + expect(parseSex('F')).toBe('female') + expect(parseSex('Femme')).toBe('female') + expect(parseSex(null)).toBeNull() + expect(parseSex('')).toBeNull() + expect(parseSex('U')).toBeNull() + expect(parseSex('other')).toBe('other') + expect(parseSex('X')).toBe('other') + expect(parseSex('Autre')).toBe('other') + expect(parseSex('robot')).toBeUndefined() + }) + + it('une valeur illisible déjà en base se lit « non précisé »', () => { + expect(sexOrNull('M')).toBe('male') + expect(sexOrNull('robot')).toBeNull() + expect(sexOrNull(undefined)).toBeNull() + }) + + it('le schéma d’écriture normalise, garde l’absence et refuse l’inconnu', () => { + expect(sexSchema.parse('F')).toBe('female') + expect(sexSchema.parse(null)).toBeNull() + expect(sexSchema.parse(undefined)).toBeUndefined() + expect(sexSchema.safeParse('robot').success).toBe(false) + }) +}) diff --git a/backend/test/registration-closed.test.ts b/backend/test/registration-closed.test.ts new file mode 100644 index 0000000..299a3d9 --- /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, passwordLogin: true, oidc: null }) + }) + + 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()).toMatchObject({ 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()).toMatchObject({ 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/backend/test/security-hardening.test.ts b/backend/test/security-hardening.test.ts index f3bd286..f5080ad 100644 --- a/backend/test/security-hardening.test.ts +++ b/backend/test/security-hardening.test.ts @@ -49,6 +49,45 @@ describe('fichiers envoyés : type lu dans les octets', () => { }) }) +// Boîte ftyp minimale : taille, « ftyp », marque principale +function ftypBox(brand: string): Buffer { + const box = Buffer.alloc(24) + box.writeUInt32BE(24, 0) + box.write('ftyp', 4, 'latin1') + box.write(brand, 8, 'latin1') + return box +} + +describe('HEIC et AVIF : reconnus, jamais pris pour une vidéo', () => { + it.each(['heic', 'heix', 'mif1', 'msf1', 'heim', 'heis'])('marque %s : image/heic, refusée à l’envoi', (brand) => { + expect(sniffMimeType(ftypBox(brand), 'photo', 'IMG_0001.HEIC')).toBe('image/heic') + expect(accepted(ftypBox(brand), 'photo', 'IMG_0001.HEIC')).toBeNull() + expect(accepted(ftypBox(brand), 'video', 'IMG_0001.MOV')).toBeNull() + }) + + it('marque avif : image/avif, refusée à l’envoi (pas de conversion)', () => { + expect(sniffMimeType(ftypBox('avif'), 'photo')).toBe('image/avif') + expect(accepted(ftypBox('avif'), 'photo')).toBeNull() + }) + + it('marque hevc : image/heic quand l’envoi se déclare photo, vidéo MP4 sinon (une vidéo HEVC reste acceptée)', () => { + expect(sniffMimeType(ftypBox('hevc'), 'photo')).toBe('image/heic') + expect(accepted(ftypBox('hevc'), 'video')).toBe('video/mp4') + }) + + it('les vidéos MP4 et QuickTime ne changent pas', () => { + expect(accepted(ftypBox('isom'), 'video')).toBe('video/mp4') + expect(accepted(ftypBox('mp42'), 'video')).toBe('video/mp4') + expect(accepted(ftypBox('qt '), 'video')).toBe('video/quicktime') + expect(accepted(ftypBox('M4A '), 'audio')).toBe('audio/mp4') + }) + + it('une image HEIC n’entre dans aucun type de média autorisé', () => { + expect(isMimeAllowedForType('photo', 'image/heic')).toBe(false) + expect(isMimeAllowedForType('photo', 'image/avif')).toBe(false) + }) +}) + describe('photos : métadonnées retirées, rotation appliquée', () => { it('retire le GPS et tourne les pixels selon l orientation du téléphone', async () => { const withGps = await sharp({ create: { width: 64, height: 48, channels: 3, background: { r: 200, g: 120, b: 60 } } }) diff --git a/backend/test/tree-visits.test.ts b/backend/test/tree-visits.test.ts new file mode 100644 index 0000000..1a800cb --- /dev/null +++ b/backend/test/tree-visits.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it, vi } from 'vitest' + +import { recordTreeVisit, VISIT_RETENTION_DAYS } from '../src/lib/tree-visits' + +function fakeApp() { + return { + prisma: { + treeVisit: { + findFirst: vi.fn().mockResolvedValue(null), + create: vi.fn().mockResolvedValue({ id: 'v' }), + update: vi.fn(), + deleteMany: vi.fn().mockResolvedValue({ count: 0 }), + }, + }, + } +} + +const request = { headers: { 'user-agent': 'Mozilla/5.0 (iPhone)' }, ip: '203.0.113.10', log: { warn: vi.fn() } } + +describe('journal des visites', () => { + it('efface les visites de plus d’un an à l’enregistrement, au plus une fois par heure', async () => { + const app = fakeApp() + const before = Date.now() + await recordTreeVisit(app as never, request as never, 'tree-1', 'share', null) + await recordTreeVisit(app as never, request as never, 'tree-2', 'share', null) + + expect(app.prisma.treeVisit.create).toHaveBeenCalledTimes(2) + expect(app.prisma.treeVisit.deleteMany).toHaveBeenCalledTimes(1) + const cutoff = app.prisma.treeVisit.deleteMany.mock.calls[0][0].where.createdAt.lt.getTime() + // Tous les arbres, pas seulement celui qu'on ouvre : un arbre qu'on ne rouvre plus est purgé aussi + expect(app.prisma.treeVisit.deleteMany.mock.calls[0][0].where).not.toHaveProperty('treeId') + expect(before - cutoff).toBeGreaterThanOrEqual(VISIT_RETENTION_DAYS * 24 * 60 * 60 * 1000 - 1000) + }) +}) diff --git a/backend/test/unknown-first-name.test.ts b/backend/test/unknown-first-name.test.ts new file mode 100644 index 0000000..730b531 --- /dev/null +++ b/backend/test/unknown-first-name.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest' + +import { gedcomNameValue, parseGedcomName } from '../src/routes/gedcom' + +// Prénom inconnu : enregistré vide. L'export écrit « /Nom/ », l'import relit un prénom vide (et non « ? »). +describe('prénom inconnu, GEDCOM', () => { + it('export : sans prénom, la valeur NAME commence par le nom', () => { + expect(gedcomNameValue('', 'Dupont')).toBe('/Dupont/') + expect(gedcomNameValue('Marie', 'Dupont')).toBe('Marie /Dupont/') + expect(gedcomNameValue('Marie', 'Du/pont')).toBe('Marie /Dupont/') + }) + + it('import : un NAME sans prénom donne un prénom vide', () => { + expect(parseGedcomName('/Dupont/')).toEqual({ firstName: '', lastName: 'Dupont' }) + expect(parseGedcomName('Marie /Dupont/')).toEqual({ firstName: 'Marie', lastName: 'Dupont' }) + }) + + it('aller-retour export puis import garde le prénom vide', () => { + expect(parseGedcomName(gedcomNameValue('', 'Martin'))).toEqual({ firstName: '', lastName: 'Martin' }) + }) +}) diff --git a/deploy/aio/entrypoint.sh b/deploy/aio/entrypoint.sh index edf3efe..4025ec7 100755 --- a/deploy/aio/entrypoint.sh +++ b/deploy/aio/entrypoint.sh @@ -78,6 +78,22 @@ stop_all() { trap 'stop_all; exit 0' TERM INT cd /app +# Migration en attente sur une base existante : copie de la base dans /data/backups avant de l'appliquer +# (une migration ne se défait pas). Les 5 dernières sont gardées. Si la copie échoue, le conteneur s'arrête. +if [ "$FIRST_START" = 0 ] && ! runuser -u node -- env PATH="$PATH" prisma migrate status >/dev/null 2>&1; then + mkdir -p "$DATA_DIR/backups" + chmod 700 "$DATA_DIR/backups" + dump="$DATA_DIR/backups/parenthese-avant-migration-$(date -u +%Y%m%dT%H%M%SZ).dump" + log "migration en attente : sauvegarde de la base dans $dump" + if ! (umask 077 && runuser -u postgres -- pg_dump -Fc "$DB_NAME" > "$dump"); then + rm -f "$dump" + log "la sauvegarde a échoué, migration annulée (place sur le disque ?)" + stop_all + exit 1 + fi + ls -1t "$DATA_DIR"/backups/parenthese-avant-migration-*.dump | tail -n +6 | xargs -r rm -f +fi + log "migrations" runuser -u node -- env PATH="$PATH" prisma migrate deploy diff --git a/deploy/backup.sh b/deploy/backup.sh index d852bf8..9b29689 100644 --- a/deploy/backup.sh +++ b/deploy/backup.sh @@ -98,12 +98,9 @@ echo "[3/5] Generate checksums..." ( cd "$BACKUP_ROOT" sha256sum "db/$(basename "$DB_BACKUP_FILE")" - if [ "$MEDIA_IS_NEW" = "1" ]; then - sha256sum "media/$(basename "$MEDIA_BACKUP_FILE")" - else - # empreinte déjà calculée le jour où l'archive a été faite - grep -h " media/$(basename "$MEDIA_BACKUP_FILE")" "$META_DIR"/genealogy-*.sha256 2>/dev/null | head -1 || true - fi + # Archive du jour ou archive gardée : son empreinte est recalculée, celle du jour où elle a été faite + # disparaît avec la rotation des fichiers d'empreintes (14 jours) alors que l'archive reste + sha256sum "media/$(basename "$MEDIA_BACKUP_FILE")" ) > "$CHECKSUM_FILE" echo "[4/5] Optional offsite upload..." diff --git a/frontend/src/App.css b/frontend/src/App.css index feb77a8..5ce57fb 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -544,31 +544,28 @@ left: 20px; z-index: 1200; max-width: 340px; - padding: 12px 14px; + padding: 18px 20px 20px; display: grid; - gap: 8px; - border-radius: 14px; - border: 1px solid rgba(42, 38, 34, 0.14); - background: rgba(242, 239, 234, 0.95); - box-shadow: 0 6px 24px rgba(52, 48, 44, 0.18); - color: #2a2622; - font-size: 13px; - line-height: 1.45; + gap: 10px; + border-radius: var(--pz-r-l); + border: 1px solid var(--pz-line); + background: var(--pz-surface); + box-shadow: var(--pz-shadow); + color: var(--pz-ink-2); + font: 400 14px/1.5 var(--pz-sans); } .tree-created-prompt strong { - color: #2A2622; - font-size: 14px; + color: var(--pz-ink); + font: 400 21px/1.15 var(--pz-serif); + letter-spacing: -.01em; } -.tree-created-prompt button { +.tree-created-prompt .pz-btn { justify-self: start; - border: 1px solid #efc7b8; - border-radius: 8px; - background: #ffffff; - color: #2a2622; - padding: 6px 10px; - cursor: pointer; + min-height: 38px; + padding: 0 18px; + font-size: 14px; } .settings-card { diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0911cf5..902ecf5 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -4,11 +4,13 @@ import Galaxy from './components/Galaxy' import PersonCard from './components/PersonCard.jsx' import { FamilyWelcome, SouvenirPicker } from './components/FamilyOnboarding.jsx' import { HELP_URL } from './utils/siteLinks.js' +import { firstNameLabel } from './utils/personName.js' import { hasSeenFamilyWelcome, markFamilyWelcomeSeen } from './utils/familyWelcome' import MediaViewer from './components/MediaViewer' import MediaManagerPanel from './components/MediaManagerPanel' import AdminPanel from './components/AdminPanel' import AccessGate from './components/AccessGate' +import EntryChoice from './components/EntryChoice' import AccountDashboard from './components/AccountDashboard' import AccountPanel from './components/AccountPanel' import TreeCreationWizard from './components/TreeCreationWizard' @@ -25,6 +27,7 @@ import { ExpandableSearch } from './components/search' import { t, dateLocale } from './i18n/index.js' import { createParentChildLink, + updateParentChildLink, deleteParentChildLink, deletePerson, deleteUnion, @@ -41,13 +44,15 @@ import { fetchSharePassword, rememberSharePassword, resolveTreeBySlug, + saveAvatarCrop, uploadPersonAvatar, updatePerson, uploadAnnotationPhoto, reorderSiblings, uploadPersonMedia, } from './api/treeApi' -import { getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js' +import { fetchImmichConfig, getImmichErrorMessage, unassignImmichPerson } from './api/immichApi.js' +import { fetchAuthOptions, oidcStartUrl } 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' @@ -226,7 +231,9 @@ function buildContributionChangesFromDraft(draft = {}) { const addedPersons = Array.isArray(draft.addedPersons) ? draft.addedPersons : [] for (const person of addedPersons) { if (!person || typeof person !== 'object') continue - if (!person.firstName || !person.lastName) continue + // Prénom inconnu (chaîne vide) : la personne part quand même, nom de famille obligatoire comme avant + // Nom de famille facultatif : seule une personne sans aucun nom est ignorée + if (!person.firstName && !person.lastName) continue // Identifiant provisoire : le serveur le remplace par le vrai id pour créer les liens const ref = typeof person.ref === 'string' && person.ref.startsWith('tmp:') ? person.ref : null @@ -239,6 +246,8 @@ function buildContributionChangesFromDraft(draft = {}) { firstName: String(person.firstName).trim(), lastName: String(person.lastName).trim(), birthName: person.birthName ? String(person.birthName).trim() : null, + nickname: person.nickname ? String(person.nickname).trim() : null, + sex: person.sex || null, birthDate: person.birthDate || null, deathDate: person.deathDate || null, isDeceased: !person.deathDate && Boolean(person.isDeceased), @@ -253,9 +262,9 @@ function buildContributionChangesFromDraft(draft = {}) { const relatedId = String(rel.personId) if (rel.type === 'child') { linkedParentIds.add(relatedId) - changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: 'biologique' } }) + changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: relatedId, childPersonId: ref, parentageType: rel.adopted ? 'adoption' : 'biologique' } }) } else if (rel.type === 'parent') { - changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: 'biologique' } }) + changes.push({ entityType: 'parent_child_link', action: 'create', after: { parentPersonId: ref, childPersonId: relatedId, parentageType: rel.adopted ? 'adoption' : 'biologique' } }) } else if (rel.type === 'spouse') { changes.push({ entityType: 'union', action: 'create', after: { partner1PersonId: relatedId, partner2PersonId: ref, unionType: 'mariage' } }) } @@ -272,6 +281,16 @@ function buildContributionChangesFromDraft(draft = {}) { } } + // Lien avec ses parents basculé (naissance / adoption) sur un lien existant + const parentageChanges = draft.parentageChanges && typeof draft.parentageChanges === 'object' ? draft.parentageChanges : {} + for (const [childId, parentageType] of Object.entries(parentageChanges)) { + for (const filiation of filiations) { + if (String(filiation.childId) !== String(childId) || String(filiation.id).startsWith('local-')) continue + if ((filiation.parentageType || 'biologique') === parentageType) continue + changes.push({ entityType: 'parent_child_link', action: 'update', entityId: String(filiation.id), after: { parentageType } }) + } + } + const modifiedPersons = draft.modifiedPersons && typeof draft.modifiedPersons === 'object' ? draft.modifiedPersons : {} @@ -283,9 +302,12 @@ function buildContributionChangesFromDraft(draft = {}) { if (Object.prototype.hasOwnProperty.call(source, 'firstName')) after.firstName = source.firstName || '' if (Object.prototype.hasOwnProperty.call(source, 'lastName')) after.lastName = source.lastName || '' if (Object.prototype.hasOwnProperty.call(source, 'birthName')) after.birthName = (source.birthName || '').trim() || null + if (Object.prototype.hasOwnProperty.call(source, 'nickname')) after.nickname = (source.nickname || '').trim() || null + if (Object.prototype.hasOwnProperty.call(source, 'sex')) after.sex = source.sex || null if (Object.prototype.hasOwnProperty.call(source, 'birthYear')) after.birthDate = yearToIsoDate(source.birthYear) if (Object.prototype.hasOwnProperty.call(source, 'deathYear')) after.deathDate = yearToIsoDate(source.deathYear) if (Object.prototype.hasOwnProperty.call(source, 'birthPlace')) after.birthPlace = (source.birthPlace || '').trim() || null + if (Object.prototype.hasOwnProperty.call(source, 'deathPlace')) after.deathPlace = (source.deathPlace || '').trim() || null if (Object.prototype.hasOwnProperty.call(source, 'profession')) after.profession = (source.profession || '').trim() || null if (Object.prototype.hasOwnProperty.call(source, 'region')) after.region = (source.region || '').trim() || null if (Object.prototype.hasOwnProperty.call(source, 'nationality')) after.nationality = (source.nationality || '').trim() || null @@ -452,6 +474,19 @@ function buildContribChangesList(draft = {}) { return items } + +// Retour du fournisseur OIDC : #oidc= ou #oidc_error=. Lu une seule fois au chargement de +// la page puis effacé de l'adresse (historique, partage d'écran). +function readOidcReturn() { + if (typeof window === 'undefined') return null + const params = new URLSearchParams(window.location.hash.slice(1)) + if (!params.has('oidc') && !params.has('oidc_error')) return null + window.history.replaceState(null, '', `${window.location.pathname}${window.location.search}`) + return { code: params.get('oidc'), error: params.get('oidc_error') } +} +const OIDC_RETURN = readOidcReturn() +let oidcSignIn = null + function App() { const auth = useAuth() const tree = useTreeAccess() @@ -489,6 +524,9 @@ function App() { }) const [adminPanelVisible, setAdminPanelVisible] = useState(false) + // Grandes belles-familles dans des onglets (réglage de ce navigateur, voir Paramètres de l'arbre) + const [familyTabs, setFamilyTabsState] = useState(() => { try { return localStorage.getItem('parenthese.familyTabs') !== 'off' } catch { return true } }) + const setFamilyTabs = useCallback((on) => { setFamilyTabsState(on); try { localStorage.setItem('parenthese.familyTabs', on ? 'on' : 'off') } catch { /* navigateur sans stockage */ } }, []) // Premiers visages : fenêtre ouverte après un import GEDCOM, ou depuis les paramètres de l'arbre const [photoStart, setPhotoStart] = useState({ visible: false, personsImported: null, refs: [], mediaRefs: [], faces: [], bundledFiles: [] }) const photoStartDirtyRef = useRef(false) @@ -507,6 +545,10 @@ function App() { }) const [treeNotFound, setTreeNotFound] = useState(false) const [accountEntryMode, setAccountEntryMode] = useState('login') + // Options de l'instance. Inscription fermée (ALLOW_REGISTRATION=false, #113) ou mot de passe désactivé + // (PASSWORD_LOGIN=false, #115) : plus de « Créer un compte » nulle part. oidc : le bouton du fournisseur. + const [authOptions, setAuthOptions] = useState({ registration: true, passwordLogin: true, oidc: null }) + const registrationOpen = authOptions.registration !== false && authOptions.passwordLogin !== false const [treeWizardVisible, setTreeWizardVisible] = useState(false) const [treeWizardLoading, setTreeWizardLoading] = useState(false) const [treeWelcomeVisible, setTreeWelcomeVisible] = useState(false) @@ -545,6 +587,10 @@ function App() { const [familyWelcomeVisible, setFamilyWelcomeVisible] = useState(false) const [souvenirPickerVisible, setSouvenirPickerVisible] = useState(false) const [pendingMediaPersonId, setPendingMediaPersonId] = useState(null) + // Message bref (toast) : enregistrement automatique d'une fiche, erreurs d'ordre des fratries + const [draftNotice, setDraftNotice] = useState('') + // Fiche en modification : elle enregistre ses changements avant qu'on la quitte (PersonCard, saveOnLeaveRef) + const personSaveOnLeaveRef = useRef(null) useEffect(() => { if (isFamilyVisitor && tree.bootState === 'ready' && !hasSeenFamilyWelcome(tree.treeContext.treeId)) { @@ -793,25 +839,55 @@ function App() { updateDraft: editMode.updateDraft, }) - const handlePersonSelect = useCallback((personLike) => { + // Quitter la fiche (changer de personne, la fermer, sortir du mode modification, envoyer, ouvrir un + // souvenir) : ses changements sont enregistrés d'abord. Faux si ça échoue : la fiche reste ouverte + // avec le message d'erreur, la saisie n'est pas jetée. En démo rien n'est persisté : on ne sauvegarde pas. + const leaveSelectedPerson = useCallback(async () => { + const current = tree.selectedPerson + const saveOnLeave = personSaveOnLeaveRef.current + if (!current || !saveOnLeave || isDemoMode) return true + const outcome = await saveOnLeave() + if (outcome === 'saved') setDraftNotice(t('Modifications de {name} enregistrées', { name: firstNameLabel(current) })) + return outcome !== 'blocked' + }, [tree.selectedPerson, isDemoMode]) + + const chooseSelectedPerson = useCallback(async (next) => { + if (!(await leaveSelectedPerson())) return false + tree.setSelectedPerson(next) + return true + }, [leaveSelectedPerson, tree.setSelectedPerson]) + + const deactivateEditMode = useCallback(async () => { + if (!(await leaveSelectedPerson())) return + editMode.deactivate() + }, [leaveSelectedPerson, editMode.deactivate]) + + const handlePersonSelect = useCallback(async (personLike) => { const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike if (personId === null || personId === undefined) { - tree.setSelectedPerson(null) + await chooseSelectedPerson(null) return } const freshPerson = persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null + if (!(await leaveSelectedPerson())) return + + // Cliquer une personne pendant un ajout ferme le panneau d'ajout (il passe au-dessus de la fiche) ; + // sa saisie reste en mémoire et reprend quand on rouvre « Ajouter » + setAddPersonPanelVisible(false) + tree.setSelectedPerson(freshPerson) trackTreeInteraction('text') trackDemoInteraction('person') - }, [tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps + }, [chooseSelectedPerson, leaveSelectedPerson, tree.setSelectedPerson, trackTreeInteraction, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps - const handleMediaSelectFresh = useCallback((media, personLike) => { + const handleMediaSelectFresh = useCallback(async (media, personLike) => { const personId = personLike && typeof personLike === 'object' ? personLike.id : personLike const freshPerson = personId !== null && personId !== undefined ? persons.find((candidate) => String(candidate.id) === String(personId)) || personLike || null : personLike || null + if (!(await leaveSelectedPerson())) return tree.handleMediaSelect(media, freshPerson) trackTreeInteraction(toInteractionMediaType(media?.type)) trackDemoInteraction('media') @@ -873,22 +949,26 @@ function App() { const handleEditToggle = useCallback(() => { if (editMode.isActive && editMode.hasDraft) { setShowConfirmModal(true) + } else if (editMode.isActive) { + deactivateEditMode() } else { - if (!editMode.isActive) trackDemoInteraction('edit') + trackDemoInteraction('edit') editMode.toggle() } - }, [editMode.isActive, editMode.hasDraft, editMode.toggle, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps + }, [editMode.isActive, editMode.hasDraft, editMode.toggle, deactivateEditMode, trackDemoInteraction]) // eslint-disable-line react-hooks/exhaustive-deps // Actions de la modal de confirmation const handleConfirmContinue = useCallback(() => { setShowConfirmModal(false) }, []) - const handleConfirmSaveAndQuit = useCallback(() => { + const handleConfirmSaveAndQuit = useCallback(async () => { + const left = await leaveSelectedPerson() + setShowConfirmModal(false) + if (!left) return editMode.saveDraft() editMode.deactivate() - setShowConfirmModal(false) - }, [editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps + }, [leaveSelectedPerson, editMode.saveDraft, editMode.deactivate]) // eslint-disable-line react-hooks/exhaustive-deps const handleConfirmDiscard = useCallback(() => { editMode.clearDraft() @@ -944,11 +1024,11 @@ function App() { setSouvenirPickerVisible(true) }, [tree.treeContext.treeId]) - const handleSouvenirPick = useCallback((person) => { + const handleSouvenirPick = useCallback(async (person) => { + if (!(await chooseSelectedPerson(person))) return setSouvenirPickerVisible(false) - tree.setSelectedPerson(person) setPendingMediaPersonId(person.id) - }, [tree.setSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps + }, [chooseSelectedPerson]) // eslint-disable-line react-hooks/exhaustive-deps const handleSouvenirPickerAddPerson = useCallback(() => { setSouvenirPickerVisible(false) @@ -962,6 +1042,8 @@ function App() { const handleAddPersonSubmit = useCallback(async (formData) => { if (!tree.treeContext.treeId && !isDemoMode) return + // La fiche ouverte est enregistrée avant que la nouvelle personne ne la remplace + if (!(await leaveSelectedPerson())) return setAddPersonLoading(true) setAddPersonError('') @@ -996,6 +1078,8 @@ function App() { firstName: formData.firstName, lastName: formData.lastName || '', birthName: formData.birthName || null, + nickname: formData.nickname || null, + sex: formData.sex || null, birthDate: formData.birthDate || null, deathDate: formData.deathDate || null, birthYear, @@ -1030,7 +1114,7 @@ function App() { childId: rel.personId, parentId: personId, unionId: null, - parentageType: 'biologique', + parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: filiations.length + 1, }) } else if (rel.type === 'child') { @@ -1039,7 +1123,7 @@ function App() { childId: personId, parentId: rel.personId, unionId: null, - parentageType: 'biologique', + parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: filiations.length + 1, }) } @@ -1075,10 +1159,12 @@ function App() { firstName: formData.firstName, lastName: formData.lastName, birthName: formData.birthName || null, + nickname: formData.nickname || null, + sex: formData.sex || null, birthDate: formData.birthDate || null, deathDate: formData.deathDate || null, isDeceased: Boolean(formData.isDeceased), - relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId })), + relations: (formData.relations || []).map((rel) => ({ type: rel.type, personId: rel.personId, adopted: Boolean(rel.adopted) })), siblingParentLinks, }, ] @@ -1086,7 +1172,7 @@ function App() { // La personne n'apparaît dans l'arbre qu'après envoi : on passe tout de suite à l'envoi // (prénom, un mot), la famille n'a pas à chercher le bouton setAddPersonPanelVisible(false) - setDraftNotice(t('{name} est dans vos modifications. Envoyez-les quand vous avez fini.', { name: formData.firstName })) + setDraftNotice(t('{name} est dans vos modifications. Envoyez-les quand vous avez fini.', { name: [formData.firstName, formData.lastName].filter(Boolean).join(' ') || t('Nouvelle personne') })) setContribSubmitError('') setContribChanges(buildContribChangesList(editMode.restoreDraft())) setShowContribModal(true) @@ -1101,6 +1187,8 @@ function App() { firstName: formData.firstName, lastName: formData.lastName, birthName: formData.birthName, + nickname: formData.nickname, + sex: formData.sex || null, birthDate: formData.birthDate, deathDate: formData.deathDate, isDeceased: Boolean(formData.isDeceased), @@ -1115,13 +1203,13 @@ function App() { linkedParentIds.add(String(rel.personId)) await createParentChildLink( tree.treeContext.treeId, - { parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: 'biologique', displayOrder: 1 }, + { parentPersonId: rel.personId, childPersonId: newPerson.id, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 }, auth.userAuth.token ) } else if (rel.type === 'parent') { await createParentChildLink( tree.treeContext.treeId, - { parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: 'biologique', displayOrder: 1 }, + { parentPersonId: newPerson.id, childPersonId: rel.personId, parentageType: rel.adopted ? 'adoption' : 'biologique', displayOrder: 1 }, auth.userAuth.token ) } else if (rel.type === 'spouse') { @@ -1168,7 +1256,6 @@ function App() { const [contribSubmitLoading, setContribSubmitLoading] = useState(false) const [contribSubmitError, setContribSubmitError] = useState('') const [contribSuccess, setContribSuccess] = useState(false) - const [draftNotice, setDraftNotice] = useState('') // Ordre d'une fratrie (glisser dans l'arbre ou flèches de la fiche) : appliqué tout de suite, enregistré ensuite. // L'arbre a déjà glissé à la nouvelle place ; en cas d'échec il revient en arrière et on le dit. @@ -1208,6 +1295,8 @@ function App() { // Soumission des modifications const handleEditSubmit = useCallback(async () => { + // Les changements de la fiche ouverte doivent être dans le brouillon avant l'envoi + if (!(await leaveSelectedPerson())) return if (isDemoMode) { // Démo: édition locale uniquement, jamais de persistance serveur. editMode.saveDraft() @@ -1230,9 +1319,12 @@ function App() { if (Object.prototype.hasOwnProperty.call(rawChanges, 'firstName')) payload.firstName = rawChanges.firstName || '' if (Object.prototype.hasOwnProperty.call(rawChanges, 'lastName')) payload.lastName = rawChanges.lastName || '' if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthName')) payload.birthName = (rawChanges.birthName || '').trim() || null + if (Object.prototype.hasOwnProperty.call(rawChanges, 'nickname')) payload.nickname = (rawChanges.nickname || '').trim() || null + if (Object.prototype.hasOwnProperty.call(rawChanges, 'sex')) payload.sex = rawChanges.sex || null if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthYear')) payload.birthDate = yearToIsoDate(rawChanges.birthYear) if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathYear')) payload.deathDate = yearToIsoDate(rawChanges.deathYear) if (Object.prototype.hasOwnProperty.call(rawChanges, 'birthPlace')) payload.birthPlace = (rawChanges.birthPlace || '').trim() || null + if (Object.prototype.hasOwnProperty.call(rawChanges, 'deathPlace')) payload.deathPlace = (rawChanges.deathPlace || '').trim() || null if (Object.prototype.hasOwnProperty.call(rawChanges, 'profession')) payload.profession = (rawChanges.profession || '').trim() || null if (Object.prototype.hasOwnProperty.call(rawChanges, 'region')) payload.region = (rawChanges.region || '').trim() || null if (Object.prototype.hasOwnProperty.call(rawChanges, 'nationality')) payload.nationality = (rawChanges.nationality || '').trim() || null @@ -1504,6 +1596,8 @@ function App() { if (Object.prototype.hasOwnProperty.call(changes, 'firstName')) localPerson.firstName = changes.firstName || '' if (Object.prototype.hasOwnProperty.call(changes, 'lastName')) localPerson.lastName = changes.lastName || '' if (Object.prototype.hasOwnProperty.call(changes, 'birthName')) localPerson.birthName = changes.birthName || null + if (Object.prototype.hasOwnProperty.call(changes, 'nickname')) localPerson.nickname = changes.nickname || null + if (Object.prototype.hasOwnProperty.call(changes, 'sex')) localPerson.sex = changes.sex || null if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) { const iso = yearToIsoDate(changes.birthYear) localPerson.birthDate = iso @@ -1515,6 +1609,7 @@ function App() { localPerson.deathYear = iso ? Number(iso.slice(0, 4)) : null } if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) localPerson.birthPlace = changes.birthPlace || null + if (Object.prototype.hasOwnProperty.call(changes, 'deathPlace')) localPerson.deathPlace = changes.deathPlace || null if (Object.prototype.hasOwnProperty.call(changes, 'profession')) localPerson.profession = changes.profession || null if (Object.prototype.hasOwnProperty.call(changes, 'region')) localPerson.region = changes.region || null if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) localPerson.nationality = changes.nationality || null @@ -1540,9 +1635,12 @@ function App() { if (Object.prototype.hasOwnProperty.call(changes, 'firstName')) payload.firstName = changes.firstName || '' if (Object.prototype.hasOwnProperty.call(changes, 'lastName')) payload.lastName = changes.lastName || '' if (Object.prototype.hasOwnProperty.call(changes, 'birthName')) payload.birthName = (changes.birthName || '').trim() || null + if (Object.prototype.hasOwnProperty.call(changes, 'nickname')) payload.nickname = (changes.nickname || '').trim() || null + if (Object.prototype.hasOwnProperty.call(changes, 'sex')) payload.sex = changes.sex || null if (Object.prototype.hasOwnProperty.call(changes, 'birthYear')) payload.birthDate = yearToIsoDate(changes.birthYear) if (Object.prototype.hasOwnProperty.call(changes, 'deathYear')) payload.deathDate = yearToIsoDate(changes.deathYear) if (Object.prototype.hasOwnProperty.call(changes, 'birthPlace')) payload.birthPlace = (changes.birthPlace || '').trim() || null + if (Object.prototype.hasOwnProperty.call(changes, 'deathPlace')) payload.deathPlace = (changes.deathPlace || '').trim() || null if (Object.prototype.hasOwnProperty.call(changes, 'profession')) payload.profession = (changes.profession || '').trim() || null if (Object.prototype.hasOwnProperty.call(changes, 'region')) payload.region = (changes.region || '').trim() || null if (Object.prototype.hasOwnProperty.call(changes, 'nationality')) payload.nationality = (changes.nationality || '').trim() || null @@ -1587,11 +1685,81 @@ function App() { trackAppEvent('portrait_added', { via: 'card' }) await refreshTreeAndKeepSelection(personId, { relayout: true }) setAvatarActionState({ loading: false, error: '' }) + // Aperçu de la photo envoyée, pour la fenêtre de recadrage qui s'ouvre tout de suite + return { previewUrl: URL.createObjectURL(prepared) } } catch (error) { setAvatarActionState({ loading: false, error: getAccountErrorMessage(error) }) + return null } }, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection]) + // Recadrage du portrait enregistré : true si c'est fait, la fenêtre se ferme alors + const handlePersonAvatarCrop = useCallback(async (personId, crop) => { + const accessToken = tree.treeContext.accessToken || auth.userAuth.token + if (!accessToken || !tree.treeContext.treeId) return false + + setAvatarActionState({ loading: true, error: '' }) + try { + await saveAvatarCrop(tree.treeContext.treeId, personId, crop, accessToken) + await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() }) + setAvatarActionState({ loading: false, error: '' }) + return true + } catch (error) { + setAvatarActionState({ + loading: false, + error: error?.payload?.error === 'avatar_from_immich' + ? t('Les photos venues d’Immich ne se recadrent pas pour l’instant.') + : getAccountErrorMessage(error), + }) + return false + } + }, [auth.userAuth.token, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection]) + + // « + photo » dans l'arbre : le sélecteur part dans le clic (Safari iOS refuse un input file ouvert hors geste), + // puis la fenêtre de recadrage s'ouvre dans la fiche de la personne + const portraitInputRef = useRef(null) + const portraitPickerPersonIdRef = useRef(null) + const [pendingPortraitCrop, setPendingPortraitCrop] = useState(null) + const [photoSourceRequest, setPhotoSourceRequest] = useState(null) + const [immichSourceReady, setImmichSourceReady] = useState(false) + + // Administrateur : Immich proposé dans le menu des sources seulement s'il est configuré + useEffect(() => { + let cancelled = false + const treeId = tree.treeContext.treeId + const token = tree.treeContext.accessToken || auth.userAuth.token + if (!userRole.isAdmin || !tree.canEditCurrentTree || !treeId || !token) { + setImmichSourceReady(false) + return undefined + } + fetchImmichConfig(treeId, token) + .then(({ config }) => { if (!cancelled) setImmichSourceReady(Boolean(config?.configured)) }) + .catch(() => { if (!cancelled) setImmichSourceReady(false) }) + return () => { cancelled = true } + }, [userRole.isAdmin, tree.canEditCurrentTree, tree.treeContext.treeId, tree.treeContext.accessToken, auth.userAuth.token]) + + const handlePhotoPlaceholderClick = useCallback((personId) => { + // En modification avec Immich : le menu des sources s'ouvre dans la fiche (le badge en est l'ancre) + if (editMode.isActive && immichSourceReady) { + setPhotoSourceRequest({ personId }) + } else { + // Sans menu : le sélecteur de fichiers part tout de suite, dans le clic + portraitPickerPersonIdRef.current = personId + portraitInputRef.current?.click() + } + handlePersonSelect(personId) + }, [editMode.isActive, immichSourceReady, handlePersonSelect]) + + const handlePortraitPicked = useCallback(async (event) => { + const file = event.target.files?.[0] + event.target.value = '' + const personId = portraitPickerPersonIdRef.current + portraitPickerPersonIdRef.current = null + if (!file || personId === null) return + const result = await handlePersonAvatarUpload(personId, file) + if (result?.previewUrl) setPendingPortraitCrop({ personId, src: result.previewUrl }) + }, [handlePersonAvatarUpload]) + // Visage venu d'Immich : recharger l'arbre pour montrer le nouveau portrait (ou le portrait restant) const handleImmichChange = useCallback(async (personId) => { await refreshTreeAndKeepSelection(personId, { relayout: false, avatarCacheBust: Date.now() }) @@ -1792,6 +1960,48 @@ function App() { } }, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode, makeLocalId]) // eslint-disable-line react-hooks/exhaustive-deps + // Choix en attente d'un contributeur (brouillon), affiché sur la fiche avant l'envoi + const [pendingParentage, setPendingParentage] = useState({}) + useEffect(() => { if (!editMode.isActive) setPendingParentage({}) }, [editMode.isActive]) // eslint-disable-line react-hooks/exhaustive-deps + + // Lien avec ses parents : naissance ou adoption, pour tous les liens parent de la personne + const handleSetParentage = useCallback(async (childPersonId, parentageType) => { + const childLinks = filiations.filter((candidate) => String(candidate.childId) === String(childPersonId)) + if (childLinks.length === 0) return + + if (isDemoMode) { + for (const link of childLinks) link.parentageType = parentageType + tree.bumpGraphRevision() + setRelationFeedback({ loading: false, success: t('Lien modifié (démo locale)'), error: '' }) + return + } + + if (!tree.canEditCurrentTree) { + const draft = editMode.restoreDraft() + const parentageChanges = { ...(draft.parentageChanges || {}), [String(childPersonId)]: parentageType } + editMode.updateDraft({ parentageChanges }) + setPendingParentage((prev) => ({ ...prev, [String(childPersonId)]: parentageType })) + setRelationFeedback({ loading: false, success: t('Modification ajoutée à la contribution'), error: '' }) + return + } + + const accessToken = tree.treeContext.accessToken || auth.userAuth.token + if (!accessToken || !tree.treeContext.treeId) return + + setRelationFeedback({ loading: true, success: '', error: '' }) + try { + for (const link of childLinks) { + if (String(link.id).startsWith('local-') || (link.parentageType || 'biologique') === parentageType) continue + await updateParentChildLink(tree.treeContext.treeId, link.id, { parentageType }, accessToken) + } + await refreshTreeAndKeepSelection(childPersonId, { relayout: true }) + setRelationFeedback({ loading: false, success: t('Lien modifié'), error: '' }) + } catch (err) { + setRelationFeedback({ loading: false, success: '', error: getAccountErrorMessage(err) }) + console.error('Failed to update parentage:', err) + } + }, [auth.userAuth.token, tree.canEditCurrentTree, tree.treeContext.accessToken, tree.treeContext.treeId, refreshTreeAndKeepSelection, isDemoMode]) // eslint-disable-line react-hooks/exhaustive-deps + // Suppression d'une relation (mode édition) const handleRemoveRelation = useCallback(async (sourcePersonId, targetPersonId, relationType) => { if (isDemoMode) { @@ -1994,6 +2204,31 @@ 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 && options?.passwordLogin !== false + if (active && options) setAuthOptions(options) + }) + .catch(() => {}) + + // Retour du fournisseur OIDC (lu une fois au chargement, voir OIDC_RETURN). Le jeton de passage ne sert qu'une + // fois : un second passage du démarrage (React en développement) attend le même échange. + if (OIDC_RETURN) { + oidcSignIn ??= OIDC_RETURN.code ? auth.handleOidcExchange(OIDC_RETURN.code) : Promise.resolve(false) + const signedIn = await oidcSignIn + if (!active) return + if (!signedIn) { + if (OIDC_RETURN.error) auth.setAccountError(getAccountErrorMessage({ payload: { error: OIDC_RETURN.error } })) + await authOptionsReady + if (!active) return + setAccountEntryMode('login') + tree.setBootState('account') + return + } + } + const slug = getSlugFromPathname() let resolvedTreeId = TREE_ID_FROM_ENV let resolvedTreeName = '' @@ -2002,12 +2237,14 @@ function App() { const resolvedTreeOwnerEmail = '' // Root app: - // - not connected => demo by default + // - not connected => accueil à deux choix (variante B, 18/10) // - connected => continue bootstrap to open the user's tree if (!slug && !TREE_ID_FROM_ENV && !query.has('account')) { const userToken = auth.restoreUserToken() if (!userToken) { - tree.handleUseDemo() + await authOptionsReady + if (!active) return + tree.setBootState('welcome') return } } @@ -2134,6 +2371,7 @@ function App() { } } + await authOptionsReady if (!active) return if (resolvedTreeId) { @@ -2150,7 +2388,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() }) } @@ -2211,8 +2449,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() @@ -2517,9 +2756,24 @@ function App() { resetSignedOutState() } + // Accueil « J'ai reçu un lien de famille » : la porte demande le code de l'arbre et son mot de passe + const handleFamilyLinkChoice = () => { + tree.setTreeContext(buildEmptyTreeContext()) + tree.setGateError('') + tree.setBootState('need-access') + } + + // « Ce n'est pas mon compte » : déconnexion, puis la porte du même arbre + const handleNotMyAccount = async () => { + setTreeNotFound(false) + await auth.handleLogout() + tree.setGateError('') + tree.setBootState('need-access') + } + // Suppression du compte : même remise à zéro que la déconnexion une fois la session effacée. - const handleDeleteAccount = async (password) => { - const errorMessage = await auth.handleDeleteAccount(password) + const handleDeleteAccount = async (confirmation) => { + const errorMessage = await auth.handleDeleteAccount(confirmation) if (errorMessage) { return errorMessage } @@ -2739,6 +2993,18 @@ function App() { ) } + if (tree.bootState === 'welcome') { + return ( +
+ handleUseAccount('login')} + onRegister={registrationOpen ? () => handleUseAccount('register') : undefined} + /> +
+ ) + } + if (tree.bootState === 'need-access') { return (
@@ -2751,9 +3017,13 @@ function App() { treeNotFound={treeNotFound} loading={tree.gateLoading} errorMessage={tree.gateError} + connected={Boolean(auth.userAuth.token)} + connectedName={accountUser?.firstName || ''} + onNotMyAccount={handleNotMyAccount} onSubmit={handleGateUnlock} onUseDemo={handleUseDemo} onUseAccount={handleUseAccount} + canRegister={registrationOpen} />
) @@ -2769,6 +3039,11 @@ function App() { trees={auth.accountTrees} activeTreeId={tree.treeContext.treeId} defaultMode={accountEntryMode} + canRegister={registrationOpen} + passwordLogin={authOptions.passwordLogin !== false} + oidcName={authOptions.oidc?.name || ''} + onOidcLogin={authOptions.oidc ? () => window.location.assign(oidcStartUrl(getSlugFromPathname() ? window.location.pathname : '/')) : undefined} + hasPassword={auth.userAuth.user?.hasPassword !== false} pendingTreeName={pendingSharedTreeName} onLogin={handleLogin} onRegister={handleRegister} @@ -2796,7 +3071,7 @@ function App() {
{t('Votre arbre est créé.')} {t("Ajoutez un proche pour commencer, comme votre mère, votre père ou un frère. Il suffit d'un prénom.")} -
@@ -2806,7 +3081,7 @@ function App() { {t("Votre arbre est créé, mais l'import n'a pas abouti.")} {` ${treeImportError} `} {t("Vous pouvez réessayer depuis les paramètres de l'arbre.")} - @@ -2839,11 +3114,11 @@ function App() { hasResults={search.hasResults} onSearch={search.handleSearch} onClear={search.clearSearch} - onResultClick={(person) => { + onResultClick={async (person) => { const fullPerson = persons.find(p => p.id === person.id) if (!fullPerson) return trackDemoInteraction('search') - tree.setSelectedPerson(fullPerson) + if (!(await chooseSelectedPerson(fullPerson))) return galaxyZoomApiRef.current?.focusPerson?.(fullPerson.id) }} /> @@ -2860,7 +3135,7 @@ function App() { hasDraft={editMode.hasDraft} draftTimestamp={editMode.draftTimestamp} role={userRole.isAdmin ? 'admin' : 'contributor'} - onDeactivate={editMode.deactivate} + onDeactivate={deactivateEditMode} onSubmit={handleEditSubmit} /> {/* Confirmation Modal (quitter mode édition avec draft) */} @@ -2929,12 +3204,13 @@ function App() { activeTreeId={tree.treeContext.treeId} onEditClick={handleEditToggle} onAddPersonClick={handleOpenAddPerson} - onAddSouvenirClick={isFamilyVisitor ? handleOpenSouvenirPicker : undefined} - helpUrl={isFamilyVisitor ? HELP_URL : ''} + onAddSouvenirClick={isFamilyVisitor || userRole.isAdmin ? handleOpenSouvenirPicker : undefined} + helpUrl={HELP_URL} onShareClick={userRole.isAdmin ? handleOpenContributionPanel : undefined} onTreeSelect={handleOpenTreeFromAccount} onCreateTree={handleOpenTreeWizard} onProfileClick={handleUseAccount} + canRegister={registrationOpen} onAccountClick={() => setAccountPanelVisible(true)} onSettingsClick={() => setAdminPanelVisible(true)} onLogout={handleLogout} @@ -2968,21 +3244,33 @@ function App() { zoomApiRef={galaxyZoomApiRef} focusPersonId={tree.treeContext.rootPersonId} assetRevision={tree.assetRevision} - photoHint={tree.canEditCurrentTree} + // « + photo » seulement en modification : en lecture, un clic sur un portrait vide ouvre la fiche (issue #124) + photoHint={tree.canEditCurrentTree && editMode.isActive} + onPhotoPlaceholderClick={tree.canEditCurrentTree && editMode.isActive ? handlePhotoPlaceholderClick : null} reorderEnabled={canReorderSiblings} onReorderSiblings={handleReorderSiblings} + familyTabs={familyTabs} /> + {tree.canEditCurrentTree && ( + + )} {tree.selectedPerson && ( tree.setSelectedPerson(null)} + onClose={() => chooseSelectedPerson(null)} onMediaSelect={handleMediaSelectFresh} + saveOnLeaveRef={personSaveOnLeaveRef} editMode={editMode.isActive} canManageMedia={tree.canSubmitContribution || tree.canEditCurrentTree} onManageMedia={media.handleOpenMediaManager} onAvatarUpload={tree.canEditCurrentTree ? handlePersonAvatarUpload : undefined} onAvatarDelete={tree.canEditCurrentTree ? handlePersonAvatarDelete : undefined} + onAvatarCrop={tree.canEditCurrentTree ? handlePersonAvatarCrop : undefined} + pendingPortraitCrop={pendingPortraitCrop} + onPendingPortraitCropDone={() => setPendingPortraitCrop(null)} + photoSourceRequest={photoSourceRequest} + onPhotoSourceRequestDone={() => setPhotoSourceRequest(null)} treeId={tree.treeContext.treeId || null} immichToken={tree.treeContext.accessToken || auth.userAuth.token} onImmichChange={tree.canEditCurrentTree ? handleImmichChange : undefined} @@ -2993,6 +3281,8 @@ function App() { onDelete={handlePersonDelete} onAddRelation={handleAddRelation} onRemoveRelation={handleRemoveRelation} + onSetParentage={handleSetParentage} + pendingParentage={pendingParentage[String(tree.selectedPerson.id)] ?? null} relationActionLoading={relationFeedback.loading} relationActionSuccess={relationFeedback.success} relationActionError={relationFeedback.error} @@ -3002,11 +3292,23 @@ function App() { onMomentSaved={media.refreshMomentMedia} /> )} + {/* Crayon de modification masqué en démo : rien n'y est enregistré */} {tree.selectedMedia && ( tree.setSelectedMedia(null)} + canEdit={Boolean(tree.canEditCurrentTree && editMode.isActive && !isDemoMode)} + onLoadLifeEvents={() => media.loadPersonLifeEvents(tree.selectedMedia.person.id)} + onSave={async (payload) => { + const { person, media: current } = tree.selectedMedia + await media.handleUpdatePersonMedia(person.id, current.id, payload) + // Le rafraîchissement de l'arbre referme la visionneuse : on la rouvre sur le souvenir modifié + tree.setSelectedMedia({ + person, + media: { ...current, label: payload.caption, lifeEventId: payload.lifeEventId }, + }) + }} /> )} { setAdminPanelVisible(false); openPhotoStart() }} onClose={() => setAdminPanelVisible(false)} + familyTabs={familyTabs} + onFamilyTabsChange={setFamilyTabs} /> LARGE_BODY_CHARS + return AbortSignal.timeout(options.timeoutMs ?? (isLargeBody ? UPLOAD_TIMEOUT_MS : DEFAULT_TIMEOUT_MS)) +} + async function apiRequest(path, options = {}) { + const { timeoutMs: _timeoutMs, ...fetchOptions } = options const providedHeaders = options.headers || {} const hasExplicitContentType = Object.keys(providedHeaders).some((key) => key.toLowerCase() === 'content-type') @@ -32,12 +48,21 @@ async function apiRequest(path, options = {}) { ...providedHeaders, } - const response = await fetch(`${API_BASE_URL}${path}`, { - ...options, - headers, - }) - - const text = await response.text() + let response + let text + try { + response = await fetch(`${API_BASE_URL}${path}`, { + ...fetchOptions, + headers, + signal: timeoutSignal(options), + }) + text = await response.text() + } catch (error) { + if (error?.name === 'TimeoutError') { + throw new ApiError(t('Le serveur ne répond pas. Vérifiez la connexion et réessayez.'), 0, { error: 'request_timeout' }) + } + throw error + } const payload = await safeParseJson(text) if (!response.ok) { diff --git a/frontend/src/api/client.test.js b/frontend/src/api/client.test.js new file mode 100644 index 0000000..f5a3de7 --- /dev/null +++ b/frontend/src/api/client.test.js @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { ApiError, apiRequest } from './client' + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +describe('apiRequest', () => { + it('une requête qui ne répond pas finit en erreur lisible', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout') + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new DOMException('trop long', 'TimeoutError'))) + await expect(apiRequest('/trees')).rejects.toMatchObject({ + name: 'ApiError', message: 'Le serveur ne répond pas. Vérifiez la connexion et réessayez.', status: 0, payload: { error: 'request_timeout' }, + }) + expect(timeout).toHaveBeenCalledWith(30_000) + }) + + it('un envoi de fichier a plus de temps, et un délai demandé passe devant', async () => { + const timeout = vi.spyOn(AbortSignal, 'timeout') + vi.stubGlobal('fetch', vi.fn().mockImplementation(async () => new Response('{"ok":true}', { status: 200 }))) + await apiRequest('/upload', { method: 'POST', body: 'x'.repeat(200_000) }) + expect(timeout).toHaveBeenLastCalledWith(5 * 60_000) + await apiRequest('/import', { method: 'POST', body: '{}', timeoutMs: 1234 }) + expect(timeout).toHaveBeenLastCalledWith(1234) + expect(fetch.mock.calls[1][1]).not.toHaveProperty('timeoutMs') + }) + + it('une autre panne réseau remonte telle quelle', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('Failed to fetch'))) + const error = await apiRequest('/trees').catch((e) => e) + expect(error).toBeInstanceOf(TypeError) + expect(error).not.toBeInstanceOf(ApiError) + }) +}) diff --git a/frontend/src/api/treeApi.js b/frontend/src/api/treeApi.js index 8ad1ad0..ca950b5 100644 --- a/frontend/src/api/treeApi.js +++ b/frontend/src/api/treeApi.js @@ -235,6 +235,19 @@ export async function reorderSiblings(treeId, order, token) { }) } +// Lien parent-enfant modifié (type de filiation : naissance ou adoption) +export async function updateParentChildLink(treeId, linkId, payload, token) { + const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }) + + return data.link +} + export async function deleteParentChildLink(treeId, linkId, token) { const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/links/${encodeURIComponent(linkId)}`, { method: 'DELETE', @@ -441,6 +454,22 @@ export async function deletePersonMedia(treeId, personId, mediaId, token) { return data } +// Modifier un souvenir (titre, moment de vie, texte de citation) : administrateurs seulement +export async function updatePersonMedia(treeId, personId, mediaId, payload, token) { + const data = await apiRequest( + `/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/media/${encodeURIComponent(mediaId)}`, + { + method: 'PATCH', + headers: { + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(payload), + }, + ) + + return data +} + export async function uploadPersonAvatar(treeId, personId, payload, token) { const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, { method: 'POST', @@ -453,6 +482,19 @@ export async function uploadPersonAvatar(treeId, personId, payload, token) { return data } +// Cadrage du portrait ({ x, y, zoom }) ou null pour le cadrage par défaut +export async function saveAvatarCrop(treeId, personId, crop, token) { + const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar/crop`, { + method: 'PUT', + headers: { + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ crop }), + }) + + return data +} + export async function deletePersonAvatar(treeId, personId, token) { const data = await apiRequest(`/trees/${encodeURIComponent(treeId)}/persons/${encodeURIComponent(personId)}/avatar`, { method: 'DELETE', @@ -496,6 +538,8 @@ export async function importGedcom(treeId, fileContent, token) { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: JSON.stringify({ content: fileContent }), + // Un gros arbre se crée en une transaction d'une minute au plus côté serveur + timeoutMs: 3 * 60_000, }) return data } diff --git a/frontend/src/components/AccessGate.jsx b/frontend/src/components/AccessGate.jsx index 7631818..ed565e6 100644 --- a/frontend/src/components/AccessGate.jsx +++ b/frontend/src/components/AccessGate.jsx @@ -23,8 +23,12 @@ const AccessGate = ({ treeNotFound = false, loading, errorMessage, + connected = false, + connectedName = '', onSubmit, onUseAccount, + canRegister = true, + onNotMyAccount, }) => { const [treeId, setTreeId] = useState(initialTreeId || '') const [password, setPassword] = useState('') @@ -55,7 +59,6 @@ const AccessGate = ({
-

{t('Arbre partagé')}

{treeNotFound ? (

{richText(t('Cet arbre est *introuvable*'))}

) : ( @@ -67,7 +70,7 @@ const AccessGate = ({ {treeNotFound ? t("Le lien est peut-être incomplet, ou l'arbre a été supprimé. Demandez un nouveau lien à la personne qui vous l'a envoyé.") : treeName - ? t('Entrez le mot de passe reçu avec le lien. Pas besoin de compte pour regarder.') + ? t('Tapez le mot de passe reçu avec le lien. Pas besoin de compte pour regarder.') : t('Entrez le code et le mot de passe reçus avec votre lien de partage.')}

@@ -86,6 +89,21 @@ const AccessGate = ({ {!treeNotFound && (
+ {connected && ( +
+ {connectedName ? ( + /^[aeiouyàâäéèêëîïôöùûüh]/i.test(connectedName) + ? t("Vous êtes sur le compte d'{name}", { name: connectedName }) + : t('Vous êtes sur le compte de {name}', { name: connectedName }) + ) : ( + t('Vous êtes sur votre compte.') + )} + + {t("Tapez une fois le mot de passe reçu avec le lien. Ensuite, l'arbre s'ouvrira directement.")} + +
+ )} + {!initialTreeId && (
@@ -103,21 +121,7 @@ const AccessGate = ({ )}
- - setFirstName(event.target.value)} - disabled={loading} - autoComplete="given-name" - maxLength={60} - /> -

{t("Facultatif. Il sert à savoir qui est venu voir l'arbre.")}

-
- -
- +
+ + {/* Compte connecté : son prénom sert déjà, on ne le redemande pas */} + {!connected && ( +
+ + setFirstName(event.target.value)} + disabled={loading} + autoComplete="given-name" + maxLength={60} + /> +

{t("Il sert à savoir qui est venu voir l'arbre.")}

+
+ )}
)} @@ -135,25 +156,42 @@ const AccessGate = ({ {!treeNotFound && ( )} - {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 && ( + )} - {onUseAccount && !treeNotFound && ( + {connected && onNotMyAccount && !treeNotFound && ( +
+ +
+ )} + + {!connected && onUseAccount && !treeNotFound && (
- + {canRegister && ( + + )}
)} diff --git a/frontend/src/components/AccountDashboard.jsx b/frontend/src/components/AccountDashboard.jsx index 6271e33..5f8eb32 100644 --- a/frontend/src/components/AccountDashboard.jsx +++ b/frontend/src/components/AccountDashboard.jsx @@ -1,5 +1,5 @@ import { Fragment, useEffect, useMemo, useState } from 'react' -import { ChevronRight, KeyRound, LogOut, Plus, Sparkles, X } from 'lucide-react' +import { ChevronRight, KeyRound, LogIn, LogOut, Plus, Sparkles, X } from 'lucide-react' import logoUrl from '../assets/parenthese-logo.svg?url' import './AccountScreens.css' import PzBusy from './PzBusy.jsx' @@ -23,6 +23,11 @@ const AccountDashboard = ({ trees, activeTreeId, defaultMode = 'login', + canRegister = true, + passwordLogin = true, + oidcName = '', + onOidcLogin, + hasPassword = true, pendingTreeName = '', onLogin, onRegister, @@ -62,7 +67,7 @@ const AccountDashboard = ({ setDeleting(true) setDeleteError('') - const errorMessage = await onDeleteAccount(deletePassword) + const errorMessage = await onDeleteAccount(hasPassword ? { password: deletePassword } : { confirmEmail: deletePassword }) setDeleting(false) if (errorMessage) { @@ -78,7 +83,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 @@ -132,67 +138,82 @@ const AccountDashboard = ({

)} -
- {isRegister && ( + {/* Fournisseur d'identité de l'instance (OIDC, #115) : il connecte, et crée le compte si l'instance en accepte */} + {onOidcLogin && ( + + )} + + {/* Sans formulaire à mot de passe, le refus du fournisseur s'affiche ici */} + {!passwordLogin && errorMessage &&
{errorMessage}
} + + {onOidcLogin && passwordLogin &&
{t('ou')}
} + + {passwordLogin && ( + + {isRegister && ( +
+ + setFirstName(event.target.value)} + required + /> +
+ )} +
- + setFirstName(event.target.value)} + id="accountEmail" + type="email" + autoComplete="email" + placeholder={t('camille@exemple.fr')} + value={email} + onChange={(event) => setEmail(event.target.value)} required />
- )} -
- - setEmail(event.target.value)} - required - /> -
+
+ + setPassword(event.target.value)} + minLength={8} + required + /> + {isRegister &&

{t('8 caractères minimum.')}

} +
-
- - setPassword(event.target.value)} - minLength={8} - required - /> - {isRegister &&

{t('8 caractères minimum.')}

} -
+ {errorMessage &&
{errorMessage}
} - {errorMessage &&
{errorMessage}
} + - - - {isRegister && ( -

- {richText( - t('En créant un compte, vous acceptez {slot}.'), - - {t('la façon dont Parenthèse traite vos données')} - , - )} -

- )} -
+ {isRegister && ( +

+ {richText( + t('En créant un compte, vous acceptez {slot}.'), + + {t('la façon dont Parenthèse traite vos données')} + , + )} +

+ )} + + )}
{t('ou')}
@@ -203,21 +224,23 @@ const AccountDashboard = ({ disabled={loading} >
-

- {isRegister ? t('Vous avez déjà un compte ?') : t('Pas encore de compte ?')}{' '} - -

+ {canRegister && ( +

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

+ )} @@ -324,11 +347,12 @@ const AccountDashboard = ({
- + {/* Compte né d'une connexion OIDC, sans mot de passe : il retape son adresse email */} + setDeletePassword(event.target.value)} disabled={deleting} diff --git a/frontend/src/components/AccountScreens.css b/frontend/src/components/AccountScreens.css index 0058537..0cac3b9 100644 --- a/frontend/src/components/AccountScreens.css +++ b/frontend/src/components/AccountScreens.css @@ -41,6 +41,16 @@ .pz-gate-owner-text { display: grid; gap: 2px; min-width: 0; } .pz-gate-owner-text strong { color: var(--pz-ink); font-weight: 500; } +/* Porte, personne déjà connectée : dit quel compte est ouvert et ce qu'il faut faire une fois */ +.access-gate-session { display: grid; gap: 4px; padding: 12px 14px; border-radius: var(--pz-r-m); background: var(--pz-a50); border: 1px solid var(--pz-a100); color: var(--pz-ink); font: 400 14px/1.45 var(--pz-sans); } +.access-gate-session-step { color: var(--pz-ink-2); } + +/* Accueil sans session (variante B) */ +.pz-btn.pz-btn--line { background: transparent; border: 1px solid var(--pz-ink); color: var(--pz-ink); } +.entry-choice-title { text-align: center; } +.entry-choice-card { padding: 18px; display: grid; gap: 10px; } +.entry-choice-name { font: 400 20px/1.2 var(--pz-serif); color: var(--pz-ink); margin: 0; } + /* Fenêtre « Mon compte » (personne liée) */ .ap-linked { display: flex; gap: 12px; align-items: center; padding: 12px 14px; border-radius: var(--pz-r-m); background: var(--pz-a50); border: 1px solid var(--pz-a100); } .ap-avatar { width: 44px; height: 44px; flex: none; display: grid; place-items: center; overflow: hidden; border-radius: 50%; background: var(--pz-a100); color: var(--pz-a700); font: 400 20px/1 var(--pz-serif); } diff --git a/frontend/src/components/AddPersonButton.css b/frontend/src/components/AddPersonButton.css deleted file mode 100644 index ad59fb0..0000000 --- a/frontend/src/components/AddPersonButton.css +++ /dev/null @@ -1,172 +0,0 @@ -/* ============================================================ - Add Person Button - FAB draggable pour ajouter personne - ============================================================ */ - -.add-person-button { - position: fixed; - width: 64px; - height: 64px; - border-radius: 50%; - - background: rgba(242, 239, 234, 0.95); - backdrop-filter: blur(20px); - -webkit-backdrop-filter: blur(20px); - border: 2px solid rgba(147, 64, 42, 0.3); - - box-shadow: - 0 8px 32px rgba(42, 38, 34, 0.25), - 0 2px 8px rgba(42, 38, 34, 0.12); - - cursor: grab; - user-select: none; - - display: flex; - align-items: center; - justify-content: center; - - transition: all 0.25s cubic-bezier(0.22, 1, 0.36, 1); - z-index: 900; - - /* Position initiale : bas-gauche */ - bottom: 24px; - left: 24px; -} - -.add-person-button:hover { - transform: scale(1.12); - box-shadow: - 0 12px 40px rgba(147, 64, 42, 0.35), - 0 4px 12px rgba(42, 38, 34, 0.18); - border-color: rgba(147, 64, 42, 0.5); -} - -.add-person-button.dragging { - cursor: grabbing; - transform: scale(1.2) rotate(5deg); - box-shadow: - 0 16px 48px rgba(147, 64, 42, 0.45), - 0 6px 16px rgba(42, 38, 34, 0.25); - border-color: rgba(147, 64, 42, 0.7); - z-index: 1100; -} - -/* Icône + */ -.add-person-button-icon { - font-size: 32px; - font-weight: 300; - color: #93402A; - line-height: 1; - filter: drop-shadow(0 1px 3px rgba(42, 38, 34, 0.15)); - transition: transform 0.2s ease; -} - -.add-person-button:hover .add-person-button-icon { - transform: rotate(90deg); -} - -.add-person-button.dragging .add-person-button-icon { - transform: rotate(180deg); -} - -/* Pulse animation sur apparition */ -@keyframes addPersonPulse { - 0% { - opacity: 0; - transform: scale(0.7); - } - 50% { - transform: scale(1.15); - } - 100% { - opacity: 1; - transform: scale(1); - } -} - -.add-person-button.entering { - animation: addPersonPulse 0.5s cubic-bezier(0.22, 1, 0.36, 1); -} - -/* Tooltip */ -.add-person-button-tooltip { - position: absolute; - top: 50%; - left: calc(100% + 12px); - transform: translateY(-50%); - - padding: 8px 14px; - background: rgba(42, 38, 34, 0.95); - color: #FFFFFF; - font-size: 14px; - font-weight: 500; - border-radius: 8px; - white-space: nowrap; - - opacity: 0; - pointer-events: none; - transition: opacity 0.2s ease; - - box-shadow: 0 4px 12px rgba(42, 38, 34, 0.2); -} - -.add-person-button:hover .add-person-button-tooltip { - opacity: 1; -} - -.add-person-button.dragging .add-person-button-tooltip { - opacity: 0; -} - -/* Instructions drag (affichées pendant drag) */ -.add-person-drag-hint { - position: fixed; - top: 24px; - left: 50%; - transform: translateX(-50%); - z-index: 1050; - - padding: 10px 18px; - background: rgba(242, 239, 234, 0.98); - backdrop-filter: blur(24px); - border-radius: 50px; - border: 1px solid rgba(147, 64, 42, 0.2); - - font-size: 14px; - font-weight: 450; - color: #2A2622; - - box-shadow: - 0 6px 24px rgba(42, 38, 34, 0.18), - 0 2px 8px rgba(42, 38, 34, 0.08); - - animation: hintSlideDown 0.3s cubic-bezier(0.22, 1, 0.36, 1); -} - -@keyframes hintSlideDown { - from { - opacity: 0; - transform: translateX(-50%) translateY(-10px); - } - to { - opacity: 1; - transform: translateX(-50%) translateY(0); - } -} - -/* Responsive mobile */ -@media (max-width: 768px) { - .add-person-button { - width: 56px; - height: 56px; - bottom: 20px; - left: 20px; - } - - .add-person-button-icon { - font-size: 28px; - } - - .add-person-button-tooltip { - display: none; - } -} diff --git a/frontend/src/components/AddPersonButton.jsx b/frontend/src/components/AddPersonButton.jsx deleted file mode 100644 index 7087c49..0000000 --- a/frontend/src/components/AddPersonButton.jsx +++ /dev/null @@ -1,84 +0,0 @@ -import { useRef, useEffect, useState } from 'react' -import { Plus, Baby, Heart, Users, MapPin } from 'lucide-react' -import './AddPersonButton.css' -import { t } from '../i18n/index.js' - -/** - * Bouton FAB draggable pour ajouter une personne - * Utilisé en mode édition pour drag-and-drop dans la galaxie - * - * @param {Object} props - * @param {boolean} props.isDragging - État dragging (du hook) - * @param {Object} props.dragPosition - Position drag {x, y} - * @param {Function} props.onDragStart - Handler début drag - * @param {string} props.linkType - Type de lien détecté ('child', 'spouse', 'sibling') - */ -function AddPersonButton({ isDragging, dragPosition, onDragStart, linkType }) { - const buttonRef = useRef(null) - const [entering, setEntering] = useState(true) - - // Animation d'entrée - useEffect(() => { - const timer = setTimeout(() => setEntering(false), 500) - return () => clearTimeout(timer) - }, []) - - const handleMouseDown = (e) => { - e.preventDefault() - if (!buttonRef.current) return - - const rect = buttonRef.current.getBoundingClientRect() - const buttonPosition = { - x: rect.left + rect.width / 2, - y: rect.top + rect.height / 2, - } - - onDragStart?.(e, buttonPosition) - } - - // Position inline si dragging - const style = isDragging - ? { - left: `${dragPosition.x - 32}px`, - top: `${dragPosition.y - 32}px`, - transition: 'none', - } - : {} - - // Message tooltip selon linkType - let tooltipText = t('Glisser pour ajouter une personne') - if (isDragging) { - if (linkType === 'child') tooltipText = t('Ajouter un enfant') - else if (linkType === 'spouse') tooltipText = t('Ajouter un conjoint') - else if (linkType === 'sibling') tooltipText = t('Ajouter un frère/sœur') - else tooltipText = t('Relâcher pour créer') - } - - return ( - <> - - - {/* Hint pendant drag */} - {isDragging && ( -
- {linkType === 'child' && <> {t('Enfant')}} - {linkType === 'spouse' && <> {t('Conjoint')}} - {linkType === 'sibling' && <> {t('Frère·Sœur')}} - {!linkType && <> {t('Positionnez la personne')}} -
- )} - - ) -} - -export default AddPersonButton diff --git a/frontend/src/components/AddPersonPanel.css b/frontend/src/components/AddPersonPanel.css index 007f39c..f4802c7 100644 --- a/frontend/src/components/AddPersonPanel.css +++ b/frontend/src/components/AddPersonPanel.css @@ -43,6 +43,8 @@ .apn-section { display: grid; gap: 12px; padding-top: 16px; border-top: 1px solid var(--pz-line-soft); } .apn-types .pz-tab { font-size: 13px; padding: 0 6px; } +/* Sexe : quatre choix, chacun sur une ligne même dans la colonne étroite du panneau */ +.apn-sex .pz-tab { white-space: nowrap; padding: 0 4px; } .apn-relation { position: relative; } .apn-search { position: relative; display: flex; align-items: center; } @@ -108,6 +110,13 @@ /* Décédé(e) sans date connue (issue #77) */ .apn-deceased { display: inline-flex; align-items: center; gap: 7px; margin-top: 6px; font: 400 13px var(--pz-sans); color: var(--pz-ink-2); cursor: pointer; } .apn-deceased input { width: 15px; height: 15px; accent-color: var(--pz-a500); } +/* Case sous une ligne de deux champs (« Je ne connais pas son prénom », « Décédé(e), date inconnue ») : sur une ligne, + sous les deux champs sur ordinateur, sous son champ sur téléphone (ordre du DOM) */ +.apn-row > .apn-row-check { grid-column: 1 / -1; grid-row: 2; margin-top: -6px; white-space: nowrap; justify-self: start; } +@media (max-width: 480px) { .apn-row > .apn-row-check { grid-row: auto; } } /* Colonnes alignées en haut : la case « décédé(e) » sous le décès ne décale pas la naissance */ .apn-row { align-items: start; } + +/* Titre de section en casse normale (pas de sur-titre en capitales dans le panneau d'ajout) */ +.apn-section-title { font: 500 14px/1.3 var(--pz-sans); color: var(--pz-ink); margin: 0 0 10px; } diff --git a/frontend/src/components/AddPersonPanel.jsx b/frontend/src/components/AddPersonPanel.jsx index b23dc40..ae448b5 100644 --- a/frontend/src/components/AddPersonPanel.jsx +++ b/frontend/src/components/AddPersonPanel.jsx @@ -4,6 +4,8 @@ import { UserPlus, X, Search, ChevronDown } from 'lucide-react' import './AddPersonPanel.css' import PzBusy from './PzBusy.jsx' import { t } from '../i18n/index.js' +import { firstNameLabel } from '../utils/personName.js' +import { SEX_CHOICES } from '../utils/personSex.js' // Titre traduit d'un bloc : le passage entre et garde son italique function withEmphasis(text) { @@ -32,12 +34,18 @@ function withEmphasis(text) { */ function AddPersonPanel({ visible, persons = [], loading = false, error = '', onSubmit, onClose }) { const [firstName, setFirstName] = useState('') + // Prénom inconnu : enregistré vide, affiché « Prénom inconnu » dans l'arbre et la fiche + const [unknownFirstName, setUnknownFirstName] = useState(false) const [lastName, setLastName] = useState('') const [birthName, setBirthName] = useState('') + const [nickname, setNickname] = useState('') + const [sex, setSex] = useState(null) const [birthDate, setBirthDate] = useState('') const [deathDate, setDeathDate] = useState('') // Décédé(e) sans date connue (issue #77) const [isDeceased, setIsDeceased] = useState(false) + // Lien d'enfant ou de parent : la personne qui est l'enfant du lien a été adoptée + const [adopted, setAdopted] = useState(false) // Les champs de date gardent leur texte : on les recrée après chaque ajout const [dateFieldsKey, setDateFieldsKey] = useState(0) const [relations, setRelations] = useState([]) @@ -45,6 +53,8 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on const [relationDropdownOpen, setRelationDropdownOpen] = useState(false) const [selectedRelationType, setSelectedRelationType] = useState('parent') const [touched, setTouched] = useState({}) + // Prénom inconnu sans nom de famille : la personne n'aurait aucun nom + const [lastNameMissing, setLastNameMissing] = useState(false) const [justAdded, setJustAdded] = useState(false) const firstNameRef = useRef(null) @@ -89,14 +99,18 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on useEffect(() => { if (justAdded && !loading && !error) { setFirstName('') + setUnknownFirstName(false) setLastName('') setBirthName('') + setNickname('') + setSex(null) setBirthDate('') setDeathDate('') setIsDeceased(false) setDateFieldsKey((k) => k + 1) setRelations([]) setTouched({}) + setLastNameMissing(false) setJustAdded(false) firstNameRef.current?.focus() } @@ -114,7 +128,7 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on setRelations((prev) => [ ...prev, - { personId: person.id, personName: `${person.firstName} ${person.lastName || ''}`.trim(), type: selectedRelationType }, + { personId: person.id, personName: `${firstNameLabel(person)} ${person.lastName || ''}`.trim(), type: selectedRelationType }, ]) setRelationSearch('') setRelationDropdownOpen(false) @@ -127,27 +141,33 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on const handleSubmit = (e) => { e.preventDefault() - if (!firstName.trim()) { + if (!unknownFirstName && !firstName.trim()) { setTouched({ firstName: true }) firstNameRef.current?.focus() return } + if (unknownFirstName && !lastName.trim()) { + setLastNameMissing(true) + return + } + setJustAdded(true) onSubmit({ - firstName: firstName.trim(), + firstName: unknownFirstName ? '' : firstName.trim(), lastName: lastName.trim(), birthName: birthName.trim() || null, + nickname: nickname.trim() || null, + sex, birthDate: birthDate || null, deathDate: deathDate || null, isDeceased: !deathDate && isDeceased, - relations, + // Adopté(e) : s'applique aux liens d'enfant ou de parent de cette fiche + relations: relations.map((rel) => ({ ...rel, adopted: adopted && (rel.type === 'child' || rel.type === 'parent') })), }) } - const isValid = firstName.trim().length > 0 - const relationTypeLabels = { parent: t('Parent de'), child: t('Enfant de'), @@ -171,7 +191,6 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on >
-

{t('Nouvelle fiche')}

{withEmphasis(t("Ajouter quelqu'un"))}

+ {/* Visible tant que le prénom est vide, et tant qu'elle est cochée (pour pouvoir la décocher). + Hors de la colonne Prénom pour tenir sur une ligne : sous les deux champs sur ordinateur, sous Prénom sur téléphone */} + {(!firstName.trim() || unknownFirstName) && ( + + )}
setLastName(e.target.value)} + onChange={(e) => { + setLastName(e.target.value) + setLastNameMissing(false) + }} placeholder={t('Dupont')} autoComplete="off" />
- {touched.firstName && !firstName.trim() && ( + {touched.firstName && !unknownFirstName && !firstName.trim() && (

{t('Il faut au moins un prénom.')}

)} + {lastNameMissing &&

{t('Il faut au moins un nom si le prénom est inconnu.')}

}
@@ -225,6 +263,36 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on />
+
+ + setNickname(e.target.value)} + placeholder={t('Loulou')} + autoComplete="off" + maxLength={120} + /> +
+ +
+ {t('Sexe')} {t('facultatif')} +
+ {SEX_CHOICES.map(({ value, label }) => ( + + ))} +
+
+
@@ -233,17 +301,18 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
- {!deathDate && ( - - )}
+ {/* Hors de la colonne Décès pour tenir sur une ligne, comme la case du prénom */} + {!deathDate && ( + + )}
-

{t('Place dans la famille')}

+

{t('Place dans la famille')}

{Object.entries(relationTypeTabs).map(([type, label]) => ( @@ -285,9 +354,9 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on
  • + {(selectedRelationType === 'child' || selectedRelationType === 'parent') && ( + + )} + {relations.length > 0 && (
    {relations.map((rel) => ( @@ -325,7 +401,9 @@ function AddPersonPanel({ visible, persons = [], loading = false, error = '', on - diff --git a/frontend/src/components/AdminPanel.jsx b/frontend/src/components/AdminPanel.jsx index eb21c25..9ced67d 100644 --- a/frontend/src/components/AdminPanel.jsx +++ b/frontend/src/components/AdminPanel.jsx @@ -33,6 +33,8 @@ function normalizeSlug(value) { } const AdminPanel = ({ + familyTabs = true, + onFamilyTabsChange = null, visible, treeId, authToken, @@ -475,6 +477,22 @@ const AdminPanel = ({ + +
    + {t('Affichage')} + +
    )} diff --git a/frontend/src/components/ContributionPanel.jsx b/frontend/src/components/ContributionPanel.jsx index 2473232..a601d2d 100644 --- a/frontend/src/components/ContributionPanel.jsx +++ b/frontend/src/components/ContributionPanel.jsx @@ -7,6 +7,7 @@ import './SettingsPanels.css' import PzBusy from './PzBusy.jsx' import { HELP_URL } from '../utils/siteLinks.js' import { t, dateLocale } from '../i18n/index.js' +import { sexLabel } from '../utils/personSex.js' // ─── helpers ──────────────────────────────────────────────────────────────── @@ -73,8 +74,11 @@ const PERSON_FIELD_LABELS = { firstName: t('Prénom'), lastName: t('Nom'), birthName: t('Nom de naissance'), + nickname: t('Surnom'), + sex: t('Sexe'), birthDate: t('Date de naissance'), deathDate: t('Date de décès'), + deathPlace: t('Lieu de décès'), birthPlace: t('Lieu de naissance'), profession: t('Métier'), nationality: t('Nationalité'), @@ -82,7 +86,9 @@ const PERSON_FIELD_LABELS = { notes: t('Notes'), } -function displayVal(v) { +function displayVal(v, key) { + // Sexe : « Non précisé » plutôt que « vide » + if (key === 'sex') return sexLabel(v) if (v === null || v === undefined || v === '') return null return String(v) } @@ -132,10 +138,11 @@ function getPersonDisplayName(change, sessionChanges = []) { const data = change.afterJson || {} return data.title || t('Moment') } - const data = (change.afterJson && typeof change.afterJson === 'object') - ? change.afterJson - : (change.beforeJson && typeof change.beforeJson === 'object' ? change.beforeJson : null) - if (!data) return null + // Une modification ne porte que les champs changés : le nom vient alors de l'« avant » (lu en base) + const after = change.afterJson && typeof change.afterJson === 'object' ? change.afterJson : null + const before = change.beforeJson && typeof change.beforeJson === 'object' ? change.beforeJson : null + if (!after && !before) return null + const data = { ...before, ...after } const name = `${data.firstName || ''} ${data.lastName || ''}`.trim() return name || null } @@ -236,6 +243,8 @@ const ContributionPanel = ({ const [newPassword, setNewPassword] = useState('') const [inviteLocalError, setInviteLocalError] = useState('') const [copyFeedback, setCopyFeedback] = useState('') + // Le message d'invitation reste replié : une ligne, le texte ne s'ouvre que sur demande + const [showInviteMessage, setShowInviteMessage] = useState(false) // Load persisted decisions from localStorage useEffect(() => { @@ -364,7 +373,6 @@ const ContributionPanel = ({ aria-labelledby="contribPanelTitle" >
    -

    {t('Partager et relire')}

    {familyTitleBefore}{t('participe')}{familyTitleAfter}

    {defaultInviteText && (
    - -