Skip to content

Commit 7ade439

Browse files
committed
Admin API to list and get user emails
1 parent d45350f commit 7ade439

File tree

7 files changed

+862
-0
lines changed

7 files changed

+862
-0
lines changed

crates/handlers/src/admin/mod.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ where
6666
description: Some("Manage users".to_owned()),
6767
..Tag::default()
6868
})
69+
.tag(Tag {
70+
name: "user-email".to_owned(),
71+
description: Some("Manage emails associated with users".to_owned()),
72+
..Tag::default()
73+
})
6974
.security_scheme(
7075
"oauth2",
7176
SecurityScheme::OAuth2 {

crates/handlers/src/admin/model.rs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,54 @@ impl Resource for User {
9999
}
100100
}
101101

102+
/// An email address for a user
103+
#[derive(Serialize, JsonSchema)]
104+
pub struct UserEmail {
105+
#[serde(skip)]
106+
id: Ulid,
107+
108+
/// When the object was created
109+
created_at: DateTime<Utc>,
110+
111+
/// The ID of the user who owns this email address
112+
#[schemars(with = "super::schema::Ulid")]
113+
user_id: Ulid,
114+
115+
/// The email address
116+
email: String,
117+
}
118+
119+
impl Resource for UserEmail {
120+
const KIND: &'static str = "user-email";
121+
const PATH: &'static str = "/api/admin/v1/user-emails";
122+
123+
fn id(&self) -> Ulid {
124+
self.id
125+
}
126+
}
127+
128+
impl From<mas_data_model::UserEmail> for UserEmail {
129+
fn from(value: mas_data_model::UserEmail) -> Self {
130+
Self {
131+
id: value.id,
132+
created_at: value.created_at,
133+
user_id: value.user_id,
134+
email: value.email,
135+
}
136+
}
137+
}
138+
139+
impl UserEmail {
140+
pub fn samples() -> [Self; 1] {
141+
[Self {
142+
id: Ulid::from_bytes([0x01; 16]),
143+
created_at: DateTime::default(),
144+
user_id: Ulid::from_bytes([0x02; 16]),
145+
email: "[email protected]".to_owned(),
146+
}]
147+
}
148+
}
149+
102150
/// A OAuth 2.0 session
103151
#[derive(Serialize, JsonSchema)]
104152
pub struct OAuth2Session {

crates/handlers/src/admin/v1/mod.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ use super::call_context::CallContext;
1616
use crate::passwords::PasswordManager;
1717

1818
mod oauth2_sessions;
19+
mod user_emails;
1920
mod users;
2021

2122
pub fn router<S>() -> ApiRouter<S>
@@ -68,4 +69,12 @@ where
6869
"/users/{id}/unlock",
6970
post_with(self::users::unlock, self::users::unlock_doc),
7071
)
72+
.api_route(
73+
"/user-emails",
74+
get_with(self::user_emails::list, self::user_emails::list_doc),
75+
)
76+
.api_route(
77+
"/user-emails/{id}",
78+
get_with(self::user_emails::get, self::user_emails::get_doc),
79+
)
7180
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
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+
6+
use aide::{transform::TransformOperation, OperationIo};
7+
use axum::{response::IntoResponse, Json};
8+
use hyper::StatusCode;
9+
use ulid::Ulid;
10+
11+
use crate::{
12+
admin::{
13+
call_context::CallContext,
14+
model::UserEmail,
15+
params::UlidPathParam,
16+
response::{ErrorResponse, SingleResponse},
17+
},
18+
impl_from_error_for_route,
19+
};
20+
21+
#[derive(Debug, thiserror::Error, OperationIo)]
22+
#[aide(output_with = "Json<ErrorResponse>")]
23+
pub enum RouteError {
24+
#[error(transparent)]
25+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
26+
27+
#[error("OAuth 2.0 session ID {0} not found")]
28+
NotFound(Ulid),
29+
}
30+
31+
impl_from_error_for_route!(mas_storage::RepositoryError);
32+
33+
impl IntoResponse for RouteError {
34+
fn into_response(self) -> axum::response::Response {
35+
let error = ErrorResponse::from_error(&self);
36+
let status = match self {
37+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
38+
Self::NotFound(_) => StatusCode::NOT_FOUND,
39+
};
40+
(status, Json(error)).into_response()
41+
}
42+
}
43+
44+
pub fn doc(operation: TransformOperation) -> TransformOperation {
45+
operation
46+
.id("getUserEmail")
47+
.summary("Get a user email")
48+
.tag("user-email")
49+
.response_with::<200, Json<SingleResponse<UserEmail>>, _>(|t| {
50+
let [sample, ..] = UserEmail::samples();
51+
let response = SingleResponse::new_canonical(sample);
52+
t.description("User email was found").example(response)
53+
})
54+
.response_with::<404, RouteError, _>(|t| {
55+
let response = ErrorResponse::from_error(&RouteError::NotFound(Ulid::nil()));
56+
t.description("User email was not found").example(response)
57+
})
58+
}
59+
60+
#[tracing::instrument(name = "handler.admin.v1.user_emails.get", skip_all, err)]
61+
pub async fn handler(
62+
CallContext { mut repo, .. }: CallContext,
63+
id: UlidPathParam,
64+
) -> Result<Json<SingleResponse<UserEmail>>, RouteError> {
65+
let session = repo
66+
.user_email()
67+
.lookup(*id)
68+
.await?
69+
.ok_or(RouteError::NotFound(*id))?;
70+
71+
Ok(Json(SingleResponse::new_canonical(UserEmail::from(
72+
session,
73+
))))
74+
}
75+
76+
#[cfg(test)]
77+
mod tests {
78+
use hyper::{Request, StatusCode};
79+
use sqlx::PgPool;
80+
use ulid::Ulid;
81+
82+
use crate::test_utils::{setup, RequestBuilderExt, ResponseExt, TestState};
83+
84+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
85+
async fn test_get(pool: PgPool) {
86+
setup();
87+
let mut state = TestState::from_pool(pool).await.unwrap();
88+
let token = state.token_with_scope("urn:mas:admin").await;
89+
let mut rng = state.rng();
90+
91+
// Provision a user and an email
92+
let mut repo = state.repository().await.unwrap();
93+
let alice = repo
94+
.user()
95+
.add(&mut rng, &state.clock, "alice".to_owned())
96+
.await
97+
.unwrap();
98+
let mas_data_model::UserEmail { id, .. } = repo
99+
.user_email()
100+
.add(
101+
&mut rng,
102+
&state.clock,
103+
&alice,
104+
"[email protected]".to_owned(),
105+
)
106+
.await
107+
.unwrap();
108+
109+
repo.save().await.unwrap();
110+
111+
let request = Request::get(format!("/api/admin/v1/user-emails/{id}"))
112+
.bearer(&token)
113+
.empty();
114+
let response = state.request(request).await;
115+
response.assert_status(StatusCode::OK);
116+
let body: serde_json::Value = response.json();
117+
assert_eq!(body["data"]["type"], "user-email");
118+
insta::assert_json_snapshot!(body, @r###"
119+
{
120+
"data": {
121+
"type": "user-email",
122+
"id": "01FSHN9AG0AJ6AC5HQ9X6H4RP4",
123+
"attributes": {
124+
"created_at": "2022-01-16T14:40:00Z",
125+
"user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
126+
"email": "[email protected]"
127+
},
128+
"links": {
129+
"self": "/api/admin/v1/user-emails/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
130+
}
131+
},
132+
"links": {
133+
"self": "/api/admin/v1/user-emails/01FSHN9AG0AJ6AC5HQ9X6H4RP4"
134+
}
135+
}
136+
"###);
137+
}
138+
139+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
140+
async fn test_not_found(pool: PgPool) {
141+
setup();
142+
let mut state = TestState::from_pool(pool).await.unwrap();
143+
let token = state.token_with_scope("urn:mas:admin").await;
144+
145+
let session_id = Ulid::nil();
146+
let request = Request::get(format!("/api/admin/v1/user-emails/{session_id}"))
147+
.bearer(&token)
148+
.empty();
149+
let response = state.request(request).await;
150+
response.assert_status(StatusCode::NOT_FOUND);
151+
}
152+
}

0 commit comments

Comments
 (0)