mobile-connector: merge pairing+devices into one self-service Mobile App page
Nightly Build / build (push) Successful in 7m3s
Nightly Build / build (push) Successful in 7m3s
The two admin-only console pages become a single "Mobile App" page visible to every logged-in user: connection status (with the last connection error for troubleshooting), the device list (admin sees all, others only their own), a pairing dialog with the QR, and — admin-only — a settings dialog hosting the plugin config, including a relay picker (official grayed out, test, custom URL). The generic plugin-detail config form defers to it via the new Plugin::config_in_detail_page flag. Pairing is now self-service: any user opens a window and the device auto-binds to them; revocation is admin-for-anyone, owner-for-self; (re)binding to another user stays admin-only. Binding-managed plugins (manages_own_access) now expose their non-admin pages to all users and self-scope per caller (web_pages_for). The relay client records the error that ends a WS session and clears it on reconnect.
This commit is contained in:
@@ -57,7 +57,7 @@ pub struct RelayApp {
|
||||
/// Per-user debounced notifiers, created on demand by the forwarders.
|
||||
pub(crate) notifiers: Mutex<HashMap<String, Arc<DelayedNotifier>>>,
|
||||
/// The user a device paired *during the current window* auto-binds to — set
|
||||
/// by the web pairing console (the admin who opened the window). `None` for
|
||||
/// by the web pairing dialog (the user who opened the window). `None` for
|
||||
/// the agent-tool flow (`mobile_start_pairing`), which leaves the device
|
||||
/// Pending for an explicit `mobile_bind_device`. Cleared on stop-pairing.
|
||||
pending_owner: Mutex<Option<String>>,
|
||||
@@ -91,7 +91,7 @@ impl RelayApp {
|
||||
}
|
||||
|
||||
/// Set (or clear) the user that devices paired during the current window
|
||||
/// auto-bind to. Called by the web pairing endpoint with the admin's id.
|
||||
/// auto-bind to. Called by the web pairing endpoint with the caller's id.
|
||||
pub(crate) async fn set_pending_owner(&self, user_id: Option<String>) {
|
||||
*self.pending_owner.lock().await = user_id;
|
||||
}
|
||||
@@ -107,6 +107,11 @@ impl RelayApp {
|
||||
&self.client
|
||||
}
|
||||
|
||||
/// The relay URL this run is configured with ("" = not configured).
|
||||
pub(crate) fn relay_url(&self) -> String {
|
||||
self.client.relay_url()
|
||||
}
|
||||
|
||||
/// Backend localizer — the router resolves its error strings to the caller's
|
||||
/// language through this (`app.i18n().for_user(user_id, key, &[])`).
|
||||
pub(crate) fn i18n(&self) -> &Arc<dyn I18nApi> {
|
||||
@@ -363,16 +368,16 @@ impl RelayApp {
|
||||
self.apply_client_payload(&from, &payload).await;
|
||||
}
|
||||
Ok(RelayEvent::ClientPaired { ed25519_pub, .. }) => {
|
||||
// Web-console pairing: the admin who opened the window is
|
||||
// Web-dialog pairing: the user who opened the window is
|
||||
// the pending owner, so bind (and thereby authorize) the
|
||||
// device to them straight away — usable on the phone at
|
||||
// once, reassignable later from the Devices page.
|
||||
// once, reassignable later from the Mobile App page.
|
||||
if let Some(owner) = self.pending_owner().await {
|
||||
match self.bind_device(ed25519_pub, owner.clone(), None).await {
|
||||
Ok(()) => info!(
|
||||
plugin = PLUGIN_ID, user_id = %owner,
|
||||
device = %hex::encode(ed25519_pub),
|
||||
"new device paired — auto-bound to pairing admin"
|
||||
"new device paired — auto-bound to pairing user"
|
||||
),
|
||||
Err(e) => warn!(plugin = PLUGIN_ID, error = %e, "auto-bind on pair failed"),
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
//! - `events` — per-user event forwarders (drive the notifiers)
|
||||
//! - `notifier` — per-user debounced Inbox pushes
|
||||
//! - `proxy` — HTTP reverse proxy to the local web UI (user-agnostic)
|
||||
//! - `router` — the QR-code HTTP endpoint
|
||||
//! - `router` — the QR-code + Mobile App console HTTP endpoints
|
||||
//! - `agent` — the `RelayAgent` control trait
|
||||
//! - `tools` — `Tool` impls callable by the host (registered in the main crate)
|
||||
|
||||
@@ -238,6 +238,10 @@ impl Plugin for MobileConnectorPlugin {
|
||||
/// the admin Plugins UI hides the "User access" checklist for this plugin.
|
||||
fn manages_own_access(&self) -> bool { true }
|
||||
|
||||
/// Config lives in the Mobile App page's own settings dialog — the generic
|
||||
/// plugin-detail form would duplicate it.
|
||||
fn config_in_detail_page(&self) -> bool { false }
|
||||
|
||||
fn config_schema(&self) -> Value {
|
||||
json!({
|
||||
"type": "object",
|
||||
@@ -275,14 +279,15 @@ impl Plugin for MobileConnectorPlugin {
|
||||
if !self.running.load(Ordering::Relaxed) {
|
||||
return None;
|
||||
}
|
||||
// Synchronous status: report connection flag from the live client.
|
||||
let connected = self
|
||||
// Synchronous status: report connection flag + last error from the
|
||||
// live client (surfaced on the Mobile App page for troubleshooting).
|
||||
let (connected, last_error) = self
|
||||
.inner
|
||||
.try_lock()
|
||||
.ok()
|
||||
.and_then(|g| g.as_ref().map(|app| app.client().is_connected()))
|
||||
.unwrap_or(false);
|
||||
Some(json!({ "connected": connected }))
|
||||
.and_then(|g| g.as_ref().map(|app| (app.client().is_connected(), app.client().last_error())))
|
||||
.unwrap_or((false, None));
|
||||
Some(json!({ "connected": connected, "last_error": last_error }))
|
||||
}
|
||||
|
||||
async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()> {
|
||||
@@ -311,28 +316,22 @@ impl Plugin for MobileConnectorPlugin {
|
||||
Some(router::build(Arc::clone(&self.inner)))
|
||||
}
|
||||
|
||||
/// Two admin-only console pages served from this plugin's own router
|
||||
/// (`web/*.js`). `manages_own_access` already hides them from non-admins.
|
||||
/// The single "Mobile App" console page served from this plugin's own
|
||||
/// router (`web/app.js`). Visible to every logged-in user — the page
|
||||
/// self-scopes (admin sees all devices, others only their own) and hosts
|
||||
/// the pairing dialog plus, for admins, the settings dialog.
|
||||
fn web_pages(&self) -> Vec<PluginPage> {
|
||||
vec![
|
||||
PluginPage {
|
||||
page_id: "pairing",
|
||||
title: "Pair a device".into(),
|
||||
icon: "qr-code",
|
||||
entry: "web/pairing.js".into(),
|
||||
admin_only: true,
|
||||
page_id: "app",
|
||||
title: "Mobile App".into(),
|
||||
icon: "phone",
|
||||
entry: "web/app.js".into(),
|
||||
admin_only: false,
|
||||
// Sidebar priority: core "Your space" items live in 10–90, so
|
||||
// plugin pages use ≥100 to land after them (see sidebar.js NAV).
|
||||
priority: 100,
|
||||
},
|
||||
PluginPage {
|
||||
page_id: "devices",
|
||||
title: "Mobile devices".into(),
|
||||
icon: "phone",
|
||||
entry: "web/devices.js".into(),
|
||||
admin_only: true,
|
||||
priority: 110,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
@@ -4,16 +4,16 @@
|
||||
//! Two audiences on one router:
|
||||
//! - the **QR endpoint** (`/pairingqrcode`) — renders the pairing QR PNG on
|
||||
//! demand from the in-memory session (no QR ever touches disk);
|
||||
//! - the **admin pairing console** — the JSON API + the two page fragments
|
||||
//! (`web/pairing.js`, `web/devices.js`) that let an admin pair, list, bind and
|
||||
//! revoke devices from the browser instead of driving the LLM control tools.
|
||||
//! - the **Mobile App console** — the JSON API + the page fragment
|
||||
//! (`web/app.js`) behind the single "Mobile App" menu page: connection
|
||||
//! status, device list, self-service pairing, and device revocation.
|
||||
//!
|
||||
//! Every request resolves the *current* [`RelayApp`] through the shared state
|
||||
//! cell (`Arc<Mutex<Option<Arc<RelayApp>>>>`), so a reconfigure (reload → fresh
|
||||
//! `RelayApp`) is transparent. Management endpoints are admin-only: the router
|
||||
//! runs inside `require_auth` (which injects [`Caller`]) and gates on
|
||||
//! [`UserChannelApi::plugin_access`], which — because the connector
|
||||
//! `manages_own_access` — returns `true` only for admins.
|
||||
//! `RelayApp`) is transparent. Access is self-scoped per caller: any logged-in
|
||||
//! user may pair a device (it auto-binds to them), list their own devices and
|
||||
//! revoke them; listing every device and (re)binding to another user stays
|
||||
//! admin-only (gated on [`UserChannelApi::is_admin`]).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -37,12 +37,13 @@ use crate::PLUGIN_ID;
|
||||
type StateCell = Arc<Mutex<Option<Arc<RelayApp>>>>;
|
||||
|
||||
// Namespaced i18n keys for the router's user-facing strings (backend tables in
|
||||
// `../i18n/*.json`). Resolved to the caller's language via `app.i18n()`. Every
|
||||
// use sits after `admin_app`, so the app — hence the localizer — is present.
|
||||
// `../i18n/*.json`). Resolved to the caller's language via `app.i18n()`.
|
||||
const KEY_RELAY_NOT_CONNECTED: &str = "plugin.mobile-connector.err.relay_not_connected";
|
||||
const KEY_ADMIN_ONLY: &str = "plugin.mobile-connector.err.admin_only";
|
||||
const KEY_USER_ID_EMPTY: &str = "plugin.mobile-connector.err.user_id_empty";
|
||||
const KEY_PUBKEY_HEX: &str = "plugin.mobile-connector.err.pubkey_hex";
|
||||
const KEY_NOT_DEVICE_OWNER: &str = "plugin.mobile-connector.err.not_device_owner";
|
||||
const KEY_NOT_PAIRING_OWNER: &str = "plugin.mobile-connector.err.not_pairing_owner";
|
||||
|
||||
/// Build the plugin's router. Takes the shared state cell so each request
|
||||
/// resolves the *current* `RelayApp` — not a snapshot from startup.
|
||||
@@ -50,11 +51,11 @@ pub fn build(state_cell: StateCell) -> Router {
|
||||
Router::new()
|
||||
.route("/pairingqrcode", get(pairing_qr))
|
||||
// Page fragments (served as ES modules to the browser).
|
||||
.route("/web/pairing.js", get(|| async { serve_js(include_str!("../web/pairing.js")) }))
|
||||
.route("/web/devices.js", get(|| async { serve_js(include_str!("../web/devices.js")) }))
|
||||
.route("/web/app.js", get(|| async { serve_js(include_str!("../web/app.js")) }))
|
||||
.route("/web/common.js", get(|| async { serve_js(include_str!("../web/common.js")) }))
|
||||
.route("/web/i18n.js", get(|| async { serve_js(include_str!("../web/i18n.js")) }))
|
||||
// Admin pairing console API.
|
||||
// Mobile App console API.
|
||||
.route("/status", get(status))
|
||||
.route("/pairing", post(start_pairing).delete(stop_pairing))
|
||||
.route("/devices", get(list_devices))
|
||||
.route("/devices/bind", post(bind_device))
|
||||
@@ -62,7 +63,7 @@ pub fn build(state_cell: StateCell) -> Router {
|
||||
.with_state(state_cell)
|
||||
}
|
||||
|
||||
// ── Admin console: shared plumbing ──────────────────────────────────────────────
|
||||
// ── Console: shared plumbing ──────────────────────────────────────────────────
|
||||
|
||||
/// Resolve the live app, or `503` when the plugin is enabled but its runloop is
|
||||
/// not up (e.g. no `relay_url` configured).
|
||||
@@ -72,10 +73,9 @@ async fn app_or_503(cell: &StateCell) -> Result<Arc<RelayApp>, Response> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Fail-closed admin gate. For a `manages_own_access` connector nobody holds a
|
||||
/// `plugin_access` grant, so this is `true` only for the built-in admin role.
|
||||
/// Fail-closed admin gate, via [`UserChannelApi::is_admin`].
|
||||
async fn require_admin(app: &RelayApp, caller: &Caller) -> Result<(), Response> {
|
||||
if app.user_channel.plugin_access(PLUGIN_ID, &caller.user_id).await {
|
||||
if app.user_channel.is_admin(&caller.user_id).await {
|
||||
Ok(())
|
||||
} else {
|
||||
let msg = app.i18n().for_user(&caller.user_id, KEY_ADMIN_ONLY, &[]).await;
|
||||
@@ -83,7 +83,7 @@ async fn require_admin(app: &RelayApp, caller: &Caller) -> Result<(), Response>
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the app and check admin in one step (the common prelude).
|
||||
/// Resolve the app and check admin in one step.
|
||||
async fn admin_app(cell: &StateCell, caller: &Caller) -> Result<Arc<RelayApp>, Response> {
|
||||
let app = app_or_503(cell).await?;
|
||||
require_admin(&app, caller).await?;
|
||||
@@ -104,6 +104,29 @@ async fn decode_pubkey(app: &RelayApp, caller: &Caller, hex: &str) -> Result<[u8
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET /status ───────────────────────────────────────────────────────────────
|
||||
|
||||
/// Connection status for the page header. Works also when the runloop is down
|
||||
/// (no `relay_url` yet) so the page can render the not-running state.
|
||||
async fn status(State(cell): State<StateCell>) -> Response {
|
||||
match cell.lock().await.as_ref() {
|
||||
Some(app) => Json(json!({
|
||||
"running": true,
|
||||
"connected": app.client().is_connected(),
|
||||
"relay_url": app.relay_url(),
|
||||
"last_error": app.client().last_error(),
|
||||
}))
|
||||
.into_response(),
|
||||
None => Json(json!({
|
||||
"running": false,
|
||||
"connected": false,
|
||||
"relay_url": null,
|
||||
"last_error": null,
|
||||
}))
|
||||
.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── POST/DELETE /pairing ────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -113,14 +136,15 @@ struct StartPairingBody {
|
||||
ttl: Option<u32>,
|
||||
}
|
||||
|
||||
/// Open a pairing window and return the QR URL. The caller (an admin) becomes
|
||||
/// the pending owner, so a device that pairs in this window auto-binds to them.
|
||||
/// Open a pairing window and return the QR URL. Self-service: the caller
|
||||
/// becomes the pending owner, so a device that pairs in this window
|
||||
/// auto-binds to them.
|
||||
async fn start_pairing(
|
||||
State(cell): State<StateCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
Json(body): Json<StartPairingBody>,
|
||||
) -> Response {
|
||||
let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r };
|
||||
let app = match app_or_503(&cell).await { Ok(a) => a, Err(r) => return r };
|
||||
// Pairing brokers through the relay: without a live WS there is no channel to
|
||||
// send `pairing_start` on ("WS outbound channel closed"). Fail with an
|
||||
// actionable message instead of the transport-level one.
|
||||
@@ -144,12 +168,20 @@ async fn start_pairing(
|
||||
}
|
||||
}
|
||||
|
||||
/// Close the pairing window and disarm auto-binding.
|
||||
/// Close the pairing window and disarm auto-binding. Only the user who opened
|
||||
/// the window (or an admin) may close it.
|
||||
async fn stop_pairing(
|
||||
State(cell): State<StateCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
) -> Response {
|
||||
let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r };
|
||||
let app = match app_or_503(&cell).await { Ok(a) => a, Err(r) => return r };
|
||||
let owner = app.pending_owner().await;
|
||||
if owner.as_deref() != Some(caller.user_id.as_str())
|
||||
&& !app.user_channel.is_admin(&caller.user_id).await
|
||||
{
|
||||
let msg = app.i18n().for_user(&caller.user_id, KEY_NOT_PAIRING_OWNER, &[]).await;
|
||||
return (StatusCode::FORBIDDEN, msg).into_response();
|
||||
}
|
||||
app.set_pending_owner(None).await;
|
||||
match app.client().stop_pairing().await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
@@ -159,32 +191,37 @@ async fn stop_pairing(
|
||||
|
||||
// ── GET /devices ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// List every known device, each tagged with its bound user, state and metadata.
|
||||
/// List devices, each tagged with its bound user, state and metadata. An admin
|
||||
/// sees every known device; anyone else only the devices bound to them.
|
||||
async fn list_devices(
|
||||
State(cell): State<StateCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
) -> Response {
|
||||
let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r };
|
||||
let app = match app_or_503(&cell).await { Ok(a) => a, Err(r) => return r };
|
||||
let is_admin = app.user_channel.is_admin(&caller.user_id).await;
|
||||
let rows = app.client().list_clients().await;
|
||||
let bindings = app.bindings.read().await;
|
||||
let devices: Vec<Value> = rows
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
.filter_map(|r| {
|
||||
let pk_hex = hex::encode(r.ed25519_pub);
|
||||
let bound_user = bindings.user_for_pubkey(&pk_hex);
|
||||
if !is_admin && bound_user.as_deref() != Some(caller.user_id.as_str()) {
|
||||
return None;
|
||||
}
|
||||
let device_info: Option<Value> =
|
||||
r.device_info.as_deref().and_then(|s| serde_json::from_str(s).ok());
|
||||
json!({
|
||||
Some(json!({
|
||||
"pubkey": pk_hex,
|
||||
"state": if r.state == ClientState::Authorized { "authorized" } else { "pending" },
|
||||
"bound_user": bound_user,
|
||||
"platform": r.platform,
|
||||
"device_info": device_info,
|
||||
"last_seen": r.last_seen,
|
||||
})
|
||||
}))
|
||||
})
|
||||
.collect();
|
||||
Json(json!({ "devices": devices })).into_response()
|
||||
Json(json!({ "devices": devices, "is_admin": is_admin })).into_response()
|
||||
}
|
||||
|
||||
// ── POST /devices/bind + /devices/revoke ────────────────────────────────────────
|
||||
@@ -197,7 +234,8 @@ struct BindBody {
|
||||
display: Option<String>,
|
||||
}
|
||||
|
||||
/// Bind (or reassign) a device to a user and authorize it.
|
||||
/// Bind (or reassign) a device to a user and authorize it. Admin-only: users
|
||||
/// get their devices bound through the self-service pairing window instead.
|
||||
async fn bind_device(
|
||||
State(cell): State<StateCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
@@ -219,14 +257,22 @@ struct RevokeBody {
|
||||
pubkey: String,
|
||||
}
|
||||
|
||||
/// Revoke a device and drop its binding.
|
||||
/// Revoke a device and drop its binding. An admin revokes any device; anyone
|
||||
/// else only a device bound to themselves.
|
||||
async fn revoke_device(
|
||||
State(cell): State<StateCell>,
|
||||
Extension(caller): Extension<Caller>,
|
||||
Json(body): Json<RevokeBody>,
|
||||
) -> Response {
|
||||
let app = match admin_app(&cell, &caller).await { Ok(a) => a, Err(r) => return r };
|
||||
let app = match app_or_503(&cell).await { Ok(a) => a, Err(r) => return r };
|
||||
let pk = match decode_pubkey(&app, &caller, &body.pubkey).await { Ok(p) => p, Err(r) => return r };
|
||||
let bound = app.bindings.read().await.user_for_pubkey(&body.pubkey);
|
||||
if bound.as_deref() != Some(caller.user_id.as_str())
|
||||
&& !app.user_channel.is_admin(&caller.user_id).await
|
||||
{
|
||||
let msg = app.i18n().for_user(&caller.user_id, KEY_NOT_DEVICE_OWNER, &[]).await;
|
||||
return (StatusCode::FORBIDDEN, msg).into_response();
|
||||
}
|
||||
match app.revoke_device(pk).await {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||
|
||||
Reference in New Issue
Block a user