mas_handlers/admin/v1/user_sessions/
list.rs

1// Copyright 2025 New Vector Ltd.
2//
3// SPDX-License-Identifier: AGPL-3.0-only
4// Please see LICENSE in the repository root for full details.
5
6use aide::{OperationIo, transform::TransformOperation};
7use axum::{
8    Json,
9    extract::{Query, rejection::QueryRejection},
10    response::IntoResponse,
11};
12use axum_macros::FromRequestParts;
13use hyper::StatusCode;
14use mas_axum_utils::record_error;
15use mas_storage::{pagination::Page, user::BrowserSessionFilter};
16use schemars::JsonSchema;
17use serde::Deserialize;
18use ulid::Ulid;
19
20use crate::{
21    admin::{
22        call_context::CallContext,
23        model::{Resource, UserSession},
24        params::Pagination,
25        response::{ErrorResponse, PaginatedResponse},
26    },
27    impl_from_error_for_route,
28};
29
30#[derive(Deserialize, JsonSchema, Clone, Copy)]
31#[serde(rename_all = "snake_case")]
32enum UserSessionStatus {
33    Active,
34    Finished,
35}
36
37impl std::fmt::Display for UserSessionStatus {
38    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
39        match self {
40            Self::Active => write!(f, "active"),
41            Self::Finished => write!(f, "finished"),
42        }
43    }
44}
45
46#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)]
47#[serde(rename = "UserSessionFilter")]
48#[aide(input_with = "Query<FilterParams>")]
49#[from_request(via(Query), rejection(RouteError))]
50pub struct FilterParams {
51    /// Retrieve the items for the given user
52    #[serde(rename = "filter[user]")]
53    #[schemars(with = "Option<crate::admin::schema::Ulid>")]
54    user: Option<Ulid>,
55
56    /// Retrieve the items with the given status
57    ///
58    /// Defaults to retrieve all sessions, including finished ones.
59    ///
60    /// * `active`: Only retrieve active sessions
61    ///
62    /// * `finished`: Only retrieve finished sessions
63    #[serde(rename = "filter[status]")]
64    status: Option<UserSessionStatus>,
65}
66
67impl std::fmt::Display for FilterParams {
68    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69        let mut sep = '?';
70
71        if let Some(user) = self.user {
72            write!(f, "{sep}filter[user]={user}")?;
73            sep = '&';
74        }
75
76        if let Some(status) = self.status {
77            write!(f, "{sep}filter[status]={status}")?;
78            sep = '&';
79        }
80
81        let _ = sep;
82        Ok(())
83    }
84}
85
86#[derive(Debug, thiserror::Error, OperationIo)]
87#[aide(output_with = "Json<ErrorResponse>")]
88pub enum RouteError {
89    #[error(transparent)]
90    Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
91
92    #[error("User ID {0} not found")]
93    UserNotFound(Ulid),
94
95    #[error("Invalid filter parameters")]
96    InvalidFilter(#[from] QueryRejection),
97}
98
99impl_from_error_for_route!(mas_storage::RepositoryError);
100
101impl IntoResponse for RouteError {
102    fn into_response(self) -> axum::response::Response {
103        let error = ErrorResponse::from_error(&self);
104        let sentry_event_id = record_error!(self, Self::Internal(_));
105        let status = match self {
106            Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
107            Self::UserNotFound(_) => StatusCode::NOT_FOUND,
108            Self::InvalidFilter(_) => StatusCode::BAD_REQUEST,
109        };
110        (status, sentry_event_id, Json(error)).into_response()
111    }
112}
113
114pub fn doc(operation: TransformOperation) -> TransformOperation {
115    operation
116        .id("listUserSessions")
117        .summary("List user sessions")
118        .description("Retrieve a list of user sessions (browser sessions).
119Note that by default, all sessions, including finished ones are returned, with the oldest first.
120Use the `filter[status]` parameter to filter the sessions by their status and `page[last]` parameter to retrieve the last N sessions.")
121        .tag("user-session")
122        .response_with::<200, Json<PaginatedResponse<UserSession>>, _>(|t| {
123            let sessions = UserSession::samples();
124            let pagination = mas_storage::Pagination::first(sessions.len());
125            let page = Page {
126                edges: sessions.into(),
127                has_next_page: true,
128                has_previous_page: false,
129            };
130
131            t.description("Paginated response of user sessions")
132                .example(PaginatedResponse::new(
133                    page,
134                    pagination,
135                    42,
136                    UserSession::PATH,
137                ))
138        })
139        .response_with::<404, RouteError, _>(|t| {
140            let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil()));
141            t.description("User was not found").example(response)
142        })
143}
144
145#[tracing::instrument(name = "handler.admin.v1.user_sessions.list", skip_all)]
146pub async fn handler(
147    CallContext { mut repo, .. }: CallContext,
148    Pagination(pagination): Pagination,
149    params: FilterParams,
150) -> Result<Json<PaginatedResponse<UserSession>>, RouteError> {
151    let base = format!("{path}{params}", path = UserSession::PATH);
152    let filter = BrowserSessionFilter::default();
153
154    // Load the user from the filter
155    let user = if let Some(user_id) = params.user {
156        let user = repo
157            .user()
158            .lookup(user_id)
159            .await?
160            .ok_or(RouteError::UserNotFound(user_id))?;
161
162        Some(user)
163    } else {
164        None
165    };
166
167    let filter = match &user {
168        Some(user) => filter.for_user(user),
169        None => filter,
170    };
171
172    let filter = match params.status {
173        Some(UserSessionStatus::Active) => filter.active_only(),
174        Some(UserSessionStatus::Finished) => filter.finished_only(),
175        None => filter,
176    };
177
178    let page = repo.browser_session().list(filter, pagination).await?;
179    let count = repo.browser_session().count(filter).await?;
180
181    Ok(Json(PaginatedResponse::new(
182        page.map(UserSession::from),
183        pagination,
184        count,
185        &base,
186    )))
187}
188
189#[cfg(test)]
190mod tests {
191    use chrono::Duration;
192    use hyper::{Request, StatusCode};
193    use insta::assert_json_snapshot;
194    use sqlx::PgPool;
195
196    use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
197
198    #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
199    async fn test_user_session_list(pool: PgPool) {
200        setup();
201        let mut state = TestState::from_pool(pool).await.unwrap();
202        let token = state.token_with_scope("urn:mas:admin").await;
203        let mut rng = state.rng();
204
205        // Provision two users, one user session for each, and finish one of them
206        let mut repo = state.repository().await.unwrap();
207        let alice = repo
208            .user()
209            .add(&mut rng, &state.clock, "alice".to_owned())
210            .await
211            .unwrap();
212        state.clock.advance(Duration::minutes(1));
213
214        let bob = repo
215            .user()
216            .add(&mut rng, &state.clock, "bob".to_owned())
217            .await
218            .unwrap();
219
220        repo.browser_session()
221            .add(&mut rng, &state.clock, &alice, None)
222            .await
223            .unwrap();
224
225        let session = repo
226            .browser_session()
227            .add(&mut rng, &state.clock, &bob, None)
228            .await
229            .unwrap();
230        state.clock.advance(Duration::minutes(1));
231        repo.browser_session()
232            .finish(&state.clock, session)
233            .await
234            .unwrap();
235
236        repo.save().await.unwrap();
237
238        let request = Request::get("/api/admin/v1/user-sessions")
239            .bearer(&token)
240            .empty();
241        let response = state.request(request).await;
242        response.assert_status(StatusCode::OK);
243        let body: serde_json::Value = response.json();
244        assert_json_snapshot!(body, @r###"
245        {
246          "meta": {
247            "count": 2
248          },
249          "data": [
250            {
251              "type": "user-session",
252              "id": "01FSHNB5309NMZYX8MFYH578R9",
253              "attributes": {
254                "created_at": "2022-01-16T14:41:00Z",
255                "finished_at": null,
256                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
257                "user_agent": null,
258                "last_active_at": null,
259                "last_active_ip": null
260              },
261              "links": {
262                "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9"
263              }
264            },
265            {
266              "type": "user-session",
267              "id": "01FSHNB530KEPHYQQXW9XPTX6Z",
268              "attributes": {
269                "created_at": "2022-01-16T14:41:00Z",
270                "finished_at": "2022-01-16T14:42:00Z",
271                "user_id": "01FSHNB530AJ6AC5HQ9X6H4RP4",
272                "user_agent": null,
273                "last_active_at": null,
274                "last_active_ip": null
275              },
276              "links": {
277                "self": "/api/admin/v1/user-sessions/01FSHNB530KEPHYQQXW9XPTX6Z"
278              }
279            }
280          ],
281          "links": {
282            "self": "/api/admin/v1/user-sessions?page[first]=10",
283            "first": "/api/admin/v1/user-sessions?page[first]=10",
284            "last": "/api/admin/v1/user-sessions?page[last]=10"
285          }
286        }
287        "###);
288
289        // Filter by user
290        let request = Request::get(format!(
291            "/api/admin/v1/user-sessions?filter[user]={}",
292            alice.id
293        ))
294        .bearer(&token)
295        .empty();
296        let response = state.request(request).await;
297        response.assert_status(StatusCode::OK);
298        let body: serde_json::Value = response.json();
299        assert_json_snapshot!(body, @r###"
300        {
301          "meta": {
302            "count": 1
303          },
304          "data": [
305            {
306              "type": "user-session",
307              "id": "01FSHNB5309NMZYX8MFYH578R9",
308              "attributes": {
309                "created_at": "2022-01-16T14:41:00Z",
310                "finished_at": null,
311                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
312                "user_agent": null,
313                "last_active_at": null,
314                "last_active_ip": null
315              },
316              "links": {
317                "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9"
318              }
319            }
320          ],
321          "links": {
322            "self": "/api/admin/v1/user-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
323            "first": "/api/admin/v1/user-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10",
324            "last": "/api/admin/v1/user-sessions?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10"
325          }
326        }
327        "###);
328
329        // Filter by status (active)
330        let request = Request::get("/api/admin/v1/user-sessions?filter[status]=active")
331            .bearer(&token)
332            .empty();
333        let response = state.request(request).await;
334        response.assert_status(StatusCode::OK);
335        let body: serde_json::Value = response.json();
336        assert_json_snapshot!(body, @r###"
337        {
338          "meta": {
339            "count": 1
340          },
341          "data": [
342            {
343              "type": "user-session",
344              "id": "01FSHNB5309NMZYX8MFYH578R9",
345              "attributes": {
346                "created_at": "2022-01-16T14:41:00Z",
347                "finished_at": null,
348                "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
349                "user_agent": null,
350                "last_active_at": null,
351                "last_active_ip": null
352              },
353              "links": {
354                "self": "/api/admin/v1/user-sessions/01FSHNB5309NMZYX8MFYH578R9"
355              }
356            }
357          ],
358          "links": {
359            "self": "/api/admin/v1/user-sessions?filter[status]=active&page[first]=10",
360            "first": "/api/admin/v1/user-sessions?filter[status]=active&page[first]=10",
361            "last": "/api/admin/v1/user-sessions?filter[status]=active&page[last]=10"
362          }
363        }
364        "###);
365
366        // Filter by status (finished)
367        let request = Request::get("/api/admin/v1/user-sessions?filter[status]=finished")
368            .bearer(&token)
369            .empty();
370        let response = state.request(request).await;
371        response.assert_status(StatusCode::OK);
372        let body: serde_json::Value = response.json();
373        assert_json_snapshot!(body, @r###"
374        {
375          "meta": {
376            "count": 1
377          },
378          "data": [
379            {
380              "type": "user-session",
381              "id": "01FSHNB530KEPHYQQXW9XPTX6Z",
382              "attributes": {
383                "created_at": "2022-01-16T14:41:00Z",
384                "finished_at": "2022-01-16T14:42:00Z",
385                "user_id": "01FSHNB530AJ6AC5HQ9X6H4RP4",
386                "user_agent": null,
387                "last_active_at": null,
388                "last_active_ip": null
389              },
390              "links": {
391                "self": "/api/admin/v1/user-sessions/01FSHNB530KEPHYQQXW9XPTX6Z"
392              }
393            }
394          ],
395          "links": {
396            "self": "/api/admin/v1/user-sessions?filter[status]=finished&page[first]=10",
397            "first": "/api/admin/v1/user-sessions?filter[status]=finished&page[first]=10",
398            "last": "/api/admin/v1/user-sessions?filter[status]=finished&page[last]=10"
399          }
400        }
401        "###);
402    }
403}