pgkiln 0.63.0 → 0.64.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.
Files changed (47) hide show
  1. package/SECURITY.md +2 -0
  2. package/db/migrations/132_app_user_mfa.sql +58 -0
  3. package/docs/apex-feature-parity.md +4 -1
  4. package/docs/guide/08-security.md +46 -5
  5. package/docs/guide/09-reference.md +5 -1
  6. package/docs/guide/11-from-apex.md +2 -1
  7. package/docs/guide/12-development.md +6 -0
  8. package/package.json +1 -1
  9. package/public/app.css +10 -0
  10. package/src/account-mfa.ts +93 -0
  11. package/src/builder/advisor.ts +8 -0
  12. package/src/builder/i18n/de.ts +6 -0
  13. package/src/builder/i18n/en.ts +6 -0
  14. package/src/builder/i18n/es.ts +6 -0
  15. package/src/builder/i18n/fr.ts +6 -0
  16. package/src/builder/i18n/nl.ts +6 -0
  17. package/src/builder/i18n/pt.ts +6 -0
  18. package/src/builder/login-page.ts +26 -2
  19. package/src/builder/users.ts +34 -3
  20. package/src/i18n/ar.ts +34 -0
  21. package/src/i18n/cs.ts +34 -0
  22. package/src/i18n/da.ts +34 -0
  23. package/src/i18n/de.ts +34 -0
  24. package/src/i18n/el.ts +34 -0
  25. package/src/i18n/es.ts +34 -0
  26. package/src/i18n/fi.ts +34 -0
  27. package/src/i18n/fr.ts +34 -0
  28. package/src/i18n/he.ts +34 -0
  29. package/src/i18n/it.ts +34 -0
  30. package/src/i18n/ja.ts +34 -0
  31. package/src/i18n/ko.ts +34 -0
  32. package/src/i18n/nb.ts +34 -0
  33. package/src/i18n/pl.ts +34 -0
  34. package/src/i18n/pt.ts +34 -0
  35. package/src/i18n/ru.ts +34 -0
  36. package/src/i18n/sv.ts +34 -0
  37. package/src/i18n/tr.ts +34 -0
  38. package/src/i18n/uk.ts +34 -0
  39. package/src/i18n/zh.ts +34 -0
  40. package/src/i18n.ts +68 -0
  41. package/src/metadata.ts +4 -1
  42. package/src/mfa.ts +76 -52
  43. package/src/remember.ts +1 -1
  44. package/src/runtime/account.ts +4 -1
  45. package/src/runtime/routes.ts +10 -6
  46. package/src/runtime/two-step.ts +395 -0
  47. package/src/sharedlogin.ts +1 -1
package/src/i18n/ru.ts CHANGED
@@ -805,4 +805,38 @@ export const ru: Record<MessageKey, string> = {
805
805
  'ai_filter.per_group': "{what} по группам",
806
806
  'ai_filter.needs_number': "Для {fn} нужен столбец с числами.",
807
807
  'ai_filter.invalid': "Это изменение невозможно: {why}",
808
+ // (sprint 130) two-step sign-in
809
+ "mfa.step_title": "Двухэтапный вход",
810
+ "mfa.step_intro": "Введите 6-значный код из приложения-аутентификатора или один из кодов восстановления.",
811
+ "mfa.code": "Код",
812
+ "mfa.verify": "Подтвердить",
813
+ "mfa.wrong": "Код неверен или уже был использован.",
814
+ "mfa.expired": "Вход занял слишком много времени. Войдите снова.",
815
+ "mfa.enrol_title": "Настройка двухэтапного входа",
816
+ "mfa.enrol_intro": "Это приложение при входе запрашивает код из приложения-аутентификатора. Добавьте его сейчас, чтобы продолжить.",
817
+ "mfa.scan": "Отсканируйте QR-код приложением-аутентификатором, например Google Authenticator, Microsoft Authenticator или 1Password.",
818
+ "mfa.or_enter": "Или введите этот ключ:",
819
+ "mfa.enter_code": "Введите 6-значный код, который показывает приложение.",
820
+ "mfa.qr_label": "QR-код для приложения-аутентификатора",
821
+ "mfa.codes_title": "Коды восстановления",
822
+ "mfa.codes_once": "Храните эти коды в надёжном месте. Каждый из них позволяет войти один раз, если у вас нет телефона. Они показываются только сейчас.",
823
+ "mfa.codes_saved": "Я сохранил эти коды",
824
+ "mfa.continue": "Продолжить",
825
+ "mfa.no_key": "Двухэтапный вход на этом сервере ещё не настроен. Обратитесь к администратору.",
826
+ "mfa.app_name": "Название",
827
+ "mfa.app_default": "Приложение-аутентификатор",
828
+ "mfa.on": "При входе вы также вводите код из приложения-аутентификатора.",
829
+ "mfa.off": "Добавьте приложение-аутентификатор, чтобы защитить учётную запись вторым шагом при входе.",
830
+ "mfa.required_you": "Это приложение требует от вас двухэтапного входа: добавьте приложение-аутентификатор.",
831
+ "mfa.add_app": "Добавить приложение-аутентификатор",
832
+ "mfa.confirm_app": "Добавить приложение",
833
+ "mfa.cancel": "Отмена",
834
+ "mfa.remove": "Удалить",
835
+ "mfa.remove_code": "Текущий код или код восстановления",
836
+ "mfa.removed": "Приложение-аутентификатор удалено.",
837
+ "mfa.keep_one": "Это приложение требует от вас двухэтапного входа: прежде чем удалить это приложение, добавьте другое.",
838
+ "mfa.added": "Приложение-аутентификатор добавлено.",
839
+ "mfa.codes_left": "Осталось кодов восстановления: {n}",
840
+ "mfa.codes_new": "Новые коды восстановления",
841
+ "mfa.recovery_used": "Вы вошли с кодом восстановления. Осталось кодов восстановления: {n}.",
808
842
  };
