mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
2366 lines
108 KiB
Rust
2366 lines
108 KiB
Rust
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
use crate::{
|
|
api::types::{
|
|
AccountIdentityConfigResponse, AccountIdentityMode, AppPublicConfigResponse,
|
|
CAPTCHA_COST_RANGE, CAPTCHA_MAX_COUNTER_RANGE, CaptchaConfigResponse,
|
|
DOMAIN_MIGRATION_DEFAULT_SALT, DomainMigrationConfigResponse,
|
|
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS,
|
|
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
|
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
|
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
|
PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
|
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
|
},
|
|
config::AdminConfig,
|
|
middleware::auth::AuthContext,
|
|
templates::{
|
|
components::{
|
|
badge::{BadgeVariant, badge},
|
|
form::{
|
|
FORM_INPUT_CLASS, checkbox, csrf_input, danger_button, form_actions,
|
|
form_field_group, secondary_button_link, select_input, submit_button, text_input,
|
|
textarea_input,
|
|
},
|
|
page_container::page_header,
|
|
section_card::{section_card_simple, section_card_with_description},
|
|
},
|
|
layout::admin_layout,
|
|
pages::instance_billing::premium_billing_section,
|
|
},
|
|
utils::timestamps::format_admin_timestamp,
|
|
};
|
|
use maud::{Markup, html};
|
|
|
|
fn format_percent(value: f64) -> String {
|
|
if value.fract() == 0.0 {
|
|
format!("{}", value as u64)
|
|
} else {
|
|
value.to_string()
|
|
}
|
|
}
|
|
|
|
fn format_decimal(value: f64) -> String {
|
|
if value.fract() == 0.0 {
|
|
format!("{}", value as u64)
|
|
} else {
|
|
value.to_string()
|
|
}
|
|
}
|
|
|
|
fn entry_count_hint(count: usize, cap: usize) -> Markup {
|
|
html! {
|
|
p class="text-xs text-neutral-500" {
|
|
(count) " of " (cap) " stored"
|
|
@if count >= cap {
|
|
" (at the cap; remove an entry before adding another)"
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn experiment_rollout_country_codes_field(name: &str, country_codes: &[String]) -> Markup {
|
|
html! {
|
|
div class="flex flex-col gap-2" {
|
|
(text_input(name, "Rollout Countries", &country_codes.join(", "), "SE, NO, FI"))
|
|
(entry_count_hint(country_codes.len(), EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES))
|
|
p class="text-xs text-neutral-500" {
|
|
"Two-letter ISO 3166-1 country codes, separated by commas or spaces. When set, \
|
|
the user rollout percentage only applies to requests from these countries, and \
|
|
a request with an unknown country is left out. Leave empty to apply the \
|
|
percentage everywhere. The always-on lists, the premium switch and the \
|
|
never-on list ignore the country. Invalid entries prevent the save."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn number_field(
|
|
name: &str,
|
|
label: &str,
|
|
value: &str,
|
|
min: Option<u32>,
|
|
max: Option<u32>,
|
|
step: &str,
|
|
helper: Option<&str>,
|
|
) -> Markup {
|
|
form_field_group(
|
|
label,
|
|
name,
|
|
false,
|
|
None,
|
|
helper,
|
|
html! {
|
|
input type="number" id=(name) name=(name) step=(step)
|
|
value=(value)
|
|
min=[min] max=[max] class=(FORM_INPUT_CLASS);
|
|
},
|
|
)
|
|
}
|
|
|
|
pub fn instance_config_page(
|
|
config: &AdminConfig,
|
|
auth: &AuthContext,
|
|
csrf_token: &str,
|
|
instance_config: Option<&InstanceConfigResponse>,
|
|
limit_config: Option<&LimitConfigResponse>,
|
|
) -> Markup {
|
|
let base = &config.base_path;
|
|
let content = html! {
|
|
(page_header("Instance Configuration", Some("Manage instance-wide settings")))
|
|
div class="space-y-10" {
|
|
@if let Some(instance_config) = instance_config {
|
|
@if instance_config.self_hosted {
|
|
(config_group(
|
|
"Identity & branding",
|
|
"How the instance presents itself to clients and the public discovery document.",
|
|
html! {
|
|
(app_public_config_section(
|
|
base,
|
|
csrf_token,
|
|
&instance_config.app_public,
|
|
instance_config.self_hosted,
|
|
))
|
|
},
|
|
))
|
|
}
|
|
(config_group(
|
|
"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,
|
|
&instance_config.registration,
|
|
instance_config.app_public.registration.collect_date_of_birth,
|
|
instance_config.self_hosted,
|
|
))
|
|
(sso_config_section(base, csrf_token, &instance_config.sso))
|
|
},
|
|
))
|
|
(config_group(
|
|
"Bot protection",
|
|
"A proof-of-work check on sign-up, login, password reset and a few other abuse-prone actions.",
|
|
html! {
|
|
(captcha_section(base, csrf_token, &instance_config.captcha))
|
|
},
|
|
))
|
|
@if instance_config.self_hosted {
|
|
(config_group(
|
|
"Community & policy",
|
|
"Community shape, direct messaging, the premium model, and optional embed services.",
|
|
html! {
|
|
(policy_config_section(
|
|
base,
|
|
csrf_token,
|
|
&instance_config.policy,
|
|
&instance_config.app_public.branding.premium_product_name,
|
|
))
|
|
},
|
|
))
|
|
(config_group(
|
|
"Premium & billing",
|
|
"The premium tier's name, Stripe credentials and the prices members pay.",
|
|
html! {
|
|
(premium_billing_section(
|
|
base,
|
|
csrf_token,
|
|
&instance_config.app_public.branding,
|
|
&instance_config.billing,
|
|
instance_config.policy.premium_mode,
|
|
))
|
|
},
|
|
))
|
|
}
|
|
(config_group(
|
|
"Runtime integrations",
|
|
"Credentials and provider choices that override environment variables at runtime.",
|
|
html! {
|
|
(integrations_config_section(
|
|
base,
|
|
csrf_token,
|
|
&instance_config.integrations,
|
|
instance_config.account_identity.mode,
|
|
&instance_config.app_public.branding.product_name,
|
|
))
|
|
},
|
|
))
|
|
(config_group(
|
|
"Push notifications",
|
|
"Consent for the relay that delivers official mobile app notifications.",
|
|
html! {
|
|
(push_relay_section(base, csrf_token, &instance_config.push_relay))
|
|
},
|
|
))
|
|
(config_group(
|
|
"Media & retention",
|
|
"Attachment expiry rules that can be changed without editing environment variables.",
|
|
html! {
|
|
(media_config_section(base, csrf_token, &instance_config.media))
|
|
},
|
|
))
|
|
(config_group(
|
|
"Infrastructure & limits",
|
|
"Gateway rollout behavior and the limit rules applied to users and guilds.",
|
|
html! {
|
|
(gateway_rollout_section(base, csrf_token, &instance_config.gateway_rollout))
|
|
@if !instance_config.self_hosted {
|
|
(domain_migration_section(base, csrf_token, &instance_config.domain_migration))
|
|
}
|
|
(voice_p2p_section(base, csrf_token, &instance_config.voice_p2p))
|
|
(experiment_delivery_section(base, csrf_token, &instance_config.experiment_delivery))
|
|
@if let Some(limit_config) = limit_config {
|
|
(limit_config_section(base, limit_config))
|
|
} @else {
|
|
(section_card_simple("Limit Configuration", html! {
|
|
p class="text-sm text-red-600" { "Failed to load limit configuration." }
|
|
}))
|
|
}
|
|
},
|
|
))
|
|
} @else {
|
|
(section_card_simple("Instance Configuration", html! {
|
|
p class="text-sm text-red-600" { "Failed to load instance configuration." }
|
|
}))
|
|
@if let Some(limit_config) = limit_config {
|
|
(limit_config_section(base, limit_config))
|
|
} @else {
|
|
(section_card_simple("Limit Configuration", html! {
|
|
p class="text-sm text-red-600" { "Failed to load limit configuration." }
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
};
|
|
admin_layout(
|
|
config,
|
|
auth,
|
|
"Instance Configuration",
|
|
"instance-config",
|
|
None,
|
|
content,
|
|
)
|
|
}
|
|
|
|
fn config_group(title: &str, description: &str, content: Markup) -> Markup {
|
|
html! {
|
|
section class="space-y-4" {
|
|
div class="space-y-1 border-b border-neutral-200 pb-3" {
|
|
h2 class="text-lg font-semibold tracking-tight text-neutral-900" { (title) }
|
|
p class="text-sm text-neutral-500" { (description) }
|
|
}
|
|
div class="space-y-6" {
|
|
(content)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn policy_config_section(
|
|
base: &str,
|
|
csrf_token: &str,
|
|
policy: &InstancePolicyResponse,
|
|
premium_name: &str,
|
|
) -> Markup {
|
|
section_card_with_description(
|
|
"Community & Policy",
|
|
"Control whether this instance runs as a single community, whether direct messages and \
|
|
friends are available, which premium model applies, and which optional embed services \
|
|
are enabled.",
|
|
html! {
|
|
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))
|
|
(community_creation_form(base, csrf_token, policy))
|
|
(services_form(base, csrf_token, policy))
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn single_community_form(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
|
|
let status = if policy.single_community_enabled {
|
|
("Enabled", BadgeVariant::Success)
|
|
} else {
|
|
("Disabled", BadgeVariant::Default)
|
|
};
|
|
html! {
|
|
div class="space-y-4" {
|
|
div class="flex flex-wrap items-center gap-2" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Single community" }
|
|
(badge(status.0, status.1))
|
|
}
|
|
@if let Some(guild_id) = policy.single_community_guild_id.as_deref() {
|
|
p class="break-all text-xs text-neutral-500" { "Community guild ID: " (guild_id) }
|
|
}
|
|
@if policy.single_community_enabled {
|
|
p class="text-sm text-neutral-500" {
|
|
"This instance funnels every member into a single community. You can turn this \
|
|
off and on again from here. The community itself is kept either way."
|
|
}
|
|
form method="post" action={(base) "/instance-config?action=disable_single_community"} {
|
|
(csrf_input(csrf_token))
|
|
(form_actions(html! {
|
|
(danger_button("Disable single-community mode"))
|
|
}))
|
|
}
|
|
} @else if policy.single_community_guild_id.is_some() {
|
|
p class="text-sm text-neutral-500" {
|
|
"Single-community mode is off. Turning it on again reuses the community above \
|
|
if it still exists, otherwise a new one is created."
|
|
}
|
|
form method="post" action={(base) "/instance-config?action=enable_single_community"} {
|
|
(csrf_input(csrf_token))
|
|
(form_actions(html! {
|
|
(submit_button("Enable single-community mode"))
|
|
}))
|
|
}
|
|
} @else {
|
|
p class="text-sm text-neutral-500" {
|
|
"Single-community mode is off. It can only be turned on for the first time \
|
|
from the self-host setup wizard."
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn direct_messages_form(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
|
|
html! {
|
|
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" { "Direct messages & friends" }
|
|
@if policy.direct_messages_locked {
|
|
(badge("Locked", BadgeVariant::Warning))
|
|
}
|
|
}
|
|
@if policy.direct_messages_locked {
|
|
p class="text-sm text-neutral-500" {
|
|
@if policy.direct_messages_disabled {
|
|
"Direct messages and friends are disabled and locked for this instance."
|
|
} @else {
|
|
"Direct messages and friends are enabled and locked for this instance."
|
|
}
|
|
}
|
|
} @else {
|
|
form method="post" action={(base) "/instance-config?action=update_policy"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-4" {
|
|
(select_input("policy_direct_messages_disabled", "Direct messages & friends", &[
|
|
("false", "Enabled"),
|
|
("true", "Disabled"),
|
|
], if policy.direct_messages_disabled { "true" } else { "false" }))
|
|
p class="text-xs text-neutral-500" {
|
|
"Re-enabling direct messages after they have been disabled locks this \
|
|
setting permanently."
|
|
}
|
|
(form_actions(html! {
|
|
(submit_button("Save direct message policy"))
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn premium_mode_form(
|
|
base: &str,
|
|
csrf_token: &str,
|
|
policy: &InstancePolicyResponse,
|
|
premium_name: &str,
|
|
) -> Markup {
|
|
let mirror_label = format!("Mirror (Free and {premium_name} tiers)");
|
|
let everyone_label = format!("Everyone (every member gets {premium_name} limits)");
|
|
html! {
|
|
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Premium model" }
|
|
form method="post" action={(base) "/instance-config?action=update_policy"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-4" {
|
|
(select_input("policy_premium_mode", "Premium model", &[
|
|
("mirror", mirror_label.as_str()),
|
|
("everyone", everyone_label.as_str()),
|
|
], policy.premium_mode.as_str()))
|
|
(form_actions(html! {
|
|
(submit_button("Save premium model"))
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn community_creation_form(
|
|
base: &str,
|
|
csrf_token: &str,
|
|
policy: &InstancePolicyResponse,
|
|
) -> Markup {
|
|
html! {
|
|
div id="community-creation" class="space-y-4 border-t border-neutral-200 pt-6" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Community creation" }
|
|
form method="post" action={(base) "/instance-config?action=update_policy"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-4" {
|
|
(select_input("policy_guild_create_access", "Who can create communities", &[
|
|
("true", "Everyone"),
|
|
("false", "Restricted"),
|
|
], if policy.guild_create_access { "true" } else { "false" }))
|
|
p class="text-xs text-neutral-500" {
|
|
"When restricted, only admins with the wildcard ACL and users matched by a "
|
|
a href={(base) "/limit-config"} class="text-blue-600 hover:underline" {
|
|
"limit rule"
|
|
}
|
|
" that grants Community Creation Access can create communities."
|
|
}
|
|
(form_actions(html! {
|
|
(submit_button("Save community creation policy"))
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn service_select(name: &str, label: &str, override_value: Option<bool>, resolved: bool) -> Markup {
|
|
let selected = match override_value {
|
|
None => "inherit",
|
|
Some(true) => "on",
|
|
Some(false) => "off",
|
|
};
|
|
let resolved_text = if resolved {
|
|
"Effective: enabled"
|
|
} else {
|
|
"Effective: disabled"
|
|
};
|
|
html! {
|
|
div class="space-y-2" {
|
|
(select_input(name, label, &[
|
|
("inherit", "Inherit env default"),
|
|
("on", "Force on"),
|
|
("off", "Force off"),
|
|
], selected))
|
|
p class="text-xs text-neutral-500" { (resolved_text) }
|
|
}
|
|
}
|
|
}
|
|
|
|
fn services_form(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup {
|
|
let available = &policy.services_available;
|
|
if !available.gif && !available.youtube && !available.bluesky {
|
|
return html! {
|
|
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Optional services" }
|
|
p class="text-sm text-neutral-500" {
|
|
"No optional embed services are available in this environment."
|
|
}
|
|
}
|
|
};
|
|
}
|
|
html! {
|
|
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Optional services" }
|
|
p class="text-sm text-neutral-500" {
|
|
"Override the environment default for each optional embed service. \
|
|
\"Inherit env default\" keeps the value configured for this deployment."
|
|
}
|
|
form method="post" action={(base) "/instance-config?action=update_policy"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-4" {
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-3" {
|
|
@if available.gif {
|
|
(service_select(
|
|
"policy_service_gif",
|
|
"GIF picker",
|
|
policy.services.gif_enabled,
|
|
policy.services_resolved.gif_enabled,
|
|
))
|
|
}
|
|
@if available.youtube {
|
|
(service_select(
|
|
"policy_service_youtube",
|
|
"YouTube embeds",
|
|
policy.services.youtube_enabled,
|
|
policy.services_resolved.youtube_enabled,
|
|
))
|
|
}
|
|
@if available.bluesky {
|
|
(service_select(
|
|
"policy_service_bluesky",
|
|
"Bluesky embeds",
|
|
policy.services.bluesky_enabled,
|
|
policy.services_resolved.bluesky_enabled,
|
|
))
|
|
}
|
|
}
|
|
(form_actions(html! {
|
|
(submit_button("Save optional services"))
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn secret_badge(label: &str, is_set: bool) -> Markup {
|
|
if is_set {
|
|
badge(&format!("{label} set"), BadgeVariant::Success)
|
|
} else {
|
|
badge(&format!("{label} missing"), BadgeVariant::Default)
|
|
}
|
|
}
|
|
|
|
fn password_input(name: &str, label: &str, helper: Option<&str>) -> Markup {
|
|
form_field_group(
|
|
label,
|
|
name,
|
|
false,
|
|
None,
|
|
helper,
|
|
html! {
|
|
input type="password" id=(name) name=(name) value="" class=(FORM_INPUT_CLASS)
|
|
autocomplete="new-password";
|
|
},
|
|
)
|
|
}
|
|
|
|
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,
|
|
product_name: &str,
|
|
) -> Markup {
|
|
let smtp_port = integrations
|
|
.email
|
|
.smtp
|
|
.port
|
|
.map(|port| port.to_string())
|
|
.unwrap_or_else(|| "587".to_owned());
|
|
section_card_with_description(
|
|
"Runtime Integrations",
|
|
"Configure optional providers without changing environment variables. Blank secret fields keep the current value.",
|
|
html! {
|
|
form method="post" action={(base) "/instance-config?action=update_integrations"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-8" {
|
|
div class="space-y-4" {
|
|
div class="flex flex-wrap items-center gap-2" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "GIF provider" }
|
|
(secret_badge("KLIPY key", integrations.gif.klipy_api_key_set))
|
|
}
|
|
div class="grid grid-cols-1 gap-4" {
|
|
(password_input("integration_klipy_api_key", "KLIPY 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" { "YouTube Data API" }
|
|
(secret_badge("API key", integrations.youtube.api_key_set))
|
|
}
|
|
(password_input("integration_youtube_api_key", "YouTube API key", Some("Leave blank to keep the current key.")))
|
|
}
|
|
|
|
@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))
|
|
}
|
|
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(""),
|
|
product_name,
|
|
))
|
|
(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 authorization", 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" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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" { "Bluesky OAuth" }
|
|
@if integrations.bluesky.effective_enabled {
|
|
(badge("Effective: enabled", BadgeVariant::Success))
|
|
} @else {
|
|
(badge("Effective: disabled", BadgeVariant::Default))
|
|
}
|
|
(badge(&format!("{} signing key(s)", integrations.bluesky.key_count), BadgeVariant::Default))
|
|
}
|
|
(checkbox("integration_bluesky_enabled", "true", "Enable Bluesky OAuth", integrations.bluesky.effective_enabled, true))
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
|
(text_input(
|
|
"integration_bluesky_client_name",
|
|
"Client name",
|
|
integrations.bluesky.client_name.as_deref().unwrap_or(""),
|
|
"Fluxer",
|
|
))
|
|
(text_input(
|
|
"integration_bluesky_client_uri",
|
|
"Client URL",
|
|
integrations.bluesky.client_uri.as_deref().unwrap_or(""),
|
|
"https://example.com",
|
|
))
|
|
(text_input(
|
|
"integration_bluesky_logo_uri",
|
|
"Logo URL",
|
|
integrations.bluesky.logo_uri.as_deref().unwrap_or(""),
|
|
"https://example.com/logo.png",
|
|
))
|
|
(text_input(
|
|
"integration_bluesky_tos_uri",
|
|
"Terms URL",
|
|
integrations.bluesky.tos_uri.as_deref().unwrap_or(""),
|
|
"https://example.com/terms",
|
|
))
|
|
(text_input(
|
|
"integration_bluesky_policy_uri",
|
|
"Privacy URL",
|
|
integrations.bluesky.policy_uri.as_deref().unwrap_or(""),
|
|
"https://example.com/privacy",
|
|
))
|
|
(text_input("integration_bluesky_key_id", "New signing key ID", "", "atproto-key-1"))
|
|
(password_input("integration_bluesky_private_key", "New private key", Some("Provide this only when adding or replacing runtime Bluesky keys.")))
|
|
}
|
|
}
|
|
|
|
(form_actions(html! {
|
|
(submit_button("Save runtime integrations"))
|
|
}))
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn media_config_section(base: &str, csrf_token: &str, media: &InstanceMediaResponse) -> Markup {
|
|
let decay = &media.attachment_decay;
|
|
let effective = &decay.effective;
|
|
let enabled = decay.enabled.unwrap_or(effective.enabled);
|
|
let min_size_mb = format_decimal(decay.min_size_mb.unwrap_or(effective.min_size_mb));
|
|
let max_size_mb = format_decimal(decay.max_size_mb.unwrap_or(effective.max_size_mb));
|
|
let max_eligible_size_mb = format_decimal(
|
|
decay
|
|
.max_eligible_size_mb
|
|
.unwrap_or(effective.max_eligible_size_mb),
|
|
);
|
|
let min_lifetime_days = decay
|
|
.min_lifetime_days
|
|
.unwrap_or(effective.min_lifetime_days)
|
|
.to_string();
|
|
let max_lifetime_days = decay
|
|
.max_lifetime_days
|
|
.unwrap_or(effective.max_lifetime_days)
|
|
.to_string();
|
|
let curve = format_decimal(decay.curve.unwrap_or(effective.curve));
|
|
let renew_threshold_days = decay
|
|
.renew_threshold_days
|
|
.unwrap_or(effective.renew_threshold_days)
|
|
.to_string();
|
|
let renew_window_days = decay
|
|
.renew_window_days
|
|
.unwrap_or(effective.renew_window_days)
|
|
.to_string();
|
|
section_card_with_description(
|
|
"Media Expiry",
|
|
"Expire eligible attachments using size-based lifetimes. This is disabled by default and applies at runtime.",
|
|
html! {
|
|
form method="post" action={(base) "/instance-config?action=update_media"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-6" {
|
|
div class="flex flex-wrap items-center gap-2" {
|
|
@if effective.enabled {
|
|
(badge("Effective: enabled", BadgeVariant::Success))
|
|
} @else {
|
|
(badge("Effective: disabled", BadgeVariant::Default))
|
|
}
|
|
}
|
|
(checkbox("media_attachment_decay_enabled", "true", "Enable attachment expiry", enabled, true))
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" {
|
|
(number_field(
|
|
"media_attachment_decay_min_size_mb",
|
|
"Small file threshold (MB)",
|
|
&min_size_mb,
|
|
Some(1),
|
|
Some(1_000_000),
|
|
"0.1",
|
|
Some("Attachments at or below this size keep the maximum lifetime."),
|
|
))
|
|
(number_field(
|
|
"media_attachment_decay_max_size_mb",
|
|
"Large file threshold (MB)",
|
|
&max_size_mb,
|
|
Some(1),
|
|
Some(1_000_000),
|
|
"0.1",
|
|
Some("Attachments at or above this size get the minimum lifetime."),
|
|
))
|
|
(number_field(
|
|
"media_attachment_decay_max_eligible_size_mb",
|
|
"Maximum eligible size (MB)",
|
|
&max_eligible_size_mb,
|
|
Some(1),
|
|
Some(1_000_000),
|
|
"0.1",
|
|
Some("Attachments above this size are not managed by expiry."),
|
|
))
|
|
(number_field(
|
|
"media_attachment_decay_min_lifetime_days",
|
|
"Minimum lifetime (days)",
|
|
&min_lifetime_days,
|
|
Some(1),
|
|
Some(36_500),
|
|
"1",
|
|
None,
|
|
))
|
|
(number_field(
|
|
"media_attachment_decay_max_lifetime_days",
|
|
"Maximum lifetime (days)",
|
|
&max_lifetime_days,
|
|
Some(1),
|
|
Some(36_500),
|
|
"1",
|
|
None,
|
|
))
|
|
(number_field(
|
|
"media_attachment_decay_curve",
|
|
"Expiry curve",
|
|
&curve,
|
|
Some(0),
|
|
Some(1),
|
|
"0.05",
|
|
Some("0 is linear. 1 is logarithmic."),
|
|
))
|
|
(number_field(
|
|
"media_attachment_decay_renew_threshold_days",
|
|
"Renew threshold (days)",
|
|
&renew_threshold_days,
|
|
Some(1),
|
|
Some(365),
|
|
"1",
|
|
Some("Viewed attachments renew when expiry is this close."),
|
|
))
|
|
(number_field(
|
|
"media_attachment_decay_renew_window_days",
|
|
"Renew window (days)",
|
|
&renew_window_days,
|
|
Some(1),
|
|
Some(365),
|
|
"1",
|
|
Some("Renewed attachments are extended by this many days."),
|
|
))
|
|
}
|
|
(form_actions(html! {
|
|
(submit_button("Save media expiry"))
|
|
}))
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn app_public_config_section(
|
|
base: &str,
|
|
csrf_token: &str,
|
|
app_public: &AppPublicConfigResponse,
|
|
self_hosted: bool,
|
|
) -> Markup {
|
|
let setup_helper = if self_hosted {
|
|
"When this is unchecked, the web app shows the self-host setup wizard."
|
|
} else {
|
|
"Hosted instances are treated as already configured by clients."
|
|
};
|
|
section_card_with_description(
|
|
"Public App Identity",
|
|
"Configure the public product name, client-visible brand assets, and initial setup state returned by the instance discovery document.",
|
|
html! {
|
|
div class="space-y-8" {
|
|
form method="post" action={(base) "/instance-config?action=update_app_public"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-6" {
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
|
(text_input(
|
|
"app_product_name",
|
|
"Product Name",
|
|
&app_public.branding.product_name,
|
|
"Fluxer",
|
|
))
|
|
(text_input(
|
|
"app_theme_color",
|
|
"Theme Color",
|
|
app_public.branding.theme_color.as_deref().unwrap_or(""),
|
|
"#5865f2",
|
|
))
|
|
}
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
|
(text_input(
|
|
"app_icon_url",
|
|
"Icon URL",
|
|
app_public.branding.icon_url.as_deref().unwrap_or(""),
|
|
"https://example.com/icon.png",
|
|
))
|
|
(text_input(
|
|
"app_symbol_url",
|
|
"Symbol URL",
|
|
app_public.branding.symbol_url.as_deref().unwrap_or(""),
|
|
"https://example.com/symbol.png",
|
|
))
|
|
(text_input(
|
|
"app_logo_url",
|
|
"Logo URL",
|
|
app_public.branding.logo_url.as_deref().unwrap_or(""),
|
|
"https://example.com/logo.png",
|
|
))
|
|
(text_input(
|
|
"app_wordmark_url",
|
|
"Wordmark URL",
|
|
app_public.branding.wordmark_url.as_deref().unwrap_or(""),
|
|
"https://example.com/wordmark.png",
|
|
))
|
|
(text_input(
|
|
"app_favicon_url",
|
|
"Favicon URL",
|
|
app_public.branding.favicon_url.as_deref().unwrap_or(""),
|
|
"https://example.com/favicon.ico",
|
|
))
|
|
(text_input(
|
|
"app_status_page_url",
|
|
"Status page URL",
|
|
app_public.branding.status_page_url.as_deref().unwrap_or(""),
|
|
"https://fluxerstatus.com",
|
|
))
|
|
(text_input(
|
|
"app_status_page_incident_history_url",
|
|
"Status page history URL",
|
|
app_public.branding.status_page_incident_history_url.as_deref().unwrap_or(""),
|
|
"https://fluxerstatus.com/history",
|
|
))
|
|
}
|
|
div class="space-y-2" {
|
|
(checkbox(
|
|
"app_setup_configured",
|
|
"true",
|
|
"Setup complete",
|
|
app_public.setup.configured,
|
|
true,
|
|
))
|
|
p class="text-xs text-neutral-500" { (setup_helper) }
|
|
}
|
|
(form_actions(html! {
|
|
(submit_button("Save Public App Identity"))
|
|
}))
|
|
}
|
|
}
|
|
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Legal Documents" }
|
|
form method="post" action={(base) "/instance-config?action=update_app_legal"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-4" {
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
|
(text_input(
|
|
"app_terms_url",
|
|
"Terms URL",
|
|
app_public.legal.terms_url.as_deref().unwrap_or(""),
|
|
"https://example.com/terms",
|
|
))
|
|
(text_input(
|
|
"app_privacy_url",
|
|
"Privacy URL",
|
|
app_public.legal.privacy_url.as_deref().unwrap_or(""),
|
|
"https://example.com/privacy",
|
|
))
|
|
(text_input(
|
|
"app_guidelines_url",
|
|
"Community Guidelines URL",
|
|
app_public.legal.guidelines_url.as_deref().unwrap_or(""),
|
|
"https://example.com/guidelines",
|
|
))
|
|
}
|
|
p class="text-xs text-neutral-500" {
|
|
"Registration asks for agreement only to the terms and privacy documents configured here. Leave both blank to hide legal consent on self-hosted registration."
|
|
}
|
|
p class="text-xs text-neutral-500" {
|
|
"The community guidelines URL is linked from the app, the report flow and enforcement emails. Use an absolute http or https URL. Leave it blank to hide those links."
|
|
}
|
|
(form_actions(html! {
|
|
(submit_button("Save Legal Documents"))
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn gateway_rollout_section(
|
|
base: &str,
|
|
csrf_token: &str,
|
|
gateway_rollout: &GatewayRolloutConfigResponse,
|
|
) -> Markup {
|
|
section_card_with_description(
|
|
"Gateway Rollout Configuration",
|
|
"Control session and guild rollout percentages, NATS timeouts, and \
|
|
concurrency limits for gateway nodes. Changes propagate instantly \
|
|
via NATS.",
|
|
html! {
|
|
form method="post" action={(base) "/instance-config?action=update_gateway_rollout"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-6" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
|
(number_field(
|
|
"gateway_rollout_session_rollout_percentage",
|
|
"Session Rollout (%)", &format_percent(gateway_rollout.session_rollout_percentage),
|
|
Some(0), Some(100), "1",
|
|
Some("Percentage of sessions allowed to start (0-100)."),
|
|
))
|
|
(select_input("gateway_rollout_session_rollout_mode", "Session Rollout Mode", &[
|
|
("modulo", "Modulo"),
|
|
("random", "Random"),
|
|
], gateway_rollout.session_rollout_mode.as_str()))
|
|
}
|
|
(number_field(
|
|
"gateway_rollout_guild_rollout_percentage",
|
|
"Guild Rollout (%)", &format_percent(gateway_rollout.guild_rollout_percentage),
|
|
Some(0), Some(100), "1",
|
|
Some("Percentage of guilds allowed to start (0-100). Uses modulo mode."),
|
|
))
|
|
|
|
h3 class="text-sm font-semibold text-neutral-900" { "RPC and Concurrency" }
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-3" {
|
|
(number_field(
|
|
"gateway_rollout_rpc_request_timeout_ms",
|
|
"RPC Request Timeout (ms)", &gateway_rollout.rpc_request_timeout_ms.to_string(),
|
|
Some(1000), Some(60000), "1",
|
|
Some("Timeout for gateway-to-API RPC calls."),
|
|
))
|
|
(number_field(
|
|
"gateway_rollout_max_concurrent_session_starts",
|
|
"Max Concurrent Session Starts", &gateway_rollout.max_concurrent_session_starts.to_string(),
|
|
Some(1), None, "1",
|
|
Some("Per-node concurrency limit."),
|
|
))
|
|
(number_field(
|
|
"gateway_rollout_max_concurrent_guild_starts",
|
|
"Max Concurrent Guild Starts", &gateway_rollout.max_concurrent_guild_starts.to_string(),
|
|
Some(1), None, "1",
|
|
Some("Per-node concurrency limit."),
|
|
))
|
|
}
|
|
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Voice E2EE" }
|
|
(select_input("gateway_rollout_voice_e2ee_scope", "Enforcement scope", &[
|
|
("guild_feature_only", "Guild feature only (require VOICE_E2EE on the guild; DMs/GDMs never E2EE)"),
|
|
("platform_wide", "Platform-wide (every guild voice channel, DM, and GDM)"),
|
|
], gateway_rollout.voice_e2ee_scope.as_str()))
|
|
|
|
(form_actions(html! {
|
|
(submit_button("Save Gateway Rollout Configuration"))
|
|
}))
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn push_relay_section(
|
|
base: &str,
|
|
csrf_token: &str,
|
|
push_relay: &PushRelayConfigResponse,
|
|
) -> Markup {
|
|
let status = if push_relay.relay_consent_accepted {
|
|
("Accepted", BadgeVariant::Success)
|
|
} else {
|
|
("Not accepted", BadgeVariant::Default)
|
|
};
|
|
let accepted_at =
|
|
format_optional_admin_timestamp(push_relay.relay_consent_accepted_at.as_deref(), "Never");
|
|
let accepted_by = push_relay
|
|
.relay_consent_accepted_by
|
|
.as_deref()
|
|
.unwrap_or("Nobody");
|
|
section_card_with_description(
|
|
"Push Relay",
|
|
"Official mobile app notifications travel through Fluxer's relay to Apple and Google. \
|
|
The relay delivers them only after an operator accepts its privacy notice.",
|
|
html! {
|
|
form method="post" action={(base) "/instance-config?action=update_push_relay"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-6" {
|
|
div class="flex flex-wrap items-center gap-2" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Relay consent" }
|
|
(badge(status.0, status.1))
|
|
}
|
|
(checkbox(
|
|
"push_relay_consent_accepted",
|
|
"true",
|
|
"Accept the push relay supplemental privacy notice",
|
|
push_relay.relay_consent_accepted,
|
|
true,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"Until this is accepted official mobile app notifications are dropped. \
|
|
Self-hosted UnifiedPush and ntfy endpoints never reach the relay and are \
|
|
unaffected. "
|
|
a href="https://fluxer.com/push-relay" target="_blank" rel="noreferrer"
|
|
class="text-neutral-900 underline decoration-neutral-300 hover:text-neutral-600 hover:decoration-neutral-500" {
|
|
"Read the notice"
|
|
}
|
|
}
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
|
(form_field_group("Accepted at", "push_relay_consent_accepted_at", false, None, None,
|
|
html! {
|
|
input type="text" id="push_relay_consent_accepted_at"
|
|
value=(accepted_at)
|
|
disabled class=(FORM_INPUT_CLASS);
|
|
},
|
|
))
|
|
(form_field_group("Accepted by user ID", "push_relay_consent_accepted_by", false, None, None,
|
|
html! {
|
|
input type="text" id="push_relay_consent_accepted_by"
|
|
value=(accepted_by)
|
|
disabled class=(FORM_INPUT_CLASS);
|
|
},
|
|
))
|
|
}
|
|
|
|
(form_actions(html! {
|
|
(submit_button("Save Push Relay Settings"))
|
|
}))
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn domain_migration_section(
|
|
base: &str,
|
|
csrf_token: &str,
|
|
domain_migration: &DomainMigrationConfigResponse,
|
|
) -> Markup {
|
|
let status = if domain_migration.enabled {
|
|
("Live", BadgeVariant::Success)
|
|
} else {
|
|
("Inert", BadgeVariant::Default)
|
|
};
|
|
let included_user_ids = domain_migration.included_user_ids.join("\n");
|
|
let excluded_user_ids = domain_migration.excluded_user_ids.join("\n");
|
|
section_card_with_description(
|
|
"Domain Migration",
|
|
"Moves web clients of the official instance from the legacy web app origin to the new \
|
|
one. Selected accounts copy their local data across and continue on the new origin. \
|
|
Clients of other instances read this configuration and ignore it.",
|
|
html! {
|
|
form method="post" action={(base) "/instance-config?action=update_domain_migration"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-6" {
|
|
div class="flex flex-wrap items-center gap-2" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
|
(badge(status.0, status.1))
|
|
span class="text-xs text-neutral-500" {
|
|
"Config version " (domain_migration.config_version)
|
|
}
|
|
}
|
|
(checkbox(
|
|
"domain_migration_enabled",
|
|
"true",
|
|
"Move selected web clients to the new origin",
|
|
domain_migration.enabled,
|
|
true,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"Off is the safe state and the kill switch. With this unchecked no client \
|
|
starts a migration and clients that already migrated stop forwarding the \
|
|
legacy origin, so the rollout and targeting fields below have no effect at all."
|
|
}
|
|
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Installed apps" }
|
|
(checkbox(
|
|
"domain_migration_standalone_forwarding",
|
|
"true",
|
|
"Forward installed desktop web apps to the new origin",
|
|
domain_migration.standalone_forwarding,
|
|
true,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"Leave this off until the manifest scope extension and the association file \
|
|
are live and verified. While it is off, installed Chromium desktop apps copy \
|
|
their data across but stay on the legacy origin and offer to install the new \
|
|
app. Installed mobile and Safari apps never forward either way."
|
|
}
|
|
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
|
(number_field(
|
|
"domain_migration_rollout_basis_points",
|
|
"Rollout (basis points)",
|
|
&domain_migration.rollout_basis_points.to_string(),
|
|
Some(0), Some(10000), "1",
|
|
Some("Share of logged-in users bucketed into the migration, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
|
))
|
|
(number_field(
|
|
"domain_migration_anonymous_rollout_basis_points",
|
|
"Anonymous rollout (basis points)",
|
|
&domain_migration.anonymous_rollout_basis_points.to_string(),
|
|
Some(0), Some(10000), "1",
|
|
Some("Share of logged-out devices sent to the new origin, in basis points. Each device is bucketed on its own random ID."),
|
|
))
|
|
(experiment_rollout_country_codes_field(
|
|
"domain_migration_rollout_country_codes",
|
|
&domain_migration.rollout_country_codes,
|
|
))
|
|
div class="flex flex-col gap-2" {
|
|
(text_input(
|
|
"domain_migration_rollout_salt",
|
|
"Rollout Salt",
|
|
&domain_migration.rollout_salt,
|
|
DOMAIN_MIGRATION_DEFAULT_SALT,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"Seeds the bucketing hash for users and devices. Changing it reshuffles \
|
|
which users and devices fall inside the percentages above. Leave it \
|
|
alone to keep the current cohort stable."
|
|
}
|
|
}
|
|
div class="flex flex-col gap-2" {
|
|
(textarea_input(
|
|
"domain_migration_included_user_ids",
|
|
"Always-on User IDs",
|
|
"1500000000000000001\n1500000000000000002",
|
|
&included_user_ids,
|
|
4,
|
|
false,
|
|
))
|
|
(entry_count_hint(
|
|
domain_migration.included_user_ids.len(),
|
|
EXPERIMENT_MAX_TARGETED_USERS,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"One snowflake per line, or comma separated. These users are targeted \
|
|
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
|
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
|
IDs are ignored."
|
|
}
|
|
}
|
|
div class="flex flex-col gap-2" {
|
|
(checkbox(
|
|
"domain_migration_include_premium_users",
|
|
"true",
|
|
"Include premium users",
|
|
domain_migration.include_premium_users,
|
|
true,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"Includes every account with active premium perks, regardless of the \
|
|
percentage above. The never-on list still wins."
|
|
}
|
|
}
|
|
div class="flex flex-col gap-2" {
|
|
(textarea_input(
|
|
"domain_migration_included_guild_ids",
|
|
"Always-on Guild IDs",
|
|
"1500000000000000005\n1500000000000000006",
|
|
&domain_migration.included_guild_ids.join("\n"),
|
|
4,
|
|
false,
|
|
))
|
|
(entry_count_hint(
|
|
domain_migration.included_guild_ids.len(),
|
|
EXPERIMENT_MAX_TARGETED_USERS,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"Same format, with guild IDs. Every member of a listed guild is \
|
|
included regardless of the percentage above, unless the user is \
|
|
in the never-on list."
|
|
}
|
|
}
|
|
div class="flex flex-col gap-2" {
|
|
(textarea_input(
|
|
"domain_migration_excluded_user_ids",
|
|
"Never-on User IDs",
|
|
"1500000000000000003\n1500000000000000004",
|
|
&excluded_user_ids,
|
|
4,
|
|
false,
|
|
))
|
|
(entry_count_hint(
|
|
domain_migration.excluded_user_ids.len(),
|
|
EXPERIMENT_MAX_TARGETED_USERS,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"Same format. Exclusion wins over both the always-on list and the \
|
|
percentage. It stops new migrations only. A user who already moved \
|
|
stays on the new origin."
|
|
}
|
|
}
|
|
|
|
(form_actions(html! {
|
|
(submit_button("Save Domain Migration Configuration"))
|
|
}))
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn voice_p2p_section(base: &str, csrf_token: &str, voice_p2p: &VoiceP2pConfigResponse) -> Markup {
|
|
let status = if voice_p2p.enabled {
|
|
("Live", BadgeVariant::Success)
|
|
} else {
|
|
("Inert", BadgeVariant::Default)
|
|
};
|
|
let included_user_ids = voice_p2p.included_user_ids.join("\n");
|
|
let excluded_user_ids = voice_p2p.excluded_user_ids.join("\n");
|
|
section_card_with_description(
|
|
"Peer-to-peer voice",
|
|
"Lets selected users start small voice calls that connect the participants directly \
|
|
instead of through the voice servers. A peer-to-peer call only moves to the voice \
|
|
servers when a participant switches it or its region changes.",
|
|
html! {
|
|
form method="post" action={(base) "/instance-config?action=update_voice_p2p"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-6" {
|
|
div class="flex flex-wrap items-center gap-2" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Master switch" }
|
|
(badge(status.0, status.1))
|
|
span class="text-xs text-neutral-500" {
|
|
"Config version " (voice_p2p.config_version)
|
|
}
|
|
}
|
|
(checkbox(
|
|
"voice_p2p_enabled",
|
|
"true",
|
|
"Let the selected users start peer-to-peer calls",
|
|
voice_p2p.enabled,
|
|
true,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"Off is the safe state and the kill switch. With this unchecked every new \
|
|
call uses the voice servers and a running peer-to-peer call accepts no \
|
|
new joins, so the rollout and targeting fields below have no effect at \
|
|
all. While it is on, the fields below select who can start a \
|
|
peer-to-peer call, and anyone who agrees to a direct connection can \
|
|
join one."
|
|
}
|
|
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Call size" }
|
|
(number_field(
|
|
"voice_p2p_max_participants",
|
|
"Maximum participants",
|
|
&voice_p2p.max_participants.to_string(),
|
|
Some(*VOICE_P2P_MAX_PARTICIPANTS_RANGE.start()),
|
|
Some(*VOICE_P2P_MAX_PARTICIPANTS_RANGE.end()),
|
|
"1",
|
|
Some("Most participants a peer-to-peer call can hold, 2 through 4. Clients only offer a peer-to-peer call in a direct message or group with at most this many members, and a join past it is refused as full. Lowering it never removes anyone from a running call."),
|
|
))
|
|
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Rollout" }
|
|
(number_field(
|
|
"voice_p2p_rollout_basis_points",
|
|
"Rollout (basis points)",
|
|
&voice_p2p.rollout_basis_points.to_string(),
|
|
Some(0), Some(10000), "1",
|
|
Some("Share of users who can start peer-to-peer calls, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."),
|
|
))
|
|
(experiment_rollout_country_codes_field(
|
|
"voice_p2p_rollout_country_codes",
|
|
&voice_p2p.rollout_country_codes,
|
|
))
|
|
div class="flex flex-col gap-2" {
|
|
(text_input(
|
|
"voice_p2p_rollout_salt",
|
|
"Rollout Salt",
|
|
&voice_p2p.rollout_salt,
|
|
VOICE_P2P_DEFAULT_SALT,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"Seeds the bucketing hash. Changing it reshuffles which users fall \
|
|
inside the percentage above. Leave it alone to keep the current \
|
|
cohort stable."
|
|
}
|
|
}
|
|
div class="flex flex-col gap-2" {
|
|
(textarea_input(
|
|
"voice_p2p_included_user_ids",
|
|
"Always-on User IDs",
|
|
"1500000000000000001\n1500000000000000002",
|
|
&included_user_ids,
|
|
4,
|
|
false,
|
|
))
|
|
(entry_count_hint(
|
|
voice_p2p.included_user_ids.len(),
|
|
EXPERIMENT_MAX_TARGETED_USERS,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"One snowflake per line, or comma separated. These users are targeted \
|
|
regardless of the percentage above. IDs must contain 1 to 20 decimal \
|
|
digits. Invalid entries prevent the save. Blank entries and duplicate \
|
|
IDs are ignored."
|
|
}
|
|
}
|
|
div class="flex flex-col gap-2" {
|
|
(checkbox(
|
|
"voice_p2p_include_premium_users",
|
|
"true",
|
|
"Include premium users",
|
|
voice_p2p.include_premium_users,
|
|
true,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"Includes every account with active premium perks, regardless of the \
|
|
percentage above. The never-on list still wins."
|
|
}
|
|
}
|
|
div class="flex flex-col gap-2" {
|
|
(textarea_input(
|
|
"voice_p2p_included_guild_ids",
|
|
"Always-on Guild IDs",
|
|
"1500000000000000005\n1500000000000000006",
|
|
&voice_p2p.included_guild_ids.join("\n"),
|
|
4,
|
|
false,
|
|
))
|
|
(entry_count_hint(
|
|
voice_p2p.included_guild_ids.len(),
|
|
EXPERIMENT_MAX_TARGETED_USERS,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"Same format, with guild IDs. Every member of a listed guild is \
|
|
included regardless of the percentage above, unless the user is \
|
|
in the never-on list."
|
|
}
|
|
}
|
|
div class="flex flex-col gap-2" {
|
|
(textarea_input(
|
|
"voice_p2p_excluded_user_ids",
|
|
"Never-on User IDs",
|
|
"1500000000000000003\n1500000000000000004",
|
|
&excluded_user_ids,
|
|
4,
|
|
false,
|
|
))
|
|
(entry_count_hint(
|
|
voice_p2p.excluded_user_ids.len(),
|
|
EXPERIMENT_MAX_TARGETED_USERS,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"Same format. Exclusion wins over both the always-on list and the \
|
|
percentage."
|
|
}
|
|
}
|
|
|
|
(form_actions(html! {
|
|
(submit_button("Save Peer-to-Peer Voice Configuration"))
|
|
}))
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn estimate_low_end_solve_seconds(cost: u32, max_counter: u32) -> f64 {
|
|
0.75 * f64::from(cost) * f64::from(max_counter) / 1_050_000.0
|
|
}
|
|
|
|
fn captcha_section(base: &str, csrf_token: &str, captcha: &CaptchaConfigResponse) -> Markup {
|
|
let status = if captcha.enabled {
|
|
("On", BadgeVariant::Success)
|
|
} else {
|
|
("Off", BadgeVariant::Default)
|
|
};
|
|
let estimate = estimate_low_end_solve_seconds(captcha.cost, captcha.max_counter);
|
|
section_card_with_description(
|
|
"Proof-of-work check",
|
|
"Clients solve it in the background. The API issues and verifies every challenge itself, \
|
|
and no third party is involved.",
|
|
html! {
|
|
div class="space-y-6" {
|
|
div class="flex flex-wrap items-center gap-2" {
|
|
(badge(status.0, status.1))
|
|
}
|
|
form method="post" action={(base) "/instance-config?action=update_captcha"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-6" {
|
|
(checkbox(
|
|
"captcha_enabled",
|
|
"true",
|
|
"Require a proof-of-work check",
|
|
captcha.enabled,
|
|
true,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"On by default. Turning it off removes the check from every request."
|
|
}
|
|
(number_field(
|
|
"captcha_cost",
|
|
"Cost (PBKDF2 iterations per try)",
|
|
&captcha.cost.to_string(),
|
|
Some(*CAPTCHA_COST_RANGE.start()),
|
|
Some(*CAPTCHA_COST_RANGE.end()),
|
|
"1",
|
|
Some("Default 5000."),
|
|
))
|
|
(number_field(
|
|
"captcha_max_counter",
|
|
"Maximum counter",
|
|
&captcha.max_counter.to_string(),
|
|
Some(*CAPTCHA_MAX_COUNTER_RANGE.start()),
|
|
Some(*CAPTCHA_MAX_COUNTER_RANGE.end()),
|
|
"1",
|
|
Some("Default 1000. Solve time grows with cost times this value."),
|
|
))
|
|
p class="text-sm text-neutral-700" {
|
|
(format!(
|
|
"Average solve: about {estimate:.1} s on a low-end Android device, \
|
|
well under a second in desktop browsers."
|
|
))
|
|
}
|
|
(form_actions(html! {
|
|
(submit_button("Save"))
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn experiment_delivery_section(
|
|
base: &str,
|
|
csrf_token: &str,
|
|
experiment_delivery: &ExperimentDeliveryConfigResponse,
|
|
) -> Markup {
|
|
section_card_with_description(
|
|
"Experiment Delivery",
|
|
"How often every client revalidates its experiment assignments. This is instance-wide \
|
|
and covers every experiment, not just the ones above. Raising the interval sheds \
|
|
request volume and makes a change take longer to reach a client. Raising the jitter \
|
|
spreads a fleet that has synchronized on one tick back out across the interval.",
|
|
html! {
|
|
form method="post" action={(base) "/instance-config?action=update_experiment_delivery"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-6" {
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
|
(number_field(
|
|
"experiment_delivery_poll_interval_seconds",
|
|
"Assignment Poll Interval (s)",
|
|
&experiment_delivery.poll_interval_seconds.to_string(),
|
|
Some(60), Some(86400), "1",
|
|
Some("How often a client re-reads its assignments, 60 to 86400 seconds. Lower values pick up changes sooner at the cost of more requests."),
|
|
))
|
|
(number_field(
|
|
"experiment_delivery_poll_jitter_percent",
|
|
"Assignment Poll Jitter (%)",
|
|
&experiment_delivery.poll_jitter_percent.to_string(),
|
|
Some(0), Some(50), "1",
|
|
Some("How far each client spreads its poll around the interval, 0 to 50 percent. Raise it to break up a fleet that polls on the same tick, set it to 0 for an exact interval."),
|
|
))
|
|
}
|
|
|
|
(form_actions(html! {
|
|
(submit_button("Save Experiment Delivery Configuration"))
|
|
}))
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn registration_config_section(
|
|
config: &AdminConfig,
|
|
csrf_token: &str,
|
|
registration: &InstanceRegistrationResponse,
|
|
collect_date_of_birth: bool,
|
|
self_hosted: bool,
|
|
) -> Markup {
|
|
let base = &config.base_path;
|
|
section_card_with_description(
|
|
"Registration Controls",
|
|
"Control public registration, admin-issued registration URLs, and pending approval requests.",
|
|
html! {
|
|
div class="space-y-8" {
|
|
(registration_policy_form(base, csrf_token, registration))
|
|
@if self_hosted {
|
|
(registration_fields_form(base, csrf_token, collect_date_of_birth))
|
|
}
|
|
(create_registration_url_form(base, csrf_token))
|
|
(registration_url_list(config, csrf_token, ®istration.urls))
|
|
(pending_registration_list(config, csrf_token, ®istration.pending_registrations))
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn registration_policy_form(
|
|
base: &str,
|
|
csrf_token: &str,
|
|
registration: &InstanceRegistrationResponse,
|
|
) -> Markup {
|
|
html! {
|
|
div class="space-y-4" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Registration Policy" }
|
|
form method="post" action={(base) "/instance-config?action=update_registration"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-4" {
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
|
(select_input("registration_mode", "Mode", &[
|
|
("open", "Open"),
|
|
("approval", "Approval required"),
|
|
("closed", "Closed"),
|
|
], registration.mode.as_str()))
|
|
div class="flex items-end pb-1" {
|
|
(checkbox(
|
|
"admin_registration_urls_enabled",
|
|
"true",
|
|
"Admin registration URLs enabled",
|
|
registration.admin_registration_urls_enabled,
|
|
true,
|
|
))
|
|
}
|
|
}
|
|
(form_actions(html! {
|
|
(submit_button("Save Registration Policy"))
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn registration_fields_form(base: &str, csrf_token: &str, collect_date_of_birth: bool) -> Markup {
|
|
html! {
|
|
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Registration Fields" }
|
|
form method="post" action={(base) "/instance-config?action=update_app_registration"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-4" {
|
|
(checkbox(
|
|
"app_collect_date_of_birth",
|
|
"true",
|
|
"Collect date of birth during registration",
|
|
collect_date_of_birth,
|
|
true,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"When disabled, new self-hosted accounts do not store a date of birth and are treated as adults."
|
|
}
|
|
(form_actions(html! {
|
|
(submit_button("Save Registration Fields"))
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn create_registration_url_form(base: &str, csrf_token: &str) -> Markup {
|
|
let action = format!("{base}/instance-config?action=create_registration_url");
|
|
html! {
|
|
div class="space-y-4 border-t border-neutral-200 pt-6" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Create Admin-Issued Registration URL" }
|
|
form method="post" action=(&action)
|
|
hx-post=(&action)
|
|
hx-target="#registration-url-list"
|
|
hx-swap="outerHTML"
|
|
hx-push-url="false"
|
|
data-admin-allow-swap="true" {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-4" {
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4" {
|
|
(text_input(
|
|
"registration_url_label",
|
|
"Label",
|
|
"",
|
|
"Invite batch, partner, or support case",
|
|
))
|
|
(number_field(
|
|
"registration_url_expires_in_days",
|
|
"Expires In Days",
|
|
"",
|
|
Some(1),
|
|
None,
|
|
"1",
|
|
Some("Leave blank for no expiration."),
|
|
))
|
|
(number_field(
|
|
"registration_url_max_uses",
|
|
"Max Uses",
|
|
"",
|
|
Some(1),
|
|
Some(1_000_000),
|
|
"1",
|
|
Some("Leave blank for unlimited uses."),
|
|
))
|
|
div class="flex items-end pb-1" {
|
|
(checkbox(
|
|
"registration_url_approval_required",
|
|
"true",
|
|
"Approval required",
|
|
false,
|
|
true,
|
|
))
|
|
}
|
|
}
|
|
(form_actions(html! {
|
|
(submit_button("Create Registration URL"))
|
|
}))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn registration_url_list(
|
|
config: &AdminConfig,
|
|
csrf_token: &str,
|
|
urls: &[RegistrationUrlResponse],
|
|
) -> Markup {
|
|
let base = &config.base_path;
|
|
html! {
|
|
div id="registration-url-list" class="space-y-4 border-t border-neutral-200 pt-6" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Existing Registration URLs" }
|
|
@if urls.is_empty() {
|
|
p class="text-sm text-neutral-500" { "No admin-issued registration URLs." }
|
|
} @else {
|
|
div class="overflow-x-auto rounded-lg border border-neutral-200" {
|
|
table class="min-w-[1100px] divide-y divide-neutral-200 text-sm" {
|
|
thead class="bg-neutral-50" {
|
|
tr {
|
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Label / URL" }
|
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Created" }
|
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Expires" }
|
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Uses" }
|
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Status" }
|
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Approval" }
|
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Action" }
|
|
}
|
|
}
|
|
tbody class="divide-y divide-neutral-200 bg-white" {
|
|
@for url in urls {
|
|
(registration_url_row(config, base, csrf_token, url))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn registration_url_row(
|
|
config: &AdminConfig,
|
|
base: &str,
|
|
csrf_token: &str,
|
|
url: &RegistrationUrlResponse,
|
|
) -> Markup {
|
|
let label = url.label.as_deref().unwrap_or("Unlabeled");
|
|
let full_url = admin_issued_registration_url(config, &url.id);
|
|
let created_at = format_admin_timestamp(&url.created_at);
|
|
let expires_at = format_optional_admin_timestamp(url.expires_at.as_deref(), "Never");
|
|
let uses = registration_url_uses_text(url);
|
|
let status = registration_url_status(url);
|
|
let approval = if url.approval_required {
|
|
"Required"
|
|
} else {
|
|
"Not required"
|
|
};
|
|
let revoke_action = format!("{base}/instance-config?action=revoke_registration_url");
|
|
html! {
|
|
tr {
|
|
td class="min-w-[28rem] px-4 py-3 align-top" {
|
|
p class="font-medium text-neutral-900" { (label) }
|
|
(copyable_registration_url(&full_url, "Copy URL"))
|
|
p class="mt-1 whitespace-nowrap text-xs text-neutral-500" { "ID: " (&url.id) }
|
|
}
|
|
td class="px-4 py-3 align-top text-neutral-700" {
|
|
p class="whitespace-nowrap" { (created_at) }
|
|
p class="whitespace-nowrap text-xs text-neutral-500" { "By: " (&url.created_by_user_id) }
|
|
}
|
|
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
|
|
(expires_at)
|
|
}
|
|
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
|
|
(uses)
|
|
}
|
|
td class="px-4 py-3 align-top whitespace-nowrap" {
|
|
(registration_url_status_badge(status))
|
|
}
|
|
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
|
|
(approval)
|
|
}
|
|
td class="px-4 py-3 align-top" {
|
|
@if status == RegistrationUrlStatus::Active {
|
|
form method="post" action=(&revoke_action)
|
|
hx-post=(&revoke_action)
|
|
hx-target="#registration-url-list"
|
|
hx-swap="outerHTML"
|
|
hx-push-url="false"
|
|
data-admin-allow-swap="true" {
|
|
(csrf_input(csrf_token))
|
|
input type="hidden" name="registration_url_id" value=(&url.id);
|
|
(compact_button("Revoke", true))
|
|
}
|
|
} @else {
|
|
span class="text-sm text-neutral-400" { "No action" }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn pending_registration_list(
|
|
config: &AdminConfig,
|
|
csrf_token: &str,
|
|
pending_registrations: &[PendingRegistrationResponse],
|
|
) -> Markup {
|
|
let base = &config.base_path;
|
|
html! {
|
|
div id="pending-registration-list" class="space-y-4 border-t border-neutral-200 pt-6" {
|
|
h3 class="text-sm font-semibold text-neutral-900" { "Pending Registrations" }
|
|
@if pending_registrations.is_empty() {
|
|
p class="text-sm text-neutral-500" { "No pending registrations." }
|
|
} @else {
|
|
div class="overflow-x-auto rounded-lg border border-neutral-200" {
|
|
table class="min-w-[940px] divide-y divide-neutral-200 text-sm" {
|
|
thead class="bg-neutral-50" {
|
|
tr {
|
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Applicant" }
|
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Requested" }
|
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Registration URL" }
|
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "IP" }
|
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Action" }
|
|
}
|
|
}
|
|
tbody class="divide-y divide-neutral-200 bg-white" {
|
|
@for pending in pending_registrations {
|
|
(pending_registration_row(config, base, csrf_token, pending))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn pending_registration_row(
|
|
config: &AdminConfig,
|
|
base: &str,
|
|
csrf_token: &str,
|
|
pending: &PendingRegistrationResponse,
|
|
) -> Markup {
|
|
let account_name = pending_registration_account_name(pending);
|
|
let requested_at = format_admin_timestamp(&pending.requested_at);
|
|
let email = pending.email.as_deref().unwrap_or("None");
|
|
let link_id = pending.registration_url_id.as_deref().unwrap_or("None");
|
|
let client_ip = pending.client_ip.as_deref().unwrap_or("None");
|
|
let approve_action = format!("{base}/instance-config?action=approve_pending_registration");
|
|
let reject_action = format!("{base}/instance-config?action=reject_pending_registration");
|
|
html! {
|
|
tr {
|
|
td class="max-w-[20rem] px-4 py-3 align-top" {
|
|
p class="font-medium text-neutral-900" { (account_name) }
|
|
@if let Some(global_name) = pending.global_name.as_deref() {
|
|
p class="truncate text-xs text-neutral-500" title=(global_name) { (global_name) }
|
|
}
|
|
p class="truncate text-xs text-neutral-500" title=(email) { (email) }
|
|
p class="whitespace-nowrap text-xs text-neutral-500" { "ID: " (&pending.user_id) }
|
|
}
|
|
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
|
|
(requested_at)
|
|
}
|
|
td class="px-4 py-3 align-top text-neutral-700" {
|
|
@if let Some(registration_url_id) = pending.registration_url_id.as_deref() {
|
|
@let full_url = admin_issued_registration_url(config, registration_url_id);
|
|
p class="whitespace-nowrap text-xs text-neutral-500" { "ID: " (registration_url_id) }
|
|
div class="mt-1" {
|
|
(compact_copy_button(&full_url, "Copy URL"))
|
|
}
|
|
} @else {
|
|
span class="text-neutral-500" { (link_id) }
|
|
}
|
|
}
|
|
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
|
|
(client_ip)
|
|
}
|
|
td class="px-4 py-3 align-top" {
|
|
div class="flex flex-nowrap gap-2" {
|
|
form method="post" action=(&approve_action)
|
|
hx-post=(&approve_action)
|
|
hx-target="#pending-registration-list"
|
|
hx-swap="outerHTML"
|
|
hx-push-url="false"
|
|
data-admin-allow-swap="true" {
|
|
(csrf_input(csrf_token))
|
|
input type="hidden" name="user_id" value=(&pending.user_id);
|
|
(compact_button("Approve", false))
|
|
}
|
|
form method="post" action=(&reject_action)
|
|
hx-post=(&reject_action)
|
|
hx-target="#pending-registration-list"
|
|
hx-swap="outerHTML"
|
|
hx-push-url="false"
|
|
data-admin-allow-swap="true" {
|
|
(csrf_input(csrf_token))
|
|
input type="hidden" name="user_id" value=(&pending.user_id);
|
|
(compact_button("Reject", true))
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn admin_issued_registration_url(config: &AdminConfig, code: &str) -> String {
|
|
format!(
|
|
"{}/register?registration_url={}",
|
|
config.web_app_endpoint.trim_end_matches('/'),
|
|
urlencoding::encode(code)
|
|
)
|
|
}
|
|
|
|
fn copyable_registration_url(value: &str, label: &str) -> Markup {
|
|
html! {
|
|
div class="mt-2 flex items-center gap-2" {
|
|
input type="url" readonly value=(value)
|
|
aria-label="Registration URL"
|
|
class="h-8 min-w-0 flex-1 rounded-lg border border-neutral-300 bg-neutral-50 px-3 py-1.5 text-xs text-neutral-800";
|
|
(compact_copy_button(value, label))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn compact_copy_button(value: &str, label: &str) -> Markup {
|
|
html! {
|
|
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 transition-all hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-brand-primary/20"
|
|
data-copy-value=(value)
|
|
onclick="window.__adminCopyToClipboard && window.__adminCopyToClipboard(this.dataset.copyValue, this, 'Copied')" {
|
|
(label)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn compact_button(label: &str, danger: bool) -> Markup {
|
|
let variant_class = if danger {
|
|
"bg-red-600 text-white hover:bg-red-700 focus:ring-red-500/30"
|
|
} else {
|
|
"bg-neutral-900 text-white hover:bg-neutral-800 focus:ring-brand-primary/20"
|
|
};
|
|
html! {
|
|
button type="submit"
|
|
class={"inline-flex h-8 shrink-0 items-center justify-center rounded-lg px-3 text-xs font-medium transition-all focus:outline-none focus:ring-2 " (variant_class)} {
|
|
(label)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum RegistrationUrlStatus {
|
|
Active,
|
|
Revoked,
|
|
Expired,
|
|
Exhausted,
|
|
}
|
|
|
|
impl RegistrationUrlStatus {
|
|
fn label(self) -> &'static str {
|
|
match self {
|
|
Self::Active => "Active",
|
|
Self::Revoked => "Revoked",
|
|
Self::Expired => "Expired",
|
|
Self::Exhausted => "Exhausted",
|
|
}
|
|
}
|
|
|
|
fn variant(self) -> BadgeVariant {
|
|
match self {
|
|
Self::Active => BadgeVariant::Success,
|
|
Self::Revoked => BadgeVariant::Danger,
|
|
Self::Expired | Self::Exhausted => BadgeVariant::Warning,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn registration_url_status(url: &RegistrationUrlResponse) -> RegistrationUrlStatus {
|
|
if url.revoked_at.is_some() {
|
|
return RegistrationUrlStatus::Revoked;
|
|
}
|
|
if url.expires_at.as_deref().is_some_and(timestamp_is_past) {
|
|
return RegistrationUrlStatus::Expired;
|
|
}
|
|
if url
|
|
.max_uses
|
|
.is_some_and(|max_uses| url.use_count >= max_uses)
|
|
{
|
|
return RegistrationUrlStatus::Exhausted;
|
|
}
|
|
RegistrationUrlStatus::Active
|
|
}
|
|
|
|
fn registration_url_status_badge(status: RegistrationUrlStatus) -> Markup {
|
|
badge(status.label(), status.variant())
|
|
}
|
|
|
|
fn timestamp_is_past(iso: &str) -> bool {
|
|
time::OffsetDateTime::parse(iso, &time::format_description::well_known::Rfc3339)
|
|
.or_else(|_| {
|
|
time::OffsetDateTime::parse(
|
|
iso,
|
|
&time::format_description::well_known::Iso8601::DEFAULT,
|
|
)
|
|
})
|
|
.map(|timestamp| timestamp < time::OffsetDateTime::now_utc())
|
|
.unwrap_or(false)
|
|
}
|
|
|
|
fn format_optional_admin_timestamp(value: Option<&str>, fallback: &str) -> String {
|
|
value
|
|
.map(format_admin_timestamp)
|
|
.unwrap_or_else(|| fallback.to_owned())
|
|
}
|
|
|
|
fn registration_url_uses_text(url: &RegistrationUrlResponse) -> String {
|
|
match url.max_uses {
|
|
Some(max_uses) => format!("{} / {}", url.use_count, max_uses),
|
|
None => format!("{} / unlimited", url.use_count),
|
|
}
|
|
}
|
|
|
|
fn pending_registration_account_name(pending: &PendingRegistrationResponse) -> String {
|
|
if pending.discriminator == 0 {
|
|
pending.username.clone()
|
|
} else {
|
|
format!("{}#{:04}", pending.username, pending.discriminator)
|
|
}
|
|
}
|
|
|
|
fn sso_config_section(base: &str, csrf_token: &str, sso: &SsoConfigResponse) -> Markup {
|
|
let allowed_domains = sso.allowed_domains.join("\n");
|
|
let secret_caption = if sso.client_secret_set {
|
|
"A secret is set. Check to clear, or enter a new value to rotate."
|
|
} else {
|
|
"No secret set yet."
|
|
};
|
|
section_card_with_description(
|
|
"Single Sign-On (SSO)",
|
|
"Configure OIDC-style SSO for the admin and client apps. When enabled, \
|
|
users can sign in through your SSO provider. Require SSO to block local \
|
|
password registration and login.",
|
|
html! {
|
|
form method="post" action={(base) "/instance-config?action=update_sso"} {
|
|
(csrf_input(csrf_token))
|
|
div class="space-y-6" {
|
|
div class="flex flex-col gap-2" {
|
|
(checkbox("sso_enabled", "true", "Enable SSO", sso.enabled, true))
|
|
(checkbox("sso_enforced", "true", "Require SSO (disables local password login and registration)", sso.enforced, true))
|
|
(checkbox("sso_auto_provision", "true", "Automatically provision users on first SSO login", sso.auto_provision, true))
|
|
}
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
|
(text_input("sso_display_name", "Display Name", sso.display_name.as_deref().unwrap_or(""), "Example Identity Provider"))
|
|
(text_input("sso_issuer", "Issuer", sso.issuer.as_deref().unwrap_or(""), "https://idp.example.com"))
|
|
}
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
|
(text_input("sso_authorization_url", "Authorization URL", sso.authorization_url.as_deref().unwrap_or(""), "https://idp.example.com/oauth/authorize"))
|
|
(text_input("sso_token_url", "Token URL", sso.token_url.as_deref().unwrap_or(""), "https://idp.example.com/oauth/token"))
|
|
}
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
|
(text_input("sso_userinfo_url", "User Info URL", sso.userinfo_url.as_deref().unwrap_or(""), "https://idp.example.com/oauth/userinfo"))
|
|
(text_input("sso_jwks_url", "JWKS URL", sso.jwks_url.as_deref().unwrap_or(""), "https://idp.example.com/.well-known/jwks.json"))
|
|
}
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
|
(text_input("sso_client_id", "Client ID", sso.client_id.as_deref().unwrap_or(""), "client-id"))
|
|
div class="space-y-2" {
|
|
(form_field_group("Client Secret", "sso_client_secret", false, None, None, html! {
|
|
input type="password" id="sso_client_secret" name="sso_client_secret"
|
|
placeholder="Leave blank to keep existing"
|
|
class=(FORM_INPUT_CLASS);
|
|
}))
|
|
(checkbox("sso_clear_client_secret", "true", "Clear secret", false, true))
|
|
p class="text-xs text-neutral-500" {
|
|
(secret_caption)
|
|
}
|
|
}
|
|
}
|
|
div class="grid grid-cols-1 gap-4 sm:grid-cols-2" {
|
|
(text_input("sso_scope", "Scope", sso.scope.as_deref().unwrap_or(""), "openid email profile"))
|
|
(form_field_group("Redirect URI", "sso_redirect_uri", false, None,
|
|
Some("Configure this exact URI in your IdP. It is derived from the public gateway URL."),
|
|
html! {
|
|
input type="url" id="sso_redirect_uri" name="sso_redirect_uri"
|
|
value=(sso.redirect_uri.as_deref().unwrap_or(""))
|
|
disabled class=(FORM_INPUT_CLASS);
|
|
},
|
|
))
|
|
}
|
|
div class="space-y-2" {
|
|
(textarea_input(
|
|
"sso_allowed_domains",
|
|
"Allowed Email Domains",
|
|
"example.com\nexample.org",
|
|
&allowed_domains,
|
|
3,
|
|
false,
|
|
))
|
|
p class="text-xs text-neutral-500" {
|
|
"Limit SSO logins to these domains (one per line). Leave empty to allow any verified email."
|
|
}
|
|
}
|
|
(form_actions(html! {
|
|
(submit_button("Save SSO Settings"))
|
|
}))
|
|
}
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
fn limit_config_section(base: &str, limit_config: &LimitConfigResponse) -> Markup {
|
|
let description = if limit_config.self_hosted.unwrap_or(false) {
|
|
"Self-hosted instance with all premium features enabled by default. Configure user and guild limits."
|
|
} else {
|
|
"Configure limit rules that control user and guild restrictions based on traits and features."
|
|
};
|
|
section_card_simple(
|
|
"Limit Configuration",
|
|
html! {
|
|
div class="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between" {
|
|
div class="space-y-1" {
|
|
p class="text-sm text-neutral-500" {
|
|
(description)
|
|
}
|
|
}
|
|
(secondary_button_link("Configure Limits", &format!("{base}/limit-config")))
|
|
}
|
|
},
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
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,
|
|
"Fluxer",
|
|
)
|
|
.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,
|
|
"Fluxer",
|
|
)
|
|
.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 =
|
|
captcha_section("/admin", "csrf", &CaptchaConfigResponse::default()).into_string();
|
|
assert!(markup.contains("/admin/instance-config?action=update_captcha"));
|
|
assert!(markup.contains(r#"name="captcha_enabled""#));
|
|
assert!(markup.contains(r#"name="captcha_cost""#));
|
|
assert!(markup.contains(r#"name="captcha_max_counter""#));
|
|
assert!(markup.contains("about 3.6 s"));
|
|
}
|
|
|
|
#[test]
|
|
fn low_end_solve_estimate_at_the_defaults_is_about_three_and_a_half_seconds() {
|
|
let seconds = estimate_low_end_solve_seconds(5_000, 1_000);
|
|
assert!((seconds - 3.57).abs() < 0.01, "{seconds}");
|
|
}
|
|
|
|
#[test]
|
|
fn domain_migration_section_shows_both_rollouts_and_list_counts() {
|
|
let domain_migration = DomainMigrationConfigResponse {
|
|
anonymous_rollout_basis_points: 250,
|
|
included_user_ids: vec!["1500000000000000001".to_owned()],
|
|
excluded_user_ids: vec![
|
|
"1500000000000000002".to_owned(),
|
|
"1500000000000000003".to_owned(),
|
|
],
|
|
..DomainMigrationConfigResponse::default()
|
|
};
|
|
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
|
|
assert!(markup.contains("action=update_domain_migration"));
|
|
assert!(markup.contains("domain_migration_enabled"));
|
|
assert!(markup.contains("name=\"domain_migration_anonymous_rollout_basis_points\""));
|
|
assert!(markup.contains("value=\"250\""));
|
|
assert!(markup.contains("name=\"domain_migration_standalone_forwarding\""));
|
|
assert!(markup.contains("name=\"domain_migration_rollout_country_codes\""));
|
|
assert!(markup.contains("1 of 1000 stored"));
|
|
assert!(markup.contains("2 of 1000 stored"));
|
|
assert!(!markup.contains("at the cap"));
|
|
}
|
|
|
|
#[test]
|
|
fn voice_p2p_section_shows_the_rollout_and_list_counts() {
|
|
let voice_p2p = VoiceP2pConfigResponse {
|
|
enabled: true,
|
|
config_version: 3,
|
|
rollout_basis_points: 250,
|
|
rollout_country_codes: vec!["SE".to_owned(), "NO".to_owned()],
|
|
max_participants: 3,
|
|
included_user_ids: vec!["1500000000000000001".to_owned()],
|
|
excluded_user_ids: vec![
|
|
"1500000000000000002".to_owned(),
|
|
"1500000000000000003".to_owned(),
|
|
],
|
|
..VoiceP2pConfigResponse::default()
|
|
};
|
|
let markup = voice_p2p_section("/admin", "csrf", &voice_p2p).into_string();
|
|
assert!(markup.contains("Peer-to-peer voice"));
|
|
assert!(markup.contains("action=update_voice_p2p"));
|
|
assert!(markup.contains("name=\"voice_p2p_enabled\""));
|
|
assert!(markup.contains("name=\"voice_p2p_rollout_basis_points\""));
|
|
assert!(markup.contains("value=\"250\""));
|
|
assert!(markup.contains("name=\"voice_p2p_max_participants\""));
|
|
assert!(markup.contains("value=\"3\""));
|
|
assert!(markup.contains("min=\"2\""));
|
|
assert!(markup.contains("max=\"4\""));
|
|
assert!(markup.contains("name=\"voice_p2p_include_premium_users\""));
|
|
assert!(markup.contains("name=\"voice_p2p_included_guild_ids\""));
|
|
assert!(markup.contains("name=\"voice_p2p_rollout_country_codes\""));
|
|
assert!(markup.contains("value=\"SE, NO\""));
|
|
assert!(markup.contains("2 of 250 stored"));
|
|
assert!(markup.contains("Config version 3"));
|
|
assert!(markup.contains("1 of 1000 stored"));
|
|
assert!(markup.contains("2 of 1000 stored"));
|
|
assert!(!markup.contains("anonymous_rollout_basis_points"));
|
|
assert!(!markup.contains("standalone_forwarding"));
|
|
}
|
|
|
|
#[test]
|
|
fn push_relay_section_shows_the_consent_toggle() {
|
|
let accepted = PushRelayConfigResponse {
|
|
relay_consent_accepted: true,
|
|
relay_consent_accepted_at: Some("2026-09-27T10:11:12.000Z".to_owned()),
|
|
relay_consent_accepted_by: Some("1130650140672000000".to_owned()),
|
|
};
|
|
let markup = push_relay_section("/admin", "csrf", &accepted).into_string();
|
|
assert!(markup.contains("action=update_push_relay"));
|
|
assert!(markup.contains("name=\"push_relay_consent_accepted\""));
|
|
assert!(markup.contains("https://fluxer.com/push-relay"));
|
|
assert!(markup.contains("value=\"Sep 27, 2026, 10:11 AM UTC\""));
|
|
assert!(markup.contains("value=\"1130650140672000000\""));
|
|
assert!(!markup.contains("name=\"push_relay_consent_accepted_at\""));
|
|
assert!(!markup.contains("name=\"push_relay_consent_accepted_by\""));
|
|
assert!(!markup.to_lowercase().contains("rollout"));
|
|
|
|
let unaccepted =
|
|
push_relay_section("/admin", "csrf", &PushRelayConfigResponse::default()).into_string();
|
|
assert!(unaccepted.contains("name=\"push_relay_consent_accepted\""));
|
|
assert!(unaccepted.contains("Not accepted"));
|
|
assert!(unaccepted.contains("value=\"Never\""));
|
|
assert!(unaccepted.contains("value=\"Nobody\""));
|
|
}
|
|
|
|
#[test]
|
|
fn premium_mode_options_use_the_configured_premium_name() {
|
|
let markup =
|
|
premium_mode_form("/admin", "csrf", &InstancePolicyResponse::default(), "Gold")
|
|
.into_string();
|
|
assert!(markup.contains("Mirror (Free and Gold tiers)"));
|
|
assert!(markup.contains("Everyone (every member gets Gold limits)"));
|
|
assert!(!markup.contains("Plutonium"));
|
|
}
|
|
|
|
#[test]
|
|
fn domain_migration_section_flags_a_list_at_its_cap() {
|
|
let domain_migration = DomainMigrationConfigResponse {
|
|
included_user_ids: (0..EXPERIMENT_MAX_TARGETED_USERS)
|
|
.map(|index| index.to_string())
|
|
.collect(),
|
|
..DomainMigrationConfigResponse::default()
|
|
};
|
|
let markup = domain_migration_section("/admin", "csrf", &domain_migration).into_string();
|
|
assert!(markup.contains("1000 of 1000 stored"));
|
|
assert!(markup.contains("at the cap"));
|
|
}
|
|
}
|