From 76653f96382f8f48f2bbe02ae173c55100de747f Mon Sep 17 00:00:00 2001 From: Quentin Gliech Date: Thu, 6 Jul 2023 15:30:26 +0200 Subject: [PATCH] Better frontend assets handling and move the react app to /account/ (#1324) This makes the Vite assets handling better, namely: - make it possible to include any vite assets in the templates - include the right `` tags for assets - include Subresource Integrity hashes - pre-compress assets and remove on-the-fly compression by the Rust server - build the CSS used by templates through Vite It also moves the React app from /app/ to /account/, and remove some of the old SSR account screens. --- .github/workflows/ci.yaml | 15 + .github/workflows/coverage.yaml | 15 + Cargo.lock | 82 +--- crates/cli/Cargo.toml | 2 +- crates/cli/src/commands/server.rs | 7 +- crates/cli/src/commands/templates.rs | 21 +- crates/cli/src/commands/worker.rs | 7 +- crates/cli/src/server.rs | 48 +- crates/cli/src/util.rs | 7 +- crates/config/src/sections/http.rs | 25 +- crates/config/src/sections/templates.rs | 16 + crates/handlers/Cargo.toml | 1 + crates/handlers/src/lib.rs | 11 +- crates/handlers/src/test_utils.rs | 10 +- .../handlers/src/views/account/emails/mod.rs | 185 -------- .../src/views/account/emails/verify.rs | 4 +- crates/handlers/src/views/account/mod.rs | 45 -- crates/handlers/src/views/app.rs | 48 ++ crates/handlers/src/views/mod.rs | 1 + crates/handlers/src/views/shared.rs | 2 + crates/oidc-client/Cargo.toml | 2 +- .../http_service/{hyper/mod.rs => hyper.rs} | 25 +- .../src/http_service/hyper/body_layer.rs | 88 ---- crates/router/src/endpoints.rs | 42 +- crates/router/src/url_builder.rs | 26 +- crates/spa/Cargo.toml | 8 - crates/spa/src/bin/render.rs | 32 -- crates/spa/src/lib.rs | 69 --- crates/spa/src/vite.rs | 274 ++++++----- crates/templates/Cargo.toml | 1 + crates/templates/src/context.rs | 94 ++-- crates/templates/src/functions.rs | 84 +++- crates/templates/src/lib.rs | 70 ++- docs/config.schema.json | 18 +- frontend/.storybook/preview.tsx | 2 +- frontend/index.html | 52 +-- frontend/package-lock.json | 437 +++++++++++++++++- frontend/package.json | 6 +- frontend/src/{index.css => main.css} | 0 frontend/src/main.tsx | 1 + frontend/src/templates.css | 20 + frontend/vite.config.ts | 34 +- templates/app.html | 47 ++ templates/base.html | 2 +- templates/components/navbar.html | 2 +- templates/pages/account/emails/index.html | 60 --- templates/pages/account/index.html | 59 --- 47 files changed, 1096 insertions(+), 1011 deletions(-) create mode 100644 crates/handlers/src/views/app.rs rename crates/oidc-client/src/http_service/{hyper/mod.rs => hyper.rs} (77%) delete mode 100644 crates/oidc-client/src/http_service/hyper/body_layer.rs delete mode 100644 crates/spa/src/bin/render.rs rename frontend/src/{index.css => main.css} (100%) create mode 100644 frontend/src/templates.css create mode 100644 templates/app.html delete mode 100644 templates/pages/account/emails/index.html delete mode 100644 templates/pages/account/index.html diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 6ef59020..7de695e4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -239,6 +239,21 @@ jobs: rustup toolchain install ${{ matrix.toolchain }} rustup default ${{ matrix.toolchain }} + - name: Install Node + uses: actions/setup-node@v3.6.0 + with: + node-version: 18 + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install Node dependencies + working-directory: ./frontend + run: npm ci + + - name: Build the frontend + working-directory: ./frontend + run: npm run build + - name: Setup OPA uses: open-policy-agent/setup-opa@v2.1.0 with: diff --git a/.github/workflows/coverage.yaml b/.github/workflows/coverage.yaml index 74cbf219..ef2a8b64 100644 --- a/.github/workflows/coverage.yaml +++ b/.github/workflows/coverage.yaml @@ -105,6 +105,21 @@ jobs: rustup default stable rustup component add llvm-tools-preview + - name: Install Node + uses: actions/setup-node@v3.6.0 + with: + node-version: 18 + cache: 'npm' + cache-dependency-path: frontend/package-lock.json + + - name: Install Node dependencies + working-directory: ./frontend + run: npm ci + + - name: Build the frontend + working-directory: ./frontend + run: npm run build + - name: Setup OPA uses: open-policy-agent/setup-opa@v2.1.0 with: diff --git a/Cargo.lock b/Cargo.lock index f56106f0..43bcdd24 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -102,21 +102,6 @@ dependencies = [ "memchr", ] -[[package]] -name = "alloc-no-stdlib" -version = "2.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" - -[[package]] -name = "alloc-stdlib" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" -dependencies = [ - "alloc-no-stdlib", -] - [[package]] name = "allocator-api2" version = "0.2.14" @@ -306,22 +291,6 @@ dependencies = [ "futures-core", ] -[[package]] -name = "async-compression" -version = "0.3.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "942c7cd7ae39e91bde4820d74132e9862e62c2f386c3aa90ccf55949f5bad63a" -dependencies = [ - "brotli", - "flate2", - "futures-core", - "memchr", - "pin-project-lite", - "tokio", - "zstd", - "zstd-safe", -] - [[package]] name = "async-executor" version = "1.5.1" @@ -966,7 +935,7 @@ dependencies = [ "cc", "cfg-if", "libc", - "miniz_oxide 0.6.2", + "miniz_oxide", "object", "rustc-demangle", ] @@ -1091,27 +1060,6 @@ dependencies = [ "cipher", ] -[[package]] -name = "brotli" -version = "3.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1a0b1dbcc8ae29329621f8d4f0d835787c1c38bb1401979b49d13b0b305ff68" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", - "brotli-decompressor", -] - -[[package]] -name = "brotli-decompressor" -version = "2.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b6561fd3f895a11e8f72af2cb7d22e08366bebc2b6b57f7744c4bda27034744" -dependencies = [ - "alloc-no-stdlib", - "alloc-stdlib", -] - [[package]] name = "bstr" version = "1.5.0" @@ -2063,16 +2011,6 @@ dependencies = [ "log", ] -[[package]] -name = "flate2" -version = "1.0.26" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b9429470923de8e8cbd4d2dc513535400b4b3fef0319fb5c4e1f520a7bef743" -dependencies = [ - "crc32fast", - "miniz_oxide 0.7.1", -] - [[package]] name = "flume" version = "0.10.14" @@ -3306,6 +3244,7 @@ dependencies = [ "mas-oidc-client", "mas-policy", "mas-router", + "mas-spa", "mas-storage", "mas-storage-pg", "mas-templates", @@ -3571,14 +3510,8 @@ name = "mas-spa" version = "0.1.0" dependencies = [ "camino", - "headers", - "http", "serde", - "serde_json", "thiserror", - "tokio", - "tower-http", - "tower-service", ] [[package]] @@ -3667,6 +3600,7 @@ dependencies = [ "http", "mas-data-model", "mas-router", + "mas-spa", "oauth2-types", "rand 0.8.5", "serde", @@ -3780,15 +3714,6 @@ dependencies = [ "adler", ] -[[package]] -name = "miniz_oxide" -version = "0.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7" -dependencies = [ - "adler", -] - [[package]] name = "mio" version = "0.8.8" @@ -6214,7 +6139,6 @@ version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8bd22a874a2d0b70452d5597b12c537331d49060824a95f49f108994f94aa4c" dependencies = [ - "async-compression", "bitflags 2.3.3", "bytes 1.4.0", "futures-core", diff --git a/crates/cli/Cargo.toml b/crates/cli/Cargo.toml index 65a069b2..c26b8234 100644 --- a/crates/cli/Cargo.toml +++ b/crates/cli/Cargo.toml @@ -25,7 +25,7 @@ serde_yaml = "0.9.22" sqlx = { version = "0.6.3", features = ["runtime-tokio-rustls", "postgres"] } tokio = { version = "1.29.1", features = ["full"] } tower = { version = "0.4.13", features = ["full"] } -tower-http = { version = "0.4.1", features = ["fs", "compression-full"] } +tower-http = { version = "0.4.1", features = ["fs"] } url = "2.4.0" watchman_client = "0.8.0" zeroize = "1.6.0" diff --git a/crates/cli/src/commands/server.rs b/crates/cli/src/commands/server.rs index c869db3c..2b5dd8a7 100644 --- a/crates/cli/src/commands/server.rs +++ b/crates/cli/src/commands/server.rs @@ -83,8 +83,11 @@ impl Options { let policy_factory = policy_factory_from_config(&config.policy).await?; let policy_factory = Arc::new(policy_factory); - let url_builder = - UrlBuilder::new(config.http.public_base.clone(), config.http.issuer.clone()); + let url_builder = UrlBuilder::new( + config.http.public_base.clone(), + config.http.issuer.clone(), + None, + ); // Load and compile the templates let templates = templates_from_config(&config.templates, &url_builder).await?; diff --git a/crates/cli/src/commands/templates.rs b/crates/cli/src/commands/templates.rs index ce98c1d9..f97f8ca0 100644 --- a/crates/cli/src/commands/templates.rs +++ b/crates/cli/src/commands/templates.rs @@ -12,13 +12,14 @@ // See the License for the specific language governing permissions and // limitations under the License. -use camino::Utf8PathBuf; use clap::Parser; +use mas_config::TemplatesConfig; use mas_storage::{Clock, SystemClock}; -use mas_templates::Templates; use rand::SeedableRng; use tracing::info_span; +use crate::util::templates_from_config; + #[derive(Parser, Debug)] pub(super) struct Options { #[clap(subcommand)] @@ -27,26 +28,24 @@ pub(super) struct Options { #[derive(Parser, Debug)] enum Subcommand { - /// Check for template validity at given path. - Check { - /// Path where the templates are - path: Utf8PathBuf, - }, + /// Check that the templates specified in the config are valid + Check, } impl Options { - pub async fn run(self, _root: &super::Options) -> anyhow::Result<()> { + pub async fn run(self, root: &super::Options) -> anyhow::Result<()> { use Subcommand as SC; match self.subcommand { - SC::Check { path } => { + SC::Check => { let _span = info_span!("cli.templates.check").entered(); + let config: TemplatesConfig = root.load_config()?; let clock = SystemClock::default(); // XXX: we should disallow SeedableRng::from_entropy let mut rng = rand_chacha::ChaChaRng::from_entropy(); let url_builder = - mas_router::UrlBuilder::new("https://example.com/".parse()?, None); - let templates = Templates::load(path, url_builder).await?; + mas_router::UrlBuilder::new("https://example.com/".parse()?, None, None); + let templates = templates_from_config(&config, &url_builder).await?; templates.check_render(clock.now(), &mut rng).await?; Ok(()) diff --git a/crates/cli/src/commands/worker.rs b/crates/cli/src/commands/worker.rs index 9559ce31..e0940bb6 100644 --- a/crates/cli/src/commands/worker.rs +++ b/crates/cli/src/commands/worker.rs @@ -37,8 +37,11 @@ impl Options { info!("Connecting to the database"); let pool = database_from_config(&config.database).await?; - let url_builder = - UrlBuilder::new(config.http.public_base.clone(), config.http.issuer.clone()); + let url_builder = UrlBuilder::new( + config.http.public_base.clone(), + config.http.issuer.clone(), + None, + ); // Load and compile the templates let templates = templates_from_config(&config.templates, &url_builder).await?; diff --git a/crates/cli/src/server.rs b/crates/cli/src/server.rs index 08d26176..368dacaa 100644 --- a/crates/cli/src/server.rs +++ b/crates/cli/src/server.rs @@ -26,13 +26,15 @@ use axum::{ extract::{FromRef, MatchedPath}, Extension, Router, }; -use hyper::{Method, Request, Response, StatusCode, Version}; +use hyper::{ + header::{HeaderValue, CACHE_CONTROL}, + Method, Request, Response, StatusCode, Version, +}; use listenfd::ListenFd; use mas_config::{HttpBindConfig, HttpResource, HttpTlsConfig, UnixOrTcp}; use mas_handlers::AppState; use mas_listener::{unix_or_tcp::UnixOrTcpListener, ConnectionInfo}; use mas_router::Route; -use mas_spa::ViteManifestService; use mas_templates::Templates; use mas_tower::{ make_span_fn, metrics_attributes_fn, DurationRecorderLayer, InFlightCounterLayer, TraceLayer, @@ -46,8 +48,8 @@ use opentelemetry_semantic_conventions::trace::{ use rustls::ServerConfig; use sentry_tower::{NewSentryLayer, SentryHttpLayer}; use tower::Layer; -use tower_http::{compression::CompressionLayer, services::ServeDir}; -use tracing::Span; +use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer}; +use tracing::{warn, Span}; use tracing_opentelemetry::OpenTelemetrySpanExt; const NET_PROTOCOL_NAME: Key = Key::from_static_str("net.protocol.name"); @@ -192,13 +194,23 @@ where router.merge(mas_handlers::graphql_router::(*playground)) } mas_config::HttpResource::Assets { path } => { - let static_service = ServeDir::new(path).append_index_html_on_directories(false); + let static_service = ServeDir::new(path) + .append_index_html_on_directories(false) + .precompressed_br() + .precompressed_gzip() + .precompressed_deflate(); + let error_layer = HandleErrorLayer::new(|_e| ready(StatusCode::INTERNAL_SERVER_ERROR)); + let cache_layer = SetResponseHeaderLayer::overriding( + CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ); + router.nest_service( mas_router::StaticAsset::route(), - error_layer.layer(static_service), + (error_layer, cache_layer).layer(static_service), ) } mas_config::HttpResource::OAuth => { @@ -215,25 +227,10 @@ where }), ), - mas_config::HttpResource::Spa { manifest } => { - let error_layer = - HandleErrorLayer::new(|_e| ready(StatusCode::INTERNAL_SERVER_ERROR)); - - // TODO: make those paths configurable - let app_base = "/app/"; - - // TODO: make that config typed and configurable - let config = serde_json::json!({ - "root": app_base, - }); - - let index_service = ViteManifestService::new( - manifest.clone(), - mas_router::StaticAsset::route().into(), - config, - ); - - router.nest_service(app_base, error_layer.layer(index_service)) + #[allow(deprecated)] + mas_config::HttpResource::Spa { .. } => { + warn!("The SPA HTTP resource is deprecated"); + router } } } @@ -266,7 +263,6 @@ where ) .layer(SentryHttpLayer::new()) .layer(NewSentryLayer::new_from_top()) - .layer(CompressionLayer::new()) .with_state(state) } diff --git a/crates/cli/src/util.rs b/crates/cli/src/util.rs index 703038c8..994a4045 100644 --- a/crates/cli/src/util.rs +++ b/crates/cli/src/util.rs @@ -111,7 +111,12 @@ pub async fn templates_from_config( config: &TemplatesConfig, url_builder: &UrlBuilder, ) -> Result { - Templates::load(config.path.clone(), url_builder.clone()).await + Templates::load( + config.path.clone(), + url_builder.clone(), + config.assets_manifest.clone(), + ) + .await } #[tracing::instrument(name = "db.connect", skip_all, err(Debug))] diff --git a/crates/config/src/sections/http.rs b/crates/config/src/sections/http.rs index 452595e5..e4c283e1 100644 --- a/crates/config/src/sections/http.rs +++ b/crates/config/src/sections/http.rs @@ -12,6 +12,8 @@ // See the License for the specific language governing permissions and // limitations under the License. +#![allow(deprecated)] + use std::{borrow::Cow, io::Cursor, ops::Deref}; use anyhow::bail; @@ -43,21 +45,11 @@ fn http_address_example_4() -> &'static str { "0.0.0.0:8080" } -#[cfg(not(feature = "docker"))] -fn http_listener_spa_manifest_default() -> Utf8PathBuf { - "./frontend/dist/manifest.json".into() -} - #[cfg(not(feature = "docker"))] fn http_listener_assets_path_default() -> Utf8PathBuf { "./frontend/dist/".into() } -#[cfg(feature = "docker")] -fn http_listener_spa_manifest_default() -> Utf8PathBuf { - "/usr/local/share/mas-cli/manifest.json".into() -} - #[cfg(feature = "docker")] fn http_listener_assets_path_default() -> Utf8PathBuf { "/usr/local/share/mas-cli/assets/".into() @@ -285,12 +277,10 @@ pub enum Resource { ConnectionInfo, /// Mount the single page app - Spa { - /// Path to the vite manifest.json - #[serde(default = "http_listener_spa_manifest_default")] - #[schemars(with = "String")] - manifest: Utf8PathBuf, - }, + /// + /// This is deprecated and will be removed in a future release. + #[deprecated = "This resource is deprecated and will be removed in a future release"] + Spa, } /// Configuration of a listener @@ -346,9 +336,6 @@ impl Default for HttpConfig { Resource::Assets { path: http_listener_assets_path_default(), }, - Resource::Spa { - manifest: http_listener_spa_manifest_default(), - }, ], tls: None, proxy_protocol: false, diff --git a/crates/config/src/sections/templates.rs b/crates/config/src/sections/templates.rs index 9cdfee54..09ab50eb 100644 --- a/crates/config/src/sections/templates.rs +++ b/crates/config/src/sections/templates.rs @@ -30,6 +30,16 @@ fn default_path() -> Utf8PathBuf { "/usr/local/share/mas-cli/templates/".into() } +#[cfg(not(feature = "docker"))] +fn default_assets_path() -> Utf8PathBuf { + "./frontend/dist/manifest.json".into() +} + +#[cfg(feature = "docker")] +fn default_assets_path() -> Utf8PathBuf { + "/usr/local/share/mas-cli/manifest.json".into() +} + /// Configuration related to templates #[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)] pub struct TemplatesConfig { @@ -37,12 +47,18 @@ pub struct TemplatesConfig { #[serde(default = "default_path")] #[schemars(with = "Option")] pub path: Utf8PathBuf, + + /// Path to the assets manifest + #[serde(default = "default_assets_path")] + #[schemars(with = "Option")] + pub assets_manifest: Utf8PathBuf, } impl Default for TemplatesConfig { fn default() -> Self { Self { path: default_path(), + assets_manifest: default_assets_path(), } } } diff --git a/crates/handlers/Cargo.toml b/crates/handlers/Cargo.toml index de8ae522..00804327 100644 --- a/crates/handlers/Cargo.toml +++ b/crates/handlers/Cargo.toml @@ -68,6 +68,7 @@ mas-matrix = { path = "../matrix" } mas-oidc-client = { path = "../oidc-client" } mas-policy = { path = "../policy" } mas-router = { path = "../router" } +mas-spa = { path = "../spa" } mas-storage = { path = "../storage" } mas-storage-pg = { path = "../storage-pg" } mas-templates = { path = "../templates" } diff --git a/crates/handlers/src/lib.rs b/crates/handlers/src/lib.rs index 20f36045..46dd54ef 100644 --- a/crates/handlers/src/lib.rs +++ b/crates/handlers/src/lib.rs @@ -268,6 +268,12 @@ where BoxRng: FromRequestParts, { Router::new() + // TODO: mount this route somewhere else? + .route(mas_router::Account::route(), get(self::views::app::get)) + .route( + mas_router::AccountWildcard::route(), + get(self::views::app::get), + ) .route( mas_router::ChangePasswordDiscovery::route(), get(|| async { mas_router::AccountPassword.go() }), @@ -286,15 +292,10 @@ where mas_router::Register::route(), get(self::views::register::get).post(self::views::register::post), ) - .route(mas_router::Account::route(), get(self::views::account::get)) .route( mas_router::AccountPassword::route(), get(self::views::account::password::get).post(self::views::account::password::post), ) - .route( - mas_router::AccountEmails::route(), - get(self::views::account::emails::get).post(self::views::account::emails::post), - ) .route( mas_router::AccountVerifyEmail::route(), get(self::views::account::emails::verify::get) diff --git a/crates/handlers/src/test_utils.rs b/crates/handlers/src/test_utils.rs index e3a98763..edcdaebb 100644 --- a/crates/handlers/src/test_utils.rs +++ b/crates/handlers/src/test_utils.rs @@ -110,10 +110,14 @@ impl TestState { .join("..") .join(".."); - let url_builder = UrlBuilder::new("https://example.com/".parse()?, None); + let url_builder = UrlBuilder::new("https://example.com/".parse()?, None, None); - let templates = - Templates::load(workspace_root.join("templates"), url_builder.clone()).await?; + let templates = Templates::load( + workspace_root.join("templates"), + url_builder.clone(), + workspace_root.join("frontend/dist/manifest.json"), + ) + .await?; // TODO: add more test keys to the store let rsa = diff --git a/crates/handlers/src/views/account/emails/mod.rs b/crates/handlers/src/views/account/emails/mod.rs index a1ee661f..df8a7865 100644 --- a/crates/handlers/src/views/account/emails/mod.rs +++ b/crates/handlers/src/views/account/emails/mod.rs @@ -12,190 +12,5 @@ // See the License for the specific language governing permissions and // limitations under the License. -use anyhow::{anyhow, Context}; -use axum::{ - extract::{Form, State}, - response::{Html, IntoResponse, Response}, -}; -use axum_extra::extract::PrivateCookieJar; -use mas_axum_utils::{ - csrf::{CsrfExt, ProtectedForm}, - FancyError, SessionInfoExt, -}; -use mas_data_model::BrowserSession; -use mas_keystore::Encrypter; -use mas_router::Route; -use mas_storage::{ - job::{JobRepositoryExt, ProvisionUserJob, VerifyEmailJob}, - user::UserEmailRepository, - BoxClock, BoxRepository, BoxRng, Clock, RepositoryAccess, -}; -use mas_templates::{AccountEmailsContext, TemplateContext, Templates}; -use rand::Rng; -use serde::Deserialize; - pub mod add; pub mod verify; - -#[derive(Deserialize, Debug)] -#[serde(tag = "action", rename_all = "snake_case")] -pub enum ManagementForm { - Add { email: String }, - ResendConfirmation { id: String }, - SetPrimary { id: String }, - Remove { id: String }, -} - -#[tracing::instrument(name = "handlers.views.account_email_list.get", skip_all, err)] -pub(crate) async fn get( - mut rng: BoxRng, - clock: BoxClock, - State(templates): State, - mut repo: BoxRepository, - cookie_jar: PrivateCookieJar, -) -> Result { - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut repo).await?; - - if let Some(session) = maybe_session { - render(&mut rng, &clock, templates, session, cookie_jar, &mut repo).await - } else { - let login = mas_router::Login::default(); - Ok((cookie_jar, login.go()).into_response()) - } -} - -async fn render( - rng: impl Rng + Send, - clock: &impl Clock, - templates: Templates, - session: BrowserSession, - cookie_jar: PrivateCookieJar, - repo: &mut impl RepositoryAccess, -) -> Result { - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(clock, rng); - - let emails = repo.user_email().all(&session.user).await?; - - let ctx = AccountEmailsContext::new(emails) - .with_session(session) - .with_csrf(csrf_token.form_value()); - - let content = templates.render_account_emails(&ctx).await?; - - Ok((cookie_jar, Html(content)).into_response()) -} - -#[tracing::instrument(name = "handlers.views.account_email_list.post", skip_all, err)] -pub(crate) async fn post( - mut rng: BoxRng, - clock: BoxClock, - State(templates): State, - mut repo: BoxRepository, - cookie_jar: PrivateCookieJar, - Form(form): Form>, -) -> Result { - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut repo).await?; - - let Some(mut session) = maybe_session else { - let login = mas_router::Login::default(); - return Ok((cookie_jar, login.go()).into_response()); - }; - - let form = cookie_jar.verify_form(&clock, form)?; - - match form { - ManagementForm::Add { email } => { - let user_email = repo - .user_email() - .add(&mut rng, &clock, &session.user, email) - .await?; - - let next = mas_router::AccountVerifyEmail::new(user_email.id); - - repo.job() - .schedule_job(VerifyEmailJob::new(&user_email)) - .await?; - - repo.save().await?; - - return Ok((cookie_jar, next.go()).into_response()); - } - ManagementForm::ResendConfirmation { id } => { - let id = id.parse()?; - - let user_email = repo - .user_email() - .lookup(id) - .await? - .context("Email not found")?; - - if user_email.user_id != session.user.id { - return Err(anyhow!("Email not found").into()); - } - - let next = mas_router::AccountVerifyEmail::new(user_email.id); - - repo.job() - .schedule_job(VerifyEmailJob::new(&user_email)) - .await?; - - repo.save().await?; - - return Ok((cookie_jar, next.go()).into_response()); - } - ManagementForm::Remove { id } => { - let id = id.parse()?; - - let email = repo - .user_email() - .lookup(id) - .await? - .context("Email not found")?; - - if email.user_id != session.user.id { - return Err(anyhow!("Email not found").into()); - } - - repo.user_email().remove(email).await?; - } - ManagementForm::SetPrimary { id } => { - let id = id.parse()?; - let email = repo - .user_email() - .lookup(id) - .await? - .context("Email not found")?; - - if email.user_id != session.user.id { - return Err(anyhow!("Email not found").into()); - } - - repo.user_email().set_as_primary(&email).await?; - session.user.primary_user_email_id = Some(email.id); - } - }; - - // XXX: It shouldn't hurt to do this even if the user didn't change their emails - // in a meaningful way - repo.job() - .schedule_job(ProvisionUserJob::new(&session.user)) - .await?; - - let reply = render( - &mut rng, - &clock, - templates.clone(), - session, - cookie_jar, - &mut repo, - ) - .await?; - - repo.save().await?; - - Ok(reply) -} diff --git a/crates/handlers/src/views/account/emails/verify.rs b/crates/handlers/src/views/account/emails/verify.rs index 401d6c8f..3a344440 100644 --- a/crates/handlers/src/views/account/emails/verify.rs +++ b/crates/handlers/src/views/account/emails/verify.rs @@ -74,7 +74,7 @@ pub(crate) async fn get( if user_email.confirmed_at.is_some() { // This email was already verified, skip - let destination = query.go_next_or_default(&mas_router::AccountEmails); + let destination = query.go_next_or_default(&mas_router::Account); return Ok((cookie_jar, destination).into_response()); } @@ -146,6 +146,6 @@ pub(crate) async fn post( repo.save().await?; - let destination = query.go_next_or_default(&mas_router::AccountEmails); + let destination = query.go_next_or_default(&mas_router::Account); Ok((cookie_jar, destination).into_response()) } diff --git a/crates/handlers/src/views/account/mod.rs b/crates/handlers/src/views/account/mod.rs index fde3bc82..3c9090ae 100644 --- a/crates/handlers/src/views/account/mod.rs +++ b/crates/handlers/src/views/account/mod.rs @@ -14,48 +14,3 @@ pub mod emails; pub mod password; - -use axum::{ - extract::State, - response::{Html, IntoResponse, Response}, -}; -use axum_extra::extract::PrivateCookieJar; -use mas_axum_utils::{csrf::CsrfExt, FancyError, SessionInfoExt}; -use mas_keystore::Encrypter; -use mas_router::Route; -use mas_storage::{ - user::{BrowserSessionRepository, UserEmailRepository}, - BoxClock, BoxRepository, BoxRng, -}; -use mas_templates::{AccountContext, TemplateContext, Templates}; - -#[tracing::instrument(name = "handlers.views.account.get", skip_all, err)] -pub(crate) async fn get( - mut rng: BoxRng, - clock: BoxClock, - State(templates): State, - mut repo: BoxRepository, - cookie_jar: PrivateCookieJar, -) -> Result { - let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); - let (session_info, cookie_jar) = cookie_jar.session_info(); - - let maybe_session = session_info.load_session(&mut repo).await?; - - let Some(session) = maybe_session else { - let login = mas_router::Login::default(); - return Ok((cookie_jar, login.go()).into_response()); - }; - - let active_sessions = repo.browser_session().count_active(&session.user).await?; - - let emails = repo.user_email().all(&session.user).await?; - - let ctx = AccountContext::new(active_sessions, emails) - .with_session(session) - .with_csrf(csrf_token.form_value()); - - let content = templates.render_account_index(&ctx).await?; - - Ok((cookie_jar, Html(content)).into_response()) -} diff --git a/crates/handlers/src/views/app.rs b/crates/handlers/src/views/app.rs new file mode 100644 index 00000000..5f5a2971 --- /dev/null +++ b/crates/handlers/src/views/app.rs @@ -0,0 +1,48 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +use axum::{ + extract::State, + response::{Html, IntoResponse}, +}; +use axum_extra::extract::PrivateCookieJar; +use mas_axum_utils::{FancyError, SessionInfoExt}; +use mas_keystore::Encrypter; +use mas_router::{PostAuthAction, Route}; +use mas_storage::BoxRepository; +use mas_templates::{AppContext, Templates}; + +#[tracing::instrument(name = "handlers.views.app.get", skip_all, err)] +pub async fn get( + State(templates): State, + mut repo: BoxRepository, + cookie_jar: PrivateCookieJar, +) -> Result { + let (session_info, cookie_jar) = cookie_jar.session_info(); + let session = session_info.load_session(&mut repo).await?; + + // TODO: keep the full path + if session.is_none() { + return Ok(( + cookie_jar, + mas_router::Login::and_then(PostAuthAction::ManageAccount).go(), + ) + .into_response()); + } + + let ctx = AppContext::default(); + let content = templates.render_app(&ctx).await?; + + Ok((cookie_jar, Html(content)).into_response()) +} diff --git a/crates/handlers/src/views/mod.rs b/crates/handlers/src/views/mod.rs index ee0ed4ad..2f28ba98 100644 --- a/crates/handlers/src/views/mod.rs +++ b/crates/handlers/src/views/mod.rs @@ -13,6 +13,7 @@ // limitations under the License. pub mod account; +pub mod app; pub mod index; pub mod login; pub mod logout; diff --git a/crates/handlers/src/views/shared.rs b/crates/handlers/src/views/shared.rs index e2caeb29..2ec1bdda 100644 --- a/crates/handlers/src/views/shared.rs +++ b/crates/handlers/src/views/shared.rs @@ -87,6 +87,8 @@ impl OptionalPostAuthAction { let link = Box::new(link); PostAuthContextInner::LinkUpstream { provider, link } } + + PostAuthAction::ManageAccount => PostAuthContextInner::ManageAccount, }; Ok(Some(PostAuthContext { diff --git a/crates/oidc-client/Cargo.toml b/crates/oidc-client/Cargo.toml index 30dae223..491ea4e7 100644 --- a/crates/oidc-client/Cargo.toml +++ b/crates/oidc-client/Cargo.toml @@ -60,7 +60,7 @@ features = ["client", "http1", "http2", "stream", "runtime" ] optional = true [dependencies.tower-http] version = "0.4.1" -features = ["follow-redirect", "decompression-full", "set-header", "timeout"] +features = ["follow-redirect", "set-header", "timeout", "map-request-body", "util"] optional = true [dev-dependencies] diff --git a/crates/oidc-client/src/http_service/hyper/mod.rs b/crates/oidc-client/src/http_service/hyper.rs similarity index 77% rename from crates/oidc-client/src/http_service/hyper/mod.rs rename to crates/oidc-client/src/http_service/hyper.rs index 775914a8..8be0fa11 100644 --- a/crates/oidc-client/src/http_service/hyper/mod.rs +++ b/crates/oidc-client/src/http_service/hyper.rs @@ -19,17 +19,13 @@ use std::time::Duration; use http::{header::USER_AGENT, HeaderValue}; +use http_body::Full; use hyper::client::{connect::dns::GaiResolver, HttpConnector}; use hyper_rustls::{ConfigBuilderExt, HttpsConnectorBuilder}; -use tower::{limit::ConcurrencyLimitLayer, BoxError, ServiceBuilder}; -use tower_http::{ - decompression::DecompressionLayer, follow_redirect::FollowRedirectLayer, - set_header::SetRequestHeaderLayer, timeout::TimeoutLayer, -}; +use mas_http::BodyToBytesResponseLayer; +use tower::{BoxError, ServiceBuilder}; +use tower_http::{timeout::TimeoutLayer, ServiceBuilderExt}; -mod body_layer; - -use self::body_layer::BodyLayer; use super::HttpService; static MAS_USER_AGENT: HeaderValue = HeaderValue::from_static("mas-oidc-client/0.0.1"); @@ -60,14 +56,11 @@ pub fn hyper_service() -> HttpService { let client = ServiceBuilder::new() .map_err(BoxError::from) - .layer(BodyLayer::default()) - .layer(DecompressionLayer::new()) - .layer(SetRequestHeaderLayer::overriding( - USER_AGENT, - MAS_USER_AGENT.clone(), - )) - .layer(ConcurrencyLimitLayer::new(10)) - .layer(FollowRedirectLayer::new()) + .map_request_body(Full::new) + .layer(BodyToBytesResponseLayer::default()) + .override_request_header(USER_AGENT, MAS_USER_AGENT.clone()) + .concurrency_limit(10) + .follow_redirects() .layer(TimeoutLayer::new(Duration::from_secs(10))) .service(client); diff --git a/crates/oidc-client/src/http_service/hyper/body_layer.rs b/crates/oidc-client/src/http_service/hyper/body_layer.rs deleted file mode 100644 index c1d940e8..00000000 --- a/crates/oidc-client/src/http_service/hyper/body_layer.rs +++ /dev/null @@ -1,88 +0,0 @@ -// Copyright 2022 Kévin Commaille. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use std::task::Poll; - -use bytes::Bytes; -use futures_util::future::BoxFuture; -use http::{Request, Response}; -use http_body::{Body, Full}; -use hyper::body::to_bytes; -use thiserror::Error; -use tower::{BoxError, Layer, Service}; - -#[derive(Debug, Error)] -#[error(transparent)] -pub enum BodyError { - Decompression(BoxError), - Service(E), -} - -#[derive(Clone)] -pub struct BodyService { - inner: S, -} - -impl BodyService { - pub const fn new(inner: S) -> Self { - Self { inner } - } -} - -impl Service> for BodyService -where - S: Service>, Response = Response, Error = E>, - ResBody: Body + Send, - S::Future: Send + 'static, -{ - type Error = BodyError; - type Response = Response; - type Future = BoxFuture<'static, Result>; - - fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll> { - self.inner.poll_ready(cx).map_err(BodyError::Service) - } - - fn call(&mut self, request: Request) -> Self::Future { - let (parts, body) = request.into_parts(); - let body = Full::new(body); - - let request = Request::from_parts(parts, body); - - let fut = self.inner.call(request); - - let fut = async { - let response = fut.await.map_err(BodyError::Service)?; - - let (parts, body) = response.into_parts(); - let body = to_bytes(body).await.map_err(BodyError::Decompression)?; - - let response = Response::from_parts(parts, body); - Ok(response) - }; - - Box::pin(fut) - } -} - -#[derive(Default, Clone, Copy)] -pub struct BodyLayer(()); - -impl Layer for BodyLayer { - type Service = BodyService; - - fn layer(&self, inner: S) -> Self::Service { - BodyService::new(inner) - } -} diff --git a/crates/router/src/endpoints.rs b/crates/router/src/endpoints.rs index 308abd9c..8b71da3e 100644 --- a/crates/router/src/endpoints.rs +++ b/crates/router/src/endpoints.rs @@ -24,6 +24,7 @@ pub enum PostAuthAction { ContinueCompatSsoLogin { id: Ulid }, ChangePassword, LinkUpstream { id: Ulid }, + ManageAccount, } impl PostAuthAction { @@ -48,6 +49,7 @@ impl PostAuthAction { Self::ContinueCompatSsoLogin { id } => CompatLoginSsoComplete::new(*id, None).go(), Self::ChangePassword => AccountPassword.go(), Self::LinkUpstream { id } => UpstreamOAuth2Link::new(*id).go(), + Self::ManageAccount => Account.go(), } } } @@ -335,7 +337,7 @@ impl From> for Register { } } -/// `GET|POST /account/emails/verify/:id` +/// `GET|POST /verify-email/:id` #[derive(Debug, Clone)] pub struct AccountVerifyEmail { id: Ulid, @@ -367,19 +369,19 @@ impl AccountVerifyEmail { impl Route for AccountVerifyEmail { type Query = PostAuthAction; fn route() -> &'static str { - "/account/emails/verify/:id" - } - - fn path(&self) -> std::borrow::Cow<'static, str> { - format!("/account/emails/verify/{}", self.id).into() + "/verify-email/:id" } fn query(&self) -> Option<&Self::Query> { self.post_auth_action.as_ref() } + + fn path(&self) -> std::borrow::Cow<'static, str> { + format!("/verify-email/{}", self.id).into() + } } -/// `GET /account/emails/add` +/// `GET /add-email` #[derive(Default, Debug, Clone)] pub struct AccountAddEmail { post_auth_action: Option, @@ -388,7 +390,7 @@ pub struct AccountAddEmail { impl Route for AccountAddEmail { type Query = PostAuthAction; fn route() -> &'static str { - "/account/emails/add" + "/add-email" } fn query(&self) -> Option<&Self::Query> { @@ -404,28 +406,28 @@ impl AccountAddEmail { } } -/// `GET /account` +/// `GET /account/` #[derive(Default, Debug, Clone)] pub struct Account; impl SimpleRoute for Account { - const PATH: &'static str = "/account"; + const PATH: &'static str = "/account/"; } -/// `GET|POST /account/password` +/// `GET /account/*` +#[derive(Default, Debug, Clone)] +pub struct AccountWildcard; + +impl SimpleRoute for AccountWildcard { + const PATH: &'static str = "/account/*rest"; +} + +/// `GET|POST /change-password` #[derive(Default, Debug, Clone)] pub struct AccountPassword; impl SimpleRoute for AccountPassword { - const PATH: &'static str = "/account/password"; -} - -/// `GET|POST /account/emails` -#[derive(Default, Debug, Clone)] -pub struct AccountEmails; - -impl SimpleRoute for AccountEmails { - const PATH: &'static str = "/account/emails"; + const PATH: &'static str = "/change-password"; } /// `GET /authorize/:grant_id` diff --git a/crates/router/src/url_builder.rs b/crates/router/src/url_builder.rs index 4cc1e2e6..b1fa1089 100644 --- a/crates/router/src/url_builder.rs +++ b/crates/router/src/url_builder.rs @@ -1,4 +1,4 @@ -// Copyright 2022 The Matrix.org Foundation C.I.C. +// Copyright 2022, 2023 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -14,6 +14,8 @@ //! Utility to build URLs +use std::borrow::Cow; + use ulid::Ulid; use url::Url; @@ -21,7 +23,8 @@ use crate::traits::Route; #[derive(Clone, Debug, PartialEq, Eq)] pub struct UrlBuilder { - base: Url, + http_base: Url, + assets_base: Cow<'static, str>, issuer: Url, } @@ -30,21 +33,26 @@ impl UrlBuilder { where U: Route, { - destination.absolute_url(&self.base) + destination.absolute_url(&self.http_base) } pub fn absolute_redirect(&self, destination: &U) -> axum::response::Redirect where U: Route, { - destination.go_absolute(&self.base) + destination.go_absolute(&self.http_base) } /// Create a new [`UrlBuilder`] from a base URL #[must_use] - pub fn new(base: Url, issuer: Option) -> Self { + pub fn new(base: Url, issuer: Option, assets_base: Option) -> Self { let issuer = issuer.unwrap_or_else(|| base.clone()); - Self { base, issuer } + let assets_base = assets_base.map_or(Cow::Borrowed("/assets/"), Cow::Owned); + Self { + http_base: base, + assets_base, + issuer, + } } /// OIDC issuer @@ -107,6 +115,12 @@ impl UrlBuilder { self.url_for(&crate::endpoints::StaticAsset::new(path)) } + /// Static asset base + #[must_use] + pub fn assets_base(&self) -> &str { + &self.assets_base + } + /// Upstream redirect URI #[must_use] pub fn upstream_oauth_callback(&self, id: Ulid) -> Url { diff --git a/crates/spa/Cargo.toml b/crates/spa/Cargo.toml index 72ea3ade..d4e190d7 100644 --- a/crates/spa/Cargo.toml +++ b/crates/spa/Cargo.toml @@ -7,14 +7,6 @@ license = "Apache-2.0" [dependencies] serde = { version = "1.0.166", features = ["derive"] } -serde_json = "1.0.100" thiserror = "1.0.41" camino = { version = "1.1.4", features = ["serde1"] } -headers = "0.3.8" -http = "0.2.9" -tower-service = "0.3.2" -tower-http = { version = "0.4.1", features = ["fs"] } -tokio = { version = "1.29.1", features = ["fs"] } -[[bin]] -name = "render" diff --git a/crates/spa/src/bin/render.rs b/crates/spa/src/bin/render.rs deleted file mode 100644 index a06a9523..00000000 --- a/crates/spa/src/bin/render.rs +++ /dev/null @@ -1,32 +0,0 @@ -// Copyright 2022 The Matrix.org Foundation C.I.C. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -use camino::Utf8Path; -use mas_spa::ViteManifest; - -fn main() { - let mut stdin = std::io::stdin(); - let manifest: ViteManifest = - serde_json::from_reader(&mut stdin).expect("failed to read manifest from stdin"); - let assets_base = Utf8Path::new("/assets/"); - - let config = serde_json::json!({ - "root": "/app/", - }); - - let html = manifest - .render(assets_base, &config) - .expect("failed to render"); - println!("{html}"); -} diff --git a/crates/spa/src/lib.rs b/crates/spa/src/lib.rs index fbef5386..ad815e9e 100644 --- a/crates/spa/src/lib.rs +++ b/crates/spa/src/lib.rs @@ -25,73 +25,4 @@ mod vite; -use std::{future::Future, pin::Pin}; - -use camino::Utf8PathBuf; -use headers::{ContentType, HeaderMapExt}; -use http::Response; -use serde::Serialize; -use tower_service::Service; - pub use self::vite::Manifest as ViteManifest; - -/// Service which renders an `index.html` based on the files in the manifest -#[derive(Debug, Clone)] -pub struct ViteManifestService { - manifest: Utf8PathBuf, - assets_base: Utf8PathBuf, - config: T, -} - -impl ViteManifestService { - #[must_use] - pub const fn new(manifest: Utf8PathBuf, assets_base: Utf8PathBuf, config: T) -> Self { - Self { - manifest, - assets_base, - config, - } - } -} - -impl Service for ViteManifestService -where - T: Clone + Serialize + Send + Sync + 'static, -{ - type Error = std::io::Error; - type Response = Response; - type Future = - Pin> + Send + Sync + 'static>>; - - fn poll_ready( - &mut self, - _cx: &mut std::task::Context<'_>, - ) -> std::task::Poll> { - std::task::Poll::Ready(Ok(())) - } - - fn call(&mut self, _req: R) -> Self::Future { - let manifest = self.manifest.clone(); - let assets_base = self.assets_base.clone(); - let config = self.config.clone(); - - Box::pin(async move { - // Read the manifest from disk - let manifest = tokio::fs::read(manifest).await?; - - // Parse it - let manifest: ViteManifest = serde_json::from_slice(&manifest) - .map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error))?; - - // Render the HTML out of the manifest - let html = manifest - .render(&assets_base, &config) - .map_err(|error| std::io::Error::new(std::io::ErrorKind::Other, error))?; - - let mut response = Response::new(html); - response.headers_mut().typed_insert(ContentType::html()); - - Ok(response) - }) - } -} diff --git a/crates/spa/src/vite.rs b/crates/spa/src/vite.rs index 80c684d4..8a56777c 100644 --- a/crates/spa/src/vite.rs +++ b/crates/spa/src/vite.rs @@ -1,9 +1,23 @@ +// Copyright 2023 The Matrix.org Foundation C.I.C. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + use std::collections::{BTreeSet, HashMap}; use camino::{Utf8Path, Utf8PathBuf}; use thiserror::Error; -#[derive(serde::Deserialize, Debug)] +#[derive(serde::Deserialize, Debug, Clone)] #[serde(rename_all = "camelCase", deny_unknown_fields)] pub struct ManifestEntry { #[allow(dead_code)] @@ -13,7 +27,6 @@ pub struct ManifestEntry { css: Option>, - #[allow(dead_code)] assets: Option>, #[allow(dead_code)] @@ -22,73 +35,14 @@ pub struct ManifestEntry { #[allow(dead_code)] is_dynamic_entry: Option, - #[allow(dead_code)] imports: Option>, dynamic_imports: Option>, + + integrity: Option, } -/// Render the HTML template -fn template(head: impl Iterator, config: &impl serde::Serialize) -> String { - // This should be kept in sync with `../../../frontend/index.html` - - // Render the items to insert in the - let head: String = head.map(|f| format!(" {f}\n")).collect(); - // Serialize the config - let config = serde_json::to_string(config).expect("failed to serialize config"); - - // Script in the which manages the dark mode class on the element - let dark_mode_script = r#" - (function () { - const query = window.matchMedia("(prefers-color-scheme: dark)"); - function handleChange(e) { - if (e.matches) { - document.documentElement.classList.add("dark") - } else { - document.documentElement.classList.remove("dark") - } - } - - query.addListener(handleChange); - handleChange(query); - })(); - "#; - - format!( - r#" - - - - - matrix-authentication-service - - -{head} - -
- -"# - ) -} - -impl ManifestEntry { - /// Get a list of items to insert in the `` - fn head<'a>(&'a self, assets_base: &'a Utf8Path) -> impl Iterator + 'a { - let css = self.css.iter().flat_map(|css| { - css.iter().map(|href| { - let href = assets_base.join(href); - format!(r#""#) - }) - }); - - let script = assets_base.join(&self.file); - let script = format!(r#""#); - - css.chain(std::iter::once(script)) - } -} - -#[derive(serde::Deserialize, Debug)] +#[derive(serde::Deserialize, Debug, Clone)] pub struct Manifest { #[serde(flatten)] inner: HashMap, @@ -98,6 +52,8 @@ pub struct Manifest { enum FileType { Script, Stylesheet, + Woff, + Woff2, } impl FileType { @@ -105,6 +61,8 @@ impl FileType { match name.extension() { Some("css") => Some(Self::Stylesheet), Some("js") => Some(Self::Script), + Some("woff") => Some(Self::Woff), + Some("woff2") => Some(Self::Woff2), _ => None, } } @@ -112,104 +70,168 @@ impl FileType { #[derive(Debug, Error)] #[error("Invalid Vite manifest")] -pub enum InvalidManifest { - #[error("No index.html")] - NoIndex, +pub enum InvalidManifest<'a> { + #[error("Can't find asset for name {name:?}")] + CantFindAssetByName { name: &'a Utf8Path }, - #[error("Can't find preloaded entry")] - CantFindPreload, + #[error("Can't find asset for file {file:?}")] + CantFindAssetByFile { file: &'a Utf8Path }, #[error("Invalid file type")] InvalidFileType, } -/// Represents an entry which should be preloaded +/// Represents an entry which should be preloaded and included #[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)] -struct Preload<'name> { - name: &'name Utf8Path, +pub struct Asset<'a> { file_type: FileType, + name: &'a Utf8Path, + integrity: Option<&'a str>, } -impl<'a> Preload<'a> { - /// Generate a `` tag for this entry - fn link(&self, assets_base: &Utf8Path) -> String { - let href = assets_base.join(self.name); +impl<'a> Asset<'a> { + fn new(entry: &'a ManifestEntry) -> Result> { + let name = &entry.file; + let integrity = entry.integrity.as_deref(); + let file_type = FileType::from_name(name).ok_or(InvalidManifest::InvalidFileType)?; + Ok(Self { + file_type, + name, + integrity, + }) + } + + fn src(&self, assets_base: &Utf8Path) -> Utf8PathBuf { + assets_base.join(self.name) + } + + /// Generate a `` tag to preload this entry + pub fn preload_tag(&self, assets_base: &Utf8Path) -> String { + let href = self.src(assets_base); + let integrity = self + .integrity + .map(|i| format!(r#"integrity="{i}" "#)) + .unwrap_or_default(); match self.file_type { FileType::Stylesheet => { - format!(r#""#) + format!(r#""#) } - FileType::Script => format!( - r#""# - ), + FileType::Script => { + format!(r#""#) + } + FileType::Woff | FileType::Woff2 => { + format!(r#""#,) + } + } + } + + /// Generate a `` or `"# + )), + FileType::Woff | FileType::Woff2 => None, } } } impl Manifest { - /// Render an `index.html` page + /// Find all assets which should be loaded for a given entrypoint /// /// # Errors /// - /// Returns an error if the manifest is invalid. - pub fn render( - &self, - assets_base: &Utf8Path, - config: &impl serde::Serialize, - ) -> Result { - let entrypoint = Utf8Path::new("index.html"); - let entry = self.inner.get(entrypoint).ok_or(InvalidManifest::NoIndex)?; - - // Find the items that should be pre-loaded - let preload = self.find_preload(entrypoint)?; - let head = preload + /// Returns an error if the entrypoint is invalid for this manifest + pub fn assets_for<'a>( + &'a self, + entrypoint: &'a Utf8Path, + ) -> Result>, InvalidManifest<'a>> { + let entry = self.lookup_by_name(entrypoint)?; + let main_asset = Asset::new(entry)?; + entry + .css .iter() - .map(|p| p.link(assets_base)) - .chain(entry.head(assets_base)); - - let html = template(head, config); - - Ok(html) + .flatten() + .map(|name| self.lookup_by_file(name).and_then(Asset::new)) + .chain(std::iter::once(Ok(main_asset))) + .collect() } - /// Find entries to preload + /// Find all assets which should be preloaded for a given entrypoint + /// + /// # Errors + /// + /// Returns an error if the entrypoint is invalid for this manifest + pub fn preload_for<'a>( + &'a self, + entrypoint: &'a Utf8Path, + ) -> Result>, InvalidManifest<'a>> { + let entry = self.lookup_by_name(entrypoint)?; + self.find_preload(entry) + } + + /// Lookup an entry in the manifest by its original name + fn lookup_by_name<'a>( + &self, + name: &'a Utf8Path, + ) -> Result<&ManifestEntry, InvalidManifest<'a>> { + self.inner + .get(name) + .ok_or(InvalidManifest::CantFindAssetByName { name }) + } + + /// Lookup an entry in the manifest by its output name + fn lookup_by_file<'a>( + &self, + file: &'a Utf8Path, + ) -> Result<&ManifestEntry, InvalidManifest<'a>> { + self.inner + .values() + .find(|e| e.file == file) + .ok_or(InvalidManifest::CantFindAssetByFile { file }) + } + + /// Recursively find all the assets that should be preloaded fn find_preload<'a>( &'a self, - entrypoint: &Utf8Path, - ) -> Result>, InvalidManifest> { - // TODO: we're preoading the whole tree. We should instead guess which component - // should be loaded based on the route. + entry: &'a ManifestEntry, + ) -> Result>, InvalidManifest<'a>> { let mut entries = BTreeSet::new(); - self.find_preload_rec(entrypoint, &mut entries)?; + self.find_preload_rec(entry, &mut entries)?; Ok(entries) } fn find_preload_rec<'a>( &'a self, - entrypoint: &Utf8Path, - entries: &mut BTreeSet>, - ) -> Result<(), InvalidManifest> { - let entry = self - .inner - .get(entrypoint) - .ok_or(InvalidManifest::CantFindPreload)?; - let name = &entry.file; - let file_type = FileType::from_name(name).ok_or(InvalidManifest::InvalidFileType)?; - let preload = Preload { name, file_type }; - let inserted = entries.insert(preload); + current_entry: &'a ManifestEntry, + entries: &mut BTreeSet>, + ) -> Result<(), InvalidManifest<'a>> { + let asset = Asset::new(current_entry)?; + let inserted = entries.insert(asset); + // If we inserted the entry, we need to find its dependencies if inserted { - if let Some(css) = &entry.css { - let file_type = FileType::Stylesheet; - for name in css { - let preload = Preload { name, file_type }; - entries.insert(preload); - } + let css = current_entry.css.iter().flatten(); + let assets = current_entry.assets.iter().flatten(); + for name in css.chain(assets) { + let entry = self.lookup_by_file(name)?; + self.find_preload_rec(entry, entries)?; } - if let Some(dynamic_imports) = &entry.dynamic_imports { - for import in dynamic_imports { - self.find_preload_rec(import, entries)?; - } + let dynamic_imports = current_entry.dynamic_imports.iter().flatten(); + let imports = current_entry.imports.iter().flatten(); + for import in dynamic_imports.chain(imports) { + let entry = self.lookup_by_name(import)?; + self.find_preload_rec(entry, entries)?; } } diff --git a/crates/templates/Cargo.toml b/crates/templates/Cargo.toml index e9631c9f..5270cde6 100644 --- a/crates/templates/Cargo.toml +++ b/crates/templates/Cargo.toml @@ -27,3 +27,4 @@ rand = "0.8.5" oauth2-types = { path = "../oauth2-types" } mas-data-model = { path = "../data-model" } mas-router = { path = "../router" } +mas-spa = { path = "../spa" } diff --git a/crates/templates/src/context.rs b/crates/templates/src/context.rs index 172a4b79..6e833428 100644 --- a/crates/templates/src/context.rs +++ b/crates/templates/src/context.rs @@ -220,6 +220,45 @@ impl TemplateContext for IndexContext { } } +/// Config used by the frontend app +#[derive(Serialize)] +pub struct AppConfig { + root: String, +} + +impl Default for AppConfig { + fn default() -> Self { + Self { + root: "/account/".into(), + } + } +} + +/// Context used by the `app.html` template +#[derive(Serialize, Default)] +pub struct AppContext { + app_config: AppConfig, +} + +impl AppContext { + /// Constructs the context for the app page with the given app root + #[must_use] + pub fn with_app_root(root: String) -> Self { + Self { + app_config: AppConfig { root }, + } + } +} + +impl TemplateContext for AppContext { + fn sample(_now: chrono::DateTime, _rng: &mut impl Rng) -> Vec + where + Self: Sized, + { + vec![Self::default()] + } +} + /// Fields of the login form #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[serde(rename_all = "snake_case")] @@ -268,6 +307,9 @@ pub enum PostAuthContextInner { /// The link link: Box, }, + + /// Go to the account management page + ManageAccount, } /// Context used in login and reauth screens, for the post-auth action to do @@ -580,58 +622,6 @@ where { } } -/// Context used by the `account/index.html` template -#[derive(Serialize)] -pub struct AccountContext { - active_sessions: usize, - emails: Vec, -} - -impl AccountContext { - /// Constructs a context for the "my account" page - #[must_use] - pub fn new(active_sessions: usize, emails: Vec) -> Self { - Self { - active_sessions, - emails, - } - } -} - -impl TemplateContext for AccountContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec - where - Self: Sized, - { - let emails: Vec = UserEmail::samples(now, rng); - vec![Self::new(5, emails)] - } -} - -/// Context used by the `account/emails.html` template -#[derive(Serialize)] -pub struct AccountEmailsContext { - emails: Vec, -} - -impl AccountEmailsContext { - /// Constructs a context for the email management page - #[must_use] - pub fn new(emails: Vec) -> Self { - Self { emails } - } -} - -impl TemplateContext for AccountEmailsContext { - fn sample(now: chrono::DateTime, rng: &mut impl Rng) -> Vec - where - Self: Sized, - { - let emails: Vec = UserEmail::samples(now, rng); - vec![Self::new(emails)] - } -} - /// Context used by the `emails/verification.{txt,html,subject}` templates #[derive(Serialize)] pub struct EmailVerificationContext { diff --git a/crates/templates/src/functions.rs b/crates/templates/src/functions.rs index c49ec054..8c999a6a 100644 --- a/crates/templates/src/functions.rs +++ b/crates/templates/src/functions.rs @@ -16,18 +16,26 @@ use std::{collections::HashMap, str::FromStr}; -use mas_router::{Route, UrlBuilder}; +use camino::Utf8Path; +use mas_router::UrlBuilder; +use mas_spa::ViteManifest; use tera::{helpers::tests::number_args_allowed, Tera, Value}; use url::Url; -pub fn register(tera: &mut Tera, url_builder: UrlBuilder) { +pub fn register(tera: &mut Tera, url_builder: UrlBuilder, vite_manifest: ViteManifest) { tera.register_tester("empty", self::tester_empty); tera.register_filter("to_params", filter_to_params); tera.register_filter("safe_get", filter_safe_get); tera.register_function("add_params_to_url", function_add_params_to_url); tera.register_function("merge", function_merge); tera.register_function("dict", function_dict); - tera.register_function("static_asset", make_static_asset(url_builder)); + tera.register_function( + "include_asset", + IncludeAsset { + url_builder, + vite_manifest, + }, + ); } fn tester_empty(value: Option<&Value>, params: &[Value]) -> Result { @@ -145,25 +153,53 @@ fn function_dict(params: &HashMap) -> Result Ok(Value::Object(ret)) } -fn make_static_asset(url_builder: UrlBuilder) -> impl tera::Function { - Box::new( - move |args: &HashMap| -> Result { - if let Some(path) = args.get("path").and_then(Value::as_str) { - let absolute = args - .get("absolute") - .and_then(Value::as_bool) - .unwrap_or(false); - let path = path.to_owned(); - let url = if absolute { - url_builder.static_asset(path).into() - } else { - let destination = mas_router::StaticAsset::new(path); - destination.relative_url().into_owned() - }; - Ok(Value::String(url)) - } else { - Err(tera::Error::msg("Invalid parameter 'path'")) - } - }, - ) +struct IncludeAsset { + url_builder: UrlBuilder, + vite_manifest: ViteManifest, +} + +impl tera::Function for IncludeAsset { + fn call(&self, args: &HashMap) -> tera::Result { + let path = args.get("path").ok_or(tera::Error::msg( + "Function `include_asset` was missing parameter `path`", + ))?; + let path: &Utf8Path = path + .as_str() + .ok_or_else(|| { + tera::Error::msg( + "Function `include_asset` received an incorrect type for arg `path`", + ) + })? + .into(); + + let assets = self.vite_manifest.assets_for(path).map_err(|e| { + tera::Error::chain( + "Invalid assets manifest while calling function `include_asset`", + e.to_string(), + ) + })?; + + let preloads = self.vite_manifest.preload_for(path).map_err(|e| { + tera::Error::chain( + "Invalid assets manifest while calling function `include_asset`", + e.to_string(), + ) + })?; + + let tags: Vec = preloads + .iter() + .map(|asset| asset.preload_tag(self.url_builder.assets_base().into())) + .chain( + assets + .iter() + .filter_map(|asset| asset.include_tag(self.url_builder.assets_base().into())), + ) + .collect(); + + Ok(Value::String(tags.join("\n"))) + } + + fn is_safe(&self) -> bool { + true + } } diff --git a/crates/templates/src/lib.rs b/crates/templates/src/lib.rs index 73a36650..1e70683c 100644 --- a/crates/templates/src/lib.rs +++ b/crates/templates/src/lib.rs @@ -29,6 +29,7 @@ use std::{collections::HashSet, string::ToString, sync::Arc}; use anyhow::Context as _; use camino::{Utf8Path, Utf8PathBuf}; use mas_router::UrlBuilder; +use mas_spa::ViteManifest; use rand::Rng; use serde::Serialize; pub use tera::escape_html; @@ -46,12 +47,12 @@ mod macros; pub use self::{ context::{ - AccountContext, AccountEmailsContext, CompatSsoContext, ConsentContext, EmailAddContext, - EmailVerificationContext, EmailVerificationPageContext, EmptyContext, ErrorContext, - FormPostContext, IndexContext, LoginContext, LoginFormField, PolicyViolationContext, - PostAuthContext, PostAuthContextInner, ReauthContext, ReauthFormField, RegisterContext, - RegisterFormField, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, - UpstreamSuggestLink, WithCsrf, WithOptionalSession, WithSession, + AppContext, CompatSsoContext, ConsentContext, EmailAddContext, EmailVerificationContext, + EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext, + LoginContext, LoginFormField, PolicyViolationContext, PostAuthContext, + PostAuthContextInner, ReauthContext, ReauthFormField, RegisterContext, RegisterFormField, + TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamSuggestLink, + WithCsrf, WithOptionalSession, WithSession, }, forms::{FieldError, FormError, FormField, FormState, ToFormState}, }; @@ -61,6 +62,7 @@ pub use self::{ pub struct Templates { tera: Arc>, url_builder: UrlBuilder, + vite_manifest_path: Utf8PathBuf, path: Utf8PathBuf, } @@ -71,6 +73,14 @@ pub enum TemplateLoadingError { #[error(transparent)] IO(#[from] std::io::Error), + /// Failed to read the assets manifest + #[error("failed to read the assets manifest")] + ViteManifestIO(#[source] std::io::Error), + + /// Failed to deserialize the assets manifest + #[error("invalid assets manifest")] + ViteManifest(#[from] serde_json::Error), + /// Some templates failed to compile #[error("could not load and compile some templates")] Compile(#[from] TeraError), @@ -106,19 +116,34 @@ impl Templates { pub async fn load( path: Utf8PathBuf, url_builder: UrlBuilder, + vite_manifest_path: Utf8PathBuf, ) -> Result { - let tera = Self::load_(&path, url_builder.clone()).await?; + let tera = Self::load_(&path, url_builder.clone(), &vite_manifest_path).await?; Ok(Self { tera: Arc::new(RwLock::new(tera)), path, url_builder, + vite_manifest_path, }) } - async fn load_(path: &Utf8Path, url_builder: UrlBuilder) -> Result { + async fn load_( + path: &Utf8Path, + url_builder: UrlBuilder, + vite_manifest_path: &Utf8Path, + ) -> Result { let path = path.to_owned(); let span = tracing::Span::current(); + // Read the assets manifest from disk + let vite_manifest = tokio::fs::read(vite_manifest_path) + .await + .map_err(TemplateLoadingError::ViteManifestIO)?; + + // Parse it + let vite_manifest: ViteManifest = + serde_json::from_slice(&vite_manifest).map_err(TemplateLoadingError::ViteManifest)?; + // This uses blocking I/Os, do that in a blocking task let mut tera = tokio::task::spawn_blocking(move || { span.in_scope(move || { @@ -131,7 +156,7 @@ impl Templates { }) .await??; - self::functions::register(&mut tera, url_builder); + self::functions::register(&mut tera, url_builder, vite_manifest); let loaded: HashSet<_> = tera.get_template_names().collect(); let needed: HashSet<_> = TEMPLATES.into_iter().collect(); @@ -156,7 +181,12 @@ impl Templates { )] pub async fn reload(&self) -> Result<(), TemplateLoadingError> { // Prepare the new Tera instance - let new_tera = Self::load_(&self.path, self.url_builder.clone()).await?; + let new_tera = Self::load_( + &self.path, + self.url_builder.clone(), + &self.vite_manifest_path, + ) + .await?; // Swap it *self.tera.write().await = new_tera; @@ -192,6 +222,9 @@ pub enum TemplateError { } register_templates! { + /// Render the frontend app + pub fn render_app(AppContext) { "app.html" } + /// Render the login page pub fn render_login(WithCsrf) { "pages/login.html" } @@ -210,15 +243,9 @@ register_templates! { /// Render the home page pub fn render_index(WithCsrf>) { "pages/index.html" } - /// Render the account management page - pub fn render_account_index(WithCsrf>) { "pages/account/index.html" } - /// Render the password change page pub fn render_account_password(WithCsrf>) { "pages/account/password.html" } - /// Render the emails management - pub fn render_account_emails(WithCsrf>) { "pages/account/emails/index.html" } - /// Render the email verification page pub fn render_account_verify_email(WithCsrf>) { "pages/account/emails/verify.html" } @@ -267,15 +294,14 @@ impl Templates { now: chrono::DateTime, rng: &mut impl Rng, ) -> anyhow::Result<()> { + check::render_app(self, now, rng).await?; check::render_login(self, now, rng).await?; check::render_register(self, now, rng).await?; check::render_consent(self, now, rng).await?; check::render_policy_violation(self, now, rng).await?; check::render_sso_login(self, now, rng).await?; check::render_index(self, now, rng).await?; - check::render_account_index(self, now, rng).await?; check::render_account_password(self, now, rng).await?; - check::render_account_emails(self, now, rng).await?; check::render_account_add_email(self, now, rng).await?; check::render_account_verify_email(self, now, rng).await?; check::render_reauth(self, now, rng).await?; @@ -305,8 +331,12 @@ mod tests { let mut rng = rand::thread_rng(); let path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../templates/"); - let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None); - let templates = Templates::load(path, url_builder).await.unwrap(); + let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None); + let vite_manifest_path = + Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../frontend/dist/manifest.json"); + let templates = Templates::load(path, url_builder, vite_manifest_path) + .await + .unwrap(); templates.check_render(now, &mut rng).await.unwrap(); } } diff --git a/docs/config.schema.json b/docs/config.schema.json index 653d3779..a2b85910 100644 --- a/docs/config.schema.json +++ b/docs/config.schema.json @@ -89,10 +89,6 @@ { "name": "assets", "path": "./frontend/dist/" - }, - { - "manifest": "./frontend/dist/manifest.json", - "name": "spa" } ] }, @@ -191,6 +187,7 @@ "templates": { "description": "Configuration related to templates", "default": { + "assets_manifest": "./frontend/dist/manifest.json", "path": "./templates/" }, "allOf": [ @@ -1685,17 +1682,13 @@ } }, { - "description": "Mount the single page app", + "description": "Mount the single page app\n\nThis is deprecated and will be removed in a future release.", + "deprecated": true, "type": "object", "required": [ "name" ], "properties": { - "manifest": { - "description": "Path to the vite manifest.json", - "default": "./frontend/dist/manifest.json", - "type": "string" - }, "name": { "type": "string", "enum": [ @@ -1790,6 +1783,11 @@ "description": "Configuration related to templates", "type": "object", "properties": { + "assets_manifest": { + "description": "Path to the assets manifest", + "default": "./frontend/dist/manifest.json", + "type": "string" + }, "path": { "description": "Path to the folder which holds the templates", "default": "./templates/", diff --git a/frontend/.storybook/preview.tsx b/frontend/.storybook/preview.tsx index fbaa5ec2..80704224 100644 --- a/frontend/.storybook/preview.tsx +++ b/frontend/.storybook/preview.tsx @@ -14,7 +14,7 @@ import { ArgTypes, Decorator, Parameters } from "@storybook/react"; import { useLayoutEffect } from "react"; -import "../src/index.css"; +import "../src/main.css"; export const parameters: Parameters = { actions: { argTypesRegex: "^on[A-Z].*" }, diff --git a/frontend/index.html b/frontend/index.html index 92a635b1..a954ad5e 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -14,36 +14,34 @@ See the License for the specific language governing permissions and limitations under the License. --> + + - - - - - - matrix-authentication-service - - - - - -
- - + query.addEventListener("change", handleChange); + handleChange(query); + })(); + + + +
+ + diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 24a652a9..ff3ca899 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -57,11 +57,14 @@ "postcss": "^8.4.24", "prettier": "2.8.0", "react-test-renderer": "^18.2.0", + "rimraf": "^5.0.1", "storybook": "^7.0.26", "tailwindcss": "^3.3.2", "typescript": "5.1.6", "vite": "^4.3.9", + "vite-plugin-compression": "^0.5.1", "vite-plugin-graphql-codegen": "^3.2.2", + "vite-plugin-manifest-sri": "^0.1.0", "vite-plugin-svgr": "^3.2.0", "vitest": "^0.32.4" } @@ -4953,6 +4956,96 @@ "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "dev": true }, + "node_modules/@isaacs/cliui": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", + "integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==", + "dev": true, + "dependencies": { + "string-width": "^5.1.2", + "string-width-cjs": "npm:string-width@^4.2.0", + "strip-ansi": "^7.0.1", + "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", + "wrap-ansi": "^8.1.0", + "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-regex": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz", + "integrity": "sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/ansi-styles": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz", + "integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==", + "dev": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/string-width": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz", + "integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==", + "dev": true, + "dependencies": { + "eastasianwidth": "^0.2.0", + "emoji-regex": "^9.2.2", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@isaacs/cliui/node_modules/strip-ansi": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz", + "integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==", + "dev": true, + "dependencies": { + "ansi-regex": "^6.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/@isaacs/cliui/node_modules/wrap-ansi": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz", + "integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==", + "dev": true, + "dependencies": { + "ansi-styles": "^6.1.0", + "string-width": "^5.0.1", + "strip-ansi": "^7.0.1" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -5497,6 +5590,16 @@ "node": ">=10.12.0" } }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "dev": true, + "optional": true, + "engines": { + "node": ">=14" + } + }, "node_modules/@pkgr/utils": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.1.tgz", @@ -9947,6 +10050,21 @@ "wrap-ansi": "^7.0.0" } }, + "node_modules/c8/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/c8/node_modules/yargs": { "version": "16.2.0", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", @@ -11085,6 +11203,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/del/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -11355,6 +11488,12 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/eastasianwidth": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", + "integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==", + "dev": true + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -13058,6 +13197,21 @@ "node": "^10.12.0 || >=12.0.0" } }, + "node_modules/flat-cache/node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "dev": true, + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/flatted": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", @@ -14794,6 +14948,24 @@ "node": ">=8" } }, + "node_modules/jackspeak": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-2.2.1.tgz", + "integrity": "sha512-MXbxovZ/Pm42f6cDIDkl3xpwv1AGwObKwfmjs2nQePiy85tP3fatofl3FC1aBsOtP/6fq5SbtgHwWcMsLP+bDw==", + "dev": true, + "dependencies": { + "@isaacs/cliui": "^8.0.2" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + }, + "optionalDependencies": { + "@pkgjs/parseargs": "^0.11.0" + } + }, "node_modules/jake": { "version": "10.8.7", "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", @@ -16785,6 +16957,31 @@ "node": ">=0.10.0" } }, + "node_modules/path-scurry": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.10.0.tgz", + "integrity": "sha512-tZFEaRQbMLjwrsmidsGJ6wDMv0iazJWk6SfIKnY4Xru8auXgmJkOBa5DUbYFcFD2Rzk2+KDlIiF0GVXNCbgC7g==", + "dev": true, + "dependencies": { + "lru-cache": "^9.1.1 || ^10.0.0", + "minipass": "^5.0.0 || ^6.0.2" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-scurry/node_modules/lru-cache": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.0.0.tgz", + "integrity": "sha512-svTf/fzsKHffP42sujkO/Rjs37BCIsQVRCeNYIm9WN8rgT7ffoUnRtZCqU+6BqcSBdv8gwJeTz8knJpgACeQMw==", + "dev": true, + "engines": { + "node": "14 || >=16.14" + } + }, "node_modules/path-to-regexp": { "version": "0.1.7", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", @@ -18161,15 +18358,92 @@ "dev": true }, "node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.1.tgz", + "integrity": "sha512-OfFZdwtd3lZ+XZzYP/6gTACubwFcHdLRqS9UX3UwpU2dnGQYkPFISRwvM3w9IiB2w7bW5qGo/uAwE4SmXXSKvg==", "dev": true, "dependencies": { - "glob": "^7.1.3" + "glob": "^10.2.5" }, "bin": { - "rimraf": "bin.js" + "rimraf": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/rimraf/node_modules/foreground-child": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", + "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "dev": true, + "dependencies": { + "cross-spawn": "^7.0.0", + "signal-exit": "^4.0.1" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.3.1.tgz", + "integrity": "sha512-9BKYcEeIs7QwlCYs+Y3GBvqAMISufUS0i2ELd11zpZjxI5V9iyRj0HgzB5/cLf2NY4vcYBTYzJ7GIui7j/4DOw==", + "dev": true, + "dependencies": { + "foreground-child": "^3.1.0", + "jackspeak": "^2.0.3", + "minimatch": "^9.0.1", + "minipass": "^5.0.0 || ^6.0.2", + "path-scurry": "^1.10.0" + }, + "bin": { + "glob": "dist/cjs/src/bin.js" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.2.tgz", + "integrity": "sha512-PZOT9g5v2ojiTL7r1xF6plNHLtOeTpSlDI007As2NlA2aYBMfVom17yqa6QzhmDP8QOhn7LjHTg7DFCVSSa6yg==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/signal-exit": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.0.2.tgz", + "integrity": "sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==", + "dev": true, + "engines": { + "node": ">=14" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -18818,6 +19092,27 @@ "node": ">=8" } }, + "node_modules/string-width-cjs": { + "name": "string-width", + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width-cjs/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true + }, "node_modules/string-width/node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -18900,6 +19195,19 @@ "node": ">=8" } }, + "node_modules/strip-ansi-cjs": { + "name": "strip-ansi", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-bom": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", @@ -20243,6 +20551,86 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/vite-plugin-compression": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/vite-plugin-compression/-/vite-plugin-compression-0.5.1.tgz", + "integrity": "sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==", + "dev": true, + "dependencies": { + "chalk": "^4.1.2", + "debug": "^4.3.3", + "fs-extra": "^10.0.0" + }, + "peerDependencies": { + "vite": ">=2.0.0" + } + }, + "node_modules/vite-plugin-compression/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/vite-plugin-compression/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/vite-plugin-compression/node_modules/fs-extra": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz", + "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/vite-plugin-compression/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/vite-plugin-compression/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/vite-plugin-graphql-codegen": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/vite-plugin-graphql-codegen/-/vite-plugin-graphql-codegen-3.2.2.tgz", @@ -20254,6 +20642,12 @@ "vite": "^2.7.0 || ^3.0.0 || ^4.0.0" } }, + "node_modules/vite-plugin-manifest-sri": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-manifest-sri/-/vite-plugin-manifest-sri-0.1.0.tgz", + "integrity": "sha512-m4gcEXwcA1MfCVYTLVHYsB03Xsc6L4VYfhxXmcYcS+rN3kTjuWkXMaA8OuOV1gFdi1bMJFkLTJCPciYApvCm/g==", + "dev": true + }, "node_modules/vite-plugin-svgr": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-3.2.0.tgz", @@ -20609,6 +21003,39 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/wrap-ansi-cjs": { + "name": "wrap-ansi", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrap-ansi-cjs/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/wrap-ansi/node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index a9cb8f5b..21226214 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,8 +7,7 @@ "dev": "vite", "generate": "graphql-codegen && eslint --fix .", "lint": "graphql-codegen && eslint . && tsc", - "build": "npm run lint && vite build --base=./ && npm run build:templates", - "build:templates": "tailwindcss --postcss --minify --config ./tailwind.templates.config.cjs -o dist/tailwind.css", + "build": "rimraf ./dist/ && vite build", "preview": "vite preview", "test": "vitest", "coverage": "vitest run --coverage", @@ -65,11 +64,14 @@ "postcss": "^8.4.24", "prettier": "2.8.0", "react-test-renderer": "^18.2.0", + "rimraf": "^5.0.1", "storybook": "^7.0.26", "tailwindcss": "^3.3.2", "typescript": "5.1.6", "vite": "^4.3.9", + "vite-plugin-compression": "^0.5.1", "vite-plugin-graphql-codegen": "^3.2.2", + "vite-plugin-manifest-sri": "^0.1.0", "vite-plugin-svgr": "^3.2.0", "vitest": "^0.32.4" } diff --git a/frontend/src/index.css b/frontend/src/main.css similarity index 100% rename from frontend/src/index.css rename to frontend/src/main.css diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 533950e3..933aa0e1 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -20,6 +20,7 @@ import { createRoot } from "react-dom/client"; import Router from "./Router"; import { HydrateAtoms } from "./atoms"; import LoadingScreen from "./components/LoadingScreen"; +import "./main.css"; createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/frontend/src/templates.css b/frontend/src/templates.css new file mode 100644 index 00000000..5094a4e8 --- /dev/null +++ b/frontend/src/templates.css @@ -0,0 +1,20 @@ +/* Copyright 2022 The Matrix.org Foundation C.I.C. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@config "../tailwind.templates.config.cjs"; + +@tailwind base; +@tailwind components; +@tailwind utilities; \ No newline at end of file diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 52d8f727..6f237c69 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -13,20 +13,33 @@ // limitations under the License. /// +import { resolve } from "path"; + import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; +import compression from "vite-plugin-compression"; import codegen from "vite-plugin-graphql-codegen"; +import manifestSRI from "vite-plugin-manifest-sri"; import svgr from "vite-plugin-svgr"; export default defineConfig((env) => ({ - base: "/app/", + base: "./", build: { manifest: true, assetsDir: "", sourcemap: true, + modulePreload: false, + + rollupOptions: { + input: [ + resolve(__dirname, "src/main.tsx"), + resolve(__dirname, "src/templates.css"), + ], + }, }, plugins: [ codegen(), + react({ babel: { plugins: [ @@ -54,6 +67,8 @@ export default defineConfig((env) => ({ }, }), + manifestSRI(), + svgr({ exportAsDefault: true, @@ -74,11 +89,26 @@ export default defineConfig((env) => ({ }, }, }), + + // Pre-compress the assets, so that the server can serve them directly + compression({ + algorithm: "gzip", + ext: ".gz", + }), + compression({ + algorithm: "brotliCompress", + ext: ".br", + }), + compression({ + algorithm: "deflate", + ext: ".zz", + }), ], server: { + base: "/account/", proxy: { // Routes mostly extracted from crates/router/src/endpoints.rs - "^/(|graphql.*|assets.*|\\.well-known.*|oauth2.*|login.*|logout.*|register.*|reauth.*|account.*|consent.*|_matrix.*|complete-compat-sso.*)$": + "^/(|graphql.*|assets.*|\\.well-known.*|oauth2.*|login.*|logout.*|register.*|reauth.*|add-email.*|verify-email.*|change-password.*|consent.*|_matrix.*|complete-compat-sso.*)$": "http://127.0.0.1:8080", }, }, diff --git a/templates/app.html b/templates/app.html new file mode 100644 index 00000000..a50d1b5b --- /dev/null +++ b/templates/app.html @@ -0,0 +1,47 @@ +{# +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +#} + +{# Must be kept in sync with frontend/index.html #} + + + + + + + matrix-authentication-service + + {{ include_asset(path='src/main.tsx') | indent(prefix=" ") | safe }} + + + +
+ + diff --git a/templates/base.html b/templates/base.html index 72635e50..6107daea 100644 --- a/templates/base.html +++ b/templates/base.html @@ -27,7 +27,7 @@ limitations under the License. {% block title %}matrix-authentication-service{% endblock title %} - + {{ include_asset(path='src/templates.css') | indent(prefix=" ") | safe }} {% block content %}{% endblock content %} diff --git a/templates/components/navbar.html b/templates/components/navbar.html index 7ad1d58d..f9d2e434 100644 --- a/templates/components/navbar.html +++ b/templates/components/navbar.html @@ -24,7 +24,7 @@ limitations under the License. Signed in as {{ current_session.user.username }}. - {{ button::link(text="My account", href="/account") }} + {{ button::link(text="My account", href="/account/") }} {{ logout::button(text="Sign out", class=button::outline_class(), csrf_token=csrf_token) }} {% else %} {{ button::link(text="Sign in", href="/login") }} diff --git a/templates/pages/account/emails/index.html b/templates/pages/account/emails/index.html deleted file mode 100644 index 4e0b512d..00000000 --- a/templates/pages/account/emails/index.html +++ /dev/null @@ -1,60 +0,0 @@ -{# -Copyright 2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -#} - -{% extends "base.html" %} - -{% block content %} - {{ navbar::top() }} - {% if current_session.user.primary_email %} - {% set primary_email = current_session.user.primary_email.email %} - {% else %} - {% set primary_email = "" %} - {% endif %} - -
-
-

Add email

- - {{ field::input(label="New email", name="email", type="email", autocomplete="email", class="xl:col-span-2") }} - {{ button::button(text="Add email", type="submit", class="xl:col-span-2 place-self-end", name="action", value="add") }} -
- -
-

Emails

- {% for item in emails %} -
- - -
{{ item.email }}
- {% if item.confirmed_at %} -
Verified
- {% else %} - {{ button::button(text="Resend verification", type="submit", name="action", value="resend_confirmation", class="mr-4") }} - {% endif %} - - {% if item.email == primary_email %} -
Primary
- {% else %} - {{ button::button(text="Set as primary", type="submit", name="action", value="set_primary", class="mr-4") }} - {% endif %} - {{ button::button(text="Delete", type="submit", name="action", value="remove") }} -
- {% endfor %} -
-
-{% endblock content %} - - diff --git a/templates/pages/account/index.html b/templates/pages/account/index.html deleted file mode 100644 index 10ebf4be..00000000 --- a/templates/pages/account/index.html +++ /dev/null @@ -1,59 +0,0 @@ -{# -Copyright 2021-2022 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -#} - -{% extends "base.html" %} - -{% block content %} - {{ navbar::top() }} -
-
-

Manage my account

-
Your username
-
{{ current_session.user.username }}
-
Unique identifier
-
{{ current_session.user.sub }}
-
Active sessions
-
{{ active_sessions }}
- {% if current_session.user.primary_email %} -
Primary email
-
{{ current_session.user.primary_email.email }}
- {% endif %} - {{ button::link_outline(text="Change password", href="/account/password", class="col-span-2 place-self-end") }} -
-
-

Current session

-
Started at
-
{{ current_session.created_at | date(format="%Y-%m-%d %H:%M:%S") }}
-
Last authentication
-
- {% if current_session.last_authentication %} - {{ current_session.last_authentication.created_at | date(format="%Y-%m-%d %H:%M:%S") }} - {% else %} - Never - {% endif %} -
- {{ button::link_outline(text="Revalidate", href="/reauth", class="col-span-2 place-self-end") }} -
-
-

Emails

- {% for email in emails %} -
{{ email.email }}
-
{% if email.confirmed_at %}Confirmed{% else %}Unconfirmed{% endif %}
- {% endfor %} - {{ button::link_outline(text="Manage", href="/account/emails", class="col-span-2 place-self-end") }} -
-
-{% endblock content %}