Compare commits

..

9 Commits

Author SHA1 Message Date
Elias Schneider
d78a1c6974 release: 0.43.0 2025-03-20 20:47:17 +01:00
Elias Schneider
c578baba95 chore: add language request issue template 2025-03-20 20:38:33 +01:00
Elias Schneider
bb23194e88 chore(translations): remove unused messages 2025-03-20 20:26:43 +01:00
Elias Schneider
31ac56004a refactor: use language code with country for messages 2025-03-20 20:15:26 +01:00
Elias Schneider
d59ec01b33 Update Crowdin configuration file 2025-03-20 20:12:48 +01:00
Elias Schneider
3ee26a2cfb chore: update Crowdin configuration 2025-03-20 20:09:05 +01:00
Elias Schneider
39395c79c3 Update Crowdin configuration file 2025-03-20 20:08:24 +01:00
Jonas Claes
269b5a3c92 feat: add support for translations (#349)
Co-authored-by: Kyle Mendell <kmendell@outlook.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-20 18:57:41 +00:00
Kyle Mendell
041c565dc1 feat(passkeys): name new passkeys based on agguids (#332)
Co-authored-by: Alessandro (Ale) Segala <43508+ItalyPaleAle@users.noreply.github.com>
Co-authored-by: Elias Schneider <login@eliasschneider.com>
2025-03-20 15:35:08 +00:00
93 changed files with 1837 additions and 457 deletions

View File

@@ -0,0 +1,20 @@
name: "🌐 Language request"
description: "You want to contribute to a language that isn't on Crowdin yet?"
title: "🌐 Language Request: <language name in english>"
labels: [language-request]
body:
- type: input
id: language-name-native
attributes:
label: "🌐 Language Name (native)"
placeholder: "Schweizerdeutsch"
validations:
required: true
- type: input
id: language-code
attributes:
label: "🌐 ISO 639-1 Language Code"
description: "You can find your language code [here](https://www.andiamo.co.uk/resources/iso-language-codes/)."
placeholder: "de-CH"
validations:
required: true

34
.github/workflows/update-aaguids.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Update AAGUIDs
on:
schedule:
- cron: "0 0 * * 1" # Runs every Monday at midnight
workflow_dispatch: # Allows manual triggering of the workflow
permissions:
contents: write
jobs:
update-aaguids:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Fetch JSON data
run: |
curl -o data.json https://raw.githubusercontent.com/pocket-id/passkey-aaguids/refs/heads/main/combined_aaguid.json
- name: Process JSON data
run: |
mkdir -p backend/resources
jq -c 'map_values(.name)' data.json > backend/resources/aaguids.json
- name: Commit changes
run: |
git config --local user.email "action@github.com"
git config --local user.name "GitHub Action"
git add backend/resources/aaguids.json
git diff --staged --quiet || git commit -m "chore: update AAGUIDs"
git push

View File

@@ -1 +1 @@
0.42.1
0.43.0

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

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

View File

@@ -1,3 +1,11 @@
## [](https://github.com/pocket-id/pocket-id/compare/v0.42.1...v) (2025-03-20)
### Features
* add support for translations ([#349](https://github.com/pocket-id/pocket-id/issues/349)) ([269b5a3](https://github.com/pocket-id/pocket-id/commit/269b5a3c9249bb8081c74741141d3d5a69ea42a2))
* **passkeys:** name new passkeys based on agguids ([#332](https://github.com/pocket-id/pocket-id/issues/332)) ([041c565](https://github.com/pocket-id/pocket-id/commit/041c565dc10f15edb3e8ab58e9a4df5e48a2a6d3))
## [](https://github.com/pocket-id/pocket-id/compare/v0.42.0...v) (2025-03-18)

View File

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

View File

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

View File

@@ -153,6 +153,7 @@ func (s *UserService) CreateUser(input dto.UserCreateDto) (model.User, error) {
Email: input.Email,
Username: input.Username,
IsAdmin: input.IsAdmin,
Locale: input.Locale,
}
if 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.Email = updatedUser.Email
user.Username = updatedUser.Username
user.Locale = updatedUser.Locale
if !updateOwnUser {
user.IsAdmin = updatedUser.IsAdmin
}

View File

@@ -95,8 +95,11 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
return model.WebauthnCredential{}, err
}
// Determine passkey name using AAGUID and User-Agent
passkeyName := s.determinePasskeyName(credential.Authenticator.AAGUID)
credentialToStore := model.WebauthnCredential{
Name: "New Passkey",
Name: passkeyName,
CredentialID: credential.ID,
AttestationType: credential.AttestationType,
PublicKey: credential.PublicKey,
@@ -112,6 +115,16 @@ func (s *WebAuthnService) VerifyRegistration(sessionID, userID string, r *http.R
return credentialToStore, nil
}
func (s *WebAuthnService) determinePasskeyName(aaguid []byte) string {
// First try to identify by AAGUID using a combination of builtin + MDS
authenticatorName := utils.GetAuthenticatorName(aaguid)
if authenticatorName != "" {
return authenticatorName
}
return "New Passkey" // Default fallback
}
func (s *WebAuthnService) BeginLogin() (*model.PublicKeyCredentialRequestOptions, error) {
options, session, err := s.webAuthn.BeginDiscoverableLogin()
if err != nil {

View File

@@ -0,0 +1,64 @@
package utils
import (
"encoding/hex"
"encoding/json"
"fmt"
"log"
"sync"
"github.com/pocket-id/pocket-id/backend/resources"
)
var (
aaguidMap map[string]string
aaguidMapOnce sync.Once
)
// FormatAAGUID converts an AAGUID byte slice to UUID string format
func FormatAAGUID(aaguid []byte) string {
if len(aaguid) == 0 {
return ""
}
// If exactly 16 bytes, format as UUID
if len(aaguid) == 16 {
return fmt.Sprintf("%x-%x-%x-%x-%x",
aaguid[0:4], aaguid[4:6], aaguid[6:8], aaguid[8:10], aaguid[10:16])
}
// Otherwise just return as hex
return hex.EncodeToString(aaguid)
}
// GetAuthenticatorName returns the name of the authenticator for the given AAGUID
func GetAuthenticatorName(aaguid []byte) string {
aaguidStr := FormatAAGUID(aaguid)
if aaguidStr == "" {
return ""
}
// Then check JSON-sourced map
aaguidMapOnce.Do(loadAAGUIDsFromFile)
if name, ok := aaguidMap[aaguidStr]; ok {
return name + " Passkey"
}
return ""
}
// loadAAGUIDsFromFile loads AAGUID data from the embedded file system
func loadAAGUIDsFromFile() {
// Read from embedded file system
data, err := resources.FS.ReadFile("aaguids.json")
if err != nil {
log.Printf("Error reading embedded AAGUID file: %v", err)
return
}
if err := json.Unmarshal(data, &aaguidMap); err != nil {
log.Printf("Error unmarshalling AAGUID data: %v", err)
return
}
}

View File

@@ -0,0 +1,126 @@
package utils
import (
"encoding/hex"
"sync"
"testing"
)
func TestFormatAAGUID(t *testing.T) {
tests := []struct {
name string
aaguid []byte
want string
}{
{
name: "empty byte slice",
aaguid: []byte{},
want: "",
},
{
name: "16 byte slice - standard UUID",
aaguid: []byte{0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10},
want: "01020304-0506-0708-090a-0b0c0d0e0f10",
},
{
name: "non-16 byte slice",
aaguid: []byte{0x01, 0x02, 0x03, 0x04, 0x05},
want: "0102030405",
},
{
name: "specific UUID example",
aaguid: mustDecodeHex("adce000235bcc60a648b0b25f1f05503"),
want: "adce0002-35bc-c60a-648b-0b25f1f05503",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatAAGUID(tt.aaguid)
if got != tt.want {
t.Errorf("FormatAAGUID() = %v, want %v", got, tt.want)
}
})
}
}
func TestGetAuthenticatorName(t *testing.T) {
// Reset the aaguidMap for testing
originalMap := aaguidMap
originalOnce := aaguidMapOnce
defer func() {
aaguidMap = originalMap
aaguidMapOnce = originalOnce
}()
// Inject a test AAGUID map
aaguidMap = map[string]string{
"adce0002-35bc-c60a-648b-0b25f1f05503": "Test Authenticator",
"00000000-0000-0000-0000-000000000000": "Zero Authenticator",
}
aaguidMapOnce = sync.Once{}
aaguidMapOnce.Do(func() {}) // Mark as done to avoid loading from file
tests := []struct {
name string
aaguid []byte
want string
}{
{
name: "empty byte slice",
aaguid: []byte{},
want: "",
},
{
name: "known AAGUID",
aaguid: mustDecodeHex("adce000235bcc60a648b0b25f1f05503"),
want: "Test Authenticator Passkey",
},
{
name: "zero UUID",
aaguid: mustDecodeHex("00000000000000000000000000000000"),
want: "Zero Authenticator Passkey",
},
{
name: "unknown AAGUID",
aaguid: mustDecodeHex("ffffffffffffffffffffffffffffffff"),
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GetAuthenticatorName(tt.aaguid)
if got != tt.want {
t.Errorf("GetAuthenticatorName() = %v, want %v", got, tt.want)
}
})
}
}
func TestLoadAAGUIDsFromFile(t *testing.T) {
// Reset the map and once flag for clean testing
aaguidMap = nil
aaguidMapOnce = sync.Once{}
// Trigger loading of AAGUIDs by calling GetAuthenticatorName
GetAuthenticatorName([]byte{0x01, 0x02, 0x03, 0x04})
if len(aaguidMap) == 0 {
t.Error("loadAAGUIDsFromFile() failed to populate aaguidMap")
}
// Check for a few known entries that should be in the embedded file
// This test will be more brittle as it depends on the content of aaguids.json,
// but it helps verify that the loading actually worked
t.Log("AAGUID map loaded with", len(aaguidMap), "entries")
}
// Helper function to convert hex string to bytes
func mustDecodeHex(s string) []byte {
bytes, err := hex.DecodeString(s)
if err != nil {
panic("invalid hex in test: " + err.Error())
}
return bytes
}

File diff suppressed because one or more lines are too long

View File

@@ -4,5 +4,5 @@ import "embed"
// Embedded file systems for the project
//go:embed email-templates images migrations fonts
//go:embed email-templates images migrations fonts aaguids.json
var FS embed.FS

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;

4
crowdin.yml Normal file
View File

@@ -0,0 +1,4 @@
files:
- source: /frontend/messages/en-US.json
translation: /%original_path%/%locale%.json
pull_request_title: 'chore(translations): update translations via Crowdin'

View File

@@ -0,0 +1,316 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "My Account",
"logout": "Logout",
"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."
}

View File

@@ -0,0 +1,311 @@
{
"$schema": "https://inlang.com/schema/inlang-message-format",
"my_account": "Mijn Account",
"logout": "Uitloggen",
"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",
"version": "0.39.0",
"version": "0.42.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pocket-id-frontend",
"version": "0.39.0",
"version": "0.42.1",
"dependencies": {
"@simplewebauthn/browser": "^13.1.0",
"@tailwindcss/vite": "^4.0.0",
@@ -25,6 +25,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.0.0",
"@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.0",
"@sveltejs/adapter-auto": "^4.0.0",
@@ -747,6 +748,78 @@
"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": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/@internationalized/date/-/date-3.7.0.tgz",
@@ -798,6 +871,13 @@
"@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": {
"version": "2.1.5",
"resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz",
@@ -1214,6 +1294,16 @@
"integrity": "sha512-TJ7Al17j3+by5y2QkTLcF/oBVMbgXBhILVgi9PuwpxQVZZvGh5BFRzWbJPmZVNKpbRLjuMzFuRwR+tdFPqCkvA==",
"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": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-auto/-/adapter-auto-4.0.0.tgz",
@@ -1909,6 +1999,13 @@
"@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": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
@@ -2099,6 +2196,33 @@
"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": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz",
@@ -2111,6 +2235,16 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"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": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
@@ -2119,6 +2253,13 @@
"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": {
"version": "7.0.6",
"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": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
@@ -2490,6 +2646,20 @@
"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": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
@@ -2814,6 +2984,16 @@
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
@@ -2826,6 +3006,16 @@
"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": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
@@ -2964,6 +3154,13 @@
"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": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
@@ -3007,6 +3204,19 @@
"integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==",
"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": {
"version": "4.5.4",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz",
@@ -3030,6 +3240,16 @@
"integrity": "sha512-a/RAk2BfKk+WFGhhOCAYqSiFLc34k8Mt/6NWRI4joER0EYUzXIcFivjjnoD3+XU1DggLn/tZc3DOAgke7l8a4A==",
"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": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
@@ -3906,6 +4126,16 @@
"integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
"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": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -4094,6 +4324,18 @@
"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": {
"version": "3.1.1",
"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==",
"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": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",
@@ -4557,12 +4813,33 @@
"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": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"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": {
"version": "1.0.0-beta.11",
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "pocket-id-frontend",
"version": "0.42.1",
"version": "0.43.0",
"private": true,
"type": "module",
"scripts": {
@@ -30,6 +30,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@inlang/paraglide-js": "^2.0.0",
"@internationalized/date": "^3.7.0",
"@playwright/test": "^1.50.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-US",
"nl-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>
<html lang="en">
<html lang="%lang%">
<head>
<meta charset="utf-8" />
<link rel="icon" href="/api/application-configuration/favicon" />

View File

@@ -1,6 +1,8 @@
import { env } from '$env/dynamic/private';
import { ACCESS_TOKEN_COOKIE_NAME } from '$lib/constants';
import { paraglideMiddleware } from '$lib/paraglide/server';
import type { Handle, HandleServerError } from '@sveltejs/kit';
import { sequence } from '@sveltejs/kit/hooks';
import { AxiosError } from 'axios';
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
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 isUnauthenticatedOnlyPath = event.url.pathname.startsWith('/login') || event.url.pathname.startsWith('/lc')
@@ -43,6 +54,8 @@ export const handle: Handle = async ({ event, resolve }) => {
return response;
};
export const handle: Handle = sequence(paraglideHandle, authenticationHandle);
export const handleError: HandleServerError = async ({ error, message, status }) => {
if (error instanceof AxiosError) {
message = error.response?.data.error || message;

View File

@@ -11,6 +11,7 @@
import { ChevronDown } from 'lucide-svelte';
import type { Snippet } from 'svelte';
import Button from './ui/button/button.svelte';
import { m } from '$lib/paraglide/messages';
let {
items,
@@ -93,7 +94,7 @@
'relative z-50 mb-4 max-w-sm',
items.data.length == 0 && searchValue == '' && 'hidden'
)}
placeholder={'Search...'}
placeholder={m.search()}
type="text"
oninput={(e) => onSearch((e.target as HTMLInputElement).value)}
/>
@@ -102,7 +103,7 @@
{#if items.data.length === 0 && searchValue === ''}
<div class="my-5 flex flex-col items-center">
<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>
{:else}
<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="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
selected={{
label: items.pagination.itemsPerPage.toString(),

View File

@@ -5,6 +5,7 @@
import { slide } from 'svelte/transition';
import { Button } from './ui/button';
import * as Card from './ui/card';
import { m } from '$lib/paraglide/messages';
let {
id,
@@ -55,7 +56,7 @@
<Card.Description>{description}</Card.Description>
{/if}
</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
class={cn(
'h-5 w-5 transition-transform duration-200',

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import * as Tooltip from '$lib/components/ui/tooltip';
import { m } from '$lib/paraglide/messages';
import { LucideCheck } from 'lucide-svelte';
import type { Snippet } from 'svelte';
@@ -31,9 +32,9 @@
<Tooltip.Trigger class="text-start" tabindex={-1} onclick={onClick}>{@render children()}</Tooltip.Trigger>
<Tooltip.Content onclick={copyToClipboard}>
{#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}
<span>Click to copy</span>
<span>{m.click_to_copy()}</span>
{/if}
</Tooltip.Content>
</Tooltip.Root>

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import { LucideXCircle } from 'lucide-svelte';
let { message, showButton = true }: { message: string; showButton?: boolean } = $props();
@@ -7,9 +8,9 @@
<div class="mt-[20%] flex flex-col items-center">
<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>
{#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}
</div>

View File

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

View File

@@ -2,6 +2,8 @@
import { Button } from '$lib/components/ui/button';
import { Calendar } from '$lib/components/ui/calendar';
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 {
CalendarDate,
@@ -30,7 +32,7 @@
open = false;
}
const df = new DateFormatter('en-US', {
const df = new DateFormatter(getLocale(), {
dateStyle: 'long'
});
</script>
@@ -44,7 +46,7 @@
builders={[builder]}
>
<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>
</Popover.Trigger>
<Popover.Content class="w-auto p-0" align="start">

View File

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

View File

@@ -4,6 +4,7 @@
import Button from '$lib/components/ui/button/button.svelte';
import { LucideLoader, LucideRefreshCw, LucideUpload } from 'lucide-svelte';
import { openConfirmDialog } from '../confirm-dialog';
import { m } from '$lib/paraglide/messages';
let {
userId,
@@ -40,11 +41,10 @@
function onReset() {
openConfirmDialog({
title: 'Reset profile picture?',
message:
'This will remove the uploaded image, and reset the profile picture to default. Do you want to continue?',
title: m.reset_profile_picture_question(),
message: m.this_will_remove_the_uploaded_image_and_reset_the_profile_picture_to_default(),
confirm: {
label: 'Reset',
label: m.reset(),
action: async () => {
isLoading = true;
await resetCallback().catch();
@@ -58,16 +58,16 @@
<div class="flex gap-5">
<div class="flex w-full flex-col justify-between gap-5 sm:flex-row">
<div>
<h3 class="text-xl font-semibold">Profile Picture</h3>
<h3 class="text-xl font-semibold">{m.profile_picture()}</h3>
{#if isLdapUser}
<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>
{:else}
<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 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}
<Button
variant="outline"
@@ -77,7 +77,7 @@
disabled={isLoading || isLdapUser}
>
<LucideRefreshCw class="mr-2 h-4 w-4" />
Reset to default
{m.reset_to_default()}
</Button>
</div>
{#if isLdapUser}

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import * as Avatar from '$lib/components/ui/avatar';
import * as DropdownMenu from '$lib/components/ui/dropdown-menu';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store';
import { LucideLogOut, LucideUser } from 'lucide-svelte';
@@ -32,10 +33,10 @@
<DropdownMenu.Separator />
<DropdownMenu.Group>
<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}
><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.Content>

View File

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

View File

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

View File

@@ -1,4 +1,5 @@
<script lang="ts">
import { m } from '$lib/paraglide/messages';
import { mode } from 'mode-watcher';
import type { HTMLAttributes } from 'svelte/elements';
@@ -7,4 +8,4 @@
const isDarkMode = $derived($mode === 'dark');
</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 Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select/index.js';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -17,14 +18,14 @@
const userService = new UserService();
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 = {
'1 hour': 60 * 60,
'12 hours': 60 * 60 * 12,
'1 day': 60 * 60 * 24,
'1 week': 60 * 60 * 24 * 7,
'1 month': 60 * 60 * 24 * 30
[m.one_hour()]: 60 * 60,
[m.twelve_hours()]: 60 * 60 * 12,
[m.one_day()]: 60 * 60 * 24,
[m.one_week()]: 60 * 60 * 24 * 7,
[m.one_month()]: 60 * 60 * 24 * 30
};
async function createOneTimeAccessToken() {
@@ -48,14 +49,14 @@
<Dialog.Root open={!!userId} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Login Code</Dialog.Title>
<Dialog.Title>{m.login_code()}</Dialog.Title>
<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>
{#if oneTimeLink === null}
<div>
<Label for="expiration">Expiration</Label>
<Label for="expiration">{m.expiration()}</Label>
<Select.Root
selected={{
label: Object.keys(availableExpirations)[0],
@@ -75,10 +76,10 @@
</Select.Root>
</div>
<Button onclick={() => createOneTimeAccessToken()} disabled={!selectedExpiration}>
Generate Code
{m.generate_code()}
</Button>
{: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 />
{/if}
</Dialog.Content>

View File

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

View File

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

View File

@@ -1,9 +1,13 @@
import { setLocale } from '$lib/paraglide/runtime';
import type { User } from '$lib/types/user.type';
import { writable } from 'svelte/store';
const userStore = writable<User | null>(null);
const setUser = (user: User) => {
if (user.locale) {
setLocale(user.locale, { reload: false });
}
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 { UserGroup } from './user-group.type';
@@ -10,6 +11,7 @@ export type User = {
isAdmin: boolean;
userGroups: UserGroup[];
customClaims: CustomClaim[];
locale?: Locale;
ldapId?: string;
};

View File

@@ -1,10 +1,11 @@
import { m } from '$lib/paraglide/messages';
import { WebAuthnError } from '@simplewebauthn/browser';
import { AxiosError } from 'axios';
import { toast } from 'svelte-sonner';
export function getAxiosErrorMessage(
e: unknown,
defaultMessage: string = 'An unknown error occurred'
defaultMessage: string = m.an_unknown_error_occurred()
) {
let message = defaultMessage;
if (e instanceof AxiosError) {
@@ -13,29 +14,29 @@ export function getAxiosErrorMessage(
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);
toast.error(message);
}
export function getWebauthnErrorMessage(e: unknown) {
const errors = {
ERROR_CEREMONY_ABORTED: 'The authentication process was aborted',
ERROR_AUTHENTICATOR_GENERAL_ERROR: 'An error occurred with the authenticator',
ERROR_CEREMONY_ABORTED: m.authentication_process_was_aborted(),
ERROR_AUTHENTICATOR_GENERAL_ERROR: m.error_occurred_with_authenticator(),
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:
'The authenticator does not support resident keys',
ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED: 'This passkey was previously registered',
m.authenticator_does_not_support_resident_keys(),
ERROR_AUTHENTICATOR_PREVIOUSLY_REGISTERED: m.passkey_was_previously_registered(),
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) {
message = errors[e.code as keyof typeof errors];
} 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) {
message = e.response?.data.error;
} else {

View File

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

View File

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

View File

@@ -3,6 +3,7 @@
import CheckmarkAnimated from '$lib/icons/checkmark-animated.svelte';
import ConnectArrow from '$lib/icons/connect-arrow.svelte';
import CrossAnimated from '$lib/icons/cross-animated.svelte';
import { m } from '$lib/paraglide/messages';
import type { OidcClientMetaData } from '$lib/types/oidc.type';
const {
@@ -61,7 +62,7 @@
class="h-10 w-10"
src="/api/oidc/clients/{client.id}/logo"
draggable={false}
alt="Client Logo"
alt={m.client_logo()}
/>
{:else}
<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 { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from './components/login-logo-error-success-indicator.svelte';
import { m } from '$lib/paraglide/messages';
const webauthnService = new WebAuthnService();
let isLoading = $state(false);
@@ -32,7 +33,7 @@
</script>
<svelte:head>
<title>Sign In</title>
<title>{m.sign_in()}</title>
</svelte:head>
<SignInWrapper showAlternativeSignInMethodButton>
@@ -40,18 +41,18 @@
<LoginLogoErrorSuccessIndicator error={!!error} />
</div>
<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>
{#if error}
<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>
{:else}
<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>
{/if}
<Button class="mt-10" {isLoading} on:click={authenticate}
>{error ? 'Try again' : 'Authenticate'}</Button
>{error ? m.try_again() : m.authenticate()}</Button
>
</SignInWrapper>

View File

@@ -4,14 +4,15 @@
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import appConfigStore from '$lib/stores/application-configuration-store';
import { LucideChevronRight, LucideMail, LucideRectangleEllipsis } from 'lucide-svelte';
const methods = [
{
icon: LucideRectangleEllipsis,
title: 'Login Code',
description: 'Enter a login code to sign in.',
title: m.login_code(),
description: m.enter_a_login_code_to_sign_in(),
href: '/login/alternative/code'
}
];
@@ -19,15 +20,15 @@
if ($appConfigStore.emailOneTimeAccessEnabled) {
methods.push({
icon: LucideMail,
title: 'Email Login',
description: 'Request a login code via email.',
title: m.email_login(),
description: m.request_a_login_code_via_email(),
href: '/login/alternative/email'
});
}
</script>
<svelte:head>
<title>Sign In</title>
<title>{m.sign_in()}</title>
</svelte:head>
<SignInWrapper>
@@ -35,9 +36,9 @@
<div class="bg-muted mx-auto rounded-2xl p-3">
<Logo class="h-10 w-10" />
</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">
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>
<div class="mt-5 flex flex-col gap-3">
{#each methods as method}
@@ -59,7 +60,7 @@
</div>
<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>
</SignInWrapper>

View File

@@ -9,6 +9,7 @@
import { onMount } from 'svelte';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
import { page } from '$app/state';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let code = $state(data.code ?? '');
@@ -26,7 +27,7 @@
try {
goto(data.redirect);
} catch (e) {
error = 'Invalid redirect URL';
error = m.invalid_redirect_url();
}
} catch (e) {
error = getAxiosErrorMessage(e);
@@ -43,20 +44,20 @@
</script>
<svelte:head>
<title>Login Code</title>
<title>{m.login_code()}</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator error={!!error} />
</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}
<p class="text-muted-foreground mt-2">
{error}. Please try again.
{error}. {m.please_try_again()}
</p>
{: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}
<form
onsubmit={(e) => {
@@ -65,10 +66,10 @@
}}
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">
<Button variant="secondary" class="w-full" href={"/login/alternative" + page.url.search}>Go back</Button>
<Button class="w-full" type="submit" {isLoading}>Submit</Button>
<Button variant="secondary" class="w-full" href={"/login/alternative" + page.url.search}>{m.go_back()}</Button>
<Button class="w-full" type="submit" {isLoading}>{m.submit()}</Button>
</div>
</form>
</SignInWrapper>

View File

@@ -6,6 +6,7 @@
import UserService from '$lib/services/user-service';
import { fade } from 'svelte/transition';
import LoginLogoErrorSuccessIndicator from '../../components/login-logo-error-success-indicator.svelte';
import { m } from '$lib/paraglide/messages';
const { data } = $props();
@@ -21,38 +22,38 @@
await userService
.requestOneTimeAccessEmail(email, data.redirect)
.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;
}
</script>
<svelte:head>
<title>Email Login</title>
<title>{m.email_login()}</title>
</svelte:head>
<SignInWrapper>
<div class="flex justify-center">
<LoginLogoErrorSuccessIndicator {success} error={!!error} />
</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}
<p class="text-muted-foreground mt-2" in:fade>
{error}. Please try again.
{error}. {m.please_try_again()}
</p>
<div class="mt-10 flex w-full justify-stretch gap-2">
<Button variant="secondary" class="w-full" href="/">Go back</Button>
<Button class="w-full" onclick={() => (error = undefined)}>Try again</Button>
<Button variant="secondary" class="w-full" href="/">{m.go_back()}</Button>
<Button class="w-full" onclick={() => (error = undefined)}>{m.try_again()}</Button>
</div>
{:else if success}
<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>
<div class="mt-8 flex w-full justify-stretch gap-2">
<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>
{:else}
<form
@@ -63,14 +64,14 @@
class="w-full max-w-[450px]"
>
<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>
<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">
<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>
</form>
{/if}

View File

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

View File

@@ -3,6 +3,7 @@
import SignInWrapper from '$lib/components/login-wrapper.svelte';
import Logo from '$lib/components/logo.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import WebAuthnService from '$lib/services/webauthn-service';
import userStore from '$lib/stores/user-store.js';
import { axiosErrorToast } from '$lib/utils/error-util.js';
@@ -22,7 +23,7 @@
</script>
<svelte:head>
<title>Logout</title>
<title>{m.logout()}</title>
</svelte:head>
<SignInWrapper>
@@ -31,13 +32,13 @@
<Logo class="h-10 w-10" />
</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">
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>
<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" {isLoading} onclick={signOut}>Sign out</Button>
<Button class="w-full" variant="secondary" onclick={() => history.back()}>{m.cancel()}</Button>
<Button class="w-full" {isLoading} onclick={signOut}>{m.sign_out()}</Button>
</div>
</SignInWrapper>

View File

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

View File

@@ -2,6 +2,7 @@
import * as Alert from '$lib/components/ui/alert';
import { Button } from '$lib/components/ui/button';
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import WebAuthnService from '$lib/services/webauthn-service';
import appConfigStore from '$lib/stores/application-configuration-store';
@@ -13,6 +14,7 @@
import { toast } from 'svelte-sonner';
import ProfilePictureSettings from '../../../lib/components/form/profile-picture-settings.svelte';
import AccountForm from './account-form.svelte';
import LocalePicker from './locale-picker.svelte';
import LoginCodeModal from './login-code-modal.svelte';
import PasskeyList from './passkey-list.svelte';
import RenamePasskeyModal from './rename-passkey-modal.svelte';
@@ -39,7 +41,7 @@
let success = true;
await userService
.updateCurrent(user)
.then(() => toast.success('Account details updated successfully'))
.then(() => toast.success(m.account_details_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
@@ -51,9 +53,7 @@
async function updateProfilePicture(image: File) {
await userService
.updateCurrentUsersProfilePicture(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);
}
@@ -72,24 +72,22 @@
</script>
<svelte:head>
<title>Account Settings</title>
<title>{m.account_settings()}</title>
</svelte:head>
{#if passkeys.length == 0}
<Alert.Root variant="warning">
<LucideAlertTriangle class="size-4" />
<Alert.Title>Passkey missing</Alert.Title>
<Alert.Title>{m.passkey_missing()}</Alert.Title>
<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>
{:else if passkeys.length == 1}
<Alert.Root variant="warning" dismissibleId="single-passkey">
<LucideAlertTriangle class="size-4" />
<Alert.Title>Single Passkey Configured</Alert.Title>
<Alert.Description
>It is recommended to add more than one passkey to avoid losing access to your account.</Alert.Description
>
<Alert.Title>{m.single_passkey_configured()}</Alert.Title>
<Alert.Description>{m.it_is_recommended_to_add_more_than_one_passkey()}</Alert.Description>
</Alert.Root>
{/if}
@@ -99,7 +97,7 @@
>
<Card.Root>
<Card.Header>
<Card.Title>Account Details</Card.Title>
<Card.Title>{m.account_details()}</Card.Title>
</Card.Header>
<Card.Content>
<AccountForm {account} callback={updateAccount} />
@@ -122,12 +120,12 @@
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Passkeys</Card.Title>
<Card.Title>{m.passkeys()}</Card.Title>
<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>
</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>
</Card.Header>
{#if passkeys.length != 0}
@@ -141,12 +139,28 @@
<Card.Header>
<div class="flex items-center justify-between">
<div>
<Card.Title>Login Code</Card.Title>
<Card.Title>{m.login_code()}</Card.Title>
<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>
</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>
</Card.Header>
</Card.Root>

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import FormInput from '$lib/components/form/form-input.svelte';
import { Button } from '$lib/components/ui/button';
import { m } from '$lib/paraglide/messages';
import type { UserCreate } from '$lib/types/user.type';
import { createForm } from '$lib/utils/form-util';
import { z } from 'zod';
@@ -24,7 +25,7 @@
.max(30)
.regex(
/^[a-z0-9_@.-]+$/,
"Username can only contain lowercase letters, numbers, underscores, dots, hyphens, and '@' symbols"
m.username_can_only_contain()
),
email: z.string().email(),
isAdmin: z.boolean()
@@ -36,7 +37,7 @@
const data = form.validate();
if (!data) return;
isLoading = true;
const success = await callback(data);
await callback(data);
// Reset form if user was successfully created
isLoading = false;
}
@@ -45,21 +46,21 @@
<form onsubmit={onSubmit}>
<div class="flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label="First name" bind:input={$inputs.firstName} />
<FormInput label={m.first_name()} bind:input={$inputs.firstName} />
</div>
<div class="w-full">
<FormInput label="Last name" bind:input={$inputs.lastName} />
<FormInput label={m.last_name()} bind:input={$inputs.lastName} />
</div>
</div>
<div class="mt-3 flex flex-col gap-3 sm:flex-row">
<div class="w-full">
<FormInput label="Email" bind:input={$inputs.email} />
<FormInput label={m.email()} bind:input={$inputs.email} />
</div>
<div class="w-full">
<FormInput label="Username" bind:input={$inputs.username} />
<FormInput label={m.username()} bind:input={$inputs.username} />
</div>
</div>
<div class="mt-5 flex justify-end">
<Button {isLoading} type="submit">Save</Button>
<Button {isLoading} type="submit">{m.save()}</Button>
</div>
</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-US': 'English',
'nl-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 * as Dialog from '$lib/components/ui/dialog';
import { Separator } from '$lib/components/ui/separator';
import { m } from '$lib/paraglide/messages';
import UserService from '$lib/services/user-service';
import { axiosErrorToast } from '$lib/utils/error-util';
@@ -37,9 +38,9 @@
<Dialog.Root open={!!code} {onOpenChange}>
<Dialog.Content class="max-w-md">
<Dialog.Header>
<Dialog.Title>Login Code</Dialog.Title>
<Dialog.Title>{m.login_code()}</Dialog.Title>
<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.Header>
@@ -49,7 +50,7 @@
</CopyToClipboard>
<div class="text-muted-foreground flex items-center justify-center gap-3">
<Separator />
<p class="text-nowrap text-xs">or visit</p>
<p class="text-nowrap text-xs">{m.or_visit()}</p>
<Separator />
</div>
<div>

View File

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

View File

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

View File

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

View File

@@ -2,6 +2,7 @@
import CopyToClipboard from '$lib/components/copy-to-clipboard.svelte';
import { Button } from '$lib/components/ui/button';
import * as Dialog from '$lib/components/ui/dialog';
import { m } from '$lib/paraglide/messages';
import type { ApiKeyResponse } from '$lib/types/api-key.type';
let {
@@ -20,22 +21,22 @@
<Dialog.Root open={!!apiKeyResponse} {onOpenChange}>
<Dialog.Content class="max-w-md" closeButton={false}>
<Dialog.Header>
<Dialog.Title>API Key Created</Dialog.Title>
<Dialog.Title>{m.api_key_created()}</Dialog.Title>
<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.Header>
{#if apiKeyResponse}
<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>
{#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>
{/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">
<CopyToClipboard value={apiKeyResponse.token}>
<span class="break-all font-mono text-sm">{apiKeyResponse.token}</span>
@@ -44,7 +45,7 @@
</div>
{/if}
<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.Content>
</Dialog.Root>

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,7 @@
<script lang="ts">
import FileInput from '$lib/components/form/file-input.svelte';
import { Label } from '$lib/components/ui/label';
import { m } from '$lib/paraglide/messages';
import { cn } from '$lib/utils/style';
import type { HTMLAttributes } from 'svelte/elements';
@@ -60,7 +61,7 @@
<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"
>
Update
{m.update()}
</span>
</div>
</FileInput>

View File

@@ -6,6 +6,7 @@
import { Button } from '$lib/components/ui/button';
import Label from '$lib/components/ui/label/label.svelte';
import * as Select from '$lib/components/ui/select';
import { m } from '$lib/paraglide/messages';
import AppConfigService from '$lib/services/app-config-service';
import type { AllAppConfig } from '$lib/types/application-configuration';
import { createForm } from '$lib/utils/form-util';
@@ -55,7 +56,7 @@
appConfig[key] = value;
});
toast.success('Email configuration updated successfully');
toast.success(m.email_configuration_updated_successfully());
return true;
}
async function onTestEmail() {
@@ -64,11 +65,11 @@
if (hasChanges) {
openConfirmDialog({
title: 'Save changes?',
title: m.save_changes_question(),
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: {
label: 'Save and send',
label: m.save_and_send(),
action: async () => {
const saved = await onSubmit();
if (saved) {
@@ -86,9 +87,9 @@
isSendingTestEmail = true;
await appConfigService
.sendTestEmail()
.then(() => toast.success('Test email sent successfully to your email address.'))
.then(() => toast.success(m.test_email_sent_successfully()))
.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));
}
@@ -96,21 +97,21 @@
<form onsubmit={onSubmit}>
<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">
<FormInput label="SMTP Host" bind:input={$inputs.smtpHost} />
<FormInput label="SMTP Port" type="number" bind:input={$inputs.smtpPort} />
<FormInput label="SMTP User" bind:input={$inputs.smtpUser} />
<FormInput label="SMTP Password" type="password" bind:input={$inputs.smtpPassword} />
<FormInput label="SMTP From" bind:input={$inputs.smtpFrom} />
<FormInput label={m.smtp_host()} bind:input={$inputs.smtpHost} />
<FormInput label={m.smtp_port()} type="number" bind:input={$inputs.smtpPort} />
<FormInput label={m.smtp_user()} bind:input={$inputs.smtpUser} />
<FormInput label={m.smtp_password()} type="password" bind:input={$inputs.smtpPassword} />
<FormInput label={m.smtp_from()} bind:input={$inputs.smtpFrom} />
<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
selected={{ value: $inputs.smtpTls.value, label: tlsOptions[$inputs.smtpTls.value] }}
onSelectedChange={(v) => ($inputs.smtpTls.value = v!.value)}
>
<Select.Trigger>
<Select.Value placeholder="Email TLS Option" />
<Select.Value placeholder={m.email_tls_option()} />
</Select.Trigger>
<Select.Content>
<Select.Item value="none" label="None" />
@@ -121,31 +122,31 @@
</div>
<CheckboxWithLabel
id="skip-cert-verify"
label="Skip Certificate Verification"
description="This can be useful for self-signed certificates."
label={m.skip_certificate_verification()}
description={m.this_can_be_useful_for_selfsigned_certificates()}
bind:checked={$inputs.smtpSkipCertVerify.value}
/>
</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">
<CheckboxWithLabel
id="email-login-notification"
label="Email Login Notification"
description="Send an email to the user when they log in from a new device."
label={m.email_login_notification()}
description={m.send_an_email_to_the_user_when_they_log_in_from_a_new_device()}
bind:checked={$inputs.emailLoginNotificationEnabled.value}
/>
<CheckboxWithLabel
id="email-login"
label="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."
label={m.email_login()}
description={m.allow_users_to_sign_in_with_a_login_code_sent_to_their_email()}
bind:checked={$inputs.emailOneTimeAccessEnabled.value}
/>
</div>
</fieldset>
<div class="mt-8 flex flex-wrap justify-end gap-3">
<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>
</form>

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,7 @@
import { toast } from 'svelte-sonner';
import { slide } from 'svelte/transition';
import OidcForm from '../oidc-client-form.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let client = $state({
@@ -27,13 +28,13 @@
const oidcService = new OidcService();
const setupDetails = $state({
'Authorization URL': `https://${$page.url.hostname}/authorize`,
'OIDC Discovery URL': `https://${$page.url.hostname}/.well-known/openid-configuration`,
'Token URL': `https://${$page.url.hostname}/api/oidc/token`,
'Userinfo URL': `https://${$page.url.hostname}/api/oidc/userinfo`,
'Logout URL': `https://${$page.url.hostname}/api/oidc/end-session`,
'Certificate URL': `https://${$page.url.hostname}/.well-known/jwks.json`,
PKCE: client.pkceEnabled ? 'Enabled' : 'Disabled'
[m.authorization_url()]: `https://${$page.url.hostname}/authorize`,
[m.oidc_discovery_url()]: `https://${$page.url.hostname}/.well-known/openid-configuration`,
[m.token_url()]: `https://${$page.url.hostname}/api/oidc/token`,
[m.userinfo_url()]: `https://${$page.url.hostname}/api/oidc/userinfo`,
[m.logout_url()]: `https://${$page.url.hostname}/api/oidc/end-session`,
[m.certificate_url()]: `https://${$page.url.hostname}/.well-known/jwks.json`,
[m.pkce()]: client.pkceEnabled ? m.enabled() : m.disabled()
});
async function updateClient(updatedClient: OidcClientCreateWithLogo) {
@@ -45,11 +46,11 @@
: Promise.resolve();
client.isPublic = updatedClient.isPublic;
setupDetails.PKCE = updatedClient.pkceEnabled ? 'Enabled' : 'Disabled';
setupDetails[m.pkce()] = updatedClient.pkceEnabled ? m.enabled() : m.disabled();
await Promise.all([dataPromise, imagePromise])
.then(() => {
toast.success('OIDC client updated successfully');
toast.success(m.oidc_client_updated_successfully());
})
.catch((e) => {
axiosErrorToast(e);
@@ -61,17 +62,17 @@
async function createClientSecret() {
openConfirmDialog({
title: 'Create new client secret',
title: m.create_new_client_secret(),
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: {
label: 'Generate',
label: m.generate(),
destructive: true,
action: async () => {
try {
const clientSecret = await oidcService.createClientSecret(client.id);
clientSecretStore.set(clientSecret);
toast.success('New client secret created successfully');
toast.success(m.new_client_secret_created_successfully());
} catch (e) {
axiosErrorToast(e);
}
@@ -84,7 +85,7 @@
await oidcService
.updateAllowedUserGroups(client.id, allowedGroups)
.then(() => {
toast.success('Allowed user groups updated successfully');
toast.success(m.allowed_user_groups_updated_successfully());
})
.catch((e) => {
axiosErrorToast(e);
@@ -97,12 +98,12 @@
</script>
<svelte:head>
<title>OIDC Client {client.name}</title>
<title>{m.oidc_client_name({ name: client.name })}</title>
</svelte:head>
<div>
<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>
<Card.Root>
@@ -112,14 +113,14 @@
<Card.Content>
<div class="flex flex-col">
<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}>
<span class="text-muted-foreground text-sm" data-testid="client-id"> {client.id}</span>
</CopyToClipboard>
</div>
{#if !client.isPublic}
<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}
<CopyToClipboard value={$clientSecretStore}>
<span class="text-muted-foreground text-sm" data-testid="client-secret">
@@ -158,7 +159,7 @@
{#if !showAllDetails}
<div class="mt-4 flex justify-center">
<Button on:click={() => (showAllDetails = true)} size="sm" variant="ghost"
>Show more details</Button
>{m.show_more_details()}</Button
>
</div>
{/if}
@@ -172,11 +173,11 @@
</Card.Root>
<CollapsibleCard
id="allowed-user-groups"
title="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."
title={m.allowed_user_groups()}
description={m.add_user_groups_to_this_client_to_restrict_access_to_users_in_these_groups()}
>
<UserGroupSelection bind:selectedGroupIds={client.allowedUserGroupIds} />
<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>
</CollapsibleCard>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,6 +13,7 @@
import { toast } from 'svelte-sonner';
import UserGroupForm from '../user-group-form.svelte';
import UserSelection from '../user-selection.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let userGroup = $state({
@@ -27,7 +28,7 @@
let success = true;
await userGroupService
.update(userGroup.id, updatedUserGroup)
.then(() => toast.success('User group updated successfully'))
.then(() => toast.success(m.user_group_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
@@ -39,7 +40,7 @@
async function updateUserGroupUsers(userIds: string[]) {
await userGroupService
.updateUsers(userGroup.id, userIds)
.then(() => toast.success('Users updated successfully'))
.then(() => toast.success(m.users_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
@@ -48,7 +49,7 @@
async function updateCustomClaims() {
await customClaimService
.updateUserGroupCustomClaims(userGroup.id, userGroup.customClaims)
.then(() => toast.success('Custom claims updated successfully'))
.then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
@@ -56,20 +57,20 @@
</script>
<svelte:head>
<title>User Group Details {userGroup.name}</title>
<title>{m.user_group_details_name({ name: userGroup.name })}</title>
</svelte:head>
<div class="flex items-center justify-between">
<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}
<Badge variant="default" class="">LDAP</Badge>
<Badge variant="default" class="">{m.ldap()}</Badge>
{/if}
</div>
<Card.Root>
<Card.Header>
<Card.Title>General</Card.Title>
<Card.Title>{m.general()}</Card.Title>
</Card.Header>
<Card.Content>
@@ -79,8 +80,8 @@
<Card.Root>
<Card.Header>
<Card.Title>Users</Card.Title>
<Card.Description>Assign users to this group.</Card.Description>
<Card.Title>{m.users()}</Card.Title>
<Card.Description>{m.assign_users_to_this_group()}</Card.Description>
</Card.Header>
<Card.Content>
@@ -91,7 +92,7 @@
<div class="mt-5 flex justify-end">
<Button
disabled={!!userGroup.ldapId && $appConfigStore.ldapEnabled}
on:click={() => updateUserGroupUsers(userGroup.userIds)}>Save</Button
on:click={() => updateUserGroupUsers(userGroup.userIds)}>{m.save()}</Button
>
</div>
</Card.Content>
@@ -99,11 +100,11 @@
<CollapsibleCard
id="user-group-custom-claims"
title="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."
title={m.custom_claims()}
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} />
<div class="mt-5 flex justify-end">
<Button onclick={updateCustomClaims} type="submit">Save</Button>
<Button onclick={updateCustomClaims} type="submit">{m.save()}</Button>
</div>
</CollapsibleCard>

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@
import { LucideChevronLeft } from 'lucide-svelte';
import { toast } from 'svelte-sonner';
import UserForm from '../user-form.svelte';
import { m } from '$lib/paraglide/messages';
let { data } = $props();
let user = $state({
@@ -27,7 +28,7 @@
async function updateUserGroups(userIds: string[]) {
await userService
.updateUserGroups(user.id, userIds)
.then(() => toast.success('User groups updated successfully'))
.then(() => toast.success(m.user_groups_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
@@ -37,7 +38,7 @@
let success = true;
await userService
.update(user.id, updatedUser)
.then(() => toast.success('User updated successfully'))
.then(() => toast.success(m.user_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
success = false;
@@ -49,7 +50,7 @@
async function updateCustomClaims() {
await customClaimService
.updateUserCustomClaims(user.id, user.customClaims)
.then(() => toast.success('Custom claims updated successfully'))
.then(() => toast.success(m.custom_claims_updated_successfully()))
.catch((e) => {
axiosErrorToast(e);
});
@@ -58,33 +59,38 @@
async function updateProfilePicture(image: File) {
await userService
.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);
}
async function resetProfilePicture() {
await userService
.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);
}
</script>
<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>
<div class="flex items-center justify-between">
<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}
<Badge variant="default" class="">LDAP</Badge>
<Badge variant="default" class="">{m.ldap()}</Badge>
{/if}
</div>
<Card.Root>
<Card.Header>
<Card.Title>General</Card.Title>
<Card.Title>{m.general()}</Card.Title>
</Card.Header>
<Card.Content>
<UserForm existingUser={user} callback={updateUser} />
@@ -104,8 +110,8 @@
<CollapsibleCard
id="user-groups"
title="User Groups"
description="Manage which groups this user belongs to."
title={m.user_groups()}
description={m.manage_which_groups_this_user_belongs_to()}
>
<UserGroupSelection
bind:selectedGroupIds={user.userGroupIds}
@@ -115,18 +121,18 @@
<Button
on:click={() => updateUserGroups(user.userGroupIds)}
disabled={!!user.ldapId && $appConfigStore.ldapEnabled}
type="submit">Save</Button
type="submit">{m.save()}</Button
>
</div>
</CollapsibleCard>
<CollapsibleCard
id="user-custom-claims"
title="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."
title={m.custom_claims()}
description={m.custom_claims_are_key_value_pairs_that_can_be_used_to_store_additional_information_about_a_user()}
>
<CustomClaimsInput bind:customClaims={user.customClaims} />
<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>
</CollapsibleCard>

View File

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

View File

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

View File

@@ -1,5 +1,6 @@
<script lang="ts">
import * as Card from '$lib/components/ui/card';
import { m } from '$lib/paraglide/messages';
import AuditLogList from './audit-log-list.svelte';
let { data } = $props();
@@ -8,14 +9,14 @@
</script>
<svelte:head>
<title>Audit Log</title>
<title>{m.audit_log()}</title>
</svelte:head>
<Card.Root>
<Card.Header>
<Card.Title>Audit Log</Card.Title>
<Card.Title>{m.audit_log()}</Card.Title>
<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.Content>

View File

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

View File

@@ -2,6 +2,7 @@ import test, { expect } from '@playwright/test';
import { users } from './data';
import { cleanupBackend } from './utils/cleanup.util';
import passkeyUtil from './utils/passkey.util';
import authUtil from './utils/auth.util';
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');
});
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 }) => {
await page.goto('/settings/account');

View File

@@ -1,16 +1,13 @@
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';
const authFile = 'tests/.auth/user.json';
setup('authenticate', async ({ page }) => {
await cleanupBackend();
await page.goto('/login');
await (await passkeyUtil.init(page)).addPasskey();
await page.getByRole('button', { name: 'Authenticate' }).click();
await authUtil.authenticate(page);
await page.waitForURL('/settings/account');
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 tailwindcss from '@tailwindcss/vite';
import { defineConfig } from 'vite';
export default defineConfig({
plugins: [sveltekit(), tailwindcss()]
plugins: [
sveltekit(),
tailwindcss(),
paraglideVitePlugin({
project: './project.inlang',
outdir: './src/lib/paraglide',
cookieName: "locale",
strategy: ['cookie', 'preferredLanguage', 'baseLocale']
}),
]
});