1
0
mirror of https://github.com/matrix-org/matrix-authentication-service.git synced 2025-08-09 04:22:45 +03:00

Link between login & register + "back to client" link

This commit is contained in:
Quentin Gliech
2021-12-14 10:29:19 +01:00
parent daf5542e6d
commit 5d7619827b
16 changed files with 313 additions and 47 deletions

1
Cargo.lock generated
View File

@@ -1605,6 +1605,7 @@ dependencies = [
"oauth2-types", "oauth2-types",
"serde", "serde",
"serde_json", "serde_json",
"serde_urlencoded",
"tera", "tera",
"thiserror", "thiserror",
"tokio", "tokio",

View File

@@ -441,7 +441,7 @@ async fn get(
} }
} }
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize, Clone)]
pub(crate) struct ContinueAuthorizationGrant<S: StorageBackend> { pub(crate) struct ContinueAuthorizationGrant<S: StorageBackend> {
#[serde( #[serde(
with = "serde_with::rust::display_fromstr", with = "serde_with::rust::display_fromstr",

View File

@@ -20,7 +20,7 @@ use serde::Deserialize;
use sqlx::{pool::PoolConnection, PgPool, Postgres}; use sqlx::{pool::PoolConnection, PgPool, Postgres};
use warp::{reply::html, Filter, Rejection, Reply}; use warp::{reply::html, Filter, Rejection, Reply};
use super::shared::PostAuthAction; use super::{shared::PostAuthAction, RegisterRequest};
use crate::{ use crate::{
errors::WrapError, errors::WrapError,
filters::{ filters::{
@@ -130,8 +130,12 @@ async fn get(
let ctx = LoginContext::default(); let ctx = LoginContext::default();
let ctx = match query.post_auth_action { let ctx = match query.post_auth_action {
Some(next) => { Some(next) => {
let register_link = RegisterRequest::from(next.clone())
.build_uri()
.wrap_error()?;
let next = next.load_context(&mut conn).await.wrap_error()?; let next = next.load_context(&mut conn).await.wrap_error()?;
ctx.with_post_action(next) ctx.with_post_action(next)
.with_register_link(register_link.to_string())
} }
None => ctx, None => ctx,
}; };

View File

@@ -28,7 +28,9 @@ use self::{
index::filter as index, login::filter as login, logout::filter as logout, index::filter as index, login::filter as login, logout::filter as logout,
reauth::filter as reauth, register::filter as register, reauth::filter as reauth, register::filter as register,
}; };
pub(crate) use self::{login::LoginRequest, reauth::ReauthRequest, shared::PostAuthAction}; pub(crate) use self::{
login::LoginRequest, reauth::ReauthRequest, register::RegisterRequest, shared::PostAuthAction,
};
pub(super) fn filter( pub(super) fn filter(
pool: &PgPool, pool: &PgPool,

View File

@@ -15,12 +15,13 @@
use argon2::Argon2; use argon2::Argon2;
use hyper::http::uri::{Parts, PathAndQuery, Uri}; use hyper::http::uri::{Parts, PathAndQuery, Uri};
use mas_config::{CookiesConfig, CsrfConfig}; use mas_config::{CookiesConfig, CsrfConfig};
use mas_data_model::BrowserSession; use mas_data_model::{BrowserSession, StorageBackend};
use mas_templates::{EmptyContext, TemplateContext, Templates}; use mas_templates::{RegisterContext, TemplateContext, Templates};
use serde::{Deserialize, Serialize}; use serde::Deserialize;
use sqlx::{pool::PoolConnection, PgPool, Postgres}; use sqlx::{pool::PoolConnection, PgPool, Postgres};
use warp::{reply::html, Filter, Rejection, Reply}; use warp::{reply::html, Filter, Rejection, Reply};
use super::{LoginRequest, PostAuthAction};
use crate::{ use crate::{
errors::WrapError, errors::WrapError,
filters::{ filters::{
@@ -33,21 +34,34 @@ use crate::{
storage::{register_user, user::start_session, PostgresqlBackend}, storage::{register_user, user::start_session, PostgresqlBackend},
}; };
#[derive(Serialize, Deserialize)] #[derive(Deserialize)]
pub struct RegisterRequest { #[serde(bound(deserialize = "S::AuthorizationGrantData: std::str::FromStr,
next: Option<String>, <S::AuthorizationGrantData as std::str::FromStr>::Err: std::fmt::Display"))]
pub struct RegisterRequest<S: StorageBackend> {
#[serde(flatten)]
post_auth_action: Option<PostAuthAction<S>>,
} }
impl RegisterRequest { impl<S: StorageBackend> From<PostAuthAction<S>> for RegisterRequest<S> {
#[allow(dead_code)] fn from(post_auth_action: PostAuthAction<S>) -> Self {
pub fn new(next: Option<String>) -> Self { Self {
Self { next } post_auth_action: Some(post_auth_action),
} }
}
}
impl<S: StorageBackend> RegisterRequest<S> {
#[allow(dead_code)] #[allow(dead_code)]
pub fn build_uri(&self) -> anyhow::Result<Uri> { pub fn build_uri(&self) -> anyhow::Result<Uri>
let qs = serde_urlencoded::to_string(self)?; where
let path_and_query = PathAndQuery::try_from(format!("/register?{}", qs))?; S::AuthorizationGrantData: std::fmt::Display,
{
let path_and_query = if let Some(next) = &self.post_auth_action {
let qs = serde_urlencoded::to_string(next)?;
PathAndQuery::try_from(format!("/register?{}", qs))?
} else {
PathAndQuery::from_static("/register")
};
let uri = Uri::from_parts({ let uri = Uri::from_parts({
let mut parts = Parts::default(); let mut parts = Parts::default();
parts.path_and_query = Some(path_and_query); parts.path_and_query = Some(path_and_query);
@@ -56,19 +70,17 @@ impl RegisterRequest {
Ok(uri) Ok(uri)
} }
fn redirect(self) -> Result<impl Reply, Rejection> { fn redirect(self) -> Result<impl Reply, Rejection>
let uri: Uri = Uri::from_parts({ where
let mut parts = Parts::default(); S::AuthorizationGrantData: std::fmt::Display,
parts.path_and_query = Some( {
self.next let uri = self
.map(warp::http::uri::PathAndQuery::try_from) .post_auth_action
.as_ref()
.map(PostAuthAction::build_uri)
.transpose() .transpose()
.wrap_error()? .wrap_error()?
.unwrap_or_else(|| PathAndQuery::from_static("/")), .unwrap_or_else(|| Uri::from_static("/"));
);
parts
})
.wrap_error()?;
Ok(warp::redirect::see_other(uri)) Ok(warp::redirect::see_other(uri))
} }
} }
@@ -88,6 +100,7 @@ pub(super) fn filter(
) -> impl Filter<Extract = (impl Reply,), Error = Rejection> + Clone + Send + Sync + 'static { ) -> impl Filter<Extract = (impl Reply,), Error = Rejection> + Clone + Send + Sync + 'static {
let get = warp::get() let get = warp::get()
.and(with_templates(templates)) .and(with_templates(templates))
.and(connection(pool))
.and(encrypted_cookie_saver(cookies_config)) .and(encrypted_cookie_saver(cookies_config))
.and(updated_csrf_token(cookies_config, csrf_config)) .and(updated_csrf_token(cookies_config, csrf_config))
.and(warp::query()) .and(warp::query())
@@ -106,15 +119,26 @@ pub(super) fn filter(
async fn get( async fn get(
templates: Templates, templates: Templates,
mut conn: PoolConnection<Postgres>,
cookie_saver: EncryptedCookieSaver, cookie_saver: EncryptedCookieSaver,
csrf_token: CsrfToken, csrf_token: CsrfToken,
query: RegisterRequest, query: RegisterRequest<PostgresqlBackend>,
maybe_session: Option<BrowserSession<PostgresqlBackend>>, maybe_session: Option<BrowserSession<PostgresqlBackend>>,
) -> Result<Box<dyn Reply>, Rejection> { ) -> Result<Box<dyn Reply>, Rejection> {
if maybe_session.is_some() { if maybe_session.is_some() {
Ok(Box::new(query.redirect()?)) Ok(Box::new(query.redirect()?))
} else { } else {
let ctx = EmptyContext.with_csrf(csrf_token.form_value()); let ctx = RegisterContext::default();
let ctx = match query.post_auth_action {
Some(next) => {
let login_link = LoginRequest::from(next.clone()).build_uri().wrap_error()?;
let next = next.load_context(&mut conn).await.wrap_error()?;
ctx.with_post_action(next)
.with_login_link(login_link.to_string())
}
None => ctx,
};
let ctx = ctx.with_csrf(csrf_token.form_value());
let content = templates.render_register(&ctx).await?; let content = templates.render_register(&ctx).await?;
let reply = html(content); let reply = html(content);
let reply = cookie_saver.save_encrypted(&csrf_token, reply)?; let reply = cookie_saver.save_encrypted(&csrf_token, reply)?;
@@ -126,8 +150,9 @@ async fn post(
mut conn: PoolConnection<Postgres>, mut conn: PoolConnection<Postgres>,
cookie_saver: EncryptedCookieSaver, cookie_saver: EncryptedCookieSaver,
form: RegisterForm, form: RegisterForm,
query: RegisterRequest, query: RegisterRequest<PostgresqlBackend>,
) -> Result<impl Reply, Rejection> { ) -> Result<impl Reply, Rejection> {
// TODO: display nice form errors
if form.password != form.password_confirm { if form.password != form.password_confirm {
return Err(anyhow::anyhow!("password mismatch")).wrap_error(); return Err(anyhow::anyhow!("password mismatch")).wrap_error();
} }

View File

@@ -21,7 +21,7 @@ use sqlx::PgExecutor;
use super::super::oauth2::ContinueAuthorizationGrant; use super::super::oauth2::ContinueAuthorizationGrant;
use crate::storage::PostgresqlBackend; use crate::storage::PostgresqlBackend;
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize, Clone)]
#[serde(rename_all = "snake_case", tag = "next")] #[serde(rename_all = "snake_case", tag = "next")]
pub(crate) enum PostAuthAction<S: StorageBackend> { pub(crate) enum PostAuthAction<S: StorageBackend> {
#[serde(bound( #[serde(bound(

View File

@@ -18,6 +18,7 @@ thiserror = "1.0.30"
tera = "1.15.0" tera = "1.15.0"
serde = { version = "1.0.131", features = ["derive"] } serde = { version = "1.0.131", features = ["derive"] }
serde_json = "1.0.72" serde_json = "1.0.72"
serde_urlencoded = "0.7.0"
url = "2.2.2" url = "2.2.2"
warp = "0.3.2" warp = "0.3.2"

View File

@@ -204,7 +204,7 @@ impl TemplateContext for IndexContext {
} }
#[derive(Serialize, Debug, Clone, Copy, Hash, PartialEq, Eq)] #[derive(Serialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "kebab-case")] #[serde(rename_all = "snake_case")]
pub enum LoginFormField { pub enum LoginFormField {
Username, Username,
Password, Password,
@@ -222,6 +222,7 @@ pub enum PostAuthContext {
pub struct LoginContext { pub struct LoginContext {
form: ErroredForm<LoginFormField>, form: ErroredForm<LoginFormField>,
next: Option<PostAuthContext>, next: Option<PostAuthContext>,
register_link: String,
} }
impl TemplateContext for LoginContext { impl TemplateContext for LoginContext {
@@ -233,6 +234,7 @@ impl TemplateContext for LoginContext {
vec![LoginContext { vec![LoginContext {
form: ErroredForm::default(), form: ErroredForm::default(),
next: None, next: None,
register_link: "/register".to_string(),
}] }]
} }
} }
@@ -250,6 +252,14 @@ impl LoginContext {
..self ..self
} }
} }
#[must_use]
pub fn with_register_link(self, register_link: String) -> Self {
Self {
register_link,
..self
}
}
} }
impl Default for LoginContext { impl Default for LoginContext {
@@ -257,6 +267,67 @@ impl Default for LoginContext {
Self { Self {
form: ErroredForm::new(), form: ErroredForm::new(),
next: None, next: None,
register_link: "/register".to_string(),
}
}
}
#[derive(Serialize, Debug, Clone, Copy, Hash, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum RegisterFormField {
Username,
Password,
PasswordConfirm,
}
/// Context used by the `register.html` template
#[derive(Serialize)]
pub struct RegisterContext {
form: ErroredForm<LoginFormField>,
next: Option<PostAuthContext>,
login_link: String,
}
impl TemplateContext for RegisterContext {
fn sample() -> Vec<Self>
where
Self: Sized,
{
// TODO: samples with errors
vec![RegisterContext {
form: ErroredForm::default(),
next: None,
login_link: "/login".to_string(),
}]
}
}
impl RegisterContext {
#[must_use]
pub fn with_form_error(self, form: ErroredForm<LoginFormField>) -> Self {
Self { form, ..self }
}
#[must_use]
pub fn with_post_action(self, next: PostAuthContext) -> Self {
Self {
next: Some(next),
..self
}
}
#[must_use]
pub fn with_login_link(self, login_link: String) -> Self {
Self { login_link, ..self }
}
}
impl Default for RegisterContext {
fn default() -> Self {
Self {
form: ErroredForm::new(),
next: None,
login_link: "/login".to_string(),
} }
} }
} }

View File

@@ -14,10 +14,16 @@
//! Additional functions, tests and filters used in templates //! Additional functions, tests and filters used in templates
use std::{collections::HashMap, str::FromStr};
use tera::{helpers::tests::number_args_allowed, Tera, Value}; use tera::{helpers::tests::number_args_allowed, Tera, Value};
use url::Url;
pub fn register(tera: &mut Tera) { pub fn register(tera: &mut Tera) {
tera.register_tester("empty", self::tester_empty); tera.register_tester("empty", self::tester_empty);
tera.register_function("add_params_to_uri", function_add_params_to_uri);
tera.register_function("merge", function_merge);
tera.register_function("dict", function_dict);
} }
fn tester_empty(value: Option<&Value>, params: &[Value]) -> Result<bool, tera::Error> { fn tester_empty(value: Option<&Value>, params: &[Value]) -> Result<bool, tera::Error> {
@@ -28,3 +34,84 @@ fn tester_empty(value: Option<&Value>, params: &[Value]) -> Result<bool, tera::E
Some(_) => Ok(false), Some(_) => Ok(false),
} }
} }
enum ParamsWhere {
Fragment,
Query,
}
fn function_add_params_to_uri(params: &HashMap<String, Value>) -> Result<Value, tera::Error> {
use ParamsWhere::{Fragment, Query};
// First, get the `uri`, `mode` and `params` parameters
let uri = params
.get("uri")
.and_then(Value::as_str)
.ok_or_else(|| tera::Error::msg("Invalid parameter `uri`"))?;
let uri = Url::from_str(uri).map_err(|e| tera::Error::chain(uri, e))?;
let mode = params
.get("mode")
.and_then(Value::as_str)
.ok_or_else(|| tera::Error::msg("Invalid parameter `mode`"))?;
let mode = match mode {
"fragment" => Fragment,
"query" => Query,
_ => return Err(tera::Error::msg("Invalid mode")),
};
let params = params
.get("params")
.and_then(Value::as_object)
.ok_or_else(|| tera::Error::msg("Invalid parameter `params`"))?;
// Get the relevant part of the URI and parse for existing parameters
let existing = match mode {
Fragment => uri.fragment(),
Query => uri.query(),
};
let existing: HashMap<String, Value> = existing
.map(serde_urlencoded::from_str)
.transpose()
.map_err(|e| tera::Error::chain(e, "Could not parse existing `uri` parameters"))?
.unwrap_or_default();
// Merge the exising and the additional parameters together
let params: HashMap<&String, &Value> = params
.iter()
// Filter out the `uri` and `mode` params
.filter(|(k, _v)| k != &"uri" && k != &"mode")
.chain(existing.iter())
.collect();
// Transform them back to urlencoded
let params = serde_urlencoded::to_string(params)
.map_err(|e| tera::Error::chain(e, "Could not serialize back parameters"))?;
let uri = {
let mut uri = uri;
match mode {
Fragment => uri.set_fragment(Some(&params)),
Query => uri.set_query(Some(&params)),
};
uri
};
Ok(Value::String(uri.to_string()))
}
fn function_merge(params: &HashMap<String, Value>) -> Result<Value, tera::Error> {
let mut ret = serde_json::Map::new();
for (k, v) in params {
let v = v
.as_object()
.ok_or_else(|| tera::Error::msg(format!("Parameter {:?} should be an object", k)))?;
ret.extend(v.clone());
}
Ok(Value::Object(ret))
}
#[allow(clippy::unnecessary_wraps)]
fn function_dict(params: &HashMap<String, Value>) -> Result<Value, tera::Error> {
let ret = params.clone().into_iter().collect();
Ok(Value::Object(ret))
}

View File

@@ -48,8 +48,8 @@ mod macros;
pub use self::context::{ pub use self::context::{
EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField, EmptyContext, ErrorContext, FormPostContext, IndexContext, LoginContext, LoginFormField,
PostAuthContext, ReauthContext, ReauthFormField, TemplateContext, WithCsrf, PostAuthContext, ReauthContext, ReauthFormField, RegisterContext, RegisterFormField,
WithOptionalSession, WithSession, TemplateContext, WithCsrf, WithOptionalSession, WithSession,
}; };
/// Wrapper around [`tera::Tera`] helping rendering the various templates /// Wrapper around [`tera::Tera`] helping rendering the various templates
@@ -280,6 +280,7 @@ register_templates! {
extra = { extra = {
"components/button.html", "components/button.html",
"components/field.html", "components/field.html",
"components/back_to_client.html",
"base.html", "base.html",
}; };
@@ -287,7 +288,7 @@ register_templates! {
pub fn render_login(WithCsrf<LoginContext>) { "login.html" } pub fn render_login(WithCsrf<LoginContext>) { "login.html" }
/// Render the registration page /// Render the registration page
pub fn render_register(WithCsrf<EmptyContext>) { "register.html" } pub fn render_register(WithCsrf<RegisterContext>) { "register.html" }
/// Render the home page /// Render the home page
pub fn render_index(WithCsrf<WithOptionalSession<IndexContext>>) { "index.html" } pub fn render_index(WithCsrf<WithOptionalSession<IndexContext>>) { "index.html" }

View File

@@ -16,6 +16,7 @@ limitations under the License.
{% import "components/button.html" as button %} {% import "components/button.html" as button %}
{% import "components/field.html" as field %} {% import "components/field.html" as field %}
{% import "components/back_to_client.html" as back_to_client %}
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>

View File

@@ -0,0 +1,30 @@
{#
Copyright 2021 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.
#}
{% macro link(text, class="", uri, mode, params) %}
{% if mode == "form_post" %}
<form method="post" action="{{ uri }}">
{% for key, value in params %}
<input type="hidden" name="{{ key }}" value="{{ value }}" />
{% endfor %}
<button class="{{ class }}" type="submit">{{ text }}</button>
</form>
{% elif mode == "fragment" or mode == "query" %}
<a class="{{ class }}" href="{{ add_params_to_uri(uri=uri, mode=mode, params=params) }}">{{ text }}</a>
{% else %}
{{ throw(message="Invalid mode") }}
{% endif %}
{% endmacro %}

View File

@@ -35,7 +35,7 @@ limitations under the License.
{% endmacro %} {% endmacro %}
{% macro link_text(text, href="#", class="") %} {% macro link_text(text, href="#", class="") %}
<a class="{{ self::text_class() }} {{ class }}" href="{{ href }}">{{ text }}</a> <a class="{{ self::text_class() }} {{ class }}" href="{{ href }}">{{ text }}</a>
{% endmacro %} {% endmacro %}
{% macro link_ghost(text, href="#", class="") %} {% macro link_ghost(text, href="#", class="") %}

View File

@@ -18,7 +18,12 @@ limitations under the License.
{% block navbar_start %} {% block navbar_start %}
{% if next and next.kind == "continue_authorization_grant" %} {% if next and next.kind == "continue_authorization_grant" %}
<a href="#">← Back</a> {{ back_to_client::link(
text="← Back",
uri=next.grant.redirect_uri,
mode=next.grant.response_mode,
params=dict(error="access_denied", state=next.grant.state)
) }}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -40,14 +45,19 @@ limitations under the License.
{{ field::input(label="Username", name="username") }} {{ field::input(label="Username", name="username") }}
{{ field::input(label="Password", name="password", type="password") }} {{ field::input(label="Password", name="password", type="password") }}
{{ button::button(text="Next") }} {{ button::button(text="Next") }}
{{ button::link_text(text="Create account", href="/register") }} {{ button::link_text(text="Create account", href=register_link) }}
</form> </form>
</section> </section>
{% if next and next.kind == "continue_authorization_grant" %} {% if next and next.kind == "continue_authorization_grant" %}
<section class="self-center my-6"> <section class="self-center my-6">
{# TODO: proper back link #} {{ back_to_client::link(
{{ button::link_text(text="Return to application", href="/") }} text="Return to application",
class=button::text_class(),
uri=next.grant.redirect_uri,
mode=next.grant.response_mode,
params=dict(error="access_denied", state=next.grant.state)
) }}
</section> </section>
{% endif %} {% endif %}
{% endblock content %} {% endblock content %}

View File

@@ -16,6 +16,17 @@ limitations under the License.
{% extends "base.html" %} {% extends "base.html" %}
{% block navbar_start %}
{% if next and next.kind == "continue_authorization_grant" %}
{{ back_to_client::link(
text="← Back",
uri=next.grant.redirect_uri,
mode=next.grant.response_mode,
params=dict(error="access_denied", state=next.grant.state)
) }}
{% endif %}
{% endblock %}
{% block content %} {% block content %}
<section class="flex items-center justify-center flex-1"> <section class="flex items-center justify-center flex-1">
<form method="POST" class="grid grid-cols-1 gap-6 w-96 m-4"> <form method="POST" class="grid grid-cols-1 gap-6 w-96 m-4">
@@ -44,5 +55,17 @@ limitations under the License.
</div> </div>
{% endif %} {% endif %}
</div> </div>
{% if next and next.kind == "continue_authorization_grant" %}
<section class="self-center my-6">
{{ back_to_client::link(
text="Return to application",
class=button::text_class(),
uri=next.grant.redirect_uri,
mode=next.grant.response_mode,
params=dict(error="access_denied", state=next.grant.state)
) }}
</section>
{% endif %}
{% endblock content %} {% endblock content %}

View File

@@ -18,7 +18,12 @@ limitations under the License.
{% block navbar_start %} {% block navbar_start %}
{% if next and next.kind == "continue_authorization_grant" %} {% if next and next.kind == "continue_authorization_grant" %}
<a href="#">← Back</a> {{ back_to_client::link(
text="← Back",
uri=next.grant.redirect_uri,
mode=next.grant.response_mode,
params=dict(error="access_denied", state=next.grant.state)
) }}
{% endif %} {% endif %}
{% endblock %} {% endblock %}
@@ -40,14 +45,19 @@ limitations under the License.
{{ field::input(label="Confirm Password", name="password_confirm", type="password") }} {{ field::input(label="Confirm Password", name="password_confirm", type="password") }}
{{ button::button(text="Next") }} {{ button::button(text="Next") }}
{# TODO: proper link #} {# TODO: proper link #}
{{ button::link_text(text="Login instead", href="/login") }} {{ button::link_text(text="Login instead", href=login_link) }}
</form> </form>
</section> </section>
{% if next %} {% if next and next.kind == "continue_authorization_grant" %}
<section class="self-center my-6"> <section class="self-center my-6">
{# TODO: proper back link #} {{ back_to_client::link(
{{ button::link_text(text="Return to application", href="/") }} text="Return to application",
class=button::text_class(),
uri=next.grant.redirect_uri,
mode=next.grant.response_mode,
params=dict(error="access_denied", state=next.grant.state)
) }}
</section> </section>
{% endif %} {% endif %}
{% endblock content %} {% endblock content %}