mas_handlers/admin/v1/user_sessions/
list.rs1use 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 #[serde(rename = "filter[user]")]
53 #[schemars(with = "Option<crate::admin::schema::Ulid>")]
54 user: Option<Ulid>,
55
56 #[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 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 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 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 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 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}