feat: Implement a webpage for self-service password resets
This commit is contained in:
parent
da8833fca4
commit
ffa3c53847
24 changed files with 797 additions and 122 deletions
118
Cargo.lock
generated
118
Cargo.lock
generated
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -458,4 +458,8 @@ pub(super) static MAPS: &[Descriptor] = &[
|
|||
name: "userroomid_invitesender",
|
||||
..descriptor::RANDOM_SMALL
|
||||
},
|
||||
Descriptor {
|
||||
name: "websessionid_session",
|
||||
..descriptor::RANDOM
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
src/web/pages/components/form.rs
Normal file
98
src/web/pages/components/form.rs
Normal 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
src/web/pages/components/mod.rs
Normal file
72
src/web/pages/components/mod.rs
Normal 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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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()?))
|
||||
|
|
|
|||
|
|
@ -1,2 +1,4 @@
|
|||
mod components;
|
||||
pub(super) mod index;
|
||||
pub(super) mod password_reset;
|
||||
pub(super) mod resources;
|
||||
|
|
|
|||
122
src/web/pages/password_reset.rs
Normal file
122
src/web/pages/password_reset.rs
Normal file
File diff suppressed because one or more lines are too long
185
src/web/pages/resources/common.css
Normal file
185
src/web/pages/resources/common.css
Normal 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
src/web/pages/resources/components.css
Normal file
44
src/web/pages/resources/components.css
Normal 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
src/web/pages/resources/error.css
Normal file
7
src/web/pages/resources/error.css
Normal 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
src/web/pages/resources/index.css
Normal file
11
src/web/pages/resources/index.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
6
src/web/pages/templates/_components/avatar.html.j2
Normal file
6
src/web/pages/templates/_components/avatar.html.j2
Normal 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 %}
|
||||
29
src/web/pages/templates/_components/form.html.j2
Normal file
29
src/web/pages/templates/_components/form.html.j2
Normal 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>
|
||||
9
src/web/pages/templates/_components/user_card.html.j2
Normal file
9
src/web/pages/templates/_components/user_card.html.j2
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 -%}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
18
src/web/pages/templates/password_reset.html.j2
Normal file
18
src/web/pages/templates/password_reset.html.j2
Normal 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 -%}
|
||||
Loading…
Add table
Reference in a new issue