1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-07-31 09:24:31 +03:00

Allow running the authentication service on a different base path

This commit is contained in:
Quentin Gliech
2023-09-05 17:51:50 +02:00
parent 9ecb666ec1
commit 9b5c8fb44b
40 changed files with 388 additions and 195 deletions

View File

@ -57,11 +57,6 @@ impl Options {
let span = info_span!("cli.run.init").entered(); let span = info_span!("cli.run.init").entered();
let config: AppConfig = root.load_config()?; let config: AppConfig = root.load_config()?;
// XXX: there should be a generic config verification step
if config.http.public_base.path() != "/" {
anyhow::bail!("The http.public_base path is not set to /, this is not supported");
}
// Connect to the database // Connect to the database
info!("Connecting to the database"); info!("Connecting to the database");
let pool = database_pool_from_config(&config.database).await?; let pool = database_pool_from_config(&config.database).await?;
@ -201,19 +196,22 @@ impl Options {
let router = crate::server::build_router( let router = crate::server::build_router(
state.clone(), state.clone(),
&config.resources, &config.resources,
config.prefix.as_deref(),
config.name.as_deref(), config.name.as_deref(),
); );
// Display some informations about where we'll be serving connections // Display some informations about where we'll be serving connections
let proto = if config.tls.is_some() { "https" } else { "http" }; let proto = if config.tls.is_some() { "https" } else { "http" };
let prefix = config.prefix.unwrap_or_default();
let addresses= listeners let addresses= listeners
.iter() .iter()
.map(|listener| { .map(|listener| {
if let Ok(addr) = listener.local_addr() { if let Ok(addr) = listener.local_addr() {
format!("{proto}://{addr:?}") format!("{proto}://{addr:?}{prefix}")
} else { } else {
warn!("Could not get local address for listener, something might be wrong!"); warn!("Could not get local address for listener, something might be wrong!");
format!("{proto}://???") format!("{proto}://???{prefix}")
} }
}) })
.join(", "); .join(", ");

View File

@ -175,6 +175,7 @@ fn on_http_response_labels<B>(res: &Response<B>) -> Vec<KeyValue> {
pub fn build_router<B>( pub fn build_router<B>(
state: AppState, state: AppState,
resources: &[HttpResource], resources: &[HttpResource],
prefix: Option<&str>,
name: Option<&str>, name: Option<&str>,
) -> Router<(), B> ) -> Router<(), B>
where where
@ -244,6 +245,11 @@ where
} }
} }
if let Some(prefix) = prefix {
let path = format!("{}/", prefix.trim_end_matches('/'));
router = Router::new().nest(&path, router);
}
router = router.fallback(mas_handlers::fallback); router = router.fallback(mas_handlers::fallback);
router router

View File

