diff --git a/.sqlx/query-27921f7ba0cd3b53df1c8a2141b904ac8b591a6093c37273b16f2a4b6d993a89.json b/.sqlx/query-27921f7ba0cd3b53df1c8a2141b904ac8b591a6093c37273b16f2a4b6d993a89.json new file mode 100644 index 000000000..1238097b5 --- /dev/null +++ b/.sqlx/query-27921f7ba0cd3b53df1c8a2141b904ac8b591a6093c37273b16f2a4b6d993a89.json @@ -0,0 +1,38 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n migration_wizard_in_progress,\n migration_wizard_completed,\n initial_wizard_in_progress,\n initial_wizard_completed\n FROM wizard\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "migration_wizard_in_progress", + "type_info": "Bool" + }, + { + "ordinal": 1, + "name": "migration_wizard_completed", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "initial_wizard_in_progress", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "initial_wizard_completed", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "27921f7ba0cd3b53df1c8a2141b904ac8b591a6093c37273b16f2a4b6d993a89" +} diff --git a/.sqlx/query-32e45d84ff0dbedf39c02b6813e99c1302322e3a08b04478a2413adcef4acc4e.json b/.sqlx/query-61c5e4b0b9e2d7a57403b52c2b0917301b5b7d8779c01405fd1b007f9fe99e30.json similarity index 85% rename from .sqlx/query-32e45d84ff0dbedf39c02b6813e99c1302322e3a08b04478a2413adcef4acc4e.json rename to .sqlx/query-61c5e4b0b9e2d7a57403b52c2b0917301b5b7d8779c01405fd1b007f9fe99e30.json index 94fd3166a..42c3a69de 100644 --- a/.sqlx/query-32e45d84ff0dbedf39c02b6813e99c1302322e3a08b04478a2413adcef4acc4e.json +++ b/.sqlx/query-61c5e4b0b9e2d7a57403b52c2b0917301b5b7d8779c01405fd1b007f9fe99e30.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "UPDATE \"settings\" SET openid_enabled = $1, wireguard_enabled = $2, webhooks_enabled = $3, worker_enabled = $4, challenge_template = $5, instance_name = $6, main_logo_url = $7, nav_logo_url = $8, smtp_server = $9, smtp_port = $10, smtp_encryption = $11, smtp_user = $12, smtp_password = $13, smtp_sender = $14, enrollment_vpn_step_optional = $15, enrollment_welcome_message = $16, enrollment_welcome_email = $17, enrollment_welcome_email_subject = $18, enrollment_use_welcome_message_as_email = $19, uuid = $20, ldap_url = $21, ldap_bind_username = $22, ldap_bind_password = $23, ldap_group_search_base = $24, ldap_user_search_base = $25, ldap_user_obj_class = $26, ldap_group_obj_class = $27, ldap_username_attr = $28, ldap_groupname_attr = $29, ldap_group_member_attr = $30, ldap_member_attr = $31, ldap_use_starttls = $32, ldap_tls_verify_cert = $33, openid_create_account = $34, license = $35, gateway_disconnect_notifications_enabled = $36, gateway_disconnect_notifications_inactivity_threshold = $37, gateway_disconnect_notifications_reconnect_notification_enabled = $38, ldap_sync_status = $39, ldap_enabled = $40, ldap_sync_enabled = $41, ldap_is_authoritative = $42, ldap_sync_interval = $43, ldap_user_auxiliary_obj_classes = $44, ldap_uses_ad = $45, ldap_user_rdn_attr = $46, ldap_sync_groups = $47, openid_username_handling = $48, ca_key_der = $49, ca_cert_der = $50, ca_expiry = $51, initial_setup_completed = $52, defguard_url = $53, default_admin_group_name = $54, authentication_period_days = $55, mfa_code_timeout_seconds = $56, public_proxy_url = $57, initial_setup_step = $58, default_admin_id = $59 WHERE id = 1", + "query": "UPDATE \"settings\" SET openid_enabled = $1, wireguard_enabled = $2, webhooks_enabled = $3, worker_enabled = $4, challenge_template = $5, instance_name = $6, main_logo_url = $7, nav_logo_url = $8, smtp_server = $9, smtp_port = $10, smtp_encryption = $11, smtp_user = $12, smtp_password = $13, smtp_sender = $14, enrollment_vpn_step_optional = $15, enrollment_welcome_message = $16, enrollment_welcome_email = $17, enrollment_welcome_email_subject = $18, enrollment_use_welcome_message_as_email = $19, uuid = $20, ldap_url = $21, ldap_bind_username = $22, ldap_bind_password = $23, ldap_group_search_base = $24, ldap_user_search_base = $25, ldap_user_obj_class = $26, ldap_group_obj_class = $27, ldap_username_attr = $28, ldap_groupname_attr = $29, ldap_group_member_attr = $30, ldap_member_attr = $31, ldap_use_starttls = $32, ldap_tls_verify_cert = $33, openid_create_account = $34, license = $35, gateway_disconnect_notifications_enabled = $36, gateway_disconnect_notifications_inactivity_threshold = $37, gateway_disconnect_notifications_reconnect_notification_enabled = $38, ldap_sync_status = $39, ldap_enabled = $40, ldap_sync_enabled = $41, ldap_is_authoritative = $42, ldap_sync_interval = $43, ldap_user_auxiliary_obj_classes = $44, ldap_uses_ad = $45, ldap_user_rdn_attr = $46, ldap_sync_groups = $47, openid_username_handling = $48, ca_key_der = $49, ca_cert_der = $50, ca_expiry = $51, initial_setup_completed = $52, defguard_url = $53, default_admin_group_name = $54, authentication_period_days = $55, mfa_code_timeout_seconds = $56, public_proxy_url = $57, initial_setup_step = $58, default_admin_id = $59, auth_cookie_timeout_days = $60, secret_key = $61, webauthn_rp_id = $62, grpc_url = $63, disable_stats_purge = $64, stats_purge_frequency_hours = $65, stats_purge_threshold_days = $66, enrollment_token_timeout_hours = $67, password_reset_token_timeout_hours = $68, enrollment_session_timeout_minutes = $69, password_reset_session_timeout_minutes = $70 WHERE id = 1", "describe": { "columns": [], "parameters": { @@ -111,10 +111,21 @@ } } }, - "Int8" + "Int8", + "Int4", + "Text", + "Text", + "Text", + "Bool", + "Int4", + "Int4", + "Int4", + "Int4", + "Int4", + "Int4" ] }, "nullable": [] }, - "hash": "32e45d84ff0dbedf39c02b6813e99c1302322e3a08b04478a2413adcef4acc4e" + "hash": "61c5e4b0b9e2d7a57403b52c2b0917301b5b7d8779c01405fd1b007f9fe99e30" } diff --git a/.sqlx/query-080139f99a90a5b4aeb3476890595a8a6dbf3d74a7fe116a2dd4fc9643203bf5.json b/.sqlx/query-cfa79b8799e40d86fa51592aed85a378c487c201c637a8bb7f214fa009316980.json similarity index 84% rename from .sqlx/query-080139f99a90a5b4aeb3476890595a8a6dbf3d74a7fe116a2dd4fc9643203bf5.json rename to .sqlx/query-cfa79b8799e40d86fa51592aed85a378c487c201c637a8bb7f214fa009316980.json index 0d320da8d..e13b76df3 100644 --- a/.sqlx/query-080139f99a90a5b4aeb3476890595a8a6dbf3d74a7fe116a2dd4fc9643203bf5.json +++ b/.sqlx/query-cfa79b8799e40d86fa51592aed85a378c487c201c637a8bb7f214fa009316980.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT openid_enabled, wireguard_enabled, webhooks_enabled, worker_enabled, challenge_template, instance_name, main_logo_url, nav_logo_url, smtp_server, smtp_port, smtp_encryption \"smtp_encryption: _\", smtp_user, smtp_password \"smtp_password?: SecretStringWrapper\", smtp_sender, enrollment_vpn_step_optional, enrollment_welcome_message, enrollment_welcome_email, enrollment_welcome_email_subject, enrollment_use_welcome_message_as_email, uuid, ldap_url, ldap_bind_username, ldap_bind_password \"ldap_bind_password?: SecretStringWrapper\", ldap_group_search_base, ldap_user_search_base, ldap_user_obj_class, ldap_group_obj_class, ldap_username_attr, ldap_groupname_attr, ldap_group_member_attr, ldap_member_attr, openid_create_account, license, gateway_disconnect_notifications_enabled, ldap_use_starttls, ldap_tls_verify_cert, gateway_disconnect_notifications_inactivity_threshold, gateway_disconnect_notifications_reconnect_notification_enabled, ldap_sync_status \"ldap_sync_status: LdapSyncStatus\", ldap_enabled, ldap_sync_enabled, ldap_is_authoritative, ldap_sync_interval, ldap_user_auxiliary_obj_classes, ldap_uses_ad, ldap_user_rdn_attr, ldap_sync_groups, openid_username_handling \"openid_username_handling: OpenIdUsernameHandling\", ca_key_der, ca_cert_der, ca_expiry, initial_setup_completed, defguard_url, default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, public_proxy_url, initial_setup_step \"initial_setup_step: InitialSetupStep\", default_admin_id FROM \"settings\" WHERE id = 1", + "query": "SELECT openid_enabled, wireguard_enabled, webhooks_enabled, worker_enabled, challenge_template, instance_name, main_logo_url, nav_logo_url, smtp_server, smtp_port, smtp_encryption \"smtp_encryption: _\", smtp_user, smtp_password \"smtp_password?: SecretStringWrapper\", smtp_sender, enrollment_vpn_step_optional, enrollment_welcome_message, enrollment_welcome_email, enrollment_welcome_email_subject, enrollment_use_welcome_message_as_email, uuid, ldap_url, ldap_bind_username, ldap_bind_password \"ldap_bind_password?: SecretStringWrapper\", ldap_group_search_base, ldap_user_search_base, ldap_user_obj_class, ldap_group_obj_class, ldap_username_attr, ldap_groupname_attr, ldap_group_member_attr, ldap_member_attr, openid_create_account, license, gateway_disconnect_notifications_enabled, ldap_use_starttls, ldap_tls_verify_cert, gateway_disconnect_notifications_inactivity_threshold, gateway_disconnect_notifications_reconnect_notification_enabled, ldap_sync_status \"ldap_sync_status: LdapSyncStatus\", ldap_enabled, ldap_sync_enabled, ldap_is_authoritative, ldap_sync_interval, ldap_user_auxiliary_obj_classes, ldap_uses_ad, ldap_user_rdn_attr, ldap_sync_groups, openid_username_handling \"openid_username_handling: OpenIdUsernameHandling\", ca_key_der, ca_cert_der, ca_expiry, initial_setup_completed, defguard_url, default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, public_proxy_url, initial_setup_step \"initial_setup_step: InitialSetupStep\", default_admin_id, auth_cookie_timeout_days, secret_key, webauthn_rp_id, grpc_url, disable_stats_purge, stats_purge_frequency_hours, stats_purge_threshold_days, enrollment_token_timeout_hours, password_reset_token_timeout_hours, enrollment_session_timeout_minutes, password_reset_session_timeout_minutes FROM \"settings\" WHERE id = 1", "describe": { "columns": [ { @@ -345,6 +345,61 @@ "ordinal": 58, "name": "default_admin_id", "type_info": "Int8" + }, + { + "ordinal": 59, + "name": "auth_cookie_timeout_days", + "type_info": "Int4" + }, + { + "ordinal": 60, + "name": "secret_key", + "type_info": "Text" + }, + { + "ordinal": 61, + "name": "webauthn_rp_id", + "type_info": "Text" + }, + { + "ordinal": 62, + "name": "grpc_url", + "type_info": "Text" + }, + { + "ordinal": 63, + "name": "disable_stats_purge", + "type_info": "Bool" + }, + { + "ordinal": 64, + "name": "stats_purge_frequency_hours", + "type_info": "Int4" + }, + { + "ordinal": 65, + "name": "stats_purge_threshold_days", + "type_info": "Int4" + }, + { + "ordinal": 66, + "name": "enrollment_token_timeout_hours", + "type_info": "Int4" + }, + { + "ordinal": 67, + "name": "password_reset_token_timeout_hours", + "type_info": "Int4" + }, + { + "ordinal": 68, + "name": "enrollment_session_timeout_minutes", + "type_info": "Int4" + }, + { + "ordinal": 69, + "name": "password_reset_session_timeout_minutes", + "type_info": "Int4" } ], "parameters": { @@ -409,8 +464,19 @@ false, false, false, - true + true, + false, + true, + true, + false, + false, + false, + false, + false, + false, + false, + false ] }, - "hash": "080139f99a90a5b4aeb3476890595a8a6dbf3d74a7fe116a2dd4fc9643203bf5" + "hash": "cfa79b8799e40d86fa51592aed85a378c487c201c637a8bb7f214fa009316980" } diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index b4287f01d..919b58511 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -16,7 +16,7 @@ use defguard_common::{ }; use defguard_core::{ auth::failed_login::FailedLoginMap, - db::AppEvent, + db::{AppEvent, models::wizard_flags::WizardFlags}, enterprise::{ activity_log_stream::activity_log_stream_manager::run_activity_log_stream_manager, license::{License, run_periodic_license_check, set_cached_license}, @@ -33,7 +33,7 @@ use defguard_event_router::{RouterReceiverSet, run_event_router}; use defguard_gateway_manager::{GatewayManager, GatewayTxSet}; use defguard_proxy_manager::{ProxyManager, ProxyTxSet}; use defguard_session_manager::{events::SessionManagerEvent, run_session_manager}; -use defguard_setup::setup::run_setup_web_server; +use defguard_setup::{migration::run_migration_web_server, setup::run_setup_web_server}; use defguard_vpn_stats_purge::run_periodic_stats_purge; use secrecy::ExposeSecret; use tokio::sync::{ @@ -94,27 +94,49 @@ async fn main() -> Result<(), anyhow::Error> { info!("Using HMAC OpenID signing key"); } + let wizard_flags = WizardFlags::init(&pool).await?; + let mut ini_server_config = true; + // initialize default settings Settings::init_defaults(&pool).await?; // initialize global settings struct initialize_current_settings(&pool).await?; + Settings::ensure_secret_key(&pool, &config).await?; let mut settings = Settings::get_current_settings(); - if !settings.initial_setup_completed { + if wizard_flags.initial_wizard_in_progress && !wizard_flags.initial_wizard_completed { if let Err(err) = run_setup_web_server(pool.clone(), config.http_bind_address, config.http_port).await { anyhow::bail!("Setup web server exited with error: {err}"); } + settings = Settings::get_current_settings(); + } else if wizard_flags.migration_wizard_in_progress && !wizard_flags.migration_wizard_completed + { + settings.update_from_config(&pool, &config).await?; + + config.initialize_post_settings(); + SERVER_CONFIG + .set(config.clone()) + .expect("Failed to initialize server config."); + + ini_server_config = false; + if let Err(err) = + run_migration_web_server(pool.clone(), config.http_bind_address, config.http_port).await + { + anyhow::bail!("Migration web server exited with error: {err}"); + } settings = Settings::get_current_settings(); } - config.initialize_post_settings(); + if ini_server_config { + config.initialize_post_settings(); - SERVER_CONFIG - .set(config.clone()) - .expect("Failed to initialize server config."); + SERVER_CONFIG + .set(config.clone()) + .expect("Failed to initialize server config."); + } // create event channels for services let (api_event_tx, api_event_rx) = unbounded_channel::(); @@ -141,7 +163,7 @@ async fn main() -> Result<(), anyhow::Error> { anyhow::bail!("CA certificate or key were not found in settings, despite completing setup.") } - // read grpc TLS cert and key + // read grpc TLS cert and key from legacy config values let grpc_cert = config .grpc_cert .as_ref() @@ -172,11 +194,13 @@ async fn main() -> Result<(), anyhow::Error> { } let (proxy_control_tx, proxy_control_rx) = channel::(100); + let proxy_secret_key = settings.secret_key_required()?.to_string(); let proxy_manager = ProxyManager::new( pool.clone(), ProxyTxSet::new(gateway_tx.clone(), bidi_event_tx.clone()), Arc::clone(&incompatible_components), proxy_control_rx, + proxy_secret_key, ); let mut gateway_manager = GatewayManager::new( @@ -208,9 +232,9 @@ async fn main() -> Result<(), anyhow::Error> { ) => error!("Web server returned early: {res:?}"), res = run_periodic_stats_purge( pool.clone(), - config.stats_purge_frequency.into(), - config.stats_purge_threshold.into() - ), if !config.disable_stats_purge => + settings.stats_purge_frequency(), + settings.stats_purge_threshold() + ), if !settings.disable_stats_purge => error!("Periodic stats purge task returned early: {res:?}"), res = run_periodic_license_check(&pool) => error!("Periodic license check task returned early: {res:?}"), diff --git a/crates/defguard_common/src/config.rs b/crates/defguard_common/src/config.rs index 6db70311a..b05f859d5 100644 --- a/crates/defguard_common/src/config.rs +++ b/crates/defguard_common/src/config.rs @@ -1,4 +1,4 @@ -use std::{fs::read_to_string, io, net::IpAddr, sync::OnceLock}; +use std::{net::IpAddr, sync::OnceLock}; use clap::{Args, Parser, Subcommand}; use humantime::Duration; @@ -13,7 +13,6 @@ use rsa::{ }; use secrecy::{ExposeSecret, SecretString}; use serde::Serialize; -use tonic::transport::{Certificate, ClientTlsConfig, Identity}; use crate::db::models::Settings; @@ -38,13 +37,15 @@ pub struct DefGuardConfig { #[arg(long, env = "DEFGUARD_LOG_FILE")] pub log_file: Option, - #[arg(long, env = "DEFGUARD_AUTH_COOKIE_TIMEOUT", default_value = "7d")] + #[arg(long, env = "DEFGUARD_AUTH_COOKIE_TIMEOUT")] #[serde(skip_serializing)] - pub auth_cookie_timeout: Duration, + #[deprecated(since = "2.0.0", note = "Use Settings.auth_cookie_timeout instead")] + pub auth_cookie_timeout: Option, #[arg(long, env = "DEFGUARD_SECRET_KEY")] #[serde(skip_serializing)] - pub secret_key: SecretString, + #[deprecated(since = "2.0.0", note = "Use Settings.secret_key instead")] + pub secret_key: Option, #[arg(long, env = "DEFGUARD_DB_HOST", default_value = "localhost")] pub database_host: String, @@ -68,9 +69,8 @@ pub struct DefGuardConfig { #[arg(long, env = "DEFGUARD_GRPC_PORT", default_value_t = 50055)] pub grpc_port: u16, - // Certificate authority (CA), certificate, and key for gRPC communication over HTTPS. - #[arg(long, env = "DEFGUARD_GRPC_CA")] - pub grpc_ca: Option, + // Certificate and key for gRPC communication over HTTPS. + // Kept in runtime config for backwards compatibility - workers still use this. #[arg(long, env = "DEFGUARD_GRPC_CERT")] pub grpc_cert: Option, #[arg(long, env = "DEFGUARD_GRPC_KEY")] @@ -92,69 +92,81 @@ pub struct DefGuardConfig { // relying party id and relying party origin for WebAuthn #[arg(long, env = "DEFGUARD_WEBAUTHN_RP_ID")] + #[deprecated(since = "2.0.0", note = "Use Settings.webauthn_rp_id instead")] pub webauthn_rp_id: Option, #[arg(long, env = "DEFGUARD_URL", value_parser = Url::parse, default_value = "http://localhost:8000")] #[deprecated(since = "2.0.0", note = "Use Settings.defguard_url instead")] pub url: Url, - #[arg(long, env = "DEFGUARD_GRPC_URL", value_parser = Url::parse, default_value = "http://localhost:50055")] - pub grpc_url: Url, + #[arg(long, env = "DEFGUARD_GRPC_URL", value_parser = Url::parse)] + #[deprecated(since = "2.0.0", note = "Use Settings.grpc_url instead")] + pub grpc_url: Option, #[arg(long, env = "DEFGUARD_DISABLE_STATS_PURGE")] - pub disable_stats_purge: bool, + #[deprecated(since = "2.0.0", note = "Use Settings.disable_stats_purge instead")] + pub disable_stats_purge: Option, - #[arg(long, env = "DEFGUARD_STATS_PURGE_FREQUENCY", default_value = "24h")] + #[arg(long, env = "DEFGUARD_STATS_PURGE_FREQUENCY")] #[serde(skip_serializing)] - pub stats_purge_frequency: Duration, + #[deprecated(since = "2.0.0", note = "Use Settings.stats_purge_frequency instead")] + pub stats_purge_frequency: Option, - #[arg(long, env = "DEFGUARD_STATS_PURGE_THRESHOLD", default_value = "30d")] + #[arg(long, env = "DEFGUARD_STATS_PURGE_THRESHOLD")] #[serde(skip_serializing)] - pub stats_purge_threshold: Duration, + #[deprecated(since = "2.0.0", note = "Use Settings.stats_purge_threshold instead")] + pub stats_purge_threshold: Option, - #[arg(long, env = "DEFGUARD_ENROLLMENT_URL", value_parser = Url::parse, default_value = "http://localhost:8080")] + #[arg(long, env = "DEFGUARD_ENROLLMENT_URL", value_parser = Url::parse)] #[deprecated(since = "2.0.0", note = "Use Settings.public_proxy_url instead")] - pub enrollment_url: Url, + pub enrollment_url: Option, - #[arg(long, env = "DEFGUARD_ENROLLMENT_TOKEN_TIMEOUT", default_value = "24h")] + #[arg(long, env = "DEFGUARD_ENROLLMENT_TOKEN_TIMEOUT")] #[serde(skip_serializing)] - pub enrollment_token_timeout: Duration, + #[deprecated( + since = "2.0.0", + note = "Use Settings.enrollment_token_timeout instead" + )] + pub enrollment_token_timeout: Option, - #[arg(long, env = "DEFGUARD_MFA_CODE_TIMEOUT", default_value = "60s")] + #[arg(long, env = "DEFGUARD_MFA_CODE_TIMEOUT")] #[serde(skip_serializing)] #[deprecated( since = "2.0.0", - note = "Use Settings.default_mfa_code_lifetime instead" + note = "Use Settings.mfa_code_timeout_seconds instead" )] - pub mfa_code_timeout: Duration, + pub mfa_code_timeout: Option, - #[arg(long, env = "DEFGUARD_SESSION_TIMEOUT", default_value = "7d")] + #[arg(long, env = "DEFGUARD_SESSION_TIMEOUT")] #[serde(skip_serializing)] - #[deprecated(since = "2.0.0", note = "Use Settings.default_authentication instead")] - pub session_timeout: Duration, - - #[arg( - long, - env = "DEFGUARD_PASSWORD_RESET_TOKEN_TIMEOUT", - default_value = "24h" + #[deprecated( + since = "2.0.0", + note = "Use Settings.authentication_period_days instead" )] - #[serde(skip_serializing)] - pub password_reset_token_timeout: Duration, + pub session_timeout: Option, - #[arg( - long, - env = "DEFGUARD_ENROLLMENT_SESSION_TIMEOUT", - default_value = "10m" - )] + #[arg(long, env = "DEFGUARD_PASSWORD_RESET_TOKEN_TIMEOUT")] #[serde(skip_serializing)] - pub enrollment_session_timeout: Duration, + #[deprecated( + since = "2.0.0", + note = "Use Settings.password_reset_token_timeout instead" + )] + pub password_reset_token_timeout: Option, - #[arg( - long, - env = "DEFGUARD_PASSWORD_RESET_SESSION_TIMEOUT", - default_value = "10m" + #[arg(long, env = "DEFGUARD_ENROLLMENT_SESSION_TIMEOUT")] + #[serde(skip_serializing)] + #[deprecated( + since = "2.0.0", + note = "Use Settings.enrollment_session_timeout instead" )] + pub enrollment_session_timeout: Option, + + #[arg(long, env = "DEFGUARD_PASSWORD_RESET_SESSION_TIMEOUT")] #[serde(skip_serializing)] - pub password_reset_session_timeout: Duration, + #[deprecated( + since = "2.0.0", + note = "Use Settings.password_reset_session_timeout instead" + )] + pub password_reset_session_timeout: Option, #[arg(long, env = "DEFGUARD_COOKIE_DOMAIN")] pub cookie_domain: Option, @@ -162,10 +174,6 @@ pub struct DefGuardConfig { #[arg(long, env = "DEFGUARD_COOKIE_INSECURE")] pub cookie_insecure: bool, - // path to certificate `.pem` file used if connecting to proxy over HTTPS - #[arg(long, env = "DEFGUARD_PROXY_GRPC_CA")] - pub proxy_grpc_ca: Option, - #[command(subcommand)] #[serde(skip_serializing)] pub cmd: Option, @@ -227,7 +235,11 @@ impl DefGuardConfig { #[must_use] pub fn new() -> Self { let config = Self::parse(); - config.validate_secret_key(); + #[allow(deprecated)] + if let Some(secret_key) = &config.secret_key { + Settings::validate_secret_key(secret_key.expose_secret()) + .expect("Invalid DEFGUARD_SECRET_KEY"); + } config } @@ -240,19 +252,20 @@ impl DefGuardConfig { /// Initialize values that depend on Settings. pub fn initialize_post_settings(&mut self) { let url = Settings::url().expect("Unable to parse Defguard URL."); - self.initialize_rp_id(&url); + // TODO(jck) + // self.initialize_rp_id(&url); self.initialize_cookie_domain(&url); } - fn initialize_rp_id(&mut self, url: &Url) { - if self.webauthn_rp_id.is_none() { - self.webauthn_rp_id = Some( - url.domain() - .expect("Unable to get domain for server URL.") - .to_string(), - ); - } - } + // fn initialize_rp_id(&mut self, url: &Url) { + // if self.webauthn_rp_id.is_none() { + // self.webauthn_rp_id = Some( + // url.domain() + // .expect("Unable to get domain for server URL.") + // .to_string(), + // ); + // } + // } fn initialize_cookie_domain(&mut self, url: &Url) { if self.cookie_domain.is_none() { @@ -264,20 +277,6 @@ impl DefGuardConfig { } } - fn validate_secret_key(&self) { - let secret_key = self.secret_key.expose_secret(); - assert!( - secret_key.trim().len() == secret_key.len(), - "SECRET_KEY cannot have leading and trailing space", - ); - - assert!( - secret_key.len() >= 64, - "SECRET_KEY must be at least 64 characters long, provided value has {} characters", - secret_key.len() - ); - } - /// Try PKCS#1 and PKCS#8 PEM formats. fn parse_openid_key(path: &str) -> Result { if let Ok(key) = RsaPrivateKey::read_pkcs1_pem_file(path) { @@ -297,25 +296,6 @@ impl DefGuardConfig { None } } - - /// Provide [`ClientTlsConfig`] from paths to cerfiticate, key, and cerfiticate authority (CA). - pub fn grpc_client_tls_config(&self) -> Result, io::Error> { - if self.grpc_ca.is_none() && (self.grpc_cert.is_none() || self.grpc_key.is_none()) { - return Ok(None); - } - let mut tls = ClientTlsConfig::new(); - if let (Some(cert_path), Some(key_path)) = (&self.grpc_cert, &self.grpc_key) { - let cert = read_to_string(cert_path)?; - let key = read_to_string(key_path)?; - tls = tls.identity(Identity::from_pem(cert, key)); - } - if let Some(ca_path) = &self.grpc_ca { - let ca = read_to_string(ca_path)?; - tls = tls.ca_certificate(Certificate::from_pem(ca)); - } - - Ok(Some(tls)) - } } impl Default for DefGuardConfig { @@ -336,29 +316,29 @@ mod tests { DefGuardConfig::command().debug_assert(); } - #[test] - fn test_generate_rp_id() { - unsafe { - env::remove_var("DEFGUARD_WEBAUTHN_RP_ID"); - } + // #[test] + // fn test_generate_rp_id() { + // unsafe { + // env::remove_var("DEFGUARD_WEBAUTHN_RP_ID"); + // } - let url = Url::parse("https://defguard.example.com").unwrap(); - let mut config = DefGuardConfig::new(); - config.initialize_rp_id(&url); + // let url = Url::parse("https://defguard.example.com").unwrap(); + // let mut config = DefGuardConfig::new(); + // config.initialize_rp_id(&url); - assert_eq!( - config.webauthn_rp_id, - Some("defguard.example.com".to_string()) - ); + // assert_eq!( + // config.webauthn_rp_id, + // Some("defguard.example.com".to_string()) + // ); - unsafe { - env::set_var("DEFGUARD_WEBAUTHN_RP_ID", "example.com"); - } + // unsafe { + // env::set_var("DEFGUARD_WEBAUTHN_RP_ID", "example.com"); + // } - let config = DefGuardConfig::new(); + // let config = DefGuardConfig::new(); - assert_eq!(config.webauthn_rp_id, Some("example.com".to_string())); - } + // assert_eq!(config.webauthn_rp_id, Some("example.com".to_string())); + // } #[test] fn test_generate_cookie_domain() { diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index 8daaf158a..dac2849f1 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -1,6 +1,8 @@ use std::{collections::HashMap, fmt, time::Duration}; use chrono::NaiveDateTime; +use rand::{RngCore, rngs::OsRng}; +use secrecy::ExposeSecret; use serde::{Deserialize, Serialize}; use sqlx::{PgExecutor, PgPool, Type, query, query_as}; use struct_patch::Patch; @@ -10,7 +12,7 @@ use url::Url; use utoipa::ToSchema; use uuid::Uuid; -use crate::{db::Id, global_value, secret::SecretStringWrapper}; +use crate::{config::DefGuardConfig, db::Id, global_value, secret::SecretStringWrapper}; global_value!(SETTINGS, Option, None, set_settings, get_settings); @@ -46,6 +48,14 @@ pub enum SettingsValidationError { CannotEnableGatewayNotifications, } +#[derive(Error, Debug)] +pub enum SettingsRequiredValueError { + #[error("Missing required setting: {0}")] + Missing(&'static str), + #[error("Invalid required setting `{0}`: {1}")] + Invalid(&'static str, &'static str), +} + #[derive(Clone, Deserialize, Serialize, PartialEq, Eq, Type, Debug, Default)] #[sqlx(type_name = "smtp_encryption", rename_all = "lowercase")] pub enum SmtpEncryption { @@ -175,6 +185,18 @@ pub struct Settings { pub public_proxy_url: String, pub initial_setup_step: InitialSetupStep, pub default_admin_id: Option, + // 1.6 config options + pub secret_key: Option, + pub webauthn_rp_id: Option, + pub grpc_url: String, + pub disable_stats_purge: bool, + auth_cookie_timeout_days: i32, + stats_purge_frequency_hours: i32, + stats_purge_threshold_days: i32, + enrollment_token_timeout_hours: i32, + password_reset_token_timeout_hours: i32, + enrollment_session_timeout_minutes: i32, + password_reset_session_timeout_minutes: i32, } // Implement manually to avoid exposing the license key. @@ -269,6 +291,63 @@ impl fmt::Debug for Settings { } impl Settings { + pub(crate) fn validate_secret_key(secret_key: &str) -> Result<(), SettingsRequiredValueError> { + if secret_key.trim().len() != secret_key.len() { + return Err(SettingsRequiredValueError::Invalid( + "secret_key", + "cannot have leading or trailing whitespace", + )); + } + + if secret_key.len() < 64 { + return Err(SettingsRequiredValueError::Invalid( + "secret_key", + "must be at least 64 characters long", + )); + } + + Ok(()) + } + + fn generate_secret_key() -> String { + let mut bytes = [0_u8; 32]; + OsRng.fill_bytes(&mut bytes); + let mut secret_key = String::with_capacity(64); + for byte in bytes { + use std::fmt::Write as _; + let _ = write!(secret_key, "{byte:02x}"); + } + secret_key + } + + pub async fn ensure_secret_key( + pool: &PgPool, + config: &DefGuardConfig, + ) -> Result<(), anyhow::Error> { + let mut settings = Settings::get_current_settings(); + + #[allow(deprecated)] + if let Some(secret_key) = &config.secret_key { + let secret_key = secret_key.expose_secret(); + Settings::validate_secret_key(secret_key)?; + if settings.secret_key.as_deref() != Some(secret_key) { + settings.secret_key = Some(secret_key.to_string()); + update_current_settings(pool, settings).await?; + } + return Ok(()); + } + + if let Some(secret_key) = settings.secret_key.as_deref() { + Settings::validate_secret_key(secret_key)?; + return Ok(()); + } + + settings.secret_key = Some(Settings::generate_secret_key()); + update_current_settings(pool, settings).await?; + + Ok(()) + } + pub async fn get<'e, E>(executor: E) -> Result, sqlx::Error> where E: PgExecutor<'e>, @@ -297,7 +376,10 @@ impl Settings { ca_key_der, ca_cert_der, ca_expiry, initial_setup_completed, defguard_url, \ default_admin_group_name, authentication_period_days, mfa_code_timeout_seconds, \ public_proxy_url, initial_setup_step \"initial_setup_step: InitialSetupStep\", \ - default_admin_id \ + default_admin_id, auth_cookie_timeout_days, secret_key, webauthn_rp_id, grpc_url, disable_stats_purge, \ + stats_purge_frequency_hours, stats_purge_threshold_days, \ + enrollment_token_timeout_hours, password_reset_token_timeout_hours, \ + enrollment_session_timeout_minutes, password_reset_session_timeout_minutes \ FROM \"settings\" WHERE id = 1", ) .fetch_optional(executor) @@ -385,7 +467,18 @@ impl Settings { mfa_code_timeout_seconds = $56, \ public_proxy_url = $57, \ initial_setup_step = $58, \ - default_admin_id = $59 \ + default_admin_id = $59, \ + auth_cookie_timeout_days = $60, \ + secret_key = $61, \ + webauthn_rp_id = $62, \ + grpc_url = $63, \ + disable_stats_purge = $64, \ + stats_purge_frequency_hours = $65, \ + stats_purge_threshold_days = $66, \ + enrollment_token_timeout_hours = $67, \ + password_reset_token_timeout_hours = $68, \ + enrollment_session_timeout_minutes = $69, \ + password_reset_session_timeout_minutes = $70 \ WHERE id = 1", self.openid_enabled, self.wireguard_enabled, @@ -446,6 +539,17 @@ impl Settings { self.public_proxy_url, &self.initial_setup_step as &InitialSetupStep, self.default_admin_id, + self.auth_cookie_timeout_days, + self.secret_key, + self.webauthn_rp_id, + self.grpc_url, + self.disable_stats_purge, + self.stats_purge_frequency_hours, + self.stats_purge_threshold_days, + self.enrollment_token_timeout_hours, + self.password_reset_token_timeout_hours, + self.enrollment_session_timeout_minutes, + self.password_reset_session_timeout_minutes, ) .execute(executor) .await?; @@ -526,9 +630,120 @@ impl Settings { Duration::from_secs(self.authentication_period_days as u64 * 24 * 3600) } + #[must_use] + pub fn auth_cookie_timeout(&self) -> Duration { + Duration::from_secs(self.auth_cookie_timeout_days as u64 * 24 * 3600) + } + + #[must_use] + pub fn stats_purge_frequency(&self) -> Duration { + Duration::from_secs(self.stats_purge_frequency_hours as u64 * 3600) + } + + #[must_use] + pub fn stats_purge_threshold(&self) -> Duration { + Duration::from_secs(self.stats_purge_threshold_days as u64 * 24 * 3600) + } + + #[must_use] + pub fn enrollment_token_timeout(&self) -> Duration { + Duration::from_secs(self.enrollment_token_timeout_hours as u64 * 3600) + } + + #[must_use] + pub fn password_reset_token_timeout(&self) -> Duration { + Duration::from_secs(self.password_reset_token_timeout_hours as u64 * 3600) + } + + #[must_use] + pub fn enrollment_session_timeout(&self) -> Duration { + Duration::from_secs(self.enrollment_session_timeout_minutes as u64 * 60) + } + + #[must_use] + pub fn password_reset_session_timeout(&self) -> Duration { + Duration::from_secs(self.password_reset_session_timeout_minutes as u64 * 60) + } + + pub fn secret_key_required(&self) -> Result<&str, SettingsRequiredValueError> { + let secret_key = self + .secret_key + .as_deref() + .ok_or(SettingsRequiredValueError::Missing("secret_key"))?; + + Settings::validate_secret_key(secret_key)?; + + Ok(secret_key) + } + pub fn proxy_public_url(&self) -> Result { Url::parse(&self.public_proxy_url) } + + #[allow(deprecated)] + pub async fn update_from_config<'e, E>( + &mut self, + executor: E, + config: &DefGuardConfig, + ) -> Result<(), sqlx::Error> + where + E: PgExecutor<'e>, + { + info!("Updating Settings from DefguardConfig: {config:?}"); + let minute = 60; + let hour = minute * 60; + let day = hour * 24; + if let Some(auth_cookie_timeout) = config.auth_cookie_timeout { + self.auth_cookie_timeout_days = (auth_cookie_timeout.as_secs() / day) as i32; + } + if let Some(secret_key) = &config.secret_key { + self.secret_key = Some(secret_key.expose_secret().to_string()); + } + if let Some(webauthn_rp_id) = &config.webauthn_rp_id { + self.webauthn_rp_id = Some(webauthn_rp_id.clone()); + } + if let Some(grpc_url) = &config.grpc_url { + self.grpc_url = grpc_url.to_string(); + } + if let Some(enrollment_url) = &config.enrollment_url { + self.public_proxy_url = enrollment_url.to_string(); + } + if let Some(mfa_code_timeout) = config.mfa_code_timeout { + self.mfa_code_timeout_seconds = mfa_code_timeout.as_secs() as i32; + } + if let Some(session_timeout) = config.session_timeout { + self.authentication_period_days = (session_timeout.as_secs() / day) as i32; + } + if let Some(disable_stats_purge) = config.disable_stats_purge { + self.disable_stats_purge = disable_stats_purge; + } + if let Some(stats_purge_frequency) = config.stats_purge_frequency { + self.stats_purge_frequency_hours = (stats_purge_frequency.as_secs() / hour) as i32; + } + if let Some(stats_purge_threshold) = config.stats_purge_threshold { + self.stats_purge_threshold_days = (stats_purge_threshold.as_secs() / day) as i32; + } + if let Some(enrollment_token_timeout) = config.enrollment_token_timeout { + self.enrollment_token_timeout_hours = + (enrollment_token_timeout.as_secs() / hour) as i32; + } + if let Some(password_reset_token_timeout) = config.password_reset_token_timeout { + self.password_reset_token_timeout_hours = + (password_reset_token_timeout.as_secs() / hour) as i32; + } + if let Some(enrollment_session_timeout) = config.enrollment_session_timeout { + self.enrollment_session_timeout_minutes = + (enrollment_session_timeout.as_secs() / minute) as i32; + } + if let Some(password_reset_session_timeout) = config.password_reset_session_timeout { + self.password_reset_session_timeout_minutes = + (password_reset_session_timeout.as_secs() / minute) as i32; + } + update_current_settings(executor, self.clone()).await?; + + info!("Updated Settings from DefguardConfig: {config:?}"); + Ok(()) + } } #[derive(Serialize)] diff --git a/crates/defguard_core/src/appstate.rs b/crates/defguard_core/src/appstate.rs index 2db868f0a..6e06111b7 100644 --- a/crates/defguard_core/src/appstate.rs +++ b/crates/defguard_core/src/appstate.rs @@ -2,11 +2,8 @@ use std::sync::{Arc, Mutex, RwLock}; use axum::extract::FromRef; use axum_extra::extract::cookie::Key; -use defguard_common::{ - config::server_config, db::models::Settings, types::proxy::ProxyControlMessage, -}; +use defguard_common::{db::models::Settings, types::proxy::ProxyControlMessage}; use reqwest::Client; -use secrecy::ExposeSecret; use serde_json::json; use sqlx::PgPool; use tokio::{ @@ -113,6 +110,7 @@ impl AppState { tx: UnboundedSender, rx: UnboundedReceiver, wireguard_tx: Sender, + key: Key, failed_logins: Arc>, event_tx: UnboundedSender, incompatible_components: Arc>, @@ -120,10 +118,10 @@ impl AppState { ) -> Self { spawn(Self::handle_triggers(pool.clone(), rx)); - let config = server_config(); let url = Settings::url().expect("Invalid Defguard URL configuration"); + let settings = Settings::get_current_settings(); let webauthn_builder = WebauthnBuilder::new( - config + settings .webauthn_rp_id .as_ref() .expect("Webauth RP ID configuration is required"), @@ -136,8 +134,6 @@ impl AppState { .expect("Invalid WebAuthn configuration"), ); - let key = Key::from(config.secret_key.expose_secret().as_bytes()); - Self { pool, tx, diff --git a/crates/defguard_core/src/db/models/migration_wizard.rs b/crates/defguard_core/src/db/models/migration_wizard.rs new file mode 100644 index 000000000..237cb5a19 --- /dev/null +++ b/crates/defguard_core/src/db/models/migration_wizard.rs @@ -0,0 +1,67 @@ +use serde::{Deserialize, Serialize}; +use sqlx::PgExecutor; + +#[allow(dead_code)] +#[derive(Serialize, Deserialize, Debug, Default)] +pub(crate) enum MigrationWizardStep { + #[default] + Welcome, + GeneralConfiguration, + CertificateAuthority, + CertificateSummary, + EdgeComponent, + EdgeComponentAdaptation, + Confirmation, + LocationMigration, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct MigrationWizardLocationState { + pub(crate) locations: Vec, + pub(crate) current_location: i64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MigrationWizardState { + pub location_state: Option, +} + +impl MigrationWizardState { + pub(crate) async fn get<'e, E>(executor: E) -> Result, sqlx::Error> + where + E: PgExecutor<'e>, + { + let state: Option = sqlx::query_scalar( + "SELECT migration_wizard_state + FROM wizard + LIMIT 1", + ) + .fetch_optional(executor) + .await? + .flatten(); + + state + .map(serde_json::from_value) + .transpose() + .map_err(|error| sqlx::Error::Decode(Box::new(error))) + } + + pub(crate) async fn save<'e, E>(&self, executor: E) -> Result<(), sqlx::Error> + where + E: PgExecutor<'e>, + { + let state = + serde_json::to_value(self).map_err(|error| sqlx::Error::Decode(Box::new(error)))?; + + sqlx::query( + "UPDATE wizard + SET migration_wizard_state = $1 + WHERE is_singleton = TRUE", + ) + .bind(state) + .execute(executor) + .await?; + + Ok(()) + } +} diff --git a/crates/defguard_core/src/db/models/mod.rs b/crates/defguard_core/src/db/models/mod.rs index b6efe1b57..74616bc53 100644 --- a/crates/defguard_core/src/db/models/mod.rs +++ b/crates/defguard_core/src/db/models/mod.rs @@ -1,3 +1,5 @@ pub mod activity_log; pub mod enrollment; +pub mod migration_wizard; pub mod webhook; +pub mod wizard_flags; diff --git a/crates/defguard_core/src/db/models/wizard_flags.rs b/crates/defguard_core/src/db/models/wizard_flags.rs new file mode 100644 index 000000000..1e2cd50d9 --- /dev/null +++ b/crates/defguard_core/src/db/models/wizard_flags.rs @@ -0,0 +1,96 @@ +use serde::{Deserialize, Serialize}; +use sqlx::{PgExecutor, prelude::FromRow}; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct WizardFlags { + pub migration_wizard_in_progress: bool, + pub migration_wizard_completed: bool, + pub initial_wizard_completed: bool, + pub initial_wizard_in_progress: bool, +} + +impl WizardFlags { + pub async fn save<'e, E>(&self, executor: E) -> Result<(), sqlx::Error> + where + E: PgExecutor<'e>, + { + sqlx::query( + "UPDATE wizard + SET + migration_wizard_in_progress = $2, + migration_wizard_completed = $3, + initial_wizard_in_progress = $4, + initial_wizard_completed = $5 + WHERE is_singleton = TRUE", + ) + .bind(self.migration_wizard_in_progress) + .bind(self.migration_wizard_completed) + .bind(self.initial_wizard_in_progress) + .bind(self.initial_wizard_completed) + .execute(executor) + .await?; + + Ok(()) + } + + pub async fn get<'e, E>(executor: E) -> Result + where + E: PgExecutor<'e>, + { + sqlx::query_as!( + Self, + "SELECT + migration_wizard_in_progress, + migration_wizard_completed, + initial_wizard_in_progress, + initial_wizard_completed + FROM wizard + LIMIT 1" + ) + .fetch_one(executor) + .await + } + + pub async fn init<'e, E>(executor: E) -> Result + where + E: PgExecutor<'e> + Copy, + { + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM wizard") + .fetch_one(executor) + .await?; + + if count == 0 { + let is_fresh_instance: bool = sqlx::query_scalar( + "SELECT + (SELECT COUNT(*) FROM \"user\") = 0 + AND (SELECT COUNT(*) FROM wireguard_network) = 0 + AND (SELECT COUNT(*) FROM \"device\") = 0", + ) + .fetch_one(executor) + .await?; + + let is_migration_needed = !is_fresh_instance; + + sqlx::query( + "INSERT INTO wizard ( + migration_wizard_in_progress, + migration_wizard_completed, + initial_wizard_in_progress, + initial_wizard_completed + ) VALUES ($1, FALSE, $2, FALSE)", + ) + .bind(is_migration_needed) + .bind(is_fresh_instance) + .execute(executor) + .await?; + + return Ok(Self { + migration_wizard_in_progress: is_migration_needed, + migration_wizard_completed: false, + initial_wizard_in_progress: is_fresh_instance, + initial_wizard_completed: false, + }); + } + Self::get(executor).await + } +} diff --git a/crates/defguard_core/src/enterprise/handlers/openid_login.rs b/crates/defguard_core/src/enterprise/handlers/openid_login.rs index 3c2e41dc2..8217caa29 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_login.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_login.rs @@ -487,7 +487,7 @@ pub async fn user_from_claims( Ok(user) } -pub(crate) async fn get_auth_info( +pub async fn get_auth_info( _license: LicenseInfo, private_cookies: PrivateCookieJar, State(appstate): State, @@ -555,12 +555,12 @@ pub(crate) async fn get_auth_info( } #[derive(Deserialize)] -pub(crate) struct AuthenticationResponse { +pub struct AuthenticationResponse { code: AuthorizationCode, state: CsrfToken, } -pub(crate) async fn auth_callback( +pub async fn auth_callback( _license: LicenseInfo, cookies: CookieJar, mut private_cookies: PrivateCookieJar, @@ -605,7 +605,7 @@ pub(crate) async fn auth_callback( let (session, user_info, mfa_info) = create_session(&appstate.pool, insecure_ip, user_agent.as_str(), &mut user).await?; - let max_age = Duration::seconds(config.auth_cookie_timeout.as_secs() as i64); + let max_age = Duration::seconds(settings.auth_cookie_timeout().as_secs() as i64); let cookie_domain = config .cookie_domain .as_ref() diff --git a/crates/defguard_core/src/handlers/auth.rs b/crates/defguard_core/src/handlers/auth.rs index dc6cb8494..1481d9e42 100644 --- a/crates/defguard_core/src/handlers/auth.rs +++ b/crates/defguard_core/src/handlers/auth.rs @@ -136,7 +136,7 @@ pub async fn create_session( (status = CREATED, description = "User authenticated, but an additional authentication factor is required"), ), )] -pub(crate) async fn authenticate( +pub async fn authenticate( cookies: CookieJar, mut private_cookies: PrivateCookieJar, user_agent: TypedHeader, @@ -237,7 +237,7 @@ pub(crate) async fn authenticate( let (session, user_info, mfa_info) = create_session(&appstate.pool, insecure_ip, user_agent.as_str(), &mut user).await?; - let max_age = Duration::seconds(server_config().auth_cookie_timeout.as_secs() as i64); + let max_age = Duration::seconds(settings.auth_cookie_timeout().as_secs() as i64); let config = server_config(); let cookie_domain = config .cookie_domain @@ -305,7 +305,7 @@ pub(crate) async fn authenticate( (status = OK, description = "User logged out"), ), )] -pub(crate) async fn logout( +pub async fn logout( cookies: CookieJar, SessionExtractor(session): SessionExtractor, user_agent: TypedHeader, diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index b530532ab..4903c08ff 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -42,6 +42,7 @@ pub mod openid_clients; pub mod openid_flow; pub(crate) mod pagination; pub mod proxy; +pub mod session_info; pub mod settings; pub(crate) mod ssh_authorized_keys; pub(crate) mod static_ips; @@ -50,6 +51,7 @@ pub(crate) mod updates; pub mod user; pub(crate) mod webhooks; pub mod wireguard; +pub(crate) mod wizard; pub mod worker; pub(crate) mod yubikey; diff --git a/crates/defguard_core/src/handlers/network_devices.rs b/crates/defguard_core/src/handlers/network_devices.rs index 10a3fa94e..8e2c78ee4 100644 --- a/crates/defguard_core/src/handlers/network_devices.rs +++ b/crates/defguard_core/src/handlers/network_devices.rs @@ -32,7 +32,6 @@ use crate::{ enterprise::{firewall::try_get_location_firewall_config, limits::update_counts}, events::{ApiEvent, ApiEventType, ApiRequestContext}, grpc::GatewayEvent, - server_config, }; #[derive(Serialize)] @@ -448,14 +447,13 @@ pub(crate) async fn start_network_device_setup( config, device: NetworkDeviceInfo::from_device(device, &mut transaction).await?, }; - let config = server_config(); let settings = Settings::get_current_settings(); let configuration_token = start_desktop_configuration( &user, &mut transaction, &user, None, - config.enrollment_token_timeout.as_secs(), + settings.enrollment_token_timeout().as_secs(), settings.proxy_public_url()?.clone(), false, Some(result.device.id), @@ -514,14 +512,13 @@ pub(crate) async fn start_network_device_setup_for_device( user which added the device not found" )) })?; - let config = server_config(); let settings = Settings::get_current_settings(); let configuration_token = start_desktop_configuration( &user, &mut transaction, &user, None, - config.enrollment_token_timeout.as_secs(), + settings.enrollment_token_timeout().as_secs(), settings.proxy_public_url()?, false, Some(device.id), diff --git a/crates/defguard_core/src/handlers/session_info.rs b/crates/defguard_core/src/handlers/session_info.rs new file mode 100644 index 000000000..57b435fba --- /dev/null +++ b/crates/defguard_core/src/handlers/session_info.rs @@ -0,0 +1,61 @@ +use axum::{extract::State, http::StatusCode}; +use defguard_common::db::models::User; +use serde::Serialize; + +use super::{ApiResponse, ApiResult}; +use crate::{ + appstate::AppState, auth::SessionExtractor, db::models::wizard_flags::WizardFlags, + error::WebError, +}; + +#[derive(Serialize)] +struct SessionInfoResponse { + authorized: bool, + is_admin: bool, + wizard_flags: Option, +} + +pub async fn get_session_info( + State(appstate): State, + session: Result, +) -> ApiResult { + let pool = &appstate.pool; + let flags = WizardFlags::get(pool).await?; + + let Ok(SessionExtractor(session)) = session else { + return Ok(ApiResponse::json( + SessionInfoResponse { + authorized: false, + is_admin: false, + wizard_flags: if flags.initial_wizard_in_progress { + Some(flags) + } else { + None + }, + }, + StatusCode::OK, + )); + }; + + let Some(user) = User::find_by_id(pool, session.user_id).await? else { + return Ok(ApiResponse::json( + SessionInfoResponse { + authorized: false, + is_admin: false, + wizard_flags: None, + }, + StatusCode::OK, + )); + }; + + let user_admin = user.is_admin(pool).await?; + + Ok(ApiResponse::json( + SessionInfoResponse { + authorized: true, + is_admin: user_admin, + wizard_flags: if user_admin { Some(flags) } else { None }, + }, + StatusCode::OK, + )) +} diff --git a/crates/defguard_core/src/handlers/user.rs b/crates/defguard_core/src/handlers/user.rs index bbbb8bfb9..884eed08f 100644 --- a/crates/defguard_core/src/handlers/user.rs +++ b/crates/defguard_core/src/handlers/user.rs @@ -48,7 +48,7 @@ use crate::{ }, error::WebError, events::{ApiEvent, ApiEventType, ApiRequestContext}, - is_valid_phone_number, server_config, + is_valid_phone_number, user_management::{delete_user_and_cleanup_devices, sync_allowed_user_devices}, }; @@ -465,7 +465,7 @@ pub async fn start_enrollment( let mut transaction = appstate.pool.begin().await?; // try to parse token expiration time if provided - let config = server_config(); + let settings = Settings::get_current_settings(); let token_expiration_time_seconds = match data.token_expiration_time { Some(time) => parse_duration(&time) .map_err(|err| { @@ -473,10 +473,9 @@ pub async fn start_enrollment( WebError::BadRequest("Failed to parse token expiration time".to_owned()) })? .as_secs(), - None => config.enrollment_token_timeout.as_secs(), + None => settings.enrollment_token_timeout().as_secs(), }; - let settings: Settings = Settings::get_current_settings(); let public_proxy_url = settings.proxy_public_url()?; let enrollment_token = start_user_enrollment( @@ -580,7 +579,6 @@ pub async fn start_remote_desktop_configuration( "Generating a new desktop activation token by {}.", session.user.username ); - let config = server_config(); let settings = Settings::get_current_settings(); let public_proxy_url = settings.proxy_public_url()?; let desktop_configuration_token = start_desktop_configuration( @@ -588,7 +586,7 @@ pub async fn start_remote_desktop_configuration( &mut transaction, &session.user, Some(email), - config.enrollment_token_timeout.as_secs(), + settings.enrollment_token_timeout().as_secs(), public_proxy_url.clone(), data.send_enrollment_notification, None, @@ -1105,16 +1103,15 @@ pub async fn reset_password( Token::delete_unused_user_password_reset_tokens(&mut transaction, user.id).await?; - let config = server_config(); + let settings = Settings::get_current_settings(); let enrollment = Token::new( user.id, Some(session.user.id), Some(user.email.clone()), - config.password_reset_token_timeout.as_secs(), + settings.password_reset_token_timeout().as_secs(), Some(PASSWORD_RESET_TOKEN_TYPE.to_string()), ); enrollment.save(&mut *transaction).await?; - let settings = Settings::get_current_settings(); let public_proxy_url = settings.proxy_public_url()?; let result = Mail::new( diff --git a/crates/defguard_core/src/handlers/wizard.rs b/crates/defguard_core/src/handlers/wizard.rs new file mode 100644 index 000000000..ee1dc5aac --- /dev/null +++ b/crates/defguard_core/src/handlers/wizard.rs @@ -0,0 +1,42 @@ +use axum::{Json, extract::State, http::StatusCode}; +use serde_json::json; + +use super::{ApiResponse, ApiResult}; +use crate::{ + appstate::AppState, + auth::AdminRole, + db::models::{migration_wizard::MigrationWizardState, wizard_flags::WizardFlags}, +}; + +pub(crate) async fn get_wizard_flags( + _role: AdminRole, + State(appstate): State, +) -> ApiResult { + let flags = WizardFlags::get(&appstate.pool).await?; + + Ok(ApiResponse::json(flags, StatusCode::OK)) +} + +pub(crate) async fn get_migration_wizard_state( + _role: AdminRole, + State(appstate): State, +) -> ApiResult { + let migration_state = MigrationWizardState::get(&appstate.pool).await?; + + Ok(ApiResponse::new( + json!({ + "migration_state": migration_state + }), + StatusCode::OK, + )) +} + +pub(crate) async fn update_migration_wizard_state( + _role: AdminRole, + State(appstate): State, + Json(data): Json, +) -> ApiResult { + data.save(&appstate.pool).await?; + + Ok(ApiResponse::new(json!({}), StatusCode::OK)) +} diff --git a/crates/defguard_core/src/lib.rs b/crates/defguard_core/src/lib.rs index bc6ae24ca..baf11401f 100644 --- a/crates/defguard_core/src/lib.rs +++ b/crates/defguard_core/src/lib.rs @@ -11,6 +11,7 @@ use axum::{ routing::{delete, get, post, put}, serve, }; +use axum_extra::extract::cookie::Key; use defguard_certs::CertificateAuthority; use defguard_common::{ VERSION, @@ -43,12 +44,14 @@ use handlers::{ find_available_ips, get_network_device, list_network_devices, modify_network_device, start_network_device_setup, start_network_device_setup_for_device, }, + session_info::get_session_info, ssh_authorized_keys::{ add_authentication_key, delete_authentication_key, fetch_authentication_keys, rename_authentication_key, }, updates::check_new_version, wireguard::all_gateways_status, + wizard::{get_migration_wizard_state, get_wizard_flags, update_migration_wizard_state}, yubikey::{delete_yubikey, rename_yubikey}, }; use ipnetwork::IpNetwork; @@ -217,6 +220,7 @@ pub fn build_webapp( wireguard_tx: Sender, worker_state: Arc>, pool: PgPool, + key: Key, failed_logins: Arc>, event_tx: UnboundedSender, version: Version, @@ -236,9 +240,15 @@ pub fn build_webapp( Router::new() .route("/health", get(health_check)) .route("/info", get(get_app_info)) + .route("/session-info", get(get_session_info)) .route("/ssh_authorized_keys", get(get_authorized_keys)) .route("/api-docs", get(openapi)) .route("/updates", get(check_new_version)) + .route("/wizard", get(get_wizard_flags)) + .route( + "/wizard/migration", + get(get_migration_wizard_state).put(update_migration_wizard_state), + ) // /auth .route("/auth", post(authenticate)) .route("/auth/logout", post(logout)) @@ -596,6 +606,7 @@ pub fn build_webapp( webhook_tx, webhook_rx, wireguard_tx, + key, failed_logins, event_tx, incompatible_components, @@ -630,12 +641,16 @@ pub async fn run_web_server( incompatible_components: Arc>, proxy_control_tx: tokio::sync::mpsc::Sender, ) -> Result<(), anyhow::Error> { + let settings = Settings::get_current_settings(); + let key = Key::from(settings.secret_key_required()?.as_bytes()); + let webapp = build_webapp( webhook_tx, webhook_rx, wireguard_tx, worker_state, pool, + key, failed_logins, event_tx, Version::parse(VERSION)?, @@ -694,8 +709,6 @@ pub async fn init_dev_env(config: &DefGuardConfig) { settings.ca_key_der = Some(ca.key_pair_der().to_vec()); settings.ca_expiry = Some(ca.expiry().expect("Failed to get CA expiry")); settings.initial_setup_completed = true; - // This should possibly be initialized somehow differently in the future since we are deprecating the enrollment URL env var. - settings.public_proxy_url = config.enrollment_url.to_string(); settings.defguard_url = config.url.to_string(); update_current_settings(&pool, settings) .await diff --git a/crates/defguard_core/tests/integration/api/common/mod.rs b/crates/defguard_core/tests/integration/api/common/mod.rs index 6d9ad0234..372ff5d72 100644 --- a/crates/defguard_core/tests/integration/api/common/mod.rs +++ b/crates/defguard_core/tests/integration/api/common/mod.rs @@ -5,13 +5,14 @@ use std::{ sync::{Arc, Mutex}, }; +use axum_extra::extract::cookie::Key; pub use defguard_common::db::setup_pool; use defguard_common::{ VERSION, config::DefGuardConfig, db::{ Id, - models::{Device, User, WireguardNetwork, settings::initialize_current_settings}, + models::{Device, Settings, User, WireguardNetwork, settings::initialize_current_settings}, }, }; use defguard_core::{ @@ -123,12 +124,20 @@ pub(crate) async fn make_base_client( // .with(tracing_subscriber::fmt::layer()) // .init(); + let key = Key::from( + Settings::get_current_settings() + .secret_key_required() + .unwrap() + .as_bytes(), + ); + let webapp = build_webapp( tx, rx, wg_tx, worker_state, pool, + key, failed_logins, api_event_tx, Version::parse(VERSION).unwrap(), diff --git a/crates/defguard_proxy_manager/src/handler.rs b/crates/defguard_proxy_manager/src/handler.rs index 06b24aba0..02111cbcf 100644 --- a/crates/defguard_proxy_manager/src/handler.rs +++ b/crates/defguard_proxy_manager/src/handler.rs @@ -7,7 +7,6 @@ use std::{ use axum_extra::extract::cookie::Key; use defguard_common::{ VERSION, - config::server_config, db::{ Id, models::{Settings, proxy::Proxy}, @@ -43,7 +42,6 @@ use defguard_version::{ use hyper_rustls::HttpsConnectorBuilder; use openidconnect::{AuthorizationCode, Nonce, Scope, core::CoreAuthenticationFlow}; use reqwest::Url; -use secrecy::ExposeSecret; use semver::Version; use sqlx::PgPool; use tokio::{ @@ -87,10 +85,12 @@ pub(super) struct ProxyHandler { pub(super) url: Url, shutdown_signal: Arc>, proxy_id: Id, + proxy_cookie_key: Key, client: Option>>, } impl ProxyHandler { + #[allow(clippy::too_many_arguments)] pub(super) fn new( pool: PgPool, url: Url, @@ -99,6 +99,7 @@ impl ProxyHandler { sessions: Arc>>, shutdown_signal: Arc>, proxy_id: Id, + proxy_cookie_key: Key, ) -> Self { // Instantiate gRPC servers. let services = ProxyServices::new(&pool, tx, remote_mfa_responses, sessions); @@ -109,6 +110,7 @@ impl ProxyHandler { url, shutdown_signal, proxy_id, + proxy_cookie_key, client: None, } } @@ -120,6 +122,7 @@ impl ProxyHandler { remote_mfa_responses: Arc>>>, sessions: Arc>>, shutdown_signal: Arc>, + proxy_cookie_key: Key, ) -> Result { let url = Url::from_str(&format!("http://{}:{}", proxy.address, proxy.port))?; let proxy_id = proxy.id; @@ -131,6 +134,7 @@ impl ProxyHandler { sessions, shutdown_signal, proxy_id, + proxy_cookie_key, )) } @@ -196,14 +200,13 @@ impl ProxyHandler { loop { let endpoint = self.endpoint()?; let settings = Settings::get_current_settings(); - let Some(ca_cert_der) = settings.ca_cert_der else { + let Some(ref ca_cert_der) = settings.ca_cert_der else { return Err(ProxyError::MissingConfiguration( "Core CA is not setup, can't create a Proxy endpoint.".to_string(), )); }; - let tls_config = - tls_certs::client_config(&ca_cert_der, certs_rx.clone(), self.proxy_id) - .map_err(|err| ProxyError::TlsConfigError(err.to_string()))?; + let tls_config = tls_certs::client_config(ca_cert_der, certs_rx.clone(), self.proxy_id) + .map_err(|err| ProxyError::TlsConfigError(err.to_string()))?; let connector = HttpsConnectorBuilder::new() .with_tls_config(tls_config) .https_only() @@ -270,13 +273,9 @@ impl ProxyHandler { info!("Connected to proxy at {}", endpoint.uri()); let mut resp_stream = response.into_inner(); - // Derive proxy cookie key from core secret to avoid transmitting it over gRPC. - let config = server_config(); - let proxy_cookie_key = Key::derive_from(config.secret_key.expose_secret().as_bytes()); - // Send initial info with private cookies key. let initial_info = InitialInfo { - private_cookies_key: proxy_cookie_key.master().to_vec(), + private_cookies_key: self.proxy_cookie_key.master().to_vec(), }; let _ = tx.send(CoreResponse { id: 0, @@ -722,12 +721,12 @@ impl ProxyHandler { as a result of proxy OpenID auth callback.", user.username ); - let config = server_config(); + let settings = Settings::get_current_settings(); let desktop_configuration = Token::new( user.id, Some(user.id), Some(user.email), - config.enrollment_token_timeout.as_secs(), + settings.enrollment_token_timeout().as_secs(), Some(ENROLLMENT_TOKEN_TYPE.to_string()), ); debug!("Saving a new desktop configuration token..."); @@ -736,7 +735,6 @@ impl ProxyHandler { "Saved desktop configuration token. Responding to \ proxy with the token." ); - let settings = Settings::get_current_settings(); let public_proxy_url = settings.proxy_public_url()?; Some(core_response::Payload::AuthCallback( diff --git a/crates/defguard_proxy_manager/src/lib.rs b/crates/defguard_proxy_manager/src/lib.rs index b4bd75255..fad1fe58c 100644 --- a/crates/defguard_proxy_manager/src/lib.rs +++ b/crates/defguard_proxy_manager/src/lib.rs @@ -4,6 +4,7 @@ use std::{ time::Duration, }; +use axum_extra::extract::cookie::Key; use defguard_common::{db::models::proxy::Proxy, types::proxy::ProxyControlMessage}; use defguard_core::{events::BidiStreamEvent, grpc::GatewayEvent, version::IncompatibleComponents}; use sqlx::PgPool; @@ -40,6 +41,7 @@ pub struct ProxyManager { tx: ProxyTxSet, incompatible_components: Arc>, proxy_control: Receiver, + proxy_cookie_key: Key, } impl ProxyManager { @@ -48,12 +50,14 @@ impl ProxyManager { tx: ProxyTxSet, incompatible_components: Arc>, proxy_control_rx: Receiver, + core_secret_key: String, ) -> Self { Self { pool, tx, incompatible_components, proxy_control: proxy_control_rx, + proxy_cookie_key: Key::derive_from(core_secret_key.as_bytes()), } } @@ -89,6 +93,7 @@ impl ProxyManager { Arc::clone(&remote_mfa_responses), Arc::clone(&sessions), Arc::new(Mutex::new(shutdown_rx)), + self.proxy_cookie_key.clone(), ) }) .collect::, _>>()?; @@ -131,6 +136,7 @@ impl ProxyManager { Arc::clone(&remote_mfa_responses), Arc::clone(&sessions), Arc::new(Mutex::new(shutdown_rx)), + self.proxy_cookie_key.clone(), ) { Ok(proxy) => { debug!("Spawning proxy task for proxy {}", proxy.url); diff --git a/crates/defguard_proxy_manager/src/servers/enrollment.rs b/crates/defguard_proxy_manager/src/servers/enrollment.rs index bd1bbef18..1c84423dc 100644 --- a/crates/defguard_proxy_manager/src/servers/enrollment.rs +++ b/crates/defguard_proxy_manager/src/servers/enrollment.rs @@ -1,7 +1,6 @@ use std::collections::HashSet; use defguard_common::{ - config::server_config, csv::AsCsv, db::{ Id, @@ -87,7 +86,8 @@ impl EnrollmentServer { ); return Err(Status::permission_denied("invalid token")); } - if enrollment.is_session_valid(server_config().enrollment_session_timeout.as_secs()) { + let settings = Settings::get_current_settings(); + if enrollment.is_session_valid(settings.enrollment_session_timeout().as_secs()) { info!("Enrollment session validated: {enrollment:?}"); Ok(enrollment) } else { @@ -164,10 +164,11 @@ impl EnrollmentServer { "Validating enrollment token and starting session for user {}({:?})", user.username, user.id, ); + let settings = Settings::get_current_settings(); let session_deadline = enrollment .start_session( &mut transaction, - server_config().enrollment_session_timeout.as_secs(), + settings.enrollment_session_timeout().as_secs(), ) .await?; info!( @@ -179,7 +180,6 @@ impl EnrollmentServer { "Retrieving settings for enrollment of user {}({:?}).", user.username, user.id ); - let settings = Settings::get_current_settings(); debug!("Settings: {settings:?}"); debug!( diff --git a/crates/defguard_proxy_manager/src/servers/password_reset.rs b/crates/defguard_proxy_manager/src/servers/password_reset.rs index b6d94f253..84ad4a81a 100644 --- a/crates/defguard_proxy_manager/src/servers/password_reset.rs +++ b/crates/defguard_proxy_manager/src/servers/password_reset.rs @@ -1,7 +1,4 @@ -use defguard_common::{ - config::server_config, - db::models::{Settings, User}, -}; +use defguard_common::db::models::{Settings, User}; use defguard_core::{ db::models::enrollment::{PASSWORD_RESET_TOKEN_TYPE, Token}, enterprise::ldap::utils::ldap_change_password, @@ -59,7 +56,8 @@ impl PasswordResetServer { return Err(Status::permission_denied("invalid token")); } - if enrollment.is_session_valid(server_config().enrollment_session_timeout.as_secs()) { + let settings = Settings::get_current_settings(); + if enrollment.is_session_valid(settings.enrollment_session_timeout().as_secs()) { info!("Password reset session validated: {enrollment:?}.",); Ok(enrollment) } else { @@ -88,7 +86,6 @@ impl PasswordResetServer { request: PasswordResetInitializeRequest, req_device_info: Option, ) -> Result<(), Status> { - let config = server_config(); debug!("Starting password reset request"); let ip_address; @@ -133,11 +130,12 @@ impl PasswordResetServer { Token::delete_unused_user_password_reset_tokens(&mut transaction, user.id).await?; + let settings = Settings::get_current_settings(); let enrollment = Token::new( user.id, None, Some(email.clone()), - config.password_reset_token_timeout.as_secs(), + settings.password_reset_token_timeout().as_secs(), Some(PASSWORD_RESET_TOKEN_TYPE.to_string()), ); enrollment.save(&mut *transaction).await?; @@ -147,7 +145,6 @@ impl PasswordResetServer { Status::internal("unexpected error") })?; - let settings = Settings::get_current_settings(); let public_proxy_url = settings.proxy_public_url().map_err(|err| { error!("Failed to get public proxy URL: {err}"); Status::internal("unexpected error") @@ -212,10 +209,11 @@ impl PasswordResetServer { Status::internal("unexpected error") })?; + let settings = Settings::get_current_settings(); let session_deadline = enrollment .start_session( &mut transaction, - server_config().password_reset_session_timeout.as_secs(), + settings.password_reset_session_timeout().as_secs(), ) .await?; diff --git a/crates/defguard_setup/src/db/mod.rs b/crates/defguard_setup/src/db/mod.rs new file mode 100644 index 000000000..c446ac883 --- /dev/null +++ b/crates/defguard_setup/src/db/mod.rs @@ -0,0 +1 @@ +pub mod models; diff --git a/crates/defguard_setup/src/db/models/mod.rs b/crates/defguard_setup/src/db/models/mod.rs new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/crates/defguard_setup/src/db/models/mod.rs @@ -0,0 +1 @@ + diff --git a/crates/defguard_setup/src/handlers.rs b/crates/defguard_setup/src/handlers/initial_wizard.rs similarity index 96% rename from crates/defguard_setup/src/handlers.rs rename to crates/defguard_setup/src/handlers/initial_wizard.rs index 4b7b952dc..640009d5d 100644 --- a/crates/defguard_setup/src/handlers.rs +++ b/crates/defguard_setup/src/handlers/initial_wizard.rs @@ -21,6 +21,7 @@ use defguard_core::{ AdminOrSetupRole, SessionInfo, failed_login::{FailedLoginMap, check_failed_logins, log_failed_login_attempt}, }, + db::models::wizard_flags::WizardFlags, error::WebError, handlers::{ApiResponse, ApiResult, SESSION_COOKIE_NAME}, headers::get_device_info, @@ -311,7 +312,11 @@ pub async fn create_ca( info!("Certificate authority created and stored"); - advance_setup_to_step(&pool, InitialSetupStep::CaSummary).await?; + let wizard_flags = WizardFlags::get(&pool).await?; + + if wizard_flags.initial_wizard_in_progress { + advance_setup_to_step(&pool, InitialSetupStep::CaSummary).await?; + } Ok(ApiResponse::with_status(StatusCode::CREATED)) } @@ -319,6 +324,7 @@ pub async fn create_ca( pub async fn get_ca(_: AdminOrSetupRole, Extension(pool): Extension) -> ApiResult { debug!("Fetching certificate authority details"); let settings = Settings::get_current_settings(); + let wizard_flags = WizardFlags::get(&pool).await?; if let Some(ca_cert_der) = settings.ca_cert_der { let ca_pem = der_to_pem(&ca_cert_der, defguard_certs::PemLabel::Certificate)?; let info = parse_certificate_info(&ca_cert_der)?; @@ -329,7 +335,9 @@ pub async fn get_ca(_: AdminOrSetupRole, Extension(pool): Extension) -> info.subject_common_name, valid_for_days ); - advance_setup_to_step(&pool, InitialSetupStep::EdgeComponent).await?; + if wizard_flags.initial_wizard_in_progress { + advance_setup_to_step(&pool, InitialSetupStep::EdgeComponent).await?; + } Ok(ApiResponse::new( json!({ "ca_cert_pem": ca_pem, "subject_common_name": info.subject_common_name, "not_before": info.not_before, "not_after": info.not_after, "valid_for_days": valid_for_days }), diff --git a/crates/defguard_setup/src/handlers/migration.rs b/crates/defguard_setup/src/handlers/migration.rs new file mode 100644 index 000000000..66c9f93c0 --- /dev/null +++ b/crates/defguard_setup/src/handlers/migration.rs @@ -0,0 +1,223 @@ +use std::sync::{Arc, Mutex}; + +use axum::{Extension, Json}; +use defguard_common::db::models::{ + Settings, User, group::Group, settings::update_current_settings, +}; +use defguard_core::{ + auth::AdminOrSetupRole, + db::models::wizard_flags::WizardFlags, + error::WebError, + handlers::{ApiResponse, ApiResult}, +}; +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::PgPool; +use tokio::sync::oneshot; +use tracing::{debug, info, warn}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct MigrationWizardLocationState { + pub locations: Vec, + pub current_location: i64, +} + +#[derive(Debug, Serialize, Deserialize, Default)] +pub enum MigrationWizardStep { + #[default] + #[serde(rename = "welcome")] + Welcome, + #[serde(rename = "general")] + General, + #[serde(rename = "ca")] + Ca, + #[serde(rename = "caSummary")] + CaSummary, + #[serde(rename = "edge")] + Edge, + #[serde(rename = "edgeAdoption")] + EdgeAdoption, + #[serde(rename = "confirmation")] + Confirmation, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct MigrationWizardState { + pub current_step: MigrationWizardStep, + pub location_state: Option, +} + +impl Default for MigrationWizardState { + fn default() -> Self { + Self { + current_step: MigrationWizardStep::Welcome, + location_state: None, + } + } +} + +pub async fn get_migration_state( + _: AdminOrSetupRole, + Extension(pool): Extension, +) -> ApiResult { + let mut transaction = pool.begin().await?; + + let raw_state: Option = sqlx::query_scalar( + "SELECT migration_wizard_state + FROM wizard + LIMIT 1", + ) + .fetch_optional(&mut *transaction) + .await? + .flatten(); + + let default_state = MigrationWizardState::default(); + + let migration_state = match raw_state { + Some(state) => match serde_json::from_value::(state) { + Ok(parsed) => parsed, + Err(error) => { + warn!("Invalid migration_wizard_state format, resetting to NULL: {error}"); + sqlx::query( + "UPDATE wizard + SET migration_wizard_state = NULL + WHERE is_singleton = TRUE", + ) + .execute(&mut *transaction) + .await?; + default_state + } + }, + None => default_state, + }; + + transaction.commit().await?; + + Ok(ApiResponse::new(json!(migration_state), StatusCode::OK)) +} + +pub async fn update_migration_state( + _: AdminOrSetupRole, + Extension(pool): Extension, + Json(data): Json, +) -> ApiResult { + let state = + serde_json::to_value(data).map_err(|error| WebError::Serialization(error.to_string()))?; + + sqlx::query( + "UPDATE wizard + SET migration_wizard_state = $1 + WHERE is_singleton = TRUE", + ) + .bind(state) + .execute(&pool) + .await?; + + Ok(ApiResponse::new(json!({}), StatusCode::OK)) +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct GeneralConfig { + defguard_url: String, + default_admin_group_name: String, + default_authentication: u32, + default_mfa_code_lifetime: u32, +} + +pub async fn set_general_config( + _: AdminOrSetupRole, + Extension(pool): Extension, + Json(general_config): Json, +) -> ApiResult { + info!("Applying initial general configuration settings"); + debug!( + "General configuration received: defguard_url={}, default_admin_group_name={}, default_authentication={}, default_mfa_code_lifetime={}", + general_config.defguard_url, + general_config.default_admin_group_name, + general_config.default_authentication, + general_config.default_mfa_code_lifetime, + ); + let default_admin_group_name = general_config.default_admin_group_name.clone(); + let mut settings = Settings::get_current_settings(); + settings.defguard_url = general_config.defguard_url; + settings.default_admin_group_name = general_config.default_admin_group_name; + settings.authentication_period_days = general_config + .default_authentication + .try_into() + .map_err(|err| { + WebError::BadRequest(format!("Invalid authentication period days: {err}")) + })?; + settings.mfa_code_timeout_seconds = general_config + .default_mfa_code_lifetime + .try_into() + .map_err(|err| WebError::BadRequest(format!("Invalid MFA code timeout seconds: {err}")))?; + update_current_settings(&pool, settings).await?; + let settings = Settings::get_current_settings(); + debug!("Settings persisted"); + + let admin_group = + if let Some(mut group) = Group::find_by_name(&pool, &default_admin_group_name).await? { + debug!( + "Admin group {} found, marking as admin", + default_admin_group_name + ); + group.is_admin = true; + group.save(&pool).await?; + group + } else { + debug!( + "Admin group {} not found, creating", + default_admin_group_name + ); + let mut group = Group::new(&default_admin_group_name); + group.is_admin = true; + group.save(&pool).await? + }; + + let admin_id = settings + .default_admin_id + .ok_or_else(|| WebError::DbError("Default admin user ID not set in settings".into()))?; + + let admin_user = User::find_by_id(&pool, admin_id).await?.ok_or_else(|| { + WebError::ObjectNotFound(format!("Admin user with ID '{admin_id}' not found")) + })?; + debug!( + "Assigning admin user {} to admin group {}", + admin_user.username, admin_group.name + ); + admin_user.add_to_group(&pool, &admin_group).await?; + + info!("Initial general configuration applied"); + + Ok(ApiResponse::with_status(StatusCode::OK)) +} + +pub async fn finish_setup( + _: AdminOrSetupRole, + Extension(pool): Extension, + Extension(setup_shutdown_tx): Extension>>>>, +) -> ApiResult { + info!("Finishing migration"); + let mut wizard_flags = WizardFlags::get(&pool).await?; + wizard_flags.migration_wizard_completed = true; + wizard_flags.migration_wizard_in_progress = false; + wizard_flags.initial_wizard_completed = true; + wizard_flags.initial_wizard_in_progress = false; + wizard_flags.save(&pool).await?; + + if let Some(tx) = setup_shutdown_tx + .lock() + .expect("Failed to lock migration shutdown sender") + .take() + { + let _ = tx.send(()); + info!("Migration completed and shutdown signal sent"); + } else { + return Err(WebError::BadRequest( + "Migration shutdown sender no longer available".to_string(), + )); + } + + Ok(ApiResponse::with_status(StatusCode::OK)) +} diff --git a/crates/defguard_setup/src/handlers/mod.rs b/crates/defguard_setup/src/handlers/mod.rs new file mode 100644 index 000000000..8776afe5e --- /dev/null +++ b/crates/defguard_setup/src/handlers/mod.rs @@ -0,0 +1,2 @@ +pub mod initial_wizard; +pub mod migration; diff --git a/crates/defguard_setup/src/lib.rs b/crates/defguard_setup/src/lib.rs index 97bcacc11..de63928c8 100644 --- a/crates/defguard_setup/src/lib.rs +++ b/crates/defguard_setup/src/lib.rs @@ -1,2 +1,4 @@ +pub mod db; pub mod handlers; +pub mod migration; pub mod setup; diff --git a/crates/defguard_setup/src/migration.rs b/crates/defguard_setup/src/migration.rs new file mode 100644 index 000000000..874d4f9fb --- /dev/null +++ b/crates/defguard_setup/src/migration.rs @@ -0,0 +1,168 @@ +use std::{ + net::{IpAddr, Ipv4Addr, SocketAddr}, + sync::{Arc, Mutex, RwLock}, +}; + +use anyhow::anyhow; +use axum::{ + Extension, Router, + routing::{get, post, put}, + serve, +}; +use axum_extra::extract::cookie::Key; +use defguard_common::{VERSION, db::models::Settings}; +use defguard_core::{ + auth::failed_login::FailedLoginMap, + handle_404, + handlers::{ + auth::{ + authenticate, email_mfa_code, email_mfa_enable, email_mfa_init, logout, mfa_disable, + mfa_enable, recovery_code, request_email_mfa_code, totp_code, totp_enable, totp_secret, + webauthn_end, webauthn_finish, webauthn_init, webauthn_start, + }, + component_setup::setup_proxy_tls_stream, + session_info::get_session_info, + settings::get_settings_essentials, + }, + health_check, + version::IncompatibleComponents, +}; +use defguard_web_ui::{index, svg, web_asset}; +use semver::Version; +use sqlx::PgPool; +use tokio::{ + net::TcpListener, + sync::{broadcast, mpsc, oneshot::Sender}, +}; +use tracing::{info, instrument}; + +use defguard_core::{ + appstate::AppState, + db::AppEvent, + enterprise::handlers::openid_login::{auth_callback, get_auth_info}, + events::ApiEvent, + grpc::GatewayEvent, +}; + +use crate::handlers::migration::{get_migration_state, set_general_config, update_migration_state}; +use crate::handlers::{ + initial_wizard::{create_ca, get_ca, upload_ca}, + migration::finish_setup, +}; + +pub fn build_migration_webapp( + pool: PgPool, + version: Version, + setup_shutdown_tx: Sender<()>, +) -> Router { + let failed_logins = Arc::new(Mutex::new(FailedLoginMap::new())); + let (webhook_tx, webhook_rx) = mpsc::unbounded_channel::(); + let (event_tx, _event_rx) = mpsc::unbounded_channel::(); + let (wireguard_tx, _wireguard_rx) = broadcast::channel::(64); + let (proxy_control_tx, _proxy_control_rx) = mpsc::channel(32); + let incompatible_components = Arc::new(RwLock::new(IncompatibleComponents::default())); + let key = Key::from( + Settings::get_current_settings() + .secret_key_required() + .expect("Missing required secret key in settings") + .as_bytes(), + ); + let app_state = AppState::new( + pool.clone(), + webhook_tx, + webhook_rx, + wireguard_tx, + key, + failed_logins.clone(), + event_tx, + incompatible_components, + proxy_control_tx, + ); + + Router::new() + .route("/", get(index)) + .route("/{*path}", get(index)) + .route("/fonts/{*path}", get(web_asset)) + .route("/assets/{*path}", get(web_asset)) + .route("/svg/{*path}", get(svg)) + .nest( + "/api/v1", + Router::new() + .route("/health", get(health_check)) + .route("/session-info", get(get_session_info)) + .route("/settings_essentials", get(get_settings_essentials)) + .route("/proxy/setup/stream", get(setup_proxy_tls_stream)) + .route("/auth", post(authenticate)) + .route("/auth/logout", post(logout)) + .route("/auth/mfa", put(mfa_enable).delete(mfa_disable)) + .route("/auth/webauthn/init", post(webauthn_init)) + .route("/auth/webauthn/finish", post(webauthn_finish)) + .route("/auth/webauthn/start", post(webauthn_start)) + .route("/auth/webauthn", post(webauthn_end)) + .route("/auth/totp/init", post(totp_secret)) + .route("/auth/totp", post(totp_enable)) + .route("/auth/totp/verify", post(totp_code)) + .route("/auth/email/init", post(email_mfa_init)) + .route( + "/auth/email", + get(request_email_mfa_code).post(email_mfa_enable), + ) + .route("/auth/email/verify", post(email_mfa_code)) + .route("/auth/recovery", post(recovery_code)) + .nest( + "/migration", + Router::new() + .route( + "/state", + get(get_migration_state).put(update_migration_state), + ) + .route("/general_config", post(set_general_config)) + .route("/ca", post(create_ca).get(get_ca)) + .route("/ca/upload", post(upload_ca)) + .route("/finish", post(finish_setup)), + ), + ) + .nest( + "/api/v1/openid", + Router::new() + .route("/callback", post(auth_callback)) + .route("/auth_info", get(get_auth_info)), + ) + .fallback_service(get(handle_404)) + .with_state(app_state) + .layer(Extension(pool)) + .layer(Extension(version)) + .layer(Extension(failed_logins)) + .layer(Extension(Arc::new(Mutex::new(Some(setup_shutdown_tx))))) +} + +#[instrument(skip_all)] +pub async fn run_migration_web_server( + pool: PgPool, + http_bind_address: Option, + http_port: u16, +) -> Result<(), anyhow::Error> { + let (setup_shutdown_tx, setup_shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + let setup_webapp = build_migration_webapp( + pool.clone(), + defguard_version::Version::parse(VERSION)?, + setup_shutdown_tx, + ); + + info!("Starting instance migration web server on port {http_port}"); + let addr = SocketAddr::new( + http_bind_address.unwrap_or(IpAddr::V4(Ipv4Addr::UNSPECIFIED)), + http_port, + ); + let listener = TcpListener::bind(&addr).await?; + serve( + listener, + setup_webapp.into_make_service_with_connect_info::(), + ) + .with_graceful_shutdown(async move { + setup_shutdown_rx.await.ok(); + info!("Shutting down instance migration web server"); + }) + .await + .map_err(|err| anyhow!("Web server can't be started {err}")) +} diff --git a/crates/defguard_setup/src/setup.rs b/crates/defguard_setup/src/setup.rs index 96091fb18..914611126 100644 --- a/crates/defguard_setup/src/setup.rs +++ b/crates/defguard_setup/src/setup.rs @@ -22,7 +22,7 @@ use sqlx::PgPool; use tokio::{net::TcpListener, sync::oneshot::Sender}; use tracing::{info, instrument}; -use crate::handlers::{ +use crate::handlers::initial_wizard::{ create_admin, create_ca, finish_setup, get_ca, set_general_config, setup_login, setup_session, upload_ca, }; diff --git a/migrations/20260225142454_[2.0.0]_migration_wizard.down.sql b/migrations/20260225142454_[2.0.0]_migration_wizard.down.sql new file mode 100644 index 000000000..b9be5a828 --- /dev/null +++ b/migrations/20260225142454_[2.0.0]_migration_wizard.down.sql @@ -0,0 +1 @@ +DROP TABLE wizard; diff --git a/migrations/20260225142454_[2.0.0]_migration_wizard.up.sql b/migrations/20260225142454_[2.0.0]_migration_wizard.up.sql new file mode 100644 index 000000000..1d53dafae --- /dev/null +++ b/migrations/20260225142454_[2.0.0]_migration_wizard.up.sql @@ -0,0 +1,10 @@ +CREATE TABLE wizard ( + migration_wizard_state JSONB DEFAULT NULL, + migration_wizard_completed BOOLEAN NOT NULL DEFAULT FALSE, + migration_wizard_in_progress BOOLEAN NOT NULL DEFAULT FALSE, + initial_wizard_completed BOOLEAN NOT NULL DEFAULT FALSE, + initial_wizard_in_progress BOOLEAN NOT NULL DEFAULT FALSE, + initial_wizard_state JSONB DEFAULT NULL, + -- Constrain to a single row + is_singleton BOOLEAN NOT NULL DEFAULT TRUE PRIMARY KEY CHECK (is_singleton) +); diff --git a/migrations/20260227091211_[2.0.0]_settings_in_db.down.sql b/migrations/20260227091211_[2.0.0]_settings_in_db.down.sql new file mode 100644 index 000000000..5e68c9996 --- /dev/null +++ b/migrations/20260227091211_[2.0.0]_settings_in_db.down.sql @@ -0,0 +1,13 @@ +ALTER TABLE settings + DROP COLUMN auth_cookie_timeout_days, + DROP COLUMN secret_key, + DROP COLUMN openid_signing_key, + DROP COLUMN webauthn_rp_id, + DROP COLUMN grpc_url, + DROP COLUMN disable_stats_purge, + DROP COLUMN stats_purge_frequency_hours, + DROP COLUMN stats_purge_threshold_days, + DROP COLUMN enrollment_token_timeout_hours, + DROP COLUMN password_reset_token_timeout_hours, + DROP COLUMN enrollment_session_timeout_minutes, + DROP COLUMN password_reset_session_timeout_minutes; diff --git a/migrations/20260227091211_[2.0.0]_settings_in_db.up.sql b/migrations/20260227091211_[2.0.0]_settings_in_db.up.sql new file mode 100644 index 000000000..b414c04c1 --- /dev/null +++ b/migrations/20260227091211_[2.0.0]_settings_in_db.up.sql @@ -0,0 +1,13 @@ +ALTER TABLE settings + ADD COLUMN auth_cookie_timeout_days int4 NOT NULL DEFAULT 7, + ADD COLUMN secret_key text, + ADD COLUMN openid_signing_key text, + ADD COLUMN webauthn_rp_id text, + ADD COLUMN grpc_url text NOT NULL DEFAULT 'http://localhost:50055', + ADD COLUMN disable_stats_purge boolean NOT NULL DEFAULT false, + ADD COLUMN stats_purge_frequency_hours int4 NOT NULL DEFAULT 24, + ADD COLUMN stats_purge_threshold_days int4 NOT NULL DEFAULT 30, + ADD COLUMN enrollment_token_timeout_hours int4 NOT NULL DEFAULT 24, + ADD COLUMN password_reset_token_timeout_hours int4 NOT NULL DEFAULT 24, + ADD COLUMN enrollment_session_timeout_minutes int4 NOT NULL DEFAULT 10, + ADD COLUMN password_reset_session_timeout_minutes int4 NOT NULL DEFAULT 10; diff --git a/web/messages/en/migration_wizard.json b/web/messages/en/migration_wizard.json new file mode 100644 index 000000000..e030b1886 --- /dev/null +++ b/web/messages/en/migration_wizard.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://inlang.com/schema/inlang-message-format", + "migration_wizard_title": "Migration Wizard", + "migration_wizard_subtitle": "This wizard will guide you through migration-related configuration of your Defguard instance.", + "migration_wizard_step_general_config_label": "General Configuration", + "migration_wizard_step_general_config_description": "Manage core details and connection parameters for your VPN location.", + "migration_wizard_step_certificate_authority_label": "Certificate Authority", + "migration_wizard_step_certificate_authority_description": "Securing component communication", + "migration_wizard_step_certificate_authority_summary_label": "Certificate Authority Summary", + "migration_wizard_step_certificate_authority_summary_description": "Securing component communication", + "migration_wizard_step_edge_component_label": "Edge Component", + "migration_wizard_step_edge_component_description": "Set up your VPN proxy quickly and ensure secure, optimized traffic flow for your users.", + "migration_wizard_step_edge_adoption_label": "Edge Component Adoption", + "migration_wizard_step_edge_adoption_description": "Review the system's checks and see if any issues need attention before deployment.", + "migration_wizard_step_confirmation_label": "Confirmation", + "migration_wizard_step_confirmation_description": "Your configuration was successful. You're all set.", + "migration_wizard_confirmation_title": "Initial system migration are complete.", + "migration_wizard_confirmation_subtitle": "You've completed the first stage of the migration. Defguard is almost ready to go.", + "migration_wizard_confirmation_locations_info": "You currently have X VPN locations configured. These locations must be upgraded.", + "migration_wizard_confirmation_architecture_change_info": "A key architectural change in Defguard 2.0 is that the Core now initiates connections to the gateways (in 1.x, gateways connected to the Core). As a result, in addition to upgrading the gateway components, you must update your firewall rules to:", + "migration_wizard_confirmation_rule_1": "Allow connections from the Core to the gateways on port 5055 tcp.", + "migration_wizard_confirmation_rule_2": "Block connections from the gateways to the Core.", + "migration_wizard_confirmation_security_notice_markdown": "**This change significantly improves the overall security of Defguard deployments**. You can [read more about it in the documentation]({link})", + "migration_wizard_confirmation_prepare_network_title": "Prepare your network", + "migration_wizard_confirmation_prepare_network_subtitle": "Please prepare all required network and firewall changes before starting the migration. Once ready, we'll begin adopting the upgraded gateway components for each VPN location.", + "migration_wizard_confirmation_checkbox_label": "I have changed all my gateways firewall rules and network setup", + "migration_wizard_ca_validity_one_year": "1 year", + "migration_wizard_ca_validity_years": "{years} years", + "migration_wizard_general_config_error_invalid_url": "Invalid URL", + "migration_wizard_general_config_error_defguard_url_required": "Defguard URL is required", + "migration_wizard_general_config_error_admin_group_required": "Default admin group name is required", + "migration_wizard_general_config_error_auth_period_min": "Authentication period must be at least 1 day", + "migration_wizard_general_config_error_mfa_timeout_min": "MFA code timeout must be at least 60 seconds", + "migration_wizard_general_config_label_defguard_url": "Defguard URL", + "migration_wizard_general_config_label_admin_group": "Default Admin Group Name", + "migration_wizard_general_config_label_auth_period": "Default Authentication Period (days)", + "migration_wizard_general_config_label_mfa_timeout": "Default MFA Code Timeout (seconds)", + "migration_wizard_general_config_label_public_proxy_url": "Public Edge component URL", + "migration_wizard_general_config_error_public_proxy_url_invalid": "Public Proxy URL must be a valid URL", + "migration_wizard_general_config_error_public_proxy_url_required": "Public Proxy URL is required", + "migration_wizard_ca_error_common_name_required": "Common name is required", + "migration_wizard_ca_error_email_invalid": "Invalid email address", + "migration_wizard_ca_error_email_required": "Email is required", + "migration_wizard_ca_error_validity_min": "Validity period must be at least 1 year", + "migration_wizard_ca_error_cert_required": "Certificate file is required", + "migration_wizard_ca_error_create_failed": "Failed to create CA. Please review the information and try again.", + "migration_wizard_ca_error_upload_failed": "Failed to upload CA. Please ensure the certificate file is valid and try again.", + "migration_wizard_ca_option_create_title": "Create a certificate authority & configure all Defguard components", + "migration_wizard_ca_option_create_description": "By choosing this option, Defguard will create its own certificate authority and automatically configure all components to use its certificates — no manual setup required.", + "migration_wizard_ca_label_common_name": "Common Name", + "migration_wizard_ca_placeholder_common_name": "Defguard Certificate Authority", + "migration_wizard_ca_label_email": "Email", + "migration_wizard_ca_placeholder_email": "email@example.com", + "migration_wizard_ca_label_validity": "Validity Period", + "migration_wizard_ca_generated_title": "Certificate Authority Generated", + "migration_wizard_ca_generated_subtitle": "The system created all required certificate files, including the root certificate and private key. You can download these files and continue with the configuration.", + "migration_wizard_ca_download_button": "Download CA certificate", + "migration_wizard_ca_validated_title": "Certificate Authority Validated", + "migration_wizard_ca_validated_subtitle": "Your uploaded Certificate Authority has been successfully validated. All required files were checked and confirmed as correct and ready for use. You can download the validated CA files if needed for your setup.", + "migration_wizard_ca_info_title": "Information extracted from uploaded file", + "migration_wizard_ca_info_label_common_name": "Common Name", + "migration_wizard_ca_info_label_validity": "Validity", + "migration_wizard_ca_validity_unknown": "—", + "migration_wizard_ca_validity_less_than_year": "Less than a year" +} diff --git a/web/package.json b/web/package.json index 137071df3..a47a5118b 100644 --- a/web/package.json +++ b/web/package.json @@ -14,19 +14,19 @@ }, "dependencies": { "@axa-ch/react-polymorphic-types": "^1.4.1", - "@floating-ui/react": "^0.27.18", - "@inlang/paraglide-js": "^2.12.0", + "@floating-ui/react": "^0.27.19", + "@inlang/paraglide-js": "^2.13.1", "@react-hook/resize-observer": "^2.0.2", "@shortercode/webzip": "1.1.1-0", "@stablelib/base64": "^2.0.1", "@stablelib/x25519": "^2.0.1", "@tanstack/react-form": "^1.28.3", "@tanstack/react-query": "^5.90.21", - "@tanstack/react-router": "^1.162.8", + "@tanstack/react-router": "^1.163.3", "@tanstack/react-table": "^8.21.3", "@tanstack/react-virtual": "^3.13.19", "@uidotdev/usehooks": "^2.4.1", - "axios": "^1.13.5", + "axios": "^1.13.6", "byte-size": "^9.0.1", "clsx": "^2.1.1", "dayjs": "^1.11.19", @@ -34,10 +34,10 @@ "humanize-duration": "^3.33.2", "ipaddr.js": "^2.3.0", "lodash-es": "^4.17.23", - "motion": "^12.34.3", + "motion": "^12.34.4", "qrcode.react": "^4.2.0", "qs": "^6.15.0", - "radashi": "^12.7.1", + "radashi": "^12.7.2", "react": "^19.2.4", "react-dom": "^19.2.4", "react-intersection-observer": "^10.0.3", @@ -52,26 +52,26 @@ }, "devDependencies": { "@biomejs/biome": "2.4.4", - "@inlang/paraglide-js": "2.12.0", - "@tanstack/devtools-vite": "^0.5.1", + "@inlang/paraglide-js": "2.13.0", + "@tanstack/devtools-vite": "^0.5.2", "@tanstack/react-devtools": "^0.9.6", "@tanstack/react-query-devtools": "^5.91.3", - "@tanstack/react-router-devtools": "^1.162.8", - "@tanstack/router-plugin": "^1.162.8", + "@tanstack/react-router-devtools": "^1.163.3", + "@tanstack/router-plugin": "^1.164.0", "@types/byte-size": "^8.1.2", "@types/humanize-duration": "^3.27.4", "@types/lodash-es": "^4.17.12", - "@types/node": "^25.3.0", + "@types/node": "^25.3.3", "@types/qs": "^6.14.0", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react-swc": "^4.2.3", - "autoprefixer": "^10.4.24", - "globals": "^17.3.0", + "autoprefixer": "^10.4.27", + "globals": "^17.4.0", "prettier": "^3.8.1", "sass": "^1.97.3", "sharp": "^0.34.5", - "stylelint": "^17.3.0", + "stylelint": "^17.4.0", "stylelint-config-standard-scss": "^17.0.0", "stylelint-scss": "^7.0.0", "typescript": "~5.9.3", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index a7d6cc56d..f1a634992 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -12,11 +12,11 @@ importers: specifier: ^1.4.1 version: 1.4.1(@types/react@19.2.14)(react@19.2.4) '@floating-ui/react': - specifier: ^0.27.18 - version: 0.27.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^0.27.19 + version: 0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@inlang/paraglide-js': - specifier: ^2.12.0 - version: 2.12.0 + specifier: ^2.13.1 + version: 2.13.1 '@react-hook/resize-observer': specifier: ^2.0.2 version: 2.0.2(react@19.2.4) @@ -36,8 +36,8 @@ importers: specifier: ^5.90.21 version: 5.90.21(react@19.2.4) '@tanstack/react-router': - specifier: ^1.162.8 - version: 1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.163.3 + version: 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/react-table': specifier: ^8.21.3 version: 8.21.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -48,8 +48,8 @@ importers: specifier: ^2.4.1 version: 2.4.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) axios: - specifier: ^1.13.5 - version: 1.13.5 + specifier: ^1.13.6 + version: 1.13.6 byte-size: specifier: ^9.0.1 version: 9.0.1 @@ -72,8 +72,8 @@ importers: specifier: ^4.17.23 version: 4.17.23 motion: - specifier: ^12.34.3 - version: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^12.34.4 + version: 12.34.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) qrcode.react: specifier: ^4.2.0 version: 4.2.0(react@19.2.4) @@ -81,8 +81,8 @@ importers: specifier: ^6.15.0 version: 6.15.0 radashi: - specifier: ^12.7.1 - version: 12.7.1 + specifier: ^12.7.2 + version: 12.7.2 react: specifier: ^19.2.4 version: 19.2.4 @@ -121,8 +121,8 @@ importers: specifier: 2.4.4 version: 2.4.4 '@tanstack/devtools-vite': - specifier: ^0.5.1 - version: 0.5.1(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0)) + specifier: ^0.5.2 + version: 0.5.2(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0)) '@tanstack/react-devtools': specifier: ^0.9.6 version: 0.9.6(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)(solid-js@1.9.9) @@ -130,11 +130,11 @@ importers: specifier: ^5.91.3 version: 5.91.3(@tanstack/react-query@5.90.21(react@19.2.4))(react@19.2.4) '@tanstack/react-router-devtools': - specifier: ^1.162.8 - version: 1.162.8(@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.162.6)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + specifier: ^1.163.3 + version: 1.163.3(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) '@tanstack/router-plugin': - specifier: ^1.162.8 - version: 1.162.8(@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0)) + specifier: ^1.164.0 + version: 1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0)) '@types/byte-size': specifier: ^8.1.2 version: 8.1.2 @@ -145,8 +145,8 @@ importers: specifier: ^4.17.12 version: 4.17.12 '@types/node': - specifier: ^25.3.0 - version: 25.3.0 + specifier: ^25.3.3 + version: 25.3.3 '@types/qs': specifier: ^6.14.0 version: 6.14.0 @@ -158,13 +158,13 @@ importers: version: 19.2.3(@types/react@19.2.14) '@vitejs/plugin-react-swc': specifier: ^4.2.3 - version: 4.2.3(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0)) + version: 4.2.3(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0)) autoprefixer: - specifier: ^10.4.24 - version: 10.4.24(postcss@8.5.6) + specifier: ^10.4.27 + version: 10.4.27(postcss@8.5.8) globals: - specifier: ^17.3.0 - version: 17.3.0 + specifier: ^17.4.0 + version: 17.4.0 prettier: specifier: ^3.8.1 version: 3.8.1 @@ -175,23 +175,23 @@ importers: specifier: ^0.34.5 version: 0.34.5 stylelint: - specifier: ^17.3.0 - version: 17.3.0(typescript@5.9.3) + specifier: ^17.4.0 + version: 17.4.0(typescript@5.9.3) stylelint-config-standard-scss: specifier: ^17.0.0 - version: 17.0.0(postcss@8.5.6)(stylelint@17.3.0(typescript@5.9.3)) + version: 17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@5.9.3)) stylelint-scss: specifier: ^7.0.0 - version: 7.0.0(stylelint@17.3.0(typescript@5.9.3)) + version: 7.0.0(stylelint@17.4.0(typescript@5.9.3)) typescript: specifier: ~5.9.3 version: 5.9.3 vite: specifier: ^7.3.1 - version: 7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0) + version: 7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0) vite-plugin-image-optimizer: specifier: ^2.0.3 - version: 2.0.3(sharp@0.34.5)(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0)) + version: 2.0.3(sharp@0.34.5)(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0)) packages: @@ -341,11 +341,11 @@ packages: cpu: [x64] os: [win32] - '@cacheable/memory@2.0.7': - resolution: {integrity: sha512-RbxnxAMf89Tp1dLhXMS7ceft/PGsDl1Ip7T20z5nZ+pwIAsQ1p2izPjVG69oCLv/jfQ7HDPHTWK0c9rcAWXN3A==} + '@cacheable/memory@2.0.8': + resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} - '@cacheable/utils@2.3.4': - resolution: {integrity: sha512-knwKUJEYgIfwShABS1BX6JyJJTglAFcEU7EXqzTdiGCXur4voqkiJkdgZIQtWNFhynzDWERcTYv/sETMu3uJWA==} + '@cacheable/utils@2.4.0': + resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==} '@csstools/css-calc@3.1.1': resolution: {integrity: sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==} @@ -360,8 +360,8 @@ packages: peerDependencies: '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.0.28': - resolution: {integrity: sha512-1NRf1CUBjnr3K7hu8BLxjQrKCxEe8FP/xmPTenAxCRZWVLbmGotkFvG9mfNpjA6k7Bw1bw4BilZq9cu19RA5pg==} + '@csstools/css-syntax-patches-for-csstree@1.0.29': + resolution: {integrity: sha512-jx9GjkkP5YHuTmko2eWAvpPnb0mB4mGRr2U7XwVNwevm8nlpobZEVk+GNmiYMk2VuA75v+plfXWyroWKmICZXg==} '@csstools/css-tokenizer@4.0.0': resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} @@ -545,29 +545,29 @@ packages: cpu: [x64] os: [win32] - '@floating-ui/core@1.7.4': - resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} - '@floating-ui/dom@1.7.5': - resolution: {integrity: sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==} + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} - '@floating-ui/react-dom@2.1.7': - resolution: {integrity: sha512-0tLRojf/1Go2JgEVm+3Frg9A3IW8bJgKgdO0BN5RkF//ufuz2joZM63Npau2ff3J6lUVYgDSNzNkR+aH3IVfjg==} + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} peerDependencies: react: '>=16.8.0' react-dom: '>=16.8.0' - '@floating-ui/react@0.27.18': - resolution: {integrity: sha512-xJWJxvmy3a05j643gQt+pRbht5XnTlGpsEsAPnMi5F5YTOEEJymA90uZKBD8OvIv5XvZ1qi4GcccSlqT3Bq44Q==} + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} peerDependencies: react: '>=17.0.0' react-dom: '>=17.0.0' - '@floating-ui/utils@0.2.10': - resolution: {integrity: sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==} + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} - '@img/colour@1.0.0': - resolution: {integrity: sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw==} + '@img/colour@1.1.0': + resolution: {integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==} engines: {node: '>=18'} '@img/sharp-darwin-arm64@0.34.5': @@ -719,8 +719,8 @@ packages: cpu: [x64] os: [win32] - '@inlang/paraglide-js@2.12.0': - resolution: {integrity: sha512-wnqTeSLcMMS2usL8zjS8bDGs9r16X00aeoGk2wVAnPfAgCChYalKdG20pS2XtJVMM1H6nBBBLKt3ZQMnKrusKQ==} + '@inlang/paraglide-js@2.13.1': + resolution: {integrity: sha512-IsGm5gHmH6hVA9iwzvoRkt+Q9nrzX+6+G1nAmxODk/l+mpm0siPebfkXiu6DODSluFuHg0Q6fjXcDXAaqOen+Q==} hasBin: true '@inlang/recommend-sherlock@0.2.1': @@ -1103,72 +1103,72 @@ packages: '@standard-schema/utils@0.3.0': resolution: {integrity: sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==} - '@swc/core-darwin-arm64@1.15.13': - resolution: {integrity: sha512-ztXusRuC5NV2w+a6pDhX13CGioMLq8CjX5P4XgVJ21ocqz9t19288Do0y8LklplDtwcEhYGTNdMbkmUT7+lDTg==} + '@swc/core-darwin-arm64@1.15.18': + resolution: {integrity: sha512-+mIv7uBuSaywN3C9LNuWaX1jJJ3SKfiJuE6Lr3bd+/1Iv8oMU7oLBjYMluX1UrEPzwN2qCdY6Io0yVicABoCwQ==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.15.13': - resolution: {integrity: sha512-cVifxQUKhaE7qcO/y9Mq6PEhoyvN9tSLzCnnFZ4EIabFHBuLtDDO6a+vLveOy98hAs5Qu1+bb5Nv0oa1Pihe3Q==} + '@swc/core-darwin-x64@1.15.18': + resolution: {integrity: sha512-wZle0eaQhnzxWX5V/2kEOI6Z9vl/lTFEC6V4EWcn+5pDjhemCpQv9e/TDJ0GIoiClX8EDWRvuZwh+Z3dhL1NAg==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.15.13': - resolution: {integrity: sha512-t+xxEzZ48enl/wGGy7SRYd7kImWQ/+wvVFD7g5JZo234g6/QnIgZ+YdfIyjHB+ZJI3F7a2IQHS7RNjxF29UkWw==} + '@swc/core-linux-arm-gnueabihf@1.15.18': + resolution: {integrity: sha512-ao61HGXVqrJFHAcPtF4/DegmwEkVCo4HApnotLU8ognfmU8x589z7+tcf3hU+qBiU1WOXV5fQX6W9Nzs6hjxDw==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.15.13': - resolution: {integrity: sha512-VndeGvKmTXFn6AGwjy0Kg8i7HccOCE7Jt/vmZwRxGtOfNZM1RLYRQ7MfDLo6T0h1Bq6eYzps3L5Ma4zBmjOnOg==} + '@swc/core-linux-arm64-gnu@1.15.18': + resolution: {integrity: sha512-3xnctOBLIq3kj8PxOCgPrGjBLP/kNOddr6f5gukYt/1IZxsITQaU9TDyjeX6jG+FiCIHjCuWuffsyQDL5Ew1bg==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [glibc] - '@swc/core-linux-arm64-musl@1.15.13': - resolution: {integrity: sha512-SmZ9m+XqCB35NddHCctvHFLqPZDAs5j8IgD36GoutufDJmeq2VNfgk5rQoqNqKmAK3Y7iFdEmI76QoHIWiCLyw==} + '@swc/core-linux-arm64-musl@1.15.18': + resolution: {integrity: sha512-0a+Lix+FSSHBSBOA0XznCcHo5/1nA6oLLjcnocvzXeqtdjnPb+SvchItHI+lfeiuj1sClYPDvPMLSLyXFaiIKw==} engines: {node: '>=10'} cpu: [arm64] os: [linux] libc: [musl] - '@swc/core-linux-x64-gnu@1.15.13': - resolution: {integrity: sha512-5rij+vB9a29aNkHq72EXI2ihDZPszJb4zlApJY4aCC/q6utgqFA6CkrfTfIb+O8hxtG3zP5KERETz8mfFK6A0A==} + '@swc/core-linux-x64-gnu@1.15.18': + resolution: {integrity: sha512-wG9J8vReUlpaHz4KOD/5UE1AUgirimU4UFT9oZmupUDEofxJKYb1mTA/DrMj0s78bkBiNI+7Fo2EgPuvOJfuAA==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [glibc] - '@swc/core-linux-x64-musl@1.15.13': - resolution: {integrity: sha512-OlSlaOK9JplQ5qn07WiBLibkOw7iml2++ojEXhhR3rbWrNEKCD7sd8+6wSavsInyFdw4PhLA+Hy6YyDBIE23Yw==} + '@swc/core-linux-x64-musl@1.15.18': + resolution: {integrity: sha512-4nwbVvCphKzicwNWRmvD5iBaZj8JYsRGa4xOxJmOyHlMDpsvvJ2OR2cODlvWyGFH6BYL1MfIAK3qph3hp0Az6g==} engines: {node: '>=10'} cpu: [x64] os: [linux] libc: [musl] - '@swc/core-win32-arm64-msvc@1.15.13': - resolution: {integrity: sha512-zwQii5YVdsfG8Ti9gIKgBKZg8qMkRZxl+OlYWUT5D93Jl4NuNBRausP20tfEkQdAPSRrMCSUZBM6FhW7izAZRg==} + '@swc/core-win32-arm64-msvc@1.15.18': + resolution: {integrity: sha512-zk0RYO+LjiBCat2RTMHzAWaMky0cra9loH4oRrLKLLNuL+jarxKLFDA8xTZWEkCPLjUTwlRN7d28eDLLMgtUcQ==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.15.13': - resolution: {integrity: sha512-hYXvyVVntqRlYoAIDwNzkS3tL2ijP3rxyWQMNKaxcCxxkCDto/w3meOK/OB6rbQSkNw0qTUcBfU9k+T0ptYdfQ==} + '@swc/core-win32-ia32-msvc@1.15.18': + resolution: {integrity: sha512-yVuTrZ0RccD5+PEkpcLOBAuPbYBXS6rslENvIXfvJGXSdX5QGi1ehC4BjAMl5FkKLiam4kJECUI0l7Hq7T1vwg==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.15.13': - resolution: {integrity: sha512-XTzKs7c/vYCcjmcwawnQvlHHNS1naJEAzcBckMI5OJlnrcgW8UtcX9NHFYvNjGtXuKv0/9KvqL4fuahdvlNGKw==} + '@swc/core-win32-x64-msvc@1.15.18': + resolution: {integrity: sha512-7NRmE4hmUQNCbYU3Hn9Tz57mK9Qq4c97ZS+YlamlK6qG9Fb5g/BB3gPDe0iLlJkns/sYv2VWSkm8c3NmbEGjbg==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.15.13': - resolution: {integrity: sha512-0l1gl/72PErwUZuavcRpRAQN9uSst+Nk++niC5IX6lmMWpXoScYx3oq/narT64/sKv/eRiPTaAjBFGDEQiWJIw==} + '@swc/core@1.15.18': + resolution: {integrity: sha512-z87aF9GphWp//fnkRsqvtY+inMVPgYW3zSlXH1kJFvRT5H/wiAn+G32qW5l3oEk63KSF1x3Ov0BfHCObAmT8RA==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -1200,8 +1200,8 @@ packages: peerDependencies: solid-js: '>=1.9.7' - '@tanstack/devtools-vite@0.5.1': - resolution: {integrity: sha512-5dXxMznSxx8NNpO9IbDC011sIdvTVvsoLaLAxm69dgDAX0+2OB8gdXrQp8dnzeNMvszKCgRxI2cgr/pjPgmnNw==} + '@tanstack/devtools-vite@0.5.2': + resolution: {integrity: sha512-UQF6qnxZ1Ad0J/r25lGYFST3cdAY2mdW1WjRyPyHLDg85pVZ52PigWp3QUnzXsx7VNQVy4+8asADwOOHpsVF0w==} engines: {node: '>=18'} peerDependencies: vite: ^6.0.0 || ^7.0.0 @@ -1258,20 +1258,20 @@ packages: peerDependencies: react: ^18 || ^19 - '@tanstack/react-router-devtools@1.162.8': - resolution: {integrity: sha512-dDohOU8eNbCukLQNcuocCTnvwSu8Z1XwbKvPc4U7KDYoUTUlJls48fXl5y/ENThK/nZEsA7i3oCy1BcX42OOlw==} + '@tanstack/react-router-devtools@1.163.3': + resolution: {integrity: sha512-42VMkV/2Z8ro7xzblPBRNZIEmCNXMzm2jD68G52p2qhjXm38wGpg46qneAESN9FtTQeVWk5aSXs47/jt7lkzmw==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/react-router': ^1.162.8 - '@tanstack/router-core': ^1.162.6 + '@tanstack/react-router': ^1.163.3 + '@tanstack/router-core': ^1.163.3 react: '>=18.0.0 || >=19.0.0' react-dom: '>=18.0.0 || >=19.0.0' peerDependenciesMeta: '@tanstack/router-core': optional: true - '@tanstack/react-router@1.162.8': - resolution: {integrity: sha512-WunoknGI5ielJ833yl/F7Vq4nv/OWzrJVBsMgyxX16Db1DwVvX/B5zTg8EMjdZUOJ7ONpvur3t4aq7KQiYRagQ==} + '@tanstack/react-router@1.163.3': + resolution: {integrity: sha512-hheBbFVb+PbxtrWp8iy6+TTRTbhx3Pn6hKo8Tv/sWlG89ZMcD1xpQWzx8ukHN9K8YWbh5rdzt4kv6u8X4kB28Q==} engines: {node: '>=20.19'} peerDependencies: react: '>=18.0.0 || >=19.0.0' @@ -1302,30 +1302,30 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@tanstack/router-core@1.162.6': - resolution: {integrity: sha512-WFMNysDsDtnlM0G0L4LPWJuvpGatlPvBLGlPnieWYKem/Ed4mRHu7Hqw78MR/CMuFSRi9Gvv91/h8F3EVswAJw==} + '@tanstack/router-core@1.163.3': + resolution: {integrity: sha512-jPptiGq/w3nuPzcMC7RNa79aU+b6OjaDzWJnBcV2UAwL4ThJamRS4h42TdhJE+oF5yH9IEnCOGQdfnbw45LbfA==} engines: {node: '>=20.19'} - '@tanstack/router-devtools-core@1.162.6': - resolution: {integrity: sha512-ni+9XmQOg9ale1e6FnhNrBymVVQAkzQ02SfAB6MgobXLp97MHiBk7d0k7DkoyVLk3tXRqmrCERWYRC8IGrcQmw==} + '@tanstack/router-devtools-core@1.163.3': + resolution: {integrity: sha512-FPi64IP0PT1IkoeyGmsD6JoOVOYAb85VCH0mUbSdD90yV0+1UB6oT+D7K27GXkp7SXMJN3mBEjU5rKnNnmSCIw==} engines: {node: '>=20.19'} peerDependencies: - '@tanstack/router-core': ^1.162.6 + '@tanstack/router-core': ^1.163.3 csstype: ^3.0.10 peerDependenciesMeta: csstype: optional: true - '@tanstack/router-generator@1.162.6': - resolution: {integrity: sha512-mzkD3kfPW50xgX1hI8YrQx76+hshsUmpI9fVvS741L0cRQKH7bCIYTvcNHkz3sftZwmjt/lh+k7arV1AMLaWhA==} + '@tanstack/router-generator@1.164.0': + resolution: {integrity: sha512-Uiyj+RtW0kdeqEd8NEd3Np1Z2nhJ2xgLS8U+5mTvFrm/s3xkM2LYjJHoLzc6am7sKPDsmeF9a4/NYq3R7ZJP0Q==} engines: {node: '>=20.19'} - '@tanstack/router-plugin@1.162.8': - resolution: {integrity: sha512-u6ZqYEjIA8jXge6JSl5UFFYPzVRciee0vwDwtkIF1Sb+G4cDdDaEjYQ4aN1/va8D7n3LptYvSMU8SeGkX+9slA==} + '@tanstack/router-plugin@1.164.0': + resolution: {integrity: sha512-cZPsEMhqzyzmuPuDbsTAzBZaT+cj0pGjwdhjxJfPCM06Ax8v4tFR7n/Ug0UCwnNAUEmKZWN3lA9uT+TxXnk9PQ==} engines: {node: '>=20.19'} peerDependencies: '@rsbuild/core': '>=1.0.2' - '@tanstack/react-router': ^1.162.8 + '@tanstack/react-router': ^1.163.3 vite: '>=5.0.0 || >=6.0.0 || >=7.0.0' vite-plugin-solid: ^2.11.10 webpack: '>=5.92.0' @@ -1419,8 +1419,8 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@25.3.0': - resolution: {integrity: sha512-4K3bqJpXpqfg2XKGK9bpDTc6xO/xoUP/RBWS7AtRMug6zZFaRekiLzjVtAoZMquxoAbzBvy5nxQ7veS5eYzf8A==} + '@types/node@25.3.3': + resolution: {integrity: sha512-DpzbrH7wIcBaJibpKo9nnSQL0MTRdnWttGyE5haGwK86xgMOkFLp7vEyfQPGLOJh5wNYiJ3V9PmUMDhV9u8kkQ==} '@types/qs@6.14.0': resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} @@ -1507,15 +1507,15 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} - autoprefixer@10.4.24: - resolution: {integrity: sha512-uHZg7N9ULTVbutaIsDRoUkoS8/h3bdsmVJYZ5l3wv8Cp/6UIIoRDm90hZ+BwxUj/hGBEzLxdHNSKuFpn8WOyZw==} + autoprefixer@10.4.27: + resolution: {integrity: sha512-NP9APE+tO+LuJGn7/9+cohklunJsXWiaWEfV3si4Gi/XHDwVNgkwr1J3RQYFIvPy76GmJ9/bW8vyoU1LcxwKHA==} engines: {node: ^10 || ^12 || >=14} hasBin: true peerDependencies: postcss: ^8.1.0 - axios@1.13.5: - resolution: {integrity: sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==} + axios@1.13.6: + resolution: {integrity: sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==} babel-dead-code-elimination@1.0.12: resolution: {integrity: sha512-GERT7L2TiYcYDtYk1IpD+ASAYXjKbLTDPhBtYj7X1NuRMDTMtAx9kyBenub1Ev41lo91OHCKdmP+egTDmfQ7Ig==} @@ -1523,10 +1523,6 @@ packages: bail@2.0.2: resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} - balanced-match@3.0.1: - resolution: {integrity: sha512-vjtV3hiLqYDNRoiAv0zC4QaGAMPomEoq83PRmYIofPswwZurCeWR5LByXm7SyoL0Zh5+2z0+HC7jG8gSZJUh0w==} - engines: {node: '>= 16'} - baseline-browser-mapping@2.10.0: resolution: {integrity: sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==} engines: {node: '>=6.0.0'} @@ -1554,8 +1550,8 @@ packages: '@75lb/nature': optional: true - cacheable@2.3.2: - resolution: {integrity: sha512-w+ZuRNmex9c1TR9RcsxbfTKCjSL0rh1WA5SABbrWprIHeNBdmyQLSYonlDy9gpD+63XT8DgZ/wNh1Smvc9WnJA==} + cacheable@2.3.3: + resolution: {integrity: sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==} call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} @@ -1569,8 +1565,8 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} - caniuse-lite@1.0.30001774: - resolution: {integrity: sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==} + caniuse-lite@1.0.30001775: + resolution: {integrity: sha512-s3Qv7Lht9zbVKE9XoTyRG6wVDCKdtOFIjBGg3+Yhn6JaytuNKPIjBMTMIY1AnOH3seL5mvF+x33oGAyK3hVt3A==} ccount@2.0.1: resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} @@ -1624,8 +1620,8 @@ packages: resolution: {integrity: sha512-yPVavfyCcRhmorC7rWlkHn15b4wDVgVmBA7kV4QVBsF7kv/9TKJAbAXVTxvTnwP8HHKjRCJDClKbciiYS7p0DQ==} engines: {node: '>=16'} - comment-json@4.5.1: - resolution: {integrity: sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg==} + comment-json@4.6.1: + resolution: {integrity: sha512-kdBIsBGqD/sAeqvzeOhBvO/bhtpbfbIU/2lw7bp182FV1cVlY7gr1Jf3Q1I+NOsCk8e4gF5Sl9iYH5cNvVmx5w==} engines: {node: '>= 6'} consola@3.4.0: @@ -1641,8 +1637,8 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} - cosmiconfig@9.0.0: - resolution: {integrity: sha512-itvL5h8RETACmOTFc4UfIyB2RfEHi71Ax6E/PivVxq9NseKbOWpeyHEOIbmAw1rs8Ak0VursQNww7lf7YtUwzg==} + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} engines: {node: '>=14'} peerDependencies: typescript: '>=4.9.5' @@ -1795,8 +1791,8 @@ packages: resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} engines: {node: '>= 0.4'} - es-toolkit@1.44.0: - resolution: {integrity: sha512-6penXeZalaV88MM3cGkFZZfOoLGWshWWfdy0tWw/RlVVyhvMaWSBTOvXNeiW3e5FwdS5ePW0LGEu17zT139ktg==} + es-toolkit@1.45.0: + resolution: {integrity: sha512-RArCX+Zea16+R1jg4mH223Z8p/ivbJjIkU3oC6ld2bdUfmDxiCkFYSi9zLOR2anucWJUeH4Djnzgd0im0nD3dw==} esbuild@0.27.3: resolution: {integrity: sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==} @@ -1857,8 +1853,8 @@ packages: flat-cache@6.1.20: resolution: {integrity: sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==} - flatted@3.3.3: - resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} + flatted@3.3.4: + resolution: {integrity: sha512-3+mMldrTAPdta5kjX2G2J7iX4zxtnwpdA8Tr2ZSjkyPSanvbZAcy6flmtnXbEybHrDcU9641lxrMfFuUxVz9vA==} follow-redirects@1.15.11: resolution: {integrity: sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==} @@ -1876,8 +1872,8 @@ packages: fraction.js@5.3.4: resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==} - framer-motion@12.34.3: - resolution: {integrity: sha512-v81ecyZKYO/DfpTwHivqkxSUBzvceOpoI+wLfgCgoUIKxlFKEXdg0oR9imxwXumT4SFy8vRk9xzJ5l3/Du/55Q==} + framer-motion@12.34.4: + resolution: {integrity: sha512-q1PwNhc1XJ3qYG7nc9+pEU5P3tnjB6Eh9vv5gGzy61nedDLB4+xk5peMCWhKM0Zn6sfhgunf/q9n0UgCoyKOBA==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -1929,8 +1925,8 @@ packages: resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} engines: {node: '>=6'} - globals@17.3.0: - resolution: {integrity: sha512-yMqGUQVVCkD4tqjOJf3TnrvaaHDMYp4VlUSObbkIiuCPe/ofdMBFIAcBbCSRFWOnos6qRiTVStDwqPLUclaxIw==} + globals@17.4.0: + resolution: {integrity: sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==} engines: {node: '>=18'} globby@16.1.1: @@ -2142,8 +2138,8 @@ packages: resolution: {integrity: sha512-FIyV/64EkKhJmjgC0g2hygpBv5RNWVPyNCqSAD7eTCv6eFWNIi4PN1UvdSJGicN/o35bnevgis4Y0UDC0qi8jQ==} engines: {node: '>=14.0.0'} - launch-editor@2.13.0: - resolution: {integrity: sha512-u+9asUHMJ99lA15VRMXw5XKfySFR9dGXwgsgS14YTbUq3GITP58mIM32At90P5fZ+MUId5Yw+IwI/yKub7jnCQ==} + launch-editor@2.13.1: + resolution: {integrity: sha512-lPSddlAAluRKJ7/cjRFoXUFzaX7q/YKI7yPHuEvSJVqoXvFnJov1/Ud87Aa4zULIbA9Nja4mSPK8l0z/7eV2wA==} lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -2286,8 +2282,8 @@ packages: motion-utils@12.29.2: resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==} - motion@12.34.3: - resolution: {integrity: sha512-xZIkBGO7v/Uvm+EyaqYd+9IpXu0sZqLywVlGdCFrrMiaO9JI4Kx51mO9KlHSWwll+gZUVY5OJsWgYI5FywJ/tw==} + motion@12.34.4: + resolution: {integrity: sha512-J0cuDNRymNzE0M2WY8CFcbQuprHBZwY+iqADKGLLe6kQUVP4kBQ2l7Z6gWK7Zfrt5Wgxs+kCojj4qu7I4wxBIw==} peerDependencies: '@emotion/is-prop-valid': '*' react: ^18.0.0 || ^19.0.0 @@ -2375,8 +2371,8 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.5.6: - resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + postcss@8.5.8: + resolution: {integrity: sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==} engines: {node: ^10 || ^12 || >=14} prettier@3.8.1: @@ -2406,8 +2402,8 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} - radashi@12.7.1: - resolution: {integrity: sha512-rwxcGY3oKMQJ+ojhS4MlxyVWqdXPVJSxg3ZjXDYYz26DYRuAAs+7XBM406/GYzPEBbjSJqdKfHDpBRfjWn34RQ==} + radashi@12.7.2: + resolution: {integrity: sha512-BfoN4XJll34ok3rCHjVlRypymJvD3cE+M3UATC8519wUgHl1/AXt2dEEIFv5865gO6t0ENAU6qEOqjr/NcIRyQ==} engines: {node: '>=16.0.0'} react-dom@19.2.4: @@ -2631,8 +2627,8 @@ packages: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} - strip-ansi@7.1.2: - resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} engines: {node: '>=12'} style-to-js@1.1.21: @@ -2679,8 +2675,8 @@ packages: peerDependencies: stylelint: ^16.8.2 || ^17.0.0 - stylelint@17.3.0: - resolution: {integrity: sha512-1POV91lcEMhj6SLVaOeA0KlS9yattS+qq+cyWqP/nYzWco7K5jznpGH1ExngvPlTM9QF1Kjd2bmuzJu9TH2OcA==} + stylelint@17.4.0: + resolution: {integrity: sha512-3kQ2/cHv3Zt8OBg+h2B8XCx9evEABQIrv4hh3uXahGz/ZEHrTR80zxBiK2NfXNaSoyBzxO1pjsz1Vhdzwn5XSw==} engines: {node: '>=20.19.0'} hasBin: true @@ -2927,8 +2923,8 @@ packages: resolution: {integrity: sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==} hasBin: true - write-file-atomic@7.0.0: - resolution: {integrity: sha512-YnlPC6JqnZl6aO4uRc+dx5PHguiR9S6WeoLtpxNT9wIG+BDya7ZNE1q7KOjVgaA73hKhKLpVPgJ5QA9THQ5BRg==} + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} engines: {node: ^20.17.0 || >=22.9.0} ws@8.19.0: @@ -3127,14 +3123,14 @@ snapshots: '@biomejs/cli-win32-x64@2.4.4': optional: true - '@cacheable/memory@2.0.7': + '@cacheable/memory@2.0.8': dependencies: - '@cacheable/utils': 2.3.4 + '@cacheable/utils': 2.4.0 '@keyv/bigmap': 1.3.1(keyv@5.6.0) hookified: 1.15.1 keyv: 5.6.0 - '@cacheable/utils@2.3.4': + '@cacheable/utils@2.4.0': dependencies: hashery: 1.5.0 keyv: 5.6.0 @@ -3148,7 +3144,7 @@ snapshots: dependencies: '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-syntax-patches-for-csstree@1.0.28': {} + '@csstools/css-syntax-patches-for-csstree@1.0.29': {} '@csstools/css-tokenizer@4.0.0': {} @@ -3248,32 +3244,32 @@ snapshots: '@esbuild/win32-x64@0.27.3': optional: true - '@floating-ui/core@1.7.4': + '@floating-ui/core@1.7.5': dependencies: - '@floating-ui/utils': 0.2.10 + '@floating-ui/utils': 0.2.11 - '@floating-ui/dom@1.7.5': + '@floating-ui/dom@1.7.6': dependencies: - '@floating-ui/core': 1.7.4 - '@floating-ui/utils': 0.2.10 + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 - '@floating-ui/react-dom@2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@floating-ui/react-dom@2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/dom': 1.7.5 + '@floating-ui/dom': 1.7.6 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@floating-ui/react@0.27.18(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@floating-ui/react@0.27.19(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@floating-ui/react-dom': 2.1.7(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@floating-ui/utils': 0.2.10 + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@floating-ui/utils': 0.2.11 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) tabbable: 6.4.0 - '@floating-ui/utils@0.2.10': {} + '@floating-ui/utils@0.2.11': {} - '@img/colour@1.0.0': {} + '@img/colour@1.1.0': {} '@img/sharp-darwin-arm64@0.34.5': optionalDependencies: @@ -3369,7 +3365,7 @@ snapshots: '@img/sharp-win32-x64@0.34.5': optional: true - '@inlang/paraglide-js@2.12.0': + '@inlang/paraglide-js@2.13.1': dependencies: '@inlang/recommend-sherlock': 0.2.1 '@inlang/sdk': 2.7.0 @@ -3383,7 +3379,7 @@ snapshots: '@inlang/recommend-sherlock@0.2.1': dependencies: - comment-json: 4.5.1 + comment-json: 4.6.1 '@inlang/sdk@2.7.0': dependencies: @@ -3685,51 +3681,51 @@ snapshots: '@standard-schema/utils@0.3.0': {} - '@swc/core-darwin-arm64@1.15.13': + '@swc/core-darwin-arm64@1.15.18': optional: true - '@swc/core-darwin-x64@1.15.13': + '@swc/core-darwin-x64@1.15.18': optional: true - '@swc/core-linux-arm-gnueabihf@1.15.13': + '@swc/core-linux-arm-gnueabihf@1.15.18': optional: true - '@swc/core-linux-arm64-gnu@1.15.13': + '@swc/core-linux-arm64-gnu@1.15.18': optional: true - '@swc/core-linux-arm64-musl@1.15.13': + '@swc/core-linux-arm64-musl@1.15.18': optional: true - '@swc/core-linux-x64-gnu@1.15.13': + '@swc/core-linux-x64-gnu@1.15.18': optional: true - '@swc/core-linux-x64-musl@1.15.13': + '@swc/core-linux-x64-musl@1.15.18': optional: true - '@swc/core-win32-arm64-msvc@1.15.13': + '@swc/core-win32-arm64-msvc@1.15.18': optional: true - '@swc/core-win32-ia32-msvc@1.15.13': + '@swc/core-win32-ia32-msvc@1.15.18': optional: true - '@swc/core-win32-x64-msvc@1.15.13': + '@swc/core-win32-x64-msvc@1.15.18': optional: true - '@swc/core@1.15.13': + '@swc/core@1.15.18': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.25 optionalDependencies: - '@swc/core-darwin-arm64': 1.15.13 - '@swc/core-darwin-x64': 1.15.13 - '@swc/core-linux-arm-gnueabihf': 1.15.13 - '@swc/core-linux-arm64-gnu': 1.15.13 - '@swc/core-linux-arm64-musl': 1.15.13 - '@swc/core-linux-x64-gnu': 1.15.13 - '@swc/core-linux-x64-musl': 1.15.13 - '@swc/core-win32-arm64-msvc': 1.15.13 - '@swc/core-win32-ia32-msvc': 1.15.13 - '@swc/core-win32-x64-msvc': 1.15.13 + '@swc/core-darwin-arm64': 1.15.18 + '@swc/core-darwin-x64': 1.15.18 + '@swc/core-linux-arm-gnueabihf': 1.15.18 + '@swc/core-linux-arm64-gnu': 1.15.18 + '@swc/core-linux-arm64-musl': 1.15.18 + '@swc/core-linux-x64-gnu': 1.15.18 + '@swc/core-linux-x64-musl': 1.15.18 + '@swc/core-win32-arm64-msvc': 1.15.18 + '@swc/core-win32-ia32-msvc': 1.15.18 + '@swc/core-win32-x64-msvc': 1.15.18 '@swc/counter@0.1.3': {} @@ -3758,7 +3754,7 @@ snapshots: transitivePeerDependencies: - csstype - '@tanstack/devtools-vite@0.5.1(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0))': + '@tanstack/devtools-vite@0.5.2(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/generator': 7.29.1 @@ -3768,9 +3764,9 @@ snapshots: '@tanstack/devtools-client': 0.0.5 '@tanstack/devtools-event-bus': 0.4.1 chalk: 5.6.2 - launch-editor: 2.13.0 + launch-editor: 2.13.1 picomatch: 4.0.3 - vite: 7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0) transitivePeerDependencies: - bufferutil - supports-color @@ -3838,22 +3834,22 @@ snapshots: '@tanstack/query-core': 5.90.20 react: 19.2.4 - '@tanstack/react-router-devtools@1.162.8(@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.162.6)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router-devtools@1.163.3(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(@tanstack/router-core@1.163.3)(csstype@3.2.3)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: - '@tanstack/react-router': 1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-devtools-core': 1.162.6(@tanstack/router-core@1.162.6)(csstype@3.2.3) + '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/router-devtools-core': 1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3) react: 19.2.4 react-dom: 19.2.4(react@19.2.4) optionalDependencies: - '@tanstack/router-core': 1.162.6 + '@tanstack/router-core': 1.163.3 transitivePeerDependencies: - csstype - '@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': + '@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)': dependencies: '@tanstack/history': 1.161.4 '@tanstack/react-store': 0.9.1(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - '@tanstack/router-core': 1.162.6 + '@tanstack/router-core': 1.163.3 isbot: 5.1.35 react: 19.2.4 react-dom: 19.2.4(react@19.2.4) @@ -3886,7 +3882,7 @@ snapshots: react: 19.2.4 react-dom: 19.2.4(react@19.2.4) - '@tanstack/router-core@1.162.6': + '@tanstack/router-core@1.163.3': dependencies: '@tanstack/history': 1.161.4 '@tanstack/store': 0.9.1 @@ -3896,18 +3892,18 @@ snapshots: tiny-invariant: 1.3.3 tiny-warning: 1.0.3 - '@tanstack/router-devtools-core@1.162.6(@tanstack/router-core@1.162.6)(csstype@3.2.3)': + '@tanstack/router-devtools-core@1.163.3(@tanstack/router-core@1.163.3)(csstype@3.2.3)': dependencies: - '@tanstack/router-core': 1.162.6 + '@tanstack/router-core': 1.163.3 clsx: 2.1.1 goober: 2.1.18(csstype@3.2.3) tiny-invariant: 1.3.3 optionalDependencies: csstype: 3.2.3 - '@tanstack/router-generator@1.162.6': + '@tanstack/router-generator@1.164.0': dependencies: - '@tanstack/router-core': 1.162.6 + '@tanstack/router-core': 1.163.3 '@tanstack/router-utils': 1.161.4 '@tanstack/virtual-file-routes': 1.161.4 prettier: 3.8.1 @@ -3918,7 +3914,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@tanstack/router-plugin@1.162.8(@tanstack/react-router@1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0))': + '@tanstack/router-plugin@1.164.0(@tanstack/react-router@1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4))(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0))': dependencies: '@babel/core': 7.29.0 '@babel/plugin-syntax-jsx': 7.28.6(@babel/core@7.29.0) @@ -3926,16 +3922,16 @@ snapshots: '@babel/template': 7.28.6 '@babel/traverse': 7.29.0 '@babel/types': 7.29.0 - '@tanstack/router-core': 1.162.6 - '@tanstack/router-generator': 1.162.6 + '@tanstack/router-core': 1.163.3 + '@tanstack/router-generator': 1.164.0 '@tanstack/router-utils': 1.161.4 '@tanstack/virtual-file-routes': 1.161.4 chokidar: 3.6.0 unplugin: 2.3.11 zod: 3.25.76 optionalDependencies: - '@tanstack/react-router': 1.162.8(react-dom@19.2.4(react@19.2.4))(react@19.2.4) - vite: 7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0) + '@tanstack/react-router': 1.163.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + vite: 7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0) transitivePeerDependencies: - supports-color @@ -4017,7 +4013,7 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@25.3.0': + '@types/node@25.3.3': dependencies: undici-types: 7.18.2 @@ -4044,11 +4040,11 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vitejs/plugin-react-swc@4.2.3(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0))': + '@vitejs/plugin-react-swc@4.2.3(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0))': dependencies: '@rolldown/pluginutils': 1.0.0-rc.2 - '@swc/core': 1.15.13 - vite: 7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0) + '@swc/core': 1.15.18 + vite: 7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0) transitivePeerDependencies: - '@swc/helpers' @@ -4090,16 +4086,16 @@ snapshots: asynckit@0.4.0: {} - autoprefixer@10.4.24(postcss@8.5.6): + autoprefixer@10.4.27(postcss@8.5.8): dependencies: browserslist: 4.28.1 - caniuse-lite: 1.0.30001774 + caniuse-lite: 1.0.30001775 fraction.js: 5.3.4 picocolors: 1.1.1 - postcss: 8.5.6 + postcss: 8.5.8 postcss-value-parser: 4.2.0 - axios@1.13.5: + axios@1.13.6: dependencies: follow-redirects: 1.15.11 form-data: 4.0.5 @@ -4118,8 +4114,6 @@ snapshots: bail@2.0.2: {} - balanced-match@3.0.1: {} - baseline-browser-mapping@2.10.0: {} binary-extensions@2.3.0: {} @@ -4131,17 +4125,17 @@ snapshots: browserslist@4.28.1: dependencies: baseline-browser-mapping: 2.10.0 - caniuse-lite: 1.0.30001774 + caniuse-lite: 1.0.30001775 electron-to-chromium: 1.5.302 node-releases: 2.0.27 update-browserslist-db: 1.2.3(browserslist@4.28.1) byte-size@9.0.1: {} - cacheable@2.3.2: + cacheable@2.3.3: dependencies: - '@cacheable/memory': 2.0.7 - '@cacheable/utils': 2.3.4 + '@cacheable/memory': 2.0.8 + '@cacheable/utils': 2.4.0 hookified: 1.15.1 keyv: 5.6.0 qified: 0.6.0 @@ -4158,7 +4152,7 @@ snapshots: callsites@3.1.0: {} - caniuse-lite@1.0.30001774: {} + caniuse-lite@1.0.30001775: {} ccount@2.0.1: {} @@ -4206,7 +4200,7 @@ snapshots: commander@11.1.0: {} - comment-json@4.5.1: + comment-json@4.6.1: dependencies: array-timsort: 1.0.3 core-util-is: 1.0.3 @@ -4220,7 +4214,7 @@ snapshots: core-util-is@1.0.3: {} - cosmiconfig@9.0.0(typescript@5.9.3): + cosmiconfig@9.0.1(typescript@5.9.3): dependencies: env-paths: 2.2.1 import-fresh: 3.3.1 @@ -4339,7 +4333,7 @@ snapshots: has-tostringtag: 1.0.2 hasown: 2.0.2 - es-toolkit@1.44.0: {} + es-toolkit@1.45.0: {} esbuild@0.27.3: optionalDependencies: @@ -4412,11 +4406,11 @@ snapshots: flat-cache@6.1.20: dependencies: - cacheable: 2.3.2 - flatted: 3.3.3 + cacheable: 2.3.3 + flatted: 3.3.4 hookified: 1.15.1 - flatted@3.3.3: {} + flatted@3.3.4: {} follow-redirects@1.15.11: {} @@ -4430,7 +4424,7 @@ snapshots: fraction.js@5.3.4: {} - framer-motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + framer-motion@12.34.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: motion-dom: 12.34.3 motion-utils: 12.29.2 @@ -4484,7 +4478,7 @@ snapshots: kind-of: 6.0.3 which: 1.3.1 - globals@17.3.0: {} + globals@17.4.0: {} globby@16.1.1: dependencies: @@ -4692,7 +4686,7 @@ snapshots: kysely@0.27.6: {} - launch-editor@2.13.0: + launch-editor@2.13.1: dependencies: picocolors: 1.1.1 shell-quote: 1.8.3 @@ -4960,9 +4954,9 @@ snapshots: motion-utils@12.29.2: {} - motion@12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4): + motion@12.34.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4): dependencies: - framer-motion: 12.34.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + framer-motion: 12.34.4(react-dom@19.2.4(react@19.2.4))(react@19.2.4) tslib: 2.8.1 optionalDependencies: react: 19.2.4 @@ -5018,13 +5012,13 @@ snapshots: postcss-resolve-nested-selector@0.1.6: {} - postcss-safe-parser@7.0.1(postcss@8.5.6): + postcss-safe-parser@7.0.1(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 - postcss-scss@4.0.9(postcss@8.5.6): + postcss-scss@4.0.9(postcss@8.5.8): dependencies: - postcss: 8.5.6 + postcss: 8.5.8 postcss-selector-parser@7.1.1: dependencies: @@ -5033,7 +5027,7 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.5.6: + postcss@8.5.8: dependencies: nanoid: 3.3.11 picocolors: 1.1.1 @@ -5059,7 +5053,7 @@ snapshots: queue-microtask@1.2.3: {} - radashi@12.7.1: {} + radashi@12.7.2: {} react-dom@19.2.4(react@19.2.4): dependencies: @@ -5126,7 +5120,7 @@ snapshots: '@reduxjs/toolkit': 2.11.2(react-redux@9.2.0(@types/react@19.2.14)(react@19.2.4)(redux@5.0.1))(react@19.2.4) clsx: 2.1.1 decimal.js-light: 2.5.1 - es-toolkit: 1.44.0 + es-toolkit: 1.45.0 eventemitter3: 5.0.4 immer: 10.2.0 react: 19.2.4 @@ -5247,7 +5241,7 @@ snapshots: sharp@0.34.5: dependencies: - '@img/colour': 1.0.0 + '@img/colour': 1.1.0 detect-libc: 2.1.2 semver: 7.7.4 optionalDependencies: @@ -5344,7 +5338,7 @@ snapshots: string-width@8.2.0: dependencies: get-east-asian-width: 1.5.0 - strip-ansi: 7.1.2 + strip-ansi: 7.2.0 stringify-entities@4.0.4: dependencies: @@ -5355,7 +5349,7 @@ snapshots: dependencies: ansi-regex: 5.0.1 - strip-ansi@7.1.2: + strip-ansi@7.2.0: dependencies: ansi-regex: 6.2.2 @@ -5367,33 +5361,33 @@ snapshots: dependencies: inline-style-parser: 0.2.7 - stylelint-config-recommended-scss@17.0.0(postcss@8.5.6)(stylelint@17.3.0(typescript@5.9.3)): + stylelint-config-recommended-scss@17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@5.9.3)): dependencies: - postcss-scss: 4.0.9(postcss@8.5.6) - stylelint: 17.3.0(typescript@5.9.3) - stylelint-config-recommended: 18.0.0(stylelint@17.3.0(typescript@5.9.3)) - stylelint-scss: 7.0.0(stylelint@17.3.0(typescript@5.9.3)) + postcss-scss: 4.0.9(postcss@8.5.8) + stylelint: 17.4.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.4.0(typescript@5.9.3)) + stylelint-scss: 7.0.0(stylelint@17.4.0(typescript@5.9.3)) optionalDependencies: - postcss: 8.5.6 + postcss: 8.5.8 - stylelint-config-recommended@18.0.0(stylelint@17.3.0(typescript@5.9.3)): + stylelint-config-recommended@18.0.0(stylelint@17.4.0(typescript@5.9.3)): dependencies: - stylelint: 17.3.0(typescript@5.9.3) + stylelint: 17.4.0(typescript@5.9.3) - stylelint-config-standard-scss@17.0.0(postcss@8.5.6)(stylelint@17.3.0(typescript@5.9.3)): + stylelint-config-standard-scss@17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@5.9.3)): dependencies: - stylelint: 17.3.0(typescript@5.9.3) - stylelint-config-recommended-scss: 17.0.0(postcss@8.5.6)(stylelint@17.3.0(typescript@5.9.3)) - stylelint-config-standard: 40.0.0(stylelint@17.3.0(typescript@5.9.3)) + stylelint: 17.4.0(typescript@5.9.3) + stylelint-config-recommended-scss: 17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@5.9.3)) + stylelint-config-standard: 40.0.0(stylelint@17.4.0(typescript@5.9.3)) optionalDependencies: - postcss: 8.5.6 + postcss: 8.5.8 - stylelint-config-standard@40.0.0(stylelint@17.3.0(typescript@5.9.3)): + stylelint-config-standard@40.0.0(stylelint@17.4.0(typescript@5.9.3)): dependencies: - stylelint: 17.3.0(typescript@5.9.3) - stylelint-config-recommended: 18.0.0(stylelint@17.3.0(typescript@5.9.3)) + stylelint: 17.4.0(typescript@5.9.3) + stylelint-config-recommended: 18.0.0(stylelint@17.4.0(typescript@5.9.3)) - stylelint-scss@7.0.0(stylelint@17.3.0(typescript@5.9.3)): + stylelint-scss@7.0.0(stylelint@17.4.0(typescript@5.9.3)): dependencies: css-tree: 3.1.0 is-plain-object: 5.0.0 @@ -5403,20 +5397,19 @@ snapshots: postcss-resolve-nested-selector: 0.1.6 postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 - stylelint: 17.3.0(typescript@5.9.3) + stylelint: 17.4.0(typescript@5.9.3) - stylelint@17.3.0(typescript@5.9.3): + stylelint@17.4.0(typescript@5.9.3): dependencies: '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-syntax-patches-for-csstree': 1.0.28 + '@csstools/css-syntax-patches-for-csstree': 1.0.29 '@csstools/css-tokenizer': 4.0.0 '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) - balanced-match: 3.0.1 colord: 2.9.3 - cosmiconfig: 9.0.0(typescript@5.9.3) + cosmiconfig: 9.0.1(typescript@5.9.3) css-functions-list: 3.3.3 css-tree: 3.1.0 debug: 4.4.3 @@ -5431,21 +5424,20 @@ snapshots: import-meta-resolve: 4.2.0 imurmurhash: 0.1.4 is-plain-object: 5.0.0 - known-css-properties: 0.37.0 mathml-tag-names: 4.0.0 meow: 14.1.0 micromatch: 4.0.8 normalize-path: 3.0.0 picocolors: 1.1.1 - postcss: 8.5.6 - postcss-safe-parser: 7.0.1(postcss@8.5.6) + postcss: 8.5.8 + postcss-safe-parser: 7.0.1(postcss@8.5.8) postcss-selector-parser: 7.1.1 postcss-value-parser: 4.2.0 string-width: 8.2.0 supports-hyperlinks: 4.4.0 svg-tags: 1.0.0 table: 6.9.0 - write-file-atomic: 7.0.0 + write-file-atomic: 7.0.1 transitivePeerDependencies: - supports-color - typescript @@ -5684,24 +5676,24 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-plugin-image-optimizer@2.0.3(sharp@0.34.5)(vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0)): + vite-plugin-image-optimizer@2.0.3(sharp@0.34.5)(vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0)): dependencies: ansi-colors: 4.1.3 pathe: 2.0.3 - vite: 7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0) + vite: 7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0) optionalDependencies: sharp: 0.34.5 - vite@7.3.1(@types/node@25.3.0)(sass@1.97.3)(tsx@4.21.0): + vite@7.3.1(@types/node@25.3.3)(sass@1.97.3)(tsx@4.21.0): dependencies: esbuild: 0.27.3 fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - postcss: 8.5.6 + postcss: 8.5.8 rollup: 4.59.0 tinyglobby: 0.2.15 optionalDependencies: - '@types/node': 25.3.0 + '@types/node': 25.3.3 fsevents: 2.3.3 sass: 1.97.3 tsx: 4.21.0 @@ -5714,9 +5706,8 @@ snapshots: dependencies: isexe: 2.0.0 - write-file-atomic@7.0.0: + write-file-atomic@7.0.1: dependencies: - imurmurhash: 0.1.4 signal-exit: 4.1.0 ws@8.19.0: {} diff --git a/web/project.inlang/settings.json b/web/project.inlang/settings.json index fa3338bde..61308a6bc 100644 --- a/web/project.inlang/settings.json +++ b/web/project.inlang/settings.json @@ -1,7 +1,9 @@ { "$schema": "https://inlang.com/schema/project-settings", "baseLocale": "en", - "locales": ["en"], + "locales": [ + "en" + ], "modules": [ "https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@latest/dist/index.js" ], @@ -24,7 +26,8 @@ "./messages/{locale}/location.json", "./messages/{locale}/settings.json", "./messages/{locale}/gateway_wizard.json", - "./messages/{locale}/initial_wizard.json" + "./messages/{locale}/initial_wizard.json", + "./messages/{locale}/migration_wizard.json" ] } } diff --git a/web/src/app/App.tsx b/web/src/app/App.tsx index 46956be78..2ede51bc5 100644 --- a/web/src/app/App.tsx +++ b/web/src/app/App.tsx @@ -2,6 +2,7 @@ import './day'; import { QueryClientProvider } from '@tanstack/react-query'; import { RouterProvider } from '@tanstack/react-router'; +import { AppSessionInfoProvider } from '../shared/providers/AppSessionInfoProvider'; import { AppThemeProvider } from '../shared/providers/AppThemeProvider'; import { queryClient } from './query'; import { router } from './router'; @@ -10,7 +11,9 @@ export const App = () => { return ( - + + + ); diff --git a/web/src/pages/MigrationWizardPage/MigrationWizardPage.tsx b/web/src/pages/MigrationWizardPage/MigrationWizardPage.tsx new file mode 100644 index 000000000..de702a819 --- /dev/null +++ b/web/src/pages/MigrationWizardPage/MigrationWizardPage.tsx @@ -0,0 +1,129 @@ +import './style.scss'; +import { useSuspenseQuery } from '@tanstack/react-query'; +import { type ReactNode, useEffect, useMemo } from 'react'; +import { m } from '../../paraglide/messages'; +import type { + WizardPageStep, + WizardWelcomePageConfig, +} from '../../shared/components/wizard/types'; +import { WizardPage } from '../../shared/components/wizard/WizardPage/WizardPage'; +import { + getMigrationStateQueryOptions, + getSettingsQueryOptions, +} from '../../shared/query'; +import { MigrationWizardCAStep } from './steps/MigrationWizardCAStep'; +import { MigrationWizardCASummaryStep } from './steps/MigrationWizardCASummaryStep'; +import { MigrationWizardConfirmationStep } from './steps/MigrationWizardConfirmationStep/MigrationWizardConfirmationStep'; +import { MigrationWizardEdgeAdoptionStep } from './steps/MigrationWizardEdgeAdoptionStep'; +import { MigrationWizardEdgeComponentStep } from './steps/MigrationWizardEdgeComponentStep'; +import { MigrationWizardGeneralConfigurationStep } from './steps/MigrationWizardGeneralConfigurationStep'; +import { MigrationWizardStart } from './steps/MigrationWizardStart'; +import { useMigrationWizardStore } from './store/useMigrationWizardStore'; +import { MigrationWizardStep, type MigrationWizardStepValue } from './types'; + +const welcomePageConfig: WizardWelcomePageConfig = { + title: 'Welcome to Defguard Migration Wizard.', + subtitle: `We've detected your previous version 1.X so email.`, + content: , + docsText: `We'll guide you through the process step by step. For full details, see the migration guide following the link bellow.`, +} as const; + +type ConfigurableSteps = Exclude; + +export const MigrationWizardPage = () => { + const { data: wizardState } = useSuspenseQuery(getMigrationStateQueryOptions); + const { data: settings } = useSuspenseQuery(getSettingsQueryOptions); + + const activeStep = useMigrationWizardStore((s) => s.activeStep); + + const stepsConfig = useMemo( + (): Record => ({ + general: { + id: MigrationWizardStep.General, + order: 1, + label: m.migration_wizard_step_general_config_label(), + description: m.migration_wizard_step_general_config_description(), + }, + ca: { + id: MigrationWizardStep.Ca, + order: 2, + label: m.migration_wizard_step_certificate_authority_label(), + description: m.migration_wizard_step_certificate_authority_description(), + }, + caSummary: { + id: MigrationWizardStep.CaSummary, + order: 3, + label: m.migration_wizard_step_certificate_authority_summary_label(), + description: m.migration_wizard_step_certificate_authority_summary_description(), + }, + edge: { + id: MigrationWizardStep.Edge, + order: 4, + label: m.migration_wizard_step_edge_component_label(), + description: m.migration_wizard_step_edge_component_description(), + }, + edgeAdoption: { + id: MigrationWizardStep.EdgeAdoption, + order: 5, + label: m.migration_wizard_step_edge_adoption_label(), + description: m.migration_wizard_step_edge_adoption_description(), + }, + confirmation: { + id: MigrationWizardStep.Confirmation, + order: 6, + label: m.migration_wizard_step_confirmation_label(), + description: m.migration_wizard_step_confirmation_description(), + }, + }), + [], + ); + + const stepsComponents = useMemo( + (): Record => ({ + general: , + ca: , + caSummary: , + edge: , + edgeAdoption: , + confirmation: , + welcome: null, + }), + [], + ); + + // sync wizard state + useEffect(() => { + if (wizardState) { + useMigrationWizardStore.setState({ + activeStep: wizardState.current_step, + }); + } + }, [wizardState]); + + // sync settings state + useEffect(() => { + if (settings) { + useMigrationWizardStore.setState({ + defguard_url: settings.defguard_url, + default_admin_group_name: settings.default_admin_group_name, + default_authentication_period_days: settings.authentication_period_days, + default_mfa_code_timeout_seconds: settings.mfa_code_timeout_seconds, + public_proxy_url: settings.public_proxy_url, + }); + } + }, [settings]); + + return ( + + {stepsComponents[activeStep]} + + ); +}; diff --git a/web/src/pages/MigrationWizardPage/steps/MigrationWizardCAStep.tsx b/web/src/pages/MigrationWizardPage/steps/MigrationWizardCAStep.tsx new file mode 100644 index 000000000..37b260a6b --- /dev/null +++ b/web/src/pages/MigrationWizardPage/steps/MigrationWizardCAStep.tsx @@ -0,0 +1,171 @@ +import { useMutation } from '@tanstack/react-query'; +import { useMemo } from 'react'; +import z from 'zod'; +import { useShallow } from 'zustand/react/shallow'; +import { m } from '../../../paraglide/messages'; +import api from '../../../shared/api/api'; +import { Controls } from '../../../shared/components/Controls/Controls'; +import { WizardCard } from '../../../shared/components/wizard/WizardCard/WizardCard'; +import { Button } from '../../../shared/defguard-ui/components/Button/Button'; +import { EvenSplit } from '../../../shared/defguard-ui/components/EvenSplit/EvenSplit'; +import type { SelectOption } from '../../../shared/defguard-ui/components/Select/types'; +import { SizedBox } from '../../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { Snackbar } from '../../../shared/defguard-ui/providers/snackbar/snackbar'; +import { ThemeSpacing } from '../../../shared/defguard-ui/types'; +import { useAppForm } from '../../../shared/form'; +import { formChangeLogic } from '../../../shared/formLogic'; +import { useMigrationWizardStore } from '../store/useMigrationWizardStore'; + +type ValidityValue = 1 | 2 | 3 | 5 | 10; + +const validityOptions: SelectOption[] = [ + { key: 1, label: m.migration_wizard_ca_validity_one_year(), value: 1 }, + { key: 2, label: m.migration_wizard_ca_validity_years({ years: 2 }), value: 2 }, + { key: 3, label: m.migration_wizard_ca_validity_years({ years: 3 }), value: 3 }, + { key: 5, label: m.migration_wizard_ca_validity_years({ years: 5 }), value: 5 }, + { + key: 10, + label: m.migration_wizard_ca_validity_years({ years: 10 }), + value: 10, + }, +]; + +type CreateCAFormFields = CreateCAStoreValues; + +type CreateCAStoreValues = { + ca_common_name: string; + ca_email: string; + ca_validity_period_years: number; +}; + +export const MigrationWizardCAStep = () => { + const createCAdefaultValues = useMigrationWizardStore( + useShallow( + (s): CreateCAFormFields => ({ + ca_common_name: s.ca_common_name, + ca_email: s.ca_email, + ca_validity_period_years: s.ca_validity_period_years, + }), + ), + ); + + const createFormSchema = useMemo( + () => + z.object({ + ca_common_name: z + .string() + .min(1, m.migration_wizard_ca_error_common_name_required()), + ca_email: z + .email(m.migration_wizard_ca_error_email_invalid()) + .min(1, m.migration_wizard_ca_error_email_required()), + ca_validity_period_years: z + .number() + .min(1, m.migration_wizard_ca_error_validity_min()), + }), + [], + ); + + const { mutateAsync: createCA } = useMutation({ + mutationFn: api.migration.ca.createCA, + onError: (error) => { + console.error('Failed to create CA:', error); + Snackbar.error(m.migration_wizard_ca_error_create_failed()); + }, + meta: { + invalidate: ['migration', 'ca'], + }, + }); + + const form = useAppForm({ + defaultValues: createCAdefaultValues, + validationLogic: formChangeLogic, + validators: { + onSubmit: createFormSchema, + onChange: createFormSchema, + }, + onSubmit: async ({ value }) => { + useMigrationWizardStore.setState({ + ca_common_name: value.ca_common_name, + ca_email: value.ca_email, + ca_validity_period_years: value.ca_validity_period_years, + }); + await createCA({ + common_name: value.ca_common_name, + email: value.ca_email, + validity_period_years: value.ca_validity_period_years, + }); + useMigrationWizardStore.getState().next(); + }, + }); + + return ( + +
{ + e.stopPropagation(); + e.preventDefault(); + form.handleSubmit(); + }} + > + + + + {(field) => ( + + )} + + + {(field) => ( + + )} + + + + + {(field) => ( + + )} + + ({ + isSubmitting: s.isSubmitting, + })} + > + {({ isSubmitting }) => ( + +