diff --git a/.sqlx/query-69d91cc060e5d6990fc3bb512a80d8a8823983288f8425947be41963668fbe00.json b/.sqlx/query-080139f99a90a5b4aeb3476890595a8a6dbf3d74a7fe116a2dd4fc9643203bf5.json similarity index 91% rename from .sqlx/query-69d91cc060e5d6990fc3bb512a80d8a8823983288f8425947be41963668fbe00.json rename to .sqlx/query-080139f99a90a5b4aeb3476890595a8a6dbf3d74a7fe116a2dd4fc9643203bf5.json index 29d8f0f49c..0d320da8d5 100644 --- a/.sqlx/query-69d91cc060e5d6990fc3bb512a80d8a8823983288f8425947be41963668fbe00.json +++ b/.sqlx/query-080139f99a90a5b4aeb3476890595a8a6dbf3d74a7fe116a2dd4fc9643203bf5.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 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 FROM \"settings\" WHERE id = 1", "describe": { "columns": [ { @@ -319,6 +319,32 @@ "ordinal": 56, "name": "public_proxy_url", "type_info": "Text" + }, + { + "ordinal": 57, + "name": "initial_setup_step: InitialSetupStep", + "type_info": { + "Custom": { + "name": "initial_setup_step", + "kind": { + "Enum": [ + "welcome", + "admin_user", + "general_configuration", + "ca", + "ca_summary", + "edge_component", + "confirmation", + "finished" + ] + } + } + } + }, + { + "ordinal": 58, + "name": "default_admin_id", + "type_info": "Int8" } ], "parameters": { @@ -381,8 +407,10 @@ false, false, false, - false + false, + false, + true ] }, - "hash": "69d91cc060e5d6990fc3bb512a80d8a8823983288f8425947be41963668fbe00" + "hash": "080139f99a90a5b4aeb3476890595a8a6dbf3d74a7fe116a2dd4fc9643203bf5" } diff --git a/.sqlx/query-3dc129f41487f6f876ec077b7750e5500bcc3ee7a16dda35eb139ae6a664aff8.json b/.sqlx/query-32e45d84ff0dbedf39c02b6813e99c1302322e3a08b04478a2413adcef4acc4e.json similarity index 83% rename from .sqlx/query-3dc129f41487f6f876ec077b7750e5500bcc3ee7a16dda35eb139ae6a664aff8.json rename to .sqlx/query-32e45d84ff0dbedf39c02b6813e99c1302322e3a08b04478a2413adcef4acc4e.json index ca5375fdbf..94fd3166a1 100644 --- a/.sqlx/query-3dc129f41487f6f876ec077b7750e5500bcc3ee7a16dda35eb139ae6a664aff8.json +++ b/.sqlx/query-32e45d84ff0dbedf39c02b6813e99c1302322e3a08b04478a2413adcef4acc4e.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 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 WHERE id = 1", "describe": { "columns": [], "parameters": { @@ -93,10 +93,28 @@ "Text", "Int4", "Int4", - "Text" + "Text", + { + "Custom": { + "name": "initial_setup_step", + "kind": { + "Enum": [ + "welcome", + "admin_user", + "general_configuration", + "ca", + "ca_summary", + "edge_component", + "confirmation", + "finished" + ] + } + } + }, + "Int8" ] }, "nullable": [] }, - "hash": "3dc129f41487f6f876ec077b7750e5500bcc3ee7a16dda35eb139ae6a664aff8" + "hash": "32e45d84ff0dbedf39c02b6813e99c1302322e3a08b04478a2413adcef4acc4e" } diff --git a/.sqlx/query-639d5cecd458667c0614ef3834be928a029fd8c53440eb01886e4c76c2367a0b.json b/.sqlx/query-6a2e1f77762591e0e33cbd6d37b9a51923add9a6ab6761cbdf04ada091ce80d5.json similarity index 61% rename from .sqlx/query-639d5cecd458667c0614ef3834be928a029fd8c53440eb01886e4c76c2367a0b.json rename to .sqlx/query-6a2e1f77762591e0e33cbd6d37b9a51923add9a6ab6761cbdf04ada091ce80d5.json index 858a46551a..934ebefa09 100644 --- a/.sqlx/query-639d5cecd458667c0614ef3834be928a029fd8c53440eb01886e4c76c2367a0b.json +++ b/.sqlx/query-6a2e1f77762591e0e33cbd6d37b9a51923add9a6ab6761cbdf04ada091ce80d5.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT instance_name, main_logo_url, nav_logo_url, wireguard_enabled, webhooks_enabled, worker_enabled, openid_enabled, initial_setup_completed FROM settings WHERE id = 1", + "query": "SELECT instance_name, main_logo_url, nav_logo_url, wireguard_enabled, webhooks_enabled, worker_enabled, openid_enabled, initial_setup_completed, initial_setup_step \"initial_setup_step: InitialSetupStep\" FROM settings WHERE id = 1", "describe": { "columns": [ { @@ -42,6 +42,27 @@ "ordinal": 7, "name": "initial_setup_completed", "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "initial_setup_step: InitialSetupStep", + "type_info": { + "Custom": { + "name": "initial_setup_step", + "kind": { + "Enum": [ + "welcome", + "admin_user", + "general_configuration", + "ca", + "ca_summary", + "edge_component", + "confirmation", + "finished" + ] + } + } + } } ], "parameters": { @@ -55,8 +76,9 @@ false, false, false, + false, false ] }, - "hash": "639d5cecd458667c0614ef3834be928a029fd8c53440eb01886e4c76c2367a0b" + "hash": "6a2e1f77762591e0e33cbd6d37b9a51923add9a6ab6761cbdf04ada091ce80d5" } diff --git a/crates/defguard_common/src/db/models/settings.rs b/crates/defguard_common/src/db/models/settings.rs index 0463f492da..4a95855751 100644 --- a/crates/defguard_common/src/db/models/settings.rs +++ b/crates/defguard_common/src/db/models/settings.rs @@ -10,7 +10,7 @@ use url::Url; use utoipa::ToSchema; use uuid::Uuid; -use crate::{global_value, secret::SecretStringWrapper}; +use crate::{db::Id, global_value, secret::SecretStringWrapper}; global_value!(SETTINGS, Option, None, set_settings, get_settings); @@ -81,6 +81,22 @@ impl LdapSyncStatus { } } +#[derive(Clone, Debug, Copy, Eq, PartialEq, Deserialize, Serialize, Default, Type, PartialOrd)] +#[sqlx(type_name = "initial_setup_step", rename_all = "snake_case")] +pub enum InitialSetupStep { + #[default] + Welcome, + AdminUser, + GeneralConfiguration, + Ca, + CaSummary, + // Adoption is not present, since the proxy is saved + // only after completing adoption step. + EdgeComponent, + Confirmation, + Finished, +} + #[derive(Clone, Deserialize, PartialEq, Patch, Serialize, Default)] #[patch(attribute(derive(Deserialize, Serialize, Debug)))] pub struct Settings { @@ -156,6 +172,8 @@ pub struct Settings { pub authentication_period_days: i32, pub mfa_code_timeout_seconds: i32, pub public_proxy_url: String, + pub initial_setup_step: InitialSetupStep, + pub default_admin_id: Option, } // Implement manually to avoid exposing the license key. @@ -242,6 +260,9 @@ impl fmt::Debug for Settings { &self.authentication_period_days, ) .field("mfa_code_timeout_seconds", &self.mfa_code_timeout_seconds) + .field("public_proxy_url", &self.public_proxy_url) + .field("initial_setup_step", &self.initial_setup_step) + .field("default_admin_id", &self.default_admin_id) .finish_non_exhaustive() } } @@ -274,7 +295,8 @@ impl Settings { 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 \ + public_proxy_url, initial_setup_step \"initial_setup_step: InitialSetupStep\", \ + default_admin_id \ FROM \"settings\" WHERE id = 1", ) .fetch_optional(executor) @@ -360,7 +382,9 @@ impl Settings { default_admin_group_name = $54, \ authentication_period_days = $55, \ mfa_code_timeout_seconds = $56, \ - public_proxy_url = $57 \ + public_proxy_url = $57, \ + initial_setup_step = $58, \ + default_admin_id = $59 \ WHERE id = 1", self.openid_enabled, self.wireguard_enabled, @@ -418,7 +442,9 @@ impl Settings { self.default_admin_group_name, self.authentication_period_days, self.mfa_code_timeout_seconds, - self.public_proxy_url + self.public_proxy_url, + &self.initial_setup_step as &InitialSetupStep, + self.default_admin_id, ) .execute(executor) .await?; @@ -514,6 +540,7 @@ pub struct SettingsEssentials { pub worker_enabled: bool, pub openid_enabled: bool, pub initial_setup_completed: bool, + pub initial_setup_step: InitialSetupStep, } impl SettingsEssentials { @@ -524,7 +551,7 @@ impl SettingsEssentials { query_as!( SettingsEssentials, "SELECT instance_name, main_logo_url, nav_logo_url, wireguard_enabled, \ - webhooks_enabled, worker_enabled, openid_enabled, initial_setup_completed \ + webhooks_enabled, worker_enabled, openid_enabled, initial_setup_completed, initial_setup_step \"initial_setup_step: InitialSetupStep\" \ FROM settings WHERE id = 1" ) .fetch_one(executor) @@ -543,6 +570,7 @@ impl From for SettingsEssentials { instance_name: settings.instance_name, main_logo_url: settings.main_logo_url, initial_setup_completed: settings.initial_setup_completed, + initial_setup_step: settings.initial_setup_step, } } } diff --git a/crates/defguard_core/src/auth/mod.rs b/crates/defguard_core/src/auth/mod.rs index 7c71d9208c..56631cd9d4 100644 --- a/crates/defguard_core/src/auth/mod.rs +++ b/crates/defguard_core/src/auth/mod.rs @@ -17,6 +17,7 @@ use defguard_common::db::{ OAuth2Token, Session, SessionState, Settings, group::{Group, Permission}, oauth2client::OAuth2Client, + settings::InitialSetupStep, user::User, }, }; @@ -220,8 +221,32 @@ where async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { let settings = Settings::get_current_settings(); if !settings.initial_setup_completed { - return Ok(Self {}); + // Allow unauthenticated access only up to the admin creation step. + if settings.initial_setup_step <= InitialSetupStep::AdminUser { + return Ok(Self {}); + } + let session_info = SessionInfo::from_request_parts(parts, state).await?; + if !session_info.user.is_active { + return Err(WebError::Forbidden("user is disabled".into())); + } + if let Some(default_admin_id) = settings.default_admin_id { + if session_info.user.id == default_admin_id { + return Ok(Self {}); + } + } + let pool = extract_pool(parts, state).await?; + let groups_with_permission = + Group::find_by_permission(&pool, Permission::IsAdmin).await?; + let group_names = groups_with_permission + .iter() + .map(|group| group.name.as_str()) + .collect::>(); + if session_info.contains_any_group(&group_names) { + return Ok(Self {}); + } + return Err(WebError::Forbidden("access denied".into())); } + let session_info = SessionInfo::from_request_parts(parts, state).await?; if !session_info.user.is_active { return Err(WebError::Forbidden("user is disabled".into())); diff --git a/crates/defguard_core/src/handlers/component_setup.rs b/crates/defguard_core/src/handlers/component_setup.rs index b0d008d025..54fbfec9d4 100644 --- a/crates/defguard_core/src/handlers/component_setup.rs +++ b/crates/defguard_core/src/handlers/component_setup.rs @@ -11,7 +11,12 @@ use defguard_common::{ auth::claims::Claims, db::{ Id, - models::{Settings, gateway::Gateway, proxy::Proxy}, + models::{ + Settings, + gateway::Gateway, + proxy::Proxy, + settings::{InitialSetupStep, update_current_settings}, + }, }, types::proxy::ProxyControlMessage, }; @@ -561,6 +566,16 @@ pub async fn setup_proxy_tls_stream( debug!("Edge proxy setup completed successfully"); + let mut settings = Settings::get_current_settings(); + if !settings.initial_setup_completed { + settings.initial_setup_step = InitialSetupStep::Confirmation; + if let Err(err) = update_current_settings(&pool, settings).await { + yield Ok(flow.error(&format!("Failed to update setup step in settings: {err}"))); + return; + } + debug!("Initial setup step advanced to 'Finished'"); + } + // Step 7: Done yield Ok(flow.step(SetupStep::Done)); }; diff --git a/crates/defguard_setup/src/handlers.rs b/crates/defguard_setup/src/handlers.rs index c8525516d9..e0a7d44b62 100644 --- a/crates/defguard_setup/src/handlers.rs +++ b/crates/defguard_setup/src/handlers.rs @@ -12,10 +12,15 @@ use axum_extra::{ }; use defguard_certs::{der_to_pem, parse_certificate_info, parse_pem_certificate}; use defguard_common::db::models::{ - Session, SessionState, Settings, User, group::Group, settings::update_current_settings, + Session, SessionState, Settings, User, + group::Group, + settings::{InitialSetupStep, update_current_settings}, }; use defguard_core::{ - auth::AdminOrSetupRole, + auth::{ + AdminOrSetupRole, SessionInfo, + failed_login::{FailedLoginMap, check_failed_logins, log_failed_login_attempt}, + }, error::WebError, handlers::{ApiResponse, ApiResult, SESSION_COOKIE_NAME}, headers::get_device_info, @@ -27,6 +32,28 @@ use sqlx::PgPool; use tokio::sync::oneshot; use tracing::{debug, info}; +async fn advance_setup_to_step(pool: &PgPool, step: InitialSetupStep) -> Result<(), WebError> { + let mut settings = Settings::get_current_settings(); + + // Don't try to advance if setup is already completed + if settings.initial_setup_completed { + debug!("Not advancing setup step as initial setup is already completed"); + return Ok(()); + } + + if settings.initial_setup_step < step { + settings.initial_setup_step = step; + update_current_settings(pool, settings).await?; + info!("Advanced initial wizard setup to step {:?}", step); + } else { + debug!( + "Not advancing initial wizard setup step from {:?} to {:?} as it is not a forward step", + settings.initial_setup_step, step + ); + } + Ok(()) +} + #[derive(Deserialize, Serialize, Debug)] pub struct CreateAdmin { first_name: String, @@ -36,6 +63,12 @@ pub struct CreateAdmin { password: String, } +#[derive(Deserialize, Serialize, Debug)] +pub struct SetupLogin { + username: String, + password: String, +} + pub async fn create_admin( cookies: CookieJar, user_agent: TypedHeader, @@ -43,6 +76,7 @@ pub async fn create_admin( Extension(pool): Extension, Json(admin): Json, ) -> Result<(CookieJar, ApiResponse), WebError> { + advance_setup_to_step(&pool, InitialSetupStep::AdminUser).await?; info!( "Creating initial admin user {} ({})", admin.username, admin.email @@ -58,6 +92,12 @@ pub async fn create_admin( .save(&pool) .await?; + debug!("Initial admin user created with ID {}", user.id); + let mut settings = Settings::get_current_settings(); + settings.default_admin_id = Some(user.id); + update_current_settings(&pool, settings).await?; + debug!("Initial admin user set as default admin in settings"); + let device_info = get_device_info(user_agent.as_str()); Session::delete_expired(&pool).await?; @@ -77,9 +117,89 @@ pub async fn create_admin( info!("Initial admin user created"); + advance_setup_to_step(&pool, InitialSetupStep::GeneralConfiguration).await?; + Ok((cookies, ApiResponse::with_status(StatusCode::CREATED))) } +pub async fn setup_login( + cookies: CookieJar, + user_agent: TypedHeader, + InsecureClientIp(insecure_ip): InsecureClientIp, + Extension(pool): Extension, + Extension(failed_logins): Extension>>, + Json(login): Json, +) -> Result<(CookieJar, ApiResponse), WebError> { + let settings = Settings::get_current_settings(); + if settings.initial_setup_completed { + return Err(WebError::Forbidden( + "Initial setup already completed".to_string(), + )); + } + let default_admin_id = settings + .default_admin_id + .ok_or_else(|| WebError::Forbidden("Default admin user not set".into()))?; + + check_failed_logins(&failed_logins, &login.username)?; + + let mut conn = pool.acquire().await?; + let user = match User::find_by_username_or_email(&mut conn, &login.username).await? { + Some(user) => user, + None => { + log_failed_login_attempt(&failed_logins, &login.username); + return Err(WebError::Authentication); + } + }; + + if user.verify_password(&login.password).is_err() { + log_failed_login_attempt(&failed_logins, &login.username); + return Err(WebError::Authentication); + } + + if !user.is_active { + return Err(WebError::Authentication); + } + + if user.id != default_admin_id { + return Err(WebError::Forbidden("access denied".into())); + } + + let device_info = get_device_info(user_agent.as_str()); + + Session::delete_expired(&pool).await?; + let session = Session::new( + user.id, + SessionState::PasswordVerified, + insecure_ip.to_string(), + Some(device_info), + ); + session.save(&pool).await?; + + let auth_cookie = Cookie::build((SESSION_COOKIE_NAME, session.id.clone())) + .path("/") + .http_only(true) + .same_site(SameSite::Lax); + let cookies = cookies.add(auth_cookie); + + Ok((cookies, ApiResponse::with_status(StatusCode::OK))) +} + +pub async fn setup_session(session: SessionInfo) -> ApiResult { + let settings = Settings::get_current_settings(); + if settings.initial_setup_completed { + return Err(WebError::Forbidden( + "Initial setup already completed".to_string(), + )); + } + let default_admin_id = settings + .default_admin_id + .ok_or_else(|| WebError::Forbidden("Default admin user not set".into()))?; + if session.user.id != default_admin_id { + return Err(WebError::Forbidden("access denied".into())); + } + Ok(ApiResponse::with_status(StatusCode::OK)) +} + #[derive(Deserialize, Serialize, Debug)] pub struct GeneralConfig { defguard_url: String, @@ -87,21 +207,20 @@ pub struct GeneralConfig { default_authentication: u32, default_mfa_code_lifetime: u32, public_proxy_url: String, - admin_username: String, } 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={}, admin_username={}", + "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, - general_config.admin_username ); let default_admin_group_name = general_config.default_admin_group_name.clone(); let mut settings = Settings::get_current_settings(); @@ -119,6 +238,7 @@ pub async fn set_general_config( .map_err(|err| WebError::BadRequest(format!("Invalid MFA code timeout seconds: {err}")))?; settings.public_proxy_url = general_config.public_proxy_url; update_current_settings(&pool, settings).await?; + let settings = Settings::get_current_settings(); debug!("Settings persisted"); let admin_group = @@ -140,22 +260,23 @@ pub async fn set_general_config( group.save(&pool).await? }; - let admin_user = User::find_by_username(&pool, &general_config.admin_username) - .await? - .ok_or_else(|| { - WebError::ObjectNotFound(format!( - "Admin user '{}' not found", - general_config.admin_username - )) - })?; + 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 {}", - general_config.admin_username, admin_group.name + admin_user.username, admin_group.name ); admin_user.add_to_group(&pool, &admin_group).await?; info!("Initial general configuration applied"); + advance_setup_to_step(&pool, InitialSetupStep::Ca).await?; + Ok(ApiResponse::with_status(StatusCode::CREATED)) } @@ -167,6 +288,7 @@ pub struct CreateCA { } pub async fn create_ca( + _: AdminOrSetupRole, Extension(pool): Extension, Json(ca_info): Json, ) -> ApiResult { @@ -192,10 +314,12 @@ pub async fn create_ca( info!("Certificate authority created and stored"); + advance_setup_to_step(&pool, InitialSetupStep::CaSummary).await?; + Ok(ApiResponse::with_status(StatusCode::CREATED)) } -pub async fn get_ca() -> ApiResult { +pub async fn get_ca(_: AdminOrSetupRole, Extension(pool): Extension) -> ApiResult { debug!("Fetching certificate authority details"); let settings = Settings::get_current_settings(); if let Some(ca_cert_der) = settings.ca_cert_der { @@ -208,6 +332,8 @@ pub async fn get_ca() -> ApiResult { info.subject_common_name, valid_for_days ); + 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 }), StatusCode::OK, @@ -225,6 +351,7 @@ pub struct UploadCA { } pub async fn upload_ca( + _: AdminOrSetupRole, Extension(pool): Extension, Json(ca_info): Json, ) -> ApiResult { @@ -239,6 +366,8 @@ pub async fn upload_ca( update_current_settings(&pool, settings).await?; + advance_setup_to_step(&pool, InitialSetupStep::CaSummary).await?; + info!("Certificate authority uploaded and stored"); Ok(ApiResponse::with_status(StatusCode::CREATED)) @@ -251,6 +380,7 @@ pub async fn finish_setup( ) -> ApiResult { info!("Finishing initial setup"); let mut settings = Settings::get_current_settings(); + settings.initial_setup_step = InitialSetupStep::Finished; settings.initial_setup_completed = true; update_current_settings(&pool, settings).await?; if let Some(tx) = setup_shutdown_tx @@ -265,5 +395,6 @@ pub async fn finish_setup( "Setup shutdown sender no longer available".to_string(), )); } + Ok(ApiResponse::with_status(StatusCode::OK)) } diff --git a/crates/defguard_setup/src/setup.rs b/crates/defguard_setup/src/setup.rs index 3b26d47114..96091fb180 100644 --- a/crates/defguard_setup/src/setup.rs +++ b/crates/defguard_setup/src/setup.rs @@ -11,6 +11,7 @@ use axum::{ }; use defguard_common::VERSION; use defguard_core::{ + auth::failed_login::FailedLoginMap, handle_404, handlers::{component_setup::setup_proxy_tls_stream, settings::get_settings_essentials}, health_check, @@ -22,10 +23,12 @@ use tokio::{net::TcpListener, sync::oneshot::Sender}; use tracing::{info, instrument}; use crate::handlers::{ - create_admin, create_ca, finish_setup, get_ca, set_general_config, upload_ca, + create_admin, create_ca, finish_setup, get_ca, set_general_config, setup_login, setup_session, + upload_ca, }; pub fn build_setup_webapp(pool: PgPool, version: Version, setup_shutdown_tx: Sender<()>) -> Router { + let failed_logins = Arc::new(Mutex::new(FailedLoginMap::new())); Router::<()>::new() .route("/", get(index)) .route("/{*path}", get(index)) @@ -45,12 +48,15 @@ pub fn build_setup_webapp(pool: PgPool, version: Version, setup_shutdown_tx: Sen .route("/ca/upload", post(upload_ca)) .route("/general_config", post(set_general_config)) .route("/admin", post(create_admin)) + .route("/login", post(setup_login)) + .route("/session", get(setup_session)) .route("/finish", post(finish_setup)), ), ) .fallback_service(get(handle_404)) .layer(Extension(pool)) .layer(Extension(version)) + .layer(Extension(failed_logins)) .layer(Extension(Arc::new(Mutex::new(Some(setup_shutdown_tx))))) } diff --git a/crates/defguard_setup/tests/initial_setup.rs b/crates/defguard_setup/tests/initial_setup.rs index 2514a088af..7c0ee0c139 100644 --- a/crates/defguard_setup/tests/initial_setup.rs +++ b/crates/defguard_setup/tests/initial_setup.rs @@ -8,7 +8,11 @@ use defguard_certs::{CertificateAuthority, PemLabel, der_to_pem}; use defguard_common::{ VERSION, db::{ - models::{Session, Settings, User, group::Group, settings::initialize_current_settings}, + models::{ + Session, Settings, User, + group::Group, + settings::{InitialSetupStep, initialize_current_settings}, + }, setup_pool, }, }; @@ -29,6 +33,17 @@ use tokio::{ const SESSION_COOKIE_NAME: &str = "defguard_session"; +async fn assert_setup_step(pool: &sqlx::PgPool, expected: InitialSetupStep) { + let settings = Settings::get(pool) + .await + .expect("Failed to fetch settings") + .expect("Settings not found"); + assert_eq!(settings.initial_setup_step, expected); + + let current_settings = Settings::get_current_settings(); + assert_eq!(current_settings.initial_setup_step, expected); +} + struct TestClient { client: Client, _jar: Arc, @@ -134,29 +149,86 @@ async fn test_create_admin(_: PgPoolOptions, options: PgConnectOptions) { .expect("Failed to fetch session") .expect("Session not created"); assert_eq!(session.user_id, user.id); + + let settings = Settings::get(&pool) + .await + .expect("Failed to fetch settings") + .expect("Settings not found"); + assert_eq!(settings.default_admin_id, Some(user.id)); + + assert_setup_step(&pool, InitialSetupStep::GeneralConfiguration).await; } #[sqlx::test] -async fn test_set_general_config(_: PgPoolOptions, options: PgConnectOptions) { +async fn test_setup_login_too_many_attempts(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; initialize_current_settings(&pool) .await .expect("Failed to initialize settings"); - let _admin = User::new( - "admin1", - Some("Passw0rd!"), - "Admin", - "Admin", - "admin1@example.com", - None, - ) - .save(&pool) - .await - .expect("Failed to create admin user"); + let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; + + let response = client + .post("/api/v1/initial_setup/admin") + .json(&json!({ + "first_name": "Admin", + "last_name": "Admin", + "username": "admin1", + "email": "admin1@example.com", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to create admin user"); + assert_eq!(response.status(), StatusCode::CREATED); + + let payload = json!({ + "username": "admin1", + "password": "WrongPass" + }); + + for _ in 0..5 { + let response = client + .post("/api/v1/initial_setup/login") + .json(&payload) + .send() + .await + .expect("Failed to login during setup"); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + } + + let response = client + .post("/api/v1/initial_setup/login") + .json(&payload) + .send() + .await + .expect("Failed to login during setup"); + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); +} + +#[sqlx::test] +async fn test_set_general_config(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + initialize_current_settings(&pool) + .await + .expect("Failed to initialize settings"); let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; + let response = client + .post("/api/v1/initial_setup/admin") + .json(&json!({ + "first_name": "Admin", + "last_name": "Admin", + "username": "admin1", + "email": "admin1@example.com", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to create admin user"); + assert_eq!(response.status(), StatusCode::CREATED); + let payload = json!({ "defguard_url": "https://example.com", "default_admin_group_name": "admins", @@ -198,6 +270,8 @@ async fn test_set_general_config(_: PgPoolOptions, options: PgConnectOptions) { .await .expect("Failed to fetch group membership"); assert!(groups.contains(&"admins".to_string())); + + assert_setup_step(&pool, InitialSetupStep::Ca).await; } #[sqlx::test] @@ -209,6 +283,20 @@ async fn test_create_ca(_: PgPoolOptions, options: PgConnectOptions) { let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; + let response = client + .post("/api/v1/initial_setup/admin") + .json(&json!({ + "first_name": "Admin", + "last_name": "Admin", + "username": "admin1", + "email": "admin1@example.com", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to create admin user"); + assert_eq!(response.status(), StatusCode::CREATED); + let payload = json!({ "common_name": "Test CA", "email": "ca@example.com", @@ -230,6 +318,8 @@ async fn test_create_ca(_: PgPoolOptions, options: PgConnectOptions) { assert!(settings.ca_cert_der.is_some()); assert!(settings.ca_key_der.is_some()); assert!(settings.ca_expiry.is_some()); + + assert_setup_step(&pool, InitialSetupStep::CaSummary).await; } #[sqlx::test] @@ -241,6 +331,20 @@ async fn test_upload_ca(_: PgPoolOptions, options: PgConnectOptions) { let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; + let response = client + .post("/api/v1/initial_setup/admin") + .json(&json!({ + "first_name": "Admin", + "last_name": "Admin", + "username": "admin1", + "email": "admin1@example.com", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to create admin user"); + assert_eq!(response.status(), StatusCode::CREATED); + let ca = CertificateAuthority::new("CA", "ca@example.com", 365).expect("Failed to create CA"); let cert_pem = der_to_pem(ca.cert_der(), PemLabel::Certificate).expect("Failed to convert cert to PEM"); @@ -260,6 +364,8 @@ async fn test_upload_ca(_: PgPoolOptions, options: PgConnectOptions) { assert!(settings.ca_cert_der.is_some()); assert!(settings.ca_key_der.is_none()); assert!(settings.ca_expiry.is_some()); + + assert_setup_step(&pool, InitialSetupStep::CaSummary).await; } #[sqlx::test] @@ -271,6 +377,20 @@ async fn test_get_ca(_: PgPoolOptions, options: PgConnectOptions) { let (client, _shutdown_rx) = make_setup_test_client(pool.clone()).await; + let response = client + .post("/api/v1/initial_setup/admin") + .json(&json!({ + "first_name": "Admin", + "last_name": "Admin", + "username": "admin1", + "email": "admin1@example.com", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to create admin user"); + assert_eq!(response.status(), StatusCode::CREATED); + let payload = json!({ "common_name": "CA", "email": "ca@example.com", @@ -295,6 +415,8 @@ async fn test_get_ca(_: PgPoolOptions, options: PgConnectOptions) { assert_eq!(body["subject_common_name"], "CA"); let pem = body["ca_cert_pem"].as_str().expect("Missing ca_cert_pem"); assert!(pem.contains("BEGIN CERTIFICATE")); + + assert_setup_step(&pool, InitialSetupStep::EdgeComponent).await; } #[sqlx::test] @@ -306,6 +428,20 @@ async fn test_finish_setup(_: PgPoolOptions, options: PgConnectOptions) { let (client, shutdown_rx) = make_setup_test_client(pool.clone()).await; + let response = client + .post("/api/v1/initial_setup/admin") + .json(&json!({ + "first_name": "Admin", + "last_name": "Admin", + "username": "admin1", + "email": "admin1@example.com", + "password": "Passw0rd!" + })) + .send() + .await + .expect("Failed to create admin user"); + assert_eq!(response.status(), StatusCode::CREATED); + let response = client .post("/api/v1/initial_setup/finish") .send() @@ -318,6 +454,9 @@ async fn test_finish_setup(_: PgPoolOptions, options: PgConnectOptions) { .expect("Failed to fetch settings") .expect("Settings not found"); assert!(settings.initial_setup_completed); + assert_eq!(settings.initial_setup_step, InitialSetupStep::Finished); + + assert_setup_step(&pool, InitialSetupStep::Finished).await; let shutdown_signal = tokio::time::timeout(std::time::Duration::from_secs(1), shutdown_rx).await; @@ -369,6 +508,8 @@ async fn test_setup_flow(_: PgPoolOptions, options: PgConnectOptions) { .expect("Failed to build reqwest client"); let base_url = format!("http://localhost:{port}"); + assert_setup_step(&pool, InitialSetupStep::Welcome).await; + let response = client .post(format!("{base_url}/api/v1/initial_setup/admin")) .json(&json!({ @@ -388,6 +529,7 @@ async fn test_setup_flow(_: PgPoolOptions, options: PgConnectOptions) { .expect("Session cookie not set") .value() .to_string(); + assert_setup_step(&pool, InitialSetupStep::GeneralConfiguration).await; let response = client .post(format!("{base_url}/api/v1/initial_setup/general_config")) @@ -403,6 +545,7 @@ async fn test_setup_flow(_: PgPoolOptions, options: PgConnectOptions) { .await .expect("Failed to set general config"); assert_eq!(response.status(), StatusCode::CREATED); + assert_setup_step(&pool, InitialSetupStep::Ca).await; let response = client .post(format!("{base_url}/api/v1/initial_setup/ca")) @@ -415,6 +558,7 @@ async fn test_setup_flow(_: PgPoolOptions, options: PgConnectOptions) { .await .expect("Failed to create CA"); assert_eq!(response.status(), StatusCode::CREATED); + assert_setup_step(&pool, InitialSetupStep::CaSummary).await; let response = client .post(format!("{base_url}/api/v1/initial_setup/finish")) @@ -422,6 +566,7 @@ async fn test_setup_flow(_: PgPoolOptions, options: PgConnectOptions) { .await .expect("Failed to finish setup"); assert_eq!(response.status(), StatusCode::OK); + assert_setup_step(&pool, InitialSetupStep::Finished).await; let settings = Settings::get(&pool) .await @@ -435,6 +580,7 @@ async fn test_setup_flow(_: PgPoolOptions, options: PgConnectOptions) { assert!(settings.ca_cert_der.is_some()); assert!(settings.ca_key_der.is_some()); assert!(settings.ca_expiry.is_some()); + assert_eq!(settings.initial_setup_step, InitialSetupStep::Finished); let admin_group = Group::find_by_name(&pool, "admins") .await diff --git a/migrations/20260205094812_[2.0.0]_initial_wizard_step_persistence.down.sql b/migrations/20260205094812_[2.0.0]_initial_wizard_step_persistence.down.sql new file mode 100644 index 0000000000..83b8d141ab --- /dev/null +++ b/migrations/20260205094812_[2.0.0]_initial_wizard_step_persistence.down.sql @@ -0,0 +1,7 @@ +ALTER TABLE settings DROP CONSTRAINT fk_default_admin; + +ALTER TABLE settings +DROP COLUMN initial_setup_step, +DROP COLUMN default_admin_id; + +DROP TYPE initial_setup_step; diff --git a/migrations/20260205094812_[2.0.0]_initial_wizard_step_persistence.up.sql b/migrations/20260205094812_[2.0.0]_initial_wizard_step_persistence.up.sql new file mode 100644 index 0000000000..0a08e70692 --- /dev/null +++ b/migrations/20260205094812_[2.0.0]_initial_wizard_step_persistence.up.sql @@ -0,0 +1,19 @@ +CREATE TYPE initial_setup_step AS ENUM ( + 'welcome', + 'admin_user', + 'general_configuration', + 'ca', + 'ca_summary', + 'edge_component', + 'confirmation', + 'finished' +); + +ALTER TABLE settings +ADD COLUMN initial_setup_step initial_setup_step NOT NULL DEFAULT 'welcome', +ADD COLUMN default_admin_id BIGINT NULL; + +ALTER TABLE settings +ADD CONSTRAINT fk_default_admin +FOREIGN KEY (default_admin_id) REFERENCES "user"(id) +ON DELETE SET NULL; diff --git a/web/messages/en/initial_wizard.json b/web/messages/en/initial_wizard.json index 367e048f88..9a1e6ca6e3 100644 --- a/web/messages/en/initial_wizard.json +++ b/web/messages/en/initial_wizard.json @@ -5,6 +5,7 @@ "initial_setup_welcome_title": "Welcome to Defguard initial configuration wizard.", "initial_setup_welcome_subtitle": "This wizard walks you through the steps to configure your VPN connection with a simple and intuitive setup process.", "initial_setup_welcome_button_configure": "Configure Defguard", + "initial_setup_login_subtitle": "Please enter your admin credentials to continue the setup wizard.", "initial_setup_step_admin_user_label": "Create Admin User", "initial_setup_step_admin_user_description": "Manage core details and connection parameters for your VPN location.", diff --git a/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx b/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx index b7fd4b3244..a1370d83da 100644 --- a/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx +++ b/web/src/pages/AddLocationPage/steps/AddLocationFirewallStep.tsx @@ -21,7 +21,7 @@ import { useAddLocationStore } from '../useAddLocationStore'; type Choice = 'disable' | 'enabled-allowed' | 'enabled-denied'; export const AddLocationFirewallStep = () => { - const [showGateway, setShowGateway] = useState(false); + const [showGateway, setShowGateway] = useState(true); const [state, setState] = useState('disable'); const navigate = useNavigate(); @@ -33,7 +33,7 @@ export const AddLocationFirewallStep = () => { onSuccess: ({ data }) => { if (showGateway) { useGatewayWizardStore.getState().start({ network_id: data.id }); - navigate({ to: '/gateway-wizard', replace: true }).then(() => { + navigate({ to: '/setup-gateway', replace: true }).then(() => { setTimeout(() => { useAddLocationStore.getState().reset(); }, 100); diff --git a/web/src/pages/EdgeSetupPage/steps/SetupEdgeComponentStep.tsx b/web/src/pages/EdgeSetupPage/steps/SetupEdgeComponentStep.tsx index 40e3397936..98aeab8ec0 100644 --- a/web/src/pages/EdgeSetupPage/steps/SetupEdgeComponentStep.tsx +++ b/web/src/pages/EdgeSetupPage/steps/SetupEdgeComponentStep.tsx @@ -40,7 +40,7 @@ export const SetupEdgeComponentStep = () => { }; const handleBack = () => { - navigate({ to: '/edge-wizard', replace: true }).then(() => { + navigate({ to: '/setup-edge', replace: true }).then(() => { setTimeout(() => { useEdgeWizardStore.getState().reset(); }, 100); diff --git a/web/src/pages/EdgesPage/EdgesTable.tsx b/web/src/pages/EdgesPage/EdgesTable.tsx index 1c86c0bc1b..ceae34adde 100644 --- a/web/src/pages/EdgesPage/EdgesTable.tsx +++ b/web/src/pages/EdgesPage/EdgesTable.tsx @@ -56,7 +56,7 @@ export const EdgesTable = ({ edges }: Props) => { iconLeft: 'globe', testId: 'add-edge', onClick: () => { - navigate({ to: '/edge-wizard' }); + navigate({ to: '/setup-edge' }); }, }), [navigate], diff --git a/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx b/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx index 66f0437965..d287d6a8be 100644 --- a/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx +++ b/web/src/pages/GatewaySetupPage/steps/SetupConfirmationStep.tsx @@ -13,7 +13,9 @@ export const SetupConfirmationStep = () => { const navigate = useNavigate(); const handleBack = () => { + const networkId = useGatewayWizardStore.getState().network_id; useGatewayWizardStore.getState().reset(); + useGatewayWizardStore.getState().start({ network_id: networkId }); }; const handleFinish = () => { diff --git a/web/src/pages/LocationsPage/components/LocationsTable.tsx b/web/src/pages/LocationsPage/components/LocationsTable.tsx index c07708f055..a0c01213c6 100644 --- a/web/src/pages/LocationsPage/components/LocationsTable.tsx +++ b/web/src/pages/LocationsPage/components/LocationsTable.tsx @@ -204,7 +204,7 @@ export const LocationsTable = ({ locations }: Props) => { onClick: async () => { useGatewayWizardStore.getState().start({ network_id: row.id }); navigate({ - to: '/gateway-wizard', + to: '/setup-gateway', }); }, }, diff --git a/web/src/pages/SetupPage/SetupLoginPage.tsx b/web/src/pages/SetupPage/SetupLoginPage.tsx new file mode 100644 index 0000000000..60c8378342 --- /dev/null +++ b/web/src/pages/SetupPage/SetupLoginPage.tsx @@ -0,0 +1,132 @@ +import { revalidateLogic } from '@tanstack/react-form'; +import { useNavigate } from '@tanstack/react-router'; +import type { AxiosError } from 'axios'; +import z from 'zod'; +import { m } from '../../paraglide/messages'; +import api from '../../shared/api/api'; +import { LoginPage } from '../../shared/components/LoginPage/LoginPage'; +import { Button } from '../../shared/defguard-ui/components/Button/Button'; +import { InfoBanner } from '../../shared/defguard-ui/components/InfoBanner/InfoBanner'; +import { SizedBox } from '../../shared/defguard-ui/components/SizedBox/SizedBox'; +import { ThemeSize, ThemeSpacing } from '../../shared/defguard-ui/types'; +import { createZodIssue } from '../../shared/defguard-ui/utils/zod'; +import { useAppForm } from '../../shared/form'; +import '../auth/LoginMain/style.scss'; +import { useMutation } from '@tanstack/react-query'; +import { useEffect, useRef, useState } from 'react'; + +const formSchema = z.object({ + username: z.string(m.form_error_required()).trim().min(1, m.form_error_required()), + password: z.string(m.form_error_required()).trim().min(1, m.form_error_required()), +}); + +type FormFields = z.infer; + +const defaults: FormFields = { + username: '', + password: '', +}; + +export const SetupLoginPage = () => { + const navigate = useNavigate(); + const [tooManyAttempts, setTooManyAttempts] = useState(false); + const attemptsTimeoutRef = useRef(null); + + const { mutateAsync } = useMutation({ + mutationFn: api.initial_setup.login, + }); + + const form = useAppForm({ + defaultValues: defaults, + validationLogic: revalidateLogic({ + mode: 'change', + modeAfterSubmission: 'change', + }), + validators: { + onChange: formSchema, + onSubmit: formSchema, + }, + onSubmit: async ({ value }) => { + if (tooManyAttempts) return; + try { + await mutateAsync(value); + navigate({ to: '/setup', replace: true }); + } catch (error) { + const status = (error as AxiosError).response?.status; + if (status === 401) { + form.setErrorMap({ + onSubmit: { + fields: { + password: createZodIssue(m.login_error_invalid(), ['password']), + }, + }, + }); + } + if (status === 429) { + setTooManyAttempts(true); + const timeoutId = setTimeout(() => { + setTooManyAttempts(false); + }, 300_000); + attemptsTimeoutRef.current = timeoutId; + } + } + }, + }); + + useEffect(() => { + return () => { + if (attemptsTimeoutRef.current !== null) { + clearTimeout(attemptsTimeoutRef.current); + } + }; + }, []); + + return ( + +

{m.login_main_title()}

+

{m.initial_setup_login_subtitle()}

+ + {tooManyAttempts && ( + <> + + + + )} + +
{ + e.preventDefault(); + e.stopPropagation(); + form.handleSubmit(); + }} + > + + {(field) => } + + + {(field) => ( + + )} + +