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

This commit is contained in:
Hampus
2026-10-05 14:10:07 +02:00
committed by GitHub
parent bf3d73a5f7
commit e1eecc3b6c
355 changed files with 52138 additions and 24097 deletions
+175 -7
View File
@@ -7124,6 +7124,68 @@
]
}
},
"/admin/users/{user_id}/password-reset-link": {
"post": {
"operationId": "create_admin_user_password_reset_link",
"summary": "Create user password reset link",
"tags": ["Admin"],
"responses": {
"200": {
"description": "Success",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/AdminPasswordResetLinkResponse"}}}
},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Create a one-time password reset link on an instance where people sign in with a username. Hand the link to the user yourself. It works once and expires after an hour. Deletes the recovery kit of the account. Creates audit log entry. Requires USER_CREATE_PASSWORD_RESET_LINK permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.",
"security": [{"adminApiKey": []}],
"parameters": [
{
"name": "user_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
"description": "The ID of the user"
}
]
}
},
"/admin/users/{user_id}/premium-flags": {
"patch": {
"operationId": "update_admin_user_premium_flags",
@@ -7258,6 +7320,65 @@
}
}
},
"/admin/users/{user_id}/recovery-kit": {
"delete": {
"operationId": "revoke_admin_user_recovery_kit",
"summary": "Revoke user recovery kit",
"tags": ["Admin"],
"responses": {
"204": {"description": "No Content"},
"400": {
"description": "Bad Request - The request was malformed or contained invalid data",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"401": {
"description": "Unauthorized - Authentication is required or the token is invalid",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"403": {
"description": "Forbidden - You do not have permission to perform this action",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
},
"429": {
"description": "Too Many Requests - You are being rate limited",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
"headers": {
"Retry-After": {
"description": "Number of seconds to wait before retrying (only on 429)",
"schema": {"type": "integer"}
},
"X-RateLimit-Limit": {
"description": "The number of requests that can be made in the current window",
"schema": {"type": "integer"}
},
"X-RateLimit-Remaining": {
"description": "The number of remaining requests that can be made",
"schema": {"type": "integer"}
},
"X-RateLimit-Reset": {
"description": "Unix timestamp when the rate limit resets",
"schema": {"type": "integer"}
}
}
},
"500": {
"description": "Internal Server Error - An unexpected error occurred",
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
}
},
"description": "Deletes the recovery kit of an account on an instance where people sign in with a username, so its key stops working. Creates audit log entry. Requires USER_DELETE_RECOVERY_KIT permission and every ACL the target account holds. Fails with USERNAME_SIGN_IN_ONLY on email instances.",
"security": [{"adminApiKey": []}],
"parameters": [
{
"name": "user_id",
"in": "path",
"required": true,
"schema": {"description": "The ID of the user", "allOf": [{"$ref": "#/components/schemas/SnowflakeType"}]},
"description": "The ID of the user"
}
]
}
},
"/admin/users/{user_id}/relationships": {
"get": {
"operationId": "list_admin_user_relationships",
@@ -9317,6 +9438,20 @@
}
}
},
"AdminPasswordResetLinkResponse": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "Password reset link to hand to the user. It is shown only once"},
"expires_at": {
"type": "string",
"format": "date-time",
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$",
"description": "ISO 8601 timestamp when the link stops working"
}
},
"required": ["url", "expires_at"],
"additionalProperties": false
},
"DeleteAllUserMessagesResponse": {
"type": "object",
"properties": {
@@ -9727,7 +9862,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 103,
"maxItems": 105,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions to assign"
@@ -10536,6 +10671,19 @@
"additionalProperties": false
},
"self_hosted": {"type": "boolean"},
"account_identity": {
"type": "object",
"properties": {
"mode": {
"description": "Sign-in method in effect on this instance",
"allOf": [{"$ref": "#/components/schemas/AccountIdentityModeSchema"}]
},
"locked": {"type": "boolean", "description": "Whether the sign-in method can no longer change"},
"tag_style": {"allOf": [{"$ref": "#/components/schemas/TagStyleSchema"}]}
},
"required": ["mode", "locked", "tag_style"],
"additionalProperties": false
},
"app_public": {
"type": "object",
"properties": {
@@ -10843,6 +10991,7 @@
"experiment_delivery",
"registration",
"self_hosted",
"account_identity",
"app_public",
"policy",
"integrations",
@@ -12603,7 +12752,7 @@
},
"acls": {
"description": "Replacement list of access control permissions for the key",
"maxItems": 103,
"maxItems": 105,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"}
}
@@ -12621,7 +12770,7 @@
"type": "string"
},
"acls": {
"maxItems": 103,
"maxItems": 105,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12651,7 +12800,7 @@
"maximum": 365
},
"acls": {
"maxItems": 103,
"maxItems": 105,
"type": "array",
"items": {"$ref": "#/components/schemas/AdminAclType"},
"description": "List of access control permissions for the key"
@@ -12672,7 +12821,7 @@
"type": "string"
},
"acls": {
"maxItems": 103,
"maxItems": 105,
"type": "array",
"items": {"type": "string"},
"description": "List of access control permissions for the key"
@@ -12685,7 +12834,7 @@
"type": "object",
"properties": {
"acls": {
"maxItems": 103,
"maxItems": 105,
"type": "array",
"items": {"type": "string", "minLength": 1, "maxLength": 64},
"description": "Every admin access control permission the admin API recognises"
@@ -12773,6 +12922,8 @@
"report:view:reporter_pii",
"system_dm:send",
"user:cancel:bulk_message_deletion",
"user:create:password_reset_link",
"user:delete:recovery_kit",
"user:delete",
"user:list:dm_channels",
"user:list:guilds",
@@ -15232,6 +15383,23 @@
],
"additionalProperties": false
},
"TagStyleSchema": {
"description": "How usernames are tagged",
"x-enumNames": ["NONE", "RANDOM"],
"x-enumDescriptions": ["Usernames are unique and shown without a tag", "Every account gets a random tag"],
"enum": ["none", "random"],
"type": "string"
},
"AccountIdentityModeSchema": {
"description": "How people identify themselves when they sign in",
"x-enumNames": ["EMAIL", "USERNAME"],
"x-enumDescriptions": [
"People sign in with an email address",
"People sign in with a username and no email is collected"
],
"enum": ["email", "username"],
"type": "string"
},
"ExperimentDeliveryConfigResponse": {
"type": "object",
"properties": {
@@ -15515,7 +15683,7 @@
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
"type": "string"
},
"acls": {"maxItems": 103, "type": "array", "items": {"type": "string"}},
"acls": {"maxItems": 105, "type": "array", "items": {"type": "string"}},
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
"has_totp": {"type": "boolean"},
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
+4
View File
@@ -76,6 +76,8 @@ pub const REPORT_VIEW: &str = "report:view";
pub const REPORT_VIEW_REPORTER_PII: &str = "report:view:reporter_pii";
pub const SYSTEM_DM_SEND: &str = "system_dm:send";
pub const USER_CANCEL_BULK_MESSAGE_DELETION: &str = "user:cancel:bulk_message_deletion";
pub const USER_CREATE_PASSWORD_RESET_LINK: &str = "user:create:password_reset_link";
pub const USER_DELETE_RECOVERY_KIT: &str = "user:delete:recovery_kit";
pub const USER_DELETE: &str = "user:delete";
pub const USER_LIST_DM_CHANNELS: &str = "user:list:dm_channels";
pub const USER_LIST_GUILDS: &str = "user:list:guilds";
@@ -180,6 +182,8 @@ pub const ALL_ACLS: &[&str] = &[
REPORT_VIEW_REPORTER_PII,
SYSTEM_DM_SEND,
USER_CANCEL_BULK_MESSAGE_DELETION,
USER_CREATE_PASSWORD_RESET_LINK,
USER_DELETE_RECOVERY_KIT,
USER_DELETE,
USER_LIST_DM_CHANNELS,
USER_LIST_GUILDS,
+13 -3
View File
@@ -2,9 +2,9 @@
use super::client::{AdminApiClient, ApiResult};
use super::types::{
CreateRegistrationUrlRequest, CreateRegistrationUrlResponse, InstanceConfigResponse,
InstanceConfigUpdateRequest, InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse,
InstancePremiumDiscovery,
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
InstanceEmailSmtpTestRequest, InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
};
impl AdminApiClient {
@@ -16,6 +16,16 @@ impl AdminApiClient {
self.get("/.well-known/fluxer", None).await
}
pub async fn get_instance_account_identity(&self) -> ApiResult<AccountIdentitySettings> {
let discovery: InstanceAccountIdentityDiscovery =
self.get("/.well-known/fluxer", None).await?;
let mode = discovery.features.account_identity;
Ok(AccountIdentitySettings {
mode,
tag_style: discovery.features.tag_style,
})
}
pub async fn update_instance_config(
&self,
update: &InstanceConfigUpdateRequest,
@@ -13,6 +13,8 @@ pub struct InstanceConfigResponse {
#[serde(default)]
pub self_hosted: bool,
#[serde(default)]
pub account_identity: AccountIdentityConfigResponse,
#[serde(default)]
pub app_public: AppPublicConfigResponse,
#[serde(default)]
pub policy: InstancePolicyResponse,
@@ -34,6 +36,79 @@ pub struct InstanceConfigResponse {
pub billing: InstanceBillingResponse,
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AccountIdentityMode {
#[default]
Email,
Username,
}
impl AccountIdentityMode {
pub fn is_username(self) -> bool {
matches!(self, Self::Username)
}
pub fn label(self) -> &'static str {
match self {
Self::Email => "Email",
Self::Username => "Username",
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, Eq, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum TagStyle {
None,
#[default]
#[serde(other)]
Random,
}
impl TagStyle {
pub fn is_none(self) -> bool {
matches!(self, Self::None)
}
pub fn label(self) -> &'static str {
match self {
Self::None => "No tags",
Self::Random => "Random tags",
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)]
pub struct AccountIdentityConfigResponse {
#[serde(default)]
pub mode: AccountIdentityMode,
#[serde(default)]
pub locked: Option<bool>,
#[serde(default)]
pub tag_style: TagStyle,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct AccountIdentitySettings {
pub mode: AccountIdentityMode,
pub tag_style: TagStyle,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstanceAccountIdentityDiscovery {
#[serde(default)]
pub features: InstanceAccountIdentityDiscoveryFeatures,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct InstanceAccountIdentityDiscoveryFeatures {
#[serde(default)]
pub account_identity: AccountIdentityMode,
#[serde(default)]
pub tag_style: TagStyle,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct InstancePolicyResponse {
#[serde(default)]
@@ -232,3 +232,9 @@ pub struct WebAuthnCredential {
}
pub type WebAuthnCredentialListResponse = Vec<WebAuthnCredential>;
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct PasswordResetLinkResponse {
pub url: String,
pub expires_at: String,
}
+22 -1
View File
@@ -5,7 +5,8 @@ use crate::api::generated::{snowflake, types as generated_types};
use super::client::{AdminApiClient, ApiError, ApiResult};
use super::types::{
AdminUser, AdminUserMeResponse, GuildInfo, ListUserGuildsResponse, LookupUserResponse,
SearchUsersResponse, TerminateSessionsResponse, UserMutationResponse,
PasswordResetLinkResponse, SearchUsersResponse, TerminateSessionsResponse,
UserMutationResponse,
};
impl AdminApiClient {
@@ -473,6 +474,26 @@ impl AdminApiClient {
Ok(())
}
pub async fn create_password_reset_link(
&self,
user_id: &str,
) -> ApiResult<PasswordResetLinkResponse> {
let response = self
.generated()
.create_admin_user_password_reset_link(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
self.generated_value(response.into_inner())
}
pub async fn revoke_recovery_kit(&self, user_id: &str) -> ApiResult<()> {
self.generated()
.revoke_admin_user_recovery_kit(&snowflake(user_id))
.await
.map_err(|e| self.generated_error(e))?;
Ok(())
}
pub async fn remove_relationship(
&self,
user_id: &str,
@@ -0,0 +1,25 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::client::AdminApiClient, middleware::auth::AuthContext, state::AppState,
utils::user_tag::with_unique_usernames,
};
use axum::{
extract::{Request, State},
middleware::Next,
response::Response,
};
pub async fn scope_account_identity(
State(state): State<AppState>,
request: Request,
next: Next,
) -> Response {
let Some(auth) = request.extensions().get::<AuthContext>() else {
return next.run(request).await;
};
let client = AdminApiClient::new(state.http_client(), state.config(), &auth.session);
let settings = state.account_identity_settings(&client).await;
let unique_usernames = settings.mode.is_username() || settings.tag_style.is_none();
with_unique_usernames(unique_usernames, next.run(request)).await
}
+1
View File
@@ -1,5 +1,6 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod account_identity;
pub mod auth;
pub mod csrf;
pub mod error_handler;
+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." }
},
+46 -1
View File
@@ -3,7 +3,7 @@
use crate::{
api::{
client::{AdminApiClient, ApiResultExt},
types::PremiumBranding,
types::{AccountIdentityMode, AccountIdentitySettings, PremiumBranding},
},
config::AdminConfig,
};
@@ -13,6 +13,8 @@ use std::{
};
const PREMIUM_BRANDING_TTL: Duration = Duration::from_secs(60);
const ACCOUNT_IDENTITY_TTL: Duration = Duration::from_secs(60);
const ACCOUNT_IDENTITY_RETRY_TTL: Duration = Duration::from_secs(10);
#[derive(Clone)]
pub struct AppState {
@@ -23,6 +25,7 @@ struct AppStateInner {
pub config: AdminConfig,
pub http_client: reqwest::Client,
premium_branding: Mutex<Option<(Instant, PremiumBranding)>>,
account_identity: Mutex<Option<(Instant, AccountIdentitySettings)>>,
}
impl AppState {
@@ -36,6 +39,7 @@ impl AppState {
config,
http_client,
premium_branding: Mutex::new(None),
account_identity: Mutex::new(None),
}),
}
}
@@ -81,6 +85,47 @@ impl AppState {
self.remember_premium_branding(branding.clone());
Some(branding)
}
pub async fn account_identity(&self, client: &AdminApiClient) -> AccountIdentityMode {
self.account_identity_settings(client).await.mode
}
pub async fn account_identity_settings(
&self,
client: &AdminApiClient,
) -> AccountIdentitySettings {
if !self.config().self_hosted {
return AccountIdentitySettings::default();
}
let previous = *self
.inner
.account_identity
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
if let Some((expires_at, settings)) = previous
&& Instant::now() < expires_at
{
return settings;
}
let (settings, ttl) = match client
.get_instance_account_identity()
.await
.log_error("load account identity mode")
{
Some(settings) => (settings, ACCOUNT_IDENTITY_TTL),
None => (
previous.map_or(AccountIdentitySettings::default(), |(_, settings)| settings),
ACCOUNT_IDENTITY_RETRY_TTL,
),
};
*self
.inner
.account_identity
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner()) =
Some((Instant::now() + ttl, settings));
settings
}
}
impl axum::extract::FromRef<AppState> for AdminConfig {
@@ -198,6 +198,7 @@ fn message_row(
msg.author_global_name.as_deref(),
Some(&msg.author_username),
None,
false,
);
let row_class = format!(
"group relative mt-4 py-0.5 pr-4 pl-4 transition-colors first:mt-0{hover}{highlight}"
@@ -1,15 +1,46 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::utils::user_tag::user_tag;
pub fn format_user_display(
global_name: Option<&str>,
username: Option<&str>,
discriminator: Option<&str>,
is_bot: bool,
) -> String {
match (global_name, username, discriminator) {
(Some(gn), Some(un), Some("0")) => format!("{gn} (@{un})"),
(Some(gn), _, _) => gn.to_owned(),
(None, Some(un), Some(d)) if d != "0" => format!("{un}#{d}"),
(None, Some(un), Some(d)) if d != "0" => user_tag(un, d, is_bot),
(None, Some(un), _) => format!("@{un}"),
_ => "Unknown".to_owned(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::user_tag::sync_with_unique_usernames;
#[test]
fn username_instances_show_bare_human_names_and_keep_bot_tags() {
sync_with_unique_usernames(true, || {
assert_eq!(
format_user_display(None, Some("alice"), Some("0000"), false),
"alice"
);
assert_eq!(
format_user_display(None, Some("helper"), Some("4363"), true),
"helper#4363"
);
});
}
#[test]
fn email_instances_keep_the_zero_tag() {
assert_eq!(
format_user_display(None, Some("alice"), Some("0000"), false),
"alice#0000"
);
}
}
+5 -3
View File
@@ -1,8 +1,10 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
config::AdminConfig, middleware::auth::AuthContext,
templates::components::media::user_avatar_url, utils::bigint::format_discriminator,
config::AdminConfig,
middleware::auth::AuthContext,
templates::components::media::user_avatar_url,
utils::{bigint::format_discriminator, user_tag::user_tag},
};
use maud::{Markup, html};
@@ -32,7 +34,7 @@ pub fn render_header(config: &AdminConfig, auth: &AuthContext, csrf_token: &str)
(display)
}
div class="truncate text-neutral-500 text-xs" {
(admin.username) "#" (format_discriminator(&admin.discriminator))
(user_tag(&admin.username, &format_discriminator(&admin.discriminator), admin.bot))
}
}
}
@@ -114,6 +114,7 @@ fn overview_card(config: &AdminConfig, app: &Application, can_list_by_owner: boo
app.owner_global_name.as_deref(),
app.owner_username.as_deref(),
app.owner_discriminator.as_deref(),
false,
);
section_card_simple(
"Overview",
@@ -157,6 +158,7 @@ fn bot_display_markup(config: &AdminConfig, app: &Application) -> Markup {
app.bot_global_name.as_deref(),
app.bot_username.as_deref(),
app.bot_discriminator.as_deref(),
true,
);
html! {
div class="space-y-1" {
@@ -189,7 +189,12 @@ fn render_application_card(config: &AdminConfig, base: &str, app: &Application)
fn format_owner_display(app: &Application) -> String {
if let (Some(un), Some(disc)) = (&app.owner_username, &app.owner_discriminator) {
format_user_display(app.owner_global_name.as_deref(), Some(un), Some(disc))
format_user_display(
app.owner_global_name.as_deref(),
Some(un),
Some(disc),
false,
)
} else {
app.owner_user_id.clone()
}
@@ -199,7 +204,7 @@ fn format_bot_display(app: &Application) -> String {
if let (Some(_bid), Some(un), Some(disc)) =
(&app.bot_user_id, &app.bot_username, &app.bot_discriminator)
{
format_user_display(app.bot_global_name.as_deref(), Some(un), Some(disc))
format_user_display(app.bot_global_name.as_deref(), Some(un), Some(disc), true)
} else {
app.bot_user_id.clone().unwrap_or_default()
}
@@ -9,7 +9,7 @@ use crate::{
resource_link::{ResourceType, resource_link},
table::{table_body, table_cell, table_head, table_header_cell, table_row},
},
utils::bigint::format_discriminator,
utils::{bigint::format_discriminator, user_tag::user_tag},
};
use maud::{Markup, html};
@@ -42,10 +42,10 @@ fn type_label(target_type: &str) -> String {
}
fn user_label(user: &AuditLogUserSummary) -> String {
let tag = format!(
"{}#{}",
user.username,
format_discriminator(&user.discriminator)
let tag = user_tag(
&user.username,
&format_discriminator(&user.discriminator),
false,
);
match user
.global_name
@@ -351,6 +351,20 @@ mod tests {
assert!(!markup.contains("/admin/users/"));
}
#[test]
fn admin_labels_drop_the_zero_tag_only_without_tags() {
let admin = AuditLogUserSummary {
id: "1500000000000000001".to_owned(),
username: "lilith".to_owned(),
discriminator: "0".to_owned(),
global_name: Some("Lilith".to_owned()),
};
assert_eq!(user_label(&admin), "Lilith (lilith#0000)");
crate::utils::user_tag::sync_with_unique_usernames(true, || {
assert_eq!(user_label(&admin), "Lilith (lilith)");
});
}
#[test]
fn unknown_target_types_stay_unlinked() {
let markup = target_cell("/admin", &entry("email_domain", "spam.example")).into_string();
+7 -1
View File
@@ -4,7 +4,7 @@ use crate::{
config::AdminConfig,
middleware::auth::AuthContext,
templates::{
components::{form::csrf_input, page_container::page_header},
components::{alert::alert_info, form::csrf_input, page_container::page_header},
layout::admin_layout,
},
};
@@ -149,10 +149,16 @@ pub fn bans_page(
ban_cfg: &BanConfig,
flash: Option<&crate::api::types::FlashMessage>,
csrf_token: &str,
username_sign_in: bool,
) -> Markup {
let base = &config.base_path;
let content = html! {
(page_header(ban_cfg.title, None))
@if username_sign_in && ban_cfg.active_page == "email-bans" {
div class="mb-6" {
(alert_info(html! { "People sign in with a username on this instance. Accounts have no email address, so email bans have no effect." }))
}
}
div class="grid gap-6 lg:grid-cols-2" {
(ban_card(base, ban_cfg, csrf_token))
(check_ban_card(base, ban_cfg, csrf_token))
@@ -177,7 +177,12 @@ fn guild_feature_label(feature: &str) -> String {
}
}
pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &str) -> Markup {
pub fn bulk_actions_page(
config: &AdminConfig,
auth: &AuthContext,
csrf_token: &str,
username_sign_in: bool,
) -> Markup {
let base = &config.base_path;
let admin_acls = auth
.admin_user
@@ -198,7 +203,7 @@ pub fn bulk_actions_page(config: &AdminConfig, auth: &AuthContext, csrf_token: &
(bulk_add_guild_members_section(base, csrf_token))
}
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USERS) {
(bulk_schedule_deletion_section(base, csrf_token))
(bulk_schedule_deletion_section(base, csrf_token, username_sign_in))
}
@if acl::has_permission(admin_acls, acl::BULK_DELETE_USER_MESSAGES) {
(bulk_delete_user_messages_section(base, csrf_token))
@@ -331,7 +336,7 @@ fn bulk_add_guild_members_section(base: &str, csrf_token: &str) -> Markup {
)
}
fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
fn bulk_schedule_deletion_section(base: &str, csrf_token: &str, username_sign_in: bool) -> Markup {
section_card_simple(
"Bulk Schedule User Deletion",
html! {
@@ -370,7 +375,11 @@ fn bulk_schedule_deletion_section(base: &str, csrf_token: &str) -> Markup {
},
))
(text_input("audit_log_reason", "Audit Log Reason (optional)", "", "Reason for this bulk operation"))
(opt_out_checkbox("notify_user", "Email each user about the scheduled deletion"))
@if username_sign_in {
input type="hidden" name="notify_user_present" value="1";
} @else {
(opt_out_checkbox("notify_user", "Email each user about the scheduled deletion"))
}
(form_actions(html! {
(danger_button("Schedule Deletion"))
}))
@@ -416,7 +425,7 @@ mod tests {
#[test]
fn deletion_form_has_no_preselected_reason() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
assert!(markup.contains(r#"<option value="" selected>Select a reason</option>"#));
for (value, _) in DELETION_REASONS {
assert!(!markup.contains(&format!(r#"<option value="{value}" selected>"#)));
@@ -425,17 +434,25 @@ mod tests {
#[test]
fn deletion_form_defaults_to_the_moderation_retention_floor() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
assert!(markup.contains(r#"name="days_until_deletion" value="60" min="14" max="365""#));
}
#[test]
fn deletion_form_emails_each_user_by_default() {
let markup = bulk_schedule_deletion_section("/admin", "csrf").into_string();
let markup = bulk_schedule_deletion_section("/admin", "csrf", false).into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn username_mode_hides_the_email_choices() {
let deletion = bulk_schedule_deletion_section("/admin", "csrf", true).into_string();
assert!(!deletion.contains("Email each user"));
assert!(!deletion.contains(r#"name="notify_user" value="true""#));
assert!(deletion.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn remove_grid_can_clear_the_deprecated_clone_features() {
let markup = guild_feature_checkbox_grid("remove_features[]", true).into_string();
@@ -18,6 +18,7 @@ use crate::{
},
utils::bigint::format_discriminator,
utils::timestamps::format_admin_timestamp,
utils::user_tag::user_tag,
};
use maud::{Markup, html};
@@ -74,7 +75,7 @@ fn owner_display(
let Some(discriminator) = discriminator else {
return owner_id.to_owned();
};
let tag = format!("{username}#{}", format_discriminator(discriminator));
let tag = user_tag(username, &format_discriminator(discriminator), false);
match global_name.filter(|value| !value.trim().is_empty()) {
Some(global_name) => format!("{global_name} ({tag})"),
None => tag,
@@ -31,6 +31,7 @@ pub const GUILD_TABS: &[(&str, &str)] = &[
("reports", "Reports"),
];
#[allow(clippy::too_many_arguments)]
pub fn guild_detail_with_tab(
config: &AdminConfig,
auth: &AuthContext,
@@ -39,9 +40,12 @@ pub fn guild_detail_with_tab(
active_tab: &str,
tab_body: Option<Markup>,
is_htmx: bool,
username_sign_in: bool,
) -> Markup {
let content = match guild {
Some(guild) => render_guild_detail(config, auth, guild, active_tab, tab_body),
Some(guild) => {
render_guild_detail(config, auth, guild, active_tab, tab_body, username_sign_in)
}
None => not_found_state("Guild", guild_id, None, None),
};
let title = if guild.is_some() {
@@ -62,6 +66,7 @@ pub fn simple_tab_content(
tab: &str,
csrf_token: &str,
admin_acls: &[String],
username_sign_in: bool,
) -> Markup {
let guild_info = GuildInfo::from(guild.clone());
match tab {
@@ -69,9 +74,13 @@ pub fn simple_tab_content(
"features" => {
guild_detail_tabs::features::features_tab(config, &guild_info, csrf_token, admin_acls)
}
"settings" => {
guild_detail_tabs::settings::settings_tab(config, guild, csrf_token, admin_acls)
}
"settings" => guild_detail_tabs::settings::settings_tab(
config,
guild,
csrf_token,
admin_acls,
username_sign_in,
),
"moderation" => guild_detail_tabs::moderation::moderation_tab(
config,
&guild_info,
@@ -92,6 +101,7 @@ fn render_guild_detail(
guild: &GuildDetailInfo,
active_tab: &str,
tab_body: Option<Markup>,
username_sign_in: bool,
) -> Markup {
let base = &config.base_path;
let admin_acls = auth
@@ -111,8 +121,16 @@ fn render_guild_detail(
effective_tab,
|tab_id| guild_tab_visible(config, tab_id, admin_acls),
);
let body = tab_body
.unwrap_or_else(|| simple_tab_content(config, guild, effective_tab, "", admin_acls));
let body = tab_body.unwrap_or_else(|| {
simple_tab_content(
config,
guild,
effective_tab,
"",
admin_acls,
username_sign_in,
)
});
html! {
div class="space-y-6" {
a href={(base) "/guilds"}
@@ -4,7 +4,9 @@ use crate::{
api::types::{GuildAuditLogEntry, GuildAuditLogUser, GuildInfo},
config::AdminConfig,
templates::components::{page_container::card_with_header, table::data_table},
utils::{bigint::format_discriminator, timestamps::snowflake_creation_date},
utils::{
bigint::format_discriminator, timestamps::snowflake_creation_date, user_tag::user_tag,
},
};
use maud::{Markup, html};
@@ -120,7 +122,7 @@ fn format_user(user: Option<&GuildAuditLogUser>) -> String {
};
let disc = u.discriminator.as_deref().unwrap_or("0000");
let disc = format_discriminator(disc);
let tag = format!("{}#{}", u.username, disc);
let tag = user_tag(&u.username, &disc, false);
match &u.global_name {
Some(gn) if !gn.trim().is_empty() => format!("{} ({})", gn, tag),
_ => tag,
@@ -9,7 +9,7 @@ use crate::{
media::user_avatar_url,
page_container::card_with_header,
},
utils::bigint::format_discriminator,
utils::{bigint::format_discriminator, user_tag::user_tag},
};
use maud::{Markup, html};
@@ -103,12 +103,14 @@ fn member_card(
member: &GuildMember,
csrf_token: &str,
) -> Markup {
let disc = format_discriminator(&member.user.discriminator);
let tag = user_tag(
&member.user.username,
&format_discriminator(&member.user.discriminator),
member.user.bot,
);
let display = match &member.user.global_name {
Some(gn) if !gn.trim().is_empty() => {
format!("{} ({}#{})", gn, member.user.username, disc)
}
_ => format!("{}#{}", member.user.username, disc),
Some(gn) if !gn.trim().is_empty() => format!("{gn} ({tag})"),
_ => tag,
};
let user_url = format!("{base}/users/{}", member.user.id);
let avatar_url = user_avatar_url(
@@ -12,7 +12,7 @@ pub mod reports;
pub mod settings;
pub mod stickers;
use crate::api::types::GuildDetailInfo;
use crate::{api::types::GuildDetailInfo, utils::user_tag::user_tag};
pub(crate) fn owner_display(guild: &GuildDetailInfo) -> String {
let Some(username) = guild.owner_username.as_deref() else {
@@ -21,7 +21,7 @@ pub(crate) fn owner_display(guild: &GuildDetailInfo) -> String {
let Some(discriminator) = guild.owner_discriminator.as_deref() else {
return guild.owner_id.clone();
};
let tag = format!("{username}#{discriminator}");
let tag = user_tag(username, discriminator, false);
if let Some(global_name) = guild
.owner_global_name
.as_deref()
@@ -4,6 +4,7 @@ use crate::{
api::types::{GuildInfo, ReportEntry},
config::AdminConfig,
templates::components::{page_container::card_with_header, table::data_table},
utils::user_tag::user_tag,
};
use maud::{Markup, html};
@@ -93,7 +94,7 @@ fn format_status(status: i32) -> &'static str {
fn format_reporter(report: &ReportEntry) -> String {
if let Some(ref username) = report.reporter_username {
let disc = report.reporter_discriminator.as_deref().unwrap_or("0000");
let tag = format!("{username}#{disc}");
let tag = user_tag(username, disc, false);
if let Some(ref gn) = report.reporter_global_name {
let trimmed = gn.trim();
if !trimmed.is_empty() {
@@ -28,16 +28,25 @@ const DISABLED_OPERATIONS: &[(&str, i32)] = &[
("MEMBER_LIST_UPDATES", 1 << 6),
];
fn low_verification_label(username_sign_in: bool) -> &'static str {
if username_sign_in {
"Low (claimed account)"
} else {
"Low (verified email)"
}
}
pub fn settings_tab(
config: &AdminConfig,
guild: &GuildDetailInfo,
csrf_token: &str,
admin_acls: &[String],
username_sign_in: bool,
) -> Markup {
let can_edit = acl::has_permission(admin_acls, acl::GUILD_UPDATE_SETTINGS);
if !can_edit {
return settings_tab_readonly(guild);
return settings_tab_readonly(guild, username_sign_in);
}
let base = &config.base_path;
@@ -55,7 +64,7 @@ pub fn settings_tab(
guild.verification_level.unwrap_or(0).min(3),
&[
(0, "None"),
(1, "Low (verified email)"),
(1, low_verification_label(username_sign_in)),
(2, "Medium (5+ minutes)"),
(3, "High (10+ minutes)"),
],
@@ -223,10 +232,10 @@ fn select_field(
}
}
fn settings_tab_readonly(guild: &GuildDetailInfo) -> Markup {
fn settings_tab_readonly(guild: &GuildDetailInfo, username_sign_in: bool) -> Markup {
let verification_label = match guild.verification_level.unwrap_or(0).min(3) {
0 => "None",
1 => "Low (verified email)",
1 => low_verification_label(username_sign_in),
2 => "Medium (5+ minutes)",
3 => "High (10+ minutes)",
_ => "Unknown",
@@ -285,3 +294,32 @@ fn readonly_field(label: &str, value: &str) -> Markup {
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn guild() -> GuildDetailInfo {
serde_json::from_value(json!({
"id": "1500000000000000001",
"owner_id": "1400000000000000001",
"name": "Guild",
"verification_level": 1
}))
.expect("valid guild detail")
}
#[test]
fn low_verification_names_a_claimed_account_in_username_mode() {
let markup = settings_tab_readonly(&guild(), true).into_string();
assert!(markup.contains("Low (claimed account)"));
assert!(!markup.contains("verified email"));
}
#[test]
fn low_verification_names_a_verified_email_in_email_mode() {
let markup = settings_tab_readonly(&guild(), false).into_string();
assert!(markup.contains("Low (verified email)"));
}
}
@@ -14,7 +14,7 @@ use crate::{
},
layout::admin_layout,
},
utils::forms::parse_comma_separated,
utils::{forms::parse_comma_separated, user_tag::user_tag},
};
use maud::{Markup, html};
@@ -270,7 +270,7 @@ fn owner_display(guild: &GuildInfo) -> String {
let Some(discriminator) = guild.owner_discriminator.as_deref() else {
return guild.owner_id.clone();
};
let tag = format!("{username}#{discriminator}");
let tag = user_tag(username, discriminator, false);
if let Some(global_name) = guild
.owner_global_name
.as_deref()
@@ -2,14 +2,15 @@
use crate::{
api::types::{
AppPublicConfigResponse, CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE,
CaptchaConfigResponse, DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
EXPERIMENT_MAX_TARGETED_USERS, ExperimentDeliveryConfigResponse,
GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse,
InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse,
LimitConfigResponse, PLUTONIUM_PAGE_DEFAULT_SALT, PendingRegistrationResponse,
PlutoniumPageConfigResponse, PushRelayConfigResponse, RegistrationUrlResponse,
SsoConfigResponse,
SsoConfigResponse, TagStyle,
},
config::AdminConfig,
middleware::auth::AuthContext,
@@ -111,6 +112,9 @@ pub fn instance_config_page(
"Access & accounts",
"Who can sign in and create accounts on this instance.",
html! {
@if instance_config.self_hosted {
(account_identity_section(&instance_config.account_identity))
}
(registration_config_section(
config,
csrf_token,
@@ -159,7 +163,12 @@ pub fn instance_config_page(
"Runtime integrations",
"Credentials and provider choices that override environment variables at runtime.",
html! {
(integrations_config_section(base, csrf_token, &instance_config.integrations))
(integrations_config_section(
base,
csrf_token,
&instance_config.integrations,
instance_config.account_identity.mode,
))
},
))
(config_group(
@@ -500,10 +509,65 @@ fn password_input(name: &str, label: &str, helper: Option<&str>) -> Markup {
)
}
fn account_identity_section(account_identity: &AccountIdentityConfigResponse) -> Markup {
let description = match account_identity.mode {
AccountIdentityMode::Username => {
"Members sign in with a username and password. The instance never collects an email \
address. A member who forgets their password uses their recovery kit or a reset link \
from an admin."
}
AccountIdentityMode::Email => "Members sign in with an email address and password.",
};
section_card_with_description(
"Sign-in Method",
"How members identify themselves when they sign in.",
html! {
div class="space-y-3" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" {
(account_identity.mode.label())
}
@match account_identity.locked {
Some(true) => (badge("Fixed", BadgeVariant::Default)),
Some(false) => (badge("Not fixed yet", BadgeVariant::Warning)),
None => {}
}
}
p class="text-sm text-neutral-600" { (description) }
@if !account_identity.mode.is_username() {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" {
(account_identity.tag_style.label())
}
}
p class="text-sm text-neutral-600" {
@match account_identity.tag_style {
TagStyle::None => {
"Each name belongs to one person and is shown without a tag."
}
TagStyle::Random => {
"Names have a random tag, like alex#4821, so several people can share a name."
}
}
}
}
p class="text-xs text-neutral-500" {
@if account_identity.mode.is_username() {
"The sign-in method is chosen during setup. It cannot be changed once setup is complete or the first account exists."
} @else {
"The sign-in method and the username tags are chosen during setup. They cannot be changed once setup is complete or the first account exists."
}
}
}
},
)
}
fn integrations_config_section(
base: &str,
csrf_token: &str,
integrations: &InstanceIntegrationsResponse,
account_identity: AccountIdentityMode,
) -> Markup {
let smtp_port = integrations
.email
@@ -536,62 +600,65 @@ fn integrations_config_section(
(password_input("integration_youtube_api_key", "YouTube API key", Some("Leave blank to keep the current key.")))
}
div class="space-y-4 border-t border-neutral-200 pt-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Email delivery" }
@if integrations.email.effective_enabled {
(badge("Effective: enabled", BadgeVariant::Success))
} @else {
(badge("Effective: disabled", BadgeVariant::Default))
@if !account_identity.is_username() {
div class="space-y-4 border-t border-neutral-200 pt-6" {
div class="flex flex-wrap items-center gap-2" {
h3 class="text-sm font-semibold text-neutral-900" { "Email delivery" }
@if integrations.email.effective_enabled {
(badge("Effective: enabled", BadgeVariant::Success))
} @else {
(badge("Effective: disabled", BadgeVariant::Default))
}
@if integrations.email.effective_disable_new_ip_authorization {
(badge("IP auth disabled", BadgeVariant::Warning))
} @else {
(badge("IP auth required", BadgeVariant::Default))
}
(secret_badge("SMTP password", integrations.email.smtp.password_set))
}
@if integrations.email.effective_disable_new_ip_authorization {
(badge("IP auth disabled", BadgeVariant::Warning))
} @else {
(badge("IP auth required", BadgeVariant::Default))
input type="hidden" name="integration_email_present" value="1";
(checkbox("integration_email_enabled", "true", "Enable email delivery", integrations.email.effective_enabled, true))
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(text_input(
"integration_email_from_email",
"From email",
integrations.email.from_email.as_deref().unwrap_or(""),
"[email protected]",
))
(text_input(
"integration_email_from_name",
"From name",
integrations.email.from_name.as_deref().unwrap_or(""),
"Fluxer",
))
(text_input(
"integration_smtp_host",
"SMTP host",
integrations.email.smtp.host.as_deref().unwrap_or(""),
"smtp.example.com",
))
(text_input(
"integration_smtp_port",
"SMTP port",
&smtp_port,
"587",
))
(text_input(
"integration_smtp_username",
"SMTP username",
integrations.email.smtp.username.as_deref().unwrap_or(""),
"[email protected]",
))
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
}
(secret_badge("SMTP password", integrations.email.smtp.password_set))
}
(checkbox("integration_email_enabled", "true", "Enable email delivery", integrations.email.effective_enabled, true))
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
(text_input(
"integration_email_from_email",
"From email",
integrations.email.from_email.as_deref().unwrap_or(""),
"[email protected]",
))
(text_input(
"integration_email_from_name",
"From name",
integrations.email.from_name.as_deref().unwrap_or(""),
"Fluxer",
))
(text_input(
"integration_smtp_host",
"SMTP host",
integrations.email.smtp.host.as_deref().unwrap_or(""),
"smtp.example.com",
))
(text_input(
"integration_smtp_port",
"SMTP port",
&smtp_port,
"587",
))
(text_input(
"integration_smtp_username",
"SMTP username",
integrations.email.smtp.username.as_deref().unwrap_or(""),
"[email protected]",
))
(password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password.")))
}
(checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true))
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorisation", integrations.email.disable_new_ip_authorization, true))
div class="flex flex-wrap gap-2" {
button type="submit"
formaction={(base) "/instance-config?action=test_smtp"}
class="inline-flex w-fit items-center justify-center gap-2 rounded-lg border border-neutral-300 bg-neutral-50 px-4 py-2 font-medium text-base text-neutral-700 transition-all duration-150 hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white" {
span { "Test SMTP connection" }
(checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true))
(checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorisation", integrations.email.disable_new_ip_authorization, true))
div class="flex flex-wrap gap-2" {
button type="submit"
formaction={(base) "/instance-config?action=test_smtp"}
class="inline-flex w-fit items-center justify-center gap-2 rounded-lg border border-neutral-300 bg-neutral-50 px-4 py-2 font-medium text-base text-neutral-700 transition-all duration-150 hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white" {
span { "Test SMTP connection" }
}
}
}
}
@@ -2032,6 +2099,95 @@ fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Marku
mod tests {
use super::*;
#[test]
fn username_instances_hide_email_delivery_and_the_smtp_test() {
let integrations = InstanceIntegrationsResponse::default();
let username = integrations_config_section(
"/admin",
"csrf",
&integrations,
AccountIdentityMode::Username,
)
.into_string();
assert!(!username.contains("Email delivery"));
assert!(!username.contains("test_smtp"));
assert!(!username.contains("integration_email_present"));
assert!(username.contains("Bluesky OAuth"));
let email = integrations_config_section(
"/admin",
"csrf",
&integrations,
AccountIdentityMode::Email,
)
.into_string();
assert!(email.contains("Email delivery"));
assert!(email.contains("test_smtp"));
assert!(email.contains(r#"name="integration_email_present" value="1""#));
}
#[test]
fn account_identity_section_has_no_tag_choice_in_username_mode() {
let markup = account_identity_section(&AccountIdentityConfigResponse {
mode: AccountIdentityMode::Username,
locked: Some(true),
tag_style: TagStyle::None,
})
.into_string();
assert!(markup.contains("Sign-in Method"));
assert!(markup.contains("Username"));
assert!(markup.contains("Fixed"));
assert!(!markup.contains("No tags"));
assert!(!markup.contains("Random tags"));
assert!(!markup.contains("username tags"));
assert!(!markup.contains("<form"));
assert!(!markup.contains("<input"));
}
#[test]
fn account_identity_section_shows_random_tags_in_email_mode() {
let markup = account_identity_section(&AccountIdentityConfigResponse {
mode: AccountIdentityMode::Email,
locked: Some(true),
tag_style: TagStyle::Random,
})
.into_string();
assert!(markup.contains("Random tags"));
assert!(!markup.contains("No tags"));
assert!(markup.contains("username tags"));
}
#[test]
fn account_identity_section_shows_no_tags_in_email_mode() {
let markup = account_identity_section(&AccountIdentityConfigResponse {
mode: AccountIdentityMode::Email,
locked: Some(true),
tag_style: TagStyle::None,
})
.into_string();
assert!(markup.contains("No tags"));
assert!(!markup.contains("Random tags"));
assert!(!markup.contains("<input"));
}
#[test]
fn account_identity_section_shows_the_lock_state_only_when_known() {
let render = |locked| {
account_identity_section(&AccountIdentityConfigResponse {
mode: AccountIdentityMode::Email,
locked,
tag_style: TagStyle::Random,
})
.into_string()
};
let unlocked = render(Some(false));
assert!(unlocked.contains("Not fixed yet"));
let unknown = render(None);
assert!(!unknown.contains("Fixed"));
assert!(!unknown.contains("Not fixed yet"));
assert!(unknown.contains("Random tags"));
}
#[test]
fn captcha_section_posts_the_switch_and_difficulty_fields() {
let markup =
@@ -22,7 +22,7 @@ use crate::{
},
layout::admin_layout,
},
utils::timestamps::format_admin_timestamp,
utils::{timestamps::format_admin_timestamp, user_tag::user_tag},
};
use maud::{Markup, html};
@@ -54,7 +54,7 @@ fn reporter_label(report: &ReportEntry) -> String {
}
if let Some(username) = &report.reporter_username {
let discriminator = report.reporter_discriminator.as_deref().unwrap_or("0000");
return format!("{username}#{discriminator}");
return user_tag(username, discriminator, false);
}
if let Some(email) = &report.reporter_email {
return email.to_owned();
@@ -71,7 +71,7 @@ fn reported_user_label(report: &ReportEntry) -> String {
.reported_user_discriminator
.as_deref()
.unwrap_or("0000");
return format!("{username}#{discriminator}");
return user_tag(username, discriminator, false);
}
format!(
"User {}",
@@ -15,6 +15,7 @@ use crate::{
},
layout::admin_layout,
},
utils::user_tag::user_tag,
};
use maud::{Markup, PreEscaped, html};
@@ -189,7 +190,7 @@ fn format_category(category: Option<&str>) -> String {
fn reporter_label(report: &ReportEntry) -> String {
if let Some(username) = &report.reporter_username {
let discriminator = report.reporter_discriminator.as_deref().unwrap_or("0000");
let tag = format!("{username}#{discriminator}");
let tag = user_tag(username, discriminator, false);
if let Some(display) = report
.reporter_global_name
.as_ref()
@@ -214,7 +215,7 @@ fn reported_user_label(report: &ReportEntry) -> String {
.reported_user_discriminator
.as_deref()
.unwrap_or("0000");
let tag = format!("{username}#{discriminator}");
let tag = user_tag(username, discriminator, false);
if let Some(display) = report
.reported_user_global_name
.as_ref()
@@ -15,7 +15,7 @@ use crate::{
layout::admin_layout,
pages::user_detail_tabs,
},
utils::bigint::format_discriminator,
utils::{bigint::format_discriminator, user_tag::user_tag},
};
use maud::{Markup, html};
@@ -163,7 +163,7 @@ fn render_user_detail(
))
}
p class="break-words text-sm text-neutral-500" {
(user.username) "#" (format_discriminator(&user.discriminator))
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
}
p class="break-all text-sm text-neutral-500" {
(user.id)
@@ -1,12 +1,17 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::{AdminUser, UserSession, WebAuthnCredential},
acl,
api::types::{
AccountIdentityMode, AdminUser, PasswordResetLinkResponse, UserSession, WebAuthnCredential,
},
config::AdminConfig,
templates::components::{
alert::{AlertVariant, alert},
form::{checkbox, csrf_input, form_actions, submit_button},
page_container::card_with_header,
},
utils::timestamps::format_admin_timestamp,
};
use maud::{Markup, html};
@@ -17,19 +22,35 @@ const BTN_CLS: &str = "w-full inline-flex items-center justify-center rounded-md
bg-brand-primary px-4 py-2 text-sm font-medium text-white \
shadow-sm hover:bg-brand-primary-dark";
pub struct AccountTabOptions<'a> {
pub admin_acls: &'a [String],
pub account_identity: AccountIdentityMode,
pub password_reset_link: Option<&'a PasswordResetLinkResponse>,
}
pub fn account_tab(
config: &AdminConfig,
user: &AdminUser,
sessions: &[UserSession],
webauthn_credentials: &[WebAuthnCredential],
csrf_token: &str,
options: &AccountTabOptions<'_>,
) -> Markup {
let base = &config.base_path;
let username_sign_in = options.account_identity.is_username();
let can_create_reset_link = username_sign_in
&& acl::has_permission(options.admin_acls, acl::USER_CREATE_PASSWORD_RESET_LINK);
let can_revoke_recovery_kit = username_sign_in
&& !user.bot
&& acl::has_permission(options.admin_acls, acl::USER_DELETE_RECOVERY_KIT);
html! {
div class="space-y-6" {
(edit_account_card(base, user, csrf_token))
@if can_create_reset_link {
(password_reset_link_card(base, user, csrf_token, options.password_reset_link))
}
(edit_account_card(base, user, csrf_token, username_sign_in))
(sessions_card(config, sessions))
(quick_actions_card(base, user, csrf_token))
(quick_actions_card(base, user, csrf_token, username_sign_in, can_revoke_recovery_kit))
(clear_fields_card(base, user, csrf_token))
(security_actions_card(base, user, csrf_token))
(webauthn_credentials_card(base, user, webauthn_credentials, csrf_token))
@@ -37,7 +58,77 @@ pub fn account_tab(
}
}
fn edit_account_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
fn password_reset_link_card(
base: &str,
user: &AdminUser,
csrf_token: &str,
link: Option<&PasswordResetLinkResponse>,
) -> Markup {
let action_url = format!(
"{base}/users/{}?action=create_password_reset_link&tab=account",
user.id
);
html! {
(card_with_header("Password Reset Link", html! {
div class="space-y-4" {
(password_reset_link_result(link))
p class="text-sm text-neutral-600" {
"Create a one-time link that lets this user choose a new password. \
Hand it to them yourself. It works once and expires after an hour."
}
form method="post"
action=(&action_url)
data-admin-result-form="true"
hx-post=(&action_url)
hx-target={"#" (PASSWORD_RESET_LINK_RESULT_ID)}
hx-swap="outerHTML"
hx-push-url="false" {
(csrf_input(csrf_token))
button type="submit" class=(BTN_CLS) { "Create Password Reset Link" }
}
}
}))
}
}
pub const PASSWORD_RESET_LINK_RESULT_ID: &str = "password-reset-link-result";
pub fn password_reset_link_result(link: Option<&PasswordResetLinkResponse>) -> Markup {
html! {
div id=(PASSWORD_RESET_LINK_RESULT_ID) hx-history=[link.is_some().then_some("false")] {
@if let Some(link) = link {
(alert(AlertVariant::Success, Some("Password reset link created"), html! {
div class="flex flex-col gap-2" {
p class="text-sm" {
"Copy this link now. It is shown only once."
}
div class="flex items-center gap-2" {
input type="url" readonly value=(link.url)
aria-label="Password reset link"
class="h-8 min-w-0 flex-1 rounded-lg border border-green-200 bg-white px-3 py-1.5 text-xs text-neutral-900";
button type="button"
class="inline-flex h-8 shrink-0 items-center justify-center rounded-lg border border-neutral-300 bg-neutral-50 px-3 text-xs font-medium text-neutral-700 hover:border-neutral-400 hover:text-neutral-900"
data-copy-value=(link.url)
onclick="window.__adminCopyToClipboard && window.__adminCopyToClipboard(this.dataset.copyValue, this, 'Copied')" {
"Copy Link"
}
}
p class="text-xs" {
"Expires " (format_admin_timestamp(&link.expires_at))
}
}
}))
}
}
}
}
fn edit_account_card(
base: &str,
user: &AdminUser,
csrf_token: &str,
username_sign_in: bool,
) -> Markup {
html! {
(card_with_header("Edit Account Information", html! {
div class="grid gap-4 md:grid-cols-2" {
@@ -46,22 +137,26 @@ fn edit_account_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
p class="text-sm font-medium text-neutral-700" { "Change Username:" }
input type="text" name="username" placeholder="New username"
required class=(INPUT_CLS);
input type="text" name="discriminator"
placeholder="Discriminator (optional)" inputmode="numeric" pattern="[0-9]{1,4}" maxlength="4"
class=(INPUT_CLS);
@if !crate::utils::user_tag::unique_usernames() || user.bot {
input type="text" name="discriminator"
placeholder="Discriminator (optional)" inputmode="numeric" pattern="[0-9]{1,4}" maxlength="4"
class=(INPUT_CLS);
}
(form_actions(html! {
(submit_button("Change Username"))
}))
}, csrf_token))
(post_form(base, &user.id, "change_email", "account",
"Are you sure you want to change this user\\'s email address?", html! {
p class="text-sm font-medium text-neutral-700" { "Change Email:" }
input type="email" name="email" placeholder="New email address"
required class=(INPUT_CLS);
(form_actions(html! {
(submit_button("Change Email"))
}))
}, csrf_token))
@if !username_sign_in {
(post_form(base, &user.id, "change_email", "account",
"Are you sure you want to change this user\\'s email address?", html! {
p class="text-sm font-medium text-neutral-700" { "Change Email:" }
input type="email" name="email" placeholder="New email address"
required class=(INPUT_CLS);
(form_actions(html! {
(submit_button("Change Email"))
}))
}, csrf_token))
}
(post_form(base, &user.id, "change_dob", "account",
"Are you sure you want to change this user\\'s date of birth?", html! {
p class="text-sm font-medium text-neutral-700" { "Change Date of Birth:" }
@@ -152,16 +247,28 @@ fn session_entry(base: &str, s: &UserSession, is_tombstone: bool) -> Markup {
}
}
fn quick_actions_card(base: &str, user: &AdminUser, csrf_token: &str) -> Markup {
fn quick_actions_card(
base: &str,
user: &AdminUser,
csrf_token: &str,
username_sign_in: bool,
can_revoke_recovery_kit: bool,
) -> Markup {
html! {
(card_with_header("Quick Actions", html! {
div class="flex flex-wrap gap-3" {
@if !user.email_verified {
@if !user.email_verified && !username_sign_in {
(action_form(base, &user.id, "verify_email", "account", None,
"Verify Email", csrf_token))
}
(action_form(base, &user.id, "send_password_reset", "account", None,
"Send Password Reset", csrf_token))
@if !username_sign_in {
(action_form(base, &user.id, "send_password_reset", "account", None,
"Send Password Reset", csrf_token))
}
@if can_revoke_recovery_kit {
(action_form(base, &user.id, "revoke_recovery_kit", "account", None,
"Revoke Recovery Kit", csrf_token))
}
}
}))
}
@@ -1,6 +1,9 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{api::types::AdminResolvedUser, utils::bigint::format_discriminator};
use crate::{
api::types::AdminResolvedUser,
utils::{bigint::format_discriminator, user_tag::user_tag},
};
pub mod account;
pub mod applications;
@@ -15,8 +18,11 @@ pub mod reports;
pub mod settings;
pub(super) fn resolved_user_display(user: &AdminResolvedUser) -> String {
let disc = format_discriminator(&user.discriminator);
let tag = format!("{}#{}", user.username, disc);
let tag = user_tag(
&user.username,
&format_discriminator(&user.discriminator),
false,
);
match &user.global_name {
Some(gn) if !gn.trim().is_empty() => format!("{} ({})", gn, tag),
_ => tag,
@@ -63,6 +63,7 @@ pub struct CurrentBan<'a> {
pub struct ModerationContext<'a> {
pub deletion_scheduler: Option<&'a AdminUser>,
pub current_ban: Option<CurrentBan<'a>>,
pub username_sign_in: bool,
}
pub fn find_current_ban<'a>(user: &AdminUser, logs: &'a [AuditLogEntry]) -> Option<CurrentBan<'a>> {
@@ -118,8 +119,8 @@ pub fn moderation_tab(
html! {
div class="space-y-6" {
div class="grid grid-cols-1 gap-6 md:grid-cols-2" {
(ban_actions_card(base, user, csrf_token, context.current_ban.as_ref()))
(deletion_card(base, user, csrf_token, context.deletion_scheduler))
(ban_actions_card(base, user, csrf_token, context.current_ban.as_ref(), context.username_sign_in))
(deletion_card(base, user, csrf_token, context.deletion_scheduler, context.username_sign_in))
}
@if can_delete_all_messages {
(delete_all_messages_card(base, user, csrf_token, delete_all_messages_dry_run))
@@ -131,11 +132,20 @@ pub fn moderation_tab(
}
}
fn notify_user_checkbox(username_sign_in: bool, label: &str) -> Markup {
if username_sign_in {
html! { input type="hidden" name="notify_user_present" value="1"; }
} else {
opt_out_checkbox("notify_user", label)
}
}
fn ban_actions_card(
base: &str,
user: &AdminUser,
csrf_token: &str,
current_ban: Option<&CurrentBan<'_>>,
username_sign_in: bool,
) -> Markup {
html! {
(card_with_header("Ban Actions", html! {
@@ -159,7 +169,7 @@ fn ban_actions_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user that the suspension was lifted"))
(notify_user_checkbox(username_sign_in, "Email the user that the suspension was lifted"))
(form_actions(html! {
(submit_button("Unban User"))
}))
@@ -194,7 +204,7 @@ fn ban_actions_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user about this suspension (temporary bans only)"))
(notify_user_checkbox(username_sign_in, "Email the user about this suspension (temporary bans only)"))
(form_actions(html! {
(submit_button("Ban/Suspend User"))
}))
@@ -335,6 +345,7 @@ fn deletion_card(
user: &AdminUser,
csrf_token: &str,
scheduler: Option<&AdminUser>,
username_sign_in: bool,
) -> Markup {
html! {
(card_with_header("Account Deletion", html! {
@@ -353,7 +364,9 @@ fn deletion_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
@if !username_sign_in {
(checkbox("notify_user", "true", "Email the user that the deletion was cancelled", false, true))
}
(checkbox("confirm", "true", &confirmation, false, true))
(form_actions(html! {
(danger_button("Cancel Deletion"))
@@ -397,7 +410,7 @@ fn deletion_card(
px-3 py-2 text-sm shadow-sm \
focus:border-brand-primary focus:outline-none \
focus:ring-1 focus:ring-brand-primary";
(opt_out_checkbox("notify_user", "Email the user about the scheduled deletion"))
(notify_user_checkbox(username_sign_in, "Email the user about the scheduled deletion"))
(form_actions(html! {
(submit_button("Schedule Deletion"))
}))
@@ -776,7 +789,8 @@ mod tests {
"deletion_scheduled_at": "2026-08-31T17:40:29.690Z"
}));
let scheduler = user(json!({"id": "1400000000000000001", "username": "lilith"}));
let markup = deletion_card("/admin", &target, "csrf", Some(&scheduler)).into_string();
let markup =
deletion_card("/admin", &target, "csrf", Some(&scheduler), false).into_string();
assert!(markup.contains(r#"href="/admin/users/1400000000000000001""#));
assert!(markup.contains("lilith"));
assert!(markup.contains("Report batch 12"));
@@ -793,7 +807,7 @@ mod tests {
#[test]
fn schedule_form_makes_the_reason_an_explicit_choice() {
let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string();
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
assert!(markup.contains(r#"<option value="" disabled selected>Choose a reason</option>"#));
assert!(!markup.contains(r#"<option value="1" selected>"#));
assert!(!markup.contains("replace_pending_deletion_at"));
@@ -801,22 +815,46 @@ mod tests {
#[test]
fn schedule_form_emails_the_user_by_default() {
let markup = deletion_card("/admin", &user(json!({})), "csrf", None).into_string();
let markup = deletion_card("/admin", &user(json!({})), "csrf", None, false).into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn temp_ban_form_emails_the_user_by_default() {
let markup = ban_actions_card("/admin", &user(json!({})), "csrf", None).into_string();
let markup =
ban_actions_card("/admin", &user(json!({})), "csrf", None, false).into_string();
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn username_mode_offers_no_email_and_sends_none() {
let pending = user(json!({
"pending_deletion_at": "2026-10-30T17:40:29.690Z",
"deletion_reason_code": 3
}));
let banned = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
let forms = [
deletion_card("/admin", &user(json!({})), "csrf", None, true).into_string(),
deletion_card("/admin", &pending, "csrf", None, true).into_string(),
ban_actions_card("/admin", &user(json!({})), "csrf", None, true).into_string(),
ban_actions_card("/admin", &banned, "csrf", None, true).into_string(),
];
for markup in &forms {
assert!(!markup.contains("Email the user"));
assert!(!markup.contains(r#"name="notify_user" value="true""#));
}
assert!(forms[0].contains(r#"name="notify_user_present" value="1""#));
assert!(!forms[1].contains("notify_user"));
assert!(forms[2].contains(r#"name="notify_user_present" value="1""#));
assert!(forms[3].contains(r#"name="notify_user_present" value="1""#));
}
#[test]
fn unban_form_separates_the_public_and_private_reasons() {
let target = user(json!({"temp_banned_until": "2026-10-01T00:00:00.000Z"}));
let markup = ban_actions_card("/admin", &target, "csrf", None).into_string();
let markup = ban_actions_card("/admin", &target, "csrf", None, false).into_string();
assert!(markup.contains("?action=unban&amp;tab=moderation"));
assert!(markup.contains(r#"name="notify_user" value="true" checked"#));
assert!(markup.contains(r#"name="notify_user_present" value="1""#));
@@ -864,7 +902,7 @@ mod tests {
.collect::<Vec<_>>(),
["3"]
);
let markup = ban_actions_card("/admin", &target, "csrf", Some(&ban)).into_string();
let markup = ban_actions_card("/admin", &target, "csrf", Some(&ban), false).into_string();
assert!(markup.contains("Regel § 3"));
assert!(markup.contains("Also sent links"));
assert!(markup.contains(r#"name="ban_audit_log_id" value="2""#));
@@ -12,6 +12,7 @@ use crate::{
utils::{
bigint::format_discriminator,
timestamps::{format_admin_timestamp, snowflake_creation_date},
user_tag::user_tag,
},
};
use maud::{Markup, html};
@@ -24,10 +25,11 @@ pub fn overview_tab(
change_log: Option<&ListUserChangeLogResponse>,
) -> Markup {
render_overview_tab(
config, user, admin_acls, csrf_token, change_log, None, false,
config, user, admin_acls, csrf_token, change_log, None, false, false,
)
}
#[allow(clippy::too_many_arguments)]
pub fn overview_tab_with_limit_config(
config: &AdminConfig,
user: &AdminUser,
@@ -35,6 +37,7 @@ pub fn overview_tab_with_limit_config(
csrf_token: &str,
change_log: Option<&ListUserChangeLogResponse>,
limit_config: Option<&LimitConfigResponse>,
username_sign_in: bool,
) -> Markup {
render_overview_tab(
config,
@@ -44,9 +47,11 @@ pub fn overview_tab_with_limit_config(
change_log,
limit_config,
true,
username_sign_in,
)
}
#[allow(clippy::too_many_arguments)]
fn render_overview_tab(
config: &AdminConfig,
user: &AdminUser,
@@ -55,6 +60,7 @@ fn render_overview_tab(
change_log: Option<&ListUserChangeLogResponse>,
limit_config: Option<&LimitConfigResponse>,
show_traits: bool,
username_sign_in: bool,
) -> Markup {
html! {
div class="space-y-6" {
@@ -118,7 +124,7 @@ fn render_overview_tab(
(snowflake_creation_date(&user.id))
}))
(detail_row("Username", html! {
(user.username) "#" (format_discriminator(&user.discriminator))
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
}))
(detail_row("Display Name", html! {
@if let Some(ref name) = user.global_name {
@@ -127,7 +133,7 @@ fn render_overview_tab(
span class="text-neutral-400" { "Not set" }
}
}))
@if acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL) {
@if acl::has_permission(admin_acls, acl::USER_VIEW_EMAIL) && !username_sign_in {
(detail_row("Email", html! {
@if let Some(ref email) = user.email {
(email)
@@ -573,3 +579,64 @@ fn custom_traits<'a>(user: &'a AdminUser, trait_definitions: &[&str]) -> Vec<&'a
.filter(|trait_name| !DERIVED_TRAITS.contains(trait_name))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn test_config() -> AdminConfig {
AdminConfig {
env: crate::config::RuntimeEnv::Test,
host: String::new(),
port: 3020,
secret_key_base: "test-secret".to_owned(),
base_path: "/admin".to_owned(),
api_endpoint: String::new(),
media_endpoint: String::new(),
static_cdn_endpoint: String::new(),
admin_endpoint: String::new(),
web_app_endpoint: String::new(),
oauth_client_id: String::new(),
oauth_client_secret: String::new(),
oauth_redirect_uri: String::new(),
build_version: "test".to_owned(),
self_hosted: true,
proxy: crate::config::ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: String::new(),
},
}
}
fn render_email_row(username_sign_in: bool) -> String {
let user: AdminUser = serde_json::from_value(serde_json::json!({
"id": "1500000000000000001",
"username": "target",
"discriminator": "0001",
"email": "[email protected]"
}))
.expect("valid admin user");
let acls = vec![acl::USER_VIEW_EMAIL.to_owned()];
render_overview_tab(
&test_config(),
&user,
&acls,
"csrf",
None,
None,
false,
username_sign_in,
)
.into_string()
}
#[test]
fn username_mode_hides_the_email_row() {
assert!(!render_email_row(true).contains("[email protected]"));
}
#[test]
fn email_mode_shows_the_email_row() {
assert!(render_email_row(false).contains("[email protected]"));
}
}
@@ -8,6 +8,7 @@ use crate::{
page_container::card_with_header,
table::data_table,
},
utils::user_tag::user_tag,
};
use maud::{Markup, html};
@@ -162,7 +163,7 @@ fn format_status(status: i32) -> &'static str {
fn format_reporter(report: &ReportEntry) -> String {
if let Some(ref username) = report.reporter_username {
let disc = report.reporter_discriminator.as_deref().unwrap_or("0000");
let tag = format!("{username}#{disc}");
let tag = user_tag(username, disc, false);
if let Some(ref gn) = report.reporter_global_name {
let trimmed = gn.trim();
if !trimmed.is_empty() {
@@ -260,7 +261,7 @@ fn format_reported_entity(report: &ReportEntry) -> String {
.reported_user_discriminator
.as_deref()
.unwrap_or("0000");
let tag = format!("{username}#{disc}");
let tag = user_tag(username, disc, false);
if let Some(ref gn) = report.reported_user_global_name {
let trimmed = gn.trim();
if !trimmed.is_empty() {
@@ -3,7 +3,10 @@
use crate::{
api::types::AdminUser,
templates::components::page_container::{card_with_header, detail_row},
utils::bigint::{format_discriminator, has_flag, list_flags},
utils::{
bigint::{format_discriminator, has_flag, list_flags},
user_tag::user_tag,
},
};
use maud::{Markup, html};
@@ -13,7 +16,7 @@ pub fn settings_tab(user: &AdminUser) -> Markup {
(card_with_header("Profile Settings", html! {
dl class="divide-y divide-neutral-100" {
(detail_row("Username", html! {
(user.username) "#" (format_discriminator(&user.discriminator))
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
}))
(detail_row("Display Name", html! {
@if let Some(ref name) = user.global_name {
@@ -10,7 +10,9 @@ use crate::{
media::user_avatar_url,
user_profile_badges::user_profile_badges,
},
utils::{bigint::format_discriminator, timestamps::snowflake_creation_date},
utils::{
bigint::format_discriminator, timestamps::snowflake_creation_date, user_tag::user_tag,
},
};
use maud::{Markup, html};
@@ -72,7 +74,7 @@ pub fn user_peek_fragment(
))
}
p class="break-words text-sm text-neutral-500" {
(user.username) "#" (format_discriminator(&user.discriminator))
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
}
div class="flex flex-wrap items-center justify-center gap-2 \
sm:justify-start" {
+49 -16
View File
@@ -22,7 +22,10 @@ use crate::{
},
layout::admin_layout,
},
utils::bigint::format_discriminator,
utils::{
bigint::format_discriminator,
user_tag::{unique_usernames, user_tag},
},
};
use maud::{Markup, html};
@@ -103,6 +106,7 @@ pub fn users_list_page(
results: Option<&[AdminUser]>,
has_more: bool,
can_view_email: bool,
username_sign_in: bool,
premium_badge_name: Option<&str>,
is_htmx: bool,
) -> Markup {
@@ -127,7 +131,7 @@ pub fn users_list_page(
p class="mb-1 text-xs text-neutral-500" {
"For example, type " span class="font-mono" { "*" } " in to search for all users."
}
(search_form(base, params))
(search_form(base, params, !username_sign_in))
}
(results_markup)
}
@@ -153,15 +157,20 @@ fn parse_ids_query(ids_query: &str) -> Vec<String> {
ids
}
fn search_form(base: &str, params: &UserListParams) -> Markup {
fn search_form(base: &str, params: &UserListParams, show_email_search: bool) -> Markup {
let action = format!("{base}/users");
let placeholder = if unique_usernames() {
"Search by user ID, username, or Stripe ID..."
} else {
"Search by user ID, username, tag#0000, or Stripe ID..."
};
html! {
form method="get" action=(&action)
class="flex flex-col gap-3 sm:flex-row sm:items-center" {
div class="flex flex-1 flex-col gap-2 sm:flex-row" {
div class="flex-1" {
input id="search-q" type="text" name="q" value=(params.q)
placeholder="Search by user ID, username, tag#0000, or Stripe ID..."
placeholder=(placeholder)
class={(FORM_CONTROL_CLASS) " " (FORM_SEARCH_INPUT_SIZE_CLASS)}
hx-get=(&action)
hx-trigger="input changed delay:300ms, search"
@@ -170,16 +179,18 @@ fn search_form(base: &str, params: &UserListParams) -> Markup {
hx-include="closest form"
hx-swap="outerHTML";
}
div class="flex-1" {
input id="search-email" type="text" name="email" value=(params.email)
placeholder="Exact email address..."
class={(FORM_CONTROL_CLASS) " " (FORM_SEARCH_INPUT_SIZE_CLASS)}
hx-get=(&action)
hx-trigger="input changed delay:300ms, search"
hx-target="#users-results"
hx-push-url="true"
hx-include="closest form"
hx-swap="outerHTML";
@if show_email_search {
div class="flex-1" {
input id="search-email" type="text" name="email" value=(params.email)
placeholder="Exact email address..."
class={(FORM_CONTROL_CLASS) " " (FORM_SEARCH_INPUT_SIZE_CLASS)}
hx-get=(&action)
hx-trigger="input changed delay:300ms, search"
hx-target="#users-results"
hx-push-url="true"
hx-include="closest form"
hx-swap="outerHTML";
}
}
div class="flex-1" {
input id="search-ip" type="text" name="ip" value=(params.ip)
@@ -349,7 +360,7 @@ fn render_users_table(
@if user.global_name.as_deref().map(|n| !n.trim().is_empty()).unwrap_or(false) {
(display_name)
} @else {
(user.username) "#" (format_discriminator(&user.discriminator))
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
}
}
(user_profile_badges(
@@ -364,7 +375,7 @@ fn render_users_table(
}
@if user.global_name.as_deref().map(|n| !n.trim().is_empty()).unwrap_or(false) {
p class="text-xs font-normal text-neutral-500" {
(user.username) "#" (format_discriminator(&user.discriminator))
(user_tag(&user.username, &format_discriminator(&user.discriminator), user.bot))
}
}
}
@@ -450,3 +461,25 @@ fn users_url(base: &str, params: &UserListParams, page: u32) -> String {
pairs.push(format!("page={page}"));
format!("{base}/users?{}", pairs.join("&"))
}
#[cfg(test)]
mod tests {
use super::*;
fn params() -> UserListParams {
UserListParams::from_query(None, None, None, None, None, None)
}
#[test]
fn username_mode_has_no_email_search() {
let markup = search_form("/admin", &params(), false).into_string();
assert!(!markup.contains("search-email"));
assert!(markup.contains("search-q"));
}
#[test]
fn email_mode_keeps_the_email_search() {
let markup = search_form("/admin", &params(), true).into_string();
assert!(markup.contains("search-email"));
}
}
+4
View File
@@ -55,6 +55,10 @@ impl MultiValueForm {
self.fields.contains_key(key)
}
pub fn has_key_starting_with(&self, prefix: &str) -> bool {
self.fields.keys().any(|key| key.starts_with(prefix))
}
pub fn values(&self, key: &str) -> &[String] {
self.fields.get(key).map(Vec::as_slice).unwrap_or_default()
}
+1
View File
@@ -3,3 +3,4 @@
pub mod bigint;
pub mod forms;
pub mod timestamps;
pub mod user_tag;
+69
View File
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::future::Future;
tokio::task_local! {
static UNIQUE_USERNAMES: bool;
}
pub async fn with_unique_usernames<F: Future>(unique_usernames: bool, future: F) -> F::Output {
UNIQUE_USERNAMES.scope(unique_usernames, future).await
}
pub fn sync_with_unique_usernames<R>(unique_usernames: bool, f: impl FnOnce() -> R) -> R {
UNIQUE_USERNAMES.sync_scope(unique_usernames, f)
}
pub fn unique_usernames() -> bool {
UNIQUE_USERNAMES.try_with(|value| *value).unwrap_or(false)
}
pub fn shows_discriminator(discriminator: &str, is_bot: bool) -> bool {
is_bot || !unique_usernames() || discriminator.trim().parse::<u16>() != Ok(0)
}
pub fn user_tag(username: &str, discriminator: &str, is_bot: bool) -> String {
if shows_discriminator(discriminator, is_bot) {
format!("{username}#{discriminator}")
} else {
username.to_owned()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn email_mode_keeps_every_tag() {
assert_eq!(user_tag("alice", "0000", false), "alice#0000");
assert_eq!(user_tag("alice", "0042", false), "alice#0042");
sync_with_unique_usernames(false, || {
assert_eq!(user_tag("alice", "0000", false), "alice#0000");
assert_eq!(user_tag("bot", "0000", true), "bot#0000");
});
}
#[test]
fn username_mode_hides_zero_tag_for_humans() {
sync_with_unique_usernames(true, || {
assert_eq!(user_tag("alice", "0000", false), "alice");
assert_eq!(user_tag("alice", "0", false), "alice");
assert!(!shows_discriminator("0000", false));
});
}
#[test]
fn username_mode_keeps_bot_and_non_zero_tags() {
sync_with_unique_usernames(true, || {
assert_eq!(user_tag("helper", "4363", true), "helper#4363");
assert_eq!(user_tag("helper", "0000", true), "helper#0000");
assert_eq!(user_tag("legacy", "0042", false), "legacy#0042");
});
}
#[test]
fn mode_defaults_to_email_outside_a_request() {
assert!(!unique_usernames());
}
}
+15
View File
@@ -441,6 +441,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
"pending_registrations": []
},
"self_hosted": false,
"account_identity": {"mode": "username", "locked": true, "tag_style": "none"},
"app_public": {
"branding": {
"product_name": "Fluxer",
@@ -601,9 +602,15 @@ fn deserialize_instance_config_response_with_unknown_keys() {
assert_eq!(resp.app_public.branding.premium_product_name, "Gold");
assert!(resp.billing.billing_active);
assert!(resp.media.attachment_decay.effective.enabled);
assert!(resp.account_identity.locked);
let ours: types::InstanceConfigResponse =
serde_json::from_str(json).expect("hand-written instance config");
assert_eq!(
ours.account_identity.mode,
types::AccountIdentityMode::Username
);
assert_eq!(ours.account_identity.locked, Some(true));
assert_eq!(ours.app_public.branding.premium_product_name, "Gold");
assert!(ours.billing.stripe_secret_key_stored);
assert_eq!(ours.billing.tax_id_collection, Some(true));
@@ -1069,3 +1076,11 @@ fn deserialize_list_admin_api_key_entry() {
assert_eq!(resp.created_by_user_id, "1130650140672000000");
assert_eq!(resp.acls.len(), 2);
}
#[test]
fn account_identity_lock_is_unknown_when_the_api_omits_it() {
let identity: types::AccountIdentityConfigResponse =
serde_json::from_str("{}").expect("empty account identity");
assert_eq!(identity.mode, types::AccountIdentityMode::Email);
assert_eq!(identity.locked, None);
}
+446
View File
@@ -0,0 +1,446 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![recursion_limit = "256"]
use axum::{
Json, Router,
body::{Body, to_bytes},
extract::State,
http::{Method, Request, StatusCode, Uri, header},
response::{IntoResponse, Response},
};
use fluxer_admin::{
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
session,
};
use serde_json::{Value, json};
use std::sync::{Arc, Mutex};
use tokio::net::TcpListener;
use tower::ServiceExt;
const SECRET_KEY: &str = "password-reset-link-test-secret";
const ADMIN_ID: &str = "1500000000000000000";
const TARGET_ID: &str = "1500000000000000042";
const RESET_URL: &str = "https://chat.example.test/reset#token=one-time-reset-token";
#[derive(Clone)]
struct MockApi {
account_identity: &'static str,
admin_acls: Vec<&'static str>,
requests: Arc<Mutex<Vec<String>>>,
}
#[tokio::test]
async fn creating_a_reset_link_shows_the_url_once_with_a_copy_button() {
let app = setup(true, "username", vec!["*"]).await;
let csrf_token = csrf_token(&app).await;
let (status, body) = post_form(
&app,
&format!("/users/{TARGET_ID}?action=create_password_reset_link&tab=account"),
&format!("_csrf={csrf_token}"),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(app.saw(&format!(
"POST /admin/users/{TARGET_ID}/password-reset-link"
)));
assert!(body.contains("Copy this link now. It is shown only once."));
assert!(body.contains(&format!(r#"value="{RESET_URL}""#)));
assert!(body.contains(&format!(r#"data-copy-value="{RESET_URL}""#)));
assert!(body.contains("Copy Link"));
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
assert!(page.contains("Create Password Reset Link"));
assert!(!page.contains(RESET_URL));
}
#[tokio::test]
async fn an_htmx_reset_link_request_gets_only_the_result_fragment() {
let app = setup(true, "username", vec!["*"]).await;
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
assert!(page.contains(r##"hx-target="#password-reset-link-result""##));
assert!(page.contains(r#"hx-push-url="false""#));
let csrf_token = csrf_token(&app).await;
let (status, body) = post_form_with_headers(
&app,
&format!("/users/{TARGET_ID}?action=create_password_reset_link&tab=account"),
&format!("_csrf={csrf_token}"),
&[
("HX-Request", "true"),
("HX-Target", "password-reset-link-result"),
],
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(
body.starts_with(r#"<div id="password-reset-link-result""#),
"{body}"
);
assert!(body.contains(r#"hx-history="false""#));
assert!(body.contains(&format!(r#"data-copy-value="{RESET_URL}""#)));
assert!(!body.contains("<html"));
assert!(!body.contains("Create Password Reset Link"));
}
#[tokio::test]
async fn revoking_a_recovery_kit_calls_the_api() {
let app = setup(true, "username", vec!["*"]).await;
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
assert!(page.contains("Revoke Recovery Kit"));
let csrf_token = csrf_token(&app).await;
let (status, _) = post_form(
&app,
&format!("/users/{TARGET_ID}?action=revoke_recovery_kit&tab=account"),
&format!("_csrf={csrf_token}"),
)
.await;
assert!(status.is_redirection() || status.is_success(), "{status}");
assert!(app.saw(&format!("DELETE /admin/users/{TARGET_ID}/recovery-kit")));
}
#[tokio::test]
async fn the_revoke_recovery_kit_action_needs_its_acl_and_a_username_instance() {
let without_acl = setup(
true,
"username",
vec![
"admin:authenticate",
"user:lookup",
"user:create:password_reset_link",
],
)
.await;
let page = get(&without_acl, &format!("/users/{TARGET_ID}?tab=account")).await;
assert!(page.contains("Create Password Reset Link"));
assert!(!page.contains("Revoke Recovery Kit"));
let with_acl = setup(
true,
"username",
vec![
"admin:authenticate",
"user:lookup",
"user:delete:recovery_kit",
],
)
.await;
let page = get(&with_acl, &format!("/users/{TARGET_ID}?tab=account")).await;
assert!(page.contains("Revoke Recovery Kit"));
let email = setup(true, "email", vec!["*"]).await;
let page = get(&email, &format!("/users/{TARGET_ID}?tab=account")).await;
assert!(!page.contains("Revoke Recovery Kit"));
}
#[tokio::test]
async fn username_instances_hide_email_actions_on_the_account_tab() {
let app = setup(true, "username", vec!["*"]).await;
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
assert!(page.contains("Create Password Reset Link"));
assert!(!page.contains("Send Password Reset"));
assert!(!page.contains("Change Email"));
assert!(!page.contains("Verify Email"));
}
#[tokio::test]
async fn the_reset_link_action_needs_its_acl() {
let app = setup(true, "username", vec!["admin:authenticate", "user:lookup"]).await;
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
assert!(page.contains("Terminate All Sessions"));
assert!(!page.contains("Create Password Reset Link"));
assert!(!page.contains("Send Password Reset"));
}
#[tokio::test]
async fn email_instances_keep_the_email_actions() {
let app = setup(true, "email", vec!["*"]).await;
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
assert!(page.contains("Send Password Reset"));
assert!(page.contains("Change Email"));
assert!(page.contains("Verify Email"));
assert!(!page.contains("Create Password Reset Link"));
}
#[tokio::test]
async fn the_email_ban_notice_stays_after_a_ban_action_on_a_username_instance() {
let notice = "Accounts have no email address, so email bans have no effect.";
let username = setup(true, "username", vec!["*"]).await;
let username_csrf = csrf_token(&username).await;
let (status, body) = post_form(
&username,
"/email-bans?action=ban",
&format!("_csrf={username_csrf}&email="),
)
.await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("Value is required"));
assert!(body.contains(notice));
let email = setup(true, "email", vec!["*"]).await;
let email_csrf = csrf_token(&email).await;
let (_, body) = post_form(
&email,
"/email-bans?action=ban",
&format!("_csrf={email_csrf}&email="),
)
.await;
assert!(body.contains("Value is required"));
assert!(!body.contains(notice));
}
#[tokio::test]
async fn hosted_admin_never_asks_discovery_for_the_sign_in_method() {
let app = setup(false, "username", vec!["*"]).await;
let page = get(&app, &format!("/users/{TARGET_ID}?tab=account")).await;
assert!(page.contains("Send Password Reset"));
assert!(!page.contains("Create Password Reset Link"));
assert!(!app.saw("GET /.well-known/fluxer"));
}
struct TestApp {
router: Router,
session_cookie: String,
requests: Arc<Mutex<Vec<String>>>,
}
impl TestApp {
fn saw(&self, route: &str) -> bool {
self.requests
.lock()
.expect("requests")
.iter()
.any(|seen| seen == route)
}
}
async fn setup(
self_hosted: bool,
account_identity: &'static str,
admin_acls: Vec<&'static str>,
) -> TestApp {
let requests = Arc::new(Mutex::new(Vec::new()));
let api_endpoint = spawn_mock_api(MockApi {
account_identity,
admin_acls,
requests: Arc::clone(&requests),
})
.await;
let router = build_router(test_config(api_endpoint, self_hosted));
let session_value = session::create_session(ADMIN_ID, "test-token", SECRET_KEY);
TestApp {
router,
session_cookie: format!("{}={session_value}", session::SESSION_COOKIE_NAME),
requests,
}
}
async fn get(app: &TestApp, uri: &str) -> String {
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri(uri)
.header(header::COOKIE, &app.session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK, "{uri}");
body_text(response).await
}
async fn csrf_token(app: &TestApp) -> String {
let response = app
.router
.clone()
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!("/users/{TARGET_ID}?tab=account"))
.header(header::COOKIE, &app.session_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
response
.headers()
.get_all(header::SET_COOKIE)
.iter()
.filter_map(|value| value.to_str().ok())
.find_map(|value| {
let pair = value.split(';').next()?;
let token = pair
.strip_prefix("__Host-csrf_token=")
.or_else(|| pair.strip_prefix("csrf_token="))?;
(!token.is_empty()).then(|| token.to_owned())
})
.expect("csrf_token cookie")
}
async fn post_form(app: &TestApp, uri: &str, body: &str) -> (StatusCode, String) {
post_form_with_headers(app, uri, body, &[]).await
}
async fn post_form_with_headers(
app: &TestApp,
uri: &str,
body: &str,
headers: &[(&str, &str)],
) -> (StatusCode, String) {
let csrf = body
.split('&')
.find_map(|pair| pair.strip_prefix("_csrf="))
.expect("form carries a csrf token");
let mut request = Request::builder()
.method(Method::POST)
.uri(uri)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header(
header::COOKIE,
format!("{}; __Host-csrf_token={csrf}", app.session_cookie),
);
for (name, value) in headers {
request = request.header(*name, *value);
}
let response = app
.router
.clone()
.oneshot(request.body(Body::from(body.to_owned())).unwrap())
.await
.unwrap();
let status = response.status();
(status, body_text(response).await)
}
async fn body_text(response: Response) -> String {
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
async fn spawn_mock_api(mock: MockApi) -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, Router::new().fallback(mock_api).with_state(mock))
.await
.unwrap();
});
format!("http://{addr}")
}
async fn mock_api(State(mock): State<MockApi>, method: Method, uri: Uri) -> Response {
let path = uri.path().to_owned();
mock.requests
.lock()
.expect("requests")
.push(format!("{method} {path}"));
let target_user = format!("/admin/users/{TARGET_ID}");
let target_sessions = format!("{target_user}/sessions");
let target_credentials = format!("{target_user}/webauthn-credentials");
let target_reset_link = format!("{target_user}/password-reset-link");
let target_recovery_kit = format!("{target_user}/recovery-kit");
match (method, path.as_str()) {
(Method::GET, "/admin/users/@me") => Json(json!({
"user": user(ADMIN_ID, "AdminUser", &mock.admin_acls)
}))
.into_response(),
(Method::GET, "/.well-known/fluxer") => Json(json!({
"features": {
"premium_enabled": false,
"account_identity": mock.account_identity
}
}))
.into_response(),
(Method::GET, p) if p == target_user => Json(json!({
"users": [user(TARGET_ID, "member", &[])]
}))
.into_response(),
(Method::GET, p) if p == target_sessions => Json(json!({ "sessions": [] })).into_response(),
(Method::GET, p) if p == target_credentials => Json(json!([])).into_response(),
(Method::POST, p) if p == target_reset_link => Json(json!({
"url": RESET_URL,
"expires_at": "2026-10-01T13:00:00.000Z"
}))
.into_response(),
(Method::DELETE, p) if p == target_recovery_kit => StatusCode::NO_CONTENT.into_response(),
_ => (
StatusCode::NOT_FOUND,
Json(json!({ "message": "not found" })),
)
.into_response(),
}
}
fn user(id: &str, username: &str, acls: &[&str]) -> Value {
json!({
"id": id,
"username": username,
"discriminator": 1,
"avatar": null,
"banner": null,
"email": null,
"email_verified": false,
"email_bounced": false,
"global_name": username,
"bio": null,
"pronouns": null,
"accent_color": null,
"date_of_birth": null,
"locale": "en-GB",
"acls": acls,
"traits": [],
"flags": "0",
"premium_flags": 0,
"bot": false,
"system": false,
"premium_type": null,
"premium_since": null,
"premium_until": null,
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"has_totp": false,
"authenticator_types": [],
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
"last_active_location": null
})
}
fn test_config(api_endpoint: String, self_hosted: bool) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: "127.0.0.1".to_owned(),
port: 0,
secret_key_base: SECRET_KEY.to_owned(),
base_path: String::new(),
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
oauth_client_id: "admin-client".to_owned(),
oauth_client_secret: "admin-secret".to_owned(),
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
build_version: "test".to_owned(),
self_hosted,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: "x-forwarded-for".to_owned(),
},
}
}
+241
View File
@@ -0,0 +1,241 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
#![recursion_limit = "256"]
use axum::{
Json, Router,
body::{Body, to_bytes},
extract::State,
http::{Method, Request, StatusCode, Uri, header},
response::{IntoResponse, Response},
};
use fluxer_admin::{
build_router,
config::{AdminConfig, ProxyConfig, RuntimeEnv},
session,
};
use serde_json::{Value, json};
use tokio::net::TcpListener;
use tower::ServiceExt;
const SECRET_KEY: &str = "username-tags-test-secret";
const ADMIN_ID: &str = "1500000000000000000";
const TARGET_ID: &str = "1500000000000000042";
const DISCRIMINATOR_INPUT: &str = r#"name="discriminator""#;
#[derive(Clone)]
struct MockApi {
account_identity: &'static str,
unique_usernames: bool,
target: Value,
}
#[tokio::test]
async fn username_instances_show_humans_without_a_tag() {
let page = account_page(true, "username", user(TARGET_ID, "member", 0, false)).await;
assert!(page.contains(r#"<p class="break-words text-sm text-neutral-500">member</p>"#));
assert!(page.contains(r#"<div class="truncate text-neutral-500 text-xs">lilith</div>"#));
assert!(!page.contains("member#0000"));
assert!(!page.contains("lilith#0000"));
assert!(!page.contains(DISCRIMINATOR_INPUT));
}
#[tokio::test]
async fn email_instances_with_random_tags_show_tags_and_allow_tag_changes() {
let page =
account_page_with(true, "email", false, user(TARGET_ID, "member", 1234, false)).await;
assert!(page.contains("member#1234"));
assert!(page.contains(DISCRIMINATOR_INPUT));
}
#[tokio::test]
async fn email_instances_with_no_tags_show_humans_without_a_tag() {
let page = account_page_with(true, "email", true, user(TARGET_ID, "member", 0, false)).await;
assert!(page.contains(r#"<p class="break-words text-sm text-neutral-500">member</p>"#));
assert!(!page.contains("member#0000"));
assert!(!page.contains("lilith#0000"));
assert!(!page.contains(DISCRIMINATOR_INPUT));
assert!(page.contains("Send Password Reset"));
}
#[tokio::test]
async fn username_instances_never_show_tags_even_if_told_random() {
let page =
account_page_with(true, "username", false, user(TARGET_ID, "member", 0, false)).await;
assert!(!page.contains("member#0000"));
assert!(!page.contains(DISCRIMINATOR_INPUT));
}
#[tokio::test]
async fn username_instances_keep_bot_tags() {
let page = account_page(true, "username", user(TARGET_ID, "helper", 4363, true)).await;
assert!(page.contains("helper#4363"));
assert!(page.contains(DISCRIMINATOR_INPUT));
}
#[tokio::test]
async fn email_instances_keep_the_zero_tag() {
let page = account_page(true, "email", user(TARGET_ID, "member", 0, false)).await;
assert!(page.contains("member#0000"));
assert!(page.contains("lilith#0000"));
assert!(page.contains(DISCRIMINATOR_INPUT));
}
#[tokio::test]
async fn hosted_admin_keeps_the_zero_tag() {
let page = account_page(false, "username", user(TARGET_ID, "member", 0, false)).await;
assert!(page.contains("member#0000"));
assert!(page.contains(DISCRIMINATOR_INPUT));
}
async fn account_page(self_hosted: bool, account_identity: &'static str, target: Value) -> String {
account_page_with(
self_hosted,
account_identity,
account_identity == "username",
target,
)
.await
}
async fn account_page_with(
self_hosted: bool,
account_identity: &'static str,
unique_usernames: bool,
target: Value,
) -> String {
let api_endpoint = spawn_mock_api(MockApi {
account_identity,
unique_usernames,
target,
})
.await;
let router = build_router(test_config(api_endpoint, self_hosted));
let session_value = session::create_session(ADMIN_ID, "test-token", SECRET_KEY);
let response = router
.oneshot(
Request::builder()
.method(Method::GET)
.uri(format!("/users/{TARGET_ID}?tab=account"))
.header(
header::COOKIE,
format!("{}={session_value}", session::SESSION_COOKIE_NAME),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
async fn spawn_mock_api(mock: MockApi) -> String {
let listener = TcpListener::bind(("127.0.0.1", 0)).await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
axum::serve(listener, Router::new().fallback(mock_api).with_state(mock))
.await
.unwrap();
});
format!("http://{addr}")
}
async fn mock_api(State(mock): State<MockApi>, method: Method, uri: Uri) -> Response {
let target_user = format!("/admin/users/{TARGET_ID}");
let target_sessions = format!("{target_user}/sessions");
let target_credentials = format!("{target_user}/webauthn-credentials");
match (method, uri.path()) {
(Method::GET, "/admin/users/@me") => Json(json!({
"user": user(ADMIN_ID, "lilith", 0, false)
}))
.into_response(),
(Method::GET, "/.well-known/fluxer") => Json(json!({
"features": {
"premium_enabled": false,
"account_identity": mock.account_identity,
"tag_style": if mock.unique_usernames { "none" } else { "random" }
}
}))
.into_response(),
(Method::GET, p) if p == target_user => {
Json(json!({ "users": [mock.target] })).into_response()
}
(Method::GET, p) if p == target_sessions => Json(json!({ "sessions": [] })).into_response(),
(Method::GET, p) if p == target_credentials => Json(json!([])).into_response(),
_ => (
StatusCode::NOT_FOUND,
Json(json!({ "message": "not found" })),
)
.into_response(),
}
}
fn user(id: &str, username: &str, discriminator: u16, bot: bool) -> Value {
json!({
"id": id,
"username": username,
"discriminator": discriminator,
"avatar": null,
"banner": null,
"email": null,
"email_verified": false,
"email_bounced": false,
"global_name": null,
"bio": null,
"pronouns": null,
"accent_color": null,
"date_of_birth": null,
"locale": "en-GB",
"acls": ["*"],
"traits": [],
"flags": "0",
"premium_flags": 0,
"bot": bot,
"system": false,
"premium_type": null,
"premium_since": null,
"premium_until": null,
"premium_grace_ends_at": null,
"premium_lifetime_sequence": null,
"has_totp": false,
"authenticator_types": [],
"temp_banned_until": null,
"pending_deletion_at": null,
"pending_bulk_message_deletion_at": null,
"deletion_reason_code": null,
"deletion_public_reason": null,
"deletion_audit_log_reason": null,
"deletion_scheduled_by": null,
"deletion_scheduled_at": null,
"last_active_at": null,
"last_active_ip": null,
"last_active_ip_reverse": null,
"last_active_location": null
})
}
fn test_config(api_endpoint: String, self_hosted: bool) -> AdminConfig {
AdminConfig {
env: RuntimeEnv::Test,
host: "127.0.0.1".to_owned(),
port: 0,
secret_key_base: SECRET_KEY.to_owned(),
base_path: String::new(),
api_endpoint,
media_endpoint: "https://media.example.test".to_owned(),
static_cdn_endpoint: "https://static.example.test".to_owned(),
admin_endpoint: "https://admin.example.test".to_owned(),
web_app_endpoint: "https://app.example.test".to_owned(),
oauth_client_id: "admin-client".to_owned(),
oauth_client_secret: "admin-secret".to_owned(),
oauth_redirect_uri: "https://admin.example.test/callback".to_owned(),
build_version: "test".to_owned(),
self_hosted,
proxy: ProxyConfig {
trust_client_ip_header: false,
client_ip_header_name: "x-forwarded-for".to_owned(),
},
}
}