1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-06 06:02:40 +03:00

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 `<link rel="preload">` 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.
This commit is contained in:
Quentin Gliech
2023-07-06 15:30:26 +02:00
committed by GitHub
parent 6cae2adc08
commit 76653f9638
47 changed files with 1096 additions and 1011 deletions

View File

@@ -239,6 +239,21 @@ jobs:
rustup toolchain install ${{ matrix.toolchain }} rustup toolchain install ${{ matrix.toolchain }}
rustup default ${{ 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 - name: Setup OPA
uses: open-policy-agent/setup-opa@v2.1.0 uses: open-policy-agent/setup-opa@v2.1.0
with: with:

View File

@@ -105,6 +105,21 @@ jobs:
rustup default stable rustup default stable
rustup component add llvm-tools-preview 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 - name: Setup OPA
uses: open-policy-agent/setup-opa@v2.1.0 uses: open-policy-agent/setup-opa@v2.1.0
with: with:

82
Cargo.lock generated
View File

@@ -102,21 +102,6 @@ dependencies = [
"memchr", "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]] [[package]]
name = "allocator-api2" name = "allocator-api2"
version = "0.2.14" version = "0.2.14"
@@ -306,22 +291,6 @@ dependencies = [
"futures-core", "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]] [[package]]
name = "async-executor" name = "async-executor"
version = "1.5.1" version = "1.5.1"
@@ -966,7 +935,7 @@ dependencies = [
"cc", "cc",
"cfg-if", "cfg-if",
"libc", "libc",
"miniz_oxide 0.6.2", "miniz_oxide",
"object", "object",
"rustc-demangle", "rustc-demangle",
] ]
@@ -1091,27 +1060,6 @@ dependencies = [
"cipher", "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]] [[package]]
name = "bstr" name = "bstr"
version = "1.5.0" version = "1.5.0"
@@ -2063,16 +2011,6 @@ dependencies = [
"log", "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]] [[package]]
name = "flume" name = "flume"
version = "0.10.14" version = "0.10.14"
@@ -3306,6 +3244,7 @@ dependencies = [
"mas-oidc-client", "mas-oidc-client",
"mas-policy", "mas-policy",
"mas-router", "mas-router",
"mas-spa",
"mas-storage", "mas-storage",
"mas-storage-pg", "mas-storage-pg",
"mas-templates", "mas-templates",
@@ -3571,14 +3510,8 @@ name = "mas-spa"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"camino", "camino",
"headers",
"http",
"serde", "serde",
"serde_json",
"thiserror", "thiserror",
"tokio",
"tower-http",
"tower-service",
] ]
[[package]] [[package]]
@@ -3667,6 +3600,7 @@ dependencies = [
"http", "http",
"mas-data-model", "mas-data-model",
"mas-router", "mas-router",
"mas-spa",
"oauth2-types", "oauth2-types",
"rand 0.8.5", "rand 0.8.5",
"serde", "serde",
@@ -3780,15 +3714,6 @@ dependencies = [
"adler", "adler",
] ]
[[package]]
name = "miniz_oxide"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7810e0be55b428ada41041c41f32c9f1a42817901b4ccf45fa3d4b6561e74c7"
dependencies = [
"adler",
]
[[package]] [[package]]
name = "mio" name = "mio"
version = "0.8.8" version = "0.8.8"
@@ -6214,7 +6139,6 @@ version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8bd22a874a2d0b70452d5597b12c537331d49060824a95f49f108994f94aa4c" checksum = "a8bd22a874a2d0b70452d5597b12c537331d49060824a95f49f108994f94aa4c"
dependencies = [ dependencies = [
"async-compression",
"bitflags 2.3.3", "bitflags 2.3.3",
"bytes 1.4.0", "bytes 1.4.0",
"futures-core", "futures-core",

View File

@@ -25,7 +25,7 @@ serde_yaml = "0.9.22"
sqlx = { version = "0.6.3", features = ["runtime-tokio-rustls", "postgres"] } sqlx = { version = "0.6.3", features = ["runtime-tokio-rustls", "postgres"] }
tokio = { version = "1.29.1", features = ["full"] } tokio = { version = "1.29.1", features = ["full"] }
tower = { version = "0.4.13", 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" url = "2.4.0"
watchman_client = "0.8.0" watchman_client = "0.8.0"
zeroize = "1.6.0" zeroize = "1.6.0"

View File

@@ -83,8 +83,11 @@ impl Options {
let policy_factory = policy_factory_from_config(&config.policy).await?; let policy_factory = policy_factory_from_config(&config.policy).await?;
let policy_factory = Arc::new(policy_factory); let policy_factory = Arc::new(policy_factory);
let url_builder = let url_builder = UrlBuilder::new(
UrlBuilder::new(config.http.public_base.clone(), config.http.issuer.clone()); config.http.public_base.clone(),
config.http.issuer.clone(),
None,
);
// Load and compile the templates // Load and compile the templates
let templates = templates_from_config(&config.templates, &url_builder).await?; let templates = templates_from_config(&config.templates, &url_builder).await?;

View File

@@ -12,13 +12,14 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use camino::Utf8PathBuf;
use clap::Parser; use clap::Parser;
use mas_config::TemplatesConfig;
use mas_storage::{Clock, SystemClock}; use mas_storage::{Clock, SystemClock};
use mas_templates::Templates;
use rand::SeedableRng; use rand::SeedableRng;
use tracing::info_span; use tracing::info_span;
use crate::util::templates_from_config;
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
pub(super) struct Options { pub(super) struct Options {
#[clap(subcommand)] #[clap(subcommand)]
@@ -27,26 +28,24 @@ pub(super) struct Options {
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
enum Subcommand { enum Subcommand {
/// Check for template validity at given path. /// Check that the templates specified in the config are valid
Check { Check,
/// Path where the templates are
path: Utf8PathBuf,
},
} }
impl Options { 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; use Subcommand as SC;
match self.subcommand { match self.subcommand {
SC::Check { path } => { SC::Check => {
let _span = info_span!("cli.templates.check").entered(); let _span = info_span!("cli.templates.check").entered();
let config: TemplatesConfig = root.load_config()?;
let clock = SystemClock::default(); let clock = SystemClock::default();
// XXX: we should disallow SeedableRng::from_entropy // XXX: we should disallow SeedableRng::from_entropy
let mut rng = rand_chacha::ChaChaRng::from_entropy(); let mut rng = rand_chacha::ChaChaRng::from_entropy();
let url_builder = let url_builder =
mas_router::UrlBuilder::new("https://example.com/".parse()?, None); mas_router::UrlBuilder::new("https://example.com/".parse()?, None, None);
let templates = Templates::load(path, url_builder).await?; let templates = templates_from_config(&config, &url_builder).await?;
templates.check_render(clock.now(), &mut rng).await?; templates.check_render(clock.now(), &mut rng).await?;
Ok(()) Ok(())

View File

@@ -37,8 +37,11 @@ impl Options {
info!("Connecting to the database"); info!("Connecting to the database");
let pool = database_from_config(&config.database).await?; let pool = database_from_config(&config.database).await?;
let url_builder = let url_builder = UrlBuilder::new(
UrlBuilder::new(config.http.public_base.clone(), config.http.issuer.clone()); config.http.public_base.clone(),
config.http.issuer.clone(),
None,
);
// Load and compile the templates // Load and compile the templates
let templates = templates_from_config(&config.templates, &url_builder).await?; let templates = templates_from_config(&config.templates, &url_builder).await?;

View File

@@ -26,13 +26,15 @@ use axum::{
extract::{FromRef, MatchedPath}, extract::{FromRef, MatchedPath},
Extension, Router, Extension, Router,
}; };
use hyper::{Method, Request, Response, StatusCode, Version}; use hyper::{
header::{HeaderValue, CACHE_CONTROL},
Method, Request, Response, StatusCode, Version,
};
use listenfd::ListenFd; use listenfd::ListenFd;
use mas_config::{HttpBindConfig, HttpResource, HttpTlsConfig, UnixOrTcp}; use mas_config::{HttpBindConfig, HttpResource, HttpTlsConfig, UnixOrTcp};
use mas_handlers::AppState; use mas_handlers::AppState;
use mas_listener::{unix_or_tcp::UnixOrTcpListener, ConnectionInfo}; use mas_listener::{unix_or_tcp::UnixOrTcpListener, ConnectionInfo};
use mas_router::Route; use mas_router::Route;
use mas_spa::ViteManifestService;
use mas_templates::Templates; use mas_templates::Templates;
use mas_tower::{ use mas_tower::{
make_span_fn, metrics_attributes_fn, DurationRecorderLayer, InFlightCounterLayer, TraceLayer, make_span_fn, metrics_attributes_fn, DurationRecorderLayer, InFlightCounterLayer, TraceLayer,
@@ -46,8 +48,8 @@ use opentelemetry_semantic_conventions::trace::{
use rustls::ServerConfig; use rustls::ServerConfig;
use sentry_tower::{NewSentryLayer, SentryHttpLayer}; use sentry_tower::{NewSentryLayer, SentryHttpLayer};
use tower::Layer; use tower::Layer;
use tower_http::{compression::CompressionLayer, services::ServeDir}; use tower_http::{services::ServeDir, set_header::SetResponseHeaderLayer};
use tracing::Span; use tracing::{warn, Span};
use tracing_opentelemetry::OpenTelemetrySpanExt; use tracing_opentelemetry::OpenTelemetrySpanExt;
const NET_PROTOCOL_NAME: Key = Key::from_static_str("net.protocol.name"); const NET_PROTOCOL_NAME: Key = Key::from_static_str("net.protocol.name");
@@ -192,13 +194,23 @@ where
router.merge(mas_handlers::graphql_router::<AppState, B>(*playground)) router.merge(mas_handlers::graphql_router::<AppState, B>(*playground))
} }
mas_config::HttpResource::Assets { path } => { 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 = let error_layer =
HandleErrorLayer::new(|_e| ready(StatusCode::INTERNAL_SERVER_ERROR)); 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( router.nest_service(
mas_router::StaticAsset::route(), mas_router::StaticAsset::route(),
error_layer.layer(static_service), (error_layer, cache_layer).layer(static_service),
) )
} }
mas_config::HttpResource::OAuth => { mas_config::HttpResource::OAuth => {
@@ -215,25 +227,10 @@ where
}), }),
), ),
mas_config::HttpResource::Spa { manifest } => { #[allow(deprecated)]
let error_layer = mas_config::HttpResource::Spa { .. } => {
HandleErrorLayer::new(|_e| ready(StatusCode::INTERNAL_SERVER_ERROR)); warn!("The SPA HTTP resource is deprecated");
router
// 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))
} }
} }
} }
@@ -266,7 +263,6 @@ where
) )
.layer(SentryHttpLayer::new()) .layer(SentryHttpLayer::new())
.layer(NewSentryLayer::new_from_top()) .layer(NewSentryLayer::new_from_top())
.layer(CompressionLayer::new())
.with_state(state) .with_state(state)
} }

View File

@@ -111,7 +111,12 @@ pub async fn templates_from_config(
config: &TemplatesConfig, config: &TemplatesConfig,
url_builder: &UrlBuilder, url_builder: &UrlBuilder,
) -> Result<Templates, TemplateLoadingError> { ) -> Result<Templates, TemplateLoadingError> {
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))] #[tracing::instrument(name = "db.connect", skip_all, err(Debug))]

View File

@@ -12,6 +12,8 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
#![allow(deprecated)]
use std::{borrow::Cow, io::Cursor, ops::Deref}; use std::{borrow::Cow, io::Cursor, ops::Deref};
use anyhow::bail; use anyhow::bail;
@@ -43,21 +45,11 @@ fn http_address_example_4() -> &'static str {
"0.0.0.0:8080" "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"))] #[cfg(not(feature = "docker"))]
fn http_listener_assets_path_default() -> Utf8PathBuf { fn http_listener_assets_path_default() -> Utf8PathBuf {
"./frontend/dist/".into() "./frontend/dist/".into()
} }
#[cfg(feature = "docker")]
fn http_listener_spa_manifest_default() -> Utf8PathBuf {
"/usr/local/share/mas-cli/manifest.json".into()
}
#[cfg(feature = "docker")] #[cfg(feature = "docker")]
fn http_listener_assets_path_default() -> Utf8PathBuf { fn http_listener_assets_path_default() -> Utf8PathBuf {
"/usr/local/share/mas-cli/assets/".into() "/usr/local/share/mas-cli/assets/".into()
@@ -285,12 +277,10 @@ pub enum Resource {
ConnectionInfo, ConnectionInfo,
/// Mount the single page app /// Mount the single page app
Spa { ///
/// Path to the vite manifest.json /// This is deprecated and will be removed in a future release.
#[serde(default = "http_listener_spa_manifest_default")] #[deprecated = "This resource is deprecated and will be removed in a future release"]
#[schemars(with = "String")] Spa,
manifest: Utf8PathBuf,
},
} }
/// Configuration of a listener /// Configuration of a listener
@@ -346,9 +336,6 @@ impl Default for HttpConfig {
Resource::Assets { Resource::Assets {
path: http_listener_assets_path_default(), path: http_listener_assets_path_default(),
}, },
Resource::Spa {
manifest: http_listener_spa_manifest_default(),
},
], ],
tls: None, tls: None,
proxy_protocol: false, proxy_protocol: false,

