Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions crates/handlers/src/admin/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
48 changes: 48 additions & 0 deletions crates/handlers/src/admin/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Utc>,

/// 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<mas_data_model::UserEmail> 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: "[email protected]".to_owned(),
}]
}
}

/// A OAuth 2.0 session
#[derive(Serialize, JsonSchema)]
pub struct OAuth2Session {
Expand Down
9 changes: 9 additions & 0 deletions crates/handlers/src/admin/v1/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ use super::call_context::CallContext;
use crate::passwords::PasswordManager;

mod oauth2_sessions;
mod user_emails;
mod users;

pub fn router<S>() -> ApiRouter<S>
Expand Down Expand Up @@ -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),
)
}
152 changes: 152 additions & 0 deletions crates/handlers/src/admin/v1/user_emails/get.rs
Original file line number Diff line number Diff line change
@@ -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<ErrorResponse>")]
pub enum RouteError {
#[error(transparent)]
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),

#[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<SingleResponse<UserEmail>>, _>(|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<Json<SingleResponse<UserEmail>>, 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,
"[email protected]".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": "[email protected]"
},
"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);
}
}
Loading
Loading