feat(auth): add username sign-in mode and recovery kits (#3215)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-05 14:10:07 +02:00
1 parent bf3d73a5f7
commit e1eecc3b6c
355 files changed
+52138 -24097

No files matched your search

+45 -6
View File
@@ -48,17 +48,41 @@ pub fn router() -> Router<AppState> {
)
}
fn render_ban_page(state: &AppState, auth: &AuthContext, key: &str, req: &Request) -> Response {
async fn render_ban_page(
state: &AppState,
auth: &AuthContext,
key: &str,
csrf_token: String,
) -> Response {
let config = state.config();
let ban_cfg = match templates::pages::bans::get_ban_config(key) {
Some(c) => c,
None => return axum::http::StatusCode::NOT_FOUND.into_response(),
};
let csrf_token = csrf::get_csrf_token(req);
let markup = templates::pages::bans::bans_page(config, auth, ban_cfg, None, &csrf_token);
let username_sign_in = email_bans_on_username_instance(state, auth, key).await;
let markup = templates::pages::bans::bans_page(
config,
auth,
ban_cfg,
None,
&csrf_token,
username_sign_in,
);
Html(markup.into_string()).into_response()
}
async fn email_bans_on_username_instance(state: &AppState, auth: &AuthContext, key: &str) -> bool {
key == "email-bans"
&& state
.account_identity(&AdminApiClient::new(
state.http_client(),
state.config(),
&auth.session,
))
.await
.is_username()
}
macro_rules! ban_get {
($name:ident, $key:expr) => {
async fn $name(
@@ -66,7 +90,8 @@ macro_rules! ban_get {
auth: axum::Extension<AuthContext>,
request: Request,
) -> Response {
render_ban_page(&state, &auth.0, $key, &request)
let csrf_token = csrf::get_csrf_token(&request);
render_ban_page(&state, &auth.0, $key, csrf_token).await
}
};
}
@@ -96,7 +121,17 @@ async fn generic_ban_post(
let value = extract_value(form, ban_cfg.input_name);
let is_htmx = htmx::is_htmx_request(headers);
let (level, msg) = execute_ban(&client, ban_key, action, &value, form).await;
flash_response(config, auth, is_htmx, level, &msg, ban_cfg, csrf_token)
let username_sign_in = !is_htmx && email_bans_on_username_instance(state, auth, ban_key).await;
flash_response(
config,
auth,
is_htmx,
level,
&msg,
ban_cfg,
csrf_token,
username_sign_in,
)
}
macro_rules! ban_post {
@@ -114,14 +149,18 @@ macro_rules! ban_post {
let form: BanFormData = match Form::from_request(request, &state).await {
Ok(Form(f)) => f,
Err(_) => {
let is_htmx = htmx::is_htmx_request(&headers);
let username_sign_in =
!is_htmx && email_bans_on_username_instance(&state, &auth.0, $key).await;
return flash_response(
state.config(),
&auth.0,
htmx::is_htmx_request(&headers),
is_htmx,
"error",
"Invalid form data",
templates::pages::bans::get_ban_config($key).unwrap(),
&csrf_token,
username_sign_in,
);
}
};
+10 -2
View File
@@ -226,6 +226,7 @@ fn ban_action_result(
}
}
#[allow(clippy::too_many_arguments)]
pub fn flash_response(
config: &crate::config::AdminConfig,
auth: &AuthContext,
@@ -234,13 +235,20 @@ pub fn flash_response(
message: &str,
ban_cfg: &templates::pages::bans::BanConfig,
csrf_token: &str,
username_sign_in: bool,
) -> Response {
if is_htmx {
render_inline_flash(level, message)
} else {
let flash = to_flash(level, message);
let markup =
templates::pages::bans::bans_page(config, auth, ban_cfg, Some(&flash), csrf_token);
let markup = templates::pages::bans::bans_page(
config,
auth,
ban_cfg,
Some(&flash),
csrf_token,
username_sign_in,
);
Html(markup.into_string()).into_response()
}
}
+4
View File
@@ -134,6 +134,7 @@ async fn guild_detail(
.as_ref()
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let username_sign_in = state.account_identity(&client).await.is_username();
let tab_body = if let Some(guild) = guild.as_ref() {
guild_tabs::render(
&client,
@@ -152,6 +153,7 @@ async fn guild_detail(
active_tab,
&csrf_token,
admin_acls,
username_sign_in,
))
})
} else {
@@ -166,6 +168,7 @@ async fn guild_detail(
active_tab,
tab_body,
is_detail_fragment,
username_sign_in,
);
Html(markup.into_string()).into_response()
}
@@ -508,6 +511,7 @@ async fn guild_tab(
normalize_guild_tab(&tab),
&csrf_token,
admin_acls,
state.account_identity(&client).await.is_username(),
),
None => maud::html! {
div class="p-4 text-red-600 text-sm" {
+8 -1
View File
@@ -201,6 +201,13 @@ async fn bulk_actions_page(
csrf: axum::Extension<CsrfToken>,
) -> Response {
let config = state.config();
let markup = templates::pages::bulk_actions::bulk_actions_page(config, &auth.0, &csrf.0.0);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let account_identity = state.account_identity(&client).await;
let markup = templates::pages::bulk_actions::bulk_actions_page(
config,
&auth.0,
&csrf.0.0,
account_identity.is_username(),
);
Html(markup.into_string()).into_response()
}
+4
View File
@@ -73,6 +73,10 @@ pub fn build_router(config: AdminConfig) -> Router {
.merge(admin::router())
.route("/", get(dashboard))
.route("/dashboard", get(dashboard))
.layer(from_fn_with_state(
state.clone(),
middleware::account_identity::scope_account_identity,
))
.layer(from_fn(middleware::htmx::flash_redirect_to_toast))
.layer(from_fn_with_state(
state.clone(),
+34 -1
View File
@@ -838,7 +838,9 @@ fn build_integrations_update(form: &MultiValueForm) -> InstanceConfigUpdateReque
youtube: Some(InstanceYoutubeIntegrationUpdateRequest {
api_key: clean("integration_youtube_api_key"),
}),
email: Some(InstanceEmailIntegrationUpdateRequest {
email: (form.has_key_starting_with("integration_email_")
|| form.has_key_starting_with("integration_smtp_"))
.then(|| InstanceEmailIntegrationUpdateRequest {
enabled: Some(form.bool_value("integration_email_enabled")),
provider: Some("smtp".to_owned()),
from_email: clean("integration_email_from_email"),
@@ -1195,6 +1197,37 @@ pub async fn limit_config_post(
mod tests {
use super::*;
#[test]
fn build_integrations_update_leaves_email_alone_when_its_fields_are_hidden() {
let hidden = build_integrations_update(&MultiValueForm::parse(
b"integration_klipy_api_key=&integration_youtube_api_key=",
));
let integrations = hidden.integrations.expect("integrations update");
assert!(integrations.email.is_none());
assert!(integrations.gif.is_some());
let shown = build_integrations_update(&MultiValueForm::parse(
b"integration_email_present=1&integration_smtp_host=smtp.example.com",
));
let email = shown
.integrations
.and_then(|integrations| integrations.email)
.expect("email update");
assert_eq!(email.enabled, Some(false));
let from_an_older_page = build_integrations_update(&MultiValueForm::parse(
b"integration_klipy_api_key=&integration_smtp_host=smtp.example.com",
));
let email = from_an_older_page
.integrations
.and_then(|integrations| integrations.email)
.expect("email update from a page without the presence marker");
assert_eq!(
email.smtp.and_then(|smtp| smtp.host).as_deref(),
Some("smtp.example.com")
);
}
#[test]
fn build_sso_update_keeps_repeated_allowed_domains() {
let form = MultiValueForm::parse(
+5
View File
@@ -367,6 +367,11 @@ pub async fn dispatch(
"Password reset sent successfully",
"Failed to send password reset",
),
"revoke_recovery_kit" => DispatchOutcome::from_result(
client.revoke_recovery_kit(user_id).await,
"Recovery kit revoked",
"Failed to revoke recovery kit",
),
"remove_relationship" => {
let Some(target_id) = get("target_user_id").or_else(|| get("target_id")) else {
return DispatchOutcome::error("Target user ID is required");
+49 -20
View File
@@ -5,6 +5,7 @@ use crate::{
api::{
audit::SearchAuditLogsParams,
client::{AdminApiClient, ApiResultExt},
types::AccountIdentityMode,
},
config::AdminConfig,
templates::{
@@ -26,6 +27,7 @@ pub struct TabQuery {
pub delete_all_messages_message_count: Option<u64>,
}
#[allow(clippy::too_many_arguments)]
pub async fn render(
client: &AdminApiClient,
config: &AdminConfig,
@@ -34,6 +36,7 @@ pub async fn render(
tab: &str,
query: &TabQuery,
admin_acls: &[String],
account_identity: AccountIdentityMode,
) -> Option<maud::Markup> {
match tab {
"overview" => {
@@ -60,31 +63,22 @@ pub async fn render(
csrf_token,
change_log.as_ref(),
limit_config.as_ref(),
account_identity.is_username(),
))
}
"account" => {
let u = client
.get_user_by_id(user_id)
.await
.log_error("load user account")?;
let s = client
.list_user_sessions(user_id)
.await
.map(|r| r.sessions)
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions"))
.unwrap_or_default();
let webauthn_credentials = client
.list_webauthn_credentials(user_id)
.await
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
.unwrap_or_default();
Some(tabs::account::account_tab(
render_account(
client,
config,
&u,
&s,
&webauthn_credentials,
csrf_token,
))
user_id,
&tabs::account::AccountTabOptions {
admin_acls,
account_identity,
password_reset_link: None,
},
)
.await
}
"moderation" => {
let u = client
@@ -145,6 +139,7 @@ pub async fn render(
let context = tabs::moderation::ModerationContext {
deletion_scheduler: deletion_scheduler.as_ref(),
current_ban: tabs::moderation::find_current_ban(&u, &ban_logs),
username_sign_in: account_identity.is_username(),
};
Some(tabs::moderation::moderation_tab(
config,
@@ -298,6 +293,40 @@ pub async fn render(
}
}
pub async fn render_account(
client: &AdminApiClient,
config: &AdminConfig,
csrf_token: &str,
user_id: &str,
options: &tabs::account::AccountTabOptions<'_>,
) -> Option<maud::Markup> {
let u = client
.get_user_by_id(user_id)
.await
.log_error("load user account")?;
let s = client
.list_user_sessions(user_id)
.await
.map(|r| r.sessions)
.map_err(
|error| tracing::warn!(%error, user_id, "admin API request failed: list user sessions"),
)
.unwrap_or_default();
let webauthn_credentials = client
.list_webauthn_credentials(user_id)
.await
.map_err(|error| tracing::warn!(%error, user_id, "admin API request failed: list webauthn credentials"))
.unwrap_or_default();
Some(tabs::account::account_tab(
config,
&u,
&s,
&webauthn_credentials,
csrf_token,
options,
))
}
fn parse_bool_flag(value: &str) -> Option<bool> {
match value.trim().to_ascii_lowercase().as_str() {
"1" | "true" => Some(true),
+104 -10
View File
@@ -9,7 +9,12 @@ use crate::{
middleware::{auth::AuthContext, csrf::CsrfToken, flash, htmx},
routes::user_tabs,
state::AppState,
templates,
templates::{
self,
pages::user_detail_tabs::account::{
PASSWORD_RESET_LINK_RESULT_ID, password_reset_link_result,
},
},
utils::forms::MultiValueForm,
};
use axum::{
@@ -86,8 +91,9 @@ async fn users_list(
.as_ref()
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL);
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let username_sign_in = state.account_identity(&client).await.is_username();
let can_view_email = acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL) && !username_sign_in;
let searching = params.has_id_lookup() || params.has_search();
let results = async {
if params.has_id_lookup() {
@@ -136,6 +142,7 @@ async fn users_list(
result_users,
has_more,
can_view_email,
username_sign_in,
premium_badge_name.as_deref(),
is_results_fragment,
);
@@ -204,8 +211,16 @@ async fn user_detail(
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let tab_body = if user.is_some() {
let account_identity = state.account_identity(&client).await;
user_tabs::render(
&client, config, &csrf.0.0, &user_id, active_tab, &tq, admin_acls,
&client,
config,
&csrf.0.0,
&user_id,
active_tab,
&tq,
admin_acls,
account_identity,
)
.await
} else {
@@ -229,6 +244,7 @@ async fn user_detail_post(
State(state): State<AppState>,
headers: HeaderMap,
auth: axum::Extension<AuthContext>,
csrf: axum::Extension<CsrfToken>,
Path(user_id): Path<String>,
Query(aq): Query<ActionQuery>,
request: Request,
@@ -252,6 +268,10 @@ async fn user_detail_post(
};
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let action = aq.action.as_deref().unwrap_or("");
if action == "create_password_reset_link" {
return create_password_reset_link(&state, &headers, &auth.0, &csrf.0.0, &client, &user_id)
.await;
}
let outcome = super::user_actions::dispatch(&client, &user_id, action, &form).await;
let mut redirect = if tab.is_empty() {
format!("{base}/users/{user_id}")
@@ -268,6 +288,74 @@ async fn user_detail_post(
flash::redirect_with_flash(&redirect, outcome.flash, config.secure_cookies())
}
async fn create_password_reset_link(
state: &AppState,
headers: &HeaderMap,
auth: &AuthContext,
csrf_token: &str,
client: &AdminApiClient,
user_id: &str,
) -> Response {
let config = state.config();
let account_url = format!("{}/users/{user_id}?tab=account", config.base_path);
let link = match client.create_password_reset_link(user_id).await {
Ok(link) => link,
Err(error) => {
tracing::warn!(%error, user_id, "admin API request failed: create password reset link");
let flash = flash::FlashData::error("Failed to create password reset link");
if htmx::is_htmx_request(headers)
&& (htmx::targets(headers, "flash-container")
|| htmx::targets(headers, PASSWORD_RESET_LINK_RESULT_ID))
{
return htmx::toast_response(&flash);
}
return flash::redirect_with_flash(&account_url, flash, config.secure_cookies());
}
};
if htmx::is_htmx_request(headers) && htmx::targets(headers, PASSWORD_RESET_LINK_RESULT_ID) {
return Html(password_reset_link_result(Some(&link)).into_string()).into_response();
}
let admin_acls = auth
.admin_user
.as_ref()
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let (user, badge_name, account_identity) = tokio::join!(
async {
client
.get_user_by_id(user_id)
.await
.log_error("load user after creating password reset link")
},
self_hosted_premium_badge_name(state, client),
state.account_identity(client)
);
let tab_body = user_tabs::render_account(
client,
config,
csrf_token,
user_id,
&templates::pages::user_detail_tabs::account::AccountTabOptions {
admin_acls,
account_identity,
password_reset_link: Some(&link),
},
)
.await;
let premium_badge_name = user.as_ref().and(badge_name);
let markup = templates::pages::user_detail::user_detail_with_tab(
config,
auth,
user.as_ref(),
user_id,
"account",
tab_body,
premium_badge_name.as_deref(),
htmx::targets(headers, "main-content"),
);
Html(markup.into_string()).into_response()
}
async fn user_tab(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
@@ -299,14 +387,20 @@ async fn user_tab(
.as_ref()
.map(|user| user.acls.as_slice())
.unwrap_or(&[]);
let account_identity = state.account_identity(&client).await;
let markup = match user {
Some(ref u) => {
user_tabs::render(&client, config, &csrf.0.0, &user_id, &tab, &tq, admin_acls)
.await
.unwrap_or_else(|| {
templates::pages::user_detail::simple_tab_content(config, u, &tab)
})
}
Some(ref u) => user_tabs::render(
&client,
config,
&csrf.0.0,
&user_id,
&tab,
&tq,
admin_acls,
account_identity,
)
.await
.unwrap_or_else(|| templates::pages::user_detail::simple_tab_content(config, u, &tab)),
None => maud::html! {
div class="p-4 text-red-600 text-sm" { "Failed to load user data." }
},