mirror of
https://forgejo.ellis.link/continuwuation/continuwuity.git
synced 2026-05-26 20:49:55 +00:00
feat: Implement a web-based account management dashboard
This commit is contained in:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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/")
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
@@ -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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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())
|
||||
}};
|
||||
}
|
||||
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
.reset-password {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: right;
|
||||
}
|
||||
@@ -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 |
@@ -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 %}
|
||||
<span class="id">{{ device_id }}</span>
|
||||
</p>
|
||||
<p>
|
||||
Last active: {{ last_active }}
|
||||
{% if let Some(metadata) = oauth_metadata %}
|
||||
• <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>
|
||||
|
||||
@@ -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) ~%}
|
||||
|
||||
@@ -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> •
|
||||
<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 %}
|
||||
@@ -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 -%}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 -%}
|
||||
|
||||
Reference in New Issue
Block a user