pgkiln 0.59.0 → 0.60.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.
@@ -1711,4 +1711,77 @@ export const fr: BuilderMessages = {
1711
1711
  'dev.not_found': 'Introuvable.',
1712
1712
  'dev.role_changed': 'Rôle modifié.',
1713
1713
  'dev.removed': 'Développeur retiré.',
1714
+ // ------------------------------------------------------------ Security: two-factor sign-in (sprint 126)
1715
+ 'mfa.menu': 'Connexion à deux facteurs',
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.',
1719
+ 'mfa.banner': 'La connexion à deux facteurs est obligatoire. Configurez-la avant le {date} : {link}',
1720
+ 'mfa.set_up_now': 'configurer maintenant',
1721
+ 'mfa.apps': 'Applications d’authentification',
1722
+ 'mfa.app_name': 'Nom',
1723
+ 'mfa.app_default': 'Téléphone',
1724
+ 'mfa.added': 'Ajoutée',
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.',
1727
+ 'mfa.add_app': 'Ajouter une application d’authentification',
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
+ 'mfa.scan': 'Scannez le code QR avec une application d’authentification.',
1730
+ 'mfa.or_enter': 'Ou saisissez cette clé à la main :',
1731
+ 'mfa.enter_code': 'Saisissez le code à 6 chiffres affiché par l’application.',
1732
+ 'mfa.qr_label': 'Code QR pour l’application d’authentification',
1733
+ 'mfa.confirm_app': 'Confirmer et activer',
1734
+ 'mfa.cancel_setup': 'Annuler',
1735
+ 'mfa.setup_expired': 'La configuration a pris trop de temps : recommencez.',
1736
+ 'mfa.wrong_setup': 'Ce code ne correspond pas : vérifiez l’heure de votre téléphone et essayez le code suivant.',
1737
+ 'mfa.app_added': 'Application d’authentification ajoutée : désormais, vous vous connectez aussi avec un code.',
1738
+ 'mfa.remove': 'Supprimer',
1739
+ 'mfa.remove_app': 'Supprimer cette application',
1740
+ 'mfa.your_password': 'Votre mot de passe',
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.',
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
+ '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.',
1746
+ 'mfa.codes_title': 'Codes de récupération',
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
+ 'mfa.codes_saved': 'J’ai conservé ces codes',
1749
+ 'mfa.codes_done': 'Terminé',
1750
+ 'mfa.codes_kept': 'Codes de récupération conservés.',
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.',
1753
+ 'mfa.codes_low': 'Il reste peu de codes de récupération : créez-en de nouveaux.',
1754
+ 'mfa.codes_new': 'Nouveaux codes de récupération',
1755
+ 'mfa.codes_new_confirm': 'Créer de nouveaux codes de récupération ? Les anciens ne fonctionneront plus.',
1756
+ 'mfa.expired': 'La connexion a pris trop de temps : reconnectez-vous.',
1757
+ 'mfa.step_title': 'Connexion à deux facteurs',
1758
+ 'mfa.step_intro': 'Saisissez le code de votre application d’authentification pour {user}.',
1759
+ 'mfa.code': 'Code',
1760
+ 'mfa.code_help': '6 chiffres de l’application, ou l’un de vos codes de récupération.',
1761
+ 'mfa.verify': 'Vérifier',
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.',
1764
+ 'mfa.wrong': 'Ce code est incorrect ou a déjà été utilisé.',
1765
+ 'mfa.column': 'Deux facteurs',
1766
+ 'mfa.on': 'activée',
1767
+ 'mfa.off': 'désactivée',
1768
+ 'mfa.reset': 'Réinitialiser',
1769
+ 'mfa.reason': 'Motif',
1770
+ 'mfa.reset_help': 'Supprime les applications et codes de récupération de {user} et le déconnecte. Journalisé ; les administrateurs sont prévenus par e-mail.',
1771
+ 'mfa.reset_btn': 'Réinitialiser la connexion à deux facteurs',
1772
+ 'mfa.reason_needed': 'Indiquez un motif pour la réinitialisation.',
1773
+ 'mfa.reset_done': 'Connexion à deux facteurs de {user} réinitialisée.',
1774
+ 'mfa.policy_title': 'Connexion à deux facteurs',
1775
+ 'mfa.policy_label': 'Exiger la connexion à deux facteurs pour',
1776
+ 'mfa.policy_off': 'Personne (facultatif)',
1777
+ 'mfa.policy_admins': 'Administrateurs',
1778
+ 'mfa.policy_all': 'Tous les développeurs',
1779
+ 'mfa.grace': 'Délai de grâce (jours)',
1780
+ 'mfa.grace_help': 'Les développeurs sans application voient un rappel jusque-là ; ensuite, seulement leur page Sécurité.',
1781
+ 'mfa.policy_help': 'Administrateurs avec la connexion à deux facteurs : {enrolled} sur {admins}. L’exiger demande au moins deux d’entre eux (ou tous, s’ils sont moins nombreux).',
1782
+ 'mfa.policy_save': 'Enregistrer',
1783
+ 'mfa.policy_saved': 'Règle à deux facteurs enregistrée.',
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
+ 'mfa.without': 'Sans connexion à deux facteurs : {list}.',
1786
+ 'mfa.cli_note': 'Bloqué ? Sur le serveur : pgkiln admin reset-2fa <developer> ou pgkiln admin set-2fa-policy off.',
1714
1787
  };