View File

@@ -30,6 +30,16 @@ fn default_path() -> Utf8PathBuf {
"/usr/local/share/mas-cli/templates/".into() "/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 /// Configuration related to templates
#[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)] #[derive(Debug, Serialize, Deserialize, JsonSchema, Clone)]
pub struct TemplatesConfig { pub struct TemplatesConfig {
@@ -37,12 +47,18 @@ pub struct TemplatesConfig {
#[serde(default = "default_path")] #[serde(default = "default_path")]
#[schemars(with = "Option<String>")] #[schemars(with = "Option<String>")]
pub path: Utf8PathBuf, pub path: Utf8PathBuf,
/// Path to the assets manifest
#[serde(default = "default_assets_path")]
#[schemars(with = "Option<String>")]
pub assets_manifest: Utf8PathBuf,
} }
impl Default for TemplatesConfig { impl Default for TemplatesConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
path: default_path(), path: default_path(),
assets_manifest: default_assets_path(),
} }
} }
} }

View File

@@ -68,6 +68,7 @@ mas-matrix = { path = "../matrix" }
mas-oidc-client = { path = "../oidc-client" } mas-oidc-client = { path = "../oidc-client" }
mas-policy = { path = "../policy" } mas-policy = { path = "../policy" }
mas-router = { path = "../router" } mas-router = { path = "../router" }
mas-spa = { path = "../spa" }
mas-storage = { path = "../storage" } mas-storage = { path = "../storage" }
mas-storage-pg = { path = "../storage-pg" } mas-storage-pg = { path = "../storage-pg" }
mas-templates = { path = "../templates" } mas-templates = { path = "../templates" }

View File

