1
0
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:
Quentin Gliech
2023-07-06 15:30:26 +02:00
committed by GitHub
parent 6cae2adc08
commit 76653f9638
47 changed files with 1096 additions and 1011 deletions

View File

@ -239,6 +239,21 @@ jobs:
rustup toolchain install ${{ matrix.toolchain }}
rustup 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:

View File

@ -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
View File

@ -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",

View File

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

View File

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

View File

@ -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(())

View File

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

View File

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

View File

@ -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))]

View File

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

View File

@ -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(),
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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())
}

View File

@ -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())
}

View File

@ -0,0 +1,48 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use axum::{
extract::State,
response::{Html, IntoResponse},
};
use axum_extra::extract::PrivateCookieJar;
use mas_axum_utils::{FancyError, SessionInfoExt};
use mas_keystore::Encrypter;
use mas_router::{PostAuthAction, Route};
use mas_storage::BoxRepository;
use mas_templates::{AppContext, Templates};
#[tracing::instrument(name = "handlers.views.app.get", skip_all, err)]
pub async fn get(
State(templates): State<Templates>,
mut repo: BoxRepository,
cookie_jar: PrivateCookieJar<Encrypter>,
) -> Result<impl IntoResponse, FancyError> {
let (session_info, cookie_jar) = cookie_jar.session_info();
let session = session_info.load_session(&mut repo).await?;
// TODO: keep the full path
if session.is_none() {
return Ok((
cookie_jar,
mas_router::Login::and_then(PostAuthAction::ManageAccount).go(),
)
.into_response());
}
let ctx = AppContext::default();
let content = templates.render_app(&ctx).await?;
Ok((cookie_jar, Html(content)).into_response())
}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,88 +0,0 @@
// Copyright 2022 Kévin Commaille.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::task::Poll;
use bytes::Bytes;
use futures_util::future::BoxFuture;
use http::{Request, Response};
use http_body::{Body, Full};
use hyper::body::to_bytes;
use thiserror::Error;
use tower::{BoxError, Layer, Service};
#[derive(Debug, Error)]
#[error(transparent)]
pub enum BodyError<E> {
Decompression(BoxError),
Service(E),
}
#[derive(Clone)]
pub struct BodyService<S> {
inner: S,
}
impl<S> BodyService<S> {
pub const fn new(inner: S) -> Self {
Self { inner }
}
}
impl<S, E, ResBody> Service<Request<Bytes>> for BodyService<S>
where
S: Service<Request<Full<Bytes>>, Response = Response<ResBody>, Error = E>,
ResBody: Body<Data = Bytes, Error = BoxError> + Send,
S::Future: Send + 'static,
{
type Error = BodyError<E>;
type Response = Response<Bytes>;
type Future = BoxFuture<'static, Result<Self::Response, Self::Error>>;
fn poll_ready(&mut self, cx: &mut std::task::Context<'_>) -> Poll<Result<(), Self::Error>> {
self.inner.poll_ready(cx).map_err(BodyError::Service)
}
fn call(&mut self, request: Request<Bytes>) -> Self::Future {
let (parts, body) = request.into_parts();
let body = Full::new(body);
let request = Request::from_parts(parts, body);
let fut = self.inner.call(request);
let fut = async {
let response = fut.await.map_err(BodyError::Service)?;
let (parts, body) = response.into_parts();
let body = to_bytes(body).await.map_err(BodyError::Decompression)?;
let response = Response::from_parts(parts, body);
Ok(response)
};
Box::pin(fut)
}
}
#[derive(Default, Clone, Copy)]
pub struct BodyLayer(());
impl<S> Layer<S> for BodyLayer {
type Service = BodyService<S>;
fn layer(&self, inner: S) -> Self::Service {
BodyService::new(inner)
}
}

View File