package/src/i18n/sv.ts CHANGED
@@ -805,4 +805,38 @@ export const sv: Record<MessageKey, string> = {
805
805
  'ai_filter.per_group': "{what} per grupp",
806
806
  'ai_filter.needs_number': "{fn} kräver en kolumn med tal.",
807
807
  'ai_filter.invalid': "Den ändringen går inte: {why}",
808
+ // (sprint 130) two-step sign-in
809
+ "mfa.step_title": "Inloggning i två steg",
810
+ "mfa.step_intro": "Ange den 6-siffriga koden från din autentiseringsapp, eller en av dina återställningskoder.",
811
+ "mfa.code": "Kod",
812
+ "mfa.verify": "Verifiera",
813
+ "mfa.wrong": "Koden är fel eller har redan använts.",
814
+ "mfa.expired": "Inloggningen tog för lång tid. Logga in igen.",
815
+ "mfa.enrol_title": "Konfigurera inloggning i två steg",
816
+ "mfa.enrol_intro": "Det här programmet ber om en kod från en autentiseringsapp när du loggar in. Lägg till en nu för att fortsätta.",
817
+ "mfa.scan": "Skanna QR-koden med en autentiseringsapp, till exempel Google Authenticator, Microsoft Authenticator eller 1Password.",
818
+ "mfa.or_enter": "Eller ange den här nyckeln:",
819
+ "mfa.enter_code": "Ange den 6-siffriga kod som appen visar.",
820
+ "mfa.qr_label": "QR-kod för en autentiseringsapp",
821
+ "mfa.codes_title": "Återställningskoder",
822
+ "mfa.codes_once": "Förvara koderna på ett säkert ställe. Var och en loggar in dig en gång när du inte har telefonen. De visas bara nu.",
823
+ "mfa.codes_saved": "Jag har sparat koderna",
824
+ "mfa.continue": "Fortsätt",
825
+ "mfa.no_key": "Inloggning i två steg är inte konfigurerad på den här servern än. Kontakta administratören.",
826
+ "mfa.app_name": "Namn",
827
+ "mfa.app_default": "Autentiseringsapp",
828
+ "mfa.on": "När du loggar in anger du också en kod från din autentiseringsapp.",
829
+ "mfa.off": "Lägg till en autentiseringsapp för att skydda ditt konto med ett andra steg när du loggar in.",
830
+ "mfa.required_you": "Det här programmet kräver inloggning i två steg för dig: lägg till en autentiseringsapp.",
831
+ "mfa.add_app": "Lägg till en autentiseringsapp",
832
+ "mfa.confirm_app": "Lägg till app",
833
+ "mfa.cancel": "Avbryt",
834
+ "mfa.remove": "Ta bort",
835
+ "mfa.remove_code": "En aktuell kod eller en återställningskod",
836
+ "mfa.removed": "Autentiseringsappen togs bort.",
837
+ "mfa.keep_one": "Det här programmet kräver inloggning i två steg för dig: lägg till en annan app innan du tar bort den här.",
838
+ "mfa.added": "Autentiseringsappen lades till.",
839
+ "mfa.codes_left": "Återstående återställningskoder: {n}",
840
+ "mfa.codes_new": "Nya återställningskoder",
841
+ "mfa.recovery_used": "Du loggade in med en återställningskod. Återstående återställningskoder: {n}.",
808
842
  };
package/src/i18n/tr.ts CHANGED
@@ -805,4 +805,38 @@ export const tr: Record<MessageKey, string> = {
805
805
  'ai_filter.per_group': "grup başına {what}",
806
806
  'ai_filter.needs_number': "{fn} sayı içeren bir sütun gerektirir.",
807
807
  'ai_filter.invalid': "Bu değişiklik yapılamaz: {why}",
808
+ // (sprint 130) two-step sign-in
809
+ "mfa.step_title": "İki adımlı oturum açma",
810
+ "mfa.step_intro": "Kimlik doğrulama uygulamanızdaki 6 haneli kodu veya kurtarma kodlarınızdan birini girin.",
811
+ "mfa.code": "Kod",
812
+ "mfa.verify": "Doğrula",
813
+ "mfa.wrong": "Kod yanlış veya daha önce kullanılmış.",
814
+ "mfa.expired": "Oturum açma çok uzun sürdü. Lütfen yeniden oturum açın.",
815
+ "mfa.enrol_title": "İki adımlı oturum açmayı ayarlayın",
816
+ "mfa.enrol_intro": "Bu uygulama oturum açarken bir kimlik doğrulama uygulamasından kod ister. Devam etmek için şimdi bir tane ekleyin.",
817
+ "mfa.scan": "QR kodunu Google Authenticator, Microsoft Authenticator veya 1Password gibi bir kimlik doğrulama uygulamasıyla tarayın.",
818
+ "mfa.or_enter": "Veya bu anahtarı girin:",
819
+ "mfa.enter_code": "Uygulamanın gösterdiği 6 haneli kodu girin.",
820
+ "mfa.qr_label": "Kimlik doğrulama uygulaması için QR kodu",
821
+ "mfa.codes_title": "Kurtarma kodları",
822
+ "mfa.codes_once": "Bu kodları güvenli bir yerde saklayın. Telefonunuz yanınızda olmadığında her biri bir kez oturum açmanızı sağlar. Yalnızca şimdi gösterilirler.",
823
+ "mfa.codes_saved": "Bu kodları kaydettim",
824
+ "mfa.continue": "Devam",
825
+ "mfa.no_key": "İki adımlı oturum açma bu sunucuda henüz ayarlanmadı. Lütfen yöneticiye başvurun.",
826
+ "mfa.app_name": "Ad",
827
+ "mfa.app_default": "Kimlik doğrulama uygulaması",
828
+ "mfa.on": "Oturum açarken kimlik doğrulama uygulamanızdan bir kod da girersiniz.",
829
+ "mfa.off": "Oturum açarken hesabınızı ikinci bir adımla korumak için bir kimlik doğrulama uygulaması ekleyin.",
830
+ "mfa.required_you": "Bu uygulama sizin için iki adımlı oturum açmayı zorunlu kılıyor: bir kimlik doğrulama uygulaması ekleyin.",
831
+ "mfa.add_app": "Kimlik doğrulama uygulaması ekle",
832
+ "mfa.confirm_app": "Uygulama ekle",
833
+ "mfa.cancel": "İptal",
834
+ "mfa.remove": "Kaldır",
835
+ "mfa.remove_code": "Geçerli bir kod veya kurtarma kodu",
836
+ "mfa.removed": "Kimlik doğrulama uygulaması kaldırıldı.",
837
+ "mfa.keep_one": "Bu uygulama sizin için iki adımlı oturum açmayı zorunlu kılıyor: bunu kaldırmadan önce başka bir uygulama ekleyin.",
838
+ "mfa.added": "Kimlik doğrulama uygulaması eklendi.",
839
+ "mfa.codes_left": "Kalan kurtarma kodları: {n}",
840
+ "mfa.codes_new": "Yeni kurtarma kodları",
841
+ "mfa.recovery_used": "Kurtarma koduyla oturum açtınız. Kalan kurtarma kodları: {n}.",
808
842
  };
