mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(admin): move pending registrations to their own page (#1388)
This commit is contained in:
1 parent
97b2ac91ca
commit
abcb5cb814
31 files changed
+839
-328
No files matched your search
@@ -104,7 +104,7 @@ pub fn admin_layout_ext(
|
||||
body class="min-h-screen overflow-hidden bg-neutral-50" hx-boost="true" {
|
||||
a href="#main-content" class="skip-link" { "Skip to main content" }
|
||||
div class="flex h-[100dvh]" {
|
||||
(render_sidebar(config, admin_acls, active_page, options.inspected_voice_region_id))
|
||||
(render_sidebar(config, admin_acls, active_page, options.inspected_voice_region_id, auth.pending_registration_count))
|
||||
div data-sidebar-overlay="" class="pointer-events-none fixed inset-0 z-30 bg-black/50 opacity-0 transition-opacity duration-200 ease-in-out lg:hidden" aria-hidden="true" {}
|
||||
div class="flex h-[100dvh] w-full flex-1 flex-col overflow-y-auto" {
|
||||
(render_header(config, auth, csrf_token))
|
||||
|
||||
@@ -4,13 +4,14 @@ use crate::{acl, config::AdminConfig};
|
||||
use maud::{Markup, PreEscaped, html};
|
||||
|
||||
use super::layout_scripts::SIDEBAR_ACTIVE_SCROLL_SCRIPT;
|
||||
use super::layout_sidebar_nav::NAV_SECTIONS;
|
||||
use super::layout_sidebar_nav::{NAV_SECTIONS, PENDING_REGISTRATIONS_KEY};
|
||||
|
||||
pub fn render_sidebar(
|
||||
config: &AdminConfig,
|
||||
admin_acls: &[String],
|
||||
active_page: &str,
|
||||
inspected_voice_region_id: Option<&str>,
|
||||
pending_registration_count: Option<u32>,
|
||||
) -> Markup {
|
||||
let base = &config.base_path;
|
||||
html! {
|
||||
@@ -40,7 +41,7 @@ pub fn render_sidebar(
|
||||
div class="space-y-1" {
|
||||
@for item in &visible_items {
|
||||
@let active = active_page == item.active_key;
|
||||
@let base_classes = "block min-h-[44px] px-3 py-2.5 rounded text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900";
|
||||
@let base_classes = "flex min-h-[44px] items-center justify-between gap-3 px-3 py-2.5 rounded text-sm transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/70 focus-visible:ring-offset-2 focus-visible:ring-offset-neutral-900";
|
||||
@let item_path = if item.active_key == "voice-servers" {
|
||||
if let Some(region_id) = inspected_voice_region_id {
|
||||
let encoded = urlencoding::encode(region_id);
|
||||
@@ -58,6 +59,9 @@ pub fn render_sidebar(
|
||||
};
|
||||
a href={(base) (item_path)} class=(classes) aria-current=[active.then_some("page")] data-active=[active.then_some("")] {
|
||||
(item.title)
|
||||
@if item.active_key == PENDING_REGISTRATIONS_KEY {
|
||||
(pending_registration_count_markup(pending_registration_count, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -69,3 +73,13 @@ pub fn render_sidebar(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn pending_registration_count_markup(count: Option<u32>, out_of_band: bool) -> Markup {
|
||||
html! {
|
||||
span id="pending-registration-count" class="tabular-nums text-neutral-400" hx-swap-oob=[out_of_band.then_some("true")] {
|
||||
@if let Some(count) = count.filter(|count| *count > 0) {
|
||||
(count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ pub struct NavItem {
|
||||
pub required_acls: &'static [&'static str],
|
||||
}
|
||||
|
||||
pub const PENDING_REGISTRATIONS_KEY: &str = "pending-registrations";
|
||||
|
||||
pub struct NavSection {
|
||||
pub title: &'static str,
|
||||
pub items: &'static [NavItem],
|
||||
@@ -44,6 +46,12 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
||||
"discovery",
|
||||
[acl::DISCOVERY_REVIEW]
|
||||
),
|
||||
item!(
|
||||
"Pending Registrations",
|
||||
"/pending-registrations",
|
||||
PENDING_REGISTRATIONS_KEY,
|
||||
[acl::REGISTRATION_REVIEW]
|
||||
),
|
||||
item!(
|
||||
"Bulk Actions",
|
||||
"/bulk-actions",
|
||||
|
||||
@@ -8,9 +8,9 @@ use crate::{
|
||||
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS,
|
||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
||||
PremiumMode, PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
||||
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
||||
InstanceRegistrationResponse, LimitConfigResponse, PremiumMode, PushRelayConfigResponse,
|
||||
RegistrationUrlResponse, SsoConfigResponse, TagStyle, VOICE_P2P_DEFAULT_SALT,
|
||||
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
||||
},
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
@@ -1640,7 +1640,7 @@ fn registration_config_section(
|
||||
let base = &config.base_path;
|
||||
section_card_with_description(
|
||||
"Registration Controls",
|
||||
"Control public registration, admin-issued registration URLs, and pending approval requests.",
|
||||
"Control public registration and admin-issued registration URLs.",
|
||||
html! {
|
||||
div class="space-y-8" {
|
||||
(registration_policy_form(base, csrf_token, registration))
|
||||
@@ -1649,7 +1649,6 @@ fn registration_config_section(
|
||||
}
|
||||
(create_registration_url_form(base, csrf_token))
|
||||
(registration_url_list(config, csrf_token, ®istration.urls))
|
||||
(pending_registration_list(config, csrf_token, ®istration.pending_registrations))
|
||||
}
|
||||
},
|
||||
)
|
||||
@@ -1872,110 +1871,7 @@ fn registration_url_row(
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
pub(super) fn admin_issued_registration_url(config: &AdminConfig, code: &str) -> String {
|
||||
format!(
|
||||
"{}/register?registration_url={}",
|
||||
config.web_app_endpoint.trim_end_matches('/'),
|
||||
@@ -1994,7 +1890,7 @@ fn copyable_registration_url(value: &str, label: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_copy_button(value: &str, label: &str) -> Markup {
|
||||
pub(super) 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"
|
||||
@@ -2005,7 +1901,7 @@ fn compact_copy_button(value: &str, label: &str) -> Markup {
|
||||
}
|
||||
}
|
||||
|
||||
fn compact_button(label: &str, danger: bool) -> Markup {
|
||||
pub(super) 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 {
|
||||
@@ -2091,14 +1987,6 @@ fn registration_url_uses_text(url: &RegistrationUrlResponse) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
|
||||
@@ -25,6 +25,7 @@ pub mod jobs_list_helpers;
|
||||
pub mod limit_config;
|
||||
pub mod login;
|
||||
pub mod messages_page;
|
||||
pub mod pending_registrations;
|
||||
pub mod profile_substring_bans;
|
||||
pub mod report_detail;
|
||||
pub mod reports_list;
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::{
|
||||
api::types::PendingRegistrationResponse,
|
||||
config::AdminConfig,
|
||||
middleware::auth::AuthContext,
|
||||
templates::{
|
||||
components::{
|
||||
error_display::error_alert, form::csrf_input, page_container::page_header,
|
||||
table::empty_state,
|
||||
},
|
||||
layout::admin_layout,
|
||||
layout_sidebar_nav::PENDING_REGISTRATIONS_KEY,
|
||||
},
|
||||
utils::timestamps::format_admin_timestamp,
|
||||
};
|
||||
use maud::{Markup, html};
|
||||
|
||||
use super::instance_config::{admin_issued_registration_url, compact_button, compact_copy_button};
|
||||
|
||||
pub fn pending_registrations_page(
|
||||
config: &AdminConfig,
|
||||
auth: &AuthContext,
|
||||
csrf_token: &str,
|
||||
pending_registrations: Option<&[PendingRegistrationResponse]>,
|
||||
) -> Markup {
|
||||
let content = html! {
|
||||
(page_header(
|
||||
"Pending Registrations",
|
||||
Some("Approve or reject accounts waiting for a registration decision."),
|
||||
))
|
||||
@match pending_registrations {
|
||||
Some(pending_registrations) => {
|
||||
(pending_registration_list(config, csrf_token, pending_registrations))
|
||||
}
|
||||
None => (error_alert("Failed to load pending registrations.")),
|
||||
}
|
||||
};
|
||||
admin_layout(
|
||||
config,
|
||||
auth,
|
||||
"Pending Registrations",
|
||||
PENDING_REGISTRATIONS_KEY,
|
||||
None,
|
||||
content,
|
||||
)
|
||||
}
|
||||
|
||||
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" {
|
||||
@if pending_registrations.is_empty() {
|
||||
(empty_state("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 client_ip = pending.client_ip.as_deref().unwrap_or("None");
|
||||
let approve_action = format!("{base}/pending-registrations/approve");
|
||||
let reject_action = format!("{base}/pending-registrations/reject");
|
||||
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() {
|
||||
p class="whitespace-nowrap text-xs text-neutral-500" { "ID: " (registration_url_id) }
|
||||
div class="mt-1" {
|
||||
(compact_copy_button(&admin_issued_registration_url(config, registration_url_id), "Copy URL"))
|
||||
}
|
||||
} @else {
|
||||
span class="text-neutral-500" { "None" }
|
||||
}
|
||||
}
|
||||
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" {
|
||||
(decision_form(&approve_action, csrf_token, &pending.user_id, "Approve", false))
|
||||
(decision_form(&reject_action, csrf_token, &pending.user_id, "Reject", true))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn decision_form(
|
||||
action: &str,
|
||||
csrf_token: &str,
|
||||
user_id: &str,
|
||||
label: &str,
|
||||
danger: bool,
|
||||
) -> Markup {
|
||||
html! {
|
||||
form method="post" action=(action)
|
||||
hx-post=(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=(user_id);
|
||||
(compact_button(label, danger))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pending_registration_account_name(pending: &PendingRegistrationResponse) -> String {
|
||||
if pending.discriminator == 0 {
|
||||
pending.username.clone()
|
||||
} else {
|
||||
format!("{}#{:04}", pending.username, pending.discriminator)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user