mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
feat(admin): move pending registrations to their own page (#1388)
This commit is contained in:
1 parent
97b2ac91ca
commit
abcb5cb814
31 files changed
+839
-328
No files matched your search
Generated
+117
-48
@@ -4152,15 +4152,17 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"/admin/instance/pending-registrations/{user_id}": {
|
"/admin/instance/pending-registrations": {
|
||||||
"patch": {
|
"get": {
|
||||||
"operationId": "update_admin_pending_registration",
|
"operationId": "list_admin_pending_registrations",
|
||||||
"summary": "Approve or reject a pending registration",
|
"summary": "List pending registrations",
|
||||||
"tags": ["Admin"],
|
"tags": ["Admin"],
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "Success",
|
"description": "Success",
|
||||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/InstanceConfigResponse"}}}
|
"content": {
|
||||||
|
"application/json": {"schema": {"$ref": "#/components/schemas/PendingRegistrationListResponse"}}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "Bad Request - The request was malformed or contained invalid data",
|
"description": "Bad Request - The request was malformed or contained invalid data",
|
||||||
@@ -4201,7 +4203,62 @@
|
|||||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
"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": []}],
|
"security": [{"adminApiKey": []}],
|
||||||
"parameters": [
|
"parameters": [
|
||||||
{
|
{
|
||||||
@@ -5887,7 +5944,7 @@
|
|||||||
"content": {"application/json": {"schema": {"$ref": "#/components/schemas/Error"}}}
|
"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": []}]
|
"security": [{"adminApiKey": []}]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -10281,7 +10338,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"acls": {
|
"acls": {
|
||||||
"maxItems": 106,
|
"maxItems": 107,
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
||||||
"description": "List of access control permissions to assign"
|
"description": "List of access control permissions to assign"
|
||||||
@@ -10297,8 +10354,11 @@
|
|||||||
},
|
},
|
||||||
"AdminUsersMeResponse": {
|
"AdminUsersMeResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {"user": {"$ref": "#/components/schemas/UserAdminResponseSchema"}},
|
"properties": {
|
||||||
"required": ["user"],
|
"user": {"$ref": "#/components/schemas/UserAdminResponseSchema"},
|
||||||
|
"pending_registration_count": {"nullable": true, "allOf": [{"$ref": "#/components/schemas/Int32Type"}]}
|
||||||
|
},
|
||||||
|
"required": ["user", "pending_registration_count"],
|
||||||
"additionalProperties": false
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"SnowflakeStringType": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "format": "snowflake"},
|
"SnowflakeStringType": {"type": "string", "pattern": "^(0|[1-9][0-9]*)$", "format": "snowflake"},
|
||||||
@@ -11331,40 +11391,9 @@
|
|||||||
],
|
],
|
||||||
"additionalProperties": false
|
"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
|
"additionalProperties": false
|
||||||
},
|
},
|
||||||
"self_hosted": {"type": "boolean"},
|
"self_hosted": {"type": "boolean"},
|
||||||
@@ -11811,6 +11840,17 @@
|
|||||||
},
|
},
|
||||||
"required": ["status"]
|
"required": ["status"]
|
||||||
},
|
},
|
||||||
|
"PendingRegistrationListResponse": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"pending_registrations": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {"$ref": "#/components/schemas/PendingRegistrationResponse"}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": ["pending_registrations"],
|
||||||
|
"additionalProperties": false
|
||||||
|
},
|
||||||
"InstanceEmailSmtpTestRequest": {
|
"InstanceEmailSmtpTestRequest": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -13515,7 +13555,7 @@
|
|||||||
},
|
},
|
||||||
"acls": {
|
"acls": {
|
||||||
"description": "Replacement list of access control permissions for the key",
|
"description": "Replacement list of access control permissions for the key",
|
||||||
"maxItems": 106,
|
"maxItems": 107,
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"$ref": "#/components/schemas/AdminAclType"}
|
"items": {"$ref": "#/components/schemas/AdminAclType"}
|
||||||
}
|
}
|
||||||
@@ -13533,7 +13573,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"acls": {
|
"acls": {
|
||||||
"maxItems": 106,
|
"maxItems": 107,
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"type": "string"},
|
"items": {"type": "string"},
|
||||||
"description": "List of access control permissions for the key"
|
"description": "List of access control permissions for the key"
|
||||||
@@ -13563,7 +13603,7 @@
|
|||||||
"maximum": 365
|
"maximum": 365
|
||||||
},
|
},
|
||||||
"acls": {
|
"acls": {
|
||||||
"maxItems": 106,
|
"maxItems": 107,
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
"items": {"$ref": "#/components/schemas/AdminAclType"},
|
||||||
"description": "List of access control permissions for the key"
|
"description": "List of access control permissions for the key"
|
||||||
@@ -13584,7 +13624,7 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"acls": {
|
"acls": {
|
||||||
"maxItems": 106,
|
"maxItems": 107,
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"type": "string"},
|
"items": {"type": "string"},
|
||||||
"description": "List of access control permissions for the key"
|
"description": "List of access control permissions for the key"
|
||||||
@@ -13597,7 +13637,7 @@
|
|||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"acls": {
|
"acls": {
|
||||||
"maxItems": 106,
|
"maxItems": 107,
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {"type": "string", "minLength": 1, "maxLength": 64},
|
"items": {"type": "string", "minLength": 1, "maxLength": 64},
|
||||||
"description": "Every admin access control permission the admin API recognises"
|
"description": "Every admin access control permission the admin API recognises"
|
||||||
@@ -13679,6 +13719,7 @@
|
|||||||
"message:delete",
|
"message:delete",
|
||||||
"message:lookup",
|
"message:lookup",
|
||||||
"message:shred",
|
"message:shred",
|
||||||
|
"registration:review",
|
||||||
"report:delete",
|
"report:delete",
|
||||||
"report:resolve",
|
"report:resolve",
|
||||||
"report:view",
|
"report:view",
|
||||||
@@ -16471,6 +16512,34 @@
|
|||||||
},
|
},
|
||||||
"PremiumCurrency": {"type": "string", "pattern": "^[A-Z]{3}$"},
|
"PremiumCurrency": {"type": "string", "pattern": "^[A-Z]{3}$"},
|
||||||
"StripePriceIdSchema": {"type": "string", "maxLength": 255, "pattern": "^price_[A-Za-z0-9]+$"},
|
"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": {
|
"InstanceBillingResponse": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -16839,7 +16908,7 @@
|
|||||||
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
|
"description": "ISO 8601 timestamp when the pending deletion was scheduled",
|
||||||
"type": "string"
|
"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"}},
|
"traits": {"maxItems": 100, "type": "array", "items": {"type": "string"}},
|
||||||
"has_totp": {"type": "boolean"},
|
"has_totp": {"type": "boolean"},
|
||||||
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
|
"authenticator_types": {"maxItems": 10, "type": "array", "items": {"$ref": "#/components/schemas/Int32Type"}},
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ pub const MESSAGE_DELETE_ALL: &str = "message:delete_all";
|
|||||||
pub const MESSAGE_DELETE: &str = "message:delete";
|
pub const MESSAGE_DELETE: &str = "message:delete";
|
||||||
pub const MESSAGE_LOOKUP: &str = "message:lookup";
|
pub const MESSAGE_LOOKUP: &str = "message:lookup";
|
||||||
pub const MESSAGE_SHRED: &str = "message:shred";
|
pub const MESSAGE_SHRED: &str = "message:shred";
|
||||||
|
pub const REGISTRATION_REVIEW: &str = "registration:review";
|
||||||
pub const REPORT_DELETE: &str = "report:delete";
|
pub const REPORT_DELETE: &str = "report:delete";
|
||||||
pub const REPORT_RESOLVE: &str = "report:resolve";
|
pub const REPORT_RESOLVE: &str = "report:resolve";
|
||||||
pub const REPORT_VIEW: &str = "report:view";
|
pub const REPORT_VIEW: &str = "report:view";
|
||||||
@@ -177,6 +178,7 @@ pub const ALL_ACLS: &[&str] = &[
|
|||||||
MESSAGE_DELETE,
|
MESSAGE_DELETE,
|
||||||
MESSAGE_LOOKUP,
|
MESSAGE_LOOKUP,
|
||||||
MESSAGE_SHRED,
|
MESSAGE_SHRED,
|
||||||
|
REGISTRATION_REVIEW,
|
||||||
REPORT_DELETE,
|
REPORT_DELETE,
|
||||||
REPORT_RESOLVE,
|
REPORT_RESOLVE,
|
||||||
REPORT_VIEW,
|
REPORT_VIEW,
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ use super::client::{AdminApiClient, ApiResult};
|
|||||||
use super::types::{
|
use super::types::{
|
||||||
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
|
AccountIdentitySettings, CreateRegistrationUrlRequest, CreateRegistrationUrlResponse,
|
||||||
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
|
InstanceAccountIdentityDiscovery, InstanceConfigResponse, InstanceConfigUpdateRequest,
|
||||||
InstanceEmailSmtpTestResponse, InstancePremiumDiscovery,
|
InstanceEmailSmtpTestResponse, InstancePremiumDiscovery, PendingRegistrationListResponse,
|
||||||
|
PendingRegistrationResponse, PendingRegistrationStatus,
|
||||||
};
|
};
|
||||||
|
|
||||||
impl AdminApiClient {
|
impl AdminApiClient {
|
||||||
@@ -74,35 +75,30 @@ impl AdminApiClient {
|
|||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn approve_pending_registration(
|
pub async fn list_pending_registrations(&self) -> ApiResult<Vec<PendingRegistrationResponse>> {
|
||||||
&self,
|
let response: PendingRegistrationListResponse = self
|
||||||
user_id: &str,
|
.get("/admin/instance/pending-registrations", None)
|
||||||
) -> ApiResult<InstanceConfigResponse> {
|
.await?;
|
||||||
self.decide_pending_registration(user_id, "approved").await
|
Ok(response.pending_registrations)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn reject_pending_registration(
|
pub async fn decide_pending_registration(
|
||||||
&self,
|
&self,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
) -> ApiResult<InstanceConfigResponse> {
|
status: PendingRegistrationStatus,
|
||||||
self.decide_pending_registration(user_id, "rejected").await
|
) -> ApiResult<Vec<PendingRegistrationResponse>> {
|
||||||
}
|
let body = serde_json::json!({"status": status.as_str()});
|
||||||
|
let response: PendingRegistrationListResponse = self
|
||||||
async fn decide_pending_registration(
|
.send(
|
||||||
&self,
|
Method::PATCH,
|
||||||
user_id: &str,
|
&format!(
|
||||||
status: &str,
|
"/admin/instance/pending-registrations/{}",
|
||||||
) -> ApiResult<InstanceConfigResponse> {
|
urlencoding::encode(user_id)
|
||||||
let body = serde_json::json!({"status": status});
|
),
|
||||||
self.send(
|
Some(&body),
|
||||||
Method::PATCH,
|
None,
|
||||||
&format!(
|
)
|
||||||
"/admin/instance/pending-registrations/{}",
|
.await?;
|
||||||
urlencoding::encode(user_id)
|
Ok(response.pending_registrations)
|
||||||
),
|
|
||||||
Some(&body),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
.await
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ pub fn deserialize_string_or_u64<'de, D: Deserializer<'de>>(d: D) -> Result<u64,
|
|||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
pub struct AdminUserMeResponse {
|
pub struct AdminUserMeResponse {
|
||||||
pub user: AdminUser,
|
pub user: AdminUser,
|
||||||
|
pub pending_registration_count: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Deserialize)]
|
#[derive(Clone, Debug, Deserialize)]
|
||||||
|
|||||||
@@ -701,8 +701,6 @@ pub struct InstanceRegistrationResponse {
|
|||||||
pub admin_registration_urls_enabled: bool,
|
pub admin_registration_urls_enabled: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub urls: Vec<RegistrationUrlResponse>,
|
pub urls: Vec<RegistrationUrlResponse>,
|
||||||
#[serde(default)]
|
|
||||||
pub pending_registrations: Vec<PendingRegistrationResponse>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for InstanceRegistrationResponse {
|
impl Default for InstanceRegistrationResponse {
|
||||||
@@ -711,7 +709,6 @@ impl Default for InstanceRegistrationResponse {
|
|||||||
mode: RegistrationMode::Open,
|
mode: RegistrationMode::Open,
|
||||||
admin_registration_urls_enabled: true,
|
admin_registration_urls_enabled: true,
|
||||||
urls: Vec::new(),
|
urls: Vec::new(),
|
||||||
pending_registrations: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -767,6 +764,26 @@ pub struct PendingRegistrationResponse {
|
|||||||
pub client_ip: Option<String>,
|
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)]
|
#[derive(Clone, Debug, Default, Serialize)]
|
||||||
pub struct InstanceConfigUpdateRequest {
|
pub struct InstanceConfigUpdateRequest {
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
api::types::{AdminUser, PremiumBranding},
|
api::types::{AdminUser, AdminUserMeResponse, PremiumBranding},
|
||||||
middleware::flash,
|
middleware::flash,
|
||||||
session::{self, Session},
|
session::{self, Session},
|
||||||
state::AppState,
|
state::AppState,
|
||||||
@@ -16,6 +16,7 @@ use axum::{
|
|||||||
pub struct AuthContext {
|
pub struct AuthContext {
|
||||||
pub session: Session,
|
pub session: Session,
|
||||||
pub admin_user: Option<AdminUser>,
|
pub admin_user: Option<AdminUser>,
|
||||||
|
pub pending_registration_count: Option<u32>,
|
||||||
pub branding: Option<PremiumBranding>,
|
pub branding: Option<PremiumBranding>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,17 +30,19 @@ pub async fn require_auth(
|
|||||||
let Some(session) = session else {
|
let Some(session) = session else {
|
||||||
return Redirect::to(&format!("{}/auth/start", config.base_path)).into_response();
|
return Redirect::to(&format!("{}/auth/start", config.base_path)).into_response();
|
||||||
};
|
};
|
||||||
let admin_user = match fetch_admin_user(state.http_client(), config, &session).await {
|
let (admin_user, pending_registration_count) =
|
||||||
AdminFetchResult::Unauthorized => {
|
match fetch_admin_user(state.http_client(), config, &session).await {
|
||||||
let login_url = format!("{}/login", config.base_path);
|
AdminFetchResult::Unauthorized => {
|
||||||
return clear_session_and_redirect(&login_url, config);
|
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,
|
AdminFetchResult::Ok(me) => (Some(me.user), me.pending_registration_count),
|
||||||
};
|
AdminFetchResult::None => (None, None),
|
||||||
|
};
|
||||||
request.extensions_mut().insert(AuthContext {
|
request.extensions_mut().insert(AuthContext {
|
||||||
session,
|
session,
|
||||||
admin_user,
|
admin_user,
|
||||||
|
pending_registration_count,
|
||||||
branding: state.self_hosted_branding().await,
|
branding: state.self_hosted_branding().await,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -83,7 +86,7 @@ fn extract_session(request: &Request, secret_key: &str) -> Option<Session> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum AdminFetchResult {
|
enum AdminFetchResult {
|
||||||
Ok(Box<AdminUser>),
|
Ok(Box<AdminUserMeResponse>),
|
||||||
Unauthorized,
|
Unauthorized,
|
||||||
None,
|
None,
|
||||||
}
|
}
|
||||||
@@ -109,11 +112,8 @@ async fn fetch_admin_user(
|
|||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return AdminFetchResult::None;
|
return AdminFetchResult::None;
|
||||||
}
|
}
|
||||||
match response
|
match response.json::<AdminUserMeResponse>().await {
|
||||||
.json::<crate::api::types::AdminUserMeResponse>()
|
Ok(me) => AdminFetchResult::Ok(Box::new(me)),
|
||||||
.await
|
|
||||||
{
|
|
||||||
Ok(resp) => AdminFetchResult::Ok(Box::new(resp.user)),
|
|
||||||
Err(_) => AdminFetchResult::None,
|
Err(_) => AdminFetchResult::None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,7 @@ pub mod guilds;
|
|||||||
pub mod jobs;
|
pub mod jobs;
|
||||||
mod message_actions;
|
mod message_actions;
|
||||||
pub mod messages;
|
pub mod messages;
|
||||||
|
pub mod registrations;
|
||||||
pub mod reports;
|
pub mod reports;
|
||||||
pub mod system;
|
pub mod system;
|
||||||
mod system_actions;
|
mod system_actions;
|
||||||
@@ -63,6 +64,7 @@ pub fn build_router(config: AdminConfig) -> Router {
|
|||||||
.merge(applications::router())
|
.merge(applications::router())
|
||||||
.merge(codes::router())
|
.merge(codes::router())
|
||||||
.merge(discovery::router())
|
.merge(discovery::router())
|
||||||
|
.merge(registrations::router())
|
||||||
.merge(jobs::router())
|
.merge(jobs::router())
|
||||||
.merge(messages::router())
|
.merge(messages::router())
|
||||||
.merge(system::router())
|
.merge(system::router())
|
||||||
|
|||||||
@@ -0,0 +1,159 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::{client::AdminApiClient, types::PendingRegistrationStatus},
|
||||||
|
middleware::{
|
||||||
|
auth::AuthContext,
|
||||||
|
csrf::CsrfToken,
|
||||||
|
flash::{self, FlashData},
|
||||||
|
htmx,
|
||||||
|
},
|
||||||
|
state::AppState,
|
||||||
|
templates::{self, layout_sidebar::pending_registration_count_markup},
|
||||||
|
utils::forms::clean_string,
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
Form, Router,
|
||||||
|
extract::State,
|
||||||
|
http::HeaderMap,
|
||||||
|
response::{Html, IntoResponse, Response},
|
||||||
|
routing::{get, post},
|
||||||
|
};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PendingRegistrationForm {
|
||||||
|
#[serde(default)]
|
||||||
|
user_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/pending-registrations", get(pending_registrations_page))
|
||||||
|
.route(
|
||||||
|
"/pending-registrations/approve",
|
||||||
|
post(approve_pending_registration),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/pending-registrations/reject",
|
||||||
|
post(reject_pending_registration),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn pending_registrations_page(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth: axum::Extension<AuthContext>,
|
||||||
|
csrf: axum::Extension<CsrfToken>,
|
||||||
|
) -> Response {
|
||||||
|
let config = state.config();
|
||||||
|
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||||
|
let pending_registrations = match client.list_pending_registrations().await {
|
||||||
|
Ok(pending_registrations) => Some(pending_registrations),
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, "admin API request failed: list pending registrations");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let markup = templates::pages::pending_registrations::pending_registrations_page(
|
||||||
|
config,
|
||||||
|
&auth.0,
|
||||||
|
&csrf.0.0,
|
||||||
|
pending_registrations.as_deref(),
|
||||||
|
);
|
||||||
|
Html(markup.into_string()).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn approve_pending_registration(
|
||||||
|
state: State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
auth: axum::Extension<AuthContext>,
|
||||||
|
csrf: axum::Extension<CsrfToken>,
|
||||||
|
form: Form<PendingRegistrationForm>,
|
||||||
|
) -> Response {
|
||||||
|
decide_pending_registration(
|
||||||
|
state,
|
||||||
|
headers,
|
||||||
|
auth,
|
||||||
|
csrf,
|
||||||
|
form,
|
||||||
|
PendingRegistrationStatus::Approved,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn reject_pending_registration(
|
||||||
|
state: State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
auth: axum::Extension<AuthContext>,
|
||||||
|
csrf: axum::Extension<CsrfToken>,
|
||||||
|
form: Form<PendingRegistrationForm>,
|
||||||
|
) -> Response {
|
||||||
|
decide_pending_registration(
|
||||||
|
state,
|
||||||
|
headers,
|
||||||
|
auth,
|
||||||
|
csrf,
|
||||||
|
form,
|
||||||
|
PendingRegistrationStatus::Rejected,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn decide_pending_registration(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
auth: axum::Extension<AuthContext>,
|
||||||
|
csrf: axum::Extension<CsrfToken>,
|
||||||
|
Form(form): Form<PendingRegistrationForm>,
|
||||||
|
status: PendingRegistrationStatus,
|
||||||
|
) -> Response {
|
||||||
|
let config = state.config();
|
||||||
|
let (success_message, error_message) = match status {
|
||||||
|
PendingRegistrationStatus::Approved => {
|
||||||
|
("Registration approved", "Failed to approve registration")
|
||||||
|
}
|
||||||
|
PendingRegistrationStatus::Rejected => {
|
||||||
|
("Registration rejected", "Failed to reject registration")
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let flash = match clean_string(form.user_id.as_deref().unwrap_or("")) {
|
||||||
|
None => FlashData::error("User ID is required"),
|
||||||
|
Some(user_id) => {
|
||||||
|
let client = AdminApiClient::new(state.http_client(), config, &auth.0.session);
|
||||||
|
match client.decide_pending_registration(&user_id, status).await {
|
||||||
|
Ok(pending_registrations) => {
|
||||||
|
let flash = FlashData::success(success_message);
|
||||||
|
if htmx::targets(&headers, "pending-registration-list") {
|
||||||
|
let markup = maud::html! {
|
||||||
|
(templates::pages::pending_registrations::pending_registration_list(
|
||||||
|
config,
|
||||||
|
&csrf.0.0,
|
||||||
|
&pending_registrations,
|
||||||
|
))
|
||||||
|
(pending_registration_count_markup(
|
||||||
|
u32::try_from(pending_registrations.len()).ok(),
|
||||||
|
true,
|
||||||
|
))
|
||||||
|
};
|
||||||
|
let mut response = Html(markup.into_string()).into_response();
|
||||||
|
htmx::add_toast_header(&mut response, &flash);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
flash
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
tracing::warn!(%error, ?status, "admin API request failed: decide pending registration");
|
||||||
|
FlashData::error(error_message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if htmx::is_htmx_request(&headers) {
|
||||||
|
return htmx::toast_response(&flash);
|
||||||
|
}
|
||||||
|
flash::redirect_with_flash(
|
||||||
|
&format!("{}/pending-registrations", config.base_path),
|
||||||
|
flash,
|
||||||
|
config.secure_cookies(),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -304,48 +304,6 @@ pub async fn instance_config_post(
|
|||||||
},
|
},
|
||||||
None => FlashData::error("Registration URL ID is required"),
|
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"),
|
_ => FlashData::error("Unknown instance config action"),
|
||||||
};
|
};
|
||||||
if htmx::is_htmx_request(&headers) {
|
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 {
|
fn render_fragment_with_toast(markup: Markup, flash: &FlashData) -> Response {
|
||||||
let mut response = Html(markup.into_string()).into_response();
|
let mut response = Html(markup.into_string()).into_response();
|
||||||
htmx::add_toast_header(&mut response, flash);
|
htmx::add_toast_header(&mut response, flash);
|
||||||
|
|||||||
@@ -104,7 +104,7 @@ pub fn admin_layout_ext(
|
|||||||
body class="min-h-screen overflow-hidden bg-neutral-50" hx-boost="true" {
|
body class="min-h-screen overflow-hidden bg-neutral-50" hx-boost="true" {
|
||||||
a href="#main-content" class="skip-link" { "Skip to main content" }
|
a href="#main-content" class="skip-link" { "Skip to main content" }
|
||||||
div class="flex h-[100dvh]" {
|
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 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" {
|
div class="flex h-[100dvh] w-full flex-1 flex-col overflow-y-auto" {
|
||||||
(render_header(config, auth, csrf_token))
|
(render_header(config, auth, csrf_token))
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ use crate::{acl, config::AdminConfig};
|
|||||||
use maud::{Markup, PreEscaped, html};
|
use maud::{Markup, PreEscaped, html};
|
||||||
|
|
||||||
use super::layout_scripts::SIDEBAR_ACTIVE_SCROLL_SCRIPT;
|
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(
|
pub fn render_sidebar(
|
||||||
config: &AdminConfig,
|
config: &AdminConfig,
|
||||||
admin_acls: &[String],
|
admin_acls: &[String],
|
||||||
active_page: &str,
|
active_page: &str,
|
||||||
inspected_voice_region_id: Option<&str>,
|
inspected_voice_region_id: Option<&str>,
|
||||||
|
pending_registration_count: Option<u32>,
|
||||||
) -> Markup {
|
) -> Markup {
|
||||||
let base = &config.base_path;
|
let base = &config.base_path;
|
||||||
html! {
|
html! {
|
||||||
@@ -40,7 +41,7 @@ pub fn render_sidebar(
|
|||||||
div class="space-y-1" {
|
div class="space-y-1" {
|
||||||
@for item in &visible_items {
|
@for item in &visible_items {
|
||||||
@let active = active_page == item.active_key;
|
@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" {
|
@let item_path = if item.active_key == "voice-servers" {
|
||||||
if let Some(region_id) = inspected_voice_region_id {
|
if let Some(region_id) = inspected_voice_region_id {
|
||||||
let encoded = urlencoding::encode(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("")] {
|
a href={(base) (item_path)} class=(classes) aria-current=[active.then_some("page")] data-active=[active.then_some("")] {
|
||||||
(item.title)
|
(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 required_acls: &'static [&'static str],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub const PENDING_REGISTRATIONS_KEY: &str = "pending-registrations";
|
||||||
|
|
||||||
pub struct NavSection {
|
pub struct NavSection {
|
||||||
pub title: &'static str,
|
pub title: &'static str,
|
||||||
pub items: &'static [NavItem],
|
pub items: &'static [NavItem],
|
||||||
@@ -44,6 +46,12 @@ pub const NAV_SECTIONS: &[NavSection] = &[
|
|||||||
"discovery",
|
"discovery",
|
||||||
[acl::DISCOVERY_REVIEW]
|
[acl::DISCOVERY_REVIEW]
|
||||||
),
|
),
|
||||||
|
item!(
|
||||||
|
"Pending Registrations",
|
||||||
|
"/pending-registrations",
|
||||||
|
PENDING_REGISTRATIONS_KEY,
|
||||||
|
[acl::REGISTRATION_REVIEW]
|
||||||
|
),
|
||||||
item!(
|
item!(
|
||||||
"Bulk Actions",
|
"Bulk Actions",
|
||||||
"/bulk-actions",
|
"/bulk-actions",
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ use crate::{
|
|||||||
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS,
|
EXPERIMENT_MAX_ROLLOUT_COUNTRY_CODES, EXPERIMENT_MAX_TARGETED_USERS,
|
||||||
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
ExperimentDeliveryConfigResponse, GatewayRolloutConfigResponse, InstanceConfigResponse,
|
||||||
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
InstanceIntegrationsResponse, InstanceMediaResponse, InstancePolicyResponse,
|
||||||
InstanceRegistrationResponse, LimitConfigResponse, PendingRegistrationResponse,
|
InstanceRegistrationResponse, LimitConfigResponse, PremiumMode, PushRelayConfigResponse,
|
||||||
PremiumMode, PushRelayConfigResponse, RegistrationUrlResponse, SsoConfigResponse, TagStyle,
|
RegistrationUrlResponse, SsoConfigResponse, TagStyle, VOICE_P2P_DEFAULT_SALT,
|
||||||
VOICE_P2P_DEFAULT_SALT, VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
VOICE_P2P_MAX_PARTICIPANTS_RANGE, VoiceP2pConfigResponse,
|
||||||
},
|
},
|
||||||
config::AdminConfig,
|
config::AdminConfig,
|
||||||
middleware::auth::AuthContext,
|
middleware::auth::AuthContext,
|
||||||
@@ -1640,7 +1640,7 @@ fn registration_config_section(
|
|||||||
let base = &config.base_path;
|
let base = &config.base_path;
|
||||||
section_card_with_description(
|
section_card_with_description(
|
||||||
"Registration Controls",
|
"Registration Controls",
|
||||||
"Control public registration, admin-issued registration URLs, and pending approval requests.",
|
"Control public registration and admin-issued registration URLs.",
|
||||||
html! {
|
html! {
|
||||||
div class="space-y-8" {
|
div class="space-y-8" {
|
||||||
(registration_policy_form(base, csrf_token, registration))
|
(registration_policy_form(base, csrf_token, registration))
|
||||||
@@ -1649,7 +1649,6 @@ fn registration_config_section(
|
|||||||
}
|
}
|
||||||
(create_registration_url_form(base, csrf_token))
|
(create_registration_url_form(base, csrf_token))
|
||||||
(registration_url_list(config, csrf_token, ®istration.urls))
|
(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(
|
pub(super) fn admin_issued_registration_url(config: &AdminConfig, code: &str) -> String {
|
||||||
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 {
|
|
||||||
format!(
|
format!(
|
||||||
"{}/register?registration_url={}",
|
"{}/register?registration_url={}",
|
||||||
config.web_app_endpoint.trim_end_matches('/'),
|
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! {
|
html! {
|
||||||
button type="button"
|
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"
|
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 {
|
let variant_class = if danger {
|
||||||
"bg-red-600 text-white hover:bg-red-700 focus:ring-red-500/30"
|
"bg-red-600 text-white hover:bg-red-700 focus:ring-red-500/30"
|
||||||
} else {
|
} 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 {
|
fn sso_config_section(base: &str, csrf_token: &str, sso: &SsoConfigResponse) -> Markup {
|
||||||
let allowed_domains = sso.allowed_domains.join("\n");
|
let allowed_domains = sso.allowed_domains.join("\n");
|
||||||
let secret_caption = if sso.client_secret_set {
|
let secret_caption = if sso.client_secret_set {
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ pub mod jobs_list_helpers;
|
|||||||
pub mod limit_config;
|
pub mod limit_config;
|
||||||
pub mod login;
|
pub mod login;
|
||||||
pub mod messages_page;
|
pub mod messages_page;
|
||||||
|
pub mod pending_registrations;
|
||||||
pub mod profile_substring_bans;
|
pub mod profile_substring_bans;
|
||||||
pub mod report_detail;
|
pub mod report_detail;
|
||||||
pub mod reports_list;
|
pub mod reports_list;
|
||||||
|
|||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
use crate::{
|
||||||
|
api::types::PendingRegistrationResponse,
|
||||||
|
config::AdminConfig,
|
||||||
|
middleware::auth::AuthContext,
|
||||||
|
templates::{
|
||||||
|
components::{
|
||||||
|
error_display::error_alert, form::csrf_input, page_container::page_header,
|
||||||
|
table::empty_state,
|
||||||
|
},
|
||||||
|
layout::admin_layout,
|
||||||
|
layout_sidebar_nav::PENDING_REGISTRATIONS_KEY,
|
||||||
|
},
|
||||||
|
utils::timestamps::format_admin_timestamp,
|
||||||
|
};
|
||||||
|
use maud::{Markup, html};
|
||||||
|
|
||||||
|
use super::instance_config::{admin_issued_registration_url, compact_button, compact_copy_button};
|
||||||
|
|
||||||
|
pub fn pending_registrations_page(
|
||||||
|
config: &AdminConfig,
|
||||||
|
auth: &AuthContext,
|
||||||
|
csrf_token: &str,
|
||||||
|
pending_registrations: Option<&[PendingRegistrationResponse]>,
|
||||||
|
) -> Markup {
|
||||||
|
let content = html! {
|
||||||
|
(page_header(
|
||||||
|
"Pending Registrations",
|
||||||
|
Some("Approve or reject accounts waiting for a registration decision."),
|
||||||
|
))
|
||||||
|
@match pending_registrations {
|
||||||
|
Some(pending_registrations) => {
|
||||||
|
(pending_registration_list(config, csrf_token, pending_registrations))
|
||||||
|
}
|
||||||
|
None => (error_alert("Failed to load pending registrations.")),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
admin_layout(
|
||||||
|
config,
|
||||||
|
auth,
|
||||||
|
"Pending Registrations",
|
||||||
|
PENDING_REGISTRATIONS_KEY,
|
||||||
|
None,
|
||||||
|
content,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pending_registration_list(
|
||||||
|
config: &AdminConfig,
|
||||||
|
csrf_token: &str,
|
||||||
|
pending_registrations: &[PendingRegistrationResponse],
|
||||||
|
) -> Markup {
|
||||||
|
let base = &config.base_path;
|
||||||
|
html! {
|
||||||
|
div id="pending-registration-list" {
|
||||||
|
@if pending_registrations.is_empty() {
|
||||||
|
(empty_state("No pending registrations."))
|
||||||
|
} @else {
|
||||||
|
div class="overflow-x-auto rounded-lg border border-neutral-200" {
|
||||||
|
table class="min-w-[940px] divide-y divide-neutral-200 text-sm" {
|
||||||
|
thead class="bg-neutral-50" {
|
||||||
|
tr {
|
||||||
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Applicant" }
|
||||||
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Requested" }
|
||||||
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Registration URL" }
|
||||||
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "IP" }
|
||||||
|
th scope="col" class="px-4 py-3 text-left font-semibold text-neutral-600" { "Action" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tbody class="divide-y divide-neutral-200 bg-white" {
|
||||||
|
@for pending in pending_registrations {
|
||||||
|
(pending_registration_row(config, base, csrf_token, pending))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_registration_row(
|
||||||
|
config: &AdminConfig,
|
||||||
|
base: &str,
|
||||||
|
csrf_token: &str,
|
||||||
|
pending: &PendingRegistrationResponse,
|
||||||
|
) -> Markup {
|
||||||
|
let account_name = pending_registration_account_name(pending);
|
||||||
|
let requested_at = format_admin_timestamp(&pending.requested_at);
|
||||||
|
let email = pending.email.as_deref().unwrap_or("None");
|
||||||
|
let client_ip = pending.client_ip.as_deref().unwrap_or("None");
|
||||||
|
let approve_action = format!("{base}/pending-registrations/approve");
|
||||||
|
let reject_action = format!("{base}/pending-registrations/reject");
|
||||||
|
html! {
|
||||||
|
tr {
|
||||||
|
td class="max-w-[20rem] px-4 py-3 align-top" {
|
||||||
|
p class="font-medium text-neutral-900" { (account_name) }
|
||||||
|
@if let Some(global_name) = pending.global_name.as_deref() {
|
||||||
|
p class="truncate text-xs text-neutral-500" title=(global_name) { (global_name) }
|
||||||
|
}
|
||||||
|
p class="truncate text-xs text-neutral-500" title=(email) { (email) }
|
||||||
|
p class="whitespace-nowrap text-xs text-neutral-500" { "ID: " (&pending.user_id) }
|
||||||
|
}
|
||||||
|
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
|
||||||
|
(requested_at)
|
||||||
|
}
|
||||||
|
td class="px-4 py-3 align-top text-neutral-700" {
|
||||||
|
@if let Some(registration_url_id) = pending.registration_url_id.as_deref() {
|
||||||
|
p class="whitespace-nowrap text-xs text-neutral-500" { "ID: " (registration_url_id) }
|
||||||
|
div class="mt-1" {
|
||||||
|
(compact_copy_button(&admin_issued_registration_url(config, registration_url_id), "Copy URL"))
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
span class="text-neutral-500" { "None" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
td class="px-4 py-3 align-top text-neutral-700 whitespace-nowrap" {
|
||||||
|
(client_ip)
|
||||||
|
}
|
||||||
|
td class="px-4 py-3 align-top" {
|
||||||
|
div class="flex flex-nowrap gap-2" {
|
||||||
|
(decision_form(&approve_action, csrf_token, &pending.user_id, "Approve", false))
|
||||||
|
(decision_form(&reject_action, csrf_token, &pending.user_id, "Reject", true))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn decision_form(
|
||||||
|
action: &str,
|
||||||
|
csrf_token: &str,
|
||||||
|
user_id: &str,
|
||||||
|
label: &str,
|
||||||
|
danger: bool,
|
||||||
|
) -> Markup {
|
||||||
|
html! {
|
||||||
|
form method="post" action=(action)
|
||||||
|
hx-post=(action)
|
||||||
|
hx-target="#pending-registration-list"
|
||||||
|
hx-swap="outerHTML"
|
||||||
|
hx-push-url="false"
|
||||||
|
data-admin-allow-swap="true" {
|
||||||
|
(csrf_input(csrf_token))
|
||||||
|
input type="hidden" name="user_id" value=(user_id);
|
||||||
|
(compact_button(label, danger))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pending_registration_account_name(pending: &PendingRegistrationResponse) -> String {
|
||||||
|
if pending.discriminator == 0 {
|
||||||
|
pending.username.clone()
|
||||||
|
} else {
|
||||||
|
format!("{}#{:04}", pending.username, pending.discriminator)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -261,8 +261,7 @@ fn deserialize_instance_config_response_with_unknown_keys() {
|
|||||||
"registration": {
|
"registration": {
|
||||||
"mode": "open",
|
"mode": "open",
|
||||||
"admin_registration_urls_enabled": false,
|
"admin_registration_urls_enabled": false,
|
||||||
"urls": [],
|
"urls": []
|
||||||
"pending_registrations": []
|
|
||||||
},
|
},
|
||||||
"self_hosted": false,
|
"self_hosted": false,
|
||||||
"account_identity": {"mode": "username", "locked": true, "tag_style": "none"},
|
"account_identity": {"mode": "username", "locked": true, "tag_style": "none"},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
body::{Body, to_bytes},
|
body::{Body, to_bytes},
|
||||||
|
extract::Path,
|
||||||
http::{HeaderMap, Method, Request, StatusCode, Uri, header},
|
http::{HeaderMap, Method, Request, StatusCode, Uri, header},
|
||||||
response::{IntoResponse, Response},
|
response::{IntoResponse, Response},
|
||||||
routing,
|
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 {
|
async fn setup_with_api(api: Router) -> TestApp {
|
||||||
let api_endpoint = spawn_mock_api(api).await;
|
let api_endpoint = spawn_mock_api(api).await;
|
||||||
let router = build_router(test_config(api_endpoint));
|
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 {
|
fn searched_report() -> Value {
|
||||||
json!({
|
json!({
|
||||||
"report_id": "1800000000000000001",
|
"report_id": "1800000000000000001",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ export const AdminAuditReadActions = {
|
|||||||
LIST_GUILD_MEMORY_STATS: 'list_guild_memory_stats',
|
LIST_GUILD_MEMORY_STATS: 'list_guild_memory_stats',
|
||||||
LIST_GUILD_STICKERS: 'list_guild_stickers',
|
LIST_GUILD_STICKERS: 'list_guild_stickers',
|
||||||
LIST_GUILD_THREADS: 'list_guild_threads',
|
LIST_GUILD_THREADS: 'list_guild_threads',
|
||||||
|
LIST_PENDING_REGISTRATIONS: 'list_pending_registrations',
|
||||||
LIST_REPORT_REASONS: 'list_report_reasons',
|
LIST_REPORT_REASONS: 'list_report_reasons',
|
||||||
LIST_USER_APPLICATIONS: 'list_user_applications',
|
LIST_USER_APPLICATIONS: 'list_user_applications',
|
||||||
LIST_USER_CHANGE_LOG: 'list_user_change_log',
|
LIST_USER_CHANGE_LOG: 'list_user_change_log',
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
InstanceEmailSmtpTestRequest,
|
InstanceEmailSmtpTestRequest,
|
||||||
InstanceEmailSmtpTestResponse,
|
InstanceEmailSmtpTestResponse,
|
||||||
PendingRegistrationActionRequest,
|
PendingRegistrationActionRequest,
|
||||||
|
PendingRegistrationListResponse,
|
||||||
RegistrationUrlIdParam,
|
RegistrationUrlIdParam,
|
||||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||||
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
import {DomainMigrationConfigSchema} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
|
||||||
@@ -98,7 +99,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
|||||||
experimentDelivery,
|
experimentDelivery,
|
||||||
registrationConfig,
|
registrationConfig,
|
||||||
registrationUrls,
|
registrationUrls,
|
||||||
pendingRegistrations,
|
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
instanceConfigRepository.getSsoConfig(),
|
instanceConfigRepository.getSsoConfig(),
|
||||||
instanceConfigRepository.getGatewayRolloutConfig(),
|
instanceConfigRepository.getGatewayRolloutConfig(),
|
||||||
@@ -109,7 +109,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
|||||||
instanceConfigRepository.getExperimentDeliveryConfig(),
|
instanceConfigRepository.getExperimentDeliveryConfig(),
|
||||||
instanceConfigRepository.getRegistrationConfig(),
|
instanceConfigRepository.getRegistrationConfig(),
|
||||||
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
instanceConfigRepository.getRegistrationUrlsForAdmin(),
|
||||||
instanceConfigRepository.getPendingRegistrations(),
|
|
||||||
]);
|
]);
|
||||||
const [appPublic, policy, resolvedServices, integrations, media, billing, accountIdentity, accountIdentityLocked] =
|
const [appPublic, policy, resolvedServices, integrations, media, billing, accountIdentity, accountIdentityLocked] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
@@ -148,7 +147,6 @@ async function buildInstanceConfigResponse(): Promise<InstanceConfigResponse> {
|
|||||||
registration: {
|
registration: {
|
||||||
...registrationConfig,
|
...registrationConfig,
|
||||||
urls: registrationUrls,
|
urls: registrationUrls,
|
||||||
pending_registrations: pendingRegistrations,
|
|
||||||
},
|
},
|
||||||
self_hosted: Config.instance.selfHosted,
|
self_hosted: Config.instance.selfHosted,
|
||||||
account_identity: {
|
account_identity: {
|
||||||
@@ -320,10 +318,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
|||||||
targetType: 'instance_config',
|
targetType: 'instance_config',
|
||||||
targetId: 0n,
|
targetId: 0n,
|
||||||
action: AdminAuditReadActions.GET_INSTANCE_CONFIG,
|
action: AdminAuditReadActions.GET_INSTANCE_CONFIG,
|
||||||
metadata: {
|
metadata: {registration_url_count: response.registration.urls.length},
|
||||||
registration_url_count: response.registration.urls.length,
|
|
||||||
pending_registration_count: response.registration.pending_registrations.length,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
return ctx.json(response);
|
return ctx.json(response);
|
||||||
},
|
},
|
||||||
@@ -738,18 +733,43 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
|||||||
return ctx.json(await buildInstanceConfigResponse());
|
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(
|
app.patch(
|
||||||
'/admin/instance/pending-registrations/:user_id',
|
'/admin/instance/pending-registrations/:user_id',
|
||||||
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
RateLimitMiddleware(RateLimitConfigs.ADMIN_USER_MODIFY),
|
||||||
requireAdminACL(AdminACLs.INSTANCE_CONFIG_UPDATE),
|
requireAdminACL(AdminACLs.REGISTRATION_REVIEW),
|
||||||
Validator('param', UserIdParam),
|
Validator('param', UserIdParam),
|
||||||
Validator('json', PendingRegistrationActionRequest),
|
Validator('json', PendingRegistrationActionRequest),
|
||||||
OpenAPI({
|
OpenAPI({
|
||||||
operationId: 'update_admin_pending_registration',
|
operationId: 'update_admin_pending_registration',
|
||||||
summary: 'Approve or reject a pending registration',
|
summary: 'Approve or reject a pending registration',
|
||||||
description:
|
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.',
|
'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: InstanceConfigResponse,
|
responseSchema: PendingRegistrationListResponse,
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
security: 'adminApiKey',
|
security: 'adminApiKey',
|
||||||
tags: 'Admin',
|
tags: 'Admin',
|
||||||
@@ -759,7 +779,7 @@ export function InstanceConfigAdminController(app: HonoApp) {
|
|||||||
const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject';
|
const decision = ctx.req.valid('json').status === 'approved' ? 'approve' : 'reject';
|
||||||
await updatePendingRegistrationUser(ctx, userId, decision);
|
await updatePendingRegistrationUser(ctx, userId, decision);
|
||||||
await instanceConfigRepository.removePendingRegistration(userId);
|
await instanceConfigRepository.removePendingRegistration(userId);
|
||||||
return ctx.json(await buildInstanceConfigResponse());
|
return ctx.json({pending_registrations: await instanceConfigRepository.getPendingRegistrations()});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import {
|
|||||||
RequireEmailAccountIdentity,
|
RequireEmailAccountIdentity,
|
||||||
RequireUsernameAccountIdentity,
|
RequireUsernameAccountIdentity,
|
||||||
} from '@app/api/middleware/AccountIdentityMiddleware';
|
} 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 {RateLimitMiddleware} from '@app/api/middleware/RateLimitMiddleware';
|
||||||
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
import {OpenAPI} from '@app/api/middleware/ResponseTypeMiddleware';
|
||||||
|
import {getInstanceConfigRepository} from '@app/api/middleware/ServiceSingletons';
|
||||||
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
import {RateLimitConfigs} from '@app/api/RateLimitConfig';
|
||||||
import type {HonoApp} from '@app/api/types/HonoEnv';
|
import type {HonoApp} from '@app/api/types/HonoEnv';
|
||||||
import {inputValidationErrorFromZodIssues, Validator} from '@app/api/Validator';
|
import {inputValidationErrorFromZodIssues, Validator} from '@app/api/Validator';
|
||||||
@@ -94,14 +95,15 @@ export function UserAdminController(app: HonoApp) {
|
|||||||
security: 'adminApiKey',
|
security: 'adminApiKey',
|
||||||
tags: 'Admin',
|
tags: 'Admin',
|
||||||
description:
|
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) => {
|
async (ctx) => {
|
||||||
const adminUser = ctx.get('user');
|
const canReviewRegistrations = hasAdminACL(ctx.get('adminUserAcls'), AdminACLs.REGISTRATION_REVIEW);
|
||||||
const cacheService = ctx.get('cacheService');
|
const [user, pendingRegistrations] = await Promise.all([
|
||||||
return ctx.json({
|
mapUserToAdminResponse(ctx.get('user'), ctx.get('cacheService')),
|
||||||
user: await mapUserToAdminResponse(adminUser, cacheService),
|
canReviewRegistrations ? getInstanceConfigRepository().getPendingRegistrations() : null,
|
||||||
});
|
]);
|
||||||
|
return ctx.json({user, pending_registration_count: pendingRegistrations?.length ?? null});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
app.get(
|
app.get(
|
||||||
|
|||||||
@@ -13,7 +13,9 @@ import {createApiTestHarness} from '@app/api/test/ApiTestHarness';
|
|||||||
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
import {HTTP_STATUS} from '@app/api/test/TestConstants';
|
||||||
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
import {createBuilder, createBuilderWithoutAuth} from '@app/api/test/TestRequestBuilder';
|
||||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
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 {
|
interface PendingRegistrationResponse {
|
||||||
user_id: string;
|
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 () => {
|
it('keeps a banned user out of the stock community on approval', async () => {
|
||||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||||
AdminACLs.AUTHENTICATE,
|
AdminACLs.AUTHENTICATE,
|
||||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
AdminACLs.REGISTRATION_REVIEW,
|
||||||
]);
|
]);
|
||||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||||
await getInstanceConfigRepository().setInstancePolicyConfig({
|
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 () => {
|
it('does not add a user who was never pending to the stock community', async () => {
|
||||||
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
const admin = await setUserACLs(harness, await createTestAccount(harness), [
|
||||||
AdminACLs.AUTHENTICATE,
|
AdminACLs.AUTHENTICATE,
|
||||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
AdminACLs.REGISTRATION_REVIEW,
|
||||||
]);
|
]);
|
||||||
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
const {owner, guild} = await setupTestGuildWithMembers(harness, 0);
|
||||||
const outsider = await createTestAccount(harness);
|
const outsider = await createTestAccount(harness);
|
||||||
@@ -99,4 +101,59 @@ describe('pending registration approval and the stock community', () => {
|
|||||||
.expect(HTTP_STATUS.NOT_FOUND)
|
.expect(HTTP_STATUS.NOT_FOUND)
|
||||||
.execute();
|
.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',
|
action: 'get_instance_config',
|
||||||
targetType: 'instance_config',
|
targetType: 'instance_config',
|
||||||
targetId: '0',
|
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',
|
method: 'PATCH',
|
||||||
route: '/admin/instance/pending-registrations/:user_id',
|
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 {UserRepository} from '@app/api/user/repositories/UserRepository';
|
||||||
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
import {AdminACLs} from '@fluxer/constants/src/AdminACLs';
|
||||||
import {APIErrorCodes} from '@fluxer/constants/src/ApiErrorCodes';
|
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';
|
import {afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi} from 'vitest';
|
||||||
|
|
||||||
const REGISTRATION_URLS_KEY = 'registration_urls';
|
const REGISTRATION_URLS_KEY = 'registration_urls';
|
||||||
@@ -62,6 +65,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
|
|||||||
AdminACLs.AUTHENTICATE,
|
AdminACLs.AUTHENTICATE,
|
||||||
AdminACLs.INSTANCE_CONFIG_VIEW,
|
AdminACLs.INSTANCE_CONFIG_VIEW,
|
||||||
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
AdminACLs.INSTANCE_CONFIG_UPDATE,
|
||||||
|
AdminACLs.REGISTRATION_REVIEW,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -82,6 +86,13 @@ describe('signups racing on registration URLs and pending approvals', () => {
|
|||||||
const readAdminConfig = (): Promise<InstanceConfigResponse> =>
|
const readAdminConfig = (): Promise<InstanceConfigResponse> =>
|
||||||
createBuilder<InstanceConfigResponse>(harness, admin.token).get('/admin/instance/config').execute();
|
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 completeSso = async (prefix: string) => {
|
||||||
const start = await createBuilderWithoutAuth<{state: string}>(harness)
|
const start = await createBuilderWithoutAuth<{state: string}>(harness)
|
||||||
.post('/auth/sso/start')
|
.post('/auth/sso/start')
|
||||||
@@ -116,7 +127,7 @@ describe('signups racing on registration URLs and pending approvals', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const expectOnePendingAccount = async () => {
|
const expectOnePendingAccount = async () => {
|
||||||
const pending = (await readAdminConfig()).registration.pending_registrations;
|
const pending = await readPendingRegistrations();
|
||||||
expect(pending).toHaveLength(1);
|
expect(pending).toHaveLength(1);
|
||||||
const account = await new UserRepository().findUnique(createUserID(BigInt(pending[0]!.user_id)));
|
const account = await new UserRepository().findUnique(createUserID(BigInt(pending[0]!.user_id)));
|
||||||
expect(account?.traits.has(REGISTRATION_PENDING_APPROVAL_TRAIT)).toBe(true);
|
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);
|
const failed = await register('seatreleased', code);
|
||||||
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
|
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
|
||||||
|
expect(await readPendingRegistrations()).toEqual([]);
|
||||||
const withdrawn = await readAdminConfig();
|
const withdrawn = await readAdminConfig();
|
||||||
expect(withdrawn.registration.pending_registrations).toEqual([]);
|
|
||||||
expect(withdrawn.registration.urls.find((url) => url.id === registrationUrl.id)?.use_count).toBe(0);
|
expect(withdrawn.registration.urls.find((url) => url.id === registrationUrl.id)?.use_count).toBe(0);
|
||||||
|
|
||||||
const retried = await register('seatreleasedretry', code);
|
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}`)));
|
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));
|
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());
|
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');
|
const failed = await register('pendingkept');
|
||||||
|
|
||||||
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
|
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 () => {
|
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');
|
const failed = await register('pendingnever');
|
||||||
|
|
||||||
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
|
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 () => {
|
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');
|
const failed = await completeSso('ssopendingkept');
|
||||||
|
|
||||||
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
|
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 () => {
|
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');
|
const failed = await completeSso('ssopendingnever');
|
||||||
|
|
||||||
expect(failed.response.status).toBe(HTTP_STATUS.INTERNAL_SERVER_ERROR);
|
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 () => {
|
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'});
|
await getInstanceConfigRepository().setRegistrationConfig({mode: 'approval'});
|
||||||
const fresh = await register('pendingafter');
|
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());
|
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}`)
|
.patch(`/admin/instance/pending-registrations/${account.userId}`)
|
||||||
.body({status: 'approved'})
|
.body({status: 'approved'})
|
||||||
.expect(HTTP_STATUS.OK)
|
.expect(HTTP_STATUS.OK)
|
||||||
.execute();
|
.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(
|
expect(
|
||||||
JSON.parse(
|
JSON.parse(
|
||||||
(await getInstanceConfigRepository().getConfig(REGISTRATION_PENDING_APPROVALS_KEY)) ?? 'null',
|
(await getInstanceConfigRepository().getConfig(REGISTRATION_PENDING_APPROVALS_KEY)) ?? 'null',
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ import {
|
|||||||
InstanceConfigResponse,
|
InstanceConfigResponse,
|
||||||
InstanceConfigUpdateRequest,
|
InstanceConfigUpdateRequest,
|
||||||
type InstanceEmailSmtpTestRequest,
|
type InstanceEmailSmtpTestRequest,
|
||||||
type PendingRegistrationResponse,
|
PendingRegistrationResponse,
|
||||||
type RegistrationUrlResponse,
|
type RegistrationUrlResponse,
|
||||||
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
} from '@fluxer/schema/src/domains/admin/AdminSchemas';
|
||||||
import {
|
import {
|
||||||
@@ -1053,7 +1053,6 @@ function parseStoredRegistrationConfig(raw: string | null): InstanceRegistration
|
|||||||
}
|
}
|
||||||
|
|
||||||
const RegistrationUrlSchema = InstanceConfigResponse.shape.registration.shape.urls.element;
|
const RegistrationUrlSchema = InstanceConfigResponse.shape.registration.shape.urls.element;
|
||||||
const PendingRegistrationSchema = InstanceConfigResponse.shape.registration.shape.pending_registrations.element;
|
|
||||||
const StoredRegistrationTimestampSchema = z.iso
|
const StoredRegistrationTimestampSchema = z.iso
|
||||||
.datetime({offset: true})
|
.datetime({offset: true})
|
||||||
.refine((value) => value === value.trim() && Number.isFinite(Date.parse(value)))
|
.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_at: StoredRegistrationTimestampSchema.nullable().default(null),
|
||||||
last_used_by_user_id: StoredNullableStringSchema.pipe(StoredSnowflakeStringSchema.nullable()),
|
last_used_by_user_id: StoredNullableStringSchema.pipe(StoredSnowflakeStringSchema.nullable()),
|
||||||
}) satisfies z.ZodType<InstanceRegistrationUrl>;
|
}) satisfies z.ZodType<InstanceRegistrationUrl>;
|
||||||
const StoredPendingRegistrationSchema = PendingRegistrationSchema.extend({
|
const StoredPendingRegistrationSchema = PendingRegistrationResponse.extend({
|
||||||
user_id: StoredSnowflakeStringSchema,
|
user_id: StoredSnowflakeStringSchema,
|
||||||
discriminator: PendingRegistrationSchema.shape.discriminator.default(0),
|
discriminator: PendingRegistrationResponse.shape.discriminator.default(0),
|
||||||
global_name: StoredNullableStringSchema,
|
global_name: StoredNullableStringSchema,
|
||||||
email: StoredNullableStringSchema,
|
email: StoredNullableStringSchema,
|
||||||
requested_at: StoredRegistrationTimestampSchema,
|
requested_at: StoredRegistrationTimestampSchema,
|
||||||
|
|||||||
@@ -274,7 +274,7 @@ const ACCEPTED_TABLE_FINDINGS = new Map<string, Readonly<Partial<Record<TableRul
|
|||||||
['admin-api/instance.mdx', {'table-identifier': 8}],
|
['admin-api/instance.mdx', {'table-identifier': 8}],
|
||||||
['admin-api/messages.mdx', {'table-identifier': 1}],
|
['admin-api/messages.mdx', {'table-identifier': 1}],
|
||||||
['admin-api/reports.mdx', {'table-fit': 1, 'table-identifier': 3}],
|
['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}],
|
['admin-api/voice.mdx', {'table-identifier': 3}],
|
||||||
['gateway/event-filtering.md', {'table-cell': 2}],
|
['gateway/event-filtering.md', {'table-cell': 2}],
|
||||||
['gateway/events.md', {'table-identifier': 1}],
|
['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_memory_stats | Guild memory statistics were listed |
|
||||||
| list_guild_stickers | The stickers of a guild were listed |
|
| list_guild_stickers | The stickers of a guild were listed |
|
||||||
| list_guild_threads | The threads 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_report_reasons | The report reason catalogue was listed |
|
||||||
| list_user_applications | The applications an account owns were read |
|
| list_user_applications | The applications an account owns were read |
|
||||||
| list_user_change_log | The contact change log of an account was 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:name<sup>5</sup> | Updates a guild name |
|
||||||
| guild:update:settings<sup>5</sup> | Updates general guild settings and clears guild fields |
|
| 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 |
|
| 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:config:view | Reads instance configuration |
|
||||||
| instance:limit_config:update | Replaces the instance-wide limit configuration |
|
| instance:limit_config:update | Replaces the instance-wide limit configuration |
|
||||||
| instance:limit_config:view | Reads 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:delete | Deletes one message |
|
||||||
| message:lookup | Reads, browses, and searches [messages](/admin-api/messages/) through the Admin API |
|
| 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 |
|
| 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: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:resolve | Updates and resolves [reports](/admin-api/reports/), and places legal holds on them |
|
||||||
| report:view | Reads and searches reports |
|
| 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.
|
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]
|
:::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.
|
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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 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
|
### Structure
|
||||||
|
|
||||||
@@ -232,15 +232,12 @@ Registration policy in force, plus every issued registration URL and every accou
|
|||||||
| mode | string | [Registration mode](#registration-modes) |
|
| mode | string | [Registration mode](#registration-modes) |
|
||||||
| admin_registration_urls_enabled<sup>1</sup> | boolean | Whether Admin-issued registration URLs are accepted |
|
| 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 |
|
| 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>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>2</sup> Ordered by creation time, newest first
|
||||||
|
|
||||||
<sup>3</sup> Ordered by request time, oldest first
|
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.
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
## Registration modes
|
## 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
|
<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
|
## 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.
|
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
|
### 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
|
### 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.
|
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
|
## Update pending registration
|
||||||
|
|
||||||
<RouteHeader method="PATCH" path="/v1/admin/instance/pending-registrations/{user_id}" auditReason />
|
<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
|
### Path parameters
|
||||||
|
|
||||||
@@ -901,7 +930,7 @@ Approves or rejects one pending registration and returns the resulting [instance
|
|||||||
|
|
||||||
| Status | Body | Condition |
|
| 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
|
<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" />
|
<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.
|
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 |
|
| Field | Type | Description |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| user | [Admin user](#admin-user-object) object | The account the request authenticated as |
|
| 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
|
### Response
|
||||||
|
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export const AdminACLs = {
|
|||||||
MESSAGE_DELETE: 'message:delete',
|
MESSAGE_DELETE: 'message:delete',
|
||||||
MESSAGE_LOOKUP: 'message:lookup',
|
MESSAGE_LOOKUP: 'message:lookup',
|
||||||
MESSAGE_SHRED: 'message:shred',
|
MESSAGE_SHRED: 'message:shred',
|
||||||
|
REGISTRATION_REVIEW: 'registration:review',
|
||||||
REPORT_DELETE: 'report:delete',
|
REPORT_DELETE: 'report:delete',
|
||||||
REPORT_RESOLVE: 'report:resolve',
|
REPORT_RESOLVE: 'report:resolve',
|
||||||
REPORT_VIEW: 'report:view',
|
REPORT_VIEW: 'report:view',
|
||||||
|
|||||||
@@ -512,7 +512,7 @@ const RegistrationUrlResponse = z.object({
|
|||||||
});
|
});
|
||||||
export type RegistrationUrlResponse = z.infer<typeof RegistrationUrlResponse>;
|
export type RegistrationUrlResponse = z.infer<typeof RegistrationUrlResponse>;
|
||||||
|
|
||||||
const PendingRegistrationResponse = z.object({
|
export const PendingRegistrationResponse = z.object({
|
||||||
user_id: SnowflakeStringType,
|
user_id: SnowflakeStringType,
|
||||||
username: z.string(),
|
username: z.string(),
|
||||||
discriminator: z.number().int().min(0).max(9999),
|
discriminator: z.number().int().min(0).max(9999),
|
||||||
@@ -524,9 +524,13 @@ const PendingRegistrationResponse = z.object({
|
|||||||
});
|
});
|
||||||
export type PendingRegistrationResponse = z.infer<typeof PendingRegistrationResponse>;
|
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({
|
const InstanceRegistrationResponse = InstanceRegistrationConfigResponse.extend({
|
||||||
urls: z.array(RegistrationUrlResponse),
|
urls: z.array(RegistrationUrlResponse),
|
||||||
pending_registrations: z.array(PendingRegistrationResponse),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const AppPublicConfigResponse = z.object({
|
const AppPublicConfigResponse = z.object({
|
||||||
|
|||||||
@@ -108,11 +108,14 @@ export const ListUserChangeLogResponseSchema = z.object({
|
|||||||
|
|
||||||
export const AdminUsersMeResponse = z.object({
|
export const AdminUsersMeResponse = z.object({
|
||||||
user: UserAdminResponseSchema,
|
user: UserAdminResponseSchema,
|
||||||
|
pending_registration_count: Int32Type.nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AdminUsersMeResponse = z.infer<typeof AdminUsersMeResponse>;
|
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>;
|
export type UserMutationResponse = z.infer<typeof UserMutationResponse>;
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user