mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
Add native Builderlab auth and community client (#2099)
Signed-off-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Bradley Axen <baxen@squareup.com> Co-authored-by: npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79 <1a7496365ae2df8adb1b932a1a6f68b9cb3950f3d179c8a451c32d194e8f7dc1@sprout-oss.stage.blox.sqprod.co> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
npub1rf6fvdj6ut0c4kcmjv4p5mmgh89nj58n69uu3fz3cvk3jn500hqs7emz79
Claude Opus 4.8
parent
af4b7087dc
commit
8908bd6b71
@@ -50,6 +50,12 @@ const rules = [
|
||||
// Do not add to this list; split the file instead. Remove each entry as its
|
||||
// file is broken up. Tracked as a follow-up.
|
||||
const overrides = new Map([
|
||||
// Native Builderlab auth/community commands add a small registration surface
|
||||
// to the existing Tauri composition root. The implementation lives in
|
||||
// builderlab.rs; this narrowly ratchets the command wiring while lib.rs is
|
||||
// queued for a broader composition-root split. Bumped for the
|
||||
// archive/unarchive/transfer community-management commands (web parity).
|
||||
["src-tauri/src/lib.rs", 1013],
|
||||
// persona-events rebase: build_deploy_payload threads `state` for the
|
||||
// read-time relay-URL workspace fallback while keeping the create-time env
|
||||
// pin (the credential-leak guard). Load-bearing feature growth from the
|
||||
|
||||
@@ -0,0 +1,508 @@
|
||||
use std::{collections::HashMap, sync::Mutex, time::Duration};
|
||||
|
||||
use axum::{
|
||||
extract::{Path, Query, State as AxumState},
|
||||
http::StatusCode,
|
||||
response::{Html, IntoResponse, Response},
|
||||
routing::get,
|
||||
Router,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri_plugin_opener::OpenerExt;
|
||||
use tokio::{net::TcpListener, sync::oneshot};
|
||||
use url::Url;
|
||||
|
||||
const BUILDERLAB_API_BASE_URL: &str = "https://app.builderlab.xyz/api/goose";
|
||||
const LOGIN_TIMEOUT: Duration = Duration::from_secs(10 * 60);
|
||||
const BB_SESSION_CREDENTIAL_HEADER: &str = "X-BB-Session-Credential";
|
||||
// Builderlab enforces an Origin check on the identity bind endpoints. Browsers
|
||||
// attach this automatically; the desktop reqwest client must set it explicitly
|
||||
// or challenge/verify fail with `invalid_origin`. It also seeds the challenge
|
||||
// body's `origin` field so both agree.
|
||||
const BUILDERLAB_ORIGIN: &str = "https://app.builderlab.xyz";
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct BuilderlabSession(Mutex<Option<StoredSession>>);
|
||||
|
||||
struct StoredSession {
|
||||
credential: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct LoginExchangeResponse {
|
||||
session_credential: String,
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct BuilderlabAuthInfo {
|
||||
expires_at: String,
|
||||
email: Option<String>,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct AuthMeResponse {
|
||||
email: Option<String>,
|
||||
name: Option<String>,
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
struct CallbackState {
|
||||
nonce: String,
|
||||
sender: Mutex<Option<oneshot::Sender<Result<String, String>>>>,
|
||||
}
|
||||
|
||||
async fn login_callback(
|
||||
Path(nonce): Path<String>,
|
||||
Query(query): Query<HashMap<String, String>>,
|
||||
AxumState(state): AxumState<std::sync::Arc<CallbackState>>,
|
||||
) -> Response {
|
||||
if nonce != state.nonce {
|
||||
return (StatusCode::NOT_FOUND, "Not found").into_response();
|
||||
}
|
||||
|
||||
let result = match query.get("code").filter(|code| !code.is_empty()) {
|
||||
Some(code) => Ok(code.clone()),
|
||||
None => Err(query
|
||||
.get("error_description")
|
||||
.or_else(|| query.get("error"))
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "Authentication callback did not include a code".to_owned())),
|
||||
};
|
||||
if let Some(sender) = state
|
||||
.sender
|
||||
.lock()
|
||||
.expect("callback sender poisoned")
|
||||
.take()
|
||||
{
|
||||
let _ = sender.send(result);
|
||||
}
|
||||
|
||||
Html(
|
||||
"<!doctype html><meta charset=utf-8><title>Buzz authentication complete</title>\
|
||||
<h1>Authentication complete</h1><p>You can close this window and return to Buzz.</p>",
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
fn api_url(path: &str) -> Result<Url, String> {
|
||||
Url::parse(&format!("{BUILDERLAB_API_BASE_URL}{path}"))
|
||||
.map_err(|error| format!("invalid Builderlab API URL: {error}"))
|
||||
}
|
||||
|
||||
fn login_url(return_to: &str) -> Result<Url, String> {
|
||||
let mut login_url = api_url("/v1/auth/login")?;
|
||||
login_url
|
||||
.query_pairs_mut()
|
||||
.append_pair("type", "cli")
|
||||
.append_pair("product", "buzz")
|
||||
.append_pair("returnTo", return_to);
|
||||
Ok(login_url)
|
||||
}
|
||||
|
||||
async fn authenticated_user(
|
||||
client: &reqwest::Client,
|
||||
credential: &str,
|
||||
) -> Result<AuthMeResponse, String> {
|
||||
let response = client
|
||||
.get(api_url("/v1/auth/me")?)
|
||||
.header(BB_SESSION_CREDENTIAL_HEADER, credential)
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Builderlab session check failed: {error}"))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Builderlab session check failed with HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|error| format!("invalid Builderlab session response: {error}"))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn start_builderlab_login(
|
||||
app: tauri::AppHandle,
|
||||
app_state: tauri::State<'_, crate::app_state::AppState>,
|
||||
session: tauri::State<'_, BuilderlabSession>,
|
||||
) -> Result<BuilderlabAuthInfo, String> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.map_err(|error| format!("could not start local authentication callback: {error}"))?;
|
||||
let port = listener
|
||||
.local_addr()
|
||||
.map_err(|error| format!("could not read local authentication callback: {error}"))?
|
||||
.port();
|
||||
let nonce = uuid::Uuid::new_v4().simple().to_string();
|
||||
let return_to = format!("http://127.0.0.1:{port}/callback/{nonce}");
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
let callback_state = std::sync::Arc::new(CallbackState {
|
||||
nonce: nonce.clone(),
|
||||
sender: Mutex::new(Some(sender)),
|
||||
});
|
||||
let router = Router::new()
|
||||
.route("/callback/{nonce}", get(login_callback))
|
||||
.with_state(callback_state);
|
||||
let server = tokio::spawn(async move {
|
||||
let _ = axum::serve(listener, router).await;
|
||||
});
|
||||
|
||||
let login_url = login_url(&return_to)?;
|
||||
if let Err(error) = app.opener().open_url(login_url.as_str(), None::<&str>) {
|
||||
server.abort();
|
||||
return Err(format!("could not open Builderlab authentication: {error}"));
|
||||
}
|
||||
|
||||
let exchange_code = match tokio::time::timeout(LOGIN_TIMEOUT, receiver).await {
|
||||
Ok(Ok(Ok(code))) => code,
|
||||
Ok(Ok(Err(error))) => {
|
||||
server.abort();
|
||||
return Err(error);
|
||||
}
|
||||
Ok(Err(_)) => {
|
||||
server.abort();
|
||||
return Err("local authentication callback stopped unexpectedly".to_owned());
|
||||
}
|
||||
Err(_) => {
|
||||
server.abort();
|
||||
return Err("Builderlab authentication timed out".to_owned());
|
||||
}
|
||||
};
|
||||
server.abort();
|
||||
|
||||
let response = app_state
|
||||
.http_client
|
||||
.post(api_url("/v1/auth/login/exchange")?)
|
||||
.json(&serde_json::json!({ "code": exchange_code }))
|
||||
.timeout(Duration::from_secs(30))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Builderlab code exchange failed: {error}"))?;
|
||||
if !response.status().is_success() {
|
||||
return Err(format!(
|
||||
"Builderlab code exchange failed with HTTP {}",
|
||||
response.status()
|
||||
));
|
||||
}
|
||||
let exchanged: LoginExchangeResponse = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|error| format!("invalid Builderlab code exchange response: {error}"))?;
|
||||
if exchanged.session_credential.is_empty() {
|
||||
return Err("Builderlab code exchange returned an empty credential".to_owned());
|
||||
}
|
||||
|
||||
let me = authenticated_user(&app_state.http_client, &exchanged.session_credential).await?;
|
||||
if exchanged.expires_at != me.expires_at {
|
||||
return Err("Builderlab session expiry did not match code exchange".to_owned());
|
||||
}
|
||||
let info = BuilderlabAuthInfo {
|
||||
expires_at: me.expires_at.clone(),
|
||||
email: me.email,
|
||||
name: me.name,
|
||||
};
|
||||
*session.0.lock().map_err(|error| error.to_string())? = Some(StoredSession {
|
||||
credential: exchanged.session_credential,
|
||||
});
|
||||
Ok(info)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_builderlab_auth(
|
||||
app_state: tauri::State<'_, crate::app_state::AppState>,
|
||||
session: tauri::State<'_, BuilderlabSession>,
|
||||
) -> Result<Option<BuilderlabAuthInfo>, String> {
|
||||
let stored = session
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?
|
||||
.as_ref()
|
||||
.map(|stored| stored.credential.clone());
|
||||
let Some(credential) = stored else {
|
||||
return Ok(None);
|
||||
};
|
||||
match authenticated_user(&app_state.http_client, &credential).await {
|
||||
Ok(me) => Ok(Some(BuilderlabAuthInfo {
|
||||
expires_at: me.expires_at,
|
||||
email: me.email,
|
||||
name: me.name,
|
||||
})),
|
||||
Err(error) => {
|
||||
*session
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|lock_error| lock_error.to_string())? = None;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) fn clear_builderlab_auth(
|
||||
session: tauri::State<'_, BuilderlabSession>,
|
||||
) -> Result<(), String> {
|
||||
*session.0.lock().map_err(|error| error.to_string())? = None;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct NostrIdentityChallenge {
|
||||
challenge_id: String,
|
||||
nonce: String,
|
||||
verification_code: String,
|
||||
origin: String,
|
||||
expires_at: String,
|
||||
}
|
||||
|
||||
async fn authenticated_json(
|
||||
client: &reqwest::Client,
|
||||
session: &BuilderlabSession,
|
||||
method: reqwest::Method,
|
||||
path: &str,
|
||||
body: serde_json::Value,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let credential = session
|
||||
.0
|
||||
.lock()
|
||||
.map_err(|error| error.to_string())?
|
||||
.as_ref()
|
||||
.map(|stored| stored.credential.clone())
|
||||
.ok_or_else(|| "Sign in to Builderlab first".to_owned())?;
|
||||
let response = client
|
||||
.request(method, api_url(path)?)
|
||||
.header(BB_SESSION_CREDENTIAL_HEADER, credential)
|
||||
.header(reqwest::header::ORIGIN, BUILDERLAB_ORIGIN)
|
||||
.json(&body)
|
||||
.timeout(Duration::from_secs(60))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| format!("Builderlab request failed: {error}"))?;
|
||||
let status = response.status();
|
||||
let value: serde_json::Value = response
|
||||
.json()
|
||||
.await
|
||||
.map_err(|error| format!("invalid Builderlab response: {error}"))?;
|
||||
if !status.is_success() {
|
||||
// Builderlab error responses carry a structured `{ error: { code,
|
||||
// message, setup_needed, ... } }` body. Pass those through as `Ok` so the
|
||||
// frontend's typed handling and friendly per-code messages apply, instead
|
||||
// of surfacing a raw JSON blob. Only fall back to a plain string when the
|
||||
// body isn't the expected shape.
|
||||
if value.get("error").is_some() {
|
||||
return Ok(value);
|
||||
}
|
||||
return Err(format!("Builderlab request failed (HTTP {status})."));
|
||||
}
|
||||
Ok(value)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn get_builderlab_nostr_identity(
|
||||
app_state: tauri::State<'_, crate::app_state::AppState>,
|
||||
session: tauri::State<'_, BuilderlabSession>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
authenticated_json(
|
||||
&app_state.http_client,
|
||||
&session,
|
||||
reqwest::Method::POST,
|
||||
"/v1/buzz/nostr-identities/current",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn bind_builderlab_nostr_identity(
|
||||
app_state: tauri::State<'_, crate::app_state::AppState>,
|
||||
session: tauri::State<'_, BuilderlabSession>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
let challenge_value = authenticated_json(
|
||||
&app_state.http_client,
|
||||
&session,
|
||||
reqwest::Method::POST,
|
||||
"/v1/buzz/nostr-identities/challenge",
|
||||
serde_json::json!({ "origin": BUILDERLAB_ORIGIN }),
|
||||
)
|
||||
.await?;
|
||||
// A structured error here (e.g. missing_mapping) arrives as an object with an
|
||||
// `error` field rather than a challenge — hand it straight back so the
|
||||
// frontend maps it to a friendly message instead of hitting a deserialize
|
||||
// failure below.
|
||||
if challenge_value.get("error").is_some() {
|
||||
return Ok(challenge_value);
|
||||
}
|
||||
let challenge: NostrIdentityChallenge = serde_json::from_value(challenge_value)
|
||||
.map_err(|error| format!("invalid Nostr identity challenge: {error}"))?;
|
||||
let keys = app_state.signing_keys()?;
|
||||
let event = crate::commands::build_nostr_identity_binding_event(
|
||||
&keys,
|
||||
&challenge.challenge_id,
|
||||
&challenge.nonce,
|
||||
&challenge.verification_code,
|
||||
&challenge.origin,
|
||||
&challenge.expires_at,
|
||||
)?;
|
||||
authenticated_json(
|
||||
&app_state.http_client,
|
||||
&session,
|
||||
reqwest::Method::POST,
|
||||
"/v1/buzz/nostr-identities/verify",
|
||||
serde_json::json!({
|
||||
"challenge_id": challenge.challenge_id,
|
||||
"nonce": challenge.nonce,
|
||||
"signed_payload": nostr::JsonUtil::as_json(&event),
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn delete_builderlab_nostr_identity(
|
||||
app_state: tauri::State<'_, crate::app_state::AppState>,
|
||||
session: tauri::State<'_, BuilderlabSession>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
authenticated_json(
|
||||
&app_state.http_client,
|
||||
&session,
|
||||
reqwest::Method::POST,
|
||||
"/v1/buzz/nostr-identities/delete",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn list_builderlab_communities(
|
||||
app_state: tauri::State<'_, crate::app_state::AppState>,
|
||||
session: tauri::State<'_, BuilderlabSession>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
authenticated_json(
|
||||
&app_state.http_client,
|
||||
&session,
|
||||
reqwest::Method::POST,
|
||||
"/v1/buzz/communities/list",
|
||||
serde_json::json!({}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn check_builderlab_community_name(
|
||||
name: String,
|
||||
app_state: tauri::State<'_, crate::app_state::AppState>,
|
||||
session: tauri::State<'_, BuilderlabSession>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
authenticated_json(
|
||||
&app_state.http_client,
|
||||
&session,
|
||||
reqwest::Method::POST,
|
||||
"/v1/buzz/communities/availability",
|
||||
serde_json::json!({ "name": name }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn create_builderlab_community(
|
||||
name: String,
|
||||
app_state: tauri::State<'_, crate::app_state::AppState>,
|
||||
session: tauri::State<'_, BuilderlabSession>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
authenticated_json(
|
||||
&app_state.http_client,
|
||||
&session,
|
||||
reqwest::Method::POST,
|
||||
"/v1/buzz/communities",
|
||||
serde_json::json!({ "name": name }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn archive_builderlab_community(
|
||||
community_id: String,
|
||||
app_state: tauri::State<'_, crate::app_state::AppState>,
|
||||
session: tauri::State<'_, BuilderlabSession>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
authenticated_json(
|
||||
&app_state.http_client,
|
||||
&session,
|
||||
reqwest::Method::POST,
|
||||
"/v1/buzz/communities/archive",
|
||||
serde_json::json!({ "community_id": community_id }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn unarchive_builderlab_community(
|
||||
community_id: String,
|
||||
app_state: tauri::State<'_, crate::app_state::AppState>,
|
||||
session: tauri::State<'_, BuilderlabSession>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
authenticated_json(
|
||||
&app_state.http_client,
|
||||
&session,
|
||||
reqwest::Method::POST,
|
||||
"/v1/buzz/communities/unarchive",
|
||||
serde_json::json!({ "community_id": community_id }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub(crate) async fn transfer_builderlab_community(
|
||||
community_id: String,
|
||||
transferee_npub: String,
|
||||
app_state: tauri::State<'_, crate::app_state::AppState>,
|
||||
session: tauri::State<'_, BuilderlabSession>,
|
||||
) -> Result<serde_json::Value, String> {
|
||||
// The Builderlab transfer endpoint expects camelCase keys, unlike the
|
||||
// archive/unarchive endpoints which take `community_id`; mirror the web
|
||||
// client's payload exactly.
|
||||
authenticated_json(
|
||||
&app_state.http_client,
|
||||
&session,
|
||||
reqwest::Method::POST,
|
||||
"/v1/buzz/communities/transfer",
|
||||
serde_json::json!({
|
||||
"communityId": community_id,
|
||||
"transfereeNpub": transferee_npub,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn api_paths_stay_on_builderlab_api_origin() {
|
||||
let login = api_url("/v1/auth/login").unwrap();
|
||||
assert_eq!(
|
||||
login.origin().ascii_serialization(),
|
||||
"https://app.builderlab.xyz"
|
||||
);
|
||||
assert_eq!(login.path(), "/api/goose/v1/auth/login");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_defaults_to_auth0_login() {
|
||||
let login = login_url("http://127.0.0.1:1234/callback/nonce").unwrap();
|
||||
let query: HashMap<_, _> = login.query_pairs().into_owned().collect();
|
||||
|
||||
assert_eq!(query.get("type").map(String::as_str), Some("cli"));
|
||||
assert_eq!(query.get("product").map(String::as_str), Some("buzz"));
|
||||
assert_eq!(
|
||||
query.get("returnTo").map(String::as_str),
|
||||
Some("http://127.0.0.1:1234/callback/nonce")
|
||||
);
|
||||
assert!(!query.contains_key("screen_hint"));
|
||||
}
|
||||
}
|
||||
@@ -343,7 +343,7 @@ fn nostr_bind_tag(name: &str, value: &str) -> Result<Tag, String> {
|
||||
Tag::parse(vec![name, value]).map_err(|error| format!("{name} tag failed: {error}"))
|
||||
}
|
||||
|
||||
fn build_nostr_identity_binding_event(
|
||||
pub(crate) fn build_nostr_identity_binding_event(
|
||||
keys: &Keys,
|
||||
challenge_id: &str,
|
||||
nonce: &str,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#![recursion_limit = "256"]
|
||||
mod app_state;
|
||||
mod archive;
|
||||
mod builderlab;
|
||||
mod commands;
|
||||
mod deep_link;
|
||||
mod event_sync;
|
||||
@@ -11,6 +12,8 @@ mod managed_agents;
|
||||
mod media_proxy;
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
mod mesh_llm;
|
||||
#[cfg(not(feature = "mesh-llm"))]
|
||||
mod mesh_llm_stubs;
|
||||
mod migration;
|
||||
#[cfg(test)]
|
||||
mod model_tests;
|
||||
@@ -26,13 +29,8 @@ mod secret_store;
|
||||
mod shutdown;
|
||||
mod templates;
|
||||
mod util;
|
||||
|
||||
#[cfg(not(feature = "mesh-llm"))]
|
||||
mod mesh_llm_stubs;
|
||||
#[cfg(not(feature = "mesh-llm"))]
|
||||
use mesh_llm_stubs::*;
|
||||
|
||||
use app_state::{build_app_state, resolve_persisted_identity, AppState};
|
||||
use builderlab::*;
|
||||
use commands::*;
|
||||
use deep_link::{
|
||||
acknowledge_pending_community_deep_link, handle_deep_link_url,
|
||||
@@ -49,6 +47,8 @@ use huddle::{
|
||||
set_voice_input_mode, speak_agent_message, start_huddle, start_stt_pipeline,
|
||||
};
|
||||
use managed_agents::{backfill_persona_snapshots, ensure_nest, try_regenerate_nest};
|
||||
#[cfg(not(feature = "mesh-llm"))]
|
||||
use mesh_llm_stubs::*;
|
||||
#[cfg(all(feature = "mesh-llm", target_os = "macos"))]
|
||||
use shutdown::{hard_exit_after_mesh_shutdown, relaunch_after_mesh_shutdown};
|
||||
use shutdown::{is_restart_request, shut_down_app};
|
||||
@@ -450,6 +450,7 @@ pub fn run() {
|
||||
.manage(build_app_state())
|
||||
.manage(ClipboardState::new())
|
||||
.manage(PendingCommunityDeepLinks::default())
|
||||
.manage(BuilderlabSession::default())
|
||||
.manage(commands::pairing::PairingHandle::new())
|
||||
.setup(move |app| {
|
||||
let app_handle = app.handle().clone();
|
||||
@@ -742,6 +743,18 @@ pub fn run() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
take_pending_community_deep_link,
|
||||
acknowledge_pending_community_deep_link,
|
||||
start_builderlab_login,
|
||||
get_builderlab_auth,
|
||||
clear_builderlab_auth,
|
||||
get_builderlab_nostr_identity,
|
||||
bind_builderlab_nostr_identity,
|
||||
delete_builderlab_nostr_identity,
|
||||
list_builderlab_communities,
|
||||
check_builderlab_community_name,
|
||||
create_builderlab_community,
|
||||
archive_builderlab_community,
|
||||
unarchive_builderlab_community,
|
||||
transfer_builderlab_community,
|
||||
title_bar_double_click,
|
||||
get_identity,
|
||||
get_nsec,
|
||||
|
||||
@@ -0,0 +1,989 @@
|
||||
import * as React from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
ArchiveRestore,
|
||||
ArrowLeftRight,
|
||||
CheckCircle2,
|
||||
ExternalLink,
|
||||
LoaderCircle,
|
||||
LogOut,
|
||||
RefreshCw,
|
||||
Unlink,
|
||||
} from "lucide-react";
|
||||
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { safeNpub } from "@/shared/lib/nostrUtils";
|
||||
import { useCommunityOnboarding } from "@/features/onboarding/communityOnboarding";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from "@/shared/ui/alert-dialog";
|
||||
import { Button, buttonVariants } from "@/shared/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
||||
|
||||
const HOST_SUFFIX = "communities.buzz.xyz";
|
||||
const VALID_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
||||
const MAX_COMMUNITIES = 3;
|
||||
|
||||
type BuilderlabAuth = {
|
||||
email?: string;
|
||||
name?: string;
|
||||
expiresAt: string;
|
||||
};
|
||||
|
||||
type ApiError = {
|
||||
code?: string;
|
||||
message?: string;
|
||||
setup_needed?: boolean;
|
||||
};
|
||||
|
||||
type NostrIdentity = {
|
||||
npub?: string;
|
||||
pubkey_hex?: string;
|
||||
};
|
||||
|
||||
type IdentityResponse = {
|
||||
identity?: NostrIdentity;
|
||||
error?: ApiError;
|
||||
correlation_id?: string;
|
||||
};
|
||||
|
||||
type HostedCommunity = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
normalized_host?: string;
|
||||
owner_pubkey?: string;
|
||||
archived_at?: string | null;
|
||||
};
|
||||
|
||||
type CommunitiesResponse = {
|
||||
communities?: HostedCommunity[];
|
||||
error?: ApiError;
|
||||
correlation_id?: string;
|
||||
};
|
||||
|
||||
type AvailabilityResponse = {
|
||||
available?: boolean;
|
||||
normalized_host?: string;
|
||||
error?: ApiError;
|
||||
correlation_id?: string;
|
||||
};
|
||||
|
||||
type CommunityMutationResponse = {
|
||||
community?: HostedCommunity;
|
||||
error?: ApiError;
|
||||
correlation_id?: string;
|
||||
};
|
||||
|
||||
function errorMessage(
|
||||
error: ApiError | undefined,
|
||||
correlationId: string | undefined,
|
||||
fallback: string,
|
||||
) {
|
||||
const messages: Record<string, string> = {
|
||||
missing_mapping: "Connect your Buzz identity before creating a community.",
|
||||
invalid_name: "Use lowercase letters, numbers, and hyphens.",
|
||||
taken: "That Buzz address is already taken.",
|
||||
limit_reached: `You've reached the limit of ${MAX_COMMUNITIES} hosted communities.`,
|
||||
relay_unavailable: "Community provisioning is temporarily unavailable.",
|
||||
identity_already_bound:
|
||||
"This Builderlab account is connected to another Buzz identity.",
|
||||
pubkey_already_bound:
|
||||
"This Buzz identity is connected to another Builderlab account.",
|
||||
not_owner: "Only the community owner can do that.",
|
||||
transferee_not_registered:
|
||||
"That person needs a connected Buzz identity before you can transfer ownership to them.",
|
||||
};
|
||||
const message = messages[error?.code ?? ""] ?? error?.message ?? fallback;
|
||||
return correlationId
|
||||
? `${message} Correlation ID: ${correlationId}`
|
||||
: message;
|
||||
}
|
||||
|
||||
function relayUrl(community: HostedCommunity) {
|
||||
const host = community.normalized_host?.trim();
|
||||
return host ? `wss://${host.replace(/^wss?:\/\//, "")}` : null;
|
||||
}
|
||||
|
||||
export function HostedCommunitiesSettingsCard() {
|
||||
const onboarding = useCommunityOnboarding();
|
||||
const localPubkey = useIdentityQuery().data?.pubkey ?? null;
|
||||
const [auth, setAuth] = React.useState<BuilderlabAuth | null>(null);
|
||||
const [communities, setCommunities] = React.useState<HostedCommunity[]>([]);
|
||||
const [identity, setIdentity] = React.useState<NostrIdentity | null>(null);
|
||||
const [name, setName] = React.useState("");
|
||||
const [availability, setAvailability] = React.useState<boolean | null>(null);
|
||||
const [checkingName, setCheckingName] = React.useState(false);
|
||||
const [loading, setLoading] = React.useState(true);
|
||||
const [action, setAction] = React.useState<string | null>(null);
|
||||
const [error, setError] = React.useState<string | null>(null);
|
||||
|
||||
const loadAccount = React.useCallback(async () => {
|
||||
setError(null);
|
||||
const [identityResponse, communitiesResponse] = await Promise.all([
|
||||
invoke<IdentityResponse>("get_builderlab_nostr_identity"),
|
||||
invoke<CommunitiesResponse>("list_builderlab_communities"),
|
||||
]);
|
||||
if (
|
||||
identityResponse.error &&
|
||||
identityResponse.error.code !== "unauthorized" &&
|
||||
// `missing_mapping` (setup_needed) just means this account hasn't linked a
|
||||
// Buzz identity yet — that's the connect-card empty state, not an error to
|
||||
// surface at the top of the page.
|
||||
!identityResponse.error.setup_needed
|
||||
) {
|
||||
throw new Error(
|
||||
errorMessage(
|
||||
identityResponse.error,
|
||||
identityResponse.correlation_id,
|
||||
"Could not load the connected Buzz identity.",
|
||||
),
|
||||
);
|
||||
}
|
||||
if (communitiesResponse.error && !communitiesResponse.error.setup_needed) {
|
||||
throw new Error(
|
||||
errorMessage(
|
||||
communitiesResponse.error,
|
||||
communitiesResponse.correlation_id,
|
||||
"Could not load communities.",
|
||||
),
|
||||
);
|
||||
}
|
||||
setIdentity(identityResponse.identity ?? null);
|
||||
setCommunities(communitiesResponse.communities ?? []);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true;
|
||||
void invoke<BuilderlabAuth | null>("get_builderlab_auth")
|
||||
.then(async (nextAuth) => {
|
||||
if (!active) return;
|
||||
setAuth(nextAuth);
|
||||
if (nextAuth) await loadAccount();
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (active) setError(String(cause));
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [loadAccount]);
|
||||
|
||||
// Returns whether the operation completed without throwing so callers (e.g.
|
||||
// dialogs) can close themselves only on success.
|
||||
const run = async (label: string, operation: () => Promise<void>) => {
|
||||
setAction(label);
|
||||
setError(null);
|
||||
try {
|
||||
await operation();
|
||||
return true;
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : String(cause));
|
||||
return false;
|
||||
} finally {
|
||||
setAction(null);
|
||||
}
|
||||
};
|
||||
|
||||
const signIn = () =>
|
||||
run("Signing in…", async () => {
|
||||
const nextAuth = await invoke<BuilderlabAuth>("start_builderlab_login");
|
||||
setAuth(nextAuth);
|
||||
await loadAccount();
|
||||
});
|
||||
|
||||
const signOut = () =>
|
||||
run("Signing out…", async () => {
|
||||
await invoke("clear_builderlab_auth");
|
||||
setAuth(null);
|
||||
setIdentity(null);
|
||||
setCommunities([]);
|
||||
setName("");
|
||||
setAvailability(null);
|
||||
});
|
||||
|
||||
const connectIdentity = () =>
|
||||
run("Connecting Buzz identity…", async () => {
|
||||
const response = await invoke<IdentityResponse>(
|
||||
"bind_builderlab_nostr_identity",
|
||||
);
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
errorMessage(
|
||||
response.error,
|
||||
response.correlation_id,
|
||||
"Could not connect the Buzz identity.",
|
||||
),
|
||||
);
|
||||
}
|
||||
setIdentity(response.identity ?? null);
|
||||
await loadAccount();
|
||||
});
|
||||
|
||||
const unpairIdentity = () =>
|
||||
run("Unpairing identity…", async () => {
|
||||
const response = await invoke<IdentityResponse>(
|
||||
"delete_builderlab_nostr_identity",
|
||||
);
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
errorMessage(
|
||||
response.error,
|
||||
response.correlation_id,
|
||||
"Could not unpair the Buzz identity.",
|
||||
),
|
||||
);
|
||||
}
|
||||
setIdentity(null);
|
||||
await loadAccount();
|
||||
});
|
||||
|
||||
// The Builderlab account can be bound to an npub that differs from the key
|
||||
// this Desktop is currently signing with (e.g. you signed into an email tied
|
||||
// to a different test identity). When that happens the community list and
|
||||
// Connect buttons operate on the *bound* npub's communities, so "Connect"
|
||||
// would drop you into a relay your local key isn't a member of. Detect it and
|
||||
// block Connect + Create until the identities match.
|
||||
const boundPubkey = identity?.pubkey_hex ?? null;
|
||||
const identityMismatch = Boolean(
|
||||
identity &&
|
||||
boundPubkey &&
|
||||
localPubkey &&
|
||||
boundPubkey.toLowerCase() !== localPubkey.toLowerCase(),
|
||||
);
|
||||
const localNpub = localPubkey ? safeNpub(localPubkey) : null;
|
||||
|
||||
const switchToDeviceIdentity = () =>
|
||||
run("Switching identity…", async () => {
|
||||
// The account is bound to a different npub, so re-binding directly returns
|
||||
// identity_already_bound. Release the current binding first, then bind
|
||||
// this device's key. If the local key is reserved by another Builderlab
|
||||
// account, the bind fails with pubkey_already_bound — surface that instead
|
||||
// of leaving the swap half-finished silently.
|
||||
const released = await invoke<IdentityResponse>(
|
||||
"delete_builderlab_nostr_identity",
|
||||
);
|
||||
if (released.error) {
|
||||
throw new Error(
|
||||
errorMessage(
|
||||
released.error,
|
||||
released.correlation_id,
|
||||
"Could not release the previously connected Buzz identity.",
|
||||
),
|
||||
);
|
||||
}
|
||||
const bound = await invoke<IdentityResponse>(
|
||||
"bind_builderlab_nostr_identity",
|
||||
);
|
||||
if (bound.error) {
|
||||
// Refresh so the UI reflects the now-unbound account before surfacing
|
||||
// the reason the swap could not complete.
|
||||
await loadAccount();
|
||||
throw new Error(
|
||||
bound.error.code === "pubkey_already_bound"
|
||||
? "This device's Buzz identity is already reserved by another Builderlab account, so it can't be connected here. Sign in with that account, or transfer the identity there first."
|
||||
: errorMessage(
|
||||
bound.error,
|
||||
bound.correlation_id,
|
||||
"Could not connect this device's Buzz identity.",
|
||||
),
|
||||
);
|
||||
}
|
||||
setIdentity(bound.identity ?? null);
|
||||
await loadAccount();
|
||||
});
|
||||
|
||||
const archiveCommunity = (community: HostedCommunity) => {
|
||||
if (!community.id) return Promise.resolve(false);
|
||||
return run("Archiving community…", async () => {
|
||||
const response = await invoke<CommunityMutationResponse>(
|
||||
"archive_builderlab_community",
|
||||
{ communityId: community.id },
|
||||
);
|
||||
// Treat a returned archived timestamp as success even if the payload also
|
||||
// carries a soft error (existing connections may take time to close).
|
||||
if (response.error && !response.community?.archived_at) {
|
||||
throw new Error(
|
||||
errorMessage(
|
||||
response.error,
|
||||
response.correlation_id,
|
||||
"Could not archive the community.",
|
||||
),
|
||||
);
|
||||
}
|
||||
await loadAccount();
|
||||
});
|
||||
};
|
||||
|
||||
const unarchiveCommunity = (community: HostedCommunity) => {
|
||||
if (!community.id) return Promise.resolve(false);
|
||||
return run("Unarchiving community…", async () => {
|
||||
const response = await invoke<CommunityMutationResponse>(
|
||||
"unarchive_builderlab_community",
|
||||
{ communityId: community.id },
|
||||
);
|
||||
if (response.error && response.community?.archived_at !== null) {
|
||||
throw new Error(
|
||||
errorMessage(
|
||||
response.error,
|
||||
response.correlation_id,
|
||||
"Could not unarchive the community.",
|
||||
),
|
||||
);
|
||||
}
|
||||
await loadAccount();
|
||||
});
|
||||
};
|
||||
|
||||
const transferCommunity = (community: HostedCommunity, npub: string) =>
|
||||
run("Transferring ownership…", async () => {
|
||||
const response = await invoke<CommunityMutationResponse>(
|
||||
"transfer_builderlab_community",
|
||||
{ communityId: community.id, transfereeNpub: npub },
|
||||
);
|
||||
if (response.error) {
|
||||
throw new Error(
|
||||
errorMessage(
|
||||
response.error,
|
||||
response.correlation_id,
|
||||
"Could not transfer ownership.",
|
||||
),
|
||||
);
|
||||
}
|
||||
await loadAccount();
|
||||
});
|
||||
|
||||
const normalizedName = name.trim().toLowerCase();
|
||||
const validName =
|
||||
normalizedName.length <= 63 && VALID_NAME.test(normalizedName);
|
||||
|
||||
// Debounced typeahead availability check: once the user pauses on a valid
|
||||
// address, check it ~500ms later so the result is ready before they click
|
||||
// Create (no separate "check" click). onChange clears the previous result, so
|
||||
// the indicator reflects the current input while typing.
|
||||
React.useEffect(() => {
|
||||
if (!identity || identityMismatch || !normalizedName || !validName) {
|
||||
setCheckingName(false);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
setCheckingName(true);
|
||||
const handle = window.setTimeout(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
const response = await invoke<AvailabilityResponse>(
|
||||
"check_builderlab_community_name",
|
||||
{ name: normalizedName },
|
||||
);
|
||||
if (cancelled) return;
|
||||
setAvailability(
|
||||
response.error ? null : (response.available ?? false),
|
||||
);
|
||||
} catch {
|
||||
if (!cancelled) setAvailability(null);
|
||||
} finally {
|
||||
if (!cancelled) setCheckingName(false);
|
||||
}
|
||||
})();
|
||||
}, 500);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(handle);
|
||||
};
|
||||
}, [normalizedName, validName, identity, identityMismatch]);
|
||||
|
||||
const createCommunity = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (
|
||||
!validName ||
|
||||
!identity ||
|
||||
identityMismatch ||
|
||||
communities.length >= MAX_COMMUNITIES
|
||||
)
|
||||
return;
|
||||
void run("Creating community…", async () => {
|
||||
const availabilityResponse = await invoke<AvailabilityResponse>(
|
||||
"check_builderlab_community_name",
|
||||
{ name: normalizedName },
|
||||
);
|
||||
if (availabilityResponse.error || !availabilityResponse.available) {
|
||||
setAvailability(false);
|
||||
throw new Error(
|
||||
errorMessage(
|
||||
availabilityResponse.error,
|
||||
availabilityResponse.correlation_id,
|
||||
"That Buzz address is already taken.",
|
||||
),
|
||||
);
|
||||
}
|
||||
const response = await invoke<CommunityMutationResponse>(
|
||||
"create_builderlab_community",
|
||||
{ name: normalizedName },
|
||||
);
|
||||
if (response.error || !response.community) {
|
||||
throw new Error(
|
||||
errorMessage(
|
||||
response.error,
|
||||
response.correlation_id,
|
||||
"Could not create the community.",
|
||||
),
|
||||
);
|
||||
}
|
||||
const url = relayUrl(response.community);
|
||||
if (!url)
|
||||
throw new Error("The new community did not return a relay address.");
|
||||
setName("");
|
||||
setAvailability(null);
|
||||
await loadAccount();
|
||||
if (
|
||||
!onboarding.start({
|
||||
source: "add-community",
|
||||
relayUrl: url,
|
||||
communityName: response.community.name ?? normalizedName,
|
||||
})
|
||||
) {
|
||||
throw new Error(
|
||||
"Another community is already being connected. Finish it before connecting this one.",
|
||||
);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const busy = action != null;
|
||||
const atCommunityLimit = communities.length >= MAX_COMMUNITIES;
|
||||
|
||||
return (
|
||||
<section className="space-y-6" data-testid="hosted-communities-settings">
|
||||
<SettingsSectionHeader
|
||||
title="Hosted communities"
|
||||
description="Buzz works with any relay. This page is only for relay hosting provided by Block — sign in with a Builderlab account to create and manage Block-hosted communities. Builderlab sign-in is used on this page alone."
|
||||
/>
|
||||
|
||||
{error ? (
|
||||
<div className="flex items-start gap-2 rounded-lg border border-destructive/40 bg-destructive/5 p-3 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" /> Checking sign-in…
|
||||
</div>
|
||||
) : !auth ? (
|
||||
<div className="rounded-xl border border-border/70 p-5">
|
||||
<h3 className="font-medium">Sign in to manage hosted communities</h3>
|
||||
<p className="mt-2 max-w-2xl text-sm text-muted-foreground">
|
||||
Authentication opens in your browser and returns securely to Buzz.
|
||||
You can use every other part of the app without signing in.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
disabled={busy}
|
||||
onClick={() => void signIn()}
|
||||
>
|
||||
{action ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
)}
|
||||
{action ?? "Sign in with Builderlab"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/70 p-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium">
|
||||
{auth.name || auth.email || "Builderlab account"}
|
||||
</p>
|
||||
{auth.name && auth.email ? (
|
||||
<p className="text-xs text-muted-foreground">{auth.email}</p>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void signOut()}
|
||||
>
|
||||
<LogOut className="h-4 w-4" /> Sign out
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{!identity ? (
|
||||
<div className="rounded-xl border border-amber-500/40 bg-amber-500/5 p-5">
|
||||
<h3 className="font-medium">
|
||||
Link this account to your Buzz identity
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
This Builderlab account isn't linked to a Buzz identity
|
||||
yet. Connect this device's key to create and own
|
||||
communities under it — Buzz signs a one-time challenge locally,
|
||||
so your private key never leaves Desktop.
|
||||
</p>
|
||||
<Button
|
||||
className="mt-4"
|
||||
disabled={busy}
|
||||
onClick={() => void connectIdentity()}
|
||||
>
|
||||
{action ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{action ?? "Connect Buzz identity"}
|
||||
</Button>
|
||||
</div>
|
||||
) : identityMismatch ? (
|
||||
<div className="rounded-xl border border-amber-500/50 bg-amber-500/5 p-5">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600" />
|
||||
<div>
|
||||
<h3 className="font-medium">
|
||||
This account is connected to a different Buzz identity
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
Your Builderlab account owns communities under another Buzz
|
||||
key, so connecting them here would join a relay this device
|
||||
isn't a member of. Creating and connecting are paused
|
||||
until the identities match.
|
||||
</p>
|
||||
<dl className="mt-3 space-y-1 text-xs">
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
<dt className="text-muted-foreground">Account uses</dt>
|
||||
<dd className="font-mono">
|
||||
{identity.npub ?? boundPubkey}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-x-2">
|
||||
<dt className="text-muted-foreground">This device</dt>
|
||||
<dd className="font-mono">{localNpub ?? localPubkey}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
className="mt-4"
|
||||
disabled={busy}
|
||||
onClick={() => void switchToDeviceIdentity()}
|
||||
>
|
||||
{action ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{action ?? "Switch to this device's identity"}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/70 p-4">
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<CheckCircle2 className="h-4 w-4 text-emerald-500" /> Buzz
|
||||
identity connected
|
||||
{identity.npub ? (
|
||||
<span className="font-mono text-xs">{identity.npub}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<UnpairIdentityButton
|
||||
busy={busy}
|
||||
onConfirm={() => void unpairIdentity()}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="font-medium">
|
||||
Your communities
|
||||
<span className="ml-2 text-xs font-normal text-muted-foreground">
|
||||
{communities.length} of {MAX_COMMUNITIES} used
|
||||
</span>
|
||||
</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={() => void run("Refreshing…", loadAccount)}
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" /> Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{communities.length === 0 ? (
|
||||
<p className="rounded-xl border border-dashed p-5 text-sm text-muted-foreground">
|
||||
No hosted communities yet.
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{[...communities]
|
||||
.sort(
|
||||
(a, b) =>
|
||||
Number(Boolean(a.archived_at)) -
|
||||
Number(Boolean(b.archived_at)),
|
||||
)
|
||||
.map((community, index) => (
|
||||
<CommunityRow
|
||||
key={community.id ?? community.normalized_host ?? index}
|
||||
community={community}
|
||||
busy={busy}
|
||||
canConnect={!identityMismatch}
|
||||
onConnect={() => {
|
||||
const url = relayUrl(community);
|
||||
if (url)
|
||||
onboarding.start({
|
||||
source: "add-community",
|
||||
relayUrl: url,
|
||||
communityName: community.name,
|
||||
});
|
||||
}}
|
||||
onArchive={() => void archiveCommunity(community)}
|
||||
onUnarchive={() => void unarchiveCommunity(community)}
|
||||
onTransfer={(npub) => transferCommunity(community, npub)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form
|
||||
className="space-y-4 rounded-xl border border-border/70 p-5"
|
||||
onSubmit={createCommunity}
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium">Create a community</h3>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
Choose the address your team will use to connect.
|
||||
</p>
|
||||
</div>
|
||||
{atCommunityLimit ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
You've reached the limit of {MAX_COMMUNITIES} hosted
|
||||
communities. Transfer one to free up a slot before creating
|
||||
another.
|
||||
</p>
|
||||
) : null}
|
||||
<div className="flex max-w-xl items-center gap-2">
|
||||
<Input
|
||||
aria-label="Community address"
|
||||
autoComplete="off"
|
||||
disabled={
|
||||
!identity || identityMismatch || busy || atCommunityLimit
|
||||
}
|
||||
maxLength={63}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value.toLowerCase());
|
||||
setAvailability(null);
|
||||
}}
|
||||
placeholder="north-star"
|
||||
spellCheck={false}
|
||||
value={name}
|
||||
/>
|
||||
<span className="shrink-0 text-sm text-muted-foreground">
|
||||
.{HOST_SUFFIX}
|
||||
</span>
|
||||
</div>
|
||||
{name && !validName ? (
|
||||
<p className="text-sm text-destructive">
|
||||
Use lowercase letters, numbers, and single hyphens.
|
||||
</p>
|
||||
) : validName && checkingName ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Checking availability…
|
||||
</p>
|
||||
) : availability === false ? (
|
||||
<p className="text-sm text-destructive">
|
||||
That address is already taken.
|
||||
</p>
|
||||
) : availability === true ? (
|
||||
<p className="text-sm text-emerald-600">
|
||||
That address is available.
|
||||
</p>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={
|
||||
!identity ||
|
||||
identityMismatch ||
|
||||
!validName ||
|
||||
availability === false ||
|
||||
checkingName ||
|
||||
busy ||
|
||||
atCommunityLimit
|
||||
}
|
||||
type="submit"
|
||||
>
|
||||
{action ? (
|
||||
<LoaderCircle className="h-4 w-4 animate-spin" />
|
||||
) : null}
|
||||
{action ?? "Create and connect"}
|
||||
</Button>
|
||||
</form>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function UnpairIdentityButton({
|
||||
busy,
|
||||
onConfirm,
|
||||
}: {
|
||||
busy: boolean;
|
||||
onConfirm: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={setOpen}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={busy}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
<Unlink className="h-4 w-4" /> Unpair identity
|
||||
</Button>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unpair this Buzz identity?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
Your Builderlab account will no longer be connected to this Buzz
|
||||
key. You can reconnect any key later, but community actions stay
|
||||
unavailable until you do.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={onConfirm}
|
||||
>
|
||||
Unpair identity
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityRow({
|
||||
community,
|
||||
busy,
|
||||
canConnect,
|
||||
onConnect,
|
||||
onArchive,
|
||||
onUnarchive,
|
||||
onTransfer,
|
||||
}: {
|
||||
community: HostedCommunity;
|
||||
busy: boolean;
|
||||
canConnect: boolean;
|
||||
onConnect: () => void;
|
||||
onArchive: () => void;
|
||||
onUnarchive: () => void;
|
||||
onTransfer: (npub: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [confirmArchive, setConfirmArchive] = React.useState(false);
|
||||
const [confirmUnarchive, setConfirmUnarchive] = React.useState(false);
|
||||
const [transferOpen, setTransferOpen] = React.useState(false);
|
||||
const url = relayUrl(community);
|
||||
const archived = Boolean(community.archived_at);
|
||||
const displayName = community.name ?? community.slug ?? "Hosted community";
|
||||
|
||||
return (
|
||||
<li
|
||||
className={`flex flex-wrap items-center justify-between gap-3 rounded-xl border border-border/70 p-4 ${
|
||||
archived ? "opacity-70" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-medium">{displayName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{community.normalized_host}
|
||||
{archived ? " · Archived" : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{archived ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy || !community.id}
|
||||
onClick={() => setConfirmUnarchive(true)}
|
||||
>
|
||||
<ArchiveRestore className="h-4 w-4" /> Unarchive
|
||||
</Button>
|
||||
<AlertDialog
|
||||
open={confirmUnarchive}
|
||||
onOpenChange={setConfirmUnarchive}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Unarchive {displayName}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This address becomes connectable again. Connections that
|
||||
closed during archival will not reconnect automatically.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onUnarchive}>
|
||||
Unarchive
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{url && canConnect ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={busy}
|
||||
onClick={onConnect}
|
||||
>
|
||||
Connect
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
disabled={busy || !community.id}
|
||||
onClick={() => setTransferOpen(true)}
|
||||
>
|
||||
<ArrowLeftRight className="h-4 w-4" /> Transfer
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-destructive hover:text-destructive"
|
||||
disabled={busy || !community.id}
|
||||
onClick={() => setConfirmArchive(true)}
|
||||
>
|
||||
<Archive className="h-4 w-4" /> Archive
|
||||
</Button>
|
||||
|
||||
<AlertDialog open={confirmArchive} onOpenChange={setConfirmArchive}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Archive {displayName}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
New and existing connections stop and the address stays
|
||||
reserved. Archiving can't be undone from here without
|
||||
unarchiving, and the community keeps counting toward your
|
||||
quota — it isn't deleted.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={buttonVariants({ variant: "destructive" })}
|
||||
onClick={onArchive}
|
||||
>
|
||||
Archive
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<TransferOwnershipDialog
|
||||
open={transferOpen}
|
||||
onOpenChange={setTransferOpen}
|
||||
communityName={displayName}
|
||||
busy={busy}
|
||||
onTransfer={onTransfer}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function TransferOwnershipDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
communityName,
|
||||
busy,
|
||||
onTransfer,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
communityName: string;
|
||||
busy: boolean;
|
||||
onTransfer: (npub: string) => Promise<boolean>;
|
||||
}) {
|
||||
const [npub, setNpub] = React.useState("");
|
||||
const npubIsValid = npub.startsWith("npub1") && npub.length >= 50;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) setNpub("");
|
||||
}, [open]);
|
||||
|
||||
const submit = async () => {
|
||||
if (!npubIsValid) return;
|
||||
const ok = await onTransfer(npub.trim());
|
||||
if (ok) onOpenChange(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Transfer ownership</DialogTitle>
|
||||
<DialogDescription>
|
||||
Transfer {communityName} to another person. You become a regular
|
||||
member. The recipient needs a connected Buzz identity first, and
|
||||
this can't be undone.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-2">
|
||||
<Input
|
||||
aria-label="Recipient npub"
|
||||
autoComplete="off"
|
||||
className="font-mono text-sm"
|
||||
placeholder="npub1…"
|
||||
spellCheck={false}
|
||||
value={npub}
|
||||
onChange={(event) => setNpub(event.target.value.trim())}
|
||||
/>
|
||||
{npub.length > 0 && !npubIsValid ? (
|
||||
<p className="text-sm text-destructive">
|
||||
Enter a valid npub that starts with npub1.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!npubIsValid || busy}
|
||||
onClick={() => void submit()}
|
||||
>
|
||||
{busy ? <LoaderCircle className="h-4 w-4 animate-spin" /> : null}
|
||||
Transfer ownership
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Keyboard,
|
||||
LayoutTemplate,
|
||||
LockKeyhole,
|
||||
MessagesSquare,
|
||||
MonitorCog,
|
||||
Moon,
|
||||
ShieldAlert,
|
||||
@@ -64,6 +65,7 @@ import { ModerationQueueCard } from "./ModerationQueueCard";
|
||||
import { NotificationSettingsCard } from "./NotificationSettingsCard";
|
||||
import { PreventSleepSettingsCard } from "./PreventSleepSettingsCard";
|
||||
import { GlobalAgentConfigSettingsCard } from "./GlobalAgentConfigSettingsCard";
|
||||
import { HostedCommunitiesSettingsCard } from "./HostedCommunitiesSettingsCard";
|
||||
import { ProfileSettingsCard } from "./ProfileSettingsCard";
|
||||
import { UpdateChecker } from "../UpdateChecker";
|
||||
import { SettingsSectionHeader } from "./SettingsSectionHeader";
|
||||
@@ -77,6 +79,7 @@ export type SettingsSection =
|
||||
| "compute"
|
||||
| "appearance"
|
||||
| "shortcuts"
|
||||
| "hosted-communities"
|
||||
| "community-members"
|
||||
| "moderation"
|
||||
| "custom-emoji"
|
||||
@@ -96,6 +99,7 @@ const SETTINGS_SECTION_VALUES: readonly SettingsSection[] = [
|
||||
"compute",
|
||||
"appearance",
|
||||
"shortcuts",
|
||||
"hosted-communities",
|
||||
"community-members",
|
||||
"moderation",
|
||||
"custom-emoji",
|
||||
@@ -178,6 +182,11 @@ export const settingsSections: SettingsSectionDescriptor[] = [
|
||||
label: "Shortcuts",
|
||||
icon: Keyboard,
|
||||
},
|
||||
{
|
||||
value: "hosted-communities",
|
||||
label: "Hosted communities",
|
||||
icon: MessagesSquare,
|
||||
},
|
||||
{
|
||||
value: "community-members",
|
||||
label: "Community access",
|
||||
@@ -727,6 +736,8 @@ export function renderSettingsSection(
|
||||
return <ThemeSettingsCard />;
|
||||
case "shortcuts":
|
||||
return <KeyboardShortcutsCard />;
|
||||
case "hosted-communities":
|
||||
return <HostedCommunitiesSettingsCard />;
|
||||
case "community-members":
|
||||
return (
|
||||
<CommunityMembersSettingsCard currentPubkey={props.currentPubkey} />
|
||||
|
||||
@@ -62,7 +62,7 @@ const settingsNavGroups: Array<{
|
||||
},
|
||||
{
|
||||
label: "Communities",
|
||||
sections: ["channel-templates", "community-members"],
|
||||
sections: ["hosted-communities", "channel-templates", "community-members"],
|
||||
},
|
||||
{
|
||||
label: "App",
|
||||
|
||||
Reference in New Issue
Block a user