diff --git a/.goreleaser.yaml b/.goreleaser.yaml index a75c965d..9f4365c5 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,44 +1,36 @@ -# Mokey goreleaser configs -# See here: https://goreleaser.com +version: 2 +project_name: mokey + before: hooks: - go mod tidy + builds: - - env: + - id: mokey + env: - CGO_ENABLED=1 goarch: - amd64 goos: - linux ldflags: - - -s -w -X github.com/ubccr/mokey/server.Version={{.Version}} + - -s -w -X github.com/tubby1981/mokey/server.Version={{.Version}} - -extldflags=-static tags: - sqlite_omit_load_extension - osusergo - - netgo -archives: - - replacements: - linux: linux - amd64: x86_64 - wrap_in_directory: true - name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Os }}-{{ .Arch }}" - files: - - LICENSE - - NOTICE - - README.md - - ChangeLog.md - - mokey.toml.sample + nfpms: - - vendor: University at Buffalo - homepage: https://github.com/ubccr/mokey - maintainer: Andrew E. Bruno - license: MIT - description: |- - FreeIPA self-service account management tool + - id: mokey formats: - deb - rpm + maintainer: Andrew E. Bruno + vendor: University at Buffalo + homepage: https://github.com/tubby1981/mokey + license: MIT + description: | + FreeIPA self-service account management tool overrides: deb: file_name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}" @@ -48,25 +40,30 @@ nfpms: file_name_template: "{{ .ProjectName }}-{{ .Version }}-{{ .Arch }}" scripts: postinstall: ./scripts/nfpm/postinstall.sh - rpm: - signature: - key_file: key.gpg - deb: - signature: - key_file: key.gpg + #rpm: + # signature: + # key_file: key.gpg + #deb: + # signature: + # key_file: key.gpg contents: - src: ./scripts/nfpm/mokey.toml.default dst: /etc/mokey/mokey.toml - type: "config|noreplace" + type: config|noreplace - src: ./scripts/nfpm/mokey.env dst: /etc/default/mokey - type: "config|noreplace" + - src: ./scripts/nfpm/translations/* + dst: /etc/mokey/translations/ + file_info: + owner: mokey + group: mokey - src: ./scripts/nfpm/mokey.service dst: /usr/lib/systemd/system/mokey.service + checksum: name_template: 'checksums.txt' snapshot: - name_template: "{{ incpatch .Version }}-SNAPSHOT-{{.ShortCommit}}" + version_template: "{{ incpatch .Version }}-SNAPSHOT-{{.ShortCommit}}" changelog: sort: desc groups: @@ -81,4 +78,4 @@ changelog: filters: exclude: - '^docs:' - - 'typo' + - 'typo' diff --git a/ChangeLog.md b/ChangeLog.md index f988a06c..9e51a7c0 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -1,5 +1,13 @@ # Mokey ChangeLog +## [v0.6.7] - 2025-04-17 +- Added `timeago` function with support for English, Portuguese, Spanish, Dutch, French, German, and Turkish. Based on [xeonx/timeago](https://github.com/xeonx/timeago). +- Some translations in the email templates were not working properly. This has been fixed. + +## [v0.6.6] - 2025-04-16 +- Add config option to hide registratrion link: enable_user_signup true/false +- Add support for multiple languages with configurable translations. See README + ## [v0.6.5] - 2024-10-28 - Update fiber, htmx (v2.0.3), hyperscript (v0.9.13) diff --git a/README.md b/README.md index 83b2b007..df3e58af 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,7 @@ Note: mokey needs to be installed on a machine already enrolled in FreeIPA. It's also recommended to have the ipa-admintools package installed. Enrolling a host in FreeIPA is outside the scope of this document. -To install mokey download a copy of the pre-compiled binary [here](https://github.com/ubccr/mokey/releases). +To install mokey download a copy of the pre-compiled binary [here](https://github.com/tubby1981/mokey/releases). tar.gz archive: @@ -83,7 +83,7 @@ $ chgrp mokey /etc/mokey/private/mokeyapp.keytab Edit mokey configuration file and set path to keytab file. The values for `token_secret` and `csrf_secret` will be automatically generated for you if left blank. Set these secret values if you'd like sessions to persist after a restart. -For other site specific config options [see here](https://github.com/ubccr/mokey/blob/main/mokey.toml.sample): +For other site specific config options [see here](https://github.com/tubby1981/mokey/blob/main/mokey.toml.sample): ``` $ vim /etc/mokey/mokey.toml @@ -139,12 +139,39 @@ Any OAuth clients configured in Hydra will be authenticated via mokey using FreeIPA as the identity provider. For an example OAuth 2.0/OIDC client application see [here](examples/mokey-oidc/main.go). +## Translations + +mokey supports multiple languages for its interface and email templates. Default are English and Dutch supported. + +### Configuring Translations + +1. **Place translation files** + Translation files should be placed in `/etc/mokey/translations/`. + For example: + - `english.toml` for English translations + - `dutch.toml` for Dutch translations + +2. **Update the configuration file** + Add the following options to `/etc/mokey/mokey.toml`: + ```toml + # Default language for the site + # Languages supported: English (english), Dutch (dutch) + # Default is english + default_language = "english" + + # Directory where translations can be placed + translations_dir = "/etc/mokey/translations" + ``` + +3. **Create custom translations** + Users can translate mokey into their own language by creating a new .toml file in the translations_dir and referencing it in the default_language configuration. This allows for complete customization of the interface and email templates in the preferred language. + ## Building from source First, you will need Go v1.21 or greater. Clone the repository: ``` -$ git clone https://github.com/ubccr/mokey +$ git clone https://github.com/tubby1981/mokey $ cd mokey $ go build . ``` diff --git a/a.out b/a.out new file mode 100755 index 00000000..e7d15643 Binary files /dev/null and b/a.out differ diff --git a/cmd/root.go b/cmd/root.go index 64a4bba0..bc082ee2 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -10,7 +10,7 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/ubccr/mokey/server" + "github.com/tubby1981/mokey/server" ) var ( diff --git a/cmd/serve/serve.go b/cmd/serve/serve.go index 9cd459b0..b4caa6f1 100644 --- a/cmd/serve/serve.go +++ b/cmd/serve/serve.go @@ -9,8 +9,8 @@ import ( "github.com/sirupsen/logrus" "github.com/spf13/cobra" "github.com/spf13/viper" - "github.com/ubccr/mokey/cmd" - "github.com/ubccr/mokey/server" + "github.com/tubby1981/mokey/cmd" + "github.com/tubby1981/mokey/server" ) var ( diff --git a/go.mod b/go.mod index a154944b..ee0afbb8 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ -module github.com/ubccr/mokey +// module github.com/ubccr/mokey +// replace github.com/ubccr/mokey => /opt/tromp/mokey +module github.com/tubby1981/mokey require ( github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 github.com/coreos/go-oidc v2.2.1+incompatible github.com/dchest/captcha v1.0.0 - github.com/dustin/go-humanize v1.0.1 github.com/essentialkaos/branca/v2 v2.0.5 github.com/gofiber/fiber/v2 v2.52.5 github.com/gofiber/storage/memory/v2 v2.0.1 @@ -27,6 +28,8 @@ require ( golang.org/x/oauth2 v0.18.0 ) +require github.com/dustin/go-humanize v1.0.1 + require ( github.com/andybalholm/brotli v1.1.1 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/main.go b/main.go index 99f10641..cc9c61a6 100644 --- a/main.go +++ b/main.go @@ -5,8 +5,8 @@ package main import ( - "github.com/ubccr/mokey/cmd" - _ "github.com/ubccr/mokey/cmd/serve" + "github.com/tubby1981/mokey/cmd" + _ "github.com/tubby1981/mokey/cmd/serve" ) func main() { diff --git a/mokey.toml.sample b/mokey.toml.sample index d1003475..b158977b 100644 --- a/mokey.toml.sample +++ b/mokey.toml.sample @@ -47,6 +47,14 @@ keytab = "/etc/mokey/private/mokeyapp.keytab" # Path to logo # logo = "/etc/mokey/assets/my-logo.png" +# Default language for the site +# Languages supported: English (english), Dutch (dutch) +# Default is english +default_language = "english" + +# Directory where translations can be placed +translations_dir = "/etc/mokey/translations" + #------------------------------------------------------------------------------ # User account settings #------------------------------------------------------------------------------ @@ -101,6 +109,9 @@ require_admin_verify = false # you could hide this error message by setting this to true. hide_invalid_username_error = false +# Enable or disable user signup link on the login page +enable_user_signup = true + #------------------------------------------------------------------------------ # Email #------------------------------------------------------------------------------ diff --git a/scripts/nfpm/mokey.toml.default b/scripts/nfpm/mokey.toml.default index 41967a05..a996655c 100644 --- a/scripts/nfpm/mokey.toml.default +++ b/scripts/nfpm/mokey.toml.default @@ -27,6 +27,14 @@ ktuser = "mokeyapp" # Path to keytab file keytab = "/etc/mokey/private/mokeyapp.keytab" +# Default language for the site +# Languages supported: English (english), Dutch (dutch) +# Default is english +default_language = "english" + +# Directory where translations can be placed +translations_dir = "/etc/mokey/translations" + #------------------------------------------------------------------------------ # User account settings #------------------------------------------------------------------------------ @@ -55,6 +63,9 @@ otp_issuer = "MYORG" # Block list of user accounts from logging in # block_users = ["username1", "username2", "username3"] +# Enable or disable user signup link on the login page +enable_user_signup = true + #------------------------------------------------------------------------------ # Email #------------------------------------------------------------------------------ diff --git a/scripts/nfpm/translations/dutch.toml.default b/scripts/nfpm/translations/dutch.toml.default new file mode 100644 index 00000000..1bf79b37 --- /dev/null +++ b/scripts/nfpm/translations/dutch.toml.default @@ -0,0 +1,261 @@ +[common] +add = "Toevoegen" +cancel = "Annuleren" +captcha_image = "Captcha-afbeelding" +captcha_instruction = "Voer de cijfers in die u in de afbeelding hieronder ziet:" +check_email = "Controleer uw e-mail voor verdere instructies." +close = "Sluiten" +confirm_password = "Bevestig wachtwoord" +current_password = "Huidig wachtwoord" +delete = "Verwijderen" +disable = "Uitschakelen" +disabled = "Uitgeschakeld" +email = "E-mail" +enable = "Inschakelen" +enabled = "Ingeschakeld" +first_name = "Voornaam" +last_name = "Achternaam" +login = "Inloggen" +must_enable_2fa = "Je moet Two-Factor authenticatie inschakelen voor je account." +new_password = "Nieuw wachtwoord" +otp_code = "OTP Code" +password = "Wachtwoord" +reload = "Herlaad" +submit = "Verzenden" +update = "Bijwerken" +username = "Gebruikersnaam" +verify = "Verifiëren" +verify_account = "Verifieer Account" +too_many_requests = "Te veel verzoeken. Probeer het later opnieuw." + +[account] +title = "Accountinstellingen" +groups = "Groepen" +home_dir = "Home Dir" +last_password_change = "Laatste wachtwoordwijziging" +never = "Nooit" +password_expires = "Wachtwoord verloopt" +phone_number = "Telefoonnummer" +settings = "Accountinstellingen" +settings_updated = "Accountinstellingen succesvol bijgewerkt" +verify_email_sent = "Een verificatie-e-mail is verzonden." +fatal_system_error = "Fatale systeemfout" +invalid_credentials = "Ongeldige inloggegevens" +invalid_username = "Ongeldige gebruikersnaam" +please_provide_password = "Voer een wachtwoord in" +please_provide_username = "Voer een gebruikersnaam in" +user_account_is_locked = "Gebruikersaccount is geblokkeerd" +you_must_enable_two_factor_authentication_first = "Schakel eerst tweefactorauthenticatie in!" +failed_to_verify_account = "Accountverificatie mislukt. Neem contact op met de beheerder." +system_error = "Systeemfout, neem contact op met de beheerder" +weak_password = "Je wachtwoord is te zwak. Zorg ervoor dat je wachtwoord een cijfer en zowel kleine als hoofdletters bevat" + +[email_template] +account_updated_preheader = "Uw account is bijgewerkt." +account_updated_greeting = "Beste " +account_updated_body_part1 = "Je hebt recentelijk je " +account_updated_body_part2 = " account bijgewerkt. Ter referentie, hier is wat er is veranderd:" +account_updated_security_notice_part1 = "Om veiligheidsredenen is dit verzoek ontvangen vanaf een " +account_updated_security_notice_part2 = " apparaat met " +account_updated_security_notice_part3 = ". Als je geen account hebt aangemaakt, negeer dan deze e-mail en " +account_updated_security_notice_part4 = "neem contact op met support " +account_updated_security_notice_part5 = " of bekijk onze " +account_updated_security_notice_help = "documentatie" +account_updated_security_notice_part6 = " als je vragen hebt." +account_updated_security_notice_contact = "neem contact op met de support " +account_updated_signature = "Bedankt," +account_verify_preheader = "Gebruik deze link om uw account te verifiëren. De link is alleen geldig voor" +account_verify_greeting = "Beste" +account_verify_body_part1 = "Je hebt recentelijk een account aangemaakt bij " +account_verify_body_part2 = " en je MOET je e-mail verifiëren voordat je je account kunt gebruiken. " +account_verify_body_part3 = "Gebruik de onderstaande knop om je e-mailadres te verifiëren. " +account_verify_body_part4 = "Deze verificatie is geldig voor de komende " +account_verify_button = "Verifieer uw account" +account_verify_login_info_intro = "Ter referentie, hier zijn uw inloggegevens:" +account_verify_login_page = "Inlogpagina" +account_verify_username = "Gebruikersnaam" +account_verify_signature_part1 = "Bedankt, Het " +account_verify_signature_part2 = " team" +account_verify_trouble_link = "Als u problemen ondervindt met de bovenstaande knop, kopieer en plak de URL dan in uw webbrowser." +password_reset_preheader = "Gebruik deze link om je wachtwoord opnieuw in te stellen. De link is alleen geldig voor" +password_reset_greeting = "Hallo" +password_reset_intro_part1 = "Je hebt onlangs verzocht om je wachtwoord voor je " +password_reset_intro_part2 = " account opnieuw in te stellen. Gebruik de knop hieronder om het opnieuw in te stellen. " +password_reset_intro_part3 = "Deze link is alleen geldig voor de komende " +password_reset_button = "Stel je wachtwoord opnieuw in" +password_reset_trouble = "Als je problemen hebt met de knop hierboven, kopieer en plak dan de onderstaande URL in je webbrowser." +closing_part1 = "Bedankt,\nHet " +closing_part2 = " team" +welcome_preheader_part1 = "Bedankt voor het aanmaken van een account bij " +welcome_preheader_part2 = ". We hebben wat informatie en bronnen verzameld om je op weg te helpen." +welcome_greeting = "Welkom," +welcome_intro_part1 = "Bedankt voor het aanmaken van een account bij " +welcome_intro_part2 = ". We zijn blij dat je er bent." +welcome_button = "Aan de slag" +welcome_note_part1 = "Ter referentie, hier zijn je inloggegevens:\nLoginpagina: " +welcome_note_part2 = "\nGebruikersnaam: " +password_reset_subject = "Stel uw wachtwoord opnieuw in" +account_verify_subject = "Verifieer uw e-mailadres" +account_updated_subject = "Uw wachtwoord is gewijzigd" +welcome_subject = "Welkom bij " +two_factor_auth_event = "Twee-factor-authenticatie " +ssh_key_event = "SSH-sleutel " +otp_token_event = "OTP-token " +password_changed_event = "Wachtwoord gewijzigd" +ssh_key_added = "toegevoegd" +ssh_key_removed = "verwijderd" +otp_token_added = "toegevoegd" +otp_token_removed = "verwijderd" +two_factor_auth_enabled = "Ingeschakeld" +two_factor_auth_disabled = "Uitgeschakeld" + +[error] +title = "Foutmeldingen" +access_denied = "Toegang geweigerd" +bad_gateway = "De server heeft een foutieve gatewayfout ondervonden. Probeer het later opnieuw of neem contact op met de sitebeheerder." +log_in_again = "inloggen opnieuw" +no_access_to_resource = "Je hebt geen toegang tot deze bron" +session_timed_out = "Je sessie is verlopen. Probeer het opnieuw" +something_bad_happened = "Er is iets mis gegaan" +sorry_page_not_found = "Sorry, de pagina die je hebt aangevraagd is niet gevonden" +system_trouble = "We hebben momenteel wat systeemproblemen. Als dit probleem aanhoudt, neem dan contact op met de sitebeheerder." + +[hydra] +consent_without_challenge = "Toestemming zonder uitdaging" +failed_to_validate_consent = "Mislukt bij het valideren van toestemming" +access_denied = "Toegang geweigerd" +failed_to_accept_consent = "Mislukt bij het accepteren van toestemming" +login_without_challenge = "Inloggen zonder uitdaging" +failed_to_validate_login = "Mislukt bij het valideren van inloggen" +failed_to_accept_login = "Mislukt bij het accepteren van inloggen" +oauth2_error = "OAuth2 Fout" + +[login] +title = "Inloggen" +create_account = "Account aanmaken" +forgot_password = "Wachtwoord vergeten?" +new_user = "Nieuwe gebruiker?" +next_button = "Volgende" +not_you = "Niet jij?" +otp_authentication = "Twee-Factor Authenticatie" +switch_account = "Account wisselen" +login_failed = "Inloggen mislukt" + +[otptoken] +title = "OTP-tokens" +6_digit_code_label = "6-cijferige code" +add_new_title = "Nieuw TOTP-token toevoegen" +add_token_prompt = "Voeg een OTP-token toe via uw authenticator-app om Twee-Factor-authenticatie op uw account in te schakelen." +added_on = "Toegevoegd op" +adding_token = "Token toevoegen..." +click_to_disable = "Klik om uit te schakelen" +click_to_enable = "Klik om in te schakelen" +delete_prompt_title = "Token verwijderen?" +delete_warning = "Deze actie kan NIET ongedaan worden gemaakt. Dit zal het token permanent verwijderen en u kunt het in de toekomst niet meer gebruiken." +disable_prompt_title = "Token uitschakelen?" +disabled = "Uitgeschakeld" +enable_prompt_title = "Token inschakelen?" +enter_6_digit_code_help = "Voer de 6-cijferige code in uit uw mobiele app" +invalid_6_digit_code = "Ongeldige 6-cijferige code. Probeer het opnieuw." +failed_to_verify_token = "Verificatie van token mislukt." +header = "OTP Tokens" +no_tokens_found = "Geen OTP-tokens gevonden" +new_token_button = "Nieuw Token" +qr_code_alt = "Scan QR-code" +scan_qr_title = "Scan QR-code met authenticator-app" +show_uri = "Toon URI" +token_description_help = "Voer de beschrijving van het token in (bijvoorbeeld het apparaat waarvoor het wordt gebruikt). Klik vervolgens op de knop Toevoegen hieronder om het nieuwe TOTP-token te verifiëren. De QR-code verschijnt op het volgende scherm. Zorg ervoor dat u deze scant met uw authenticator-app en voer de 6-cijferige code in om te verifiëren." +token_description_label = "Tokenbeschrijving" +token_description_placeholder = "Mijn telefoon" +invalid_otp = "Ongeldige OTP-code." + +[password] +enter_new = "Voer een nieuw wachtwoord in" +confirm_new = "Bevestig je nieuwe wachtwoord" +mismatch = "Wachtwoorden komen niet overeen. Bevestig je wachtwoord." +enter_current = "Voer je huidige wachtwoord in" +same_as_new = "Het huidige wachtwoord is hetzelfde als het nieuwe wachtwoord. Kies een ander wachtwoord." +min_length = "Wachtwoord voldoet niet aan het beleid. Minimumlengte: %d" +policy_not_met = "Wachtwoord voldoet niet aan het beleid. Probeer zowel hoofd‑/kleine letters, cijfers en speciale tekens te gebruiken." + +[password_change] +title = "Wachtwoord Wijzigen" +confirm_new_password = "Bevestig nieuw wachtwoord" +otp_help = "Voer de zes-cijferige code in uit je mobiele app" +success = "Wachtwoord succesvol bijgewerkt" + +[password_expired] +title = "Wachtwoord Verlopen" +change_password_button = "Wachtwoord Wijzigen" + +[password_forgot] +title = "Wachtwoord vergeten" +email_sent = "Er is een e-mail voor het opnieuw instellen van uw wachtwoord verstuurd." + +[password_reset] +title = "Wachtwoord Herstellen" +reset_button = "Wachtwoord Herstellen" +success_message = "Je wachtwoord is succesvol hersteld" + +[profile] +title = "Profielinstellingen" +account = "Account" +avatar_alt = "Gebruikersavatar" +logout = "Uitloggen" +otp_tokens = "OTP Tokens" +security = "Beveiliging" +ssh_keys = "SSH-sleutels" + +[security] +title = "Beveiligingsinstellingen" +authentication_methods = "Authenticatiemethoden" +confirm_disable = "Uitschakelen" +confirm_enable = "Inschakelen" +disable = "Klik om uit te schakelen" +disable_message = "Dit zal de Two-Factor authenticatie uitschakelen. Weet je zeker dat je dit wilt doen?" +disable_title = "Two-Factor authenticatie uitschakelen?" +enable = "Klik om in te schakelen" +enable_message = "Dit zal de Two-Factor authenticatie inschakelen. Weet je zeker dat je dit wilt doen?" +enable_title = "Two-Factor authenticatie inschakelen?" +two_factor_authentication = "Two-Factor authenticatie" + +[signup] +title = "Aanmelden" +already_have_account = "Heb je al een account?" +allowed_domains = "Toegestane domeinen" +create_account = "Account maken" +tos_link = "Algemene voorwaarden" +tos_message = "Door op 'Account maken' te klikken ga je akkoord met onze" + +[signup_success] +title = "Bevestig je account" +account_created = "Account succesvol aangemaakt" +admin_verification = "Een beheerder moet je account activeren voordat je het kunt gebruiken." +email_verification = "Je moet je e-mailadres verifiëren om je account te activeren." + +[sshkey_list] +title = "SSH Sleutels" +delete_key = "Sleutel Verwijderen?" +delete_warning = "Deze actie KAN NIET worden teruggedraaid. Dit zal de sleutel permanent verwijderen en je kunt deze in de toekomst niet meer gebruiken" +enable_mfa = "Je moet Two-Factor authenticatie inschakelen voordat je SSH-sleutels kunt toevoegen!" +new_key = "Nieuwe SSH Sleutel" +no_keys = "Geen SSH sleutels geüpload" +type = "Type" +please_provide_sshkey = "Voer een SSH-sleutel in" +invalid_sshkey = "Ongeldige SSH-sleutel" + +[sshkey_new] +title = "SSH Sleutels Toevoegen" +add_title = "Nieuwe SSH Sleutel Toevoegen" +adding = "SSH-sleutel toevoegen..." +key_help = "Plak hierboven de inhoud van de SSH publieke sleutel. Moet beginnen met 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', of 'sk-ssh-ed25519@openssh.com'" +public_key = "Publieke Sleutel" + +[verify_account] +title = "Account Verifiëren" +instruction = "Klik hieronder op verifiëren om je account in te stellen." +message = "Verifieer je e-mail" + +[verify_success] +title = "Account Verifiëren" +message = "Je account is succesvol geverifieerd. Dank je wel" diff --git a/scripts/nfpm/translations/english.toml.default b/scripts/nfpm/translations/english.toml.default new file mode 100644 index 00000000..a1b933cc --- /dev/null +++ b/scripts/nfpm/translations/english.toml.default @@ -0,0 +1,261 @@ +[common] +add = "Add" +cancel = "Cancel" +captcha_image = "Captcha Image" +captcha_instruction = "Enter the numbers you see in the image below:" +check_email = "Check your email for further instructions." +close = "Close" +confirm_password = "Confirm Password" +current_password = "Current Password" +delete = "Delete" +disable = "Disable" +disabled = "Disabled" +email = "Email" +enable = "Enable" +enabled = "Enabled" +first_name = "First Name" +last_name = "Last Name" +login = "Login" +must_enable_2fa = "You must enable Two-Factor authentication for your account." +new_password = "New Password" +otp_code = "OTP Code" +password = "Password" +reload = "Reload" +submit = "Submit" +update = "Update" +username = "Username" +verify = "Verify" +verify_account = "Verify Account" +too_many_requests = "Too many requests. Please try again later." + +[account] +title = "Account Settings" +groups = "Groups" +home_dir = "Home Directory" +last_password_change = "Last Password Change" +never = "Never" +password_expires = "Password Expires" +phone_number = "Phone Number" +settings = "Account Settings" +settings_updated = "Account settings successfully updated" +verify_email_sent = "A verification email has been sent." +fatal_system_error = "Fatal system error" +invalid_credentials = "Invalid credentials" +invalid_username = "Invalid username" +please_provide_password = "Please provide a password" +please_provide_username = "Please provide a username" +user_account_is_locked = "User account is locked" +you_must_enable_two_factor_authentication_first = "You must enable Two-Factor Authentication first!" +failed_to_verify_account = "Failed to verify account. Please contact the administrator." +system_error = "System error please contact administrator" +weak_password = "Your password is too weak. Please ensure your password includes a number and lower/upper case character" + +[email_template] +account_updated_preheader = "Your account has been updated." +account_updated_greeting = "Hi" +account_updated_body_part1 = "You recently updated your " +account_updated_body_part2 = " account. For reference, here's what changed:" +account_updated_security_notice_part1 = "For security, this request was received from a " +account_updated_security_notice_part2 = " device using " +account_updated_security_notice_part3 = ". If you did not create an account, please ignore this email and " +account_updated_security_notice_part4 = "contact support" +account_updated_security_notice_part5 = " or check out our " +account_updated_security_notice_help = "documentation" +account_updated_security_notice_part6 = " if you have questions." +account_updated_security_notice_contact = "contact support" +account_updated_signature = "Thanks," +account_verify_preheader = "Use this link to verify your account. The link is only valid for" +account_verify_greeting = "Hi" +account_verify_body_part1 = "You recently created an account at " +account_verify_body_part2 = " and you MUST verify your email before using your account. " +account_verify_body_part3 = "Use the button below to verify your email address. " +account_verify_body_part4 = "This verification is valid for the next " +account_verify_button = "Verify your account" +account_verify_login_info_intro = "For reference, here's your login information:" +account_verify_login_page = "Login Page" +account_verify_username = "Username" +account_verify_signature_part1 = "Thanks, The " +account_verify_signature_part2 = " team" +account_verify_trouble_link = "If you're having trouble with the button above, copy and paste the URL into your web browser." +password_reset_preheader = "Use this link to reset your password. The link is only valid for" +password_reset_greeting = "Hi" +password_reset_intro_part1 = "You recently requested to reset your password for your " +password_reset_intro_part2 = " account. Use the button below to reset it. " +password_reset_intro_part3 = "This link is only valid for the next " +password_reset_button = "Reset your password" +password_reset_trouble = "If you’re having trouble with the button above, copy and paste the URL below into your web browser." +closing_part1 = "Thanks,\nThe " +closing_part2 = " team" +welcome_preheader_part1 = "Thanks for creating an account at " +welcome_preheader_part2 = ". We've pulled together some information and resources to help you get started." +welcome_greeting = "Welcome," +welcome_intro_part1 = "Thanks for creating an account at " +welcome_intro_part2 = ". We're glad you're here." +welcome_button = "Getting Started" +welcome_note_part1 = "For reference, here's your login information:\nLogin Page: " +welcome_note_part2 = "\nUsername: " +password_reset_subject = "Reset your password" +account_verify_subject = "Verify your email address" +account_updated_subject = "Your password has been changed" +welcome_subject = "Welcome to " +two_factor_auth_event = "Two-Factor Authentication " +ssh_key_event = "SSH key " +otp_token_event = "OTP token " +password_changed_event = "Password changed" +ssh_key_added = "added" +ssh_key_removed = "removed" +otp_token_added = "added" +otp_token_removed = "removed" +two_factor_auth_enabled = "Enabled" +two_factor_auth_disabled = "Disabled" + +[error] +title = "Error Messages" +access_denied = "Access Denied" +bad_gateway = "The server encountered a bad gateway error. Please try again later or contact the site administrator." +log_in_again = "log in again" +no_access_to_resource = "You do not have access to this resource" +session_timed_out = "Your session has timed out. Please try again" +something_bad_happened = "Something bad happened" +sorry_page_not_found = "Sorry, the page you requested was not found" +system_trouble = "We are currently experiencing some system trouble. If this problem persists, please contact the site administrator." + +[hydra] +consent_without_challenge = "Consent without challenge" +failed_to_validate_consent = "Failed to validate consent" +access_denied = "Access denied" +failed_to_accept_consent = "Failed to accept consent" +login_without_challenge = "Login without challenge" +failed_to_validate_login = "Failed to validate login" +failed_to_accept_login = "Failed to accept login" +oauth2_error = "OAuth2 Error" + +[login] +title = "Login" +create_account = "Create Account" +forgot_password = "Forgot Password?" +new_user = "New User?" +next_button = "Next" +not_you = "Not You?" +otp_authentication = "Two-Factor Authentication" +switch_account = "Switch Account" +login_failed = "Login failed" + +[otptoken] +title = "OTP Tokens" +6_digit_code_label = "6-digit Code" +add_new_title = "Add New TOTP Token" +add_token_prompt = "Add an OTP token via your authenticator app to enable Two-Factor authentication on your account." +added_on = "Added On" +adding_token = "Adding Token..." +click_to_disable = "Click to Disable" +click_to_enable = "Click to Enable" +delete_prompt_title = "Delete Token?" +delete_warning = "This action CANNOT be undone. This will permanently delete the token and you will not be able to use it in the future." +disable_prompt_title = "Disable Token?" +disabled = "Disabled" +enable_prompt_title = "Enable Token?" +enter_6_digit_code_help = "Enter the 6-digit code from your mobile app" +invalid_6_digit_code = "Invalid 6-digit code. Please try again." +failed_to_verify_token = "Failed to verify token." +header = "OTP Tokens" +new_token_button = "New Token" +no_tokens_found = "No OTP Tokens Found" +qr_code_alt = "Scan QR Code" +scan_qr_title = "Scan QR Code with Authenticator App" +show_uri = "Show URI" +token_description_help = "Enter the token description (e.g., the device it's used for). Then click Add below to verify the new TOTP token. The QR code will appear on the next screen. Be sure to scan it with your authenticator app and enter the 6-digit code to verify." +token_description_label = "Token Description" +token_description_placeholder = "My Phone" +invalid_otp = "Invalid OTP code." + +[password] +enter_new = "Please enter a new password" +confirm_new = "Please confirm your new password" +mismatch = "Passwords do not match. Please confirm your password." +enter_current = "Please enter your current password" +same_as_new = "Current password is the same as new password. Please set a different password." +min_length = "Password does not conform to policy. Min length: %d" +policy_not_met = "Password does not conform to policy. Try including both upper/lower case, numbers, and other characters" + +[password_change] +title = "Change Password" +confirm_new_password = "Confirm New Password" +otp_help = "Enter the 6-digit code from your mobile app" +success = "Password successfully updated" + +[password_expired] +title = "Password Expired" +change_password_button = "Change Password" + +[password_forgot] +title = "Forgot Password" +email_sent = "An email to reset your password has been sent." + +[password_reset] +title = "Reset Password" +reset_button = "Reset Password" +success_message = "Your password has been successfully reset" + +[profile] +title = "Profile Settings" +account = "Account" +avatar_alt = "User Avatar" +logout = "Logout" +otp_tokens = "OTP Tokens" +security = "Security" +ssh_keys = "SSH Keys" + +[security] +title = "Security Settings" +authentication_methods = "Authentication Methods" +confirm_disable = "Disable" +confirm_enable = "Enable" +disable = "Click to Disable" +disable_message = "This will disable Two-Factor authentication. Are you sure you want to do this?" +disable_title = "Disable Two-Factor Authentication?" +enable = "Click to Enable" +enable_message = "This will enable Two-Factor authentication. Are you sure you want to do this?" +enable_title = "Enable Two-Factor Authentication?" +two_factor_authentication = "Two-Factor Authentication" + +[signup] +title = "Sign Up" +already_have_account = "Already have an account?" +allowed_domains = "Allowed Domains" +create_account = "Create Account" +tos_link = "Terms of Service" +tos_message = "By clicking 'Create Account,' you agree to our" + +[signup_success] +title = "Confirm Your Account" +account_created = "Account successfully created" +admin_verification = "An administrator must activate your account before you can use it." +email_verification = "You need to verify your email address to activate your account." + +[sshkey_list] +title = "SSH Keys" +delete_key = "Delete Key?" +delete_warning = "This action CANNOT be undone. This will permanently delete the key and you will not be able to use it in the future." +enable_mfa = "You must enable Two-Factor authentication before you can add SSH keys!" +new_key = "New SSH Key" +no_keys = "No SSH keys uploaded" +type = "Type" +please_provide_sshkey = "Please provide an ssh key" +invalid_sshkey = "Invalid ssh key" + +[sshkey_new] +title = "Add SSH Keys" +add_title = "Add New SSH Key" +adding = "Adding SSH Key..." +key_help = "Paste the contents of the SSH public key above. Must start with 'ssh-rsa', 'ecdsa-sha2-nistp256', 'ecdsa-sha2-nistp384', 'ecdsa-sha2-nistp521', 'ssh-ed25519', 'sk-ecdsa-sha2-nistp256@openssh.com', or 'sk-ssh-ed25519@openssh.com'" +public_key = "Public Key" + +[verify_account] +title = "Verify Account" +instruction = "Click Verify below to set up your account." +message = "Verify Your Email" + +[verify_success] +title = "Verify Account" +message = "Your account has been successfully verified. Thank you" diff --git a/server/account.go b/server/account.go index 80e73b65..5ce9c02f 100644 --- a/server/account.go +++ b/server/account.go @@ -212,7 +212,7 @@ func (r *Router) AccountVerify(c *fiber.Ctx) error { "email": claims.Email, "err": err, }).Error("Verifying account failed while fetching user from FreeIPA") - return c.Status(fiber.StatusInternalServerError).SendString("Failed to verify account please contact administrator") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "account.failed_to_verify_account")) } if user.Locked && !viper.GetBool("accounts.require_admin_verify") { @@ -223,7 +223,7 @@ func (r *Router) AccountVerify(c *fiber.Ctx) error { "email": claims.Email, "error": err, }).Error("Verify account failed to enable user in FreeIPA") - return c.Status(fiber.StatusInternalServerError).SendString("Failed to verify account please contact administrator") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "account.failed_to_verify_account")) } } @@ -238,7 +238,7 @@ func (r *Router) AccountVerify(c *fiber.Ctx) error { "email": claims.Email, "error": err, }).Error("Verify account failed to modify user category in FreeIPA") - return c.Status(fiber.StatusInternalServerError).SendString("Failed to verify account please contact administrator") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "account.failed_to_verify_account")) } } diff --git a/server/auth.go b/server/auth.go index e86ef627..ee92d924 100644 --- a/server/auth.go +++ b/server/auth.go @@ -154,7 +154,7 @@ func (r *Router) RequireMFA(c *fiber.Ctx) error { user := r.user(c) if !user.OTPOnly() { - return c.Status(fiber.StatusUnauthorized).SendString("You must enable Two-Factor Authentication first!") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.you_must_enable_two_factor_authentication_first")) } return c.Next() @@ -164,7 +164,7 @@ func (r *Router) CheckUser(c *fiber.Ctx) error { username := c.FormValue("username") if username == "" { - return c.Status(fiber.StatusBadRequest).SendString("Please provide a username") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "account.please_provide_username")) } if isBlocked(username) { @@ -172,7 +172,7 @@ func (r *Router) CheckUser(c *fiber.Ctx) error { "username": username, }).Warn("AUDIT User account is blocked from logging in") r.metrics.totalFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("Invalid username") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.invalid_username")) } userRec, err := r.adminClient.UserShow(username) @@ -185,7 +185,7 @@ func (r *Router) CheckUser(c *fiber.Ctx) error { if !viper.GetBool("accounts.hide_invalid_username_error") { r.metrics.totalFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("Invalid username") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.invalid_username")) } userRec = new(ipa.User) userRec.Username = username @@ -195,7 +195,7 @@ func (r *Router) CheckUser(c *fiber.Ctx) error { "username": username, }).Error("Failed to fetch user info from FreeIPA") r.metrics.totalFailedLogins.Inc() - return c.Status(fiber.StatusInternalServerError).SendString("Fatal system error") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "account.fatal_system_error")) } } @@ -204,7 +204,7 @@ func (r *Router) CheckUser(c *fiber.Ctx) error { "username": username, }).Warn("AUDIT User account is locked in FreeIPA") r.metrics.totalFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("User account is locked") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.user_account_is_locked")) } log.WithFields(log.Fields{ @@ -227,11 +227,11 @@ func (r *Router) Authenticate(c *fiber.Ctx) error { otp := c.FormValue("otp") if username == "" { - return c.Status(fiber.StatusBadRequest).SendString("Please provide a username") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "account.please_provide_username")) } if password == "" { - return c.Status(fiber.StatusBadRequest).SendString("Please provide a password") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "account.please_provide_password")) } if isBlocked(username) { @@ -239,7 +239,7 @@ func (r *Router) Authenticate(c *fiber.Ctx) error { "username": username, }).Warn("AUDIT User account is blocked from logging in") r.metrics.totalFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("Invalid credentials") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.invalid_credentials")) } client := ipa.NewDefaultClient() @@ -286,7 +286,7 @@ func (r *Router) Authenticate(c *fiber.Ctx) error { "err": err, }).Error("AUDIT Failed login attempt") r.metrics.totalFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("Invalid credentials") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.invalid_credentials")) } } @@ -297,7 +297,7 @@ func (r *Router) Authenticate(c *fiber.Ctx) error { "err": err, }).Error("Failed to ping FreeIPA") r.metrics.totalFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("Invalid credentials") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.invalid_credentials")) } sess, err := r.session(c) diff --git a/server/email.go b/server/email.go index 287df0d4..82248b92 100644 --- a/server/email.go +++ b/server/email.go @@ -80,7 +80,7 @@ func (e *Emailer) SendPasswordResetEmail(user *ipa.User, ctx *fiber.Ctx) error { "link_expires": strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(time.Duration(viper.GetInt("email.token_max_age"))*time.Second), "", "")), } - return e.sendEmail(user, ctx, "Please reset your password", "password-reset", vars) + return e.sendEmail(user, ctx, Translate("", "email_template.password_reset_subject"), "password-reset", vars) } func (e *Emailer) SendAccountVerifyEmail(user *ipa.User, ctx *fiber.Ctx) error { @@ -94,7 +94,7 @@ func (e *Emailer) SendAccountVerifyEmail(user *ipa.User, ctx *fiber.Ctx) error { "link_expires": strings.TrimSpace(humanize.RelTime(time.Now(), time.Now().Add(time.Duration(viper.GetInt("email.token_max_age"))*time.Second), "", "")), } - return e.sendEmail(user, ctx, "Verify your email", "account-verify", vars) + return e.sendEmail(user, ctx, Translate("", "email_template.account_verify_subject"), "account-verify", vars) } func (e *Emailer) SendWelcomeEmail(user *ipa.User, ctx *fiber.Ctx) error { @@ -102,59 +102,73 @@ func (e *Emailer) SendWelcomeEmail(user *ipa.User, ctx *fiber.Ctx) error { "getting_started_url": viper.GetString("site.getting_started_url"), } - subject := "Welcome to " + viper.GetString("site.name") + subject := Translate("", "email_template.welcome_subject") + " " + viper.GetString("site.name") return e.sendEmail(user, ctx, subject, "welcome", vars) + } func (e *Emailer) SendMFAChangedEmail(enabled bool, user *ipa.User, ctx *fiber.Ctx) error { - verb := "Disabled" - if enabled { - verb = "Enabled" - } - event := "Two-Factor Authentication " + verb - - vars := map[string]interface{}{ - "event": event, - } + var verbKey string + if enabled { + verbKey = "email_template.two_factor_auth_enabled" + } else { + verbKey = "email_template.two_factor_auth_disabled" + } - return e.sendEmail(user, ctx, event, "account-updated", vars) -} + verb := Translate("", verbKey) + event := Translate("", "email_template.two_factor_auth_event") + verb -func (e *Emailer) SendSSHKeyUpdatedEmail(added bool, user *ipa.User, ctx *fiber.Ctx) error { - verb := "removed" - if added { - verb = "added" - } - event := "SSH key " + verb + vars := map[string]interface{}{ + "event": event, + } - vars := map[string]interface{}{ - "event": event, - } + return e.sendEmail(user, ctx, event, "account-updated", vars) +} - return e.sendEmail(user, ctx, event, "account-updated", vars) +func (e Emailer) SendSSHKeyUpdatedEmail(added bool, user *ipa.User, ctx *fiber.Ctx) error { + var verbKey string + if added { + verbKey = "email_template.ssh_key_added" + } else { + verbKey = "email_template.ssh_key_removed" + } + + verb := Translate("", verbKey) + event := Translate("", "email_template.ssh_key_event") + verb + + vars := map[string]interface{}{ + "event": event, + } + return e.sendEmail(user, ctx, event, "account-updated", vars) } func (e *Emailer) SendOTPTokenUpdatedEmail(added bool, user *ipa.User, ctx *fiber.Ctx) error { - verb := "removed" - if added { - verb = "added" - } - event := "OTP token " + verb + var verbKey string + if added { + verbKey = "email_template.otp_token_added" + } else { + verbKey = "email_template.otp_token_removed" + } - vars := map[string]interface{}{ - "event": event, - } + verb := Translate("", verbKey) + event := Translate("", "email_template.otp_token_event") + verb + + vars := map[string]interface{}{ + "event": event, + } - return e.sendEmail(user, ctx, event, "account-updated", vars) + return e.sendEmail(user, ctx, event, "account-updated", vars) } func (e *Emailer) SendPasswordChangedEmail(user *ipa.User, ctx *fiber.Ctx) error { - vars := map[string]interface{}{ - "event": "Password changed", - } + event := Translate("", "email_template.password_changed_event") + + vars := map[string]interface{}{ + "event": event, + } - return e.sendEmail(user, ctx, "Your password has been changed", "account-updated", vars) + return e.sendEmail(user, ctx, Translate("", "email_template.account_updated_subject"), "account-updated", vars) } func (e *Emailer) quotedBody(body []byte) ([]byte, error) { @@ -174,166 +188,179 @@ func (e *Emailer) quotedBody(body []byte) ([]byte, error) { } func (e *Emailer) sendEmail(user *ipa.User, ctx *fiber.Ctx, subject, tmpl string, data map[string]interface{}) error { - log.WithFields(log.Fields{ - "email": user.Email, - "username": user.Username, - }).Debug("Sending email to user") - - if data == nil { - data = make(map[string]interface{}) - } - - ua := useragent.Parse(ctx.Get(fiber.HeaderUserAgent)) - - data["os"] = ua.OS - data["browser"] = ua.Name - data["user"] = user - data["date"] = time.Now() - data["contact"] = viper.GetString("email.from") - data["sig"] = viper.GetString("email.signature") - data["site_name"] = viper.GetString("site.name") - data["help_url"] = viper.GetString("site.help_url") - data["homepage"] = viper.GetString("site.homepage") - data["base_url"] = BaseURL(ctx) - - var text bytes.Buffer - err := e.templates.ExecuteTemplate(&text, tmpl+".txt", data) - if err != nil { - return err - } - - txtBody, err := e.quotedBody(text.Bytes()) - if err != nil { - return err - } - - var html bytes.Buffer - err = e.templates.ExecuteTemplate(&html, tmpl+".html", data) - if err != nil { - return err - } - - htmlBody, err := e.quotedBody(html.Bytes()) - if err != nil { - return err - } - - header := make(textproto.MIMEHeader) - header.Set("Mime-Version", "1.0") - header.Set("Date", time.Now().Format(time.RFC1123Z)) - header.Set("To", user.Email) - header.Set("Subject", fmt.Sprintf("[%s] %s", viper.GetString("site.name"), subject)) - header.Set("From", viper.GetString("email.from")) - - var multipartBody bytes.Buffer - mp := multipart.NewWriter(&multipartBody) - header.Set("Content-Type", fmt.Sprintf("multipart/alternative;%s boundary=%s", crlf, mp.Boundary())) - - txtPart, err := mp.CreatePart(textproto.MIMEHeader( - map[string][]string{ - "Content-Type": []string{"text/plain; charset=utf-8"}, - "Content-Transfer-Encoding": []string{"quoted-printable"}, - })) - if err != nil { - return err - } - - _, err = txtPart.Write(txtBody) - if err != nil { - return err - } - - htmlPart, err := mp.CreatePart(textproto.MIMEHeader( - map[string][]string{ - "Content-Type": []string{"text/html; charset=utf-8"}, - "Content-Transfer-Encoding": []string{"quoted-printable"}, - })) - if err != nil { - return err - } - - _, err = htmlPart.Write(htmlBody) - if err != nil { - return err - } - - err = mp.Close() - if err != nil { - return err - } - - smtpHostPort := fmt.Sprintf("%s:%d", viper.GetString("email.smtp_host"), viper.GetInt("email.smtp_port")) - var conn net.Conn - tlsMode := viper.GetString("email.smtp_tls") - - switch tlsMode { - case "on": - tlsConfig := &tls.Config{ - InsecureSkipVerify: false, - ServerName: viper.GetString("email.smtp_host"), - } - conn, err = tls.Dial("tcp", smtpHostPort, tlsConfig) - case "off", "starttls": - conn, err = net.Dial("tcp", smtpHostPort) - default: - return fmt.Errorf("invalid config value for smtp_tls: %s", tlsMode) - } - - if err != nil { - return err - } - - c, err := smtp.NewClient(conn, viper.GetString("email.smtp_host")) - if err != nil { - return err - } - defer c.Close() - - if tlsMode == "starttls" { - err := c.StartTLS(&tls.Config{ - ServerName: viper.GetString("email.smtp_host"), - }) - if err != nil { - return err - } - } - - if viper.IsSet("email.smtp_username") && viper.IsSet("email.smtp_password") { - auth := smtp.PlainAuth("", viper.GetString("email.smtp_username"), viper.GetString("email.smtp_password"), viper.GetString("email.smtp_host")) - if err = c.Auth(auth); err != nil { - log.Error(err) - return err - } - } - if err = c.Mail(viper.GetString("email.from")); err != nil { - log.Error(err) - return err - } - if err = c.Rcpt(user.Email); err != nil { - log.Error(err) - return err - } - - wc, err := c.Data() - if err != nil { - return err - } - defer wc.Close() - - var buf bytes.Buffer - for k, vv := range header { - for _, v := range vv { - fmt.Fprintf(&buf, "%s: %s\r\n", k, v) - } - } - fmt.Fprintf(&buf, "\r\n") - - if _, err = buf.WriteTo(wc); err != nil { - return err - } - if _, err = wc.Write(multipartBody.Bytes()); err != nil { - return err - } - - return nil + log.WithFields(log.Fields{ + "email": user.Email, + "username": user.Username, + }).Debug("Sending email to user") + + if data == nil { + data = make(map[string]interface{}) + } + + ua := useragent.Parse(ctx.Get(fiber.HeaderUserAgent)) + + data["os"] = ua.OS + data["browser"] = ua.Name + data["user"] = user + data["date"] = time.Now() + data["contact"] = viper.GetString("email.from") + data["sig"] = viper.GetString("email.signature") + data["site_name"] = viper.GetString("site.name") + data["help_url"] = viper.GetString("site.help_url") + data["homepage"] = viper.GetString("site.homepage") + data["base_url"] = BaseURL(ctx) + + // Ensure the "lang" key exists in the data map + defaultLang := "en" + if viper.IsSet("site.default_language") { + defaultLang = viper.GetString("site.default_language") + } + + if lang, exists := data["lang"]; !exists || lang == "" { + log.Printf("DEBUG: 'lang' key not found or empty, using default language '%s'", defaultLang) + data["lang"] = defaultLang + } else { + log.Debugf("DEBUG: Using provided 'lang' key with value: %v", lang) + } + + var text bytes.Buffer + err := e.templates.ExecuteTemplate(&text, tmpl+".txt", data) + if err != nil { + return err + } + + txtBody, err := e.quotedBody(text.Bytes()) + if err != nil { + return err + } + + var html bytes.Buffer + err = e.templates.ExecuteTemplate(&html, tmpl+".html", data) + if err != nil { + return err + } + + htmlBody, err := e.quotedBody(html.Bytes()) + if err != nil { + return err + } + + header := make(textproto.MIMEHeader) + header.Set("Mime-Version", "1.0") + header.Set("Date", time.Now().Format(time.RFC1123Z)) + header.Set("To", user.Email) + header.Set("Subject", fmt.Sprintf("[%s] %s", viper.GetString("site.name"), subject)) + header.Set("From", viper.GetString("email.from")) + + var multipartBody bytes.Buffer + mp := multipart.NewWriter(&multipartBody) + header.Set("Content-Type", fmt.Sprintf("multipart/alternative;%s boundary=%s", crlf, mp.Boundary())) + + txtPart, err := mp.CreatePart(textproto.MIMEHeader{ + "Content-Type": []string{"text/plain; charset=utf-8"}, + "Content-Transfer-Encoding": []string{"quoted-printable"}, + }) + if err != nil { + return err + } + + _, err = txtPart.Write(txtBody) + if err != nil { + return err + } + + htmlPart, err := mp.CreatePart(textproto.MIMEHeader{ + "Content-Type": []string{"text/html; charset=utf-8"}, + "Content-Transfer-Encoding": []string{"quoted-printable"}, + }) + if err != nil { + return err + } + + _, err = htmlPart.Write(htmlBody) + if err != nil { + return err + } + + err = mp.Close() + if err != nil { + return err + } + + smtpHostPort := fmt.Sprintf("%s:%d", viper.GetString("email.smtp_host"), viper.GetInt("email.smtp_port")) + var conn net.Conn + tlsMode := viper.GetString("email.smtp_tls") + + switch tlsMode { + case "on": + tlsConfig := &tls.Config{ + InsecureSkipVerify: false, + ServerName: viper.GetString("email.smtp_host"), + } + conn, err = tls.Dial("tcp", smtpHostPort, tlsConfig) + case "off", "starttls": + conn, err = net.Dial("tcp", smtpHostPort) + default: + return fmt.Errorf("invalid config value for smtp_tls: %s", tlsMode) + } + + if err != nil { + return err + } + + c, err := smtp.NewClient(conn, viper.GetString("email.smtp_host")) + if err != nil { + return err + } + defer c.Close() + + if tlsMode == "starttls" { + err := c.StartTLS(&tls.Config{ + ServerName: viper.GetString("email.smtp_host"), + }) + if err != nil { + return err + } + } + + if viper.IsSet("email.smtp_username") && viper.IsSet("email.smtp_password") { + auth := smtp.PlainAuth("", viper.GetString("email.smtp_username"), viper.GetString("email.smtp_password"), viper.GetString("email.smtp_host")) + if err = c.Auth(auth); err != nil { + log.Error(err) + return err + } + } + + if err = c.Mail(viper.GetString("email.from")); err != nil { + log.Error(err) + return err + } + if err = c.Rcpt(user.Email); err != nil { + log.Error(err) + return err + } + + wc, err := c.Data() + if err != nil { + return err + } + defer wc.Close() + + var buf bytes.Buffer + for k, vv := range header { + for _, v := range vv { + fmt.Fprintf(&buf, "%s: %s\r\n", k, v) + } + } + fmt.Fprintf(&buf, "\r\n") + + if _, err = buf.WriteTo(wc); err != nil { + return err + } + if _, err = wc.Write(multipartBody.Bytes()); err != nil { + return err + } + + return nil } + diff --git a/server/hydra.go b/server/hydra.go index 01671349..a1e13c5e 100644 --- a/server/hydra.go +++ b/server/hydra.go @@ -28,7 +28,7 @@ func (r *Router) ConsentGet(c *fiber.Ctx) error { "ip": RemoteIP(c), }).Error("Consent endpoint was called without a consent challenge") r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusBadRequest).SendString("consent without challenge") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "hydra.consent_without_challenge")) } cparams := admin.NewGetConsentRequestParams() @@ -40,7 +40,7 @@ func (r *Router) ConsentGet(c *fiber.Ctx) error { "error": err, }).Error("Failed to validate the consent challenge") r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusInternalServerError).SendString("Failed to validate consent") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.failed_to_validate_consent")) } consent := cresponse.Payload @@ -52,12 +52,12 @@ func (r *Router) ConsentGet(c *fiber.Ctx) error { "username": consent.Subject, }).Warn("Failed to find User record for consent") r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusInternalServerError).SendString("Failed to validate consent") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.failed_to_validate_consent")) } if viper.GetBool("accounts.require_mfa") && !user.OTPOnly() { r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("Access denied.") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "hydra.access_denied")) } params := admin.NewAcceptConsentRequestParams() @@ -83,7 +83,7 @@ func (r *Router) ConsentGet(c *fiber.Ctx) error { "error": err, }).Error("Failed to accept the consent challenge") r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusInternalServerError).SendString("Failed to accept consent") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.failed_to_accept_consent")) } log.WithFields(log.Fields{ @@ -102,7 +102,7 @@ func (r *Router) LoginOAuthGet(c *fiber.Ctx) error { log.WithFields(log.Fields{ "ip": RemoteIP(c), }).Error("Login OAuth endpoint was called without a challenge") - return c.Status(fiber.StatusBadRequest).SendString("login without challenge") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "hydra.login_without_challenge")) } getparams := admin.NewGetLoginRequestParams() @@ -113,7 +113,7 @@ func (r *Router) LoginOAuthGet(c *fiber.Ctx) error { log.WithFields(log.Fields{ "error": err, }).Error("Failed to validate the login challenge") - return c.Status(fiber.StatusInternalServerError).SendString("Failed to validate login") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.failed_to_validate_login")) } login := response.Payload @@ -131,12 +131,12 @@ func (r *Router) LoginOAuthGet(c *fiber.Ctx) error { "username": *login.Subject, }).Warn("Failed to find User record for login") r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusInternalServerError).SendString("Failed to validate login") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.failed_to_validate_login")) } if viper.GetBool("accounts.require_mfa") && !user.OTPOnly() { r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusUnauthorized).SendString("Access denied.") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "hydra.access_denied")) } acceptparams := admin.NewAcceptLoginRequestParams() @@ -152,7 +152,7 @@ func (r *Router) LoginOAuthGet(c *fiber.Ctx) error { "error": err, }).Error("Failed to accept the GET login challenge") r.metrics.totalHydraFailedLogins.Inc() - return c.Status(fiber.StatusInternalServerError).SendString("Failed to accept login") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.failed_to_accept_login")) } log.WithFields(log.Fields{ @@ -190,7 +190,7 @@ func (r *Router) LoginOAuthPost(username, challenge string, c *fiber.Ctx) error "username": username, "error": err, }).Error("Failed to accept the POST login challenge") - return c.Status(fiber.StatusInternalServerError).SendString("Failed to accept login") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.failed_to_accept_login")) } log.WithFields(log.Fields{ @@ -216,7 +216,7 @@ func (r *Router) HydraError(c *fiber.Ctx) error { "hint": hint, }).Error("OAuth2 request failed") - return c.Status(fiber.StatusInternalServerError).SendString("OAuth2 Error") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "hydra.oauth2_error")) } func (r *Router) revokeHydraAuthenticationSession(username string, c *fiber.Ctx) error { diff --git a/server/middleware.go b/server/middleware.go index 398560dc..dfa0a08b 100644 --- a/server/middleware.go +++ b/server/middleware.go @@ -93,7 +93,7 @@ func LimitReachedHandler(c *fiber.Ctx) error { log.WithFields(log.Fields{ "ip": RemoteIP(c), }).Warn("Limit reached") - return c.Status(fiber.StatusTooManyRequests).SendString("Too many requests. Please try again later.") + return c.Status(fiber.StatusTooManyRequests).SendString(Translate("", "common.too_many_requests")) } func (r *Router) RequireHTMX(c *fiber.Ctx) error { diff --git a/server/otp.go b/server/otp.go index d8dc62f6..5e9fa66d 100644 --- a/server/otp.go +++ b/server/otp.go @@ -134,7 +134,7 @@ func (r *Router) OTPTokenVerify(c *fiber.Ctx) error { key, err := otp.NewKeyFromURL(uri) if err != nil || action == "cancel" { client.RemoveOTPToken(uuid) - vars["message"] = "Failed to verify token." + vars["message"] = Translate("", "otp.failed_to_verify_token") return r.tokenList(c, vars) } @@ -154,7 +154,7 @@ func (r *Router) OTPTokenVerify(c *fiber.Ctx) error { "uuid": uuid, "username": user.Username, }).Error("Failed to verify OTP token") - return c.Status(fiber.StatusBadRequest).SendString("Invalid 6-digit code. Please try again.") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "otp.invalid_6_digit_code")) } autoMFA := false diff --git a/server/password.go b/server/password.go index 0347ddbb..914eac5b 100644 --- a/server/password.go +++ b/server/password.go @@ -27,7 +27,8 @@ func checkPassword(pass string) error { l := len([]rune(pass)) if l < minLength { - return fmt.Errorf("Password does not conform to policy. Min length: %d", minLength) + // Translators: “Min length: %d” – keep the placeholder for the length + return fmt.Errorf(Translate("", "password.min_length"), minLength) } numCategories := 0 @@ -65,7 +66,8 @@ func checkPassword(pass string) error { } if numCategories < minClasses { - return fmt.Errorf("Password does not conform to policy. Try including both upper/lower case, numbers, and other characters") + // Translators: “Password does not conform to policy…” – generic message + return fmt.Errorf(Translate("", "password.policy_not_met")) } return nil @@ -73,15 +75,18 @@ func checkPassword(pass string) error { func validatePassword(password, passwordConfirm string) error { if password == "" { - return errors.New("Please enter a new password") + // Translators: Prompt user to enter a new password + return errors.New(Translate("", "password.enter_new")) } if passwordConfirm == "" { - return errors.New("Please confirm your new password") + // Translators: Prompt user to confirm the new password + return errors.New(Translate("", "password.confirm_new")) } if password != passwordConfirm { - return errors.New("Password do not match. Please confirm your password.") + // Translators: Prompt user to confirm the new password + return errors.New(Translate("", "password.confirm_new")) } if err := checkPassword(password); err != nil { @@ -93,11 +98,13 @@ func validatePassword(password, passwordConfirm string) error { func validatePasswordChange(passwordCurrent, password, passwordConfirm string) error { if passwordCurrent == "" { - return errors.New("Please enter you current password") + // Translators: Prompt user to enter current password + return errors.New(Translate("", "password.enter_current")) } if passwordCurrent == passwordConfirm { - return errors.New("Current password is the same as new password. Please set a different password.") + // Translators: Current password equals new password + return errors.New(Translate("", "password.same_as_new")) } return validatePassword(password, passwordConfirm) @@ -121,7 +128,8 @@ func (r *Router) PasswordChange(c *fiber.Ctx) error { otp := c.FormValue("otpcode") if user.OTPOnly() && otp == "" { - vars["message"] = "Please enter the 6-digit OTP code from your mobile app" + // Translators: OTP prompt for users that only use OTP + vars["message"] = Translate("", "password_change.otp_help") return c.Render("password.html", vars) } @@ -144,7 +152,7 @@ func (r *Router) PasswordChange(c *fiber.Ctx) error { "username": user.Username, "error": err.Error(), }).Error("Failed to change password") - vars["message"] = "Fatal system error" + vars["message"] = Translate("", "account.system_error") } } else { err = r.emailer.SendPasswordChangedEmail(user, c) @@ -260,7 +268,7 @@ func (r *Router) PasswordReset(c *fiber.Ctx) error { otp := c.FormValue("otpcode") if user.OTPOnly() && otp == "" { - return c.Status(fiber.StatusBadRequest).SendString("Please enter the 6-digit OTP code from your mobile app") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "password_change.otp_help")) } if err := validatePassword(password, passwordConfirm); err != nil { @@ -269,7 +277,7 @@ func (r *Router) PasswordReset(c *fiber.Ctx) error { rand, err := r.adminClient.ResetPassword(user.Username) if err != nil { - return c.Status(fiber.StatusInternalServerError).SendString("System error please contact administrator") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "account.system_error")) } err = r.adminClient.SetPassword(user.Username, rand, password, otp) @@ -280,19 +288,19 @@ func (r *Router) PasswordReset(c *fiber.Ctx) error { "username": user.Username, "error": err, }).Error("Password does not conform to policy") - return c.Status(fiber.StatusBadRequest).SendString("Your password is too weak. Please ensure your password includes a number and lower/upper case character") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "account.weak_password")) case errors.Is(err, ipa.ErrInvalidPassword): log.WithFields(log.Fields{ "username": user.Username, "error": err, }).Error("invalid password from FreeIPA") - return c.Status(fiber.StatusBadRequest).SendString("Invalid OTP code.") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "otptoken.invalid_otp")) default: log.WithFields(log.Fields{ "username": user.Username, "error": err, }).Error("failed to set user password in FreeIPA") - return c.Status(fiber.StatusInternalServerError).SendString("System error please contact administrator") + return c.Status(fiber.StatusInternalServerError).SendString(Translate("", "account.system_error")) } } @@ -352,7 +360,7 @@ func (r *Router) PasswordExpired(c *fiber.Ctx) error { otp := c.FormValue("otp") if user.OTPOnly() && otp == "" { - return c.Status(fiber.StatusBadRequest).SendString("Please enter the 6-digit OTP code from your mobile app") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "password_change.otp_help")) } if err := validatePasswordChange(password, newpass, newpass2); err != nil { @@ -386,7 +394,7 @@ func (r *Router) PasswordExpired(c *fiber.Ctx) error { "username": user.Username, "ipa_client_error": err, }).Error("Failed to login after expired password change") - return c.Status(fiber.StatusUnauthorized).SendString("Login failed") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "login.login_failed")) } _, err = client.Ping() @@ -395,7 +403,7 @@ func (r *Router) PasswordExpired(c *fiber.Ctx) error { "username": user.Username, "ipa_client_error": err, }).Error("Failed to ping FreeIPA after expired password change") - return c.Status(fiber.StatusUnauthorized).SendString("Invalid credentials") + return c.Status(fiber.StatusUnauthorized).SendString(Translate("", "account.invalid_credentials")) } sess.Set(SessionKeyAuthenticated, true) diff --git a/server/sshpubkey.go b/server/sshpubkey.go index a0709e6d..a09a8a3a 100644 --- a/server/sshpubkey.go +++ b/server/sshpubkey.go @@ -26,7 +26,8 @@ func (r *Router) SSHKeyAdd(c *fiber.Ctx) error { key := c.FormValue("key") if key == "" { - return c.Status(fiber.StatusBadRequest).SendString("Please provide an ssh key") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "sshkey_list.please_provide_sshkey")) + } authKey, err := ipa.NewSSHAuthorizedKey(key) @@ -35,7 +36,7 @@ func (r *Router) SSHKeyAdd(c *fiber.Ctx) error { "username": user.Username, "err": err, }).Error("Failed to add new ssh key") - return c.Status(fiber.StatusBadRequest).SendString("Invalid ssh key") + return c.Status(fiber.StatusBadRequest).SendString(Translate("", "sshkey_list.invalid_sshkey")) } if title != "" { diff --git a/server/template.go b/server/template.go index 2c19cd1b..a33df7f0 100644 --- a/server/template.go +++ b/server/template.go @@ -8,9 +8,11 @@ import ( "sort" "strings" "time" + "fmt" - "github.com/dustin/go-humanize" "github.com/spf13/viper" + log "github.com/sirupsen/logrus" + "github.com/gofiber/fiber/v2" ) //go:embed templates @@ -24,6 +26,7 @@ var funcMap = template.FuncMap{ "ConfigValueBool": ConfigValueBool, "AllowedDomains": AllowedDomains, "BreakNewlines": BreakNewlines, + "Translate": Translate, } type TemplateRenderer struct { @@ -31,14 +34,20 @@ type TemplateRenderer struct { } func NewTemplateRenderer() (*TemplateRenderer, error) { + // Laad vertalingen + err := LoadTranslations() + if err != nil { + return nil, fmt.Errorf("failed to load translations: %w", err) + } tmpl := template.New("") tmpl.Funcs(funcMap) - tmpl, err := tmpl.ParseFS(templateFiles, "templates/*.html") + tmpl, err = tmpl.ParseFS(templateFiles, "templates/*.html") if err != nil { return nil, err } + // Add local templates if available if viper.IsSet("site.templates_dir") { localTemplatePath := filepath.Join(viper.GetString("site.templates_dir"), "*.html") localTemplates, err := filepath.Glob(localTemplatePath) @@ -68,7 +77,31 @@ func (t *TemplateRenderer) Load() error { } func (t *TemplateRenderer) Render(w io.Writer, name string, data interface{}, layouts ...string) error { - return t.templates.ExecuteTemplate(w, name, data) + // Same logic to check if "lang" is set and apply translation + var dataMap map[string]interface{} + switch v := data.(type) { + case map[string]interface{}: + dataMap = v + case fiber.Map: + dataMap = map[string]interface{}(v) + default: + log.Println("WARN: The provided data is not a map[string]interface{}, wrapping in map") + dataMap = map[string]interface{}{"data": data} + } + + defaultLang := "en" + if viper.IsSet("site.default_language") { + defaultLang = viper.GetString("site.default_language") + } + + if lang, exists := dataMap["lang"]; !exists { + //log.Printf("DEBUG: 'lang' key not found, using default language '%s'", defaultLang) + dataMap["lang"] = defaultLang + } else { + log.Debugf("DEBUG: Found 'lang' key with value: %v", lang) + } + + return t.templates.ExecuteTemplate(w, name, dataMap) } func AllowedDomains() string { @@ -95,7 +128,7 @@ func ConfigValueBool(key string) bool { } func TimeAgo(t time.Time) string { - return humanize.Time(t) + return Dutch.Format(t) } func SplitSSHFP(fp string) []string { diff --git a/server/templates/401.html b/server/templates/401.html index 069ccf28..e0f2f53b 100644 --- a/server/templates/401.html +++ b/server/templates/401.html @@ -1,16 +1,15 @@ {{ template "header.html" . }}
-
+
- -
+
{{ template "footer.html" . }} diff --git a/server/templates/403-partial.html b/server/templates/403-partial.html index 1819cb9d..1dacf2f0 100644 --- a/server/templates/403-partial.html +++ b/server/templates/403-partial.html @@ -1,3 +1,3 @@ diff --git a/server/templates/403.html b/server/templates/403.html index 2cb257fb..af120bc2 100644 --- a/server/templates/403.html +++ b/server/templates/403.html @@ -1,16 +1,15 @@ {{ template "header.html" . }}
-
+
- -
+
{{ template "footer.html" . }} diff --git a/server/templates/404-partial.html b/server/templates/404-partial.html index 6c0ba759..f12224d8 100644 --- a/server/templates/404-partial.html +++ b/server/templates/404-partial.html @@ -1,3 +1,3 @@ diff --git a/server/templates/404.html b/server/templates/404.html index ecdaf7a7..405b2614 100644 --- a/server/templates/404.html +++ b/server/templates/404.html @@ -1,16 +1,15 @@ {{ template "header.html" . }}
-
+
- -
+
{{ template "footer.html" . }} diff --git a/server/templates/500-partial.html b/server/templates/500-partial.html index 2b4437a8..d2a7799c 100644 --- a/server/templates/500-partial.html +++ b/server/templates/500-partial.html @@ -1,3 +1,3 @@ diff --git a/server/templates/500.html b/server/templates/500.html index 29834cc8..fe727a6b 100644 --- a/server/templates/500.html +++ b/server/templates/500.html @@ -1,16 +1,15 @@ {{ template "header.html" . }}
-
+
- -
+
{{ template "footer.html" . }} diff --git a/server/templates/502-partial.html b/server/templates/502-partial.html new file mode 100644 index 00000000..95f3b6c5 --- /dev/null +++ b/server/templates/502-partial.html @@ -0,0 +1,3 @@ + diff --git a/server/templates/502.html b/server/templates/502.html new file mode 100644 index 00000000..bdd17675 --- /dev/null +++ b/server/templates/502.html @@ -0,0 +1,13 @@ +{{ template "header.html" . }} +
+
+ + + +
+
+{{ template "footer.html" . }} diff --git a/server/templates/account-verify-forgot-success.html b/server/templates/account-verify-forgot-success.html index 0727458b..154b4d46 100644 --- a/server/templates/account-verify-forgot-success.html +++ b/server/templates/account-verify-forgot-success.html @@ -1,12 +1,12 @@