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