feat: Improve the initial setup experience

- Issue a single-use token for initial account creation
- Disable registration through other methods until the first account is made
- Print helpful instructions to the console on the first run
- Improve the welcome message sent in the admin room on first run
This commit is contained in:
Ginger 2026-02-12 18:24:24 -05:00 committed by Ellis Git
parent 11a088be5d
commit 047eba0442
14 changed files with 373 additions and 143 deletions

2
Cargo.lock generated
View file

@ -1230,6 +1230,7 @@ dependencies = [
name = "conduwuit_service"
version = "0.5.4"
dependencies = [
"askama 0.14.0",
"async-trait",
"base64 0.22.1",
"blurhash",
@ -1264,6 +1265,7 @@ dependencies = [
"tracing",
"url",
"webpage",
"yansi",
]
[[package]]

View file

@ -549,6 +549,12 @@ features = ["sync", "tls-rustls", "rustls-provider"]
[workspace.dependencies.resolv-conf]
version = "0.7.5"
[workspace.dependencies.yansi]
version = "1.0.1"
[workspace.dependencies.askama]
version = "0.14.0"
#
# Patches
#

View file

@ -167,27 +167,8 @@ pub(super) async fn create_user(&self, username: String, password: Option<String
// we dont add a device since we're not the user, just the creator
// if this account creation is from the CLI / --execute, invite the first user
// to admin room
if let Ok(admin_room) = self.services.admin.get_admin_room().await {
if self
.services
.rooms
.state_cache
.room_joined_count(&admin_room)
.await
.is_ok_and(is_equal_to!(1))
{
self.services
.admin
.make_user_admin(&user_id)
.boxed()
.await?;
warn!("Granting {user_id} admin privileges as the first user");
}
} else {
debug!("create_user admin command called without an admin room being available");
}
// Make the first user to register an administrator and disable first-run mode.
self.services.firstrun.empower_first_user(&user_id).await?;
self.write_str(&format!("Created user with user_id: {user_id} and password: `{password}`"))
.await

View file

@ -148,7 +148,12 @@ pub(crate) async fn register_route(
let is_guest = body.kind == RegistrationKind::Guest;
let emergency_mode_enabled = services.config.emergency_password.is_some();
if !services.config.allow_registration && body.appservice_info.is_none() {
// Allow registration if it's enabled in the config file or if this is the first
// run (so the first user account can be created)
let allow_registration =
services.config.allow_registration || services.firstrun.is_first_run();
if !allow_registration && body.appservice_info.is_none() {
match (body.username.as_ref(), body.initial_device_display_name.as_ref()) {
| (Some(username), Some(device_display_name)) => {
info!(
@ -302,54 +307,63 @@ pub(crate) async fn register_route(
let skip_auth = body.appservice_info.is_some() || is_guest;
// Populate required UIAA flows
if services
.registration_tokens
.iterate_tokens()
.next()
.await
.is_some()
{
// Registration token required
if services.firstrun.is_first_run() {
// Registration token forced while in first-run mode
uiaainfo.flows.push(AuthFlow {
stages: vec![AuthType::RegistrationToken],
});
}
if services.config.recaptcha_private_site_key.is_some() {
if let Some(pubkey) = &services.config.recaptcha_site_key {
// ReCaptcha required
uiaainfo
.flows
.push(AuthFlow { stages: vec![AuthType::ReCaptcha] });
uiaainfo.params = serde_json::value::to_raw_value(&serde_json::json!({
"m.login.recaptcha": {
"public_key": pubkey,
},
}))
.expect("Failed to serialize recaptcha params");
}
}
if uiaainfo.flows.is_empty() && !skip_auth {
// Registration isn't _disabled_, but there's no captcha configured and no
// registration tokens currently set. Bail out by default unless open
// registration was explicitly enabled.
if !services
.config
.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse
} else {
if services
.registration_tokens
.iterate_tokens()
.next()
.await
.is_some()
{
return Err!(Request(Forbidden(
"This server is not accepting registrations at this time."
)));
// Registration token required
uiaainfo.flows.push(AuthFlow {
stages: vec![AuthType::RegistrationToken],
});
}
// We have open registration enabled (😧), provide a dummy stage
uiaainfo = UiaaInfo {
flows: vec![AuthFlow { stages: vec![AuthType::Dummy] }],
completed: Vec::new(),
params: Box::default(),
session: None,
auth_error: None,
};
if services.config.recaptcha_private_site_key.is_some() {
if let Some(pubkey) = &services.config.recaptcha_site_key {
// ReCaptcha required
uiaainfo
.flows
.push(AuthFlow { stages: vec![AuthType::ReCaptcha] });
uiaainfo.params = serde_json::value::to_raw_value(&serde_json::json!({
"m.login.recaptcha": {
"public_key": pubkey,
},
}))
.expect("Failed to serialize recaptcha params");
}
}
if uiaainfo.flows.is_empty() && !skip_auth {
// Registration isn't _disabled_, but there's no captcha configured and no
// registration tokens currently set. Bail out by default unless open
// registration was explicitly enabled.
if !services
.config
.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse
{
return Err!(Request(Forbidden(
"This server is not accepting registrations at this time."
)));
}
// We have open registration enabled (😧), provide a dummy stage
uiaainfo = UiaaInfo {
flows: vec![AuthFlow { stages: vec![AuthType::Dummy] }],
completed: Vec::new(),
params: Box::default(),
session: None,
auth_error: None,
};
}
}
if !skip_auth {
@ -507,39 +521,29 @@ pub(crate) async fn register_route(
}
}
// If this is the first real user, grant them admin privileges except for guest
// users
// Note: the server user is generated first
if !is_guest {
if let Ok(admin_room) = services.admin.get_admin_room().await {
if services
.rooms
.state_cache
.room_joined_count(&admin_room)
.await
.is_ok_and(is_equal_to!(1))
{
services.admin.make_user_admin(&user_id).boxed().await?;
warn!("Granting {user_id} admin privileges as the first user");
} else if services.config.suspend_on_register {
// This is not an admin, suspend them.
// Note that we can still do auto joins for suspended users
// Make the first user to register an administrator and disable first-run mode.
let was_first_user = services.firstrun.empower_first_user(&user_id).await?;
// If the registering user was not the first and we're suspending users on
// register, suspend them.
if !was_first_user && services.config.suspend_on_register {
// Note that we can still do auto joins for suspended users
services
.users
.suspend_account(&user_id, &services.globals.server_user)
.await;
// And send an @room notice to the admin room, to prompt admins to review the
// new user and ideally unsuspend them if deemed appropriate.
if services.server.config.admin_room_notices {
services
.users
.suspend_account(&user_id, &services.globals.server_user)
.await;
// And send an @room notice to the admin room, to prompt admins to review the
// new user and ideally unsuspend them if deemed appropriate.
if services.server.config.admin_room_notices {
services
.admin
.send_loud_message(RoomMessageEventContent::text_plain(format!(
"User {user_id} has been suspended as they are not the first user \
on this server. Please review and unsuspend them if appropriate."
)))
.await
.ok();
}
.admin
.send_loud_message(RoomMessageEventContent::text_plain(format!(
"User {user_id} has been suspended as they are not the first user on \
this server. Please review and unsuspend them if appropriate."
)))
.await
.ok();
}
}
}

View file

@ -79,6 +79,7 @@ zstd_compression = [
]
[dependencies]
askama.workspace = true
async-trait.workspace = true
base64.workspace = true
bytes.workspace = true
@ -118,6 +119,7 @@ webpage.optional = true
blurhash.workspace = true
blurhash.optional = true
recaptcha-verify = { version = "0.1.5", default-features = false }
yansi.workspace = true
[target.'cfg(all(unix, target_os = "linux"))'.dependencies]
sd-notify.workspace = true

View file

@ -26,7 +26,7 @@ pub(super) async fn console_auto_stop(&self) {
/// Execute admin commands after startup
#[implement(super::Service)]
pub(super) async fn startup_execute(&self) -> Result {
pub(crate) async fn startup_execute(&self) -> Result {
// List of commands to execute
let commands = &self.services.server.config.admin_execute;

View file

@ -126,23 +126,6 @@ pub async fn make_user_admin(&self, user_id: &UserId) -> Result {
}
}
if self.services.server.config.admin_room_notices {
let welcome_message = String::from(
"## Thank you for trying out Continuwuity!\n\nContinuwuity is a hard fork of conduwuit, which is also a hard fork of Conduit, currently in Beta. The Beta status initially was inherited from Conduit, however overtime this Beta status is rapidly becoming less and less relevant as our codebase significantly diverges more and more. Continuwuity is quite stable and very usable as a daily driver and for a low-medium sized homeserver. There is still a lot of more work to be done, but it is in a far better place than the project was in early 2024.\n\nHelpful links:\n> Source code: https://forgejo.ellis.link/continuwuation/continuwuity\n> Documentation: https://continuwuity.org/\n> Report issues: https://forgejo.ellis.link/continuwuation/continuwuity/issues\n\nFor a list of available commands, send the following message in this room: `!admin --help`\n\nHere are some rooms you can join (by typing the command into your client) -\n\nContinuwuity space: `/join #space:continuwuity.org`\nContinuwuity main room (Ask questions and get notified on updates): `/join #continuwuity:continuwuity.org`\nContinuwuity offtopic room: `/join #offtopic:continuwuity.org`",
);
// Send welcome message
self.services
.timeline
.build_and_append_pdu(
PduBuilder::timeline(&RoomMessageEventContent::text_markdown(welcome_message)),
server_user,
Some(&room_id),
&state_lock,
)
.await?;
}
Ok(())
}

View file

@ -137,7 +137,6 @@ impl crate::Service for Service {
let mut signals = self.services.server.signal.subscribe();
let receiver = self.channel.1.clone();
self.startup_execute().await?;
self.console_auto_start().await;
loop {

View file

@ -1,13 +1,19 @@
use std::sync::{
Arc, OnceLock,
atomic::{AtomicBool, Ordering},
use std::{
io::IsTerminal,
sync::{Arc, OnceLock},
};
use askama::Template;
use async_trait::async_trait;
use conduwuit::Result;
use conduwuit::{Result, info, utils::ReadyExt, warn};
use futures::StreamExt;
use ruma::{UserId, events::room::message::RoomMessageEventContent};
use crate::{Dep, config, users};
use crate::{
Dep, admin, config, globals,
registration_tokens::{self, ValidToken, ValidTokenSource},
users,
};
pub struct Service {
services: Services,
@ -30,11 +36,16 @@ pub struct Service {
/// 3. OnceLock<OnceLock<()>>, representing first run mode being inactive.
/// The marker may not transition out of this state.
first_run_marker: OnceLock<OnceLock<()>>,
/// A single-use registration token which may be used to create the first
/// account.
first_account_token: String,
}
struct Services {
config: Dep<config::Service>,
users: Dep<users::Service>,
globals: Dep<globals::Service>,
admin: Dep<admin::Service>,
}
#[async_trait]
@ -44,9 +55,12 @@ impl crate::Service for Service {
services: Services {
config: args.depend::<config::Service>("config"),
users: args.depend::<users::Service>("users"),
globals: args.depend::<globals::Service>("globals"),
admin: args.depend::<admin::Service>("admin"),
},
// marker starts in an indeterminate state
first_run_marker: OnceLock::new(),
first_account_token: registration_tokens::Service::generate_token_string(),
}))
}
@ -58,6 +72,7 @@ impl crate::Service for Service {
.services
.users
.list_local_users()
.ready_filter(|user| *user != self.services.globals.server_user)
.next()
.await
.is_none();
@ -87,15 +102,68 @@ impl Service {
}
/// Disable first run mode and begin normal operation.
pub fn disable_first_run(&self) {
///
/// Returns true if first run mode was successfully disabled, and false if
/// first run mode was already disabled.
fn disable_first_run(&self) -> bool {
self.first_run_marker
.get()
.expect("First run mode should not be disabled during server startup")
.set(())
.expect("First run mode should not be disabled more than once");
.is_ok()
}
pub(crate) fn print_banner(&self) {
/// If first-run mode is active, grant admin powers to the specified user
/// and disable first-run mode.
///
/// Returns Ok(true) if the specified user was the first user, and Ok(false)
/// if they were not.
pub async fn empower_first_user(&self, user: &UserId) -> Result<bool> {
#[derive(Template)]
#[template(path = "welcome.md.j2")]
struct WelcomeMessage<'a> {
config: &'a Dep<config::Service>,
domain: &'a str,
}
// If first run mode isn't active, do nothing.
if !self.disable_first_run() {
return Ok(false);
}
self.services.admin.make_user_admin(user).await?;
// Send the welcome message
let welcome_message = WelcomeMessage {
config: &self.services.config,
domain: self.services.globals.server_name().as_str(),
}
.render()
.expect("should have been able to render welcome message template");
self.services
.admin
.send_loud_message(RoomMessageEventContent::text_markdown(welcome_message))
.await?;
Ok(true)
}
/// Get the single-use registration token which may be used to create the
/// first account.
pub fn get_first_account_token(&self) -> Option<ValidToken> {
if self.is_first_run() {
Some(ValidToken {
token: self.first_account_token.clone(),
source: ValidTokenSource::FirstAccount,
})
} else {
None
}
}
pub(crate) fn print_first_run_banner(&self) {
use yansi::Paint;
// This function is specially called by the core after all other
// services have started. It runs last to ensure that the banner it
// prints comes after any other logging which may occur on startup.
@ -104,6 +172,125 @@ impl Service {
return;
}
println!("meow");
eprintln!();
eprintln!("{}", "============".bold());
eprintln!(
"Welcome to {} {}!",
"Continuwuity".bold().bright_magenta(),
conduwuit::version::version().bold()
);
eprintln!();
eprintln!(
"In order to use your new homeserver, you need to create its first user account."
);
eprintln!(
"Open your Matrix client of choice and register an account on {} using the \
registration token {} . Pick your own username and password!",
self.services.globals.server_name().bold().green(),
self.first_account_token.as_str().bold().green()
);
match (
self.services.config.allow_registration,
self.services.config.get_config_file_token().is_some(),
) {
| (true, true) => {
eprintln!(
"{} until you create an account using the token above.",
"The registration token you set in your configuration will not function"
.red()
);
},
| (true, false) => {
eprintln!(
"{} until you create an account using the token above.",
"Nobody else will be able to register".green()
);
},
| (false, true) => {
eprintln!(
"{} because you have disabled registration in your configuration. If this \
is not desired, set `allow_registration` to true and restart Continuwuity.",
"The registration token you set in your configuration will not be usable"
.yellow()
);
},
| (false, false) => {
eprintln!(
"{} to allow you to create an account. Because registration is not enabled \
in your configuration, it will be disabled again once your account is \
created.",
"Registration has been temporarily enabled".yellow()
);
},
}
if self.services.config.suspend_on_register {
eprintln!(
"{} Because you enabled suspend-on-register in your configuration, accounts \
created after yours will be automatically suspended.",
"Your account will not be suspended when you register.".green()
);
}
if self
.services
.config
.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse
{
eprintln!();
eprintln!(
"{}",
"You have enabled open registration in your configuration! You almost certainly \
do not want to do this."
.bold()
.on_red()
);
eprintln!(
"{}",
"Servers with open, unrestricted registration are prone to abuse by spammers. \
Users on your server may be unable to join chatrooms which block open \
registration servers."
.red()
);
eprintln!(
"If you enabled it only for the purpose of creating the first account, {} and \
create the first account using the token above.",
"disable it now, restart Continuwuity,".red(),
);
// TODO link to a guide on setting up reCAPTCHA
}
if self.services.config.emergency_password.is_some() {
eprintln!();
eprintln!(
"{}",
"You have set an emergency password for the server user! You almost certainly \
do not want to do this."
.red()
);
eprintln!(
"If you set the password only for the purpose of creating the first account, {} \
and create the first account using the token above.",
"disable it now, restart Continuwuity,".red(),
);
}
eprintln!();
if std::io::stdin().is_terminal() && self.services.config.admin_console_automatic {
eprintln!(
"You may also create the first user through the admin console below using the \
`users create-user` command."
);
} else {
eprintln!(
"If you're running the server interactively, you may also create the first user \
through the admin console using the `users create-user` command. Press Ctrl-C \
to open the console."
);
}
eprintln!("If you need assistance setting up your homeserver, make a Matrix account on another homeserver and join our chatroom: https://matrix.to/#/#continuwuity:continuwuity.org");
eprintln!("{}", "============".bold());
}
}

View file

@ -1,14 +1,17 @@
mod data;
use std::sync::Arc;
use std::{future::ready, pin::Pin, sync::Arc};
use conduwuit::{Err, Result, utils};
use data::Data;
pub use data::{DatabaseTokenInfo, TokenExpires};
use futures::{Stream, StreamExt};
use futures::{
Stream, StreamExt,
stream::{iter, once},
};
use ruma::OwnedUserId;
use crate::{Dep, config};
use crate::{Dep, config, firstrun};
const RANDOM_TOKEN_LENGTH: usize = 16;
@ -19,6 +22,7 @@ pub struct Service {
struct Services {
config: Dep<config::Service>,
firstrun: Dep<firstrun::Service>,
}
/// A validated registration token which may be used to create an account.
@ -46,6 +50,9 @@ pub enum ValidTokenSource {
ConfigFile,
/// A database token which has been checked to be valid.
Database(DatabaseTokenInfo),
/// The single-use token which may be used to create the homeserver's first
/// account.
FirstAccount,
}
impl std::fmt::Display for ValidTokenSource {
@ -53,6 +60,7 @@ impl std::fmt::Display for ValidTokenSource {
match self {
| Self::ConfigFile => write!(f, "Token defined in config."),
| Self::Database(info) => info.fmt(f),
| Self::FirstAccount => write!(f, "Initial setup token."),
}
}
}
@ -63,6 +71,7 @@ impl crate::Service for Service {
db: Data::new(args.db),
services: Services {
config: args.depend::<config::Service>("config"),
firstrun: args.depend::<firstrun::Service>("firstrun"),
},
}))
}
@ -71,13 +80,17 @@ impl crate::Service for Service {
}
impl Service {
/// Generate a random string suitable to be used as a registration token.
#[must_use]
pub fn generate_token_string() -> String { utils::random_string(RANDOM_TOKEN_LENGTH) }
/// Issue a new registration token and save it in the database.
pub fn issue_token(
&self,
creator: OwnedUserId,
expires: Option<TokenExpires>,
) -> (String, DatabaseTokenInfo) {
let token = utils::random_string(RANDOM_TOKEN_LENGTH);
let token = Self::generate_token_string();
let info = DatabaseTokenInfo::new(creator, expires);
self.db.save_token(&token, &info);
@ -87,20 +100,31 @@ impl Service {
/// Get all the "special" registration tokens that aren't defined in the
/// database.
fn iterate_static_tokens(&self) -> impl Iterator<Item = ValidToken> {
// right now this is just the config file token
// This does not include the first-account token, because it's special:
// no other registration tokens are valid when it is set.
self.services.config.get_config_file_token().into_iter()
}
/// Validate a registration token.
pub async fn validate_token(&self, token: String) -> Option<ValidToken> {
// Check static registration tokens first
// Check for the first-account token first
if let Some(first_account_token) = self.services.firstrun.get_first_account_token() {
if first_account_token == *token {
return Some(first_account_token);
}
// If the first-account token is set, no other tokens are valid
return None;
}
// Then static registration tokens
for static_token in self.iterate_static_tokens() {
if static_token == *token {
return Some(static_token);
}
}
// Now check the database
// Then check the database
if let Some(token_info) = self.db.lookup_token_info(&token).await
&& token_info.is_valid()
{
@ -117,14 +141,14 @@ impl Service {
/// Mark a valid token as having been used to create a new account.
pub fn mark_token_as_used(&self, ValidToken { token, source }: ValidToken) {
match source {
| ValidTokenSource::ConfigFile => {
// we don't track uses of the config file token, do nothing
},
| ValidTokenSource::Database(mut info) => {
info.uses = info.uses.saturating_add(1);
self.db.save_token(&token, &info);
},
| _ => {
// Do nothing for other token sources.
},
}
}
@ -135,7 +159,6 @@ impl Service {
pub fn revoke_token(&self, ValidToken { token, source }: ValidToken) -> Result {
match source {
| ValidTokenSource::ConfigFile => {
// the config file token cannot be revoked
Err!(
"The token set in the config file cannot be revoked. Edit the config file \
to change it."
@ -145,11 +168,19 @@ impl Service {
self.db.revoke_token(&token);
Ok(())
},
| ValidTokenSource::FirstAccount => {
Err!("The initial setup token cannot be revoked.")
},
}
}
/// Iterate over all valid registration tokens.
pub fn iterate_tokens(&self) -> impl Stream<Item = ValidToken> + Send + '_ {
pub fn iterate_tokens(&self) -> Pin<Box<dyn Stream<Item = ValidToken> + Send + '_>> {
// If the first-account token is set, no other tokens are valid
if let Some(first_account_token) = self.services.firstrun.get_first_account_token() {
return once(ready(first_account_token)).boxed();
}
let db_tokens = self
.db
.iterate_and_clean_tokens()
@ -158,6 +189,6 @@ impl Service {
source: ValidTokenSource::Database(info),
});
futures::stream::iter(self.iterate_static_tokens()).chain(db_tokens)
iter(self.iterate_static_tokens()).chain(db_tokens).boxed()
}
}

View file

@ -149,8 +149,12 @@ impl Services {
debug_info!("Services startup complete.");
// print first-run banner if necessary
self.firstrun.print_banner();
// Run startup admin commands
self.admin.startup_execute().await?;
// Prin first-run banner if necessary. This needs to be done after the startup
// admin commands are run in case one of them created the first user.
self.firstrun.print_first_run_banner();
Ok(Arc::clone(self))
}

View file

@ -0,0 +1,29 @@
## Thank you for trying out Continuwuity!
Your new homeserver is ready to use! {%- if config.allow_federation %} To make sure you can federate with the rest of the Matrix network, consider checking your domain (`{{ domain }}`) with a federation tester like [this one](https://connectivity-tester.mtrnord.blog/). {%- endif %}
{% if config.get_config_file_token().is_some() -%}
Users may now create accounts normally using the configured registration token.
{%- else if config.recaptcha_site_key.is_some() -%}
Users may now create accounts normally after solving a CAPTCHA.
{%- else if config.yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse -%}
**This server has open, unrestricted registration enabled!** Anyone, including spammers, may now create an account with no further steps. If this is not desired behavior, set `yes_i_am_very_very_sure_i_want_an_open_registration_server_prone_to_abuse` to `false` in your configuration and restart the server.
{%- else if config.allow_registration -%}
To allow more users to register, use the `!admin token` admin commands to issue registration tokens, or set a registration token in the configuration.
{%- else -%}
You've disabled registration. To create more accounts, use the `!admin users create-user` admin command.
{%- endif %}
This room is your server's admin room. You can send messages starting with `!admin` in this room to perform a range of administrative actions.
To view a list of available commands, send the following message: `!admin --help`
Project chatrooms:
> Support chatroom: https://matrix.to/#/#continuwuity:continuwuity.org
> Update announcements: https://matrix.to/#/#announcements:continuwuity.org
> Other chatrooms: https://matrix.to/#/#space:continuwuity.org
>
Helpful links:
> Source code: https://forgejo.ellis.link/continuwuation/continuwuity
> Documentation: https://continuwuity.org/
> Report issues: https://forgejo.ellis.link/continuwuation/continuwuity/issues

View file

@ -30,7 +30,7 @@ use ruma::{
use serde::{Deserialize, Serialize};
use serde_json::json;
use crate::{Dep, account_data, admin, appservice, globals, rooms};
use crate::{Dep, account_data, admin, appservice, firstrun, globals, rooms};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct UserSuspension {
@ -55,6 +55,7 @@ struct Services {
globals: Dep<globals::Service>,
state_accessor: Dep<rooms::state_accessor::Service>,
state_cache: Dep<rooms::state_cache::Service>,
firstrun: Dep<firstrun::Service>,
}
struct Data {
@ -96,6 +97,7 @@ impl crate::Service for Service {
state_accessor: args
.depend::<rooms::state_accessor::Service>("rooms::state_accessor"),
state_cache: args.depend::<rooms::state_cache::Service>("rooms::state_cache"),
firstrun: args.depend::<firstrun::Service>("firstrun"),
},
db: Data {
keychangeid_userid: args.db["keychangeid_userid"].clone(),
@ -187,7 +189,9 @@ impl Service {
self.db
.userid_origin
.insert(user_id, origin.unwrap_or("password"));
self.set_password(user_id, password).await
self.set_password(user_id, password).await?;
Ok(())
}
/// Deactivate account

View file

@ -20,9 +20,7 @@ crate-type = [
[dependencies]
conduwuit-build-metadata.workspace = true
conduwuit-service.workspace = true
askama = "0.14.0"
askama.workspace = true
axum.workspace = true
futures.workspace = true
tracing.workspace = true