package/src/i18n/uk.ts CHANGED
@@ -805,4 +805,38 @@ export const uk: Record<MessageKey, string> = {
805
805
  'ai_filter.per_group': "{what} за групами",
806
806
  'ai_filter.needs_number': "Для {fn} потрібен стовпець із числами.",
807
807
  'ai_filter.invalid': "Ця зміна неможлива: {why}",
808
+ // (sprint 130) two-step sign-in
809
+ "mfa.step_title": "Двоетапний вхід",
810
+ "mfa.step_intro": "Введіть 6-значний код із застосунку-автентифікатора або один із кодів відновлення.",
811
+ "mfa.code": "Код",
812
+ "mfa.verify": "Підтвердити",
813
+ "mfa.wrong": "Код неправильний або вже був використаний.",
814
+ "mfa.expired": "Вхід тривав надто довго. Увійдіть знову.",
815
+ "mfa.enrol_title": "Налаштування двоетапного входу",
816
+ "mfa.enrol_intro": "Цей застосунок під час входу запитує код із застосунку-автентифікатора. Додайте його зараз, щоб продовжити.",
817
+ "mfa.scan": "Відскануйте QR-код застосунком-автентифікатором, наприклад Google Authenticator, Microsoft Authenticator або 1Password.",
818
+ "mfa.or_enter": "Або введіть цей ключ:",
819
+ "mfa.enter_code": "Введіть 6-значний код, який показує застосунок.",
820
+ "mfa.qr_label": "QR-код для застосунку-автентифікатора",
821
+ "mfa.codes_title": "Коди відновлення",
822
+ "mfa.codes_once": "Зберігайте ці коди в надійному місці. Кожен із них дає змогу увійти один раз, коли у вас немає телефона. Вони показуються лише зараз.",
823
+ "mfa.codes_saved": "Я зберіг ці коди",
824
+ "mfa.continue": "Продовжити",
825
+ "mfa.no_key": "Двоетапний вхід на цьому сервері ще не налаштовано. Зверніться до адміністратора.",
826
+ "mfa.app_name": "Назва",
827
+ "mfa.app_default": "Застосунок-автентифікатор",
828
+ "mfa.on": "Під час входу ви також вводите код із застосунку-автентифікатора.",
829
+ "mfa.off": "Додайте застосунок-автентифікатор, щоб захистити обліковий запис другим кроком під час входу.",
830
+ "mfa.required_you": "Цей застосунок вимагає від вас двоетапного входу: додайте застосунок-автентифікатор.",
831
+ "mfa.add_app": "Додати застосунок-автентифікатор",
832
+ "mfa.confirm_app": "Додати застосунок",
833
+ "mfa.cancel": "Скасувати",
834
+ "mfa.remove": "Вилучити",
835
+ "mfa.remove_code": "Поточний код або код відновлення",
836
+ "mfa.removed": "Застосунок-автентифікатор вилучено.",
837
+ "mfa.keep_one": "Цей застосунок вимагає від вас двоетапного входу: перш ніж вилучити цей застосунок, додайте інший.",
838
+ "mfa.added": "Застосунок-автентифікатор додано.",
839
+ "mfa.codes_left": "Залишилося кодів відновлення: {n}",
840
+ "mfa.codes_new": "Нові коди відновлення",
841
+ "mfa.recovery_used": "Ви увійшли з кодом відновлення. Залишилося кодів відновлення: {n}.",
808
842
  };
