diff --git a/.sqlx/query-3ea2a5fbb1ec0dc86448b6145cf8b5f8fa8c6ab81da002d0d1ddb5445e7c6d31.json b/.sqlx/query-1a828cfcdc03c0aef96b6b18e43c47c7ffbee0e6a825568b4a55f69e225bb7b0.json similarity index 60% rename from .sqlx/query-3ea2a5fbb1ec0dc86448b6145cf8b5f8fa8c6ab81da002d0d1ddb5445e7c6d31.json rename to .sqlx/query-1a828cfcdc03c0aef96b6b18e43c47c7ffbee0e6a825568b4a55f69e225bb7b0.json index cb1765ce7d..eddf80ae9f 100644 --- a/.sqlx/query-3ea2a5fbb1ec0dc86448b6145cf8b5f8fa8c6ab81da002d0d1ddb5445e7c6d31.json +++ b/.sqlx/query-1a828cfcdc03c0aef96b6b18e43c47c7ffbee0e6a825568b4a55f69e225bb7b0.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "SELECT n.id network_id, n.name network_name, n.endpoint gateway_endpoint, wnd.wireguard_ips \"device_wireguard_ips: Vec\", vs.endpoint \"device_endpoint?\", vs.latest_handshake \"latest_handshake?\", vs.state \"state?: VpnClientSessionState\" FROM wireguard_network_device wnd JOIN wireguard_network n ON n.id = wnd.wireguard_network_id LEFT JOIN LATERAL ( SELECT id, state, location_id, endpoint, latest_handshake FROM vpn_client_session LEFT JOIN LATERAL ( SELECT session_id, endpoint, latest_handshake FROM vpn_session_stats WHERE session_id = vpn_client_session.id ORDER BY collected_at DESC LIMIT 1 ) vss ON vss.session_id = vpn_client_session.id WHERE location_id = n.id and device_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1 ) vs ON vs.location_id = n.id WHERE wnd.device_id = $1", + "query": "SELECT n.id network_id, n.name network_name, n.endpoint gateway_endpoint, wnd.wireguard_ips \"device_wireguard_ips: Vec\", latest_session.endpoint \"device_endpoint?\", last_successful_session.connected_at \"last_connected_at?\", latest_session.state \"state?: VpnClientSessionState\" FROM wireguard_network_device wnd JOIN wireguard_network n ON n.id = wnd.wireguard_network_id LEFT JOIN LATERAL ( SELECT id, state, location_id, endpoint, connected_at FROM vpn_client_session WHERE location_id = n.id and device_id = $1 ORDER BY created_at DESC, id DESC LIMIT 1 ) latest_session ON latest_session.location_id = n.id LEFT JOIN LATERAL ( SELECT connected_at FROM vpn_client_session WHERE location_id = n.id AND device_id = $1 AND connected_at IS NOT NULL ORDER BY connected_at DESC, id DESC LIMIT 1 ) last_successful_session ON true WHERE wnd.device_id = $1", "describe": { "columns": [ { @@ -30,7 +30,7 @@ }, { "ordinal": 5, - "name": "latest_handshake?", + "name": "last_connected_at?", "type_info": "Timestamp" }, { @@ -61,9 +61,9 @@ false, false, false, - false, + true, false ] }, - "hash": "3ea2a5fbb1ec0dc86448b6145cf8b5f8fa8c6ab81da002d0d1ddb5445e7c6d31" + "hash": "1a828cfcdc03c0aef96b6b18e43c47c7ffbee0e6a825568b4a55f69e225bb7b0" } diff --git a/crates/defguard_common/src/db/models/device.rs b/crates/defguard_common/src/db/models/device.rs index 218e5f7596..9baf0727ac 100644 --- a/crates/defguard_common/src/db/models/device.rs +++ b/crates/defguard_common/src/db/models/device.rs @@ -239,25 +239,25 @@ impl UserDevice { // fetch device config and connection info for all allowed networks let result = query!( "SELECT n.id network_id, n.name network_name, n.endpoint gateway_endpoint, \ - wnd.wireguard_ips \"device_wireguard_ips: Vec\", vs.endpoint \"device_endpoint?\", \ - vs.latest_handshake \"latest_handshake?\", \ - vs.state \"state?: VpnClientSessionState\" \ + wnd.wireguard_ips \"device_wireguard_ips: Vec\", latest_session.endpoint \"device_endpoint?\", \ + last_successful_session.connected_at \"last_connected_at?\", \ + latest_session.state \"state?: VpnClientSessionState\" \ FROM wireguard_network_device wnd \ JOIN wireguard_network n ON n.id = wnd.wireguard_network_id \ LEFT JOIN LATERAL ( \ - SELECT id, state, location_id, endpoint, latest_handshake \ + SELECT id, state, location_id, endpoint, connected_at \ FROM vpn_client_session \ - LEFT JOIN LATERAL ( \ - SELECT session_id, endpoint, latest_handshake \ - FROM vpn_session_stats \ - WHERE session_id = vpn_client_session.id \ - ORDER BY collected_at DESC \ - LIMIT 1 \ - ) vss ON vss.session_id = vpn_client_session.id \ WHERE location_id = n.id and device_id = $1 \ ORDER BY created_at DESC, id DESC \ LIMIT 1 \ - ) vs ON vs.location_id = n.id \ + ) latest_session ON latest_session.location_id = n.id \ + LEFT JOIN LATERAL ( \ + SELECT connected_at \ + FROM vpn_client_session \ + WHERE location_id = n.id AND device_id = $1 AND connected_at IS NOT NULL \ + ORDER BY connected_at DESC, id DESC \ + LIMIT 1 \ + ) last_successful_session ON true \ WHERE wnd.device_id = $1", device.id, ) @@ -293,7 +293,7 @@ impl UserDevice { .map(IpAddr::to_string) .collect(), last_connected_ip: device_ip, - last_connected_at: r.latest_handshake, + last_connected_at: r.last_connected_at, is_active, } }) @@ -1793,6 +1793,190 @@ mod test { assert_eq!(network_info.preshared_key, None); } + #[sqlx::test] + async fn test_user_device_from_device_keeps_latest_successful_connection_timestamp( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let user = User::new( + "testuser", + Some("password"), + "Tester", + "Test", + "test@test.com", + None, + ) + .save(&pool) + .await + .unwrap(); + + let device = Device::new( + "device".into(), + "pubkey".into(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + + let network = WireguardNetwork::default() + .try_set_address("10.1.1.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + WireguardNetworkDevice::new( + network.id, + device.id, + [IpAddr::from_str("10.1.1.2").unwrap()], + ) + .insert(&pool) + .await + .unwrap(); + + let last_successful_connection = NaiveDate::from_ymd_opt(2026, 1, 2) + .expect("expected valid date") + .and_hms_opt(3, 4, 5) + .expect("expected valid time"); + let newer_session_created_at = NaiveDate::from_ymd_opt(2026, 1, 3) + .expect("expected valid date") + .and_hms_opt(4, 5, 6) + .expect("expected valid time"); + + let mut connected_session = VpnClientSession::new( + network.id, + user.id, + device.id, + Some(last_successful_connection), + None, + ); + connected_session.created_at = last_successful_connection; + connected_session.save(&pool).await.unwrap(); + + let mut disconnected_session = + VpnClientSession::new(network.id, user.id, device.id, None, None); + disconnected_session.created_at = newer_session_created_at; + disconnected_session.disconnected_at = Some(newer_session_created_at); + disconnected_session.state = VpnClientSessionState::Disconnected; + disconnected_session.save(&pool).await.unwrap(); + + let user_device = UserDevice::from_device(&pool, device) + .await + .unwrap() + .unwrap(); + let network_info = user_device + .networks + .into_iter() + .find(|network_info| network_info.network_id == network.id) + .expect("expected created network in user device response"); + + assert!(!network_info.is_active); + assert_eq!( + network_info.last_connected_at, + Some(last_successful_connection) + ); + } + + #[sqlx::test] + async fn test_user_device_from_device_keeps_latest_successful_connection_timestamp_for_newer_new_session( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let user = User::new( + "testuser", + Some("password"), + "Tester", + "Test", + "test@test.com", + None, + ) + .save(&pool) + .await + .unwrap(); + + let device = Device::new( + "device".into(), + "pubkey".into(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + + let network = WireguardNetwork::default() + .try_set_address("10.1.1.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + WireguardNetworkDevice::new( + network.id, + device.id, + [IpAddr::from_str("10.1.1.2").unwrap()], + ) + .insert(&pool) + .await + .unwrap(); + + let last_successful_connection = NaiveDate::from_ymd_opt(2026, 1, 2) + .expect("expected valid date") + .and_hms_opt(3, 4, 5) + .expect("expected valid time"); + let newer_session_created_at = NaiveDate::from_ymd_opt(2026, 1, 3) + .expect("expected valid date") + .and_hms_opt(4, 5, 6) + .expect("expected valid time"); + + let disconnected_at = NaiveDate::from_ymd_opt(2026, 1, 2) + .expect("expected valid date") + .and_hms_opt(3, 5, 6) + .expect("expected valid time"); + + let mut connected_session = VpnClientSession::new( + network.id, + user.id, + device.id, + Some(last_successful_connection), + None, + ); + connected_session.created_at = last_successful_connection; + connected_session.disconnected_at = Some(disconnected_at); + connected_session.state = VpnClientSessionState::Disconnected; + connected_session.save(&pool).await.unwrap(); + + let mut new_session = VpnClientSession::new(network.id, user.id, device.id, None, None); + new_session.created_at = newer_session_created_at; + new_session.save(&pool).await.unwrap(); + + let user_device = UserDevice::from_device(&pool, device) + .await + .unwrap() + .unwrap(); + let network_info = user_device + .networks + .into_iter() + .find(|network_info| network_info.network_id == network.id) + .expect("expected created network in user device response"); + + assert!(!network_info.is_active); + assert_eq!( + network_info.last_connected_at, + Some(last_successful_connection) + ); + } + #[sqlx::test] fn test_all_for_network_and_user(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/crates/defguard_core/tests/integration/api/user.rs b/crates/defguard_core/tests/integration/api/user.rs index db08ab75c2..929ee2720a 100644 --- a/crates/defguard_core/tests/integration/api/user.rs +++ b/crates/defguard_core/tests/integration/api/user.rs @@ -1,7 +1,15 @@ +use std::net::{IpAddr, Ipv4Addr}; + +use chrono::NaiveDate; use defguard_common::{ db::{ Id, - models::{MFAMethod, WebAuthn, device::AddDevice, oauth2client::OAuth2Client}, + models::{ + Device, DeviceType, MFAMethod, User, WebAuthn, WireguardNetwork, + device::{AddDevice, WireguardNetworkDevice}, + oauth2client::OAuth2Client, + vpn_client_session::{VpnClientSession, VpnClientSessionState}, + }, }, types::user_info::UserInfo, }; @@ -267,6 +275,185 @@ async fn test_get_user(_: PgPoolOptions, options: PgConnectOptions) { client.assert_event_queue_is_empty(); } +#[sqlx::test] +async fn test_get_user_exposes_active_network_state(_: PgPoolOptions, options: PgConnectOptions) { + let pool = setup_pool(options).await; + + let (mut client, pool) = make_client_with_db(pool).await; + client.login_user("admin", "pass123").await; + + let username = "active-user"; + let device_name = "active-device"; + let device_wireguard_ip = IpAddr::V4(Ipv4Addr::new(10, 1, 1, 2)); + + let user = User::new( + username, + Some("pass123"), + "Active", + "User", + "active.user@example.com", + None, + ) + .save(&pool) + .await + .unwrap(); + + let network_response = make_network(&client, "active-network").await; + let network: WireguardNetwork = network_response.json().await; + + let device = Device::new( + device_name.into(), + "key".into(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + + WireguardNetworkDevice::new(network.id, device.id, [device_wireguard_ip]) + .insert(&pool) + .await + .unwrap(); + + let session_connected_at = NaiveDate::from_ymd_opt(2026, 1, 2) + .expect("expected valid connected_at date") + .and_hms_opt(3, 4, 5) + .expect("expected valid connected_at time"); + + VpnClientSession::new( + network.id, + user.id, + device.id, + Some(session_connected_at), + None, + ) + .save(&pool) + .await + .unwrap(); + + let user_details = fetch_user_details(&client, username).await; + + assert_eq!(user_details.user.username, username); + assert_eq!(user_details.devices.len(), 1); + + let user_device = user_details + .devices + .iter() + .find(|user_device| user_device.device.id == device.id) + .expect("expected created device in user details response"); + assert_eq!(user_device.device.name, device_name); + assert_eq!(user_device.networks.len(), 1); + + let network_info = user_device + .networks + .iter() + .find(|network_info| network_info.network_id == network.id) + .expect("expected created network in user details response"); + assert_eq!(network_info.network_name, "active-network"); + assert_eq!(network_info.network_gateway_ip, "192.168.4.14"); + assert_eq!( + network_info.device_wireguard_ips, + vec![device_wireguard_ip.to_string()] + ); + assert!(network_info.is_active); + assert_eq!(network_info.last_connected_at, Some(session_connected_at)); +} + +#[sqlx::test] +async fn test_get_user_keeps_last_successful_connection_for_newer_disconnected_session( + _: PgPoolOptions, + options: PgConnectOptions, +) { + let pool = setup_pool(options).await; + + let (mut client, pool) = make_client_with_db(pool).await; + client.login_user("admin", "pass123").await; + + let username = "inactive-user"; + let device_name = "inactive-device"; + let device_wireguard_ip = IpAddr::V4(Ipv4Addr::new(10, 1, 1, 2)); + + let user = User::new( + username, + Some("pass123"), + "Inactive", + "User", + "inactive.user@example.com", + None, + ) + .save(&pool) + .await + .unwrap(); + + let network_response = make_network(&client, "inactive-network").await; + let network: WireguardNetwork = network_response.json().await; + + let device = Device::new( + device_name.into(), + "key".into(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + + WireguardNetworkDevice::new(network.id, device.id, [device_wireguard_ip]) + .insert(&pool) + .await + .unwrap(); + + let last_successful_connection = NaiveDate::from_ymd_opt(2026, 1, 2) + .expect("expected valid connected_at date") + .and_hms_opt(3, 4, 5) + .expect("expected valid connected_at time"); + let disconnected_at = NaiveDate::from_ymd_opt(2026, 1, 3) + .expect("expected valid disconnected date") + .and_hms_opt(4, 5, 6) + .expect("expected valid disconnected time"); + + let mut connected_session = VpnClientSession::new( + network.id, + user.id, + device.id, + Some(last_successful_connection), + None, + ); + connected_session.created_at = last_successful_connection; + connected_session.save(&pool).await.unwrap(); + + let mut disconnected_session = + VpnClientSession::new(network.id, user.id, device.id, None, None); + disconnected_session.created_at = disconnected_at; + disconnected_session.disconnected_at = Some(disconnected_at); + disconnected_session.state = VpnClientSessionState::Disconnected; + disconnected_session.save(&pool).await.unwrap(); + + let user_details = fetch_user_details(&client, username).await; + + let user_device = user_details + .devices + .iter() + .find(|user_device| user_device.device.id == device.id) + .expect("expected created device in user details response"); + let network_info = user_device + .networks + .iter() + .find(|network_info| network_info.network_id == network.id) + .expect("expected created network in user details response"); + + assert!(!network_info.is_active); + assert_eq!( + network_info.last_connected_at, + Some(last_successful_connection) + ); +} + #[sqlx::test] async fn test_username_available(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; diff --git a/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx b/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx index 99f1055210..5d68eda064 100644 --- a/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx +++ b/web/src/pages/LocationOverviewPage/LocationOverviewUsersTable.tsx @@ -109,7 +109,7 @@ const ExpandedUserDevicesRow = ({ - + {device.device_name} diff --git a/web/src/pages/UsersOverviewPage/UsersTable.tsx b/web/src/pages/UsersOverviewPage/UsersTable.tsx index 80ec2b6d3b..af43427af2 100644 --- a/web/src/pages/UsersOverviewPage/UsersTable.tsx +++ b/web/src/pages/UsersOverviewPage/UsersTable.tsx @@ -47,6 +47,7 @@ import { getUsersOverviewQueryOptions, } from '../../shared/query'; import { displayDate } from '../../shared/utils/displayDate'; +import { isDeviceOnline, isUserOnline } from '../../shared/utils/userOnlineStatus'; import { useAddUserModal } from './modals/AddUserModal/useAddUserModal'; type RowData = UsersListItem; @@ -144,6 +145,8 @@ export const UsersTable = () => { }, cell: (info) => { const rowData = info.row.original; + const online = isUserOnline(rowData); + return ( { variant="initials" firstName={rowData.first_name} lastName={rowData.last_name} + online={online} /> {info.getValue()} @@ -195,18 +199,6 @@ export const UsersTable = () => { ), }), - columnHelper.accessor('mfa_enabled', { - header: m.users_col_mfa(), - size: 60, - minSize: 60, - cell: (info) => ( - - {info.getValue() ? ( - - ) : null} - - ), - }), columnHelper.accessor('groups', { header: m.users_col_groups(), size: 370, @@ -225,6 +217,18 @@ export const UsersTable = () => { }, cell: (info) => , }), + columnHelper.accessor('mfa_enabled', { + header: m.users_col_mfa(), + size: 56, + minSize: 56, + cell: (info) => ( + + {info.getValue() ? ( + + ) : null} + + ), + }), columnHelper.accessor('enrolled', { header: m.users_col_enrolled(), size: 150, @@ -579,6 +583,7 @@ export const UsersTable = () => { const reservedPubkeys = row.original.devices.map((d) => d.wireguard_pubkey); return row.original.devices.map((device, deviceIndex) => { const lastRow = isLast && deviceIndex === row.original.devices.length - 1; + const deviceOnline = isDeviceOnline(device); const latestNetwork = orderBy( device.networks.filter((n) => isPresent(n.last_connected_at)), (d) => d.last_connected_at, @@ -608,8 +613,13 @@ export const UsersTable = () => { - - + +
+ + {deviceOnline && ( +
{device.name}
diff --git a/web/src/pages/UsersOverviewPage/style.scss b/web/src/pages/UsersOverviewPage/style.scss index 692524776c..b173038920 100644 --- a/web/src/pages/UsersOverviewPage/style.scss +++ b/web/src/pages/UsersOverviewPage/style.scss @@ -4,9 +4,23 @@ box-sizing: border-box; } - .table .device-name-cell { - svg path { - fill: var(--fg-success); + .expanded-device-icon-wrapper { + position: relative; + display: inline-flex; + flex: none; + overflow: visible; + + .expanded-device-online-indicator { + position: absolute; + right: -6px; + bottom: 0; + width: 8px; + height: 8px; + border-radius: var(--radius-full); + background-color: var(--fg-success); + border: 2px solid var(--bg-default, #fff); + box-sizing: content-box; + pointer-events: none; } } } diff --git a/web/src/shared/defguard-ui b/web/src/shared/defguard-ui index 345b718ab2..e77b9152d9 160000 --- a/web/src/shared/defguard-ui +++ b/web/src/shared/defguard-ui @@ -1 +1 @@ -Subproject commit 345b718ab2e066213689611d4c8fe90af943368f +Subproject commit e77b9152d960c7ce25c2ed06504f4e94238dfaaa diff --git a/web/src/shared/utils/userOnlineStatus.ts b/web/src/shared/utils/userOnlineStatus.ts new file mode 100644 index 0000000000..404b6bd659 --- /dev/null +++ b/web/src/shared/utils/userOnlineStatus.ts @@ -0,0 +1,7 @@ +import type { Device, UsersListItem } from '../api/types'; + +export const isDeviceOnline = (device: Device): boolean => + device.networks.some((network) => network.is_active); + +export const isUserOnline = (user: UsersListItem): boolean => + user.devices.some(isDeviceOnline);