feat: Implement a webpage for self-service password resets

This commit is contained in:
Ginger committed 2026-03-18 12:42:55 -04:00
1 parent da8833fca4
commit ffa3c53847
24 files changed
+797 -122

No files matched your search

Generated
+118
View File
@@ -461,6 +461,7 @@ dependencies = [
"axum",
"axum-core",
"bytes",
"cookie",
"futures-core",
"futures-util",
"headers",
@@ -1166,15 +1167,22 @@ name = "conduwuit_web"
version = "0.5.7-alpha.1"
dependencies = [
"askama",
"async-trait",
"axum",
"axum-extra",
"base64 0.22.1",
"conduwuit_build_metadata",
"conduwuit_core",
"conduwuit_service",
"futures",
"memory-serve",
"rand 0.10.0",
"ruma",
"serde",
"thiserror 2.0.18",
"tower-http",
"tracing",
"validator",
]
[[package]]
@@ -1257,6 +1265,17 @@ dependencies = [
"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]]
name = "coolor"
version = "1.1.0"
@@ -1509,6 +1528,41 @@ version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "data-encoding"
version = "2.10.0"
@@ -2540,6 +2594,12 @@ version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "ident_case"
version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39"
[[package]]
name = "idna"
version = "1.1.0"
@@ -3859,6 +3919,28 @@ dependencies = [
"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]]
name = "proc-macro2"
version = "1.0.106"
@@ -5211,6 +5293,12 @@ dependencies = [
"quote",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subslice"
version = "0.2.3"
@@ -5986,6 +6074,36 @@ dependencies = [
"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]]
name = "valuable"
version = "0.1.1"
+1 -1
View File
@@ -99,7 +99,7 @@ features = [
[workspace.dependencies.axum-extra]
version = "0.12.0"
default-features = false
features = ["typed-header", "tracing"]
features = ["typed-header", "tracing", "cookie"]
[workspace.dependencies.axum-server]
version = "0.7.2"
+3 -2
View File
@@ -25,8 +25,9 @@
#
# 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
# as the base domain for user-facing links (such as password reset links) created by Continuwuity.
# If `client` is not set under `[global.well_known]`, the server name will
# be used as the base domain for user-facing links (such as password
# reset links) created by Continuwuity.
#
# Examples of delegation:
# - https://continuwuity.org/.well-known/matrix/server
+4
View File
@@ -458,4 +458,8 @@ pub(super) static MAPS: &[Descriptor] = &[
name: "userroomid_invitesender",
..descriptor::RANDOM_SMALL
},
Descriptor {
name: "websessionid_session",
..descriptor::RANDOM
},
];
+1 -1
View File
@@ -65,5 +65,5 @@ impl Data {
}
/// 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); }
}
+7
View File
@@ -20,14 +20,21 @@ crate-type = [
[dependencies]
conduwuit-build-metadata.workspace = true
conduwuit-service.workspace = true
conduwuit-core.workspace = true
async-trait.workspace = true
askama.workspace = true
axum.workspace = true
axum-extra.workspace = true
base64.workspace = true
futures.workspace = true
tracing.workspace = true
rand.workspace = true
ruma.workspace = true
thiserror.workspace = true
tower-http.workspace = true
serde.workspace = true
memory-serve = "2.1.0"
validator = { version = "0.20.0", features = ["derive"] }
[build-dependencies]
memory-serve = "2.1.0"
+16 -5
View File
@@ -15,23 +15,33 @@ type State = state::State;
enum WebError {
#[error("Failed to render template: {0}")]
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 {
fn into_response(self) -> Response {
#[derive(Debug, Template)]
#[template(path = "error.html.j2")]
#[allow(unused)]
struct Error {
err: WebError,
error: WebError,
status: StatusCode,
}
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()
} else {
(status, "Something went wrong").into_response()
@@ -46,8 +56,9 @@ pub fn build() -> Router<state::State> {
Router::new()
.merge(index::build())
.merge(resources::build())
.merge(password_reset::build())
.layer(SetResponseHeaderLayer::if_not_present(
header::CONTENT_SECURITY_POLICY,
HeaderValue::from_static("default-src 'self'"),
HeaderValue::from_static("default-src 'self'; img-src 'self' data:;"),
))
}
+98
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,
}
}
}
};
}
+72
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 }
}
}
+14 -5
View File
@@ -5,24 +5,33 @@ use axum::{
response::{Html, IntoResponse},
routing::get,
};
use conduwuit_service::state;
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(
State(services): State<state::State>,
State(services): State<crate::State>,
) -> Result<impl IntoResponse, WebError> {
#[derive(Debug, Template)]
#[template(path = "index.html.j2")]
struct Index<'a> {
server_name: &'a str,
client_domain: &'a str,
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 {
server_name: services.config.server_name.as_str(),
client_domain,
first_run: services.firstrun.is_first_run(),
};
Ok(Html(template.render()?))
+2
View File
@@ -1,2 +1,4 @@
mod components;
pub(super) mod index;
pub(super) mod password_reset;
pub(super) mod resources;
+122
View File
@@ -0,0 +1,122 @@
use askama::Template;
use axum::{
Router,
extract::{Query, State},
http::StatusCode,
response::{Html, IntoResponse, Redirect, Response},
routing::get,
};
use ruma::{UserId, user_id};
use serde::Deserialize;
use validator::Validate;
use crate::{
WebError, form,
pages::components::{UserCard, form::Form},
};
const TEST: &str = "data:image/png;base64,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 Line truncated
#[derive(Deserialize)]
struct PasswordResetQuery {
token: String,
}
#[derive(Debug, Template)]
#[template(path = "password_reset.html.j2")]
struct PasswordReset<'a> {
user_card: UserCard<'a>,
body: PasswordResetBody,
}
#[derive(Debug)]
enum PasswordResetBody {
Form(Form<'static>),
Success,
}
form! {
struct PasswordResetForm {
#[validate(length(min = 1, message = "Password cannot be empty"))]
new_password: String where {
input_type: "password",
label: "New password",
autocomplete: "new-password"
},
#[validate(must_match(other = "new_password", message = "Passwords must match"))]
confirm_new_password: String where {
input_type: "password",
label: "Confirm new password",
autocomplete: "new-password"
}
submit: "Reset Password"
}
}
pub(crate) fn build() -> Router<crate::State> {
Router::new().route(
"/_continuwuity/password_reset",
get(get_password_reset).post(post_password_reset),
)
}
async fn password_reset_form(
services: crate::State,
query: PasswordResetQuery,
reset_form: Form<'static>,
) -> Result<impl IntoResponse, WebError> {
let Some(token) = services.password_reset.check_token(&query.token).await else {
return Err(WebError::BadRequest("Invalid reset token".to_owned()));
};
let user_card = UserCard::for_local_user(&services, &token.info.user).await;
let template = PasswordReset {
user_card,
body: PasswordResetBody::Form(reset_form),
};
Ok(Html(template.render()?))
}
async fn get_password_reset(
State(services): State<crate::State>,
Query(query): Query<PasswordResetQuery>,
) -> Result<impl IntoResponse, WebError> {
password_reset_form(services, query, PasswordResetForm::build(None)).await
}
async fn post_password_reset(
State(services): State<crate::State>,
Query(query): Query<PasswordResetQuery>,
axum::Form(form): axum::Form<PasswordResetForm>,
) -> Result<Response, WebError> {
match form.validate() {
| Ok(()) => {
let Some(token) = services.password_reset.check_token(&query.token).await else {
return Err(WebError::BadRequest("Invalid reset token".to_owned()));
};
let user_id = token.info.user.clone();
services
.password_reset
.consume_token(token, &form.new_password)
.await?;
let user_card = UserCard::for_local_user(&services, &user_id).await;
let template = PasswordReset {
user_card,
body: PasswordResetBody::Success,
};
Ok(Html(template.render()?).into_response())
},
| Err(err) => Ok((
StatusCode::BAD_REQUEST,
password_reset_form(services, query, PasswordResetForm::build(Some(err))).await,
)
.into_response()),
}
}
+185
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%;
}
}
+44
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);
}
}
}
}
+7
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);
}
+11
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);
}
-94
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;
}
@@ -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 %}
@@ -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>
@@ -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>
+3 -1
View File
@@ -7,7 +7,9 @@
<meta name="viewport" content="width=device-width, initial-scale=1" />
<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>
<body>
+20 -11
View File
@@ -1,20 +1,29 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="/_continuwuity/resources/error.css">
{%- endblock -%}
{%- block title -%}
Server Error
🐈 Request Error
{%- endblock -%}
{%- block content -%}
<h1>
{%- match err -%}
{% else -%} 500: Internal Server Error
{%- endmatch -%}
</h1>
<pre class="k10y" aria-hidden>
      />   フ
      |  _  _|
     /` ミ_xノ
     /      |
    /  ヽ   ノ
    │  | | |
 / ̄|   | | |
 | ( ̄ヽ__ヽ_)__)
 \二つ
</pre>
<div class="panel">
<h1>Request error<small aria-hidden>(︶^︶)</small></h1>
{%- match err -%}
{% when WebError::Render(err) -%}
<pre>{{ err }}</pre>
{% else -%} <p>An error occurred</p>
{%- endmatch -%}
<pre><code>{{ error }}</code></pre>
</div>
{%- endblock -%}
+7 -2
View File
@@ -1,4 +1,9 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="/_continuwuity/resources/index.css">
{%- endblock -%}
{%- block content -%}
<div class="panel">
<h1>
@@ -6,10 +11,10 @@
</h1>
<p>Continuwuity is successfully installed and working.</p>
{%- 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>
{%- 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 %}
</div>
@@ -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 -%}