feat(gating): multi-profils (Base+) non destructif #306

Closed
maximus wants to merge 1 commit from issue-300-multi-profile-gate into issue-299-routes-sidebar
Owner

Gate multi-profils (Base+), NON destructif — 3e maillon de la pile feature-gating (base = issue-299-routes-sidebar).

  • ProfileSwitcher : les profils au-delà de l'actif portent un cadenas (seulement quand ready && !allowed, anti-flash) ; le clic ouvre un dialog upsell (UpsellGate réutilisé dans un overlay, prop additive onNavigate pour fermer le dialog après « J'ai déjà une clé »). Le profil actif reste pleinement accessible.
  • ProfileFormModal = point unique de création (createProfile), couvre les 2 entrées (ProfileSwitcher ET ProfileSelectionPage) : bouton « Créer » visible avec cadenas, clic → panneau upsell compact (mêmes clés upsell.* ; pas UpsellGate car le modal s'ouvre aussi depuis ProfileSelectionPage, rendue HORS BrowserRouter → useNavigate crasherait). Race guard dans handleSave (fenêtre de boot licence).
  • ProfileSelectionPage : tuile « Créer » marquée d'un cadenas ; la sélection de profil y reste libre (page atteignable uniquement quand aucun profil actif ne résout — pas de « profil actif » à y définir, cf. decisions-log).
  • Prédicats purs src/shared/profileGate.ts (isProfileSwitchLocked, isProfileCreationLocked) + 10 tests (matrice ready/allowed/actif/compte, création du 1er profil jamais bloquée).
  • NON destructif : aucun code de suppression ; profiles.json intact ; un upgrade Base/Premium fait tout réapparaître sans migration.
  • Zéro nouvelle clé i18n (réutilise upsell.*, nav.locked, license.editions.*) ; CHANGELOG centralisé dans #302.

Tests : 871 vitest verts (861 baseline + 10) ; npm run build (tsc + vite) propre.

Resolves #300
Generated autonomously by /autopilot run of 2026-07-20

Gate multi-profils (Base+), NON destructif — 3e maillon de la pile feature-gating (base = issue-299-routes-sidebar). - **ProfileSwitcher** : les profils au-delà de l'actif portent un cadenas (seulement quand `ready && !allowed`, anti-flash) ; le clic ouvre un dialog upsell (UpsellGate réutilisé dans un overlay, prop additive `onNavigate` pour fermer le dialog après « J'ai déjà une clé »). Le profil actif reste pleinement accessible. - **ProfileFormModal** = point unique de création (`createProfile`), couvre les 2 entrées (ProfileSwitcher ET ProfileSelectionPage) : bouton « Créer » visible avec cadenas, clic → panneau upsell compact (mêmes clés `upsell.*` ; pas UpsellGate car le modal s'ouvre aussi depuis ProfileSelectionPage, rendue HORS BrowserRouter → `useNavigate` crasherait). Race guard dans `handleSave` (fenêtre de boot licence). - **ProfileSelectionPage** : tuile « Créer » marquée d'un cadenas ; la sélection de profil y reste libre (page atteignable uniquement quand aucun profil actif ne résout — pas de « profil actif » à y définir, cf. decisions-log). - **Prédicats purs** `src/shared/profileGate.ts` (`isProfileSwitchLocked`, `isProfileCreationLocked`) + 10 tests (matrice ready/allowed/actif/compte, création du 1er profil jamais bloquée). - **NON destructif** : aucun code de suppression ; `profiles.json` intact ; un upgrade Base/Premium fait tout réapparaître sans migration. - Zéro nouvelle clé i18n (réutilise `upsell.*`, `nav.locked`, `license.editions.*`) ; CHANGELOG centralisé dans #302. Tests : 871 vitest verts (861 baseline + 10) ; `npm run build` (tsc + vite) propre. Resolves #300 Generated autonomously by /autopilot run of 2026-07-20
maximus added 1 commit 2026-07-21 02:24:49 +00:00
A Free user keeps full access to their active profile; profiles beyond
it show a lock in ProfileSwitcher and open an upsell dialog instead of
switching. Creating a profile beyond the first is locked at the single
creation point, ProfileFormModal (reached from both ProfileSwitcher and
ProfileSelectionPage), with a race guard in handleSave covering the
license boot window. Both creation entries stay visible with a lock
(locked-not-hidden). Nothing is ever removed from profiles.json — an
upgrade to Base/Premium makes every profile reappear untouched.

- New pure predicates in src/shared/profileGate.ts
  (isProfileSwitchLocked, isProfileCreationLocked) + 10 vitest
- ProfileFormModal upsell panel reuses upsell.* keys WITHOUT UpsellGate:
  the modal also opens from ProfileSelectionPage, which renders outside
  BrowserRouter, where UpsellGate's useNavigate would throw
- UpsellGate gains an optional onNavigate callback so the
  ProfileSwitcher upsell dialog can close itself after navigation
- No lock while the license is loading (anti-flash, ready guard);
  zero new i18n keys; no DB migration

Resolves #300

Generated autonomously by /autopilot run of 2026-07-20

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
maximus added the
status:review
type:feature
source:human
autopilot:pending-human
labels 2026-07-21 02:24:58 +00:00
Author
Owner

Review adversariale — APPROVE

Résumé : Gate multi-profils (Base+) correctement implémenté, non destructif par construction — les prédicats purs (profileGate.ts) ne décident que du verrouillage UI, aucun code de suppression, profiles.json intact. Chaque affirmation du body vérifie contre le code (lecture git show, sans checkout).

Points vérifiés

  • Point unique de création confirmé : createProfile n'a qu'un seul call site UI (ProfileFormModal.tsx:78) ; aucun chemin parallèle via saveProfiles. Gater le modal couvre bien les 2 entrées (ProfileSwitcher et ProfileSelectionPage).
  • Contrainte hors-router exacte : LicenseProvider est monté au-dessus de App dans main.tsx, donc useEntitlement fonctionne sur ProfileSelectionPage (rendue hors BrowserRouter) ; le useNavigate inconditionnel d'UpsellGate y crasherait — le panneau compact est justifié.
  • Anti-flash cohérent avec #299 : Sidebar.tsx:47 applique if (!ready || allowed) return null ; les prédicats suivent la même règle (if (!gate.ready || gate.allowed) return false).
  • Pas de contournement par PinDialog : dans ProfileSwitcher, pinProfile n'est set qu'après le check isLocked — le PIN path est inatteignable sur une row verrouillée. Le remplacement du cadenas PIN par le cadenas gating sur les rows verrouillées est documenté.
  • Edge cases couverts : activeProfileId === null ne verrouille rien (never-lock-out), 1er profil jamais bloqué, !ready ne verrouille rien, race guard handleSave (fenêtre de boot licence). Gestion des profils existants (rename/PIN/delete) intacte.
  • i18n : les 9 clés réutilisées (upsell.*, nav.locked, license.editions.*, profile.create, common.cancel) existent toutes en FR et EN ; zéro texte en dur, zéro nouvelle clé comme annoncé.
  • Tests : 10 tests purs, matrice complète (ready/allowed/actif/null/compte) ; pas de skip/only ; suite déclarée verte (871).
  • Data : aucune migration touchée. Commit conventionnel, Resolves #300 présent.
  • CHANGELOG centralisé dans #302 : vérifié — l'entrée y couvre le gate multi-profils avec la référence #300.
  • CI : maillon intermédiaire de pile (base = issue-299-routes-sidebar) → no-status attendu ; valider le tip cumulé en local avant merge (gotcha connu).

Suggestions (non bloquantes)

  1. DRY : le panneau upsell compact de ProfileFormModal duplique le markup CTA d'UpsellGate. Une extraction future UpsellPanel (présentational, router-free) que UpsellGate wrapperait éliminerait la duplication tout en respectant la contrainte hors-router.
  2. profileGate.test.ts:36 — le libellé « (allowed short-circuits) » est inexact : dans if (!gate.ready || gate.allowed), c'est !ready qui est évalué en premier pour {allowed: true, ready: false}. L'assertion reste correcte.
  3. Bug cosmétique préexistant (hors diff, ProfileFormModal.tsx:188) : le label PIN affiche ({t("common.cancel").toLowerCase()}) → « (annuler) » là où on attend vraisemblablement « (optionnel) ». À corriger hors pile.
## Review adversariale — APPROVE **Résumé** : Gate multi-profils (Base+) correctement implémenté, non destructif par construction — les prédicats purs (`profileGate.ts`) ne décident que du verrouillage UI, aucun code de suppression, `profiles.json` intact. Chaque affirmation du body vérifie contre le code (lecture `git show`, sans checkout). ### Points vérifiés - **Point unique de création confirmé** : `createProfile` n'a qu'un seul call site UI (`ProfileFormModal.tsx:78`) ; aucun chemin parallèle via `saveProfiles`. Gater le modal couvre bien les 2 entrées (ProfileSwitcher et ProfileSelectionPage). - **Contrainte hors-router exacte** : `LicenseProvider` est monté au-dessus de `App` dans `main.tsx`, donc `useEntitlement` fonctionne sur ProfileSelectionPage (rendue hors BrowserRouter) ; le `useNavigate` inconditionnel d'UpsellGate y crasherait — le panneau compact est justifié. - **Anti-flash cohérent avec #299** : `Sidebar.tsx:47` applique `if (!ready || allowed) return null` ; les prédicats suivent la même règle (`if (!gate.ready || gate.allowed) return false`). - **Pas de contournement par PinDialog** : dans ProfileSwitcher, `pinProfile` n'est set qu'après le check `isLocked` — le PIN path est inatteignable sur une row verrouillée. Le remplacement du cadenas PIN par le cadenas gating sur les rows verrouillées est documenté. - **Edge cases couverts** : `activeProfileId === null` ne verrouille rien (never-lock-out), 1er profil jamais bloqué, `!ready` ne verrouille rien, race guard `handleSave` (fenêtre de boot licence). Gestion des profils existants (rename/PIN/delete) intacte. - **i18n** : les 9 clés réutilisées (`upsell.*`, `nav.locked`, `license.editions.*`, `profile.create`, `common.cancel`) existent toutes en FR et EN ; zéro texte en dur, zéro nouvelle clé comme annoncé. - **Tests** : 10 tests purs, matrice complète (ready/allowed/actif/null/compte) ; pas de skip/only ; suite déclarée verte (871). - **Data** : aucune migration touchée. Commit conventionnel, `Resolves #300` présent. - **CHANGELOG centralisé dans #302** : vérifié — l'entrée y couvre le gate multi-profils avec la référence #300. - **CI** : maillon intermédiaire de pile (base = `issue-299-routes-sidebar`) → `no-status` attendu ; valider le tip cumulé en local avant merge (gotcha connu). ### Suggestions (non bloquantes) 1. **DRY** : le panneau upsell compact de `ProfileFormModal` duplique le markup CTA d'`UpsellGate`. Une extraction future `UpsellPanel` (présentational, router-free) que `UpsellGate` wrapperait éliminerait la duplication tout en respectant la contrainte hors-router. 2. `profileGate.test.ts:36` — le libellé « (allowed short-circuits) » est inexact : dans `if (!gate.ready || gate.allowed)`, c'est `!ready` qui est évalué en premier pour `{allowed: true, ready: false}`. L'assertion reste correcte. 3. Bug cosmétique **préexistant** (hors diff, `ProfileFormModal.tsx:188`) : le label PIN affiche `({t("common.cancel").toLowerCase()})` → « (annuler) » là où on attend vraisemblablement « (optionnel) ». À corriger hors pile.
Author
Owner

Mergé en fast-forward sur main (pile #304→#308), voir commit b89074e. Verdict /pr-review : APPROVE. Fermeture manuelle : le merge local ff n est pas détecté comme merged par Forgejo.

Mergé en fast-forward sur main (pile #304→#308), voir commit b89074e. Verdict /pr-review : APPROVE. Fermeture manuelle : le merge local ff n est pas détecté comme *merged* par Forgejo.
maximus closed this pull request 2026-07-22 00:23:44 +00:00

Pull request closed

Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#306
No description provided.