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