mas_handlers/graphql/model/
users.rs

1// Copyright 2024, 2025 New Vector Ltd.
2// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
3//
4// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
5// Please see LICENSE files in the repository root for full details.
6
7use anyhow::Context as _;
8use async_graphql::{
9    Context, Description, Enum, ID, Object, Union,
10    connection::{Connection, Edge, OpaqueCursor, query},
11};
12use chrono::{DateTime, Utc};
13use mas_data_model::Device;
14use mas_storage::{
15    Pagination, RepositoryAccess,
16    app_session::AppSessionFilter,
17    compat::{CompatSessionFilter, CompatSsoLoginFilter, CompatSsoLoginRepository},
18    oauth2::{OAuth2SessionFilter, OAuth2SessionRepository},
19    upstream_oauth2::{UpstreamOAuthLinkFilter, UpstreamOAuthLinkRepository},
20    user::{BrowserSessionFilter, BrowserSessionRepository, UserEmailFilter, UserEmailRepository},
21};
22
23use super::{
24    BrowserSession, CompatSession, Cursor, NodeCursor, NodeType, OAuth2Session,
25    PreloadedTotalCount, SessionState, UpstreamOAuth2Link,
26    compat_sessions::{CompatSessionType, CompatSsoLogin},
27    matrix::MatrixUser,
28};
29use crate::graphql::{DateFilter, state::ContextExt};
30
31#[derive(Description)]
32/// A user is an individual's account.
33pub struct User(pub mas_data_model::User);
34
35impl From<mas_data_model::User> for User {
36    fn from(v: mas_data_model::User) -> Self {
37        Self(v)
38    }
39}
40
41impl From<mas_data_model::BrowserSession> for User {
42    fn from(v: mas_data_model::BrowserSession) -> Self {
43        Self(v.user)
44    }
45}
46
47#[Object(use_type_description)]
48impl User {
49    /// ID of the object.
50    pub async fn id(&self) -> ID {
51        NodeType::User.id(self.0.id)
52    }
53
54    /// Username chosen by the user.
55    async fn username(&self) -> &str {
56        &self.0.username
57    }
58
59    /// When the object was created.
60    pub async fn created_at(&self) -> DateTime<Utc> {
61        self.0.created_at
62    }
63
64    /// When the user was locked out.
65    pub async fn locked_at(&self) -> Option<DateTime<Utc>> {
66        self.0.locked_at
67    }
68
69    /// Whether the user can request admin privileges.
70    pub async fn can_request_admin(&self) -> bool {
71        self.0.can_request_admin
72    }
73
74    /// Access to the user's Matrix account information.
75    async fn matrix(&self, ctx: &Context<'_>) -> Result<MatrixUser, async_graphql::Error> {
76        let state = ctx.state();
77        let conn = state.homeserver_connection();
78        Ok(MatrixUser::load(conn, &self.0.username).await?)
79    }
80
81    /// Get the list of compatibility SSO logins, chronologically sorted
82    async fn compat_sso_logins(
83        &self,
84        ctx: &Context<'_>,
85
86        #[graphql(desc = "Returns the elements in the list that come after the cursor.")]
87        after: Option<String>,
88        #[graphql(desc = "Returns the elements in the list that come before the cursor.")]
89        before: Option<String>,
90        #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option<i32>,
91        #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option<i32>,
92    ) -> Result<Connection<Cursor, CompatSsoLogin, PreloadedTotalCount>, async_graphql::Error> {
93        let state = ctx.state();
94        let mut repo = state.repository().await?;
95
96        query(
97            after,
98            before,
99            first,
100            last,
101            async |after, before, first, last| {
102                let after_id = after
103                    .map(|x: OpaqueCursor<NodeCursor>| x.extract_for_type(NodeType::CompatSsoLogin))
104                    .transpose()?;
105                let before_id = before
106                    .map(|x: OpaqueCursor<NodeCursor>| x.extract_for_type(NodeType::CompatSsoLogin))
107                    .transpose()?;
108                let pagination = Pagination::try_new(before_id, after_id, first, last)?;
109
110                let filter = CompatSsoLoginFilter::new().for_user(&self.0);
111
112                let page = repo.compat_sso_login().list(filter, pagination).await?;
113
114                // Preload the total count if requested
115                let count = if ctx.look_ahead().field("totalCount").exists() {
116                    Some(repo.compat_sso_login().count(filter).await?)
117                } else {
118                    None
119                };
120
121                repo.cancel().await?;
122
123                let mut connection = Connection::with_additional_fields(
124                    page.has_previous_page,
125                    page.has_next_page,
126                    PreloadedTotalCount(count),
127                );
128                connection.edges.extend(page.edges.into_iter().map(|edge| {
129                    Edge::new(
130                        OpaqueCursor(NodeCursor(NodeType::CompatSsoLogin, edge.cursor)),
131                        CompatSsoLogin(edge.node),
132                    )
133                }));
134
135                Ok::<_, async_graphql::Error>(connection)
136            },
137        )
138        .await
139    }
140
141    /// Get the list of compatibility sessions, chronologically sorted
142    #[allow(clippy::too_many_arguments)]
143    async fn compat_sessions(
144        &self,
145        ctx: &Context<'_>,
146
147        #[graphql(name = "state", desc = "List only sessions with the given state.")]
148        state_param: Option<SessionState>,
149
150        #[graphql(name = "type", desc = "List only sessions with the given type.")]
151        type_param: Option<CompatSessionType>,
152
153        #[graphql(
154            name = "lastActive",
155            desc = "List only sessions with a last active time is between the given bounds."
156        )]
157        last_active: Option<DateFilter>,
158
159        #[graphql(desc = "Returns the elements in the list that come after the cursor.")]
160        after: Option<String>,
161        #[graphql(desc = "Returns the elements in the list that come before the cursor.")]
162        before: Option<String>,
163        #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option<i32>,
164        #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option<i32>,
165    ) -> Result<Connection<Cursor, CompatSession, PreloadedTotalCount>, async_graphql::Error> {
166        let state = ctx.state();
167        let mut repo = state.repository().await?;
168        let last_active = last_active.unwrap_or_default();
169
170        query(
171            after,
172            before,
173            first,
174            last,
175            async |after, before, first, last| {
176                let after_id = after
177                    .map(|x: OpaqueCursor<NodeCursor>| x.extract_for_type(NodeType::CompatSession))
178                    .transpose()?;
179                let before_id = before
180                    .map(|x: OpaqueCursor<NodeCursor>| x.extract_for_type(NodeType::CompatSession))
181                    .transpose()?;
182                let pagination = Pagination::try_new(before_id, after_id, first, last)?;
183
184                // Build the query filter
185                let filter = CompatSessionFilter::new().for_user(&self.0);
186                let filter = match state_param {
187                    Some(SessionState::Active) => filter.active_only(),
188                    Some(SessionState::Finished) => filter.finished_only(),
189                    None => filter,
190                };
191                let filter = match type_param {
192                    Some(CompatSessionType::SsoLogin) => filter.sso_login_only(),
193                    Some(CompatSessionType::Unknown) => filter.unknown_only(),
194                    None => filter,
195                };
196
197                let filter = match last_active.after {
198                    Some(after) => filter.with_last_active_after(after),
199                    None => filter,
200                };
201                let filter = match last_active.before {
202                    Some(before) => filter.with_last_active_before(before),
203                    None => filter,
204                };
205
206                let page = repo.compat_session().list(filter, pagination).await?;
207
208                // Preload the total count if requested
209                let count = if ctx.look_ahead().field("totalCount").exists() {
210                    Some(repo.compat_session().count(filter).await?)
211                } else {
212                    None
213                };
214
215                repo.cancel().await?;
216
217                let mut connection = Connection::with_additional_fields(
218                    page.has_previous_page,
219                    page.has_next_page,
220                    PreloadedTotalCount(count),
221                );
222                connection.edges.extend(page.edges.into_iter().map(|edge| {
223                    let (session, sso_login) = edge.node;
224                    Edge::new(
225                        OpaqueCursor(NodeCursor(NodeType::CompatSession, session.id)),
226                        CompatSession::new(session).with_loaded_sso_login(sso_login),
227                    )
228                }));
229
230                Ok::<_, async_graphql::Error>(connection)
231            },
232        )
233        .await
234    }
235
236    /// Get the list of active browser sessions, chronologically sorted
237    async fn browser_sessions(
238        &self,
239        ctx: &Context<'_>,
240
241        #[graphql(name = "state", desc = "List only sessions in the given state.")]
242        state_param: Option<SessionState>,
243
244        #[graphql(
245            name = "lastActive",
246            desc = "List only sessions with a last active time is between the given bounds."
247        )]
248        last_active: Option<DateFilter>,
249
250        #[graphql(desc = "Returns the elements in the list that come after the cursor.")]
251        after: Option<String>,
252        #[graphql(desc = "Returns the elements in the list that come before the cursor.")]
253        before: Option<String>,
254        #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option<i32>,
255        #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option<i32>,
256    ) -> Result<Connection<Cursor, BrowserSession, PreloadedTotalCount>, async_graphql::Error> {
257        let state = ctx.state();
258        let mut repo = state.repository().await?;
259        let last_active = last_active.unwrap_or_default();
260
261        query(
262            after,
263            before,
264            first,
265            last,
266            async |after, before, first, last| {
267                let after_id = after
268                    .map(|x: OpaqueCursor<NodeCursor>| x.extract_for_type(NodeType::BrowserSession))
269                    .transpose()?;
270                let before_id = before
271                    .map(|x: OpaqueCursor<NodeCursor>| x.extract_for_type(NodeType::BrowserSession))
272                    .transpose()?;
273                let pagination = Pagination::try_new(before_id, after_id, first, last)?;
274
275                let filter = BrowserSessionFilter::new().for_user(&self.0);
276                let filter = match state_param {
277                    Some(SessionState::Active) => filter.active_only(),
278                    Some(SessionState::Finished) => filter.finished_only(),
279                    None => filter,
280                };
281
282                let filter = match last_active.after {
283                    Some(after) => filter.with_last_active_after(after),
284                    None => filter,
285                };
286                let filter = match last_active.before {
287                    Some(before) => filter.with_last_active_before(before),
288                    None => filter,
289                };
290
291                let page = repo.browser_session().list(filter, pagination).await?;
292
293                // Preload the total count if requested
294                let count = if ctx.look_ahead().field("totalCount").exists() {
295                    Some(repo.browser_session().count(filter).await?)
296                } else {
297                    None
298                };
299
300                repo.cancel().await?;
301
302                let mut connection = Connection::with_additional_fields(
303                    page.has_previous_page,
304                    page.has_next_page,
305                    PreloadedTotalCount(count),
306                );
307                connection.edges.extend(page.edges.into_iter().map(|edge| {
308                    Edge::new(
309                        OpaqueCursor(NodeCursor(NodeType::BrowserSession, edge.cursor)),
310                        BrowserSession(edge.node),
311                    )
312                }));
313
314                Ok::<_, async_graphql::Error>(connection)
315            },
316        )
317        .await
318    }
319
320    /// Get the list of emails, chronologically sorted
321    async fn emails(
322        &self,
323        ctx: &Context<'_>,
324
325        #[graphql(
326            deprecation = "Emails are always confirmed, and have only one state",
327            name = "state",
328            desc = "List only emails in the given state."
329        )]
330        state_param: Option<UserEmailState>,
331
332        #[graphql(desc = "Returns the elements in the list that come after the cursor.")]
333        after: Option<String>,
334        #[graphql(desc = "Returns the elements in the list that come before the cursor.")]
335        before: Option<String>,
336        #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option<i32>,
337        #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option<i32>,
338    ) -> Result<Connection<Cursor, UserEmail, PreloadedTotalCount>, async_graphql::Error> {
339        let state = ctx.state();
340        let mut repo = state.repository().await?;
341        let _ = state_param;
342
343        query(
344            after,
345            before,
346            first,
347            last,
348            async |after, before, first, last| {
349                let after_id = after
350                    .map(|x: OpaqueCursor<NodeCursor>| x.extract_for_type(NodeType::UserEmail))
351                    .transpose()?;
352                let before_id = before
353                    .map(|x: OpaqueCursor<NodeCursor>| x.extract_for_type(NodeType::UserEmail))
354                    .transpose()?;
355                let pagination = Pagination::try_new(before_id, after_id, first, last)?;
356
357                let filter = UserEmailFilter::new().for_user(&self.0);
358
359                let page = repo.user_email().list(filter, pagination).await?;
360
361                // Preload the total count if requested
362                let count = if ctx.look_ahead().field("totalCount").exists() {
363                    Some(repo.user_email().count(filter).await?)
364                } else {
365                    None
366                };
367
368                repo.cancel().await?;
369
370                let mut connection = Connection::with_additional_fields(
371                    page.has_previous_page,
372                    page.has_next_page,
373                    PreloadedTotalCount(count),
374                );
375                connection.edges.extend(page.edges.into_iter().map(|edge| {
376                    Edge::new(
377                        OpaqueCursor(NodeCursor(NodeType::UserEmail, edge.cursor)),
378                        UserEmail(edge.node),
379                    )
380                }));
381
382                Ok::<_, async_graphql::Error>(connection)
383            },
384        )
385        .await
386    }
387
388    /// Get the list of OAuth 2.0 sessions, chronologically sorted
389    #[allow(clippy::too_many_arguments)]
390    async fn oauth2_sessions(
391        &self,
392        ctx: &Context<'_>,
393
394        #[graphql(name = "state", desc = "List only sessions in the given state.")]
395        state_param: Option<SessionState>,
396
397        #[graphql(desc = "List only sessions for the given client.")] client: Option<ID>,
398
399        #[graphql(
400            name = "lastActive",
401            desc = "List only sessions with a last active time is between the given bounds."
402        )]
403        last_active: Option<DateFilter>,
404
405        #[graphql(desc = "Returns the elements in the list that come after the cursor.")]
406        after: Option<String>,
407        #[graphql(desc = "Returns the elements in the list that come before the cursor.")]
408        before: Option<String>,
409        #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option<i32>,
410        #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option<i32>,
411    ) -> Result<Connection<Cursor, OAuth2Session, PreloadedTotalCount>, async_graphql::Error> {
412        let state = ctx.state();
413        let mut repo = state.repository().await?;
414        let last_active = last_active.unwrap_or_default();
415
416        query(
417            after,
418            before,
419            first,
420            last,
421            async |after, before, first, last| {
422                let after_id = after
423                    .map(|x: OpaqueCursor<NodeCursor>| x.extract_for_type(NodeType::OAuth2Session))
424                    .transpose()?;
425                let before_id = before
426                    .map(|x: OpaqueCursor<NodeCursor>| x.extract_for_type(NodeType::OAuth2Session))
427                    .transpose()?;
428                let pagination = Pagination::try_new(before_id, after_id, first, last)?;
429
430                let client = if let Some(id) = client {
431                    // Load the client if we're filtering by it
432                    let id = NodeType::OAuth2Client.extract_ulid(&id)?;
433                    let client = repo
434                        .oauth2_client()
435                        .lookup(id)
436                        .await?
437                        .ok_or(async_graphql::Error::new("Unknown client ID"))?;
438
439                    Some(client)
440                } else {
441                    None
442                };
443
444                let filter = OAuth2SessionFilter::new().for_user(&self.0);
445
446                let filter = match state_param {
447                    Some(SessionState::Active) => filter.active_only(),
448                    Some(SessionState::Finished) => filter.finished_only(),
449                    None => filter,
450                };
451
452                let filter = match client.as_ref() {
453                    Some(client) => filter.for_client(client),
454                    None => filter,
455                };
456
457                let filter = match last_active.after {
458                    Some(after) => filter.with_last_active_after(after),
459                    None => filter,
460                };
461                let filter = match last_active.before {
462                    Some(before) => filter.with_last_active_before(before),
463                    None => filter,
464                };
465
466                let page = repo.oauth2_session().list(filter, pagination).await?;
467
468                let count = if ctx.look_ahead().field("totalCount").exists() {
469                    Some(repo.oauth2_session().count(filter).await?)
470                } else {
471                    None
472                };
473
474                repo.cancel().await?;
475
476                let mut connection = Connection::with_additional_fields(
477                    page.has_previous_page,
478                    page.has_next_page,
479                    PreloadedTotalCount(count),
480                );
481
482                connection.edges.extend(page.edges.into_iter().map(|edge| {
483                    Edge::new(
484                        OpaqueCursor(NodeCursor(NodeType::OAuth2Session, edge.cursor)),
485                        OAuth2Session(edge.node),
486                    )
487                }));
488
489                Ok::<_, async_graphql::Error>(connection)
490            },
491        )
492        .await
493    }
494
495    /// Get the list of upstream OAuth 2.0 links
496    async fn upstream_oauth2_links(
497        &self,
498        ctx: &Context<'_>,
499
500        #[graphql(desc = "Returns the elements in the list that come after the cursor.")]
501        after: Option<String>,
502        #[graphql(desc = "Returns the elements in the list that come before the cursor.")]
503        before: Option<String>,
504        #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option<i32>,
505        #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option<i32>,
506    ) -> Result<Connection<Cursor, UpstreamOAuth2Link, PreloadedTotalCount>, async_graphql::Error>
507    {
508        let state = ctx.state();
509        let mut repo = state.repository().await?;
510
511        query(
512            after,
513            before,
514            first,
515            last,
516            async |after, before, first, last| {
517                let after_id = after
518                    .map(|x: OpaqueCursor<NodeCursor>| {
519                        x.extract_for_type(NodeType::UpstreamOAuth2Link)
520                    })
521                    .transpose()?;
522                let before_id = before
523                    .map(|x: OpaqueCursor<NodeCursor>| {
524                        x.extract_for_type(NodeType::UpstreamOAuth2Link)
525                    })
526                    .transpose()?;
527                let pagination = Pagination::try_new(before_id, after_id, first, last)?;
528
529                let filter = UpstreamOAuthLinkFilter::new()
530                    .for_user(&self.0)
531                    .enabled_providers_only();
532
533                let page = repo.upstream_oauth_link().list(filter, pagination).await?;
534
535                // Preload the total count if requested
536                let count = if ctx.look_ahead().field("totalCount").exists() {
537                    Some(repo.upstream_oauth_link().count(filter).await?)
538                } else {
539                    None
540                };
541
542                repo.cancel().await?;
543
544                let mut connection = Connection::with_additional_fields(
545                    page.has_previous_page,
546                    page.has_next_page,
547                    PreloadedTotalCount(count),
548                );
549                connection.edges.extend(page.edges.into_iter().map(|edge| {
550                    Edge::new(
551                        OpaqueCursor(NodeCursor(NodeType::UpstreamOAuth2Link, edge.cursor)),
552                        UpstreamOAuth2Link::new(edge.node),
553                    )
554                }));
555
556                Ok::<_, async_graphql::Error>(connection)
557            },
558        )
559        .await
560    }
561
562    /// Get the list of both compat and OAuth 2.0 sessions, chronologically
563    /// sorted
564    #[allow(clippy::too_many_arguments)]
565    async fn app_sessions(
566        &self,
567        ctx: &Context<'_>,
568
569        #[graphql(name = "state", desc = "List only sessions in the given state.")]
570        state_param: Option<SessionState>,
571
572        #[graphql(name = "device", desc = "List only sessions for the given device.")]
573        device_param: Option<String>,
574
575        #[graphql(
576            name = "lastActive",
577            desc = "List only sessions with a last active time is between the given bounds."
578        )]
579        last_active: Option<DateFilter>,
580
581        #[graphql(
582            name = "browserSession",
583            desc = "List only sessions for the given session."
584        )]
585        browser_session_param: Option<ID>,
586
587        #[graphql(desc = "Returns the elements in the list that come after the cursor.")]
588        after: Option<String>,
589        #[graphql(desc = "Returns the elements in the list that come before the cursor.")]
590        before: Option<String>,
591        #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option<i32>,
592        #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option<i32>,
593    ) -> Result<Connection<Cursor, AppSession, PreloadedTotalCount>, async_graphql::Error> {
594        let state = ctx.state();
595        let requester = ctx.requester();
596        let mut repo = state.repository().await?;
597        let last_active = last_active.unwrap_or_default();
598
599        query(
600            after,
601            before,
602            first,
603            last,
604            async |after, before, first, last| {
605                let after_id = after
606                    .map(|x: OpaqueCursor<NodeCursor>| {
607                        x.extract_for_types(&[NodeType::OAuth2Session, NodeType::CompatSession])
608                    })
609                    .transpose()?;
610                let before_id = before
611                    .map(|x: OpaqueCursor<NodeCursor>| {
612                        x.extract_for_types(&[NodeType::OAuth2Session, NodeType::CompatSession])
613                    })
614                    .transpose()?;
615                let pagination = Pagination::try_new(before_id, after_id, first, last)?;
616
617                let device_param = device_param.map(Device::try_from).transpose()?;
618
619                let filter = AppSessionFilter::new().for_user(&self.0);
620
621                let filter = match state_param {
622                    Some(SessionState::Active) => filter.active_only(),
623                    Some(SessionState::Finished) => filter.finished_only(),
624                    None => filter,
625                };
626
627                let filter = match device_param.as_ref() {
628                    Some(device) => filter.for_device(device),
629                    None => filter,
630                };
631
632                let maybe_session = match browser_session_param {
633                    Some(id) => {
634                        // This might fail, but we're probably alright with it
635                        let id = NodeType::BrowserSession
636                            .extract_ulid(&id)
637                            .context("Invalid browser_session parameter")?;
638
639                        let Some(session) = repo
640                            .browser_session()
641                            .lookup(id)
642                            .await?
643                            .filter(|u| requester.is_owner_or_admin(u))
644                        else {
645                            // If we couldn't find the session or if the requester can't access it,
646                            // return an empty list
647                            return Ok(Connection::with_additional_fields(
648                                false,
649                                false,
650                                PreloadedTotalCount(Some(0)),
651                            ));
652                        };
653
654                        Some(session)
655                    }
656                    None => None,
657                };
658
659                let filter = match maybe_session {
660                    Some(ref session) => filter.for_browser_session(session),
661                    None => filter,
662                };
663
664                let filter = match last_active.after {
665                    Some(after) => filter.with_last_active_after(after),
666                    None => filter,
667                };
668                let filter = match last_active.before {
669                    Some(before) => filter.with_last_active_before(before),
670                    None => filter,
671                };
672
673                let page = repo.app_session().list(filter, pagination).await?;
674
675                let count = if ctx.look_ahead().field("totalCount").exists() {
676                    Some(repo.app_session().count(filter).await?)
677                } else {
678                    None
679                };
680
681                repo.cancel().await?;
682
683                let mut connection = Connection::with_additional_fields(
684                    page.has_previous_page,
685                    page.has_next_page,
686                    PreloadedTotalCount(count),
687                );
688
689                connection
690                    .edges
691                    .extend(page.edges.into_iter().map(|edge| match edge.node {
692                        mas_storage::app_session::AppSession::Compat(session) => Edge::new(
693                            OpaqueCursor(NodeCursor(NodeType::CompatSession, edge.cursor)),
694                            AppSession::CompatSession(Box::new(CompatSession::new(*session))),
695                        ),
696                        mas_storage::app_session::AppSession::OAuth2(session) => Edge::new(
697                            OpaqueCursor(NodeCursor(NodeType::OAuth2Session, edge.cursor)),
698                            AppSession::OAuth2Session(Box::new(OAuth2Session(*session))),
699                        ),
700                    }));
701
702                Ok::<_, async_graphql::Error>(connection)
703            },
704        )
705        .await
706    }
707
708    /// Check if the user has a password set.
709    async fn has_password(&self, ctx: &Context<'_>) -> Result<bool, async_graphql::Error> {
710        let state = ctx.state();
711        let mut repo = state.repository().await?;
712
713        let password = repo.user_password().active(&self.0).await?;
714
715        Ok(password.is_some())
716    }
717}
718
719/// A session in an application, either a compatibility or an OAuth 2.0 one
720#[derive(Union)]
721pub enum AppSession {
722    CompatSession(Box<CompatSession>),
723    OAuth2Session(Box<OAuth2Session>),
724}
725
726/// A user email address
727#[derive(Description)]
728pub struct UserEmail(pub mas_data_model::UserEmail);
729
730#[Object(use_type_description)]
731impl UserEmail {
732    /// ID of the object.
733    pub async fn id(&self) -> ID {
734        NodeType::UserEmail.id(self.0.id)
735    }
736
737    /// Email address
738    async fn email(&self) -> &str {
739        &self.0.email
740    }
741
742    /// When the object was created.
743    pub async fn created_at(&self) -> DateTime<Utc> {
744        self.0.created_at
745    }
746
747    /// When the email address was confirmed. Is `null` if the email was never
748    /// verified by the user.
749    #[graphql(deprecation = "Emails are always confirmed now.")]
750    async fn confirmed_at(&self) -> Option<DateTime<Utc>> {
751        Some(self.0.created_at)
752    }
753}
754
755/// The state of a compatibility session.
756#[derive(Enum, Copy, Clone, Eq, PartialEq)]
757pub enum UserEmailState {
758    /// The email address is pending confirmation.
759    Pending,
760
761    /// The email address has been confirmed.
762    Confirmed,
763}
764
765/// A recovery ticket
766#[derive(Description)]
767pub struct UserRecoveryTicket(pub mas_data_model::UserRecoveryTicket);
768
769/// The status of a recovery ticket
770#[derive(Enum, Copy, Clone, Eq, PartialEq)]
771pub enum UserRecoveryTicketStatus {
772    /// The ticket is valid
773    Valid,
774
775    /// The ticket has expired
776    Expired,
777
778    /// The ticket has been consumed
779    Consumed,
780}
781
782#[Object(use_type_description)]
783impl UserRecoveryTicket {
784    /// ID of the object.
785    pub async fn id(&self) -> ID {
786        NodeType::UserRecoveryTicket.id(self.0.id)
787    }
788
789    /// When the object was created.
790    pub async fn created_at(&self) -> DateTime<Utc> {
791        self.0.created_at
792    }
793
794    /// The status of the ticket
795    pub async fn status(
796        &self,
797        context: &Context<'_>,
798    ) -> Result<UserRecoveryTicketStatus, async_graphql::Error> {
799        let state = context.state();
800        let clock = state.clock();
801        let mut repo = state.repository().await?;
802
803        // Lookup the session associated with the ticket
804        let session = repo
805            .user_recovery()
806            .lookup_session(self.0.user_recovery_session_id)
807            .await?
808            .context("Failed to lookup session")?;
809        repo.cancel().await?;
810
811        if session.consumed_at.is_some() {
812            return Ok(UserRecoveryTicketStatus::Consumed);
813        }
814
815        if self.0.expires_at < clock.now() {
816            return Ok(UserRecoveryTicketStatus::Expired);
817        }
818
819        Ok(UserRecoveryTicketStatus::Valid)
820    }
821
822    /// The username associated with this ticket
823    pub async fn username(&self, ctx: &Context<'_>) -> Result<String, async_graphql::Error> {
824        // We could expose the UserEmail, then the User, but this is unauthenticated, so
825        // we don't want to risk leaking too many objects. Instead, we just give the
826        // username as a property of the UserRecoveryTicket
827        let state = ctx.state();
828        let mut repo = state.repository().await?;
829        let user_email = repo
830            .user_email()
831            .lookup(self.0.user_email_id)
832            .await?
833            .context("Failed to lookup user email")?;
834
835        let user = repo
836            .user()
837            .lookup(user_email.user_id)
838            .await?
839            .context("Failed to lookup user")?;
840        repo.cancel().await?;
841
842        Ok(user.username)
843    }
844
845    /// The email address associated with this ticket
846    pub async fn email(&self, ctx: &Context<'_>) -> Result<String, async_graphql::Error> {
847        // We could expose the UserEmail directly, but this is unauthenticated, so we
848        // don't want to risk leaking too many objects. Instead, we just give
849        // the email as a property of the UserRecoveryTicket
850        let state = ctx.state();
851        let mut repo = state.repository().await?;
852        let user_email = repo
853            .user_email()
854            .lookup(self.0.user_email_id)
855            .await?
856            .context("Failed to lookup user email")?;
857        repo.cancel().await?;
858
859        Ok(user_email.email)
860    }
861}
862
863/// A email authentication session
864#[derive(Description)]
865pub struct UserEmailAuthentication(pub mas_data_model::UserEmailAuthentication);
866
867#[Object(use_type_description)]
868impl UserEmailAuthentication {
869    /// ID of the object.
870    pub async fn id(&self) -> ID {
871        NodeType::UserEmailAuthentication.id(self.0.id)
872    }
873
874    /// When the object was created.
875    pub async fn created_at(&self) -> DateTime<Utc> {
876        self.0.created_at
877    }
878
879    /// When the object was last updated.
880    pub async fn completed_at(&self) -> Option<DateTime<Utc>> {
881        self.0.completed_at
882    }
883
884    /// The email address associated with this session
885    pub async fn email(&self) -> &str {
886        &self.0.email
887    }
888}