forked from coracle/caravel
Simplify relay upsert
This commit is contained in:
@@ -0,0 +1 @@
|
||||
ALTER TABLE relays ADD COLUMN config TEXT;
|
||||
+55
-48
@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::auth::verify_nip98;
|
||||
use crate::models::{NewRelay, NewTenant, Relay, UpdateRelay};
|
||||
use crate::models::{NewTenant, Relay, RelayConfig};
|
||||
use crate::provisioning::Provisioner;
|
||||
use crate::repo::Repo;
|
||||
|
||||
@@ -211,6 +211,7 @@ struct CreateRelayRequest {
|
||||
icon: String,
|
||||
description: String,
|
||||
plan: String,
|
||||
config: Option<RelayConfig>,
|
||||
}
|
||||
|
||||
async fn create_tenant_relay(
|
||||
@@ -241,7 +242,7 @@ async fn create_tenant_relay(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let relay = NewRelay {
|
||||
let relay = Relay {
|
||||
id: Uuid::new_v4().to_string(),
|
||||
tenant: pubkey.clone(),
|
||||
name: payload.name,
|
||||
@@ -251,9 +252,10 @@ async fn create_tenant_relay(
|
||||
description: payload.description,
|
||||
plan: payload.plan,
|
||||
status: "pending".to_string(),
|
||||
config: payload.config,
|
||||
};
|
||||
|
||||
if let Err(err) = state.repo.create_relay(&relay).await {
|
||||
if let Err(err) = state.repo.upsert_relay(&relay).await {
|
||||
if is_unique_subdomain_violation(&err) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
@@ -273,7 +275,17 @@ async fn create_tenant_relay(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
spawn_provisioning_worker(state.repo.clone(), state.provisioner.clone(), relay.clone());
|
||||
if let Err(err) = state.provisioner.sync_relay(&relay, true).await {
|
||||
tracing::error!(relay_id = relay.id, error = %err, "zooid create failed");
|
||||
let _ = state.repo.update_relay_status(&relay.id, "provisioning_failed").await;
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError { error: format!("failed to provision relay: {err}") }),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
let _ = state.repo.update_relay_status(&relay.id, "active").await;
|
||||
|
||||
(StatusCode::CREATED, Json(relay)).into_response()
|
||||
}
|
||||
@@ -311,6 +323,7 @@ struct UpdateRelayRequest {
|
||||
icon: String,
|
||||
description: String,
|
||||
plan: String,
|
||||
config: Option<RelayConfig>,
|
||||
}
|
||||
|
||||
async fn update_tenant_relay(
|
||||
@@ -344,8 +357,9 @@ async fn update_tenant_relay(
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
let updated = UpdateRelay {
|
||||
let relay = Relay {
|
||||
id: existing.id,
|
||||
tenant: existing.tenant,
|
||||
name: payload.name,
|
||||
subdomain: payload.subdomain.clone(),
|
||||
schema: payload.subdomain.replace('-', "_"),
|
||||
@@ -353,9 +367,10 @@ async fn update_tenant_relay(
|
||||
description: payload.description,
|
||||
plan: payload.plan,
|
||||
status: existing.status,
|
||||
config: payload.config,
|
||||
};
|
||||
|
||||
if let Err(err) = state.repo.update_relay(&updated).await {
|
||||
if let Err(err) = state.repo.upsert_relay(&relay).await {
|
||||
if is_unique_subdomain_violation(&err) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
@@ -375,7 +390,16 @@ async fn update_tenant_relay(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(updated)).into_response()
|
||||
if let Err(err) = state.provisioner.sync_relay(&relay, false).await {
|
||||
tracing::error!(relay_id = relay.id, error = %err, "zooid patch failed");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError { error: format!("failed to update relay config: {err}") }),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(relay)).into_response()
|
||||
}
|
||||
|
||||
async fn deactivate_tenant_relay(
|
||||
@@ -408,18 +432,13 @@ async fn deactivate_tenant_relay(
|
||||
return forbidden();
|
||||
}
|
||||
|
||||
let updated = UpdateRelay {
|
||||
id: existing.id,
|
||||
name: existing.name,
|
||||
subdomain: existing.subdomain.clone(),
|
||||
schema: existing.subdomain.replace('-', "_"),
|
||||
icon: existing.icon,
|
||||
description: existing.description,
|
||||
plan: existing.plan,
|
||||
let relay = Relay {
|
||||
status: "deactivated".to_string(),
|
||||
config: None,
|
||||
..existing
|
||||
};
|
||||
|
||||
if let Err(_) = state.repo.update_relay(&updated).await {
|
||||
if let Err(_) = state.repo.upsert_relay(&relay).await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
@@ -429,7 +448,7 @@ async fn deactivate_tenant_relay(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(updated)).into_response()
|
||||
(StatusCode::OK, Json(relay)).into_response()
|
||||
}
|
||||
|
||||
async fn list_tenant_invoices(
|
||||
@@ -734,8 +753,9 @@ async fn admin_update_relay(
|
||||
}
|
||||
};
|
||||
|
||||
let updated = UpdateRelay {
|
||||
let relay = Relay {
|
||||
id: existing.id,
|
||||
tenant: existing.tenant,
|
||||
name: payload.name,
|
||||
subdomain: payload.subdomain.clone(),
|
||||
schema: payload.subdomain.replace('-', "_"),
|
||||
@@ -743,9 +763,10 @@ async fn admin_update_relay(
|
||||
description: payload.description,
|
||||
plan: payload.plan,
|
||||
status: existing.status,
|
||||
config: payload.config,
|
||||
};
|
||||
|
||||
if let Err(err) = state.repo.update_relay(&updated).await {
|
||||
if let Err(err) = state.repo.upsert_relay(&relay).await {
|
||||
if is_unique_subdomain_violation(&err) {
|
||||
return (
|
||||
StatusCode::CONFLICT,
|
||||
@@ -765,7 +786,16 @@ async fn admin_update_relay(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(updated)).into_response()
|
||||
if let Err(err) = state.provisioner.sync_relay(&relay, false).await {
|
||||
tracing::error!(relay_id = relay.id, error = %err, "zooid patch failed");
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError { error: format!("failed to update relay config: {err}") }),
|
||||
)
|
||||
.into_response();
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(relay)).into_response()
|
||||
}
|
||||
|
||||
async fn admin_deactivate_relay(
|
||||
@@ -798,18 +828,13 @@ async fn admin_deactivate_relay(
|
||||
}
|
||||
};
|
||||
|
||||
let updated = UpdateRelay {
|
||||
id: existing.id,
|
||||
name: existing.name,
|
||||
subdomain: existing.subdomain.clone(),
|
||||
schema: existing.subdomain.replace('-', "_"),
|
||||
icon: existing.icon,
|
||||
description: existing.description,
|
||||
plan: existing.plan,
|
||||
let relay = Relay {
|
||||
status: "deactivated".to_string(),
|
||||
config: None,
|
||||
..existing
|
||||
};
|
||||
|
||||
if let Err(_) = state.repo.update_relay(&updated).await {
|
||||
if let Err(_) = state.repo.upsert_relay(&relay).await {
|
||||
return (
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
Json(ApiError {
|
||||
@@ -819,25 +844,7 @@ async fn admin_deactivate_relay(
|
||||
.into_response();
|
||||
}
|
||||
|
||||
(StatusCode::OK, Json(updated)).into_response()
|
||||
(StatusCode::OK, Json(relay)).into_response()
|
||||
}
|
||||
|
||||
fn spawn_provisioning_worker(repo: Repo, provisioner: Provisioner, relay: NewRelay) {
|
||||
tokio::spawn(async move {
|
||||
tracing::info!(relay_id = relay.id, "provisioning worker started");
|
||||
if let Err(err) = provisioner.provision_relay(&relay).await {
|
||||
tracing::error!(relay_id = relay.id, error = %err, "provisioning failed");
|
||||
if let Err(err) = repo
|
||||
.update_relay_status(&relay.id, "provisioning_failed")
|
||||
.await
|
||||
{
|
||||
tracing::error!(relay_id = relay.id, error = %err, "failed to update relay status");
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = repo.update_relay_status(&relay.id, "active").await {
|
||||
tracing::error!(relay_id = relay.id, error = %err, "failed to update relay status");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+11
-26
@@ -1,5 +1,14 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RelayConfig {
|
||||
pub policy: Option<serde_json::Value>,
|
||||
pub groups: Option<serde_json::Value>,
|
||||
pub management: Option<serde_json::Value>,
|
||||
pub blossom: Option<serde_json::Value>,
|
||||
pub push: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
pub struct Tenant {
|
||||
pub pubkey: String,
|
||||
@@ -14,7 +23,7 @@ pub struct NewTenant {
|
||||
pub tenant_nwc_url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Relay {
|
||||
pub id: String,
|
||||
pub tenant: String,
|
||||
@@ -25,31 +34,7 @@ pub struct Relay {
|
||||
pub description: String,
|
||||
pub plan: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NewRelay {
|
||||
pub id: String,
|
||||
pub tenant: String,
|
||||
pub name: String,
|
||||
pub subdomain: String,
|
||||
pub schema: String,
|
||||
pub icon: String,
|
||||
pub description: String,
|
||||
pub plan: String,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UpdateRelay {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub subdomain: String,
|
||||
pub schema: String,
|
||||
pub icon: String,
|
||||
pub description: String,
|
||||
pub plan: String,
|
||||
pub status: String,
|
||||
pub config: Option<RelayConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, sqlx::FromRow)]
|
||||
|
||||
+115
-118
@@ -3,12 +3,13 @@ use rand::RngCore;
|
||||
use rand::rngs::OsRng;
|
||||
use reqwest::Client;
|
||||
use serde::Serialize;
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use nostr_sdk::nostr::Keys;
|
||||
use nostr_sdk::nostr::nips::nip98::{HttpData, HttpMethod};
|
||||
use nostr_sdk::nostr::types::url::Url;
|
||||
|
||||
use crate::models::NewRelay;
|
||||
use crate::models::{Relay, RelayConfig};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Provisioner {
|
||||
@@ -35,31 +36,49 @@ impl Provisioner {
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn provision_relay(&self, relay: &NewRelay) -> Result<()> {
|
||||
let host = format!("{}.{}", relay.subdomain, self.relay_domain);
|
||||
let secret = generate_secret_hex();
|
||||
|
||||
let payload = ZooidConfig::new(relay, host, secret);
|
||||
/// Create or update a relay in zooid.
|
||||
///
|
||||
/// On creation, POSTs the full config (including a generated secret and host).
|
||||
/// On update, PATCHes only the mutable fields (info + config sections).
|
||||
pub async fn sync_relay(&self, relay: &Relay, is_new: bool) -> Result<()> {
|
||||
let url = format!("{}/relay/{}", self.base_url.trim_end_matches('/'), relay.id);
|
||||
let auth = self.build_auth_header(&url, HttpMethod::POST).await?;
|
||||
|
||||
let res = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header(reqwest::header::AUTHORIZATION, auth)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
if is_new {
|
||||
let host = format!("{}.{}", relay.subdomain, self.relay_domain);
|
||||
let secret = generate_secret_hex();
|
||||
let payload = build_full_config(relay, host, secret);
|
||||
let auth = self.build_auth_header(&url, HttpMethod::POST).await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
return Err(anyhow!(
|
||||
"zooid provisioning failed for {}: {} {}",
|
||||
url,
|
||||
status,
|
||||
body
|
||||
));
|
||||
let res = self
|
||||
.client
|
||||
.post(&url)
|
||||
.header(reqwest::header::AUTHORIZATION, auth)
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("zooid create failed: {} {}", status, body));
|
||||
}
|
||||
} else {
|
||||
let patch = build_patch(relay);
|
||||
let auth = self.build_auth_header(&url, HttpMethod::PATCH).await?;
|
||||
|
||||
let res = self
|
||||
.client
|
||||
.patch(&url)
|
||||
.header(reqwest::header::AUTHORIZATION, auth)
|
||||
.json(&patch)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
if !res.status().is_success() {
|
||||
let status = res.status();
|
||||
let body = res.text().await.unwrap_or_default();
|
||||
return Err(anyhow!("zooid patch failed: {} {}", status, body));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
@@ -73,108 +92,86 @@ impl Provisioner {
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ZooidConfig {
|
||||
host: String,
|
||||
schema: String,
|
||||
secret: String,
|
||||
info: ZooidInfo,
|
||||
policy: ZooidPolicy,
|
||||
groups: ZooidGroups,
|
||||
push: ZooidPush,
|
||||
management: ZooidManagement,
|
||||
blossom: ZooidBlossom,
|
||||
roles: ZooidRoles,
|
||||
/// Builds the full zooid config payload for relay creation (POST).
|
||||
fn build_full_config(relay: &Relay, host: String, secret: String) -> Value {
|
||||
let blossom_default = relay.plan != "free";
|
||||
let cfg = relay.config.as_ref();
|
||||
|
||||
json!({
|
||||
"host": host,
|
||||
"schema": relay.schema,
|
||||
"secret": secret,
|
||||
"info": {
|
||||
"name": relay.name,
|
||||
"icon": relay.icon,
|
||||
"pubkey": relay.tenant,
|
||||
"description": relay.description,
|
||||
},
|
||||
"policy": {
|
||||
"public_join": cfg_bool(cfg, |c| &c.policy, "public_join", false),
|
||||
"strip_signatures": cfg_bool(cfg, |c| &c.policy, "strip_signatures", false),
|
||||
},
|
||||
"groups": {
|
||||
"enabled": cfg_bool(cfg, |c| &c.groups, "enabled", true),
|
||||
"auto_join": cfg_bool(cfg, |c| &c.groups, "auto_join", true),
|
||||
},
|
||||
"push": {
|
||||
"enabled": cfg_bool(cfg, |c| &c.push, "enabled", true),
|
||||
},
|
||||
"management": {
|
||||
"enabled": cfg_bool(cfg, |c| &c.management, "enabled", true),
|
||||
},
|
||||
"blossom": {
|
||||
"enabled": cfg_bool(cfg, |c| &c.blossom, "enabled", blossom_default),
|
||||
},
|
||||
"roles": {
|
||||
"member": { "pubkeys": [], "can_invite": true, "can_manage": false }
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
impl ZooidConfig {
|
||||
fn new(relay: &NewRelay, host: String, secret: String) -> Self {
|
||||
let blossom_enabled = relay.plan != "free";
|
||||
/// Builds the partial zooid patch payload for relay updates (PATCH).
|
||||
fn build_patch(relay: &Relay) -> Value {
|
||||
let blossom_default = relay.plan != "free";
|
||||
let cfg = relay.config.as_ref();
|
||||
|
||||
Self {
|
||||
host,
|
||||
schema: relay.schema.clone(),
|
||||
secret,
|
||||
info: ZooidInfo {
|
||||
name: relay.name.clone(),
|
||||
icon: relay.icon.clone(),
|
||||
pubkey: relay.tenant.clone(),
|
||||
description: relay.description.clone(),
|
||||
},
|
||||
policy: ZooidPolicy {
|
||||
public_join: false,
|
||||
strip_signatures: false,
|
||||
},
|
||||
groups: ZooidGroups {
|
||||
enabled: true,
|
||||
auto_join: true,
|
||||
},
|
||||
push: ZooidPush { enabled: true },
|
||||
management: ZooidManagement {
|
||||
enabled: true,
|
||||
methods: Vec::new(),
|
||||
},
|
||||
blossom: ZooidBlossom {
|
||||
enabled: blossom_enabled,
|
||||
},
|
||||
roles: ZooidRoles {
|
||||
member: ZooidRole {
|
||||
pubkeys: Vec::new(),
|
||||
can_invite: true,
|
||||
can_manage: false,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
json!({
|
||||
"info": {
|
||||
"name": relay.name,
|
||||
"icon": relay.icon,
|
||||
"description": relay.description,
|
||||
},
|
||||
"policy": {
|
||||
"public_join": cfg_bool(cfg, |c| &c.policy, "public_join", false),
|
||||
"strip_signatures": cfg_bool(cfg, |c| &c.policy, "strip_signatures", false),
|
||||
},
|
||||
"groups": {
|
||||
"enabled": cfg_bool(cfg, |c| &c.groups, "enabled", true),
|
||||
"auto_join": cfg_bool(cfg, |c| &c.groups, "auto_join", true),
|
||||
},
|
||||
"push": {
|
||||
"enabled": cfg_bool(cfg, |c| &c.push, "enabled", true),
|
||||
},
|
||||
"management": {
|
||||
"enabled": cfg_bool(cfg, |c| &c.management, "enabled", true),
|
||||
},
|
||||
"blossom": {
|
||||
"enabled": cfg_bool(cfg, |c| &c.blossom, "enabled", blossom_default),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ZooidInfo {
|
||||
name: String,
|
||||
icon: String,
|
||||
pubkey: String,
|
||||
description: String,
|
||||
fn cfg_bool(
|
||||
cfg: Option<&RelayConfig>,
|
||||
section: impl Fn(&RelayConfig) -> &Option<Value>,
|
||||
key: &str,
|
||||
default: bool,
|
||||
) -> bool {
|
||||
cfg.and_then(|c| section(c).as_ref())
|
||||
.and_then(|v| v[key].as_bool())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ZooidPolicy {
|
||||
public_join: bool,
|
||||
strip_signatures: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ZooidGroups {
|
||||
enabled: bool,
|
||||
auto_join: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ZooidPush {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ZooidManagement {
|
||||
enabled: bool,
|
||||
methods: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ZooidBlossom {
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ZooidRoles {
|
||||
member: ZooidRole,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ZooidRole {
|
||||
pubkeys: Vec<String>,
|
||||
can_invite: bool,
|
||||
can_manage: bool,
|
||||
}
|
||||
|
||||
fn generate_secret_hex() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
|
||||
+42
-33
@@ -1,11 +1,27 @@
|
||||
use anyhow::Result;
|
||||
use sqlx::{Sqlite, SqlitePool, Transaction};
|
||||
use sqlx::{Row, Sqlite, SqlitePool, Transaction};
|
||||
|
||||
use crate::models::{
|
||||
Invoice, InvoiceItem, NewInvoice, NewInvoiceItem, NewRelay, NewTenant, Relay, Tenant,
|
||||
UpdateRelay,
|
||||
Invoice, InvoiceItem, NewInvoice, NewInvoiceItem, NewTenant, Relay, Tenant,
|
||||
};
|
||||
|
||||
fn relay_from_row(row: sqlx::sqlite::SqliteRow) -> Relay {
|
||||
let config_json: Option<String> = row.get("config");
|
||||
let config = config_json.and_then(|s| serde_json::from_str(&s).ok());
|
||||
Relay {
|
||||
id: row.get("id"),
|
||||
tenant: row.get("tenant"),
|
||||
name: row.get("name"),
|
||||
subdomain: row.get("subdomain"),
|
||||
schema: row.get("schema"),
|
||||
icon: row.get("icon"),
|
||||
description: row.get("description"),
|
||||
plan: row.get("plan"),
|
||||
status: row.get("status"),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct Repo {
|
||||
pool: SqlitePool,
|
||||
@@ -75,10 +91,20 @@ impl Repo {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn create_relay(&self, relay: &NewRelay) -> Result<()> {
|
||||
pub async fn upsert_relay(&self, relay: &Relay) -> Result<()> {
|
||||
let config_json = relay.config.as_ref().map(serde_json::to_string).transpose()?;
|
||||
sqlx::query(
|
||||
"INSERT INTO relays (id, tenant, name, subdomain, schema, icon, description, plan, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
"INSERT INTO relays (id, tenant, name, subdomain, schema, icon, description, plan, status, config)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name = excluded.name,
|
||||
subdomain = excluded.subdomain,
|
||||
schema = excluded.schema,
|
||||
icon = excluded.icon,
|
||||
description = excluded.description,
|
||||
plan = excluded.plan,
|
||||
status = excluded.status,
|
||||
config = excluded.config",
|
||||
)
|
||||
.bind(&relay.id)
|
||||
.bind(&relay.tenant)
|
||||
@@ -89,24 +115,7 @@ impl Repo {
|
||||
.bind(&relay.description)
|
||||
.bind(&relay.plan)
|
||||
.bind(&relay.status)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn update_relay(&self, relay: &UpdateRelay) -> Result<()> {
|
||||
sqlx::query(
|
||||
"UPDATE relays SET name = ?, subdomain = ?, schema = ?, icon = ?, description = ?, plan = ?, status = ?
|
||||
WHERE id = ?",
|
||||
)
|
||||
.bind(&relay.name)
|
||||
.bind(&relay.subdomain)
|
||||
.bind(&relay.schema)
|
||||
.bind(&relay.icon)
|
||||
.bind(&relay.description)
|
||||
.bind(&relay.plan)
|
||||
.bind(&relay.status)
|
||||
.bind(&relay.id)
|
||||
.bind(config_json)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
Ok(())
|
||||
@@ -132,32 +141,32 @@ impl Repo {
|
||||
}
|
||||
|
||||
pub async fn get_relay(&self, id: &str) -> Result<Option<Relay>> {
|
||||
let relay = sqlx::query_as::<_, Relay>(
|
||||
"SELECT id, tenant, name, subdomain, schema, icon, description, plan, status FROM relays WHERE id = ?",
|
||||
let row = sqlx::query(
|
||||
"SELECT id, tenant, name, subdomain, schema, icon, description, plan, status, config FROM relays WHERE id = ?",
|
||||
)
|
||||
.bind(id)
|
||||
.fetch_optional(&self.pool)
|
||||
.await?;
|
||||
Ok(relay)
|
||||
Ok(row.map(relay_from_row))
|
||||
}
|
||||
|
||||
pub async fn list_relays_by_tenant(&self, tenant: &str) -> Result<Vec<Relay>> {
|
||||
let relays = sqlx::query_as::<_, Relay>(
|
||||
"SELECT id, tenant, name, subdomain, schema, icon, description, plan, status FROM relays WHERE tenant = ? ORDER BY name",
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, tenant, name, subdomain, schema, icon, description, plan, status, config FROM relays WHERE tenant = ? ORDER BY name",
|
||||
)
|
||||
.bind(tenant)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(relays)
|
||||
Ok(rows.into_iter().map(relay_from_row).collect())
|
||||
}
|
||||
|
||||
pub async fn list_relays(&self) -> Result<Vec<Relay>> {
|
||||
let relays = sqlx::query_as::<_, Relay>(
|
||||
"SELECT id, tenant, name, subdomain, schema, icon, description, plan, status FROM relays ORDER BY name",
|
||||
let rows = sqlx::query(
|
||||
"SELECT id, tenant, name, subdomain, schema, icon, description, plan, status, config FROM relays ORDER BY name",
|
||||
)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
Ok(relays)
|
||||
Ok(rows.into_iter().map(relay_from_row).collect())
|
||||
}
|
||||
|
||||
pub async fn create_invoice_with_items(
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
export default function Checkbox(props: { label: string; checked: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={props.checked}
|
||||
onChange={e => props.onChange(e.currentTarget.checked)}
|
||||
class="w-4 h-4 rounded border-gray-300 text-blue-600"
|
||||
/>
|
||||
<span class="text-sm text-gray-700">{props.label}</span>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { For, Show } from "solid-js"
|
||||
import type { Relay } from "../lib/api"
|
||||
|
||||
function Field(props: { label: string; children: any }) {
|
||||
return (
|
||||
<div>
|
||||
<dt class="text-xs font-medium text-gray-500 uppercase tracking-wide">{props.label}</dt>
|
||||
<dd class="mt-0.5 text-sm text-gray-900">{props.children}</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Badge(props: { on: boolean; onLabel?: string; offLabel?: string }) {
|
||||
const label = () => (props.on ? (props.onLabel ?? "Yes") : (props.offLabel ?? "No"))
|
||||
return (
|
||||
<span class={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${props.on ? "bg-green-100 text-green-700" : "bg-gray-100 text-gray-500"}`}>
|
||||
{label()}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Section(props: { title: string; children: any }) {
|
||||
return (
|
||||
<div>
|
||||
<h3 class="text-xs font-semibold text-gray-400 uppercase tracking-wider mb-3">{props.title}</h3>
|
||||
<dl class="grid grid-cols-2 gap-x-6 gap-y-4 sm:grid-cols-3">
|
||||
{props.children}
|
||||
</dl>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type RelayDetailCardProps = {
|
||||
relay: Relay
|
||||
showTenant?: boolean
|
||||
}
|
||||
|
||||
export default function RelayDetailCard(props: RelayDetailCardProps) {
|
||||
const r = () => props.relay
|
||||
const cfg = () => r().config
|
||||
|
||||
return (
|
||||
<div class="space-y-6">
|
||||
{/* Header */}
|
||||
<div class="flex items-start gap-4">
|
||||
<Show when={r().icon}>
|
||||
<img src={r().icon} alt="" class="w-14 h-14 rounded-xl object-cover flex-shrink-0 border border-gray-200" />
|
||||
</Show>
|
||||
<div class="min-w-0">
|
||||
<h1 class="text-2xl font-bold text-gray-900">{r().name}</h1>
|
||||
<a
|
||||
href={`wss://${r().subdomain}.spaces.coracle.social`}
|
||||
class="text-sm text-blue-600 hover:underline break-all"
|
||||
>
|
||||
wss://{r().subdomain}.spaces.coracle.social
|
||||
</a>
|
||||
<Show when={r().description.trim()}>
|
||||
<p class="mt-2 text-sm text-gray-600">{r().description}</p>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<hr class="border-gray-200" />
|
||||
|
||||
{/* Relay info */}
|
||||
<Section title="Relay">
|
||||
<Field label="Plan">
|
||||
<span class="capitalize">{r().plan}</span>
|
||||
</Field>
|
||||
<Field label="Status">
|
||||
<span class={`capitalize font-medium ${r().status === "active" ? "text-green-600" : r().status === "deactivated" ? "text-red-500" : "text-yellow-600"}`}>
|
||||
{r().status}
|
||||
</span>
|
||||
</Field>
|
||||
<Field label="Schema">{r().schema}</Field>
|
||||
<Show when={props.showTenant}>
|
||||
<Field label="Tenant">
|
||||
<span class="font-mono text-xs break-all">{r().tenant}</span>
|
||||
</Field>
|
||||
</Show>
|
||||
</Section>
|
||||
|
||||
<hr class="border-gray-200" />
|
||||
|
||||
{/* Config sections */}
|
||||
<Show when={cfg()}>
|
||||
<Section title="Policy">
|
||||
<Field label="Public join">
|
||||
<Badge on={cfg().policy.public_join} onLabel="Enabled" offLabel="Disabled" />
|
||||
</Field>
|
||||
<Field label="Strip signatures">
|
||||
<Badge on={cfg().policy.strip_signatures} onLabel="Enabled" offLabel="Disabled" />
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<hr class="border-gray-200" />
|
||||
|
||||
<Section title="Groups (NIP 29)">
|
||||
<Field label="Enabled">
|
||||
<Badge on={cfg().groups.enabled} />
|
||||
</Field>
|
||||
<Field label="Auto-join">
|
||||
<Badge on={cfg().groups.auto_join} />
|
||||
</Field>
|
||||
</Section>
|
||||
|
||||
<hr class="border-gray-200" />
|
||||
|
||||
<Section title="Management (NIP 86)">
|
||||
<Field label="Enabled">
|
||||
<Badge on={cfg().management.enabled} />
|
||||
</Field>
|
||||
|
||||
</Section>
|
||||
|
||||
<hr class="border-gray-200" />
|
||||
|
||||
<Section title="Features">
|
||||
<Field label="Blossom">
|
||||
<Badge on={cfg().blossom.enabled} onLabel="Enabled" offLabel="Disabled" />
|
||||
</Field>
|
||||
<Field label="Push (NIP 9a)">
|
||||
<Badge on={cfg().push.enabled} onLabel="Enabled" offLabel="Disabled" />
|
||||
</Field>
|
||||
</Section>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,3 +1,6 @@
|
||||
import type { RelayConfig } from "../lib/api"
|
||||
import Checkbox from "./Checkbox"
|
||||
|
||||
type RelayFormProps = {
|
||||
name: string
|
||||
setName: (value: string) => void
|
||||
@@ -10,6 +13,8 @@ type RelayFormProps = {
|
||||
plan: string
|
||||
setPlan: (value: string) => void
|
||||
plans: readonly string[]
|
||||
config: RelayConfig
|
||||
setConfig: (value: RelayConfig) => void
|
||||
onSubmit: (e: Event) => void
|
||||
submitting: boolean
|
||||
error?: string
|
||||
@@ -18,8 +23,13 @@ type RelayFormProps = {
|
||||
}
|
||||
|
||||
export default function RelayForm(props: RelayFormProps) {
|
||||
function patchConfig(patch: Partial<RelayConfig>) {
|
||||
props.setConfig({ ...props.config, ...patch })
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={props.onSubmit} class="space-y-4">
|
||||
<form onSubmit={props.onSubmit} class="space-y-6">
|
||||
{/* Basic info */}
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Relay Name</label>
|
||||
<input
|
||||
@@ -73,6 +83,67 @@ export default function RelayForm(props: RelayFormProps) {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Policy */}
|
||||
<fieldset class="border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<legend class="text-sm font-semibold text-gray-700 px-1">Policy</legend>
|
||||
<Checkbox
|
||||
label="Allow public join (anyone can join without an invite)"
|
||||
checked={props.config.policy.public_join}
|
||||
onChange={v => patchConfig({ policy: { ...props.config.policy, public_join: v } })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Strip signatures when serving events to non-admins"
|
||||
checked={props.config.policy.strip_signatures}
|
||||
onChange={v => patchConfig({ policy: { ...props.config.policy, strip_signatures: v } })}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{/* Groups (NIP 29) */}
|
||||
<fieldset class="border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<legend class="text-sm font-semibold text-gray-700 px-1">Groups (NIP 29)</legend>
|
||||
<Checkbox
|
||||
label="Enable NIP 29 groups"
|
||||
checked={props.config.groups.enabled}
|
||||
onChange={v => patchConfig({ groups: { ...props.config.groups, enabled: v } })}
|
||||
/>
|
||||
<Checkbox
|
||||
label="Allow members to join groups without approval"
|
||||
checked={props.config.groups.auto_join}
|
||||
onChange={v => patchConfig({ groups: { ...props.config.groups, auto_join: v } })}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{/* Management (NIP 86) */}
|
||||
<fieldset class="border border-gray-200 rounded-lg p-4 space-y-3">
|
||||
<legend class="text-sm font-semibold text-gray-700 px-1">Management (NIP 86)</legend>
|
||||
<Checkbox
|
||||
label="Enable NIP 86 relay management"
|
||||
checked={props.config.management.enabled}
|
||||
onChange={v => patchConfig({ management: { enabled: v } })}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{/* Blossom */}
|
||||
<fieldset class="border border-gray-200 rounded-lg p-4">
|
||||
<legend class="text-sm font-semibold text-gray-700 px-1">Blossom</legend>
|
||||
<Checkbox
|
||||
label="Enable Blossom media storage"
|
||||
checked={props.config.blossom.enabled}
|
||||
onChange={v => patchConfig({ blossom: { enabled: v } })}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{/* Push (NIP 9a) */}
|
||||
<fieldset class="border border-gray-200 rounded-lg p-4">
|
||||
<legend class="text-sm font-semibold text-gray-700 px-1">Push (NIP 9a)</legend>
|
||||
<Checkbox
|
||||
label="Enable NIP 9a push notifications"
|
||||
checked={props.config.push.enabled}
|
||||
onChange={v => patchConfig({ push: { enabled: v } })}
|
||||
/>
|
||||
</fieldset>
|
||||
|
||||
{props.error && <p class="text-sm text-red-600">{props.error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -87,6 +87,27 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export type RelayConfig = {
|
||||
policy: {
|
||||
public_join: boolean
|
||||
strip_signatures: boolean
|
||||
}
|
||||
groups: {
|
||||
enabled: boolean
|
||||
auto_join: boolean
|
||||
}
|
||||
management: {
|
||||
enabled: boolean
|
||||
|
||||
}
|
||||
blossom: {
|
||||
enabled: boolean
|
||||
}
|
||||
push: {
|
||||
enabled: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export type Relay = {
|
||||
id: string
|
||||
tenant: string
|
||||
@@ -97,6 +118,7 @@ export type Relay = {
|
||||
description: string
|
||||
plan: string
|
||||
status: string
|
||||
config: RelayConfig
|
||||
}
|
||||
|
||||
export type Tenant = {
|
||||
@@ -125,6 +147,7 @@ export type UpdateRelayInput = {
|
||||
icon: string
|
||||
description: string
|
||||
plan: string
|
||||
config: RelayConfig
|
||||
}
|
||||
|
||||
export type AdminCheck = {
|
||||
@@ -145,6 +168,7 @@ export type CreateRelayInput = {
|
||||
icon: string
|
||||
description: string
|
||||
plan: string
|
||||
config: RelayConfig
|
||||
}
|
||||
|
||||
export function createTenantRelay(input: CreateRelayInput) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createResource, createSignal, Show } from "solid-js"
|
||||
import { adminDeactivateRelay, adminGetRelay } from "../../lib/api"
|
||||
import BackLink from "../../components/BackLink"
|
||||
import PageContainer from "../../components/PageContainer"
|
||||
import RelayDetailCard from "../../components/RelayDetailCard"
|
||||
import ResourceState from "../../components/ResourceState"
|
||||
import useMinLoading from "../../components/useMinLoading"
|
||||
|
||||
@@ -43,14 +44,7 @@ export default function AdminRelayDetail() {
|
||||
<Show when={!loading() && relay()}>
|
||||
{(r) => (
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 mb-1 py-2">{r().name}</h1>
|
||||
<p class="text-sm text-gray-500">{r().subdomain}.spaces.coracle.social</p>
|
||||
<p class="text-sm text-gray-500 mt-2 break-all">Tenant: {r().tenant}</p>
|
||||
<p class="text-sm text-gray-700 mt-2">Plan: <span class="uppercase">{r().plan}</span></p>
|
||||
<p class="text-sm text-gray-700">Status: <span class="uppercase">{r().status}</span></p>
|
||||
<Show when={r().description.trim()}>
|
||||
<p class="mt-3 text-gray-700">{r().description}</p>
|
||||
</Show>
|
||||
<RelayDetailCard relay={r()} showTenant />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { Show, createEffect, createResource, createSignal } from "solid-js"
|
||||
import { adminGetRelay, adminUpdateRelay } from "../../lib/api"
|
||||
import { adminGetRelay, adminUpdateRelay, type RelayConfig } from "../../lib/api"
|
||||
import RelayForm from "../../components/RelayForm"
|
||||
import { RELAY_PLAN_IDS, type RelayPlanId } from "../../lib/relayPlans"
|
||||
import { slugify } from "../../lib/slugify"
|
||||
@@ -9,6 +9,14 @@ import PageContainer from "../../components/PageContainer"
|
||||
import ResourceState from "../../components/ResourceState"
|
||||
import useMinLoading from "../../components/useMinLoading"
|
||||
|
||||
const DEFAULT_CONFIG: RelayConfig = {
|
||||
policy: { public_join: false, strip_signatures: false },
|
||||
groups: { enabled: false, auto_join: false },
|
||||
management: { enabled: false },
|
||||
blossom: { enabled: false },
|
||||
push: { enabled: false },
|
||||
}
|
||||
|
||||
export default function AdminRelayEdit() {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
@@ -21,6 +29,7 @@ export default function AdminRelayEdit() {
|
||||
const [icon, setIcon] = createSignal("")
|
||||
const [description, setDescription] = createSignal("")
|
||||
const [plan, setPlan] = createSignal<RelayPlanId>("free")
|
||||
const [config, setConfig] = createSignal<RelayConfig>(DEFAULT_CONFIG)
|
||||
const [error, setError] = createSignal("")
|
||||
const [submitting, setSubmitting] = createSignal(false)
|
||||
|
||||
@@ -32,6 +41,7 @@ export default function AdminRelayEdit() {
|
||||
setIcon(data.icon)
|
||||
setDescription(data.description)
|
||||
setPlan(RELAY_PLAN_IDS.includes(data.plan as RelayPlanId) ? (data.plan as RelayPlanId) : "free")
|
||||
setConfig(data.config ?? DEFAULT_CONFIG)
|
||||
})
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
@@ -45,6 +55,7 @@ export default function AdminRelayEdit() {
|
||||
icon: icon().trim(),
|
||||
description: description().trim(),
|
||||
plan: plan(),
|
||||
config: config(),
|
||||
})
|
||||
navigate(`/admin/relays/${relayId()}`)
|
||||
} catch (e) {
|
||||
@@ -79,6 +90,8 @@ export default function AdminRelayEdit() {
|
||||
plan={plan()}
|
||||
setPlan={setPlan}
|
||||
plans={RELAY_PLAN_IDS}
|
||||
config={config()}
|
||||
setConfig={setConfig}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting()}
|
||||
error={error()}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { createResource, createSignal, Show } from "solid-js"
|
||||
import { deactivateTenantRelay, getTenantRelay } from "../../lib/api"
|
||||
import BackLink from "../../components/BackLink"
|
||||
import PageContainer from "../../components/PageContainer"
|
||||
import RelayDetailCard from "../../components/RelayDetailCard"
|
||||
import ResourceState from "../../components/ResourceState"
|
||||
import useMinLoading from "../../components/useMinLoading"
|
||||
|
||||
@@ -41,13 +42,9 @@ export default function RelayDetail() {
|
||||
/>
|
||||
|
||||
<Show when={!loading() && relay()}>
|
||||
{(loadedRelay) => (
|
||||
{(r) => (
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 py-2">{loadedRelay().name}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500">https://{loadedRelay().subdomain}.spaces.coracle.social</p>
|
||||
<Show when={loadedRelay().description.trim()}>
|
||||
<p class="mt-3 text-gray-700">{loadedRelay().description}</p>
|
||||
</Show>
|
||||
<RelayDetailCard relay={r()} />
|
||||
</div>
|
||||
)}
|
||||
</Show>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { Show, createEffect, createResource, createSignal } from "solid-js"
|
||||
import { getTenantRelay, updateTenantRelay } from "../../lib/api"
|
||||
import { getTenantRelay, updateTenantRelay, type RelayConfig } from "../../lib/api"
|
||||
import RelayForm from "../../components/RelayForm"
|
||||
import { RELAY_PLAN_IDS, type RelayPlanId } from "../../lib/relayPlans"
|
||||
import { slugify } from "../../lib/slugify"
|
||||
@@ -9,6 +9,14 @@ import PageContainer from "../../components/PageContainer"
|
||||
import ResourceState from "../../components/ResourceState"
|
||||
import useMinLoading from "../../components/useMinLoading"
|
||||
|
||||
const DEFAULT_CONFIG: RelayConfig = {
|
||||
policy: { public_join: false, strip_signatures: false },
|
||||
groups: { enabled: false, auto_join: false },
|
||||
management: { enabled: false },
|
||||
blossom: { enabled: false },
|
||||
push: { enabled: false },
|
||||
}
|
||||
|
||||
export default function RelayEdit() {
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
@@ -21,6 +29,7 @@ export default function RelayEdit() {
|
||||
const [icon, setIcon] = createSignal("")
|
||||
const [description, setDescription] = createSignal("")
|
||||
const [plan, setPlan] = createSignal<RelayPlanId>("free")
|
||||
const [config, setConfig] = createSignal<RelayConfig>(DEFAULT_CONFIG)
|
||||
const [error, setError] = createSignal("")
|
||||
const [submitting, setSubmitting] = createSignal(false)
|
||||
|
||||
@@ -32,6 +41,7 @@ export default function RelayEdit() {
|
||||
setIcon(data.icon)
|
||||
setDescription(data.description)
|
||||
setPlan(RELAY_PLAN_IDS.includes(data.plan as RelayPlanId) ? (data.plan as RelayPlanId) : "free")
|
||||
setConfig(data.config ?? DEFAULT_CONFIG)
|
||||
})
|
||||
|
||||
async function handleSubmit(e: Event) {
|
||||
@@ -45,6 +55,7 @@ export default function RelayEdit() {
|
||||
icon: icon().trim(),
|
||||
description: description().trim(),
|
||||
plan: plan(),
|
||||
config: config(),
|
||||
})
|
||||
navigate(`/relays/${relayId()}`)
|
||||
} catch (e) {
|
||||
@@ -79,6 +90,8 @@ export default function RelayEdit() {
|
||||
plan={plan()}
|
||||
setPlan={setPlan}
|
||||
plans={RELAY_PLAN_IDS}
|
||||
config={config()}
|
||||
setConfig={setConfig}
|
||||
onSubmit={handleSubmit}
|
||||
submitting={submitting()}
|
||||
error={error()}
|
||||
|
||||
Reference in New Issue
Block a user