package/src/i18n/zh.ts CHANGED
@@ -805,4 +805,38 @@ export const zh: Record<MessageKey, string> = {
805
805
  'ai_filter.per_group': "每组 {what}",
806
806
  'ai_filter.needs_number': "{fn} 需要数字列。",
807
807
  'ai_filter.invalid': "无法进行此更改:{why}",
808
+ // (sprint 130) two-step sign-in
809
+ "mfa.step_title": "两步登录",
810
+ "mfa.step_intro": "请输入身份验证器应用中的 6 位代码,或你的一个恢复代码。",
811
+ "mfa.code": "代码",
812
+ "mfa.verify": "验证",
813
+ "mfa.wrong": "代码不正确,或已被使用。",
814
+ "mfa.expired": "登录耗时过长。请重新登录。",
815
+ "mfa.enrol_title": "设置两步登录",
816
+ "mfa.enrol_intro": "此应用程序在登录时需要身份验证器应用中的代码。请立即添加一个以继续。",
817
+ "mfa.scan": "使用身份验证器应用(如 Google Authenticator、Microsoft Authenticator 或 1Password)扫描二维码。",
818
+ "mfa.or_enter": "或输入此密钥:",
819
+ "mfa.enter_code": "输入应用显示的 6 位代码。",
820
+ "mfa.qr_label": "用于身份验证器应用的二维码",
821
+ "mfa.codes_title": "恢复代码",
822
+ "mfa.codes_once": "请将这些代码保存在安全的地方。没有手机时,每个代码可让你登录一次。它们只会显示这一次。",
823
+ "mfa.codes_saved": "我已保存这些代码",
824
+ "mfa.continue": "继续",
825
+ "mfa.no_key": "此服务器尚未设置两步登录。请联系管理员。",
826
+ "mfa.app_name": "名称",
827
+ "mfa.app_default": "身份验证器应用",
828
+ "mfa.on": "登录时,你还需要输入身份验证器应用中的代码。",
829
+ "mfa.off": "添加身份验证器应用,在登录时用第二步保护你的账户。",
830
+ "mfa.required_you": "此应用程序要求你使用两步登录:请添加身份验证器应用。",
831
+ "mfa.add_app": "添加身份验证器应用",
832
+ "mfa.confirm_app": "添加应用",
833
+ "mfa.cancel": "取消",
834
+ "mfa.remove": "移除",
835
+ "mfa.remove_code": "当前代码或恢复代码",
836
+ "mfa.removed": "已移除身份验证器应用。",
837
+ "mfa.keep_one": "此应用程序要求你使用两步登录:请先添加另一个应用,再移除此应用。",
838
+ "mfa.added": "已添加身份验证器应用。",
839
+ "mfa.codes_left": "剩余恢复代码:{n}",
840
+ "mfa.codes_new": "新的恢复代码",
841
+ "mfa.recovery_used": "你使用恢复代码登录了。剩余恢复代码:{n}。",
808
842
  };
package/src/i18n.ts CHANGED
@@ -832,6 +832,40 @@ const en = {
832
832
  'ai_filter.per_group': "{what} per group",
833
833
  'ai_filter.needs_number': "{fn} needs a column with numbers.",
834
834
  'ai_filter.invalid': "That change is not possible: {why}",
835
+ // (sprint 130) two-factor sign-in
836
+ "mfa.step_title": "Two-factor sign-in",
837
+ "mfa.step_intro": "Enter the 6-digit code from your authenticator app, or one of your recovery codes.",
838
+ "mfa.code": "Code",
839
+ "mfa.verify": "Verify",
840
+ "mfa.wrong": "That code is not right, or it was used already.",
841
+ "mfa.expired": "Signing in took too long. Please sign in again.",
842
+ "mfa.enrol_title": "Set up two-factor sign-in",
843
+ "mfa.enrol_intro": "This application asks for a code from an authenticator app when you sign in. Add one now to continue.",
844
+ "mfa.scan": "Scan the QR code with an authenticator app, such as Google Authenticator, Microsoft Authenticator or 1Password.",
845
+ "mfa.or_enter": "Or enter this key:",
846
+ "mfa.enter_code": "Enter the 6-digit code the app shows.",
847
+ "mfa.qr_label": "QR code for an authenticator app",
848
+ "mfa.codes_title": "Recovery codes",
849
+ "mfa.codes_once": "Keep these codes in a safe place. Each one signs you in once when you don't have your phone. They are shown only now.",
850
+ "mfa.codes_saved": "I have saved these codes",
851
+ "mfa.continue": "Continue",
852
+ "mfa.no_key": "Two-factor sign-in is not set up on this server yet. Please ask the administrator.",
853
+ "mfa.app_name": "Name",
854
+ "mfa.app_default": "Authenticator app",
855
+ "mfa.on": "When you sign in, you also give a code from your authenticator app.",
856
+ "mfa.off": "Add an authenticator app to protect your account with a second step when you sign in.",
857
+ "mfa.required_you": "This application requires two-factor sign-in for you: add an authenticator app.",
858
+ "mfa.add_app": "Add an authenticator app",
859
+ "mfa.confirm_app": "Add app",
860
+ "mfa.cancel": "Cancel",
861
+ "mfa.remove": "Remove",
862
+ "mfa.remove_code": "A current code or a recovery code",
863
+ "mfa.removed": "The authenticator app was removed.",
864
+ "mfa.keep_one": "This application requires two-factor sign-in for you: add another app before you remove this one.",
865
+ "mfa.added": "The authenticator app was added.",
866
+ "mfa.codes_left": "Recovery codes left: {n}",
867
+ "mfa.codes_new": "New recovery codes",
868
+ "mfa.recovery_used": "You signed in with a recovery code. Recovery codes left: {n}.",
835
869
  };
836
870
 
837
871
  export type MessageKey = keyof typeof en;