@ -312,6 +312,10 @@ pub struct ListenerConfig {
/// List of resources to mount /// List of resources to mount
pub resources: Vec<Resource>, pub resources: Vec<Resource>,
/// HTTP prefix to mount the resources on
#[serde(default)]
pub prefix: Option<String>,
/// List of sockets to bind /// List of sockets to bind
pub binds: Vec<BindConfig>, pub binds: Vec<BindConfig>,
@ -359,6 +363,7 @@ impl Default for HttpConfig {
path: http_listener_assets_path_default(), path: http_listener_assets_path_default(),
}, },
], ],
prefix: None,
tls: None, tls: None,
proxy_protocol: false, proxy_protocol: false,
binds: vec![BindConfig::Address { binds: vec![BindConfig::Address {
@ -368,6 +373,7 @@ impl Default for HttpConfig {
ListenerConfig { ListenerConfig {
name: Some("internal".to_owned()), name: Some("internal".to_owned()),
resources: vec![Resource::Health], resources: vec![Resource::Health],
prefix: None,
tls: None, tls: None,
proxy_protocol: false, proxy_protocol: false,
binds: vec![BindConfig::Listen { binds: vec![BindConfig::Listen {

View File

@ -27,7 +27,7 @@ use mas_axum_utils::{
FancyError, SessionInfoExt, FancyError, SessionInfoExt,
}; };
use mas_data_model::Device; use mas_data_model::Device;
use mas_router::{CompatLoginSsoAction, PostAuthAction, Route}; use mas_router::{CompatLoginSsoAction, PostAuthAction, UrlBuilder};
use mas_storage::{ use mas_storage::{
compat::{CompatSessionRepository, CompatSsoLoginRepository}, compat::{CompatSessionRepository, CompatSsoLoginRepository},
job::{JobRepositoryExt, ProvisionDeviceJob}, job::{JobRepositoryExt, ProvisionDeviceJob},
@ -65,6 +65,7 @@ pub async fn get(
clock: BoxClock, clock: BoxClock,
mut repo: BoxRepository, mut repo: BoxRepository,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
cookie_jar: CookieJar, cookie_jar: CookieJar,
Path(id): Path<Ulid>, Path(id): Path<Ulid>,
Query(params): Query<Params>, Query(params): Query<Params>,
@ -78,10 +79,10 @@ pub async fn get(
// If there is no session, redirect to the login or register screen // If there is no session, redirect to the login or register screen
let url = match params.action { let url = match params.action {
Some(CompatLoginSsoAction::Register) => { Some(CompatLoginSsoAction::Register) => {
mas_router::Register::and_continue_compat_sso_login(id).go() url_builder.redirect(&mas_router::Register::and_continue_compat_sso_login(id))
} }
Some(CompatLoginSsoAction::Login) | None => { Some(CompatLoginSsoAction::Login) | None => {
mas_router::Login::and_continue_compat_sso_login(id).go() url_builder.redirect(&mas_router::Login::and_continue_compat_sso_login(id))
} }
}; };
@ -92,7 +93,7 @@ pub async fn get(
if session.user.primary_user_email_id.is_none() { if session.user.primary_user_email_id.is_none() {
let destination = mas_router::AccountAddEmail::default() let destination = mas_router::AccountAddEmail::default()
.and_then(PostAuthAction::continue_compat_sso_login(id)); .and_then(PostAuthAction::continue_compat_sso_login(id));
return Ok((cookie_jar, destination.go()).into_response()); return Ok((cookie_jar, url_builder.redirect(&destination)).into_response());
} }
let login = repo let login = repo
@ -134,6 +135,7 @@ pub async fn post(
mut repo: BoxRepository, mut repo: BoxRepository,
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
cookie_jar: CookieJar, cookie_jar: CookieJar,
Path(id): Path<Ulid>, Path(id): Path<Ulid>,
Query(params): Query<Params>, Query(params): Query<Params>,
@ -148,10 +150,10 @@ pub async fn post(
// If there is no session, redirect to the login or register screen // If there is no session, redirect to the login or register screen
let url = match params.action { let url = match params.action {
Some(CompatLoginSsoAction::Register) => { Some(CompatLoginSsoAction::Register) => {
mas_router::Register::and_continue_compat_sso_login(id).go() url_builder.redirect(&mas_router::Register::and_continue_compat_sso_login(id))
} }
Some(CompatLoginSsoAction::Login) | None => { Some(CompatLoginSsoAction::Login) | None => {
mas_router::Login::and_continue_compat_sso_login(id).go() url_builder.redirect(&mas_router::Login::and_continue_compat_sso_login(id))
} }
}; };
@ -162,7 +164,7 @@ pub async fn post(
if session.user.primary_user_email_id.is_none() { if session.user.primary_user_email_id.is_none() {
let destination = mas_router::AccountAddEmail::default() let destination = mas_router::AccountAddEmail::default()
.and_then(PostAuthAction::continue_compat_sso_login(id)); .and_then(PostAuthAction::continue_compat_sso_login(id));
return Ok((cookie_jar, destination.go()).into_response()); return Ok((cookie_jar, url_builder.redirect(&destination)).into_response());
} }
let login = repo let login = repo

View File

@ -30,7 +30,7 @@
clippy::let_with_type_underscore, clippy::let_with_type_underscore,
)] )]
use std::{borrow::Cow, convert::Infallible, time::Duration}; use std::{convert::Infallible, time::Duration};
use axum::{ use axum::{
body::{Bytes, HttpBody}, body::{Bytes, HttpBody},
@ -276,6 +276,18 @@ where
mas_router::CompatRefresh::route(), mas_router::CompatRefresh::route(),
post(self::compat::refresh::post), post(self::compat::refresh::post),
) )
.route(
mas_router::CompatLoginSsoRedirect::route(),
get(self::compat::login_sso_redirect::get),
)
.route(
mas_router::CompatLoginSsoRedirectIdp::route(),
get(self::compat::login_sso_redirect::get),
)
.route(
mas_router::CompatLoginSsoRedirectSlash::route(),
get(self::compat::login_sso_redirect::get),
)
.layer( .layer(
CorsLayer::new() CorsLayer::new()
.allow_origin(Any) .allow_origin(Any)
@ -318,16 +330,19 @@ where
// XXX: hard-coded redirect from /account to /account/ // XXX: hard-coded redirect from /account to /account/
.route( .route(
"/account", "/account",
get(|RawQuery(query): RawQuery| async { get(
let route = mas_router::Account::route(); |State(url_builder): State<UrlBuilder>, RawQuery(query): RawQuery| async move {
let destination = if let Some(query) = query { let prefix = url_builder.prefix().unwrap_or_default();
Cow::Owned(format!("{route}?{query}")) let route = mas_router::Account::route();
} else { let destination = if let Some(query) = query {
Cow::Borrowed(route) format!("{prefix}{route}?{query}")
}; } else {
format!("{prefix}{route}")
};
axum::response::Redirect::to(&destination) axum::response::Redirect::to(&destination)
}), },
),
) )
.route(mas_router::Account::route(), get(self::views::app::get)) .route(mas_router::Account::route(), get(self::views::app::get))
.route( .route(
@ -336,7 +351,9 @@ where
) )
.route( .route(
mas_router::ChangePasswordDiscovery::route(), mas_router::ChangePasswordDiscovery::route(),
get(|| async { mas_router::AccountPassword.go() }), get(|State(url_builder): State<UrlBuilder>| async move {
url_builder.redirect(&mas_router::AccountPassword)
}),
) )
.route(mas_router::Index::route(), get(self::views::index::get)) .route(mas_router::Index::route(), get(self::views::index::get))
.route( .route(
@ -378,18 +395,6 @@ where
mas_router::Consent::route(), mas_router::Consent::route(),
get(self::oauth2::consent::get).post(self::oauth2::consent::post), get(self::oauth2::consent::get).post(self::oauth2::consent::post),
) )
.route(
mas_router::CompatLoginSsoRedirect::route(),
get(self::compat::login_sso_redirect::get),
)
.route(
mas_router::CompatLoginSsoRedirectIdp::route(),
get(self::compat::login_sso_redirect::get),
)
.route(
mas_router::CompatLoginSsoRedirectSlash::route(),
get(self::compat::login_sso_redirect::get),
)
.route( .route(
mas_router::CompatLoginSsoComplete::route(), mas_router::CompatLoginSsoComplete::route(),
get(self::compat::login_sso_complete::get).post(self::compat::login_sso_complete::post), get(self::compat::login_sso_complete::get).post(self::compat::login_sso_complete::post),

View File

@ -21,7 +21,7 @@ use mas_axum_utils::{cookies::CookieJar, csrf::CsrfExt, sentry::SentryEventID, S
use mas_data_model::{AuthorizationGrant, BrowserSession, Client, Device}; use mas_data_model::{AuthorizationGrant, BrowserSession, Client, Device};
use mas_keystore::Keystore; use mas_keystore::Keystore;
use mas_policy::{EvaluationResult, Policy}; use mas_policy::{EvaluationResult, Policy};
use mas_router::{PostAuthAction, Route, UrlBuilder}; use mas_router::{PostAuthAction, UrlBuilder};
use mas_storage::{ use mas_storage::{
oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository, OAuth2SessionRepository}, oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository, OAuth2SessionRepository},
user::BrowserSessionRepository, user::BrowserSessionRepository,
@ -117,7 +117,11 @@ pub(crate) async fn get(
let Some(session) = maybe_session else { let Some(session) = maybe_session else {
// If there is no session, redirect to the login screen, redirecting here after // If there is no session, redirect to the login screen, redirecting here after
// logout // logout
return Ok((cookie_jar, mas_router::Login::and_then(continue_grant).go()).into_response()); return Ok((
cookie_jar,
url_builder.redirect(&mas_router::Login::and_then(continue_grant)),
)
.into_response());
}; };
activity_tracker activity_tracker
@ -137,7 +141,7 @@ pub(crate) async fn get(
repo, repo,
key_store, key_store,
policy, policy,
url_builder, &url_builder,
grant, grant,
&client, &client,
&session, &session,
@ -150,12 +154,12 @@ pub(crate) async fn get(
} }
Err(GrantCompletionError::RequiresReauth) => Ok(( Err(GrantCompletionError::RequiresReauth) => Ok((
cookie_jar, cookie_jar,
mas_router::Reauth::and_then(continue_grant).go(), url_builder.redirect(&mas_router::Reauth::and_then(continue_grant)),
) )
.into_response()), .into_response()),
Err(GrantCompletionError::RequiresConsent) => { Err(GrantCompletionError::RequiresConsent) => {
let next = mas_router::Consent(grant_id); let next = mas_router::Consent(grant_id);
Ok((cookie_jar, next.go()).into_response()) Ok((cookie_jar, url_builder.redirect(&next)).into_response())
} }
Err(GrantCompletionError::PolicyViolation(grant, res)) => { Err(GrantCompletionError::PolicyViolation(grant, res)) => {
warn!(violation = ?res, "Authorization grant for client {} denied by policy", client.id); warn!(violation = ?res, "Authorization grant for client {} denied by policy", client.id);
@ -206,7 +210,7 @@ pub(crate) async fn complete(
mut repo: BoxRepository, mut repo: BoxRepository,
key_store: Keystore, key_store: Keystore,
mut policy: Policy, mut policy: Policy,
url_builder: UrlBuilder, url_builder: &UrlBuilder,
grant: AuthorizationGrant, grant: AuthorizationGrant,
client: &Client, client: &Client,
browser_session: &BrowserSession, browser_session: &BrowserSession,
@ -273,7 +277,7 @@ pub(crate) async fn complete(
params.id_token = Some(generate_id_token( params.id_token = Some(generate_id_token(
rng, rng,
clock, clock,
&url_builder, url_builder,
&key_store, &key_store,
client, client,
&grant, &grant,

View File

@ -21,7 +21,7 @@ use mas_axum_utils::{cookies::CookieJar, csrf::CsrfExt, sentry::SentryEventID, S
use mas_data_model::{AuthorizationCode, Pkce}; use mas_data_model::{AuthorizationCode, Pkce};
use mas_keystore::Keystore; use mas_keystore::Keystore;
use mas_policy::Policy; use mas_policy::Policy;
use mas_router::{PostAuthAction, Route, UrlBuilder}; use mas_router::{PostAuthAction, UrlBuilder};
use mas_storage::{ use mas_storage::{
oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository}, oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository},
BoxClock, BoxRepository, BoxRng, BoxClock, BoxRepository, BoxRng,
@ -313,16 +313,14 @@ pub(crate) async fn get(
// Client asked for a registration, show the registration prompt // Client asked for a registration, show the registration prompt
repo.save().await?; repo.save().await?;
mas_router::Register::and_then(continue_grant) url_builder.redirect(&mas_router::Register::and_then(continue_grant))
.go()
.into_response() .into_response()
} }
None => { None => {
// Other cases where we don't have a session, ask for a login // Other cases where we don't have a session, ask for a login
repo.save().await?; repo.save().await?;
mas_router::Login::and_then(continue_grant) url_builder.redirect(&mas_router::Login::and_then(continue_grant))
.go()
.into_response() .into_response()
} }
@ -336,8 +334,7 @@ pub(crate) async fn get(
activity_tracker.record_browser_session(&clock, &session).await; activity_tracker.record_browser_session(&clock, &session).await;
mas_router::Reauth::and_then(continue_grant) url_builder.redirect(&mas_router::Reauth::and_then(continue_grant))
.go()
.into_response() .into_response()
} }
@ -353,7 +350,7 @@ pub(crate) async fn get(
repo, repo,
key_store, key_store,
policy, policy,
url_builder, &url_builder,
grant, grant,
&client, &client,
&user_session, &user_session,
@ -403,7 +400,7 @@ pub(crate) async fn get(
repo, repo,
key_store, key_store,
policy, policy,
url_builder, &url_builder,
grant, grant,
&client, &client,
&user_session, &user_session,
@ -412,7 +409,7 @@ pub(crate) async fn get(
{ {
Ok(params) => callback_destination.go(&templates, params).await?, Ok(params) => callback_destination.go(&templates, params).await?,
Err(GrantCompletionError::RequiresConsent) => { Err(GrantCompletionError::RequiresConsent) => {
mas_router::Consent(grant_id).go().into_response() url_builder.redirect(&mas_router::Consent(grant_id)).into_response()
} }
Err(GrantCompletionError::PolicyViolation(grant, res)) => { Err(GrantCompletionError::PolicyViolation(grant, res)) => {
warn!(violation = ?res, "Authorization grant for client {} denied by policy", client.id); warn!(violation = ?res, "Authorization grant for client {} denied by policy", client.id);
@ -426,8 +423,7 @@ pub(crate) async fn get(
Html(content).into_response() Html(content).into_response()
} }
Err(GrantCompletionError::RequiresReauth) => { Err(GrantCompletionError::RequiresReauth) => {
mas_router::Reauth::and_then(continue_grant) url_builder.redirect(&mas_router::Reauth::and_then(continue_grant))
.go()
.into_response() .into_response()
} }
Err(GrantCompletionError::Internal(e)) => { Err(GrantCompletionError::Internal(e)) => {

View File

@ -25,7 +25,7 @@ use mas_axum_utils::{
}; };
use mas_data_model::{AuthorizationGrantStage, Device}; use mas_data_model::{AuthorizationGrantStage, Device};
use mas_policy::Policy; use mas_policy::Policy;
use mas_router::{PostAuthAction, Route}; use mas_router::{PostAuthAction, UrlBuilder};
use mas_storage::{ use mas_storage::{
oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository}, oauth2::{OAuth2AuthorizationGrantRepository, OAuth2ClientRepository},
BoxClock, BoxRepository, BoxRng, BoxClock, BoxRepository, BoxRng,
@ -84,6 +84,7 @@ pub(crate) async fn get(
clock: BoxClock, clock: BoxClock,
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
mut policy: Policy, mut policy: Policy,
mut repo: BoxRepository, mut repo: BoxRepository,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
@ -142,7 +143,7 @@ pub(crate) async fn get(
} }
} else { } else {
let login = mas_router::Login::and_continue_grant(grant_id); let login = mas_router::Login::and_continue_grant(grant_id);
Ok((cookie_jar, login.go()).into_response()) Ok((cookie_jar, url_builder.redirect(&login)).into_response())
} }
} }
@ -159,6 +160,7 @@ pub(crate) async fn post(
mut repo: BoxRepository, mut repo: BoxRepository,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
cookie_jar: CookieJar, cookie_jar: CookieJar,
State(url_builder): State<UrlBuilder>,
Path(grant_id): Path<Ulid>, Path(grant_id): Path<Ulid>,
Form(form): Form<ProtectedForm<()>>, Form(form): Form<ProtectedForm<()>>,
) -> Result<Response, RouteError> { ) -> Result<Response, RouteError> {
@ -177,7 +179,7 @@ pub(crate) async fn post(
let Some(session) = maybe_session else { let Some(session) = maybe_session else {
let login = mas_router::Login::and_then(next); let login = mas_router::Login::and_then(next);
return Ok((cookie_jar, login.go()).into_response()); return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
}; };
activity_tracker activity_tracker
@ -222,5 +224,5 @@ pub(crate) async fn post(
repo.save().await?; repo.save().await?;
Ok((cookie_jar, next.go_next()).into_response()) Ok((cookie_jar, next.go_next(&url_builder)).into_response())
} }

View File

@ -25,7 +25,7 @@ use mas_keystore::{Encrypter, Keystore};
use mas_oidc_client::requests::{ use mas_oidc_client::requests::{
authorization_code::AuthorizationValidationData, jose::JwtVerificationData, authorization_code::AuthorizationValidationData, jose::JwtVerificationData,
}; };
use mas_router::{Route, UrlBuilder}; use mas_router::UrlBuilder;
use mas_storage::{ use mas_storage::{
upstream_oauth2::{ upstream_oauth2::{
UpstreamOAuthLinkRepository, UpstreamOAuthProviderRepository, UpstreamOAuthLinkRepository, UpstreamOAuthProviderRepository,
@ -268,6 +268,6 @@ pub(crate) async fn get(
Ok(( Ok((
cookie_jar, cookie_jar,
mas_router::UpstreamOAuth2Link::new(link.id).go(), url_builder.redirect(&mas_router::UpstreamOAuth2Link::new(link.id)),
)) ))
} }

View File

@ -27,6 +27,7 @@ use mas_axum_utils::{
use mas_data_model::{UpstreamOAuthProviderImportPreference, User}; use mas_data_model::{UpstreamOAuthProviderImportPreference, User};
use mas_jose::jwt::Jwt; use mas_jose::jwt::Jwt;
use mas_policy::Policy; use mas_policy::Policy;
use mas_router::UrlBuilder;
use mas_storage::{ use mas_storage::{
job::{JobRepositoryExt, ProvisionUserJob}, job::{JobRepositoryExt, ProvisionUserJob},
upstream_oauth2::{UpstreamOAuthLinkRepository, UpstreamOAuthSessionRepository}, upstream_oauth2::{UpstreamOAuthLinkRepository, UpstreamOAuthSessionRepository},
@ -191,6 +192,7 @@ pub(crate) async fn get(
mut repo: BoxRepository, mut repo: BoxRepository,
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
cookie_jar: CookieJar, cookie_jar: CookieJar,
user_agent: Option<TypedHeader<headers::UserAgent>>, user_agent: Option<TypedHeader<headers::UserAgent>>,
Path(link_id): Path<Ulid>, Path(link_id): Path<Ulid>,
@ -248,7 +250,7 @@ pub(crate) async fn get(
repo.save().await?; repo.save().await?;
post_auth_action.go_next().into_response() post_auth_action.go_next(&url_builder).into_response()
} }
(Some(user_session), Some(user_id)) => { (Some(user_session), Some(user_id)) => {
@ -311,7 +313,7 @@ pub(crate) async fn get(
repo.save().await?; repo.save().await?;
post_auth_action.go_next().into_response() post_auth_action.go_next(&url_builder).into_response()
} }
(None, None) => { (None, None) => {
@ -383,6 +385,7 @@ pub(crate) async fn post(
cookie_jar: CookieJar, cookie_jar: CookieJar,
user_agent: Option<TypedHeader<headers::UserAgent>>, user_agent: Option<TypedHeader<headers::UserAgent>>,
mut policy: Policy, mut policy: Policy,
State(url_builder): State<UrlBuilder>,
Path(link_id): Path<Ulid>, Path(link_id): Path<Ulid>,
Form(form): Form<ProtectedForm<FormData>>, Form(form): Form<ProtectedForm<FormData>>,
) -> Result<impl IntoResponse, RouteError> { ) -> Result<impl IntoResponse, RouteError> {
@ -577,5 +580,5 @@ pub(crate) async fn post(
repo.save().await?; repo.save().await?;
Ok((cookie_jar, post_auth_action.go_next())) Ok((cookie_jar, post_auth_action.go_next(&url_builder)))
} }

View File

@ -22,7 +22,7 @@ use mas_axum_utils::{
FancyError, SessionInfoExt, FancyError, SessionInfoExt,
}; };
use mas_policy::Policy; use mas_policy::Policy;
use mas_router::Route; use mas_router::UrlBuilder;
use mas_storage::{ use mas_storage::{
job::{JobRepositoryExt, VerifyEmailJob}, job::{JobRepositoryExt, VerifyEmailJob},
user::UserEmailRepository, user::UserEmailRepository,
@ -44,6 +44,7 @@ pub(crate) async fn get(
clock: BoxClock, clock: BoxClock,
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
mut repo: BoxRepository, mut repo: BoxRepository,
cookie_jar: CookieJar, cookie_jar: CookieJar,
@ -55,7 +56,7 @@ pub(crate) async fn get(
let Some(session) = maybe_session else { let Some(session) = maybe_session else {
let login = mas_router::Login::default(); let login = mas_router::Login::default();
return Ok((cookie_jar, login.go()).into_response()); return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
}; };
activity_tracker activity_tracker
@ -80,6 +81,7 @@ pub(crate) async fn post(
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
mut policy: Policy, mut policy: Policy,
cookie_jar: CookieJar, cookie_jar: CookieJar,
State(url_builder): State<UrlBuilder>,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
Query(query): Query<OptionalPostAuthAction>, Query(query): Query<OptionalPostAuthAction>,
Form(form): Form<ProtectedForm<EmailForm>>, Form(form): Form<ProtectedForm<EmailForm>>,
@ -91,7 +93,7 @@ pub(crate) async fn post(
let Some(session) = maybe_session else { let Some(session) = maybe_session else {
let login = mas_router::Login::default(); let login = mas_router::Login::default();
return Ok((cookie_jar, login.go()).into_response()); return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
}; };
// XXX: we really should show human readable errors on the form here // XXX: we really should show human readable errors on the form here
@ -135,9 +137,9 @@ pub(crate) async fn post(
next next
}; };
next.go() url_builder.redirect(&next)
} else { } else {
query.go_next_or_default(&mas_router::Account::default()) query.go_next_or_default(&url_builder, &mas_router::Account::default())
}; };
repo.save().await?; repo.save().await?;

View File

@ -22,7 +22,7 @@ use mas_axum_utils::{
csrf::{CsrfExt, ProtectedForm}, csrf::{CsrfExt, ProtectedForm},
FancyError, SessionInfoExt, FancyError, SessionInfoExt,
}; };
use mas_router::Route; use mas_router::UrlBuilder;
use mas_storage::{ use mas_storage::{
job::{JobRepositoryExt, ProvisionUserJob}, job::{JobRepositoryExt, ProvisionUserJob},
user::UserEmailRepository, user::UserEmailRepository,
@ -50,6 +50,7 @@ pub(crate) async fn get(
clock: BoxClock, clock: BoxClock,
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
mut repo: BoxRepository, mut repo: BoxRepository,
Query(query): Query<OptionalPostAuthAction>, Query(query): Query<OptionalPostAuthAction>,
@ -63,7 +64,7 @@ pub(crate) async fn get(
let Some(session) = maybe_session else { let Some(session) = maybe_session else {
let login = mas_router::Login::default(); let login = mas_router::Login::default();
return Ok((cookie_jar, login.go()).into_response()); return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
}; };
activity_tracker activity_tracker
@ -79,7 +80,7 @@ pub(crate) async fn get(
if user_email.confirmed_at.is_some() { if user_email.confirmed_at.is_some() {
// This email was already verified, skip // This email was already verified, skip
let destination = query.go_next_or_default(&mas_router::Account::default()); let destination = query.go_next_or_default(&url_builder, &mas_router::Account::default());
return Ok((cookie_jar, destination).into_response()); return Ok((cookie_jar, destination).into_response());
} }
@ -103,6 +104,7 @@ pub(crate) async fn post(
clock: BoxClock, clock: BoxClock,
mut repo: BoxRepository, mut repo: BoxRepository,
cookie_jar: CookieJar, cookie_jar: CookieJar,
State(url_builder): State<UrlBuilder>,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
Query(query): Query<OptionalPostAuthAction>, Query(query): Query<OptionalPostAuthAction>,
Path(id): Path<Ulid>, Path(id): Path<Ulid>,
@ -115,7 +117,7 @@ pub(crate) async fn post(
let Some(session) = maybe_session else { let Some(session) = maybe_session else {
let login = mas_router::Login::default(); let login = mas_router::Login::default();
return Ok((cookie_jar, login.go()).into_response()); return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
}; };
let user_email = repo let user_email = repo
@ -157,6 +159,6 @@ pub(crate) async fn post(
.record_browser_session(&clock, &session) .record_browser_session(&clock, &session)
.await; .await;
let destination = query.go_next_or_default(&mas_router::Account::default()); let destination = query.go_next_or_default(&url_builder, &mas_router::Account::default());
Ok((cookie_jar, destination).into_response()) Ok((cookie_jar, destination).into_response())
} }

View File

@ -26,7 +26,7 @@ use mas_axum_utils::{
use mas_data_model::BrowserSession; use mas_data_model::BrowserSession;
use mas_i18n::DataLocale; use mas_i18n::DataLocale;
use mas_policy::Policy; use mas_policy::Policy;
use mas_router::Route; use mas_router::UrlBuilder;
use mas_storage::{ use mas_storage::{
user::{BrowserSessionRepository, UserPasswordRepository}, user::{BrowserSessionRepository, UserPasswordRepository},
BoxClock, BoxRepository, BoxRng, Clock, BoxClock, BoxRepository, BoxRng, Clock,
@ -53,12 +53,15 @@ pub(crate) async fn get(
State(templates): State<Templates>, State(templates): State<Templates>,
State(password_manager): State<PasswordManager>, State(password_manager): State<PasswordManager>,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
State(url_builder): State<UrlBuilder>,
mut repo: BoxRepository, mut repo: BoxRepository,
cookie_jar: CookieJar, cookie_jar: CookieJar,
) -> Result<Response, FancyError> { ) -> Result<Response, FancyError> {
// If the password manager is disabled, we can go back to the account page. // If the password manager is disabled, we can go back to the account page.
if !password_manager.is_enabled() { if !password_manager.is_enabled() {
return Ok(mas_router::Account::default().go().into_response()); return Ok(url_builder
.redirect(&mas_router::Account::default())
.into_response());
} }
let (session_info, cookie_jar) = cookie_jar.session_info(); let (session_info, cookie_jar) = cookie_jar.session_info();
@ -73,7 +76,7 @@ pub(crate) async fn get(
render(&mut rng, &clock, locale, templates, session, cookie_jar).await render(&mut rng, &clock, locale, templates, session, cookie_jar).await
} else { } else {
let login = mas_router::Login::and_then(mas_router::PostAuthAction::ChangePassword); let login = mas_router::Login::and_then(mas_router::PostAuthAction::ChangePassword);
Ok((cookie_jar, login.go()).into_response()) Ok((cookie_jar, url_builder.redirect(&login)).into_response())
} }
} }
@ -105,6 +108,7 @@ pub(crate) async fn post(
State(password_manager): State<PasswordManager>, State(password_manager): State<PasswordManager>,
State(templates): State<Templates>, State(templates): State<Templates>,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
State(url_builder): State<UrlBuilder>,
mut policy: Policy, mut policy: Policy,
mut repo: BoxRepository, mut repo: BoxRepository,
cookie_jar: CookieJar, cookie_jar: CookieJar,
@ -123,7 +127,7 @@ pub(crate) async fn post(
let Some(session) = maybe_session else { let Some(session) = maybe_session else {
let login = mas_router::Login::and_then(mas_router::PostAuthAction::ChangePassword); let login = mas_router::Login::and_then(mas_router::PostAuthAction::ChangePassword);
return Ok((cookie_jar, login.go()).into_response()); return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
}; };
let user_password = repo let user_password = repo

View File

@ -17,7 +17,7 @@ use axum::{
response::{Html, IntoResponse}, response::{Html, IntoResponse},
}; };
use mas_axum_utils::{cookies::CookieJar, FancyError, SessionInfoExt}; use mas_axum_utils::{cookies::CookieJar, FancyError, SessionInfoExt};
use mas_router::{PostAuthAction, Route}; use mas_router::{PostAuthAction, UrlBuilder};
use mas_storage::{BoxClock, BoxRepository}; use mas_storage::{BoxClock, BoxRepository};
use mas_templates::{AppContext, TemplateContext, Templates}; use mas_templates::{AppContext, TemplateContext, Templates};
@ -28,6 +28,7 @@ pub async fn get(
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>, State(templates): State<Templates>,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
State(url_builder): State<UrlBuilder>,
action: Option<Query<mas_router::AccountAction>>, action: Option<Query<mas_router::AccountAction>>,
mut repo: BoxRepository, mut repo: BoxRepository,
clock: BoxClock, clock: BoxClock,
@ -41,7 +42,9 @@ pub async fn get(
let Some(session) = session else { let Some(session) = session else {
return Ok(( return Ok((
cookie_jar, cookie_jar,
mas_router::Login::and_then(PostAuthAction::manage_account(action)).go(), url_builder.redirect(&mas_router::Login::and_then(
PostAuthAction::manage_account(action),
)),
) )
.into_response()); .into_response());
}; };
@ -50,7 +53,7 @@ pub async fn get(
.record_browser_session(&clock, &session) .record_browser_session(&clock, &session)
.await; .await;
let ctx = AppContext::default().with_language(locale); let ctx = AppContext::from_url_builder(&url_builder).with_language(locale);
let content = templates.render_app(&ctx)?; let content = templates.render_app(&ctx)?;
Ok((cookie_jar, Html(content)).into_response()) Ok((cookie_jar, Html(content)).into_response())

View File

@ -26,7 +26,7 @@ use mas_axum_utils::{
}; };
use mas_data_model::BrowserSession; use mas_data_model::BrowserSession;
use mas_i18n::DataLocale; use mas_i18n::DataLocale;
use mas_router::{Route, UpstreamOAuth2Authorize}; use mas_router::{UpstreamOAuth2Authorize, UrlBuilder};
use mas_storage::{ use mas_storage::{
upstream_oauth2::UpstreamOAuthProviderRepository, upstream_oauth2::UpstreamOAuthProviderRepository,
user::{BrowserSessionRepository, UserPasswordRepository, UserRepository}, user::{BrowserSessionRepository, UserPasswordRepository, UserRepository},
@ -59,6 +59,7 @@ pub(crate) async fn get(
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(password_manager): State<PasswordManager>, State(password_manager): State<PasswordManager>,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
mut repo: BoxRepository, mut repo: BoxRepository,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
Query(query): Query<OptionalPostAuthAction>, Query(query): Query<OptionalPostAuthAction>,
@ -74,7 +75,7 @@ pub(crate) async fn get(
.record_browser_session(&clock, &session) .record_browser_session(&clock, &session)
.await; .await;
let reply = query.go_next(); let reply = query.go_next(&url_builder);
return Ok((cookie_jar, reply).into_response()); return Ok((cookie_jar, reply).into_response());
}; };
@ -91,7 +92,7 @@ pub(crate) async fn get(
destination = destination.and_then(action); destination = destination.and_then(action);
}; };
return Ok((cookie_jar, destination.go()).into_response()); return Ok((cookie_jar, url_builder.redirect(&destination)).into_response());
}; };
let content = render( let content = render(
@ -117,6 +118,7 @@ pub(crate) async fn post(
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(password_manager): State<PasswordManager>, State(password_manager): State<PasswordManager>,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
mut repo: BoxRepository, mut repo: BoxRepository,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
Query(query): Query<OptionalPostAuthAction>, Query(query): Query<OptionalPostAuthAction>,
@ -185,7 +187,7 @@ pub(crate) async fn post(
.await; .await;
let cookie_jar = cookie_jar.set_session(&session_info); let cookie_jar = cookie_jar.set_session(&session_info);
let reply = query.go_next(); let reply = query.go_next(&url_builder);
Ok((cookie_jar, reply).into_response()) Ok((cookie_jar, reply).into_response())
} }
Err(e) => { Err(e) => {
@ -360,7 +362,7 @@ mod test {
let response = state.request(Request::get("/login").empty()).await; let response = state.request(Request::get("/login").empty()).await;
response.assert_status(StatusCode::SEE_OTHER); response.assert_status(StatusCode::SEE_OTHER);
response.assert_header_value(LOCATION, &first_provider_login.relative_url()); response.assert_header_value(LOCATION, &first_provider_login.path_and_query());
// Adding a second provider should show a login page with both providers // Adding a second provider should show a login page with both providers
let mut repo = state.repository().await.unwrap(); let mut repo = state.repository().await.unwrap();
@ -391,13 +393,13 @@ mod test {
.contains(&escape_html(&first_provider.issuer))); .contains(&escape_html(&first_provider.issuer)));
assert!(response assert!(response
.body() .body()
.contains(&escape_html(&first_provider_login.relative_url()))); .contains(&escape_html(&first_provider_login.path_and_query())));
assert!(response assert!(response
.body() .body()
.contains(&escape_html(&second_provider.issuer))); .contains(&escape_html(&second_provider.issuer)));
assert!(response assert!(response
.body() .body()
.contains(&escape_html(&second_provider_login.relative_url()))); .contains(&escape_html(&second_provider_login.path_and_query())));
} }
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")] #[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]

View File

@ -12,13 +12,16 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
use axum::{extract::Form, response::IntoResponse}; use axum::{
extract::{Form, State},
response::IntoResponse,
};
use mas_axum_utils::{ use mas_axum_utils::{
cookies::CookieJar, cookies::CookieJar,
csrf::{CsrfExt, ProtectedForm}, csrf::{CsrfExt, ProtectedForm},
FancyError, SessionInfoExt, FancyError, SessionInfoExt,
}; };
use mas_router::{PostAuthAction, Route}; use mas_router::{PostAuthAction, UrlBuilder};
use mas_storage::{user::BrowserSessionRepository, BoxClock, BoxRepository}; use mas_storage::{user::BrowserSessionRepository, BoxClock, BoxRepository};
use crate::BoundActivityTracker; use crate::BoundActivityTracker;
@ -28,6 +31,7 @@ pub(crate) async fn post(
clock: BoxClock, clock: BoxClock,
mut repo: BoxRepository, mut repo: BoxRepository,
cookie_jar: CookieJar, cookie_jar: CookieJar,
State(url_builder): State<UrlBuilder>,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
Form(form): Form<ProtectedForm<Option<PostAuthAction>>>, Form(form): Form<ProtectedForm<Option<PostAuthAction>>>,
) -> Result<impl IntoResponse, FancyError> { ) -> Result<impl IntoResponse, FancyError> {
@ -49,9 +53,9 @@ pub(crate) async fn post(
repo.save().await?; repo.save().await?;
let destination = if let Some(action) = form { let destination = if let Some(action) = form {
action.go_next() action.go_next(&url_builder)
} else { } else {
mas_router::Login::default().go() url_builder.redirect(&mas_router::Login::default())
}; };
Ok((cookie_jar, destination)) Ok((cookie_jar, destination))

View File

@ -23,7 +23,7 @@ use mas_axum_utils::{
csrf::{CsrfExt, ProtectedForm}, csrf::{CsrfExt, ProtectedForm},
FancyError, SessionInfoExt, FancyError, SessionInfoExt,
}; };
use mas_router::Route; use mas_router::UrlBuilder;
use mas_storage::{ use mas_storage::{
user::{BrowserSessionRepository, UserPasswordRepository}, user::{BrowserSessionRepository, UserPasswordRepository},
BoxClock, BoxRepository, BoxRng, BoxClock, BoxRepository, BoxRng,
@ -47,6 +47,7 @@ pub(crate) async fn get(
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(password_manager): State<PasswordManager>, State(password_manager): State<PasswordManager>,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
mut repo: BoxRepository, mut repo: BoxRepository,
Query(query): Query<OptionalPostAuthAction>, Query(query): Query<OptionalPostAuthAction>,
@ -54,7 +55,9 @@ pub(crate) async fn get(
) -> Result<Response, FancyError> { ) -> Result<Response, FancyError> {
if !password_manager.is_enabled() { if !password_manager.is_enabled() {
// XXX: do something better here // XXX: do something better here
return Ok(mas_router::Account::default().go().into_response()); return Ok(url_builder
.redirect(&mas_router::Account::default())
.into_response());
} }
let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng); let (csrf_token, cookie_jar) = cookie_jar.csrf_token(&clock, &mut rng);
@ -66,7 +69,7 @@ pub(crate) async fn get(
// If there is no session, redirect to the login screen, keeping the // If there is no session, redirect to the login screen, keeping the
// PostAuthAction // PostAuthAction
let login = mas_router::Login::from(query.post_auth_action); let login = mas_router::Login::from(query.post_auth_action);
return Ok((cookie_jar, login.go()).into_response()); return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
}; };
activity_tracker activity_tracker
@ -95,6 +98,7 @@ pub(crate) async fn post(
mut rng: BoxRng, mut rng: BoxRng,
clock: BoxClock, clock: BoxClock,
State(password_manager): State<PasswordManager>, State(password_manager): State<PasswordManager>,
State(url_builder): State<UrlBuilder>,
mut repo: BoxRepository, mut repo: BoxRepository,
Query(query): Query<OptionalPostAuthAction>, Query(query): Query<OptionalPostAuthAction>,
cookie_jar: CookieJar, cookie_jar: CookieJar,
@ -115,7 +119,7 @@ pub(crate) async fn post(
// If there is no session, redirect to the login screen, keeping the // If there is no session, redirect to the login screen, keeping the
// PostAuthAction // PostAuthAction
let login = mas_router::Login::from(query.post_auth_action); let login = mas_router::Login::from(query.post_auth_action);
return Ok((cookie_jar, login.go()).into_response()); return Ok((cookie_jar, url_builder.redirect(&login)).into_response());
}; };
// Load the user password // Load the user password
@ -162,6 +166,6 @@ pub(crate) async fn post(
let cookie_jar = cookie_jar.set_session(&session); let cookie_jar = cookie_jar.set_session(&session);
repo.save().await?; repo.save().await?;
let reply = query.go_next(); let reply = query.go_next(&url_builder);
Ok((cookie_jar, reply).into_response()) Ok((cookie_jar, reply).into_response())
} }

View File

@ -29,7 +29,7 @@ use mas_axum_utils::{
}; };
use mas_i18n::DataLocale; use mas_i18n::DataLocale;
use mas_policy::Policy; use mas_policy::Policy;
use mas_router::Route; use mas_router::UrlBuilder;
use mas_storage::{ use mas_storage::{
job::{JobRepositoryExt, ProvisionUserJob, VerifyEmailJob}, job::{JobRepositoryExt, ProvisionUserJob, VerifyEmailJob},
user::{BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository}, user::{BrowserSessionRepository, UserEmailRepository, UserPasswordRepository, UserRepository},
@ -64,6 +64,7 @@ pub(crate) async fn get(
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(templates): State<Templates>, State(templates): State<Templates>,
State(password_manager): State<PasswordManager>, State(password_manager): State<PasswordManager>,
State(url_builder): State<UrlBuilder>,
mut repo: BoxRepository, mut repo: BoxRepository,
Query(query): Query<OptionalPostAuthAction>, Query(query): Query<OptionalPostAuthAction>,
cookie_jar: CookieJar, cookie_jar: CookieJar,
@ -74,14 +75,14 @@ pub(crate) async fn get(
let maybe_session = session_info.load_session(&mut repo).await?; let maybe_session = session_info.load_session(&mut repo).await?;
if maybe_session.is_some() { if maybe_session.is_some() {
let reply = query.go_next(); let reply = query.go_next(&url_builder);
return Ok((cookie_jar, reply).into_response()); return Ok((cookie_jar, reply).into_response());
} }
if !password_manager.is_enabled() { if !password_manager.is_enabled() {
// If password-based login is disabled, redirect to the login page here // If password-based login is disabled, redirect to the login page here
return Ok(mas_router::Login::from(query.post_auth_action) return Ok(url_builder
.go() .redirect(&mas_router::Login::from(query.post_auth_action))
.into_response()); .into_response());
} }
@ -106,6 +107,7 @@ pub(crate) async fn post(
PreferredLanguage(locale): PreferredLanguage, PreferredLanguage(locale): PreferredLanguage,
State(password_manager): State<PasswordManager>, State(password_manager): State<PasswordManager>,
State(templates): State<Templates>, State(templates): State<Templates>,
State(url_builder): State<UrlBuilder>,
mut policy: Policy, mut policy: Policy,
mut repo: BoxRepository, mut repo: BoxRepository,
activity_tracker: BoundActivityTracker, activity_tracker: BoundActivityTracker,
@ -239,7 +241,7 @@ pub(crate) async fn post(
.await; .await;
let cookie_jar = cookie_jar.set_session(&session); let cookie_jar = cookie_jar.set_session(&session);
Ok((cookie_jar, next.go()).into_response()) Ok((cookie_jar, url_builder.redirect(&next)).into_response())
} }
async fn render( async fn render(
@ -282,12 +284,12 @@ mod tests {
state state
}; };
let request = Request::get(&*mas_router::Register::default().relative_url()).empty(); let request = Request::get(&*mas_router::Register::default().path_and_query()).empty();
let response = state.request(request).await; let response = state.request(request).await;
response.assert_status(StatusCode::SEE_OTHER); response.assert_status(StatusCode::SEE_OTHER);
response.assert_header_value(LOCATION, "/login"); response.assert_header_value(LOCATION, "/login");
let request = Request::post(&*mas_router::Register::default().relative_url()).form( let request = Request::post(&*mas_router::Register::default().path_and_query()).form(
serde_json::json!({ serde_json::json!({
"csrf": "abc", "csrf": "abc",
"username": "john", "username": "john",

View File

@ -13,7 +13,7 @@
// limitations under the License. // limitations under the License.
use anyhow::Context; use anyhow::Context;
use mas_router::{PostAuthAction, Route}; use mas_router::{PostAuthAction, Route, UrlBuilder};
use mas_storage::{ use mas_storage::{
compat::CompatSsoLoginRepository, compat::CompatSsoLoginRepository,
oauth2::OAuth2AuthorizationGrantRepository, oauth2::OAuth2AuthorizationGrantRepository,
@ -30,14 +30,19 @@ pub(crate) struct OptionalPostAuthAction {
} }
impl OptionalPostAuthAction { impl OptionalPostAuthAction {
pub fn go_next_or_default<T: Route>(&self, default: &T) -> axum::response::Redirect { pub fn go_next_or_default<T: Route>(
self.post_auth_action &self,
.as_ref() url_builder: &UrlBuilder,
.map_or_else(|| default.go(), mas_router::PostAuthAction::go_next) default: &T,
) -> axum::response::Redirect {
self.post_auth_action.as_ref().map_or_else(
|| url_builder.redirect(default),
|action| action.go_next(url_builder),
)
} }
pub fn go_next(&self) -> axum::response::Redirect { pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect {
self.go_next_or_default(&mas_router::Index) self.go_next_or_default(url_builder, &mas_router::Index)
} }
pub async fn load_context<'a>( pub async fn load_context<'a>(

View File

@ -16,6 +16,7 @@ use serde::{Deserialize, Serialize};
use ulid::Ulid; use ulid::Ulid;
pub use crate::traits::*; pub use crate::traits::*;
use crate::UrlBuilder;
#[derive(Deserialize, Serialize, Clone, Debug)] #[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(rename_all = "snake_case", tag = "kind")] #[serde(rename_all = "snake_case", tag = "kind")]
@ -57,16 +58,19 @@ impl PostAuthAction {
PostAuthAction::ManageAccount { action } PostAuthAction::ManageAccount { action }
} }
pub fn go_next(&self) -> axum::response::Redirect { pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect {
match self { match self {
Self::ContinueAuthorizationGrant { id } => ContinueAuthorizationGrant(*id).go(), Self::ContinueAuthorizationGrant { id } => {
Self::ContinueCompatSsoLogin { id } => CompatLoginSsoComplete::new(*id, None).go(), url_builder.redirect(&ContinueAuthorizationGrant(*id))
Self::ChangePassword => AccountPassword.go(),
Self::LinkUpstream { id } => UpstreamOAuth2Link::new(*id).go(),
Self::ManageAccount { action } => Account {
action: action.clone(),
} }
.go(), Self::ContinueCompatSsoLogin { id } => {
url_builder.redirect(&CompatLoginSsoComplete::new(*id, None))
}
Self::ChangePassword => url_builder.redirect(&AccountPassword),
Self::LinkUpstream { id } => url_builder.redirect(&UpstreamOAuth2Link::new(*id)),
Self::ManageAccount { action } => url_builder.redirect(&Account {
action: action.clone(),
}),
} }
} }
} }
@ -219,10 +223,10 @@ impl Login {
self.post_auth_action.as_ref() self.post_auth_action.as_ref()
} }
pub fn go_next(&self) -> axum::response::Redirect { pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect {
match &self.post_auth_action { match &self.post_auth_action {
Some(action) => action.go_next(), Some(action) => action.go_next(url_builder),
None => Index.go(), None => url_builder.redirect(&Index),
} }
} }
} }
@ -268,10 +272,10 @@ impl Reauth {
self.post_auth_action.as_ref() self.post_auth_action.as_ref()
} }
pub fn go_next(&self) -> axum::response::Redirect { pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect {
match &self.post_auth_action { match &self.post_auth_action {
Some(action) => action.go_next(), Some(action) => action.go_next(url_builder),
None => Index.go(), None => url_builder.redirect(&Index),
} }
} }
} }
@ -328,10 +332,10 @@ impl Register {
self.post_auth_action.as_ref() self.post_auth_action.as_ref()
} }
pub fn go_next(&self) -> axum::response::Redirect { pub fn go_next(&self, url_builder: &UrlBuilder) -> axum::response::Redirect {
match &self.post_auth_action { match &self.post_auth_action {
Some(action) => action.go_next(), Some(action) => action.go_next(url_builder),
None => Index.go(), None => url_builder.redirect(&Index),
} }
} }
} }

View File

@ -38,12 +38,12 @@ mod tests {
#[test] #[test]
fn test_relative_urls() { fn test_relative_urls() {
assert_eq!( assert_eq!(
OidcConfiguration.relative_url(), OidcConfiguration.path_and_query(),
Cow::Borrowed("/.well-known/openid-configuration") Cow::Borrowed("/.well-known/openid-configuration")
); );
assert_eq!(Index.relative_url(), Cow::Borrowed("/")); assert_eq!(Index.path_and_query(), Cow::Borrowed("/"));
assert_eq!( assert_eq!(
Login::and_continue_grant(Ulid::nil()).relative_url(), Login::and_continue_grant(Ulid::nil()).path_and_query(),
Cow::Borrowed("/login?kind=continue_authorization_grant&id=00000000000000000000000000") Cow::Borrowed("/login?kind=continue_authorization_grant&id=00000000000000000000000000")
); );
} }

View File

@ -28,7 +28,7 @@ pub trait Route {
Cow::Borrowed(Self::route()) Cow::Borrowed(Self::route())
} }
fn relative_url(&self) -> Cow<'static, str> { fn path_and_query(&self) -> Cow<'static, str> {
let path = self.path(); let path = self.path();
if let Some(query) = self.query() { if let Some(query) = self.query() {
let query = serde_urlencoded::to_string(query).unwrap(); let query = serde_urlencoded::to_string(query).unwrap();
@ -39,17 +39,10 @@ pub trait Route {
} }
fn absolute_url(&self, base: &Url) -> Url { fn absolute_url(&self, base: &Url) -> Url {
let relative = self.relative_url(); let relative = self.path_and_query();
let relative = relative.trim_start_matches('/');
base.join(relative.borrow()).unwrap() base.join(relative.borrow()).unwrap()
} }
fn go(&self) -> axum::response::Redirect {
axum::response::Redirect::to(&self.relative_url())
}
fn go_absolute(&self, base: &Url) -> axum::response::Redirect {
axum::response::Redirect::to(self.absolute_url(base).as_str())
}
} }
pub trait SimpleRoute { pub trait SimpleRoute {

View File

@ -14,8 +14,6 @@
//! Utility to build URLs //! Utility to build URLs
use std::borrow::Cow;
use ulid::Ulid; use ulid::Ulid;
use url::Url; use url::Url;
@ -24,32 +22,91 @@ use crate::traits::Route;
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct UrlBuilder { pub struct UrlBuilder {
http_base: Url, http_base: Url,
assets_base: Cow<'static, str>, prefix: String,
assets_base: String,
issuer: Url, issuer: Url,
} }
impl UrlBuilder { impl UrlBuilder {
fn url_for<U>(&self, destination: &U) -> Url fn absolute_url_for<U>(&self, destination: &U) -> Url
where where
U: Route, U: Route,
{ {
destination.absolute_url(&self.http_base) destination.absolute_url(&self.http_base)
} }
/// Create a relative URL for a route, prefixed with the base URL
#[must_use]
pub fn relative_url_for<U>(&self, destination: &U) -> String
where
U: Route,
{
format!(
"{prefix}{destination}",
prefix = self.prefix,
destination = destination.path_and_query()
)
}
/// The prefix added to all relative URLs
#[must_use]
pub fn prefix(&self) -> Option<&str> {
if self.prefix.is_empty() {
None
} else {
Some(&self.prefix)
}
}
/// Create a (relative) redirect response to a route
pub fn redirect<U>(&self, destination: &U) -> axum::response::Redirect
where
U: Route,
{
let uri = self.relative_url_for(destination);
axum::response::Redirect::to(&uri)
}
/// Create an absolute redirect response to a route
pub fn absolute_redirect<U>(&self, destination: &U) -> axum::response::Redirect pub fn absolute_redirect<U>(&self, destination: &U) -> axum::response::Redirect
where where
U: Route, U: Route,
{ {
destination.go_absolute(&self.http_base) let uri = self.absolute_url_for(destination);
axum::response::Redirect::to(uri.as_str())
} }
/// Create a new [`UrlBuilder`] from a base URL /// Create a new [`UrlBuilder`] from a base URL
///
/// # Panics
///
/// Panics if the base URL contains a fragment, a query, credentials or
/// isn't HTTP/HTTPS;
#[must_use] #[must_use]
pub fn new(base: Url, issuer: Option<Url>, assets_base: Option<String>) -> Self { pub fn new(base: Url, issuer: Option<Url>, assets_base: Option<String>) -> Self {
assert!(
base.scheme() == "http" || base.scheme() == "https",
"base URL must be HTTP/HTTPS"
);
assert_eq!(base.query(), None, "base URL must not contain a query");
assert_eq!(
base.fragment(),
None,
"base URL must not contain a fragment"
);
assert_eq!(base.username(), "", "base URL must not contain credentials");
assert_eq!(
base.password(),
None,
"base URL must not contain credentials"
);
let issuer = issuer.unwrap_or_else(|| base.clone()); let issuer = issuer.unwrap_or_else(|| base.clone());
let assets_base = assets_base.map_or(Cow::Borrowed("/assets/"), Cow::Owned); let prefix = base.path().trim_end_matches('/').to_owned();
let assets_base = assets_base.unwrap_or_else(|| format!("{prefix}/assets/"));
Self { Self {
http_base: base, http_base: base,
prefix,
assets_base, assets_base,
issuer, issuer,
} }
@ -70,49 +127,49 @@ impl UrlBuilder {
/// OAuth 2.0 authorization endpoint /// OAuth 2.0 authorization endpoint
#[must_use] #[must_use]
pub fn oauth_authorization_endpoint(&self) -> Url { pub fn oauth_authorization_endpoint(&self) -> Url {
self.url_for(&crate::endpoints::OAuth2AuthorizationEndpoint) self.absolute_url_for(&crate::endpoints::OAuth2AuthorizationEndpoint)
} }
/// OAuth 2.0 token endpoint /// OAuth 2.0 token endpoint
#[must_use] #[must_use]
pub fn oauth_token_endpoint(&self) -> Url { pub fn oauth_token_endpoint(&self) -> Url {
self.url_for(&crate::endpoints::OAuth2TokenEndpoint) self.absolute_url_for(&crate::endpoints::OAuth2TokenEndpoint)
} }
/// OAuth 2.0 introspection endpoint /// OAuth 2.0 introspection endpoint
#[must_use] #[must_use]
pub fn oauth_introspection_endpoint(&self) -> Url { pub fn oauth_introspection_endpoint(&self) -> Url {
self.url_for(&crate::endpoints::OAuth2Introspection) self.absolute_url_for(&crate::endpoints::OAuth2Introspection)
} }
/// OAuth 2.0 revocation endpoint /// OAuth 2.0 revocation endpoint
#[must_use] #[must_use]
pub fn oauth_revocation_endpoint(&self) -> Url { pub fn oauth_revocation_endpoint(&self) -> Url {
self.url_for(&crate::endpoints::OAuth2Revocation) self.absolute_url_for(&crate::endpoints::OAuth2Revocation)
} }
/// OAuth 2.0 client registration endpoint /// OAuth 2.0 client registration endpoint
#[must_use] #[must_use]
pub fn oauth_registration_endpoint(&self) -> Url { pub fn oauth_registration_endpoint(&self) -> Url {
self.url_for(&crate::endpoints::OAuth2RegistrationEndpoint) self.absolute_url_for(&crate::endpoints::OAuth2RegistrationEndpoint)
} }
// OIDC userinfo endpoint // OIDC userinfo endpoint
#[must_use] #[must_use]
pub fn oidc_userinfo_endpoint(&self) -> Url { pub fn oidc_userinfo_endpoint(&self) -> Url {
self.url_for(&crate::endpoints::OidcUserinfo) self.absolute_url_for(&crate::endpoints::OidcUserinfo)
} }
/// JWKS URI /// JWKS URI
#[must_use] #[must_use]
pub fn jwks_uri(&self) -> Url { pub fn jwks_uri(&self) -> Url {
self.url_for(&crate::endpoints::OAuth2Keys) self.absolute_url_for(&crate::endpoints::OAuth2Keys)
} }
/// Static asset /// Static asset
#[must_use] #[must_use]
pub fn static_asset(&self, path: String) -> Url { pub fn static_asset(&self, path: String) -> Url {
self.url_for(&crate::endpoints::StaticAsset::new(path)) self.absolute_url_for(&crate::endpoints::StaticAsset::new(path))
} }
/// Static asset base /// Static asset base
@ -124,18 +181,83 @@ impl UrlBuilder {
/// GraphQL endpoint /// GraphQL endpoint
#[must_use] #[must_use]
pub fn graphql_endpoint(&self) -> Url { pub fn graphql_endpoint(&self) -> Url {
self.url_for(&crate::endpoints::GraphQL) self.absolute_url_for(&crate::endpoints::GraphQL)
} }
/// Upstream redirect URI /// Upstream redirect URI
#[must_use] #[must_use]
pub fn upstream_oauth_callback(&self, id: Ulid) -> Url { pub fn upstream_oauth_callback(&self, id: Ulid) -> Url {
self.url_for(&crate::endpoints::UpstreamOAuth2Callback::new(id)) self.absolute_url_for(&crate::endpoints::UpstreamOAuth2Callback::new(id))
} }
/// Upstream authorize URI /// Upstream authorize URI
#[must_use] #[must_use]
pub fn upstream_oauth_authorize(&self, id: Ulid) -> Url { pub fn upstream_oauth_authorize(&self, id: Ulid) -> Url {
self.url_for(&crate::endpoints::UpstreamOAuth2Authorize::new(id)) self.absolute_url_for(&crate::endpoints::UpstreamOAuth2Authorize::new(id))
}
}
#[cfg(test)]
mod tests {
#[test]
#[should_panic]
fn test_invalid_base_url_scheme() {
let _ = super::UrlBuilder::new(url::Url::parse("file:///tmp/").unwrap(), None, None);
}
#[test]
#[should_panic]
fn test_invalid_base_url_query() {
let _ = super::UrlBuilder::new(
url::Url::parse("https://example.com/?foo=bar").unwrap(),
None,
None,
);
}
#[test]
#[should_panic]
fn test_invalid_base_url_fragment() {
let _ = super::UrlBuilder::new(
url::Url::parse("https://example.com/#foo").unwrap(),
None,
None,
);
}
#[test]
#[should_panic]
fn test_invalid_base_url_credentials() {
let _ = super::UrlBuilder::new(
url::Url::parse("https://foo@example.com/").unwrap(),
None,
None,
);
}
#[test]
fn test_url_prefix() {
let builder = super::UrlBuilder::new(
url::Url::parse("https://example.com/foo/").unwrap(),
None,
None,
);
assert_eq!(builder.prefix, "/foo");
let builder =
super::UrlBuilder::new(url::Url::parse("https://example.com/").unwrap(), None, None);
assert_eq!(builder.prefix, "");
}
#[test]
fn test_absolute_uri_prefix() {
let builder = super::UrlBuilder::new(
url::Url::parse("https://example.com/foo/").unwrap(),
None,
None,
);
let uri = builder.absolute_url_for(&crate::endpoints::OAuth2AuthorizationEndpoint);
assert_eq!(uri.as_str(), "https://example.com/foo/authorize");
} }
} }

View File

@ -23,7 +23,7 @@ use mas_data_model::{
UpstreamOAuthLink, UpstreamOAuthProvider, User, UserEmail, UserEmailVerification, UpstreamOAuthLink, UpstreamOAuthProvider, User, UserEmail, UserEmailVerification,
}; };
use mas_i18n::DataLocale; use mas_i18n::DataLocale;
use mas_router::{PostAuthAction, Route}; use mas_router::{Account, GraphQL, PostAuthAction, Route, UrlBuilder};
use rand::Rng; use rand::Rng;
use serde::{ser::SerializeStruct, Deserialize, Serialize}; use serde::{ser::SerializeStruct, Deserialize, Serialize};
use ulid::Ulid; use ulid::Ulid;
@ -276,30 +276,29 @@ impl TemplateContext for IndexContext {
/// Config used by the frontend app /// Config used by the frontend app
#[derive(Serialize)] #[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AppConfig { pub struct AppConfig {
root: String, root: String,
} graphql_endpoint: String,
impl Default for AppConfig {
fn default() -> Self {
Self {
root: "/account/".into(),
}
}
} }
/// Context used by the `app.html` template /// Context used by the `app.html` template
#[derive(Serialize, Default)] #[derive(Serialize)]
pub struct AppContext { pub struct AppContext {
app_config: AppConfig, app_config: AppConfig,
} }
impl AppContext { impl AppContext {
/// Constructs the context for the app page with the given app root /// Constructs the context given the [`UrlBuilder`]
#[must_use] #[must_use]
pub fn with_app_root(root: String) -> Self { pub fn from_url_builder(url_builder: &UrlBuilder) -> Self {
let root = url_builder.relative_url_for(&Account::default());
let graphql_endpoint = url_builder.relative_url_for(&GraphQL);
Self { Self {
app_config: AppConfig { root }, app_config: AppConfig {
root,
graphql_endpoint,
},
} }
} }
} }
@ -309,7 +308,8 @@ impl TemplateContext for AppContext {
where where
Self: Sized, Self: Sized,
{ {
vec![Self::default()] let url_builder = UrlBuilder::new("https://example.com/".parse().unwrap(), None, None);
vec![Self::from_url_builder(&url_builder)]
} }
} }
@ -935,7 +935,7 @@ impl UpstreamRegister {
fn for_link_id(id: Ulid) -> Self { fn for_link_id(id: Ulid) -> Self {
let login_link = mas_router::Login::and_link_upstream(id) let login_link = mas_router::Login::and_link_upstream(id)
.relative_url() .path_and_query()
.into(); .into();
Self { Self {

View File

@ -52,7 +52,7 @@ pub fn register(
env.add_global( env.add_global(
"include_asset", "include_asset",
Value::from_object(IncludeAsset { Value::from_object(IncludeAsset {
url_builder, url_builder: url_builder.clone(),
vite_manifest, vite_manifest,
}), }),
); );
@ -60,6 +60,19 @@ pub fn register(
"translator", "translator",
Value::from_object(TranslatorFunc { translator }), Value::from_object(TranslatorFunc { translator }),
); );
env.add_filter("prefix_url", move |url: &str| -> String {
if !url.starts_with('/') {
// Let's assume it's not an internal URL and return it as-is
return url.to_owned();
}
let Some(prefix) = url_builder.prefix() else {
// If there is no prefix to add, return the URL as-is
return url.to_owned();
};
format!("{prefix}{url}")
});
} }
fn tester_empty(seq: &dyn SeqObject) -> bool { fn tester_empty(seq: &dyn SeqObject) -> bool {

View File

@ -1278,6 +1278,10 @@
"description": "A unique name for this listener which will be shown in traces and in metrics labels", "description": "A unique name for this listener which will be shown in traces and in metrics labels",
"type": "string" "type": "string"
}, },
"prefix": {
"description": "HTTP prefix to mount the resources on",
"type": "string"
},
"proxy_protocol": { "proxy_protocol": {
"description": "Accept HAProxy's Proxy Protocol V1", "description": "Accept HAProxy's Proxy Protocol V1",
"default": false, "default": false,

View File

@ -23,7 +23,7 @@ limitations under the License.
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>matrix-authentication-service</title> <title>matrix-authentication-service</title>
<script type="application/javascript"> <script type="application/javascript">
window.APP_CONFIG = JSON.parse('{"root": "/account/"}'); window.APP_CONFIG = JSON.parse('{"root": "/account/", "graphqlEndpoint": "/graphql"}');
(function () { (function () {
const query = window.matchMedia("(prefers-color-scheme: dark)"); const query = window.matchMedia("(prefers-color-scheme: dark)");
function handleChange(list) { function handleChange(list) {

View File

@ -38,7 +38,7 @@ const HydrateLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
path, path,
}) => { }) => {
useHydrateAtoms([ useHydrateAtoms([
[appConfigAtom, { root: "/" }], [appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
[locationAtom, { pathname: path }], [locationAtom, { pathname: path }],
]); ]);
return <>{children}</>; return <>{children}</>;

View File

@ -40,7 +40,7 @@ const meta = {
const WithHomePage: React.FC<React.PropsWithChildren<{}>> = ({ children }) => { const WithHomePage: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
useHydrateAtoms([ useHydrateAtoms([
[appConfigAtom, { root: "/" }], [appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
[locationAtom, { pathname: "/" }], [locationAtom, { pathname: "/" }],
]); ]);
return <>{children}</>; return <>{children}</>;

View File

@ -35,7 +35,7 @@ const meta = {
const WithHomePage: React.FC<React.PropsWithChildren<{}>> = ({ children }) => { const WithHomePage: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
useHydrateAtoms([ useHydrateAtoms([
[appConfigAtom, { root: "/" }], [appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
[locationAtom, { pathname: "/" }], [locationAtom, { pathname: "/" }],
]); ]);
return <>{children}</>; return <>{children}</>;

View File

@ -38,7 +38,7 @@ const HydrateLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
path, path,
}) => { }) => {
useHydrateAtoms([ useHydrateAtoms([
[appConfigAtom, { root: "/" }], [appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
[locationAtom, { pathname: path }], [locationAtom, { pathname: path }],
]); ]);
return <>{children}</>; return <>{children}</>;

View File

@ -27,7 +27,7 @@ type Props = {
const WithHomePage: React.FC<React.PropsWithChildren<{}>> = ({ children }) => { const WithHomePage: React.FC<React.PropsWithChildren<{}>> = ({ children }) => {
useHydrateAtoms([ useHydrateAtoms([
[appConfigAtom, { root: "/" }], [appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
[locationAtom, { pathname: "/" }], [locationAtom, { pathname: "/" }],
]); ]);
return <>{children}</>; return <>{children}</>;

View File

@ -12,10 +12,16 @@
// See the License for the specific language governing permissions and // See the License for the specific language governing permissions and
// limitations under the License. // limitations under the License.
type AppConfig = { export type AppConfig = {
root: string; root: string;
graphqlEndpoint: string;
}; };
interface Window { interface IWindow {
APP_CONFIG: AppConfig; APP_CONFIG?: AppConfig;
} }
const config: AppConfig = (typeof window !== "undefined" &&
(window as IWindow).APP_CONFIG) || { root: "/", graphqlEndpoint: "/graphql" };
export default config;

View File

@ -18,6 +18,7 @@ import { cacheExchange } from "@urql/exchange-graphcache";
import { refocusExchange } from "@urql/exchange-refocus"; import { refocusExchange } from "@urql/exchange-refocus";
import { requestPolicyExchange } from "@urql/exchange-request-policy"; import { requestPolicyExchange } from "@urql/exchange-request-policy";
import appConfig from "./config";
import type { import type {
MutationAddEmailArgs, MutationAddEmailArgs,
MutationRemoveEmailArgs, MutationRemoveEmailArgs,
@ -130,7 +131,7 @@ const exchanges = [
]; ];
export const client = createClient({ export const client = createClient({
url: "/graphql", url: appConfig.graphqlEndpoint,
// Add the devtools exchange in development // Add the devtools exchange in development
exchanges: import.meta.env.DEV ? [devtoolsExchange, ...exchanges] : exchanges, exchanges: import.meta.env.DEV ? [devtoolsExchange, ...exchanges] : exchanges,
}); });

View File

@ -15,11 +15,11 @@
import { atom } from "jotai"; import { atom } from "jotai";
import { atomWithLocation } from "jotai-location"; import { atomWithLocation } from "jotai-location";
import appConfig, { AppConfig } from "../config";
import { Location, pathToRoute, Route, routeToPath } from "./routes"; import { Location, pathToRoute, Route, routeToPath } from "./routes";
export const appConfigAtom = atom<AppConfig>( export const appConfigAtom = atom<AppConfig>(appConfig);
typeof window !== "undefined" ? window.APP_CONFIG : { root: "/" },
);
const locationToRoute = (root: string, location: Location): Route => { const locationToRoute = (root: string, location: Location): Route => {
if (!location.pathname || !location.pathname.startsWith(root)) { if (!location.pathname || !location.pathname.startsWith(root)) {

View File

@ -24,7 +24,7 @@ const HydrateLocation: React.FC<React.PropsWithChildren<{ path: string }>> = ({
path, path,
}) => { }) => {
useHydrateAtoms([ useHydrateAtoms([
[appConfigAtom, { root: "/" }], [appConfigAtom, { root: "/", graphqlEndpoint: "/graphql" }],
[locationAtom, { pathname: path }], [locationAtom, { pathname: path }],
]); ]);
return <>{children}</>; return <>{children}</>;

View File

@ -15,15 +15,15 @@ limitations under the License.
#} #}
{% macro link(text, href="#", class="") %} {% macro link(text, href="#", class="") %}
<a class="cpd-button {{ class }}" data-kind="primary" data-size="lg" href="{{ href }}">{{ text }}</a> <a class="cpd-button {{ class }}" data-kind="primary" data-size="lg" href="{{ href | prefix_url }}">{{ text }}</a>
{% endmacro %} {% endmacro %}
{% macro link_text(text, href="#", class="") %} {% macro link_text(text, href="#", class="") %}
<a class="cpd-link {{ class }}" data-kind="primary" href="{{ href }}">{{ text }}</a> <a class="cpd-link {{ class }}" data-kind="primary" href="{{ href | prefix_url }}">{{ text }}</a>
{% endmacro %} {% endmacro %}
{% macro link_outline(text, href="#", class="") %} {% macro link_outline(text, href="#", class="") %}
<a class="cpd-button {{ class }}" data-kind="secondary" data-size="lg" href="{{ href }}">{{ text }}</a> <a class="cpd-button {{ class }}" data-kind="secondary" data-size="lg" href="{{ href | prefix_url }}">{{ text }}</a>
{% endmacro %} {% endmacro %}
{% macro button( {% macro button(

View File

@ -15,7 +15,7 @@ limitations under the License.
#} #}
{% macro button(text, csrf_token, as_link=false, post_logout_action={}) %} {% macro button(text, csrf_token, as_link=false, post_logout_action={}) %}
<form method="POST" action="/logout" class="inline"> <form method="POST" action="{{ "/logout" | prefix_url }}" class="inline">
<input type="hidden" name="csrf" value="{{ csrf_token }}" /> <input type="hidden" name="csrf" value="{{ csrf_token }}" />
{% for key, value in post_logout_action|items %} {% for key, value in post_logout_action|items %}
<input type="hidden" name="{{ key }}" value="{{ value }}" /> <input type="hidden" name="{{ key }}" value="{{ value }}" />

View File

@ -22,7 +22,7 @@ limitations under the License.
<h1 class="text-xl font-semibold">{{ _("mas.not_found.heading") }}</h1> <h1 class="text-xl font-semibold">{{ _("mas.not_found.heading") }}</h1>
<p>{{ _("mas.not_found.description") }}</p> <p>{{ _("mas.not_found.description") }}</p>
<div> <div>
<a class="cpd-link" data-kind="primary" href="/">{{ _("mas.back_to_homepage") }}</a> {{ button.link_text(text=_("mas.back_to_homepage"), href="/") }}
</div> </div>
<hr /> <hr />

View File

@ -77,7 +77,7 @@
}, },
"back_to_homepage": "Go back to the homepage", "back_to_homepage": "Go back to the homepage",
"@back_to_homepage": { "@back_to_homepage": {
"context": "pages/404.html:25:64-89" "context": "pages/404.html:25:37-62"
}, },
"change_password": { "change_password": {
"change": "Change password", "change": "Change password",