@ -24,6 +24,7 @@ pub enum PostAuthAction {
ContinueCompatSsoLogin { id: Ulid },
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`

View File

@ -1,4 +1,4 @@
// Copyright 2022 The Matrix.org Foundation C.I.C.
// Copyright 2022, 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// 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 {

View File

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

View File

@ -1,32 +0,0 @@
// Copyright 2022 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use camino::Utf8Path;
use mas_spa::ViteManifest;
fn main() {
let mut stdin = std::io::stdin();
let manifest: ViteManifest =
serde_json::from_reader(&mut stdin).expect("failed to read manifest from stdin");
let assets_base = Utf8Path::new("/assets/");
let config = serde_json::json!({
"root": "/app/",
});
let html = manifest
.render(assets_base, &config)
.expect("failed to render");
println!("{html}");
}

View File

@ -25,73 +25,4 @@
mod vite;
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)
})
}
}

View File

@ -1,9 +1,23 @@
// Copyright 2023 The Matrix.org Foundation C.I.C.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
use std::collections::{BTreeSet, HashMap};
use 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)?;
}
}

View File

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

View File

@ -220,6 +220,45 @@ impl TemplateContext for IndexContext {
}
}
/// Config used by the frontend app
#[derive(Serialize)]
pub struct AppConfig {
root: String,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
root: "/account/".into(),
}
}
}
/// Context used by the `app.html` template
#[derive(Serialize, Default)]
pub struct AppContext {
app_config: AppConfig,
}
impl AppContext {
/// Constructs the context for the app page with the given app root
#[must_use]
pub fn with_app_root(root: String) -> Self {
Self {
app_config: AppConfig { root },
}
}
}
impl TemplateContext for AppContext {
fn sample(_now: chrono::DateTime<Utc>, _rng: &mut impl Rng) -> Vec<Self>
where
Self: Sized,
{
vec![Self::default()]
}
}
/// Fields of the login form
#[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 {

View File

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

View File

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

View File

@ -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/",

View File

@ -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].*" },

View File

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

View File

@ -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",

View File

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

View File

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

View File

@ -0,0 +1,20 @@
/* Copyright 2022 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@config "../tailwind.templates.config.cjs";
@tailwind base;
@tailwind components;
@tailwind utilities;

View File

@ -13,20 +13,33 @@
// limitations under the License.
/// <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
View File

@ -0,0 +1,47 @@
{#
Copyright 2023 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
#}
{# Must be kept in sync with frontend/index.html #}
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>matrix-authentication-service</title>
<script>
window.APP_CONFIG = JSON.parse("{{ app_config | json_encode | addslashes | safe }}");
(function () {
const query = window.matchMedia("(prefers-color-scheme: dark)");
function handleChange(list) {
if (list.matches) {
document.documentElement.classList.add("dark");
} else {
document.documentElement.classList.remove("dark");
}
}
query.addEventListener("change", handleChange);
handleChange(query);
})();
</script>
{{ include_asset(path='src/main.tsx') | indent(prefix=" ") | safe }}
</head>
<body>
<div id="root"></div>
</body>
</html>

View File

@ -27,7 +27,7 @@ limitations under the License.
<meta charset="utf-8">
<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 %}

View File

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

View File

@ -1,60 +0,0 @@
{#
Copyright 2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
#}
{% extends "base.html" %}
{% block content %}
{{ navbar::top() }}
{% if current_session.user.primary_email %}
{% set primary_email = current_session.user.primary_email.email %}
{% else %}
{% set primary_email = "" %}
{% endif %}
<section class="container mx-auto grid gap-4 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 p-2">
<form class="rounded border-2 border-grey-50 dark:border-grey-450 p-4 grid gap-4 xl:grid-cols-2 grid-cols-1 place-content-start" method="POST">
<h2 class="text-xl font-bold xl:col-span-2">Add email</h2>
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
{{ field::input(label="New email", name="email", type="email", autocomplete="email", class="xl:col-span-2") }}
{{ button::button(text="Add email", type="submit", class="xl:col-span-2 place-self-end", name="action", value="add") }}
</form>
<div class="rounded border-2 border-grey-50 dark:border-grey-450 xl:col-span-2 p-4">
<h2 class="text-xl font-bold xl:col-span-3">Emails</h2>
{% for item in emails %}
<form class="flex my-2 items-center justify-items-center" method="POST">
<input type="hidden" name="csrf" value="{{ csrf_token }}" />
<input type="hidden" name="id" value="{{ item.id }}" />
<div class="font-bold flex-1">{{ item.email }}</div>
{% if item.confirmed_at %}
<div class="mr-4">Verified</div>
{% else %}
{{ button::button(text="Resend verification", type="submit", name="action", value="resend_confirmation", class="mr-4") }}
{% endif %}
{% if item.email == primary_email %}
<div class="mr-4">Primary</div>
{% else %}
{{ button::button(text="Set as primary", type="submit", name="action", value="set_primary", class="mr-4") }}
{% endif %}
{{ button::button(text="Delete", type="submit", name="action", value="remove") }}
</form>
{% endfor %}
</div>
</section>
{% endblock content %}

View File

@ -1,59 +0,0 @@
{#
Copyright 2021-2022 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
#}
{% extends "base.html" %}
{% block content %}
{{ navbar::top() }}
<section class="container mx-auto grid gap-4 grid-cols-1 md:grid-cols-2 xl:grid-cols-3 p-2">
<div class="rounded border-2 border-grey-50 dark:border-grey-450 p-4 grid gap-4 xl:grid-cols-2 grid-cols-1 place-content-start">
<h1 class="text-2xl font-bold xl:col-span-2">Manage my account</h1>
<div class="font-bold">Your username</div>
<div>{{ current_session.user.username }}</div>
<div class="font-bold">Unique identifier</div>
<div>{{ current_session.user.sub }}</div>
<div class="font-bold">Active sessions</div>
<div>{{ active_sessions }}</div>
{% if current_session.user.primary_email %}
<div class="font-bold">Primary email</div>
<div>{{ current_session.user.primary_email.email }}</div>
{% endif %}
{{ button::link_outline(text="Change password", href="/account/password", class="col-span-2 place-self-end") }}
</div>
<div class="rounded border-2 border-grey-50 dark:border-grey-450 p-4 grid gap-4 xl:grid-cols-2 grid-cols-1 place-content-start">
<h2 class="text-xl font-bold xl:col-span-2">Current session</h2>
<div class="font-bold">Started at</div>
<div>{{ current_session.created_at | date(format="%Y-%m-%d %H:%M:%S") }}</div>
<div class="font-bold">Last authentication</div>
<div>
{% if current_session.last_authentication %}
{{ current_session.last_authentication.created_at | date(format="%Y-%m-%d %H:%M:%S") }}
{% else %}
Never
{% endif %}
</div>
{{ button::link_outline(text="Revalidate", href="/reauth", class="col-span-2 place-self-end") }}
</div>
<div class="rounded border-2 border-grey-50 dark:border-grey-450 p-4 grid gap-4 xl:grid-cols-2 grid-cols-1 place-content-start">
<h2 class="text-xl font-bold xl:col-span-2">Emails</h2>
{% for email in emails %}
<div class="font-bold">{{ email.email }}</div>
<div>{% if email.confirmed_at %}Confirmed{% else %}Unconfirmed{% endif %}</div>
{% endfor %}
{{ button::link_outline(text="Manage", href="/account/emails", class="col-span-2 place-self-end") }}
</div>
</section>
{% endblock content %}