feat: Implement a web-based account management dashboard

This commit is contained in:
Ginger
2026-04-27 16:47:08 -04:00
parent 02948960fa
commit 6b0b8344d4
72 changed files with 2554 additions and 677 deletions
@@ -0,0 +1,46 @@
use axum::{Router, extract::State, routing::on};
use conduwuit_service::oauth::OAuthTicket;
use crate::{
extract::PostForm,
pages::{GET_POST, Result, components::UserCard},
response,
session::{LoginTarget, User},
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new().route("/", on(GET_POST, route_cross_signing_reset))
}
template! {
struct CrossSigningReset use "cross_signing_reset.html.j2" {
user_card: UserCard,
body: CrossSigningResetBody
}
}
#[derive(Debug)]
enum CrossSigningResetBody {
Form,
Success,
}
async fn route_cross_signing_reset(
State(services): State<crate::State>,
user: User,
PostForm(form): PostForm<()>,
) -> Result {
let user_id = user.expect_recent(LoginTarget::CrossSigningReset)?;
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
if form.is_some() {
services
.oauth
.issue_ticket(user_id.localpart().to_owned(), OAuthTicket::CrossSigningReset);
response!(CrossSigningReset::new(&services, user_card, CrossSigningResetBody::Success))
} else {
response!(CrossSigningReset::new(&services, user_card, CrossSigningResetBody::Form))
}
}
+124
View File
@@ -0,0 +1,124 @@
use axum::{Router, extract::State, routing::on};
use conduwuit_api::client::full_user_deactivate;
use conduwuit_service::oauth::OAuthTicket;
use futures::StreamExt;
use ruma::{OwnedRoomId, OwnedUserId, UserId};
use serde::Deserialize;
use tower_sessions::Session;
use validator::{Validate, ValidationError, ValidationErrors};
use crate::{
extract::PostForm,
form,
pages::{
GET_POST, Result,
components::{UserCard, form::Form},
},
response,
session::{LoginTarget, User},
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new().route("/", on(GET_POST, route_deactivate))
}
template! {
struct Deactivate use "deactivate.html.j2" {
body: DeactivateBody
}
}
#[derive(Debug)]
enum DeactivateBody {
Form {
user_id: OwnedUserId,
user_card: UserCard,
form: Form<'static>,
},
Success,
}
form! {
struct DeactivateForm {
password: String where {
input_type: "password",
label: "Enter your password to confirm",
autocomplete: "current-password"
},
#[validate(required(message = "This checkbox must be checked"))]
confirm: Option<String> where {
input_type: "checkbox",
label: "I understand that deactivating my account cannot be undone."
}
submit: "Deactivate my account",
slowdown: true
}
}
async fn route_deactivate(
State(services): State<crate::State>,
user: User,
session: Session,
PostForm(form): PostForm<DeactivateForm>,
) -> Result {
let user_id = user.expect_recent(LoginTarget::Deactivate)?;
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
let body = {
if let Some(form) = form {
if let Err(err) = validate_deactivate_form(&services, &user_id, form).await {
DeactivateBody::Form {
user_id,
user_card,
form: DeactivateForm::with_errors(err),
}
} else {
let all_joined_rooms: Vec<OwnedRoomId> = services
.rooms
.state_cache
.rooms_joined(&user_id)
.collect()
.await;
full_user_deactivate(&services, &user_id, &all_joined_rooms).await?;
session.clear().await;
DeactivateBody::Success
}
} else {
DeactivateBody::Form {
user_id,
user_card,
form: DeactivateForm::build(),
}
}
};
response!(Deactivate::new(&services, body))
}
async fn validate_deactivate_form(
services: &crate::State,
user_id: &UserId,
form: DeactivateForm,
) -> Result<(), ValidationErrors> {
form.validate()?;
if services.users.check_password(user_id, &form.password)
.await
.is_err()
{
let mut errors = ValidationErrors::new();
errors.add(
"password",
ValidationError::new("wrong").with_message("Incorrect password".into()),
);
return Err(errors);
}
Ok(())
}
+206
View File
@@ -0,0 +1,206 @@
use axum::{
Router,
extract::{Query, State},
routing::{get, on, post},
};
use conduwuit_core::warn;
use conduwuit_service::{mailer::messages, threepid::session::ValidationSessions};
use lettre::{Address, message::Mailbox};
use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId};
use serde::{Deserialize, Serialize};
use crate::{
WebError,
extract::{Expect, PostForm},
form,
pages::{
GET_POST, Result,
account::ThreepidQuery,
components::{UserCard, form::Form},
},
response,
session::{LoginTarget, User},
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new()
.route("/change/", on(GET_POST, route_change_email_request))
.route("/change/validate", get(get_change_email))
.route("/change/delete", post(post_delete_email))
}
template! {
struct ChangeEmailRequest use "change_email_request.html.j2" {
user_card: UserCard,
email: Option<String>,
form: Form<'static>,
may_remove: bool
}
}
form! {
struct ChangeEmailRequestForm {
email: Address where {
input_type: "email",
label: "Email address"
}
submit: "Change email"
}
}
template! {
struct ChangeEmail use "change_email.html.j2" {
user_card: UserCard,
body: ChangeEmailBody
}
}
template! {
struct DeleteEmail use "delete_email.html.j2" {
user_card: UserCard
}
}
#[derive(Debug)]
enum ChangeEmailBody {
ValidationPending {
session_id: OwnedSessionId,
client_secret: OwnedClientSecret,
validation_error: bool,
},
Success,
}
async fn route_change_email_request(
State(services): State<crate::State>,
user: User,
PostForm(form): PostForm<ChangeEmailRequestForm>,
) -> Result {
let user_id = user.expect_recent(LoginTarget::ChangeEmail)?;
let template = ChangeEmailRequest::new(
&services,
UserCard::for_local_user(&services, user_id.clone()).await,
services
.threepid
.get_email_for_localpart(user_id.localpart())
.await
.map(|address| address.to_string()),
ChangeEmailRequestForm::build(),
services.threepid.email_requirement().may_remove(),
);
let Some(form) = form else {
return response!(template);
};
let client_secret = ClientSecret::new();
let session_id = {
let display_name = services.users.displayname(&user_id).await.ok();
match services
.threepid
.send_validation_email(
Mailbox::new(display_name, form.email.clone()),
|verification_link| messages::ChangeEmail {
server_name: services.globals.server_name().as_str(),
user_id: Some(&user_id),
verification_link,
},
&client_secret,
0,
)
.await
{
| Ok(session_id) => session_id,
| Err(err) => {
// If we couldn't send an email, generate a random session ID to not give that
// away
warn!(
"Failed to send email change message for {user_id} to {}: {err}",
form.email
);
ValidationSessions::generate_session_id()
},
}
};
response!(ChangeEmail::new(
&services,
UserCard::for_local_user(&services, user_id).await,
ChangeEmailBody::ValidationPending {
session_id,
client_secret,
validation_error: false
}
))
}
#[derive(Deserialize, Serialize)]
struct ChangeEmailQuery {
#[serde(flatten)]
threepid: ThreepidQuery,
}
async fn get_change_email(
State(services): State<crate::State>,
Expect(Query(ChangeEmailQuery {
threepid: ThreepidQuery { client_secret, session_id },
})): Expect<Query<ChangeEmailQuery>>,
user: User,
) -> Result {
let user_id = user.expect(LoginTarget::ChangeEmail)?;
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
if !services.threepid.email_requirement().may_change() {
return Err(WebError::Forbidden("You may not change your email address.".to_owned()));
}
let Ok(session) = services
.threepid
.get_valid_session(&session_id, &client_secret)
.await
else {
return response!(ChangeEmail::new(
&services,
user_card,
ChangeEmailBody::ValidationPending {
session_id,
client_secret,
validation_error: true
}
));
};
let new_email = session.consume();
if let Err(err) = services
.threepid
.associate_localpart_email(user_id.localpart(), &new_email)
.await
{
return response!(BadRequest(err.message()));
}
response!(ChangeEmail::new(&services, user_card, ChangeEmailBody::Success))
}
async fn post_delete_email(State(services): State<crate::State>, user: User) -> Result {
let user_id = user.expect(LoginTarget::ChangeEmail)?;
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
if !services.threepid.email_requirement().may_remove() {
return Err(WebError::Forbidden("You may not remove your email address.".to_owned()));
}
let _ = services
.threepid
.disassociate_localpart_email(user_id.localpart())
.await;
response!(DeleteEmail::new(&services, user_card))
}
+137
View File
@@ -0,0 +1,137 @@
use std::time::SystemTime;
use axum::{
Router,
extract::{Query, State},
response::{IntoResponse, Redirect},
routing::{get, on},
};
use conduwuit_api::client::handle_login;
use ruma::{
OwnedUserId,
api::client::uiaa::{EmailUserIdentifier, MatrixUserIdentifier, UserIdentifier},
};
use serde::{Deserialize, Serialize};
use tower_sessions::Session;
use validator::Validate;
use crate::{
WebError,
extract::{Expect, PostForm},
pages::{GET_POST, Result, components::UserCard},
response,
session::{LoginQuery, User, UserSession},
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new()
.route("/login", on(GET_POST, route_login))
.route("/logout", get(get_logout))
}
template! {
struct Login use "login.html.j2" {
body: LoginBody,
login_error: Option<String>
}
}
#[derive(Debug)]
enum LoginBody {
Unauthenticated {
server_name: String,
},
Authenticated {
user_card: UserCard,
},
}
#[derive(Deserialize)]
struct LoginForm {
identifier: Option<String>,
password: String,
}
async fn route_login(
State(services): State<crate::State>,
Expect(Query(query)): Expect<Query<LoginQuery>>,
session_store: Session,
user: User,
PostForm(form): PostForm<LoginForm>,
) -> Result {
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(),
},
| Some(user_id) => {
if !query.reauthenticate {
return response!(Redirect::to(&query.next.target_path()));
}
let user_card = UserCard::for_local_user(&services, user_id.to_owned()).await;
LoginBody::Authenticated { user_card }
},
};
let mut template = Login::new(&services, body, None);
if let Some(form) = form {
let login_result = match (user_id, form.identifier) {
| (Some(user_id), _) => {
// The user is already authenticated, we need to check their password
services.users.check_password(&user_id, &form.password).await
},
| (None, Some(identifier)) => {
// The user isn't authenticated, we need to log them in
// Yes, this does parse the email twice (handle_login does it again). I don't
// think this really needs to be optimized.
let identifier = if identifier.parse::<lettre::Address>().is_ok() {
UserIdentifier::Email(EmailUserIdentifier::new(identifier))
} else {
UserIdentifier::Matrix(MatrixUserIdentifier::new(identifier))
};
handle_login(&services, Some(&identifier), &form.password, None).await
},
| (None, None) => {
// The user isn't authenticated and didn't supply an identity
return response!(WebError::BadRequest("No identity provided".to_owned()));
},
};
let user_id = match login_result {
| Ok(user_id) => user_id,
| Err(err) => {
let error_message = if let conduwuit_core::Error::Request(_, message, _) = err {
message.into_owned()
} else {
"Internal login error".to_owned()
};
template.login_error = Some(error_message);
return response!(template);
},
};
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");
return response!(Redirect::to(&query.next.target_path()));
}
response!(template)
}
async fn get_logout(session: Session) -> impl IntoResponse {
let _ = session.remove::<OwnedUserId>(User::KEY).await;
Redirect::to("/_continuwuity/account/")
}
+77
View File
@@ -0,0 +1,77 @@
use axum::{
Router,
extract::State,
response::{IntoResponse, Response},
routing::get,
};
use conduwuit_service::threepid::EmailRequirement;
use futures::StreamExt;
use ruma::{OwnedClientSecret, OwnedSessionId};
use serde::{Deserialize, Serialize};
use crate::{
WebError,
pages::components::{DeviceCard, UserCard},
response,
session::{LoginTarget, User},
template,
};
mod cross_signing_reset;
mod deactivate;
mod email;
mod login;
mod password;
pub(crate) fn build() -> Router<crate::State> {
#[allow(clippy::wildcard_imports)]
use self::*;
Router::new()
.route("/", get(get_account))
.merge(login::build())
.nest("/password/", password::build())
.nest("/email/", email::build())
.nest("/cross_signing_reset", cross_signing_reset::build())
.nest("/deactivate", deactivate::build())
}
#[derive(Deserialize, Serialize)]
struct ThreepidQuery {
client_secret: OwnedClientSecret,
session_id: OwnedSessionId,
}
template! {
struct Account use "account.html.j2" {
user_card: UserCard,
email_requirement: EmailRequirement,
email: Option<String>,
devices: Vec<DeviceCard>
}
}
async fn get_account(
State(services): State<crate::State>,
user: User,
) -> Result<Response, WebError> {
let user_id = user.expect(LoginTarget::Account)?;
let email_requirement = services.threepid.email_requirement();
let email = services
.threepid
.get_email_for_localpart(user_id.localpart())
.await
.map(|address| address.to_string());
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
let devices = services
.users
.all_device_ids(&user_id)
.then(async |device_id| DeviceCard::for_device(&services, &user_id, device_id).await)
.collect()
.await;
response!(Account::new(&services, user_card, email_requirement, email, devices))
}
+118
View File
@@ -0,0 +1,118 @@
use axum::{Router, extract::State, routing::on};
use conduwuit_service::users::HashedPassword;
use ruma::UserId;
use validator::{Validate, ValidationError, ValidationErrors};
use crate::{
extract::PostForm,
form,
pages::{
GET_POST, Result,
components::{UserCard, form::Form},
},
response,
session::{LoginTarget, User},
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new().route("/", on(GET_POST, route_change_password))
}
template! {
struct ChangePassword use "change_password.html.j2" {
user_card: UserCard,
body: ChangePasswordBody
}
}
#[derive(Debug)]
enum ChangePasswordBody {
Form(Form<'static>),
Success,
}
form! {
struct ChangePasswordForm {
#[validate(length(min = 1, message = "Current password cannot be empty"))]
current_password: String where {
input_type: "password",
label: "Current password",
autocomplete: "current-password"
},
#[validate(length(min = 1, message = "New 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: "Change password"
}
}
async fn route_change_password(
State(services): State<crate::State>,
user: User,
PostForm(form): PostForm<ChangePasswordForm>,
) -> Result {
let user_id = user.expect(LoginTarget::ChangePassword)?;
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
let body = if let Some(form) = form {
match change_password(&services, &user_id, form).await {
| Ok(()) => ChangePasswordBody::Success,
| Err(errors) => ChangePasswordBody::Form(ChangePasswordForm::with_errors(errors)),
}
} else {
ChangePasswordBody::Form(ChangePasswordForm::build())
};
response!(ChangePassword::new(&services, user_card, body))
}
async fn change_password(
services: &crate::State,
user_id: &UserId,
form: ChangePasswordForm,
) -> Result<(), ValidationErrors> {
form.validate()?;
if services.users.check_password(user_id, &form.current_password)
.await
.is_err()
{
let mut errors = ValidationErrors::new();
errors.add(
"current_password",
ValidationError::new("wrong").with_message("Incorrect password".into()),
);
return Err(errors);
}
match HashedPassword::new(&form.new_password) {
Ok(hash) => {
services.users.set_password(user_id, Some(hash));
},
Err(err) => {
let mut errors = ValidationErrors::new();
errors.add(
"new_password",
ValidationError::new("malformed").with_message(err.message().into()),
);
return Err(errors);
}
}
Ok(())
}
+13
View File
@@ -0,0 +1,13 @@
use axum::Router;
mod change;
mod reset;
pub(crate) fn build() -> Router<crate::State> {
#[allow(clippy::wildcard_imports)]
use self::*;
Router::new()
.nest("/change", change::build())
.nest("/reset/", reset::build())
}
+245
View File
@@ -0,0 +1,245 @@
use axum::{
Router,
extract::{Query, State},
routing::on,
};
use conduwuit_core::warn;
use conduwuit_service::{mailer::messages, threepid::session::ValidationSessions, users::HashedPassword};
use lettre::{Address, message::Mailbox};
use ruma::{ClientSecret, OwnedClientSecret, OwnedSessionId, UserId};
use serde::{Deserialize, Serialize};
use validator::{Validate, ValidationError, ValidationErrors};
use crate::{
WebError,
extract::{Expect, PostForm},
form,
pages::{
GET_POST, Result,
account::ThreepidQuery,
components::{UserCard, form::Form},
},
response,
session::require_active,
template,
};
pub(crate) fn build() -> Router<crate::State> {
Router::new()
.route("/", on(GET_POST, route_reset_password_request))
.route("/validate", on(GET_POST, route_reset_password))
}
template! {
struct ResetPasswordRequest use "reset_password_request.html.j2" {
body: ResetPasswordRequestBody
}
}
#[derive(Debug)]
enum ResetPasswordRequestBody {
Form(Form<'static>),
Unavailable,
}
form! {
struct ResetPasswordRequestForm {
email: Address where {
input_type: "email",
label: "Email address"
}
submit: "Send email"
}
}
async fn route_reset_password_request(
State(services): State<crate::State>,
PostForm(form): PostForm<ResetPasswordRequestForm>,
) -> Result {
// Check if SMTP is configured
if services.mailer.mailer().is_none() {
return response!(ResetPasswordRequest::new(
&services,
ResetPasswordRequestBody::Unavailable
));
}
let Some(form) = form else {
// For GET requests return the reset request form
return response!(ResetPasswordRequest::new(
&services,
ResetPasswordRequestBody::Form(ResetPasswordRequestForm::build())
));
};
let client_secret = ClientSecret::new();
let session_id = async {
let Some(localpart) = services.threepid.get_localpart_for_email(&form.email).await else {
warn!("No user is associated with the email address {}", form.email);
return None;
};
let user_id =
UserId::parse(format!("@{localpart}:{}", services.globals.server_name())).unwrap();
let display_name = services.users.displayname(&user_id).await.ok();
match services
.threepid
.send_validation_email(
Mailbox::new(display_name.clone(), form.email.clone()),
|verification_link| messages::PasswordReset {
display_name: display_name.as_deref(),
user_id: &user_id,
verification_link,
},
&client_secret,
0,
)
.await
{
| Ok(session_id) => Some(session_id),
| Err(err) => {
warn!("Failed to send reset email for {localpart} to {}: {err}", form.email);
None
},
}
}
.await
.unwrap_or_else(|| {
// If we couldn't send an email, generate a random session ID to not give that
// away
ValidationSessions::generate_session_id()
});
response!(ResetPassword::new(&services, ResetPasswordBody::ValidationPending {
client_secret,
session_id,
validation_error: false
}))
}
template! {
struct ResetPassword use "reset_password.html.j2" {
body: ResetPasswordBody
}
}
#[derive(Debug)]
enum ResetPasswordBody {
ValidationPending {
session_id: OwnedSessionId,
client_secret: OwnedClientSecret,
validation_error: bool,
},
ValidationSuccess {
user_card: UserCard,
form: Form<'static>,
},
ResetSuccess {
user_card: UserCard,
},
}
form! {
struct ResetPasswordForm {
#[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"
}
}
#[derive(Deserialize, Serialize)]
struct ResetPasswordQuery {
#[serde(flatten)]
threepid: ThreepidQuery,
}
async fn route_reset_password(
State(services): State<crate::State>,
Expect(Query(query)): Expect<Query<ResetPasswordQuery>>,
PostForm(form): PostForm<ResetPasswordForm>,
) -> Result {
let body = match services
.threepid
.get_valid_session(&query.threepid.session_id, &query.threepid.client_secret)
.await
{
| Ok(session) => {
let Some(localpart) = services
.threepid
.get_localpart_for_email(&session.email)
.await
else {
return Err(WebError::BadRequest("Inapplicable threepid session.".to_owned()));
};
let user_id =
UserId::parse(format!("@{localpart}:{}", services.globals.server_name()))
.unwrap();
require_active(&services, &user_id).await?;
let user_card = UserCard::for_local_user(&services, user_id.clone()).await;
if let Some(form) = form {
if let Err(err) = form.validate() {
ResetPasswordBody::ValidationSuccess {
user_card,
form: ResetPasswordForm::with_errors(err),
}
} else {
match HashedPassword::new(&form.new_password) {
Ok(hash) => {
let _ = session.consume();
services.users.set_password(&user_id, Some(hash));
ResetPasswordBody::ResetSuccess { user_card }
},
Err(err) => {
let mut errors = ValidationErrors::new();
errors.add(
"new_password",
ValidationError::new("malformed").with_message(err.message().into()),
);
ResetPasswordBody::ValidationSuccess {
user_card,
form: ResetPasswordForm::with_errors(errors),
}
}
}
}
} else {
ResetPasswordBody::ValidationSuccess {
user_card,
form: ResetPasswordForm::build(),
}
}
},
| Err(_) => ResetPasswordBody::ValidationPending {
session_id: query.threepid.session_id,
client_secret: query.threepid.client_secret,
validation_error: true,
},
};
response!(ResetPassword::new(&services, body))
}
+40 -9
View File
@@ -1,13 +1,25 @@
use askama::{Template, filters::HtmlSafe};
use validator::ValidationErrors;
use validator::{ValidationError, ValidationErrors};
/// A reusable form component with field validation.
#[derive(Debug, Template)]
#[template(path = "_components/form.html.j2")]
pub(crate) struct Form<'a> {
pub inputs: Vec<FormInput<'a>>,
inputs: Vec<FormInput<'a>>,
submit_label: &'a str,
slowdown: bool,
pub validation_errors: Option<ValidationErrors>,
pub submit_label: &'a str,
}
impl<'a> Form<'a> {
pub(crate) fn new(inputs: Vec<FormInput<'a>>, submit_label: &'a str, slowdown: bool) -> Self {
Self {
inputs,
submit_label,
slowdown,
validation_errors: None,
}
}
}
impl HtmlSafe for Form<'_> {}
@@ -50,6 +62,16 @@ impl Default for FormInput<'_> {
}
}
#[macro_export]
macro_rules! default {
($value:expr) => {
$value
};
() => {
Default::default()
};
}
/// Generate a deserializable struct which may be turned into a [`Form`]
/// for inclusion in another template.
#[macro_export]
@@ -63,6 +85,7 @@ macro_rules! form {
),*
submit: $submit_label:expr
$(, slowdown: $slowdown:expr)?
}
) => {
#[derive(Debug, serde::Deserialize, validator::Validate)]
@@ -77,9 +100,9 @@ macro_rules! form {
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![
fn build() -> $crate::pages::components::form::Form<'static> {
$crate::pages::components::form::Form::new(
vec![
$(
$crate::pages::components::form::FormInput {
id: stringify!($name),
@@ -89,9 +112,17 @@ macro_rules! form {
},
)*
],
validation_errors,
submit_label: $submit_label,
}
$submit_label,
$crate::default!($($slowdown)?)
)
}
/// Generate a [`Form`] with validation errors.
#[allow(unused)]
fn with_errors(errors: validator::ValidationErrors) -> $crate::pages::components::form::Form<'static> {
let mut form = Self::build();
form.validation_errors = Some(errors);
form
}
}
};
+108 -11
View File
@@ -1,8 +1,10 @@
use std::time::SystemTime;
use askama::{Template, filters::HtmlSafe};
use base64::Engine;
use conduwuit_core::result::FlatOk;
use conduwuit_service::{Services, media::mxc::Mxc};
use ruma::UserId;
use conduwuit_core::{result::FlatOk, utils};
use conduwuit_service::{Services, media::mxc::Mxc, oauth::client_metadata::ClientMetadata};
use ruma::{OwnedDeviceId, OwnedUserId, UserId};
pub(super) mod form;
@@ -22,20 +24,20 @@ 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(super) struct UserCard {
pub user_id: OwnedUserId,
pub display_name: Option<String>,
pub avatar_src: Option<String>,
}
impl HtmlSafe for UserCard<'_> {}
impl HtmlSafe for UserCard {}
impl<'a> UserCard<'a> {
pub(super) async fn for_local_user(services: &Services, user_id: &'a UserId) -> Self {
let display_name = services.users.displayname(user_id).await.ok();
impl UserCard {
pub(super) async fn for_local_user(services: &Services, user_id: OwnedUserId) -> 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_url = services.users.avatar_url(&user_id).await.ok()?;
let (server_name, media_id) = avatar_url.parts().ok()?;
let file = services
.media
@@ -57,7 +59,7 @@ impl<'a> UserCard<'a> {
Self { user_id, display_name, avatar_src }
}
fn avatar(&'a self) -> Avatar<'a> {
fn avatar(&self) -> Avatar<'_> {
let avatar_type = if let Some(ref avatar_src) = self.avatar_src {
AvatarType::Image(avatar_src)
} else if let Some(initial) = self
@@ -73,3 +75,98 @@ impl<'a> UserCard<'a> {
Avatar { avatar_type }
}
}
#[derive(Debug, Template)]
#[template(path = "_components/device_card.html.j2")]
pub(super) struct DeviceCard {
pub device_id: OwnedDeviceId,
pub display_name: Option<String>,
pub avatar_src: Option<String>,
pub last_active: String,
pub oauth_metadata: Option<ClientMetadata>,
}
impl HtmlSafe for DeviceCard {}
impl DeviceCard {
pub(super) async fn for_device(
services: &Services,
user_id: &UserId,
device_id: OwnedDeviceId,
) -> Self {
let device = services
.users
.get_device_metadata(user_id, &device_id)
.await
.ok();
let oauth_metadata = async {
let client_id = services.oauth.get_client_id_for_device(&device_id).await?;
Some(
services
.oauth
.get_client_registration(&client_id)
.await
.expect("client should exist"),
)
}
.await;
let display_name = oauth_metadata
.as_ref()
.and_then(|metadata| metadata.client_name.clone())
.or(device
.as_ref()
.and_then(|device| device.display_name.clone()));
let avatar_src = oauth_metadata
.as_ref()
.and_then(|metadata| metadata.logo_uri.as_ref())
.map(|uri| uri.as_str().to_owned());
let last_active = device
.as_ref()
.and_then(|device| device.last_seen_ts)
.map_or_else(
|| "unknown".to_owned(),
|active| {
active
.to_system_time()
.and_then(|t| SystemTime::now().duration_since(t).ok())
.map_or_else(
|| "now".to_owned(),
|duration| format!("{} ago", utils::time::pretty(duration)),
)
},
);
Self {
device_id,
display_name,
avatar_src,
last_active,
oauth_metadata,
}
}
fn avatar(&self) -> Avatar<'_> {
let avatar_type = if let Some(avatar_src) = &self.avatar_src {
AvatarType::Image(avatar_src.as_str())
} else if let Some(initial) = self
.display_name
.as_ref()
.and_then(|name| name.chars().next())
{
if self.oauth_metadata.is_some() {
AvatarType::Initial(initial)
} else {
AvatarType::Initial('❖')
}
} else {
AvatarType::Initial('?')
};
Avatar { avatar_type }
}
}
+1 -1
View File
@@ -5,7 +5,7 @@ use crate::{WebError, template};
pub(crate) fn build() -> Router<crate::State> {
Router::new()
.route("/", get(index))
.route("/_continuwuity/", get(index))
.route(&format!("{}/", crate::ROUTE_PREFIX), get(index))
}
async fn index(State(services): State<crate::State>) -> Result<impl IntoResponse, WebError> {
+23 -1
View File
@@ -1,10 +1,18 @@
use axum::{response::Response, routing::MethodFilter};
use crate::WebError;
pub(super) mod account;
mod components;
pub(super) mod debug;
pub(super) mod index;
pub(super) mod password_reset;
pub(super) mod resources;
pub(super) mod threepid;
type Result<T = Response, E = WebError> = std::result::Result<T, E>;
const GET_POST: MethodFilter = MethodFilter::GET.or(MethodFilter::POST);
#[derive(Debug)]
pub(crate) struct TemplateContext {
pub allow_indexing: bool,
@@ -27,6 +35,7 @@ macro_rules! template {
) => {
#[derive(Debug, askama::Template)]
#[template(path = $path)]
#[allow(clippy::useless_let_if_seq)]
struct $name$(<$lifetime>)? {
context: $crate::pages::TemplateContext,
$($field_name: $field_type,)*
@@ -54,3 +63,16 @@ macro_rules! template {
}
};
}
#[macro_export]
macro_rules! response {
(BadRequest($body:expr)) => {
response!((axum::http::StatusCode::BAD_REQUEST, $body))
};
($body:expr) => {{
use axum::response::IntoResponse;
Ok($body.into_response())
}};
}
-119
View File
@@ -1,119 +0,0 @@
use axum::{
Router,
extract::{
Query, State,
rejection::{FormRejection, QueryRejection},
},
http::StatusCode,
response::{IntoResponse, Response},
routing::get,
};
use serde::Deserialize;
use validator::Validate;
use crate::{
WebError, form,
pages::components::{UserCard, form::Form},
template,
};
const INVALID_TOKEN_ERROR: &str = "Invalid reset token. Your reset link may have expired.";
template! {
struct PasswordReset<'a> use "password_reset.html.j2" {
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("/account/reset_password", get(get_password_reset).post(post_password_reset))
}
#[derive(Deserialize)]
struct PasswordResetQuery {
token: String,
}
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_TOKEN_ERROR.to_owned()));
};
let user_card = UserCard::for_local_user(&services, &token.info.user).await;
Ok(PasswordReset::new(&services, user_card, PasswordResetBody::Form(reset_form))
.into_response())
}
async fn get_password_reset(
State(services): State<crate::State>,
query: Result<Query<PasswordResetQuery>, QueryRejection>,
) -> Result<impl IntoResponse, WebError> {
let Query(query) = query?;
password_reset_form(services, query, PasswordResetForm::build(None)).await
}
async fn post_password_reset(
State(services): State<crate::State>,
query: Result<Query<PasswordResetQuery>, QueryRejection>,
form: Result<axum::Form<PasswordResetForm>, FormRejection>,
) -> Result<Response, WebError> {
let Query(query) = query?;
let axum::Form(form) = form?;
match form.validate() {
| Ok(()) => {
let Some(token) = services.password_reset.check_token(&query.token).await else {
return Err(WebError::BadRequest(INVALID_TOKEN_ERROR.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;
Ok(PasswordReset::new(&services, user_card, PasswordResetBody::Success)
.into_response())
},
| Err(err) => Ok((
StatusCode::BAD_REQUEST,
password_reset_form(services, query, PasswordResetForm::build(Some(err))).await,
)
.into_response()),
}
}
+90 -7
View File
@@ -9,6 +9,7 @@
--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);
--avatar-color: var(--c2);
--name-lightness: 0.45;
--background-lightness: 0.9;
@@ -26,7 +27,7 @@
@media (prefers-color-scheme: dark) {
color-scheme: dark;
--text-color: #fff;
--text-color: #f5ebeb;
--secondary: #888;
--bg: oklch(0.15 0.042 317.27);
--panel-bg: oklch(0.24 0.03 317.27);
@@ -54,10 +55,13 @@
}
body {
display: grid;
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-around;
margin: 0;
padding: 0;
place-items: center;
min-height: 100vh;
color: var(--text-color);
@@ -73,6 +77,7 @@ html {
footer {
padding-inline: 0.25rem;
margin-top: 1rem;
height: max(fit-content, 2rem);
.logo {
@@ -83,12 +88,24 @@ footer {
p {
margin: 1rem 0;
a {
white-space: nowrap;
}
}
section {
margin: 1rem 0;
}
em {
color: oklch(from var(--c2) var(--name-lightness) c h);
font-weight: bold;
font-style: normal;
&.negative {
color: red;
}
}
small {
@@ -112,34 +129,79 @@ small.error {
background-color: var(--panel-bg);
padding-inline: 1.5rem;
padding-block: 1rem;
margin-top: 1em;
margin-bottom: auto;
box-shadow: 0 0.25em 0.375em hsla(0, 0%, 0%, 0.1);
&.middle {
margin-top: 0;
margin-bottom: 0;
}
&.narrow {
--preferred-width: 12rem + 20dvw;
--maximum-width: 36rem;
input, button {
input, button, a.button {
width: 100%;
}
}
&:not(.narrow) form p {
margin-bottom: 0;
}
}
img.matrix-icon {
@media (prefers-color-scheme: dark) {
filter: invert();
}
}
h1.with-matrix-icon {
display: flex;
align-items: center;
a:last-of-type {
margin-left: auto;
img {
height: 1em;
}
}
}
h1 a.back {
font-size: initial;
font-weight: initial;
}
label {
display: block;
}
input, button {
a, a:visited {
color: oklch(from var(--c1) var(--name-lightness) c h);
}
input, button, a.button {
display: inline-block;
padding: 0.5em;
margin-bottom: 0.5em;
font-size: inherit;
font-family: inherit;
line-height: normal;
color: white;
text-decoration: none;
background-color: transparent;
border: none;
border-radius: var(--border-radius-sm);
&:visited {
color: white;
}
}
input {
@@ -151,14 +213,29 @@ input {
}
}
button {
input[type="checkbox"] {
display: inline;
margin: 0;
}
button, a.button {
background-color: var(--c1);
transition: opacity .2s;
text-align: center;
&:enabled:hover {
opacity: 0.8;
cursor: pointer;
}
&:disabled {
color: lightgray;
background-color: gray;
}
&:not(:disabled) {
transition: linear color, background-color 0.1s;
}
}
h1 {
@@ -166,6 +243,11 @@ h1 {
margin-bottom: 0.67em;
}
.fullwidth {
width: 100%;
margin-bottom: 0 !important;
}
@media (max-width: 425px) {
main {
padding-block-start: 2rem;
@@ -175,11 +257,12 @@ h1 {
.panel {
border-radius: 0;
width: 100%;
margin-top: 0;
}
}
@media (max-width: 799px) {
input, button {
input, button, a.button {
width: 100%;
}
}
+20 -7
View File
@@ -11,12 +11,17 @@
font-size: calc(var(--avatar-size) * 0.5);
font-weight: 700;
line-height: calc(var(--avatar-size) - 2px);
user-select: none;
color: oklch(from var(--c1) calc(l + 0.2) c h);
background-color: var(--c1);
color: oklch(from var(--avatar-color) calc(l + 0.2) c h);
background-color: var(--avatar-color);
}
.user-card {
.green-avatar {
--avatar-color: var(--c1);
}
.card {
display: flex;
flex-direction: row;
align-items: center;
@@ -32,13 +37,21 @@
p {
margin: 0;
&.display-name {
&.name {
font-weight: 700;
}
}
&:nth-of-type(2) {
color: var(--secondary);
}
.id {
color: var(--secondary);
font-weight: normal;
}
}
}
.card-list {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 10px;
}
+7 -1
View File
@@ -2,12 +2,18 @@
font-family: monospace;
font-size: x-small;
font-weight: 700;
transform: translate(1rem, 1.6rem);
transform: translate(0rem, 2rem);
color: var(--secondary);
user-select: none;
margin: 0;
padding: 0;
}
h1 {
display: flex;
align-items: center;
}
code {
white-space: pre-wrap;
}
+5
View File
@@ -0,0 +1,5 @@
.reset-password {
display: flex;
width: 100%;
justify-content: right;
}
+14
View File
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 19.1.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 520 520" style="enable-background:new 0 0 520 520;" xml:space="preserve">
<path d="M13.7,11.9v496.2h35.7V520H0V0h49.4v11.9H13.7z"/>
<path d="M166.3,169.2v25.1h0.7c6.7-9.6,14.8-17,24.2-22.2c9.4-5.3,20.3-7.9,32.5-7.9c11.7,0,22.4,2.3,32.1,6.8
c9.7,4.5,17,12.6,22.1,24c5.5-8.1,13-15.3,22.4-21.5c9.4-6.2,20.6-9.3,33.5-9.3c9.8,0,18.9,1.2,27.3,3.6c8.4,2.4,15.5,6.2,21.5,11.5
c6,5.3,10.6,12.1,14,20.6c3.3,8.5,5,18.7,5,30.7v124.1h-50.9V249.6c0-6.2-0.2-12.1-0.7-17.6c-0.5-5.5-1.8-10.3-3.9-14.3
c-2.2-4.1-5.3-7.3-9.5-9.7c-4.2-2.4-9.9-3.6-17-3.6c-7.2,0-13,1.4-17.4,4.1c-4.4,2.8-7.9,6.3-10.4,10.8c-2.5,4.4-4.2,9.4-5,15.1
c-0.8,5.6-1.3,11.3-1.3,17v103.3h-50.9v-104c0-5.5-0.1-10.9-0.4-16.3c-0.2-5.4-1.3-10.3-3.1-14.9c-1.8-4.5-4.8-8.2-9-10.9
c-4.2-2.7-10.3-4.1-18.5-4.1c-2.4,0-5.6,0.5-9.5,1.6c-3.9,1.1-7.8,3.1-11.5,6.1c-3.7,3-6.9,7.3-9.5,12.9c-2.6,5.6-3.9,13-3.9,22.1
v107.6h-50.9V169.2H166.3z"/>
<path d="M506.3,508.1V11.9h-35.7V0H520v520h-49.4v-11.9H506.3z"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+7
View File
@@ -0,0 +1,7 @@
const SLOWDOWN_TIMEOUT = 5 * 1000;
document.querySelectorAll(".slowdown").forEach((element) => element.setAttribute("disabled", ""));
setTimeout(() => {
document.querySelectorAll(".slowdown").forEach((element) => element.removeAttribute("disabled"));
}, SLOWDOWN_TIMEOUT);
@@ -0,0 +1,19 @@
<div class="card">
{{ avatar() }}
<div class="info">
<p class="name">
{% if let Some(display_name) = display_name %}
{{ display_name }}
{% else %}
Unknown device
{% endif %}
&nbsp;<span class="id">{{ device_id }}</span>
</p>
<p>
Last active: {{ last_active }}
{% if let Some(metadata) = oauth_metadata %}
&nbsp;&bullet;&nbsp;<a href="{{ metadata.client_uri }}">Client information</a>
{% endif %}
</p>
</div>
</div>
@@ -1,30 +1,50 @@
{% macro errors(field_errors, name) %}
{% 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 %}
{% endmacro %}
<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 %}
{% if input.input_type == "checkbox" %}
<label for="{{ input.id }}">
<input
type="checkbox"
id="{{ input.id }}"
{% if input.type_name.is_some() %}name="{{ input.id }}"{% endif %}
{% if input.required %}required{% endif %}
>
{{ input.label }}
</label>
{{ errors(field_errors, name) }}
{% else %}
<label for="{{ input.id }}">{{ input.label }}</label>
{{ errors(field_errors, name) }}
<input
type="{{ input.input_type }}"
id="{{ input.id }}"
autocomplete="{{ input.autocomplete }}"
{% if input.type_name.is_some() %}name="{{ input.id }}"{% endif %}
{% if input.required %}required{% endif %}
>
{% endif %}
<input
type="{{ input.input_type }}"
id="{{ input.id }}"
autocomplete="{{ input.autocomplete }}"
{% if input.type_name.is_some() %}name="{{ input.id }}"{% endif %}
{% if input.required %}required{% endif %}
>
</p>
{% endfor %}
<button type="submit">{{ submit_label }}</button>
<button type="submit"{% if slowdown %} class="slowdown"{% endif %}>{{ submit_label }}</button>
{% if slowdown %}
<script src="{{ crate::ROUTE_PREFIX }}/resources/slowdown.js"></script>
{% endif %}
</form>
@@ -1,9 +1,9 @@
<div class="user-card">
<div class="card green-avatar">
{{ avatar() }}
<div class="info">
{% if let Some(display_name) = display_name %}
<p class="display-name">{{ display_name }}</p>
<p class="name">{{ display_name }}</p>
{% endif %}
<p class="user_id">{{ user_id }}</p>
<p class="id">{{ user_id }}</p>
</div>
</div>
+5 -5
View File
@@ -9,17 +9,17 @@
<meta name="robots" content="noindex" />
{%- endif %}
<link rel="icon" href="/_continuwuity/resources/logo.svg">
<link rel="stylesheet" href="/_continuwuity/resources/common.css">
<link rel="stylesheet" href="/_continuwuity/resources/components.css">
<link rel="icon" href="{{ crate::ROUTE_PREFIX }}/resources/logo.svg">
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/common.css">
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/components.css">
{% block head %}{% endblock %}
</head>
<body>
<main>{%~ block content %}{% endblock ~%}</main>
{%~ block content %}{% endblock ~%}
{%~ block footer ~%}
<footer>
<img class="logo" src="/_continuwuity/resources/logo.svg">
<img class="logo" src="{{ crate::ROUTE_PREFIX }}/resources/logo.svg">
<p>Powered by <a href="https://continuwuity.org">Continuwuity</a> {{ env!("CARGO_PKG_VERSION") }}
{%~ if let Some(version_info) = conduwuit_build_metadata::version_tag() ~%}
{%~ if let Some(url) = conduwuit_build_metadata::GIT_REMOTE_COMMIT_URL.or(conduwuit_build_metadata::GIT_REMOTE_WEB_URL) ~%}
+53
View File
@@ -0,0 +1,53 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Your account
{%- endblock -%}
{%- block content -%}
<div class="panel">
<h1>Manage your account</h1>
{{ user_card }}
<section>
{% if email_requirement.may_change() %}
<p>
{% if let Some(email) = email %}
Your account's associated email address is <code>{{ email }}</code>.
{% else %}
Your account has no associated email address.
{% endif %}
<a href="email/change/">Change your email</a>
</p>
{% endif %}
<p>
<a href="password/change">Change your password</a>
</p>
</section>
<section>
<a class="button fullwidth" href="logout">Log out</a>
</section>
<section>
<details>
<summary>Your devices ({{ devices.len() }})</summary>
<div class="card-list">
{% for device in devices %}
{{ device }}
{% endfor %}
</div>
</details>
</section>
<section>
<details>
<summary>Danger zone</summary>
<p>
Settings here <em class="negative">may affect the integrity of your account</em>.
</p>
<a href="cross_signing_reset">Reset your digital identity</a> &bullet;
<a href="deactivate">Deactivate your account</a>
</details>
</section>
</div>
{%- endblock -%}
@@ -0,0 +1,33 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Change your email
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1>Change your email</h1>
{{ user_card }}
{% match body %}
{% when ChangeEmailBody::ValidationPending { session_id, client_secret, validation_error } %}
<p>
A message has been sent to your new email address with a validation link. 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>
{% when ChangeEmailBody::Success %}
<p>
Your email address has been changed successfully. <a href="{{ crate::ROUTE_PREFIX }}/account/">Back</a>
</p>
{% endmatch %}
</div>
{%- endblock -%}
@@ -0,0 +1,35 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Change your email
{%- endblock -%}
{%- block content -%}
<div class="panel">
<h1>Change your email <a class="back" href="{{ crate::ROUTE_PREFIX }}/account/">Back</a></h1>
{{ user_card }}
<p>
Your email address will be used for automated emails, such as password reset requests. It is also
visible to your homeserver's administrator, who may use it to contact you directly.
</p>
<p>
{% if let Some(email) = email %}
Your account's associated email address is <code>{{ email }}</code>.
To change your email address, enter your new address below.
{% else %}
Your account has no associated email address. To add an email address, enter it below.
{% endif %}
</p>
{{ form }}
{% if may_remove %}
<p>
You may remove your email address. Note that, if your account has no email address,
you will not be able to reset your password if you forget it.
</p>
<form method="post" action="delete">
<button type="submit">Remove your email address</button>
</form>
{% endif %}
</div>
{% endblock %}
@@ -0,0 +1,25 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/login.css">
{%- endblock -%}
{%- block title -%}
Change your password
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1>Change your password</h1>
{{ user_card }}
{% match body %}
{% when ChangePasswordBody::Form(reset_form) %}
{{ reset_form }}
<a class="reset-password" href="reset/"><i>Forgot your password?</i></a>
{% when ChangePasswordBody::Success %}
<p>
Your password has been changed successfully. <a href="{{ crate::ROUTE_PREFIX }}/account/">Back</a>
</p>
{% endmatch %}
</div>
{%- endblock -%}
@@ -0,0 +1,43 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Reset your digital identity
{%- endblock -%}
{%- block content -%}
<div class="panel">
<h1>Reset your digital identity <a class="back" href="{{ crate::ROUTE_PREFIX }}/account/">Back</a></h1>
{{ user_card }}
{% match body %}
{% when CrossSigningResetBody::Form %}
<p>
If you've lost your end-to-end encryption recovery key, you need to reset your digital identity to continue
using end-to-end encryption.
</p>
<p>
<b>You don't need to do this</b> if you still have access to a confirmed device. You can use that device
to change your recovery key without resetting your digital identity. Only reset your digital identity if you are
absolutely sure that you have lost your recovery key and can't use any of your confirmed devices.
</p>
<p>
What will happen:
<ul>
<li>✅ Your account information, joined chatrooms, and preferences will not change.</li>
<li>⚠️ You will <em class="negative">permanently lose access</em> to your encrypted message history.</li>
<li>⚠️ You will need to confirm your devices and verify your contacts again.</li>
</ul>
</p>
<form method="post">
<button type="submit" class="slowdown">I understand, begin the reset process</button>
</form>
<script src="{{ crate::ROUTE_PREFIX }}/resources/slowdown.js"></script>
{% when CrossSigningResetBody::Success %}
<p>
The identity reset has been approved for the next ten minutes.
Return to your Matrix client to finish resetting your identity.
Remember that you will <em class="negative">permanently lose access</em>
to your encrypted message history if you continue.
</p>
{% endmatch %}
</div>
{% endblock %}
@@ -0,0 +1,37 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Deactivate your account
{%- endblock -%}
{%- block content -%}
<div class="panel">
<h1>Deactivate your account <a class="back" href="{{ crate::ROUTE_PREFIX }}/account/">Back</a></h1>
{% match body %}
{% when DeactivateBody::Form { user_id, user_card, form } %}
{{ user_card }}
<p>
<em class="negative">Please read this carefully. Deactivating your account is a permanent action.</em>
</p>
<p>
What will happen:
<ul>
<li>Your account will be <em class="negative">permanently locked.</em>
You will not be able to reactivate it or sign back in.</em>
<li>Nobody, including you, will <b>ever</b> be able to re-use the user ID <code>{{ user_id }}</code>.</li>
<li>Your profile information will be wiped from the server.</li>
<li>You will be removed from all chatrooms and direct messages you are in.</li>
</ul>
</p>
<p>
Your messages will remain in chatrooms you were participating in.
</p>
<hr>
{{ form }}
{% when DeactivateBody::Success %}
<p>
Your account has been deactivated and you have been signed out of Matrix.
</p>
{% endmatch %}
</div>
{% endblock %}
@@ -0,0 +1,15 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Change your email
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1>Change your email</h1>
{{ user_card }}
<p>
Your email address has been removed. <a href="{{ crate::ROUTE_PREFIX }}/account/">Back</a>
</p>
</div>
{% endblock %}
+28 -26
View File
@@ -1,7 +1,7 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="/_continuwuity/resources/error.css">
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/error.css">
{%- endblock -%}
{%- block title -%}
@@ -9,33 +9,35 @@
{%- endblock -%}
{%- block content -%}
<pre class="k10y" aria-hidden>
      />  
      |  _  _|
     ` ミ_x
     /      |
    /   
    │  | | |
 / ̄|   | | |
 | ( ̄ヽ__ヽ_)__)
 \二つ
</pre>
<div class="panel">
<h1>
{% if status == StatusCode::NOT_FOUND %}
Not found
{% else if status == StatusCode::INTERNAL_SERVER_ERROR %}
Internal server error
{% else %}
Bad request
<div class="error-body">
<pre class="k10y" aria-hidden>
      />  
      |  _  _|
     ` ミ_x
     /      |
    /  ヽ  
    │  | | |
 / ̄|   | | |
 | ( ̄ヽ__ヽ_)__)
 \二つ
</pre>
<div class="panel middle">
<h1>
{% if status == StatusCode::NOT_FOUND %}
Not found
{% else if status == StatusCode::INTERNAL_SERVER_ERROR %}
Internal server error
{% else %}
Bad request
{% endif %}
</h1>
{% if status == StatusCode::INTERNAL_SERVER_ERROR %}
<p>Please <a href="https://forgejo.ellis.link/continuwuation/continuwuity/issues/new">submit a bug report</a> 🥺</p>
{% endif %}
</h1>
{% if status == StatusCode::INTERNAL_SERVER_ERROR %}
<p>Please <a href="https://forgejo.ellis.link/continuwuation/continuwuity/issues/new">submit a bug report</a> 🥺</p>
{% endif %}
<pre><code>{{ error }}</code></pre>
<pre style="white-space: pre-wrap"><code>{{ error }}</code></pre>
</div>
</div>
{%- endblock -%}
+3 -2
View File
@@ -1,11 +1,11 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="/_continuwuity/resources/index.css">
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/index.css">
{%- endblock -%}
{%- block content -%}
<div class="panel">
<div class="panel middle">
<h1>
Welcome to <a class="project-name" href="https://continuwuity.org">Continuwuity</a>!
</h1>
@@ -15,6 +15,7 @@
<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><a href="{{ crate::ROUTE_PREFIX }}/account/">Manage your account</a></p>
{%- endif %}
</div>
+53
View File
@@ -0,0 +1,53 @@
{% extends "_layout.html.j2" %}
{%- block head -%}
<link rel="stylesheet" href="{{ crate::ROUTE_PREFIX }}/resources/login.css">
{%- endblock -%}
{%- block title -%}
Log in
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
{% match body %}
{% when LoginBody::Unauthenticated { server_name } %}
<h1 class="with-matrix-icon">
Log in to Matrix
<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>
<p>
You're about to log in to your account on <em>{{ server_name }}</em>
</p>
<hr>
<form method="post">
<p>
<label for="identifier">Username or email address</label>
<input type="text" name="identifier" autocomplete="username">
</p>
<p>
<label for="password">Password</label>
<input type="password" name="password" autocomplete="current-password">
</p>
<button type="submit">Log in</button>
</form>
{% when LoginBody::Authenticated { user_card } %}
<h1>Confirm your identity</h1>
{{ user_card }}
<p>Enter your password to continue.</p>
<form method="post">
<p>
<label for="password">Password</label>
<input type="password" name="password" autocomplete="current-password">
</p>
<button type="submit">Continue</button>
</form>
{% 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 -%}
@@ -1,18 +0,0 @@
{% 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 -%}
@@ -0,0 +1,36 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Reset your password
{%- endblock -%}
{%- block content -%}
<div class="panel narrow">
<h1>Reset your password</h1>
{% match body %}
{% when ResetPasswordBody::ValidationPending { session_id, client_secret, validation_error } %}
<p>
Check your inbox for the validation email. If you do not receive the email:
<ul>
<li>Check your spam filter.</li>
<li>Your Matrix account may not be associated with an email address. Contact your homeserver's
administrator for assistance.</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>
{% when ResetPasswordBody::ValidationSuccess { user_card, form } %}
{{ user_card }}
{{ form }}
{% when ResetPasswordBody::ResetSuccess { user_card } %}
{{ user_card }}
<p>Your password has been reset successfully.</p>
{% endmatch %}
</div>
{%- endblock -%}
@@ -0,0 +1,33 @@
{% extends "_layout.html.j2" %}
{%- block title -%}
Reset your password
{%- endblock -%}
{%- block content -%}
{% decl body_class -%}
{% if let ResetPasswordRequestBody::Unavailable = body -%}
{% let body_class = "panel middle" -%}
{% else -%}
{% let body_class = "panel" -%}
{% endif -%}
<div class="{{ body_class }}">
<h1>Reset your password</h1>
{% match body %}
{% when ResetPasswordRequestBody::Form(form) %}
<p>
To reset your password, enter your email below. If your Matrix account has an associated email address,
you will receive an email with a link to reset your password.
</p>
<p>
If your Matrix account does not have an associated email address, contact your homeserver's administrator
to reset your password.
</p>
{{ form }}
{% when ResetPasswordRequestBody::Unavailable %}
<p>
To reset your password, contact your homeserver's administrator.
</p>
{% endmatch %}
</div>
{%- endblock -%}
@@ -1,8 +1,12 @@
{% extends "_layout.html.j2" %}
{% block title %}
Email verification
{% endblock %}
{%- block content -%}
<div class="panel">
<div class="panel middle">
<h1>Email verification</h1>
<p>Your email address has been verified. Return to your Matrix client to continue.</p>
<p>Your email address has been verified. Please continue in the original application.</p>
</div>
{%- endblock content -%}