feat(admin): move pending registrations to their own page (#1388)

This commit is contained in:
ArcticWarmth authored and GitHub committed 2026-10-14 13:45:27 +02:00
1 parent 97b2ac91ca
commit abcb5cb814
31 files changed
+839 -328

No files matched your search

+2
View File
@@ -70,6 +70,7 @@ pub const MESSAGE_DELETE_ALL: &str = "message:delete_all";
pub const MESSAGE_DELETE: &str = "message:delete";
pub const MESSAGE_LOOKUP: &str = "message:lookup";
pub const MESSAGE_SHRED: &str = "message:shred";
pub const REGISTRATION_REVIEW: &str = "registration:review";
pub const REPORT_DELETE: &str = "report:delete";
pub const REPORT_RESOLVE: &str = "report:resolve";
pub const REPORT_VIEW: &str = "report:view";
@@ -177,6 +178,7 @@ pub const ALL_ACLS: &[&str] = &[
MESSAGE_DELETE,
MESSAGE_LOOKUP,
MESSAGE_SHRED,
REGISTRATION_REVIEW,
REPORT_DELETE,
REPORT_RESOLVE,
REPORT_VIEW,
+23 -27
View File
@@ -6,7 +6,8 @@ use super::client::{AdminApiClient, ApiResult};
use super::types::{
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
InstanceEmailSmtpTestResponse, InstancePremiumDiscovery, PendingRegistrationListResponse,
PendingRegistrationResponse, PendingRegistrationStatus,
};
impl AdminApiClient {
@@ -74,35 +75,30 @@ impl AdminApiClient {
.await
}
pub async fn approve_pending_registration(
&self,
user_id: &str,
) -> ApiResult<InstanceConfigResponse> {
self.decide_pending_registration(user_id, "approved").await
pub async fn list_pending_registrations(&self) -> ApiResult<Vec<PendingRegistrationResponse>> {
let response: PendingRegistrationListResponse = self
.get("/admin/instance/pending-registrations", None)
.await?;
Ok(response.pending_registrations)
}
pub async fn reject_pending_registration(
pub async fn decide_pending_registration(
&self,
user_id: &str,
) -> ApiResult<InstanceConfigResponse> {
self.decide_pending_registration(user_id, "rejected").await
}
async fn decide_pending_registration(
&self,
user_id: &str,
status: &str,
) -> ApiResult<InstanceConfigResponse> {
let body = serde_json::json!({"status": status});
self.send(
Method::PATCH,
&format!(
"/admin/instance/pending-registrations/{}",
urlencoding::encode(user_id)
),
Some(&body),
None,
)
.await
status: PendingRegistrationStatus,
) -> ApiResult<Vec<PendingRegistrationResponse>> {
let body = serde_json::json!({"status": status.as_str()});
let response: PendingRegistrationListResponse = self
.send(
Method::PATCH,
&format!(
"/admin/instance/pending-registrations/{}",
urlencoding::encode(user_id)
),
Some(&body),
None,
)
.await?;
Ok(response.pending_registrations)
}
}
+1
View File
@@ -32,6 +32,7 @@ pub fn deserialize_string_or_u64<'de, D: Deserializer<'de>>(d: D) -> Result<u64,
#[derive(Clone, Debug, Deserialize)]
pub struct AdminUserMeResponse {
pub user: AdminUser,
pub pending_registration_count: Option<u32>,
}
#[derive(Clone, Debug, Deserialize)]
+20 -3
View File
@@ -701,8 +701,6 @@ pub struct InstanceRegistrationResponse {
pub admin_registration_urls_enabled: bool,
#[serde(default)]
pub urls: Vec<RegistrationUrlResponse>,
#[serde(default)]
pub pending_registrations: Vec<PendingRegistrationResponse>,
}
impl Default for InstanceRegistrationResponse {
@@ -711,7 +709,6 @@ impl Default for InstanceRegistrationResponse {
mode: RegistrationMode::Open,
admin_registration_urls_enabled: true,
urls: Vec::new(),
pending_registrations: Vec::new(),
}
}
}
@@ -767,6 +764,26 @@ pub struct PendingRegistrationResponse {
pub client_ip: Option<String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum PendingRegistrationStatus {
Approved,
Rejected,
}
impl PendingRegistrationStatus {
pub fn as_str(self) -> &'static str {
match self {
Self::Approved => "approved",
Self::Rejected => "rejected",
}
}
}
#[derive(Clone, Debug, Deserialize)]
pub struct PendingRegistrationListResponse {
pub pending_registrations: Vec<PendingRegistrationResponse>,
}
#[derive(Clone, Debug, Default, Serialize)]
pub struct InstanceConfigUpdateRequest {
#[serde(skip_serializing_if = "Option::is_none")]
+15 -15
View File
@@ -1,7 +1,7 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::types::{AdminUser, PremiumBranding},
api::types::{AdminUser, AdminUserMeResponse, PremiumBranding},
middleware::flash,
session::{self, Session},
state::AppState,
@@ -16,6 +16,7 @@ use axum::{
pub struct AuthContext {
pub session: Session,
pub admin_user: Option<AdminUser>,
pub pending_registration_count: Option<u32>,
pub branding: Option<PremiumBranding>,
}
@@ -29,17 +30,19 @@ pub async fn require_auth(
let Some(session) = session else {
return Redirect::to(&format!("{}/auth/start", config.base_path)).into_response();
};
let admin_user = match fetch_admin_user(state.http_client(), config, &session).await {
AdminFetchResult::Unauthorized => {
let login_url = format!("{}/login", config.base_path);
return clear_session_and_redirect(&login_url, config);
}
AdminFetchResult::Ok(admin_user) => Some(*admin_user),
AdminFetchResult::None => None,
};
let (admin_user, pending_registration_count) =
match fetch_admin_user(state.http_client(), config, &session).await {
AdminFetchResult::Unauthorized => {
let login_url = format!("{}/login", config.base_path);
return clear_session_and_redirect(&login_url, config);
}
AdminFetchResult::Ok(me) => (Some(me.user), me.pending_registration_count),
AdminFetchResult::None => (None, None),
};
request.extensions_mut().insert(AuthContext {
session,
admin_user,
pending_registration_count,
branding: state.self_hosted_branding().await,
});
@@ -83,7 +86,7 @@ fn extract_session(request: &Request, secret_key: &str) -> Option<Session> {
}
enum AdminFetchResult {
Ok(Box<AdminUser>),
Ok(Box<AdminUserMeResponse>),
Unauthorized,
None,
}
@@ -109,11 +112,8 @@ async fn fetch_admin_user(
if !response.status().is_success() {
return AdminFetchResult::None;
}
match response
.json::<crate::api::types::AdminUserMeResponse>()
.await
{
Ok(resp) => AdminFetchResult::Ok(Box::new(resp.user)),
match response.json::<AdminUserMeResponse>().await {
Ok(me) => AdminFetchResult::Ok(Box::new(me)),
Err(_) => AdminFetchResult::None,
}
}
+2
View File
@@ -13,6 +13,7 @@ pub mod guilds;
pub mod jobs;
mod message_actions;
pub mod messages;
pub mod registrations;
pub mod reports;
pub mod system;
mod system_actions;
@@ -63,6 +64,7 @@ pub fn build_router(config: AdminConfig) -> Router {
.merge(applications::router())
.merge(codes::router())
.merge(discovery::router())
.merge(registrations::router())
.merge(jobs::router())
.merge(messages::router())
.merge(system::router())
+159
View File
@@ -0,0 +1,159 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::{
api::{client::AdminApiClient, types::PendingRegistrationStatus},
middleware::{
auth::AuthContext,
csrf::CsrfToken,
flash::{self, FlashData},
htmx,
},
state::AppState,
templates::{self, layout_sidebar::pending_registration_count_markup},
utils::forms::clean_string,
};
use axum::{
Form, Router,
extract::State,
http::HeaderMap,
response::{Html, IntoResponse, Response},
routing::{get, post},
};
use serde::Deserialize;
#[derive(Deserialize)]
struct PendingRegistrationForm {
#[serde(default)]
user_id: Option<String>,
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/pending-registrations", get(pending_registrations_page))
.route(
"/pending-registrations/approve",
post(approve_pending_registration),
)
.route(
"/pending-registrations/reject",
post(reject_pending_registration),
)
}
async fn pending_registrations_page(
State(state): State<AppState>,
auth: axum::Extension<AuthContext>,
csrf: axum::Extension<CsrfToken>,
) -> Response {
let config = state.config();
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
let pending_registrations = match client.list_pending_registrations().await {
Ok(pending_registrations) => Some(pending_registrations),
Err(error) => {
tracing::warn!(%error, "admin API request failed: list pending registrations");
None
}
};
let markup = templates::pages::pending_registrations::pending_registrations_page(
config,
&auth.0,
&csrf.0.0,
pending_registrations.as_deref(),
);
Html(markup.into_string()).into_response()
}
async fn approve_pending_registration(
state: State<AppState>,
headers: HeaderMap,
auth: axum::Extension<AuthContext>,
csrf: axum::Extension<CsrfToken>,
form: Form<PendingRegistrationForm>,
) -> Response {
decide_pending_registration(
state,
headers,
auth,
csrf,
form,
PendingRegistrationStatus::Approved,
)
.await
}
async fn reject_pending_registration(
state: State<AppState>,
headers: HeaderMap,
auth: axum::Extension<AuthContext>,
csrf: axum::Extension<CsrfToken>,
form: Form<PendingRegistrationForm>,
) -> Response {
decide_pending_registration(
state,
headers,
auth,
csrf,
form,
PendingRegistrationStatus::Rejected,
)
.await
}
async fn decide_pending_registration(
State(state): State<AppState>,
headers: HeaderMap,
auth: axum::Extension<AuthContext>,
csrf: axum::Extension<CsrfToken>,
Form(form): Form<PendingRegistrationForm>,
status: PendingRegistrationStatus,
) -> Response {
let config = state.config();
let (success_message, error_message) = match status {
PendingRegistrationStatus::Approved => {
("Registration approved", "Failed to approve registration")
}
PendingRegistrationStatus::Rejected => {
("Registration rejected", "Failed to reject registration")
}
};
let flash = match clean_string(form.user_id.as_deref().unwrap_or("")) {
None => FlashData::error("User ID is required"),
Some(user_id) => {
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
match client.decide_pending_registration(&user_id, status).await {
Ok(pending_registrations) => {
let flash = FlashData::success(success_message);
if htmx::targets(&headers, "pending-registration-list") {
let markup = maud::html! {
(templates::pages::pending_registrations::pending_registration_list(
config,
&csrf.0.0,
&pending_registrations,
))
(pending_registration_count_markup(
u32::try_from(pending_registrations.len()).ok(),
true,
))
};
let mut response = Html(markup.into_string()).into_response();
htmx::add_toast_header(&mut response, &flash);
return response;
}
flash
}
Err(error) => {
tracing::warn!(%error, ?status, "admin API request failed: decide pending registration");
FlashData::error(error_message)
}
}
}
};
if htmx::is_htmx_request(&headers) {
return htmx::toast_response(&flash);
}
flash::redirect_with_flash(
&format!("{}/pending-registrations", config.base_path),
flash,
config.secure_cookies(),
)
}
-58
View File
@@ -304,48 +304,6 @@ pub async fn instance_config_post(
},
None => FlashData::error("Registration URL ID is required"),
},
"approve_pending_registration" => match form.clean("user_id") {
Some(user_id) => match client.approve_pending_registration(&user_id).await {
Ok(instance_config) => {
let flash = FlashData::success("Registration approved");
if htmx::targets(&headers, "pending-registration-list") {
return render_pending_registration_list_response(
config,
&csrf.0.0,
&instance_config,
&flash,
);
}
flash
}
Err(error) => {
tracing::warn!(%error, "admin API request failed: approve pending registration");
FlashData::error("Failed to approve registration")
}
},
None => FlashData::error("User ID is required"),
},
"reject_pending_registration" => match form.clean("user_id") {
Some(user_id) => match client.reject_pending_registration(&user_id).await {
Ok(instance_config) => {
let flash = FlashData::success("Registration rejected");
if htmx::targets(&headers, "pending-registration-list") {
return render_pending_registration_list_response(
config,
&csrf.0.0,
&instance_config,
&flash,
);
}
flash
}
Err(error) => {
tracing::warn!(%error, "admin API request failed: reject pending registration");
FlashData::error("Failed to reject registration")
}
},
None => FlashData::error("User ID is required"),
},
_ => FlashData::error("Unknown instance config action"),
};
if htmx::is_htmx_request(&headers) {
@@ -381,22 +339,6 @@ fn render_registration_url_list_response(
)
}
fn render_pending_registration_list_response(
config: &AdminConfig,
csrf_token: &str,
instance_config: &crate::api::types::InstanceConfigResponse,
flash: &FlashData,
) -> Response {
render_fragment_with_toast(
templates::pages::instance_config::pending_registration_list(
config,
csrf_token,
&instance_config.registration.pending_registrations,
),
flash,
)
}
fn render_fragment_with_toast(markup: Markup, flash: &FlashData) -> Response {
let mut response = Html(markup.into_string()).into_response();
htmx::add_toast_header(&mut response, flash);
+1 -1
View File
@@ -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))
+16 -2
View File
@@ -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, &registration.urls))
(pending_registration_list(config, csrf_token, &registration.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 {
+1
View File
@@ -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)
}
}