pgkiln 0.60.0 → 0.62.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1714,16 +1714,16 @@ export const fr: BuilderMessages = {
1714
1714
  // ------------------------------------------------------------ Security: two-factor sign-in (sprint 126)
1715
1715
  'mfa.menu': 'Connexion à deux facteurs',
1716
1716
  'mfa.title': 'Sécurité',
1717
- 'mfa.intro': 'Connectez-vous avec un code d’une application d’authentification après votre mot de passe. Les codes de récupération vous laissent entrer si vous perdez le téléphone.',
1718
- 'mfa.required_you': 'Votre administrateur exige la connexion à deux facteurs : ajoutez une application d’authentification.',
1717
+ 'mfa.intro': 'Après votre mot de passe, confirmez que c’est vous avec une clé d’accès (empreinte, visage, clé de sécurité) ou un code d’une application d’authentification. Les codes de récupération vous laissent entrer si vous les perdez.',
1718
+ 'mfa.required_you': 'Votre administrateur exige la connexion à deux facteurs : ajoutez une clé d’accès ou une application d’authentification.',
1719
1719
  'mfa.banner': 'La connexion à deux facteurs est obligatoire. Configurez-la avant le {date} : {link}',
1720
1720
  'mfa.set_up_now': 'configurer maintenant',
1721
- 'mfa.apps': 'Applications d’authentification',
1721
+ 'mfa.factors': 'Clés d’accès et applications d’authentification',
1722
1722
  'mfa.app_name': 'Nom',
1723
1723
  'mfa.app_default': 'Téléphone',
1724
1724
  'mfa.added': 'Ajoutée',
1725
1725
  'mfa.last_used': 'Dernière utilisation',
1726
- 'mfa.no_apps': 'Pas encore d’application d’authentification : vous vous connectez avec votre mot de passe seul.',
1726
+ 'mfa.no_apps': 'Pas encore de second facteur : vous vous connectez avec votre mot de passe seul.',
1727
1727
  'mfa.add_app': 'Ajouter une application d’authentification',
1728
1728
  'mfa.no_key': 'La connexion à deux facteurs a besoin de la clé de l’instance (PGKILN_SECRET_KEY) pour stocker le secret de l’application.',
1729
1729
  'mfa.scan': 'Scannez le code QR avec une application d’authentification.',
@@ -1739,28 +1739,28 @@ export const fr: BuilderMessages = {
1739
1739
  'mfa.remove_app': 'Supprimer cette application',
1740
1740
  'mfa.your_password': 'Votre mot de passe',
1741
1741
  'mfa.wrong_password': 'Mot de passe incorrect.',
1742
- 'mfa.cannot_remove_required': 'La connexion à deux facteurs est obligatoire pour vous : ajoutez une autre application avant de supprimer celle-ci.',
1742
+ 'mfa.cannot_remove_required': 'La connexion à deux facteurs est obligatoire pour vous : ajoutez une autre clé ou application avant de supprimer celle-ci.',
1743
1743
  'mfa.cannot_remove_admin': 'Au moins deux administrateurs (ou tous, s’ils sont moins nombreux) doivent garder la connexion à deux facteurs tant que la règle est active.',
1744
1744
  'mfa.keep_admins': 'Pas maintenant : la règle à deux facteurs exige au moins deux administrateurs avec la connexion à deux facteurs (ou tous, s’ils sont moins nombreux).',
1745
- 'mfa.removed': 'Application d’authentification supprimée.',
1745
+ 'mfa.removed': 'Supprimé.',
1746
1746
  'mfa.codes_title': 'Codes de récupération',
1747
1747
  'mfa.codes_once': 'Conservez ces codes en lieu sûr, loin de votre téléphone. Chacun remplace une fois un code de l’application. Ils ne sont affichés que maintenant.',
1748
1748
  'mfa.codes_saved': 'J’ai conservé ces codes',
1749
1749
  'mfa.codes_done': 'Terminé',
1750
1750
  'mfa.codes_kept': 'Codes de récupération conservés.',
1751
1751
  'mfa.codes_left': 'Codes de récupération inutilisés : {n}.',
1752
- 'mfa.codes_after': 'Vous recevez des codes de récupération en ajoutant une application d’authentification.',
1752
+ 'mfa.codes_after': 'Vous recevez des codes de récupération en ajoutant une clé d’accès ou une application d’authentification.',
1753
1753
  'mfa.codes_low': 'Il reste peu de codes de récupération : créez-en de nouveaux.',
1754
1754
  'mfa.codes_new': 'Nouveaux codes de récupération',
1755
1755
  'mfa.codes_new_confirm': 'Créer de nouveaux codes de récupération ? Les anciens ne fonctionneront plus.',
1756
1756
  'mfa.expired': 'La connexion a pris trop de temps : reconnectez-vous.',
1757
1757
  'mfa.step_title': 'Connexion à deux facteurs',
1758
- 'mfa.step_intro': 'Saisissez le code de votre application d’authentification pour {user}.',
1758
+ 'mfa.step_intro': 'Confirmez que c’est vous, {user} : utilisez une clé d’accès ou saisissez un code de votre application d’authentification.',
1759
1759
  'mfa.code': 'Code',
1760
1760
  'mfa.code_help': '6 chiffres de l’application, ou l’un de vos codes de récupération.',
1761
1761
  'mfa.verify': 'Vérifier',
1762
1762
  'mfa.cancel': 'Retour à la connexion',
1763
- 'mfa.locked_out': 'Téléphone et codes perdus ? Demandez à un administrateur de réinitialiser votre connexion à deux facteurs.',
1763
+ 'mfa.locked_out': 'Téléphone, clé et codes perdus ? Demandez à un administrateur de réinitialiser votre connexion à deux facteurs.',
1764
1764
  'mfa.wrong': 'Ce code est incorrect ou a déjà été utilisé.',
1765
1765
  'mfa.column': 'Deux facteurs',
1766
1766
  'mfa.on': 'activée',
@@ -1784,4 +1784,40 @@ export const fr: BuilderMessages = {
1784
1784
  'mfa.policy_needs_admins': 'D’abord, au moins deux administrateurs (ou tous, s’ils sont moins nombreux) configurent la connexion à deux facteurs, pour que personne ne reste bloqué seul.',
1785
1785
  'mfa.without': 'Sans connexion à deux facteurs : {list}.',
1786
1786
  'mfa.cli_note': 'Bloqué ? Sur le serveur : pgkiln admin reset-2fa <developer> ou pgkiln admin set-2fa-policy off.',
1787
+ // ------------------------------------------------------------ Security: passkeys and trusted browsers (sprint 127)
1788
+ 'mfa.kind': 'Type',
1789
+ 'mfa.kind_passkey': 'Clés d’accès',
1790
+ 'mfa.kind_app': 'Applications d’authentification',
1791
+ 'mfa.other_domain': 'pour {domain}',
1792
+ 'mfa.other_domain_help': 'Créée pour {domain} : elle ne fonctionne pas à cette adresse. Ajoutez ici une nouvelle clé.',
1793
+ 'mfa.passkey_intro': 'Connectez-vous avec votre empreinte, votre visage, le verrouillage d’écran ou une clé de sécurité. Une clé d’accès ne fonctionne que sur ce site.',
1794
+ 'mfa.passkey_default': 'Clé d’accès',
1795
+ 'mfa.add_passkey': 'Ajouter une clé d’accès',
1796
+ 'mfa.passkey_needs_js': 'Ajouter une clé d’accès nécessite JavaScript ; une application d’authentification fonctionne sans.',
1797
+ 'mfa.passkey_failed': 'La clé d’accès n’a pas fonctionné. Réessayez ou utilisez un code.',
1798
+ 'mfa.passkey_known': 'Cette clé d’accès est déjà ajoutée.',
1799
+ 'mfa.passkey_added': 'Clé d’accès ajoutée : vous pouvez désormais vous connecter avec elle.',
1800
+ 'mfa.remove_passkey': 'Supprimer cette clé d’accès',
1801
+ 'mfa.use_passkey': 'Utiliser une clé d’accès',
1802
+ 'mfa.trust_browser': 'Faire confiance à ce navigateur pendant {days} jours',
1803
+ 'mfa.trusted_title': 'Navigateurs de confiance',
1804
+ 'mfa.trusted_count': 'Navigateurs qui passent la seconde étape : {n} (jusqu’à {days} jours).',
1805
+ 'mfa.trusted_off': 'La confiance envers les navigateurs est désactivée.',
1806
+ 'mfa.forget_browsers': 'Oublier les navigateurs de confiance',
1807
+ 'mfa.browsers_forgotten': 'Chaque navigateur redemande la seconde étape.',
1808
+ 'mfa.trust_days': 'Faire confiance à un navigateur (jours)',
1809
+ 'mfa.trust_days_help': 'Après la seconde étape, un développeur peut la passer dans ce navigateur pendant ce nombre de jours. 0 : jamais.',
1810
+ 'mfa.passkey_needs_host': 'Les clés d’accès ont besoin d’un nom d’hôte, pas d’une adresse IP ({host}) : ouvrez le builder sur localhost ou son nom de domaine.',
1811
+ 'signin.passkey': 'Se connecter avec une clé d’accès',
1812
+ 'mfa.passkey_signin': 'Les développeurs peuvent se connecter avec une clé d’accès seule (sans mot de passe)',
1813
+ 'mfa.passkey_signin_help': 'Seulement quand l’appareil a vérifié le développeur (empreinte, visage ou code PIN) : la clé compte alors pour les deux facteurs.',
1814
+ 'mfa.weak_required': 'La connexion à deux facteurs est obligatoire : changez le mot de passe par défaut avant de continuer.',
1815
+ 'wsp.mfa': 'Connexion à deux facteurs',
1816
+ 'wsp.mfa_required': 'Exiger la connexion à deux facteurs des développeurs de cet espace de travail',
1817
+ 'wsp.mfa_required_help': 'En plus de la politique de l’instance. Les développeurs sans clé d’accès ni application d’authentification ont le délai de grâce de l’instance, puis seulement leur page Sécurité.',
1818
+ 'wsp.mfa_since': 'Obligatoire depuis le {date}.',
1819
+ 'wsp.mfa_without': 'Pas encore configuré : {list}.',
1820
+ 'wsp.mfa_save': 'Enregistrer la règle à deux facteurs',
1821
+ 'wsp.mfa_on': 'Les développeurs de cet espace de travail ont besoin de la connexion à deux facteurs.',
1822
+ 'wsp.mfa_off': 'L’espace de travail n’exige plus la connexion à deux facteurs (la politique de l’instance s’applique toujours).',
1787
1823
  };
@@ -1714,16 +1714,16 @@ export const nl: BuilderMessages = {
1714
1714
  // ------------------------------------------------------------ Security: two-factor sign-in (sprint 126)
1715
1715
  'mfa.menu': 'Aanmelden in twee stappen',
1716
1716
  'mfa.title': 'Beveiliging',
1717
- 'mfa.intro': 'Meld u na uw wachtwoord aan met een code uit een authenticator-app. Met herstelcodes komt u binnen als u de telefoon kwijtraakt.',
1718
- 'mfa.required_you': 'Uw beheerder vereist aanmelden in twee stappen: voeg een authenticator-app toe.',
1717
+ 'mfa.intro': 'Bevestig na uw wachtwoord dat u het bent met een passkey (vingerafdruk, gezicht, beveiligingssleutel) of een code uit een authenticator-app. Met herstelcodes komt u binnen als u die kwijtraakt.',
1718
+ 'mfa.required_you': 'Uw beheerder vereist aanmelden in twee stappen: voeg een passkey of een authenticator-app toe.',
1719
1719
  'mfa.banner': 'Aanmelden in twee stappen is verplicht. Stel het in vóór {date}: {link}',
1720
1720
  'mfa.set_up_now': 'nu instellen',
1721
- 'mfa.apps': 'Authenticator-apps',
1721
+ 'mfa.factors': 'Passkeys en authenticator-apps',
1722
1722
  'mfa.app_name': 'Naam',
1723
1723
  'mfa.app_default': 'Telefoon',
1724
1724
  'mfa.added': 'Toegevoegd',
1725
1725
  'mfa.last_used': 'Laatst gebruikt',
1726
- 'mfa.no_apps': 'Nog geen authenticator-app: u meldt zich alleen met uw wachtwoord aan.',
1726
+ 'mfa.no_apps': 'Nog geen tweede factor: u meldt zich alleen met uw wachtwoord aan.',
1727
1727
  'mfa.add_app': 'Authenticator-app toevoegen',
1728
1728
  'mfa.no_key': 'Aanmelden in twee stappen heeft de instantiesleutel (PGKILN_SECRET_KEY) nodig om het geheim van de app op te slaan.',
1729
1729
  'mfa.scan': 'Scan de QR-code met een authenticator-app.',
@@ -1739,28 +1739,28 @@ export const nl: BuilderMessages = {
1739
1739
  'mfa.remove_app': 'Deze app verwijderen',
1740
1740
  'mfa.your_password': 'Uw wachtwoord',
1741
1741
  'mfa.wrong_password': 'Onjuist wachtwoord.',
1742
- 'mfa.cannot_remove_required': 'Aanmelden in twee stappen is voor u verplicht: voeg eerst een andere app toe.',
1742
+ 'mfa.cannot_remove_required': 'Aanmelden in twee stappen is voor u verplicht: voeg eerst een andere passkey of app toe.',
1743
1743
  'mfa.cannot_remove_admin': 'Minstens twee beheerders (of alle, als het er minder zijn) moeten aanmelden in twee stappen houden zolang het beleid aan staat.',
1744
1744
  'mfa.keep_admins': 'Nu niet: het beleid vereist minstens twee beheerders met aanmelden in twee stappen (of alle, als het er minder zijn).',
1745
- 'mfa.removed': 'Authenticator-app verwijderd.',
1745
+ 'mfa.removed': 'Verwijderd.',
1746
1746
  'mfa.codes_title': 'Herstelcodes',
1747
1747
  'mfa.codes_once': 'Bewaar deze codes op een veilige plek, niet bij uw telefoon. Elke code vervangt één keer een app-code. Ze worden alleen nu getoond.',
1748
1748
  'mfa.codes_saved': 'Ik heb deze codes bewaard',
1749
1749
  'mfa.codes_done': 'Klaar',
1750
1750
  'mfa.codes_kept': 'Herstelcodes bewaard.',
1751
1751
  'mfa.codes_left': 'Ongebruikte herstelcodes: {n}.',
1752
- 'mfa.codes_after': 'U krijgt herstelcodes zodra u een authenticator-app toevoegt.',
1752
+ 'mfa.codes_after': 'U krijgt herstelcodes zodra u een passkey of authenticator-app toevoegt.',
1753
1753
  'mfa.codes_low': 'Nog maar weinig herstelcodes: maak nieuwe.',
1754
1754
  'mfa.codes_new': 'Nieuwe herstelcodes',
1755
1755
  'mfa.codes_new_confirm': 'Nieuwe herstelcodes maken? De oude werken dan niet meer.',
1756
1756
  'mfa.expired': 'Het aanmelden duurde te lang: meld u opnieuw aan.',
1757
1757
  'mfa.step_title': 'Aanmelden in twee stappen',
1758
- 'mfa.step_intro': 'Voer de code uit uw authenticator-app voor {user} in.',
1758
+ 'mfa.step_intro': 'Bevestig dat u het bent, {user}: gebruik een passkey of voer een code uit uw authenticator-app in.',
1759
1759
  'mfa.code': 'Code',
1760
1760
  'mfa.code_help': '6 cijfers uit de app, of een van uw herstelcodes.',
1761
1761
  'mfa.verify': 'Controleren',
1762
1762
  'mfa.cancel': 'Terug naar aanmelden',
1763
- 'mfa.locked_out': 'Telefoon en codes kwijt? Vraag een beheerder uw aanmelden in twee stappen te resetten.',
1763
+ 'mfa.locked_out': 'Telefoon, sleutel en codes kwijt? Vraag een beheerder uw aanmelden in twee stappen te resetten.',
1764
1764
  'mfa.wrong': 'Die code klopt niet of is al gebruikt.',
1765
1765
  'mfa.column': 'Twee stappen',
1766
1766
  'mfa.on': 'aan',
@@ -1784,4 +1784,40 @@ export const nl: BuilderMessages = {
1784
1784
  'mfa.policy_needs_admins': 'Eerst moeten minstens twee beheerders (of alle, als het er minder zijn) aanmelden in twee stappen instellen, zodat niemand alleen buitengesloten raakt.',
1785
1785
  'mfa.without': 'Zonder aanmelden in twee stappen: {list}.',
1786
1786
  'mfa.cli_note': 'Buitengesloten? Op de server: pgkiln admin reset-2fa <developer> of pgkiln admin set-2fa-policy off.',
1787
+ // ------------------------------------------------------------ Security: passkeys and trusted browsers (sprint 127)
1788
+ 'mfa.kind': 'Soort',
1789
+ 'mfa.kind_passkey': 'Passkeys',
1790
+ 'mfa.kind_app': 'Authenticator-apps',
1791
+ 'mfa.other_domain': 'voor {domain}',
1792
+ 'mfa.other_domain_help': 'Gemaakt voor {domain}: werkt niet op dit adres. Voeg hier een nieuwe passkey toe.',
1793
+ 'mfa.passkey_intro': 'Meld u aan met uw vingerafdruk, gezicht, schermvergrendeling of een beveiligingssleutel. Een passkey werkt alleen op deze site.',
1794
+ 'mfa.passkey_default': 'Passkey',
1795
+ 'mfa.add_passkey': 'Passkey toevoegen',
1796
+ 'mfa.passkey_needs_js': 'Een passkey toevoegen vraagt JavaScript; een authenticator-app werkt zonder.',
1797
+ 'mfa.passkey_failed': 'De passkey werkte niet. Probeer het opnieuw of gebruik een code.',
1798
+ 'mfa.passkey_known': 'Deze passkey is al toegevoegd.',
1799
+ 'mfa.passkey_added': 'Passkey toegevoegd: voortaan kunt u zich ermee aanmelden.',
1800
+ 'mfa.remove_passkey': 'Deze passkey verwijderen',
1801
+ 'mfa.use_passkey': 'Passkey gebruiken',
1802
+ 'mfa.trust_browser': 'Deze browser {days} dagen vertrouwen',
1803
+ 'mfa.trusted_title': 'Vertrouwde browsers',
1804
+ 'mfa.trusted_count': 'Browsers die de tweede stap overslaan: {n} (hoogstens {days} dagen).',
1805
+ 'mfa.trusted_off': 'Browsers vertrouwen staat uit.',
1806
+ 'mfa.forget_browsers': 'Vertrouwde browsers vergeten',
1807
+ 'mfa.browsers_forgotten': 'Elke browser vraagt weer om de tweede stap.',
1808
+ 'mfa.trust_days': 'Browser vertrouwen (dagen)',
1809
+ 'mfa.trust_days_help': 'Na de tweede stap mag een ontwikkelaar die in die browser zoveel dagen overslaan. 0: nooit.',
1810
+ 'mfa.passkey_needs_host': 'Passkeys hebben een hostnaam nodig, geen IP-adres ({host}): open de builder via localhost of de domeinnaam.',
1811
+ 'signin.passkey': 'Aanmelden met een passkey',
1812
+ 'mfa.passkey_signin': 'Ontwikkelaars mogen alleen met een passkey aanmelden (zonder wachtwoord)',
1813
+ 'mfa.passkey_signin_help': 'Alleen als het apparaat de ontwikkelaar heeft gecontroleerd (vingerafdruk, gezicht of pincode): de passkey telt dan als beide factoren.',
1814
+ 'mfa.weak_required': 'Tweestapsaanmelding is verplicht: wijzig het standaardwachtwoord voordat u verdergaat.',
1815
+ 'wsp.mfa': 'Tweestapsaanmelding',
1816
+ 'wsp.mfa_required': 'Tweestapsaanmelding verplichten voor de ontwikkelaars van deze werkruimte',
1817
+ 'wsp.mfa_required_help': 'Bovenop het beleid van de instantie. Ontwikkelaars zonder passkey of authenticator-app krijgen de overgangstermijn van de instantie, daarna alleen hun beveiligingspagina.',
1818
+ 'wsp.mfa_since': 'Verplicht sinds {date}.',
1819
+ 'wsp.mfa_without': 'Nog niet ingesteld: {list}.',
1820
+ 'wsp.mfa_save': 'Tweestapsregel opslaan',
1821
+ 'wsp.mfa_on': 'De ontwikkelaars van deze werkruimte hebben tweestapsaanmelding nodig.',
1822
+ 'wsp.mfa_off': 'De werkruimte verplicht geen tweestapsaanmelding meer (het beleid van de instantie blijft gelden).',
1787
1823
  };
@@ -1714,16 +1714,16 @@ export const pt: BuilderMessages = {
1714
1714
  // ------------------------------------------------------------ Security: two-factor sign-in (sprint 126)
1715
1715
  'mfa.menu': 'Login em duas etapas',
1716
1716
  'mfa.title': 'Segurança',
1717
- 'mfa.intro': 'Entre com um código de um aplicativo autenticador depois da senha. Os códigos de recuperação permitem entrar se você perder o telefone.',
1718
- 'mfa.required_you': 'Seu administrador exige login em duas etapas: adicione um aplicativo autenticador.',
1717
+ 'mfa.intro': 'Depois da senha, confirme que é você com uma chave de acesso (digital, rosto, chave de segurança) ou um código de um aplicativo autenticador. Os códigos de recuperação permitem entrar se você os perder.',
1718
+ 'mfa.required_you': 'Seu administrador exige login em duas etapas: adicione uma chave de acesso ou um aplicativo autenticador.',
1719
1719
  'mfa.banner': 'O login em duas etapas é obrigatório. Configure-o antes de {date}: {link}',
1720
1720
  'mfa.set_up_now': 'configurar agora',
1721
- 'mfa.apps': 'Aplicativos autenticadores',
1721
+ 'mfa.factors': 'Chaves de acesso e aplicativos autenticadores',
1722
1722
  'mfa.app_name': 'Nome',
1723
1723
  'mfa.app_default': 'Telefone',
1724
1724
  'mfa.added': 'Adicionado',
1725
1725
  'mfa.last_used': 'Último uso',
1726
- 'mfa.no_apps': 'Nenhum aplicativo autenticador ainda: você entra apenas com sua senha.',
1726
+ 'mfa.no_apps': 'Nenhum segundo fator ainda: você entra apenas com sua senha.',
1727
1727
  'mfa.add_app': 'Adicionar um aplicativo autenticador',
1728
1728
  'mfa.no_key': 'O login em duas etapas precisa da chave da instância (PGKILN_SECRET_KEY) para guardar o segredo do aplicativo.',
1729
1729
  'mfa.scan': 'Leia o código QR com um aplicativo autenticador.',
@@ -1739,28 +1739,28 @@ export const pt: BuilderMessages = {
1739
1739
  'mfa.remove_app': 'Remover este aplicativo',
1740
1740
  'mfa.your_password': 'Sua senha',
1741
1741
  'mfa.wrong_password': 'Senha incorreta.',
1742
- 'mfa.cannot_remove_required': 'O login em duas etapas é obrigatório para você: adicione outro aplicativo antes de remover este.',
1742
+ 'mfa.cannot_remove_required': 'O login em duas etapas é obrigatório para você: adicione outra chave ou aplicativo antes de remover este.',
1743
1743
  'mfa.cannot_remove_admin': 'Pelo menos dois administradores (ou todos, se forem menos) devem manter o login em duas etapas enquanto a política estiver ativa.',
1744
1744
  'mfa.keep_admins': 'Agora não: a política exige pelo menos dois administradores com login em duas etapas (ou todos, se forem menos).',
1745
- 'mfa.removed': 'Aplicativo autenticador removido.',
1745
+ 'mfa.removed': 'Removido.',
1746
1746
  'mfa.codes_title': 'Códigos de recuperação',
1747
1747
  'mfa.codes_once': 'Guarde estes códigos em local seguro, longe do telefone. Cada um substitui uma vez um código do aplicativo. Eles são mostrados só agora.',
1748
1748
  'mfa.codes_saved': 'Guardei estes códigos',
1749
1749
  'mfa.codes_done': 'Concluído',
1750
1750
  'mfa.codes_kept': 'Códigos de recuperação guardados.',
1751
1751
  'mfa.codes_left': 'Códigos de recuperação não usados: {n}.',
1752
- 'mfa.codes_after': 'Você recebe códigos de recuperação ao adicionar um aplicativo autenticador.',
1752
+ 'mfa.codes_after': 'Você recebe códigos de recuperação ao adicionar uma chave de acesso ou um aplicativo autenticador.',
1753
1753
  'mfa.codes_low': 'Restam poucos códigos de recuperação: gere novos.',
1754
1754
  'mfa.codes_new': 'Novos códigos de recuperação',
1755
1755
  'mfa.codes_new_confirm': 'Gerar novos códigos de recuperação? Os antigos deixam de funcionar.',
1756
1756
  'mfa.expired': 'O login demorou demais: entre novamente.',
1757
1757
  'mfa.step_title': 'Login em duas etapas',
1758
- 'mfa.step_intro': 'Digite o código do seu aplicativo autenticador para {user}.',
1758
+ 'mfa.step_intro': 'Confirme que é você, {user}: use uma chave de acesso ou digite um código do seu aplicativo autenticador.',
1759
1759
  'mfa.code': 'Código',
1760
1760
  'mfa.code_help': '6 dígitos do aplicativo ou um dos seus códigos de recuperação.',
1761
1761
  'mfa.verify': 'Verificar',
1762
1762
  'mfa.cancel': 'Voltar ao login',
1763
- 'mfa.locked_out': 'Perdeu o telefone e os códigos? Peça a um administrador para redefinir seu login em duas etapas.',
1763
+ 'mfa.locked_out': 'Perdeu o telefone, a chave e os códigos? Peça a um administrador para redefinir seu login em duas etapas.',
1764
1764
  'mfa.wrong': 'Esse código não está certo ou já foi usado.',
1765
1765
  'mfa.column': 'Duas etapas',
1766
1766
  'mfa.on': 'ativado',
@@ -1784,4 +1784,40 @@ export const pt: BuilderMessages = {
1784
1784
  'mfa.policy_needs_admins': 'Primeiro, pelo menos dois administradores (ou todos, se forem menos) configuram o login em duas etapas, para ninguém ficar bloqueado sozinho.',
1785
1785
  'mfa.without': 'Sem login em duas etapas: {list}.',
1786
1786
  'mfa.cli_note': 'Bloqueado? No servidor: pgkiln admin reset-2fa <developer> ou pgkiln admin set-2fa-policy off.',
1787
+ // ------------------------------------------------------------ Security: passkeys and trusted browsers (sprint 127)
1788
+ 'mfa.kind': 'Tipo',
1789
+ 'mfa.kind_passkey': 'Chaves de acesso',
1790
+ 'mfa.kind_app': 'Aplicativos autenticadores',
1791
+ 'mfa.other_domain': 'para {domain}',
1792
+ 'mfa.other_domain_help': 'Criada para {domain}: não funciona neste endereço. Adicione aqui uma nova chave.',
1793
+ 'mfa.passkey_intro': 'Entre com sua digital, rosto, bloqueio de tela ou uma chave de segurança. Uma chave de acesso só funciona neste site.',
1794
+ 'mfa.passkey_default': 'Chave de acesso',
1795
+ 'mfa.add_passkey': 'Adicionar uma chave de acesso',
1796
+ 'mfa.passkey_needs_js': 'Adicionar uma chave de acesso exige JavaScript; um aplicativo autenticador funciona sem ele.',
1797
+ 'mfa.passkey_failed': 'A chave de acesso não funcionou. Tente de novo ou use um código.',
1798
+ 'mfa.passkey_known': 'Esta chave de acesso já foi adicionada.',
1799
+ 'mfa.passkey_added': 'Chave de acesso adicionada: a partir de agora você pode entrar com ela.',
1800
+ 'mfa.remove_passkey': 'Remover esta chave de acesso',
1801
+ 'mfa.use_passkey': 'Usar uma chave de acesso',
1802
+ 'mfa.trust_browser': 'Confiar neste navegador por {days} dias',
1803
+ 'mfa.trusted_title': 'Navegadores confiáveis',
1804
+ 'mfa.trusted_count': 'Navegadores que pulam a segunda etapa: {n} (até {days} dias).',
1805
+ 'mfa.trusted_off': 'A confiança em navegadores está desativada.',
1806
+ 'mfa.forget_browsers': 'Esquecer os navegadores confiáveis',
1807
+ 'mfa.browsers_forgotten': 'Todos os navegadores voltam a pedir a segunda etapa.',
1808
+ 'mfa.trust_days': 'Confiar em um navegador (dias)',
1809
+ 'mfa.trust_days_help': 'Depois da segunda etapa, um desenvolvedor pode pulá-la naquele navegador por estes dias. 0: nunca.',
1810
+ 'mfa.passkey_needs_host': 'Chaves de acesso precisam de um nome de host, não de um endereço IP ({host}): abra o construtor em localhost ou no nome de domínio.',
1811
+ 'signin.passkey': 'Entrar com uma chave de acesso',
1812
+ 'mfa.passkey_signin': 'Os desenvolvedores podem entrar só com uma chave de acesso (sem senha)',
1813
+ 'mfa.passkey_signin_help': 'Só quando o dispositivo verificou o desenvolvedor (impressão digital, rosto ou PIN): a chave conta então como os dois fatores.',
1814
+ 'mfa.weak_required': 'O login em duas etapas é obrigatório: altere a senha padrão antes de continuar.',
1815
+ 'wsp.mfa': 'Login em duas etapas',
1816
+ 'wsp.mfa_required': 'Exigir login em duas etapas dos desenvolvedores deste espaço de trabalho',
1817
+ 'wsp.mfa_required_help': 'Além da política da instância. Desenvolvedores sem chave de acesso ou app autenticador têm o período de carência da instância e depois só a página de segurança.',
1818
+ 'wsp.mfa_since': 'Obrigatório desde {date}.',
1819
+ 'wsp.mfa_without': 'Ainda não configurado: {list}.',
1820
+ 'wsp.mfa_save': 'Salvar a regra de duas etapas',
1821
+ 'wsp.mfa_on': 'Os desenvolvedores deste espaço de trabalho precisam do login em duas etapas.',
1822
+ 'wsp.mfa_off': 'O espaço de trabalho já não exige login em duas etapas (a política da instância continua valendo).',
1787
1823
  };
@@ -27,7 +27,10 @@ import { appFromFileRoutes } from './appfromfile.ts';
27
27
  import { clearDevbar } from '../runtime/devbar.ts';
28
28
  import { EMAIL } from './find-workspace.ts';
29
29
  import { cookieLanguage, LANG_COOKIE, tr, translatorFor, validBuilderLanguage } from './i18n.ts';
30
- import { checkSecondFactor, codesLeft, hasFactor, leavesTooFewAdmins } from '../mfa.ts';
30
+ import { checkSecondFactor, codesLeft, forgetBrowsers, hasFactor, isTrustedBrowser, leavesTooFewAdmins, passkeyByCredential, passkeySignin, passkeysOf, trustBrowser, trustDays, usePasskey, userHandle } from '../mfa.ts';
31
+ import { isIP } from 'node:net';
32
+ import { giveChallenge, takeChallenge } from './security.ts';
33
+ import { relyingParty, verifyAssertion, WebAuthnError } from '../webauthn.ts';
31
34
  import { chooseLanguage, langCookieOptions, languageRoutes, signInLanguageLinks } from './language.ts';
32
35
 
33
36
  // Builder pages: sign-in, workspace and app home, settings, activity and
@@ -65,6 +68,8 @@ export async function builderRoutes(app: FastifyInstance) {
65
68
  await saveState(s);
66
69
  const r = remembered(req);
67
70
  const next = safeNext(req.query?.next);
71
+ // (sprint 128) a passkey alone, when the instance allows it and the address is a host name
72
+ const alone = (await passkeySignin()) && !isIP(relyingParty(req).rpId);
68
73
  const field = (name: string, label: string, ic: string, value: string, opts: { type?: string; required?: boolean; auto?: string; placeholder?: string; reveal?: boolean }) =>
69
74
  html`<div class="field signin-field"><label class="sr-only" for="f_${name}">${label}</label>
70
75
  <div class="signin-input">${icon(ic)}<input id="f_${name}" name="${name}" type="${opts.type ?? 'text'}" value="${value}" placeholder="${opts.placeholder ?? label}"${opts.required ? raw(' required') : ''}${opts.auto ? raw(` autocomplete="${opts.auto}"`) : ''}>
@@ -90,6 +95,9 @@ export async function builderRoutes(app: FastifyInstance) {
90
95
  ${field('password', tx('signin.password'), 'key', '', { type: 'password', required: true, auto: 'current-password', reveal: true })}
91
96
  <label class="check signin-remember"><input type="checkbox" name="remember" value="1"${r ? raw(' checked') : ''}> ${tx('signin.remember')}</label>
92
97
  <button class="btn btn-hot signin-submit">${tx('signin.submit')}</button>
98
+ ${alone ? html`<input type="hidden" name="credentialId"><input type="hidden" name="clientDataJSON"><input type="hidden" name="authenticatorData"><input type="hidden" name="signature"><input type="hidden" name="userHandle">
99
+ <button class="btn signin-submit signin-passkey" hidden data-passkey="${BASE}/login/passkey/options" data-passkey-submit="${BASE}/login/passkey" data-passkey-error="${tx('mfa.passkey_failed')}">${icon('fingerprint')} ${tx('signin.passkey')}</button>
100
+ <p class="small" role="status" data-passkey-status></p>` : ''}
93
101
  <p class="muted small signin-hint">${tx('signin.hint')}</p>
94
102
  <nav class="signin-actions" aria-label="${tx('signin.quick')}">
95
103
  <a href="${BASE}/find-workspace">${icon('search')} ${tx('signin.find_ws')}</a>
@@ -136,22 +144,15 @@ export async function builderRoutes(app: FastifyInstance) {
136
144
  [username, password],
137
145
  );
138
146
  // a workspace, when given, must be one of the developer's (administrators: any); the same answer otherwise
139
- const ws = dev && workspace
140
- ? await owner.one<{ id: number }>(
141
- `select w.id from meta.workspace w
142
- where lower(w.name) = lower($1)
143
- and (exists (select 1 from meta.developer d where d.username = $2 and d.is_admin)
144
- or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $2))`,
145
- [workspace, dev.username],
146
- )
147
- : undefined;
147
+ const ws = dev && workspace ? await workspaceOf(workspace, dev.username) : undefined;
148
148
  if (!dev || (workspace && !ws)) {
149
149
  await logActivity({ username: username.slice(0, 100), event: 'login_failed', ip, detail: workspace ? 'builder (workspace)' : 'builder' });
150
150
  flash(s, tx(workspace ? 'signin.invalid_ws' : 'signin.invalid'), 'error');
151
151
  return back(reply, s, again);
152
152
  }
153
- // (sprint 126, SEC-2) a developer with a second factor: the password was right, the session comes after the code
154
- if (await hasFactor(dev.username)) {
153
+ // (sprint 126, SEC-2) a developer with a second factor: the password was right, the session comes after the code;
154
+ // (sprint 127) unless this browser was trusted after a second factor, for the instance's number of days
155
+ if ((await hasFactor(dev.username)) && !(await isTrustedBrowser(dev.username, req.cookies?.[TRUST_COOKIE]))) {
155
156
  s.state.__MFA = JSON.stringify({ u: dev.username, ws: ws ? String(ws.id) : null, w: workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, at: Date.now() });
156
157
  await saveState(s);
157
158
  return reply.redirect(`${BASE}/login/2fa`, 303);
@@ -159,6 +160,75 @@ export async function builderRoutes(app: FastifyInstance) {
159
160
  return finishSignIn(req, reply, s, { username: dev.username, language: dev.language }, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, picked, detail: 'builder' });
160
161
  });
161
162
 
163
+ /** A workspace by name, when it is one of the developer's (administrators: any). */
164
+ const workspaceOf = (workspace: string, username: string) =>
165
+ owner.one<{ id: number }>(
166
+ `select w.id from meta.workspace w
167
+ where lower(w.name) = lower($1)
168
+ and (exists (select 1 from meta.developer d where d.username = $2 and d.is_admin)
169
+ or exists (select 1 from meta.workspace_member m where m.workspace_id = w.id and m.username = $2))`,
170
+ [workspace, username],
171
+ );
172
+
173
+ // (sprint 128, SEC-2) signing in with a passkey alone: no user name, no password. The browser offers the
174
+ // passkeys it holds for this site (discoverable credentials); the authenticator must verify the developer
175
+ // (fingerprint, face, PIN), so the passkey counts as both factors. Failures count with wrong passwords.
176
+ app.post(`${BASE}/login/passkey/options`, async (req: Req, reply) => {
177
+ const s = await getSession(req, reply, null, BASE);
178
+ if (req.body?.__csrf !== s.csrf_token) return reply.code(403).send('Reload the page.');
179
+ if (!(await passkeySignin())) return reply.code(404).send('Passkey sign-in is off.');
180
+ const challenge = giveChallenge(s as Parameters<typeof giveChallenge>[0], 'get');
181
+ await saveState(s);
182
+ reply.header('cache-control', 'no-store');
183
+ return reply.send({ mode: 'get', challenge, rpId: relyingParty(req).rpId, allow: [], uv: 'required' });
184
+ });
185
+ app.post(`${BASE}/login/passkey`, async (req: Req, reply) => {
186
+ const s = await getSession(req, reply, null, BASE);
187
+ const ip = clientIp(req);
188
+ const picked = validBuilderLanguage(s.state.__BLANG);
189
+ const tx = translatorFor(picked ?? cookieLanguage(req));
190
+ const workspace = String(req.body?.workspace ?? '').trim().slice(0, 60);
191
+ const next = safeNext(req.body?.next);
192
+ const again = next === `${BASE}/home` ? `${BASE}/login` : `${BASE}/login?next=${encodeURIComponent(next)}`;
193
+ const challenge = takeChallenge(s as Parameters<typeof takeChallenge>[0], 'get');
194
+ if (req.body?.__csrf !== s.csrf_token || !(await passkeySignin())) {
195
+ flash(s, tx('signin.expired'), 'error');
196
+ return back(reply, s, again);
197
+ }
198
+ const rp = relyingParty(req);
199
+ const key = await passkeyByCredential(String(req.body?.credentialId ?? ''), rp.rpId);
200
+ const who = key?.username ?? '';
201
+ if (await loginThrottled(null, who, ip)) {
202
+ logActivity({ username: who, event: 'login_locked', ip, detail: 'builder passkey' });
203
+ flash(s, tx('signin.throttled'), 'error');
204
+ return back(reply, s, again);
205
+ }
206
+ let why = key ? '' : 'unknown passkey';
207
+ if (key && !challenge) why = 'no or an expired challenge';
208
+ else if (key && req.body?.userHandle && req.body.userHandle !== userHandle(key.username)) why = 'the user handle does not match';
209
+ else if (key && challenge) {
210
+ try {
211
+ const r = verifyAssertion(
212
+ { clientDataJSON: String(req.body?.clientDataJSON ?? ''), authenticatorData: String(req.body?.authenticatorData ?? ''), signature: String(req.body?.signature ?? '') },
213
+ { alg: key.alg, jwk: key.public_key, signCount: Number(key.sign_count) }, { challenge, ...rp });
214
+ if (!r.userVerified) why = 'the authenticator did not verify the user';
215
+ else if (!(await usePasskey(key.id, key.sign_count, r.signCount))) why = 'used at the same time elsewhere';
216
+ } catch (e) {
217
+ why = e instanceof WebAuthnError ? e.message : 'unreadable answer';
218
+ }
219
+ }
220
+ const ws = !why && workspace ? await workspaceOf(workspace, who) : undefined;
221
+ if (!why && workspace && !ws) why = 'not a workspace of the developer';
222
+ if (why) {
223
+ await logActivity({ username: who.slice(0, 100), event: 'login_failed', ip, detail: `builder passkey: ${why}` });
224
+ flash(s, tx(why === 'not a workspace of the developer' ? 'signin.invalid_ws' : 'mfa.passkey_failed'), 'error');
225
+ return back(reply, s, again);
226
+ }
227
+ const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [who]);
228
+ if (!dev) return back(reply, s, again);
229
+ return finishSignIn(req, reply, s, dev, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak: false, picked, detail: 'builder passkey' });
230
+ });
231
+
162
232
  /** The session after a successful sign-in (password, and the second factor when the developer has one). */
163
233
  async function finishSignIn(req: Req, reply: Parameters<typeof back>[0], s: Awaited<ReturnType<typeof getSession>>, dev: { username: string; language: string | null },
164
234
  o: { ws: string | null; workspace: string; next: string; remember: boolean; weak: boolean; picked: ReturnType<typeof validBuilderLanguage>; detail: string }) {
@@ -187,8 +257,17 @@ export async function builderRoutes(app: FastifyInstance) {
187
257
  return reply.redirect(next, 303);
188
258
  }
189
259
 
190
- // (sprint 126, SEC-2) the second step: a code from the authenticator app, or a recovery code
260
+ // (sprint 126, SEC-2) the second step: a code from the authenticator app, or a recovery code;
261
+ // (sprint 127) or a passkey, and "trust this browser" when the instance allows it
191
262
  const MFA_SECONDS = 300;
263
+ const TRUST_COOKIE = 'pgkiln_btrust';
264
+ const trustThisBrowser = async (req: Req, reply: Parameters<typeof back>[0], username: string) => {
265
+ const days = await trustDays();
266
+ if (!days || req.body?.trust !== '1') return;
267
+ const token = await trustBrowser(username, days, String(req.headers['user-agent'] ?? ''));
268
+ reply.setCookie(TRUST_COOKIE, token, { path: BASE, httpOnly: true, sameSite: 'lax', maxAge: days * 86400, secure: process.env.COOKIE_SECURE === 'true' });
269
+ logActivity({ username, event: '2fa_trusted_browser', ip: clientIp(req), detail: `${days} days` });
270
+ };
192
271
  const pendingMfa = (s: { state: Record<string, any> }) => {
193
272
  try {
194
273
  const p = JSON.parse(String(s.state.__MFA ?? ''));
@@ -209,6 +288,7 @@ export async function builderRoutes(app: FastifyInstance) {
209
288
  }
210
289
  const error = takeFlash(s, '__ERROR');
211
290
  await saveState(s);
291
+ const [keys, days] = await Promise.all([passkeysOf(p.u, relyingParty(req).rpId), trustDays()]);
212
292
  return reply.type('text/html').send(documentShell(
213
293
  `${tx('mfa.step_title')} · ${tx('shell.builder')}`,
214
294
  html`<main class="signin">
@@ -223,10 +303,14 @@ export async function builderRoutes(app: FastifyInstance) {
223
303
  <p class="muted u-mt0">${tx('mfa.step_intro', { user: p.u })}</p>
224
304
  ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
225
305
  ${csrf(s)}
306
+ ${keys.length ? html`<input type="hidden" name="credentialId"><input type="hidden" name="clientDataJSON"><input type="hidden" name="authenticatorData"><input type="hidden" name="signature">
307
+ <button class="btn btn-hot signin-submit" hidden data-passkey="${BASE}/login/2fa/passkey/options" data-passkey-submit="${BASE}/login/2fa/passkey" data-passkey-error="${tx('mfa.passkey_failed')}">${icon('fingerprint')} ${tx('mfa.use_passkey')}</button>
308
+ <p class="small" role="status" data-passkey-status></p>` : ''}
226
309
  <div class="field signin-field"><label class="label" for="f_code">${tx('mfa.code')}</label>
227
310
  <div class="signin-input">${icon('key')}<input id="f_code" name="code" required autocomplete="one-time-code" autofocus maxlength="20" spellcheck="false" autocapitalize="off"></div>
228
311
  <small class="help">${tx('mfa.code_help')}</small></div>
229
- <button class="btn btn-hot signin-submit">${tx('mfa.verify')}</button>
312
+ ${days ? html`<label class="check"><input type="checkbox" name="trust" value="1"> ${tx('mfa.trust_browser', { days })}</label>` : ''}
313
+ <button class="btn${keys.length ? '' : ' btn-hot'} signin-submit">${tx('mfa.verify')}</button>
230
314
  <p class="muted small signin-hint"><a href="${BASE}/login">${tx('mfa.cancel')}</a> · ${tx('mfa.locked_out')}</p>
231
315
  </form>
232
316
  </section>
@@ -265,11 +349,69 @@ export async function builderRoutes(app: FastifyInstance) {
265
349
  const left = await codesLeft(p.u);
266
350
  await logActivity({ username: p.u, event: '2fa_recovery_used', ip, detail: `${left} left` });
267
351
  // to the Security page: one code fewer, maybe time for new ones
352
+ await trustThisBrowser(req, reply, p.u);
268
353
  return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: `${BASE}/security`, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa (recovery code)' });
269
354
  }
355
+ await trustThisBrowser(req, reply, p.u);
270
356
  return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: p.next, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa' });
271
357
  });
272
358
 
359
+ // (sprint 127) the second step with a passkey: options for navigator.credentials.get(), then the browser's answer
360
+ app.post(`${BASE}/login/2fa/passkey/options`, async (req: Req, reply) => {
361
+ const s = await getSession(req, reply, null, BASE);
362
+ const p = pendingMfa(s);
363
+ if (req.body?.__csrf !== s.csrf_token || !p) return reply.code(403).send('Sign in again.');
364
+ const rp = relyingParty(req);
365
+ const keys = await passkeysOf(p.u, rp.rpId);
366
+ if (!keys.length) return reply.code(404).send('No passkey for this site.');
367
+ const challenge = giveChallenge(s as Parameters<typeof giveChallenge>[0], 'get');
368
+ await saveState(s);
369
+ reply.header('cache-control', 'no-store');
370
+ return reply.send({ mode: 'get', challenge, rpId: rp.rpId, allow: keys.map((k) => ({ id: k.credential_id, transports: k.transports })) });
371
+ });
372
+ app.post(`${BASE}/login/2fa/passkey`, async (req: Req, reply) => {
373
+ const s = await getSession(req, reply, null, BASE);
374
+ const ip = clientIp(req);
375
+ const picked = validBuilderLanguage(s.state.__BLANG);
376
+ const tx = translatorFor(picked ?? cookieLanguage(req));
377
+ const p = pendingMfa(s);
378
+ const challenge = takeChallenge(s as Parameters<typeof takeChallenge>[0], 'get');
379
+ if (req.body?.__csrf !== s.csrf_token || !p) {
380
+ delete s.state.__MFA;
381
+ flash(s, tx(p ? 'signin.expired' : 'mfa.expired'), 'error');
382
+ return back(reply, s, `${BASE}/login`);
383
+ }
384
+ if (await loginThrottled(null, p.u, ip)) {
385
+ logActivity({ username: p.u, event: 'login_locked', ip, detail: 'builder 2fa' });
386
+ delete s.state.__MFA;
387
+ flash(s, tx('signin.throttled'), 'error');
388
+ return back(reply, s, `${BASE}/login`);
389
+ }
390
+ const rp = relyingParty(req);
391
+ const key = (await passkeysOf(p.u, rp.rpId)).find((k) => k.credential_id === String(req.body?.credentialId ?? ''));
392
+ let why = 'unknown passkey';
393
+ if (key && challenge) {
394
+ try {
395
+ const r = verifyAssertion(
396
+ { clientDataJSON: String(req.body?.clientDataJSON ?? ''), authenticatorData: String(req.body?.authenticatorData ?? ''), signature: String(req.body?.signature ?? '') },
397
+ { alg: key.alg, jwk: key.public_key, signCount: Number(key.sign_count) }, { challenge, ...rp });
398
+ if (await usePasskey(key.id, key.sign_count, r.signCount)) why = '';
399
+ else why = 'used at the same time elsewhere';
400
+ } catch (e) {
401
+ why = e instanceof WebAuthnError ? e.message : 'unreadable answer';
402
+ }
403
+ } else if (!challenge) why = 'no or an expired challenge';
404
+ if (why) {
405
+ await logActivity({ username: p.u, event: 'login_failed', ip, detail: `builder 2fa passkey: ${why}` });
406
+ flash(s, tx('mfa.passkey_failed'), 'error');
407
+ return back(reply, s, `${BASE}/login/2fa`);
408
+ }
409
+ const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [p.u]);
410
+ if (!dev) return back(reply, s, `${BASE}/login`);
411
+ await trustThisBrowser(req, reply, p.u);
412
+ return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: p.next, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa (passkey)' });
413
+ });
414
+
273
415
  app.post(`${BASE}/logout`, async (req: Req, reply) => {
274
416
  const s = await getSession(req, reply, null, BASE);
275
417
  if (req.body?.__csrf === s.csrf_token) {
@@ -865,8 +1007,9 @@ export async function builderRoutes(app: FastifyInstance) {
865
1007
  [s.username, req.body?.current ?? '', req.body?.password],
866
1008
  );
867
1009
  if (r.rowCount !== 1) throw new Error(tr(s)('dev.err.current'));
868
- // End all other builder sessions of this developer.
1010
+ // End all other builder sessions of this developer, and (sprint 127) forget the browsers they trusted.
869
1011
  await owner.query('delete from meta.session where app_id is null and username = $1 and id <> $2', [s.username, s.id]);
1012
+ await forgetBrowsers(s.username!);
870
1013
  delete s.state.__WEAK;
871
1014
  flash(s, tr(s)('dev.pw_changed'));
872
1015
  } catch (e) {