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

+117 -48
View File
@@ -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"}},
+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)
}
}
+1 -2
View File
@@ -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"},
+99
View File
@@ -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",
@@ -39,6 +39,7 @@ export const AdminAuditReadActions = {
LIST_GUILD_MEMORY_STATS: 'list_guild_memory_stats',
LIST_GUILD_STICKERS: 'list_guild_stickers',
LIST_GUILD_THREADS: 'list_guild_threads',
LIST_PENDING_REGISTRATIONS: 'list_pending_registrations',
LIST_REPORT_REASONS: 'list_report_reasons',
LIST_USER_APPLICATIONS: 'list_user_applications',
LIST_USER_CHANGE_LOG: 'list_user_change_log',
@@ -34,6 +34,7 @@ import {
InstanceEmailSmtpTestRequest,
InstanceEmailSmtpTestResponse,
PendingRegistrationActionRequest,
PendingRegistrationListResponse,
RegistrationUrlIdParam,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
@@ -98,7 +99,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
experimentDelivery,
registrationConfig,
registrationUrls,
pendingRegistrations,
] = await Promise.all([
instanceConfigRepository.getSsoConfig(),
instanceConfigRepository.getGatewayRolloutConfig(),
@@ -109,7 +109,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
instanceConfigRepository.getExperimentDeliveryConfig(),
instanceConfigRepository.getRegistrationConfig(),
instanceConfigRepository.getRegistrationUrlsForAdmin(),
instanceConfigRepository.getPendingRegistrations(),
]);
const [appPublic, policy, resolvedServices, integrations, media, billing, accountIdentity, accountIdentityLocked] =
await Promise.all([
@@ -148,7 +147,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
registration: {
...registrationConfig,
urls: registrationUrls,
pending_registrations: pendingRegistrations,
},
self_hosted: Config.instance.selfHosted,
account_identity: {
@@ -320,10 +318,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
targetType: 'instance_config',
targetId: 0n,
action: AdminAuditReadActions.GET_INSTANCE_CONFIG,
metadata: {
registration_url_count: response.registration.urls.length,
pending_registration_count: response.registration.pending_registrations.length,
},
metadata: {registration_url_count: response.registration.urls.length},
});
return ctx.json(response);
},
@@ -738,18 +733,43 @@ export function InstanceConfigAdminController(app: HonoApp) {
return ctx.json(await buildInstanceConfigResponse());
},
);
app.get(
'/admin/instance/pending-registrations',
RateLimitMiddleware(RateLimitConfigs.ADMIN_LOOKUP),
requireAdminACL(AdminACLs.REGISTRATION_REVIEW),
OpenAPI({
operationId: 'list_admin_pending_registrations',
summary: 'List pending registrations',
description:
'Lists every registration waiting for manual review, oldest first. Requires REGISTRATION_REVIEW permission.',
responseSchema: PendingRegistrationListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
}),
async (ctx) => {
const pendingRegistrations = await instanceConfigRepository.getPendingRegistrations();
await recordAdminRead(ctx, {
targetType: 'instance_config',
targetId: 0n,
action: AdminAuditReadActions.LIST_PENDING_REGISTRATIONS,
metadata: {pending_registration_count: pendingRegistrations.length},
});
return ctx.json({pending_registrations: pendingRegistrations});
},
);
app.patch(
'/admin/instance/pending-registrations/:user_id',
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
requireAdminACL(AdminACLs.REGISTRATION_REVIEW),
Validator('param', UserIdParam),
Validator('json', PendingRegistrationActionRequest),
OpenAPI({
operationId: 'update_admin_pending_registration',
summary: 'Approve or reject a pending registration',
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.',
responseSchema: InstanceConfigResponse,
'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.',
responseSchema: PendingRegistrationListResponse,
statusCode: 200,
security: 'adminApiKey',
tags: 'Admin',
@@ -759,7 +779,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject';
await updatePendingRegistrationUser(ctx, userId, decision);
await instanceConfigRepository.removePendingRegistration(userId);
return ctx.json(await buildInstanceConfigResponse());
return ctx.json({pending_registrations: await instanceConfigRepository.getPendingRegistrations()});
},
);
}
@@ -8,9 +8,10 @@ import {
RequireEmailAccountIdentity,
RequireUsernameAccountIdentity,
} from '@app/api/middleware/AccountIdentityMiddleware';
import {requireAdminACL, requireGrantedAdminACLs} from '@app/api/middleware/AdminMiddleware';
import {hasAdminACL, requireAdminACL, requireGrantedAdminACLs} from '@app/api/middleware/AdminMiddleware';
import {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
import type {HonoApp} from '@app/api/types/HonoEnv';
import {inputValidationErrorFromZodIssues, Validator} from '@app/api/Validator';
@@ -94,14 +95,15 @@ export function UserAdminController(app: HonoApp) {
security: 'adminApiKey',
tags: 'Admin',
description:
'Return the admin the request was authenticated as, with the admin permissions, roles, and metadata of the account. Requires AUTHENTICATE permission.',
'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.',
}),
async (ctx) => {
const adminUser = ctx.get('user');
const cacheService = ctx.get('cacheService');
return ctx.json({
user: await mapUserToAdminResponse(adminUser, cacheService),
});
const canReviewRegistrations = hasAdminACL(ctx.get('adminUserAcls'), AdminACLs.REGISTRATION_REVIEW);
const [user, pendingRegistrations] = await Promise.all([
mapUserToAdminResponse(ctx.get('user'), ctx.get('cacheService')),
canReviewRegistrations ? getInstanceConfigRepository().getPendingRegistrations() : null,
]);
return ctx.json({user, pending_registration_count: pendingRegistrations?.length ?? null});
},
);
app.get(
@@ -13,7 +13,9 @@ import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
import {HTTP_STATUS} from '@app/api/test/TestConstants';
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {afterAll, beforeAll, beforeEach, describe, it} from 'vitest';
import type {PendingRegistrationListResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {AdminUsersMeResponse} from '@fluxer/schema/src/domains/admin/AdminUserSchemas';
import {afterAll, beforeAll, beforeEach, describe, expect, it} from 'vitest';
interface PendingRegistrationResponse {
user_id: string;
@@ -37,7 +39,7 @@ describe('pending registration approval and the stock community', () => {
it('keeps a banned user out of the stock community on approval', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_UPDATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
await getInstanceConfigRepository().setInstancePolicyConfig({
@@ -79,7 +81,7 @@ describe('pending registration approval and the stock community', () => {
it('does not add a user who was never pending to the stock community', async () => {
const admin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_UPDATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
const outsider = await createTestAccount(harness);
@@ -99,4 +101,59 @@ describe('pending registration approval and the stock community', () => {
.expect(HTTP_STATUS.NOT_FOUND)
.execute();
});
it('lets the review ACL alone list and decide registrations', async () => {
const reviewer = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.REGISTRATION_REVIEW,
]);
const configAdmin = await setUserACLs(harness, await createTestAccount(harness), [
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
]);
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const pending = await createBuilderWithoutAuth<PendingRegistrationResponse>(harness)
.post('/auth/register')
.body({
email: createUniqueEmail('reviewonly'),
username: createUniqueUsername('reviewonly'),
global_name: 'Review only',
password: 'reviewing-since-1999',
date_of_birth: '2000-01-01',
consent: true,
})
.execute();
const listed = await createBuilder<PendingRegistrationListResponse>(harness, reviewer.token)
.get('/admin/instance/pending-registrations')
.expect(HTTP_STATUS.OK)
.execute();
expect(listed.pending_registrations.map((entry) => entry.user_id)).toEqual([pending.user_id]);
const reviewerMe = await createBuilder<AdminUsersMeResponse>(harness, reviewer.token)
.get('/admin/users/@me')
.execute();
expect(reviewerMe.pending_registration_count).toBe(1);
const configAdminMe = await createBuilder<AdminUsersMeResponse>(harness, configAdmin.token)
.get('/admin/users/@me')
.execute();
expect(configAdminMe.pending_registration_count).toBeNull();
await createBuilder(harness, configAdmin.token)
.get('/admin/instance/pending-registrations')
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
.execute();
await createBuilder(harness, configAdmin.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.FORBIDDEN, 'MISSING_ACL')
.execute();
const decided = await createBuilder<PendingRegistrationListResponse>(harness, reviewer.token)
.patch(`/admin/instance/pending-registrations/${pending.user_id}`)
.body({status: 'rejected'})
.expect(HTTP_STATUS.OK)
.execute();
expect(decided.pending_registrations).toEqual([]);
});
});
@@ -35,7 +35,7 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
action: 'get_instance_config',
targetType: 'instance_config',
targetId: '0',
metadata: {registration_url_count: '1', pending_registration_count: '0'},
metadata: {registration_url_count: '1'},
},
};
},
@@ -129,6 +129,30 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray<AdminAuditCoverageCase
};
},
},
{
method: 'GET',
route: '/admin/instance/pending-registrations',
async prepare({harness}) {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
await registerUser(harness, {
email: createUniqueEmail('coverage-listed'),
username: createUniqueUsername('coveragelisted'),
global_name: 'Coverage Listed',
password: 'coverage-listed-password',
date_of_birth: '2000-01-01',
consent: true,
});
return {
request: {path: '/admin/instance/pending-registrations'},
expected: {
action: 'list_pending_registrations',
targetType: 'instance_config',
targetId: '0',
metadata: {pending_registration_count: '1'},
},
};
},
},
{
method: 'PATCH',
route: '/admin/instance/pending-registrations/:user_id',
@@ -23,7 +23,10 @@ import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequest
import {UserRepository} from '@app/api/user/repositories/UserRepository';
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
import type {InstanceConfigResponse} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import type {
InstanceConfigResponse,
PendingRegistrationListResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
const REGISTRATION_URLS_KEY = 'registration_urls';
@@ -62,6 +65,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
AdminACLs.AUTHENTICATE,
AdminACLs.INSTANCE_CONFIG_VIEW,
AdminACLs.INSTANCE_CONFIG_UPDATE,
AdminACLs.REGISTRATION_REVIEW,
]);
});
@@ -82,6 +86,13 @@ describe('signups racing on registration URLs and pending approvals', () => {
const readAdminConfig = (): Promise<InstanceConfigResponse> =>
createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config').execute();
const readPendingRegistrations = async (): Promise<PendingRegistrationListResponse['pending_registrations']> =>
(
await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
.get('/admin/instance/pending-registrations')
.execute()
).pending_registrations;
const completeSso = async (prefix: string) => {
const start = await createBuilderWithoutAuth<{state: string}>(harness)
.post('/auth/sso/start')
@@ -116,7 +127,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
};
const expectOnePendingAccount = async () => {
const pending = (await readAdminConfig()).registration.pending_registrations;
const pending = await readPendingRegistrations();
expect(pending).toHaveLength(1);
const account = await new UserRepository().findUnique(createUserID(BigInt(pending[0]!.user_id)));
expect(account?.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)).toBe(true);
@@ -209,8 +220,8 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await register('seatreleased', code);
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect(await readPendingRegistrations()).toEqual([]);
const withdrawn = await readAdminConfig();
expect(withdrawn.registration.pending_registrations).toEqual([]);
expect(withdrawn.registration.urls.find((url) => url.id === registrationUrl.id)?.use_count).toBe(0);
const retried = await register('seatreleasedretry', code);
@@ -307,7 +318,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const attempts = await Promise.all(Array.from({length: 5}, (_, index) => register(`pending${index}`)));
expect(attempts.map((attempt) => attempt.json.registration_pending_approval)).toEqual(Array(5).fill(true));
const pending = (await readAdminConfig()).registration.pending_registrations.map((entry) => entry.user_id);
const pending = (await readPendingRegistrations()).map((entry) => entry.user_id);
expect(pending.toSorted()).toEqual(attempts.map((attempt) => attempt.json.user_id).toSorted());
});
@@ -340,7 +351,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await register('pendingkept');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toHaveLength(1);
expect(await readPendingRegistrations()).toHaveLength(1);
});
it('lists no pending approval for an approval-mode signup that failed before the account was created', async () => {
@@ -350,7 +361,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await register('pendingnever');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toEqual([]);
expect(await readPendingRegistrations()).toEqual([]);
});
it('lists an SSO account provisioned in approval mode whose provisioning failed after the account was created', async () => {
@@ -383,7 +394,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await completeSso('ssopendingkept');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toHaveLength(1);
expect(await readPendingRegistrations()).toHaveLength(1);
});
it('lists no pending approval for an SSO signup in approval mode that failed before the account was created', async () => {
@@ -394,7 +405,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
const failed = await completeSso('ssopendingnever');
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
expect((await readAdminConfig()).registration.pending_registrations).toEqual([]);
expect(await readPendingRegistrations()).toEqual([]);
});
it('keeps a stored pending approval listed until an admin decides it', async () => {
@@ -417,16 +428,16 @@ describe('signups racing on registration URLs and pending approvals', () => {
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
const fresh = await register('pendingafter');
const listed = (await readAdminConfig()).registration.pending_registrations.map((entry) => entry.user_id);
const listed = (await readPendingRegistrations()).map((entry) => entry.user_id);
expect(listed.toSorted()).toEqual([account.userId, fresh.json.user_id].toSorted());
const decided = await createBuilder<InstanceConfigResponse>(harness, admin.token)
const decided = await createBuilder<PendingRegistrationListResponse>(harness, admin.token)
.patch(`/admin/instance/pending-registrations/${account.userId}`)
.body({status: 'approved'})
.expect(HTTP_STATUS.OK)
.execute();
expect(decided.registration.pending_registrations.map((entry) => entry.user_id)).toEqual([fresh.json.user_id]);
expect(decided.pending_registrations.map((entry) => entry.user_id)).toEqual([fresh.json.user_id]);
expect(
JSON.parse(
(await getInstanceConfigRepository().getConfig(REGISTRATION_PENDING_APPROVALS_KEY)) ?? 'null',
@@ -61,7 +61,7 @@ import {
InstanceConfigResponse,
InstanceConfigUpdateRequest,
type InstanceEmailSmtpTestRequest,
type PendingRegistrationResponse,
PendingRegistrationResponse,
type RegistrationUrlResponse,
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
import {
@@ -1053,7 +1053,6 @@ function parseStoredRegistrationConfig(raw: string | null): InstanceRegistration
}
const RegistrationUrlSchema = InstanceConfigResponse.shape.registration.shape.urls.element;
const PendingRegistrationSchema = InstanceConfigResponse.shape.registration.shape.pending_registrations.element;
const StoredRegistrationTimestampSchema = z.iso
.datetime({offset: true})
.refine((value) => value === value.trim() && Number.isFinite(Date.parse(value)))
@@ -1086,9 +1085,9 @@ const StoredRegistrationUrlSchema = RegistrationUrlSchema.extend({
last_used_at: StoredRegistrationTimestampSchema.nullable().default(null),
last_used_by_user_id: StoredNullableStringSchema.pipe(StoredSnowflakeStringSchema.nullable()),
}) satisfies z.ZodType<InstanceRegistrationUrl>;
const StoredPendingRegistrationSchema = PendingRegistrationSchema.extend({
const StoredPendingRegistrationSchema = PendingRegistrationResponse.extend({
user_id: StoredSnowflakeStringSchema,
discriminator: PendingRegistrationSchema.shape.discriminator.default(0),
discriminator: PendingRegistrationResponse.shape.discriminator.default(0),
global_name: StoredNullableStringSchema,
email: StoredNullableStringSchema,
requested_at: StoredRegistrationTimestampSchema,
+1 -1
View File
@@ -274,7 +274,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map<string, Readonly<Partial<Record<TableRul
['admin-api/instance.mdx', {'table-identifier': 8}],
['admin-api/messages.mdx', {'table-identifier': 1}],
['admin-api/reports.mdx', {'table-fit': 1, 'table-identifier': 3}],
['admin-api/users.mdx', {'table-identifier': 3}],
['admin-api/users.mdx', {'table-identifier': 4}],
['admin-api/voice.mdx', {'table-identifier': 3}],
['gateway/event-filtering.md', {'table-cell': 2}],
['gateway/events.md', {'table-identifier': 1}],
@@ -287,6 +287,7 @@ An entry with one of these actions has `access` set to `read`.
| list_guild_memory_stats | Guild memory statistics were listed |
| list_guild_stickers | The stickers of a guild were listed |
| list_guild_threads | The threads of a guild were listed |
| list_pending_registrations | Pending registrations were read |
| list_report_reasons | The report reason catalogue was listed |
| list_user_applications | The applications an account owns were read |
| list_user_change_log | The contact change log of an account was read |
@@ -569,7 +570,7 @@ The registry is returned in this order by [List ACLs](#list-acls). A value outsi
| guild:update:name<sup>5</sup> | Updates a guild name |
| guild:update:settings<sup>5</sup> | Updates general guild settings and clears guild fields |
| guild:update:vanity<sup>5</sup> | Updates a guild custom invite code |
| instance:config:update | Updates [instance](/admin-api/instance/) configuration, branding assets, SMTP tests, registration URLs, and pending registrations |
| instance:config:update | Updates [instance](/admin-api/instance/) configuration, branding assets, SMTP tests, and registration URLs |
| instance:config:view | Reads instance configuration |
| instance:limit_config:update | Replaces the instance-wide limit configuration |
| instance:limit_config:view | Reads the instance-wide limit configuration |
@@ -577,6 +578,7 @@ The registry is returned in this order by [List ACLs](#list-acls). A value outsi
| message:delete | Deletes one message |
| message:lookup | Reads, browses, and searches [messages](/admin-api/messages/) through the Admin API |
| message:shred | Queues an explicit message shred job and reads its state |
| registration:review | Reads and decides [pending registrations](/admin-api/instance/#list-pending-registrations) |
| report:delete | Deletes a [report](/admin-api/reports/#delete-report) with its stored evidence |
| report:resolve | Updates and resolves [reports](/admin-api/reports/), and places legal holds on them |
| report:view | Reads and searches reports |
@@ -10,7 +10,7 @@ Instance configuration is everything an operator can change at runtime. It cover
Every write is a merge over the stored configuration, and an omitted key leaves the stored value unchanged. The limit configuration write is the one exception and replaces the stored document.
Reading configuration requires the [Admin ACL](/admin-api/#acl-registry) `instance:config:view`, and writing it requires `instance:config:update`. The limit configuration has its own pair, `instance:limit_config:view` and `instance:limit_config:update`.
Reading configuration requires the [Admin ACL](/admin-api/#acl-registry) `instance:config:view`, and writing it requires `instance:config:update`. The limit configuration has its own pair, `instance:limit_config:view` and `instance:limit_config:update`. Pending registrations are read and decided with `registration:review` alone.
:::caution[No read returns a stored secret]
A secret is reported by a companion boolean such as `client_secret_set`. Except for the single sign-on client secret, that boolean is true whether the value is stored here or supplied by deployment configuration.
@@ -33,7 +33,7 @@ Missing settings use the defaults documented below. Invalid stored configuration
| voice_p2p | [peer-to-peer voice configuration](#peer-to-peer-voice-configuration-object) object | Peer-to-peer voice rollout |
| captcha | [captcha configuration](#captcha-configuration-object) object | ALTCHA proof-of-work captcha settings |
| experiment_delivery | [experiment delivery configuration](#experiment-delivery-configuration-object) object | Cadence every client polls the experiments route on |
| registration | [registration configuration](#registration-configuration-object) object | Registration policy, issued URLs, and pending registrations |
| registration | [registration configuration](#registration-configuration-object) object | Registration policy and issued URLs |
| self_hosted | boolean | Whether the deployment runs in self-hosted mode |
| account_identity | [account identity configuration](#account-identity-configuration-object) object | How members sign in, and whether that can still change |
| app_public | [public application configuration](#public-application-configuration-object) object | Branding, legal, setup, and registration field policy |
@@ -223,7 +223,7 @@ Both fields are served to every account, whether or not any experiment targets t
## Registration configuration object
Registration policy in force, plus every issued registration URL and every account awaiting a decision.
Registration policy in force, plus every issued registration URL. Accounts awaiting a decision are read through [List pending registrations](#list-pending-registrations).
### Structure
@@ -232,15 +232,12 @@ Registration policy in force, plus every issued registration URL and every accou
| mode | string | [Registration mode](#registration-modes) |
| admin_registration_urls_enabled<sup>1</sup> | boolean | Whether Admin-issued registration URLs are accepted |
| urls<sup>2</sup> | array[[registration URL](#registration-url-object) object] | Every issued registration URL, including revoked and exhausted records |
| pending_registrations<sup>3</sup> | array[[pending registration](#pending-registration-object) object] | Accounts awaiting a decision |
<sup>1</sup> While the value is false, registration refuses every supplied registration URL code, so an issued URL stops working without being revoked
<sup>2</sup> Ordered by creation time, newest first
<sup>3</sup> Ordered by request time, oldest first
Both collections are embedded in the configuration response. There is no separate listing operation and no pagination, so every configuration read returns every issued URL.
The URLs are embedded in the configuration response. There is no separate listing operation and no pagination, so every configuration read returns every issued URL.
## Registration modes
@@ -315,6 +312,18 @@ One account that registered and is still waiting for an Admin decision.
<sup>1</sup> Stored at registration time and never refreshed, so it can be stale by the time an Admin reads it
## Pending registration list object
Every account still waiting for an Admin decision.
### Structure
| Field | Type | Description |
| --- | --- | --- |
| pending_registrations<sup>1</sup> | array[[pending registration](#pending-registration-object) object] | Accounts awaiting a decision |
<sup>1</sup> Ordered by request time, oldest first. There is no pagination
## Public application configuration object
Branding, legal, setup and registration field policy, in the Admin form of what the [Instance](/http-api/instance/) resource publishes to clients.
@@ -631,7 +640,7 @@ Returns the [instance configuration](#instance-configuration-object) object. Req
### Side effects
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `get_instance_config`, target type `instance_config`, target ID `0`, and metadata keys `registration_url_count` and `pending_registration_count`.
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `get_instance_config`, target type `instance_config`, target ID `0`, and metadata key `registration_url_count`.
### Rate limit
@@ -879,11 +888,31 @@ The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-obje
100 requests per minute for each authenticated user, on the `admin:user:modify` bucket.
## List pending registrations
<RouteHeader method="GET" path="/v1/admin/instance/pending-registrations" auditReason />
Returns the [pending registration list](#pending-registration-list-object) object. Requires `registration:review`.
### Response
| Status | Body | Condition |
| --- | --- | --- |
| 200 | [pending registration list](#pending-registration-list-object) object | Pending registrations were returned |
### Side effects
The operation records one [Admin audit entry](/admin-api/#admin-audit-entry-object) with action `list_pending_registrations`, target type `instance_config`, target ID `0`, and metadata key `pending_registration_count`.
### Rate limit
200 requests per minute for each authenticated user, on the `admin:lookup` bucket.
## Update pending registration
<RouteHeader method="PATCH" path="/v1/admin/instance/pending-registrations/{user_id}" auditReason />
Approves or rejects one pending registration and returns the resulting [instance configuration](#instance-configuration-object) object. Requires `instance:config:update`.
Approves or rejects one pending registration and returns the resulting [pending registration list](#pending-registration-list-object) object. Requires `registration:review`.
### Path parameters
@@ -901,7 +930,7 @@ Approves or rejects one pending registration and returns the resulting [instance
| Status | Body | Condition |
| --- | --- | --- |
| 200<sup>1</sup> | [instance configuration](#instance-configuration-object) object | The decision was applied |
| 200<sup>1</sup> | [pending registration list](#pending-registration-list-object) object | The decision was applied |
<sup>1</sup> An account that no longer exists still answers 200. The pending registration is removed and no trait is written
@@ -586,7 +586,7 @@ The `user_id` selector adds `user_id_count` for the number of IDs supplied, and
<RouteHeader method="GET" path="/v1/admin/users/@me" />
Returns the account the request authenticated as, wrapped in a `user` member. Requires `admin:authenticate`.
Returns the account the request authenticated as, wrapped in a `user` member, with the number of pending registrations. Requires `admin:authenticate`.
Every Admin credential holds `admin:authenticate`, so every account that can reach the Admin API can read this route. `email`, `date_of_birth`, and the IP fields are returned unredacted whatever the acting credential holds. `acls` reports the ACL set stored on the account. For an Admin API key credential that set can be wider than what the key itself can use.
@@ -595,6 +595,9 @@ Every Admin credential holds `admin:authenticate`, so every account that can rea
| Field | Type | Description |
| --- | --- | --- |
| user | [Admin user](#admin-user-object) object | The account the request authenticated as |
| pending_registration_count<sup>1</sup> | ?integer | Number of [pending registrations](/admin-api/instance/#list-pending-registrations), or null when the credential does not hold `registration:review` |
<sup>1</sup> Read from the same list as [List pending registrations](/admin-api/instance/#list-pending-registrations), without recording an audit entry
### Response
+1
View File
@@ -71,6 +71,7 @@ export const AdminACLs = {
MESSAGE_DELETE: 'message:delete',
MESSAGE_LOOKUP: 'message:lookup',
MESSAGE_SHRED: 'message:shred',
REGISTRATION_REVIEW: 'registration:review',
REPORT_DELETE: 'report:delete',
REPORT_RESOLVE: 'report:resolve',
REPORT_VIEW: 'report:view',
@@ -512,7 +512,7 @@ const RegistrationUrlResponse = z.object({
});
export type RegistrationUrlResponse = z.infer<typeof RegistrationUrlResponse>;
const PendingRegistrationResponse = z.object({
export const PendingRegistrationResponse = z.object({
user_id: SnowflakeStringType,
username: z.string(),
discriminator: z.number().int().min(0).max(9999),
@@ -524,9 +524,13 @@ const PendingRegistrationResponse = z.object({
});
export type PendingRegistrationResponse = z.infer<typeof PendingRegistrationResponse>;
export const PendingRegistrationListResponse = z.object({
pending_registrations: z.array(PendingRegistrationResponse),
});
export type PendingRegistrationListResponse = z.infer<typeof PendingRegistrationListResponse>;
const InstanceRegistrationResponse = InstanceRegistrationConfigResponse.extend({
urls: z.array(RegistrationUrlResponse),
pending_registrations: z.array(PendingRegistrationResponse),
});
const AppPublicConfigResponse = z.object({
@@ -108,11 +108,14 @@ export const ListUserChangeLogResponseSchema = z.object({
export const AdminUsersMeResponse = z.object({
user: UserAdminResponseSchema,
pending_registration_count: Int32Type.nullable(),
});
export type AdminUsersMeResponse = z.infer<typeof AdminUsersMeResponse>;
export const UserMutationResponse = AdminUsersMeResponse;
export const UserMutationResponse = z.object({
user: UserAdminResponseSchema,
});
export type UserMutationResponse = z.infer<typeof UserMutationResponse>;