From 16ab5ca03656d9695529cd64c2ffbe0c9008db57 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Mar 2026 16:35:03 +0100 Subject: [PATCH 01/13] add user online indicator --- web/src/pages/UsersOverviewPage/UsersTable.tsx | 4 ++++ web/src/shared/utils/userOnlineStatus.ts | 7 +++++++ 2 files changed, 11 insertions(+) create mode 100644 web/src/shared/utils/userOnlineStatus.ts diff --git a/web/src/pages/UsersOverviewPage/UsersTable.tsx b/web/src/pages/UsersOverviewPage/UsersTable.tsx index 13bd69f9dc..8909677d5d 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 { isUserOnline } from '../../shared/utils/userOnlineStatus'; import { useAddUserModal } from './modals/AddUserModal/useAddUserModal'; type RowData = UsersListItem; @@ -158,6 +159,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()} 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); From b51daae3b0ad9cb57cd7353531b637f984ec8789 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Wed, 18 Mar 2026 16:35:15 +0100 Subject: [PATCH 02/13] update routes --- web/src/routeTree.gen.ts | 43 ++++++++++++------------- web/src/routes/error/migration-auth.tsx | 2 +- 2 files changed, 22 insertions(+), 23 deletions(-) diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index e9fa1c3d2e..443c5dd0e8 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -16,6 +16,7 @@ import { Route as AuthorizedRouteImport } from './routes/_authorized' import { Route as R404RouteImport } from './routes/404' import { Route as IndexRouteImport } from './routes/index' import { Route as AuthIndexRouteImport } from './routes/auth/index' +import { Route as ErrorMigrationAuthRouteImport } from './routes/error/migration-auth' import { Route as AuthMfaRouteImport } from './routes/auth/mfa' import { Route as AuthLoginRouteImport } from './routes/auth/login' import { Route as AuthLoadingRouteImport } from './routes/auth/loading' @@ -31,7 +32,6 @@ import { Route as AuthMfaTotpRouteImport } from './routes/auth/mfa/totp' import { Route as AuthMfaRecoveryRouteImport } from './routes/auth/mfa/recovery' import { Route as AuthMfaEmailRouteImport } from './routes/auth/mfa/email' import { Route as WizardMigrationLocationsRouteImport } from './routes/_wizard/migration/locations' -import { Route as AuthorizedErrorMigrationAuthRouteImport } from './routes/_authorized/error/migration-auth' import { Route as AuthorizedWizardSetupEdgeRouteImport } from './routes/_authorized/_wizard/setup-edge' import { Route as AuthorizedWizardAddLocationRouteImport } from './routes/_authorized/_wizard/add-location' import { Route as AuthorizedWizardAddExternalOpenidRouteImport } from './routes/_authorized/_wizard/add-external-openid' @@ -102,6 +102,11 @@ const AuthIndexRoute = AuthIndexRouteImport.update({ path: '/', getParentRoute: () => AuthRoute, } as any) +const ErrorMigrationAuthRoute = ErrorMigrationAuthRouteImport.update({ + id: '/error/migration-auth', + path: '/error/migration-auth', + getParentRoute: () => rootRouteImport, +} as any) const AuthMfaRoute = AuthMfaRouteImport.update({ id: '/mfa', path: '/mfa', @@ -177,12 +182,6 @@ const WizardMigrationLocationsRoute = path: '/migration/locations', getParentRoute: () => rootRouteImport, } as any) -const AuthorizedErrorMigrationAuthRoute = - AuthorizedErrorMigrationAuthRouteImport.update({ - id: '/error/migration-auth', - path: '/error/migration-auth', - getParentRoute: () => AuthorizedRoute, - } as any) const AuthorizedWizardSetupEdgeRoute = AuthorizedWizardSetupEdgeRouteImport.update({ id: '/_wizard/setup-edge', @@ -404,6 +403,7 @@ export interface FileRoutesByFullPath { '/auth/loading': typeof AuthLoadingRoute '/auth/login': typeof AuthLoginRoute '/auth/mfa': typeof AuthMfaRouteWithChildren + '/error/migration-auth': typeof ErrorMigrationAuthRoute '/auth/': typeof AuthIndexRoute '/activity': typeof AuthorizedDefaultActivityRoute '/edges': typeof AuthorizedDefaultEdgesRoute @@ -415,7 +415,6 @@ export interface FileRoutesByFullPath { '/add-external-openid': typeof AuthorizedWizardAddExternalOpenidRoute '/add-location': typeof AuthorizedWizardAddLocationRoute '/setup-edge': typeof AuthorizedWizardSetupEdgeRoute - '/error/migration-auth': typeof AuthorizedErrorMigrationAuthRoute '/migration/locations': typeof WizardMigrationLocationsRoute '/auth/mfa/email': typeof AuthMfaEmailRoute '/auth/mfa/recovery': typeof AuthMfaRecoveryRoute @@ -461,6 +460,7 @@ export interface FileRoutesByTo { '/auth/loading': typeof AuthLoadingRoute '/auth/login': typeof AuthLoginRoute '/auth/mfa': typeof AuthMfaRouteWithChildren + '/error/migration-auth': typeof ErrorMigrationAuthRoute '/auth': typeof AuthIndexRoute '/activity': typeof AuthorizedDefaultActivityRoute '/edges': typeof AuthorizedDefaultEdgesRoute @@ -472,7 +472,6 @@ export interface FileRoutesByTo { '/add-external-openid': typeof AuthorizedWizardAddExternalOpenidRoute '/add-location': typeof AuthorizedWizardAddLocationRoute '/setup-edge': typeof AuthorizedWizardSetupEdgeRoute - '/error/migration-auth': typeof AuthorizedErrorMigrationAuthRoute '/migration/locations': typeof WizardMigrationLocationsRoute '/auth/mfa/email': typeof AuthMfaEmailRoute '/auth/mfa/recovery': typeof AuthMfaRecoveryRoute @@ -522,6 +521,7 @@ export interface FileRoutesById { '/auth/loading': typeof AuthLoadingRoute '/auth/login': typeof AuthLoginRoute '/auth/mfa': typeof AuthMfaRouteWithChildren + '/error/migration-auth': typeof ErrorMigrationAuthRoute '/auth/': typeof AuthIndexRoute '/_authorized/_default/activity': typeof AuthorizedDefaultActivityRoute '/_authorized/_default/edges': typeof AuthorizedDefaultEdgesRoute @@ -533,7 +533,6 @@ export interface FileRoutesById { '/_authorized/_wizard/add-external-openid': typeof AuthorizedWizardAddExternalOpenidRoute '/_authorized/_wizard/add-location': typeof AuthorizedWizardAddLocationRoute '/_authorized/_wizard/setup-edge': typeof AuthorizedWizardSetupEdgeRoute - '/_authorized/error/migration-auth': typeof AuthorizedErrorMigrationAuthRoute '/_wizard/migration/locations': typeof WizardMigrationLocationsRoute '/auth/mfa/email': typeof AuthMfaEmailRoute '/auth/mfa/recovery': typeof AuthMfaRecoveryRoute @@ -582,6 +581,7 @@ export interface FileRouteTypes { | '/auth/loading' | '/auth/login' | '/auth/mfa' + | '/error/migration-auth' | '/auth/' | '/activity' | '/edges' @@ -593,7 +593,6 @@ export interface FileRouteTypes { | '/add-external-openid' | '/add-location' | '/setup-edge' - | '/error/migration-auth' | '/migration/locations' | '/auth/mfa/email' | '/auth/mfa/recovery' @@ -639,6 +638,7 @@ export interface FileRouteTypes { | '/auth/loading' | '/auth/login' | '/auth/mfa' + | '/error/migration-auth' | '/auth' | '/activity' | '/edges' @@ -650,7 +650,6 @@ export interface FileRouteTypes { | '/add-external-openid' | '/add-location' | '/setup-edge' - | '/error/migration-auth' | '/migration/locations' | '/auth/mfa/email' | '/auth/mfa/recovery' @@ -699,6 +698,7 @@ export interface FileRouteTypes { | '/auth/loading' | '/auth/login' | '/auth/mfa' + | '/error/migration-auth' | '/auth/' | '/_authorized/_default/activity' | '/_authorized/_default/edges' @@ -710,7 +710,6 @@ export interface FileRouteTypes { | '/_authorized/_wizard/add-external-openid' | '/_authorized/_wizard/add-location' | '/_authorized/_wizard/setup-edge' - | '/_authorized/error/migration-auth' | '/_wizard/migration/locations' | '/auth/mfa/email' | '/auth/mfa/recovery' @@ -754,6 +753,7 @@ export interface RootRouteChildren { WizardSetupRoute: typeof WizardSetupRoute WizardSetupGatewayRoute: typeof WizardSetupGatewayRoute WizardSetupLoginRoute: typeof WizardSetupLoginRoute + ErrorMigrationAuthRoute: typeof ErrorMigrationAuthRoute WizardMigrationLocationsRoute: typeof WizardMigrationLocationsRoute WizardMigrationIndexRoute: typeof WizardMigrationIndexRoute } @@ -809,6 +809,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthIndexRouteImport parentRoute: typeof AuthRoute } + '/error/migration-auth': { + id: '/error/migration-auth' + path: '/error/migration-auth' + fullPath: '/error/migration-auth' + preLoaderRoute: typeof ErrorMigrationAuthRouteImport + parentRoute: typeof rootRouteImport + } '/auth/mfa': { id: '/auth/mfa' path: '/mfa' @@ -914,13 +921,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof WizardMigrationLocationsRouteImport parentRoute: typeof rootRouteImport } - '/_authorized/error/migration-auth': { - id: '/_authorized/error/migration-auth' - path: '/error/migration-auth' - fullPath: '/error/migration-auth' - preLoaderRoute: typeof AuthorizedErrorMigrationAuthRouteImport - parentRoute: typeof AuthorizedRoute - } '/_authorized/_wizard/setup-edge': { id: '/_authorized/_wizard/setup-edge' path: '/setup-edge' @@ -1258,7 +1258,6 @@ interface AuthorizedRouteChildren { AuthorizedWizardAddExternalOpenidRoute: typeof AuthorizedWizardAddExternalOpenidRoute AuthorizedWizardAddLocationRoute: typeof AuthorizedWizardAddLocationRoute AuthorizedWizardSetupEdgeRoute: typeof AuthorizedWizardSetupEdgeRoute - AuthorizedErrorMigrationAuthRoute: typeof AuthorizedErrorMigrationAuthRoute } const AuthorizedRouteChildren: AuthorizedRouteChildren = { @@ -1268,7 +1267,6 @@ const AuthorizedRouteChildren: AuthorizedRouteChildren = { AuthorizedWizardAddExternalOpenidRoute, AuthorizedWizardAddLocationRoute: AuthorizedWizardAddLocationRoute, AuthorizedWizardSetupEdgeRoute: AuthorizedWizardSetupEdgeRoute, - AuthorizedErrorMigrationAuthRoute: AuthorizedErrorMigrationAuthRoute, } const AuthorizedRouteWithChildren = AuthorizedRoute._addFileChildren( @@ -1320,6 +1318,7 @@ const rootRouteChildren: RootRouteChildren = { WizardSetupRoute: WizardSetupRoute, WizardSetupGatewayRoute: WizardSetupGatewayRoute, WizardSetupLoginRoute: WizardSetupLoginRoute, + ErrorMigrationAuthRoute: ErrorMigrationAuthRoute, WizardMigrationLocationsRoute: WizardMigrationLocationsRoute, WizardMigrationIndexRoute: WizardMigrationIndexRoute, } diff --git a/web/src/routes/error/migration-auth.tsx b/web/src/routes/error/migration-auth.tsx index 685d65e5fb..6d0eccb32d 100644 --- a/web/src/routes/error/migration-auth.tsx +++ b/web/src/routes/error/migration-auth.tsx @@ -2,7 +2,7 @@ import { createFileRoute, redirect } from '@tanstack/react-router'; import { ErrorMigrationInProgressPage } from '../../pages/ErrorMigrationInProgressPage/ErrorMigrationInProgressPage'; import { getSessionInfoQueryOptions } from '../../shared/query'; -export const Route = createFileRoute('/_authorized/error/migration-auth')({ +export const Route = createFileRoute('/error/migration-auth')({ beforeLoad: async ({ context }) => { const sessionInfo = (await context.queryClient.fetchQuery(getSessionInfoQueryOptions)) .data; From 821e2d46b89f7092eb257c063c56da247780d0c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Mar 2026 08:05:46 +0100 Subject: [PATCH 03/13] add test for is_active flag --- .../defguard_common/src/db/models/device.rs | 282 +++++++++++++++++- 1 file changed, 281 insertions(+), 1 deletion(-) diff --git a/crates/defguard_common/src/db/models/device.rs b/crates/defguard_common/src/db/models/device.rs index a3ccb525ab..eb4dec02ec 100644 --- a/crates/defguard_common/src/db/models/device.rs +++ b/crates/defguard_common/src/db/models/device.rs @@ -218,7 +218,7 @@ impl UserDevice { 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 \ + ORDER BY created_at DESC, id DESC \ LIMIT 1 \ ) vs ON vs.location_id = n.id \ WHERE wnd.device_id = $1", @@ -1405,6 +1405,286 @@ mod test { assert_ok!(Device::validate_pubkey(valid_test_key)); } + #[sqlx::test] + async fn test_user_device_is_active_tracks_vpn_client_session_state( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let network = WireguardNetwork::default() + .try_set_address("10.1.1.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + let user = User::new( + "testuser", + Some("hunter2"), + "Tester", + "Test", + "test@test.com", + None, + ) + .save(&pool) + .await + .unwrap(); + + let device = Device::new( + "testdevice".into(), + "key".into(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + + WireguardNetworkDevice::new( + network.id, + device.id, + [IpAddr::from_str("10.1.1.2").unwrap()], + ) + .insert(&pool) + .await + .unwrap(); + + let user_device = UserDevice::from_device(&pool, device.clone()) + .await + .unwrap() + .unwrap(); + assert_eq!(user_device.networks.len(), 1); + assert!(!user_device.networks[0].is_active); + + let mut session = crate::db::models::vpn_client_session::VpnClientSession::new( + network.id, user.id, device.id, None, None, + ) + .save(&pool) + .await + .unwrap(); + + let user_device = UserDevice::from_device(&pool, device.clone()) + .await + .unwrap() + .unwrap(); + assert!(!user_device.networks[0].is_active); + + session.state = VpnClientSessionState::Connected; + session.connected_at = Some(Utc::now().naive_utc()); + session.save(&pool).await.unwrap(); + + let user_device = UserDevice::from_device(&pool, device.clone()) + .await + .unwrap() + .unwrap(); + assert!(user_device.networks[0].is_active); + + session.state = VpnClientSessionState::Disconnected; + session.disconnected_at = Some(Utc::now().naive_utc()); + session.save(&pool).await.unwrap(); + + let user_device = UserDevice::from_device(&pool, device) + .await + .unwrap() + .unwrap(); + assert!(!user_device.networks[0].is_active); + } + + #[sqlx::test] + async fn test_user_device_is_active_uses_newest_vpn_client_session_state( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let network = WireguardNetwork::default() + .try_set_address("10.1.1.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + let user = User::new( + "testuser", + Some("hunter2"), + "Tester", + "Test", + "test@test.com", + None, + ) + .save(&pool) + .await + .unwrap(); + + let device = Device::new( + "testdevice".into(), + "key".into(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + + WireguardNetworkDevice::new( + network.id, + device.id, + [IpAddr::from_str("10.1.1.2").unwrap()], + ) + .insert(&pool) + .await + .unwrap(); + + let older_created_at = NaiveDate::from_ymd_opt(2026, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(); + let newer_created_at = NaiveDate::from_ymd_opt(2026, 1, 1) + .unwrap() + .and_hms_opt(0, 1, 0) + .unwrap(); + + let mut older_session = crate::db::models::vpn_client_session::VpnClientSession::new( + network.id, user.id, device.id, None, None, + ) + .save(&pool) + .await + .unwrap(); + older_session.state = VpnClientSessionState::Connected; + older_session.connected_at = Some(older_created_at); + older_session.save(&pool).await.unwrap(); + sqlx::query("UPDATE vpn_client_session SET created_at = $1 WHERE id = $2") + .bind(older_created_at) + .bind(older_session.id) + .execute(&pool) + .await + .unwrap(); + + let mut newer_session = crate::db::models::vpn_client_session::VpnClientSession::new( + network.id, user.id, device.id, None, None, + ) + .save(&pool) + .await + .unwrap(); + newer_session.state = VpnClientSessionState::Disconnected; + newer_session.disconnected_at = Some(newer_created_at); + newer_session.save(&pool).await.unwrap(); + sqlx::query("UPDATE vpn_client_session SET created_at = $1 WHERE id = $2") + .bind(newer_created_at) + .bind(newer_session.id) + .execute(&pool) + .await + .unwrap(); + + let user_device = UserDevice::from_device(&pool, device) + .await + .unwrap() + .unwrap(); + + assert_eq!(user_device.networks.len(), 1); + assert!(!user_device.networks[0].is_active); + } + + #[sqlx::test] + async fn test_user_device_is_active_uses_newest_vpn_client_session_state_when_created_at_ties( + _: PgPoolOptions, + options: PgConnectOptions, + ) { + let pool = setup_pool(options).await; + + let network = WireguardNetwork::default() + .try_set_address("10.1.1.1/24") + .unwrap() + .save(&pool) + .await + .unwrap(); + + let user = User::new( + "testuser", + Some("hunter2"), + "Tester", + "Test", + "test@test.com", + None, + ) + .save(&pool) + .await + .unwrap(); + + let device = Device::new( + "testdevice".into(), + "key".into(), + user.id, + DeviceType::User, + None, + true, + ) + .save(&pool) + .await + .unwrap(); + + WireguardNetworkDevice::new( + network.id, + device.id, + [IpAddr::from_str("10.1.1.2").unwrap()], + ) + .insert(&pool) + .await + .unwrap(); + + let tied_created_at = NaiveDate::from_ymd_opt(2026, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(); + + let mut older_session = crate::db::models::vpn_client_session::VpnClientSession::new( + network.id, user.id, device.id, None, None, + ) + .save(&pool) + .await + .unwrap(); + older_session.state = VpnClientSessionState::Connected; + older_session.connected_at = Some(tied_created_at); + older_session.save(&pool).await.unwrap(); + sqlx::query("UPDATE vpn_client_session SET created_at = $1 WHERE id = $2") + .bind(tied_created_at) + .bind(older_session.id) + .execute(&pool) + .await + .unwrap(); + + let mut newer_session = crate::db::models::vpn_client_session::VpnClientSession::new( + network.id, user.id, device.id, None, None, + ) + .save(&pool) + .await + .unwrap(); + newer_session.state = VpnClientSessionState::Disconnected; + newer_session.disconnected_at = Some(tied_created_at); + newer_session.save(&pool).await.unwrap(); + sqlx::query("UPDATE vpn_client_session SET created_at = $1 WHERE id = $2") + .bind(tied_created_at) + .bind(newer_session.id) + .execute(&pool) + .await + .unwrap(); + + assert!(newer_session.id > older_session.id); + + let user_device = UserDevice::from_device(&pool, device) + .await + .unwrap() + .unwrap(); + + assert_eq!(user_device.networks.len(), 1); + assert!(!user_device.networks[0].is_active); + } + #[sqlx::test] fn test_all_for_network_and_user(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; From 6e453661558d8e6db76b2e6e811f2cd57ab8c709 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Mar 2026 08:06:59 +0100 Subject: [PATCH 04/13] fix device icon color --- web/src/pages/UsersOverviewPage/UsersTable.tsx | 4 ++-- web/src/pages/UsersOverviewPage/style.scss | 6 ------ 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/web/src/pages/UsersOverviewPage/UsersTable.tsx b/web/src/pages/UsersOverviewPage/UsersTable.tsx index 8909677d5d..9b08d2f98a 100644 --- a/web/src/pages/UsersOverviewPage/UsersTable.tsx +++ b/web/src/pages/UsersOverviewPage/UsersTable.tsx @@ -587,8 +587,8 @@ export const UsersTable = () => { - - + + {device.name} diff --git a/web/src/pages/UsersOverviewPage/style.scss b/web/src/pages/UsersOverviewPage/style.scss index 692524776c..f198f8aae8 100644 --- a/web/src/pages/UsersOverviewPage/style.scss +++ b/web/src/pages/UsersOverviewPage/style.scss @@ -3,10 +3,4 @@ padding-top: var(--spacing-3xl); box-sizing: border-box; } - - .table .device-name-cell { - svg path { - fill: var(--fg-success); - } - } } From 6216aa97cfada6d38e399d759950e2f7e3defff2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Mar 2026 10:44:53 +0100 Subject: [PATCH 05/13] fix device icon color --- .../pages/LocationOverviewPage/LocationOverviewUsersTable.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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} From 52e44b76f0e8de4c51c036996396f6f47e82e049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Mar 2026 11:29:04 +0100 Subject: [PATCH 06/13] update ui submodule --- web/src/shared/defguard-ui | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From ebd04fb150c26e62098999d3ea357bfae0136621 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Mar 2026 11:41:45 +0100 Subject: [PATCH 07/13] update connection timestamp --- ...760815ca58662f4a2a8563738052a9cf108b8f936e.json} | 8 ++++---- ...f303dfe2a67880adeaca1e10e50bea9b9fc53e08845.json | 2 +- ...0c607fc52cb537dfded663e6bbee73d5f908f0ca89e.json | 2 +- crates/defguard_common/src/db/models/device.rs | 13 +++---------- 4 files changed, 9 insertions(+), 16 deletions(-) rename .sqlx/{query-f64a22a8141030a41aa9b85275158a3a75e5ebfa0f14c84b1aefc8582156eb03.json => query-335da4ebc0dadcc6ece2cd760815ca58662f4a2a8563738052a9cf108b8f936e.json} (62%) diff --git a/.sqlx/query-f64a22a8141030a41aa9b85275158a3a75e5ebfa0f14c84b1aefc8582156eb03.json b/.sqlx/query-335da4ebc0dadcc6ece2cd760815ca58662f4a2a8563738052a9cf108b8f936e.json similarity index 62% rename from .sqlx/query-f64a22a8141030a41aa9b85275158a3a75e5ebfa0f14c84b1aefc8582156eb03.json rename to .sqlx/query-335da4ebc0dadcc6ece2cd760815ca58662f4a2a8563738052a9cf108b8f936e.json index 72e7fb3e2e..de3a964f35 100644 --- a/.sqlx/query-f64a22a8141030a41aa9b85275158a3a75e5ebfa0f14c84b1aefc8582156eb03.json +++ b/.sqlx/query-335da4ebc0dadcc6ece2cd760815ca58662f4a2a8563738052a9cf108b8f936e.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 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\", vs.endpoint \"device_endpoint?\", vs.connected_at \"connected_at?\", 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, connected_at FROM vpn_client_session 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", "describe": { "columns": [ { @@ -30,7 +30,7 @@ }, { "ordinal": 5, - "name": "latest_handshake?", + "name": "connected_at?", "type_info": "Timestamp" }, { @@ -61,9 +61,9 @@ false, false, false, - false, + true, false ] }, - "hash": "f64a22a8141030a41aa9b85275158a3a75e5ebfa0f14c84b1aefc8582156eb03" + "hash": "335da4ebc0dadcc6ece2cd760815ca58662f4a2a8563738052a9cf108b8f936e" } diff --git a/.sqlx/query-47c406366d0b53ca805cff303dfe2a67880adeaca1e10e50bea9b9fc53e08845.json b/.sqlx/query-47c406366d0b53ca805cff303dfe2a67880adeaca1e10e50bea9b9fc53e08845.json index 0e36c94cdb..8a6776d365 100644 --- a/.sqlx/query-47c406366d0b53ca805cff303dfe2a67880adeaca1e10e50bea9b9fc53e08845.json +++ b/.sqlx/query-47c406366d0b53ca805cff303dfe2a67880adeaca1e10e50bea9b9fc53e08845.json @@ -82,7 +82,7 @@ false, false, true, - false, + true, false, false, false, diff --git a/.sqlx/query-59d048f8110a53745afd80c607fc52cb537dfded663e6bbee73d5f908f0ca89e.json b/.sqlx/query-59d048f8110a53745afd80c607fc52cb537dfded663e6bbee73d5f908f0ca89e.json index d62e957d33..548a89c193 100644 --- a/.sqlx/query-59d048f8110a53745afd80c607fc52cb537dfded663e6bbee73d5f908f0ca89e.json +++ b/.sqlx/query-59d048f8110a53745afd80c607fc52cb537dfded663e6bbee73d5f908f0ca89e.json @@ -80,7 +80,7 @@ false, false, true, - false, + true, false, false, false, diff --git a/crates/defguard_common/src/db/models/device.rs b/crates/defguard_common/src/db/models/device.rs index eb4dec02ec..17c4121c26 100644 --- a/crates/defguard_common/src/db/models/device.rs +++ b/crates/defguard_common/src/db/models/device.rs @@ -203,20 +203,13 @@ impl UserDevice { 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.connected_at \"connected_at?\", \ 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 \ + 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 \ @@ -256,7 +249,7 @@ impl UserDevice { .map(IpAddr::to_string) .collect(), last_connected_ip: device_ip, - last_connected_at: r.latest_handshake, + last_connected_at: r.connected_at, is_active, } }) From 3e1568e987e36dcffc5f428e61c2e3b6fe408120 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Mar 2026 11:41:56 +0100 Subject: [PATCH 08/13] move mfa column --- .../pages/UsersOverviewPage/UsersTable.tsx | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/web/src/pages/UsersOverviewPage/UsersTable.tsx b/web/src/pages/UsersOverviewPage/UsersTable.tsx index 9b08d2f98a..9f57fff18a 100644 --- a/web/src/pages/UsersOverviewPage/UsersTable.tsx +++ b/web/src/pages/UsersOverviewPage/UsersTable.tsx @@ -213,18 +213,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, @@ -243,6 +231,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, From d71b944652933237d56785c0c455eee6846462f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Mar 2026 11:48:05 +0100 Subject: [PATCH 09/13] add is_active flag test --- .../tests/integration/api/user.rs | 97 ++++++++++++++++++- 1 file changed, 96 insertions(+), 1 deletion(-) diff --git a/crates/defguard_core/tests/integration/api/user.rs b/crates/defguard_core/tests/integration/api/user.rs index db08ab75c2..9444975f02 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, + }, }, types::user_info::UserInfo, }; @@ -267,6 +275,93 @@ 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_username_available(_: PgPoolOptions, options: PgConnectOptions) { let pool = setup_pool(options).await; From b82020d72ad987291a1f252c0c297198982b3dbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Mar 2026 12:03:57 +0100 Subject: [PATCH 10/13] update timestamp tests --- .../defguard_common/src/db/models/device.rs | 201 +++++++++++++++++- .../tests/integration/api/user.rs | 94 +++++++- 2 files changed, 289 insertions(+), 6 deletions(-) diff --git a/crates/defguard_common/src/db/models/device.rs b/crates/defguard_common/src/db/models/device.rs index 24ec015fce..9baf0727ac 100644 --- a/crates/defguard_common/src/db/models/device.rs +++ b/crates/defguard_common/src/db/models/device.rs @@ -239,9 +239,9 @@ 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.connected_at \"connected_at?\", \ - 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 ( \ @@ -250,7 +250,14 @@ impl UserDevice { 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, ) @@ -286,7 +293,7 @@ impl UserDevice { .map(IpAddr::to_string) .collect(), last_connected_ip: device_ip, - last_connected_at: r.connected_at, + last_connected_at: r.last_connected_at, is_active, } }) @@ -1786,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 9444975f02..929ee2720a 100644 --- a/crates/defguard_core/tests/integration/api/user.rs +++ b/crates/defguard_core/tests/integration/api/user.rs @@ -8,7 +8,7 @@ use defguard_common::{ Device, DeviceType, MFAMethod, User, WebAuthn, WireguardNetwork, device::{AddDevice, WireguardNetworkDevice}, oauth2client::OAuth2Client, - vpn_client_session::VpnClientSession, + vpn_client_session::{VpnClientSession, VpnClientSessionState}, }, }, types::user_info::UserInfo, @@ -362,6 +362,98 @@ async fn test_get_user_exposes_active_network_state(_: PgPoolOptions, options: P 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; From 20a87b7cb0c1b8d5bea37929098446bad8afbd81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Mar 2026 12:57:40 +0100 Subject: [PATCH 11/13] add online indicator for user devices --- .../pages/UsersOverviewPage/UsersTable.tsx | 10 +++++++-- web/src/pages/UsersOverviewPage/style.scss | 22 ++++++++++++++++++- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/web/src/pages/UsersOverviewPage/UsersTable.tsx b/web/src/pages/UsersOverviewPage/UsersTable.tsx index 8a2311ff73..af43427af2 100644 --- a/web/src/pages/UsersOverviewPage/UsersTable.tsx +++ b/web/src/pages/UsersOverviewPage/UsersTable.tsx @@ -47,7 +47,7 @@ import { getUsersOverviewQueryOptions, } from '../../shared/query'; import { displayDate } from '../../shared/utils/displayDate'; -import { isUserOnline } from '../../shared/utils/userOnlineStatus'; +import { isDeviceOnline, isUserOnline } from '../../shared/utils/userOnlineStatus'; import { useAddUserModal } from './modals/AddUserModal/useAddUserModal'; type RowData = UsersListItem; @@ -583,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, @@ -613,7 +614,12 @@ export const UsersTable = () => { - +
+ + {deviceOnline && ( +
{device.name}
diff --git a/web/src/pages/UsersOverviewPage/style.scss b/web/src/pages/UsersOverviewPage/style.scss index f198f8aae8..336f65234d 100644 --- a/web/src/pages/UsersOverviewPage/style.scss +++ b/web/src/pages/UsersOverviewPage/style.scss @@ -3,4 +3,24 @@ padding-top: var(--spacing-3xl); box-sizing: border-box; } -} + + .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; + } + } +} \ No newline at end of file From 3b0edf1b35ea454b080c1e0780d6a8bf5d0592e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Mar 2026 13:07:11 +0100 Subject: [PATCH 12/13] update query data --- ...aef96b6b18e43c47c7ffbee0e6a825568b4a55f69e225bb7b0.json} | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) rename .sqlx/{query-335da4ebc0dadcc6ece2cd760815ca58662f4a2a8563738052a9cf108b8f936e.json => query-1a828cfcdc03c0aef96b6b18e43c47c7ffbee0e6a825568b4a55f69e225bb7b0.json} (60%) diff --git a/.sqlx/query-335da4ebc0dadcc6ece2cd760815ca58662f4a2a8563738052a9cf108b8f936e.json b/.sqlx/query-1a828cfcdc03c0aef96b6b18e43c47c7ffbee0e6a825568b4a55f69e225bb7b0.json similarity index 60% rename from .sqlx/query-335da4ebc0dadcc6ece2cd760815ca58662f4a2a8563738052a9cf108b8f936e.json rename to .sqlx/query-1a828cfcdc03c0aef96b6b18e43c47c7ffbee0e6a825568b4a55f69e225bb7b0.json index de3a964f35..eddf80ae9f 100644 --- a/.sqlx/query-335da4ebc0dadcc6ece2cd760815ca58662f4a2a8563738052a9cf108b8f936e.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.connected_at \"connected_at?\", 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, connected_at FROM vpn_client_session 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": "connected_at?", + "name": "last_connected_at?", "type_info": "Timestamp" }, { @@ -65,5 +65,5 @@ false ] }, - "hash": "335da4ebc0dadcc6ece2cd760815ca58662f4a2a8563738052a9cf108b8f936e" + "hash": "1a828cfcdc03c0aef96b6b18e43c47c7ffbee0e6a825568b4a55f69e225bb7b0" } From 65e24ff644553e80dea7a3068f4969df6d061750 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maciej=20W=C3=B3jcik?= Date: Thu, 19 Mar 2026 13:07:15 +0100 Subject: [PATCH 13/13] formatting --- web/src/pages/UsersOverviewPage/style.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/pages/UsersOverviewPage/style.scss b/web/src/pages/UsersOverviewPage/style.scss index 336f65234d..b173038920 100644 --- a/web/src/pages/UsersOverviewPage/style.scss +++ b/web/src/pages/UsersOverviewPage/style.scss @@ -23,4 +23,4 @@ pointer-events: none; } } -} \ No newline at end of file +}