diff --git a/backend/.sqlx/query-de52a67986f2dc60d0fe3d4b5c034fb848adf648757beb202501bb245fd42e21.json b/backend/.sqlx/query-5820c34829bee0e13ba67cdfc0efa9965a1f90977703aa41ade20b2750ed9647.json similarity index 73% rename from backend/.sqlx/query-de52a67986f2dc60d0fe3d4b5c034fb848adf648757beb202501bb245fd42e21.json rename to backend/.sqlx/query-5820c34829bee0e13ba67cdfc0efa9965a1f90977703aa41ade20b2750ed9647.json index 282dc075db..dfdb6eca3d 100644 --- a/backend/.sqlx/query-de52a67986f2dc60d0fe3d4b5c034fb848adf648757beb202501bb245fd42e21.json +++ b/backend/.sqlx/query-5820c34829bee0e13ba67cdfc0efa9965a1f90977703aa41ade20b2750ed9647.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "SELECT\n id,\n username,\n fullname,\n password_hash,\n needs_password_change,\n role,\n last_activity_at,\n updated_at,\n created_at\n FROM users\n WHERE ($1 IS NULL OR role = $1)\n ", + "query": "\n SELECT\n u.id,\n u.username,\n u.fullname,\n u.role,\n u.password_hash,\n u.needs_password_change,\n u.last_activity_at,\n (\n SELECT COUNT(*)\n FROM sessions s\n WHERE s.user_id = u.id\n AND s.expires_at > datetime($1)\n ) > 0 as \"is_logged_in!: bool\",\n u.updated_at,\n u.created_at\n FROM users u WHERE u.username = $2 COLLATE NOCASE\n ", "describe": { "columns": [ { @@ -37,35 +37,35 @@ } }, { - "name": "password_hash", + "name": "role", "ordinal": 3, "type_info": "Text", "origin": { "Table": { "table": "users", - "name": "password_hash" + "name": "role" } } }, { - "name": "needs_password_change", + "name": "password_hash", "ordinal": 4, - "type_info": "Integer", + "type_info": "Text", "origin": { "Table": { "table": "users", - "name": "needs_password_change" + "name": "password_hash" } } }, { - "name": "role", + "name": "needs_password_change", "ordinal": 5, - "type_info": "Text", + "type_info": "Integer", "origin": { "Table": { "table": "users", - "name": "role" + "name": "needs_password_change" } } }, @@ -81,8 +81,14 @@ } }, { - "name": "updated_at", + "name": "is_logged_in!: bool", "ordinal": 7, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "updated_at", + "ordinal": 8, "type_info": "Text", "origin": { "Table": { @@ -93,7 +99,7 @@ }, { "name": "created_at", - "ordinal": 8, + "ordinal": 9, "type_info": "Text", "origin": { "Table": { @@ -104,7 +110,7 @@ } ], "parameters": { - "Right": 1 + "Right": 2 }, "nullable": [ false, @@ -115,8 +121,9 @@ false, true, false, + false, false ] }, - "hash": "de52a67986f2dc60d0fe3d4b5c034fb848adf648757beb202501bb245fd42e21" + "hash": "5820c34829bee0e13ba67cdfc0efa9965a1f90977703aa41ade20b2750ed9647" } diff --git a/backend/.sqlx/query-58d15e5e4ab40ba070d149624aec6556f4576b31d1abdd1204c7b013e81d9818.json b/backend/.sqlx/query-7b43ccc49592fcc0c7aec52b7b487b6dfc32565ea6150aaddc75d6b42a3c87f4.json similarity index 74% rename from backend/.sqlx/query-58d15e5e4ab40ba070d149624aec6556f4576b31d1abdd1204c7b013e81d9818.json rename to backend/.sqlx/query-7b43ccc49592fcc0c7aec52b7b487b6dfc32565ea6150aaddc75d6b42a3c87f4.json index 79a272f4f8..96104ca646 100644 --- a/backend/.sqlx/query-58d15e5e4ab40ba070d149624aec6556f4576b31d1abdd1204c7b013e81d9818.json +++ b/backend/.sqlx/query-7b43ccc49592fcc0c7aec52b7b487b6dfc32565ea6150aaddc75d6b42a3c87f4.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n id,\n username,\n fullname,\n role,\n password_hash,\n needs_password_change,\n last_activity_at,\n updated_at,\n created_at\n FROM users WHERE id = ?\n ", + "query": "\n SELECT\n u.id,\n u.username,\n u.fullname,\n u.role,\n u.password_hash,\n u.needs_password_change,\n u.last_activity_at,\n (\n SELECT COUNT(*)\n FROM sessions s\n WHERE s.user_id = u.id\n AND s.expires_at > datetime($1)\n ) > 0 as \"is_logged_in: bool\",\n u.updated_at,\n u.created_at\n FROM users u WHERE u.id = $2\n ", "describe": { "columns": [ { @@ -81,8 +81,14 @@ } }, { - "name": "updated_at", + "name": "is_logged_in: bool", "ordinal": 7, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "updated_at", + "ordinal": 8, "type_info": "Text", "origin": { "Table": { @@ -93,7 +99,7 @@ }, { "name": "created_at", - "ordinal": 8, + "ordinal": 9, "type_info": "Text", "origin": { "Table": { @@ -104,7 +110,7 @@ } ], "parameters": { - "Right": 1 + "Right": 2 }, "nullable": [ false, @@ -115,8 +121,9 @@ false, true, false, + false, false ] }, - "hash": "58d15e5e4ab40ba070d149624aec6556f4576b31d1abdd1204c7b013e81d9818" + "hash": "7b43ccc49592fcc0c7aec52b7b487b6dfc32565ea6150aaddc75d6b42a3c87f4" } diff --git a/backend/.sqlx/query-ae5fb9db21a81b890109cb0261bd28f35fe40e921bdb70000e9edc05a8af78d6.json b/backend/.sqlx/query-9167c1b9360a0afa70894641ac318f1d9b36cd638e7b009b6e2d9ed9009aaf84.json similarity index 73% rename from backend/.sqlx/query-ae5fb9db21a81b890109cb0261bd28f35fe40e921bdb70000e9edc05a8af78d6.json rename to backend/.sqlx/query-9167c1b9360a0afa70894641ac318f1d9b36cd638e7b009b6e2d9ed9009aaf84.json index b0449bb062..dba7583964 100644 --- a/backend/.sqlx/query-ae5fb9db21a81b890109cb0261bd28f35fe40e921bdb70000e9edc05a8af78d6.json +++ b/backend/.sqlx/query-9167c1b9360a0afa70894641ac318f1d9b36cd638e7b009b6e2d9ed9009aaf84.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "\n SELECT\n id,\n username,\n fullname,\n role,\n password_hash,\n needs_password_change,\n last_activity_at,\n updated_at,\n created_at\n FROM users WHERE username = ? COLLATE NOCASE\n ", + "query": "SELECT\n u.id,\n u.username,\n u.fullname,\n u.password_hash,\n u.needs_password_change,\n u.role,\n u.last_activity_at,\n (\n SELECT COUNT(*)\n FROM sessions s\n WHERE s.user_id = u.id\n AND s.expires_at > datetime($1)\n ) > 0 as \"is_logged_in: bool\",\n u.updated_at,\n u.created_at\n FROM users u\n WHERE ($2 IS NULL OR u.role = $2)\n ", "describe": { "columns": [ { @@ -37,35 +37,35 @@ } }, { - "name": "role", + "name": "password_hash", "ordinal": 3, "type_info": "Text", "origin": { "Table": { "table": "users", - "name": "role" + "name": "password_hash" } } }, { - "name": "password_hash", + "name": "needs_password_change", "ordinal": 4, - "type_info": "Text", + "type_info": "Integer", "origin": { "Table": { "table": "users", - "name": "password_hash" + "name": "needs_password_change" } } }, { - "name": "needs_password_change", + "name": "role", "ordinal": 5, - "type_info": "Integer", + "type_info": "Text", "origin": { "Table": { "table": "users", - "name": "needs_password_change" + "name": "role" } } }, @@ -81,8 +81,14 @@ } }, { - "name": "updated_at", + "name": "is_logged_in: bool", "ordinal": 7, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "updated_at", + "ordinal": 8, "type_info": "Text", "origin": { "Table": { @@ -93,7 +99,7 @@ }, { "name": "created_at", - "ordinal": 8, + "ordinal": 9, "type_info": "Text", "origin": { "Table": { @@ -104,7 +110,7 @@ } ], "parameters": { - "Right": 1 + "Right": 2 }, "nullable": [ false, @@ -115,8 +121,9 @@ false, true, false, + false, false ] }, - "hash": "ae5fb9db21a81b890109cb0261bd28f35fe40e921bdb70000e9edc05a8af78d6" + "hash": "9167c1b9360a0afa70894641ac318f1d9b36cd638e7b009b6e2d9ed9009aaf84" } diff --git a/backend/.sqlx/query-cf86905fc1fc1d6a9fdd644e8c0e7834ab5bac2b52192d2dd7358e3ed346ba3a.json b/backend/.sqlx/query-d7e19e02ecc37d2d280876571341bc37d401f4d8991351a8d2a6521ee456266e.json similarity index 87% rename from backend/.sqlx/query-cf86905fc1fc1d6a9fdd644e8c0e7834ab5bac2b52192d2dd7358e3ed346ba3a.json rename to backend/.sqlx/query-d7e19e02ecc37d2d280876571341bc37d401f4d8991351a8d2a6521ee456266e.json index 9d5b944a2f..8b5cbfa14d 100644 --- a/backend/.sqlx/query-cf86905fc1fc1d6a9fdd644e8c0e7834ab5bac2b52192d2dd7358e3ed346ba3a.json +++ b/backend/.sqlx/query-d7e19e02ecc37d2d280876571341bc37d401f4d8991351a8d2a6521ee456266e.json @@ -1,6 +1,6 @@ { "db_name": "SQLite", - "query": "INSERT INTO users (username, fullname, password_hash, needs_password_change, role)\n VALUES (?, ?, ?, ?, ?)\n RETURNING\n id,\n username,\n fullname,\n password_hash,\n needs_password_change,\n role,\n last_activity_at,\n updated_at,\n created_at\n ", + "query": "INSERT INTO users (username, fullname, password_hash, needs_password_change, role)\n VALUES (?, ?, ?, ?, ?)\n RETURNING\n id,\n username,\n fullname,\n password_hash,\n needs_password_change,\n role,\n last_activity_at,\n 0 as \"is_logged_in: bool\", -- New users are never logged in\n updated_at,\n created_at\n ", "describe": { "columns": [ { @@ -81,8 +81,14 @@ } }, { - "name": "updated_at", + "name": "is_logged_in: bool", "ordinal": 7, + "type_info": "Integer", + "origin": "Expression" + }, + { + "name": "updated_at", + "ordinal": 8, "type_info": "Text", "origin": { "Table": { @@ -93,7 +99,7 @@ }, { "name": "created_at", - "ordinal": 8, + "ordinal": 9, "type_info": "Text", "origin": { "Table": { @@ -115,8 +121,9 @@ false, true, false, + false, false ] }, - "hash": "cf86905fc1fc1d6a9fdd644e8c0e7834ab5bac2b52192d2dd7358e3ed346ba3a" + "hash": "d7e19e02ecc37d2d280876571341bc37d401f4d8991351a8d2a6521ee456266e" } diff --git a/backend/openapi.json b/backend/openapi.json index b348d5df18..de46667222 100644 --- a/backend/openapi.json +++ b/backend/openapi.json @@ -9002,6 +9002,7 @@ "id", "username", "role", + "is_logged_in", "updated_at", "created_at" ], @@ -9015,6 +9016,9 @@ "id": { "$ref": "#/components/schemas/UserId" }, + "is_logged_in": { + "type": "boolean" + }, "last_activity_at": { "type": "string" }, diff --git a/backend/src/api/middleware/authentication/middleware.rs b/backend/src/api/middleware/authentication/middleware.rs index 26b385e1d5..9cfa36b0e9 100644 --- a/backend/src/api/middleware/authentication/middleware.rs +++ b/backend/src/api/middleware/authentication/middleware.rs @@ -42,7 +42,8 @@ pub(crate) async fn inject_user( return request; }; - let Ok(Some(session)) = session_repo::get_by_identifier(&mut conn, &session_id).await else { + let Ok(Some(session)) = session_repo::get_by_identifier_if_valid(&mut conn, &session_id).await + else { return request; }; diff --git a/backend/src/api/middleware/authentication/mod.rs b/backend/src/api/middleware/authentication/mod.rs index e9a7f3446e..2b77ffa250 100644 --- a/backend/src/api/middleware/authentication/mod.rs +++ b/backend/src/api/middleware/authentication/mod.rs @@ -271,6 +271,59 @@ mod tests { ); } + #[test(sqlx::test(fixtures("../../../../fixtures/users.sql")))] + async fn test_user_online_status_before_login(pool: SqlitePool) { + let app = create_app(&pool); + + let cookie = login_as_admin(app.clone()).await; + + // Get the a second user that hasn't logged in + let response = app + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/api/users/2") + .header(CONTENT_TYPE, "application/json") + .header(USER_AGENT, TEST_USER_AGENT) + .header(COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let result: User = serde_json::from_slice(&body).unwrap(); + assert!(!result.is_logged_in()); + } + + #[test(sqlx::test(fixtures("../../../../fixtures/users.sql")))] + async fn test_user_online_status_after_login(pool: SqlitePool) { + let app = create_app(&pool); + + let cookie = login_as_admin(app.clone()).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/api/users/1") + .header(CONTENT_TYPE, "application/json") + .header(USER_AGENT, TEST_USER_AGENT) + .header(COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let result: User = serde_json::from_slice(&body).unwrap(); + + assert!(result.is_logged_in()); + } + #[test(sqlx::test(fixtures("../../../../fixtures/users.sql")))] async fn test_logout(pool: SqlitePool) { let app = create_app(&pool); @@ -327,6 +380,91 @@ mod tests { ); } + #[test(sqlx::test(fixtures("../../../../fixtures/users.sql")))] + async fn test_user_online_status_after_logout(pool: SqlitePool) { + let app = create_app(&pool); + + let cookie_admin_1 = login_as_admin(app.clone()).await; + + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/login") + .header(CONTENT_TYPE, "application/json") + .header(USER_AGENT, TEST_USER_AGENT) + .body(Body::from( + serde_json::to_vec(&Credentials { + username: "admin2".to_string(), + password: "Admin2Password01".to_string(), + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + let cookie_admin_2 = response.headers().get("set-cookie").unwrap().clone(); + + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/api/users/2") + .header(CONTENT_TYPE, "application/json") + .header(USER_AGENT, TEST_USER_AGENT) + .header(COOKIE, &cookie_admin_1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let result: User = serde_json::from_slice(&body).unwrap(); + + // User 2 is currently logged in + assert!(result.is_logged_in()); + + // Logout user 2 + let _ = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/logout") + .header(COOKIE, &cookie_admin_2) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let response = app + .clone() + .oneshot( + Request::builder() + .method(Method::GET) + .uri("/api/users/2") + .header(CONTENT_TYPE, "application/json") + .header(USER_AGENT, TEST_USER_AGENT) + .header(COOKIE, &cookie_admin_1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let body = response.into_body().collect().await.unwrap().to_bytes(); + let result: User = serde_json::from_slice(&body).unwrap(); + + // User 2 is currently logged out + assert!(!result.is_logged_in()); + } + #[test(sqlx::test(fixtures("../../../../fixtures/users.sql")))] async fn test_account(pool: SqlitePool) { let app = create_app(&pool); diff --git a/backend/src/repository/session_repo.rs b/backend/src/repository/session_repo.rs index af6d04a823..e1ac9c8a15 100644 --- a/backend/src/repository/session_repo.rs +++ b/backend/src/repository/session_repo.rs @@ -98,8 +98,9 @@ pub(crate) struct SessionIdentifier { pub ip_address: String, } -/// Get a session by its key and validate user agent and IP address -pub(crate) async fn get_by_identifier( +/// Get a session by its key and validate user agent and IP address and expiry. +/// If no valid session was found, returns `Ok(None)`. +pub(crate) async fn get_by_identifier_if_valid( conn: &mut SqliteConnection, session: &SessionIdentifier, ) -> Result, sqlx::Error> { diff --git a/backend/src/repository/user_repo.rs b/backend/src/repository/user_repo.rs index 8444a1d747..fd7ceb4cdc 100644 --- a/backend/src/repository/user_repo.rs +++ b/backend/src/repository/user_repo.rs @@ -32,6 +32,7 @@ pub struct User { #[serde(skip_serializing_if = "Option::is_none")] #[schema(value_type = String)] last_activity_at: Option>, + is_logged_in: bool, #[schema(value_type = String)] updated_at: DateTime, #[schema(value_type = String)] @@ -89,6 +90,10 @@ impl User { self.needs_password_change } + pub fn is_logged_in(&self) -> bool { + self.is_logged_in + } + #[cfg(test)] pub fn test_user(role: Role, user_id: UserId) -> Self { Self { @@ -102,6 +107,7 @@ impl User { ) .unwrap(), last_activity_at: None, + is_logged_in: false, updated_at: Utc::now(), created_at: Utc::now(), } @@ -159,6 +165,7 @@ pub async fn create( needs_password_change, role, last_activity_at, + 0 as "is_logged_in: bool", -- New users are never logged in updated_at, created_at "#, @@ -267,21 +274,30 @@ pub async fn get_by_username( conn: &mut SqliteConnection, username: &str, ) -> Result, AuthenticationError> { + let now = Utc::now(); + let user = sqlx::query_as!( User, r#" SELECT - id, - username, - fullname, - role, - password_hash, - needs_password_change, - last_activity_at, - updated_at, - created_at - FROM users WHERE username = ? COLLATE NOCASE + u.id, + u.username, + u.fullname, + u.role, + u.password_hash, + u.needs_password_change, + u.last_activity_at, + ( + SELECT COUNT(*) + FROM sessions s + WHERE s.user_id = u.id + AND s.expires_at > datetime($1) + ) > 0 as "is_logged_in!: bool", + u.updated_at, + u.created_at + FROM users u WHERE u.username = $2 COLLATE NOCASE "#, + now, username ) .fetch_optional(conn) @@ -295,21 +311,30 @@ pub async fn get_by_id( conn: &mut SqliteConnection, user_id: UserId, ) -> Result, AuthenticationError> { + let now = Utc::now().to_rfc3339(); + let user = sqlx::query_as!( User, r#" SELECT - id, - username, - fullname, - role, - password_hash, - needs_password_change, - last_activity_at, - updated_at, - created_at - FROM users WHERE id = ? + u.id, + u.username, + u.fullname, + u.role, + u.password_hash, + u.needs_password_change, + u.last_activity_at, + ( + SELECT COUNT(*) + FROM sessions s + WHERE s.user_id = u.id + AND s.expires_at > datetime($1) + ) > 0 as "is_logged_in: bool", + u.updated_at, + u.created_at + FROM users u WHERE u.id = $2 "#, + now, user_id ) .fetch_optional(conn) @@ -322,21 +347,30 @@ pub async fn list( conn: &mut SqliteConnection, filter_role: Option, ) -> Result, sqlx::Error> { + let now = Utc::now().to_rfc3339(); + let users = query_as!( User, r#"SELECT - id, - username, - fullname, - password_hash, - needs_password_change, - role, - last_activity_at, - updated_at, - created_at - FROM users - WHERE ($1 IS NULL OR role = $1) + u.id, + u.username, + u.fullname, + u.password_hash, + u.needs_password_change, + u.role, + u.last_activity_at, + ( + SELECT COUNT(*) + FROM sessions s + WHERE s.user_id = u.id + AND s.expires_at > datetime($1) + ) > 0 as "is_logged_in: bool", + u.updated_at, + u.created_at + FROM users u + WHERE ($2 IS NULL OR u.role = $2) "#, + now, filter_role, ) .fetch_all(conn) @@ -630,6 +664,7 @@ mod tests { ) .unwrap(), last_activity_at: None, + is_logged_in: false, updated_at: chrono::Utc::now(), created_at: chrono::Utc::now(), }; diff --git a/frontend/src/features/users/components/UserListPage.tsx b/frontend/src/features/users/components/UserListPage.tsx index 0a248c48fd..7f2b5d4086 100644 --- a/frontend/src/features/users/components/UserListPage.tsx +++ b/frontend/src/features/users/components/UserListPage.tsx @@ -69,7 +69,10 @@ export function UserListPage() { {user.fullname || {t("users.not_used")}} - {user.last_activity_at ? formatDateTime(new Date(user.last_activity_at)) : "–"} +
+ {user.last_activity_at ? formatDateTime(new Date(user.last_activity_at)) : "–"} + {user.is_logged_in && } +
))} diff --git a/frontend/src/features/users/components/users.module.css b/frontend/src/features/users/components/users.module.css index 287cab57ab..c4f8ecbcd4 100644 --- a/frontend/src/features/users/components/users.module.css +++ b/frontend/src/features/users/components/users.module.css @@ -8,3 +8,18 @@ text-overflow: ellipsis; } } + +.activityCell { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +} + +.onlineDot { + flex: none; + width: 0.5rem; + height: 0.5rem; + border-radius: 50%; + background-color: var(--interactive-default); +} diff --git a/frontend/src/i18n/locales/nl/users.json b/frontend/src/i18n/locales/nl/users.json index 8a650b608f..cac82bfea0 100644 --- a/frontend/src/i18n/locales/nl/users.json +++ b/frontend/src/i18n/locales/nl/users.json @@ -18,6 +18,7 @@ "fullname_hint": "Deze is terug te zien in de logs", "fullname": "Volledige naam", "last_activity": "Laatste activiteit", + "online": "Online", "manage": "Gebruikers beheren", "mandatory": "Dit is een verplichte vraag. Maak een keuze uit de opties hieronder.", "new_password": "Nieuw wachtwoord", diff --git a/frontend/src/testing/api-mocks/UserMockData.ts b/frontend/src/testing/api-mocks/UserMockData.ts index cc776053da..332a68e0c2 100644 --- a/frontend/src/testing/api-mocks/UserMockData.ts +++ b/frontend/src/testing/api-mocks/UserMockData.ts @@ -16,6 +16,7 @@ export const userMockData: User[] = [ role: "administrator", fullname: "Sanne Molenaar", last_activity_at: today.toISOString(), + is_logged_in: false, created_at, updated_at, }, @@ -25,6 +26,7 @@ export const userMockData: User[] = [ role: "coordinator_gsb", fullname: "Jayden Ahmen", last_activity_at: yesterday.toISOString(), + is_logged_in: false, created_at, updated_at, }, @@ -32,6 +34,7 @@ export const userMockData: User[] = [ id: 3, username: "Gebruiker01", role: "typist_gsb", + is_logged_in: false, created_at, updated_at, }, @@ -39,6 +42,7 @@ export const userMockData: User[] = [ id: 4, username: "Gebruiker02", role: "typist_gsb", + is_logged_in: false, created_at, updated_at, }, @@ -46,6 +50,7 @@ export const userMockData: User[] = [ id: 5, username: "Gebruiker03", role: "typist_gsb", + is_logged_in: false, created_at, updated_at, }, diff --git a/frontend/src/types/generated/openapi.ts b/frontend/src/types/generated/openapi.ts index 76856735ed..5eda4a091a 100644 --- a/frontend/src/types/generated/openapi.ts +++ b/frontend/src/types/generated/openapi.ts @@ -1578,6 +1578,7 @@ export interface User { created_at: string; fullname?: string; id: UserId; + is_logged_in: boolean; last_activity_at?: string; role: Role; updated_at: string;