diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js
index e79ee029..246383c2 100644
--- a/frontend/eslint.config.js
+++ b/frontend/eslint.config.js
@@ -20,7 +20,7 @@ export default [
}
},
{
- files: ['**/*.svelte'],
+ files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
languageOptions: {
parserOptions: {
parser: ts.parser
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index 11e2c512..d344796e 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -110,7 +110,6 @@
"client": "Client",
"actor": "Actor",
"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",
@@ -136,7 +135,6 @@
"device_passkeys_only_description": "Allow only passkeys built into the user's device.",
"external_security_keys_only": "External security keys only",
"external_security_keys_only_description": "Allow only roaming authenticators such as USB or NFC security keys.",
- "passkey_configuration_updated_successfully": "Passkey configuration updated successfully",
"synced_passkeys_not_allowed": "Synced passkeys are not allowed by your administrator",
"manage_your_passkeys_that_you_can_use_to_authenticate_yourself": "Manage your passkeys that you can use to authenticate yourself.",
"manage_this_users_passkeys": "Manage this user's passkeys.",
@@ -181,13 +179,11 @@
"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. It may take a few minutes to update.",
"general": "General",
"configure_smtp_to_send_emails": "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",
- "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",
@@ -213,7 +209,6 @@
"email_login_code_from_admin": "Email Login Code from Admin",
"allows_an_admin_to_send_a_login_code_to_the_user": "Allows an admin to send a login code to the user via email.",
"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.",
@@ -266,9 +261,6 @@
"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.",
@@ -286,8 +278,6 @@
"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.",
@@ -320,7 +310,6 @@
"certificate_url": "Certificate URL",
"enabled": "Enabled",
"disabled": "Disabled",
- "oidc_client_updated_successfully": "OIDC client updated successfully",
"client_type": "Type",
"client_type_standard": "Standard",
"client_type_metadata_document": "Metadata Document",
@@ -344,7 +333,6 @@
"unrestrict": "Unrestrict",
"restrict": "Restrict",
"user_groups_restriction_updated_successfully": "User groups restriction updated successfully",
- "allowed_user_groups_updated_successfully": "Allowed user groups updated successfully",
"favicon": "Favicon",
"light_mode_logo": "Light Mode Logo",
"dark_mode_logo": "Dark Mode Logo",
@@ -468,7 +456,6 @@
"configure_user_creation": "Manage user creation settings, including signup methods and default permissions for new users.",
"user_creation_groups_description": "Assign these groups automatically to new users upon signup.",
"user_creation_claims_description": "Assign these custom claims automatically to new users upon signup.",
- "user_creation_updated_successfully": "User creation settings updated successfully.",
"signup_disabled_description": "User signups are completely disabled. Only administrators can create new user accounts.",
"signup_requires_valid_token": "A valid signup token is required to create an account",
"go_to_login": "Go to login",
@@ -547,7 +534,6 @@
"allowed_oidc_clients_description": "Select the OIDC clients that members of this user group are allowed to sign in to.",
"unrestrict_oidc_client": "Unrestrict {clientName}",
"confirm_unrestrict_oidc_client_description": "Are you sure you want to unrestrict the OIDC client {#b}{clientName}{/b}? This will remove all group assignments for this client and any user will be able to sign in.",
- "allowed_oidc_clients_updated_successfully": "Allowed OIDC clients updated successfully",
"yes": "Yes",
"no": "No",
"restricted": "Restricted",
@@ -556,7 +542,6 @@
"scim_endpoint": "SCIM Endpoint",
"scim_token": "SCIM Token",
"last_successful_sync_at": "Last successful sync: {time}",
- "scim_configuration_updated_successfully": "SCIM configuration updated successfully.",
"scim_enabled_successfully": "SCIM enabled successfully.",
"scim_disabled_successfully": "SCIM disabled successfully.",
"disable_scim_provisioning": "Disable SCIM Provisioning",
@@ -603,9 +588,7 @@
"add_permission": "Add permission",
"type": "Type",
"api_created_successfully": "API created successfully",
- "api_updated_successfully": "API updated successfully",
"api_deleted_successfully": "API deleted successfully",
- "api_permissions_updated_successfully": "Permissions updated successfully",
"are_you_sure_you_want_to_delete_this_api": "Are you sure you want to delete this API? Clients will lose access to its permissions.",
"api_access": "API access",
"api_access_description": "Select which APIs this client may request tokens for on behalf of users (user-delegated access) and for itself via the client credentials grant (client access), and which permissions it may ask for.",
@@ -670,5 +653,11 @@
"client_secrets_limit_reached": "An app cannot have more than 20 client secrets.",
"delete_client_secret": "Delete client secret",
"are_you_sure_you_want_to_delete_this_client_secret": "Are you sure you want to delete this client secret? Apps using it will no longer be able to authenticate.",
- "client_secret_deleted_successfully": "Client secret deleted successfully"
+ "client_secret_deleted_successfully": "Client secret deleted successfully",
+ "you_have_unsaved_changes": "You have unsaved changes",
+ "changes_saved_successfully": "Changes saved successfully",
+ "discard": "Discard",
+ "discard_unsaved_changes": "Discard unsaved changes?",
+ "discard_unsaved_changes_confirm": "You have unsaved changes on this page. Are you sure you want to discard them?",
+ "please_fix_the_errors_before_saving": "Please fix the highlighted errors before saving"
}
diff --git a/frontend/src/app.css b/frontend/src/app.css
index 5d05eee5..c7373ca5 100644
--- a/frontend/src/app.css
+++ b/frontend/src/app.css
@@ -20,12 +20,15 @@
::file-selector-button {
border-color: var(--color-gray-200, currentcolor);
}
+
* {
@apply border-border outline-ring/50;
}
+
body {
@apply bg-background text-foreground;
}
+
html {
@apply font-sans;
}
@@ -222,3 +225,105 @@
.animate-delayed-fade {
animation: delayed-fade 0.5s ease-out forwards;
}
+
+@keyframes shake {
+ 0%,
+ 100% {
+ transform: translateX(0);
+ }
+
+ 25% {
+ transform: translateX(-10px);
+ }
+
+ 50% {
+ transform: translateX(10px);
+ }
+
+ 75% {
+ transform: translateX(-10px);
+ }
+}
+
+.animate-shake {
+ animation: shake 0.5s ease-in-out;
+}
+
+/* The bar shares its surface token with the cards it floats over, so it needs an elevation of its own */
+.shadow-raised {
+ box-shadow:
+ inset 0 1px 0 rgb(255 255 255 / 0.5),
+ 0 1px 2px rgb(0 0 0 / 0.08),
+ 0 12px 32px -10px rgb(0 0 0 / 0.25);
+}
+
+.dark .shadow-raised {
+ box-shadow:
+ inset 0 1px 0 rgb(255 255 255 / 0.07),
+ 0 1px 2px rgb(0 0 0 / 0.6),
+ 0 16px 40px -12px rgb(0 0 0 / 0.9);
+}
+
+/* Marks a moment: the bar arriving, a save settling, a blocked navigation */
+@keyframes bloom {
+ 0% {
+ opacity: 0;
+ }
+
+ 30% {
+ opacity: 1;
+ }
+
+ 100% {
+ opacity: 0;
+ }
+}
+
+.animate-bloom {
+ animation: bloom 1.5s ease-out forwards;
+}
+
+/* Dark mode has glow headroom: a luminous colour over a near-black pill reads as light spilling out of it */
+/* Light mode has none, so the same gradient washes out to almost nothing there */
+/* The light values keep the same origin below the pill, so the bloom rises the same way in both, but spread wider and carry more colour to register against white */
+.bloom-pending {
+ background: radial-gradient(150% 260% at 50% 140%, rgb(217 119 6 / 0.26), transparent 72%);
+}
+
+/* Waits out the bar's 380ms motion so it blooms after the pill settles */
+/* `both` keeps it hidden during that wait */
+.bloom-success {
+ background: radial-gradient(150% 260% at 50% 140%, rgb(22 163 74 / 0.3), transparent 72%);
+ animation-delay: 380ms;
+ animation-fill-mode: both;
+}
+
+.bloom-error {
+ background: radial-gradient(
+ 150% 260% at 50% 140%,
+ color-mix(in oklab, var(--destructive) 28%, transparent),
+ transparent 72%
+ );
+}
+
+.dark .bloom-pending {
+ background: radial-gradient(130% 150% at 50% 130%, rgb(251 191 36 / 0.24), transparent 70%);
+}
+
+.dark .bloom-success {
+ background: radial-gradient(130% 150% at 50% 130%, rgb(34 197 94 / 0.24), transparent 70%);
+}
+
+.dark .bloom-error {
+ background: radial-gradient(
+ 130% 150% at 50% 130%,
+ color-mix(in oklab, var(--destructive) 26%, transparent),
+ transparent 70%
+ );
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .animate-bloom {
+ animation: none;
+ }
+}
diff --git a/frontend/src/lib/components/email-verification-state-box.svelte b/frontend/src/lib/components/email-verification-state-box.svelte
index e684c180..4edc860a 100644
--- a/frontend/src/lib/components/email-verification-state-box.svelte
+++ b/frontend/src/lib/components/email-verification-state-box.svelte
@@ -1,4 +1,5 @@
diff --git a/frontend/src/lib/components/table/advanced-table.svelte b/frontend/src/lib/components/table/advanced-table.svelte
index 56a86270..3dc4b920 100644
--- a/frontend/src/lib/components/table/advanced-table.svelte
+++ b/frontend/src/lib/components/table/advanced-table.svelte
@@ -1,4 +1,6 @@
diff --git a/frontend/src/lib/components/unsaved-changes-bar.svelte b/frontend/src/lib/components/unsaved-changes-bar.svelte
new file mode 100644
index 00000000..c77b039c
--- /dev/null
+++ b/frontend/src/lib/components/unsaved-changes-bar.svelte
@@ -0,0 +1,247 @@
+
+
+
+
+
+{#snippet divider()}
+
+{/snippet}
+
+{#if barOpen}
+