@@ -1711,4 +1711,77 @@ export const nl: BuilderMessages = {
1711
1711
  'dev.not_found': 'Niet gevonden.',
1712
1712
  'dev.role_changed': 'Rol gewijzigd.',
1713
1713
  'dev.removed': 'Ontwikkelaar verwijderd.',
1714
+ // ------------------------------------------------------------ Security: two-factor sign-in (sprint 126)
1715
+ 'mfa.menu': 'Aanmelden in twee stappen',
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.',
1719
+ 'mfa.banner': 'Aanmelden in twee stappen is verplicht. Stel het in vóór {date}: {link}',
1720
+ 'mfa.set_up_now': 'nu instellen',
1721
+ 'mfa.apps': 'Authenticator-apps',
1722
+ 'mfa.app_name': 'Naam',
1723
+ 'mfa.app_default': 'Telefoon',
1724
+ 'mfa.added': 'Toegevoegd',
1725
+ 'mfa.last_used': 'Laatst gebruikt',
1726
+ 'mfa.no_apps': 'Nog geen authenticator-app: u meldt zich alleen met uw wachtwoord aan.',
1727
+ 'mfa.add_app': 'Authenticator-app toevoegen',
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
+ 'mfa.scan': 'Scan de QR-code met een authenticator-app.',
1730
+ 'mfa.or_enter': 'Of voer deze sleutel met de hand in:',
1731
+ 'mfa.enter_code': 'Voer de 6-cijferige code uit de app in.',
1732
+ 'mfa.qr_label': 'QR-code voor de authenticator-app',
1733
+ 'mfa.confirm_app': 'Bevestigen en inschakelen',
1734
+ 'mfa.cancel_setup': 'Annuleren',
1735
+ 'mfa.setup_expired': 'Het instellen duurde te lang: begin opnieuw.',
1736
+ 'mfa.wrong_setup': 'Die code klopt niet: controleer de tijd op uw telefoon en probeer de volgende code.',
1737
+ 'mfa.app_added': 'Authenticator-app toegevoegd: voortaan meldt u zich ook met een code aan.',
1738
+ 'mfa.remove': 'Verwijderen',
1739
+ 'mfa.remove_app': 'Deze app verwijderen',
1740
+ 'mfa.your_password': 'Uw wachtwoord',
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.',
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
+ '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.',
1746
+ 'mfa.codes_title': 'Herstelcodes',
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
+ 'mfa.codes_saved': 'Ik heb deze codes bewaard',
1749
+ 'mfa.codes_done': 'Klaar',
1750
+ 'mfa.codes_kept': 'Herstelcodes bewaard.',
1751
+ 'mfa.codes_left': 'Ongebruikte herstelcodes: {n}.',
1752
+ 'mfa.codes_after': 'U krijgt herstelcodes zodra u een authenticator-app toevoegt.',
1753
+ 'mfa.codes_low': 'Nog maar weinig herstelcodes: maak nieuwe.',
1754
+ 'mfa.codes_new': 'Nieuwe herstelcodes',
1755
+ 'mfa.codes_new_confirm': 'Nieuwe herstelcodes maken? De oude werken dan niet meer.',
1756
+ 'mfa.expired': 'Het aanmelden duurde te lang: meld u opnieuw aan.',
1757
+ 'mfa.step_title': 'Aanmelden in twee stappen',
1758
+ 'mfa.step_intro': 'Voer de code uit uw authenticator-app voor {user} in.',
1759
+ 'mfa.code': 'Code',
1760
+ 'mfa.code_help': '6 cijfers uit de app, of een van uw herstelcodes.',
1761
+ 'mfa.verify': 'Controleren',
1762
+ 'mfa.cancel': 'Terug naar aanmelden',
1763
+ 'mfa.locked_out': 'Telefoon en codes kwijt? Vraag een beheerder uw aanmelden in twee stappen te resetten.',
1764
+ 'mfa.wrong': 'Die code klopt niet of is al gebruikt.',
1765
+ 'mfa.column': 'Twee stappen',
1766
+ 'mfa.on': 'aan',
1767
+ 'mfa.off': 'uit',
1768
+ 'mfa.reset': 'Resetten',
1769
+ 'mfa.reason': 'Reden',
1770
+ 'mfa.reset_help': 'Verwijdert de apps en herstelcodes van {user} en meldt deze af. Wordt gelogd; de beheerders krijgen een e-mail.',
1771
+ 'mfa.reset_btn': 'Aanmelden in twee stappen resetten',
1772
+ 'mfa.reason_needed': 'Geef een reden voor de reset.',
1773
+ 'mfa.reset_done': 'Aanmelden in twee stappen van {user} gereset.',
1774
+ 'mfa.policy_title': 'Aanmelden in twee stappen',
1775
+ 'mfa.policy_label': 'Aanmelden in twee stappen verplicht voor',
1776
+ 'mfa.policy_off': 'Niemand (optioneel)',
1777
+ 'mfa.policy_admins': 'Beheerders',
1778
+ 'mfa.policy_all': 'Alle ontwikkelaars',
1779
+ 'mfa.grace': 'Overgangsperiode (dagen)',
1780
+ 'mfa.grace_help': 'Ontwikkelaars zonder app zien tot dan een herinnering; daarna alleen hun Beveiligingspagina.',
1781
+ 'mfa.policy_help': 'Beheerders met aanmelden in twee stappen: {enrolled} van {admins}. Verplichten kan pas met minstens twee (of alle, als het er minder zijn).',
1782
+ 'mfa.policy_save': 'Opslaan',
1783
+ 'mfa.policy_saved': 'Beleid voor twee stappen opgeslagen.',
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
+ 'mfa.without': 'Zonder aanmelden in twee stappen: {list}.',
1786
+ 'mfa.cli_note': 'Buitengesloten? Op de server: pgkiln admin reset-2fa <developer> of pgkiln admin set-2fa-policy off.',
1714
1787
  };
