diff --git a/crates/graphql/src/model/browser_sessions.rs b/crates/graphql/src/model/browser_sessions.rs index 08c1d410..e8de9ba7 100644 --- a/crates/graphql/src/model/browser_sessions.rs +++ b/crates/graphql/src/model/browser_sessions.rs @@ -12,23 +12,13 @@ // See the License for the specific language governing permissions and // limitations under the License. -use async_graphql::{Context, Description, Enum, Object, ID}; +use async_graphql::{Context, Description, Object, ID}; use chrono::{DateTime, Utc}; use mas_storage::{user::BrowserSessionRepository, RepositoryAccess}; -use super::{NodeType, User}; +use super::{NodeType, SessionState, User}; use crate::state::ContextExt; -/// The state of a browser session. -#[derive(Enum, Copy, Clone, Eq, PartialEq)] -pub enum BrowserSessionState { - /// The session is active. - Active, - - /// The session is no longer active. - Finished, -} - /// A browser session represents a logged in user in a browser. #[derive(Description)] pub struct BrowserSession(pub mas_data_model::BrowserSession); @@ -80,11 +70,11 @@ impl BrowserSession { } /// The state of the session. - pub async fn state(&self) -> BrowserSessionState { + pub async fn state(&self) -> SessionState { if self.0.finished_at.is_some() { - BrowserSessionState::Finished + SessionState::Finished } else { - BrowserSessionState::Active + SessionState::Active } } diff --git a/crates/graphql/src/model/compat_sessions.rs b/crates/graphql/src/model/compat_sessions.rs index d3794ebd..862e9fad 100644 --- a/crates/graphql/src/model/compat_sessions.rs +++ b/crates/graphql/src/model/compat_sessions.rs @@ -18,7 +18,7 @@ use chrono::{DateTime, Utc}; use mas_storage::{compat::CompatSessionRepository, user::UserRepository}; use url::Url; -use super::{NodeType, User}; +use super::{NodeType, SessionState, User}; use crate::state::ContextExt; /// Lazy-loaded reverse reference. @@ -57,16 +57,6 @@ impl CompatSession { } } -/// The state of a compatibility session. -#[derive(Enum, Copy, Clone, Eq, PartialEq)] -pub enum CompatSessionState { - /// The session is active. - Active, - - /// The session is no longer active. - Finished, -} - /// The type of a compatibility session. #[derive(Enum, Copy, Clone, Eq, PartialEq)] pub enum CompatSessionType { @@ -136,10 +126,10 @@ impl CompatSession { } /// The state of the session. - pub async fn state(&self) -> CompatSessionState { + pub async fn state(&self) -> SessionState { match &self.session.state { - mas_data_model::CompatSessionState::Valid => CompatSessionState::Active, - mas_data_model::CompatSessionState::Finished { .. } => CompatSessionState::Finished, + mas_data_model::CompatSessionState::Valid => SessionState::Active, + mas_data_model::CompatSessionState::Finished { .. } => SessionState::Finished, } } diff --git a/crates/graphql/src/model/cursor.rs b/crates/graphql/src/model/cursor.rs index 3588d08e..cd430be5 100644 --- a/crates/graphql/src/model/cursor.rs +++ b/crates/graphql/src/model/cursor.rs @@ -22,6 +22,14 @@ pub use super::NodeType; pub struct NodeCursor(pub NodeType, pub Ulid); impl NodeCursor { + pub fn extract_for_types(&self, node_types: &[NodeType]) -> Result { + if node_types.contains(&self.0) { + Ok(self.1) + } else { + Err(async_graphql::Error::new("invalid cursor")) + } + } + pub fn extract_for_type(&self, node_type: NodeType) -> Result { if self.0 == node_type { Ok(self.1) diff --git a/crates/graphql/src/model/mod.rs b/crates/graphql/src/model/mod.rs index 7ebec1fd..ed43a8af 100644 --- a/crates/graphql/src/model/mod.rs +++ b/crates/graphql/src/model/mod.rs @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -use async_graphql::{Interface, Object}; +use async_graphql::{Enum, Interface, Object}; use chrono::{DateTime, Utc}; mod browser_sessions; @@ -63,3 +63,13 @@ impl PreloadedTotalCount { .ok_or_else(|| async_graphql::Error::new("total count not preloaded")) } } + +/// The state of a session +#[derive(Enum, Copy, Clone, Eq, PartialEq)] +pub enum SessionState { + /// The session is active. + Active, + + /// The session is no longer active. + Finished, +} diff --git a/crates/graphql/src/model/oauth.rs b/crates/graphql/src/model/oauth.rs index 5b367ba3..090968a0 100644 --- a/crates/graphql/src/model/oauth.rs +++ b/crates/graphql/src/model/oauth.rs @@ -15,25 +15,14 @@ use anyhow::Context as _; use async_graphql::{Context, Description, Enum, Object, ID}; use chrono::{DateTime, Utc}; -use mas_data_model::SessionState; use mas_storage::{oauth2::OAuth2ClientRepository, user::BrowserSessionRepository}; use oauth2_types::{oidc::ApplicationType, scope::Scope}; use ulid::Ulid; use url::Url; -use super::{BrowserSession, NodeType, User}; +use super::{BrowserSession, NodeType, SessionState, User}; use crate::{state::ContextExt, UserId}; -/// The state of an OAuth 2.0 session. -#[derive(Enum, Copy, Clone, Eq, PartialEq)] -pub enum OAuth2SessionState { - /// The session is active. - Active, - - /// The session is no longer active. - Finished, -} - /// An OAuth 2.0 session represents a client session which used the OAuth APIs /// to login. #[derive(Description)] @@ -73,16 +62,16 @@ impl OAuth2Session { /// When the session ended. pub async fn finished_at(&self) -> Option> { match &self.0.state { - SessionState::Valid => None, - SessionState::Finished { finished_at } => Some(*finished_at), + mas_data_model::SessionState::Valid => None, + mas_data_model::SessionState::Finished { finished_at } => Some(*finished_at), } } /// The state of the session. - pub async fn state(&self) -> OAuth2SessionState { + pub async fn state(&self) -> SessionState { match &self.0.state { - SessionState::Valid => OAuth2SessionState::Active, - SessionState::Finished { .. } => OAuth2SessionState::Finished, + mas_data_model::SessionState::Valid => SessionState::Active, + mas_data_model::SessionState::Finished { .. } => SessionState::Finished, } } diff --git a/crates/graphql/src/model/users.rs b/crates/graphql/src/model/users.rs index e8f25ecc..ad540c0f 100644 --- a/crates/graphql/src/model/users.rs +++ b/crates/graphql/src/model/users.rs @@ -14,10 +14,11 @@ use async_graphql::{ connection::{query, Connection, Edge, OpaqueCursor}, - Context, Description, Enum, Object, ID, + Context, Description, Enum, Object, Union, ID, }; use chrono::{DateTime, Utc}; use mas_storage::{ + app_session::AppSessionFilter, compat::{CompatSessionFilter, CompatSsoLoginFilter, CompatSsoLoginRepository}, oauth2::{OAuth2SessionFilter, OAuth2SessionRepository}, upstream_oauth2::{UpstreamOAuthLinkFilter, UpstreamOAuthLinkRepository}, @@ -26,12 +27,10 @@ use mas_storage::{ }; use super::{ - browser_sessions::BrowserSessionState, - compat_sessions::{CompatSessionState, CompatSessionType, CompatSsoLogin}, + compat_sessions::{CompatSessionType, CompatSsoLogin}, matrix::MatrixUser, - oauth::OAuth2SessionState, BrowserSession, CompatSession, Cursor, NodeCursor, NodeType, OAuth2Session, - PreloadedTotalCount, UpstreamOAuth2Link, + PreloadedTotalCount, SessionState, UpstreamOAuth2Link, }; use crate::state::ContextExt; @@ -160,7 +159,7 @@ impl User { ctx: &Context<'_>, #[graphql(name = "state", desc = "List only sessions with the given state.")] - state_param: Option, + state_param: Option, #[graphql(name = "type", desc = "List only sessions with the given type.")] type_param: Option, @@ -192,8 +191,8 @@ impl User { // Build the query filter let filter = CompatSessionFilter::new().for_user(&self.0); let filter = match state_param { - Some(CompatSessionState::Active) => filter.active_only(), - Some(CompatSessionState::Finished) => filter.finished_only(), + Some(SessionState::Active) => filter.active_only(), + Some(SessionState::Finished) => filter.finished_only(), None => filter, }; let filter = match type_param { @@ -239,7 +238,7 @@ impl User { ctx: &Context<'_>, #[graphql(name = "state", desc = "List only sessions in the given state.")] - state_param: Option, + state_param: Option, #[graphql(desc = "Returns the elements in the list that come after the cursor.")] after: Option, @@ -267,8 +266,8 @@ impl User { let filter = BrowserSessionFilter::new().for_user(&self.0); let filter = match state_param { - Some(BrowserSessionState::Active) => filter.active_only(), - Some(BrowserSessionState::Finished) => filter.finished_only(), + Some(SessionState::Active) => filter.active_only(), + Some(SessionState::Finished) => filter.finished_only(), None => filter, }; @@ -377,7 +376,7 @@ impl User { ctx: &Context<'_>, #[graphql(name = "state", desc = "List only sessions in the given state.")] - state_param: Option, + state_param: Option, #[graphql(desc = "List only sessions for the given client.")] client: Option, @@ -422,8 +421,8 @@ impl User { let filter = OAuth2SessionFilter::new().for_user(&self.0); let filter = match state_param { - Some(OAuth2SessionState::Active) => filter.active_only(), - Some(OAuth2SessionState::Finished) => filter.finished_only(), + Some(SessionState::Active) => filter.active_only(), + Some(SessionState::Finished) => filter.finished_only(), None => filter, }; @@ -525,6 +524,94 @@ impl User { ) .await } + + /// Get the list of both compat and OAuth 2.0 sessions, chronologically + /// sorted + #[allow(clippy::too_many_arguments)] + async fn app_sessions( + &self, + ctx: &Context<'_>, + + #[graphql(name = "state", desc = "List only sessions in the given state.")] + state_param: Option, + + #[graphql(desc = "Returns the elements in the list that come after the cursor.")] + after: Option, + #[graphql(desc = "Returns the elements in the list that come before the cursor.")] + before: Option, + #[graphql(desc = "Returns the first *n* elements from the list.")] first: Option, + #[graphql(desc = "Returns the last *n* elements from the list.")] last: Option, + ) -> Result, async_graphql::Error> { + let state = ctx.state(); + let mut repo = state.repository().await?; + + query( + after, + before, + first, + last, + |after, before, first, last| async move { + let after_id = after + .map(|x: OpaqueCursor| { + x.extract_for_types(&[NodeType::OAuth2Session, NodeType::CompatSession]) + }) + .transpose()?; + let before_id = before + .map(|x: OpaqueCursor| { + x.extract_for_types(&[NodeType::OAuth2Session, NodeType::CompatSession]) + }) + .transpose()?; + let pagination = Pagination::try_new(before_id, after_id, first, last)?; + + let filter = AppSessionFilter::new().for_user(&self.0); + + let filter = match state_param { + Some(SessionState::Active) => filter.active_only(), + Some(SessionState::Finished) => filter.finished_only(), + None => filter, + }; + + let page = repo.app_session().list(filter, pagination).await?; + + let count = if ctx.look_ahead().field("totalCount").exists() { + Some(repo.app_session().count(filter).await?) + } else { + None + }; + + repo.cancel().await?; + + let mut connection = Connection::with_additional_fields( + page.has_previous_page, + page.has_next_page, + PreloadedTotalCount(count), + ); + + connection + .edges + .extend(page.edges.into_iter().map(|s| match s { + mas_storage::app_session::AppSession::Compat(session) => Edge::new( + OpaqueCursor(NodeCursor(NodeType::CompatSession, session.id)), + AppSession::CompatSession(Box::new(CompatSession::new(*session))), + ), + mas_storage::app_session::AppSession::OAuth2(session) => Edge::new( + OpaqueCursor(NodeCursor(NodeType::OAuth2Session, session.id)), + AppSession::OAuth2Session(Box::new(OAuth2Session(*session))), + ), + })); + + Ok::<_, async_graphql::Error>(connection) + }, + ) + .await + } +} + +/// A session in an application, either a compatibility or an OAuth 2.0 one +#[derive(Union)] +enum AppSession { + CompatSession(Box), + OAuth2Session(Box), } /// A user email address diff --git a/frontend/schema.graphql b/frontend/schema.graphql index ced4de93..2dfe6103 100644 --- a/frontend/schema.graphql +++ b/frontend/schema.graphql @@ -110,6 +110,44 @@ type Anonymous implements Node { id: ID! } +""" +A session in an application, either a compatibility or an OAuth 2.0 one +""" +union AppSession = CompatSession | Oauth2Session + +type AppSessionConnection { + """ + Information to aid in pagination. + """ + pageInfo: PageInfo! + """ + A list of edges. + """ + edges: [AppSessionEdge!]! + """ + A list of nodes. + """ + nodes: [AppSession!]! + """ + Identifies the total count of items in the connection. + """ + totalCount: Int! +} + +""" +An edge in a connection. +""" +type AppSessionEdge { + """ + The item at the end of the edge + """ + node: AppSession! + """ + A cursor for use in pagination + """ + cursor: String! +} + """ An authentication records when a user enter their credential in a browser session. @@ -152,7 +190,7 @@ type BrowserSession implements Node & CreationEvent { """ The state of the session. """ - state: BrowserSessionState! + state: SessionState! """ The user-agent string with which the session was created. """ @@ -200,20 +238,6 @@ type BrowserSessionEdge { cursor: String! } -""" -The state of a browser session. -""" -enum BrowserSessionState { - """ - The session is active. - """ - ACTIVE - """ - The session is no longer active. - """ - FINISHED -} - """ A compat session represents a client session which used the legacy Matrix login API. @@ -246,7 +270,7 @@ type CompatSession implements Node & CreationEvent { """ The state of the session. """ - state: CompatSessionState! + state: SessionState! """ The last IP address used by the session. """ @@ -290,20 +314,6 @@ type CompatSessionEdge { cursor: String! } -""" -The state of a compatibility session. -""" -enum CompatSessionState { - """ - The session is active. - """ - ACTIVE - """ - The session is no longer active. - """ - FINISHED -} - """ The type of a compatibility session. """ @@ -747,7 +757,7 @@ type Oauth2Session implements Node & CreationEvent { """ The state of the session. """ - state: Oauth2SessionState! + state: SessionState! """ The browser session which started this OAuth 2.0 session. """ @@ -799,20 +809,6 @@ type Oauth2SessionEdge { cursor: String! } -""" -The state of an OAuth 2.0 session. -""" -enum Oauth2SessionState { - """ - The session is active. - """ - ACTIVE - """ - The session is no longer active. - """ - FINISHED -} - """ Information about pagination in a connection """ @@ -1008,6 +1004,20 @@ A client session, either compat or OAuth 2.0 """ union Session = CompatSession | Oauth2Session +""" +The state of a session +""" +enum SessionState { + """ + The session is active. + """ + ACTIVE + """ + The session is no longer active. + """ + FINISHED +} + """ The input for the `addEmail` mutation """ @@ -1258,7 +1268,7 @@ type User implements Node { """ List only sessions with the given state. """ - state: CompatSessionState + state: SessionState """ List only sessions with the given type. """ @@ -1287,7 +1297,7 @@ type User implements Node { """ List only sessions in the given state. """ - state: BrowserSessionState + state: SessionState """ Returns the elements in the list that come after the cursor. """ @@ -1337,7 +1347,7 @@ type User implements Node { """ List only sessions in the given state. """ - state: Oauth2SessionState + state: SessionState """ List only sessions for the given client. """ @@ -1380,6 +1390,32 @@ type User implements Node { """ last: Int ): UpstreamOAuth2LinkConnection! + """ + Get the list of both compat and OAuth 2.0 sessions, chronologically + sorted + """ + appSessions( + """ + List only sessions in the given state. + """ + state: SessionState + """ + Returns the elements in the list that come after the cursor. + """ + after: String + """ + Returns the elements in the list that come before the cursor. + """ + before: String + """ + Returns the first *n* elements from the list. + """ + first: Int + """ + Returns the last *n* elements from the list. + """ + last: Int + ): AppSessionConnection! } """ diff --git a/frontend/src/components/BrowserSessionList.tsx b/frontend/src/components/BrowserSessionList.tsx index 02c0429a..2873d273 100644 --- a/frontend/src/components/BrowserSessionList.tsx +++ b/frontend/src/components/BrowserSessionList.tsx @@ -19,7 +19,7 @@ import { useTransition } from "react"; import { mapQueryAtom } from "../atoms"; import { graphql } from "../gql"; -import { BrowserSessionState, PageInfo } from "../gql/graphql"; +import { SessionState, PageInfo } from "../gql/graphql"; import { atomForCurrentPagination, atomWithPagination, @@ -36,7 +36,7 @@ import { Title } from "./Typography"; const QUERY = graphql(/* GraphQL */ ` query BrowserSessionList( $userId: ID! - $state: BrowserSessionState + $state: SessionState $first: Int $after: String $last: Int @@ -72,7 +72,7 @@ const QUERY = graphql(/* GraphQL */ ` } `); -const filterAtom = atom(BrowserSessionState.Active); +const filterAtom = atom(SessionState.Active); const currentPaginationAtom = atomForCurrentPagination(); const browserSessionListFamily = atomFamily((userId: string) => { @@ -129,11 +129,7 @@ const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => { const toggleFilter = (): void => { startTransition(() => { setPagination(FIRST_PAGE); - setFilter( - filter === BrowserSessionState.Active - ? null - : BrowserSessionState.Active, - ); + setFilter(filter === SessionState.Active ? null : SessionState.Active); }); }; @@ -149,7 +145,7 @@ const BrowserSessionList: React.FC<{ userId: string }> = ({ userId }) => {