diff --git a/crates/handlers/src/admin/mod.rs b/crates/handlers/src/admin/mod.rs index dca3e0c2d..f460f0c2a 100644 --- a/crates/handlers/src/admin/mod.rs +++ b/crates/handlers/src/admin/mod.rs @@ -66,6 +66,11 @@ where description: Some("Manage users".to_owned()), ..Tag::default() }) + .tag(Tag { + name: "user-email".to_owned(), + description: Some("Manage emails associated with users".to_owned()), + ..Tag::default() + }) .security_scheme( "oauth2", SecurityScheme::OAuth2 { diff --git a/crates/handlers/src/admin/model.rs b/crates/handlers/src/admin/model.rs index 497881596..a0cbafda0 100644 --- a/crates/handlers/src/admin/model.rs +++ b/crates/handlers/src/admin/model.rs @@ -99,6 +99,54 @@ impl Resource for User { } } +/// An email address for a user +#[derive(Serialize, JsonSchema)] +pub struct UserEmail { + #[serde(skip)] + id: Ulid, + + /// When the object was created + created_at: DateTime, + + /// The ID of the user who owns this email address + #[schemars(with = "super::schema::Ulid")] + user_id: Ulid, + + /// The email address + email: String, +} + +impl Resource for UserEmail { + const KIND: &'static str = "user-email"; + const PATH: &'static str = "/api/admin/v1/user-emails"; + + fn id(&self) -> Ulid { + self.id + } +} + +impl From for UserEmail { + fn from(value: mas_data_model::UserEmail) -> Self { + Self { + id: value.id, + created_at: value.created_at, + user_id: value.user_id, + email: value.email, + } + } +} + +impl UserEmail { + pub fn samples() -> [Self; 1] { + [Self { + id: Ulid::from_bytes([0x01; 16]), + created_at: DateTime::default(), + user_id: Ulid::from_bytes([0x02; 16]), + email: "alice@example.com".to_owned(), + }] + } +} + /// A OAuth 2.0 session #[derive(Serialize, JsonSchema)] pub struct OAuth2Session { diff --git a/crates/handlers/src/admin/v1/mod.rs b/crates/handlers/src/admin/v1/mod.rs index 73060f825..d4afef951 100644 --- a/crates/handlers/src/admin/v1/mod.rs +++ b/crates/handlers/src/admin/v1/mod.rs @@ -16,6 +16,7 @@ use super::call_context::CallContext; use crate::passwords::PasswordManager; mod oauth2_sessions; +mod user_emails; mod users; pub fn router() -> ApiRouter @@ -68,4 +69,12 @@ where "/users/{id}/unlock", post_with(self::users::unlock, self::users::unlock_doc), ) + .api_route( + "/user-emails", + get_with(self::user_emails::list, self::user_emails::list_doc), + ) + .api_route( + "/user-emails/{id}", + get_with(self::user_emails::get, self::user_emails::get_doc), + ) } diff --git a/crates/handlers/src/admin/v1/user_emails/get.rs b/crates/handlers/src/admin/v1/user_emails/get.rs new file mode 100644 index 000000000..f3b85d35e --- /dev/null +++ b/crates/handlers/src/admin/v1/user_emails/get.rs @@ -0,0 +1,152 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use aide::{transform::TransformOperation, OperationIo}; +use axum::{response::IntoResponse, Json}; +use hyper::StatusCode; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::UserEmail, + params::UlidPathParam, + response::{ErrorResponse, SingleResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("OAuth 2.0 session ID {0} not found")] + NotFound(Ulid), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::NotFound(_) => StatusCode::NOT_FOUND, + }; + (status, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("getUserEmail") + .summary("Get a user email") + .tag("user-email") + .response_with::<200, Json>, _>(|t| { + let [sample, ..] = UserEmail::samples(); + let response = SingleResponse::new_canonical(sample); + t.description("User email was found").example(response) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil())); + t.description("User email was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.user_emails.get", skip_all, err)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + id: UlidPathParam, +) -> Result>, RouteError> { + let session = repo + .user_email() + .lookup(*id) + .await? + .ok_or(RouteError::NotFound(*id))?; + + Ok(Json(SingleResponse::new_canonical(UserEmail::from( + session, + )))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use sqlx::PgPool; + use ulid::Ulid; + + use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_get(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision a user and an email + let mut repo = state.repository().await.unwrap(); + let alice = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let mas_data_model::UserEmail { id, .. } = repo + .user_email() + .add( + &mut rng, + &state.clock, + &alice, + "alice@example.com".to_owned(), + ) + .await + .unwrap(); + + repo.save().await.unwrap(); + + let request = Request::get(format!("/api/admin/v1/user-emails/{id}")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + assert_eq!(body["data"]["type"], "user-email"); + insta::assert_json_snapshot!(body, @r###" + { + "data": { + "type": "user-email", + "id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "email": "alice@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + }, + "links": { + "self": "/api/admin/v1/user-emails/01FSHN9AG0AJ6AC5HQ9X6H4RP4" + } + } + "###); + } + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_not_found(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + + let session_id = Ulid::nil(); + let request = Request::get(format!("/api/admin/v1/user-emails/{session_id}")) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::NOT_FOUND); + } +} diff --git a/crates/handlers/src/admin/v1/user_emails/list.rs b/crates/handlers/src/admin/v1/user_emails/list.rs new file mode 100644 index 000000000..cd12fb6c6 --- /dev/null +++ b/crates/handlers/src/admin/v1/user_emails/list.rs @@ -0,0 +1,320 @@ +// Copyright 2024 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +use aide::{transform::TransformOperation, OperationIo}; +use axum::{ + extract::{rejection::QueryRejection, Query}, + response::IntoResponse, + Json, +}; +use axum_macros::FromRequestParts; +use hyper::StatusCode; +use mas_storage::{user::UserEmailFilter, Page}; +use schemars::JsonSchema; +use serde::Deserialize; +use ulid::Ulid; + +use crate::{ + admin::{ + call_context::CallContext, + model::{Resource, UserEmail}, + params::Pagination, + response::{ErrorResponse, PaginatedResponse}, + }, + impl_from_error_for_route, +}; + +#[derive(FromRequestParts, Deserialize, JsonSchema, OperationIo)] +#[serde(rename = "UserEmailFilter")] +#[aide(input_with = "Query")] +#[from_request(via(Query), rejection(RouteError))] +pub struct FilterParams { + /// Retrieve the items for the given user + #[serde(rename = "filter[user]")] + #[schemars(with = "Option")] + user: Option, + + /// Retrieve the user email with the given email address + #[serde(rename = "filter[email]")] + email: Option, +} + +impl std::fmt::Display for FilterParams { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut sep = '?'; + + if let Some(user) = self.user { + write!(f, "{sep}filter[user]={user}")?; + sep = '&'; + } + + if let Some(email) = &self.email { + write!(f, "{sep}filter[email]={email}")?; + sep = '&'; + } + + let _ = sep; + Ok(()) + } +} + +#[derive(Debug, thiserror::Error, OperationIo)] +#[aide(output_with = "Json")] +pub enum RouteError { + #[error(transparent)] + Internal(Box), + + #[error("User ID {0} not found")] + UserNotFound(Ulid), + + #[error("Invalid filter parameters")] + InvalidFilter(#[from] QueryRejection), +} + +impl_from_error_for_route!(mas_storage::RepositoryError); + +impl IntoResponse for RouteError { + fn into_response(self) -> axum::response::Response { + let error = ErrorResponse::from_error(&self); + let status = match self { + Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::UserNotFound(_) => StatusCode::NOT_FOUND, + Self::InvalidFilter(_) => StatusCode::BAD_REQUEST, + }; + (status, Json(error)).into_response() + } +} + +pub fn doc(operation: TransformOperation) -> TransformOperation { + operation + .id("listUserEmails") + .summary("List user emails") + .description("Retrieve a list of user emails.") + .tag("user-email") + .response_with::<200, Json>, _>(|t| { + let emails = UserEmail::samples(); + let pagination = mas_storage::Pagination::first(emails.len()); + let page = Page { + edges: emails.into(), + has_next_page: true, + has_previous_page: false, + }; + + t.description("Paginated response of user emails") + .example(PaginatedResponse::new( + page, + pagination, + 42, + UserEmail::PATH, + )) + }) + .response_with::<404, RouteError, _>(|t| { + let response = ErrorResponse::from_error(&RouteError::UserNotFound(Ulid::nil())); + t.description("User was not found").example(response) + }) +} + +#[tracing::instrument(name = "handler.admin.v1.user_emails.list", skip_all, err)] +pub async fn handler( + CallContext { mut repo, .. }: CallContext, + Pagination(pagination): Pagination, + params: FilterParams, +) -> Result>, RouteError> { + let base = format!("{path}{params}", path = UserEmail::PATH); + let filter = UserEmailFilter::default(); + + // Load the user from the filter + let user = if let Some(user_id) = params.user { + let user = repo + .user() + .lookup(user_id) + .await? + .ok_or(RouteError::UserNotFound(user_id))?; + + Some(user) + } else { + None + }; + + let filter = match &user { + Some(user) => filter.for_user(user), + None => filter, + }; + + let filter = match ¶ms.email { + Some(email) => filter.for_email(email), + None => filter, + }; + + let page = repo.user_email().list(filter, pagination).await?; + let count = repo.user_email().count(filter).await?; + + Ok(Json(PaginatedResponse::new( + page.map(UserEmail::from), + pagination, + count, + &base, + ))) +} + +#[cfg(test)] +mod tests { + use hyper::{Request, StatusCode}; + use sqlx::PgPool; + + use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState}; + + #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] + async fn test_list(pool: PgPool) { + setup(); + let mut state = TestState::from_pool(pool).await.unwrap(); + let token = state.token_with_scope("urn:mas:admin").await; + let mut rng = state.rng(); + + // Provision two users, two emails + let mut repo = state.repository().await.unwrap(); + let alice = repo + .user() + .add(&mut rng, &state.clock, "alice".to_owned()) + .await + .unwrap(); + let bob = repo + .user() + .add(&mut rng, &state.clock, "bob".to_owned()) + .await + .unwrap(); + + repo.user_email() + .add( + &mut rng, + &state.clock, + &alice, + "alice@example.com".to_owned(), + ) + .await + .unwrap(); + repo.user_email() + .add(&mut rng, &state.clock, &bob, "bob@example.com".to_owned()) + .await + .unwrap(); + repo.save().await.unwrap(); + + let request = Request::get("/api/admin/v1/user-emails") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 2 + }, + "data": [ + { + "type": "user-email", + "id": "01FSHN9AG09NMZYX8MFYH578R9", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "email": "alice@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9" + } + }, + { + "type": "user-email", + "id": "01FSHN9AG0KEPHYQQXW9XPTX6Z", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "user_id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4", + "email": "bob@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01FSHN9AG0KEPHYQQXW9XPTX6Z" + } + } + ], + "links": { + "self": "/api/admin/v1/user-emails?page[first]=10", + "first": "/api/admin/v1/user-emails?page[first]=10", + "last": "/api/admin/v1/user-emails?page[last]=10" + } + } + "###); + + // Filter by user + let request = Request::get(format!( + "/api/admin/v1/user-emails?filter[user]={}", + alice.id + )) + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 1 + }, + "data": [ + { + "type": "user-email", + "id": "01FSHN9AG09NMZYX8MFYH578R9", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "email": "alice@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9" + } + } + ], + "links": { + "self": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10", + "first": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[first]=10", + "last": "/api/admin/v1/user-emails?filter[user]=01FSHN9AG0MZAA6S4AF7CTV32E&page[last]=10" + } + } + "###); + + // Filter by email + let request = Request::get("/api/admin/v1/user-emails?filter[email]=alice@example.com") + .bearer(&token) + .empty(); + let response = state.request(request).await; + response.assert_status(StatusCode::OK); + let body: serde_json::Value = response.json(); + insta::assert_json_snapshot!(body, @r###" + { + "meta": { + "count": 1 + }, + "data": [ + { + "type": "user-email", + "id": "01FSHN9AG09NMZYX8MFYH578R9", + "attributes": { + "created_at": "2022-01-16T14:40:00Z", + "user_id": "01FSHN9AG0MZAA6S4AF7CTV32E", + "email": "alice@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01FSHN9AG09NMZYX8MFYH578R9" + } + } + ], + "links": { + "self": "/api/admin/v1/user-emails?filter[email]=alice@example.com&page[first]=10", + "first": "/api/admin/v1/user-emails?filter[email]=alice@example.com&page[first]=10", + "last": "/api/admin/v1/user-emails?filter[email]=alice@example.com&page[last]=10" + } + } + "###); + } +} diff --git a/crates/handlers/src/admin/v1/user_emails/mod.rs b/crates/handlers/src/admin/v1/user_emails/mod.rs new file mode 100644 index 000000000..23c05c416 --- /dev/null +++ b/crates/handlers/src/admin/v1/user_emails/mod.rs @@ -0,0 +1,12 @@ +// Copyright 2025 New Vector Ltd. +// +// SPDX-License-Identifier: AGPL-3.0-only +// Please see LICENSE in the repository root for full details. + +mod get; +mod list; + +pub use self::{ + get::{doc as get_doc, handler as get}, + list::{doc as list_doc, handler as list}, +}; diff --git a/docs/api/spec.json b/docs/api/spec.json index e2dc48831..d6e774ee5 100644 --- a/docs/api/spec.json +++ b/docs/api/spec.json @@ -1061,6 +1061,209 @@ } } } + }, + "/api/admin/v1/user-emails": { + "get": { + "tags": [ + "user-email" + ], + "summary": "List user emails", + "description": "Retrieve a list of user emails.", + "operationId": "listUserEmails", + "parameters": [ + { + "in": "query", + "name": "page[before]", + "description": "Retrieve the items before the given ID", + "schema": { + "description": "Retrieve the items before the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[after]", + "description": "Retrieve the items after the given ID", + "schema": { + "description": "Retrieve the items after the given ID", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[first]", + "description": "Retrieve the first N items", + "schema": { + "description": "Retrieve the first N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "page[last]", + "description": "Retrieve the last N items", + "schema": { + "description": "Retrieve the last N items", + "type": "integer", + "format": "uint", + "minimum": 1.0, + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[user]", + "description": "Retrieve the items for the given user", + "schema": { + "description": "Retrieve the items for the given user", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "style": "form" + }, + { + "in": "query", + "name": "filter[email]", + "description": "Retrieve the user email with the given email address", + "schema": { + "description": "Retrieve the user email with the given email address", + "type": "string", + "nullable": true + }, + "style": "form" + } + ], + "responses": { + "200": { + "description": "Paginated response of user emails", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PaginatedResponse_for_UserEmail" + }, + "example": { + "meta": { + "count": 42 + }, + "data": [ + { + "type": "user-email", + "id": "01040G2081040G2081040G2081", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "user_id": "02081040G2081040G2081040G2", + "email": "alice@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01040G2081040G2081040G2081" + } + } + ], + "links": { + "self": "/api/admin/v1/user-emails?page[first]=1", + "first": "/api/admin/v1/user-emails?page[first]=1", + "last": "/api/admin/v1/user-emails?page[last]=1", + "next": "/api/admin/v1/user-emails?page[after]=01040G2081040G2081040G2081&page[first]=1" + } + } + } + } + }, + "404": { + "description": "User was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "User ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } + }, + "/api/admin/v1/user-emails/{id}": { + "get": { + "tags": [ + "user-email" + ], + "summary": "Get a user email", + "operationId": "getUserEmail", + "parameters": [ + { + "in": "path", + "name": "id", + "required": true, + "schema": { + "title": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "description": "User email was found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SingleResponse_for_UserEmail" + }, + "example": { + "data": { + "type": "user-email", + "id": "01040G2081040G2081040G2081", + "attributes": { + "created_at": "1970-01-01T00:00:00Z", + "user_id": "02081040G2081040G2081040G2", + "email": "alice@example.com" + }, + "links": { + "self": "/api/admin/v1/user-emails/01040G2081040G2081040G2081" + } + }, + "links": { + "self": "/api/admin/v1/user-emails/01040G2081040G2081040G2081" + } + } + } + } + }, + "404": { + "description": "User email was not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + }, + "example": { + "errors": [ + { + "title": "OAuth 2.0 session ID 00000000000000000000000000 not found" + } + ] + } + } + } + } + } + } } }, "components": { @@ -1579,6 +1782,115 @@ "type": "boolean" } } + }, + "UserEmailFilter": { + "type": "object", + "properties": { + "filter[user]": { + "description": "Retrieve the items for the given user", + "$ref": "#/components/schemas/ULID", + "nullable": true + }, + "filter[email]": { + "description": "Retrieve the user email with the given email address", + "type": "string", + "nullable": true + } + } + }, + "PaginatedResponse_for_UserEmail": { + "description": "A top-level response with a page of resources", + "type": "object", + "required": [ + "data", + "links", + "meta" + ], + "properties": { + "meta": { + "description": "Response metadata", + "$ref": "#/components/schemas/PaginationMeta" + }, + "data": { + "description": "The list of resources", + "type": "array", + "items": { + "$ref": "#/components/schemas/SingleResource_for_UserEmail" + } + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/PaginationLinks" + } + } + }, + "SingleResource_for_UserEmail": { + "description": "A single resource, with its type, ID, attributes and related links", + "type": "object", + "required": [ + "attributes", + "id", + "links", + "type" + ], + "properties": { + "type": { + "description": "The type of the resource", + "type": "string" + }, + "id": { + "description": "The ID of the resource", + "$ref": "#/components/schemas/ULID" + }, + "attributes": { + "description": "The attributes of the resource", + "$ref": "#/components/schemas/UserEmail" + }, + "links": { + "description": "Related links", + "$ref": "#/components/schemas/SelfLinks" + } + } + }, + "UserEmail": { + "description": "An email address for a user", + "type": "object", + "required": [ + "created_at", + "email", + "user_id" + ], + "properties": { + "created_at": { + "description": "When the object was created", + "type": "string", + "format": "date-time" + }, + "user_id": { + "description": "The ID of the user who owns this email address", + "$ref": "#/components/schemas/ULID" + }, + "email": { + "description": "The email address", + "type": "string" + } + } + }, + "SingleResponse_for_UserEmail": { + "description": "A top-level response with a single resource", + "type": "object", + "required": [ + "data", + "links" + ], + "properties": { + "data": { + "$ref": "#/components/schemas/SingleResource_for_UserEmail" + }, + "links": { + "$ref": "#/components/schemas/SelfLinks" + } + } } } }, @@ -1597,6 +1909,10 @@ { "name": "user", "description": "Manage users" + }, + { + "name": "user-email", + "description": "Manage emails associated with users" } ] }