@@ -1711,4 +1711,77 @@ export const pt: BuilderMessages = {
1711
1711
  'dev.not_found': 'Não encontrado.',
1712
1712
  'dev.role_changed': 'Papel alterado.',
1713
1713
  'dev.removed': 'Desenvolvedor removido.',
1714
+ // ------------------------------------------------------------ Security: two-factor sign-in (sprint 126)
1715
+ 'mfa.menu': 'Login em duas etapas',
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.',
1719
+ 'mfa.banner': 'O login em duas etapas é obrigatório. Configure-o antes de {date}: {link}',
1720
+ 'mfa.set_up_now': 'configurar agora',
1721
+ 'mfa.apps': 'Aplicativos autenticadores',
1722
+ 'mfa.app_name': 'Nome',
1723
+ 'mfa.app_default': 'Telefone',
1724
+ 'mfa.added': 'Adicionado',
1725
+ 'mfa.last_used': 'Último uso',
1726
+ 'mfa.no_apps': 'Nenhum aplicativo autenticador ainda: você entra apenas com sua senha.',
1727
+ 'mfa.add_app': 'Adicionar um aplicativo autenticador',
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
+ 'mfa.scan': 'Leia o código QR com um aplicativo autenticador.',
1730
+ 'mfa.or_enter': 'Ou digite esta chave à mão:',
1731
+ 'mfa.enter_code': 'Digite o código de 6 dígitos que o aplicativo mostra.',
1732
+ 'mfa.qr_label': 'Código QR para o aplicativo autenticador',
1733
+ 'mfa.confirm_app': 'Confirmar e ativar',
1734
+ 'mfa.cancel_setup': 'Cancelar',
1735
+ 'mfa.setup_expired': 'A configuração demorou demais: comece de novo.',
1736
+ 'mfa.wrong_setup': 'Esse código não confere: verifique a hora do telefone e tente o próximo código.',
1737
+ 'mfa.app_added': 'Aplicativo autenticador adicionado: a partir de agora você entra também com um código.',
1738
+ 'mfa.remove': 'Remover',
1739
+ 'mfa.remove_app': 'Remover este aplicativo',
1740
+ 'mfa.your_password': 'Sua senha',
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.',
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
+ '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.',
1746
+ 'mfa.codes_title': 'Códigos de recuperação',
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
+ 'mfa.codes_saved': 'Guardei estes códigos',
1749
+ 'mfa.codes_done': 'Concluído',
1750
+ 'mfa.codes_kept': 'Códigos de recuperação guardados.',
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.',
1753
+ 'mfa.codes_low': 'Restam poucos códigos de recuperação: gere novos.',
1754
+ 'mfa.codes_new': 'Novos códigos de recuperação',
1755
+ 'mfa.codes_new_confirm': 'Gerar novos códigos de recuperação? Os antigos deixam de funcionar.',
1756
+ 'mfa.expired': 'O login demorou demais: entre novamente.',
1757
+ 'mfa.step_title': 'Login em duas etapas',
1758
+ 'mfa.step_intro': 'Digite o código do seu aplicativo autenticador para {user}.',
1759
+ 'mfa.code': 'Código',
1760
+ 'mfa.code_help': '6 dígitos do aplicativo ou um dos seus códigos de recuperação.',
1761
+ 'mfa.verify': 'Verificar',
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.',
1764
+ 'mfa.wrong': 'Esse código não está certo ou já foi usado.',
1765
+ 'mfa.column': 'Duas etapas',
1766
+ 'mfa.on': 'ativado',
1767
+ 'mfa.off': 'desativado',
1768
+ 'mfa.reset': 'Redefinir',
1769
+ 'mfa.reason': 'Motivo',
1770
+ 'mfa.reset_help': 'Remove os aplicativos e códigos de recuperação de {user} e encerra a sessão. Fica registrado; os administradores recebem um e-mail.',
1771
+ 'mfa.reset_btn': 'Redefinir o login em duas etapas',
1772
+ 'mfa.reason_needed': 'Informe um motivo para a redefinição.',
1773
+ 'mfa.reset_done': 'Login em duas etapas de {user} redefinido.',
1774
+ 'mfa.policy_title': 'Login em duas etapas',
1775
+ 'mfa.policy_label': 'Exigir login em duas etapas de',
1776
+ 'mfa.policy_off': 'Ninguém (opcional)',
1777
+ 'mfa.policy_admins': 'Administradores',
1778
+ 'mfa.policy_all': 'Todos os desenvolvedores',
1779
+ 'mfa.grace': 'Período de carência (dias)',
1780
+ 'mfa.grace_help': 'Desenvolvedores sem aplicativo veem um lembrete até lá; depois, só a página de Segurança.',
1781
+ 'mfa.policy_help': 'Administradores com login em duas etapas: {enrolled} de {admins}. Para exigir é preciso pelo menos dois (ou todos, se forem menos).',
1782
+ 'mfa.policy_save': 'Salvar',
1783
+ 'mfa.policy_saved': 'Política de duas etapas salva.',
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
+ 'mfa.without': 'Sem login em duas etapas: {list}.',
1786
+ 'mfa.cli_note': 'Bloqueado? No servidor: pgkiln admin reset-2fa <developer> ou pgkiln admin set-2fa-policy off.',
1714
1787
  };