@@ -1633,6 +1667,40 @@ const nl: Record<MessageKey, string> = {
1633
1667
  'ai_filter.per_group': "{what} per groep",
1634
1668
  'ai_filter.needs_number': "{fn} vraagt een kolom met getallen.",
1635
1669
  'ai_filter.invalid': "Die wijziging kan niet: {why}",
1670
+ // (sprint 130) two-factor sign-in
1671
+ "mfa.step_title": "Aanmelden in twee stappen",
1672
+ "mfa.step_intro": "Voer de 6-cijferige code uit je authenticator-app in, of een van je herstelcodes.",
1673
+ "mfa.code": "Code",
1674
+ "mfa.verify": "Controleren",
1675
+ "mfa.wrong": "Die code klopt niet, of is al gebruikt.",
1676
+ "mfa.expired": "Het aanmelden duurde te lang. Meld je opnieuw aan.",
1677
+ "mfa.enrol_title": "Aanmelden in twee stappen instellen",
1678
+ "mfa.enrol_intro": "Deze applicatie vraagt bij het aanmelden om een code uit een authenticator-app. Voeg er nu een toe om verder te gaan.",
1679
+ "mfa.scan": "Scan de QR-code met een authenticator-app, zoals Google Authenticator, Microsoft Authenticator of 1Password.",
1680
+ "mfa.or_enter": "Of voer deze sleutel in:",
1681
+ "mfa.enter_code": "Voer de 6-cijferige code in die de app toont.",
1682
+ "mfa.qr_label": "QR-code voor een authenticator-app",
1683
+ "mfa.codes_title": "Herstelcodes",
1684
+ "mfa.codes_once": "Bewaar deze codes op een veilige plek. Met elke code meld je je één keer aan als je je telefoon niet bij je hebt. Ze worden alleen nu getoond.",
1685
+ "mfa.codes_saved": "Ik heb deze codes bewaard",
1686
+ "mfa.continue": "Doorgaan",
1687
+ "mfa.no_key": "Aanmelden in twee stappen is op deze server nog niet ingesteld. Vraag het de beheerder.",
1688
+ "mfa.app_name": "Naam",
1689
+ "mfa.app_default": "Authenticator-app",
1690
+ "mfa.on": "Bij het aanmelden geef je ook een code uit je authenticator-app.",
1691
+ "mfa.off": "Voeg een authenticator-app toe om je account bij het aanmelden met een tweede stap te beschermen.",
1692
+ "mfa.required_you": "Deze applicatie vereist voor jou aanmelden in twee stappen: voeg een authenticator-app toe.",
1693
+ "mfa.add_app": "Authenticator-app toevoegen",
1694
+ "mfa.confirm_app": "App toevoegen",
1695
+ "mfa.cancel": "Annuleren",
1696
+ "mfa.remove": "Verwijderen",
1697
+ "mfa.remove_code": "Een actuele code of een herstelcode",
1698
+ "mfa.removed": "De authenticator-app is verwijderd.",
1699
+ "mfa.keep_one": "Deze applicatie vereist voor jou aanmelden in twee stappen: voeg eerst een andere app toe voordat je deze verwijdert.",
1700
+ "mfa.added": "De authenticator-app is toegevoegd.",
1701
+ "mfa.codes_left": "Resterende herstelcodes: {n}",
1702
+ "mfa.codes_new": "Nieuwe herstelcodes",
1703
+ "mfa.recovery_used": "Je hebt je aangemeld met een herstelcode. Resterende herstelcodes: {n}.",
1636
1704
  };
1637
1705
 
1638
1706
  const BUILTIN: Record<string, Record<string, string>> = { en, nl, de, fr, es, it, pt, pl, sv, da, nb, cs, ja, zh, fi, tr, el, ru, uk, ko, ar, he };
