// SPDX-License-Identifier: AGPL-3.0-or-later use crate::{ api::types::{ AppPublicConfigResponse, ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse, InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse, InstanceRegistrationResponse, LimitConfigResponse, MessageHoverTrackingConfigResponse, MessageKeyboardFocusConfigResponse, NoiseSuppressionBackend, PendingRegistrationResponse, RegistrationUrlResponse, SsoConfigResponse, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS, VoiceNoiseSuppressionConfigResponse, }, 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, }, 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 number_field( name: &str, label: &str, value: &str, min: Option, max: Option, 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! { (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)) (deferred_phone_gate_form(base, csrf_token, &instance_config.policy)) }, )) @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)) }, )) } (config_group( "Runtime integrations", "Credentials and provider choices that override environment variables at runtime.", html! { (integrations_config_section(base, csrf_token, &instance_config.integrations)) }, )) (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)) (voice_noise_suppression_section(base, csrf_token, &instance_config.voice_noise_suppression)) (message_hover_tracking_section(base, csrf_token, &instance_config.message_hover_tracking)) (message_keyboard_focus_section(base, csrf_token, &instance_config.message_keyboard_focus)) (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) -> 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)) (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 deferred_phone_gate_form( base: &str, csrf_token: &str, policy: &InstancePolicyResponse, ) -> Markup { let gate = &policy.deferred_phone_gate; let status = if gate.enabled { ("Enabled", BadgeVariant::Success) } else { ("Disabled", BadgeVariant::Default) }; 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" { "Deferred phone verification" } (badge(status.0, status.1)) } p class="text-sm text-neutral-500" { "When enabled, a phone requirement raised at registration is held back and only \ applied if the account joins a discoverable community, or one above the member \ threshold, within the window. Accounts that wait out the window are not challenged. \ Inbound-SMS requirements are never deferred." } form method="post" action={(base) "/instance-config?action=update_policy"} { (csrf_input(csrf_token)) div class="space-y-4" { (select_input("policy_deferred_phone_gate_enabled", "Deferred phone verification", &[ ("true", "Enabled"), ("false", "Disabled"), ], if gate.enabled { "true" } else { "false" })) (text_input( "policy_deferred_phone_gate_window_hours", "Window (hours)", &gate.window_hours.to_string(), "6", )) (text_input( "policy_deferred_phone_gate_member_threshold", "Member threshold", &gate.member_threshold.to_string(), "50", )) (form_actions(html! { (submit_button("Save deferred phone verification")) })) } } } } } fn premium_mode_form(base: &str, csrf_token: &str, policy: &InstancePolicyResponse) -> Markup { 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 (Free and Premium tiers)"), ("everyone", "Everyone (every member gets Plutonium limits)"), ], policy.premium_mode.as_str())) (form_actions(html! { (submit_button("Save premium model")) })) } } } } } fn service_select(name: &str, label: &str, override_value: Option, 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 integrations_config_section( base: &str, csrf_token: &str, integrations: &InstanceIntegrationsResponse, ) -> Markup { let captcha_provider = integrations .captcha .provider .as_deref() .unwrap_or(integrations.captcha.effective_provider.as_str()); 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."))) } 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" { "Bot protection" } (secret_badge("hCaptcha secret", integrations.captcha.hcaptcha_secret_key_set)) (secret_badge("Turnstile secret", integrations.captcha.turnstile_secret_key_set)) } div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" { (select_input("integration_captcha_provider", "Provider", &[ ("none", "Disabled"), ("hcaptcha", "hCaptcha"), ("turnstile", "Cloudflare Turnstile"), ], captcha_provider)) (text_input( "integration_hcaptcha_site_key", "hCaptcha site key", integrations.captcha.hcaptcha_site_key.as_deref().unwrap_or(""), "", )) (password_input("integration_hcaptcha_secret_key", "hCaptcha secret key", Some("Leave blank to keep the current secret."))) (text_input( "integration_turnstile_site_key", "Turnstile site key", integrations.captcha.turnstile_site_key.as_deref().unwrap_or(""), "", )) (password_input("integration_turnstile_secret_key", "Turnstile secret key", Some("Leave blank to keep the current secret."))) } } 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)) } (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(""), "notifications@example.com", )) (text_input( "integration_email_from_name", "From name", integrations.email.from_name.as_deref().unwrap_or(""), "Fluxer", )) (text_input( "integration_smtp_host", "SMTP host", integrations.email.smtp.host.as_deref().unwrap_or(""), "smtp.example.com", )) (text_input( "integration_smtp_port", "SMTP port", &smtp_port, "587", )) (text_input( "integration_smtp_username", "SMTP username", integrations.email.smtp.username.as_deref().unwrap_or(""), "user@example.com", )) (password_input("integration_smtp_password", "SMTP password", Some("Leave blank to keep the current password."))) } (checkbox("integration_smtp_secure", "true", "Use TLS", integrations.email.smtp.secure.unwrap_or(true), true)) (checkbox("integration_email_disable_new_ip_authorization", "true", "Disable new IP login authorisation", integrations.email.disable_new_ip_authorization, true)) div class="flex flex-wrap gap-2" { button type="submit" formaction={(base) "/instance-config?action=test_smtp"} class="inline-flex w-fit items-center justify-center gap-2 rounded-lg border border-neutral-300 bg-neutral-50 px-4 py-2 font-medium text-base text-neutral-700 transition-all duration-150 hover:border-neutral-400 hover:text-neutral-900 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-white" { span { "Test SMTP connection" } } } } 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", )) } 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", )) } p class="text-xs text-neutral-500" { "Registration asks for agreement only to the documents configured here. Leave both blank to hide legal consent on self-hosted registration." } (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 voice_noise_suppression_section( base: &str, csrf_token: &str, voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse, ) -> Markup { let status = if voice_noise_suppression.enabled { ("Live", BadgeVariant::Success) } else { ("Inert", BadgeVariant::Default) }; let backend_labels = NoiseSuppressionBackend::ALL.map(|backend| (backend.to_string(), backend.label())); let backend_options = backend_labels .iter() .map(|(value, label)| (value.as_str(), *label)) .collect::>(); let included_user_ids = voice_noise_suppression.included_user_ids.join("\n"); let excluded_user_ids = voice_noise_suppression.excluded_user_ids.join("\n"); let guild_overrides = voice_noise_suppression .guild_overrides .iter() .map(|entry| format!("{}={}", entry.guild_id, entry.backend)) .collect::>() .join("\n"); section_card_with_description( "Voice Noise Suppression", "Pick which noise suppression backend targeted clients load in voice calls, and how many \ of them are targeted. While the master switch below is off nothing on this form reaches \ any client: every user keeps the audio pipeline they have today, whatever the rest of \ these fields say.", html! { form method="post" action={(base) "/instance-config?action=update_voice_noise_suppression"} { (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_noise_suppression.config_version) } } (checkbox( "voice_ns_enabled", "true", "Serve noise suppression assignments to clients", voice_noise_suppression.enabled, true, )) p class="text-xs text-neutral-500" { "Off is the safe state. With this unchecked every client is told the \ feature is inert and keeps its current behavior, so the rollout, targeting \ and override fields below have no effect at all." } h3 class="text-sm font-semibold text-neutral-900" { "Backends" } (select_input( "voice_ns_default_backend", "Default Backend", &backend_options, &voice_noise_suppression.default_backend.to_string(), )) p class="text-xs text-neutral-500" { "The backend assigned by always-on user rules and the canary. A default \ that is not ticked below is unavailable, but per-guild overrides can \ still target users." } div class="grid grid-cols-1 gap-2 sm:grid-cols-2" { @for backend in NoiseSuppressionBackend::ALL { (checkbox( "voice_ns_enabled_backends[]", &backend.to_string(), backend.label(), voice_noise_suppression.enabled_backends.contains(&backend), true, )) } } p class="text-xs text-neutral-500" { "Backends clients are allowed to load. Unticking one withdraws it from \ every user, including anyone who picked it themselves." } (checkbox( "voice_ns_allow_user_override", "true", "Let users pick their own backend from the ticked list", voice_noise_suppression.allow_user_override, true, )) p class="text-xs text-neutral-500" { "Applies only to users who are already targeted. It never pulls anyone \ into the rollout." } h3 class="text-sm font-semibold text-neutral-900" { "Rollout" } (number_field( "voice_ns_rollout_basis_points", "Rollout (basis points)", &voice_noise_suppression.rollout_basis_points.to_string(), Some(0), Some(10000), "1", Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."), )) div class="flex flex-col gap-2" { (text_input( "voice_ns_rollout_salt", "Rollout Salt", &voice_noise_suppression.rollout_salt, "voice-ns-v1", )) 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_ns_included_user_ids", "Always-on User IDs", "1500000000000000001\n1500000000000000002", &included_user_ids, 4, false, )) (entry_count_hint( voice_noise_suppression.included_user_ids.len(), VOICE_NS_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" { (textarea_input( "voice_ns_excluded_user_ids", "Never-on User IDs", "1500000000000000003\n1500000000000000004", &excluded_user_ids, 4, false, )) (entry_count_hint( voice_noise_suppression.excluded_user_ids.len(), VOICE_NS_MAX_TARGETED_USERS, )) p class="text-xs text-neutral-500" { "Same format. Exclusion wins over both the always-on list and the \ percentage, so this is the per-user kill switch." } } h3 class="text-sm font-semibold text-neutral-900" { "Per-guild overrides" } div class="flex flex-col gap-2" { (textarea_input( "voice_ns_guild_overrides", "Guild Overrides", "1600000000000000001=rnnoise\n1600000000000000002=deep_filter", &guild_overrides, 4, false, )) (entry_count_hint( voice_noise_suppression.guild_overrides.len(), VOICE_NS_MAX_GUILD_OVERRIDES, )) p class="text-xs text-neutral-500" { "One per line as guild_id=backend. A guild \ rule targets callers even outside the canary. Always-on user rules \ take precedence, and excluded users stay off. Invalid lines and \ conflicting rules for the same guild prevent the save. \ Unticked backends stay stored but are inactive." } } h3 class="text-sm font-semibold text-neutral-900" { "Processing" } (checkbox( "voice_ns_stereo_enabled", "true", "Process stereo input instead of downmixing to mono", voice_noise_suppression.stereo_enabled, true, )) p class="text-xs text-neutral-500" { "Costs more CPU on the client. Leave off unless you are testing stereo \ capture." } div class="grid grid-cols-1 gap-4 sm:grid-cols-2" { (number_field( "voice_ns_suppression_strength", "Suppression Strength", &voice_noise_suppression.suppression_strength.to_string(), Some(0), Some(100), "1", Some("How aggressively the backend removes noise, 0 to 100. Higher values cut more background but chew more of the voice."), )) } (form_actions(html! { (submit_button("Save Voice Noise Suppression Configuration")) })) } } }, ) } fn message_hover_tracking_section( base: &str, csrf_token: &str, message_hover_tracking: &MessageHoverTrackingConfigResponse, ) -> Markup { let status = if message_hover_tracking.enabled { ("Live", BadgeVariant::Success) } else { ("Inert", BadgeVariant::Default) }; let included_user_ids = message_hover_tracking.included_user_ids.join("\n"); let excluded_user_ids = message_hover_tracking.excluded_user_ids.join("\n"); section_card_with_description( "Message Hover Tracking", "Picks which message hover implementation targeted clients run in the message list. A \ targeted client resolves the hovered message from one shared pointer oracle and drives \ the message action bar from that state. While the master switch below is off every \ client keeps the per-row implementation it ships with, whatever the rest of these \ fields say.", html! { form method="post" action={(base) "/instance-config?action=update_message_hover_tracking"} { (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 " (message_hover_tracking.config_version) } } (checkbox( "message_hover_enabled", "true", "Serve message hover tracking assignments to clients", message_hover_tracking.enabled, true, )) p class="text-xs text-neutral-500" { "Off is the safe state. With this unchecked every client is told the \ rollout is inert and keeps its current hover behavior, so the rollout \ and targeting fields below have no effect at all." } h3 class="text-sm font-semibold text-neutral-900" { "Rollout" } (number_field( "message_hover_rollout_basis_points", "Rollout (basis points)", &message_hover_tracking.rollout_basis_points.to_string(), Some(0), Some(10000), "1", Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."), )) div class="flex flex-col gap-2" { (text_input( "message_hover_rollout_salt", "Rollout Salt", &message_hover_tracking.rollout_salt, "message-hover-tracking-v1", )) 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( "message_hover_included_user_ids", "Always-on User IDs", "1500000000000000001\n1500000000000000002", &included_user_ids, 4, false, )) 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" { (textarea_input( "message_hover_excluded_user_ids", "Never-on User IDs", "1500000000000000003\n1500000000000000004", &excluded_user_ids, 4, false, )) p class="text-xs text-neutral-500" { "Same format. Exclusion wins over both the always-on list and the \ percentage, so this is the per-user kill switch." } } (form_actions(html! { (submit_button("Save Message Hover Tracking Configuration")) })) } } }, ) } fn message_keyboard_focus_section( base: &str, csrf_token: &str, message_keyboard_focus: &MessageKeyboardFocusConfigResponse, ) -> Markup { let status = if message_keyboard_focus.enabled { ("Live", BadgeVariant::Success) } else { ("Inert", BadgeVariant::Default) }; let included_user_ids = message_keyboard_focus.included_user_ids.join("\n"); let excluded_user_ids = message_keyboard_focus.excluded_user_ids.join("\n"); section_card_with_description( "Message Keyboard Focus", "Picks whether targeted clients run the keyboard navigation rework in the message list. \ A targeted client reaches the message list from the composer with one Tab, walks \ messages with the arrow keys through revealed blocked groups, and draws the focus ring \ inside each row. While the master switch below is off every client keeps the keyboard \ navigation it ships with, whatever the rest of these fields say.", html! { form method="post" action={(base) "/instance-config?action=update_message_keyboard_focus"} { (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 " (message_keyboard_focus.config_version) } } (checkbox( "message_keyboard_focus_enabled", "true", "Serve message keyboard focus assignments to clients", message_keyboard_focus.enabled, true, )) p class="text-xs text-neutral-500" { "Off is the safe state. With this unchecked every client is told the \ rollout is inert and keeps its current keyboard navigation, so the rollout \ and targeting fields below have no effect at all." } h3 class="text-sm font-semibold text-neutral-900" { "Rollout" } (number_field( "message_keyboard_focus_rollout_basis_points", "Rollout (basis points)", &message_keyboard_focus.rollout_basis_points.to_string(), Some(0), Some(10000), "1", Some("Share of users bucketed into the canary, in basis points: 0 is nobody, 100 is 1%, 10000 is everybody."), )) div class="flex flex-col gap-2" { (text_input( "message_keyboard_focus_rollout_salt", "Rollout Salt", &message_keyboard_focus.rollout_salt, "message-keyboard-focus-v1", )) 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( "message_keyboard_focus_included_user_ids", "Always-on User IDs", "1500000000000000001\n1500000000000000002", &included_user_ids, 4, false, )) 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" { (textarea_input( "message_keyboard_focus_excluded_user_ids", "Never-on User IDs", "1500000000000000003\n1500000000000000004", &excluded_user_ids, 4, false, )) p class="text-xs text-neutral-500" { "Same format. Exclusion wins over both the always-on list and the \ percentage, so this is the per-user kill switch." } } (form_actions(html! { (submit_button("Save Message Keyboard Focus Configuration")) })) } } }, ) } 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 one 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 synchronised 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. 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::*; use crate::api::types::VoiceNoiseSuppressionGuildOverride; fn rendered_voice_noise_suppression_section( voice_noise_suppression: &VoiceNoiseSuppressionConfigResponse, ) -> String { voice_noise_suppression_section("/admin", "csrf", voice_noise_suppression).into_string() } #[test] fn voice_noise_suppression_section_shows_list_counts_and_caps() { let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse { included_user_ids: vec!["1500000000000000001".to_owned()], excluded_user_ids: vec![ "1500000000000000002".to_owned(), "1500000000000000003".to_owned(), ], guild_overrides: vec![VoiceNoiseSuppressionGuildOverride { guild_id: "1600000000000000001".to_owned(), backend: NoiseSuppressionBackend::Rnnoise, }], ..VoiceNoiseSuppressionConfigResponse::default() }; let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression); assert!(markup.contains("1 of 1000 stored")); assert!(markup.contains("2 of 1000 stored")); assert!(markup.contains("1 of 200 stored")); assert!(!markup.contains("at the cap")); } #[test] fn voice_noise_suppression_section_flags_a_list_at_its_cap() { let voice_noise_suppression = VoiceNoiseSuppressionConfigResponse { included_user_ids: (0..VOICE_NS_MAX_TARGETED_USERS) .map(|index| index.to_string()) .collect(), ..VoiceNoiseSuppressionConfigResponse::default() }; let markup = rendered_voice_noise_suppression_section(&voice_noise_suppression); assert!(markup.contains("1000 of 1000 stored")); assert!(markup.contains("at the cap")); } }