mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
fix(admin): save premium limits and model changes (#3386)
This commit is contained in:
1 parent
7d2c51a57f
commit
6acd7f3d22
18 files changed
+1035
-59
No files matched your search
@@ -143,7 +143,7 @@ pub(super) fn billing_result<T>(result: Result<T, ApiError>) -> FlashData {
|
||||
}
|
||||
}
|
||||
|
||||
fn validation_message(error: &ApiError) -> Option<String> {
|
||||
pub(super) fn validation_message(error: &ApiError) -> Option<String> {
|
||||
let ApiError::Http {
|
||||
status: 400,
|
||||
message,
|
||||
|
||||
@@ -18,7 +18,7 @@ use crate::{
|
||||
InstanceIntegrationsUpdateRequest, InstanceMediaUpdateRequest,
|
||||
InstancePolicyUpdateRequest, InstanceRegistrationConfigUpdateRequest,
|
||||
InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest,
|
||||
LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, PremiumMode,
|
||||
LimitConfigUpdateRequest, LimitKeyMetadata, LimitRule, LimitRuleFilters, PremiumMode,
|
||||
PushRelayConfigUpdateRequest, RegistrationMode, SsoConfigUpdateRequest,
|
||||
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceE2eeScope, VoiceP2pConfigUpdateRequest,
|
||||
},
|
||||
@@ -405,12 +405,17 @@ fn render_fragment_with_toast(markup: Markup, flash: &FlashData) -> Response {
|
||||
response
|
||||
}
|
||||
|
||||
fn instance_config_result<T, E: std::fmt::Display>(result: Result<T, E>) -> FlashData {
|
||||
fn instance_config_result<T>(result: Result<T, crate::api::client::ApiError>) -> FlashData {
|
||||
match result {
|
||||
Ok(_) => FlashData::success("Instance config updated"),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: update instance config");
|
||||
FlashData::error("Failed to update instance config")
|
||||
match super::billing_actions::validation_message(&error) {
|
||||
Some(message) => {
|
||||
FlashData::error(format!("Failed to update instance config: {message}"))
|
||||
}
|
||||
None => FlashData::error("Failed to update instance config"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1042,6 +1047,7 @@ fn update_limit_rule_values(
|
||||
rule: &mut LimitRule,
|
||||
form: &MultiValueForm,
|
||||
limit_keys: &[String],
|
||||
metadata: &std::collections::BTreeMap<String, LimitKeyMetadata>,
|
||||
fallback_limits: Option<&std::collections::BTreeMap<String, u64>>,
|
||||
) {
|
||||
let mut limits = std::collections::BTreeMap::new();
|
||||
@@ -1055,12 +1061,19 @@ fn update_limit_rule_values(
|
||||
{
|
||||
limits.extend(defaults.clone());
|
||||
}
|
||||
for key in limit_keys {
|
||||
if metadata.get(key).is_some_and(|metadata| metadata.is_toggle)
|
||||
&& rule.limits.contains_key(key)
|
||||
{
|
||||
limits.entry(key.clone()).or_insert(0);
|
||||
}
|
||||
}
|
||||
rule.limits = limits;
|
||||
rule.filters = build_limit_filters(form);
|
||||
}
|
||||
|
||||
fn limit_config_result<T, E: std::fmt::Display>(
|
||||
result: Result<T, E>,
|
||||
fn limit_config_result<T>(
|
||||
result: Result<T, crate::api::client::ApiError>,
|
||||
success_message: &'static str,
|
||||
error_message: &'static str,
|
||||
) -> FlashData {
|
||||
@@ -1068,7 +1081,10 @@ fn limit_config_result<T, E: std::fmt::Display>(
|
||||
Ok(_) => FlashData::success(success_message),
|
||||
Err(error) => {
|
||||
tracing::warn!(%error, "admin API request failed: update limit config");
|
||||
FlashData::error(error_message)
|
||||
match super::billing_actions::validation_message(&error) {
|
||||
Some(message) => FlashData::error(format!("{error_message}: {message}")),
|
||||
None => FlashData::error(error_message),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1137,7 +1153,13 @@ pub async fn limit_config_post(
|
||||
.defaults
|
||||
.get(&rule_id)
|
||||
.or_else(|| current.defaults.get("default"));
|
||||
update_limit_rule_values(rule, &form, ¤t.limit_keys, fallback);
|
||||
update_limit_rule_values(
|
||||
rule,
|
||||
&form,
|
||||
¤t.limit_keys,
|
||||
¤t.metadata,
|
||||
fallback,
|
||||
);
|
||||
let request = LimitConfigUpdateRequest { limit_config };
|
||||
let result = client.update_limit_config(&request).await;
|
||||
let flash = limit_config_result(
|
||||
@@ -1147,6 +1169,43 @@ pub async fn limit_config_post(
|
||||
);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
"reset" => {
|
||||
let rule = aq
|
||||
.rule
|
||||
.as_deref()
|
||||
.and_then(clean_string)
|
||||
.and_then(|rule_id| {
|
||||
limit_config
|
||||
.rules
|
||||
.iter_mut()
|
||||
.find(|rule| rule.id == rule_id)
|
||||
});
|
||||
let Some(rule) = rule else {
|
||||
return redirect_back_with_flash(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("Rule not found"),
|
||||
secure_cookies,
|
||||
);
|
||||
};
|
||||
let Some(defaults) = current.defaults.get(&rule.id) else {
|
||||
return redirect_back_with_flash(
|
||||
base,
|
||||
"/limit-config",
|
||||
FlashData::error("This rule has no defaults to reset to"),
|
||||
secure_cookies,
|
||||
);
|
||||
};
|
||||
rule.limits = defaults.clone();
|
||||
let request = LimitConfigUpdateRequest { limit_config };
|
||||
let result = client.update_limit_config(&request).await;
|
||||
let flash = limit_config_result(
|
||||
result,
|
||||
"Limit rule reset to defaults",
|
||||
"Failed to reset limit rule",
|
||||
);
|
||||
return redirect_back_with_flash(base, "/limit-config", flash, secure_cookies);
|
||||
}
|
||||
"delete" => {
|
||||
let rule_id = match aq.rule.as_deref().and_then(clean_string) {
|
||||
Some(rule_id) => rule_id,
|
||||
@@ -1360,6 +1419,109 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn instance_config_result_surfaces_the_api_validation_message() {
|
||||
let error = crate::api::client::ApiError::Http {
|
||||
status: 400,
|
||||
message: serde_json::json!({
|
||||
"code": "INVALID_FORM_BODY",
|
||||
"message": "Input Validation Error",
|
||||
"errors": [{
|
||||
"path": "policy.premium_mode",
|
||||
"code": "X",
|
||||
"message": "Disable billing before switching the premium mode to everyone"
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
instance_config_result::<()>(Err(error)).message,
|
||||
"Failed to update instance config: policy.premium_mode: Disable billing before switching the premium mode to everyone"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn limit_config_result_surfaces_the_api_validation_message() {
|
||||
let error = crate::api::client::ApiError::Http {
|
||||
status: 400,
|
||||
message: serde_json::json!({
|
||||
"code": "INVALID_FORM_BODY",
|
||||
"message": "Input Validation Error",
|
||||
"errors": [{
|
||||
"path": "limit_config.rules.0.limits.max_bio_length",
|
||||
"code": "VALUE_MUST_BE_INTEGER_IN_RANGE",
|
||||
"message": "Value must be an integer between 0 and 320."
|
||||
}]
|
||||
})
|
||||
.to_string(),
|
||||
};
|
||||
assert_eq!(
|
||||
limit_config_result::<()>(Err(error), "Saved", "Failed to update limit configuration")
|
||||
.message,
|
||||
"Failed to update limit configuration: limit_config.rules.0.limits.max_bio_length: Value must be an integer between 0 and 320."
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_limit_rule_values_saves_unchecked_toggles_as_off() {
|
||||
let toggle = |key: &str, is_toggle: bool| {
|
||||
(
|
||||
key.to_owned(),
|
||||
LimitKeyMetadata {
|
||||
key: key.to_owned(),
|
||||
label: key.to_owned(),
|
||||
description: String::new(),
|
||||
category: "features".to_owned(),
|
||||
scope: "user".to_owned(),
|
||||
is_toggle,
|
||||
unit: None,
|
||||
min: None,
|
||||
max: None,
|
||||
},
|
||||
)
|
||||
};
|
||||
let metadata = std::collections::BTreeMap::from([
|
||||
toggle("feature_animated_avatar", true),
|
||||
toggle("feature_animated_banner", true),
|
||||
toggle("max_guilds", false),
|
||||
]);
|
||||
let limit_keys = metadata.keys().cloned().collect::<Vec<_>>();
|
||||
let mut rule = LimitRule {
|
||||
id: "premium".to_owned(),
|
||||
filters: None,
|
||||
limits: std::collections::BTreeMap::from([
|
||||
("feature_animated_avatar".to_owned(), 1),
|
||||
("feature_animated_banner".to_owned(), 1),
|
||||
("max_guilds".to_owned(), 200),
|
||||
]),
|
||||
modified_fields: None,
|
||||
};
|
||||
let form =
|
||||
MultiValueForm::parse(b"traits=premium&feature_animated_banner=1&max_guilds=250");
|
||||
update_limit_rule_values(&mut rule, &form, &limit_keys, &metadata, None);
|
||||
assert_eq!(
|
||||
rule.limits,
|
||||
std::collections::BTreeMap::from([
|
||||
("feature_animated_avatar".to_owned(), 0),
|
||||
("feature_animated_banner".to_owned(), 1),
|
||||
("max_guilds".to_owned(), 250),
|
||||
])
|
||||
);
|
||||
|
||||
let mut custom = LimitRule {
|
||||
id: "vip".to_owned(),
|
||||
filters: None,
|
||||
limits: std::collections::BTreeMap::from([("max_guilds".to_owned(), 300)]),
|
||||
modified_fields: None,
|
||||
};
|
||||
let form = MultiValueForm::parse(b"traits=vip&max_guilds=300");
|
||||
update_limit_rule_values(&mut custom, &form, &limit_keys, &metadata, None);
|
||||
assert_eq!(
|
||||
custom.limits,
|
||||
std::collections::BTreeMap::from([("max_guilds".to_owned(), 300)])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_experiment_user_ids_splits_newlines_and_commas() {
|
||||
assert_eq!(
|
||||
@@ -1823,7 +1985,13 @@ mod tests {
|
||||
limits: std::collections::BTreeMap::new(),
|
||||
modified_fields: None,
|
||||
};
|
||||
update_limit_rule_values(&mut rule, &form, &["message_send".to_owned()], None);
|
||||
update_limit_rule_values(
|
||||
&mut rule,
|
||||
&form,
|
||||
&["message_send".to_owned()],
|
||||
&std::collections::BTreeMap::new(),
|
||||
None,
|
||||
);
|
||||
assert_eq!(rule.limits.get("message_send"), Some(&1));
|
||||
assert_eq!(
|
||||
rule.filters.expect("filters").traits,
|
||||
|
||||
@@ -151,6 +151,10 @@ pub fn danger_button(label: &str) -> Markup {
|
||||
button_markup(label, "submit", DANGER_BUTTON_CLASS)
|
||||
}
|
||||
|
||||
pub fn secondary_button(label: &str) -> Markup {
|
||||
button_markup(label, "submit", SECONDARY_BUTTON_CLASS)
|
||||
}
|
||||
|
||||
pub fn form_field_group(
|
||||
label: &str,
|
||||
name: &str,
|
||||
|
||||
@@ -9,7 +9,7 @@ use crate::{
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
||||
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
PremiumMode, PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
@@ -158,6 +158,7 @@ pub fn instance_config_page(
|
||||
csrf_token,
|
||||
&instance_config.policy,
|
||||
&instance_config.app_public.branding.premium_product_name,
|
||||
instance_config.billing.enabled == Some(true),
|
||||
))
|
||||
},
|
||||
))
|
||||
@@ -264,6 +265,7 @@ fn policy_config_section(
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
premium_name: &str,
|
||||
billing_enabled: bool,
|
||||
) -> Markup {
|
||||
section_card_with_description(
|
||||
"Community & Policy",
|
||||
@@ -274,7 +276,7 @@ fn policy_config_section(
|
||||
div class="space-y-8" {
|
||||
(single_community_form(base, csrf_token, policy))
|
||||
(direct_messages_form(base, csrf_token, policy))
|
||||
(premium_mode_form(base, csrf_token, policy, premium_name))
|
||||
(premium_mode_form(base, csrf_token, policy, premium_name, billing_enabled))
|
||||
(community_creation_form(base, csrf_token, policy))
|
||||
(services_form(base, csrf_token, policy))
|
||||
}
|
||||
@@ -373,6 +375,7 @@ fn premium_mode_form(
|
||||
csrf_token: &str,
|
||||
policy: &InstancePolicyResponse,
|
||||
premium_name: &str,
|
||||
billing_enabled: bool,
|
||||
) -> Markup {
|
||||
let mirror_label = format!("Mirror (Free and {premium_name} tiers)");
|
||||
let everyone_label = format!("Everyone (every member gets {premium_name} limits)");
|
||||
@@ -386,6 +389,16 @@ fn premium_mode_form(
|
||||
("mirror", mirror_label.as_str()),
|
||||
("everyone", everyone_label.as_str()),
|
||||
], policy.premium_mode.as_str()))
|
||||
p class="text-xs text-neutral-500" {
|
||||
"Limits you have not edited follow the model. Limits you changed under Limit \
|
||||
Config keep your values in both models."
|
||||
}
|
||||
@if billing_enabled && matches!(policy.premium_mode, PremiumMode::Mirror) {
|
||||
p class="text-sm text-amber-700" {
|
||||
"Billing is enabled. Disable it under Premium & billing before switching \
|
||||
the premium model to Everyone."
|
||||
}
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save premium model"))
|
||||
}))
|
||||
|
||||
@@ -8,7 +8,10 @@ use crate::{
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{
|
||||
form::{FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, submit_button},
|
||||
form::{
|
||||
FORM_INPUT_CLASS, csrf_input, danger_button, form_actions, secondary_button,
|
||||
submit_button,
|
||||
},
|
||||
page_container::{card_with_header, page_header},
|
||||
tooltip,
|
||||
},
|
||||
@@ -81,7 +84,7 @@ fn render_limit_page(
|
||||
div class="space-y-6" {
|
||||
(rule_tabs(config, &rules, active_rule_id, can_update))
|
||||
@if let Some(rule) = active_rule {
|
||||
(rule_header(config, csrf_token, rule, can_update))
|
||||
(rule_header(&config.base_path, csrf_token, rule, can_update, response.defaults.contains_key(&rule.id)))
|
||||
(rule_editor(config, csrf_token, response, rule, can_update))
|
||||
} @else {
|
||||
div class="rounded-lg border border-neutral-200 bg-white p-4 shadow-sm" {
|
||||
@@ -151,11 +154,18 @@ fn rule_tabs(
|
||||
}
|
||||
|
||||
fn rule_header(
|
||||
config: &AdminConfig,
|
||||
base: &str,
|
||||
csrf_token: &str,
|
||||
rule: &LimitRule,
|
||||
can_update: bool,
|
||||
has_defaults: bool,
|
||||
) -> Markup {
|
||||
let can_reset = can_update
|
||||
&& has_defaults
|
||||
&& rule
|
||||
.modified_fields
|
||||
.as_ref()
|
||||
.is_some_and(|fields| !fields.is_empty());
|
||||
card_with_header(
|
||||
"",
|
||||
html! {
|
||||
@@ -176,11 +186,20 @@ fn rule_header(
|
||||
}
|
||||
}
|
||||
}
|
||||
@if can_update && rule.id != "default" {
|
||||
form method="post"
|
||||
action={(config.base_path) "/limit-config?action=delete&rule=" (rule.id)} {
|
||||
(csrf_input(csrf_token))
|
||||
(danger_button("Delete Rule"))
|
||||
div class="flex flex-wrap gap-2" {
|
||||
@if can_reset {
|
||||
form method="post"
|
||||
action={(base) "/limit-config?action=reset&rule=" (rule.id)} {
|
||||
(csrf_input(csrf_token))
|
||||
(secondary_button("Reset to Defaults"))
|
||||
}
|
||||
}
|
||||
@if can_update && rule.id != "default" {
|
||||
form method="post"
|
||||
action={(base) "/limit-config?action=delete&rule=" (rule.id)} {
|
||||
(csrf_input(csrf_token))
|
||||
(danger_button("Delete Rule"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -550,6 +550,10 @@ fn traits_form(
|
||||
class="w-full rounded-lg border border-neutral-300 bg-white px-3 py-1.5 text-sm text-neutral-900 placeholder:text-neutral-400 focus:border-brand-primary focus:outline-none focus:ring-2 focus:ring-brand-primary/20" {
|
||||
(custom_traits.join("\n"))
|
||||
}
|
||||
p class="text-neutral-500 text-xs" {
|
||||
"The premium trait follows each member's subscription or gift and cannot be \
|
||||
assigned here."
|
||||
}
|
||||
}
|
||||
(form_actions(html! {
|
||||
(submit_button("Save Traits"))
|
||||
|
||||
@@ -225,6 +225,60 @@ async fn instance_config_legal_form_round_trips_the_guidelines_url() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn limit_rule_reset_form_sends_the_rule_defaults() {
|
||||
let updates = Arc::new(Mutex::new(Vec::<Value>::new()));
|
||||
let sink = updates.clone();
|
||||
let edited = || {
|
||||
let mut config = limit_config();
|
||||
config["limit_config"]["rules"][0]["limits"]["maxGuilds"] = json!(150);
|
||||
config["limit_config"]["rules"][0]["modifiedFields"] = json!(["maxGuilds"]);
|
||||
config
|
||||
};
|
||||
let api = Router::new()
|
||||
.route(
|
||||
"/admin/limit-config",
|
||||
routing::get(move || async move { json_response(edited()) }).put(
|
||||
move |Json(body): Json<Value>| {
|
||||
let sink = sink.clone();
|
||||
async move {
|
||||
sink.lock().unwrap().push(body);
|
||||
json_response(limit_config())
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
.fallback(mock_api);
|
||||
let app = setup_with_api(api).await;
|
||||
let (headers, page) = get_with_headers(&app, "/limit-config", &[]).await;
|
||||
let csrf_token = csrf_cookie(&headers)
|
||||
.unwrap_or_else(|| panic!("limit config page did not set csrf_token cookie\n{page}"));
|
||||
assert_form_has_csrf(
|
||||
&page,
|
||||
"/limit-config?action=reset&rule=default",
|
||||
&csrf_token,
|
||||
);
|
||||
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
|
||||
let (status, _, body) = post_form_with_headers(
|
||||
&app,
|
||||
"/limit-config?action=reset&rule=default",
|
||||
&[("Cookie", &cookie)],
|
||||
&format!("_csrf={csrf_token}"),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
status.is_redirection() || status.is_success(),
|
||||
"{status} {body}"
|
||||
);
|
||||
assert_eq!(
|
||||
*updates.lock().unwrap(),
|
||||
vec![json!({"limit_config": {
|
||||
"traitDefinitions": [],
|
||||
"rules": [{"id": "default", "limits": {"maxGuilds": 100}}]
|
||||
}})]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn report_resolve_form_sends_the_chosen_resolution_and_refuses_none() {
|
||||
let received = Arc::new(Mutex::new(Vec::<Value>::new()));
|
||||
|
||||
Reference in new issue
Block a user