package/src/metadata.ts CHANGED
@@ -78,6 +78,9 @@ export interface App {
78
78
  password_reset: boolean;
79
79
  /** (067) session sharing group (src/sharedlogin.ts) */
80
80
  session_group: string | null;
81
+ /** (132) two-step sign-in for the application's users (src/account-mfa.ts) */
82
+ mfa: 'off' | 'optional' | 'required' | 'roles';
83
+ mfa_roles: string[];
81
84
  /** LDAP directories the password form checks after local accounts */
82
85
  ldap_directories: string[];
83
86
  /** Progressive Web App: installable, offline pages, offline form queue */
@@ -369,7 +372,7 @@ const agg = (table: string, fk: string, parent: string, appId: string) =>
369
372
  // No caching on purpose: edits made in the builder show up on the next request.
370
373
  export async function loadApp(alias: string) {
371
374
  return runtime.one<App>(
372
- `select a.id, a.alias, a.name, a.version, a.home_page, a.authentication, a.access_control, a.sso_providers, a.local_login, a.remember_me_days, a.login_page, a.password_reset, a.session_group, a.ldap_directories, a.header_name, a.header_auto_create, a.logout_url, a.db_auth_roles, a.db_auth_member_of, a.custom_auth_function, a.custom_auth_code, a.custom_auth_post_code, a.nav_list, a.navbar_list, a.pwa, a.pwa_short_name, a.pwa_icon is not null as pwa_has_icon, a.pwa_offline_pages, a.pwa_offline_submit, a.pwa_push, a.db_role, a.debug, a.debug_level, a.theme,
375
+ `select a.id, a.alias, a.name, a.version, a.home_page, a.authentication, a.access_control, a.sso_providers, a.local_login, a.remember_me_days, a.login_page, a.password_reset, a.session_group, a.mfa, a.mfa_roles, a.ldap_directories, a.header_name, a.header_auto_create, a.logout_url, a.db_auth_roles, a.db_auth_member_of, a.custom_auth_function, a.custom_auth_code, a.custom_auth_post_code, a.nav_list, a.navbar_list, a.pwa, a.pwa_short_name, a.pwa_icon is not null as pwa_has_icon, a.pwa_offline_pages, a.pwa_offline_submit, a.pwa_push, a.db_role, a.debug, a.debug_level, a.theme,
373
376
  a.availability, a.availability_message, a.availability_users, a.feedback, a.error_function, a.session_warning_seconds, a.component_settings,
374
377
  a.language, a.languages, a.language_from, a.date_format, a.timestamp_format, a.time_zone, a.time_zone_auto, a.currency,
375
378
  coalesce((select jsonb_agg(jsonb_build_object('name', l.name, 'query', l.query, 'rest_source', l.rest_source)) from meta.lov l where l.app_id = a.id), '[]') as lovs,
package/src/mfa.ts CHANGED
@@ -94,6 +94,75 @@ export function newRecoveryCode(): string {
94
94
  export const normaliseCode = (code: string) => code.toLowerCase().replace(/[^a-z0-9]/g, '');
95
95
  export const hashCode = (code: string) => createHash('sha256').update(normaliseCode(code)).digest('hex');
96
96
 
97
+ // ---------------------------------------------------------------- where factors are kept
98
+
99
+ /**
100
+ * (sprint 130, SEC-3) The tables of one kind of user: developers (128, by user name) or application accounts
101
+ * (132, by account id). The code checks, recovery codes and the "each step once" rule are the same for both.
102
+ */
103
+ export interface FactorStore { factors: string; codes: string; key: string }
104
+ export const DEVELOPERS: FactorStore = { factors: 'meta.developer_factor', codes: 'meta.developer_recovery_code', key: 'username' };
105
+ export const ACCOUNTS: FactorStore = { factors: 'meta.account_factor', codes: 'meta.account_recovery_code', key: 'account_id' };
106
+ type Who = string | number;
107
+
108
+ export async function storeCodesLeft(st: FactorStore, who: Who): Promise<number> {
109
+ return (await owner.one<{ n: number }>(`select count(*)::int as n from ${st.codes} where ${st.key} = $1 and confirmed and used_at is null`, [who]))!.n;
110
+ }
111
+
112
+ export async function storeHasFactor(st: FactorStore, who: Who): Promise<boolean> {
113
+ return !!(await owner.one(`select 1 as ok from ${st.factors} where ${st.key} = $1`, [who]));
114
+ }
115
+
116
+ export async function storeAddTotp(st: FactorStore, who: Who, name: string, secret: string, step: number) {
117
+ await owner.query(
118
+ `insert into ${st.factors} (${st.key}, kind, name, secret_enc, last_step, last_used_at) values ($1, 'totp', $2, $3, $4, now())`,
119
+ [who, name.trim().slice(0, 60) || 'Authenticator', encryptSecret(secret), step],
120
+ );
121
+ }
122
+
123
+ export async function storeNewCodes(st: FactorStore, who: Who): Promise<string[]> {
124
+ const codes = Array.from({ length: RECOVERY_CODES }, newRecoveryCode);
125
+ await owner.tx(async (c) => {
126
+ await c.query(`delete from ${st.codes} where ${st.key} = $1`, [who]);
127
+ for (const code of codes) await c.query(`insert into ${st.codes} (${st.key}, code_hash) values ($1, $2)`, [who, hashCode(code)]);
128
+ });
129
+ return codes;
130
+ }
131
+
132
+ export async function storeConfirmCodes(st: FactorStore, who: Who) {
133
+ await owner.query(`update ${st.codes} set confirmed = true where ${st.key} = $1 and used_at is null`, [who]);
134
+ }
135
+
136
+ /**
137
+ * Check a second-factor code: an authenticator code (each step once) or a confirmed, unused recovery code
138
+ * (then used up). Returns what matched, or null.
139
+ */
140
+ export async function storeCheck(st: FactorStore, who: Who, code: string): Promise<{ kind: 'totp' | 'recovery'; name?: string } | null> {
141
+ const digits = code.replace(/\s/g, '');
142
+ if (/^\d{6}$/.test(digits)) {
143
+ const rows = (await owner.query<{ id: number; name: string; secret_enc: string; last_step: string | null }>(
144
+ `select id, name, secret_enc, last_step from ${st.factors} where ${st.key} = $1 and kind = 'totp'`, [who])).rows;
145
+ for (const f of rows) {
146
+ let secret: Buffer;
147
+ try {
148
+ secret = unbase32(decryptSecret(f.secret_enc));
149
+ } catch {
150
+ continue; // the instance key changed: recovery codes still work
151
+ }
152
+ const step = matchTotp(secret, digits, f.last_step === null ? null : Number(f.last_step));
153
+ // the update only wins once per step, also with two requests at the same time
154
+ if (step !== null && (await owner.query(`update ${st.factors} set last_step = $2, last_used_at = now() where id = $1 and (last_step is null or last_step < $2)`, [f.id, step])).rowCount)
155
+ return { kind: 'totp', name: f.name };
156
+ }
157
+ return null;
158
+ }
159
+ if (normaliseCode(code).length !== 10) return null;
160
+ const used = await owner.query(
161
+ `update ${st.codes} set used_at = now() where ${st.key} = $1 and code_hash = $2 and confirmed and used_at is null`,
162
+ [who, hashCode(code)]);
163
+ return used.rowCount ? { kind: 'recovery' } : null;
164
+ }
165
+
97
166
  // ---------------------------------------------------------------- the developer's factors
98
167
 
99
168
  export interface Factor { id: number; kind: 'totp' | 'passkey'; name: string; created_at: string; last_used_at: string | null; rp_id: string | null }
@@ -103,22 +172,13 @@ export async function factorsOf(username: string, db: Pick<pg.ClientBase, 'query
103
172
  }
104
173
 
105
174
  /** Recovery codes left (confirmed, unused). */
106
- export async function codesLeft(username: string): Promise<number> {
107
- return (await owner.one<{ n: number }>('select count(*)::int as n from meta.developer_recovery_code where username = $1 and confirmed and used_at is null', [username]))!.n;
108
- }
175
+ export const codesLeft = (username: string) => storeCodesLeft(DEVELOPERS, username);
109
176
 
110
177
  /** Whether the developer has a working second factor (an authenticator app; recovery codes back it up). */
111
- export async function hasFactor(username: string): Promise<boolean> {
112
- return !!(await owner.one('select 1 as ok from meta.developer_factor where username = $1', [username]));
113
- }
178
+ export const hasFactor = (username: string) => storeHasFactor(DEVELOPERS, username);
114
179
 
115
180
  /** Add an authenticator app (after the developer confirmed a code from it). */
116
- export async function addTotp(username: string, name: string, secret: string, step: number) {
117
- await owner.query(
118
- `insert into meta.developer_factor (username, kind, name, secret_enc, last_step, last_used_at) values ($1, 'totp', $2, $3, $4, now())`,
119
- [username, name.trim().slice(0, 60) || 'Authenticator', encryptSecret(secret), step],
120
- );
121
- }
181
+ export const addTotp = (username: string, name: string, secret: string, step: number) => storeAddTotp(DEVELOPERS, username, name, secret, step);
122
182
 
123
183
  // ---------------------------------------------------------------- passkeys (sprint 127)
124
184
 
@@ -211,48 +271,12 @@ export async function forgetBrowsers(username: string) {
211
271
  }
212
272
 
213
273
  /** New recovery codes (the old ones stop working); shown once, counted only when confirmed. */
214
- export async function newRecoveryCodes(username: string): Promise<string[]> {
215
- const codes = Array.from({ length: RECOVERY_CODES }, newRecoveryCode);
216
- await owner.tx(async (c) => {
217
- await c.query('delete from meta.developer_recovery_code where username = $1', [username]);
218
- for (const code of codes) await c.query('insert into meta.developer_recovery_code (username, code_hash) values ($1, $2)', [username, hashCode(code)]);
219
- });
220
- return codes;
221
- }
274
+ export const newRecoveryCodes = (username: string) => storeNewCodes(DEVELOPERS, username);
222
275
 
223
- export async function confirmRecoveryCodes(username: string) {
224
- await owner.query('update meta.developer_recovery_code set confirmed = true where username = $1 and used_at is null', [username]);
225
- }
276
+ export const confirmRecoveryCodes = (username: string) => storeConfirmCodes(DEVELOPERS, username);
226
277
 
227
- /**
228
- * Check a second-factor code at sign-in: an authenticator code (each step once) or a confirmed, unused
229
- * recovery code (then used up). Returns what matched, or null.
230
- */
231
- export async function checkSecondFactor(username: string, code: string): Promise<{ kind: 'totp' | 'recovery'; name?: string } | null> {
232
- const digits = code.replace(/\s/g, '');
233
- if (/^\d{6}$/.test(digits)) {
234
- const rows = (await owner.query<{ id: number; name: string; secret_enc: string; last_step: string | null }>(
235
- 'select id, name, secret_enc, last_step from meta.developer_factor where username = $1 and kind = $2', [username, 'totp'])).rows;
236
- for (const f of rows) {
237
- let secret: Buffer;
238
- try {
239
- secret = unbase32(decryptSecret(f.secret_enc));
240
- } catch {
241
- continue; // the instance key changed: recovery codes still work
242
- }
243
- const step = matchTotp(secret, digits, f.last_step === null ? null : Number(f.last_step));
244
- // the update only wins once per step, also with two requests at the same time
245
- if (step !== null && (await owner.query('update meta.developer_factor set last_step = $2, last_used_at = now() where id = $1 and (last_step is null or last_step < $2)', [f.id, step])).rowCount)
246
- return { kind: 'totp', name: f.name };
247
- }
248
- return null;
249
- }
250
- if (normaliseCode(code).length !== 10) return null;
251
- const used = await owner.query(
252
- 'update meta.developer_recovery_code set used_at = now() where username = $1 and code_hash = $2 and confirmed and used_at is null',
253
- [username, hashCode(code)]);
254
- return used.rowCount ? { kind: 'recovery' } : null;
255
- }
278
+ /** Check a developer's second-factor code at sign-in (see storeCheck). */
279
+ export const checkSecondFactor = (username: string, code: string) => storeCheck(DEVELOPERS, username, code);
256
280
 
257
281
  // ---------------------------------------------------------------- policy
258
282
 
package/src/remember.ts CHANGED
@@ -56,7 +56,7 @@ export async function useRemember(req: FastifyRequest, reply: FastifyReply, a: R
56
56
  );
57
57
  if (!row || !row.active) return clear(), null;
58
58
  // an expired password needs the password form; access may have been withdrawn
59
- if (row.method === 'password' && (await passwordDaysLeft(row.username)) === 0) return clear(), null;
59
+ if (row.method.split('+')[0] === 'password' && (await passwordDaysLeft(row.username)) === 0) return clear(), null;
60
60
  const access = await ssoAccess(a.id, row.username, row.groups);
61
61
  if (!access.allowed) return clear(), null;
62
62
  return { username: row.username, roles: access.roles, groups: row.groups, method: row.method, expiresAt: new Date(row.expires_at) };
@@ -12,11 +12,13 @@ import { chrome, clientTexts } from './render.ts';
12
12
  import { appStyles, choosable, chosenStyleName, styleChoice } from './styles.ts';
13
13
  import { loadApp } from '../metadata.ts';
14
14
  import { pushSection } from './push.ts';
15
+ import { twoStepSection } from './two-step.ts';
15
16
  import { appWithLocale, loadContext, safeNext, txContext, type Req } from './routes.ts';
16
17
 
17
18
  // "My account": details, own password, and preferences (light/dark and
18
19
  // language). APEX has the APIs for this (APEX_UTIL.CHANGE_CURRENT_USER_PW,
19
- // theme style per user); pgkiln provides the page itself.
20
+ // theme style per user); pgkiln provides the page itself. (sprint 130)
21
+ // Two-step sign-in: authenticator apps and recovery codes (two-step.ts).
20
22
 
21
23
  /** Keep a signed-in user's style variant for this app (meta.account_style; '' = the base colours). */
22
24
  async function saveStyle(appId: number, username: string, style: string) {
@@ -115,6 +117,7 @@ async function accountPage(ctx: PageContext, reply: FastifyReply, error?: string
115
117
  <form method="post" action="${ctx.base}/account/devices">${csrf}<button class="btn">${t('account.forget_devices')}</button></form>
116
118
  </div></section>`
117
119
  : ''}
120
+ ${await twoStepSection(ctx)}
118
121
  ${await pushSection(ctx)}
119
122
  </div>
120
123
  </div>
@@ -48,6 +48,8 @@ import { resolveLocale, THEME_COOKIE, themeCookieOptions, translateApp, translat
48
48
  import { chrome, dialogClosePage, languagePicker, renderPage } from './render.ts';
49
49
  import { LOGIN_ERROR, LOGIN_USER, safeNext, signInForm } from './signin.ts';
50
50
  import { passwordResetRoutes } from './password-reset.ts';
51
+ import { passwordSignIn, twoStepRoutes } from './two-step.ts';
52
+ import { carriesSecondStep } from '../account-mfa.ts';
51
53
  import { devbarOf, devbarRoutes, sessionDebugLevel } from './devbar.ts';
52
54
  import { availabilityRefusal } from './availability.ts';
53
55
  import { feedbackRoutes } from './feedback.ts';
@@ -229,7 +231,8 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
229
231
  // the session ended, but the browser was remembered: sign in again silently (a new token each time)
230
232
  if (app.authentication === 'app_users' && !session.username) {
231
233
  const remembered = await useRemember(req, reply, app);
232
- if (remembered)
234
+ // (sprint 130) one made before the application asked this user for a second step doesn't stand in for it
235
+ if (remembered && (await carriesSecondStep(app, remembered.username, remembered.roles, remembered.method)))
233
236
  session = await signIn(req, reply, app, session, remembered.username, {
234
237
  extraRoles: remembered.roles, groups: remembered.groups, method: remembered.method,
235
238
  remember: true, rememberUntil: remembered.expiresAt, detail: 'remember me',
@@ -238,7 +241,7 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
238
241
  // (067) another application of the session sharing group was signed in to: sign in here too
239
242
  if (!session.username && sharingGroup(app)) {
240
243
  const shared = await useShared(req, reply, app);
241
- if (shared)
244
+ if (shared && (await carriesSecondStep(app, shared.username, shared.roles, shared.method)))
242
245
  session = await signIn(req, reply, app, session, shared.username, {
243
246
  extraRoles: shared.roles, groups: shared.groups, method: shared.method, shared: shared.hash, detail: 'shared sign-in',
244
247
  });
@@ -619,6 +622,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
619
622
  fileRoutes(app, loadContext, txContext, forbidden);
620
623
  // (087) "Forgot password?"
621
624
  passwordResetRoutes(app);
625
+ twoStepRoutes(app);
622
626
  // (079) the developer toolbar's session state viewer and actions
623
627
  devbarRoutes(app, loadContext);
624
628
  // (084) users' feedback
@@ -1272,7 +1276,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1272
1276
  logActivity({ appId: a.id, username: account, event: 'login_failed', ip, detail: `ldap:${d.name}: no access` });
1273
1277
  return fail(locale.t('login.no_access', { user: account, app: a.name }), 403);
1274
1278
  }
1275
- return completeLogin(req, reply, a, session, account, {
1279
+ return passwordSignIn(req, reply, a, session, account, {
1276
1280
  next, remember, extraRoles: access.roles, groups: user.groups, method: `ldap:${d.name}`, detail: `ldap:${d.name}`,
1277
1281
  });
1278
1282
  }
@@ -1283,7 +1287,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1283
1287
  logActivity({ appId: a.id, username: r.username, event: 'password_expired', ip });
1284
1288
  return reply.type('text/html').send(expiredPage(a, locale, session, r.username, safeNext(a, next)));
1285
1289
  }
1286
- return completeLogin(req, reply, a, session, r.username, { next, remember });
1290
+ return passwordSignIn(req, reply, a, session, r.username, { next, remember });
1287
1291
  });
1288
1292
 
1289
1293
  app.post('/a/:alias/password', async (req: Req, reply) => {
@@ -1309,7 +1313,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1309
1313
  }
1310
1314
  logActivity({ appId: a.id, username, event: 'password_changed', ip });
1311
1315
  const canonical = (await runtime.one<{ username: string }>('select username from meta.account where lower(username) = lower($1)', [username]))!.username;
1312
- return completeLogin(req, reply, a, session, canonical, { next });
1316
+ return passwordSignIn(req, reply, a, session, canonical, { next });
1313
1317
  });
1314
1318
 
1315
1319
  // ---------------------------------------------------------------- single sign-on (OpenID Connect)
@@ -1374,7 +1378,7 @@ export async function runtimeRoutes(app: FastifyInstance) {
1374
1378
  return simplePage(reply, 403, locale.t('login.no_access_title'), locale.t('login.no_access', { user: result.username, app: a.name }), `/a/${a.alias}/login`, locale);
1375
1379
  }
1376
1380
  const session = await getSession(req, reply, a.id, `/a/${a.alias}`);
1377
- return completeLogin(req, reply, a, session, result.username, { extraRoles: access.roles, next: result.next ?? undefined, detail: `sso:${p.name}` });
1381
+ return completeLogin(req, reply, a, session, result.username, { extraRoles: access.roles, next: result.next ?? undefined, method: `sso:${p.name}`, detail: `sso:${p.name}` });
1378
1382
  };
1379
1383
 
1380
1384
  // SAML: the IdP posts the response cross-site, where SameSite=Lax cookies aren't sent; post it on