diff --git a/crates/defguard/src/main.rs b/crates/defguard/src/main.rs index bb032cbf80..fe667f87d0 100644 --- a/crates/defguard/src/main.rs +++ b/crates/defguard/src/main.rs @@ -244,7 +244,12 @@ async fn main() -> Result<(), anyhow::Error> { let proxy_secret_key = settings.secret_key_required()?; let proxy_manager = ProxyManager::new( pool.clone(), - ProxyTxSet::new(gateway_tx.clone(), bidi_event_tx.clone(), ldap_tx.clone()), + ProxyTxSet::new( + gateway_tx.clone(), + bidi_event_tx.clone(), + ldap_tx.clone(), + api_event_tx.clone(), + ), Arc::clone(&incompatible_components), proxy_control_rx, proxy_secret_key, diff --git a/crates/defguard_core/src/db/models/activity_log/metadata.rs b/crates/defguard_core/src/db/models/activity_log/metadata.rs index e14ef0176f..a3ecf3d5a7 100644 --- a/crates/defguard_core/src/db/models/activity_log/metadata.rs +++ b/crates/defguard_core/src/db/models/activity_log/metadata.rs @@ -113,6 +113,14 @@ pub struct UserMetadata { pub user: UserNoSecrets, } +#[derive(Serialize)] +pub struct UserImportBlockedMetadata { + pub username: String, + pub email: String, + pub user_count: u32, + pub limit: u32, +} + #[derive(Serialize)] pub struct UserModifiedMetadata { pub before: UserNoSecrets, diff --git a/crates/defguard_core/src/db/models/activity_log/mod.rs b/crates/defguard_core/src/db/models/activity_log/mod.rs index e698ddf090..0554e30139 100644 --- a/crates/defguard_core/src/db/models/activity_log/mod.rs +++ b/crates/defguard_core/src/db/models/activity_log/mod.rs @@ -45,6 +45,7 @@ pub enum EventType { MfaSecurityKeyRemoved, // User management UserAdded, + UserImportBlocked, UserRemoved, UserModified, UserGroupsModified, diff --git a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs index 47dbd37f08..a3c046d4cd 100644 --- a/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs +++ b/crates/defguard_core/src/enterprise/grpc/desktop_client_mfa.rs @@ -78,7 +78,7 @@ impl ClientMfaServer { return Err(Status::invalid_argument("invalid MFA method")); } - let (ip, _user_agent) = parse_client_ip_agent(&info).map_err(Status::internal)?; + let (ip, user_agent) = parse_client_ip_agent(&info).map_err(Status::internal)?; let context = BidiRequestContext::new( user.id, user.username.clone(), @@ -114,7 +114,19 @@ impl ClientMfaServer { } }; - match user_from_claims(&self.pool, Nonce::new(request.nonce.clone()), code, url).await { + // This path only re-verifies an already-existing user's identity via OpenID + // for MFA, so it never creates a new account, hence no `ApiEvent` channel. + match user_from_claims( + &self.pool, + Nonce::new(request.nonce.clone()), + code, + url, + Some(ip), + Some(&user_agent), + None, + ) + .await + { Ok(claims_user) => { // if thats not our user, prevent login if claims_user.id != user.id { diff --git a/crates/defguard_core/src/enterprise/handlers/openid_login.rs b/crates/defguard_core/src/enterprise/handlers/openid_login.rs index cf5f0a51fb..fdbb5fcc9d 100644 --- a/crates/defguard_core/src/enterprise/handlers/openid_login.rs +++ b/crates/defguard_core/src/enterprise/handlers/openid_login.rs @@ -1,3 +1,5 @@ +use std::net::IpAddr; + use axum::{Json, extract::State, http::StatusCode}; use axum_extra::{ TypedHeader, @@ -24,6 +26,7 @@ use reqwest::Url; use serde_json::json; use sqlx::PgPool; use time::Duration; +use tokio::sync::mpsc::UnboundedSender; const COOKIE_MAX_AGE: Duration = Duration::days(1); static CSRF_COOKIE_NAME: &str = "csrf"; @@ -43,6 +46,7 @@ use crate::{ limits::{get_counts, update_counts}, }, error::WebError, + events::{ApiEvent, ApiEventType, ApiRequestContext}, handlers::{ ApiResponse, AuthResponse, ClientIpAddr, SESSION_COOKIE_NAME, SIGN_IN_COOKIE_NAME, auth::create_session, @@ -200,11 +204,20 @@ pub async fn make_oidc_client( } /// Get or create `User` from OpenID claims. +/// +/// `ip_addr`/`user_agent`/`event_tx` are only used to emit an activity log event +/// when account creation is blocked by the license user limit. Pass `None` for +/// `event_tx` only if this call can never create a new account (e.g. the desktop +/// client MFA flow, which merely re-verifies an existing user's identity); +/// any caller that can create accounts should supply a real `ApiEvent` sender. pub async fn user_from_claims( pool: &PgPool, nonce: Nonce, code: AuthorizationCode, callback_url: Url, + ip_addr: Option, + user_agent: Option<&str>, + event_tx: Option<&UnboundedSender>, ) -> Result, WebError> { let Some(provider) = OpenIdProvider::get_current(pool).await? else { return Err(WebError::ObjectNotFound("OpenID provider not set".into())); @@ -410,6 +423,10 @@ pub async fn user_from_claims( } if let Some((user_count, limit)) = reached_user_license_limit() { + // Details (username/email/counts) are recorded in the activity + // log and admin notification email, but deliberately not + // returned to the client, which only learns that it should + // contact an administrator. error!( "Skipping OpenID account creation for user {username} (email: {}) because \ license user limit has been reached ({user_count}/{limit})", @@ -421,7 +438,30 @@ pub async fn user_from_claims( {err}" ); } - return Err(WebError::Forbidden("License limit reached.")); + if let Some(event_tx) = event_tx + && let Err(err) = event_tx.send(ApiEvent { + context: ApiRequestContext::new( + None::, + username.clone(), + ip_addr, + user_agent.unwrap_or_default().to_string(), + ), + event: Box::new(ApiEventType::UserImportBlocked { + username: username.clone(), + email: email.as_str().to_string(), + user_count, + limit, + }), + }) + { + error!( + "Failed to emit activity log event for blocked OpenID account \ + creation: {err}" + ); + } + return Err(WebError::LicenseLimitReached( + "Could not log in. Please contact your administrator.".to_string(), + )); } // Extract all necessary information from the token or call the userinfo endpoint. @@ -634,6 +674,9 @@ pub async fn auth_callback( Nonce::new(cookie_nonce), payload.code, settings.callback_url()?, + Some(ip_addr), + Some(user_agent.as_str()), + Some(&appstate.event_tx), ) .await?; diff --git a/crates/defguard_core/src/error.rs b/crates/defguard_core/src/error.rs index 169ce4b22c..9cd851b3cc 100644 --- a/crates/defguard_core/src/error.rs +++ b/crates/defguard_core/src/error.rs @@ -46,6 +46,8 @@ pub enum WebError { Authorization(String), #[error("User groups not synced: {0}")] UserGroupsNotSynced(String), + #[error("License limit reached: {0}")] + LicenseLimitReached(String), #[error("Authentication error")] Authentication, #[error("Forbidden error: {0}")] diff --git a/crates/defguard_core/src/events.rs b/crates/defguard_core/src/events.rs index 7c788edbc8..0fce5fc6b2 100644 --- a/crates/defguard_core/src/events.rs +++ b/crates/defguard_core/src/events.rs @@ -31,7 +31,9 @@ use crate::{ #[derive(Debug, Clone, PartialEq)] pub struct ApiRequestContext { pub timestamp: NaiveDateTime, - pub user_id: Id, + /// `None` for events about a user that doesn't have an account yet, e.g. an + /// OpenID login blocked before the corresponding account could be created. + pub user_id: Option, pub username: String, pub ip: Option, pub device: String, @@ -40,7 +42,7 @@ pub struct ApiRequestContext { impl ApiRequestContext { #[must_use] pub fn new( - user_id: Id, + user_id: impl Into>, username: String, ip: impl Into>, device: String, @@ -48,7 +50,7 @@ impl ApiRequestContext { let timestamp = Utc::now().naive_utc(); Self { timestamp, - user_id, + user_id: user_id.into(), username, ip: ip.into(), device, @@ -133,6 +135,14 @@ pub enum ApiEventType { UserAdded { user: User, }, + /// Account auto-provisioning (e.g. via OpenID or LDAP) was blocked because it + /// would have exceeded the license user limit. + UserImportBlocked { + username: String, + email: String, + user_count: u32, + limit: u32, + }, UserRemoved { user: User, }, diff --git a/crates/defguard_core/src/handlers/mod.rs b/crates/defguard_core/src/handlers/mod.rs index bef91a7414..f030ed90a0 100644 --- a/crates/defguard_core/src/handlers/mod.rs +++ b/crates/defguard_core/src/handlers/mod.rs @@ -67,6 +67,7 @@ pub(crate) mod yubikey; pub enum WebErrorCode { NetworkFull, UserGroupsNotSynced, + LicenseLimitReached, CertMissingCertPem, CertMissingKeyPem, CertInvalidCertOrKey, @@ -303,6 +304,13 @@ impl From for ApiResponse { StatusCode::UNAUTHORIZED, ) } + WebError::LicenseLimitReached(msg) => { + warn!(msg); + Self::new( + json!({"msg": msg, "code": WebErrorCode::LicenseLimitReached}), + StatusCode::FORBIDDEN, + ) + } WebError::CertMissingCertPem => Self::new( json!({"msg": web_error.to_string(), "code": WebErrorCode::CertMissingCertPem}), StatusCode::BAD_REQUEST, diff --git a/crates/defguard_core/tests/integration/api/common/client.rs b/crates/defguard_core/tests/integration/api/common/client.rs index 3820d81728..0ee201b6d6 100644 --- a/crates/defguard_core/tests/integration/api/common/client.rs +++ b/crates/defguard_core/tests/integration/api/common/client.rs @@ -193,7 +193,8 @@ impl TestClient { "Event type mismatch at index {index}: expected {expected_event:?}, got {event:?}", ); assert_eq!( - expected_user_id, user_id, + Some(*expected_user_id), + *user_id, "User ID mismatch at index {index}: expected {expected_user_id:?}, got {user_id:?}", ); assert_eq!( @@ -206,7 +207,7 @@ impl TestClient { /// Receive all messages currently present in API event queue /// /// Can also be used to clear the queue. - pub fn drain_all_events(&mut self) -> Vec<(ApiEventType, Id, String)> { + pub fn drain_all_events(&mut self) -> Vec<(ApiEventType, Option, String)> { let mut all_events = Vec::new(); loop { diff --git a/crates/defguard_event_logger/src/description.rs b/crates/defguard_event_logger/src/description.rs index e46577f07f..b58e27608c 100644 --- a/crates/defguard_event_logger/src/description.rs +++ b/crates/defguard_event_logger/src/description.rs @@ -56,6 +56,15 @@ pub fn get_api_event_description(event: &ApiEventType) -> Option { user.email )) } + ApiEventType::UserImportBlocked { + username, + email, + user_count, + limit, + } => Some(format!( + "Blocked automatic creation of account for user {username} (email: {email}) \ + because the license user limit has been reached ({user_count}/{limit})" + )), ApiEventType::UserRemoved { user } => Some(format!("Removed user {user}")), ApiEventType::UserModified { before, after } => { let mut description = format!("Modified user {after}"); diff --git a/crates/defguard_event_logger/src/lib.rs b/crates/defguard_event_logger/src/lib.rs index 60874f9065..ccea78c3a0 100644 --- a/crates/defguard_event_logger/src/lib.rs +++ b/crates/defguard_event_logger/src/lib.rs @@ -18,10 +18,11 @@ use defguard_core::{ OpenIdAppModifiedMetadata, OpenIdAppStateChangedMetadata, OpenIdProviderMetadata, PasswordChangedByAdminMetadata, PasswordResetMetadata, ProxyDeletedMetadata, ProxyModifiedMetadata, SettingsUpdateMetadata, UserGroupsModifiedMetadata, - UserMetadata, UserMfaDisabledMetadata, UserModifiedMetadata, UserSnatBindingMetadata, - UserSnatBindingModifiedMetadata, VpnClientMetadata, VpnClientMfaFailedMetadata, - VpnClientMfaMetadata, VpnLocationMetadata, VpnLocationModifiedMetadata, - WebHookMetadata, WebHookModifiedMetadata, WebHookStateChangedMetadata, + UserImportBlockedMetadata, UserMetadata, UserMfaDisabledMetadata, UserModifiedMetadata, + UserSnatBindingMetadata, UserSnatBindingModifiedMetadata, VpnClientMetadata, + VpnClientMfaFailedMetadata, VpnClientMfaMetadata, VpnLocationMetadata, + VpnLocationModifiedMetadata, WebHookMetadata, WebHookModifiedMetadata, + WebHookStateChangedMetadata, }, }, events::{ @@ -332,6 +333,21 @@ fn map_to_activity_log_event(message: EventLoggerMessage) -> ActivityLogEvent ( + EventType::UserImportBlocked, + serde_json::to_value(UserImportBlockedMetadata { + username, + email, + user_count, + limit, + }) + .ok(), + ), ApiEventType::UserRemoved { user } => ( EventType::UserRemoved, serde_json::to_value(UserMetadata { user: user.into() }).ok(), diff --git a/crates/defguard_event_logger/src/message.rs b/crates/defguard_event_logger/src/message.rs index b8e7476364..c49b9bcb07 100644 --- a/crates/defguard_event_logger/src/message.rs +++ b/crates/defguard_event_logger/src/message.rs @@ -155,7 +155,7 @@ impl EventContext { Self { timestamp: val.timestamp, - user_id: Some(val.user_id), + user_id: val.user_id, username: val.username, location, ip: val.ip, diff --git a/crates/defguard_event_logger/src/tests/mod.rs b/crates/defguard_event_logger/src/tests/mod.rs index 8c4e76d9a8..81e903d49b 100644 --- a/crates/defguard_event_logger/src/tests/mod.rs +++ b/crates/defguard_event_logger/src/tests/mod.rs @@ -562,6 +562,18 @@ fn api_event_cases() -> Vec { module: ActivityLogModule::Defguard, description_contains: Some("Added"), }, + EventTestCase { + name: "UserImportBlocked", + message: api_message(ApiEventType::UserImportBlocked { + username: "testuser".to_string(), + email: "testuser@example.com".to_string(), + user_count: 10, + limit: 10, + }), + event_type: EventType::UserImportBlocked, + module: ActivityLogModule::Defguard, + description_contains: Some("Blocked"), + }, EventTestCase { name: "UserRemoved", message: api_message(ApiEventType::UserRemoved { user: user.clone() }), diff --git a/crates/defguard_proxy_manager/src/handler.rs b/crates/defguard_proxy_manager/src/handler.rs index e47666e888..ed5888f161 100644 --- a/crates/defguard_proxy_manager/src/handler.rs +++ b/crates/defguard_proxy_manager/src/handler.rs @@ -31,7 +31,7 @@ use defguard_core::{ ldap::utils::ldap_update_user_state, }, error::WebError, - events::LdapSyncEventType, + events::{ApiEvent, LdapSyncEventType}, grpc::{ GatewayCommand, proxy::client_mfa::{ @@ -879,6 +879,9 @@ impl ProxyHandler { Nonce::new(request.nonce), code, callback_url, + None, + None, + Some(&self.services.event_tx), ) .await { @@ -952,6 +955,9 @@ impl ProxyHandler { WebError::BadRequest(message) => { (Code::InvalidArgument as i32, message) } + WebError::LicenseLimitReached(message) => { + (Code::ResourceExhausted as i32, message) + } _ => ( Code::Internal as i32, "OpenID authentication failed".to_owned(), @@ -1231,6 +1237,7 @@ struct ProxyServices { client_mfa: ClientMfaServer, polling: PollingServer, ldap: UnboundedSender, + event_tx: UnboundedSender, } impl ProxyServices { @@ -1263,6 +1270,7 @@ impl ProxyServices { client_mfa, polling, ldap: tx.ldap.clone(), + event_tx: tx.event_tx.clone(), } } } diff --git a/crates/defguard_proxy_manager/src/lib.rs b/crates/defguard_proxy_manager/src/lib.rs index 4bf4e8e4db..e032b0f950 100644 --- a/crates/defguard_proxy_manager/src/lib.rs +++ b/crates/defguard_proxy_manager/src/lib.rs @@ -13,7 +13,7 @@ use defguard_common::{ types::proxy::ProxyControlMessage, }; use defguard_core::{ - events::{BidiStreamEvent, LdapSyncEventType}, + events::{ApiEvent, BidiStreamEvent, LdapSyncEventType}, grpc::proxy::client_mfa::ClientLoginSession, version::IncompatibleComponents, }; @@ -426,6 +426,7 @@ pub struct ProxyTxSet { wireguard: Sender, bidi_events: UnboundedSender, pub(crate) ldap: UnboundedSender, + pub(crate) event_tx: UnboundedSender, } impl ProxyTxSet { @@ -434,11 +435,13 @@ impl ProxyTxSet { wireguard: Sender, bidi_events: UnboundedSender, ldap: UnboundedSender, + event_tx: UnboundedSender, ) -> Self { Self { wireguard, bidi_events, ldap, + event_tx, } } } diff --git a/crates/defguard_proxy_manager/src/tests/common/mod.rs b/crates/defguard_proxy_manager/src/tests/common/mod.rs index 877cf71f1f..75907c03ff 100644 --- a/crates/defguard_proxy_manager/src/tests/common/mod.rs +++ b/crates/defguard_proxy_manager/src/tests/common/mod.rs @@ -27,7 +27,7 @@ use defguard_common::{ }, gateway_event::GatewayCommand, }; -use defguard_core::events::BidiStreamEvent; +use defguard_core::events::{ApiEvent, BidiStreamEvent}; use defguard_proto::proxy::{ AcmeChallenge, AcmeIssueEvent, CoreRequest, CoreResponse, InitialInfo, core_response, proxy_server, @@ -400,6 +400,7 @@ pub(crate) struct HandlerTestContext { pub(crate) proxy: Proxy, pub(crate) gateway_tx: broadcast::Sender, pub(crate) bidi_events_rx: UnboundedReceiver, + pub(crate) event_rx: UnboundedReceiver, pub(crate) mock_proxy: Option, handler_task: Option>>, /// Keep-alive handle: holds the sender so the handler's shutdown receiver @@ -426,7 +427,8 @@ impl HandlerTestContext { let (gateway_tx, _) = broadcast::channel(16); let (bidi_events_tx, bidi_events_rx) = mpsc::unbounded_channel::(); let (ldap_tx, _ldap_rx) = mpsc::unbounded_channel(); - let tx_set = ProxyTxSet::new(gateway_tx.clone(), bidi_events_tx, ldap_tx); + let (event_tx, event_rx) = mpsc::unbounded_channel(); + let tx_set = ProxyTxSet::new(gateway_tx.clone(), bidi_events_tx, ldap_tx, event_tx); let (_, certs_rx) = watch::channel(Arc::new(HashMap::new())); let incompatible_components = Arc::new(std::sync::RwLock::new( @@ -470,6 +472,7 @@ impl HandlerTestContext { proxy, gateway_tx, bidi_events_rx, + event_rx, mock_proxy: Some(mock_proxy), handler_task: Some(handler_task), _shutdown_tx: Some(shutdown_tx), @@ -607,7 +610,8 @@ impl ManagerTestContext { let (gateway_tx, _) = broadcast::channel(16); let (bidi_events_tx, _bidi_events_rx) = mpsc::unbounded_channel::(); let (ldap_tx, _ldap_rx) = mpsc::unbounded_channel(); - let tx_set = ProxyTxSet::new(gateway_tx, bidi_events_tx, ldap_tx); + let (event_tx, _event_rx) = mpsc::unbounded_channel(); + let tx_set = ProxyTxSet::new(gateway_tx, bidi_events_tx, ldap_tx, event_tx); let incompatible_components = Arc::new(std::sync::RwLock::new( defguard_core::version::IncompatibleComponents::default(), diff --git a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/oidc.rs b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/oidc.rs index 7333fdd5ba..536732d9dc 100644 --- a/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/oidc.rs +++ b/crates/defguard_proxy_manager/src/tests/proxy_manager/handler/oidc.rs @@ -1,7 +1,14 @@ #![allow(deprecated)] use defguard_common::db::models::settings::{Settings, update_current_settings}; use defguard_core::{ - db::models::enrollment::Token, enterprise::handlers::openid_login::build_state, + db::models::enrollment::Token, + enterprise::{ + handlers::openid_login::build_state, + license::{License, LicenseTier, SupportType, set_cached_license}, + limits::update_counts, + }, + events::{ApiEvent, ApiEventType}, + grpc::proto::enterprise::license::LicenseLimits, }; use defguard_proto::{ client_types::{AuthFlowType, AuthInfoRequest, MfaMethod}, @@ -11,6 +18,7 @@ use defguard_proto::{ }, }; use sqlx::postgres::{PgConnectOptions, PgPoolOptions}; +use tokio::time::timeout; use super::support::{ assert_error_response, assert_vpn_session_exists, clear_test_license, complete_proxy_handshake, @@ -18,7 +26,7 @@ use super::support::{ expect_bidi_mfa_success, make_device_info, make_oidc_code, send_mfa_finish, send_mfa_start, set_public_proxy_url, set_test_license_business, }; -use crate::tests::common::{HandlerTestContext, MockOidcProvider}; +use crate::tests::common::{HandlerTestContext, MockOidcProvider, RECEIVE_TIMEOUT}; #[sqlx::test] async fn test_auth_callback_creates_new_user_on_first_login( @@ -519,3 +527,79 @@ async fn test_auth_callback_exchanges_code_for_enrollment_token( clear_test_license(); context.finish().await.expect_server_finished().await; } + +#[sqlx::test] +async fn test_auth_callback_blocked_by_license_limit_emits_user_import_blocked_event( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let mut context = HandlerTestContext::new(options).await; + complete_proxy_handshake(&mut context).await; + + // Reach the user limit: one existing user, license capped at one user. + create_user(&context.pool).await; + update_counts(&context.pool) + .await + .expect("failed to refresh license usage counts"); + set_cached_license(Some(License { + customer_id: "test-customer-id".into(), + subscription: false, + valid_until: None, + limits: Some(LicenseLimits { + users: 1, + devices: 100, + locations: 100, + network_devices: Some(100), + }), + version_date_limit: None, + tier: LicenseTier::Business, + support_type: SupportType::Basic, + features: vec![], + })); + + let mock = MockOidcProvider::start().await; + let _provider = create_oidc_provider(&context.pool, &mock).await; + set_public_proxy_url(&context.pool, &mock.base_url).await; + + let raw_nonce = "test-nonce-license-limit"; + let email = "blocked-oidc-user@example.com"; + let code = make_oidc_code("blocked-oidc-user-sub", email, raw_nonce); + + context.mock_proxy().send_request(CoreRequest { + id: 20, + device_info: None, + payload: Some(core_request::Payload::AuthCallback(AuthCallbackRequest { + code, + nonce: raw_nonce.to_owned(), + })), + }); + + let response = context.mock_proxy_mut().recv_outbound().await; + let code = assert_error_response(&response); + assert_eq!( + code, + tonic::Code::ResourceExhausted, + "expected ResourceExhausted status when license user limit is reached" + ); + + let ApiEvent { event, .. } = timeout(RECEIVE_TIMEOUT, context.event_rx.recv()) + .await + .expect("timed out waiting for UserImportBlocked activity log event") + .expect("event channel closed"); + match *event { + ApiEventType::UserImportBlocked { + email: blocked_email, + user_count, + limit, + .. + } => { + assert_eq!(blocked_email, email); + assert_eq!(user_count, 1); + assert_eq!(limit, 1); + } + other => panic!("expected UserImportBlocked event, got: {other:?}"), + } + + clear_test_license(); + context.finish().await.expect_server_finished().await; +} diff --git a/web/messages/en/activity.json b/web/messages/en/activity.json index 2f915f7ba0..d9d6a026d6 100644 --- a/web/messages/en/activity.json +++ b/web/messages/en/activity.json @@ -7,6 +7,7 @@ "activity_event_user_mfa_login_failed": "User MFA login failed", "activity_event_user_logout": "User logout", "activity_event_user_added": "User added", + "activity_event_user_import_blocked": "User import blocked", "activity_event_user_modified": "User modified", "activity_event_user_removed": "User removed", "activity_event_user_groups_modified": "User groups modified", diff --git a/web/messages/en/api-error.json b/web/messages/en/api-error.json index b9f6ca9fb1..0869f47981 100644 --- a/web/messages/en/api-error.json +++ b/web/messages/en/api-error.json @@ -2,6 +2,7 @@ "$schema": "https://inlang.com/schema/inlang-message-format", "api_error_network_full": "There are no free IP adresses in the network - please change the network settings", "api_error_user_groups_not_synced": "You could not be logged in because your group is not synchronized, please contact your administrator", + "api_error_license_limit_reached": "You could not be logged in, please contact your administrator", "api_error_cert_missing_cert_pem": "Please provide a certificate file", "api_error_cert_missing_key_pem": "Please provide a private key file", "api_error_cert_invalid_cert_or_key": "The certificate or private key is invalid, or they don't match", diff --git a/web/src/routes/auth/callback.tsx b/web/src/routes/auth/callback.tsx index 7af423bd30..e0defaf434 100644 --- a/web/src/routes/auth/callback.tsx +++ b/web/src/routes/auth/callback.tsx @@ -32,7 +32,10 @@ export const Route = createFileRoute('/auth/callback')({ }, 1000); } catch (e) { const code = (e as AxiosError).response?.data?.code; - if (code === WebErrorCode.UserGroupsNotSynced) { + if ( + code === WebErrorCode.UserGroupsNotSynced || + code === WebErrorCode.LicenseLimitReached + ) { setTimeout(() => { Snackbar.error(getApiErrorMessage(code)); }, 1000); diff --git a/web/src/shared/api/activity-log-types.ts b/web/src/shared/api/activity-log-types.ts index ff1112b4ba..3fdba8881a 100644 --- a/web/src/shared/api/activity-log-types.ts +++ b/web/src/shared/api/activity-log-types.ts @@ -22,6 +22,7 @@ export const ActivityLogEventType = { UserMfaLoginFailed: 'user_mfa_login_failed', UserLogout: 'user_logout', UserAdded: 'user_added', + UserImportBlocked: 'user_import_blocked', UserModified: 'user_modified', UserRemoved: 'user_removed', UserGroupsModified: 'user_groups_modified', diff --git a/web/src/shared/api/types.ts b/web/src/shared/api/types.ts index 274e847553..02f4d31650 100644 --- a/web/src/shared/api/types.ts +++ b/web/src/shared/api/types.ts @@ -403,6 +403,7 @@ export interface MfaFinishResponse { export const WebErrorCode = { NetworkFull: 'network_full', UserGroupsNotSynced: 'user_groups_not_synced', + LicenseLimitReached: 'license_limit_reached', CertMissingCertPem: 'cert_missing_cert_pem', CertMissingKeyPem: 'cert_missing_key_pem', CertInvalidCertOrKey: 'cert_invalid_cert_or_key',