feat: Implement a webpage for self-service password resets

This commit is contained in:
Ginger 2026-03-03 13:20:16 -05:00
parent da8833fca4
commit ffa3c53847
No known key found for this signature in database
24 changed files with 797 additions and 122 deletions

118
Cargo.lock generated
View file

@ -461,6 +461,7 @@ dependencies = [
"axum", "axum",
"axum-core", "axum-core",
"bytes", "bytes",
"cookie",
"futures-core", "futures-core",
"futures-util", "futures-util",
"headers", "headers",
@ -1166,15 +1167,22 @@ name = "conduwuit_web"
version = "0.5.7-alpha.1" version = "0.5.7-alpha.1"
dependencies = [ dependencies = [
"askama", "askama",
"async-trait",
"axum", "axum",
"axum-extra",
"base64 0.22.1",
"conduwuit_build_metadata", "conduwuit_build_metadata",
"conduwuit_core",
"conduwuit_service", "conduwuit_service",
"futures", "futures",
"memory-serve", "memory-serve",
"rand 0.10.0", "rand 0.10.0",
"ruma",
"serde",
"thiserror 2.0.18", "thiserror 2.0.18",
"tower-http", "tower-http",
"tracing", "tracing",
"validator",
] ]
[[package]] [[package]]
@ -1257,6 +1265,17 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "cookie"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747"
dependencies = [
"percent-encoding",
"time",
"version_check",
]
[[package]] [[package]]
name = "coolor" name = "coolor"
version = "1.1.0" version = "1.1.0"
@ -1509,6 +1528,41 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "817fa642fb0ee7fe42e95783e00e0969927b96091bdd4b9b1af082acd943913b" checksum = "817fa642fb0ee7fe42e95783e00e0969927b96091bdd4b9b1af082acd943913b"
[[package]]
name = "darling"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee"
dependencies = [
"darling_core",
"darling_macro",
]
[[package]]
name = "darling_core"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e"
dependencies = [
"fnv",
"ident_case",
"proc-macro2",
"quote",
"strsim",
"syn",
]
[[package]]
name = "darling_macro"
version = "0.20.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead"
dependencies = [
"darling_core",
"quote",
"syn",
]
[[package]] [[package]]
name = "data-encoding" name = "data-encoding"
version = "2.10.0" version = "2.10.0"
@ -2540,6 +2594,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]] [[package]]
name = "idna" name = "idna"
version = "1.1.0" version = "1.1.0"
@ -3859,6 +3919,28 @@ dependencies = [
"toml_edit 0.25.5+spec-1.1.0", "toml_edit 0.25.5+spec-1.1.0",
] ]
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@ -5211,6 +5293,12 @@ dependencies = [
"quote", "quote",
] ]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]] [[package]]
name = "subslice" name = "subslice"
version = "0.2.3" version = "0.2.3"
@ -5986,6 +6074,36 @@ dependencies = [
"wasm-bindgen", "wasm-bindgen",
] ]
[[package]]
name = "validator"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43fb22e1a008ece370ce08a3e9e4447a910e92621bb49b85d6e48a45397e7cfa"
dependencies = [
"idna",
"once_cell",
"regex",
"serde",
"serde_derive",
"serde_json",
"url",
"validator_derive",
]
[[package]]
name = "validator_derive"
version = "0.20.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca"
dependencies = [
"darling",
"once_cell",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "valuable" name = "valuable"
version = "0.1.1" version = "0.1.1"

View file

@ -99,7 +99,7 @@ features = [
[workspace.dependencies.axum-extra] [workspace.dependencies.axum-extra]
version = "0.12.0" version = "0.12.0"
default-features = false default-features = false
features = ["typed-header", "tracing"] features = ["typed-header", "tracing", "cookie"]
[workspace.dependencies.axum-server] [workspace.dependencies.axum-server]
version = "0.7.2" version = "0.7.2"

View file

@ -25,8 +25,9 @@
# #
# Also see the `[global.well_known]` config section at the very bottom. # Also see the `[global.well_known]` config section at the very bottom.
# #
# If `client` is not set under `[global.well_known]`, the server name will be used # If `client` is not set under `[global.well_known]`, the server name will
# as the base domain for user-facing links (such as password reset links) created by Continuwuity. # be used as the base domain for user-facing links (such as password
# reset links) created by Continuwuity.
# #
# Examples of delegation: # Examples of delegation:
# - https://continuwuity.org/.well-known/matrix/server # - https://continuwuity.org/.well-known/matrix/server

View file

@ -458,4 +458,8 @@ pub(super) static MAPS: &[Descriptor] = &[
name: "userroomid_invitesender", name: "userroomid_invitesender",
..descriptor::RANDOM_SMALL ..descriptor::RANDOM_SMALL
}, },
Descriptor {
name: "websessionid_session",
..descriptor::RANDOM
},
]; ];

