mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +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
Generated
+117
-48
@@ -4152,15 +4152,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/admin/instance/pending-registrations/{user_id}": {
|
||||
"patch": {
|
||||
"operationId": "update_admin_pending_registration",
|
||||
"summary": "Approve or reject a pending registration",
|
||||
"/admin/instance/pending-registrations": {
|
||||
"get": {
|
||||
"operationId": "list_admin_pending_registrations",
|
||||
"summary": "List pending registrations",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceConfigResponse"}}}
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/PendingRegistrationListResponse"}}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
@@ -4201,7 +4203,62 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Decides a registration waiting for manual review. Approving removes its pending registration trait, rejecting also prevents the account from logging in. Requires INSTANCE_CONFIG_UPDATE permission.",
|
||||
"description": "Lists every registration waiting for manual review, oldest first. Requires REGISTRATION_REVIEW permission.",
|
||||
"security": [{"adminApiKey": []}]
|
||||
}
|
||||
},
|
||||
"/admin/instance/pending-registrations/{user_id}": {
|
||||
"patch": {
|
||||
"operationId": "update_admin_pending_registration",
|
||||
"summary": "Approve or reject a pending registration",
|
||||
"tags": ["Admin"],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Success",
|
||||
"content": {
|
||||
"application/json": {"schema": {"$ref": "#/components/schemas/PendingRegistrationListResponse"}}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"401": {
|
||||
"description": "Unauthorized - Authentication is required or the token is invalid",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden - You do not have permission to perform this action",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
},
|
||||
"429": {
|
||||
"description": "Too Many Requests - You are being rate limited",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/ThrottledError"}}},
|
||||
"headers": {
|
||||
"Retry-After": {
|
||||
"description": "Number of seconds to wait before retrying (only on 429)",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Limit": {
|
||||
"description": "The number of requests that can be made in the current window",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Remaining": {
|
||||
"description": "The number of remaining requests that can be made",
|
||||
"schema": {"type": "integer"}
|
||||
},
|
||||
"X-RateLimit-Reset": {
|
||||
"description": "Unix timestamp when the rate limit resets",
|
||||
"schema": {"type": "integer"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error - An unexpected error occurred",
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Decides a registration waiting for manual review and returns the registrations still waiting. Approving removes its pending registration trait, rejecting also prevents the account from logging in. Requires REGISTRATION_REVIEW permission.",
|
||||
"security": [{"adminApiKey": []}],
|
||||
"parameters": [
|
||||
{
|
||||
@@ -5887,7 +5944,7 @@
|
||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
||||
}
|
||||
},
|
||||
"description": "Return the admin the request was authenticated as, with the admin permissions, roles, and metadata of the account. Requires AUTHENTICATE permission.",
|
||||
"description": "Return the admin the request was authenticated as, with the admin permissions, roles, and metadata of the account, plus the number of pending registrations when the admin holds REGISTRATION_REVIEW. Requires AUTHENTICATE permission.",
|
||||
"security": [{"adminApiKey": []}]
|
||||
}
|
||||
},
|
||||
@@ -10281,7 +10338,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acls": {
|
||||
"maxItems": 106,
|
||||
"maxItems": 107,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
||||
"description": "List of access control permissions to assign"
|
||||
@@ -10297,8 +10354,11 @@
|
||||
},
|
||||
"AdminUsersMeResponse": {
|
||||
"type": "object",
|
||||
"properties": {"user": {"$ref": "#/components/schemas/UserAdminResponseSchema"}},
|
||||
"required": ["user"],
|
||||
"properties": {
|
||||
"user": {"$ref": "#/components/schemas/UserAdminResponseSchema"},
|
||||
"pending_registration_count": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}
|
||||
},
|
||||
"required": ["user", "pending_registration_count"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"SnowflakeStringType": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "format": "snowflake"},
|
||||
@@ -11331,40 +11391,9 @@
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
},
|
||||
"pending_registrations": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"$ref": "#/components/schemas/SnowflakeStringType"},
|
||||
"username": {"type": "string"},
|
||||
"discriminator": {"type": "integer", "minimum": 0, "maximum": 9999},
|
||||
"global_name": {"nullable": true, "type": "string"},
|
||||
"email": {"nullable": true, "type": "string"},
|
||||
"requested_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
},
|
||||
"registration_url_id": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 128},
|
||||
"client_ip": {"nullable": true, "type": "string"}
|
||||
},
|
||||
"required": [
|
||||
"user_id",
|
||||
"username",
|
||||
"discriminator",
|
||||
"global_name",
|
||||
"email",
|
||||
"requested_at",
|
||||
"registration_url_id",
|
||||
"client_ip"
|
||||
],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": ["mode", "admin_registration_urls_enabled", "urls", "pending_registrations"],
|
||||
"required": ["mode", "admin_registration_urls_enabled", "urls"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"self_hosted": {"type": "boolean"},
|
||||
@@ -11811,6 +11840,17 @@
|
||||
},
|
||||
"required": ["status"]
|
||||
},
|
||||
"PendingRegistrationListResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"pending_registrations": {
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/PendingRegistrationResponse"}
|
||||
}
|
||||
},
|
||||
"required": ["pending_registrations"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"InstanceEmailSmtpTestRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -13515,7 +13555,7 @@
|
||||
},
|
||||
"acls": {
|
||||
"description": "Replacement list of access control permissions for the key",
|
||||
"maxItems": 106,
|
||||
"maxItems": 107,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"}
|
||||
}
|
||||
@@ -13533,7 +13573,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 106,
|
||||
"maxItems": 107,
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -13563,7 +13603,7 @@
|
||||
"maximum": 365
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 106,
|
||||
"maxItems": 107,
|
||||
"type": "array",
|
||||
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -13584,7 +13624,7 @@
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {
|
||||
"maxItems": 106,
|
||||
"maxItems": 107,
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "List of access control permissions for the key"
|
||||
@@ -13597,7 +13637,7 @@
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"acls": {
|
||||
"maxItems": 106,
|
||||
"maxItems": 107,
|
||||
"type": "array",
|
||||
"items": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||
"description": "Every admin access control permission the admin API recognises"
|
||||
@@ -13679,6 +13719,7 @@
|
||||
"message:delete",
|
||||
"message:lookup",
|
||||
"message:shred",
|
||||
"registration:review",
|
||||
"report:delete",
|
||||
"report:resolve",
|
||||
"report:view",
|
||||
@@ -16471,6 +16512,34 @@
|
||||
},
|
||||
"PremiumCurrency": {"type": "string", "pattern": "^[A-Z]{3}$"},
|
||||
"StripePriceIdSchema": {"type": "string", "maxLength": 255, "pattern": "^price_[A-Za-z0-9]+$"},
|
||||
"PendingRegistrationResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"user_id": {"$ref": "#/components/schemas/SnowflakeStringType"},
|
||||
"username": {"type": "string"},
|
||||
"discriminator": {"type": "integer", "minimum": 0, "maximum": 9999},
|
||||
"global_name": {"nullable": true, "type": "string"},
|
||||
"email": {"nullable": true, "type": "string"},
|
||||
"requested_at": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d:[0-5]\\d(?:\\.\\d+)?(?:Z))$"
|
||||
},
|
||||
"registration_url_id": {"nullable": true, "type": "string", "minLength": 1, "maxLength": 128},
|
||||
"client_ip": {"nullable": true, "type": "string"}
|
||||
},
|
||||
"required": [
|
||||
"user_id",
|
||||
"username",
|
||||
"discriminator",
|
||||
"global_name",
|
||||
"email",
|
||||
"requested_at",
|
||||
"registration_url_id",
|
||||
"client_ip"
|
||||
],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"InstanceBillingResponse": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -16839,7 +16908,7 @@
|
||||
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
|
||||
"type": "string"
|
||||
},
|
||||
"acls": {"maxItems": 106, "type": "array", "items": {"type": "string"}},
|
||||
"acls": {"maxItems": 107, "type": "array", "items": {"type": "string"}},
|
||||
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
|
||||
"has_totp": {"type": "boolean"},
|
||||
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -261,8 +261,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
||||
"registration": {
|
||||
"mode": "open",
|
||||
"admin_registration_urls_enabled": false,
|
||||
"urls": [],
|
||||
"pending_registrations": []
|
||||
"urls": []
|
||||
},
|
||||
"self_hosted": false,
|
||||
"account_identity": {"mode": "username", "locked": true, "tag_style": "none"},
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use axum::{
|
||||
Json, Router,
|
||||
body::{Body, to_bytes},
|
||||
extract::Path,
|
||||
http::{HeaderMap, Method, Request, StatusCode, Uri, header},
|
||||
response::{IntoResponse, Response},
|
||||
routing,
|
||||
@@ -272,6 +273,91 @@ async fn report_delete_form_confirms_sends_the_reason_and_toasts_a_held_report()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn pending_registration_decision_refreshes_the_list_and_sidebar_count() {
|
||||
let received = Arc::new(Mutex::new(Vec::<(String, Value)>::new()));
|
||||
let sink = received.clone();
|
||||
let api = Router::new()
|
||||
.route(
|
||||
"/admin/users/@me",
|
||||
routing::get(|| async {
|
||||
json_response(json!({"user": admin_user(), "pending_registration_count": 2}))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/admin/instance/pending-registrations",
|
||||
routing::get(|| async {
|
||||
json_response(json!({"pending_registrations": [
|
||||
pending_registration("1500000000000000002"),
|
||||
pending_registration("1500000000000000003"),
|
||||
]}))
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/admin/instance/pending-registrations/{user_id}",
|
||||
routing::patch(
|
||||
move |Path(user_id): Path<String>, Json(body): Json<Value>| {
|
||||
let sink = sink.clone();
|
||||
async move {
|
||||
sink.lock().unwrap().push((user_id, body));
|
||||
json_response(json!({"pending_registrations": [
|
||||
pending_registration("1500000000000000003"),
|
||||
]}))
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
.fallback(mock_api);
|
||||
let app = setup_with_api(api).await;
|
||||
let (headers, page) = get_with_headers(&app, "/pending-registrations", &[]).await;
|
||||
assert!(
|
||||
page.contains(r#"<span id="pending-registration-count" class="tabular-nums text-neutral-400">2</span>"#),
|
||||
"{page}"
|
||||
);
|
||||
assert!(page.contains("ID: 1500000000000000002"), "{page}");
|
||||
let csrf_token = csrf_cookie(&headers).unwrap_or_else(|| {
|
||||
panic!("pending registrations page did not set csrf_token cookie\n{page}")
|
||||
});
|
||||
assert_form_has_csrf(&page, "/pending-registrations/approve", &csrf_token);
|
||||
assert_form_has_csrf(&page, "/pending-registrations/reject", &csrf_token);
|
||||
|
||||
let cookie = format!("{}; csrf_token={}", app.session_cookie, csrf_token);
|
||||
let (status, headers, fragment) = post_form_with_headers(
|
||||
&app,
|
||||
"/pending-registrations/approve",
|
||||
&[
|
||||
("HX-Request", "true"),
|
||||
("HX-Target", "pending-registration-list"),
|
||||
("Cookie", &cookie),
|
||||
],
|
||||
&format!("_csrf={csrf_token}&user_id=1500000000000000002"),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK, "{fragment}");
|
||||
assert!(
|
||||
fragment.contains(r#"id="pending-registration-list""#),
|
||||
"{fragment}"
|
||||
);
|
||||
assert!(!fragment.contains("ID: 1500000000000000002"), "{fragment}");
|
||||
assert!(fragment.contains("ID: 1500000000000000003"), "{fragment}");
|
||||
assert!(
|
||||
fragment.contains(r#"hx-swap-oob="true">1</span>"#),
|
||||
"{fragment}"
|
||||
);
|
||||
let toast = headers
|
||||
.get("X-Fluxer-Admin-Toast")
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or_default();
|
||||
assert!(toast.contains("Registration approved"), "{toast}");
|
||||
assert_eq!(
|
||||
*received.lock().unwrap(),
|
||||
vec![(
|
||||
"1500000000000000002".to_owned(),
|
||||
json!({"status": "approved"})
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
async fn setup_with_api(api: Router) -> TestApp {
|
||||
let api_endpoint = spawn_mock_api(api).await;
|
||||
let router = build_router(test_config(api_endpoint));
|
||||
@@ -440,6 +526,19 @@ fn user(id: &str, username: &str) -> Value {
|
||||
})
|
||||
}
|
||||
|
||||
fn pending_registration(user_id: &str) -> Value {
|
||||
json!({
|
||||
"user_id": user_id,
|
||||
"username": "applicant",
|
||||
"discriminator": 0,
|
||||
"global_name": null,
|
||||
"email": "[email protected]",
|
||||
"requested_at": "2026-10-14T12:00:00.000Z",
|
||||
"registration_url_id": null,
|
||||
"client_ip": "203.0.113.7"
|
||||
})
|
||||
}
|
||||
|
||||
fn searched_report() -> Value {
|
||||
json!({
|
||||
"report_id": "1800000000000000001",
|
||||
|
||||
Reference in new issue
Block a user