feat: add support for translations (#349)

Co-authored-by: Kyle Mendell <kmendell@outlook.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
This commit is contained in:
Jonas Claes
2025-03-20 19:57:41 +01:00
committed by GitHub
parent 041c565dc1
commit 269b5a3c92
83 changed files with 1567 additions and 453 deletions

5
.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,5 @@
{
"recommendations": [
"inlang.vs-code-extension"
]
}

View File

@@ -9,18 +9,20 @@ type UserDto struct {
FirstName string `json:"firstName"` FirstName string `json:"firstName"`
LastName string `json:"lastName"` LastName string `json:"lastName"`
IsAdmin bool `json:"isAdmin"` IsAdmin bool `json:"isAdmin"`
Locale *string `json:"locale"`
CustomClaims []CustomClaimDto `json:"customClaims"` CustomClaims []CustomClaimDto `json:"customClaims"`
UserGroups []UserGroupDto `json:"userGroups"` UserGroups []UserGroupDto `json:"userGroups"`
LdapID *string `json:"ldapId"` LdapID *string `json:"ldapId"`
} }
type UserCreateDto struct { type UserCreateDto struct {
Username string `json:"username" binding:"required,username,min=2,max=50"` Username string `json:"username" binding:"required,username,min=2,max=50"`
Email string `json:"email" binding:"required,email"` Email string `json:"email" binding:"required,email"`
FirstName string `json:"firstName" binding:"required,min=1,max=50"` FirstName string `json:"firstName" binding:"required,min=1,max=50"`
LastName string `json:"lastName" binding:"required,min=1,max=50"` LastName string `json:"lastName" binding:"required,min=1,max=50"`
IsAdmin bool `json:"isAdmin"` IsAdmin bool `json:"isAdmin"`
LdapID string `json:"-"` Locale *string `json:"locale"`
LdapID string `json:"-"`
} }
type OneTimeAccessTokenCreateDto struct { type OneTimeAccessTokenCreateDto struct {

View File

@@ -14,6 +14,7 @@ type User struct {
FirstName string `sortable:"true"` FirstName string `sortable:"true"`
LastName string `sortable:"true"` LastName string `sortable:"true"`
IsAdmin bool `sortable:"true"` IsAdmin bool `sortable:"true"`
Locale *string
LdapID *string LdapID *string
CustomClaims []CustomClaim CustomClaims []CustomClaim

View File

@@ -153,6 +153,7 @@ func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
Email: input.Email, Email: input.Email,
Username: input.Username, Username: input.Username,
IsAdmin: input.IsAdmin, IsAdmin: input.IsAdmin,
Locale: input.Locale,
} }
if input.LdapID != "" { if input.LdapID != "" {
user.LdapID = &input.LdapID user.LdapID = &input.LdapID
@@ -182,6 +183,7 @@ func (s *UserService) UpdateUser(userID string, updatedUser dto.UserCreateDto, u
user.LastName = updatedUser.LastName user.LastName = updatedUser.LastName
user.Email = updatedUser.Email user.Email = updatedUser.Email
user.Username = updatedUser.Username user.Username = updatedUser.Username
user.Locale = updatedUser.Locale
if !updateOwnUser { if !updateOwnUser {
user.IsAdmin = updatedUser.IsAdmin user.IsAdmin = updatedUser.IsAdmin
} }

View File

@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN locale;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN locale TEXT;

View File

@@ -0,0 +1 @@
ALTER TABLE users DROP COLUMN locale;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN locale TEXT;

318
frontend/messages/en.json Normal file
View File

@@ -0,0 +1,318 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "My Account",
"logout": "Logout",
"english": "English",
"dutch": "Nederlands",
"confirm": "Confirm",
"key": "Key",
"value": "Value",
"remove_custom_claim": "Remove custom claim",
"add_custom_claim": "Add custom claim",
"add_another": "Add another",
"select_a_date": "Select a date",
"select_file": "Select File",
"profile_picture": "Profile Picture",
"profile_picture_is_managed_by_ldap_server": "The profile picture is managed by the LDAP server and cannot be changed here.",
"click_profile_picture_to_upload_custom": "Click on the profile picture to upload a custom one from your files.",
"image_should_be_in_format": "The image should be in PNG or JPEG format.",
"items_per_page": "Items per page",
"no_items_found": "No items found",
"search": "Search...",
"expand_card": "Expand card",
"copied": "Copied",
"click_to_copy": "Click to copy",
"something_went_wrong": "Something went wrong",
"go_back_to_home": "Go back to home",
"dont_have_access_to_your_passkey": "Don't have access to your passkey?",
"login_background": "Login background",
"logo": "Logo",
"login_code": "Login Code",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Create a login code that the user can use to sign in without a passkey once.",
"one_hour": "1 hour",
"twelve_hours": "12 hours",
"one_day": "1 day",
"one_week": "1 week",
"one_month": "1 month",
"expiration": "Expiration",
"generate_code": "Generate Code",
"name": "Name",
"browser_unsupported": "Browser unsupported",
"this_browser_does_not_support_passkeys": "This browser doesn't support passkeys. Please or use a alternative sign in method.",
"an_unknown_error_occurred": "An unknown error occurred",
"authentication_process_was_aborted": "The authentication process was aborted",
"error_occurred_with_authenticator": "An error occurred with the authenticator",
"authenticator_does_not_support_discoverable_credentials": "The authenticator does not support discoverable credentials",
"authenticator_does_not_support_resident_keys": "The authenticator does not support resident keys",
"passkey_was_previously_registered": "This passkey was previously registered",
"authenticator_does_not_support_any_of_the_requested_algorithms": "The authenticator does not support any of the requested algorithms",
"authenticator_timed_out": "The authenticator timed out",
"critical_error_occurred_contact_administrator": "A critical error occurred. Please contact your administrator.",
"sign_in_to": "Sign in to {name}",
"client_not_found": "Client not found",
"client_wants_to_access_the_following_information": "<b>{client}</b> wants to access the following information:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Do you want to sign in to <b>{client}</b> with your <b>{appName}</b> account?",
"email": "Email",
"view_your_email_address": "View your email address",
"profile": "Profile",
"view_your_profile_information": "View your profile information",
"groups": "Groups",
"view_the_groups_you_are_a_member_of": "View the groups you are a member of",
"cancel": "Cancel",
"sign_in": "Sign in",
"try_again": "Try again",
"client_logo": "Client Logo",
"sign_out": "Sign out",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Do you want to sign out of Pocket ID with the account <b>{username}</b>?",
"sign_in_to_appname": "Sign in to {appName}",
"please_try_to_sign_in_again": "Please try to sign in again.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Authenticate yourself with your passkey to access the admin panel.",
"authenticate": "Authenticate",
"appname_setup": "{appName} Setup",
"please_try_again": "Please try again.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "You're about to sign in to the initial admin account. Anyone with this link can access the account until a passkey is added. Please set up a passkey as soon as possible to prevent unauthorized access.",
"continue": "Continue",
"alternative_sign_in": "Alternative Sign In",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "If you dont't have access to your passkey, you can sign in using one of the following methods.",
"use_your_passkey_instead": "Use your passkey instead?",
"email_login": "Email Login",
"enter_a_login_code_to_sign_in": "Enter a login code to sign in.",
"request_a_login_code_via_email": "Request a login code via email.",
"go_back": "Go back",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "An email has been sent to the provided email, if it exists in the system.",
"enter_code": "Enter code",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Enter your email address to receive an email with a login code.",
"your_email": "Your email",
"submit": "Submit",
"enter_the_code_you_received_to_sign_in": "Enter the code you received to sign in.",
"code": "Code",
"invalid_redirect_url": "Invalid redirect URL",
"audit_log": "Audit Log",
"users": "Users",
"user_groups": "User Groups",
"oidc_clients": "OIDC Clients",
"api_keys": "API Keys",
"application_configuration": "Application Configuration",
"settings": "Settings",
"update_pocket_id": "Update Pocket ID",
"powered_by": "Powered by",
"see_your_account_activities_from_the_last_3_months": "See your account activities from the last 3 months.",
"time": "Time",
"event": "Event",
"approximate_location": "Approximate Location",
"ip_address": "IP Address",
"device": "Device",
"client": "Client",
"unknown": "Unknown",
"account_details_updated_successfully": "Account details updated successfully",
"profile_picture_updated_successfully": "Profile picture updated successfully. It may take a few minutes to update.",
"account_settings": "Account Settings",
"passkey_missing": "Passkey missing",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Please add a passkey to prevent losing access to your account.",
"single_passkey_configured": "Single Passkey Configured",
"it_is_recommended_to_add_more_than_one_passkey": "It is recommended to add more than one passkey to avoid losing access to your account.",
"account_details": "Account Details",
"passkeys": "Passkeys",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
"add_passkey": "Add Passkey",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Create a one-time login code to sign in from a different device without a passkey.",
"create": "Create",
"first_name": "First name",
"last_name": "Last name",
"username": "Username",
"save": "Save",
"username_can_only_contain": "Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Sign in using the following code. The code will expire in 15 minutes.",
"or_visit": "or visit",
"added_on": "Added on",
"rename": "Rename",
"delete": "Delete",
"are_you_sure_you_want_to_delete_this_passkey": "Are you sure you want to delete this passkey?",
"passkey_deleted_successfully": "Passkey deleted successfully",
"delete_passkey_name": "Delete {passkeyName}",
"passkey_name_updated_successfully": "Passkey name updated successfully",
"name_passkey": "Name Passkey",
"name_your_passkey_to_easily_identify_it_later": "Name your passkey to easily identify it later.",
"create_api_key": "Create API Key",
"add_a_new_api_key_for_programmatic_access": "Add a new API key for programmatic access.",
"add_api_key": "Add API Key",
"manage_api_keys": "Manage API Keys",
"api_key_created": "API Key Created",
"for_security_reasons_this_key_will_only_be_shown_once": "For security reasons, this key will only be shown once. Please store it securely.",
"description": "Description",
"api_key": "API Key",
"close": "Close",
"name_to_identify_this_api_key": "Name to identify this API key.",
"expires_at": "Expires At",
"when_this_api_key_will_expire": "When this API key will expire.",
"optional_description_to_help_identify_this_keys_purpose": "Optional description to help identify this key's purpose.",
"name_must_be_at_least_3_characters": "Name must be at least 3 characters",
"name_cannot_exceed_50_characters": "Name cannot exceed 50 characters",
"expiration_date_must_be_in_the_future": "Expiration date must be in the future",
"revoke_api_key": "Revoke API Key",
"never": "Never",
"revoke": "Revoke",
"api_key_revoked_successfully": "API key revoked successfully",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Are you sure you want to revoke the API key \"{apiKeyName}\"? This will break any integrations using this key.",
"last_used": "Last Used",
"actions": "Actions",
"images_updated_successfully": "Images updated successfully",
"general": "General",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Enable email notifications to alert users when a login is detected from a new device or location.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configure LDAP settings to sync users and groups from an LDAP server.",
"images": "Images",
"update": "Update",
"email_configuration_updated_successfully": "Email configuration updated successfully",
"save_changes_question": "Save changes?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "You have to save the changes before sending a test email. Do you want to save now?",
"save_and_send": "Save and send",
"test_email_sent_successfully": "Test email sent successfully to your email address.",
"failed_to_send_test_email": "Failed to send test email. Check the server logs for more information.",
"smtp_configuration": "SMTP Configuration",
"smtp_host": "SMTP Host",
"smtp_port": "SMTP Port",
"smtp_user": "SMTP User",
"smtp_password": "SMTP Password",
"smtp_from": "SMTP From",
"smtp_tls_option": "SMTP TLS Option",
"email_tls_option": "Email TLS Option",
"skip_certificate_verification": "Skip Certificate Verification",
"this_can_be_useful_for_selfsigned_certificates": "This can be useful for self-signed certificates.",
"enabled_emails": "Enabled Emails",
"email_login_notification": "Email Login Notification",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Send an email to the user when they log in from a new device.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Allows users to sign in with a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry.",
"send_test_email": "Send test email",
"application_configuration_updated_successfully": "Application configuration updated successfully",
"application_name": "Application Name",
"session_duration": "Session Duration",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "The duration of a session in minutes before the user has to sign in again.",
"enable_self_account_editing": "Enable Self-Account Editing",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Whether the users should be able to edit their own account details.",
"emails_verified": "Emails Verified",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Whether the user's email should be marked as verified for the OIDC clients.",
"ldap_configuration_updated_successfully": "LDAP configuration updated successfully",
"ldap_disabled_successfully": "LDAP disabled successfully",
"ldap_sync_finished": "LDAP sync finished",
"client_configuration": "Client Configuration",
"ldap_url": "LDAP URL",
"ldap_bind_dn": "LDAP Bind DN",
"ldap_bind_password": "LDAP Bind Password",
"ldap_base_dn": "LDAP Base DN",
"user_search_filter": "User Search Filter",
"the_search_filter_to_use_to_search_or_sync_users": "The Search filter to use to search/sync users.",
"groups_search_filter": "Groups Search Filter",
"the_search_filter_to_use_to_search_or_sync_groups": "The Search filter to use to search/sync groups.",
"attribute_mapping": "Attribute Mapping",
"user_unique_identifier_attribute": "User Unique Identifier Attribute",
"the_value_of_this_attribute_should_never_change": "The value of this attribute should never change.",
"username_attribute": "Username Attribute",
"user_mail_attribute": "User Mail Attribute",
"user_first_name_attribute": "User First Name Attribute",
"user_last_name_attribute": "User Last Name Attribute",
"user_profile_picture_attribute": "User Profile Picture Attribute",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "The value of this attribute can either be a URL, a binary or a base64 encoded image.",
"group_members_attribute": "Group Members Attribute",
"the_attribute_to_use_for_querying_members_of_a_group": "The attribute to use for querying members of a group.",
"group_unique_identifier_attribute": "Group Unique Identifier Attribute",
"group_name_attribute": "Group Name Attribute",
"admin_group_name": "Admin Group Name",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Members of this group will have Admin Privileges in Pocket ID.",
"disable": "Disable",
"sync_now": "Sync now",
"enable": "Enable",
"user_created_successfully": "User created successfully",
"create_user": "Create User",
"add_a_new_user_to_appname": "Add a new user to {appName}",
"add_user": "Add User",
"manage_users": "Manage Users",
"admin_privileges": "Admin Privileges",
"admins_have_full_access_to_the_admin_panel": "Admins have full access to the admin panel.",
"delete_firstname_lastname": "Delete {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Are you sure you want to delete this user?",
"user_deleted_successfully": "User deleted successfully",
"role": "Role",
"source": "Source",
"admin": "Admin",
"user": "User",
"local": "Local",
"toggle_menu": "Toggle menu",
"edit": "Edit",
"user_groups_updated_successfully": "User groups updated successfully",
"user_updated_successfully": "User updated successfully",
"custom_claims_updated_successfully": "Custom claims updated successfully",
"back": "Back",
"user_details_firstname_lastname": "User Details {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Manage which groups this user belongs to.",
"custom_claims": "Custom Claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested.",
"user_group_created_successfully": "User group created successfully",
"create_user_group": "Create User Group",
"create_a_new_group_that_can_be_assigned_to_users": "Create a new group that can be assigned to users.",
"add_group": "Add Group",
"manage_user_groups": "Manage User Groups",
"friendly_name": "Friendly Name",
"name_that_will_be_displayed_in_the_ui": "Name that will be displayed in the UI",
"name_that_will_be_in_the_groups_claim": "Name that will be in the \"groups\" claim",
"delete_name": "Delete {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Are you sure you want to delete this user group?",
"user_group_deleted_successfully": "User group deleted successfully",
"user_count": "User Count",
"user_group_updated_successfully": "User group updated successfully",
"users_updated_successfully": "Users updated successfully",
"user_group_details_name": "User Group Details {name}",
"assign_users_to_this_group": "Assign users to this group.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts.",
"oidc_client_created_successfully": "OIDC client created successfully",
"create_oidc_client": "Create OIDC Client",
"add_a_new_oidc_client_to_appname": "Add a new OIDC client to {appName}.",
"add_oidc_client": "Add OIDC Client",
"manage_oidc_clients": "Manage OIDC Clients",
"one_time_link": "One Time Link",
"use_this_link_to_sign_in_once": "Use this link to sign in once. This is needed for users who haven't added a passkey yet or\n\t\t\t\thave lost it.",
"add": "Add",
"callback_urls": "Callback URLs",
"logout_callback_urls": "Logout Callback URLs",
"public_client": "Public Client",
"public_clients_do_not_have_a_client_secret_and_use_pkce_instead": "Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks.",
"name_logo": "{name} logo",
"change_logo": "Change Logo",
"upload_logo": "Upload Logo",
"remove_logo": "Remove Logo",
"are_you_sure_you_want_to_delete_this_oidc_client": "Are you sure you want to delete this OIDC client?",
"oidc_client_deleted_successfully": "OIDC client deleted successfully",
"authorization_url": "Authorization URL",
"oidc_discovery_url": "OIDC Discovery URL",
"token_url": "Token URL",
"userinfo_url": "Userinfo URL",
"logout_url": "Logout URL",
"certificate_url": "Certificate URL",
"enabled": "Enabled",
"disabled": "Disabled",
"oidc_client_updated_successfully": "OIDC client updated successfully",
"create_new_client_secret": "Create new client secret",
"are_you_sure_you_want_to_create_a_new_client_secret": "Are you sure you want to create a new client secret? The old one will be invalidated.",
"generate": "Generate",
"new_client_secret_created_successfully": "New client secret created successfully",
"allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
"oidc_client_name": "OIDC Client {name}",
"client_id": "Client ID",
"client_secret": "Client secret",
"show_more_details": "Show more details",
"allowed_user_groups": "Allowed User Groups",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client.",
"favicon": "Favicon",
"light_mode_logo": "Light Mode Logo",
"dark_mode_logo": "Dark Mode Logo",
"background_image": "Background Image",
"language": "Language",
"reset_profile_picture_question": "Reset profile picture?",
"this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default": "This will remove the uploaded image, and reset the profile picture to default. Do you want to continue?",
"reset": "Reset",
"reset_to_default": "Reset to default",
"profile_picture_has_been_reset": "Profile picture has been reset. It may take a few minutes to update.",
"select_the_language_you_want_to_use": "Select the language you want to use. Some languages may not be fully translated."
}

313
frontend/messages/nl.json Normal file
View File

@@ -0,0 +1,313 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "Mijn Account",
"logout": "Uitloggen",
"english": "English",
"dutch": "Nederlands",
"confirm": "Bevestigen",
"key": "Sleutel",
"value": "Waarde",
"remove_custom_claim": "Aangepaste claim verwijderen",
"add_custom_claim": "Aangepaste claim toevoegen",
"add_another": "Voeg nog een toe",
"select_a_date": "Selecteer een datum",
"select_file": "Selecteer bestand",
"profile_picture": "Profielfoto",
"profile_picture_is_managed_by_ldap_server": "De profielfoto wordt beheerd door de LDAP-server en kan hier niet worden gewijzigd.",
"click_profile_picture_to_upload_custom": "Klik op de profielfoto om een aangepaste foto uit uw bestanden te uploaden.",
"image_should_be_in_format": "De afbeelding moet in PNG- of JPEG-formaat zijn.",
"items_per_page": "Aantal per pagina",
"no_items_found": "Geen items gevonden",
"search": "Zoekopdracht...",
"expand_card": "Kaart uitbreiden",
"copied": "Gekopieerd",
"click_to_copy": "Klik om te kopiëren",
"something_went_wrong": "Er is iets misgegaan",
"go_back_to_home": "Ga terug naar huis",
"dont_have_access_to_your_passkey": "Hebt u geen toegang tot uw toegangscode?",
"login_background": "Inlogachtergrond",
"logo": "Logo",
"login_code": "Inlogcode",
"create_a_login_code_to_sign_in_without_a_passkey_once": "Maak een inlogcode aan waarmee de gebruiker zich eenmalig kan aanmelden zonder passkey.",
"one_hour": "1 uur",
"twelve_hours": "12 uur",
"one_day": "1 dag",
"one_week": "1 week",
"one_month": "1 maand",
"expiration": "Vervaldatum",
"generate_code": "Genereer code",
"name": "Naam",
"browser_unsupported": "Browser niet ondersteund",
"this_browser_does_not_support_passkeys": "Deze browser ondersteunt geen passkeys. Gebruik een alternatieve aanmeldmethode.",
"an_unknown_error_occurred": "Er is een onbekende fout opgetreden",
"authentication_process_was_aborted": "Het authenticatieproces is afgebroken",
"error_occurred_with_authenticator": "Er is een fout opgetreden met de authenticator",
"authenticator_does_not_support_discoverable_credentials": "De authenticator ondersteunt geen vindbare referenties",
"authenticator_does_not_support_resident_keys": "De authenticator ondersteunt geen residente sleutels",
"passkey_was_previously_registered": "Deze toegangscode is eerder geregistreerd",
"authenticator_does_not_support_any_of_the_requested_algorithms": "De authenticator ondersteunt geen van de gevraagde algoritmen",
"authenticator_timed_out": "De authenticator is verlopen",
"critical_error_occurred_contact_administrator": "Er is een kritieke fout opgetreden. Neem contact op met uw beheerder.",
"sign_in_to": "Meld u aan bij {name}",
"client_not_found": "Client niet gevonden",
"client_wants_to_access_the_following_information": "<b>{client}</b> wil toegang tot de volgende informatie:",
"do_you_want_to_sign_in_to_client_with_your_app_name_account": "Wilt u zich aanmelden bij <b>{client}</b> met uw <b>{appName}</b> account?",
"email": "E-mail",
"view_your_email_address": "Bekijk uw e-mailadres",
"profile": "Profiel",
"view_your_profile_information": "Bekijk uw profielgegevens",
"groups": "Groepen",
"view_the_groups_you_are_a_member_of": "Bekijk de groepen waarvan u lid bent",
"cancel": "Annuleren",
"sign_in": "Aanmelden",
"try_again": "Probeer het opnieuw",
"client_logo": "Client logo",
"sign_out": "Afmelden",
"do_you_want_to_sign_out_of_pocketid_with_the_account": "Wilt u zich afmelden bij Pocket ID met het account <b>{username}</b> ?",
"sign_in_to_appname": "Meld u aan bij {appName}",
"please_try_to_sign_in_again": "Probeer opnieuw in te loggen.",
"authenticate_yourself_with_your_passkey_to_access_the_admin_panel": "Verifieer uzelf met uw toegangscode om toegang te krijgen tot het beheerderspaneel.",
"authenticate": "Authenticeren",
"appname_setup": "{appName} Instellen",
"please_try_again": "Probeer het opnieuw.",
"you_are_about_to_sign_in_to_the_initial_admin_account": "U staat op het punt om in te loggen op het oorspronkelijke beheerdersaccount. Iedereen met deze link heeft toegang tot het account totdat er een passkey is toegevoegd. Stel zo snel mogelijk een passkey in om ongeautoriseerde toegang te voorkomen.",
"continue": "Doorgaan",
"alternative_sign_in": "Alternatieve aanmelding",
"if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods": "Als u geen toegang hebt tot uw toegangscode, kunt u zich op een van de volgende manieren aanmelden.",
"use_your_passkey_instead": "Wilt u in plaats daarvan uw toegangscode gebruiken?",
"email_login": "E-mail inloggen",
"enter_a_login_code_to_sign_in": "Voer een inlogcode in om in te loggen.",
"request_a_login_code_via_email": "Vraag een inlogcode aan via e-mail.",
"go_back": "Ga terug",
"an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system": "Er is een e-mail verzonden naar het opgegeven e-mailadres, indien dit in het systeem voorkomt.",
"enter_code": "Voer code in",
"enter_your_email_address_to_receive_an_email_with_a_login_code": "Voer uw e-mailadres in om een e-mail met een inlogcode te ontvangen.",
"your_email": "Uw e-mail",
"submit": "Indienen",
"enter_the_code_you_received_to_sign_in": "Voer de code in die u hebt ontvangen om in te loggen.",
"code": "Code",
"invalid_redirect_url": "Ongeldige omleidings-URL",
"audit_log": "Audit logboek",
"users": "Gebruikers",
"user_groups": "Gebruikersgroepen",
"oidc_clients": "OIDC-clients",
"api_keys": "API-sleutels",
"application_configuration": "Toepassingsconfiguratie",
"settings": "Instellingen",
"update_pocket_id": "Pocket-ID bijwerken",
"powered_by": "Aangedreven door",
"see_your_account_activities_from_the_last_3_months": "Bekijk uw accountactiviteiten van de afgelopen 3 maanden.",
"time": "Tijd",
"event": "Evenement",
"approximate_location": "Geschatte locatie",
"ip_address": "IP-adres",
"device": "Apparaat",
"client": "Cliënt",
"unknown": "Onbekend",
"account_details_updated_successfully": "Accountgegevens succesvol bijgewerkt",
"profile_picture_updated_successfully": "Profielfoto succesvol bijgewerkt. Het kan enkele minuten duren voordat de wijzigingen zichtbaar zijn.",
"account_settings": "Accountinstellingen",
"passkey_missing": "Passkey ontbreekt",
"please_provide_a_passkey_to_prevent_losing_access_to_your_account": "Voeg een passkey toe om te voorkomen dat u de toegang tot uw account verliest.",
"single_passkey_configured": "Eén enkele toegangscode geconfigureerd",
"it_is_recommended_to_add_more_than_one_passkey": "Het is raadzaam om meer dan één toegangscode toe te voegen om te voorkomen dat u de toegang tot uw account verliest.",
"account_details": "Accountgegevens",
"passkeys": "Toegangscodes",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Beheer de toegangscodes waarmee u uzelf kunt verifiëren.",
"add_passkey": "Passkey toevoegen",
"create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey": "Maak een eenmalige inlogcode aan om in te loggen vanaf een ander apparaat zonder passkey.",
"create": "Creëren",
"first_name": "Voornaam",
"last_name": "Achternaam",
"username": "Gebruikersnaam",
"save": "Opslaan",
"username_can_only_contain": "Gebruikersnaam mag alleen kleine letters, cijfers, onderstrepingstekens, punten, koppeltekens en '@'-symbolen bevatten",
"sign_in_using_the_following_code_the_code_will_expire_in_minutes": "Meld u aan met de volgende code. De code verloopt over 15 minuten.",
"or_visit": "of bezoek",
"added_on": "Toegevoegd op",
"rename": "Hernoemen",
"delete": "Verwijderen",
"are_you_sure_you_want_to_delete_this_passkey": "Weet u zeker dat u deze toegangscode wilt verwijderen?",
"passkey_deleted_successfully": "Passkey succesvol verwijderd",
"delete_passkey_name": "Verwijder {passkeyName}",
"passkey_name_updated_successfully": "Passkey naam succesvol bijgewerkt",
"name_passkey": "Naam Passkey",
"name_your_passkey_to_easily_identify_it_later": "Geef uw toegangscode een naam, zodat u deze later gemakkelijk kunt terugvinden.",
"create_api_key": "API-sleutel aanmaken",
"add_a_new_api_key_for_programmatic_access": "Voeg een nieuwe API-sleutel toe voor programmatische toegang.",
"add_api_key": "API-sleutel toevoegen",
"manage_api_keys": "API-sleutels beheren",
"api_key_created": "API-sleutel gemaakt",
"for_security_reasons_this_key_will_only_be_shown_once": "Om veiligheidsredenen wordt deze sleutel slechts één keer getoond. Bewaar hem veilig.",
"description": "Beschrijving",
"api_key": "API-sleutel",
"close": "Dichtbij",
"name_to_identify_this_api_key": "Naam om deze API-sleutel te identificeren.",
"expires_at": "Verloopt op",
"when_this_api_key_will_expire": "Wanneer deze API-sleutel verloopt.",
"optional_description_to_help_identify_this_keys_purpose": "Optionele beschrijving om het doel van deze sleutel te helpen identificeren.",
"name_must_be_at_least_3_characters": "Naam moet minimaal 3 tekens lang zijn",
"name_cannot_exceed_50_characters": "Naam mag niet langer zijn dan 50 tekens",
"expiration_date_must_be_in_the_future": "Vervaldatum moet in de toekomst liggen",
"revoke_api_key": "API-sleutel intrekken",
"never": "Nooit",
"revoke": "Herroepen",
"api_key_revoked_successfully": "API-sleutel succesvol ingetrokken",
"are_you_sure_you_want_to_revoke_the_api_key_apikeyname": "Weet u zeker dat u de API-sleutel \" {apiKeyName} \" wilt intrekken? Hiermee worden alle integraties die deze sleutel gebruiken, verbroken.",
"last_used": "Laatst gebruikt",
"actions": "Acties",
"images_updated_successfully": "Afbeeldingen succesvol bijgewerkt",
"general": "Algemeen",
"enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location": "Schakel e-mailmeldingen in om gebruikers te waarschuwen wanneer er wordt ingelogd vanaf een nieuw apparaat of een nieuwe locatie.",
"ldap": "LDAP",
"configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server": "Configureer LDAP-instellingen om gebruikers en groepen vanaf een LDAP-server te synchroniseren.",
"images": "Afbeeldingen",
"update": "Update",
"email_configuration_updated_successfully": "E-mailconfiguratie succesvol bijgewerkt",
"save_changes_question": "Wijzigingen opslaan?",
"you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now": "U moet de wijzigingen opslaan voordat u een test-e-mail verzendt. Wilt u nu opslaan?",
"save_and_send": "Opslaan en verzenden",
"test_email_sent_successfully": "Test-e-mail succesvol verzonden naar uw e-mailadres.",
"failed_to_send_test_email": "Het is niet gelukt om een test-e-mail te versturen. Controleer de serverlogs voor meer informatie.",
"smtp_configuration": "SMTP-configuratie",
"smtp_host": "SMTP-host",
"smtp_port": "SMTP-poort",
"smtp_user": "SMTP-gebruiker",
"smtp_password": "SMTP-wachtwoord",
"smtp_from": "SMTP van",
"smtp_tls_option": "SMTP TLS-optie",
"email_tls_option": "E-mail TLS-optie",
"skip_certificate_verification": "Certificaatverificatie overslaan",
"this_can_be_useful_for_selfsigned_certificates": "Dit kan handig zijn voor zelfondertekende certificaten.",
"enabled_emails": "Ingeschakelde e-mails",
"email_login_notification": "E-mail-inlogmelding",
"send_an_email_to_the_user_when_they_log_in_from_a_new_device": "Stuur een e-mail naar de gebruiker wanneer deze zich aanmeldt vanaf een nieuw apparaat.",
"allow_users_to_sign_in_with_a_login_code_sent_to_their_email": "Hiermee kunnen gebruikers inloggen met een inlogcode die naar hun e-mail is gestuurd. Dit vermindert de beveiliging aanzienlijk, omdat iedereen met toegang tot de e-mail van de gebruiker toegang kan krijgen.",
"send_test_email": "Test-e-mail verzenden",
"application_configuration_updated_successfully": "Toepassingsconfiguratie succesvol bijgewerkt",
"application_name": "Toepassingsnaam",
"session_duration": "Sessieduur",
"the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again": "De duur van een sessie in minuten voordat de gebruiker zich opnieuw moet aanmelden.",
"enable_self_account_editing": "Zelf-accountbewerking inschakelen",
"whether_the_users_should_be_able_to_edit_their_own_account_details": "Of gebruikers hun eigen accountgegevens moeten kunnen bewerken.",
"emails_verified": "E-mails geverifieerd",
"whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients": "Of het e-mailadres van de gebruiker als geverifieerd moet worden gemarkeerd voor de OIDC-clients.",
"ldap_configuration_updated_successfully": "LDAP-configuratie succesvol bijgewerkt",
"ldap_disabled_successfully": "LDAP succesvol uitgeschakeld",
"ldap_sync_finished": "LDAP-synchronisatie voltooid",
"client_configuration": "Clientconfiguratie",
"ldap_url": "LDAP-URL",
"ldap_bind_dn": "LDAP Bind-DN",
"ldap_bind_password": "LDAP Bind-wachtwoord",
"ldap_base_dn": "LDAP-basis-DN",
"user_search_filter": "Gebruikerszoekfilter",
"the_search_filter_to_use_to_search_or_sync_users": "Het zoekfilter waarmee u gebruikers kunt zoeken/synchroniseren.",
"groups_search_filter": "Groepen Zoekfilter",
"the_search_filter_to_use_to_search_or_sync_groups": "Het zoekfilter waarmee u groepen kunt zoeken/synchroniseren.",
"attribute_mapping": "Attribuuttoewijzing",
"user_unique_identifier_attribute": "Gebruiker uniek identificatiekenmerk",
"the_value_of_this_attribute_should_never_change": "De waarde van dit kenmerk mag nooit veranderen.",
"username_attribute": "Gebruikersnaam Attribuut",
"user_mail_attribute": "Gebruikersmailkenmerk",
"user_first_name_attribute": "Gebruikersvoornaam Attribuut",
"user_last_name_attribute": "Gebruikersnaam Achternaam Attribuut",
"user_profile_picture_attribute": "Gebruikersprofielfoto-attribuut",
"the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image": "De waarde van dit kenmerk kan een URL, een binair bestand of een base64-gecodeerde afbeelding zijn.",
"group_members_attribute": "Groepsleden Attribuut",
"the_attribute_to_use_for_querying_members_of_a_group": "Het kenmerk dat gebruikt moet worden om leden van een groep te bevragen.",
"group_unique_identifier_attribute": "Groeps uniek identificatiekenmerk",
"group_name_attribute": "Groepsnaam Attribuut",
"admin_group_name": "Naam van beheerdersgroep",
"members_of_this_group_will_have_admin_privileges_in_pocketid": "Leden van deze groep hebben beheerdersrechten in Pocket ID.",
"disable": "Uitzetten",
"sync_now": "Nu synchroniseren",
"enable": "Inschakelen",
"user_created_successfully": "Gebruiker succesvol aangemaakt",
"create_user": "Gebruiker aanmaken",
"add_a_new_user_to_appname": "Voeg een nieuwe gebruiker toe aan {appName}",
"add_user": "Gebruiker toevoegen",
"manage_users": "Gebruikers beheren",
"admin_privileges": "Beheerdersrechten",
"admins_have_full_access_to_the_admin_panel": "Beheerders hebben volledige toegang tot het beheerderspaneel.",
"delete_firstname_lastname": "Verwijderen {firstName} {lastName}",
"are_you_sure_you_want_to_delete_this_user": "Weet u zeker dat u deze gebruiker wilt verwijderen?",
"user_deleted_successfully": "Gebruiker succesvol verwijderd",
"role": "Rol",
"source": "Bron",
"admin": "Beheerder",
"user": "Gebruiker",
"local": "Lokaal",
"toggle_menu": "Menu wisselen",
"edit": "Bewerking",
"user_groups_updated_successfully": "Gebruikersgroepen succesvol bijgewerkt",
"user_updated_successfully": "Gebruiker succesvol bijgewerkt",
"custom_claims_updated_successfully": "Aangepaste claims succesvol bijgewerkt",
"back": "Terug",
"user_details_firstname_lastname": "Gebruikersgegevens {firstName} {lastName}",
"manage_which_groups_this_user_belongs_to": "Beheer tot welke groepen deze gebruiker behoort.",
"custom_claims": "Aangepaste claims",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user": "Aangepaste claims zijn sleutel-waardeparen die kunnen worden gebruikt om aanvullende informatie over een gebruiker op te slaan. Deze claims worden opgenomen in het ID-token als de scope 'profile' wordt aangevraagd.",
"user_group_created_successfully": "Gebruikersgroep succesvol aangemaakt",
"create_user_group": "Gebruikersgroep aanmaken",
"create_a_new_group_that_can_be_assigned_to_users": "Maak een nieuwe groep aan die aan gebruikers kan worden toegewezen.",
"add_group": "Groep toevoegen",
"manage_user_groups": "Gebruikersgroepen beheren",
"friendly_name": "Vriendelijke naam",
"name_that_will_be_displayed_in_the_ui": "Naam die in de gebruikersinterface wordt weergegeven",
"name_that_will_be_in_the_groups_claim": "Naam die in de claim 'groepen' zal staan",
"delete_name": "Verwijder {name}",
"are_you_sure_you_want_to_delete_this_user_group": "Weet u zeker dat u deze gebruikersgroep wilt verwijderen?",
"user_group_deleted_successfully": "Gebruikersgroep succesvol verwijderd",
"user_count": "Gebruikersaantal",
"user_group_updated_successfully": "Gebruikersgroep succesvol bijgewerkt",
"users_updated_successfully": "Gebruikers succesvol bijgewerkt",
"user_group_details_name": "Gebruikersgroepdetails {name}",
"assign_users_to_this_group": "Gebruikers aan deze groep toewijzen.",
"custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized": "Aangepaste claims zijn sleutel-waardeparen die kunnen worden gebruikt om aanvullende informatie over een gebruiker op te slaan. Deze claims worden opgenomen in het ID-token als de scope 'profile' wordt opgevraagd. Aangepaste claims die zijn gedefinieerd voor de gebruiker, krijgen prioriteit als er conflicten zijn.",
"oidc_client_created_successfully": "OIDC-client succesvol aangemaakt",
"create_oidc_client": "Maak een OIDC-client",
"add_a_new_oidc_client_to_appname": "Voeg een nieuwe OIDC-client toe aan {appName} .",
"add_oidc_client": "OIDC-client toevoegen",
"manage_oidc_clients": "OIDC-clients beheren",
"one_time_link": "Eenmalige link",
"use_this_link_to_sign_in_once": "Gebruik deze link om u eenmalig aan te melden. Dit is nodig voor gebruikers die nog geen passkey hebben toegevoegd of\nben het kwijt.",
"add": "Toevoegen",
"callback_urls": "Callback-URL's",
"logout_callback_urls": "Callback-URL's voor afmelden",
"public_client": "Publieke client",
"public_clients_do_not_have_a_client_secret_and_use_pkce_instead": "Publieke clients hebben geen client secret en gebruiken in plaats daarvan PKCE. Schakel dit in als uw client een SPA of mobiele app is.",
"pkce": "PKCE",
"public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks": "Public Key Code Exchange is een beveiligingsfunctie om CSRF- en autorisatiecode-onderscheppingsaanvallen te voorkomen.",
"name_logo": "{name} logo",
"change_logo": "Logo wijzigen",
"upload_logo": "Logo uploaden",
"remove_logo": "Logo verwijderen",
"are_you_sure_you_want_to_delete_this_oidc_client": "Weet u zeker dat u deze OIDC-client wilt verwijderen?",
"oidc_client_deleted_successfully": "OIDC-client succesvol verwijderd",
"authorization_url": "Autorisatie-URL",
"oidc_discovery_url": "OIDC-ontdekkings-URL",
"token_url": "Token-URL",
"userinfo_url": "Gebruikersinfo-URL",
"logout_url": "Uitlog-URL",
"certificate_url": "Certificaat-URL",
"enabled": "Ingeschakeld",
"disabled": "Uitgeschakeld",
"oidc_client_updated_successfully": "OIDC-client succesvol bijgewerkt",
"create_new_client_secret": "Nieuw clientgeheim aanmaken",
"are_you_sure_you_want_to_create_a_new_client_secret": "Weet u zeker dat u een nieuw client secret wilt aanmaken? De oude wordt ongeldig.",
"generate": "Genereren",
"new_client_secret_created_successfully": "Nieuw clientgeheim succesvol aangemaakt",
"allowed_user_groups_updated_successfully": "Toegestane gebruikersgroepen succesvol bijgewerkt",
"oidc_client_name": "OIDC-client {name}",
"client_id": "Client id",
"client_secret": "Client geheim",
"show_more_details": "Meer details weergeven",
"allowed_user_groups": "Toegestane gebruikersgroepen",
"add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups": "Voeg gebruikersgroepen toe aan deze client om de toegang tot gebruikers in deze groepen te beperken. Als er geen gebruikersgroepen zijn geselecteerd, hebben alle gebruikers toegang tot deze client.",
"favicon": "Favicon",
"light_mode_logo": "Lichte modus logo",
"dark_mode_logo": "Donkere modus logo",
"background_image": "Achtergrondfoto",
"language": "Taal",
"profile_picture_has_been_reset": "Profielfoto is gereset. Het kan enkele minuten duren voordat de wijzigingen zichtbaar zijn."
}

View File

@@ -1,12 +1,12 @@
{ {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.39.0", "version": "0.42.1",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pocket-id-frontend", "name": "pocket-id-frontend",
"version": "0.39.0", "version": "0.42.1",
"dependencies": { "dependencies": {
"@simplewebauthn/browser": "^13.1.0", "@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
@@ -25,6 +25,7 @@
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@inlang/paraglide-js": "^2.0.0",
"@internationalized/date": "^3.7.0", "@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0", "@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",
@@ -747,6 +748,78 @@
"url": "https://github.com/sponsors/nzakas" "url": "https://github.com/sponsors/nzakas"
} }
}, },
"node_modules/@inlang/paraglide-js": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@inlang/paraglide-js/-/paraglide-js-2.0.0.tgz",
"integrity": "sha512-ufe/k4tfBIQrJf6X1L+KGtvHYRhvDPX53m7vVe+IOYs0DkyR7RkBgwPBQb3kbXKpr5atCD+D2BDh/I7EpK5Clg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@inlang/recommend-sherlock": "0.2.1",
"@inlang/sdk": "2.4.2",
"commander": "11.1.0",
"consola": "3.4.0",
"json5": "2.2.3",
"unplugin": "^2.1.2",
"urlpattern-polyfill": "^10.0.0"
},
"bin": {
"paraglide-js": "bin/run.js"
}
},
"node_modules/@inlang/paraglide-js/node_modules/@inlang/sdk": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@inlang/sdk/-/sdk-2.4.2.tgz",
"integrity": "sha512-EqL32PcFHOlXWEg2o0nftSBZ376tSxuAhV8uTZoaq521AKSRMEvjTpVsJ9eS6ZJDCRiIXx7avtsdVNwkUntf8A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@lix-js/sdk": "0.4.2",
"@sinclair/typebox": "^0.31.17",
"kysely": "^0.27.4",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^10.0.0"
},
"engines": {
"node": ">=18.0.0"
}
},
"node_modules/@inlang/paraglide-js/node_modules/@lix-js/sdk": {
"version": "0.4.2",
"resolved": "https://registry.npmjs.org/@lix-js/sdk/-/sdk-0.4.2.tgz",
"integrity": "sha512-wrQQMAZzOxQEAssxUnajn7Djua98MlIzs+V6GdX51VN6b7iA3qvZJY4L9xEEMh0nRFvpAO3wOt7uBth9580pog==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@lix-js/server-api-schema": "0.1.1",
"dedent": "1.5.1",
"human-id": "^4.1.1",
"js-sha256": "^0.11.0",
"kysely": "^0.27.4",
"sqlite-wasm-kysely": "0.3.0",
"uuid": "^10.0.0"
},
"engines": {
"node": ">=21"
}
},
"node_modules/@inlang/paraglide-js/node_modules/@sinclair/typebox": {
"version": "0.31.28",
"resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.31.28.tgz",
"integrity": "sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@inlang/recommend-sherlock": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@inlang/recommend-sherlock/-/recommend-sherlock-0.2.1.tgz",
"integrity": "sha512-ckv8HvHy/iTqaVAEKrr+gnl+p3XFNwe5D2+6w6wJk2ORV2XkcRkKOJ/XsTUJbPSiyi4PI+p+T3bqbmNx/rDUlg==",
"dev": true,
"license": "MIT",
"dependencies": {
"comment-json": "^4.2.3"
}
},
"node_modules/@internationalized/date": { "node_modules/@internationalized/date": {
"version": "3.7.0", "version": "3.7.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.7.0.tgz", "resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.7.0.tgz",
@@ -798,6 +871,13 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lix-js/server-api-schema": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/@lix-js/server-api-schema/-/server-api-schema-0.1.1.tgz",
"integrity": "sha512-W1Z7KKOxAQ4Dag9V2wrDevHPh5rPk+icBUsxNfNCNB2tlPrKpba99562vcTCPoT03KXpihEbWutZNujCRtMA+g==",
"dev": true,
"license": "Apache-2.0"
},
"node_modules/@nodelib/fs.scandir": { "node_modules/@nodelib/fs.scandir": {
"version": "2.1.5", "version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1214,6 +1294,16 @@
"integrity": "sha512-TJ7Al17j3+by5y2QkTLcF/oBVMbgXBhILVgi9PuwpxQVZZvGh5BFRzWbJPmZVNKpbRLjuMzFuRwR+tdFPqCkvA==", "integrity": "sha512-TJ7Al17j3+by5y2QkTLcF/oBVMbgXBhILVgi9PuwpxQVZZvGh5BFRzWbJPmZVNKpbRLjuMzFuRwR+tdFPqCkvA==",
"optional": true "optional": true
}, },
"node_modules/@sqlite.org/sqlite-wasm": {
"version": "3.48.0-build4",
"resolved": "https://registry.npmjs.org/@sqlite.org/sqlite-wasm/-/sqlite-wasm-3.48.0-build4.tgz",
"integrity": "sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"sqlite-wasm": "bin/index.js"
}
},
"node_modules/@sveltejs/adapter-auto": { "node_modules/@sveltejs/adapter-auto": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-4.0.0.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-4.0.0.tgz",
@@ -1909,6 +1999,13 @@
"@ark/util": "0.38.0" "@ark/util": "0.38.0"
} }
}, },
"node_modules/array-timsort": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/array-timsort/-/array-timsort-1.0.3.tgz",
"integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==",
"dev": true,
"license": "MIT"
},
"node_modules/asynckit": { "node_modules/asynckit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2099,6 +2196,33 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/commander": {
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/commander/-/commander-11.1.0.tgz",
"integrity": "sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=16"
}
},
"node_modules/comment-json": {
"version": "4.2.5",
"resolved": "https://registry.npmjs.org/comment-json/-/comment-json-4.2.5.tgz",
"integrity": "sha512-bKw/r35jR3HGt5PEPm1ljsQQGyCrR8sFGNiN5L+ykDHdpO8Smxkrkla9Yi6NkQyUrb8V54PGhfMs6NrIwtxtdw==",
"dev": true,
"license": "MIT",
"dependencies": {
"array-timsort": "^1.0.3",
"core-util-is": "^1.0.3",
"esprima": "^4.0.1",
"has-own-prop": "^2.0.0",
"repeat-string": "^1.6.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/commondir": { "node_modules/commondir": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -2111,6 +2235,16 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true "dev": true
}, },
"node_modules/consola": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/consola/-/consola-3.4.0.tgz",
"integrity": "sha512-EiPU8G6dQG0GFHNR8ljnZFki/8a+cQwEQ+7wpxdChl02Q8HXlwEZWD5lqAF8vC2sEC3Tehr8hy7vErz88LHyUA==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.18.0 || >=16.10.0"
}
},
"node_modules/cookie": { "node_modules/cookie": {
"version": "0.6.0", "version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -2119,6 +2253,13 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"dev": true,
"license": "MIT"
},
"node_modules/cross-spawn": { "node_modules/cross-spawn": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -2173,6 +2314,21 @@
} }
} }
}, },
"node_modules/dedent": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/dedent/-/dedent-1.5.1.tgz",
"integrity": "sha512-+LxW+KLWxu3HW3M2w2ympwtqPrqYRzU8fqi6Fhd18fBALe15blJPI/I4+UHveMVG6lJqB4JNd4UG0S5cnVHwIg==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"babel-plugin-macros": "^3.1.0"
},
"peerDependenciesMeta": {
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/deep-is": { "node_modules/deep-is": {
"version": "0.1.4", "version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2490,6 +2646,20 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": { "node_modules/esquery": {
"version": "1.6.0", "version": "1.6.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz", "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
@@ -2814,6 +2984,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/has-own-prop": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/has-own-prop/-/has-own-prop-2.0.0.tgz",
"integrity": "sha512-Pq0h+hvsVm6dDEa8x82GnLSYHOzNDt7f0ddFa3FqcQlgzEiptPqL+XrOJNavjOzSYiYWIrgeVYYgGlLmnxwilQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/hasown": { "node_modules/hasown": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -2826,6 +3006,16 @@
"node": ">= 0.4" "node": ">= 0.4"
} }
}, },
"node_modules/human-id": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/human-id/-/human-id-4.1.1.tgz",
"integrity": "sha512-3gKm/gCSUipeLsRYZbbdA1BD83lBoWUkZ7G9VFrhWPAU76KwYo5KR8V28bpoPm/ygy0x5/GCbpRQdY7VLYCoIg==",
"dev": true,
"license": "MIT",
"bin": {
"human-id": "dist/cli.js"
}
},
"node_modules/ignore": { "node_modules/ignore": {
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2964,6 +3154,13 @@
"url": "https://github.com/sponsors/panva" "url": "https://github.com/sponsors/panva"
} }
}, },
"node_modules/js-sha256": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.11.0.tgz",
"integrity": "sha512-6xNlKayMZvds9h1Y1VWc0fQHQ82BxTXizWPEtEeGvmOUYpBRy4gbWroHLpzowe6xiQhHpelCQiE7HEdznyBL9Q==",
"dev": true,
"license": "MIT"
},
"node_modules/js-yaml": { "node_modules/js-yaml": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@@ -3007,6 +3204,19 @@
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"dev": true "dev": true
}, },
"node_modules/json5": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
"integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
"dev": true,
"license": "MIT",
"bin": {
"json5": "lib/cli.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/keyv": { "node_modules/keyv": {
"version": "4.5.4", "version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3030,6 +3240,16 @@
"integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==", "integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==",
"dev": true "dev": true
}, },
"node_modules/kysely": {
"version": "0.27.6",
"resolved": "https://registry.npmjs.org/kysely/-/kysely-0.27.6.tgz",
"integrity": "sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/levn": { "node_modules/levn": {
"version": "0.4.1", "version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -3906,6 +4126,16 @@
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"optional": true "optional": true
}, },
"node_modules/repeat-string": {
"version": "1.6.1",
"resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz",
"integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10"
}
},
"node_modules/resolve": { "node_modules/resolve": {
"version": "1.22.10", "version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -4094,6 +4324,18 @@
"source-map": "^0.6.0" "source-map": "^0.6.0"
} }
}, },
"node_modules/sqlite-wasm-kysely": {
"version": "0.3.0",
"resolved": "https://registry.npmjs.org/sqlite-wasm-kysely/-/sqlite-wasm-kysely-0.3.0.tgz",
"integrity": "sha512-TzjBNv7KwRw6E3pdKdlRyZiTmUIE0UttT/Sl56MVwVARl/u5gp978KepazCJZewFUnlWHz9i3NQd4kOtP/Afdg==",
"dev": true,
"dependencies": {
"@sqlite.org/sqlite-wasm": "^3.48.0-build2"
},
"peerDependencies": {
"kysely": "*"
}
},
"node_modules/strip-json-comments": { "node_modules/strip-json-comments": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -4548,6 +4790,20 @@
"integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==", "integrity": "sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg==",
"devOptional": true "devOptional": true
}, },
"node_modules/unplugin": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/unplugin/-/unplugin-2.2.0.tgz",
"integrity": "sha512-m1ekpSwuOT5hxkJeZGRxO7gXbXT3gF26NjQ7GdVHoLoF8/nopLcd/QfPigpCy7i51oFHiRJg/CyHhj4vs2+KGw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.14.0",
"webpack-virtual-modules": "^0.6.2"
},
"engines": {
"node": ">=18.12.0"
}
},
"node_modules/uri-js": { "node_modules/uri-js": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -4557,12 +4813,33 @@
"punycode": "^2.1.0" "punycode": "^2.1.0"
} }
}, },
"node_modules/urlpattern-polyfill": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/urlpattern-polyfill/-/urlpattern-polyfill-10.0.0.tgz",
"integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==",
"dev": true,
"license": "MIT"
},
"node_modules/util-deprecate": { "node_modules/util-deprecate": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true "dev": true
}, },
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"dev": true,
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/valibot": { "node_modules/valibot": {
"version": "1.0.0-beta.11", "version": "1.0.0-beta.11",
"resolved": "https://registry.npmjs.org/valibot/-/valibot-1.0.0-beta.11.tgz", "resolved": "https://registry.npmjs.org/valibot/-/valibot-1.0.0-beta.11.tgz",
@@ -4687,6 +4964,13 @@
} }
} }
}, },
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
"integrity": "sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==",
"dev": true,
"license": "MIT"
},
"node_modules/which": { "node_modules/which": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -30,6 +30,7 @@
"zod": "^3.24.1" "zod": "^3.24.1"
}, },
"devDependencies": { "devDependencies": {
"@inlang/paraglide-js": "^2.0.0",
"@internationalized/date": "^3.7.0", "@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0", "@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0", "@sveltejs/adapter-auto": "^4.0.0",

1
frontend/project.inlang/.gitignore vendored Normal file
View File

@@ -0,0 +1 @@
cache

View File

@@ -0,0 +1 @@
O2jvFph6P4Jpehf2BT

View File

@@ -0,0 +1,15 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": [
"en",
"nl"
],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4/dist/index.js",
"https://cdn.jsdelivr.net/npm/@inlang/plugin-m-function-matcher@2/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}

View File

@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="%lang%">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="/api/application-configuration/favicon" /> <link rel="icon" href="/api/application-configuration/favicon" />

View File

@@ -1,6 +1,8 @@
import { env } from '$env/dynamic/private'; import { env } from '$env/dynamic/private';
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants'; import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import { paraglideMiddleware } from '$lib/paraglide/server';
import type { Handle, HandleServerError } from '@sveltejs/kit'; import type { Handle, HandleServerError } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { decodeJwt } from 'jose'; import { decodeJwt } from 'jose';
@@ -9,7 +11,16 @@ import { decodeJwt } from 'jose';
// this is still secure as process will just be undefined in the browser // this is still secure as process will just be undefined in the browser
process.env.INTERNAL_BACKEND_URL = env.INTERNAL_BACKEND_URL ?? 'http://localhost:8080'; process.env.INTERNAL_BACKEND_URL = env.INTERNAL_BACKEND_URL ?? 'http://localhost:8080';
export const handle: Handle = async ({ event, resolve }) => { // Handle to use the paraglide middleware
const paraglideHandle: Handle = ({ event, resolve }) => {
return paraglideMiddleware(event.request, ({ locale }) => {
return resolve(event, {
transformPageChunk: ({ html }) => html.replace('%lang%', locale)
});
});
};
const authenticationHandle: Handle = async ({ event, resolve }) => {
const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME)); const { isSignedIn, isAdmin } = verifyJwt(event.cookies.get(ACCESS_TOKEN_COOKIE_NAME));
const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/lc') const isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/lc')
@@ -43,6 +54,8 @@ export const handle: Handle = async ({ event, resolve }) => {
return response; return response;
}; };
export const handle: Handle = sequence(paraglideHandle, authenticationHandle);
export const handleError: HandleServerError = async ({ error, message, status }) => { export const handleError: HandleServerError = async ({ error, message, status }) => {
if (error instanceof AxiosError) { if (error instanceof AxiosError) {
message = error.response?.data.error || message; message = error.response?.data.error || message;

View File

@@ -11,6 +11,7 @@
import { ChevronDown } from 'lucide-svelte'; import { ChevronDown } from 'lucide-svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import Button from './ui/button/button.svelte'; import Button from './ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
let { let {
items, items,
@@ -93,7 +94,7 @@
'relative z-50 mb-4 max-w-sm', 'relative z-50 mb-4 max-w-sm',
items.data.length == 0 && searchValue == '' && 'hidden' items.data.length == 0 && searchValue == '' && 'hidden'
)} )}
placeholder={'Search...'} placeholder={m.search()}
type="text" type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)} oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/> />
@@ -102,7 +103,7 @@
{#if items.data.length === 0 && searchValue === ''} {#if items.data.length === 0 && searchValue === ''}
<div class="my-5 flex flex-col items-center"> <div class="my-5 flex flex-col items-center">
<Empty class="text-muted-foreground h-20" /> <Empty class="text-muted-foreground h-20" />
<p class="text-muted-foreground mt-3 text-sm">No items found</p> <p class="text-muted-foreground mt-3 text-sm">{m.no_items_found()}</p>
</div> </div>
{:else} {:else}
<Table.Root class="min-w-full table-auto overflow-x-auto"> <Table.Root class="min-w-full table-auto overflow-x-auto">
@@ -166,7 +167,7 @@
<div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row"> <div class="mt-5 flex flex-col-reverse items-center justify-between gap-3 sm:flex-row">
<div class="flex items-center space-x-2"> <div class="flex items-center space-x-2">
<p class="text-sm font-medium">Items per page</p> <p class="text-sm font-medium">{m.items_per_page()}</p>
<Select.Root <Select.Root
selected={{ selected={{
label: items.pagination.itemsPerPage.toString(), label: items.pagination.itemsPerPage.toString(),

View File

@@ -5,6 +5,7 @@
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import { Button } from './ui/button'; import { Button } from './ui/button';
import * as Card from './ui/card'; import * as Card from './ui/card';
import { m } from '$lib/paraglide/messages';
let { let {
id, id,
@@ -55,7 +56,7 @@
<Card.Description>{description}</Card.Description> <Card.Description>{description}</Card.Description>
{/if} {/if}
</div> </div>
<Button class="ml-10 h-8 p-3" variant="ghost" aria-label="Expand card"> <Button class="ml-10 h-8 p-3" variant="ghost" aria-label={m.expand_card()}>
<LucideChevronDown <LucideChevronDown
class={cn( class={cn(
'h-5 w-5 transition-transform duration-200', 'h-5 w-5 transition-transform duration-200',

View File

@@ -1,12 +1,13 @@
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
import ConfirmDialog from './confirm-dialog.svelte'; import ConfirmDialog from './confirm-dialog.svelte';
import { m } from '$lib/paraglide/messages';
export const confirmDialogStore = writable({ export const confirmDialogStore = writable({
open: false, open: false,
title: '', title: '',
message: '', message: '',
confirm: { confirm: {
label: 'Confirm', label: m.confirm(),
destructive: false, destructive: false,
action: () => {} action: () => {}
} }

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip'; import * as Tooltip from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages';
import { LucideCheck } from 'lucide-svelte'; import { LucideCheck } from 'lucide-svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
@@ -31,9 +32,9 @@
<Tooltip.Trigger class="text-start" tabindex={-1} onclick={onClick}>{@render children()}</Tooltip.Trigger> <Tooltip.Trigger class="text-start" tabindex={-1} onclick={onClick}>{@render children()}</Tooltip.Trigger>
<Tooltip.Content onclick={copyToClipboard}> <Tooltip.Content onclick={copyToClipboard}>
{#if copied} {#if copied}
<span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> Copied</span> <span class="flex items-center"><LucideCheck class="mr-1 h-4 w-4" /> {m.copied()}</span>
{:else} {:else}
<span>Click to copy</span> <span>{m.click_to_copy()}</span>
{/if} {/if}
</Tooltip.Content> </Tooltip.Content>
</Tooltip.Root> </Tooltip.Root>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import { LucideXCircle } from 'lucide-svelte'; import { LucideXCircle } from 'lucide-svelte';
let { message, showButton = true }: { message: string; showButton?: boolean } = $props(); let { message, showButton = true }: { message: string; showButton?: boolean } = $props();
@@ -7,9 +8,9 @@
<div class="mt-[20%] flex flex-col items-center"> <div class="mt-[20%] flex flex-col items-center">
<LucideXCircle class="h-12 w-12 text-muted-foreground" /> <LucideXCircle class="h-12 w-12 text-muted-foreground" />
<h1 class="mt-3 text-2xl font-semibold">Something went wrong</h1> <h1 class="mt-3 text-2xl font-semibold">{m.something_went_wrong()}</h1>
<p class="text-muted-foreground">{message}</p> <p class="text-muted-foreground">{message}</p>
{#if showButton} {#if showButton}
<Button size="sm" class="mt-5" href="/">Go back to home</Button> <Button size="sm" class="mt-5" href="/">{m.go_back_to_home()}</Button>
{/if} {/if}
</div> </div>

View File

@@ -8,6 +8,7 @@
import { onMount, type Snippet } from 'svelte'; import { onMount, type Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
import AutoCompleteInput from './auto-complete-input.svelte'; import AutoCompleteInput from './auto-complete-input.svelte';
import { m } from '$lib/paraglide/messages';
let { let {
customClaims = $bindable(), customClaims = $bindable(),
@@ -41,15 +42,15 @@
{#each customClaims as _, i} {#each customClaims as _, i}
<div class="flex gap-x-2"> <div class="flex gap-x-2">
<AutoCompleteInput <AutoCompleteInput
placeholder="Key" placeholder={m.key()}
suggestions={filteredSuggestions} suggestions={filteredSuggestions}
bind:value={customClaims[i].key} bind:value={customClaims[i].key}
/> />
<Input placeholder="Value" bind:value={customClaims[i].value} /> <Input placeholder={m.value()} bind:value={customClaims[i].value} />
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
aria-label="Remove custom claim" aria-label={m.remove_custom_claim()}
on:click={() => (customClaims = customClaims.filter((_, index) => index !== i))} on:click={() => (customClaims = customClaims.filter((_, index) => index !== i))}
> >
<LucideMinus class="h-4 w-4" /> <LucideMinus class="h-4 w-4" />
@@ -69,7 +70,7 @@
on:click={() => (customClaims = [...customClaims, { key: '', value: '' }])} on:click={() => (customClaims = [...customClaims, { key: '', value: '' }])}
> >
<LucidePlus class="mr-1 h-4 w-4" /> <LucidePlus class="mr-1 h-4 w-4" />
{customClaims.length === 0 ? 'Add custom claim' : 'Add another'} {customClaims.length === 0 ? m.add_custom_claim() : m.add_another()}
</Button> </Button>
{/if} {/if}
</div> </div>

View File

@@ -2,6 +2,8 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Calendar } from '$lib/components/ui/calendar'; import { Calendar } from '$lib/components/ui/calendar';
import * as Popover from '$lib/components/ui/popover'; import * as Popover from '$lib/components/ui/popover';
import { m } from '$lib/paraglide/messages';
import { getLocale } from '$lib/paraglide/runtime';
import { cn } from '$lib/utils/style'; import { cn } from '$lib/utils/style';
import { import {
CalendarDate, CalendarDate,
@@ -30,7 +32,7 @@
open = false; open = false;
} }
const df = new DateFormatter('en-US', { const df = new DateFormatter(getLocale(), {
dateStyle: 'long' dateStyle: 'long'
}); });
</script> </script>
@@ -44,7 +46,7 @@
builders={[builder]} builders={[builder]}
> >
<CalendarIcon class="mr-2 h-4 w-4" /> <CalendarIcon class="mr-2 h-4 w-4" />
{date ? df.format(date.toDate(getLocalTimeZone())) : 'Select a date'} {date ? df.format(date.toDate(getLocalTimeZone())) : m.select_a_date()}
</Button> </Button>
</Popover.Trigger> </Popover.Trigger>
<Popover.Content class="w-auto p-0" align="start"> <Popover.Content class="w-auto p-0" align="start">

View File

@@ -3,6 +3,7 @@
import type { HTMLInputAttributes } from 'svelte/elements'; import type { HTMLInputAttributes } from 'svelte/elements';
import type { VariantProps } from 'tailwind-variants'; import type { VariantProps } from 'tailwind-variants';
import type { buttonVariants } from '$lib/components/ui/button'; import type { buttonVariants } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
let { let {
id, id,
@@ -21,7 +22,7 @@
{#if restProps.children} {#if restProps.children}
{@render restProps.children()} {@render restProps.children()}
{:else} {:else}
Select File {m.select_file()}
{/if} {/if}
</button> </button>
<input {id} {...restProps} type="file" class="hidden" /> <input {id} {...restProps} type="file" class="hidden" />

View File

@@ -4,6 +4,7 @@
import Button from '$lib/components/ui/button/button.svelte'; import Button from '$lib/components/ui/button/button.svelte';
import { LucideLoader, LucideRefreshCw, LucideUpload } from 'lucide-svelte'; import { LucideLoader, LucideRefreshCw, LucideUpload } from 'lucide-svelte';
import { openConfirmDialog } from '../confirm-dialog'; import { openConfirmDialog } from '../confirm-dialog';
import { m } from '$lib/paraglide/messages';
let { let {
userId, userId,
@@ -40,11 +41,10 @@
function onReset() { function onReset() {
openConfirmDialog({ openConfirmDialog({
title: 'Reset profile picture?', title: m.reset_profile_picture_question(),
message: message: m.this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default(),
'This will remove the uploaded image, and reset the profile picture to default. Do you want to continue?',
confirm: { confirm: {
label: 'Reset', label: m.reset(),
action: async () => { action: async () => {
isLoading = true; isLoading = true;
await resetCallback().catch(); await resetCallback().catch();
@@ -58,16 +58,16 @@
<div class="flex gap-5"> <div class="flex gap-5">
<div class="flex w-full flex-col justify-between gap-5 sm:flex-row"> <div class="flex w-full flex-col justify-between gap-5 sm:flex-row">
<div> <div>
<h3 class="text-xl font-semibold">Profile Picture</h3> <h3 class="text-xl font-semibold">{m.profile_picture()}</h3>
{#if isLdapUser} {#if isLdapUser}
<p class="text-muted-foreground mt-1 text-sm"> <p class="text-muted-foreground mt-1 text-sm">
The profile picture is managed by the LDAP server and cannot be changed here. {m.profile_picture_is_managed_by_ldap_server()}
</p> </p>
{:else} {:else}
<p class="text-muted-foreground mt-1 text-sm"> <p class="text-muted-foreground mt-1 text-sm">
Click on the profile picture to upload a custom one from your files. {m.click_profile_picture_to_upload_custom()}
</p> </p>
<p class="text-muted-foreground mt-1 text-sm">The image should be in PNG or JPEG format.</p> <p class="text-muted-foreground mt-1 text-sm">{m.image_should_be_in_format()}</p>
{/if} {/if}
<Button <Button
variant="outline" variant="outline"
@@ -77,7 +77,7 @@
disabled={isLoading || isLdapUser} disabled={isLoading || isLdapUser}
> >
<LucideRefreshCw class="mr-2 h-4 w-4" /> <LucideRefreshCw class="mr-2 h-4 w-4" />
Reset to default {m.reset_to_default()}
</Button> </Button>
</div> </div>
{#if isLdapUser} {#if isLdapUser}

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import * as Avatar from '$lib/components/ui/avatar'; import * as Avatar from '$lib/components/ui/avatar';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { LucideLogOut, LucideUser } from 'lucide-svelte'; import { LucideLogOut, LucideUser } from 'lucide-svelte';
@@ -32,10 +33,10 @@
<DropdownMenu.Separator /> <DropdownMenu.Separator />
<DropdownMenu.Group> <DropdownMenu.Group>
<DropdownMenu.Item href="/settings/account" <DropdownMenu.Item href="/settings/account"
><LucideUser class="mr-2 h-4 w-4" /> My Account</DropdownMenu.Item ><LucideUser class="mr-2 h-4 w-4" /> {m.my_account()}</DropdownMenu.Item
> >
<DropdownMenu.Item on:click={logout} <DropdownMenu.Item on:click={logout}
><LucideLogOut class="mr-2 h-4 w-4" /> Logout</DropdownMenu.Item ><LucideLogOut class="mr-2 h-4 w-4" /> {m.logout()}</DropdownMenu.Item
> >
</DropdownMenu.Group> </DropdownMenu.Group>
</DropdownMenu.Content> </DropdownMenu.Content>

View File

@@ -1,5 +1,5 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/stores'; import { page } from '$app/state';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import Logo from '../logo.svelte'; import Logo from '../logo.svelte';
@@ -8,7 +8,7 @@
const authUrls = [/^\/authorize$/, /^\/login(?:\/.*)?$/, /^\/logout$/]; const authUrls = [/^\/authorize$/, /^\/login(?:\/.*)?$/, /^\/logout$/];
let isAuthPage = $derived( let isAuthPage = $derived(
!$page.error && authUrls.some((pattern) => pattern.test($page.url.pathname)) !page.error && authUrls.some((pattern) => pattern.test(page.url.pathname))
); );
</script> </script>
@@ -26,8 +26,10 @@
</h1> </h1>
{/if} {/if}
</div> </div>
{#if $userStore?.id} <div class="flex items-center justify-between gap-4">
<HeaderAvatar /> {#if $userStore?.id}
{/if} <HeaderAvatar />
{/if}
</div>
</div> </div>
</div> </div>

View File

@@ -2,6 +2,7 @@
import { page } from '$app/state'; import { page } from '$app/state';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import * as Card from './ui/card'; import * as Card from './ui/card';
import { m } from '$lib/paraglide/messages';
let { let {
children, children,
@@ -29,7 +30,7 @@
)}`} )}`}
class="text-muted-foreground text-xs" class="text-muted-foreground text-xs"
> >
Don't have access to your passkey? {m.dont_have_access_to_your_passkey()}
</a> </a>
</div> </div>
{/if} {/if}
@@ -38,7 +39,7 @@
<img <img
src="/api/application-configuration/background-image" src="/api/application-configuration/background-image"
class="h-screen w-[calc(100vw-650px)] rounded-l-[60px] object-cover" class="h-screen w-[calc(100vw-650px)] rounded-l-[60px] object-cover"
alt="Login background" alt={m.login_background()}
/> />
</div> </div>
@@ -60,7 +61,7 @@
)}`} )}`}
class="text-muted-foreground mt-7 flex justify-center text-xs" class="text-muted-foreground mt-7 flex justify-center text-xs"
> >
Don't have access to your passkey? {m.dont_have_access_to_your_passkey()}
</a> </a>
{/if} {/if}
</Card.CardContent> </Card.CardContent>

View File

@@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import { m } from '$lib/paraglide/messages';
import { mode } from 'mode-watcher'; import { mode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
@@ -7,4 +8,4 @@
const isDarkMode = $derived($mode === 'dark'); const isDarkMode = $derived($mode === 'dark');
</script> </script>
<img {...props} src="/api/application-configuration/logo?light={!isDarkMode}" alt="Logo" /> <img {...props} src="/api/application-configuration/logo?light={!isDarkMode}" alt={m.logo()} />

View File

@@ -5,6 +5,7 @@
import Input from '$lib/components/ui/input/input.svelte'; import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select/index.js'; import * as Select from '$lib/components/ui/select/index.js';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
@@ -17,14 +18,14 @@
const userService = new UserService(); const userService = new UserService();
let oneTimeLink: string | null = $state(null); let oneTimeLink: string | null = $state(null);
let selectedExpiration: keyof typeof availableExpirations = $state('1 hour'); let selectedExpiration: keyof typeof availableExpirations = $state(m.one_hour());
let availableExpirations = { let availableExpirations = {
'1 hour': 60 * 60, [m.one_hour()]: 60 * 60,
'12 hours': 60 * 60 * 12, [m.twelve_hours()]: 60 * 60 * 12,
'1 day': 60 * 60 * 24, [m.one_day()]: 60 * 60 * 24,
'1 week': 60 * 60 * 24 * 7, [m.one_week()]: 60 * 60 * 24 * 7,
'1 month': 60 * 60 * 24 * 30 [m.one_month()]: 60 * 60 * 24 * 30
}; };
async function createOneTimeAccessToken() { async function createOneTimeAccessToken() {
@@ -48,14 +49,14 @@
<Dialog.Root open={!!userId} {onOpenChange}> <Dialog.Root open={!!userId} {onOpenChange}>
<Dialog.Content class="max-w-md"> <Dialog.Content class="max-w-md">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Login Code</Dialog.Title> <Dialog.Title>{m.login_code()}</Dialog.Title>
<Dialog.Description <Dialog.Description
>Create a login code that the user can use to sign in without a passkey once.</Dialog.Description >{m.create_a_login_code_to_sign_in_without_a_passkey_once()}</Dialog.Description
> >
</Dialog.Header> </Dialog.Header>
{#if oneTimeLink === null} {#if oneTimeLink === null}
<div> <div>
<Label for="expiration">Expiration</Label> <Label for="expiration">{m.expiration()}</Label>
<Select.Root <Select.Root
selected={{ selected={{
label: Object.keys(availableExpirations)[0], label: Object.keys(availableExpirations)[0],
@@ -75,10 +76,10 @@
</Select.Root> </Select.Root>
</div> </div>
<Button onclick={() => createOneTimeAccessToken()} disabled={!selectedExpiration}> <Button onclick={() => createOneTimeAccessToken()} disabled={!selectedExpiration}>
Generate Code {m.generate_code()}
</Button> </Button>
{:else} {:else}
<Label for="login-code" class="sr-only">Login Code</Label> <Label for="login-code" class="sr-only">{m.login_code()}</Label>
<Input id="login-code" value={oneTimeLink} readonly /> <Input id="login-code" value={oneTimeLink} readonly />
{/if} {/if}
</Dialog.Content> </Dialog.Content>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte'; import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import UserGroupService from '$lib/services/user-group-service'; import UserGroupService from '$lib/services/user-group-service';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { UserGroup } from '$lib/types/user-group.type'; import type { UserGroup } from '$lib/types/user-group.type';
@@ -34,7 +35,7 @@
items={groups} items={groups}
{requestOptions} {requestOptions}
onRefresh={async (o) => (groups = await userGroupService.list(o))} onRefresh={async (o) => (groups = await userGroupService.list(o))}
columns={[{ label: 'Name', sortColumn: 'friendlyName' }]} columns={[{ label: m.name(), sortColumn: 'friendlyName' }]}
bind:selectedIds={selectedGroupIds} bind:selectedIds={selectedGroupIds}
{selectionDisabled} {selectionDisabled}
> >

View File

@@ -1,4 +1,5 @@
<script> <script>
import { m } from '$lib/paraglide/messages';
import Logo from './logo.svelte'; import Logo from './logo.svelte';
</script> </script>
@@ -6,8 +7,8 @@
<div class="bg-muted mx-auto rounded-2xl p-3"> <div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="h-10 w-10" /> <Logo class="h-10 w-10" />
</div> </div>
<p class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Browser unsupported</p> <p class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">{m.browser_unsupported()}</p>
<p class="text-muted-foreground mt-3"> <p class="text-muted-foreground mt-3">
This browser doesn't support passkeys. Please or use a alternative sign in method. {m.this_browser_does_not_support_passkeys()}
</p> </p>
</div> </div>

View File

@@ -1,9 +1,13 @@
import { setLocale } from '$lib/paraglide/runtime';
import type { User } from '$lib/types/user.type'; import type { User } from '$lib/types/user.type';
import { writable } from 'svelte/store'; import { writable } from 'svelte/store';
const userStore = writable<User | null>(null); const userStore = writable<User | null>(null);
const setUser = (user: User) => { const setUser = (user: User) => {
if (user.locale) {
setLocale(user.locale, { reload: false });
}
userStore.set(user); userStore.set(user);
}; };

View File

@@ -1,3 +1,4 @@
import type { Locale } from '$lib/paraglide/runtime';
import type { CustomClaim } from './custom-claim.type'; import type { CustomClaim } from './custom-claim.type';
import type { UserGroup } from './user-group.type'; import type { UserGroup } from './user-group.type';
@@ -10,6 +11,7 @@ export type User = {
isAdmin: boolean; isAdmin: boolean;
userGroups: UserGroup[]; userGroups: UserGroup[];
customClaims: CustomClaim[]; customClaims: CustomClaim[];
locale?: Locale;
ldapId?: string; ldapId?: string;
}; };

View File

@@ -1,10 +1,11 @@
import { m } from '$lib/paraglide/messages';
import { WebAuthnError } from '@simplewebauthn/browser'; import { WebAuthnError } from '@simplewebauthn/browser';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
export function getAxiosErrorMessage( export function getAxiosErrorMessage(
e: unknown, e: unknown,
defaultMessage: string = 'An unknown error occurred' defaultMessage: string = m.an_unknown_error_occurred()
) { ) {
let message = defaultMessage; let message = defaultMessage;
if (e instanceof AxiosError) { if (e instanceof AxiosError) {
@@ -13,29 +14,29 @@ export function getAxiosErrorMessage(
return message; return message;
} }
export function axiosErrorToast(e: unknown, defaultMessage: string = 'An unknown error occurred') { export function axiosErrorToast(e: unknown, defaultMessage: string = m.an_unknown_error_occurred()) {
const message = getAxiosErrorMessage(e, defaultMessage); const message = getAxiosErrorMessage(e, defaultMessage);
toast.error(message); toast.error(message);
} }
export function getWebauthnErrorMessage(e: unknown) { export function getWebauthnErrorMessage(e: unknown) {
const errors = { const errors = {
ERROR_CEREMONY_ABORTED: 'The authentication process was aborted', ERROR_CEREMONY_ABORTED: m.authentication_process_was_aborted(),
ERROR_AUTHENTICATOR_GENERAL_ERROR: 'An error occurred with the authenticator', ERROR_AUTHENTICATOR_GENERAL_ERROR: m.error_occurred_with_authenticator(),
ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT: ERROR_AUTHENTICATOR_MISSING_DISCOVERABLE_CREDENTIAL_SUPPORT:
'The authenticator does not support discoverable credentials', m.authenticator_does_not_support_discoverable_credentials(),
ERROR_AUTHENTICATOR_MISSING_RESIDENT_KEY_SUPPORT: ERROR_AUTHENTICATOR_MISSING_RESIDENT_KEY_SUPPORT:
'The authenticator does not support resident keys', m.authenticator_does_not_support_resident_keys(),
ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED: 'This passkey was previously registered', ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED: m.passkey_was_previously_registered(),
ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG: ERROR_AUTHENTICATOR_NO_SUPPORTED_PUBKEYCREDPARAMS_ALG:
'The authenticator does not support any of the requested algorithms' m.authenticator_does_not_support_any_of_the_requested_algorithms()
}; };
let message = 'An unknown error occurred'; let message = m.an_unknown_error_occurred();
if (e instanceof WebAuthnError && e.code in errors) { if (e instanceof WebAuthnError && e.code in errors) {
message = errors[e.code as keyof typeof errors]; message = errors[e.code as keyof typeof errors];
} else if (e instanceof WebAuthnError && e?.message.includes('timed out')) { } else if (e instanceof WebAuthnError && e?.message.includes('timed out')) {
message = 'The authenticator timed out'; message = m.authenticator_timed_out();
} else if (e instanceof AxiosError && e.response?.data.error) { } else if (e instanceof AxiosError && e.response?.data.error) {
message = e.response?.data.error; message = e.response?.data.error;
} else { } else {

View File

@@ -4,6 +4,7 @@
import Error from '$lib/components/error.svelte'; import Error from '$lib/components/error.svelte';
import Header from '$lib/components/header/header.svelte'; import Header from '$lib/components/header/header.svelte';
import { Toaster } from '$lib/components/ui/sonner'; import { Toaster } from '$lib/components/ui/sonner';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import userStore from '$lib/stores/user-store'; import userStore from '$lib/stores/user-store';
import { ModeWatcher } from 'mode-watcher'; import { ModeWatcher } from 'mode-watcher';
@@ -30,10 +31,7 @@
</script> </script>
{#if !appConfig} {#if !appConfig}
<Error <Error message={m.critical_error_occurred_contact_administrator()} showButton={false} />
message="A critical error occurred. Please contact your administrator."
showButton={false}
/>
{:else} {:else}
<Header /> <Header />
{@render children()} {@render children()}

View File

@@ -14,6 +14,7 @@
import type { PageData } from './$types'; import type { PageData } from './$types';
import ClientProviderImages from './components/client-provider-images.svelte'; import ClientProviderImages from './components/client-provider-images.svelte';
import ScopeItem from './components/scope-item.svelte'; import ScopeItem from './components/scope-item.svelte';
import { m } from '$lib/paraglide/messages';
const webauthnService = new WebAuthnService(); const webauthnService = new WebAuthnService();
const oidService = new OidcService(); const oidService = new OidcService();
@@ -77,15 +78,15 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Sign in to {client.name}</title> <title>{m.sign_in_to({name: client.name})}</title>
</svelte:head> </svelte:head>
{#if client == null} {#if client == null}
<p>Client not found</p> <p>{m.client_not_found()}</p>
{:else} {:else}
<SignInWrapper showAlternativeSignInMethodButton> <SignInWrapper showAlternativeSignInMethodButton>
<ClientProviderImages {client} {success} error={!!errorMessage} /> <ClientProviderImages {client} {success} error={!!errorMessage} />
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Sign in to {client.name}</h1> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">{m.sign_in_to({name: client.name})}</h1>
{#if errorMessage} {#if errorMessage}
<p class="text-muted-foreground mb-10 mt-2"> <p class="text-muted-foreground mb-10 mt-2">
{errorMessage}. {errorMessage}.
@@ -93,34 +94,36 @@
{/if} {/if}
{#if !authorizationRequired && !errorMessage} {#if !authorizationRequired && !errorMessage}
<p class="text-muted-foreground mb-10 mt-2"> <p class="text-muted-foreground mb-10 mt-2">
Do you want to sign in to <b>{client.name}</b> with your {@html m.do_you_want_to_sign_in_to_client_with_your_app_name_account({
<b>{$appConfigStore.appName}</b> account? client: client.name,
appName: $appConfigStore.appName
})}
</p> </p>
{:else if authorizationRequired} {:else if authorizationRequired}
<div transition:slide={{ duration: 300 }}> <div transition:slide={{ duration: 300 }}>
<Card.Root class="mb-10 mt-6"> <Card.Root class="mb-10 mt-6">
<Card.Header class="pb-5"> <Card.Header class="pb-5">
<p class="text-muted-foreground text-start"> <p class="text-muted-foreground text-start">
<b>{client.name}</b> wants to access the following information: {@html m.client_wants_to_access_the_following_information({ client: client.name })}
</p> </p>
</Card.Header> </Card.Header>
<Card.Content data-testid="scopes"> <Card.Content data-testid="scopes">
<div class="flex flex-col gap-3"> <div class="flex flex-col gap-3">
{#if scope!.includes('email')} {#if scope!.includes('email')}
<ScopeItem icon={LucideMail} name="Email" description="View your email address" /> <ScopeItem icon={LucideMail} name={m.email()} description={m.view_your_email_address()} />
{/if} {/if}
{#if scope!.includes('profile')} {#if scope!.includes('profile')}
<ScopeItem <ScopeItem
icon={LucideUser} icon={LucideUser}
name="Profile" name={m.profile()}
description="View your profile information" description={m.view_your_profile_information()}
/> />
{/if} {/if}
{#if scope!.includes('groups')} {#if scope!.includes('groups')}
<ScopeItem <ScopeItem
icon={LucideUsers} icon={LucideUsers}
name="Groups" name={m.groups()}
description="View the groups you are a member of" description={m.view_the_groups_you_are_a_member_of()}
/> />
{/if} {/if}
</div> </div>
@@ -129,11 +132,11 @@
</div> </div>
{/if} {/if}
<div class="flex w-full justify-stretch gap-2"> <div class="flex w-full justify-stretch gap-2">
<Button onclick={() => history.back()} class="w-full" variant="secondary">Cancel</Button> <Button onclick={() => history.back()} class="w-full" variant="secondary">{m.cancel()}</Button>
{#if !errorMessage} {#if !errorMessage}
<Button class="w-full" {isLoading} on:click={authorize}>Sign in</Button> <Button class="w-full" {isLoading} on:click={authorize}>{m.sign_in()}</Button>
{:else} {:else}
<Button class="w-full" on:click={() => (errorMessage = null)}>Try again</Button> <Button class="w-full" on:click={() => (errorMessage = null)}>{m.try_again()}</Button>
{/if} {/if}
</div> </div>
</SignInWrapper> </SignInWrapper>

View File

@@ -3,6 +3,7 @@
import CheckmarkAnimated from '$lib/icons/checkmark-animated.svelte'; import CheckmarkAnimated from '$lib/icons/checkmark-animated.svelte';
import ConnectArrow from '$lib/icons/connect-arrow.svelte'; import ConnectArrow from '$lib/icons/connect-arrow.svelte';
import CrossAnimated from '$lib/icons/cross-animated.svelte'; import CrossAnimated from '$lib/icons/cross-animated.svelte';
import { m } from '$lib/paraglide/messages';
import type { OidcClientMetaData } from '$lib/types/oidc.type'; import type { OidcClientMetaData } from '$lib/types/oidc.type';
const { const {
@@ -61,7 +62,7 @@
class="h-10 w-10" class="h-10 w-10"
src="/api/oidc/clients/{client.id}/logo" src="/api/oidc/clients/{client.id}/logo"
draggable={false} draggable={false}
alt="Client Logo" alt={m.client_logo()}
/> />
{:else} {:else}
<div class="flex h-10 w-10 items-center justify-center text-3xl font-bold"> <div class="flex h-10 w-10 items-center justify-center text-3xl font-bold">

View File

@@ -9,6 +9,7 @@
import { startAuthentication } from '@simplewebauthn/browser'; import { startAuthentication } from '@simplewebauthn/browser';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte'; import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
import { m } from '$lib/paraglide/messages';
const webauthnService = new WebAuthnService(); const webauthnService = new WebAuthnService();
let isLoading = $state(false); let isLoading = $state(false);
@@ -32,7 +33,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Sign In</title> <title>{m.sign_in()}</title>
</svelte:head> </svelte:head>
<SignInWrapper showAlternativeSignInMethodButton> <SignInWrapper showAlternativeSignInMethodButton>
@@ -40,18 +41,18 @@
<LoginLogoErrorSuccessIndicator error={!!error} /> <LoginLogoErrorSuccessIndicator error={!!error} />
</div> </div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl"> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">
Sign in to {$appConfigStore.appName} {m.sign_in_to_appname({ appName: $appConfigStore.appName})}
</h1> </h1>
{#if error} {#if error}
<p class="text-muted-foreground mt-2" in:fade> <p class="text-muted-foreground mt-2" in:fade>
{error}. Please try to sign in again. {error}. {m.please_try_to_sign_in_again()}
</p> </p>
{:else} {:else}
<p class="text-muted-foreground mt-2" in:fade> <p class="text-muted-foreground mt-2" in:fade>
Authenticate yourself with your passkey to access the admin panel. {m.authenticate_yourself_with_your_passkey_to_access_the_admin_panel()}
</p> </p>
{/if} {/if}
<Button class="mt-10" {isLoading} on:click={authenticate} <Button class="mt-10" {isLoading} on:click={authenticate}
>{error ? 'Try again' : 'Authenticate'}</Button >{error ? m.try_again() : m.authenticate()}</Button
> >
</SignInWrapper> </SignInWrapper>

View File

@@ -4,14 +4,15 @@
import Logo from '$lib/components/logo.svelte'; import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import { LucideChevronRight, LucideMail, LucideRectangleEllipsis } from 'lucide-svelte'; import { LucideChevronRight, LucideMail, LucideRectangleEllipsis } from 'lucide-svelte';
const methods = [ const methods = [
{ {
icon: LucideRectangleEllipsis, icon: LucideRectangleEllipsis,
title: 'Login Code', title: m.login_code(),
description: 'Enter a login code to sign in.', description: m.enter_a_login_code_to_sign_in(),
href: '/login/alternative/code' href: '/login/alternative/code'
} }
]; ];
@@ -19,15 +20,15 @@
if ($appConfigStore.emailOneTimeAccessEnabled) { if ($appConfigStore.emailOneTimeAccessEnabled) {
methods.push({ methods.push({
icon: LucideMail, icon: LucideMail,
title: 'Email Login', title: m.email_login(),
description: 'Request a login code via email.', description: m.request_a_login_code_via_email(),
href: '/login/alternative/email' href: '/login/alternative/email'
}); });
} }
</script> </script>
<svelte:head> <svelte:head>
<title>Sign In</title> <title>{m.sign_in()}</title>
</svelte:head> </svelte:head>
<SignInWrapper> <SignInWrapper>
@@ -35,9 +36,9 @@
<div class="bg-muted mx-auto rounded-2xl p-3"> <div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="h-10 w-10" /> <Logo class="h-10 w-10" />
</div> </div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Alternative Sign In</h1> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">{m.alternative_sign_in()}</h1>
<p class="text-muted-foreground mt-3"> <p class="text-muted-foreground mt-3">
If you dont't have access to your passkey, you can sign in using one of the following methods. {m.if_you_do_not_have_access_to_your_passkey_you_can_sign_in_using_one_of_the_following_methods()}
</p> </p>
<div class="mt-5 flex flex-col gap-3"> <div class="mt-5 flex flex-col gap-3">
{#each methods as method} {#each methods as method}
@@ -59,7 +60,7 @@
</div> </div>
<a class="text-muted-foreground mt-5 text-xs" href={'/login' + page.url.search} <a class="text-muted-foreground mt-5 text-xs" href={'/login' + page.url.search}
>Use your passkey instead?</a >{m.use_your_passkey_instead()}</a
> >
</div> </div>
</SignInWrapper> </SignInWrapper>

View File

@@ -9,6 +9,7 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte'; import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
import { page } from '$app/state'; import { page } from '$app/state';
import { m } from '$lib/paraglide/messages';
let { data } = $props(); let { data } = $props();
let code = $state(data.code ?? ''); let code = $state(data.code ?? '');
@@ -26,7 +27,7 @@
try { try {
goto(data.redirect); goto(data.redirect);
} catch (e) { } catch (e) {
error = 'Invalid redirect URL'; error = m.invalid_redirect_url();
} }
} catch (e) { } catch (e) {
error = getAxiosErrorMessage(e); error = getAxiosErrorMessage(e);
@@ -43,20 +44,20 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Login Code</title> <title>{m.login_code()}</title>
</svelte:head> </svelte:head>
<SignInWrapper> <SignInWrapper>
<div class="flex justify-center"> <div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} /> <LoginLogoErrorSuccessIndicator error={!!error} />
</div> </div>
<h1 class="font-playfair mt-5 text-4xl font-bold">Login Code</h1> <h1 class="font-playfair mt-5 text-4xl font-bold">{m.login_code()}</h1>
{#if error} {#if error}
<p class="text-muted-foreground mt-2"> <p class="text-muted-foreground mt-2">
{error}. Please try again. {error}. {m.please_try_again()}
</p> </p>
{:else} {:else}
<p class="text-muted-foreground mt-2">Enter the code you received to sign in.</p> <p class="text-muted-foreground mt-2">{m.enter_the_code_you_received_to_sign_in()}</p>
{/if} {/if}
<form <form
onsubmit={(e) => { onsubmit={(e) => {
@@ -65,10 +66,10 @@
}} }}
class="w-full max-w-[450px]" class="w-full max-w-[450px]"
> >
<Input id="Email" class="mt-7" placeholder="Code" bind:value={code} type="text" /> <Input id="Email" class="mt-7" placeholder={m.code()} bind:value={code} type="text" />
<div class="mt-8 flex justify-stretch gap-2"> <div class="mt-8 flex justify-stretch gap-2">
<Button variant="secondary" class="w-full" href={"/login/alternative" + page.url.search}>Go back</Button> <Button variant="secondary" class="w-full" href={"/login/alternative" + page.url.search}>{m.go_back()}</Button>
<Button class="w-full" type="submit" {isLoading}>Submit</Button> <Button class="w-full" type="submit" {isLoading}>{m.submit()}</Button>
</div> </div>
</form> </form>
</SignInWrapper> </SignInWrapper>

View File

@@ -6,6 +6,7 @@
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import { fade } from 'svelte/transition'; import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte'; import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
import { m } from '$lib/paraglide/messages';
const { data } = $props(); const { data } = $props();
@@ -21,38 +22,38 @@
await userService await userService
.requestOneTimeAccessEmail(email, data.redirect) .requestOneTimeAccessEmail(email, data.redirect)
.then(() => (success = true)) .then(() => (success = true))
.catch((e) => (error = e.response?.data.error || 'An unknown error occurred')); .catch((e) => (error = e.response?.data.error || m.an_unknown_error_occurred()));
isLoading = false; isLoading = false;
} }
</script> </script>
<svelte:head> <svelte:head>
<title>Email Login</title> <title>{m.email_login()}</title>
</svelte:head> </svelte:head>
<SignInWrapper> <SignInWrapper>
<div class="flex justify-center"> <div class="flex justify-center">
<LoginLogoErrorSuccessIndicator {success} error={!!error} /> <LoginLogoErrorSuccessIndicator {success} error={!!error} />
</div> </div>
<h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">Email Login</h1> <h1 class="font-playfair mt-5 text-3xl font-bold sm:text-4xl">{m.email_login()}</h1>
{#if error} {#if error}
<p class="text-muted-foreground mt-2" in:fade> <p class="text-muted-foreground mt-2" in:fade>
{error}. Please try again. {error}. {m.please_try_again()}
</p> </p>
<div class="mt-10 flex w-full justify-stretch gap-2"> <div class="mt-10 flex w-full justify-stretch gap-2">
<Button variant="secondary" class="w-full" href="/">Go back</Button> <Button variant="secondary" class="w-full" href="/">{m.go_back()}</Button>
<Button class="w-full" onclick={() => (error = undefined)}>Try again</Button> <Button class="w-full" onclick={() => (error = undefined)}>{m.try_again()}</Button>
</div> </div>
{:else if success} {:else if success}
<p class="text-muted-foreground mt-2" in:fade> <p class="text-muted-foreground mt-2" in:fade>
An email has been sent to the provided email, if it exists in the system. {m.an_email_has_been_sent_to_the_provided_email_if_it_exists_in_the_system()}
</p> </p>
<div class="mt-8 flex w-full justify-stretch gap-2"> <div class="mt-8 flex w-full justify-stretch gap-2">
<Button variant="secondary" class="w-full" href={'/login/alternative' + page.url.search} <Button variant="secondary" class="w-full" href={'/login/alternative' + page.url.search}
>Go back</Button >{m.go_back()}</Button
> >
<Button class="w-full" href={'/login/alternative/code' + page.url.search}>Enter code</Button> <Button class="w-full" href={'/login/alternative/code' + page.url.search}>{m.enter_code()}</Button>
</div> </div>
{:else} {:else}
<form <form
@@ -63,14 +64,14 @@
class="w-full max-w-[450px]" class="w-full max-w-[450px]"
> >
<p class="text-muted-foreground mt-2" in:fade> <p class="text-muted-foreground mt-2" in:fade>
Enter your email address to receive an email with a login code. {m.enter_your_email_address_to_receive_an_email_with_a_login_code()}
</p> </p>
<Input id="Email" class="mt-7" placeholder="Your email" bind:value={email} /> <Input id="Email" class="mt-7" placeholder={m.your_email()} bind:value={email} />
<div class="mt-8 flex justify-stretch gap-2"> <div class="mt-8 flex justify-stretch gap-2">
<Button variant="secondary" class="w-full" href={'/login/alternative' + page.url.search} <Button variant="secondary" class="w-full" href={'/login/alternative' + page.url.search}
>Go back</Button >{m.go_back()}</Button
> >
<Button class="w-full" type="submit" {isLoading}>Submit</Button> <Button class="w-full" type="submit" {isLoading}>{m.submit()}</Button>
</div> </div>
</form> </form>
{/if} {/if}

View File

@@ -2,6 +2,7 @@
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import SignInWrapper from '$lib/components/login-wrapper.svelte'; import SignInWrapper from '$lib/components/login-wrapper.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import appConfigStore from '$lib/stores/application-configuration-store.js'; import appConfigStore from '$lib/stores/application-configuration-store.js';
import userStore from '$lib/stores/user-store.js'; import userStore from '$lib/stores/user-store.js';
@@ -33,18 +34,16 @@
<LoginLogoErrorSuccessIndicator error={!!error} /> <LoginLogoErrorSuccessIndicator error={!!error} />
</div> </div>
<h1 class="font-playfair mt-5 text-4xl font-bold"> <h1 class="font-playfair mt-5 text-4xl font-bold">
{`${$appConfigStore.appName} Setup`} {m.appname_setup({ appName: $appConfigStore.appName })}
</h1> </h1>
{#if error} {#if error}
<p class="text-muted-foreground mt-2"> <p class="text-muted-foreground mt-2">
{error}. Please try again. {error}. {m.please_try_again()}
</p> </p>
{:else} {:else}
<p class="text-muted-foreground mt-2"> <p class="text-muted-foreground mt-2">
You're about to sign in to the initial admin account. Anyone with this link can access the {m.you_are_about_to_sign_in_to_the_initial_admin_account()}
account until a passkey is added. Please set up a passkey as soon as possible to prevent
unauthorized access.
</p> </p>
<Button class="mt-5" {isLoading} on:click={authenticate}>Continue</Button> <Button class="mt-5" {isLoading} on:click={authenticate}>{m.continue()}</Button>
{/if} {/if}
</SignInWrapper> </SignInWrapper>

View File

@@ -3,6 +3,7 @@
import SignInWrapper from '$lib/components/login-wrapper.svelte'; import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte'; import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store.js'; import userStore from '$lib/stores/user-store.js';
import { axiosErrorToast } from '$lib/utils/error-util.js'; import { axiosErrorToast } from '$lib/utils/error-util.js';
@@ -22,7 +23,7 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Logout</title> <title>{m.logout()}</title>
</svelte:head> </svelte:head>
<SignInWrapper> <SignInWrapper>
@@ -31,13 +32,13 @@
<Logo class="h-10 w-10" /> <Logo class="h-10 w-10" />
</div> </div>
</div> </div>
<h1 class="font-playfair mt-5 text-4xl font-bold">Sign out</h1> <h1 class="font-playfair mt-5 text-4xl font-bold">{m.sign_out()}</h1>
<p class="text-muted-foreground mt-2"> <p class="text-muted-foreground mt-2">
Do you want to sign out of Pocket ID with the account <b>{$userStore?.username}</b>? {@html m.do_you_want_to_sign_out_of_pocketid_with_the_account({ username: $userStore?.username ?? '' })}
</p> </p>
<div class="mt-10 flex w-full justify-stretch gap-2"> <div class="mt-10 flex w-full justify-stretch gap-2">
<Button class="w-full" variant="secondary" onclick={() => history.back()}>Cancel</Button> <Button class="w-full" variant="secondary" onclick={() => history.back()}>{m.cancel()}</Button>
<Button class="w-full" {isLoading} onclick={signOut}>Sign out</Button> <Button class="w-full" {isLoading} onclick={signOut}>{m.sign_out()}</Button>
</div> </div>
</SignInWrapper> </SignInWrapper>

View File

@@ -4,6 +4,7 @@
import { LucideExternalLink } from 'lucide-svelte'; import { LucideExternalLink } from 'lucide-svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { LayoutData } from './$types'; import type { LayoutData } from './$types';
import { m } from '$lib/paraglide/messages';
let { let {
children, children,
@@ -16,19 +17,19 @@
const { versionInformation } = data; const { versionInformation } = data;
let links = $state([ let links = $state([
{ href: '/settings/account', label: 'My Account' }, { href: '/settings/account', label: m.my_account() },
{ href: '/settings/audit-log', label: 'Audit Log' } { href: '/settings/audit-log', label: m.audit_log() }
]); ]);
if ($userStore?.isAdmin) { if ($userStore?.isAdmin) {
links = [ links = [
// svelte-ignore state_referenced_locally // svelte-ignore state_referenced_locally
...links, ...links,
{ href: '/settings/admin/users', label: 'Users' }, { href: '/settings/admin/users', label: m.users() },
{ href: '/settings/admin/user-groups', label: 'User Groups' }, { href: '/settings/admin/user-groups', label: m.user_groups() },
{ href: '/settings/admin/oidc-clients', label: 'OIDC Clients' }, { href: '/settings/admin/oidc-clients', label: m.oidc_clients() },
{ href: '/settings/admin/api-keys', label: 'API Keys' }, { href: '/settings/admin/api-keys', label: m.api_keys() },
{ href: '/settings/admin/application-configuration', label: 'Application Configuration' } { href: '/settings/admin/application-configuration', label: m.application_configuration() }
]; ];
} }
</script> </script>
@@ -40,7 +41,7 @@
> >
<div class="min-w-[200px] xl:min-w-[250px]"> <div class="min-w-[200px] xl:min-w-[250px]">
<div class="mx-auto grid w-full gap-2"> <div class="mx-auto grid w-full gap-2">
<h1 class="mb-5 text-3xl font-semibold">Settings</h1> <h1 class="mb-5 text-3xl font-semibold">{m.settings()}</h1>
</div> </div>
<nav class="text-muted-foreground grid gap-4 text-sm"> <nav class="text-muted-foreground grid gap-4 text-sm">
{#each links as { href, label }} {#each links as { href, label }}
@@ -54,7 +55,7 @@
target="_blank" target="_blank"
class="flex items-center gap-2" class="flex items-center gap-2"
> >
Update Pocket ID <LucideExternalLink class="my-auto inline-block h-3 w-3" /> {m.update_pocket_id()} <LucideExternalLink class="my-auto inline-block h-3 w-3" />
</a> </a>
{/if} {/if}
</nav> </nav>
@@ -65,7 +66,7 @@
</main> </main>
<div class="flex flex-col items-center"> <div class="flex flex-col items-center">
<p class="text-muted-foreground py-3 text-xs"> <p class="text-muted-foreground py-3 text-xs">
Powered by <a {m.powered_by()} <a
class="text-foreground" class="text-foreground"
href="https://github.com/pocket-id/pocket-id" href="https://github.com/pocket-id/pocket-id"
target="_blank">Pocket ID</a target="_blank">Pocket ID</a

View File

@@ -2,6 +2,7 @@
import * as Alert from '$lib/components/ui/alert'; import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
@@ -13,6 +14,7 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import ProfilePictureSettings from '../../../lib/components/form/profile-picture-settings.svelte'; import ProfilePictureSettings from '../../../lib/components/form/profile-picture-settings.svelte';
import AccountForm from './account-form.svelte'; import AccountForm from './account-form.svelte';
import LocalePicker from './locale-picker.svelte';
import LoginCodeModal from './login-code-modal.svelte'; import LoginCodeModal from './login-code-modal.svelte';
import PasskeyList from './passkey-list.svelte'; import PasskeyList from './passkey-list.svelte';
import RenamePasskeyModal from './rename-passkey-modal.svelte'; import RenamePasskeyModal from './rename-passkey-modal.svelte';
@@ -39,7 +41,7 @@
let success = true; let success = true;
await userService await userService
.updateCurrent(user) .updateCurrent(user)
.then(() => toast.success('Account details updated successfully')) .then(() => toast.success(m.account_details_updated_successfully()))
.catch((e) => { .catch((e) => {
axiosErrorToast(e); axiosErrorToast(e);
success = false; success = false;
@@ -51,9 +53,7 @@
async function updateProfilePicture(image: File) { async function updateProfilePicture(image: File) {
await userService await userService
.updateCurrentUsersProfilePicture(image) .updateCurrentUsersProfilePicture(image)
.then(() => .then(() => toast.success(m.profile_picture_updated_successfully()))
toast.success('Profile picture updated successfully. It may take a few minutes to update.')
)
.catch(axiosErrorToast); .catch(axiosErrorToast);
} }
@@ -72,24 +72,22 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Account Settings</title> <title>{m.account_settings()}</title>
</svelte:head> </svelte:head>
{#if passkeys.length == 0} {#if passkeys.length == 0}
<Alert.Root variant="warning"> <Alert.Root variant="warning">
<LucideAlertTriangle class="size-4" /> <LucideAlertTriangle class="size-4" />
<Alert.Title>Passkey missing</Alert.Title> <Alert.Title>{m.passkey_missing()}</Alert.Title>
<Alert.Description <Alert.Description
>Please add a passkey to prevent losing access to your account.</Alert.Description >{m.please_provide_a_passkey_to_prevent_losing_access_to_your_account()}</Alert.Description
> >
</Alert.Root> </Alert.Root>
{:else if passkeys.length == 1} {:else if passkeys.length == 1}
<Alert.Root variant="warning" dismissibleId="single-passkey"> <Alert.Root variant="warning" dismissibleId="single-passkey">
<LucideAlertTriangle class="size-4" /> <LucideAlertTriangle class="size-4" />
<Alert.Title>Single Passkey Configured</Alert.Title> <Alert.Title>{m.single_passkey_configured()}</Alert.Title>
<Alert.Description <Alert.Description>{m.it_is_recommended_to_add_more_than_one_passkey()}</Alert.Description>
>It is recommended to add more than one passkey to avoid losing access to your account.</Alert.Description
>
</Alert.Root> </Alert.Root>
{/if} {/if}
@@ -99,7 +97,7 @@
> >
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title>Account Details</Card.Title> <Card.Title>{m.account_details()}</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<AccountForm {account} callback={updateAccount} /> <AccountForm {account} callback={updateAccount} />
@@ -122,12 +120,12 @@
<Card.Header> <Card.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Card.Title>Passkeys</Card.Title> <Card.Title>{m.passkeys()}</Card.Title>
<Card.Description class="mt-1"> <Card.Description class="mt-1">
Manage your passkeys that you can use to authenticate yourself. {m.manage_your_passkeys_that_you_can_use_to_authenticate_yourself()}
</Card.Description> </Card.Description>
</div> </div>
<Button size="sm" class="ml-3" on:click={createPasskey}>Add Passkey</Button> <Button size="sm" class="ml-3" on:click={createPasskey}>{m.add_passkey()}</Button>
</div> </div>
</Card.Header> </Card.Header>
{#if passkeys.length != 0} {#if passkeys.length != 0}
@@ -141,12 +139,28 @@
<Card.Header> <Card.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Card.Title>Login Code</Card.Title> <Card.Title>{m.login_code()}</Card.Title>
<Card.Description class="mt-1"> <Card.Description class="mt-1">
Create a one-time login code to sign in from a different device without a passkey. {m.create_a_one_time_login_code_to_sign_in_from_a_different_device_without_a_passkey()}
</Card.Description> </Card.Description>
</div> </div>
<Button size="sm" class="ml-auto" on:click={() => (showLoginCodeModal = true)}>Create</Button> <Button size="sm" class="ml-auto" on:click={() => (showLoginCodeModal = true)}
>{m.create()}</Button
>
</div>
</Card.Header>
</Card.Root>
<Card.Root>
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>{m.language()}</Card.Title>
<Card.Description class="mt-1">
{m.select_the_language_you_want_to_use()}
</Card.Description>
</div>
<LocalePicker />
</div> </div>
</Card.Header> </Card.Header>
</Card.Root> </Card.Root>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type { UserCreate } from '$lib/types/user.type'; import type { UserCreate } from '$lib/types/user.type';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { z } from 'zod'; import { z } from 'zod';
@@ -24,7 +25,7 @@
.max(30) .max(30)
.regex( .regex(
/^[a-z0-9_@.-]+$/, /^[a-z0-9_@.-]+$/,
"Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols" m.username_can_only_contain()
), ),
email: z.string().email(), email: z.string().email(),
isAdmin: z.boolean() isAdmin: z.boolean()
@@ -36,7 +37,7 @@
const data = form.validate(); const data = form.validate();
if (!data) return; if (!data) return;
isLoading = true; isLoading = true;
const success = await callback(data); await callback(data);
// Reset form if user was successfully created // Reset form if user was successfully created
isLoading = false; isLoading = false;
} }
@@ -45,21 +46,21 @@
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="flex flex-col gap-3 sm:flex-row"> <div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full"> <div class="w-full">
<FormInput label="First name" bind:input={$inputs.firstName} /> <FormInput label={m.first_name()} bind:input={$inputs.firstName} />
</div> </div>
<div class="w-full"> <div class="w-full">
<FormInput label="Last name" bind:input={$inputs.lastName} /> <FormInput label={m.last_name()} bind:input={$inputs.lastName} />
</div> </div>
</div> </div>
<div class="mt-3 flex flex-col gap-3 sm:flex-row"> <div class="mt-3 flex flex-col gap-3 sm:flex-row">
<div class="w-full"> <div class="w-full">
<FormInput label="Email" bind:input={$inputs.email} /> <FormInput label={m.email()} bind:input={$inputs.email} />
</div> </div>
<div class="w-full"> <div class="w-full">
<FormInput label="Username" bind:input={$inputs.username} /> <FormInput label={m.username()} bind:input={$inputs.username} />
</div> </div>
</div> </div>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button> <Button {isLoading} type="submit">{m.save()}</Button>
</div> </div>
</form> </form>

View File

@@ -0,0 +1,39 @@
<script lang="ts">
import * as Select from '$lib/components/ui/select';
import { getLocale, setLocale, type Locale } from '$lib/paraglide/runtime';
import UserService from '$lib/services/user-service';
import userStore from '$lib/stores/user-store';
const userService = new UserService();
const currentLocale = getLocale();
const locales = {
en: 'English',
nl: 'Nederlands'
};
function updateLocale(locale: Locale) {
setLocale(locale);
userService.updateCurrent({
...$userStore!,
locale
});
}
</script>
<Select.Root
selected={{
label: locales[currentLocale],
value: currentLocale
}}
onSelectedChange={(v) => updateLocale(v!.value)}
>
<Select.Trigger class="h-9 max-w-[200px]" aria-label="Select locale">
<Select.Value>{locales[currentLocale]}</Select.Value>
</Select.Trigger>
<Select.Content>
{#each Object.entries(locales) as [value, label]}
<Select.Item {value}>{label}</Select.Item>
{/each}
</Select.Content>
</Select.Root>

View File

@@ -3,6 +3,7 @@
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte'; import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { Separator } from '$lib/components/ui/separator'; import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
@@ -37,9 +38,9 @@
<Dialog.Root open={!!code} {onOpenChange}> <Dialog.Root open={!!code} {onOpenChange}>
<Dialog.Content class="max-w-md"> <Dialog.Content class="max-w-md">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Login Code</Dialog.Title> <Dialog.Title>{m.login_code()}</Dialog.Title>
<Dialog.Description <Dialog.Description
>Sign in using the following code. The code will expire in 15 minutes. >{m.sign_in_using_the_following_code_the_code_will_expire_in_minutes()}
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
@@ -49,7 +50,7 @@
</CopyToClipboard> </CopyToClipboard>
<div class="text-muted-foreground flex items-center justify-center gap-3"> <div class="text-muted-foreground flex items-center justify-center gap-3">
<Separator /> <Separator />
<p class="text-nowrap text-xs">or visit</p> <p class="text-nowrap text-xs">{m.or_visit()}</p>
<Separator /> <Separator />
</div> </div>
<div> <div>

View File

@@ -8,6 +8,7 @@
import { LucideKeyRound, LucidePencil, LucideTrash } from 'lucide-svelte'; import { LucideKeyRound, LucidePencil, LucideTrash } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import RenamePasskeyModal from './rename-passkey-modal.svelte'; import RenamePasskeyModal from './rename-passkey-modal.svelte';
import { m } from '$lib/paraglide/messages';
let { passkeys = $bindable() }: { passkeys: Passkey[] } = $props(); let { passkeys = $bindable() }: { passkeys: Passkey[] } = $props();
@@ -17,16 +18,16 @@
async function deletePasskey(passkey: Passkey) { async function deletePasskey(passkey: Passkey) {
openConfirmDialog({ openConfirmDialog({
title: `Delete ${passkey.name}`, title: m.delete_passkey_name({ passkeyName: passkey.name }),
message: 'Are you sure you want to delete this passkey?', message: m.are_you_sure_you_want_to_delete_this_passkey(),
confirm: { confirm: {
label: 'Delete', label: m.delete(),
destructive: true, destructive: true,
action: async () => { action: async () => {
try { try {
await webauthnService.removeCredential(passkey.id); await webauthnService.removeCredential(passkey.id);
passkeys = await webauthnService.listCredentials(); passkeys = await webauthnService.listCredentials();
toast.success('Passkey deleted successfully'); toast.success(m.passkey_deleted_successfully());
} catch (e) { } catch (e) {
axiosErrorToast(e); axiosErrorToast(e);
} }
@@ -44,7 +45,7 @@
<div> <div>
<p>{passkey.name}</p> <p>{passkey.name}</p>
<p class="text-xs text-muted-foreground"> <p class="text-xs text-muted-foreground">
Added on {new Date(passkey.createdAt).toLocaleDateString()} {m.added_on()} {new Date(passkey.createdAt).toLocaleDateString()}
</p> </p>
</div> </div>
</div> </div>
@@ -53,13 +54,13 @@
on:click={() => (passkeyToRename = passkey)} on:click={() => (passkeyToRename = passkey)}
size="sm" size="sm"
variant="outline" variant="outline"
aria-label="Rename"><LucidePencil class="h-3 w-3" /></Button aria-label={m.rename()}><LucidePencil class="h-3 w-3" /></Button
> >
<Button <Button
on:click={() => deletePasskey(passkey)} on:click={() => deletePasskey(passkey)}
size="sm" size="sm"
variant="outline" variant="outline"
aria-label="Delete"><LucideTrash class="h-3 w-3 text-red-500" /></Button aria-label={m.delete()}><LucideTrash class="h-3 w-3 text-red-500" /></Button
> >
</div> </div>
</div> </div>

View File

@@ -3,6 +3,7 @@
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service'; import WebAuthnService from '$lib/services/webauthn-service';
import type { Passkey } from '$lib/types/passkey.type'; import type { Passkey } from '$lib/types/passkey.type';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
@@ -35,7 +36,7 @@
.updateCredentialName(passkey!.id, name) .updateCredentialName(passkey!.id, name)
.then(() => { .then(() => {
passkey = null; passkey = null;
toast.success('Passkey name updated successfully'); toast.success(m.passkey_name_updated_successfully());
callback?.(); callback?.();
}) })
.catch(axiosErrorToast); .catch(axiosErrorToast);
@@ -45,16 +46,16 @@
<Dialog.Root open={!!passkey} {onOpenChange}> <Dialog.Root open={!!passkey} {onOpenChange}>
<Dialog.Content class="max-w-md"> <Dialog.Content class="max-w-md">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>Name Passkey</Dialog.Title> <Dialog.Title>{m.name_passkey()}</Dialog.Title>
<Dialog.Description>Name your passkey to easily identify it later.</Dialog.Description> <Dialog.Description>{m.name_your_passkey_to_easily_identify_it_later()}</Dialog.Description>
</Dialog.Header> </Dialog.Header>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="grid items-center gap-4 sm:grid-cols-4"> <div class="grid items-center gap-4 sm:grid-cols-4">
<Label for="name" class="sm:text-right">Name</Label> <Label for="name" class="sm:text-right">{m.name()}</Label>
<Input id="name" bind:value={name} class="col-span-3" /> <Input id="name" bind:value={name} class="col-span-3" />
</div> </div>
<Dialog.Footer class="mt-4"> <Dialog.Footer class="mt-4">
<Button type="submit">Save</Button> <Button type="submit">{m.save()}</Button>
</Dialog.Footer> </Dialog.Footer>
</form> </form>
</Dialog.Content> </Dialog.Content>

View File

@@ -9,6 +9,7 @@
import ApiKeyDialog from './api-key-dialog.svelte'; import ApiKeyDialog from './api-key-dialog.svelte';
import ApiKeyForm from './api-key-form.svelte'; import ApiKeyForm from './api-key-form.svelte';
import ApiKeyList from './api-key-list.svelte'; import ApiKeyList from './api-key-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props(); let { data } = $props();
let apiKeys = $state(data.apiKeys); let apiKeys = $state(data.apiKeys);
@@ -35,18 +36,18 @@
</script> </script>
<svelte:head> <svelte:head>
<title>API Keys</title> <title>{m.api_keys()}</title>
</svelte:head> </svelte:head>
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Card.Title>Create API Key</Card.Title> <Card.Title>{m.create_api_key()}</Card.Title>
<Card.Description>Add a new API key for programmatic access.</Card.Description> <Card.Description>{m.add_a_new_api_key_for_programmatic_access()}</Card.Description>
</div> </div>
{#if !expandAddApiKey} {#if !expandAddApiKey}
<Button on:click={() => (expandAddApiKey = true)}>Add API Key</Button> <Button on:click={() => (expandAddApiKey = true)}>{m.add_api_key()}</Button>
{:else} {:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddApiKey = false)}> <Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddApiKey = false)}>
<LucideMinus class="h-5 w-5" /> <LucideMinus class="h-5 w-5" />
@@ -65,7 +66,7 @@
<Card.Root class="mt-6"> <Card.Root class="mt-6">
<Card.Header> <Card.Header>
<Card.Title>Manage API Keys</Card.Title> <Card.Title>{m.manage_api_keys()}</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<ApiKeyList {apiKeys} requestOptions={apiKeysRequestOptions} /> <ApiKeyList {apiKeys} requestOptions={apiKeysRequestOptions} />

View File

@@ -2,6 +2,7 @@
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte'; import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import { m } from '$lib/paraglide/messages';
import type { ApiKeyResponse } from '$lib/types/api-key.type'; import type { ApiKeyResponse } from '$lib/types/api-key.type';
let { let {
@@ -20,22 +21,22 @@
<Dialog.Root open={!!apiKeyResponse} {onOpenChange}> <Dialog.Root open={!!apiKeyResponse} {onOpenChange}>
<Dialog.Content class="max-w-md" closeButton={false}> <Dialog.Content class="max-w-md" closeButton={false}>
<Dialog.Header> <Dialog.Header>
<Dialog.Title>API Key Created</Dialog.Title> <Dialog.Title>{m.api_key_created()}</Dialog.Title>
<Dialog.Description> <Dialog.Description>
For security reasons, this key will only be shown once. Please store it securely. {m.for_security_reasons_this_key_will_only_be_shown_once()}
</Dialog.Description> </Dialog.Description>
</Dialog.Header> </Dialog.Header>
{#if apiKeyResponse} {#if apiKeyResponse}
<div> <div>
<div class="mb-2 font-medium">Name</div> <div class="mb-2 font-medium">{m.name()}</div>
<p class="text-muted-foreground">{apiKeyResponse.apiKey.name}</p> <p class="text-muted-foreground">{apiKeyResponse.apiKey.name}</p>
{#if apiKeyResponse.apiKey.description} {#if apiKeyResponse.apiKey.description}
<div class="mb-2 mt-4 font-medium">Description</div> <div class="mb-2 mt-4 font-medium">{m.description()}</div>
<p class="text-muted-foreground">{apiKeyResponse.apiKey.description}</p> <p class="text-muted-foreground">{apiKeyResponse.apiKey.description}</p>
{/if} {/if}
<div class="mb-2 mt-4 font-medium">API Key</div> <div class="mb-2 mt-4 font-medium">{m.api_key()}</div>
<div class="bg-muted rounded-md p-2"> <div class="bg-muted rounded-md p-2">
<CopyToClipboard value={apiKeyResponse.token}> <CopyToClipboard value={apiKeyResponse.token}>
<span class="break-all font-mono text-sm">{apiKeyResponse.token}</span> <span class="break-all font-mono text-sm">{apiKeyResponse.token}</span>
@@ -44,7 +45,7 @@
</div> </div>
{/if} {/if}
<Dialog.Footer class="mt-3"> <Dialog.Footer class="mt-3">
<Button variant="default" on:click={() => onOpenChange(false)}>Close</Button> <Button variant="default" on:click={() => onOpenChange(false)}>{m.close()}</Button>
</Dialog.Footer> </Dialog.Footer>
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type { ApiKeyCreate } from '$lib/types/api-key.type'; import type { ApiKeyCreate } from '$lib/types/api-key.type';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { z } from 'zod'; import { z } from 'zod';
@@ -26,10 +27,10 @@
const formSchema = z.object({ const formSchema = z.object({
name: z name: z
.string() .string()
.min(3, 'Name must be at least 3 characters') .min(3, m.name_must_be_at_least_3_characters())
.max(50, 'Name cannot exceed 50 characters'), .max(50, m.name_cannot_exceed_50_characters()),
description: z.string().default(''), description: z.string().default(''),
expiresAt: z.date().min(new Date(), 'Expiration date must be in the future') expiresAt: z.date().min(new Date(), m.expiration_date_must_be_in_the_future())
}); });
const { inputs, ...form } = createForm<typeof formSchema>(formSchema, apiKey); const { inputs, ...form } = createForm<typeof formSchema>(formSchema, apiKey);
@@ -54,25 +55,25 @@
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2"> <div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput <FormInput
label="Name" label={m.name()}
bind:input={$inputs.name} bind:input={$inputs.name}
description="Name to identify this API key." description={m.name_to_identify_this_api_key()}
/> />
<FormInput <FormInput
label="Expires At" label={m.expires_at()}
type="date" type="date"
description="When this API key will expire." description={m.when_this_api_key_will_expire()}
bind:input={$inputs.expiresAt} bind:input={$inputs.expiresAt}
/> />
<div class="col-span-1 md:col-span-2"> <div class="col-span-1 md:col-span-2">
<FormInput <FormInput
label="Description" label={m.description()}
description="Optional description to help identify this key's purpose." description={m.optional_description_to_help_identify_this_keys_purpose()}
bind:input={$inputs.description} bind:input={$inputs.description}
/> />
</div> </div>
</div> </div>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button> <Button {isLoading} type="submit">{m.save()}</Button>
</div> </div>
</form> </form>

View File

@@ -3,6 +3,7 @@
import { openConfirmDialog } from '$lib/components/confirm-dialog'; import { openConfirmDialog } from '$lib/components/confirm-dialog';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import ApiKeyService from '$lib/services/api-key-service'; import ApiKeyService from '$lib/services/api-key-service';
import type { ApiKey } from '$lib/types/api-key.type'; import type { ApiKey } from '$lib/types/api-key.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
@@ -21,22 +22,22 @@
const apiKeyService = new ApiKeyService(); const apiKeyService = new ApiKeyService();
function formatDate(dateStr: string | undefined) { function formatDate(dateStr: string | undefined) {
if (!dateStr) return 'Never'; if (!dateStr) return m.never();
return new Date(dateStr).toLocaleString(); return new Date(dateStr).toLocaleString();
} }
function revokeApiKey(apiKey: ApiKey) { function revokeApiKey(apiKey: ApiKey) {
openConfirmDialog({ openConfirmDialog({
title: 'Revoke API Key', title: m.revoke_api_key(),
message: `Are you sure you want to revoke the API key "${apiKey.name}"? This will break any integrations using this key.`, message: m.are_you_sure_you_want_to_revoke_the_api_key_apikeyname({ apiKeyName: apiKey.name }),
confirm: { confirm: {
label: 'Revoke', label: m.revoke(),
destructive: true, destructive: true,
action: async () => { action: async () => {
try { try {
await apiKeyService.revoke(apiKey.id); await apiKeyService.revoke(apiKey.id);
apiKeys = await apiKeyService.list(requestOptions); apiKeys = await apiKeyService.list(requestOptions);
toast.success('API key revoked successfully'); toast.success(m.api_key_revoked_successfully());
} catch (e) { } catch (e) {
axiosErrorToast(e); axiosErrorToast(e);
} }
@@ -52,11 +53,11 @@
onRefresh={async (o) => (apiKeys = await apiKeyService.list(o))} onRefresh={async (o) => (apiKeys = await apiKeyService.list(o))}
withoutSearch withoutSearch
columns={[ columns={[
{ label: 'Name', sortColumn: 'name' }, { label: m.name(), sortColumn: 'name' },
{ label: 'Description' }, { label: m.description() },
{ label: 'Expires At', sortColumn: 'expiresAt' }, { label: m.expires_at(), sortColumn: 'expiresAt' },
{ label: 'Last Used', sortColumn: 'lastUsedAt' }, { label: m.last_used(), sortColumn: 'lastUsedAt' },
{ label: 'Actions', hidden: true } { label: m.actions(), hidden: true }
]} ]}
> >
{#snippet rows({ item })} {#snippet rows({ item })}
@@ -65,7 +66,7 @@
<Table.Cell>{formatDate(item.expiresAt)}</Table.Cell> <Table.Cell>{formatDate(item.expiresAt)}</Table.Cell>
<Table.Cell>{formatDate(item.lastUsedAt)}</Table.Cell> <Table.Cell>{formatDate(item.lastUsedAt)}</Table.Cell>
<Table.Cell class="flex justify-end"> <Table.Cell class="flex justify-end">
<Button on:click={() => revokeApiKey(item)} size="sm" variant="outline" aria-label="Revoke" <Button on:click={() => revokeApiKey(item)} size="sm" variant="outline" aria-label={m.revoke()}
><LucideBan class="h-3 w-3 text-red-500" /></Button ><LucideBan class="h-3 w-3 text-red-500" /></Button
> >
</Table.Cell> </Table.Cell>

View File

@@ -9,6 +9,7 @@
import AppConfigGeneralForm from './forms/app-config-general-form.svelte'; import AppConfigGeneralForm from './forms/app-config-general-form.svelte';
import AppConfigLdapForm from './forms/app-config-ldap-form.svelte'; import AppConfigLdapForm from './forms/app-config-ldap-form.svelte';
import UpdateApplicationImages from './update-application-images.svelte'; import UpdateApplicationImages from './update-application-images.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props(); let { data } = $props();
let appConfig = $state(data.appConfig); let appConfig = $state(data.appConfig);
@@ -46,36 +47,35 @@
: Promise.resolve(); : Promise.resolve();
await Promise.all([lightLogoPromise, darkLogoPromise, backgroundImagePromise, faviconPromise]) await Promise.all([lightLogoPromise, darkLogoPromise, backgroundImagePromise, faviconPromise])
.then(() => toast.success('Images updated successfully')) .then(() => toast.success(m.images_updated_successfully()))
.catch(axiosErrorToast); .catch(axiosErrorToast);
} }
</script> </script>
<svelte:head> <svelte:head>
<title>Application Configuration</title> <title>{m.application_configuration()}</title>
</svelte:head> </svelte:head>
<CollapsibleCard id="application-configuration-general" title="General" defaultExpanded> <CollapsibleCard id="application-configuration-general" title={m.general()} defaultExpanded>
<AppConfigGeneralForm {appConfig} callback={updateAppConfig} /> <AppConfigGeneralForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard> </CollapsibleCard>
<CollapsibleCard <CollapsibleCard
id="application-configuration-email" id="application-configuration-email"
title="Email" title={m.email()}
description="Enable email notifications to alert users when a login is detected from a new device or description={m.enable_email_notifications_to_alert_users_when_a_login_is_detected_from_a_new_device_or_location()}
location."
> >
<AppConfigEmailForm {appConfig} callback={updateAppConfig} /> <AppConfigEmailForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard> </CollapsibleCard>
<CollapsibleCard <CollapsibleCard
id="application-configuration-ldap" id="application-configuration-ldap"
title="LDAP" title={m.ldap()}
description="Configure LDAP settings to sync users and groups from an LDAP server." description={m.configure_ldap_settings_to_sync_users_and_groups_from_an_ldap_server()}
> >
<AppConfigLdapForm {appConfig} callback={updateAppConfig} /> <AppConfigLdapForm {appConfig} callback={updateAppConfig} />
</CollapsibleCard> </CollapsibleCard>
<CollapsibleCard id="application-configuration-images" title="Images"> <CollapsibleCard id="application-configuration-images" title={m.images()}>
<UpdateApplicationImages callback={updateImages} /> <UpdateApplicationImages callback={updateImages} />
</CollapsibleCard> </CollapsibleCard>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import FileInput from '$lib/components/form/file-input.svelte'; import FileInput from '$lib/components/form/file-input.svelte';
import { Label } from '$lib/components/ui/label'; import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style'; import { cn } from '$lib/utils/style';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
@@ -60,7 +61,7 @@
<span <span
class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform font-medium opacity-0 transition-opacity group-hover:opacity-100" class="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform font-medium opacity-0 transition-opacity group-hover:opacity-100"
> >
Update {m.update()}
</span> </span>
</div> </div>
</FileInput> </FileInput>

View File

@@ -6,6 +6,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select'; import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import AppConfigService from '$lib/services/app-config-service'; import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
@@ -55,7 +56,7 @@
appConfig[key] = value; appConfig[key] = value;
}); });
toast.success('Email configuration updated successfully'); toast.success(m.email_configuration_updated_successfully());
return true; return true;
} }
async function onTestEmail() { async function onTestEmail() {
@@ -64,11 +65,11 @@
if (hasChanges) { if (hasChanges) {
openConfirmDialog({ openConfirmDialog({
title: 'Save changes?', title: m.save_changes_question(),
message: message:
'You have to save the changes before sending a test email. Do you want to save now?', m.you_have_to_save_the_changes_before_sending_a_test_email_do_you_want_to_save_now(),
confirm: { confirm: {
label: 'Save and send', label: m.save_and_send(),
action: async () => { action: async () => {
const saved = await onSubmit(); const saved = await onSubmit();
if (saved) { if (saved) {
@@ -86,9 +87,9 @@
isSendingTestEmail = true; isSendingTestEmail = true;
await appConfigService await appConfigService
.sendTestEmail() .sendTestEmail()
.then(() => toast.success('Test email sent successfully to your email address.')) .then(() => toast.success(m.test_email_sent_successfully()))
.catch(() => .catch(() =>
toast.error('Failed to send test email. Check the server logs for more information.') toast.error(m.failed_to_send_test_email())
) )
.finally(() => (isSendingTestEmail = false)); .finally(() => (isSendingTestEmail = false));
} }
@@ -96,21 +97,21 @@
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<fieldset disabled={uiConfigDisabled}> <fieldset disabled={uiConfigDisabled}>
<h4 class="text-lg font-semibold">SMTP Configuration</h4> <h4 class="text-lg font-semibold">{m.smtp_configuration()}</h4>
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2"> <div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} /> <FormInput label={m.smtp_host()} bind:input={$inputs.smtpHost} />
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} /> <FormInput label={m.smtp_port()} type="number" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} /> <FormInput label={m.smtp_user()} bind:input={$inputs.smtpUser} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} /> <FormInput label={m.smtp_password()} type="password" bind:input={$inputs.smtpPassword} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} /> <FormInput label={m.smtp_from()} bind:input={$inputs.smtpFrom} />
<div class="grid gap-2"> <div class="grid gap-2">
<Label class="mb-0" for="smtp-tls">SMTP TLS Option</Label> <Label class="mb-0" for="smtp-tls">{m.smtp_tls_option()}</Label>
<Select.Root <Select.Root
selected={{ value: $inputs.smtpTls.value, label: tlsOptions[$inputs.smtpTls.value] }} selected={{ value: $inputs.smtpTls.value, label: tlsOptions[$inputs.smtpTls.value] }}
onSelectedChange={(v) => ($inputs.smtpTls.value = v!.value)} onSelectedChange={(v) => ($inputs.smtpTls.value = v!.value)}
> >
<Select.Trigger> <Select.Trigger>
<Select.Value placeholder="Email TLS Option" /> <Select.Value placeholder={m.email_tls_option()} />
</Select.Trigger> </Select.Trigger>
<Select.Content> <Select.Content>
<Select.Item value="none" label="None" /> <Select.Item value="none" label="None" />
@@ -121,31 +122,31 @@
</div> </div>
<CheckboxWithLabel <CheckboxWithLabel
id="skip-cert-verify" id="skip-cert-verify"
label="Skip Certificate Verification" label={m.skip_certificate_verification()}
description="This can be useful for self-signed certificates." description={m.this_can_be_useful_for_selfsigned_certificates()}
bind:checked={$inputs.smtpSkipCertVerify.value} bind:checked={$inputs.smtpSkipCertVerify.value}
/> />
</div> </div>
<h4 class="mt-10 text-lg font-semibold">Enabled Emails</h4> <h4 class="mt-10 text-lg font-semibold">{m.enabled_emails()}</h4>
<div class="mt-4 flex flex-col gap-5"> <div class="mt-4 flex flex-col gap-5">
<CheckboxWithLabel <CheckboxWithLabel
id="email-login-notification" id="email-login-notification"
label="Email Login Notification" label={m.email_login_notification()}
description="Send an email to the user when they log in from a new device." description={m.send_an_email_to_the_user_when_they_log_in_from_a_new_device()}
bind:checked={$inputs.emailLoginNotificationEnabled.value} bind:checked={$inputs.emailLoginNotificationEnabled.value}
/> />
<CheckboxWithLabel <CheckboxWithLabel
id="email-login" id="email-login"
label="Email Login" label={m.email_login()}
description="Allows users to sign in with a login code sent to their email. This reduces the security significantly as anyone with access to the user's email can gain entry." description={m.allow_users_to_sign_in_with_a_login_code_sent_to_their_email()}
bind:checked={$inputs.emailOneTimeAccessEnabled.value} bind:checked={$inputs.emailOneTimeAccessEnabled.value}
/> />
</div> </div>
</fieldset> </fieldset>
<div class="mt-8 flex flex-wrap justify-end gap-3"> <div class="mt-8 flex flex-wrap justify-end gap-3">
<Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail} <Button isLoading={isSendingTestEmail} variant="secondary" onclick={onTestEmail}
>Send test email</Button >{m.send_test_email()}</Button
> >
<Button type="submit" disabled={uiConfigDisabled}>Save</Button> <Button type="submit" disabled={uiConfigDisabled}>{m.save()}</Button>
</div> </div>
</form> </form>

View File

@@ -3,6 +3,7 @@
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte'; import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
@@ -39,35 +40,35 @@
if (!data) return; if (!data) return;
isLoading = true; isLoading = true;
await callback(data).finally(() => (isLoading = false)); await callback(data).finally(() => (isLoading = false));
toast.success('Application configuration updated successfully'); toast.success(m.application_configuration_updated_successfully());
} }
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<fieldset class="flex flex-col gap-5" disabled={uiConfigDisabled}> <fieldset class="flex flex-col gap-5" disabled={uiConfigDisabled}>
<div class="flex flex-col gap-5"> <div class="flex flex-col gap-5">
<FormInput label="Application Name" bind:input={$inputs.appName} /> <FormInput label={m.application_name()} bind:input={$inputs.appName} />
<FormInput <FormInput
label="Session Duration" label={m.session_duration()}
type="number" type="number"
description="The duration of a session in minutes before the user has to sign in again." description={m.the_duration_of_a_session_in_minutes_before_the_user_has_to_sign_in_again()}
bind:input={$inputs.sessionDuration} bind:input={$inputs.sessionDuration}
/> />
<CheckboxWithLabel <CheckboxWithLabel
id="self-account-editing" id="self-account-editing"
label="Enable Self-Account Editing" label={m.enable_self_account_editing()}
description="Whether the users should be able to edit their own account details." description={m.whether_the_users_should_be_able_to_edit_their_own_account_details()}
bind:checked={$inputs.allowOwnAccountEdit.value} bind:checked={$inputs.allowOwnAccountEdit.value}
/> />
<CheckboxWithLabel <CheckboxWithLabel
id="emails-verified" id="emails-verified"
label="Emails Verified" label={m.emails_verified()}
description="Whether the user's email should be marked as verified for the OIDC clients." description={m.whether_the_users_email_should_be_marked_as_verified_for_the_oidc_clients()}
bind:checked={$inputs.emailsVerified.value} bind:checked={$inputs.emailsVerified.value}
/> />
</div> </div>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button> <Button {isLoading} type="submit">{m.save()}</Button>
</div> </div>
</fieldset> </fieldset>
</form> </form>

View File

@@ -3,6 +3,7 @@
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte'; import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import AppConfigService from '$lib/services/app-config-service'; import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration'; import type { AllAppConfig } from '$lib/types/application-configuration';
import { axiosErrorToast } from '$lib/utils/error-util'; import { axiosErrorToast } from '$lib/utils/error-util';
@@ -74,14 +75,14 @@
...data, ...data,
ldapEnabled: true ldapEnabled: true
}); });
toast.success('LDAP configuration updated successfully'); toast.success(m.ldap_configuration_updated_successfully());
return true; return true;
} }
async function onDisable() { async function onDisable() {
ldapEnabled = false; ldapEnabled = false;
await callback({ ldapEnabled }); await callback({ ldapEnabled });
toast.success('LDAP disabled successfully'); toast.success(m.ldap_disabled_successfully());
} }
async function onEnable() { async function onEnable() {
@@ -94,7 +95,7 @@
ldapSyncing = true; ldapSyncing = true;
await appConfigService await appConfigService
.syncLdap() .syncLdap()
.then(() => toast.success('LDAP sync finished')) .then(() => toast.success(m.ldap_sync_finished()))
.catch(axiosErrorToast); .catch(axiosErrorToast);
ldapSyncing = false; ldapSyncing = false;
@@ -102,98 +103,98 @@
</script> </script>
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<h4 class="text-lg font-semibold">Client Configuration</h4> <h4 class="text-lg font-semibold">{m.client_configuration()}</h4>
<fieldset disabled={uiConfigDisabled}> <fieldset disabled={uiConfigDisabled}>
<div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2"> <div class="mt-4 grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput <FormInput
label="LDAP URL" label={m.ldap_url()}
placeholder="ldap://example.com:389" placeholder="ldap://example.com:389"
bind:input={$inputs.ldapUrl} bind:input={$inputs.ldapUrl}
/> />
<FormInput <FormInput
label="LDAP Bind DN" label={m.ldap_bind_dn()}
placeholder="cn=people,dc=example,dc=com" placeholder="cn=people,dc=example,dc=com"
bind:input={$inputs.ldapBindDn} bind:input={$inputs.ldapBindDn}
/> />
<FormInput label="LDAP Bind Password" type="password" bind:input={$inputs.ldapBindPassword} /> <FormInput label={m.ldap_bind_password()} type="password" bind:input={$inputs.ldapBindPassword} />
<FormInput <FormInput
label="LDAP Base DN" label={m.ldap_base_dn()}
placeholder="dc=example,dc=com" placeholder="dc=example,dc=com"
bind:input={$inputs.ldapBase} bind:input={$inputs.ldapBase}
/> />
<FormInput <FormInput
label="User Search Filter" label={m.user_search_filter()}
description="The Search filter to use to search/sync users." description={m.the_search_filter_to_use_to_search_or_sync_users()}
placeholder="(objectClass=person)" placeholder="(objectClass=person)"
bind:input={$inputs.ldapUserSearchFilter} bind:input={$inputs.ldapUserSearchFilter}
/> />
<FormInput <FormInput
label="Groups Search Filter" label={m.groups_search_filter()}
description="The Search filter to use to search/sync groups." description={m.the_search_filter_to_use_to_search_or_sync_groups()}
placeholder="(objectClass=groupOfNames)" placeholder="(objectClass=groupOfNames)"
bind:input={$inputs.ldapUserGroupSearchFilter} bind:input={$inputs.ldapUserGroupSearchFilter}
/> />
<CheckboxWithLabel <CheckboxWithLabel
id="skip-cert-verify" id="skip-cert-verify"
label="Skip Certificate Verification" label={m.skip_certificate_verification()}
description="This can be useful for self-signed certificates." description={m.this_can_be_useful_for_selfsigned_certificates()}
bind:checked={$inputs.ldapSkipCertVerify.value} bind:checked={$inputs.ldapSkipCertVerify.value}
/> />
</div> </div>
<h4 class="mt-10 text-lg font-semibold">Attribute Mapping</h4> <h4 class="mt-10 text-lg font-semibold">{m.attribute_mapping()}</h4>
<div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2"> <div class="mt-4 grid grid-cols-1 items-end gap-5 md:grid-cols-2">
<FormInput <FormInput
label="User Unique Identifier Attribute" label={m.user_unique_identifier_attribute()}
description="The value of this attribute should never change." description={m.the_value_of_this_attribute_should_never_change()}
placeholder="uuid" placeholder="uuid"
bind:input={$inputs.ldapAttributeUserUniqueIdentifier} bind:input={$inputs.ldapAttributeUserUniqueIdentifier}
/> />
<FormInput <FormInput
label="Username Attribute" label={m.username_attribute()}
placeholder="uid" placeholder="uid"
bind:input={$inputs.ldapAttributeUserUsername} bind:input={$inputs.ldapAttributeUserUsername}
/> />
<FormInput <FormInput
label="User Mail Attribute" label={m.user_mail_attribute()}
placeholder="mail" placeholder="mail"
bind:input={$inputs.ldapAttributeUserEmail} bind:input={$inputs.ldapAttributeUserEmail}
/> />
<FormInput <FormInput
label="User First Name Attribute" label={m.user_first_name_attribute()}
placeholder="givenName" placeholder="givenName"
bind:input={$inputs.ldapAttributeUserFirstName} bind:input={$inputs.ldapAttributeUserFirstName}
/> />
<FormInput <FormInput
label="User Last Name Attribute" label={m.user_last_name_attribute()}
placeholder="sn" placeholder="sn"
bind:input={$inputs.ldapAttributeUserLastName} bind:input={$inputs.ldapAttributeUserLastName}
/> />
<FormInput <FormInput
label="User Profile Picture Attribute" label={m.user_profile_picture_attribute()}
description="The value of this attribute can either be a URL, a binary or a base64 encoded image." description={m.the_value_of_this_attribute_can_either_be_a_url_binary_or_base64_encoded_image()}
placeholder="jpegPhoto" placeholder="jpegPhoto"
bind:input={$inputs.ldapAttributeUserProfilePicture} bind:input={$inputs.ldapAttributeUserProfilePicture}
/> />
<FormInput <FormInput
label="Group Members Attribute" label={m.group_members_attribute()}
description="The attribute to use for querying members of a group." description={m.the_attribute_to_use_for_querying_members_of_a_group()}
placeholder="member" placeholder="member"
bind:input={$inputs.ldapAttributeGroupMember} bind:input={$inputs.ldapAttributeGroupMember}
/> />
<FormInput <FormInput
label="Group Unique Identifier Attribute" label={m.group_unique_identifier_attribute()}
description="The value of this attribute should never change." description={m.the_value_of_this_attribute_should_never_change()}
placeholder="uuid" placeholder="uuid"
bind:input={$inputs.ldapAttributeGroupUniqueIdentifier} bind:input={$inputs.ldapAttributeGroupUniqueIdentifier}
/> />
<FormInput <FormInput
label="Group Name Attribute" label={m.group_name_attribute()}
placeholder="cn" placeholder="cn"
bind:input={$inputs.ldapAttributeGroupName} bind:input={$inputs.ldapAttributeGroupName}
/> />
<FormInput <FormInput
label="Admin Group Name" label={m.admin_group_name()}
description="Members of this group will have Admin Privileges in Pocket ID." description={m.members_of_this_group_will_have_admin_privileges_in_pocketid()}
placeholder="_admin_group_name" placeholder="_admin_group_name"
bind:input={$inputs.ldapAttributeAdminGroup} bind:input={$inputs.ldapAttributeAdminGroup}
/> />
@@ -202,11 +203,11 @@
<div class="mt-8 flex flex-wrap justify-end gap-3"> <div class="mt-8 flex flex-wrap justify-end gap-3">
{#if ldapEnabled} {#if ldapEnabled}
<Button variant="secondary" onclick={onDisable} disabled={uiConfigDisabled}>Disable</Button> <Button variant="secondary" onclick={onDisable} disabled={uiConfigDisabled}>{m.disable()}</Button>
<Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>Sync now</Button> <Button variant="secondary" onclick={syncLdap} isLoading={ldapSyncing}>{m.sync_now()}</Button>
<Button type="submit" disabled={uiConfigDisabled}>Save</Button> <Button type="submit" disabled={uiConfigDisabled}>{m.save()}</Button>
{:else} {:else}
<Button onclick={onEnable} disabled={uiConfigDisabled}>Enable</Button> <Button onclick={onEnable} disabled={uiConfigDisabled}>{m.enable()}</Button>
{/if} {/if}
</div> </div>
</form> </form>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import Button from '$lib/components/ui/button/button.svelte'; import Button from '$lib/components/ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
import ApplicationImage from './application-image.svelte'; import ApplicationImage from './application-image.svelte';
let { let {
@@ -23,7 +24,7 @@
<ApplicationImage <ApplicationImage
id="favicon" id="favicon"
imageClass="h-14 w-14 p-2" imageClass="h-14 w-14 p-2"
label="Favicon" label={m.favicon()}
bind:image={favicon} bind:image={favicon}
imageURL="/api/application-configuration/favicon" imageURL="/api/application-configuration/favicon"
accept="image/x-icon" accept="image/x-icon"
@@ -31,7 +32,7 @@
<ApplicationImage <ApplicationImage
id="logo-light" id="logo-light"
imageClass="h-32 w-32" imageClass="h-32 w-32"
label="Light Mode Logo" label={m.light_mode_logo()}
bind:image={logoLight} bind:image={logoLight}
imageURL="/api/application-configuration/logo?light=true" imageURL="/api/application-configuration/logo?light=true"
forceColorScheme="light" forceColorScheme="light"
@@ -39,7 +40,7 @@
<ApplicationImage <ApplicationImage
id="logo-dark" id="logo-dark"
imageClass="h-32 w-32" imageClass="h-32 w-32"
label="Dark Mode Logo" label={m.dark_mode_logo()}
bind:image={logoDark} bind:image={logoDark}
imageURL="/api/application-configuration/logo?light=false" imageURL="/api/application-configuration/logo?light=false"
forceColorScheme="dark" forceColorScheme="dark"
@@ -47,13 +48,13 @@
<ApplicationImage <ApplicationImage
id="background-image" id="background-image"
imageClass="h-[350px] max-w-[500px]" imageClass="h-[350px] max-w-[500px]"
label="Background Image" label={m.background_image()}
bind:image={backgroundImage} bind:image={backgroundImage}
imageURL="/api/application-configuration/background-image" imageURL="/api/application-configuration/background-image"
/> />
</div> </div>
<div class="flex justify-end"> <div class="flex justify-end">
<Button class="mt-5" onclick={() => callback(logoLight, logoDark, backgroundImage, favicon)} <Button class="mt-5" onclick={() => callback(logoLight, logoDark, backgroundImage, favicon)}
>Save</Button >{m.save()}</Button
> >
</div> </div>

View File

@@ -12,6 +12,7 @@
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import OIDCClientForm from './oidc-client-form.svelte'; import OIDCClientForm from './oidc-client-form.svelte';
import OIDCClientList from './oidc-client-list.svelte'; import OIDCClientList from './oidc-client-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props(); let { data } = $props();
let clients = $state(data.clients); let clients = $state(data.clients);
@@ -29,7 +30,7 @@
const clientSecret = await oidcService.createClientSecret(createdClient.id); const clientSecret = await oidcService.createClientSecret(createdClient.id);
clientSecretStore.set(clientSecret); clientSecretStore.set(clientSecret);
goto(`/settings/admin/oidc-clients/${createdClient.id}`); goto(`/settings/admin/oidc-clients/${createdClient.id}`);
toast.success('OIDC client created successfully'); toast.success(m.oidc_client_created_successfully());
return true; return true;
} catch (e) { } catch (e) {
axiosErrorToast(e); axiosErrorToast(e);
@@ -39,18 +40,18 @@
</script> </script>
<svelte:head> <svelte:head>
<title>OIDC Clients</title> <title>{m.oidc_clients()}</title>
</svelte:head> </svelte:head>
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Card.Title>Create OIDC Client</Card.Title> <Card.Title>{m.create_oidc_client()}</Card.Title>
<Card.Description>Add a new OIDC client to {$appConfigStore.appName}.</Card.Description> <Card.Description>{m.add_a_new_oidc_client_to_appname({ appName: $appConfigStore.appName})}</Card.Description>
</div> </div>
{#if !expandAddClient} {#if !expandAddClient}
<Button on:click={() => (expandAddClient = true)}>Add OIDC Client</Button> <Button on:click={() => (expandAddClient = true)}>{m.add_oidc_client()}</Button>
{:else} {:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddClient = false)}> <Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddClient = false)}>
<LucideMinus class="h-5 w-5" /> <LucideMinus class="h-5 w-5" />
@@ -69,7 +70,7 @@
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title>Manage OIDC Clients</Card.Title> <Card.Title>{m.manage_oidc_clients()}</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<OIDCClientList {clients} requestOptions={clientsRequestOptions} /> <OIDCClientList {clients} requestOptions={clientsRequestOptions} />

View File

@@ -16,6 +16,7 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte'; import OidcForm from '../oidc-client-form.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props(); let { data } = $props();
let client = $state({ let client = $state({
@@ -27,13 +28,13 @@
const oidcService = new OidcService(); const oidcService = new OidcService();
const setupDetails = $state({ const setupDetails = $state({
'Authorization URL': `https://${$page.url.hostname}/authorize`, [m.authorization_url()]: `https://${$page.url.hostname}/authorize`,
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`, [m.oidc_discovery_url()]: `https://${$page.url.hostname}/.well-known/openid-configuration`,
'Token URL': `https://${$page.url.hostname}/api/oidc/token`, [m.token_url()]: `https://${$page.url.hostname}/api/oidc/token`,
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`, [m.userinfo_url()]: `https://${$page.url.hostname}/api/oidc/userinfo`,
'Logout URL': `https://${$page.url.hostname}/api/oidc/end-session`, [m.logout_url()]: `https://${$page.url.hostname}/api/oidc/end-session`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`, [m.certificate_url()]: `https://${$page.url.hostname}/.well-known/jwks.json`,
PKCE: client.pkceEnabled ? 'Enabled' : 'Disabled' [m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled()
}); });
async function updateClient(updatedClient: OidcClientCreateWithLogo) { async function updateClient(updatedClient: OidcClientCreateWithLogo) {
@@ -45,11 +46,11 @@
: Promise.resolve(); : Promise.resolve();
client.isPublic = updatedClient.isPublic; client.isPublic = updatedClient.isPublic;
setupDetails.PKCE = updatedClient.pkceEnabled ? 'Enabled' : 'Disabled'; setupDetails[m.pkce()] = updatedClient.pkceEnabled ? m.enabled() : m.disabled();
await Promise.all([dataPromise, imagePromise]) await Promise.all([dataPromise, imagePromise])
.then(() => { .then(() => {
toast.success('OIDC client updated successfully'); toast.success(m.oidc_client_updated_successfully());
}) })
.catch((e) => { .catch((e) => {
axiosErrorToast(e); axiosErrorToast(e);
@@ -61,17 +62,17 @@
async function createClientSecret() { async function createClientSecret() {
openConfirmDialog({ openConfirmDialog({
title: 'Create new client secret', title: m.create_new_client_secret(),
message: message:
'Are you sure you want to create a new client secret? The old one will be invalidated.', m.are_you_sure_you_want_to_create_a_new_client_secret(),
confirm: { confirm: {
label: 'Generate', label: m.generate(),
destructive: true, destructive: true,
action: async () => { action: async () => {
try { try {
const clientSecret = await oidcService.createClientSecret(client.id); const clientSecret = await oidcService.createClientSecret(client.id);
clientSecretStore.set(clientSecret); clientSecretStore.set(clientSecret);
toast.success('New client secret created successfully'); toast.success(m.new_client_secret_created_successfully());
} catch (e) { } catch (e) {
axiosErrorToast(e); axiosErrorToast(e);
} }
@@ -84,7 +85,7 @@
await oidcService await oidcService
.updateAllowedUserGroups(client.id, allowedGroups) .updateAllowedUserGroups(client.id, allowedGroups)
.then(() => { .then(() => {
toast.success('Allowed user groups updated successfully'); toast.success(m.allowed_user_groups_updated_successfully());
}) })
.catch((e) => { .catch((e) => {
axiosErrorToast(e); axiosErrorToast(e);
@@ -97,12 +98,12 @@
</script> </script>
<svelte:head> <svelte:head>
<title>OIDC Client {client.name}</title> <title>{m.oidc_client_name({ name: client.name })}</title>
</svelte:head> </svelte:head>
<div> <div>
<a class="text-muted-foreground flex text-sm" href="/settings/admin/oidc-clients" <a class="text-muted-foreground flex text-sm" href="/settings/admin/oidc-clients"
><LucideChevronLeft class="h-5 w-5" /> Back</a ><LucideChevronLeft class="h-5 w-5" /> {m.back()}</a
> >
</div> </div>
<Card.Root> <Card.Root>
@@ -112,14 +113,14 @@
<Card.Content> <Card.Content>
<div class="flex flex-col"> <div class="flex flex-col">
<div class="mb-2 flex flex-col sm:flex-row sm:items-center"> <div class="mb-2 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">Client ID</Label> <Label class="mb-0 w-44">{m.client_id()}</Label>
<CopyToClipboard value={client.id}> <CopyToClipboard value={client.id}>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span> <span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</CopyToClipboard> </CopyToClipboard>
</div> </div>
{#if !client.isPublic} {#if !client.isPublic}
<div class="mb-2 mt-1 flex flex-col sm:flex-row sm:items-center"> <div class="mb-2 mt-1 flex flex-col sm:flex-row sm:items-center">
<Label class="mb-0 w-44">Client secret</Label> <Label class="mb-0 w-44">{m.client_secret()}</Label>
{#if $clientSecretStore} {#if $clientSecretStore}
<CopyToClipboard value={$clientSecretStore}> <CopyToClipboard value={$clientSecretStore}>
<span class="text-muted-foreground text-sm" data-testid="client-secret"> <span class="text-muted-foreground text-sm" data-testid="client-secret">
@@ -158,7 +159,7 @@
{#if !showAllDetails} {#if !showAllDetails}
<div class="mt-4 flex justify-center"> <div class="mt-4 flex justify-center">
<Button on:click={() => (showAllDetails = true)} size="sm" variant="ghost" <Button on:click={() => (showAllDetails = true)} size="sm" variant="ghost"
>Show more details</Button >{m.show_more_details()}</Button
> >
</div> </div>
{/if} {/if}
@@ -172,11 +173,11 @@
</Card.Root> </Card.Root>
<CollapsibleCard <CollapsibleCard
id="allowed-user-groups" id="allowed-user-groups"
title="Allowed User Groups" title={m.allowed_user_groups()}
description="Add user groups to this client to restrict access to users in these groups. If no user groups are selected, all users will have access to this client." description={m.add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups()}
> >
<UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} /> <UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} />
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button on:click={() => updateUserGroupClients(client.allowedUserGroupIds)}>Save</Button> <Button on:click={() => updateUserGroupClients(client.allowedUserGroupIds)}>{m.save()}</Button>
</div> </div>
</CollapsibleCard> </CollapsibleCard>

View File

@@ -2,6 +2,7 @@
import * as Dialog from '$lib/components/ui/dialog'; import * as Dialog from '$lib/components/ui/dialog';
import Input from '$lib/components/ui/input/input.svelte'; import Input from '$lib/components/ui/input/input.svelte';
import Label from '$lib/components/ui/label/label.svelte'; import Label from '$lib/components/ui/label/label.svelte';
import { m } from '$lib/paraglide/messages';
let { let {
oneTimeLink = $bindable() oneTimeLink = $bindable()
@@ -19,13 +20,12 @@
<Dialog.Root open={!!oneTimeLink} {onOpenChange}> <Dialog.Root open={!!oneTimeLink} {onOpenChange}>
<Dialog.Content class="max-w-md"> <Dialog.Content class="max-w-md">
<Dialog.Header> <Dialog.Header>
<Dialog.Title>One Time Link</Dialog.Title> <Dialog.Title>{m.one_time_link()}</Dialog.Title>
<Dialog.Description <Dialog.Description
>Use this link to sign in once. This is needed for users who haven't added a passkey yet or >{m.use_this_link_to_sign_in_once()}</Dialog.Description
have lost it.</Dialog.Description
> >
</Dialog.Header> </Dialog.Header>
<Label for="one-time-link">One Time Link</Label> <Label for="one-time-link">{m.one_time_link()}</Label>
<Input id="one-time-link" value={oneTimeLink} readonly /> <Input id="one-time-link" value={oneTimeLink} readonly />
</Dialog.Content> </Dialog.Content>
</Dialog.Root> </Dialog.Root>

View File

@@ -2,6 +2,7 @@
import FormInput from '$lib/components/form/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { Input } from '$lib/components/ui/input'; import { Input } from '$lib/components/ui/input';
import { m } from '$lib/paraglide/messages';
import { LucideMinus, LucidePlus } from 'lucide-svelte'; import { LucideMinus, LucidePlus } from 'lucide-svelte';
import type { Snippet } from 'svelte'; import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements'; import type { HTMLAttributes } from 'svelte/elements';
@@ -53,7 +54,7 @@
on:click={() => (callbackURLs = [...callbackURLs, ''])} on:click={() => (callbackURLs = [...callbackURLs, ''])}
> >
<LucidePlus class="mr-1 h-4 w-4" /> <LucidePlus class="mr-1 h-4 w-4" />
{callbackURLs.length === 0 ? 'Add' : 'Add another'} {callbackURLs.length === 0 ? m.add() : m.add_another()}
</Button> </Button>
{/if} {/if}
</div> </div>

View File

@@ -12,6 +12,7 @@
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
import { z } from 'zod'; import { z } from 'zod';
import OidcCallbackUrlInput from './oidc-callback-url-input.svelte'; import OidcCallbackUrlInput from './oidc-callback-url-input.svelte';
import { m } from '$lib/paraglide/messages';
let { let {
callback, callback,
@@ -79,16 +80,16 @@
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<div class="grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-7 sm:flex-row"> <div class="grid grid-cols-1 md:grid-cols-2 gap-x-3 gap-y-7 sm:flex-row">
<FormInput label="Name" class="w-full" bind:input={$inputs.name} /> <FormInput label={m.name()} class="w-full" bind:input={$inputs.name} />
<div></div> <div></div>
<OidcCallbackUrlInput <OidcCallbackUrlInput
label="Callback URLs" label={m.callback_urls()}
class="w-full" class="w-full"
bind:callbackURLs={$inputs.callbackURLs.value} bind:callbackURLs={$inputs.callbackURLs.value}
bind:error={$inputs.callbackURLs.error} bind:error={$inputs.callbackURLs.error}
/> />
<OidcCallbackUrlInput <OidcCallbackUrlInput
label="Logout Callback URLs" label={m.logout_callback_urls()}
class="w-full" class="w-full"
allowEmpty allowEmpty
bind:callbackURLs={$inputs.logoutCallbackURLs.value} bind:callbackURLs={$inputs.logoutCallbackURLs.value}
@@ -96,8 +97,8 @@
/> />
<CheckboxWithLabel <CheckboxWithLabel
id="public-client" id="public-client"
label="Public Client" label={m.public_client()}
description="Public clients do not have a client secret and use PKCE instead. Enable this if your client is a SPA or mobile app." description={m.public_clients_do_not_have_a_client_secret_and_use_pkce_instead()}
onCheckedChange={(v) => { onCheckedChange={(v) => {
if (v == true) form.setValue('pkceEnabled', true); if (v == true) form.setValue('pkceEnabled', true);
}} }}
@@ -105,21 +106,21 @@
/> />
<CheckboxWithLabel <CheckboxWithLabel
id="pkce" id="pkce"
label="PKCE" label={m.pkce()}
description="Public Key Code Exchange is a security feature to prevent CSRF and authorization code interception attacks." description={m.public_key_code_exchange_is_a_security_feature_to_prevent_csrf_and_authorization_code_interception_attacks()}
disabled={$inputs.isPublic.value} disabled={$inputs.isPublic.value}
bind:checked={$inputs.pkceEnabled.value} bind:checked={$inputs.pkceEnabled.value}
/> />
</div> </div>
<div class="mt-8"> <div class="mt-8">
<Label for="logo">Logo</Label> <Label for="logo">{m.logo()}</Label>
<div class="mt-2 flex items-end gap-3"> <div class="mt-2 flex items-end gap-3">
{#if logoDataURL} {#if logoDataURL}
<div class="bg-muted h-32 w-32 rounded-2xl p-3"> <div class="bg-muted h-32 w-32 rounded-2xl p-3">
<img <img
class="m-auto max-h-full max-w-full object-contain" class="m-auto max-h-full max-w-full object-contain"
src={logoDataURL} src={logoDataURL}
alt={`${$inputs.name.value} logo`} alt={m.name_logo({name: $inputs.name.value})}
/> />
</div> </div>
{/if} {/if}
@@ -131,17 +132,17 @@
onchange={onLogoChange} onchange={onLogoChange}
> >
<Button variant="secondary"> <Button variant="secondary">
{logoDataURL ? 'Change Logo' : 'Upload Logo'} {logoDataURL ? m.change_logo() : m.upload_logo()}
</Button> </Button>
</FileInput> </FileInput>
{#if logoDataURL} {#if logoDataURL}
<Button variant="outline" on:click={resetLogo}>Remove Logo</Button> <Button variant="outline" on:click={resetLogo}>{m.remove_logo()}</Button>
{/if} {/if}
</div> </div>
</div> </div>
</div> </div>
<div class="w-full"></div> <div class="w-full"></div>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button> <Button {isLoading} type="submit">{m.save()}</Button>
</div> </div>
</form> </form>

View File

@@ -10,6 +10,7 @@
import { LucidePencil, LucideTrash } from 'lucide-svelte'; import { LucidePencil, LucideTrash } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import OneTimeLinkModal from './client-secret.svelte'; import OneTimeLinkModal from './client-secret.svelte';
import { m } from '$lib/paraglide/messages';
let { let {
clients = $bindable(), clients = $bindable(),
@@ -25,16 +26,16 @@
async function deleteClient(client: OidcClient) { async function deleteClient(client: OidcClient) {
openConfirmDialog({ openConfirmDialog({
title: `Delete ${client.name}`, title: m.delete_name({name: client.name}),
message: 'Are you sure you want to delete this OIDC client?', message: m.are_you_sure_you_want_to_delete_this_oidc_client(),
confirm: { confirm: {
label: 'Delete', label: m.delete(),
destructive: true, destructive: true,
action: async () => { action: async () => {
try { try {
await oidcService.removeClient(client.id); await oidcService.removeClient(client.id);
clients = await oidcService.listClients(requestOptions!); clients = await oidcService.listClients(requestOptions!);
toast.success('OIDC client deleted successfully'); toast.success(m.oidc_client_deleted_successfully());
} catch (e) { } catch (e) {
axiosErrorToast(e); axiosErrorToast(e);
} }
@@ -49,9 +50,9 @@
{requestOptions} {requestOptions}
onRefresh={async (o) => (clients = await oidcService.listClients(o))} onRefresh={async (o) => (clients = await oidcService.listClients(o))}
columns={[ columns={[
{ label: 'Logo' }, { label: m.logo() },
{ label: 'Name', sortColumn: 'name' }, { label: m.name(), sortColumn: 'name' },
{ label: 'Actions', hidden: true } { label: m.actions(), hidden: true }
]} ]}
> >
{#snippet rows({ item })} {#snippet rows({ item })}
@@ -61,7 +62,7 @@
<img <img
class="m-auto max-h-full max-w-full object-contain" class="m-auto max-h-full max-w-full object-contain"
src="/api/oidc/clients/{item.id}/logo" src="/api/oidc/clients/{item.id}/logo"
alt="{item.name} logo" alt={m.name_logo({name: item.name})}
/> />
</div> </div>
{/if} {/if}
@@ -72,9 +73,9 @@
href="/settings/admin/oidc-clients/{item.id}" href="/settings/admin/oidc-clients/{item.id}"
size="sm" size="sm"
variant="outline" variant="outline"
aria-label="Edit"><LucidePencil class="h-3 w-3 " /></Button aria-label={m.edit()}><LucidePencil class="h-3 w-3 " /></Button
> >
<Button on:click={() => deleteClient(item)} size="sm" variant="outline" aria-label="Delete" <Button on:click={() => deleteClient(item)} size="sm" variant="outline" aria-label={m.delete()}
><LucideTrash class="h-3 w-3 text-red-500" /></Button ><LucideTrash class="h-3 w-3 text-red-500" /></Button
> >
</Table.Cell> </Table.Cell>

View File

@@ -11,6 +11,7 @@
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import UserGroupForm from './user-group-form.svelte'; import UserGroupForm from './user-group-form.svelte';
import UserGroupList from './user-group-list.svelte'; import UserGroupList from './user-group-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props(); let { data } = $props();
let userGroups = $state(data.userGroups); let userGroups = $state(data.userGroups);
@@ -24,7 +25,7 @@
await userGroupService await userGroupService
.create(userGroup) .create(userGroup)
.then((createdUserGroup) => { .then((createdUserGroup) => {
toast.success('User group created successfully'); toast.success(m.user_group_created_successfully());
goto(`/settings/admin/user-groups/${createdUserGroup.id}`); goto(`/settings/admin/user-groups/${createdUserGroup.id}`);
}) })
.catch((e) => { .catch((e) => {
@@ -36,18 +37,18 @@
</script> </script>
<svelte:head> <svelte:head>
<title>User Groups</title> <title>{m.user_groups()}</title>
</svelte:head> </svelte:head>
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Card.Title>Create User Group</Card.Title> <Card.Title>{m.create_user_group()}</Card.Title>
<Card.Description>Create a new group that can be assigned to users.</Card.Description> <Card.Description>{m.create_a_new_group_that_can_be_assigned_to_users()}</Card.Description>
</div> </div>
{#if !expandAddUserGroup} {#if !expandAddUserGroup}
<Button on:click={() => (expandAddUserGroup = true)}>Add Group</Button> <Button on:click={() => (expandAddUserGroup = true)}>{m.add_group()}</Button>
{:else} {:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUserGroup = false)}> <Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUserGroup = false)}>
<LucideMinus class="h-5 w-5" /> <LucideMinus class="h-5 w-5" />
@@ -66,7 +67,7 @@
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title>Manage User Groups</Card.Title> <Card.Title>{m.manage_user_groups()}</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<UserGroupList {userGroups} requestOptions={userGroupsRequestOptions} /> <UserGroupList {userGroups} requestOptions={userGroupsRequestOptions} />

View File

@@ -13,6 +13,7 @@
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import UserGroupForm from '../user-group-form.svelte'; import UserGroupForm from '../user-group-form.svelte';
import UserSelection from '../user-selection.svelte'; import UserSelection from '../user-selection.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props(); let { data } = $props();
let userGroup = $state({ let userGroup = $state({
@@ -27,7 +28,7 @@
let success = true; let success = true;
await userGroupService await userGroupService
.update(userGroup.id, updatedUserGroup) .update(userGroup.id, updatedUserGroup)
.then(() => toast.success('User group updated successfully')) .then(() => toast.success(m.user_group_updated_successfully()))
.catch((e) => { .catch((e) => {
axiosErrorToast(e); axiosErrorToast(e);
success = false; success = false;
@@ -39,7 +40,7 @@
async function updateUserGroupUsers(userIds: string[]) { async function updateUserGroupUsers(userIds: string[]) {
await userGroupService await userGroupService
.updateUsers(userGroup.id, userIds) .updateUsers(userGroup.id, userIds)
.then(() => toast.success('Users updated successfully')) .then(() => toast.success(m.users_updated_successfully()))
.catch((e) => { .catch((e) => {
axiosErrorToast(e); axiosErrorToast(e);
}); });
@@ -48,7 +49,7 @@
async function updateCustomClaims() { async function updateCustomClaims() {
await customClaimService await customClaimService
.updateUserGroupCustomClaims(userGroup.id, userGroup.customClaims) .updateUserGroupCustomClaims(userGroup.id, userGroup.customClaims)
.then(() => toast.success('Custom claims updated successfully')) .then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => { .catch((e) => {
axiosErrorToast(e); axiosErrorToast(e);
}); });
@@ -56,20 +57,20 @@
</script> </script>
<svelte:head> <svelte:head>
<title>User Group Details {userGroup.name}</title> <title>{m.user_group_details_name({ name: userGroup.name })}</title>
</svelte:head> </svelte:head>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<a class="text-muted-foreground flex text-sm" href="/settings/admin/user-groups" <a class="text-muted-foreground flex text-sm" href="/settings/admin/user-groups"
><LucideChevronLeft class="h-5 w-5" /> Back</a ><LucideChevronLeft class="h-5 w-5" /> {m.back()}</a
> >
{#if !!userGroup.ldapId} {#if !!userGroup.ldapId}
<Badge variant="default" class="">LDAP</Badge> <Badge variant="default" class="">{m.ldap()}</Badge>
{/if} {/if}
</div> </div>
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title>General</Card.Title> <Card.Title>{m.general()}</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
@@ -79,8 +80,8 @@
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title>Users</Card.Title> <Card.Title>{m.users()}</Card.Title>
<Card.Description>Assign users to this group.</Card.Description> <Card.Description>{m.assign_users_to_this_group()}</Card.Description>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
@@ -91,7 +92,7 @@
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button <Button
disabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled} disabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
on:click={() => updateUserGroupUsers(userGroup.userIds)}>Save</Button on:click={() => updateUserGroupUsers(userGroup.userIds)}>{m.save()}</Button
> >
</div> </div>
</Card.Content> </Card.Content>
@@ -99,11 +100,11 @@
<CollapsibleCard <CollapsibleCard
id="user-group-custom-claims" id="user-group-custom-claims"
title="Custom Claims" title={m.custom_claims()}
description="Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested. Custom claims defined on the user will be prioritized if there are conflicts." description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user_prioritized()}
> >
<CustomClaimsInput bind:customClaims={userGroup.customClaims} /> <CustomClaimsInput bind:customClaims={userGroup.customClaims} />
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button> <Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div> </div>
</CollapsibleCard> </CollapsibleCard>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { UserGroupCreate } from '$lib/types/user-group.type'; import type { UserGroupCreate } from '$lib/types/user-group.type';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
@@ -60,23 +61,23 @@
<div class="flex flex-col gap-3 sm:flex-row"> <div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full"> <div class="w-full">
<FormInput <FormInput
label="Friendly Name" label={m.friendly_name()}
description="Name that will be displayed in the UI" description={m.name_that_will_be_displayed_in_the_ui()}
bind:input={$inputs.friendlyName} bind:input={$inputs.friendlyName}
onInput={onFriendlyNameInput} onInput={onFriendlyNameInput}
/> />
</div> </div>
<div class="w-full"> <div class="w-full">
<FormInput <FormInput
label="Name" label={m.name()}
description={`Name that will be in the "groups" claim`} description={m.name_that_will_be_in_the_groups_claim()}
bind:input={$inputs.name} bind:input={$inputs.name}
onInput={onNameInput} onInput={onNameInput}
/> />
</div> </div>
</div> </div>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button> <Button {isLoading} type="submit">{m.save()}</Button>
</div> </div>
</fieldset> </fieldset>
</form> </form>

View File

@@ -5,6 +5,7 @@
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import UserGroupService from '$lib/services/user-group-service'; import UserGroupService from '$lib/services/user-group-service';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
@@ -26,16 +27,16 @@
async function deleteUserGroup(userGroup: UserGroup) { async function deleteUserGroup(userGroup: UserGroup) {
openConfirmDialog({ openConfirmDialog({
title: `Delete ${userGroup.name}`, title: m.delete_name({ name: userGroup.name }),
message: 'Are you sure you want to delete this user group?', message: m.are_you_sure_you_want_to_delete_this_user_group(),
confirm: { confirm: {
label: 'Delete', label: m.delete(),
destructive: true, destructive: true,
action: async () => { action: async () => {
try { try {
await userGroupService.remove(userGroup.id); await userGroupService.remove(userGroup.id);
userGroups = await userGroupService.list(requestOptions!); userGroups = await userGroupService.list(requestOptions!);
toast.success('User group deleted successfully'); toast.success(m.user_group_deleted_successfully());
} catch (e) { } catch (e) {
axiosErrorToast(e); axiosErrorToast(e);
} }
@@ -50,11 +51,11 @@
onRefresh={async (o) => (userGroups = await userGroupService.list(o))} onRefresh={async (o) => (userGroups = await userGroupService.list(o))}
{requestOptions} {requestOptions}
columns={[ columns={[
{ label: 'Friendly Name', sortColumn: 'friendlyName' }, { label: m.friendly_name(), sortColumn: 'friendlyName' },
{ label: 'Name', sortColumn: 'name' }, { label: m.name(), sortColumn: 'name' },
{ label: 'User Count', sortColumn: 'userCount' }, { label: m.user_count(), sortColumn: 'userCount' },
...($appConfigStore.ldapEnabled ? [{ label: 'Source' }] : []), ...($appConfigStore.ldapEnabled ? [{ label: m.source() }] : []),
{ label: 'Actions', hidden: true } { label: m.actions(), hidden: true }
]} ]}
> >
{#snippet rows({ item })} {#snippet rows({ item })}
@@ -63,7 +64,7 @@
<Table.Cell>{item.userCount}</Table.Cell> <Table.Cell>{item.userCount}</Table.Cell>
{#if $appConfigStore.ldapEnabled} {#if $appConfigStore.ldapEnabled}
<Table.Cell> <Table.Cell>
<Badge variant={item.ldapId ? 'default' : 'outline'}>{item.ldapId ? 'LDAP' : 'Local'}</Badge <Badge variant={item.ldapId ? 'default' : 'outline'}>{item.ldapId ? m.ldap() : m.local()}</Badge
> >
</Table.Cell> </Table.Cell>
{/if} {/if}
@@ -72,18 +73,18 @@
<DropdownMenu.Trigger asChild let:builder> <DropdownMenu.Trigger asChild let:builder>
<Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}> <Button aria-haspopup="true" size="icon" variant="ghost" builders={[builder]}>
<Ellipsis class="h-4 w-4" /> <Ellipsis class="h-4 w-4" />
<span class="sr-only">Toggle menu</span> <span class="sr-only">{m.toggle_menu()}</span>
</Button> </Button>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content align="end"> <DropdownMenu.Content align="end">
<DropdownMenu.Item href="/settings/admin/user-groups/{item.id}" <DropdownMenu.Item href="/settings/admin/user-groups/{item.id}"
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item ><LucidePencil class="mr-2 h-4 w-4" /> {m.edit()}</DropdownMenu.Item
> >
{#if !item.ldapId || !$appConfigStore.ldapEnabled} {#if !item.ldapId || !$appConfigStore.ldapEnabled}
<DropdownMenu.Item <DropdownMenu.Item
class="text-red-500 focus:!text-red-700" class="text-red-500 focus:!text-red-700"
on:click={() => deleteUserGroup(item)} on:click={() => deleteUserGroup(item)}
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item ><LucideTrash class="mr-2 h-4 w-4" />{m.delete()}</DropdownMenu.Item
> >
{/if} {/if}
</DropdownMenu.Content> </DropdownMenu.Content>

View File

@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import AdvancedTable from '$lib/components/advanced-table.svelte'; import AdvancedTable from '$lib/components/advanced-table.svelte';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service'; import UserService from '$lib/services/user-service';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
import type { User } from '$lib/types/user.type'; import type { User } from '$lib/types/user.type';
@@ -35,8 +36,8 @@
onRefresh={async (o) => (users = await userService.list(o))} onRefresh={async (o) => (users = await userService.list(o))}
{requestOptions} {requestOptions}
columns={[ columns={[
{ label: 'Name', sortColumn: 'firstName' }, { label: m.name(), sortColumn: 'firstName' },
{ label: 'Email', sortColumn: 'email' } { label: m.email(), sortColumn: 'email' }
]} ]}
bind:selectedIds={selectedUserIds} bind:selectedIds={selectedUserIds}
{selectionDisabled} {selectionDisabled}

View File

@@ -10,6 +10,7 @@
import { slide } from 'svelte/transition'; import { slide } from 'svelte/transition';
import UserForm from './user-form.svelte'; import UserForm from './user-form.svelte';
import UserList from './user-list.svelte'; import UserList from './user-list.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props(); let { data } = $props();
let users = $state(data.users); let users = $state(data.users);
@@ -23,7 +24,7 @@
let success = true; let success = true;
await userService await userService
.create(user) .create(user)
.then(() => toast.success('User created successfully')) .then(() => toast.success(m.user_created_successfully()))
.catch((e) => { .catch((e) => {
axiosErrorToast(e); axiosErrorToast(e);
success = false; success = false;
@@ -35,18 +36,18 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Users</title> <title>{m.users()}</title>
</svelte:head> </svelte:head>
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<div> <div>
<Card.Title>Create User</Card.Title> <Card.Title>{m.create_user()}</Card.Title>
<Card.Description>Add a new user to {$appConfigStore.appName}.</Card.Description> <Card.Description>{m.add_a_new_user_to_appname({ appName: $appConfigStore.appName })}.</Card.Description>
</div> </div>
{#if !expandAddUser} {#if !expandAddUser}
<Button on:click={() => (expandAddUser = true)}>Add User</Button> <Button on:click={() => (expandAddUser = true)}>{m.add_user()}</Button>
{:else} {:else}
<Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUser = false)}> <Button class="h-8 p-3" variant="ghost" on:click={() => (expandAddUser = false)}>
<LucideMinus class="h-5 w-5" /> <LucideMinus class="h-5 w-5" />
@@ -65,7 +66,7 @@
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title>Manage Users</Card.Title> <Card.Title>{m.manage_users()}</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<UserList {users} requestOptions={usersRequestOptions} /> <UserList {users} requestOptions={usersRequestOptions} />

View File

@@ -14,6 +14,7 @@
import { LucideChevronLeft } from 'lucide-svelte'; import { LucideChevronLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import UserForm from '../user-form.svelte'; import UserForm from '../user-form.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props(); let { data } = $props();
let user = $state({ let user = $state({
@@ -27,7 +28,7 @@
async function updateUserGroups(userIds: string[]) { async function updateUserGroups(userIds: string[]) {
await userService await userService
.updateUserGroups(user.id, userIds) .updateUserGroups(user.id, userIds)
.then(() => toast.success('User groups updated successfully')) .then(() => toast.success(m.user_groups_updated_successfully()))
.catch((e) => { .catch((e) => {
axiosErrorToast(e); axiosErrorToast(e);
}); });
@@ -37,7 +38,7 @@
let success = true; let success = true;
await userService await userService
.update(user.id, updatedUser) .update(user.id, updatedUser)
.then(() => toast.success('User updated successfully')) .then(() => toast.success(m.user_updated_successfully()))
.catch((e) => { .catch((e) => {
axiosErrorToast(e); axiosErrorToast(e);
success = false; success = false;
@@ -49,7 +50,7 @@
async function updateCustomClaims() { async function updateCustomClaims() {
await customClaimService await customClaimService
.updateUserCustomClaims(user.id, user.customClaims) .updateUserCustomClaims(user.id, user.customClaims)
.then(() => toast.success('Custom claims updated successfully')) .then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => { .catch((e) => {
axiosErrorToast(e); axiosErrorToast(e);
}); });
@@ -58,33 +59,38 @@
async function updateProfilePicture(image: File) { async function updateProfilePicture(image: File) {
await userService await userService
.updateProfilePicture(user.id, image) .updateProfilePicture(user.id, image)
.then(() => toast.success('Profile picture updated successfully. It may take a few minutes to update.')) .then(() => toast.success(m.profile_picture_updated_successfully()))
.catch(axiosErrorToast); .catch(axiosErrorToast);
} }
async function resetProfilePicture() { async function resetProfilePicture() {
await userService await userService
.resetProfilePicture(user.id) .resetProfilePicture(user.id)
.then(() => toast.success('Profile picture has been reset. It may take a few minutes to update.')) .then(() => toast.success(m.profile_picture_has_been_reset()))
.catch(axiosErrorToast); .catch(axiosErrorToast);
} }
</script> </script>
<svelte:head> <svelte:head>
<title>User Details {user.firstName} {user.lastName}</title> <title
>{m.user_details_firstname_lastname({
firstName: user.firstName,
lastName: user.lastName
})}</title
>
</svelte:head> </svelte:head>
<div class="flex items-center justify-between"> <div class="flex items-center justify-between">
<a class="text-muted-foreground flex text-sm" href="/settings/admin/users" <a class="text-muted-foreground flex text-sm" href="/settings/admin/users"
><LucideChevronLeft class="h-5 w-5" /> Back</a ><LucideChevronLeft class="h-5 w-5" /> {m.back()}</a
> >
{#if !!user.ldapId} {#if !!user.ldapId}
<Badge variant="default" class="">LDAP</Badge> <Badge variant="default" class="">{m.ldap()}</Badge>
{/if} {/if}
</div> </div>
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title>General</Card.Title> <Card.Title>{m.general()}</Card.Title>
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>
<UserForm existingUser={user} callback={updateUser} /> <UserForm existingUser={user} callback={updateUser} />
@@ -104,8 +110,8 @@
<CollapsibleCard <CollapsibleCard
id="user-groups" id="user-groups"
title="User Groups" title={m.user_groups()}
description="Manage which groups this user belongs to." description={m.manage_which_groups_this_user_belongs_to()}
> >
<UserGroupSelection <UserGroupSelection
bind:selectedGroupIds={user.userGroupIds} bind:selectedGroupIds={user.userGroupIds}
@@ -115,18 +121,18 @@
<Button <Button
on:click={() => updateUserGroups(user.userGroupIds)} on:click={() => updateUserGroups(user.userGroupIds)}
disabled={!!user.ldapId && $appConfigStore.ldapEnabled} disabled={!!user.ldapId && $appConfigStore.ldapEnabled}
type="submit">Save</Button type="submit">{m.save()}</Button
> >
</div> </div>
</CollapsibleCard> </CollapsibleCard>
<CollapsibleCard <CollapsibleCard
id="user-custom-claims" id="user-custom-claims"
title="Custom Claims" title={m.custom_claims()}
description="Custom claims are key-value pairs that can be used to store additional information about a user. These claims will be included in the ID token if the scope 'profile' is requested." description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user()}
> >
<CustomClaimsInput bind:customClaims={user.customClaims} /> <CustomClaimsInput bind:customClaims={user.customClaims} />
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button on:click={updateCustomClaims} type="submit">Save</Button> <Button on:click={updateCustomClaims} type="submit">{m.save()}</Button>
</div> </div>
</CollapsibleCard> </CollapsibleCard>

View File

@@ -2,6 +2,7 @@
import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte'; import CheckboxWithLabel from '$lib/components/form/checkbox-with-label.svelte';
import FormInput from '$lib/components/form/form-input.svelte'; import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button'; import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store'; import appConfigStore from '$lib/stores/application-configuration-store';
import type { User, UserCreate } from '$lib/types/user.type'; import type { User, UserCreate } from '$lib/types/user.type';
import { createForm } from '$lib/utils/form-util'; import { createForm } from '$lib/utils/form-util';
@@ -35,7 +36,7 @@
.max(30) .max(30)
.regex( .regex(
/^[a-z0-9_@.-]+$/, /^[a-z0-9_@.-]+$/,
"Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols" m.username_can_only_contain()
), ),
email: z.string().email(), email: z.string().email(),
isAdmin: z.boolean() isAdmin: z.boolean()
@@ -57,19 +58,19 @@
<form onsubmit={onSubmit}> <form onsubmit={onSubmit}>
<fieldset disabled={inputDisabled}> <fieldset disabled={inputDisabled}>
<div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2"> <div class="grid grid-cols-1 items-start gap-5 md:grid-cols-2">
<FormInput label="First name" bind:input={$inputs.firstName} /> <FormInput label={m.first_name()} bind:input={$inputs.firstName} />
<FormInput label="Last name" bind:input={$inputs.lastName} /> <FormInput label={m.last_name()} bind:input={$inputs.lastName} />
<FormInput label="Username" bind:input={$inputs.username} /> <FormInput label={m.username()} bind:input={$inputs.username} />
<FormInput label="Email" bind:input={$inputs.email} /> <FormInput label={m.email()} bind:input={$inputs.email} />
<CheckboxWithLabel <CheckboxWithLabel
id="admin-privileges" id="admin-privileges"
label="Admin Privileges" label={m.admin_privileges()}
description="Admins have full access to the admin panel." description={m.admins_have_full_access_to_the_admin_panel()}
bind:checked={$inputs.isAdmin.value} bind:checked={$inputs.isAdmin.value}
/> />
</div> </div>
<div class="mt-5 flex justify-end"> <div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button> <Button {isLoading} type="submit">{m.save()}</Button>
</div> </div>
</fieldset> </fieldset>
</form> </form>

View File

@@ -15,6 +15,7 @@
import Ellipsis from 'lucide-svelte/icons/ellipsis'; import Ellipsis from 'lucide-svelte/icons/ellipsis';
import { toast } from 'svelte-sonner'; import { toast } from 'svelte-sonner';
import OneTimeLinkModal from '$lib/components/one-time-link-modal.svelte'; import OneTimeLinkModal from '$lib/components/one-time-link-modal.svelte';
import { m } from '$lib/paraglide/messages';
let { let {
users = $bindable(), users = $bindable(),
@@ -27,10 +28,10 @@
async function deleteUser(user: User) { async function deleteUser(user: User) {
openConfirmDialog({ openConfirmDialog({
title: `Delete ${user.firstName} ${user.lastName}`, title: m.delete_firstname_lastname({firstName: user.firstName, lastName: user.lastName}),
message: 'Are you sure you want to delete this user?', message: m.are_you_sure_you_want_to_delete_this_user(),
confirm: { confirm: {
label: 'Delete', label: m.delete(),
destructive: true, destructive: true,
action: async () => { action: async () => {
try { try {
@@ -39,7 +40,7 @@
} catch (e) { } catch (e) {
axiosErrorToast(e); axiosErrorToast(e);
} }
toast.success('User deleted successfully'); toast.success(m.user_deleted_successfully());
} }
} }
}); });
@@ -51,13 +52,13 @@
{requestOptions} {requestOptions}
onRefresh={async (options) => (users = await userService.list(options))} onRefresh={async (options) => (users = await userService.list(options))}
columns={[ columns={[
{ label: 'First name', sortColumn: 'firstName' }, { label: m.first_name(), sortColumn: 'firstName' },
{ label: 'Last name', sortColumn: 'lastName' }, { label: m.last_name(), sortColumn: 'lastName' },
{ label: 'Email', sortColumn: 'email' }, { label: m.email(), sortColumn: 'email' },
{ label: 'Username', sortColumn: 'username' }, { label: m.username(), sortColumn: 'username' },
{ label: 'Role', sortColumn: 'isAdmin' }, { label: m.role(), sortColumn: 'isAdmin' },
...($appConfigStore.ldapEnabled ? [{ label: 'Source' }] : []), ...($appConfigStore.ldapEnabled ? [{ label: m.source()}] : []),
{ label: 'Actions', hidden: true } { label: m.actions(), hidden: true }
]} ]}
> >
{#snippet rows({ item })} {#snippet rows({ item })}
@@ -66,11 +67,11 @@
<Table.Cell>{item.email}</Table.Cell> <Table.Cell>{item.email}</Table.Cell>
<Table.Cell>{item.username}</Table.Cell> <Table.Cell>{item.username}</Table.Cell>
<Table.Cell> <Table.Cell>
<Badge variant="outline">{item.isAdmin ? 'Admin' : 'User'}</Badge> <Badge variant="outline">{item.isAdmin ? m.admin() : m.user()}</Badge>
</Table.Cell> </Table.Cell>
{#if $appConfigStore.ldapEnabled} {#if $appConfigStore.ldapEnabled}
<Table.Cell> <Table.Cell>
<Badge variant={item.ldapId ? 'default' : 'outline'}>{item.ldapId ? 'LDAP' : 'Local'}</Badge <Badge variant={item.ldapId ? 'default' : 'outline'}>{item.ldapId ? m.ldap() : m.local()}</Badge
> >
</Table.Cell> </Table.Cell>
{/if} {/if}
@@ -78,20 +79,20 @@
<DropdownMenu.Root> <DropdownMenu.Root>
<DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}> <DropdownMenu.Trigger class={buttonVariants({ variant: 'ghost', size: 'icon' })}>
<Ellipsis class="h-4 w-4" /> <Ellipsis class="h-4 w-4" />
<span class="sr-only">Toggle menu</span> <span class="sr-only">{m.toggle_menu()}</span>
</DropdownMenu.Trigger> </DropdownMenu.Trigger>
<DropdownMenu.Content align="end"> <DropdownMenu.Content align="end">
<DropdownMenu.Item onclick={() => (userIdToCreateOneTimeLink = item.id)} <DropdownMenu.Item onclick={() => (userIdToCreateOneTimeLink = item.id)}
><LucideLink class="mr-2 h-4 w-4" />Login Code</DropdownMenu.Item ><LucideLink class="mr-2 h-4 w-4" />{m.login_code()}</DropdownMenu.Item
> >
<DropdownMenu.Item onclick={() => goto(`/settings/admin/users/${item.id}`)} <DropdownMenu.Item onclick={() => goto(`/settings/admin/users/${item.id}`)}
><LucidePencil class="mr-2 h-4 w-4" /> Edit</DropdownMenu.Item ><LucidePencil class="mr-2 h-4 w-4" /> {m.edit()}</DropdownMenu.Item
> >
{#if !item.ldapId || !$appConfigStore.ldapEnabled} {#if !item.ldapId || !$appConfigStore.ldapEnabled}
<DropdownMenu.Item <DropdownMenu.Item
class="text-red-500 focus:!text-red-700" class="text-red-500 focus:!text-red-700"
onclick={() => deleteUser(item)} onclick={() => deleteUser(item)}
><LucideTrash class="mr-2 h-4 w-4" />Delete</DropdownMenu.Item ><LucideTrash class="mr-2 h-4 w-4" />{m.delete()}</DropdownMenu.Item
> >
{/if} {/if}
</DropdownMenu.Content> </DropdownMenu.Content>

View File

@@ -1,5 +1,6 @@
<script lang="ts"> <script lang="ts">
import * as Card from '$lib/components/ui/card'; import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import AuditLogList from './audit-log-list.svelte'; import AuditLogList from './audit-log-list.svelte';
let { data } = $props(); let { data } = $props();
@@ -8,14 +9,14 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Audit Log</title> <title>{m.audit_log()}</title>
</svelte:head> </svelte:head>
<Card.Root> <Card.Root>
<Card.Header> <Card.Header>
<Card.Title>Audit Log</Card.Title> <Card.Title>{m.audit_log()}</Card.Title>
<Card.Description class="mt-1" <Card.Description class="mt-1"
>See your account activities from the last 3 months.</Card.Description >{m.see_your_account_activities_from_the_last_3_months()}</Card.Description
> >
</Card.Header> </Card.Header>
<Card.Content> <Card.Content>

View File

@@ -2,6 +2,7 @@
import AdvancedTable from '$lib/components/advanced-table.svelte'; import AdvancedTable from '$lib/components/advanced-table.svelte';
import { Badge } from '$lib/components/ui/badge'; import { Badge } from '$lib/components/ui/badge';
import * as Table from '$lib/components/ui/table'; import * as Table from '$lib/components/ui/table';
import { m } from '$lib/paraglide/messages';
import AuditLogService from '$lib/services/audit-log-service'; import AuditLogService from '$lib/services/audit-log-service';
import type { AuditLog } from '$lib/types/audit-log.type'; import type { AuditLog } from '$lib/types/audit-log.type';
import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type'; import type { Paginated, SearchPaginationSortRequest } from '$lib/types/pagination.type';
@@ -27,12 +28,12 @@
{requestOptions} {requestOptions}
onRefresh={async (options) => (auditLogs = await auditLogService.list(options))} onRefresh={async (options) => (auditLogs = await auditLogService.list(options))}
columns={[ columns={[
{ label: 'Time', sortColumn: 'createdAt' }, { label: m.time(), sortColumn: 'createdAt' },
{ label: 'Event', sortColumn: 'event' }, { label: m.event(), sortColumn: 'event' },
{ label: 'Approximate Location', sortColumn: 'city' }, { label: m.approximate_location(), sortColumn: 'city' },
{ label: 'IP Address', sortColumn: 'ipAddress' }, { label: m.ip_address(), sortColumn: 'ipAddress' },
{ label: 'Device', sortColumn: 'device' }, { label: m.device(), sortColumn: 'device' },
{ label: 'Client' } { label: m.client() }
]} ]}
withoutSearch withoutSearch
> >
@@ -42,7 +43,7 @@
<Badge variant="outline">{toFriendlyEventString(item.event)}</Badge> <Badge variant="outline">{toFriendlyEventString(item.event)}</Badge>
</Table.Cell> </Table.Cell>
<Table.Cell <Table.Cell
>{item.city && item.country ? `${item.city}, ${item.country}` : 'Unknown'}</Table.Cell >{item.city && item.country ? `${item.city}, ${item.country}` : m.unknown()}</Table.Cell
> >
<Table.Cell>{item.ipAddress}</Table.Cell> <Table.Cell>{item.ipAddress}</Table.Cell>
<Table.Cell>{item.device}</Table.Cell> <Table.Cell>{item.device}</Table.Cell>

View File

@@ -2,6 +2,7 @@ import test, { expect } from '@playwright/test';
import { users } from './data'; import { users } from './data';
import { cleanupBackend } from './utils/cleanup.util'; import { cleanupBackend } from './utils/cleanup.util';
import passkeyUtil from './utils/passkey.util'; import passkeyUtil from './utils/passkey.util';
import authUtil from './utils/auth.util';
test.beforeEach(cleanupBackend); test.beforeEach(cleanupBackend);
@@ -37,6 +38,22 @@ test('Update account details fails with already taken username', async ({ page }
await expect(page.getByRole('status')).toHaveText('Username is already in use'); await expect(page.getByRole('status')).toHaveText('Username is already in use');
}); });
test('Change Locale', async ({ page }) => {
await page.goto('/settings/account');
await page.getByLabel('Select Locale').click();
await page.getByRole('option', { name: 'Nederlands' }).click();
// Check if th language heading now says 'Taal' instead of 'Language'
await expect(page.getByRole('heading', { name: 'Taal' })).toBeVisible();
// Clear all cookies and sign in again to check if the language is still set to Dutch
await page.context().clearCookies();
await authUtil.authenticate(page);
await expect(page.getByRole('heading', { name: 'Taal' })).toBeVisible();
});
test('Add passkey to an account', async ({ page }) => { test('Add passkey to an account', async ({ page }) => {
await page.goto('/settings/account'); await page.goto('/settings/account');

View File

@@ -1,16 +1,13 @@
import { test as setup } from '@playwright/test'; import { test as setup } from '@playwright/test';
import passkeyUtil from './utils/passkey.util'; import authUtil from './utils/auth.util';
import { cleanupBackend } from './utils/cleanup.util'; import { cleanupBackend } from './utils/cleanup.util';
const authFile = 'tests/.auth/user.json'; const authFile = 'tests/.auth/user.json';
setup('authenticate', async ({ page }) => { setup('authenticate', async ({ page }) => {
await cleanupBackend(); await cleanupBackend();
await page.goto('/login');
await (await passkeyUtil.init(page)).addPasskey(); await authUtil.authenticate(page);
await page.getByRole('button', { name: 'Authenticate' }).click();
await page.waitForURL('/settings/account'); await page.waitForURL('/settings/account');
await page.context().storageState({ path: authFile }); await page.context().storageState({ path: authFile });

View File

@@ -0,0 +1,12 @@
import type { Page } from '@playwright/test';
import passkeyUtil from './passkey.util';
async function authenticate(page: Page) {
await page.goto('/login');
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Authenticate' }).click();
}
export default { authenticate };

View File

@@ -1,7 +1,17 @@
import { paraglideVitePlugin } from '@inlang/paraglide-js';
import { sveltekit } from '@sveltejs/kit/vite'; import { sveltekit } from '@sveltejs/kit/vite';
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite'; import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [sveltekit(), tailwindcss()] plugins: [
sveltekit(),
tailwindcss(),
paraglideVitePlugin({
project: './project.inlang',
outdir: './src/lib/paraglide',
cookieName: "locale",
strategy: ['cookie', 'preferredLanguage', 'baseLocale']
}),
]
}); });