feat: move sso and ldap logins into plugins (C2)

This commit is contained in:
LukeGus committed 2026-09-24 18:23:20 -05:00
1 parent 5c18435105
commit 925da4a661
223 files changed
+25778 -6820

No files matched your search

+50
View File
@@ -0,0 +1,50 @@
{
"loginWithLdap": "Sign in with LDAP",
"loginWithProvider": "Login with {{name}}",
"loading": "Loading...",
"requiredField": "This field is required",
"username": "LDAP Username",
"password": "LDAP Password",
"signIn": "Sign In",
"permissions": {
"manage": {
"title": "Manage LDAP directories",
"description": "Add, change and remove the LDAP directories people can sign in with."
}
},
"providers": {
"docsLink": "View docs",
"none": "No LDAP directories configured.",
"add": "Add Directory",
"edit": "Edit Directory",
"delete": "Delete Directory",
"deleteConfirm": "Delete this directory? Users who sign in with it will not be able to log in.",
"name": "Display Name",
"namePlaceholder": "Company directory",
"nameRequired": "A display name is required",
"enabled": "Enabled",
"save": "Save Directory",
"saving": "Saving...",
"saved": "Saved",
"saveFailed": "Failed to save",
"deleted": "Deleted",
"deleteFailed": "Failed to delete",
"loadFailed": "Failed to load LDAP directories",
"cancel": "Cancel"
},
"fields": {
"host": "LDAP Host",
"port": "Port",
"useTls": "Use TLS (LDAPS)",
"bindDn": "Bind DN",
"bindPassword": "Bind Password",
"secretKeep": "A password is saved. Leave this empty to keep it.",
"userSearchBase": "User Search Base",
"userSearchFilter": "User Search Filter",
"usernameAttr": "Username Attribute",
"displayNameAttr": "Display Name Attribute",
"groupSearchBase": "Group Search Base",
"adminGroup": "Admin Group",
"allowedUsers": "Allowed Users"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Teken aan met {{name}}",
"loading": "Laai tans...",
"requiredField": "Hierdie veld is verpligtend",
"username": "LDAP-gebruikersnaam",
"password": "LDAP-wagwoord",
"signIn": "Aanmeld",
"providers": {
"docsLink": "Bekyk dokumente",
"name": "Vertoonnaam",
"enabled": "Geaktiveer",
"saving": "Stoor...",
"saved": "Gestoor",
"saveFailed": "Kon nie stoor nie",
"deleted": "Verwyder",
"deleteFailed": "Kon nie verwyder nie",
"cancel": "Kanselleer"
},
"fields": {
"host": "LDAP-gasheer",
"port": "Hawe",
"useTls": "Gebruik TLS (LDAPS)",
"bindDn": "Bind DN",
"bindPassword": "Bind Wagwoord",
"userSearchBase": "Gebruikersoekbasis",
"userSearchFilter": "Gebruikersoekfilter",
"usernameAttr": "Gebruikersnaam-kenmerk",
"displayNameAttr": "Vertoonnaam-kenmerk",
"groupSearchBase": "Groepsoekbasis",
"adminGroup": "Admingroep",
"allowedUsers": "Toegelate Gebruikers"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "تسجيل الدخول باستخدام {{name}}",
"loading": "جارٍ التحميل...",
"requiredField": "هذا الحقل مطلوب",
"username": "اسم مستخدم LDAP",
"password": "كلمة مرور LDAP",
"signIn": "تسجيل الدخول",
"providers": {
"docsLink": "عرض المستندات",
"name": "الاسم المعروض",
"enabled": "مُمكّن",
"saving": "جارٍ الحفظ...",
"saved": "محفوظ",
"saveFailed": "فشل في الحفظ",
"deleted": "محذوف",
"deleteFailed": "فشل في الحذف",
"cancel": "إلغاء"
},
"fields": {
"host": "مضيف LDAP",
"port": "المنفذ",
"useTls": "استخدام TLS (LDAPS)",
"bindDn": "اسم مميز للربط",
"bindPassword": "كلمة مرور الارتباط",
"userSearchBase": "قاعدة بحث المستخدمين",
"userSearchFilter": "مرشح بحث المستخدمين",
"usernameAttr": "سمة اسم المستخدم",
"displayNameAttr": "سمة اسم العرض",
"groupSearchBase": "قاعدة بحث المجموعات",
"adminGroup": "مجموعة المديرين",
"allowedUsers": "المستخدمون المسموح بهم"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Влезте с {{name}}",
"loading": "Зареждане...",
"requiredField": "Това поле е задължително",
"username": "Потребителско име на LDAP",
"password": "LDAP парола",
"signIn": "Вход",
"providers": {
"docsLink": "Преглед на документи",
"name": "Показвано име",
"enabled": "Активирано",
"saving": "Запазване...",
"saved": "Запазено",
"saveFailed": "Запазването не бе успешно",
"deleted": "Изтрито",
"deleteFailed": "Изтриването не бе успешно",
"cancel": "Отказ"
},
"fields": {
"host": "LDAP хост",
"port": "Порт",
"useTls": "Използвайте TLS (LDAPS)",
"bindDn": "Обвързване DN",
"bindPassword": "Свързване на парола",
"userSearchBase": "База за търсене на потребители",
"userSearchFilter": "Филтър за търсене на потребители",
"usernameAttr": "Атрибут на потребителското име",
"displayNameAttr": "Атрибут за показвано име",
"groupSearchBase": "База за групово търсене",
"adminGroup": "Административна група",
"allowedUsers": "Разрешени потребители"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "{{name}} দিয়ে লগইন করুন",
"loading": "লোড হচ্ছে...",
"requiredField": "এই ক্ষেত্রটি আবশ্যক",
"username": "LDAP ব্যবহারকারীর নাম",
"password": "এলডিএপি পাসওয়ার্ড",
"signIn": "সাইন ইন করুন",
"providers": {
"docsLink": "নথি দেখুন",
"name": "প্রদর্শনের নাম",
"enabled": "সক্ষম করা হয়েছে",
"saving": "সঞ্চয়...",
"saved": "সংরক্ষিত",
"saveFailed": "সংরক্ষণ করতে ব্যর্থ হয়েছে",
"deleted": "মুছে ফেলা হয়েছে",
"deleteFailed": "মুছে ফেলতে ব্যর্থ হয়েছে",
"cancel": "বাতিল করুন"
},
"fields": {
"host": "এলডিএপি হোস্ট",
"port": "বন্দর",
"useTls": "TLS (LDAPS) ব্যবহার করুন",
"bindDn": "ডিএনকে আবদ্ধ করুন",
"bindPassword": "পাসওয়ার্ড সংযুক্ত করুন",
"userSearchBase": "ব্যবহারকারী অনুসন্ধান ভিত্তি",
"userSearchFilter": "ব্যবহারকারী অনুসন্ধান ফিল্টার",
"usernameAttr": "ব্যবহারকারীর নাম অ্যাট্রিবিউট",
"displayNameAttr": "ডিসপ্লে নেম অ্যাট্রিবিউট",
"groupSearchBase": "গ্রুপ অনুসন্ধান ভিত্তি",
"adminGroup": "অ্যাডমিন গ্রুপ",
"allowedUsers": "অনুমোদিত ব্যবহারকারী"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Inicia la sessió amb {{name}}",
"loading": "S'està carregant...",
"requiredField": "Aquest camp és obligatori",
"username": "Nom d'usuari LDAP",
"password": "Contrasenya LDAP",
"signIn": "Inicia la sessió",
"providers": {
"docsLink": "Veure documents",
"name": "Nom de visualització",
"enabled": "Activat",
"saving": "Desant...",
"saved": "Desat",
"saveFailed": "No s'ha pogut desar",
"deleted": "Suprimit",
"deleteFailed": "No s'ha pogut suprimir",
"cancel": "Cancel·la"
},
"fields": {
"host": "Amfitrió LDAP",
"port": "Port",
"useTls": "Utilitza TLS (LDAPS)",
"bindDn": "Vinculació de nom de domini",
"bindPassword": "Contrasenya de vinculació",
"userSearchBase": "Base de cerca d'usuaris",
"userSearchFilter": "Filtre de cerca d'usuaris",
"usernameAttr": "Atribut de nom d'usuari",
"displayNameAttr": "Atribut del nom de visualització",
"groupSearchBase": "Base de cerca de grups",
"adminGroup": "Grup d'administradors",
"allowedUsers": "Usuaris permesos"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Přihlaste se pomocí {{name}}",
"loading": "Načítání...",
"requiredField": "Toto pole je povinné",
"username": "Uživatelské jméno LDAP",
"password": "Heslo LDAP",
"signIn": "Přihlásit se",
"providers": {
"docsLink": "Zobrazit dokumenty",
"name": "Zobrazovaný název",
"enabled": "Povoleno",
"saving": "Ukládání...",
"saved": "Uloženo",
"saveFailed": "Uložení se nezdařilo",
"deleted": "Smazáno",
"deleteFailed": "Nepodařilo se smazat",
"cancel": "Zrušit"
},
"fields": {
"host": "Hostitel LDAP",
"port": "Přístav",
"useTls": "Používejte TLS (LDAPS)",
"bindDn": "Vázat DN",
"bindPassword": "Vázat heslo",
"userSearchBase": "Vyhledávací báze uživatelů",
"userSearchFilter": "Filtr vyhledávání uživatelů",
"usernameAttr": "Atribut uživatelského jména",
"displayNameAttr": "Atribut zobrazovaného názvu",
"groupSearchBase": "Základna pro vyhledávání skupin",
"adminGroup": "Skupina administrátorů",
"allowedUsers": "Povolení uživatelé"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Log ind med {{name}}",
"loading": "Indlæser...",
"requiredField": "Dette felt er påkrævet",
"username": "LDAP-brugernavn",
"password": "LDAP-adgangskode",
"signIn": "Log ind",
"providers": {
"docsLink": "Se dokumenter",
"name": "Vist navn",
"enabled": "Aktiveret",
"saving": "Gemmer...",
"saved": "Gemt",
"saveFailed": "Kunne ikke gemme",
"deleted": "Slettet",
"deleteFailed": "Kunne ikke slette",
"cancel": "Ophæve"
},
"fields": {
"host": "LDAP-vært",
"port": "Havn",
"useTls": "Brug TLS (LDAPS)",
"bindDn": "Bind DN",
"bindPassword": "Bind adgangskode",
"userSearchBase": "Brugersøgningsbase",
"userSearchFilter": "Brugersøgningsfilter",
"usernameAttr": "Brugernavnsattribut",
"displayNameAttr": "Visningsnavnattribut",
"groupSearchBase": "Gruppesøgningsbase",
"adminGroup": "Admingruppe",
"allowedUsers": "Tilladte brugere"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Anmeldung mit {{name}}",
"loading": "Laden...",
"requiredField": "Dieses Feld ist erforderlich",
"username": "LDAP-Benutzername",
"password": "LDAP-Passwort",
"signIn": "Anmelden",
"providers": {
"docsLink": "Dokumentation anzeigen",
"name": "Anzeigename",
"enabled": "Aktiviert",
"saving": "Speichern...",
"saved": "Gespeichert",
"saveFailed": "Speichern fehlgeschlagen",
"deleted": "Gelöscht",
"deleteFailed": "Löschen fehlgeschlagen",
"cancel": "Abbrechen"
},
"fields": {
"host": "LDAP-Host",
"port": "Port",
"useTls": "TLS verwenden (LDAPS)",
"bindDn": "Bind-DN",
"bindPassword": "Bind-Passwort",
"userSearchBase": "Benutzer-Suchbasis",
"userSearchFilter": "Benutzersuchfilter",
"usernameAttr": "Benutzernamen-Attribut",
"displayNameAttr": "Anzeigename-Attribut",
"groupSearchBase": "Gruppen-Suchbasis",
"adminGroup": "Admin-Gruppe",
"allowedUsers": "Erlaubte Benutzer"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Σύνδεση με {{name}}",
"loading": "Φόρτωση...",
"requiredField": "Αυτό το πεδίο είναι υποχρεωτικό",
"username": "Όνομα χρήστη LDAP",
"password": "Κωδικός πρόσβασης LDAP",
"signIn": "Σύνδεση",
"providers": {
"docsLink": "Προβολή εγγράφων",
"name": "Εμφανιζόμενο όνομα",
"enabled": "Ενεργοποιημένο",
"saving": "Οικονομία...",
"saved": "Αποθηκεύτηκε",
"saveFailed": "Η αποθήκευση απέτυχε",
"deleted": "Διαγράφηκε",
"deleteFailed": "Η διαγραφή απέτυχε",
"cancel": "Ματαίωση"
},
"fields": {
"host": "Κεντρικός υπολογιστής LDAP",
"port": "Λιμάνι",
"useTls": "Χρήση TLS (LDAPS)",
"bindDn": "Σύνδεση DN",
"bindPassword": "Σύνδεση κωδικού πρόσβασης",
"userSearchBase": "Βάση αναζήτησης χρηστών",
"userSearchFilter": "Φίλτρο αναζήτησης χρήστη",
"usernameAttr": "Χαρακτηριστικό ονόματος χρήστη",
"displayNameAttr": "Χαρακτηριστικό ονόματος εμφάνισης",
"groupSearchBase": "Βάση αναζήτησης ομάδας",
"adminGroup": "Ομάδα Διαχειριστών",
"allowedUsers": "Επιτρεπόμενοι χρήστες"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Iniciar sesión con {{name}}",
"loading": "Cargando...",
"requiredField": "Este campo es obligatorio",
"username": "Nombre de usuario LDAP",
"password": "Contraseña LDAP",
"signIn": "Iniciar sesión",
"providers": {
"docsLink": "Ver documentación",
"name": "Nombre visible",
"enabled": "Activado",
"saving": "Guardando...",
"saved": "Guardado",
"saveFailed": "Error al guardar",
"deleted": "Eliminado",
"deleteFailed": "Error al eliminar",
"cancel": "Cancelar"
},
"fields": {
"host": "Host LDAP",
"port": "Puerto",
"useTls": "Usar TLS (LDAPS)",
"bindDn": "DN de enlace",
"bindPassword": "Contraseña de enlace",
"userSearchBase": "Base de búsqueda de usuarios",
"userSearchFilter": "Filtro de búsqueda de usuarios",
"usernameAttr": "Atributo de nombre de usuario",
"displayNameAttr": "Atributo de nombre visible",
"groupSearchBase": "Base de búsqueda de grupos",
"adminGroup": "Grupo de administradores",
"allowedUsers": "Usuarios permitidos"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Kirjaudu sisään tunnuksella {{name}}",
"loading": "Ladataan...",
"requiredField": "Tämä kenttä on pakollinen",
"username": "LDAP-käyttäjätunnus",
"password": "LDAP-salasana",
"signIn": "Kirjaudu sisään",
"providers": {
"docsLink": "Näytä dokumentit",
"name": "Näyttönimi",
"enabled": "Käytössä",
"saving": "Tallennetaan...",
"saved": "Tallennettu",
"saveFailed": "Tallennus epäonnistui",
"deleted": "Poistettu",
"deleteFailed": "Poistaminen epäonnistui",
"cancel": "Peruuttaa"
},
"fields": {
"host": "LDAP-isäntä",
"port": "Portti",
"useTls": "Käytä TLS:ää (LDAPS)",
"bindDn": "Sido DN",
"bindPassword": "Sido salasana",
"userSearchBase": "Käyttäjähakupohja",
"userSearchFilter": "Käyttäjän hakusuodatin",
"usernameAttr": "Käyttäjätunnusattribuutti",
"displayNameAttr": "Näyttönimen attribuutti",
"groupSearchBase": "Ryhmän hakukanta",
"adminGroup": "Hallintaryhmä",
"allowedUsers": "Sallitut käyttäjät"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Se connecter avec {{name}}",
"loading": "Chargement...",
"requiredField": "Ce champ est obligatoire",
"username": "Nom d'utilisateur LDAP",
"password": "Mot de passe LDAP",
"signIn": "Se connecter",
"providers": {
"docsLink": "Documentation",
"name": "Nom d’affichage",
"enabled": "Activé",
"saving": "Enregistrement…",
"saved": "Enregistré",
"saveFailed": "Échec de l'enregistrement",
"deleted": "Supprimé",
"deleteFailed": "Échec de la suppression",
"cancel": "Annuler"
},
"fields": {
"host": "Hôte LDAP",
"port": "Port",
"useTls": "Utiliser TLS (LDAPS)",
"bindDn": "DN de liaison",
"bindPassword": "Mot de passe de liaison",
"userSearchBase": "Base de recherche utilisateur",
"userSearchFilter": "Filtre de recherche utilisateur",
"usernameAttr": "Attribut de nom d’utilisateur",
"displayNameAttr": "Attribut de nom d’affichage",
"groupSearchBase": "Base de recherche de groupe",
"adminGroup": "Groupe admin",
"allowedUsers": "Utilisateurs autorisés"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "התחברות באמצעות {{name}}",
"loading": "טְעִינָה...",
"requiredField": "שדה זה נדרש",
"username": "שם משתמש LDAP",
"password": "סיסמת LDAP",
"signIn": "כניסה",
"providers": {
"docsLink": "הצג מסמכים",
"name": "שם תצוגה",
"enabled": "מופעל",
"saving": "חִסָכוֹן...",
"saved": "נשמר",
"saveFailed": "השמירה נכשלה",
"deleted": "נמחק",
"deleteFailed": "המחיקה נכשלה",
"cancel": "לְבַטֵל"
},
"fields": {
"host": "מארח LDAP",
"port": "נָמָל",
"useTls": "השתמש ב-TLS (LDAPS)",
"bindDn": "קשר DN",
"bindPassword": "קשירת סיסמה",
"userSearchBase": "בסיס חיפוש משתמשים",
"userSearchFilter": "מסנן חיפוש משתמשים",
"usernameAttr": "מאפיין שם משתמש",
"displayNameAttr": "מאפיין שם תצוגה",
"groupSearchBase": "בסיס חיפוש קבוצתי",
"adminGroup": "קבוצת ניהול",
"allowedUsers": "משתמשים מורשים"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "{{name}} से लॉगिन करें",
"loading": "लोड हो रहा है...",
"requiredField": "यह फ़ील्ड आवश्यक है",
"username": "LDAP उपयोगकर्ता नाम",
"password": "LDAP पासवर्ड",
"signIn": "साइन इन करें",
"providers": {
"docsLink": "दस्तावेज़ देखें",
"name": "प्रदर्शन नाम",
"enabled": "सक्षम",
"saving": "सहेजा जा रहा है...",
"saved": "सहेजा गया",
"saveFailed": "सहेजने में विफल",
"deleted": "हटाया गया",
"deleteFailed": "हटाने में विफल",
"cancel": "रद्द करें"
},
"fields": {
"host": "LDAP होस्ट",
"port": "पोर्ट",
"useTls": "TLS (LDAPS) का उपयोग करें",
"bindDn": "Bind DN",
"bindPassword": "Bind पासवर्ड",
"userSearchBase": "उपयोगकर्ता खोज आधार",
"userSearchFilter": "उपयोगकर्ता खोज फ़िल्टर",
"usernameAttr": "उपयोगकर्ता नाम विशेषता",
"displayNameAttr": "प्रदर्शन नाम विशेषता",
"groupSearchBase": "समूह खोज आधार",
"adminGroup": "एडमिन समूह",
"allowedUsers": "अनुमत उपयोगकर्ता"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Bejelentkezés {{name}} használatával",
"loading": "Terhelés...",
"requiredField": "Ez a mező kötelező",
"username": "LDAP felhasználónév",
"password": "LDAP jelszó",
"signIn": "Bejelentkezés",
"providers": {
"docsLink": "Dokumentumok megtekintése",
"name": "Megjelenített név",
"enabled": "Engedélyezve",
"saving": "Megtakarítás...",
"saved": "Mentett",
"saveFailed": "Nem sikerült menteni",
"deleted": "Törölt",
"deleteFailed": "Nem sikerült törölni",
"cancel": "Mégsem"
},
"fields": {
"host": "LDAP-gazdagép",
"port": "Kikötő",
"useTls": "TLS (LDAPS) használata",
"bindDn": "Kötési DN",
"bindPassword": "Jelszó kötése",
"userSearchBase": "Felhasználói keresési bázis",
"userSearchFilter": "Felhasználói keresési szűrő",
"usernameAttr": "Felhasználónév attribútum",
"displayNameAttr": "Megjelenített név attribútum",
"groupSearchBase": "Csoportkeresési bázis",
"adminGroup": "Adminisztrátori csoport",
"allowedUsers": "Engedélyezett felhasználók"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Masuk dengan {{name}}",
"loading": "Memuat...",
"requiredField": "Kolom ini wajib diisi.",
"username": "Nama Pengguna LDAP",
"password": "Kata Sandi LDAP",
"signIn": "Masuk",
"providers": {
"docsLink": "Lihat dokumen",
"name": "Nama Tampilan",
"enabled": "Diaktifkan",
"saving": "Penghematan...",
"saved": "Tersimpan",
"saveFailed": "Gagal menyimpan",
"deleted": "Dihapus",
"deleteFailed": "Gagal menghapus",
"cancel": "Membatalkan"
},
"fields": {
"host": "Host LDAP",
"port": "Pelabuhan",
"useTls": "Gunakan TLS (LDAPS)",
"bindDn": "Ikat DN",
"bindPassword": "Kata Sandi Terikat",
"userSearchBase": "Basis Pencarian Pengguna",
"userSearchFilter": "Filter Pencarian Pengguna",
"usernameAttr": "Atribut Nama Pengguna",
"displayNameAttr": "Atribut Nama Tampilan",
"groupSearchBase": "Basis Pencarian Grup",
"adminGroup": "Grup Admin",
"allowedUsers": "Pengguna yang Diizinkan"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Accedi con {{name}}",
"loading": "Caricamento in corso...",
"requiredField": "Questo campo è obbligatorio",
"username": "Nome utente LDAP",
"password": "Password LDAP",
"signIn": "Accedi",
"providers": {
"docsLink": "Visualizza documentazione",
"name": "Nome visualizzato",
"enabled": "Abilitato",
"saving": "Salvataggio in corso...",
"saved": "Salvato",
"saveFailed": "Salvataggio non riuscito",
"deleted": "Eliminato",
"deleteFailed": "Eliminazione non riuscita",
"cancel": "Annulla"
},
"fields": {
"host": "Host LDAP",
"port": "Porta",
"useTls": "Usa TLS (LDAPS)",
"bindDn": "Bind DN",
"bindPassword": "Password di bind",
"userSearchBase": "Base di ricerca utenti",
"userSearchFilter": "Filtro di ricerca utenti",
"usernameAttr": "Attributo nome utente",
"displayNameAttr": "Attributo nome visualizzato",
"groupSearchBase": "Base di ricerca gruppi",
"adminGroup": "Gruppo amministratori",
"allowedUsers": "Utenti autorizzati"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "{{name}}でログイン",
"loading": "読み込み中...",
"requiredField": "この項目は必須です",
"username": "LDAPユーザー名",
"password": "LDAPパスワード",
"signIn": "サインイン",
"providers": {
"docsLink": "ドキュメントを表示",
"name": "表示名",
"enabled": "有効",
"saving": "保存中...",
"saved": "保存しました",
"saveFailed": "保存に失敗しました",
"deleted": "削除しました",
"deleteFailed": "削除に失敗しました",
"cancel": "キャンセル"
},
"fields": {
"host": "LDAPホスト",
"port": "ポート",
"useTls": "TLS (LDAPS) を使用",
"bindDn": "バインドDN",
"bindPassword": "バインドパスワード",
"userSearchBase": "ユーザー検索ベース",
"userSearchFilter": "ユーザー検索フィルター",
"usernameAttr": "ユーザー名属性",
"displayNameAttr": "表示名属性",
"groupSearchBase": "グループ検索ベース",
"adminGroup": "管理者グループ",
"allowedUsers": "許可ユーザー"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "{{name}}으로 로그인",
"loading": "로딩 중...",
"requiredField": "필수 입력란입니다",
"username": "LDAP 사용자 이름",
"password": "LDAP 비밀번호",
"signIn": "로그인",
"providers": {
"docsLink": "문서 보기",
"name": "표시 이름",
"enabled": "사용",
"saving": "저장 중...",
"saved": "저장됨",
"saveFailed": "저장 실패",
"deleted": "삭제됨",
"deleteFailed": "삭제 실패",
"cancel": "취소"
},
"fields": {
"host": "LDAP 호스트",
"port": "포트",
"useTls": "TLS 사용 (LDAPS)",
"bindDn": "바인딩 DN",
"bindPassword": "바인딩 암호",
"userSearchBase": "사용자 검색 기준",
"userSearchFilter": "사용자 검색 필터",
"usernameAttr": "사용자 이름 속성",
"displayNameAttr": "표시 이름 속성",
"groupSearchBase": "그룹 검색 기준",
"adminGroup": "관리자 그룹",
"allowedUsers": "허용 사용자"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Inloggen met {{name}}",
"loading": "Laden...",
"requiredField": "Dit veld is verplicht.",
"username": "LDAP-gebruikersnaam",
"password": "LDAP-wachtwoord",
"signIn": "Inloggen",
"providers": {
"docsLink": "Documenten bekijken",
"name": "Weergavenaam",
"enabled": "Ingeschakeld",
"saving": "Besparing...",
"saved": "Opgeslagen",
"saveFailed": "Opslaan mislukt",
"deleted": "Verwijderd",
"deleteFailed": "Verwijderen mislukt",
"cancel": "Annuleren"
},
"fields": {
"host": "LDAP-host",
"port": "Haven",
"useTls": "Gebruik TLS (LDAPS)",
"bindDn": "Bind DN",
"bindPassword": "Bind wachtwoord",
"userSearchBase": "Gebruikerszoekbasis",
"userSearchFilter": "Gebruikerszoekfilter",
"usernameAttr": "Gebruikersnaamkenmerk",
"displayNameAttr": "Weergavenaamkenmerk",
"groupSearchBase": "Groepszoekbasis",
"adminGroup": "Beheerdersgroep",
"allowedUsers": "Toegestane gebruikers"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Logg inn med {{name}}",
"loading": "Laster inn...",
"requiredField": "Dette feltet er obligatorisk",
"username": "LDAP-brukernavn",
"password": "LDAP-passord",
"signIn": "Logg inn",
"providers": {
"docsLink": "Vis dokumenter",
"name": "Visningsnavn",
"enabled": "Aktivert",
"saving": "Lagrer...",
"saved": "Lagret",
"saveFailed": "Kunne ikke lagre",
"deleted": "Slettet",
"deleteFailed": "Kunne ikke slette",
"cancel": "Kansellere"
},
"fields": {
"host": "LDAP-vert",
"port": "Havn",
"useTls": "Bruk TLS (LDAPS)",
"bindDn": "Bind DN",
"bindPassword": "Bind passord",
"userSearchBase": "Brukersøkbase",
"userSearchFilter": "Brukersøkfilter",
"usernameAttr": "Brukernavnattributt",
"displayNameAttr": "Visningsnavnattributt",
"groupSearchBase": "Gruppesøkbase",
"adminGroup": "Admingruppe",
"allowedUsers": "Tillatte brukere"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Zaloguj się za pomocą {{name}}",
"loading": "Ładowanie...",
"requiredField": "To pole jest wymagane",
"username": "Nazwa użytkownika LDAP",
"password": "Hasło LDAP",
"signIn": "Zaloguj się",
"providers": {
"docsLink": "Zobacz dokumentację",
"name": "Nazwa wyświetlana",
"enabled": "Włączone",
"saving": "Zapisywanie...",
"saved": "Zapisano",
"saveFailed": "Nie udało się zapisać",
"deleted": "Usunięto",
"deleteFailed": "Nie udało się usunąć",
"cancel": "Anuluj"
},
"fields": {
"host": "Host LDAP",
"port": "Port",
"useTls": "Użyj TLS (LDAPS)",
"bindDn": "Bind DN",
"bindPassword": "Hasło Bind",
"userSearchBase": "Baza wyszukiwania użytkowników",
"userSearchFilter": "Filtr wyszukiwania użytkownika",
"usernameAttr": "Atrybut nazwy użytkownika",
"displayNameAttr": "Atrybut nazwy wyświetlanej",
"groupSearchBase": "Baza wyszukiwania grup",
"adminGroup": "Grupa administratorów",
"allowedUsers": "Dozwoleni użytkownicy"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Faça login com {{name}}",
"loading": "Carregando...",
"requiredField": "Este campo é obrigatório",
"username": "Nome de usuário LDAP",
"password": "Senha LDAP",
"signIn": "Entrar",
"providers": {
"docsLink": "Ver documentos",
"name": "Nome de exibição",
"enabled": "Habilitado",
"saving": "Salvando...",
"saved": "Salvo",
"saveFailed": "Falha ao salvar",
"deleted": "Excluído",
"deleteFailed": "Falha ao excluir",
"cancel": "Cancelar"
},
"fields": {
"host": "Host LDAP",
"port": "Porta",
"useTls": "Usar TLS (LDAPS)",
"bindDn": "Vincular DN",
"bindPassword": "Senha de vinculação",
"userSearchBase": "Base de pesquisa de usuários",
"userSearchFilter": "Filtro de pesquisa do usuário",
"usernameAttr": "Atributo de nome de usuário",
"displayNameAttr": "Atributo de nome de exibição",
"groupSearchBase": "Base de pesquisa de grupo",
"adminGroup": "Grupo de Administração",
"allowedUsers": "Usuários autorizados"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Iniciar sessão com {{name}}",
"loading": "A carregar...",
"requiredField": "Este campo é obrigatório",
"username": "Nome de utilizador LDAP",
"password": "Palavra-passe LDAP",
"signIn": "Iniciar sessão",
"providers": {
"docsLink": "Ver docs",
"name": "Nome de apresentação",
"enabled": "Ativado",
"saving": "A guardar...",
"saved": "Guardado",
"saveFailed": "Falha ao guardar",
"deleted": "Eliminado",
"deleteFailed": "Falha ao eliminar",
"cancel": "Cancelar"
},
"fields": {
"host": "Servidor LDAP",
"port": "Porta",
"useTls": "Usar TLS (LDAPS)",
"bindDn": "Bind DN",
"bindPassword": "Palavra-passe de Bind",
"userSearchBase": "Base de pesquisa de utilizadores",
"userSearchFilter": "Filtro de pesquisa de utilizadores",
"usernameAttr": "Atributo de nome de utilizador",
"displayNameAttr": "Atributo de nome de apresentação",
"groupSearchBase": "Base de pesquisa de grupos",
"adminGroup": "Grupo de administradores",
"allowedUsers": "Utilizadores permitidos"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Autentificare cu {{name}}",
"loading": "Încărcare...",
"requiredField": "Acest câmp este obligatoriu",
"username": "Nume de utilizator LDAP",
"password": "Parolă LDAP",
"signIn": "Conectare",
"providers": {
"docsLink": "Vizualizați documentele",
"name": "Numele de afișare",
"enabled": "Activat",
"saving": "Economisire...",
"saved": "Salvat",
"saveFailed": "Salvarea nu a reușit",
"deleted": "Șters",
"deleteFailed": "Ștergerea nu a reușit",
"cancel": "Anula"
},
"fields": {
"host": "Gazdă LDAP",
"port": "Port",
"useTls": "Utilizați TLS (LDAPS)",
"bindDn": "Legați DN-ul",
"bindPassword": "Parolă de legare",
"userSearchBase": "Baza de căutare a utilizatorilor",
"userSearchFilter": "Filtru de căutare utilizator",
"usernameAttr": "Atribut nume utilizator",
"displayNameAttr": "Atributul numelui afișat",
"groupSearchBase": "Baza de căutare a grupului",
"adminGroup": "Grup de administratori",
"allowedUsers": "Utilizatori permiși"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Войти через {{name}}",
"loading": "Загрузка...",
"requiredField": "Обязательное поле",
"username": "Имя пользователя LDAP",
"password": "Пароль LDAP",
"signIn": "Войти",
"providers": {
"docsLink": "Документация",
"name": "Отображаемое имя",
"enabled": "Включено",
"saving": "Сохранение...",
"saved": "Сохранено",
"saveFailed": "Не удалось сохранить",
"deleted": "Удалено",
"deleteFailed": "Не удалось удалить",
"cancel": "Отмена"
},
"fields": {
"host": "LDAP-сервер",
"port": "Порт",
"useTls": "Использовать TLS (LDAPS)",
"bindDn": "Bind DN",
"bindPassword": "Пароль привязки",
"userSearchBase": "База поиска пользователей",
"userSearchFilter": "Фильтр поиска пользователей",
"usernameAttr": "Атрибут имени пользователя",
"displayNameAttr": "Атрибут отображаемого имени",
"groupSearchBase": "База поиска групп",
"adminGroup": "Группа администраторов",
"allowedUsers": "Разрешенные пользователи"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Пријавите се са {{name}}",
"loading": "Учитавање...",
"requiredField": "Ово поље је обавезно",
"username": "LDAP корисничко име",
"password": "LDAP лозинка",
"signIn": "Пријавите се",
"providers": {
"docsLink": "Прикажи документе",
"name": "Приказано име",
"enabled": "Омогућено",
"saving": "Чување...",
"saved": "Сачувано",
"saveFailed": "Чување није успело",
"deleted": "Избрисано",
"deleteFailed": "Брисање није успело",
"cancel": "Откажи"
},
"fields": {
"host": "LDAP хост",
"port": "Лука",
"useTls": "Користите TLS (LDAPS)",
"bindDn": "Повезивање DN-а",
"bindPassword": "Повезивање лозинке",
"userSearchBase": "База претраге корисника",
"userSearchFilter": "Филтер за претрагу корисника",
"usernameAttr": "Атрибут корисничког имена",
"displayNameAttr": "Атрибут приказаног имена",
"groupSearchBase": "База групне претраге",
"adminGroup": "Администраторска група",
"allowedUsers": "Дозвољени корисници"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Logga in med {{name}}",
"loading": "Belastning...",
"requiredField": "Detta fält är obligatoriskt",
"username": "LDAP-användarnamn",
"password": "LDAP-lösenord",
"signIn": "Logga in",
"providers": {
"docsLink": "Visa dokument",
"name": "Visningsnamn",
"enabled": "Aktiverad",
"saving": "Sparande...",
"saved": "Sparad",
"saveFailed": "Misslyckades med att spara",
"deleted": "Borttagen",
"deleteFailed": "Misslyckades med att radera",
"cancel": "Avboka"
},
"fields": {
"host": "LDAP-värd",
"port": "Hamn",
"useTls": "Använd TLS (LDAPS)",
"bindDn": "Bind DN",
"bindPassword": "Bind lösenord",
"userSearchBase": "Användarsökningsbas",
"userSearchFilter": "Användarsökningsfilter",
"usernameAttr": "Användarnamnsattribut",
"displayNameAttr": "Attribut för visningsnamn",
"groupSearchBase": "Gruppsökningsbas",
"adminGroup": "Administratörsgrupp",
"allowedUsers": "Tillåtna användare"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "เข้าสู่ระบบด้วย {{name}}",
"loading": "กำลังโหลด...",
"requiredField": "ช่องนี้จำเป็นต้องกรอก",
"username": "ชื่อผู้ใช้ LDAP",
"password": "รหัสผ่าน LDAP",
"signIn": "เข้าสู่ระบบ",
"providers": {
"docsLink": "ดูเอกสาร",
"name": "ชื่อที่แสดง",
"enabled": "เปิดใช้งาน",
"saving": "ประหยัด...",
"saved": "บันทึกแล้ว",
"saveFailed": "บันทึกไม่สำเร็จ",
"deleted": "ลบแล้ว",
"deleteFailed": "ลบไม่สำเร็จ",
"cancel": "ยกเลิก"
},
"fields": {
"host": "โฮสต์ LDAP",
"port": "ท่าเรือ",
"useTls": "ใช้ TLS (LDAPS)",
"bindDn": "ผูก DN",
"bindPassword": "รหัสผ่านผูกมัด",
"userSearchBase": "ฐานการค้นหาผู้ใช้",
"userSearchFilter": "ตัวกรองการค้นหาผู้ใช้",
"usernameAttr": "คุณลักษณะชื่อผู้ใช้",
"displayNameAttr": "คุณลักษณะชื่อที่แสดง",
"groupSearchBase": "ฐานการค้นหากลุ่ม",
"adminGroup": "กลุ่มผู้ดูแลระบบ",
"allowedUsers": "ผู้ใช้ที่ได้รับอนุญาต"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "{{name}} ile giriş yap",
"loading": "Yükleniyor...",
"requiredField": "Bu alan zorunludur",
"username": "LDAP Kullanıcı Adı",
"password": "LDAP Parolası",
"signIn": "Oturum Aç",
"providers": {
"docsLink": "Belgeleri görüntüle",
"name": "Görünen Ad",
"enabled": "Etkin",
"saving": "Kaydediliyor...",
"saved": "Kaydedildi",
"saveFailed": "Kaydedilemedi",
"deleted": "Silindi",
"deleteFailed": "Silinemedi",
"cancel": "İptal"
},
"fields": {
"host": "LDAP Sunucusu",
"port": "Port",
"useTls": "TLS Kullan (LDAPS)",
"bindDn": "Bind DN",
"bindPassword": "Bind Parolası",
"userSearchBase": "Kullanıcı Arama Tabanı",
"userSearchFilter": "Kullanıcı Arama Süzgeci",
"usernameAttr": "Kullanıcı Adı Özniteliği",
"displayNameAttr": "Görünen Ad Özniteliği",
"groupSearchBase": "Grup Arama Tabanı",
"adminGroup": "Yönetici Grubu",
"allowedUsers": "İzin Verilen Kullanıcılar"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Увійти за допомогою {{name}}",
"loading": "Завантаження...",
"requiredField": "Це поле обов'язкове",
"username": "Ім'я користувача LDAP",
"password": "Пароль LDAP",
"signIn": "Увійти",
"providers": {
"docsLink": "Переглянути документи",
"name": "Відображуване ім'я",
"enabled": "Увімкнено",
"saving": "Збереження...",
"saved": "Збережено",
"saveFailed": "Не вдалося зберегти",
"deleted": "Видалено",
"deleteFailed": "Не вдалося видалити",
"cancel": "Скасувати"
},
"fields": {
"host": "Хост LDAP",
"port": "Порт",
"useTls": "Використовувати TLS (LDAPS)",
"bindDn": "Прив'язане DN",
"bindPassword": "Прив’язати пароль",
"userSearchBase": "База пошуку користувачів",
"userSearchFilter": "Фільтр пошуку користувачів",
"usernameAttr": "Атрибут імені користувача",
"displayNameAttr": "Атрибут відображуваного імені",
"groupSearchBase": "База групового пошуку",
"adminGroup": "Група адміністраторів",
"allowedUsers": "Дозволені користувачі"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "Đăng nhập bằng {{name}}",
"loading": "Đang tải...",
"requiredField": "Trường này là bắt buộc",
"username": "Tên người dùng LDAP",
"password": "Mật khẩu LDAP",
"signIn": "Đăng nhập",
"providers": {
"docsLink": "Xem tài liệu",
"name": "Tên hiển thị",
"enabled": "Đã bật",
"saving": "Đang lưu...",
"saved": "Đã lưu",
"saveFailed": "Không thể lưu",
"deleted": "Đã xóa",
"deleteFailed": "Không thể xóa",
"cancel": "Hủy"
},
"fields": {
"host": "Máy chủ LDAP",
"port": "Cổng",
"useTls": "Dùng TLS (LDAPS)",
"bindDn": "Bind DN",
"bindPassword": "Mật khẩu Bind",
"userSearchBase": "Cơ sở tìm kiếm người dùng",
"userSearchFilter": "Bộ lọc tìm kiếm người dùng",
"usernameAttr": "Thuộc tính tên người dùng",
"displayNameAttr": "Thuộc tính tên hiển thị",
"groupSearchBase": "Cơ sở tìm kiếm nhóm",
"adminGroup": "Nhóm quản trị",
"allowedUsers": "Người dùng được phép"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "使用{{name}}登录",
"loading": "加载中...",
"requiredField": "此字段为必填项",
"username": "LDAP 用户名",
"password": "LDAP 密码",
"signIn": "登录",
"providers": {
"docsLink": "查看文档",
"name": "显示名称",
"enabled": "已启用",
"saving": "保存中...",
"saved": "已保存",
"saveFailed": "保存失败",
"deleted": "已删除",
"deleteFailed": "删除失败",
"cancel": "取消"
},
"fields": {
"host": "LDAP 主机",
"port": "端口",
"useTls": "使用 TLS (LDAPS)",
"bindDn": "绑定 DN",
"bindPassword": "绑定密码",
"userSearchBase": "用户搜索基准",
"userSearchFilter": "用户搜索过滤器",
"usernameAttr": "用户名属性",
"displayNameAttr": "显示名称属性",
"groupSearchBase": "群组搜索基准",
"adminGroup": "管理员群组",
"allowedUsers": "允许的用户"
}
}
@@ -0,0 +1,33 @@
{
"loginWithProvider": "使用 {{name}} 登入",
"loading": "載入中...",
"requiredField": "此欄位為必填",
"username": "LDAP 使用者名稱",
"password": "LDAP 密碼",
"signIn": "登入",
"providers": {
"docsLink": "檢視文件",
"name": "顯示名稱",
"enabled": "已啟用",
"saving": "儲存中...",
"saved": "已儲存",
"saveFailed": "儲存失敗",
"deleted": "已刪除",
"deleteFailed": "刪除失敗",
"cancel": "取消"
},
"fields": {
"host": "LDAP 主機",
"port": "連接埠",
"useTls": "使用 TLS (LDAPS)",
"bindDn": "綁定 DN",
"bindPassword": "綁定密碼",
"userSearchBase": "使用者搜尋基底",
"userSearchFilter": "使用者搜尋篩選器",
"usernameAttr": "使用者名稱屬性",
"displayNameAttr": "顯示名稱屬性",
"groupSearchBase": "群組搜尋基底",
"adminGroup": "管理員群組",
"allowedUsers": "允許的使用者"
}
}
+49
View File
@@ -0,0 +1,49 @@
{
"id": "ldap",
"name": "LDAP",
"version": "1.0.0",
"description": "Sign in with a username and password checked against an LDAP or Active Directory server.",
"author": {
"name": "Termix"
},
"license": "MIT",
"repository": "https://github.com/Termix-SSH/Termix",
"category": "Access & Security",
"icon": "Server",
"engine": {
"termix": ">=2.9.0",
"api": "1"
},
"capabilities": [
"db:own",
"secrets:own",
"network:serve",
"auth:provide",
"ui:surface"
],
"contributes": {
"auth": {
"loginMethods": ["ldap"]
},
"permissions": [
{
"name": "manage",
"titleKey": "permissions.manage.title",
"descriptionKey": "permissions.manage.description",
"defaultRoles": ["admin"]
}
],
"settings": {
"admin": [
{
"key": "providers",
"type": "custom",
"component": "providers",
"permission": "manage"
}
]
}
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
}
@@ -0,0 +1,12 @@
-- ldap 0001: create_providers
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS `p_ldap_providers` (
`id` int AUTO_INCREMENT PRIMARY KEY,
`name` text NOT NULL,
`enabled` boolean NOT NULL DEFAULT true,
`display_order` int NOT NULL DEFAULT 0,
`config` text NOT NULL,
`created_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP),
`updated_at` text NOT NULL DEFAULT (CURRENT_TIMESTAMP)
);
@@ -0,0 +1,12 @@
-- ldap 0001: create_providers
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "p_ldap_providers" (
"id" serial PRIMARY KEY,
"name" text NOT NULL,
"enabled" boolean NOT NULL DEFAULT true,
"display_order" integer NOT NULL DEFAULT 0,
"config" text NOT NULL,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+43
View File
@@ -0,0 +1,43 @@
{
"version": 1,
"tables": {
"providers": {
"columns": {
"id": {
"type": "id",
"primaryKey": true,
"notNull": true
},
"name": {
"type": "text",
"notNull": true
},
"enabled": {
"type": "boolean",
"notNull": true,
"defaultValue": true
},
"displayOrder": {
"type": "integer",
"notNull": true,
"defaultValue": 0
},
"config": {
"type": "text",
"notNull": true
},
"createdAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
},
"updatedAt": {
"type": "timestamp",
"notNull": true,
"defaultNow": true
}
},
"indexes": []
}
}
}
@@ -0,0 +1,12 @@
-- ldap 0001: create_providers
-- Generated by termix-plugin migrations. Review before committing.
CREATE TABLE IF NOT EXISTS "p_ldap_providers" (
"id" integer PRIMARY KEY AUTOINCREMENT,
"name" text NOT NULL,
"enabled" integer NOT NULL DEFAULT 1,
"display_order" integer NOT NULL DEFAULT 0,
"config" text NOT NULL,
"created_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" text NOT NULL DEFAULT CURRENT_TIMESTAMP
);
+19
View File
@@ -0,0 +1,19 @@
{
"name": "@termix-plugin/ldap",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -p tsconfig.json",
"validate": "termix-plugin validate"
},
"dependencies": {
"ldapjs": "^3.0.7"
},
"devDependencies": {
"@termix/plugin-sdk": "*",
"@types/ldapjs": "^3.0.6"
}
}
+34
View File
@@ -0,0 +1,34 @@
import type { Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { providers } from "./tables.js";
import { createProviderStore } from "./providers.js";
import { createLdapLogin, METHOD_ID } from "./ldap.js";
import { registerLdapRoutes } from "./routes.js";
export async function activate(ctx: PluginContext) {
const table = await ctx.db.define(providers);
const store = createProviderStore(ctx, table);
// One form per enabled directory.
ctx.auth.registerLoginMethod({
id: METHOD_ID,
labelKey: "loginWithLdap",
icon: "server",
kind: "form",
external: true,
describe: async () =>
(await store.listRows()).map((row) => ({
id: String(row.id),
label: row.name,
enabled: !!row.enabled,
type: "ldap",
})),
verify: createLdapLogin(ctx, store),
});
registerLdapRoutes(ctx.http.router<Router>(), ctx, store);
}
export async function deactivate() {
// Everything above was registered through ctx and is disposed by core.
}
+205
View File
@@ -0,0 +1,205 @@
/**
* LDAP sign-in as a form login method: a service bind to find the user, a
* bind as the user to check the password, then an optional admin group
* lookup. Core finds or provisions the user and issues the session.
*/
import ldap from "ldapjs";
import {
LoginMethodError,
type PluginContext,
type PluginLoginRequest,
type PluginVerifiedIdentity,
} from "@termix/plugin-sdk/backend";
import type { ProviderStore } from "./providers.js";
import type { LdapConfig } from "./types.js";
export const METHOD_ID = "ldap";
export function ldapEscapeFilter(value: string): string {
return value.replace(
/[\\*()\x00]/g,
(c) => `\\${c.charCodeAt(0).toString(16).padStart(2, "0")}`,
);
}
/** The provider id identities from this directory are stored under. */
export function identityProvider(providerId: number): string {
return `ldap:${providerId}`;
}
function createClient(config: LdapConfig): ldap.Client {
const useTLS = !!config.useTLS;
const url = `${useTLS ? "ldaps" : "ldap"}://${config.host}:${config.port || 389}`;
return ldap.createClient({
url,
tlsOptions: useTLS ? { rejectUnauthorized: false } : undefined,
});
}
function bind(client: ldap.Client, dn: string, password: string) {
return new Promise<void>((resolve, reject) => {
client.bind(dn, password, (err) => (err ? reject(err) : resolve()));
});
}
function search(
client: ldap.Client,
base: string,
filter: string,
attributes: string[],
): Promise<ldap.SearchEntry[]> {
return new Promise((resolve, reject) => {
const entries: ldap.SearchEntry[] = [];
client.search(base, { filter, attributes, scope: "sub" }, (err, res) => {
if (err) return reject(err);
res.on("searchEntry", (entry) => entries.push(entry));
res.on("error", reject);
res.on("end", () => resolve(entries));
});
});
}
function unbind(client: ldap.Client): void {
try {
client.unbind();
} catch {
// best effort
}
}
function firstValue(entry: ldap.SearchEntry, key: string): string {
const attr = entry.attributes.find((a) => a.type === key);
if (!attr) return "";
return Array.isArray(attr.values) ? attr.values[0] : String(attr.values);
}
export function createLdapLogin(ctx: PluginContext, store: ProviderStore) {
return async function verify(
request: PluginLoginRequest,
instanceId: string | null,
): Promise<PluginVerifiedIdentity> {
const body = request.body ?? {};
const providerId = Number(instanceId ?? body.providerId);
const username = typeof body.username === "string" ? body.username : "";
const password = typeof body.password === "string" ? body.password : "";
if (!Number.isInteger(providerId) || !username || !password) {
throw new LoginMethodError(
"providerId, username, and password are required",
400,
);
}
// Same limits as password login: an LDAP bind is a password guess.
const clientIp = request.ip || "unknown";
const rateLimitKey = `${providerId}:${username}`;
const lock = await ctx.auth.loginRateLimit.isLocked(clientIp, rateLimitKey);
if (lock.locked) {
const error = new LoginMethodError(
"Too many login attempts. Please try again later.",
429,
);
Object.assign(error, { remainingTime: lock.remainingTime });
throw error;
}
const provider = await store.find(providerId);
if (!provider || !provider.enabled) {
throw new LoginMethodError("LDAP provider not found", 404);
}
const config = provider.config;
if (
!config.host ||
!config.bindDN ||
!config.userSearchBase ||
!config.userSearchFilter
) {
throw new LoginMethodError("LDAP provider is misconfigured", 500);
}
const refuse = async (reason: string) => {
await ctx.auth.loginRateLimit.recordFailure(clientIp, rateLimitKey);
ctx.log.warn(`LDAP login refused for ${username}: ${reason}`);
return new LoginMethodError("Invalid username or password", 401);
};
const serviceClient = createClient(config);
try {
await bind(serviceClient, config.bindDN, config.bindPassword);
const filter = config.userSearchFilter.replace(
/\{\{username\}\}/g,
ldapEscapeFilter(username),
);
const entries = await search(
serviceClient,
config.userSearchBase,
filter,
[
config.usernameAttribute || "uid",
config.displayNameAttribute || "cn",
"mail",
"email",
],
);
if (entries.length === 0) throw await refuse("user not found");
const userEntry = entries[0];
const userDN = userEntry.dn.toString();
const uid =
firstValue(userEntry, config.usernameAttribute || "uid") || username;
const displayName =
firstValue(userEntry, config.displayNameAttribute || "cn") || username;
const email =
firstValue(userEntry, "mail") || firstValue(userEntry, "email") || "";
const userClient = createClient(config);
try {
await bind(userClient, userDN, password);
} catch {
throw await refuse("wrong password");
} finally {
unbind(userClient);
}
let isAdmin: boolean | undefined;
if (config.adminGroup && config.groupSearchBase) {
isAdmin = false;
try {
const groups = await search(
serviceClient,
config.groupSearchBase,
`(member=${ldapEscapeFilter(userDN)})`,
["cn", "dn"],
);
isAdmin = groups.some(
(group) =>
firstValue(group, "cn") === config.adminGroup ||
group.dn.toString() === config.adminGroup,
);
} catch (error) {
ctx.log.warn(`LDAP group check failed: ${String(error)}`);
}
}
return {
kind: "external",
provider: identityProvider(providerId),
subject: uid,
email: email || null,
name: displayName,
isAdmin,
allowedUsers: config.allowedUsers ?? null,
legacy: {
identifier: `ldap:${providerId}:${uid}`,
providerRowId: providerId,
},
rememberMe: !!body.rememberMe,
rateLimitKey,
};
} finally {
unbind(serviceClient);
}
};
}
+141
View File
@@ -0,0 +1,141 @@
import { and, asc, desc, eq } from "drizzle-orm";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import type { LdapConfig, ProviderRow } from "./types.js";
/* eslint-disable @typescript-eslint/no-explicit-any */
// ctx.db.define hands the table back untyped and the drizzle handle is the
// server's own, so both are typed at this module's edge.
type Table = any;
type Drizzle = any;
/* eslint-enable @typescript-eslint/no-explicit-any */
const LEGACY_PREFIXES = ["encoded:", "encrypted:"];
export type ProviderStore = ReturnType<typeof createProviderStore>;
export function createProviderStore(ctx: PluginContext, table: Table) {
const client = () => ctx.db.client<Drizzle>();
/** Opens the bind password: sealed, or the 2.8 base64 forms. */
async function openSecret(value: string): Promise<string> {
const prefix = LEGACY_PREFIXES.find((p) => value.startsWith(p));
if (prefix) {
return Buffer.from(value.slice(prefix.length), "base64").toString("utf8");
}
return (await ctx.secrets.unseal(value)) ?? value;
}
async function parseConfig(raw: string): Promise<LdapConfig> {
let config: Record<string, unknown>;
try {
config = JSON.parse(raw);
} catch {
config = {};
}
if (typeof config.bindPassword === "string" && config.bindPassword) {
config.bindPassword = await openSecret(config.bindPassword);
}
return config as unknown as LdapConfig;
}
async function sealConfig(config: Record<string, unknown>): Promise<string> {
const out = { ...config };
if (typeof out.bindPassword === "string" && out.bindPassword) {
out.bindPassword = await ctx.secrets.seal(out.bindPassword);
}
return JSON.stringify(out);
}
async function findRow(id: number): Promise<ProviderRow | null> {
const drizzle = await client();
const rows = (await drizzle
.select()
.from(table)
.where(eq(table.id, id))
.limit(1)) as ProviderRow[];
return rows[0] ?? null;
}
return {
parseConfig,
findRow,
async listRows(): Promise<ProviderRow[]> {
const drizzle = await client();
return (await drizzle
.select()
.from(table)
.orderBy(asc(table.displayOrder), asc(table.id))) as ProviderRow[];
},
async find(id: number): Promise<{
row: ProviderRow;
enabled: boolean;
config: LdapConfig;
} | null> {
const row = await findRow(id);
if (!row) return null;
return {
row,
enabled: !!row.enabled,
config: await parseConfig(row.config),
};
},
async create(input: {
name: string;
enabled: boolean;
displayOrder: number;
config: Record<string, unknown>;
}): Promise<ProviderRow> {
const drizzle = await client();
const now = new Date().toISOString();
await drizzle.insert(table).values({
name: input.name,
enabled: input.enabled,
displayOrder: input.displayOrder,
config: await sealConfig(input.config),
createdAt: now,
updatedAt: now,
});
await ctx.db.persist();
// No returning() on every engine: find the row by what was written.
const rows = (await drizzle
.select()
.from(table)
.where(and(eq(table.name, input.name), eq(table.createdAt, now)))
.orderBy(desc(table.id))
.limit(1)) as ProviderRow[];
return rows[0];
},
async update(
id: number,
values: Partial<{
name: string;
enabled: boolean;
displayOrder: number;
config: Record<string, unknown>;
}>,
): Promise<ProviderRow | null> {
const drizzle = await client();
const { config, ...rest } = values;
await drizzle
.update(table)
.set({
...rest,
...(config ? { config: await sealConfig(config) } : {}),
updatedAt: new Date().toISOString(),
})
.where(eq(table.id, id));
await ctx.db.persist();
return findRow(id);
},
async remove(id: number): Promise<void> {
const drizzle = await client();
await drizzle.delete(table).where(eq(table.id, id));
await ctx.db.persist();
},
};
}
+265
View File
@@ -0,0 +1,265 @@
import type { Request, Response, Router } from "express";
import type { PluginContext } from "@termix/plugin-sdk/backend";
import { identityProvider } from "./ldap.js";
import type { ProviderStore } from "./providers.js";
import { REQUIRED_FIELDS, type ProviderRow } from "./types.js";
function fail(
ctx: PluginContext,
res: Response,
message: string,
error: unknown,
) {
ctx.log.error(
message,
error instanceof Error ? error : new Error(String(error)),
);
res.status(500).json({ error: message });
}
function missingFields(config: Record<string, unknown>): string[] {
return REQUIRED_FIELDS.filter((field) => !config[field]);
}
export function registerLdapRoutes(
router: Router,
ctx: PluginContext,
store: ProviderStore,
): void {
router.use(ctx.rbac.require("manage") as never);
async function present(row: ProviderRow) {
const { bindPassword, ...config } = (await store.parseConfig(
row.config,
)) as unknown as Record<string, unknown>;
return {
id: row.id,
name: row.name,
enabled: !!row.enabled,
displayOrder: row.displayOrder,
config,
hasBindPassword: typeof bindPassword === "string" && !!bindPassword,
};
}
/**
* @openapi
* /plugin-api/ldap/providers:
* get:
* summary: List LDAP directories
* description: Every configured LDAP directory. The bind password is never sent, only whether one is set. Needs ldap.manage.
* tags:
* - LDAP
* responses:
* 200:
* description: Directories.
* 403:
* description: Missing permission.
*/
router.get("/providers", async (_req: Request, res: Response) => {
try {
const rows = await store.listRows();
res.json({ providers: await Promise.all(rows.map(present)) });
} catch (error) {
fail(ctx, res, "Failed to list LDAP providers", error);
}
});
/**
* @openapi
* /plugin-api/ldap/providers:
* post:
* summary: Add an LDAP directory
* description: Needs ldap.manage.
* tags:
* - LDAP
* requestBody:
* required: true
* content:
* application/json:
* schema:
* type: object
* properties:
* name:
* type: string
* enabled:
* type: boolean
* config:
* type: object
* responses:
* 201:
* description: Directory added.
* 400:
* description: Missing fields.
* 403:
* description: Missing permission.
*/
router.post("/providers", async (req: Request, res: Response) => {
try {
const {
name,
enabled = true,
displayOrder = 0,
config = {},
} = req.body ?? {};
if (typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "Provider name is required" });
return;
}
const missing = missingFields(config);
if (missing.length > 0) {
res.status(400).json({
error: `Missing required LDAP fields: ${missing.join(", ")}`,
});
return;
}
const row = await store.create({
name: name.trim(),
enabled: !!enabled,
displayOrder: Number(displayOrder) || 0,
config: { ...(config as Record<string, unknown>) },
});
await ctx.audit.record({
action: "ldap_provider_create",
resourceType: "ldap_provider",
resourceId: String(row.id),
resourceName: row.name,
success: true,
});
res.status(201).json(await present(row));
} catch (error) {
fail(ctx, res, "Failed to create LDAP provider", error);
}
});
/**
* @openapi
* /plugin-api/ldap/providers/{id}:
* put:
* summary: Update an LDAP directory
* description: Config fields are merged over the stored ones; an empty bind password keeps the stored one. Needs ldap.manage.
* tags:
* - LDAP
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Directory updated.
* 400:
* description: Missing fields.
* 403:
* description: Missing permission.
* 404:
* description: Directory not found.
*/
router.put("/providers/:id", async (req: Request, res: Response) => {
try {
const id = Number.parseInt(String(req.params.id), 10);
const row = Number.isInteger(id) ? await store.findRow(id) : null;
if (!row) {
res.status(404).json({ error: "Provider not found" });
return;
}
const { name, enabled, displayOrder, config } = req.body ?? {};
const values: Parameters<ProviderStore["update"]>[1] = {};
if (name !== undefined) {
if (typeof name !== "string" || !name.trim()) {
res.status(400).json({ error: "Provider name is required" });
return;
}
values.name = name.trim();
}
if (enabled !== undefined) values.enabled = !!enabled;
if (displayOrder !== undefined) {
values.displayOrder = Number(displayOrder) || 0;
}
if (config && typeof config === "object") {
const incoming = { ...(config as Record<string, unknown>) };
if (!incoming.bindPassword) delete incoming.bindPassword;
const merged = {
...((await store.parseConfig(row.config)) as unknown as Record<
string,
unknown
>),
...incoming,
};
const missing = missingFields(merged);
if (missing.length > 0) {
res.status(400).json({
error: `Missing required LDAP fields: ${missing.join(", ")}`,
});
return;
}
values.config = merged;
}
const updated = await store.update(id, values);
await ctx.audit.record({
action: "ldap_provider_update",
resourceType: "ldap_provider",
resourceId: String(id),
resourceName: updated?.name ?? row.name,
success: true,
});
res.json(await present(updated ?? row));
} catch (error) {
fail(ctx, res, "Failed to update LDAP provider", error);
}
});
/**
* @openapi
* /plugin-api/ldap/providers/{id}:
* delete:
* summary: Delete an LDAP directory
* description: Refused while users still sign in with it. Needs ldap.manage.
* tags:
* - LDAP
* parameters:
* - in: path
* name: id
* required: true
* schema:
* type: integer
* responses:
* 200:
* description: Directory deleted.
* 403:
* description: Missing permission.
* 404:
* description: Directory not found.
* 409:
* description: Users are still linked to it.
*/
router.delete("/providers/:id", async (req: Request, res: Response) => {
try {
const id = Number.parseInt(String(req.params.id), 10);
const row = Number.isInteger(id) ? await store.findRow(id) : null;
if (!row) {
res.status(404).json({ error: "Provider not found" });
return;
}
if ((await ctx.auth.countLinkedUsers(identityProvider(id))) > 0) {
res.status(409).json({
error:
"Users still sign in with this directory. Disable it instead, or remove those users first.",
});
return;
}
await store.remove(id);
await ctx.audit.record({
action: "ldap_provider_delete",
resourceType: "ldap_provider",
resourceId: String(id),
resourceName: row.name,
success: true,
});
res.json({ message: "LDAP provider deleted" });
} catch (error) {
fail(ctx, res, "Failed to delete LDAP provider", error);
}
});
}
+24
View File
@@ -0,0 +1,24 @@
import {
boolean,
defineTable,
id,
integer,
text,
timestamp,
} from "@termix/plugin-sdk/db";
/**
* LDAP directories. Rows from 2.8 are copied in from sso_providers by core's
* boot migration, keeping their ids, because identities point at them.
*/
export const providers = defineTable("providers", {
id: id(),
name: text().notNull(),
enabled: boolean().notNull().default(true),
displayOrder: integer().notNull().default(0),
config: text().notNull(),
createdAt: timestamp().notNull().defaultNow(),
updatedAt: timestamp().notNull().defaultNow(),
});
export const tables = [providers];
+34
View File
@@ -0,0 +1,34 @@
export interface LdapConfig {
host: string;
port: number;
useTLS: boolean;
bindDN: string;
bindPassword: string;
userSearchBase: string;
userSearchFilter: string;
usernameAttribute: string;
displayNameAttribute: string;
groupSearchBase?: string;
adminGroup?: string;
allowedUsers?: string;
}
export interface ProviderRow {
id: number;
name: string;
enabled: boolean;
displayOrder: number;
config: string;
createdAt: string;
updatedAt: string;
}
export const REQUIRED_FIELDS = [
"host",
"port",
"bindDN",
"bindPassword",
"userSearchBase",
"userSearchFilter",
"usernameAttribute",
] as const;
@@ -0,0 +1,101 @@
import { useEffect, useState } from "react";
import { ChevronDown, ChevronUp } from "lucide-react";
import { toast } from "sonner";
import { Button, Input } from "@termix/plugin-sdk/ui";
import {
useTranslation,
type LoginMethodUIProps,
} from "@termix/plugin-sdk/frontend";
const labelClass =
"text-[10px] font-bold uppercase tracking-widest text-muted-foreground";
/** A collapsible username and password form per enabled directory. */
export function LdapLoginForms({
instances,
disabled,
submit,
}: LoginMethodUIProps) {
const { t } = useTranslation();
const [expandedId, setExpandedId] = useState<string | null>(null);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [loading, setLoading] = useState(false);
useEffect(() => {
setUsername("");
setPassword("");
}, [expandedId]);
return (
<>
{instances.map((instance) => {
const expanded = expandedId === instance.id;
return (
<div key={instance.id} className="flex flex-col border border-border">
<button
type="button"
onClick={() => setExpandedId(expanded ? null : instance.id)}
className="flex items-center justify-between w-full px-3 py-2.5 text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
<span>{t("loginWithProvider", { name: instance.label })}</span>
{expanded ? (
<ChevronUp className="size-3.5" />
) : (
<ChevronDown className="size-3.5" />
)}
</button>
{expanded && (
<form
onSubmit={async (event) => {
event.preventDefault();
if (!username.trim() || !password) {
toast.error(t("requiredField"));
return;
}
setLoading(true);
try {
await submit(
{ username: username.trim(), password },
instance.id,
);
} finally {
setLoading(false);
}
}}
className="flex flex-col gap-3 p-3 border-t border-border"
>
<label className="flex flex-col gap-1.5">
<span className={labelClass}>{t("username")}</span>
<Input
value={username}
onChange={(e) => setUsername(e.target.value)}
disabled={disabled || loading}
autoFocus
/>
</label>
<label className="flex flex-col gap-1.5">
<span className={labelClass}>{t("password")}</span>
<Input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
disabled={disabled || loading}
className="font-mono"
/>
</label>
<Button
type="submit"
className="w-full bg-accent-brand hover:bg-accent-brand/90 text-background font-bold"
disabled={disabled || loading}
>
{loading ? t("loading") : t("signIn")}
</Button>
</form>
)}
</div>
);
})}
</>
);
}
@@ -0,0 +1,439 @@
import { useCallback, useEffect, useState, type ReactNode } from "react";
import { Pencil, Plus, Trash2 } from "lucide-react";
import { toast } from "sonner";
import {
Button,
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
Input,
Switch,
} from "@termix/plugin-sdk/ui";
import { usePluginApi, useTranslation } from "@termix/plugin-sdk/frontend";
import { createLdapApi, type LdapProvider } from "./ldap-api";
type Fields = {
host: string;
port: string;
useTLS: boolean;
bindDN: string;
bindPassword: string;
userSearchBase: string;
userSearchFilter: string;
usernameAttribute: string;
displayNameAttribute: string;
groupSearchBase: string;
adminGroup: string;
allowedUsers: string;
};
const EMPTY_FIELDS: Fields = {
host: "",
port: "389",
useTLS: false,
bindDN: "",
bindPassword: "",
userSearchBase: "",
userSearchFilter: "(uid={{username}})",
usernameAttribute: "uid",
displayNameAttribute: "cn",
groupSearchBase: "",
adminGroup: "",
allowedUsers: "",
};
function errorMessage(error: unknown, fallback: string): string {
const err = error as {
response?: { data?: { error?: string } };
message?: string;
};
return err.response?.data?.error || err.message || fallback;
}
const labelClass =
"text-[10px] font-semibold text-muted-foreground uppercase tracking-widest";
function Field({
label,
required,
hint,
children,
}: {
label: string;
required?: boolean;
hint?: string;
children: ReactNode;
}) {
return (
<div className="flex flex-col gap-1">
<label className={labelClass}>
{label}
{required && <span className="text-accent-brand ml-1">*</span>}
</label>
{hint && (
<span className="text-[10px] text-muted-foreground">{hint}</span>
)}
{children}
</div>
);
}
function ProviderDialog({
open,
onOpenChange,
provider,
onSaved,
}: {
open: boolean;
onOpenChange: (open: boolean) => void;
provider: LdapProvider | null;
onSaved: () => void;
}) {
const { t } = useTranslation();
const api = createLdapApi(usePluginApi());
const [name, setName] = useState("");
const [enabled, setEnabled] = useState(true);
const [fields, setFields] = useState<Fields>(EMPTY_FIELDS);
const [saving, setSaving] = useState(false);
useEffect(() => {
if (!open) return;
setName(provider?.name ?? "");
setEnabled(provider?.enabled ?? true);
const config = provider?.config ?? {};
const next = { ...EMPTY_FIELDS };
for (const key of Object.keys(next) as Array<keyof Fields>) {
const value = config[key];
if (key === "useTLS") next.useTLS = value === true;
else if (key === "port" && value !== undefined) next.port = String(value);
else if (typeof value === "string") next[key] = value as never;
}
next.bindPassword = "";
setFields(next);
}, [open, provider]);
const set = (key: Exclude<keyof Fields, "useTLS">) => (value: string) =>
setFields((prev) => ({ ...prev, [key]: value }));
async function save() {
if (!name.trim()) {
toast.error(t("providers.nameRequired"));
return;
}
const config: Record<string, unknown> = {
host: fields.host.trim(),
port: Number.parseInt(fields.port, 10) || 389,
useTLS: fields.useTLS,
bindDN: fields.bindDN.trim(),
userSearchBase: fields.userSearchBase.trim(),
userSearchFilter: fields.userSearchFilter.trim(),
usernameAttribute: fields.usernameAttribute.trim() || "uid",
displayNameAttribute: fields.displayNameAttribute.trim() || "cn",
groupSearchBase: fields.groupSearchBase.trim() || undefined,
adminGroup: fields.adminGroup.trim() || undefined,
allowedUsers: fields.allowedUsers.trim() || undefined,
};
if (fields.bindPassword) config.bindPassword = fields.bindPassword;
setSaving(true);
try {
const input = { name: name.trim(), enabled, config };
if (provider) await api.update(provider.id, input);
else await api.create(input);
toast.success(t("providers.saved"));
onSaved();
onOpenChange(false);
} catch (error) {
toast.error(errorMessage(error, t("providers.saveFailed")));
} finally {
setSaving(false);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{provider ? t("providers.edit") : t("providers.add")}
</DialogTitle>
</DialogHeader>
<div className="flex flex-col gap-3 pt-2">
<Field label={t("providers.name")} required>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("providers.namePlaceholder")}
className="text-xs"
/>
</Field>
<div className="flex items-center justify-between">
<label className={labelClass}>{t("providers.enabled")}</label>
<Switch checked={enabled} onCheckedChange={setEnabled} />
</div>
<div className="flex flex-col gap-3 border-t border-border pt-3">
<span className="text-[10px] text-muted-foreground">
<a
href="https://docs.termix.site/features/authentication/ldap"
target="_blank"
rel="noreferrer"
className="text-accent-brand hover:underline"
>
{t("providers.docsLink")}
</a>
</span>
<Field label={t("fields.host")} required>
<Input
value={fields.host}
onChange={(e) => set("host")(e.target.value)}
placeholder="ldap.example.com"
className="text-xs"
/>
</Field>
<div className="flex gap-2">
<div className="flex-1">
<Field label={t("fields.port")} required>
<Input
value={fields.port}
onChange={(e) => set("port")(e.target.value)}
placeholder="389"
className="text-xs"
/>
</Field>
</div>
<div className="flex items-end pb-1.5 gap-2">
<Switch
checked={fields.useTLS}
onCheckedChange={(useTLS) =>
setFields((prev) => ({ ...prev, useTLS }))
}
aria-label={t("fields.useTls")}
/>
<span className="text-[10px] text-muted-foreground">
{t("fields.useTls")}
</span>
</div>
</div>
<Field label={t("fields.bindDn")} required>
<Input
value={fields.bindDN}
onChange={(e) => set("bindDN")(e.target.value)}
placeholder="cn=admin,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field
label={t("fields.bindPassword")}
required={!provider?.hasBindPassword}
hint={
provider?.hasBindPassword ? t("fields.secretKeep") : undefined
}
>
<Input
type="password"
value={fields.bindPassword}
onChange={(e) => set("bindPassword")(e.target.value)}
className="text-xs"
/>
</Field>
<Field label={t("fields.userSearchBase")} required>
<Input
value={fields.userSearchBase}
onChange={(e) => set("userSearchBase")(e.target.value)}
placeholder="ou=users,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("fields.userSearchFilter")} required>
<Input
value={fields.userSearchFilter}
onChange={(e) => set("userSearchFilter")(e.target.value)}
placeholder="(uid={{username}})"
className="text-xs"
/>
</Field>
<Field label={t("fields.usernameAttr")} required>
<Input
value={fields.usernameAttribute}
onChange={(e) => set("usernameAttribute")(e.target.value)}
placeholder="uid"
className="text-xs"
/>
</Field>
<Field label={t("fields.displayNameAttr")} required>
<Input
value={fields.displayNameAttribute}
onChange={(e) => set("displayNameAttribute")(e.target.value)}
placeholder="cn"
className="text-xs"
/>
</Field>
<div className="border-t border-border pt-3 flex flex-col gap-3">
<Field label={t("fields.groupSearchBase")}>
<Input
value={fields.groupSearchBase}
onChange={(e) => set("groupSearchBase")(e.target.value)}
placeholder="ou=groups,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("fields.adminGroup")}>
<Input
value={fields.adminGroup}
onChange={(e) => set("adminGroup")(e.target.value)}
placeholder="cn=admins,ou=groups,dc=example,dc=com"
className="text-xs"
/>
</Field>
<Field label={t("fields.allowedUsers")}>
<Input
value={fields.allowedUsers}
onChange={(e) => set("allowedUsers")(e.target.value)}
placeholder="user1,user2,@domain.com"
className="text-xs"
/>
</Field>
</div>
</div>
<div className="flex gap-2 justify-end pt-2">
<Button
variant="outline"
size="sm"
className="text-xs"
onClick={() => onOpenChange(false)}
>
{t("providers.cancel")}
</Button>
<Button
size="sm"
variant="outline"
className="text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={save}
disabled={saving}
>
{saving ? t("providers.saving") : t("providers.save")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
/**
* The "providers" custom field on the plugin's admin settings page: the
* directory list with an editor.
*/
export function ProvidersSetting() {
const { t } = useTranslation();
const api = createLdapApi(usePluginApi());
const [providers, setProviders] = useState<LdapProvider[] | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<LdapProvider | null>(null);
const load = useCallback(async () => {
try {
setProviders(await api.list());
} catch (error) {
setProviders([]);
toast.error(errorMessage(error, t("providers.loadFailed")));
}
// api is rebuilt each render from the same client.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
void load();
}, [load]);
async function toggleEnabled(provider: LdapProvider) {
try {
await api.update(provider.id, { enabled: !provider.enabled });
await load();
} catch (error) {
toast.error(errorMessage(error, t("providers.saveFailed")));
}
}
async function remove(provider: LdapProvider) {
if (!window.confirm(t("providers.deleteConfirm"))) return;
try {
await api.remove(provider.id);
toast.success(t("providers.deleted"));
await load();
} catch (error) {
toast.error(errorMessage(error, t("providers.deleteFailed")));
}
}
if (providers === null) return null;
return (
<div className="flex flex-col gap-3">
{providers.length === 0 ? (
<span className="text-[10px] text-muted-foreground">
{t("providers.none")}
</span>
) : (
<div className="flex flex-col gap-2">
{providers.map((provider) => (
<div
key={provider.id}
className="flex items-center gap-2 p-2 border border-border bg-background"
>
<span className="flex-1 min-w-0 text-xs font-medium truncate">
{provider.name}
</span>
<Switch
checked={provider.enabled}
onCheckedChange={() => void toggleEnabled(provider)}
aria-label={t("providers.enabled")}
/>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-foreground"
onClick={() => {
setEditing(provider);
setDialogOpen(true);
}}
title={t("providers.edit")}
>
<Pencil className="size-3" />
</Button>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 text-muted-foreground hover:text-destructive"
onClick={() => void remove(provider)}
title={t("providers.delete")}
>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
)}
<Button
variant="outline"
size="sm"
className="self-start text-xs border-accent-brand/40 text-accent-brand hover:bg-accent-brand/10 hover:text-accent-brand"
onClick={() => {
setEditing(null);
setDialogOpen(true);
}}
>
<Plus className="size-3" />
{t("providers.add")}
</Button>
<ProviderDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
provider={editing}
onSaved={() => void load()}
/>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import type { TermixApp } from "@termix/plugin-sdk/frontend";
import { LdapLoginForms } from "./LdapLoginForms";
import { ProvidersSetting } from "./ProvidersSetting";
export function activate(app: TermixApp): void {
app.registerLoginMethod({
id: "ldap",
titleKey: "loginWithLdap",
component: LdapLoginForms,
});
app.registerSettingsComponent("providers", ProvidersSetting);
}
+35
View File
@@ -0,0 +1,35 @@
import type { PluginApiClient } from "@termix/plugin-sdk/frontend";
export interface LdapProvider {
id: number;
name: string;
enabled: boolean;
displayOrder: number;
config: Record<string, unknown>;
hasBindPassword: boolean;
}
export interface LdapProviderInput {
name?: string;
enabled?: boolean;
config?: Record<string, unknown>;
}
/** Paths are relative to /plugin-api/ldap/. */
export function createLdapApi(api: PluginApiClient) {
return {
async list() {
return (await api.get<{ providers: LdapProvider[] }>("/providers")).data
.providers;
},
async create(input: LdapProviderInput) {
return (await api.post<LdapProvider>("/providers", input)).data;
},
async update(id: number, input: LdapProviderInput) {
return (await api.put<LdapProvider>(`/providers/${id}`, input)).data;
},
async remove(id: number) {
await api.delete(`/providers/${id}`);
},
};
}
+125
View File
@@ -0,0 +1,125 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
import express, { type Router } from "express";
import {
createMockCtx,
createTestDb,
type MockPluginContext,
type TestDb,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import manifestJson from "../../manifest.json";
export const pluginDir = fileURLToPath(new URL("../..", import.meta.url));
export const manifest = manifestJson as unknown as PluginManifest;
export interface TestServer {
db: TestDb;
mock: MockPluginContext;
request: (
method: string,
path: string,
options?: {
user?: string;
body?: unknown;
form?: Record<string, string>;
headers?: Record<string, string>;
},
// Route bodies vary per test; asserting on them is the point.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
) => Promise<{ status: number; body: any; location: string | null }>;
close: () => Promise<void>;
}
const realFetch = globalThis.fetch;
export async function startServer(
options: {
capabilities?: string[];
permissions?: string[];
linkedUsers?: Record<string, number>;
loginAttemptLimit?: number;
before?: (sqlite: TestDb["sqlite"]) => void;
} = {},
): Promise<TestServer> {
const db = await createTestDb(pluginDir, { before: options.before });
db.sqlite
.prepare("INSERT INTO users (id, username, is_admin) VALUES (?, ?, 1)")
.run("admin", "admin");
let router: Router | null = null;
const mock = createMockCtx({
pluginId: manifest.id,
manifest,
capabilities: options.capabilities ?? manifest.capabilities,
db: db.database,
router: () => (router = express.Router()),
permissions: options.permissions,
linkedUsers: options.linkedUsers,
loginAttemptLimit: options.loginAttemptLimit,
});
const { activate } = await import("../../src/backend/index.js");
await activate(mock.ctx);
const app = express();
app.use(express.json());
app.use(express.urlencoded({ extended: false }));
app.use((req, _res, next) => {
mock.setActor(req.header("x-test-user") || undefined);
next();
});
app.use((req, res, next) => router!(req, res, next));
const server = http.createServer(app);
await new Promise<void>((resolve) => server.listen(0, resolve));
const { port } = server.address() as AddressInfo;
return {
db,
mock,
async request(
method,
path,
{ user = "admin", body, form, headers = {} } = {},
) {
const payload =
form !== undefined
? new URLSearchParams(form).toString()
: body !== undefined && method !== "GET"
? JSON.stringify(body)
: undefined;
const response = await realFetch(`http://127.0.0.1:${port}${path}`, {
method,
redirect: "manual",
headers: {
"x-test-user": user,
...(form !== undefined
? { "content-type": "application/x-www-form-urlencoded" }
: payload !== undefined
? { "content-type": "application/json" }
: {}),
...headers,
},
body: payload,
});
const text = await response.text();
let parsed: unknown = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
parsed = text;
}
return {
status: response.status,
body: parsed,
location: response.headers.get("location"),
};
},
async close() {
await new Promise<void>((resolve) => server.close(() => resolve()));
db.close();
},
};
}
+304
View File
@@ -0,0 +1,304 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { startServer, type TestServer } from "./helpers.js";
const directory = vi.hoisted(() => ({
users: {} as Record<
string,
{ dn: string; password: string; attrs: Record<string, string> }
>,
adminDns: [] as string[],
servicePassword: "svc",
binds: [] as string[],
}));
vi.mock("ldapjs", () => {
function createClient() {
return {
bind: (dn: string, password: string, cb: (err?: Error) => void) => {
directory.binds.push(dn);
if (dn === "cn=service" && password === directory.servicePassword) {
return cb();
}
const user = Object.values(directory.users).find((u) => u.dn === dn);
if (user && user.password === password) return cb();
cb(new Error("invalid credentials"));
},
search: (
base: string,
options: { filter: string },
cb: (err: Error | null, res: unknown) => void,
) => {
const listeners: Record<string, (value?: unknown) => void> = {};
cb(null, {
on: (event: string, fn: (value?: unknown) => void) => {
listeners[event] = fn;
},
});
queueMicrotask(() => {
if (base === "ou=groups") {
for (const dn of directory.adminDns) {
if (options.filter.includes(dn)) {
listeners.searchEntry?.({
dn: { toString: () => "cn=admins,ou=groups" },
attributes: [{ type: "cn", values: ["admins"] }],
});
}
}
} else {
const match = /uid=([^)]+)/.exec(options.filter);
const user = match ? directory.users[match[1]] : undefined;
if (user) {
listeners.searchEntry?.({
dn: { toString: () => user.dn },
attributes: Object.entries(user.attrs).map(([type, value]) => ({
type,
values: [value],
})),
});
}
}
listeners.end?.();
});
},
unbind: () => {},
};
}
return { default: { createClient }, createClient };
});
const CONFIG = {
host: "ldap.example",
port: 389,
bindDN: "cn=service",
bindPassword: "svc",
userSearchBase: "ou=people",
userSearchFilter: "(uid={{username}})",
usernameAttribute: "uid",
displayNameAttribute: "cn",
groupSearchBase: "ou=groups",
adminGroup: "admins",
allowedUsers: "@example.com",
};
let server: TestServer | null = null;
beforeEach(() => {
directory.users = {
bob: {
dn: "uid=bob,ou=people",
password: "hunter2",
attrs: { uid: "bob", cn: "Bob Builder", mail: "bob@example.com" },
},
};
directory.adminDns = [];
directory.binds = [];
});
afterEach(async () => {
await server?.close();
server = null;
});
async function withDirectory(options: Parameters<typeof startServer>[0] = {}) {
server = await startServer(options);
const created = await server.request("POST", "/providers", {
body: { name: "Corp LDAP", config: CONFIG },
});
expect(created.status).toBe(201);
return { s: server, id: created.body.id as number };
}
function verify(
s: TestServer,
instance: string,
body: Record<string, unknown>,
) {
const method = s.mock.auth.loginMethods[0];
return method.verify!(
{ body, query: {}, headers: {}, ip: "10.0.0.1" },
instance,
);
}
describe("LDAP sign-in", () => {
it("binds as the service, then as the user, and returns the identity", async () => {
const { s, id } = await withDirectory();
directory.adminDns = ["uid=bob,ou=people"];
const identity = await verify(s, String(id), {
username: "bob",
password: "hunter2",
rememberMe: true,
});
expect(identity).toEqual({
kind: "external",
provider: `ldap:${id}`,
subject: "bob",
email: "bob@example.com",
name: "Bob Builder",
isAdmin: true,
allowedUsers: "@example.com",
legacy: { identifier: `ldap:${id}:bob`, providerRowId: id },
rememberMe: true,
rateLimitKey: `${id}:bob`,
});
expect(directory.binds).toEqual(["cn=service", "uid=bob,ou=people"]);
});
it("is not an admin when the directory says so", async () => {
const { s, id } = await withDirectory();
const identity = await verify(s, String(id), {
username: "bob",
password: "hunter2",
});
expect(identity).toMatchObject({ isAdmin: false });
});
it("refuses a wrong password and an unknown user the same way", async () => {
const { s, id } = await withDirectory();
await expect(
verify(s, String(id), { username: "bob", password: "nope" }),
).rejects.toMatchObject({
status: 401,
message: "Invalid username or password",
});
await expect(
verify(s, String(id), { username: "mallory", password: "x" }),
).rejects.toMatchObject({
status: 401,
message: "Invalid username or password",
});
expect(s.mock.auth.loginFailures.get(`10.0.0.1|${id}:bob`)).toBe(1);
});
it("locks out after too many failures, like password login", async () => {
const { s, id } = await withDirectory({ loginAttemptLimit: 2 });
for (let i = 0; i < 2; i++) {
await expect(
verify(s, String(id), { username: "bob", password: "nope" }),
).rejects.toMatchObject({ status: 401 });
}
await expect(
verify(s, String(id), { username: "bob", password: "hunter2" }),
).rejects.toMatchObject({ status: 429 });
});
it("escapes the username in the search filter", async () => {
const { s, id } = await withDirectory();
await expect(
verify(s, String(id), { username: "bob)(uid=*", password: "hunter2" }),
).rejects.toMatchObject({ status: 401 });
});
it("refuses a disabled or unknown directory", async () => {
const { s, id } = await withDirectory();
await s.request("PUT", `/providers/${id}`, { body: { enabled: false } });
await expect(
verify(s, String(id), { username: "bob", password: "hunter2" }),
).rejects.toMatchObject({ status: 404 });
await expect(
verify(s, "999", { username: "bob", password: "hunter2" }),
).rejects.toMatchObject({ status: 404 });
expect(await s.mock.auth.loginMethods[0].describe!()).toEqual([
{ id: String(id), label: "Corp LDAP", enabled: false, type: "ldap" },
]);
});
it("needs a directory, a username and a password", async () => {
const { s } = await withDirectory();
await expect(
verify(s, null as never, { username: "bob" }),
).rejects.toMatchObject({ status: 400 });
});
});
describe("directory admin routes", () => {
it("seal the bind password and never send it back", async () => {
const { s, id } = await withDirectory();
const stored = s.db.sqlite
.prepare("SELECT config FROM p_ldap_providers WHERE id = ?")
.get(id) as { config: string };
expect(JSON.parse(stored.config).bindPassword).toMatch(/^sealed:/);
const listed = await s.request("GET", "/providers");
expect(listed.body.providers[0]).toMatchObject({
name: "Corp LDAP",
hasBindPassword: true,
});
expect(listed.body.providers[0].config.bindPassword).toBeUndefined();
});
it("keep the bind password when an edit leaves it empty", async () => {
const { s, id } = await withDirectory();
const updated = await s.request("PUT", `/providers/${id}`, {
body: { config: { host: "ldap2.example", bindPassword: "" } },
});
expect(updated.body).toMatchObject({
hasBindPassword: true,
config: { host: "ldap2.example" },
});
await expect(
verify(s, String(id), { username: "bob", password: "hunter2" }),
).resolves.toMatchObject({ subject: "bob" });
});
it("reject a directory with missing fields", async () => {
server = await startServer();
const response = await server.request("POST", "/providers", {
body: { name: "X", config: { host: "a" } },
});
expect(response.status).toBe(400);
});
it("will not delete a directory people still sign in with", async () => {
const { s, id } = await withDirectory({
linkedUsers: { "ldap:1": 1 },
});
expect(id).toBe(1);
expect((await s.request("DELETE", `/providers/${id}`)).status).toBe(409);
});
it("delete a directory nobody uses", async () => {
const { s, id } = await withDirectory();
expect((await s.request("DELETE", `/providers/${id}`)).status).toBe(200);
expect(await s.mock.auth.loginMethods[0].describe!()).toEqual([]);
});
it("need ldap.manage", async () => {
server = await startServer({ permissions: [] });
for (const [method, path] of [
["GET", "/providers"],
["POST", "/providers"],
["PUT", "/providers/1"],
["DELETE", "/providers/1"],
]) {
expect((await server.request(method, path, { body: {} })).status).toBe(
403,
);
}
});
});
describe("capabilities", () => {
it.each(["db:own", "auth:provide", "network:serve"])(
"activate fails closed without %s",
async (capability) => {
const { manifest } = await import("./helpers.js");
await expect(
startServer({
capabilities: manifest.capabilities.filter((c) => c !== capability),
}),
).rejects.toThrow();
},
);
it("cannot read a bind password without secrets:own", async () => {
const { manifest } = await import("./helpers.js");
server = await startServer({
capabilities: manifest.capabilities.filter((c) => c !== "secrets:own"),
});
const response = await server.request("POST", "/providers", {
body: { name: "Corp LDAP", config: CONFIG },
});
expect(response.status).toBe(500);
});
});
@@ -0,0 +1,93 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { fireEvent, screen, waitFor } from "@testing-library/react";
import {
renderWithApp,
type RenderedPluginApp,
} from "@termix/plugin-sdk/testing";
import type { PluginManifest } from "@termix/plugin-sdk/manifest";
import * as plugin from "../../src/frontend/index";
import manifestJson from "../../manifest.json";
import locales from "../../locales/en.json";
const manifest = manifestJson as unknown as PluginManifest;
let rendered: RenderedPluginApp | null = null;
afterEach(async () => {
await rendered?.deactivate();
rendered = null;
});
describe(`${manifest.id} activate`, () => {
it("registers the login form and the directory settings", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
expect(rendered.registered.loginMethods()).toEqual(["ldap"]);
expect(rendered.registered.settingsComponents()).toEqual(["providers"]);
});
it("submits the username and password for the chosen directory", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const submit = vi.fn(async () => {});
rendered.renderLoginMethod("ldap", {
instances: [{ id: "4", label: "Corp" }],
submit,
});
fireEvent.click(screen.getByText("Login with Corp"));
fireEvent.change(screen.getByLabelText(locales.username), {
target: { value: " bob " },
});
fireEvent.change(screen.getByLabelText(locales.password), {
target: { value: "hunter2" },
});
fireEvent.click(screen.getByText(locales.signIn));
await waitFor(() =>
expect(submit).toHaveBeenCalledWith(
{ username: "bob", password: "hunter2" },
"4",
),
);
});
it("does not submit an empty form", async () => {
rendered = await renderWithApp(plugin, { manifest, locales });
const submit = vi.fn(async () => {});
rendered.renderLoginMethod("ldap", {
instances: [{ id: "4", label: "Corp" }],
submit,
});
fireEvent.click(screen.getByText("Login with Corp"));
fireEvent.click(screen.getByText(locales.signIn));
expect(submit).not.toHaveBeenCalled();
});
it("lists directories without their bind password", async () => {
const get = vi.fn(async () => ({
data: {
providers: [
{
id: 4,
name: "Corp LDAP",
enabled: true,
displayOrder: 0,
config: { host: "ldap.example" },
hasBindPassword: true,
},
],
},
}));
rendered = await renderWithApp(plugin, {
manifest,
locales,
api: { get } as never,
});
rendered.renderSettingsComponent("providers");
expect(await screen.findByText("Corp LDAP")).toBeTruthy();
expect(get).toHaveBeenCalledWith("/providers");
});
it("removes everything on deactivate", async () => {
const app = await renderWithApp(plugin, { manifest, locales });
await app.deactivate();
expect(app.registered.loginMethods()).toEqual([]);
});
});
+4
View File
@@ -0,0 +1,4 @@
{
"extends": "@termix/plugin-sdk/tsconfig.plugin.json",
"include": ["src/**/*.ts", "src/**/*.tsx", "tests/**/*.ts", "tests/**/*.tsx"]
}
+3
View File
@@ -0,0 +1,3 @@
import { pluginVitestConfig } from "@termix/plugin-sdk/vitest-preset";
export default pluginVitestConfig(import.meta.url);