feat: Add support for registering accounts with the web UI

This commit is contained in:
Ginger
2026-05-06 14:00:41 -04:00
parent 53d51cf831
commit baf76cd4dc
23 changed files with 1260 additions and 489 deletions
+20 -3
View File
@@ -17,7 +17,11 @@ use tower_sessions::Session;
use crate::{
ROUTE_PREFIX, WebError,
extract::{Expect, PostForm},
pages::{GET_POST, Result, TemplateContext, components::UserCard},
pages::{
GET_POST, Result, TemplateContext,
account::register::{TrustedFlowStatus, UntrustedFlowStatus, registration_flow_status},
components::UserCard,
},
response,
session::{LoginQuery, LoginTarget, User, UserSession},
template,
@@ -41,6 +45,7 @@ template! {
enum LoginBody {
Unauthenticated {
server_name: String,
registration_available: bool,
},
Authenticated {
user_card: UserCard,
@@ -65,8 +70,20 @@ async fn route_login(
let user_id = user.into_session().map(|session| session.user_id);
let body = match &user_id {
| None => LoginBody::Unauthenticated {
server_name: services.globals.server_name().to_string(),
| None => {
let (trusted_flow_status, untrusted_flow_status) =
registration_flow_status(&services).await;
LoginBody::Unauthenticated {
server_name: services.globals.server_name().to_string(),
registration_available: matches!(
trusted_flow_status,
TrustedFlowStatus::Available
) || matches!(
untrusted_flow_status,
UntrustedFlowStatus::Available { .. }
),
}
},
| Some(user_id) => {
if !reauthenticate {
+2
View File
@@ -31,6 +31,7 @@ pub(crate) mod device;
pub(crate) mod email;
pub(crate) mod login;
pub(crate) mod password;
pub(crate) mod register;
pub(crate) fn build() -> Router<crate::State> {
#[allow(clippy::wildcard_imports)]
@@ -45,6 +46,7 @@ pub(crate) fn build() -> Router<crate::State> {
.nest("/cross_signing_reset", cross_signing_reset::build())
.nest("/deactivate", deactivate::build())
.nest("/device/", device::build())
.nest("/register/", register::build())
}
#[derive(Deserialize, Serialize)]
+541
View File
@@ -0,0 +1,541 @@
use std::{collections::BTreeMap, time::SystemTime};
use axum::{
Extension, Router,
extract::{Query, State},
response::{Redirect, Response},
routing::{get, on},
};
use conduwuit_core::{config::TermsDocument, warn};
use conduwuit_service::{
mailer::messages, registration_tokens::ValidToken, users::HashedPassword,
};
use futures::StreamExt;
use lettre::{Address, message::Mailbox};
use ruma::{ClientSecret, OwnedClientSecret, OwnedServerName, OwnedSessionId, OwnedUserId};
use serde::{Deserialize, Serialize};
use tower_sessions::Session;
use validator::{Validate, ValidationError, ValidationErrors};
use crate::{
WebError,
extract::{Expect, PostForm},
pages::{GET_POST, Result, TemplateContext, account::ThreepidQuery},
response,
session::{LoginTarget, User, UserSession},
template,
};
const COMPLETED_REGISTRATION_KEY: &str = "completed_registration";
pub(crate) fn build() -> Router<crate::State> {
Router::new()
.route("/", on(GET_POST, route_register))
.route("/validate", get(get_register_confirm_email))
}
template! {
struct Register use "register.html.j2" {
server_name: OwnedServerName,
body: RegisterBody
}
}
#[derive(Debug)]
enum RegisterBody {
Unavailable,
UsernamePrompt {
allow_federation: bool,
trusted_flow_status: TrustedFlowStatus,
untrusted_flow_status: UntrustedFlowStatus,
username_error: Option<String>,
},
DetailsPrompt {
username: Option<String>,
require_email: bool,
flow: RegistrationFlowParameters,
terms: BTreeMap<String, TermsDocument>,
validation_errors: ValidationErrors,
},
}
#[derive(Debug)]
pub(super) enum TrustedFlowStatus {
Unavailable,
Available,
}
#[derive(Debug)]
pub(super) enum UntrustedFlowStatus {
Unavailable,
Available {
require_email: bool,
},
}
#[derive(Deserialize)]
struct RegistrationQuery {
username: Option<String>,
token: Option<String>,
flow: Option<RequestedRegistrationFlow>,
#[serde(default)]
from_landing: bool,
}
#[derive(Clone, Copy, Deserialize)]
#[serde(rename_all = "snake_case")]
enum RequestedRegistrationFlow {
Untrusted,
Trusted,
}
#[derive(Debug)]
enum RegistrationFlowParameters {
Untrusted {
recaptcha_sitekey: Option<String>,
},
Trusted {
registration_token: Option<String>,
},
}
#[derive(Deserialize, Validate)]
struct RegistrationForm {
flow: RequestedRegistrationFlow,
username: String,
email: Option<Address>,
#[validate(length(min = 1, message = "Password cannot be empty"))]
password: String,
#[validate(must_match(other = "password", message = "Passwords must match"))]
confirm_password: String,
registration_token: Option<String>,
#[serde(rename = "g-recaptcha-response")]
recaptcha_response: Option<String>,
}
#[derive(Deserialize, Serialize)]
struct CompletedRegistration {
user_id: OwnedUserId,
password_hash: HashedPassword,
registration_token: Option<ValidToken>,
}
async fn route_register(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
session_store: Session,
Expect(Query(query)): Expect<Query<RegistrationQuery>>,
PostForm(form): PostForm<RegistrationForm>,
) -> Result {
let validation_errors = if let Some(form) = form {
match form.validate() {
| Ok(()) => {
match begin_registration(&services, context.clone(), session_store, form).await? {
| Ok(response) => return Ok(response),
| Err(err) => err,
}
},
| Err(err) => err,
}
} else {
ValidationErrors::new()
};
let (trusted_flow_status, untrusted_flow_status) = registration_flow_status(&services).await;
if matches!(trusted_flow_status, TrustedFlowStatus::Unavailable)
&& matches!(untrusted_flow_status, UntrustedFlowStatus::Unavailable)
{
return response!(Register::new(
context,
services.globals.server_name().to_owned(),
RegisterBody::Unavailable
));
}
if query.username.is_some() && query.flow.is_none() {
return response!(WebError::BadRequest(
"A flow must be provided if a username is provided".to_owned()
));
}
if let Some(username) = &query.username
&& query.from_landing
{
// Check if the username is valid and available before showing the details form
// to keep the user from wasting their time
if let Err(err) = services
.users
.determine_registration_user_id(Some(username.to_owned()), None, None)
.await
{
return response!(Register::new(
context,
services.globals.server_name().to_owned(),
RegisterBody::UsernamePrompt {
allow_federation: services.config.allow_federation,
trusted_flow_status,
untrusted_flow_status,
username_error: Some(err.message()),
}
));
}
}
let body = {
let terms = services.config.registration_terms.documents.clone();
match (query.flow, query.token) {
| (Some(RequestedRegistrationFlow::Trusted), token) | (_, token @ Some(_)) =>
RegisterBody::DetailsPrompt {
username: query.username,
require_email: services
.config
.smtp
.as_ref()
.is_some_and(|smtp| smtp.require_email_for_token_registration),
flow: RegistrationFlowParameters::Trusted { registration_token: token },
terms,
validation_errors,
},
| (Some(RequestedRegistrationFlow::Untrusted), _) => RegisterBody::DetailsPrompt {
username: query.username,
require_email: services
.config
.smtp
.as_ref()
.is_some_and(|smtp| smtp.require_email_for_registration),
flow: RegistrationFlowParameters::Untrusted {
recaptcha_sitekey: services.config.recaptcha_site_key.clone(),
},
terms,
validation_errors,
},
| (None, None) => RegisterBody::UsernamePrompt {
allow_federation: services.config.allow_federation,
trusted_flow_status,
untrusted_flow_status,
username_error: None,
},
}
};
response!(Register::new(context, services.globals.server_name().to_owned(), body))
}
template! {
struct RegisterConfirmEmail use "register_confirm_email.html.j2" {
session_id: OwnedSessionId,
client_secret: OwnedClientSecret,
validation_error: bool
}
}
#[derive(Deserialize, Serialize)]
struct RegisterConfirmEmailQuery {
#[serde(flatten)]
threepid: ThreepidQuery,
}
async fn get_register_confirm_email(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
session_store: Session,
Expect(Query(RegisterConfirmEmailQuery {
threepid: ThreepidQuery { client_secret, session_id },
})): Expect<Query<RegisterConfirmEmailQuery>>,
) -> Result {
let Some(completed_registration) = session_store
.get::<CompletedRegistration>(COMPLETED_REGISTRATION_KEY)
.await
.expect("should be able to deserialize completed session")
else {
return response!(WebError::BadRequest(
"Inapplicable session. What are you doing here?".to_owned()
));
};
let Ok(session) = services
.threepid
.get_valid_session(&session_id, &client_secret)
.await
else {
return response!(RegisterConfirmEmail::new(context, session_id, client_secret, true,));
};
let email = session.consume();
complete_registration(&services, session_store, completed_registration, Some(email)).await;
response!(Redirect::to(&LoginTarget::Account.target_path()))
}
async fn begin_registration(
services: &crate::State,
context: TemplateContext,
session_store: Session,
form: RegistrationForm,
) -> Result<Result<Response, ValidationErrors>> {
let open_registration = services
.config
.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse;
let mut errors = ValidationErrors::new();
let user_id = match services
.users
.determine_registration_user_id(Some(form.username), form.email.as_ref(), None)
.await
{
| Ok(user_id) => user_id,
| Err(err) => {
errors.add(
"username",
ValidationError::new("invalid").with_message(err.message().into()),
);
return Ok(Err(errors));
},
};
let password_hash = match HashedPassword::new(&form.password) {
| Ok(password) => password,
| Err(err) => {
errors.add(
"password",
ValidationError::new("invalid").with_message(err.message().into()),
);
return Ok(Err(errors));
},
};
let mut registration_token = None;
// Check flow-specific form fields
match form.flow {
| RequestedRegistrationFlow::Trusted => {
// If the form claims to be using the trusted flow, it has to have a
// registration token
let Some(valid_token) = async {
services
.registration_tokens
.validate_token(form.registration_token?)
.await
}
.await
else {
errors.add(
"registration_token",
ValidationError::new("invalid")
.with_message("Invalid registration token".into()),
);
return Ok(Err(errors));
};
registration_token = Some(valid_token);
},
| RequestedRegistrationFlow::Untrusted => {
// Don't check auth for the untrusted flow at all if open reg is enabled
if !open_registration {
// If the form claims to be using the untrusted flow, it _may_ need to have a
// reCAPTCHA response if reCAPTCHA is configured
if let Some(recaptcha_private_site_key) =
&services.config.recaptcha_private_site_key
{
let Some(recaptcha_response) = form.recaptcha_response else {
return Err(WebError::BadRequest(
"reCAPTCHA response expected".to_owned(),
));
};
if recaptcha_verify::verify_v3(
recaptcha_private_site_key,
&recaptcha_response,
None,
)
.await
.is_err()
{
errors.add(
"recaptcha",
ValidationError::new("missing")
.with_message("Please complete the CAPTCHA".into()),
);
return Ok(Err(errors));
}
}
}
},
}
let completed_registration = CompletedRegistration {
user_id,
password_hash,
registration_token,
};
// Check if we need to send an email
let require_email = services
.config
.smtp
.as_ref()
.is_some_and(|smtp| match form.flow {
| RequestedRegistrationFlow::Trusted => smtp.require_email_for_token_registration,
| RequestedRegistrationFlow::Untrusted =>
!open_registration && smtp.require_email_for_registration,
});
if require_email {
// If an email is required we have to validate it before we can complete
// registration
let Some(address) = form.email else {
errors.add(
"email",
ValidationError::new("missing")
.with_message("Please provide an email address".into()),
);
return Ok(Err(errors));
};
if services
.threepid
.get_localpart_for_email(&address)
.await
.is_some()
{
errors.add(
"email",
ValidationError::new("in_use")
.with_message("This email address is already in use.".into()),
);
return Ok(Err(errors));
}
let client_secret = ClientSecret::new();
let session_id = {
match services
.threepid
.send_validation_email(
Mailbox::new(None, address.clone()),
|verification_link| messages::NewAccount {
server_name: services.globals.server_name().as_str(),
verification_link,
},
&client_secret,
0,
)
.await
{
| Ok(session_id) => session_id,
| Err(err) => {
warn!(
"Failed to send new account message for {} to {}: {err}",
&completed_registration.user_id, address,
);
errors.add(
"email",
ValidationError::new("invalid").with_message(
"Failed to send validation email. Is this address correct?".into(),
),
);
return Ok(Err(errors));
},
}
};
session_store
.insert(COMPLETED_REGISTRATION_KEY, completed_registration)
.await
.expect("should have been able to serialize completed registration");
Ok(response!(RegisterConfirmEmail::new(context, session_id, client_secret, false,)))
} else {
// If email isn't required we can immediately complete registration
complete_registration(services, session_store, completed_registration, None).await;
Ok(response!(Redirect::to(&LoginTarget::Account.target_path())))
}
}
async fn complete_registration(
services: &crate::State,
session_store: Session,
CompletedRegistration {
user_id,
password_hash,
registration_token,
}: CompletedRegistration,
email: Option<Address>,
) {
services
.users
.create_local_account(&user_id, password_hash, email)
.await;
if let Some(registration_token) = registration_token {
services
.registration_tokens
.mark_token_as_used(registration_token);
}
let user_session = UserSession { user_id, last_login: SystemTime::now() };
session_store
.insert(User::KEY, user_session)
.await
.expect("should be able to serialize user session");
}
pub(super) async fn registration_flow_status(
services: &crate::State,
) -> (TrustedFlowStatus, UntrustedFlowStatus) {
// Allow registration if it's enabled in the config file or if this is the first
// run (so the first user account can be created)
let allow_registration =
services.config.allow_registration || services.firstrun.is_first_run();
// Trusted flow is only available if any registration tokens exist
let trusted_flow_status = {
if !allow_registration {
TrustedFlowStatus::Unavailable
} else if services
.registration_tokens
.iterate_tokens()
.next()
.await
.is_some()
{
TrustedFlowStatus::Available
} else {
TrustedFlowStatus::Unavailable
}
};
// Untrusted flow is available if email is required for registration,
// or reCAPTCHA is configured, or open registration is enabled
let untrusted_flow_status = {
let require_email = services
.config
.smtp
.as_ref()
.is_some_and(|smtp| smtp.require_email_for_registration);
if !allow_registration {
UntrustedFlowStatus::Unavailable
} else if services.config.recaptcha_private_site_key.is_some() || require_email {
UntrustedFlowStatus::Available { require_email }
} else if services
.config
.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse
{
UntrustedFlowStatus::Available { require_email: false }
} else {
UntrustedFlowStatus::Unavailable
}
};
(trusted_flow_status, untrusted_flow_status)
}
+15 -13
View File
@@ -1,6 +1,9 @@
use axum::{Extension, Router, extract::State, response::IntoResponse, routing::get};
use axum::{Extension, Router, extract::State, routing::get};
use crate::{WebError, pages::TemplateContext, template};
use crate::{
pages::{Result, TemplateContext},
response, template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new()
@@ -8,21 +11,20 @@ pub(crate) fn build() -> Router<crate::State> {
.route(&format!("{}/", crate::ROUTE_PREFIX), get(index))
}
template! {
struct Index<'a> use "index.html.j2" {
server_name: &'a str,
first_run: bool
}
}
async fn index(
State(services): State<crate::State>,
Extension(context): Extension<TemplateContext>,
) -> Result<impl IntoResponse, WebError> {
template! {
struct Index<'a> use "index.html.j2" {
server_name: &'a str,
first_run: bool
}
}
Ok(Index::new(
) -> Result {
response!(Index::new(
context,
services.globals.server_name().as_str(),
services.firstrun.is_first_run(),
)
.into_response())
))
}
+7 -1
View File
@@ -49,11 +49,17 @@ pub(super) async fn template_context_middleware(
let mut response = next.run(request).await;
let child_src = if config.recaptcha_site_key.is_some() {
"www.google.com"
} else {
"'none'"
};
response.headers_mut().insert(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_str(&format!(
"default-src 'none'; style-src 'self'; img-src 'self' 'https' data:; script-src \
'nonce-{csp_nonce}';"
'nonce-{csp_nonce}'; child-src {child_src};"
))
.expect("should be able to build CSP header"),
);
+6
View File
@@ -108,6 +108,11 @@ em {
}
}
hr {
border-width: 1px;
border-color: var(--secondary);
}
small {
color: var(--secondary);
}
@@ -223,6 +228,7 @@ input {
input[type="checkbox"] {
display: inline;
margin: 0;
width: auto !important;
}
button, a.button {
+60 -3
View File
@@ -1,5 +1,62 @@
.reset-password {
.centered-links {
display: flex;
width: 100%;
justify-content: right;
justify-content: space-between;
:last-child {
margin-left: auto;
}
}
.text-rule {
display: flex;
align-items: center;
text-align: center;
color: var(--secondary);
margin-bottom: 0.5em;
}
.text-rule::before, .text-rule::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--secondary);
}
.text-rule:not(:empty)::before {
margin-right: 1rem;
}
.text-rule:not(:empty)::after {
margin-left: 1rem;
}
.username-input {
display: flex;
padding: 0.5em;
margin-bottom: 0.5em;
line-height: 1;
border-radius: var(--border-radius-sm);
border: 2px solid var(--secondary);
&:has(input:focus-visible) {
outline: 2px solid var(--c1);
border-color: transparent;
}
input {
flex: 1;
padding: 0;
margin: 0;
border: none;
outline: none;
}
span {
flex: 0;
color: var(--secondary);
&:first-of-type {
margin-inline-end: 0.5em;
}
}
}
+1 -2
View File
@@ -28,8 +28,7 @@ Device information
{% else %}
This device can access and control all features of your Matrix account.
<br>
<small>❖ <i>This is a legacy device. Legacy devices
always have full access to your account.</i></small>
<small>❖ <i>This is a legacy device. Legacy devices always have full access to your account.</i></small>
{% endif %}
</p>
</section>
+10 -2
View File
@@ -11,7 +11,7 @@ Log in
{%- block content -%}
<div class="panel narrow">
{% match body %}
{% when LoginBody::Unauthenticated { server_name } %}
{% when LoginBody::Unauthenticated { server_name, registration_available } %}
<h1 class="with-matrix-icon">
{% if has_next %}
Log in to continue
@@ -37,6 +37,12 @@ Log in
</p>
<button type="submit">Log in</button>
</form>
<div class="centered-links">
{% if registration_available %}
<a href="{{ crate::ROUTE_PREFIX }}/account/register/">Sign up</a>
{% endif %}
<a href="{{ crate::ROUTE_PREFIX }}/account/password/reset/">Forgot your password?</a>
</div>
{% when LoginBody::Authenticated { user_card } %}
<h1>Confirm your identity</h1>
{{ user_card }}
@@ -48,10 +54,12 @@ Log in
</p>
<button type="submit">Continue</button>
</form>
<div class="centered-links">
<a href="{{ crate::ROUTE_PREFIX }}/account/password/reset/">Forgot your password?</a>
</div>
{% endmatch %}
{% if let Some(error) = login_error %}
<small class="error">{{ error }}</small>
{% endif %}
<a class="reset-password" href="password/reset/"><i>Forgot your password?</i></a>
</div>
{%- endblock -%}
+159
View File
@@ -0,0 +1,159 @@
{% extends "_layout.html.j2" %}
{% import "_components/form.html.j2" as form %}
{%- block head -%}
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/login.css">
{%- endblock -%}
{%- block title -%}
Sign up
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1 class="with-matrix-icon">
{% if false %}
Sign up to continue
{% else %}
Sign up
{% endif %}
<a href="https://matrix.org" target="_blank" noreferer>
<img class="matrix-icon" alt="Matrix logo" aria-ignore src="{{ crate::ROUTE_PREFIX }}/resources/matrix-icon.svg">
</a>
</h1>
{% match body %}
{% when RegisterBody::Unavailable %}
<p>
This server is not currently accepting new accounts.
</p>
{% when RegisterBody::UsernamePrompt { allow_federation, untrusted_flow_status, trusted_flow_status, username_error } %}
<p>
You're about to register a new Matrix account on <em>{{ server_name }}</em>.
</p>
{% if allow_federation %}
<p>
Like email, Matrix is a network of servers. Your account will be able to talk to
users on hundreds of different Matrix servers across the world.
</p>
{% endif %}
<hr>
<p>
Choose a username to continue.
</p>
<form method="get">
<input type="hidden" name="from_landing" value="true">
<p>
<label for="username">Username</label>
<span class="username-input">
<span>@</span>
<input type="text" name="username" autocomplete="username" required>
<span>:{{ server_name }}</span>
</span>
{% if let Some(username_error) = username_error %}
<small class="error">
{{ username_error }}
</small>
{% endif %}
</p>
{% if let UntrustedFlowStatus::Available { require_email } = untrusted_flow_status %}
{% if require_email %}
<button type="submit" name="flow" value="untrusted">Continue with email</button>
{% else %}
<button type="submit" name="flow" value="untrusted">Continue</button>
{% endif %}
{% endif %}
{% if let UntrustedFlowStatus::Available { .. } = untrusted_flow_status && let TrustedFlowStatus::Available = trusted_flow_status %}
<div class="text-rule">or</div>
{% endif %}
{% if let TrustedFlowStatus::Available = trusted_flow_status %}
<button type="submit" name="flow" value="trusted">Continue with a registration token</button>
{% endif %}
</form>
{% when RegisterBody::DetailsPrompt { username, require_email, flow, terms, validation_errors } %}
{% let validation_errors = validation_errors.clone() %}
{% let field_errors = validation_errors.field_errors() %}
<form method="post">
<p>
<label for="username">Username</label>
<span class="username-input">
<span>@</span>
{% if let Some(username) = username %}
<input type="text" name="username" value="{{ username }}" autocomplete="username" required>
{% else %}
<input type="text" name="username" autocomplete="username" required>
{% endif %}
<span>:{{ server_name }}</span>
</span>
{{ form::errors(field_errors, std::borrow::Cow::Borrowed("username")) }}
</p>
<p>
Just a few more details to finish creating your account.
</p>
<p>
<label for="password">Password</label>
<input type="password" name="password" autocomplete="new-password" required>
{{ form::errors(field_errors, std::borrow::Cow::Borrowed("password")) }}
</p>
<p>
<label for="confirm_password">Confirm password</label>
<input type="password" name="confirm_password" autocomplete="new-password" required>
{{ form::errors(field_errors, std::borrow::Cow::Borrowed("confirm_password")) }}
</p>
{% if require_email %}
<p>
<label for="email">Email address</label>
<input type="email" name="email" required>
{{ form::errors(field_errors, std::borrow::Cow::Borrowed("email")) }}
</p>
{% endif %}
{% match flow %}
{% when RegistrationFlowParameters::Untrusted { recaptcha_sitekey } %}
<input type="hidden" name="flow" value="untrusted">
{% if let Some(recaptcha_sitekey) = recaptcha_sitekey %}
<script src="https://www.google.com/recaptcha/enterprise.js" nonce="{{ context.csp_nonce }}" async defer></script>
<noscript>
<p>
⚠️ Please enable JavaScript to complete the reCAPTCHA challenge.
</p>
</noscript>
<p>
<span class="g-recaptcha" data-sitekey="{{ recaptcha_sitekey }}"></span>
{{ form::errors(field_errors, std::borrow::Cow::Borrowed("recaptcha")) }}
</p>
{% endif %}
{% when RegistrationFlowParameters::Trusted { registration_token } %}
<input type="hidden" name="flow" value="trusted">
{% if let Some(registration_token) = registration_token %}
<input type="hidden" name="registration_token" value="{{ registration_token }}">
<div>
{{ form::errors(field_errors, std::borrow::Cow::Borrowed("registration_token")) }}
</div>
{% else %}
<p>
<label for="username">Registration token</label>
<input type="text" name="registration_token" autocomplete="none" required>
{{ form::errors(field_errors, std::borrow::Cow::Borrowed("registration_token")) }}
</p>
{% endif %}
{% endmatch %}
{% if !terms.is_empty() %}
<p>
{% for (id, document) in terms %}
<label for="policy-{{ id }}">
<input
type="checkbox"
id="policy-{{ id }}"
required
>
I agree to the <a target="_blank" href="{{ document.url }}">{{ document.name }}</a>
</label>
{% endfor %}
<small><i>All policy links will open in a new tab.</i></small>
</p>
{% endif %}
<button type="submit">Continue</button>
</form>
{% endmatch %}
</div>
{%- endblock -%}
@@ -0,0 +1,27 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Confirm your email
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1>Confirm your email</h1>
<p>
A message has been sent to your new email address with a validation link.
To finish creating your account, click the link and then return to this page.
If you do not receive the email:
<ul>
<li>Check your spam filter.</li>
</ul>
</p>
{% if validation_error %}
<small class="error">Validation failed. Have you clicked the link in the email that was sent to you?</small>
{% endif %}
<form method="get" action="validate">
<input type="hidden" name="session_id" value="{{ session_id }}">
<input type="hidden" name="client_secret" value="{{ client_secret }}">
<button type="submit">Continue</button>
</form>
</div>
{%- endblock -%}