From abcb5cb81468f7ff31e144fba08e6363afdfb304 Mon Sep 17 00:00:00 2001 From: ArcticWarmth Date: Wed, 14 Oct 2026 06:45:27 -0500 Subject: [PATCH] feat(admin): move pending registrations to their own page (#1388) --- fluxer_admin/openapi-admin.json | 165 +++++++++++++----- fluxer_admin/src/acl.rs | 2 + fluxer_admin/src/api/instance_config.rs | 50 +++--- fluxer_admin/src/api/types/common.rs | 1 + fluxer_admin/src/api/types/instance_config.rs | 23 ++- fluxer_admin/src/middleware/auth.rs | 30 ++-- fluxer_admin/src/routes/mod.rs | 2 + fluxer_admin/src/routes/registrations.rs | 159 +++++++++++++++++ fluxer_admin/src/routes/system_actions.rs | 58 ------ fluxer_admin/src/templates/layout.rs | 2 +- fluxer_admin/src/templates/layout_sidebar.rs | 18 +- .../src/templates/layout_sidebar_nav.rs | 8 + .../src/templates/pages/instance_config.rs | 126 +------------ fluxer_admin/src/templates/pages/mod.rs | 1 + .../templates/pages/pending_registrations.rs | 158 +++++++++++++++++ fluxer_admin/tests/api_deserialization.rs | 3 +- fluxer_admin/tests/htmx_acceptance.rs | 99 +++++++++++ fluxer_api/src/api/admin/AdminAuditActions.ts | 1 + .../InstanceConfigAdminController.ts | 42 +++-- .../admin/controllers/UserAdminController.ts | 16 +- ...eConfigPendingRegistrationApproval.test.ts | 63 ++++++- .../InstanceConfigAdminAuditCases.ts | 26 ++- .../tests/RegistrationSignupRaces.test.ts | 33 ++-- .../api/instance/InstanceConfigRepository.ts | 7 +- fluxer_docs/scripts/VerifyDocsStyle.ts | 2 +- .../src/content/docs/admin-api/index.mdx | 4 +- .../src/content/docs/admin-api/instance.mdx | 49 ++++-- .../src/content/docs/admin-api/users.mdx | 5 +- packages/constants/src/AdminACLs.ts | 1 + .../schema/src/domains/admin/AdminSchemas.ts | 8 +- .../src/domains/admin/AdminUserSchemas.ts | 5 +- 31 files changed, 839 insertions(+), 328 deletions(-) create mode 100644 fluxer_admin/src/routes/registrations.rs create mode 100644 fluxer_admin/src/templates/pages/pending_registrations.rs diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 4535a58e2..5be9ac0e8 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -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"}}, diff --git a/fluxer_admin/src/acl.rs b/fluxer_admin/src/acl.rs index 859c5ec34..d5978868d 100644 --- a/fluxer_admin/src/acl.rs +++ b/fluxer_admin/src/acl.rs @@ -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, diff --git a/fluxer_admin/src/api/instance_config.rs b/fluxer_admin/src/api/instance_config.rs index 78225637c..d1b76b746 100644 --- a/fluxer_admin/src/api/instance_config.rs +++ b/fluxer_admin/src/api/instance_config.rs @@ -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 { - self.decide_pending_registration(user_id, "approved").await + pub async fn list_pending_registrations(&self) -> ApiResult> { + 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 { - self.decide_pending_registration(user_id, "rejected").await - } - - async fn decide_pending_registration( - &self, - user_id: &str, - status: &str, - ) -> ApiResult { - 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> { + 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) } } diff --git a/fluxer_admin/src/api/types/common.rs b/fluxer_admin/src/api/types/common.rs index 38d0b3e8b..b9d2a9495 100644 --- a/fluxer_admin/src/api/types/common.rs +++ b/fluxer_admin/src/api/types/common.rs @@ -32,6 +32,7 @@ pub fn deserialize_string_or_u64<'de, D: Deserializer<'de>>(d: D) -> Result, } #[derive(Clone, Debug, Deserialize)] diff --git a/fluxer_admin/src/api/types/instance_config.rs b/fluxer_admin/src/api/types/instance_config.rs index 4011a642b..d48d0351f 100644 --- a/fluxer_admin/src/api/types/instance_config.rs +++ b/fluxer_admin/src/api/types/instance_config.rs @@ -701,8 +701,6 @@ pub struct InstanceRegistrationResponse { pub admin_registration_urls_enabled: bool, #[serde(default)] pub urls: Vec, - #[serde(default)] - pub pending_registrations: Vec, } 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, } +#[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, +} + #[derive(Clone, Debug, Default, Serialize)] pub struct InstanceConfigUpdateRequest { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/fluxer_admin/src/middleware/auth.rs b/fluxer_admin/src/middleware/auth.rs index 85b33842b..7c0b1e161 100644 --- a/fluxer_admin/src/middleware/auth.rs +++ b/fluxer_admin/src/middleware/auth.rs @@ -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, + pub pending_registration_count: Option, pub branding: Option, } @@ -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 { } enum AdminFetchResult { - Ok(Box), + Ok(Box), Unauthorized, None, } @@ -109,11 +112,8 @@ async fn fetch_admin_user( if !response.status().is_success() { return AdminFetchResult::None; } - match response - .json::() - .await - { - Ok(resp) => AdminFetchResult::Ok(Box::new(resp.user)), + match response.json::().await { + Ok(me) => AdminFetchResult::Ok(Box::new(me)), Err(_) => AdminFetchResult::None, } } diff --git a/fluxer_admin/src/routes/mod.rs b/fluxer_admin/src/routes/mod.rs index bc49a2d78..b9d1802de 100644 --- a/fluxer_admin/src/routes/mod.rs +++ b/fluxer_admin/src/routes/mod.rs @@ -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()) diff --git a/fluxer_admin/src/routes/registrations.rs b/fluxer_admin/src/routes/registrations.rs new file mode 100644 index 000000000..f36be69ce --- /dev/null +++ b/fluxer_admin/src/routes/registrations.rs @@ -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, +} + +pub fn router() -> Router { + 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, + auth: axum::Extension, + csrf: axum::Extension, +) -> 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, + headers: HeaderMap, + auth: axum::Extension, + csrf: axum::Extension, + form: Form, +) -> Response { + decide_pending_registration( + state, + headers, + auth, + csrf, + form, + PendingRegistrationStatus::Approved, + ) + .await +} + +async fn reject_pending_registration( + state: State, + headers: HeaderMap, + auth: axum::Extension, + csrf: axum::Extension, + form: Form, +) -> Response { + decide_pending_registration( + state, + headers, + auth, + csrf, + form, + PendingRegistrationStatus::Rejected, + ) + .await +} + +async fn decide_pending_registration( + State(state): State, + headers: HeaderMap, + auth: axum::Extension, + csrf: axum::Extension, + Form(form): Form, + 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(), + ) +} diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index 46b36a8f2..fd5144d1a 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -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); diff --git a/fluxer_admin/src/templates/layout.rs b/fluxer_admin/src/templates/layout.rs index 12db8a5bd..262b3330a 100644 --- a/fluxer_admin/src/templates/layout.rs +++ b/fluxer_admin/src/templates/layout.rs @@ -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)) diff --git a/fluxer_admin/src/templates/layout_sidebar.rs b/fluxer_admin/src/templates/layout_sidebar.rs index ceb0f3256..c01d27ff5 100644 --- a/fluxer_admin/src/templates/layout_sidebar.rs +++ b/fluxer_admin/src/templates/layout_sidebar.rs @@ -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, ) -> 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, 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) + } + } + } +} diff --git a/fluxer_admin/src/templates/layout_sidebar_nav.rs b/fluxer_admin/src/templates/layout_sidebar_nav.rs index 051728aa3..4fc1005e6 100644 --- a/fluxer_admin/src/templates/layout_sidebar_nav.rs +++ b/fluxer_admin/src/templates/layout_sidebar_nav.rs @@ -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", diff --git a/fluxer_admin/src/templates/pages/instance_config.rs b/fluxer_admin/src/templates/pages/instance_config.rs index cedaf1fc5..ea43664a4 100644 --- a/fluxer_admin/src/templates/pages/instance_config.rs +++ b/fluxer_admin/src/templates/pages/instance_config.rs @@ -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 { diff --git a/fluxer_admin/src/templates/pages/mod.rs b/fluxer_admin/src/templates/pages/mod.rs index bcddb6787..9b203c748 100644 --- a/fluxer_admin/src/templates/pages/mod.rs +++ b/fluxer_admin/src/templates/pages/mod.rs @@ -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; diff --git a/fluxer_admin/src/templates/pages/pending_registrations.rs b/fluxer_admin/src/templates/pages/pending_registrations.rs new file mode 100644 index 000000000..8c0e9df59 --- /dev/null +++ b/fluxer_admin/src/templates/pages/pending_registrations.rs @@ -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) + } +} diff --git a/fluxer_admin/tests/api_deserialization.rs b/fluxer_admin/tests/api_deserialization.rs index 646b3f336..9b104fe52 100644 --- a/fluxer_admin/tests/api_deserialization.rs +++ b/fluxer_admin/tests/api_deserialization.rs @@ -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"}, diff --git a/fluxer_admin/tests/htmx_acceptance.rs b/fluxer_admin/tests/htmx_acceptance.rs index eb963cb53..54492a7ea 100644 --- a/fluxer_admin/tests/htmx_acceptance.rs +++ b/fluxer_admin/tests/htmx_acceptance.rs @@ -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, Json(body): Json| { + 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#"2"#), + "{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"#), + "{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": "applicant@example.com", + "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", diff --git a/fluxer_api/src/api/admin/AdminAuditActions.ts b/fluxer_api/src/api/admin/AdminAuditActions.ts index 84ad71890..f9b083837 100644 --- a/fluxer_api/src/api/admin/AdminAuditActions.ts +++ b/fluxer_api/src/api/admin/AdminAuditActions.ts @@ -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', diff --git a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts index d457ff0ba..efb48fc9a 100644 --- a/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/InstanceConfigAdminController.ts @@ -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 { experimentDelivery, registrationConfig, registrationUrls, - pendingRegistrations, ] = await Promise.all([ instanceConfigRepository.getSsoConfig(), instanceConfigRepository.getGatewayRolloutConfig(), @@ -109,7 +109,6 @@ async function buildInstanceConfigResponse(): Promise { 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 { 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()}); }, ); } diff --git a/fluxer_api/src/api/admin/controllers/UserAdminController.ts b/fluxer_api/src/api/admin/controllers/UserAdminController.ts index ef70ca12e..79fc65c3a 100644 --- a/fluxer_api/src/api/admin/controllers/UserAdminController.ts +++ b/fluxer_api/src/api/admin/controllers/UserAdminController.ts @@ -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( diff --git a/fluxer_api/src/api/admin/tests/InstanceConfigPendingRegistrationApproval.test.ts b/fluxer_api/src/api/admin/tests/InstanceConfigPendingRegistrationApproval.test.ts index 8e71d98d0..4350275bc 100644 --- a/fluxer_api/src/api/admin/tests/InstanceConfigPendingRegistrationApproval.test.ts +++ b/fluxer_api/src/api/admin/tests/InstanceConfigPendingRegistrationApproval.test.ts @@ -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(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(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(harness, reviewer.token) + .get('/admin/users/@me') + .execute(); + expect(reviewerMe.pending_registration_count).toBe(1); + const configAdminMe = await createBuilder(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(harness, reviewer.token) + .patch(`/admin/instance/pending-registrations/${pending.user_id}`) + .body({status: 'rejected'}) + .expect(HTTP_STATUS.OK) + .execute(); + expect(decided.pending_registrations).toEqual([]); + }); }); diff --git a/fluxer_api/src/api/admin/tests/audit_coverage/InstanceConfigAdminAuditCases.ts b/fluxer_api/src/api/admin/tests/audit_coverage/InstanceConfigAdminAuditCases.ts index bbb4bda4a..dd63b45f1 100644 --- a/fluxer_api/src/api/admin/tests/audit_coverage/InstanceConfigAdminAuditCases.ts +++ b/fluxer_api/src/api/admin/tests/audit_coverage/InstanceConfigAdminAuditCases.ts @@ -35,7 +35,7 @@ export const InstanceConfigAdminAuditCases: ReadonlyArray { 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 => createBuilder(harness, admin.token).get('/admin/instance/config').execute(); + const readPendingRegistrations = async (): Promise => + ( + await createBuilder(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(harness, admin.token) + const decided = await createBuilder(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', diff --git a/fluxer_api/src/api/instance/InstanceConfigRepository.ts b/fluxer_api/src/api/instance/InstanceConfigRepository.ts index 6e349d21d..48f230ab5 100644 --- a/fluxer_api/src/api/instance/InstanceConfigRepository.ts +++ b/fluxer_api/src/api/instance/InstanceConfigRepository.ts @@ -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; -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, diff --git a/fluxer_docs/scripts/VerifyDocsStyle.ts b/fluxer_docs/scripts/VerifyDocsStyle.ts index 6b0b7a46d..a2b2460d9 100644 --- a/fluxer_docs/scripts/VerifyDocsStyle.ts +++ b/fluxer_docs/scripts/VerifyDocsStyle.ts @@ -274,7 +274,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map5 | Updates a guild name | | guild:update:settings5 | Updates general guild settings and clears guild fields | | guild:update:vanity5 | 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 | diff --git a/fluxer_docs/src/content/docs/admin-api/instance.mdx b/fluxer_docs/src/content/docs/admin-api/instance.mdx index 16edf47db..409e28028 100644 --- a/fluxer_docs/src/content/docs/admin-api/instance.mdx +++ b/fluxer_docs/src/content/docs/admin-api/instance.mdx @@ -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_enabled1 | boolean | Whether Admin-issued registration URLs are accepted | | urls2 | array[[registration URL](#registration-url-object) object] | Every issued registration URL, including revoked and exhausted records | -| pending_registrations3 | array[[pending registration](#pending-registration-object) object] | Accounts awaiting a decision | 1 While the value is false, registration refuses every supplied registration URL code, so an issued URL stops working without being revoked 2 Ordered by creation time, newest first -3 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. 1 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_registrations1 | array[[pending registration](#pending-registration-object) object] | Accounts awaiting a decision | + +1 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 + + + +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 -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 | | --- | --- | --- | -| 2001 | [instance configuration](#instance-configuration-object) object | The decision was applied | +| 2001 | [pending registration list](#pending-registration-list-object) object | The decision was applied | 1 An account that no longer exists still answers 200. The pending registration is removed and no trait is written diff --git a/fluxer_docs/src/content/docs/admin-api/users.mdx b/fluxer_docs/src/content/docs/admin-api/users.mdx index 57286bc3c..26a7a5bb9 100644 --- a/fluxer_docs/src/content/docs/admin-api/users.mdx +++ b/fluxer_docs/src/content/docs/admin-api/users.mdx @@ -586,7 +586,7 @@ The `user_id` selector adds `user_id_count` for the number of IDs supplied, and -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_count1 | ?integer | Number of [pending registrations](/admin-api/instance/#list-pending-registrations), or null when the credential does not hold `registration:review` | + +1 Read from the same list as [List pending registrations](/admin-api/instance/#list-pending-registrations), without recording an audit entry ### Response diff --git a/packages/constants/src/AdminACLs.ts b/packages/constants/src/AdminACLs.ts index 54ec6d1cd..10b1faab8 100644 --- a/packages/constants/src/AdminACLs.ts +++ b/packages/constants/src/AdminACLs.ts @@ -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', diff --git a/packages/schema/src/domains/admin/AdminSchemas.ts b/packages/schema/src/domains/admin/AdminSchemas.ts index 1f9607a6a..ee53cc3a7 100644 --- a/packages/schema/src/domains/admin/AdminSchemas.ts +++ b/packages/schema/src/domains/admin/AdminSchemas.ts @@ -512,7 +512,7 @@ const RegistrationUrlResponse = z.object({ }); export type RegistrationUrlResponse = z.infer; -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; +export const PendingRegistrationListResponse = z.object({ + pending_registrations: z.array(PendingRegistrationResponse), +}); +export type PendingRegistrationListResponse = z.infer; + const InstanceRegistrationResponse = InstanceRegistrationConfigResponse.extend({ urls: z.array(RegistrationUrlResponse), - pending_registrations: z.array(PendingRegistrationResponse), }); const AppPublicConfigResponse = z.object({ diff --git a/packages/schema/src/domains/admin/AdminUserSchemas.ts b/packages/schema/src/domains/admin/AdminUserSchemas.ts index fc82de9aa..4ab09944f 100644 --- a/packages/schema/src/domains/admin/AdminUserSchemas.ts +++ b/packages/schema/src/domains/admin/AdminUserSchemas.ts @@ -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; -export const UserMutationResponse = AdminUsersMeResponse; +export const UserMutationResponse = z.object({ + user: UserAdminResponseSchema, +}); export type UserMutationResponse = z.infer;