From ca520dfd9a4b2f1b699f97745725a3a198d844da Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 6 Jul 2023 17:49:50 +0200 Subject: [PATCH] frontend: Show all compatibilities sessions, not just SSO logins Also cleans up a bunch of things in the frontend --- crates/graphql/src/model/compat_sessions.rs | 12 +- crates/graphql/src/model/users.rs | 59 ++++- .../graphql/src/mutations/compat_session.rs | 3 +- crates/handlers/src/lib.rs | 8 +- crates/storage-pg/src/compat/session.rs | 158 +++++++++++- crates/storage/src/compat/session.rs | 28 +- frontend/index.html | 8 +- frontend/schema.graphql | 42 +++ frontend/src/components/AddEmailForm.tsx | 28 +- frontend/src/components/BlockList.tsx | 4 +- frontend/src/components/BrowserSession.tsx | 2 +- .../{CompatSsoLogin.tsx => CompatSession.tsx} | 65 ++--- ...SsoLoginList.tsx => CompatSessionList.tsx} | 36 +-- frontend/src/components/Input.stories.ts | 50 ---- frontend/src/components/Input.tsx | 28 -- frontend/src/components/Layout.tsx | 6 +- frontend/src/components/OAuth2Session.tsx | 9 +- .../src/components/PaginationControls.tsx | 2 +- frontend/src/components/UserEmail.tsx | 109 ++++---- frontend/src/gql/gql.ts | 30 +-- frontend/src/gql/graphql.ts | 241 +++++++++--------- frontend/src/gql/schema.ts | 135 ++++++++++ frontend/src/pages/Account.tsx | 6 +- frontend/src/pages/Home.tsx | 6 +- frontend/tailwind.config.cjs | 2 +- 25 files changed, 708 insertions(+), 369 deletions(-) rename frontend/src/components/{CompatSsoLogin.tsx => CompatSession.tsx} (78%) rename frontend/src/components/{CompatSsoLoginList.tsx => CompatSessionList.tsx} (78%) delete mode 100644 frontend/src/components/Input.stories.ts delete mode 100644 frontend/src/components/Input.tsx diff --git a/crates/graphql/src/model/compat_sessions.rs b/crates/graphql/src/model/compat_sessions.rs index f52d315c..ef675c90 100644 --- a/crates/graphql/src/model/compat_sessions.rs +++ b/crates/graphql/src/model/compat_sessions.rs @@ -24,7 +24,10 @@ use crate::state::ContextExt; /// A compat session represents a client session which used the legacy Matrix /// login API. #[derive(Description)] -pub struct CompatSession(pub mas_data_model::CompatSession); +pub struct CompatSession( + pub mas_data_model::CompatSession, + pub Option, +); #[Object(use_type_description)] impl CompatSession { @@ -61,6 +64,11 @@ impl CompatSession { pub async fn finished_at(&self) -> Option> { self.0.finished_at() } + + /// The associated SSO login, if any. + pub async fn sso_login(&self) -> Option { + self.1.as_ref().map(|l| CompatSsoLogin(l.clone())) + } } /// A compat SSO login represents a login done through the legacy Matrix login @@ -114,6 +122,6 @@ impl CompatSsoLogin { .context("Could not load compat session")?; repo.cancel().await?; - Ok(Some(CompatSession(session))) + Ok(Some(CompatSession(session, Some(self.0.clone())))) } } diff --git a/crates/graphql/src/model/users.rs b/crates/graphql/src/model/users.rs index bd5302ae..bd83bf49 100644 --- a/crates/graphql/src/model/users.rs +++ b/crates/graphql/src/model/users.rs @@ -22,14 +22,17 @@ use mas_storage::{ oauth2::OAuth2SessionRepository, upstream_oauth2::UpstreamOAuthLinkRepository, user::{BrowserSessionRepository, UserEmailRepository}, - Pagination, + Pagination, RepositoryAccess, }; use super::{ compat_sessions::CompatSsoLogin, BrowserSession, Cursor, NodeCursor, NodeType, OAuth2Session, UpstreamOAuth2Link, }; -use crate::{model::matrix::MatrixUser, state::ContextExt}; +use crate::{ + model::{matrix::MatrixUser, CompatSession}, + state::ContextExt, +}; #[derive(Description)] /// A user is an individual's account. @@ -129,6 +132,58 @@ impl User { .await } + /// Get the list of compatibility sessions, chronologically sorted + async fn compat_sessions( + &self, + ctx: &Context<'_>, + + #[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_type(NodeType::CompatSsoLogin)) + .transpose()?; + let before_id = before + .map(|x: OpaqueCursor| x.extract_for_type(NodeType::CompatSsoLogin)) + .transpose()?; + let pagination = Pagination::try_new(before_id, after_id, first, last)?; + + let page = repo + .compat_session() + .list_paginated(&self.0, pagination) + .await?; + + repo.cancel().await?; + + let mut connection = Connection::new(page.has_previous_page, page.has_next_page); + connection + .edges + .extend(page.edges.into_iter().map(|(session, sso_login)| { + Edge::new( + OpaqueCursor(NodeCursor(NodeType::CompatSession, session.id)), + CompatSession(session, sso_login), + ) + })); + + Ok::<_, async_graphql::Error>(connection) + }, + ) + .await + } + /// Get the list of active browser sessions, chronologically sorted async fn browser_sessions( &self, diff --git a/crates/graphql/src/mutations/compat_session.rs b/crates/graphql/src/mutations/compat_session.rs index 209f9b3b..ae065a4a 100644 --- a/crates/graphql/src/mutations/compat_session.rs +++ b/crates/graphql/src/mutations/compat_session.rs @@ -66,7 +66,8 @@ impl EndCompatSessionPayload { /// Returns the ended session. async fn compat_session(&self) -> Option { match self { - Self::Ended(session) => Some(CompatSession(session.clone())), + // XXX: the SSO login is not returned here. + Self::Ended(session) => Some(CompatSession(session.clone(), None)), Self::NotFound => None, } } diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 46dd54ef..b8f5f542 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -40,7 +40,9 @@ use axum::{ Router, }; use headers::HeaderName; -use hyper::header::{ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_LANGUAGE, CONTENT_TYPE}; +use hyper::header::{ + ACCEPT, ACCEPT_LANGUAGE, AUTHORIZATION, CONTENT_LANGUAGE, CONTENT_LENGTH, CONTENT_TYPE, +}; use mas_http::CorsLayerExt; use mas_keystore::{Encrypter, Keystore}; use mas_policy::PolicyFactory; @@ -268,7 +270,8 @@ where BoxRng: FromRequestParts, { Router::new() - // TODO: mount this route somewhere else? + // XXX: hard-coded redirect from /account to /account/ + .route("/account", get(|| async { mas_router::Account.go() })) .route(mas_router::Account::route(), get(self::views::app::get)) .route( mas_router::AccountWildcard::route(), @@ -351,6 +354,7 @@ where if let Ok(res) = templates.render_error(ctx).await { let (mut parts, _original_body) = response.into_parts(); parts.headers.remove(CONTENT_TYPE); + parts.headers.remove(CONTENT_LENGTH); return Ok((parts, Html(res)).into_response()); } } diff --git a/crates/storage-pg/src/compat/session.rs b/crates/storage-pg/src/compat/session.rs index ea4bc251..cb1f7cd4 100644 --- a/crates/storage-pg/src/compat/session.rs +++ b/crates/storage-pg/src/compat/session.rs @@ -14,14 +14,20 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; -use mas_data_model::{CompatSession, CompatSessionState, Device, User}; -use mas_storage::{compat::CompatSessionRepository, Clock}; +use mas_data_model::{ + CompatSession, CompatSessionState, CompatSsoLogin, CompatSsoLoginState, Device, User, +}; +use mas_storage::{compat::CompatSessionRepository, Clock, Page, Pagination}; use rand::RngCore; -use sqlx::PgConnection; +use sqlx::{PgConnection, QueryBuilder}; use ulid::Ulid; +use url::Url; use uuid::Uuid; -use crate::{tracing::ExecuteExt, DatabaseError, DatabaseInconsistencyError, LookupResultExt}; +use crate::{ + pagination::QueryBuilderExt, tracing::ExecuteExt, DatabaseError, DatabaseInconsistencyError, + LookupResultExt, +}; /// An implementation of [`CompatSessionRepository`] for a PostgreSQL connection pub struct PgCompatSessionRepository<'c> { @@ -75,6 +81,101 @@ impl TryFrom for CompatSession { } } +#[derive(sqlx::FromRow)] +struct CompatSessionAndSsoLoginLookup { + compat_session_id: Uuid, + device_id: String, + user_id: Uuid, + created_at: DateTime, + finished_at: Option>, + is_synapse_admin: bool, + compat_sso_login_id: Option, + compat_sso_login_token: Option, + compat_sso_login_redirect_uri: Option, + compat_sso_login_created_at: Option>, + compat_sso_login_fulfilled_at: Option>, + compat_sso_login_exchanged_at: Option>, +} + +impl TryFrom for (CompatSession, Option) { + type Error = DatabaseInconsistencyError; + + fn try_from(value: CompatSessionAndSsoLoginLookup) -> Result { + let id = value.compat_session_id.into(); + let device = Device::try_from(value.device_id).map_err(|e| { + DatabaseInconsistencyError::on("compat_sessions") + .column("device_id") + .row(id) + .source(e) + })?; + + let state = match value.finished_at { + None => CompatSessionState::Valid, + Some(finished_at) => CompatSessionState::Finished { finished_at }, + }; + + let session = CompatSession { + id, + state, + user_id: value.user_id.into(), + device, + created_at: value.created_at, + is_synapse_admin: value.is_synapse_admin, + }; + + match ( + value.compat_sso_login_id, + value.compat_sso_login_token, + value.compat_sso_login_redirect_uri, + value.compat_sso_login_created_at, + value.compat_sso_login_fulfilled_at, + value.compat_sso_login_exchanged_at, + ) { + (None, None, None, None, None, None) => Ok((session, None)), + ( + Some(id), + Some(login_token), + Some(redirect_uri), + Some(created_at), + fulfilled_at, + exchanged_at, + ) => { + let id = id.into(); + let redirect_uri = Url::parse(&redirect_uri).map_err(|e| { + DatabaseInconsistencyError::on("compat_sso_logins") + .column("redirect_uri") + .row(id) + .source(e) + })?; + + let state = match (fulfilled_at, exchanged_at) { + (Some(fulfilled_at), None) => CompatSsoLoginState::Fulfilled { + fulfilled_at, + session_id: session.id, + }, + (Some(fulfilled_at), Some(exchanged_at)) => CompatSsoLoginState::Exchanged { + fulfilled_at, + exchanged_at, + session_id: session.id, + }, + _ => return Err(DatabaseInconsistencyError::on("compat_sso_logins").row(id)), + }; + + let login = CompatSsoLogin { + id, + redirect_uri, + login_token, + created_at, + state, + }; + + Ok((session, Some(login))) + } + _ => Err(DatabaseInconsistencyError::on("compat_sso_logins").row(id)), + } + } +} + #[async_trait] impl<'c> CompatSessionRepository for PgCompatSessionRepository<'c> { type Error = DatabaseError; @@ -201,4 +302,53 @@ impl<'c> CompatSessionRepository for PgCompatSessionRepository<'c> { Ok(compat_session) } + + #[tracing::instrument( + name = "db.compat_session.list_paginated", + skip_all, + fields( + db.statement, + %user.id, + ), + err, + )] + async fn list_paginated( + &mut self, + user: &User, + pagination: Pagination, + ) -> Result)>, Self::Error> { + let mut query = QueryBuilder::new( + r#" + SELECT cs.compat_session_id + , cs.device_id + , cs.user_id + , cs.created_at + , cs.finished_at + , cs.is_synapse_admin + , cl.compat_sso_login_id + , cl.login_token as compat_sso_login_token + , cl.redirect_uri as compat_sso_login_redirect_uri + , cl.created_at as compat_sso_login_created_at + , cl.fulfilled_at as compat_sso_login_fulfilled_at + , cl.exchanged_at as compat_sso_login_exchanged_at + + FROM compat_sessions cs + LEFT JOIN compat_sso_logins cl USING (compat_session_id) + "#, + ); + + query + .push(" WHERE cs.user_id = ") + .push_bind(Uuid::from(user.id)) + .generate_pagination("cs.compat_session_id", pagination); + + let edges: Vec = query + .build_query_as() + .traced() + .fetch_all(&mut *self.conn) + .await?; + + let page = pagination.process(edges).try_map(TryFrom::try_from)?; + Ok(page) + } } diff --git a/crates/storage/src/compat/session.rs b/crates/storage/src/compat/session.rs index 8dd1b069..eb2d7603 100644 --- a/crates/storage/src/compat/session.rs +++ b/crates/storage/src/compat/session.rs @@ -13,11 +13,11 @@ // limitations under the License. use async_trait::async_trait; -use mas_data_model::{CompatSession, Device, User}; +use mas_data_model::{CompatSession, CompatSsoLogin, Device, User}; use rand_core::RngCore; use ulid::Ulid; -use crate::{repository_impl, Clock}; +use crate::{repository_impl, Clock, Page, Pagination}; /// A [`CompatSessionRepository`] helps interacting with /// [`CompatSessionRepository`] saved in the storage backend @@ -80,6 +80,24 @@ pub trait CompatSessionRepository: Send + Sync { clock: &dyn Clock, compat_session: CompatSession, ) -> Result; + + /// Get a paginated list of compat sessions for a user + /// + /// Returns a page of compat sessions, with the associated SSO logins if any + /// + /// # Parameters + /// + /// * `user`: The user to get the compat sessions for + /// * `pagination`: The pagination parameters + /// + /// # Errors + /// + /// Returns [`Self::Error`] if the underlying repository fails + async fn list_paginated( + &mut self, + user: &User, + pagination: Pagination, + ) -> Result)>, Self::Error>; } repository_impl!(CompatSessionRepository: @@ -99,4 +117,10 @@ repository_impl!(CompatSessionRepository: clock: &dyn Clock, compat_session: CompatSession, ) -> Result; + + async fn list_paginated( + &mut self, + user: &User, + pagination: Pagination, + ) -> Result)>, Self::Error>; ); diff --git a/frontend/index.html b/frontend/index.html index a954ad5e..f8ab2e9e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -22,15 +22,15 @@ limitations under the License. matrix-authentication-service -