@@ -3,6 +3,7 @@ import { html } from '../html.ts';
3
3
  import { configurationValue, CONFIGURATION, INSTANCE_SETTINGS, instanceSetting, origin, refreshInstanceSettings, saveInstanceSettings } from '../instance.ts';
4
4
  import { clientIp, logActivity, type Session } from '../session.ts';
5
5
  import { isAdmin } from './locks.ts';
6
+ import { policyRegion } from './security.ts';
6
7
  import { releaseCheckRegion } from './workspace-home.ts';
7
8
  import { back, BASE, csrf, developer, flash, region, send, shell, type Req } from './ui.ts';
8
9
 
@@ -39,6 +40,7 @@ export async function instanceRoutes(app: FastifyInstance) {
39
40
  })}</div>
40
41
  <div class="buttons"><button class="btn btn-hot">Save</button></div></form>`)}
41
42
  ${await releaseCheckRegion(s)}
43
+ ${await policyRegion(s)}
42
44
  ${region('Configuration', html`<p class="muted u-mt0">Environment variables of this server (chapter 1 of the guide). Secrets show only whether they are set.</p>
43
45
  <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Variable</th><th>Value</th><th>Purpose</th></tr></thead>
44
46
  <tbody>${CONFIGURATION.map((c) => {
@@ -19,6 +19,7 @@ import { appOr404 } from './forms.ts';
19
19
  import { docToFiles, filesToZip } from '../appfiles.ts';
20
20
  import { homeRoutes, rememberApp } from './home.ts';
21
21
  import { currentWorkspace, placeApp, workshopDb, workspaceRoutes } from './workspaces.ts';
22
+ import { securityRoutes } from './security.ts';
22
23
  import { instanceRoutes } from './instance.ts';
23
24
  import { saveTimeZoneSettings, timeZoneSettings } from './globalization.ts';
24
25
  import { WIZARD_KINDS, wizardRoutes, wizardTables } from './wizards.ts';
@@ -26,6 +27,7 @@ import { appFromFileRoutes } from './appfromfile.ts';
26
27
  import { clearDevbar } from '../runtime/devbar.ts';
27
28
  import { EMAIL } from './find-workspace.ts';
28
29
  import { cookieLanguage, LANG_COOKIE, tr, translatorFor, validBuilderLanguage } from './i18n.ts';
30
+ import { checkSecondFactor, codesLeft, hasFactor, leavesTooFewAdmins } from '../mfa.ts';
29
31
  import { chooseLanguage, langCookieOptions, languageRoutes, signInLanguageLinks } from './language.ts';
30
32
 
31
33
  // Builder pages: sign-in, workspace and app home, settings, activity and
@@ -148,27 +150,124 @@ export async function builderRoutes(app: FastifyInstance) {
148
150
  flash(s, tx(workspace ? 'signin.invalid_ws' : 'signin.invalid'), 'error');
149
151
  return back(reply, s, again);
150
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)) {
155
+ 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
+ await saveState(s);
157
+ return reply.redirect(`${BASE}/login/2fa`, 303);
158
+ }
159
+ 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
+
162
+ /** The session after a successful sign-in (password, and the second factor when the developer has one). */
163
+ async function finishSignIn(req: Req, reply: Parameters<typeof back>[0], s: Awaited<ReturnType<typeof getSession>>, dev: { username: string; language: string | null },
164
+ o: { ws: string | null; workspace: string; next: string; remember: boolean; weak: boolean; picked: ReturnType<typeof validBuilderLanguage>; detail: string }) {
165
+ const ip = clientIp(req);
166
+ const { picked, next, workspace } = o;
151
167
  await destroySession(reply, s, BASE);
152
168
  const ns = await createSession(reply, null, BASE, dev.username);
153
169
  // the builder theme chosen earlier on this device
154
170
  const theme = validTheme(req.cookies?.[THEME_COOKIE]);
155
171
  if (theme) ns.state.__BTHEME = theme;
156
- if (ws) ns.state.__WS = String(ws.id);
172
+ if (o.ws) ns.state.__WS = o.ws;
157
173
  // (sprint 121) the builder language: a link chosen on the sign-in page is stored for the developer; else theirs
158
174
  if (picked) await chooseLanguage(reply, ns, picked);
159
175
  else {
160
176
  ns.state.__BLANG = validBuilderLanguage(dev.language) ?? 'en';
161
177
  if (dev.language && ns.state.__BLANG !== 'en') reply.setCookie(LANG_COOKIE, ns.state.__BLANG, langCookieOptions());
162
178
  }
163
- if (password === 'admin' || password === dev.username) ns.state.__WEAK = '1';
179
+ if (o.weak) ns.state.__WEAK = '1';
164
180
  await saveState(ns);
165
- if (req.body?.remember === '1')
181
+ if (o.remember)
166
182
  reply.setCookie(REMEMBER_COOKIE, Buffer.from(JSON.stringify({ w: workspace, u: dev.username })).toString('base64url'), {
167
183
  path: BASE, httpOnly: true, sameSite: 'lax', maxAge: 365 * 24 * 3600, secure: process.env.COOKIE_SECURE === 'true',
168
184
  });
169
185
  else reply.clearCookie(REMEMBER_COOKIE, { path: BASE });
170
- logActivity({ username: dev.username, event: 'login', ip, detail: 'builder' });
186
+ logActivity({ username: dev.username, event: 'login', ip, detail: o.detail });
171
187
  return reply.redirect(next, 303);
188
+ }
189
+
190
+ // (sprint 126, SEC-2) the second step: a code from the authenticator app, or a recovery code
191
+ const MFA_SECONDS = 300;
192
+ const pendingMfa = (s: { state: Record<string, any> }) => {
193
+ try {
194
+ const p = JSON.parse(String(s.state.__MFA ?? ''));
195
+ return p && typeof p.u === 'string' && Date.now() - Number(p.at) < MFA_SECONDS * 1000 ? p as { u: string; ws: string | null; w: string; next: string; remember: boolean; weak: boolean } : null;
196
+ } catch {
197
+ return null;
198
+ }
199
+ };
200
+ app.get(`${BASE}/login/2fa`, async (req: Req, reply) => {
201
+ const s = await getSession(req, reply, null, BASE);
202
+ if (s.username) return reply.redirect(`${BASE}/home`);
203
+ const p = pendingMfa(s);
204
+ const tx = translatorFor(validBuilderLanguage(s.state.__BLANG) ?? cookieLanguage(req));
205
+ if (!p) {
206
+ delete s.state.__MFA;
207
+ flash(s, tx('mfa.expired'), 'error');
208
+ return back(reply, s, `${BASE}/login`);
209
+ }
210
+ const error = takeFlash(s, '__ERROR');
211
+ await saveState(s);
212
+ return reply.type('text/html').send(documentShell(
213
+ `${tx('mfa.step_title')} · ${tx('shell.builder')}`,
214
+ html`<main class="signin">
215
+ <section class="signin-hero" aria-label="pgkiln">
216
+ <span class="signin-logo" aria-hidden="true">${brandMark()}</span>
217
+ <h1 class="signin-title">pgkiln</h1>
218
+ <p class="signin-tagline">${tx('signin.tagline')}</p>
219
+ </section>
220
+ <section class="signin-panel">
221
+ <form method="post" class="signin-form">
222
+ <h2>${tx('mfa.step_title')}</h2>
223
+ <p class="muted u-mt0">${tx('mfa.step_intro', { user: p.u })}</p>
224
+ ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
225
+ ${csrf(s)}
226
+ <div class="field signin-field"><label class="label" for="f_code">${tx('mfa.code')}</label>
227
+ <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
+ <small class="help">${tx('mfa.code_help')}</small></div>
229
+ <button class="btn btn-hot signin-submit">${tx('mfa.verify')}</button>
230
+ <p class="muted small signin-hint"><a href="${BASE}/login">${tx('mfa.cancel')}</a> · ${tx('mfa.locked_out')}</p>
231
+ </form>
232
+ </section>
233
+ </main>`,
234
+ 'login-body ide-login signin-body', {}, builderHead(),
235
+ { theme: validTheme(req.cookies?.[THEME_COOKIE]) ?? 'dark', lang: tx.lang },
236
+ ));
237
+ });
238
+ app.post(`${BASE}/login/2fa`, async (req: Req, reply) => {
239
+ const s = await getSession(req, reply, null, BASE);
240
+ const ip = clientIp(req);
241
+ const picked = validBuilderLanguage(s.state.__BLANG);
242
+ const tx = translatorFor(picked ?? cookieLanguage(req));
243
+ const p = pendingMfa(s);
244
+ if (req.body?.__csrf !== s.csrf_token || !p) {
245
+ delete s.state.__MFA;
246
+ flash(s, tx(p ? 'signin.expired' : 'mfa.expired'), 'error');
247
+ return back(reply, s, `${BASE}/login`);
248
+ }
249
+ if (await loginThrottled(null, p.u, ip)) {
250
+ logActivity({ username: p.u, event: 'login_locked', ip, detail: 'builder 2fa' });
251
+ delete s.state.__MFA;
252
+ flash(s, tx('signin.throttled'), 'error');
253
+ return back(reply, s, `${BASE}/login`);
254
+ }
255
+ const ok = await checkSecondFactor(p.u, String(req.body?.code ?? '').slice(0, 40));
256
+ if (!ok) {
257
+ // counts with the failed passwords (lockout after the same number of attempts)
258
+ await logActivity({ username: p.u, event: 'login_failed', ip, detail: 'builder 2fa' });
259
+ flash(s, tx('mfa.wrong'), 'error');
260
+ return back(reply, s, `${BASE}/login/2fa`);
261
+ }
262
+ const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [p.u]);
263
+ if (!dev) return back(reply, s, `${BASE}/login`);
264
+ if (ok.kind === 'recovery') {
265
+ const left = await codesLeft(p.u);
266
+ await logActivity({ username: p.u, event: '2fa_recovery_used', ip, detail: `${left} left` });
267
+ // to the Security page: one code fewer, maybe time for new ones
268
+ 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
+ }
270
+ 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' });
172
271
  });
173
272
 
174
273
  app.post(`${BASE}/logout`, async (req: Req, reply) => {
@@ -205,6 +304,7 @@ export async function builderRoutes(app: FastifyInstance) {
205
304
  await languageRoutes(app);
206
305
  await workspaceRoutes(app);
207
306
  await instanceRoutes(app);
307
+ await securityRoutes(app);
208
308
  await wizardRoutes(app);
209
309
  await appFromFileRoutes(app);
210
310
 
@@ -686,7 +786,8 @@ export async function builderRoutes(app: FastifyInstance) {
686
786
  app.get(`${BASE}/developers`, async (req: Req, reply) => {
687
787
  const s = await developer(req, reply);
688
788
  if (!s) return;
689
- const devs = (await owner.query('select username, is_admin, email from meta.developer order by 1')).rows;
789
+ const devs = (await owner.query(`select username, is_admin, email, exists (select 1 from meta.developer_factor f where f.username = d.username) as mfa
790
+ from meta.developer d order by 1`)).rows;
690
791
  const admin = devs.some((d) => d.username === s.username && d.is_admin);
691
792
  const me = devs.find((d) => d.username === s.username);
692
793
  const tx = tr(s);
@@ -703,11 +804,14 @@ export async function builderRoutes(app: FastifyInstance) {
703
804
  <div class="buttons"><button class="btn">${tx('dev.save')}</button></div></form>`)}
704
805
  ${region(tx('dev.accounts'), html`
705
806
  <p class="muted u-mt0">${tx('dev.accounts_help')}</p>
706
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('dev.username')}</th><th>${tx('dev.role')}</th><th>${tx('dev.email_col')}</th><th></th></tr></thead><tbody>
807
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('dev.username')}</th><th>${tx('dev.role')}</th><th>${tx('dev.email_col')}</th><th>${tx('mfa.column')}</th><th></th></tr></thead><tbody>
707
808
  ${devs.map((d) => html`<tr><td data-label="${tx('dev.username')}">${d.username}${d.username === s.username ? ` ${tx('dev.you')}` : ''}</td><td data-label="${tx('dev.role')}">${d.is_admin ? tx('dev.administrator') : tx('dev.developer')}</td>
708
809
  <td data-label="${tx('dev.email_col')}">${admin && d.username !== s.username
709
810
  ? html`<form method="post" action="${BASE}/developers/email" class="search u-m0 u-mwnone">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input name="email" type="email" value="${d.email ?? ''}" aria-label="${tx('dev.email_of', { user: d.username })}" placeholder="${tx('dev.none')}"><button class="btn">${tx('dev.save')}</button></form>`
710
- : html`<span class="wrap-anywhere">${d.email ?? ''}</span>`}</td><td>${d.username === s.username || !admin ? '' : html`
811
+ : html`<span class="wrap-anywhere">${d.email ?? ''}</span>`}</td>
812
+ <td data-label="${tx('mfa.column')}">${d.mfa ? html`${icon('lock')} ${tx('mfa.on')}${admin && d.username !== s.username ? html` <details class="menu u-inline"><summary class="link-button">${tx('mfa.reset')}</summary><div class="menu-panel"><form method="post" action="${BASE}/developers/reset-2fa" class="menu-section">${csrf(s)}<input type="hidden" name="username" value="${d.username}">
813
+ ${input(`reason_${d.username}`.replace(/[^\w]/g, '_'), tx('mfa.reason'), '', { required: true, help: tx('mfa.reset_help', { user: d.username }) })}
814
+ <button class="btn btn-sm btn-danger">${tx('mfa.reset_btn')}</button></form></div></details>` : ''}` : html`<span class="muted">${tx('mfa.off')}</span>`}</td><td>${d.username === s.username || !admin ? '' : html`
711
815
  <form method="post" action="${BASE}/developers/admin" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input type="hidden" name="is_admin" value="${d.is_admin ? 'false' : 'true'}"><button class="link-button">${d.is_admin ? tx('dev.make_dev') : tx('dev.make_admin')}</button></form>
712
816
  · <form method="post" action="${BASE}/developers/delete" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><button class="link-button" data-confirm="${tx('dev.remove_confirm', { user: d.username })}">${tx('dev.remove')}</button></form>`}</td></tr>`)}
713
817
  </tbody></table></div>
@@ -791,6 +895,10 @@ export async function builderRoutes(app: FastifyInstance) {
791
895
  if (!s) return;
792
896
  if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators change roles.');
793
897
  if (req.body?.username && req.body.username !== s.username) {
898
+ if (req.body.is_admin !== 'true' && (await leavesTooFewAdmins(req.body.username))) {
899
+ flash(s, tr(s)('mfa.keep_admins'), 'error');
900
+ return back(reply, s, `${BASE}/developers`);
901
+ }
794
902
  await owner.query('update meta.developer set is_admin = $2 where username = $1', [req.body.username, req.body.is_admin === 'true']);
795
903
  flash(s, tr(s)('dev.role_changed'));
796
904
  }
@@ -802,6 +910,10 @@ export async function builderRoutes(app: FastifyInstance) {
802
910
  if (!s) return;
803
911
  if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators remove developers.');
804
912
  if (req.body?.username && req.body.username !== s.username) {
913
+ if (await leavesTooFewAdmins(req.body.username)) {
914
+ flash(s, tr(s)('mfa.keep_admins'), 'error');
915
+ return back(reply, s, `${BASE}/developers`);
916
+ }
805
917
  await owner.query('delete from meta.developer where username = $1', [req.body.username]);
806
918
  await owner.query('delete from meta.session where app_id is null and username = $1', [req.body.username]);
807
919
  flash(s, tr(s)('dev.removed'));