View file

@ -65,5 +65,5 @@ impl Data {
} }
/// Remove a reset token. /// Remove a reset token.
pub(super) fn remove_token(&self, token: &str) { self.passwordresettoken_info.del(token); } pub(super) fn remove_token(&self, token: &str) { self.passwordresettoken_info.remove(token); }
} }

View file

@ -20,14 +20,21 @@ crate-type = [
[dependencies] [dependencies]
conduwuit-build-metadata.workspace = true conduwuit-build-metadata.workspace = true
conduwuit-service.workspace = true conduwuit-service.workspace = true
conduwuit-core.workspace = true
async-trait.workspace = true
askama.workspace = true askama.workspace = true
axum.workspace = true axum.workspace = true
axum-extra.workspace = true
base64.workspace = true
futures.workspace = true futures.workspace = true
tracing.workspace = true tracing.workspace = true
rand.workspace = true rand.workspace = true
ruma.workspace = true
thiserror.workspace = true thiserror.workspace = true
tower-http.workspace = true tower-http.workspace = true
serde.workspace = true
memory-serve = "2.1.0" memory-serve = "2.1.0"
validator = { version = "0.20.0", features = ["derive"] }
[build-dependencies] [build-dependencies]
memory-serve = "2.1.0" memory-serve = "2.1.0"

View file

@ -15,23 +15,33 @@ type State = state::State;
enum WebError { enum WebError {
#[error("Failed to render template: {0}")] #[error("Failed to render template: {0}")]
Render(#[from] askama::Error), Render(#[from] askama::Error),
#[error("Failed to validate form body: {0}")]
ValidationError(#[from] validator::ValidationErrors),
#[error("Bad request: {0}")]
BadRequest(String),
#[error("Internal server error: {0}")]
InternalError(#[from] conduwuit_core::Error),
} }
impl IntoResponse for WebError { impl IntoResponse for WebError {
fn into_response(self) -> Response { fn into_response(self) -> Response {
#[derive(Debug, Template)] #[derive(Debug, Template)]
#[template(path = "error.html.j2")] #[template(path = "error.html.j2")]
#[allow(unused)]
struct Error { struct Error {
err: WebError, error: WebError,
status: StatusCode,
} }
let status = match &self { let status = match &self {
| Self::Render(_) => StatusCode::INTERNAL_SERVER_ERROR, | Self::ValidationError(_) => StatusCode::BAD_REQUEST,
| Self::BadRequest(_) => StatusCode::BAD_REQUEST,
| _ => StatusCode::INTERNAL_SERVER_ERROR,
}; };
let tmpl = Error { err: self }; let template = Error { error: self, status: status.clone() };
if let Ok(body) = tmpl.render() { if let Ok(body) = template.render() {
(status, Html(body)).into_response() (status, Html(body)).into_response()
} else { } else {
(status, "Something went wrong").into_response() (status, "Something went wrong").into_response()
@ -46,8 +56,9 @@ pub fn build() -> Router<state::State> {
Router::new() Router::new()
.merge(index::build()) .merge(index::build())
.merge(resources::build()) .merge(resources::build())
.merge(password_reset::build())
.layer(SetResponseHeaderLayer::if_not_present( .layer(SetResponseHeaderLayer::if_not_present(
header::CONTENT_SECURITY_POLICY, header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'self'"), HeaderValue::from_static("default-src 'self'; img-src 'self' data:;"),
)) ))
} }

View file

@ -0,0 +1,98 @@
use askama::{Template, filters::HtmlSafe};
use validator::ValidationErrors;
/// A reusable form component with field validation.
#[derive(Debug, Template)]
#[template(path = "_components/form.html.j2", print = "code")]
pub(crate) struct Form<'a> {
pub inputs: Vec<FormInput<'a>>,
pub validation_errors: Option<ValidationErrors>,
pub submit_label: &'a str,
}
impl HtmlSafe for Form<'_> {}
/// An input element in a form component.
#[derive(Debug, Clone, Copy)]
pub(crate) struct FormInput<'a> {
/// The field name of the input.
pub id: &'static str,
/// The `type` property of the input.
pub input_type: &'a str,
/// The contents of the input's label.
pub label: &'a str,
/// Whether the input is required. Defaults to `true`.
pub required: bool,
/// The autocomplete mode for the input. Defaults to `on`.
pub autocomplete: &'a str,
// This is a hack to make the form! macro's support for client-only fields
// work properly. Client-only fields are specified in the macro without a type and aren't
// included in the POST body or as a field in the generated struct.
// To keep the field from being included in the POST body, its `name` property needs not to
// be set in the template. Because of limitations of macro_rules!'s repetition feature, this
// field needs to exist to allow the template to check if the field is client-only.
#[doc(hidden)]
pub type_name: Option<&'static str>,
}
impl Default for FormInput<'_> {
fn default() -> Self {
Self {
id: "",
input_type: "text",
label: "",
required: true,
autocomplete: "",
type_name: None,
}
}
}
/// Generate a deserializable struct which may be turned into a [`Form`]
/// for inclusion in another template.
#[macro_export]
macro_rules! form {
(
$(#[$struct_meta:meta])*
struct $struct_name:ident {
$(
$(#[$field_meta:meta])*
$name:ident$(: $type:ty)? where { $($prop:ident: $value:expr),* }
),*
submit: $submit_label:expr
}
) => {
#[derive(Debug, serde::Deserialize, validator::Validate)]
$(#[$struct_meta])*
struct $struct_name {
$(
$(#[$field_meta])*
$(pub $name: $type,)?
)*
}
impl $struct_name {
/// Generate a [`Form`] which matches the shape of this struct.
#[allow(clippy::needless_update)]
fn build(validation_errors: Option<validator::ValidationErrors>) -> $crate::pages::components::form::Form<'static> {
$crate::pages::components::form::Form {
inputs: vec![
$(
$crate::pages::components::form::FormInput {
id: stringify!($name),
$(type_name: Some(stringify!($type)),)?
$($prop: $value),*,
..Default::default()
},
)*
],
validation_errors,
submit_label: $submit_label,
}
}
}
};
}

View file

@ -0,0 +1,72 @@
use askama::{Template, filters::HtmlSafe};
use base64::Engine;
use conduwuit_core::{Result, result::FlatOk, utils::TryFutureExtExt};
use conduwuit_service::Services;
use futures::TryFutureExt;
use ruma::UserId;
pub(super) mod form;
#[derive(Debug)]
pub(super) enum AvatarType<'a> {
Initial(char),
Image(&'a str),
}
#[derive(Debug, Template)]
#[template(path = "_components/avatar.html.j2")]
pub(super) struct Avatar<'a> {
pub(super) avatar_type: AvatarType<'a>,
}
impl HtmlSafe for Avatar<'_> {}
#[derive(Debug, Template)]
#[template(path = "_components/user_card.html.j2")]
pub(super) struct UserCard<'a> {
pub user_id: &'a UserId,
pub display_name: Option<String>,
pub avatar_src: Option<String>,
}
impl HtmlSafe for UserCard<'_> {}
impl<'a> UserCard<'a> {
pub async fn for_local_user(services: &Services, user_id: &'a UserId) -> Self {
let display_name = services.users.displayname(user_id).await.ok();
let avatar_src = async {
let avatar_url = services.users.avatar_url(user_id).await.ok()?;
let avatar_mxc = avatar_url.parts().ok()?;
let file = services.media.get(&avatar_mxc).await.flat_ok()?;
Some(format!(
"data:{};base64,{}",
file.content_type
.unwrap_or_else(|| "application/octet-stream".to_owned()),
file.content
.map(|content| base64::prelude::BASE64_STANDARD.encode(content))
.unwrap_or_default(),
))
}
.await;
Self { user_id, display_name, avatar_src }
}
fn avatar(&'a self) -> Avatar<'a> {
let avatar_type = if let Some(ref avatar_src) = self.avatar_src {
AvatarType::Image(avatar_src)
} else if let Some(initial) = self
.display_name
.as_ref()
.and_then(|display_name| display_name.chars().next())
{
AvatarType::Initial(initial)
} else {
AvatarType::Initial(self.user_id.localpart().chars().next().unwrap())
};
Avatar { avatar_type }
}
}

View file

@ -5,24 +5,33 @@ use axum::{
response::{Html, IntoResponse}, response::{Html, IntoResponse},
routing::get, routing::get,
}; };
use conduwuit_service::state;
use crate::WebError; use crate::WebError;
pub(crate) fn build() -> Router<state::State> { Router::new().route("/", get(index_handler)) } pub(crate) fn build() -> Router<crate::State> { Router::new().route("/", get(index_handler)) }
async fn index_handler( async fn index_handler(
State(services): State<state::State>, State(services): State<crate::State>,
) -> Result<impl IntoResponse, WebError> { ) -> Result<impl IntoResponse, WebError> {
#[derive(Debug, Template)] #[derive(Debug, Template)]
#[template(path = "index.html.j2")] #[template(path = "index.html.j2")]
struct Index<'a> { struct Index<'a> {
server_name: &'a str, client_domain: &'a str,
first_run: bool, first_run: bool,
} }
let client_domain = services.config.get_client_domain();
let host = client_domain
.host_str()
.expect("client domain should have a host");
let client_domain = if let Some(port) = client_domain.port() {
&format!("{host}:{port}")
} else {
host
};
let template = Index { let template = Index {
server_name: services.config.server_name.as_str(), client_domain,
first_run: services.firstrun.is_first_run(), first_run: services.firstrun.is_first_run(),
}; };
Ok(Html(template.render()?)) Ok(Html(template.render()?))

View file

@ -1,2 +1,4 @@
mod components;
pub(super) mod index; pub(super) mod index;
pub(super) mod password_reset;
pub(super) mod resources; pub(super) mod resources;

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,185 @@
:root {
color-scheme: light;
--font-stack: sans-serif;
--background-color: #fff;
--text-color: #000;
--secondary: #666;
--bg: oklch(0.76 0.0854 317.27);
--panel-bg: oklch(0.91 0.042 317.27);
--c1: oklch(0.44 0.177 353.06);
--c2: oklch(0.59 0.158 150.88);
--name-lightness: 0.45;
--background-lightness: 0.9;
--background-gradient:
radial-gradient(42.12% 56.13% at 100% 0%, oklch(from var(--c2) var(--background-lightness) c h) 0%, #fff0 100%),
radial-gradient(42.01% 79.63% at 52.86% 0%, #d9ff5333 0%, #fff0 100%),
radial-gradient(79.67% 58.09% at 0% 0%, oklch(from var(--c1) var(--background-lightness) c h) 0%, #fff0 100%);
--normal-font-size: 1rem;
--small-font-size: 0.8rem;
--border-radius-sm: 5px;
--border-radius-lg: 15px;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
--text-color: #fff;
--secondary: #888;
--bg: oklch(0.15 0.042 317.27);
--panel-bg: oklch(0.24 0.03 317.27);
--name-lightness: 0.8;
--background-lightness: 0.2;
--background-gradient:
radial-gradient(
42.12% 56.13% at 100% 0%,
oklch(from var(--c2) var(--background-lightness) c h) 0%,
#12121200 100%
),
radial-gradient(55.81% 87.78% at 48.37% 0%, #000 0%, #12121200 89.55%),
radial-gradient(
122.65% 88.24% at 0% 0%,
oklch(from var(--c1) var(--background-lightness) c h) 0%,
#12121200 100%
);
}
}
* {
box-sizing: border-box;
}
body {
display: grid;
margin: 0;
padding: 0;
place-items: center;
min-height: 100vh;
color: var(--text-color);
font-family: var(--font-stack);
line-height: 1.5;
}
html {
background-color: var(--bg);
background-image: var(--background-gradient);
font-size: var(--normal-font-size);
}
footer {
padding-inline: 0.25rem;
height: max(fit-content, 2rem);
.logo {
width: 100%;
height: 64px;
}
}
p {
margin: 1rem 0;
}
em {
color: oklch(from var(--c2) var(--name-lightness) c h);
font-weight: bold;
font-style: normal;
}
small {
color: var(--secondary);
}
small.error {
display: block;
color: red;
font-size: small;
font-style: italic;
margin-bottom: 0.5rem;
}
.panel {
--preferred-width: 12rem + 40dvw;
--maximum-width: 48rem;
width: min(clamp(24rem, var(--preferred-width), var(--maximum-width)), calc(100dvw - 3rem));
border-radius: var(--border-radius-lg);
background-color: var(--panel-bg);
padding-inline: 1.5rem;
padding-block: 1rem;
box-shadow: 0 0.25em 0.375em hsla(0, 0%, 0%, 0.1);
&.narrow {
--preferred-width: 12rem + 20dvw;
--maximum-width: 36rem;
input, button {
width: 100%;
}
}
}
label {
display: block;
}
input, button {
display: inline-block;
padding: 0.5em;
margin-bottom: 0.5em;
font-size: inherit;
font-family: inherit;
color: var(--text-color);
background-color: transparent;
border: none;
border-radius: var(--border-radius-sm);
}
input {
border: 2px solid var(--secondary);
&:focus-visible {
outline: 2px solid var(--c1);
border-color: transparent;
}
}
button {
background-color: var(--c1);
transition: opacity .2s;
&:enabled:hover {
opacity: 0.8;
cursor: pointer;
}
}
h1 {
margin-top: 0;
margin-bottom: 0.67em;
}
@media (max-width: 425px) {
main {
padding-block-start: 2rem;
width: 100%;
}
.panel {
border-radius: 0;
width: 100%;
}
}
@media (max-width: 799px) {
input, button {
width: 100%;
}
}

View file

@ -0,0 +1,44 @@
.avatar {
--avatar-size: 56px;
display: inline-block;
aspect-ratio: 1 / 1;
inline-size: var(--avatar-size);
border-radius: 50%;
text-align: center;
text-transform: uppercase;
font-size: calc(var(--avatar-size) * 0.5);
font-weight: 700;
line-height: calc(var(--avatar-size) - 2px);
color: oklch(from var(--c1) var(--name-lightness) c h);
background-color: var(--c1);
}
.user-card {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
background-color: oklch(from var(--panel-bg) calc(l - 0.05) c h);
border-radius: var(--border-radius-lg);
padding: 16px;
.info {
flex: 1 1;
p {
margin: 0;
&.display-name {
font-weight: 700;
}
&:nth-of-type(2) {
color: var(--secondary);
}
}
}
}

View file

@ -0,0 +1,7 @@
.k10y {
font-family: monospace;
font-size: x-small;
font-weight: 700;
transform: translate(1rem, 1.6rem);
color: var(--secondary);
}

View file

@ -0,0 +1,11 @@
.project-name {
text-decoration: none;
background: linear-gradient(
130deg,
oklch(from var(--c1) var(--name-lightness) c h),
oklch(from var(--c2) var(--name-lightness) c h)
);
background-clip: text;
color: transparent;
filter: brightness(1.2);
}

View file

@ -1,94 +0,0 @@
:root {
color-scheme: light;
--font-stack: sans-serif;
--background-color: #fff;
--text-color: #000;
--bg: oklch(0.76 0.0854 317.27);
--panel-bg: oklch(0.91 0.042 317.27);
--name-lightness: 0.45;
@media (prefers-color-scheme: dark) {
color-scheme: dark;
--text-color: #fff;
--bg: oklch(0.15 0.042 317.27);
--panel-bg: oklch(0.24 0.03 317.27);
--name-lightness: 0.8;
}
--c1: oklch(0.44 0.177 353.06);
--c2: oklch(0.59 0.158 150.88);
--normal-font-size: 1rem;
--small-font-size: 0.8rem;
}
body {
color: var(--text-color);
font-family: var(--font-stack);
margin: 0;
padding: 0;
display: grid;
place-items: center;
min-height: 100vh;
}
html {
background-color: var(--bg);
background-image: linear-gradient(
70deg,
oklch(from var(--bg) l + 0.2 c h),
oklch(from var(--bg) l - 0.2 c h)
);
font-size: 16px;
}
.panel {
width: min(clamp(24rem, 12rem + 40vw, 48rem), calc(100vw - 3rem));
border-radius: 15px;
background-color: var(--panel-bg);
padding-inline: 1.5rem;
padding-block: 1rem;
box-shadow: 0 0.25em 0.375em hsla(0, 0%, 0%, 0.1);
}
@media (max-width: 24rem) {
.panel {
padding-inline: 0.25rem;
width: calc(100vw - 0.5rem);
border-radius: 0;
margin-block-start: 0.2rem;
}
main {
height: 100%;
}
}
footer {
padding-inline: 0.25rem;
height: max(fit-content, 2rem);
}
.project-name {
text-decoration: none;
background: linear-gradient(
130deg,
oklch(from var(--c1) var(--name-lightness) c h),
oklch(from var(--c2) var(--name-lightness) c h)
);
background-clip: text;
color: transparent;
filter: brightness(1.2);
}
b {
color: oklch(from var(--c2) var(--name-lightness) c h);
}
.logo {
width: 100%;
height: 64px;
}

View file

@ -0,0 +1,6 @@
{% match avatar_type %}
{% when AvatarType::Initial with (initial) %}
<span class="avatar" role="img">{{ initial }}</span>
{% when AvatarType::Image with (src) %}
<img class="avatar" src="{{ src }}">
{% endmatch %}

View file

@ -0,0 +1,29 @@
<form method="post">
{% let validation_errors = validation_errors.clone().unwrap_or_default() %}
{% let field_errors = validation_errors.field_errors() %}
{% for input in inputs %}
<p>
<label for="{{ input.id }}">{{ input.label }}</label>
{% let name = std::borrow::Cow::from(*input.id) %}
{% if let Some(errors) = field_errors.get(name) %}
{% for error in errors %}
<small class="error">
{% if let Some(message) = error.message %}
{{ message }}
{% else %}
Mysterious validation error <code>{{ error.code }}</code>!
{% endif %}
</small>
{% endfor %}
{% endif %}
<input
type="{{ input.input_type }}"
id="{{ input.id }}"
{% if input.type_name.is_some() %}name="{{ input.id }}"{% endif %}
{% if input.required %}required{% endif %}
>
</p>
{% endfor %}
<button type="submit">{{ submit_label }}</button>
</form>

View file

@ -0,0 +1,9 @@
<div class="user-card">
{{ avatar() }}
<div class="info">
{% if let Some(display_name) = display_name %}
<p class="display-name">{{ display_name }}</p>
{% endif %}
<p class="user_id">{{ user_id }}</p>
</div>
</div>

View file

@ -7,7 +7,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/_continuwuity/resources/logo.svg"> <link rel="icon" href="/_continuwuity/resources/logo.svg">
<link rel="stylesheet" href="/_continuwuity/resources/style.css"> <link rel="stylesheet" href="/_continuwuity/resources/common.css">
<link rel="stylesheet" href="/_continuwuity/resources/components.css">
{% block head %}{% endblock %}
</head> </head>
<body> <body>

View file

@ -1,20 +1,29 @@
{% extends "_layout.html.j2" %} {% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="/_continuwuity/resources/error.css">
{%- endblock -%}
{%- block title -%} {%- block title -%}
Server Error 🐈 Request Error
{%- endblock -%} {%- endblock -%}
{%- block content -%} {%- block content -%}
<h1> <pre class="k10y" aria-hidden>
{%- match err -%}         
{% else -%} 500: Internal Server Error       |  _  _|
{%- endmatch -%}      ` ミ_x
</h1>      /      |
    /  ヽ   ノ
    │  | | |
 / ̄|   | | |
 | ( ̄ヽ__ヽ_)__)
 \二つ
</pre>
<div class="panel">
<h1>Request error<small aria-hidden>(︶^︶)</small></h1>
{%- match err -%} <pre><code>{{ error }}</code></pre>
{% when WebError::Render(err) -%} </div>
<pre>{{ err }}</pre>
{% else -%} <p>An error occurred</p>
{%- endmatch -%}
{%- endblock -%} {%- endblock -%}

View file

@ -1,4 +1,9 @@
{% extends "_layout.html.j2" %} {% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="/_continuwuity/resources/index.css">
{%- endblock -%}
{%- block content -%} {%- block content -%}
<div class="panel"> <div class="panel">
<h1> <h1>
@ -6,10 +11,10 @@
</h1> </h1>
<p>Continuwuity is successfully installed and working.</p> <p>Continuwuity is successfully installed and working.</p>
{%- if first_run %} {%- if first_run %}
<p>To get started, <b>check the server logs</b> for instructions on how to create the first account.</p> <p>To get started, <em>check the server logs</em> for instructions on how to create the first account.</p>
<p>For support, take a look at the <a href="https://continuwuity.org/introduction">documentation</a> or join the <a href="https://matrix.to/#/#continuwuity:continuwuity.org?via=continuwuity.org&via=ellis.link&via=explodie.org&via=matrix.org">Continuwuity Matrix room</a>.</p> <p>For support, take a look at the <a href="https://continuwuity.org/introduction">documentation</a> or join the <a href="https://matrix.to/#/#continuwuity:continuwuity.org?via=continuwuity.org&via=ellis.link&via=explodie.org&via=matrix.org">Continuwuity Matrix room</a>.</p>
{%- else %} {%- else %}
<p>To get started, <a href="https://matrix.org/ecosystem/clients">choose a client</a> and connect to <code>{{ server_name }}</code>.</p> <p>To get started, <a href="https://matrix.org/ecosystem/clients">choose a client</a> and connect to <code>{{ client_domain }}</code>.</p>
{%- endif %} {%- endif %}
</div> </div>

View file

@ -0,0 +1,18 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Reset Password
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1>Reset Password</h1>
{{ user_card }}
{% match body %}
{% when PasswordResetBody::Form(reset_form) %}
{{ reset_form }}
{% when PasswordResetBody::Success %}
<p>Your password has been reset successfully.</p>
{% endmatch %}
</div>
{%- endblock -%}