@@ -268,6 +268,12 @@ where
BoxRng: FromRequestParts<S>, BoxRng: FromRequestParts<S>,
{ {
Router::new() 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( .route(
mas_router::ChangePasswordDiscovery::route(), mas_router::ChangePasswordDiscovery::route(),
get(|| async { mas_router::AccountPassword.go() }), get(|| async { mas_router::AccountPassword.go() }),
@@ -286,15 +292,10 @@ where
mas_router::Register::route(), mas_router::Register::route(),
get(self::views::register::get).post(self::views::register::post), get(self::views::register::get).post(self::views::register::post),
) )
.route(mas_router::Account::route(), get(self::views::account::get))
.route( .route(
mas_router::AccountPassword::route(), mas_router::AccountPassword::route(),
get(self::views::account::password::get).post(self::views::account::password::post), 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( .route(
mas_router::AccountVerifyEmail::route(), mas_router::AccountVerifyEmail::route(),
get(self::views::account::emails::verify::get) get(self::views::account::emails::verify::get)

View File

@@ -110,10 +110,14 @@ impl TestState {
.join("..") .join("..")
.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 = let templates = Templates::load(
Templates::load(workspace_root.join("templates"), url_builder.clone()).await?; workspace_root.join("templates"),
url_builder.clone(),
workspace_root.join("frontend/dist/manifest.json"),
)
.await?;
// TODO: add more test keys to the store // TODO: add more test keys to the store
let rsa = let rsa =

View File

@@ -12,190 +12,5 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // 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 add;
pub mod verify; 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<Templates>,
mut repo: BoxRepository,
cookie_jar: PrivateCookieJar<Encrypter>,
) -> Result<Response, FancyError> {
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<E: std::error::Error>(
rng: impl Rng + Send,
clock: &impl Clock,
templates: Templates,
session: BrowserSession,
cookie_jar: PrivateCookieJar<Encrypter>,
repo: &mut impl RepositoryAccess<Error = E>,
) -> Result<Response, FancyError> {
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<Templates>,
mut repo: BoxRepository,
cookie_jar: PrivateCookieJar<Encrypter>,
Form(form): Form<ProtectedForm<ManagementForm>>,
) -> Result<Response, FancyError> {
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)
}

View File

@@ -74,7 +74,7 @@ pub(crate) async fn get(
if user_email.confirmed_at.is_some() { if user_email.confirmed_at.is_some() {
// This email was already verified, skip // 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()); return Ok((cookie_jar, destination).into_response());
} }
@@ -146,6 +146,6 @@ pub(crate) async fn post(
repo.save().await?; 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()) Ok((cookie_jar, destination).into_response())
} }

View File

@@ -14,48 +14,3 @@
pub mod emails; pub mod emails;
pub mod password; 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<Templates>,
mut repo: BoxRepository,
cookie_jar: PrivateCookieJar<Encrypter>,
) -> Result<Response, FancyError> {
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())
}

View File

@@ -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<Templates>,
mut repo: BoxRepository,
cookie_jar: PrivateCookieJar<Encrypter>,
) -> Result<impl IntoResponse, FancyError> {
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())
}

View File

@@ -13,6 +13,7 @@
// limitations under the License. // limitations under the License.
pub mod account; pub mod account;
pub mod app;
pub mod index; pub mod index;
pub mod login; pub mod login;
pub mod logout; pub mod logout;

View File

@@ -87,6 +87,8 @@ impl OptionalPostAuthAction {
let link = Box::new(link); let link = Box::new(link);
PostAuthContextInner::LinkUpstream { provider, link } PostAuthContextInner::LinkUpstream { provider, link }
} }
PostAuthAction::ManageAccount => PostAuthContextInner::ManageAccount,
}; };
Ok(Some(PostAuthContext { Ok(Some(PostAuthContext {

View File

@@ -60,7 +60,7 @@ features = ["client", "http1", "http2", "stream", "runtime" ]
optional = true optional = true
[dependencies.tower-http] [dependencies.tower-http]
version = "0.4.1" version = "0.4.1"
features = ["follow-redirect", "decompression-full", "set-header", "timeout"] features = ["follow-redirect", "set-header", "timeout", "map-request-body", "util"]
optional = true optional = true
[dev-dependencies] [dev-dependencies]

View File

@@ -19,17 +19,13 @@
use std::time::Duration; use std::time::Duration;
use http::{header::USER_AGENT, HeaderValue}; use http::{header::USER_AGENT, HeaderValue};
use http_body::Full;
use hyper::client::{connect::dns::GaiResolver, HttpConnector}; use hyper::client::{connect::dns::GaiResolver, HttpConnector};
use hyper_rustls::{ConfigBuilderExt, HttpsConnectorBuilder}; use hyper_rustls::{ConfigBuilderExt, HttpsConnectorBuilder};
use tower::{limit::ConcurrencyLimitLayer, BoxError, ServiceBuilder}; use mas_http::BodyToBytesResponseLayer;
use tower_http::{ use tower::{BoxError, ServiceBuilder};
decompression::DecompressionLayer, follow_redirect::FollowRedirectLayer, use tower_http::{timeout::TimeoutLayer, ServiceBuilderExt};
set_header::SetRequestHeaderLayer, timeout::TimeoutLayer,
};
mod body_layer;
use self::body_layer::BodyLayer;
use super::HttpService; use super::HttpService;
static MAS_USER_AGENT: HeaderValue = HeaderValue::from_static("mas-oidc-client/0.0.1"); 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() let client = ServiceBuilder::new()
.map_err(BoxError::from) .map_err(BoxError::from)
.layer(BodyLayer::default()) .map_request_body(Full::new)
.layer(DecompressionLayer::new()) .layer(BodyToBytesResponseLayer::default())
.layer(SetRequestHeaderLayer::overriding( .override_request_header(USER_AGENT, MAS_USER_AGENT.clone())
USER_AGENT, .concurrency_limit(10)
MAS_USER_AGENT.clone(), .follow_redirects()
))
.layer(ConcurrencyLimitLayer::new(10))
.layer(FollowRedirectLayer::new())
.layer(TimeoutLayer::new(Duration::from_secs(10))) .layer(TimeoutLayer::new(Duration::from_secs(10)))
.service(client); .service(client);

View File

@@ -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<E> {
Decompression(BoxError),
Service(E),
}
#[derive(Clone)]
pub struct BodyService<S> {
inner: S,
}
impl<S> BodyService<S> {
pub const fn new(inner: S) -> Self {
Self { inner }
}
}
impl<S, E, ResBody> Service<Request<Bytes>> for BodyService<S>
where
S: Service<Request<Full<Bytes>>, Response = Response<ResBody>, Error = E>,
ResBody: Body<Data = Bytes, Error = BoxError> + Send,
S::Future: Send + 'static,
{
type Error = BodyError<E>;
type Response = Response<Bytes>;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx).map_err(BodyError::Service)
}
fn call(&mut self, request: Request<Bytes>) -> 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<S> Layer<S> for BodyLayer {
type Service = BodyService<S>;
fn layer(&self, inner: S) -> Self::Service {
BodyService::new(inner)
}
}

View File

@@ -24,6 +24,7 @@ pub enum PostAuthAction {
ContinueCompatSsoLogin { id: Ulid }, ContinueCompatSsoLogin { id: Ulid },
ChangePassword, ChangePassword,
LinkUpstream { id: Ulid }, LinkUpstream { id: Ulid },
ManageAccount,
} }
impl PostAuthAction { impl PostAuthAction {
@@ -48,6 +49,7 @@ impl PostAuthAction {
Self::ContinueCompatSsoLogin { id } => CompatLoginSsoComplete::new(*id, None).go(), Self::ContinueCompatSsoLogin { id } => CompatLoginSsoComplete::new(*id, None).go(),
Self::ChangePassword => AccountPassword.go(), Self::ChangePassword => AccountPassword.go(),
Self::LinkUpstream { id } => UpstreamOAuth2Link::new(*id).go(), Self::LinkUpstream { id } => UpstreamOAuth2Link::new(*id).go(),
Self::ManageAccount => Account.go(),
} }
} }
} }
@@ -335,7 +337,7 @@ impl From<Option<PostAuthAction>> for Register {
} }
} }
/// `GET|POST /account/emails/verify/:id` /// `GET|POST /verify-email/:id`
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AccountVerifyEmail { pub struct AccountVerifyEmail {
id: Ulid, id: Ulid,
@@ -367,19 +369,19 @@ impl AccountVerifyEmail {
impl Route for AccountVerifyEmail { impl Route for AccountVerifyEmail {
type Query = PostAuthAction; type Query = PostAuthAction;
fn route() -> &'static str { fn route() -> &'static str {
"/account/emails/verify/:id" "/verify-email/:id"
}
fn path(&self) -> std::borrow::Cow<'static, str> {
format!("/account/emails/verify/{}", self.id).into()
} }
fn query(&self) -> Option<&Self::Query> { fn query(&self) -> Option<&Self::Query> {
self.post_auth_action.as_ref() 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)] #[derive(Default, Debug, Clone)]
pub struct AccountAddEmail { pub struct AccountAddEmail {
post_auth_action: Option<PostAuthAction>, post_auth_action: Option<PostAuthAction>,
@@ -388,7 +390,7 @@ pub struct AccountAddEmail {
impl Route for AccountAddEmail { impl Route for AccountAddEmail {
type Query = PostAuthAction; type Query = PostAuthAction;
fn route() -> &'static str { fn route() -> &'static str {
"/account/emails/add" "/add-email"
} }
fn query(&self) -> Option<&Self::Query> { fn query(&self) -> Option<&Self::Query> {
@@ -404,28 +406,28 @@ impl AccountAddEmail {
} }
} }
/// `GET /account` /// `GET /account/`
#[derive(Default, Debug, Clone)] #[derive(Default, Debug, Clone)]
pub struct Account; pub struct Account;
impl SimpleRoute for 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)] #[derive(Default, Debug, Clone)]
pub struct AccountPassword; pub struct AccountPassword;
impl SimpleRoute for AccountPassword { impl SimpleRoute for AccountPassword {
const PATH: &'static str = "/account/password"; const PATH: &'static str = "/change-password";
}
/// `GET|POST /account/emails`
#[derive(Default, Debug, Clone)]
pub struct AccountEmails;
impl SimpleRoute for AccountEmails {
const PATH: &'static str = "/account/emails";
} }
/// `GET /authorize/:grant_id` /// `GET /authorize/:grant_id`

View File

@@ -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"); // Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. // you may not use this file except in compliance with the License.
@@ -14,6 +14,8 @@
//! Utility to build URLs //! Utility to build URLs
use std::borrow::Cow;
use ulid::Ulid; use ulid::Ulid;
use url::Url; use url::Url;
@@ -21,7 +23,8 @@ use crate::traits::Route;
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct UrlBuilder { pub struct UrlBuilder {
base: Url, http_base: Url,
assets_base: Cow<'static, str>,
issuer: Url, issuer: Url,
} }
@@ -30,21 +33,26 @@ impl UrlBuilder {
where where
U: Route, U: Route,
{ {
destination.absolute_url(&self.base) destination.absolute_url(&self.http_base)
} }
pub fn absolute_redirect<U>(&self, destination: &U) -> axum::response::Redirect pub fn absolute_redirect<U>(&self, destination: &U) -> axum::response::Redirect
where where
U: Route, U: Route,
{ {
destination.go_absolute(&self.base) destination.go_absolute(&self.http_base)
} }
/// Create a new [`UrlBuilder`] from a base URL /// Create a new [`UrlBuilder`] from a base URL
#[must_use] #[must_use]
pub fn new(base: Url, issuer: Option<Url>) -> Self { pub fn new(base: Url, issuer: Option<Url>, assets_base: Option<String>) -> Self {
let issuer = issuer.unwrap_or_else(|| base.clone()); 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 /// OIDC issuer
@@ -107,6 +115,12 @@ impl UrlBuilder {
self.url_for(&crate::endpoints::StaticAsset::new(path)) 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 /// Upstream redirect URI
#[must_use] #[must_use]
pub fn upstream_oauth_callback(&self, id: Ulid) -> Url { pub fn upstream_oauth_callback(&self, id: Ulid) -> Url {

View File

@@ -7,14 +7,6 @@ license = "Apache-2.0"
[dependencies] [dependencies]
serde = { version = "1.0.166", features = ["derive"] } serde = { version = "1.0.166", features = ["derive"] }
serde_json = "1.0.100"
thiserror = "1.0.41" thiserror = "1.0.41"
camino = { version = "1.1.4", features = ["serde1"] } 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"

View File

@@ -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}");
}

View File

@@ -25,73 +25,4 @@
mod vite; 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; 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<T> {
manifest: Utf8PathBuf,
assets_base: Utf8PathBuf,
config: T,
}
impl<T> ViteManifestService<T> {
#[must_use]
pub const fn new(manifest: Utf8PathBuf, assets_base: Utf8PathBuf, config: T) -> Self {
Self {
manifest,
assets_base,
config,
}
}
}
impl<T, R> Service<R> for ViteManifestService<T>
where
T: Clone + Serialize + Send + Sync + 'static,
{
type Error = std::io::Error;
type Response = Response<String>;
type Future =
Pin<Box<dyn Future<Output = Result<Self::Response, Self::Error>> + Send + Sync + 'static>>;
fn poll_ready(
&mut self,
_cx: &mut std::task::Context<'_>,
) -> std::task::Poll<Result<(), Self::Error>> {
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)
})
}
}

View File

@@ -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 std::collections::{BTreeSet, HashMap};
use camino::{Utf8Path, Utf8PathBuf}; use camino::{Utf8Path, Utf8PathBuf};
use thiserror::Error; use thiserror::Error;
#[derive(serde::Deserialize, Debug)] #[derive(serde::Deserialize, Debug, Clone)]
#[serde(rename_all = "camelCase", deny_unknown_fields)] #[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct ManifestEntry { pub struct ManifestEntry {
#[allow(dead_code)] #[allow(dead_code)]
@@ -13,7 +27,6 @@ pub struct ManifestEntry {
css: Option<Vec<Utf8PathBuf>>, css: Option<Vec<Utf8PathBuf>>,
#[allow(dead_code)]
assets: Option<Vec<Utf8PathBuf>>, assets: Option<Vec<Utf8PathBuf>>,
#[allow(dead_code)] #[allow(dead_code)]
@@ -22,73 +35,14 @@ pub struct ManifestEntry {
#[allow(dead_code)] #[allow(dead_code)]
is_dynamic_entry: Option<bool>, is_dynamic_entry: Option<bool>,
#[allow(dead_code)]
imports: Option<Vec<Utf8PathBuf>>, imports: Option<Vec<Utf8PathBuf>>,
dynamic_imports: Option<Vec<Utf8PathBuf>>, dynamic_imports: Option<Vec<Utf8PathBuf>>,
integrity: Option<String>,
} }
/// Render the HTML template #[derive(serde::Deserialize, Debug, Clone)]
fn template(head: impl Iterator<Item = String>, config: &impl serde::Serialize) -> String {
// This should be kept in sync with `../../../frontend/index.html`
// Render the items to insert in the <head>
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 <head> which manages the dark mode class on the <html> 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#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>matrix-authentication-service</title>
<script>window.APP_CONFIG = {config};</script>
<script>{dark_mode_script}</script>
{head}</head>
<body>
<div id="root"></div>
</body>
</html>"#
)
}
impl ManifestEntry {
/// Get a list of items to insert in the `<head>`
fn head<'a>(&'a self, assets_base: &'a Utf8Path) -> impl Iterator<Item = String> + 'a {
let css = self.css.iter().flat_map(|css| {
css.iter().map(|href| {
let href = assets_base.join(href);
format!(r#"<link rel="stylesheet" href="{href}" />"#)
})
});
let script = assets_base.join(&self.file);
let script = format!(r#"<script type="module" crossorigin src="{script}"></script>"#);
css.chain(std::iter::once(script))
}
}
#[derive(serde::Deserialize, Debug)]
pub struct Manifest { pub struct Manifest {
#[serde(flatten)] #[serde(flatten)]
inner: HashMap<Utf8PathBuf, ManifestEntry>, inner: HashMap<Utf8PathBuf, ManifestEntry>,
@@ -98,6 +52,8 @@ pub struct Manifest {
enum FileType { enum FileType {
Script, Script,
Stylesheet, Stylesheet,
Woff,
Woff2,
} }
impl FileType { impl FileType {
@@ -105,6 +61,8 @@ impl FileType {
match name.extension() { match name.extension() {
Some("css") => Some(Self::Stylesheet), Some("css") => Some(Self::Stylesheet),
Some("js") => Some(Self::Script), Some("js") => Some(Self::Script),
Some("woff") => Some(Self::Woff),
Some("woff2") => Some(Self::Woff2),
_ => None, _ => None,
} }
} }
@@ -112,104 +70,168 @@ impl FileType {
#[derive(Debug, Error)] #[derive(Debug, Error)]
#[error("Invalid Vite manifest")] #[error("Invalid Vite manifest")]
pub enum InvalidManifest { pub enum InvalidManifest<'a> {
#[error("No index.html")] #[error("Can't find asset for name {name:?}")]
NoIndex, CantFindAssetByName { name: &'a Utf8Path },
#[error("Can't find preloaded entry")] #[error("Can't find asset for file {file:?}")]
CantFindPreload, CantFindAssetByFile { file: &'a Utf8Path },
#[error("Invalid file type")] #[error("Invalid file type")]
InvalidFileType, 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)] #[derive(Debug, Copy, Clone, Hash, PartialEq, Eq, PartialOrd, Ord)]
struct Preload<'name> { pub struct Asset<'a> {
name: &'name Utf8Path,
file_type: FileType, file_type: FileType,
name: &'a Utf8Path,
integrity: Option<&'a str>,
} }
impl<'a> Preload<'a> { impl<'a> Asset<'a> {
/// Generate a `<link>` tag for this entry fn new(entry: &'a ManifestEntry) -> Result<Self, InvalidManifest<'a>> {
fn link(&self, assets_base: &Utf8Path) -> String { let name = &entry.file;
let href = assets_base.join(self.name); 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 `<link rel="preload">` 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 { match self.file_type {
FileType::Stylesheet => { FileType::Stylesheet => {
format!(r#"<link rel="preload" href="{href}" as="style" />"#) format!(r#"<link rel="preload" href="{href}" as="style" crossorigin {integrity}/>"#)
} }
FileType::Script => format!( FileType::Script => {
r#"<link rel="preload" href="{href}" as="script" crossorigin="anonymous" />"# format!(r#"<link rel="modulepreload" href="{href}" crossorigin {integrity}/>"#)
), }
FileType::Woff | FileType::Woff2 => {
format!(r#"<link rel="preload" href="{href}" as="font" crossorigin {integrity}/>"#,)
}
}
}
/// Generate a `<link>` or `<script>` tag to include this entry
pub fn include_tag(&self, assets_base: &Utf8Path) -> Option<String> {
let src = self.src(assets_base);
let integrity = self
.integrity
.map(|i| format!(r#"integrity="{i}" "#))
.unwrap_or_default();
match self.file_type {
FileType::Stylesheet => Some(format!(
r#"<link rel="stylesheet" href="{src}" crossorigin {integrity}/>"#
)),
FileType::Script => Some(format!(
r#"<script type="module" src="{src}" crossorigin {integrity}></script>"#
)),
FileType::Woff | FileType::Woff2 => None,
} }
} }
} }
impl Manifest { impl Manifest {
/// Render an `index.html` page /// Find all assets which should be loaded for a given entrypoint
/// ///
/// # Errors /// # Errors
/// ///
/// Returns an error if the manifest is invalid. /// Returns an error if the entrypoint is invalid for this manifest
pub fn render( pub fn assets_for<'a>(
&self, &'a self,
assets_base: &Utf8Path, entrypoint: &'a Utf8Path,
config: &impl serde::Serialize, ) -> Result<BTreeSet<Asset<'a>>, InvalidManifest<'a>> {
) -> Result<String, InvalidManifest> { let entry = self.lookup_by_name(entrypoint)?;
let entrypoint = Utf8Path::new("index.html"); let main_asset = Asset::new(entry)?;
let entry = self.inner.get(entrypoint).ok_or(InvalidManifest::NoIndex)?; entry
.css
// Find the items that should be pre-loaded
let preload = self.find_preload(entrypoint)?;
let head = preload
.iter() .iter()
.map(|p| p.link(assets_base)) .flatten()
.chain(entry.head(assets_base)); .map(|name| self.lookup_by_file(name).and_then(Asset::new))
.chain(std::iter::once(Ok(main_asset)))
let html = template(head, config); .collect()
Ok(html)
} }
/// 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<BTreeSet<Asset<'a>>, 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>( fn find_preload<'a>(
&'a self, &'a self,
entrypoint: &Utf8Path, entry: &'a ManifestEntry,
) -> Result<BTreeSet<Preload<'a>>, InvalidManifest> { ) -> Result<BTreeSet<Asset<'a>>, InvalidManifest<'a>> {
// TODO: we're preoading the whole tree. We should instead guess which component
// should be loaded based on the route.
let mut entries = BTreeSet::new(); let mut entries = BTreeSet::new();
self.find_preload_rec(entrypoint, &mut entries)?; self.find_preload_rec(entry, &mut entries)?;
Ok(entries) Ok(entries)
} }
fn find_preload_rec<'a>( fn find_preload_rec<'a>(
&'a self, &'a self,
entrypoint: &Utf8Path, current_entry: &'a ManifestEntry,
entries: &mut BTreeSet<Preload<'a>>, entries: &mut BTreeSet<Asset<'a>>,
) -> Result<(), InvalidManifest> { ) -> Result<(), InvalidManifest<'a>> {
let entry = self let asset = Asset::new(current_entry)?;
.inner let inserted = entries.insert(asset);
.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);
// If we inserted the entry, we need to find its dependencies
if inserted { if inserted {
if let Some(css) = &entry.css { let css = current_entry.css.iter().flatten();
let file_type = FileType::Stylesheet; let assets = current_entry.assets.iter().flatten();
for name in css { for name in css.chain(assets) {
let preload = Preload { name, file_type }; let entry = self.lookup_by_file(name)?;
entries.insert(preload); self.find_preload_rec(entry, entries)?;
}
} }
if let Some(dynamic_imports) = &entry.dynamic_imports { let dynamic_imports = current_entry.dynamic_imports.iter().flatten();
for import in dynamic_imports { let imports = current_entry.imports.iter().flatten();
self.find_preload_rec(import, entries)?; for import in dynamic_imports.chain(imports) {
} let entry = self.lookup_by_name(import)?;
self.find_preload_rec(entry, entries)?;
} }
} }

View File

@@ -27,3 +27,4 @@ rand = "0.8.5"
oauth2-types = { path = "../oauth2-types" } oauth2-types = { path = "../oauth2-types" }
mas-data-model = { path = "../data-model" } mas-data-model = { path = "../data-model" }
mas-router = { path = "../router" } mas-router = { path = "../router" }
mas-spa = { path = "../spa" }

View File

@@ -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<Utc>, _rng: &mut impl Rng) -> Vec<Self>
where
Self: Sized,
{
vec![Self::default()]
}
}
/// Fields of the login form /// Fields of the login form
#[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[derive(Serialize, Deserialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -268,6 +307,9 @@ pub enum PostAuthContextInner {
/// The link /// The link
link: Box<UpstreamOAuthLink>, link: Box<UpstreamOAuthLink>,
}, },
/// Go to the account management page
ManageAccount,
} }
/// Context used in login and reauth screens, for the post-auth action to do /// 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<UserEmail>,
}
impl AccountContext {
/// Constructs a context for the "my account" page
#[must_use]
pub fn new(active_sessions: usize, emails: Vec<UserEmail>) -> Self {
Self {
active_sessions,
emails,
}
}
}
impl TemplateContext for AccountContext {
fn sample(now: chrono::DateTime<Utc>, rng: &mut impl Rng) -> Vec<Self>
where
Self: Sized,
{
let emails: Vec<UserEmail> = UserEmail::samples(now, rng);
vec![Self::new(5, emails)]
}
}
/// Context used by the `account/emails.html` template
#[derive(Serialize)]
pub struct AccountEmailsContext {
emails: Vec<UserEmail>,
}
impl AccountEmailsContext {
/// Constructs a context for the email management page
#[must_use]
pub fn new(emails: Vec<UserEmail>) -> Self {
Self { emails }
}
}
impl TemplateContext for AccountEmailsContext {
fn sample(now: chrono::DateTime<Utc>, rng: &mut impl Rng) -> Vec<Self>
where
Self: Sized,
{
let emails: Vec<UserEmail> = UserEmail::samples(now, rng);
vec![Self::new(emails)]
}
}
/// Context used by the `emails/verification.{txt,html,subject}` templates /// Context used by the `emails/verification.{txt,html,subject}` templates
#[derive(Serialize)] #[derive(Serialize)]
pub struct EmailVerificationContext { pub struct EmailVerificationContext {

View File

@@ -16,18 +16,26 @@
use std::{collections::HashMap, str::FromStr}; 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 tera::{helpers::tests::number_args_allowed, Tera, Value};
use url::Url; 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_tester("empty", self::tester_empty);
tera.register_filter("to_params", filter_to_params); tera.register_filter("to_params", filter_to_params);
tera.register_filter("safe_get", filter_safe_get); tera.register_filter("safe_get", filter_safe_get);
tera.register_function("add_params_to_url", function_add_params_to_url); tera.register_function("add_params_to_url", function_add_params_to_url);
tera.register_function("merge", function_merge); tera.register_function("merge", function_merge);
tera.register_function("dict", function_dict); 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<bool, tera::Error> { fn tester_empty(value: Option<&Value>, params: &[Value]) -> Result<bool, tera::Error> {
@@ -145,25 +153,53 @@ fn function_dict(params: &HashMap<String, Value>) -> Result<Value, tera::Error>
Ok(Value::Object(ret)) Ok(Value::Object(ret))
} }
fn make_static_asset(url_builder: UrlBuilder) -> impl tera::Function { struct IncludeAsset {
Box::new( url_builder: UrlBuilder,
move |args: &HashMap<String, Value>| -> Result<Value, tera::Error> { vite_manifest: ViteManifest,
if let Some(path) = args.get("path").and_then(Value::as_str) { }
let absolute = args
.get("absolute") impl tera::Function for IncludeAsset {
.and_then(Value::as_bool) fn call(&self, args: &HashMap<String, Value>) -> tera::Result<Value> {
.unwrap_or(false); let path = args.get("path").ok_or(tera::Error::msg(
let path = path.to_owned(); "Function `include_asset` was missing parameter `path`",
let url = if absolute { ))?;
url_builder.static_asset(path).into() let path: &Utf8Path = path
} else { .as_str()
let destination = mas_router::StaticAsset::new(path); .ok_or_else(|| {
destination.relative_url().into_owned() tera::Error::msg(
}; "Function `include_asset` received an incorrect type for arg `path`",
Ok(Value::String(url)) )
} else { })?
Err(tera::Error::msg("Invalid parameter '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<String> = 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
}
} }

View File

@@ -29,6 +29,7 @@ use std::{collections::HashSet, string::ToString, sync::Arc};
use anyhow::Context as _; use anyhow::Context as _;
use camino::{Utf8Path, Utf8PathBuf}; use camino::{Utf8Path, Utf8PathBuf};
use mas_router::UrlBuilder; use mas_router::UrlBuilder;
use mas_spa::ViteManifest;
use rand::Rng; use rand::Rng;
use serde::Serialize; use serde::Serialize;
pub use tera::escape_html; pub use tera::escape_html;
@@ -46,12 +47,12 @@ mod macros;
pub use self::{ pub use self::{
context::{ context::{
AccountContext, AccountEmailsContext, CompatSsoContext, ConsentContext, EmailAddContext, AppContext, CompatSsoContext, ConsentContext, EmailAddContext, EmailVerificationContext,
EmailVerificationContext, EmailVerificationPageContext, EmptyContext, ErrorContext, EmailVerificationPageContext, EmptyContext, ErrorContext, FormPostContext, IndexContext,
FormPostContext, IndexContext, LoginContext, LoginFormField, PolicyViolationContext, LoginContext, LoginFormField, PolicyViolationContext, PostAuthContext,
PostAuthContext, PostAuthContextInner, ReauthContext, ReauthFormField, RegisterContext, PostAuthContextInner, ReauthContext, ReauthFormField, RegisterContext, RegisterFormField,
RegisterFormField, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, TemplateContext, UpstreamExistingLinkContext, UpstreamRegister, UpstreamSuggestLink,
UpstreamSuggestLink, WithCsrf, WithOptionalSession, WithSession, WithCsrf, WithOptionalSession, WithSession,
}, },
forms::{FieldError, FormError, FormField, FormState, ToFormState}, forms::{FieldError, FormError, FormField, FormState, ToFormState},
}; };
@@ -61,6 +62,7 @@ pub use self::{
pub struct Templates { pub struct Templates {
tera: Arc<RwLock<Tera>>, tera: Arc<RwLock<Tera>>,
url_builder: UrlBuilder, url_builder: UrlBuilder,
vite_manifest_path: Utf8PathBuf,
path: Utf8PathBuf, path: Utf8PathBuf,
} }
@@ -71,6 +73,14 @@ pub enum TemplateLoadingError {
#[error(transparent)] #[error(transparent)]
IO(#[from] std::io::Error), 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 /// Some templates failed to compile
#[error("could not load and compile some templates")] #[error("could not load and compile some templates")]
Compile(#[from] TeraError), Compile(#[from] TeraError),
@@ -106,19 +116,34 @@ impl Templates {
pub async fn load( pub async fn load(
path: Utf8PathBuf, path: Utf8PathBuf,
url_builder: UrlBuilder, url_builder: UrlBuilder,
vite_manifest_path: Utf8PathBuf,
) -> Result<Self, TemplateLoadingError> { ) -> Result<Self, TemplateLoadingError> {
let tera = Self::load_(&path, url_builder.clone()).await?; let tera = Self::load_(&path, url_builder.clone(), &vite_manifest_path).await?;
Ok(Self { Ok(Self {
tera: Arc::new(RwLock::new(tera)), tera: Arc::new(RwLock::new(tera)),
path, path,
url_builder, url_builder,
vite_manifest_path,
}) })
} }
async fn load_(path: &Utf8Path, url_builder: UrlBuilder) -> Result<Tera, TemplateLoadingError> { async fn load_(
path: &Utf8Path,
url_builder: UrlBuilder,
vite_manifest_path: &Utf8Path,
) -> Result<Tera, TemplateLoadingError> {
let path = path.to_owned(); let path = path.to_owned();
let span = tracing::Span::current(); 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 // This uses blocking I/Os, do that in a blocking task
let mut tera = tokio::task::spawn_blocking(move || { let mut tera = tokio::task::spawn_blocking(move || {
span.in_scope(move || { span.in_scope(move || {
@@ -131,7 +156,7 @@ impl Templates {
}) })
.await??; .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 loaded: HashSet<_> = tera.get_template_names().collect();
let needed: HashSet<_> = TEMPLATES.into_iter().collect(); let needed: HashSet<_> = TEMPLATES.into_iter().collect();
@@ -156,7 +181,12 @@ impl Templates {
)] )]
pub async fn reload(&self) -> Result<(), TemplateLoadingError> { pub async fn reload(&self) -> Result<(), TemplateLoadingError> {
// Prepare the new Tera instance // 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 // Swap it
*self.tera.write().await = new_tera; *self.tera.write().await = new_tera;
@@ -192,6 +222,9 @@ pub enum TemplateError {
} }
register_templates! { register_templates! {
/// Render the frontend app
pub fn render_app(AppContext) { "app.html" }
/// Render the login page /// Render the login page
pub fn render_login(WithCsrf<LoginContext>) { "pages/login.html" } pub fn render_login(WithCsrf<LoginContext>) { "pages/login.html" }
@@ -210,15 +243,9 @@ register_templates! {
/// Render the home page /// Render the home page
pub fn render_index(WithCsrf<WithOptionalSession<IndexContext>>) { "pages/index.html" } pub fn render_index(WithCsrf<WithOptionalSession<IndexContext>>) { "pages/index.html" }
/// Render the account management page
pub fn render_account_index(WithCsrf<WithSession<AccountContext>>) { "pages/account/index.html" }
/// Render the password change page /// Render the password change page
pub fn render_account_password(WithCsrf<WithSession<EmptyContext>>) { "pages/account/password.html" } pub fn render_account_password(WithCsrf<WithSession<EmptyContext>>) { "pages/account/password.html" }
/// Render the emails management
pub fn render_account_emails(WithCsrf<WithSession<AccountEmailsContext>>) { "pages/account/emails/index.html" }
/// Render the email verification page /// Render the email verification page
pub fn render_account_verify_email(WithCsrf<WithSession<EmailVerificationPageContext>>) { "pages/account/emails/verify.html" } pub fn render_account_verify_email(WithCsrf<WithSession<EmailVerificationPageContext>>) { "pages/account/emails/verify.html" }
@@ -267,15 +294,14 @@ impl Templates {
now: chrono::DateTime<chrono::Utc>, now: chrono::DateTime<chrono::Utc>,
rng: &mut impl Rng, rng: &mut impl Rng,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
check::render_app(self, now, rng).await?;
check::render_login(self, now, rng).await?; check::render_login(self, now, rng).await?;
check::render_register(self, now, rng).await?; check::render_register(self, now, rng).await?;
check::render_consent(self, now, rng).await?; check::render_consent(self, now, rng).await?;
check::render_policy_violation(self, now, rng).await?; check::render_policy_violation(self, now, rng).await?;
check::render_sso_login(self, now, rng).await?; check::render_sso_login(self, now, rng).await?;
check::render_index(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_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_add_email(self, now, rng).await?;
check::render_account_verify_email(self, now, rng).await?; check::render_account_verify_email(self, now, rng).await?;
check::render_reauth(self, now, rng).await?; check::render_reauth(self, now, rng).await?;
@@ -305,8 +331,12 @@ mod tests {
let mut rng = rand::thread_rng(); let mut rng = rand::thread_rng();
let path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../templates/"); let path = Utf8Path::new(env!("CARGO_MANIFEST_DIR")).join("../../templates/");
let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None); let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None);
let templates = Templates::load(path, url_builder).await.unwrap(); 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(); templates.check_render(now, &mut rng).await.unwrap();
} }
} }

View File

@@ -89,10 +89,6 @@
{ {
"name": "assets", "name": "assets",
"path": "./frontend/dist/" "path": "./frontend/dist/"
},
{
"manifest": "./frontend/dist/manifest.json",
"name": "spa"
} }
] ]
}, },
@@ -191,6 +187,7 @@
"templates": { "templates": {
"description": "Configuration related to templates", "description": "Configuration related to templates",
"default": { "default": {
"assets_manifest": "./frontend/dist/manifest.json",
"path": "./templates/" "path": "./templates/"
}, },
"allOf": [ "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", "type": "object",
"required": [ "required": [
"name" "name"
], ],
"properties": { "properties": {
"manifest": {
"description": "Path to the vite manifest.json",
"default": "./frontend/dist/manifest.json",
"type": "string"
},
"name": { "name": {
"type": "string", "type": "string",
"enum": [ "enum": [
@@ -1790,6 +1783,11 @@
"description": "Configuration related to templates", "description": "Configuration related to templates",
"type": "object", "type": "object",
"properties": { "properties": {
"assets_manifest": {
"description": "Path to the assets manifest",
"default": "./frontend/dist/manifest.json",
"type": "string"
},
"path": { "path": {
"description": "Path to the folder which holds the templates", "description": "Path to the folder which holds the templates",
"default": "./templates/", "default": "./templates/",

View File

@@ -14,7 +14,7 @@
import { ArgTypes, Decorator, Parameters } from "@storybook/react"; import { ArgTypes, Decorator, Parameters } from "@storybook/react";
import { useLayoutEffect } from "react"; import { useLayoutEffect } from "react";
import "../src/index.css"; import "../src/main.css";
export const parameters: Parameters = { export const parameters: Parameters = {
actions: { argTypesRegex: "^on[A-Z].*" }, actions: { argTypesRegex: "^on[A-Z].*" },

View File

@@ -14,17 +14,16 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
--> -->
<!-- Must be kept in sync with templates/app.html -->
<!DOCTYPE html> <!DOCTYPE html>
<html lang="en"> <html lang="en">
<head>
<head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="stylesheet" href="/src/index.css" />
<title>matrix-authentication-service</title> <title>matrix-authentication-service</title>
<script> <script>
window.APP_CONFIG = {root: "/app/"}; window.APP_CONFIG = JSON.parse('{root: "/app/"}');
(function () { (function () {
const query = window.matchMedia("(prefers-color-scheme: dark)"); const query = window.matchMedia("(prefers-color-scheme: dark)");
function handleChange(list) { function handleChange(list) {
@@ -39,11 +38,10 @@ limitations under the License.
handleChange(query); handleChange(query);
})(); })();
</script> </script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

View File

@@ -57,11 +57,14 @@
"postcss": "^8.4.24", "postcss": "^8.4.24",
"prettier": "2.8.0", "prettier": "2.8.0",
"react-test-renderer": "^18.2.0", "react-test-renderer": "^18.2.0",
"rimraf": "^5.0.1",
"storybook": "^7.0.26", "storybook": "^7.0.26",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"typescript": "5.1.6", "typescript": "5.1.6",
"vite": "^4.3.9", "vite": "^4.3.9",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-graphql-codegen": "^3.2.2", "vite-plugin-graphql-codegen": "^3.2.2",
"vite-plugin-manifest-sri": "^0.1.0",
"vite-plugin-svgr": "^3.2.0", "vite-plugin-svgr": "^3.2.0",
"vitest": "^0.32.4" "vitest": "^0.32.4"
} }
@@ -4953,6 +4956,96 @@
"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==", "integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
"dev": true "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": { "node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", "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": ">=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": { "node_modules/@pkgr/utils": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.1.tgz", "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.4.1.tgz",
@@ -9947,6 +10050,21 @@
"wrap-ansi": "^7.0.0" "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": { "node_modules/c8/node_modules/yargs": {
"version": "16.2.0", "version": "16.2.0",
"resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz", "resolved": "https://registry.npmjs.org/yargs/-/yargs-16.2.0.tgz",
@@ -11085,6 +11203,21 @@
"url": "https://github.com/sponsors/sindresorhus" "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": { "node_modules/delayed-stream": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
@@ -11355,6 +11488,12 @@
"safe-buffer": "~5.1.0" "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": { "node_modules/ee-first": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", "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": "^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": { "node_modules/flatted": {
"version": "3.2.7", "version": "3.2.7",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz",
@@ -14794,6 +14948,24 @@
"node": ">=8" "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": { "node_modules/jake": {
"version": "10.8.7", "version": "10.8.7",
"resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz", "resolved": "https://registry.npmjs.org/jake/-/jake-10.8.7.tgz",
@@ -16785,6 +16957,31 @@
"node": ">=0.10.0" "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": { "node_modules/path-to-regexp": {
"version": "0.1.7", "version": "0.1.7",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz",
@@ -18161,15 +18358,92 @@
"dev": true "dev": true
}, },
"node_modules/rimraf": { "node_modules/rimraf": {
"version": "3.0.2", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.1.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", "integrity": "sha512-OfFZdwtd3lZ+XZzYP/6gTACubwFcHdLRqS9UX3UwpU2dnGQYkPFISRwvM3w9IiB2w7bW5qGo/uAwE4SmXXSKvg==",
"dev": true, "dev": true,
"dependencies": { "dependencies": {
"glob": "^7.1.3" "glob": "^10.2.5"
}, },
"bin": { "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": { "funding": {
"url": "https://github.com/sponsors/isaacs" "url": "https://github.com/sponsors/isaacs"
@@ -18818,6 +19092,27 @@
"node": ">=8" "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": { "node_modules/string-width/node_modules/emoji-regex": {
"version": "8.0.0", "version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
@@ -18900,6 +19195,19 @@
"node": ">=8" "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": { "node_modules/strip-bom": {
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz",
@@ -20243,6 +20551,86 @@
"url": "https://opencollective.com/vitest" "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": { "node_modules/vite-plugin-graphql-codegen": {
"version": "3.2.2", "version": "3.2.2",
"resolved": "https://registry.npmjs.org/vite-plugin-graphql-codegen/-/vite-plugin-graphql-codegen-3.2.2.tgz", "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" "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": { "node_modules/vite-plugin-svgr": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-3.2.0.tgz", "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" "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": { "node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "4.3.0", "version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",

View File

@@ -7,8 +7,7 @@
"dev": "vite", "dev": "vite",
"generate": "graphql-codegen && eslint --fix .", "generate": "graphql-codegen && eslint --fix .",
"lint": "graphql-codegen && eslint . && tsc", "lint": "graphql-codegen && eslint . && tsc",
"build": "npm run lint && vite build --base=./ && npm run build:templates", "build": "rimraf ./dist/ && vite build",
"build:templates": "tailwindcss --postcss --minify --config ./tailwind.templates.config.cjs -o dist/tailwind.css",
"preview": "vite preview", "preview": "vite preview",
"test": "vitest", "test": "vitest",
"coverage": "vitest run --coverage", "coverage": "vitest run --coverage",
@@ -65,11 +64,14 @@
"postcss": "^8.4.24", "postcss": "^8.4.24",
"prettier": "2.8.0", "prettier": "2.8.0",
"react-test-renderer": "^18.2.0", "react-test-renderer": "^18.2.0",
"rimraf": "^5.0.1",
"storybook": "^7.0.26", "storybook": "^7.0.26",
"tailwindcss": "^3.3.2", "tailwindcss": "^3.3.2",
"typescript": "5.1.6", "typescript": "5.1.6",
"vite": "^4.3.9", "vite": "^4.3.9",
"vite-plugin-compression": "^0.5.1",
"vite-plugin-graphql-codegen": "^3.2.2", "vite-plugin-graphql-codegen": "^3.2.2",
"vite-plugin-manifest-sri": "^0.1.0",
"vite-plugin-svgr": "^3.2.0", "vite-plugin-svgr": "^3.2.0",
"vitest": "^0.32.4" "vitest": "^0.32.4"
} }

View File

@@ -20,6 +20,7 @@ import { createRoot } from "react-dom/client";
import Router from "./Router"; import Router from "./Router";
import { HydrateAtoms } from "./atoms"; import { HydrateAtoms } from "./atoms";
import LoadingScreen from "./components/LoadingScreen"; import LoadingScreen from "./components/LoadingScreen";
import "./main.css";
createRoot(document.getElementById("root") as HTMLElement).render( createRoot(document.getElementById("root") as HTMLElement).render(
<StrictMode> <StrictMode>

View File

@@ -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;

View File

@@ -13,20 +13,33 @@
// limitations under the License. // limitations under the License.
/// <reference types="vitest" /> /// <reference types="vitest" />
import { resolve } from "path";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import { defineConfig } from "vite"; import { defineConfig } from "vite";
import compression from "vite-plugin-compression";
import codegen from "vite-plugin-graphql-codegen"; import codegen from "vite-plugin-graphql-codegen";
import manifestSRI from "vite-plugin-manifest-sri";
import svgr from "vite-plugin-svgr"; import svgr from "vite-plugin-svgr";
export default defineConfig((env) => ({ export default defineConfig((env) => ({
base: "/app/", base: "./",
build: { build: {
manifest: true, manifest: true,
assetsDir: "", assetsDir: "",
sourcemap: true, sourcemap: true,
modulePreload: false,
rollupOptions: {
input: [
resolve(__dirname, "src/main.tsx"),
resolve(__dirname, "src/templates.css"),
],
},
}, },
plugins: [ plugins: [
codegen(), codegen(),
react({ react({
babel: { babel: {
plugins: [ plugins: [
@@ -54,6 +67,8 @@ export default defineConfig((env) => ({
}, },
}), }),
manifestSRI(),
svgr({ svgr({
exportAsDefault: true, 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: { server: {
base: "/account/",
proxy: { proxy: {
// Routes mostly extracted from crates/router/src/endpoints.rs // 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", "http://127.0.0.1:8080",
}, },
}, },

47
templates/app.html Normal file
View File

@@ -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 #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>matrix-authentication-service</title>
<script>
window.APP_CONFIG = JSON.parse("{{ app_config | json_encode | addslashes | safe }}");
(function () {
const query = window.matchMedia("(prefers-color-scheme: dark)");
function handleChange(list) {
if (list.matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
query.addEventListener("change", handleChange);
handleChange(query);
})();
</script>
{{ include_asset(path='src/main.tsx') | indent(prefix=" ") | safe }}
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@@ -27,7 +27,7 @@ limitations under the License.
<meta charset="utf-8"> <meta charset="utf-8">
<title>{% block title %}matrix-authentication-service{% endblock title %}</title> <title>{% block title %}matrix-authentication-service{% endblock title %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="{{ static_asset(path='tailwind.css') }}"> {{ include_asset(path='src/templates.css') | indent(prefix=" ") | safe }}
</head> </head>
<body class="bg-white text-black-900 dark:bg-black-800 dark:text-white flex flex-col min-h-screen"> <body class="bg-white text-black-900 dark:bg-black-800 dark:text-white flex flex-col min-h-screen">
{% block content %}{% endblock content %} {% block content %}{% endblock content %}

View File

@@ -24,7 +24,7 @@ limitations under the License.
Signed in as <span class="font-bold">{{ current_session.user.username }}</span>. Signed in as <span class="font-bold">{{ current_session.user.username }}</span>.
</div> </div>
{{ 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) }} {{ logout::button(text="Sign out", class=button::outline_class(), csrf_token=csrf_token) }}
{% else %} {% else %}
{{ button::link(text="Sign in", href="/login") }} {{ button::link(text="Sign in", href="/login") }}

View File

@@ -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 %}
<section class="container mx-auto grid gap-4 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 p-2">
<form class="rounded border-2 border-grey-50 dark:border-grey-450 p-4 grid gap-4 xl:grid-cols-2 grid-cols-1 place-content-start" method="POST">
<h2 class="text-xl font-bold xl:col-span-2">Add email</h2>
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{{ 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") }}
</form>
<div class="rounded border-2 border-grey-50 dark:border-grey-450 xl:col-span-2 p-4">
<h2 class="text-xl font-bold xl:col-span-3">Emails</h2>
{% for item in emails %}
<form class="flex my-2 items-center justify-items-center" method="POST">
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
<input type="hidden" name="id" value="{{ item.id }}" />
<div class="font-bold flex-1">{{ item.email }}</div>
{% if item.confirmed_at %}
<div class="mr-4">Verified</div>
{% else %}
{{ button::button(text="Resend verification", type="submit", name="action", value="resend_confirmation", class="mr-4") }}
{% endif %}
{% if item.email == primary_email %}
<div class="mr-4">Primary</div>
{% 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") }}
</form>
{% endfor %}
</div>
</section>
{% endblock content %}

View File

@@ -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() }}
<section class="container mx-auto grid gap-4 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 p-2">
<div class="rounded border-2 border-grey-50 dark:border-grey-450 p-4 grid gap-4 xl:grid-cols-2 grid-cols-1 place-content-start">
<h1 class="text-2xl font-bold xl:col-span-2">Manage my account</h1>
<div class="font-bold">Your username</div>
<div>{{ current_session.user.username }}</div>
<div class="font-bold">Unique identifier</div>
<div>{{ current_session.user.sub }}</div>
<div class="font-bold">Active sessions</div>
<div>{{ active_sessions }}</div>
{% if current_session.user.primary_email %}
<div class="font-bold">Primary email</div>
<div>{{ current_session.user.primary_email.email }}</div>
{% endif %}
{{ button::link_outline(text="Change password", href="/account/password", class="col-span-2 place-self-end") }}
</div>
<div class="rounded border-2 border-grey-50 dark:border-grey-450 p-4 grid gap-4 xl:grid-cols-2 grid-cols-1 place-content-start">
<h2 class="text-xl font-bold xl:col-span-2">Current session</h2>
<div class="font-bold">Started at</div>
<div>{{ current_session.created_at | date(format="%Y-%m-%d %H:%M:%S") }}</div>
<div class="font-bold">Last authentication</div>
<div>
{% if current_session.last_authentication %}
{{ current_session.last_authentication.created_at | date(format="%Y-%m-%d %H:%M:%S") }}
{% else %}
Never
{% endif %}
</div>
{{ button::link_outline(text="Revalidate", href="/reauth", class="col-span-2 place-self-end") }}
</div>
<div class="rounded border-2 border-grey-50 dark:border-grey-450 p-4 grid gap-4 xl:grid-cols-2 grid-cols-1 place-content-start">
<h2 class="text-xl font-bold xl:col-span-2">Emails</h2>
{% for email in emails %}
<div class="font-bold">{{ email.email }}</div>
<div>{% if email.confirmed_at %}Confirmed{% else %}Unconfirmed{% endif %}</div>
{% endfor %}
{{ button::link_outline(text="Manage", href="/account/emails", class="col-span-2 place-self-end") }}
</div>
</section>
{% endblock content %}