mobile-connector: merge pairing+devices into one self-service Mobile App page
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:
2026-07-27 23:49:50 +01:00
parent a78259551e
commit 50e1333d99
18 changed files with 834 additions and 411 deletions
+10 -5
View File
@@ -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"),
}
+20 -21
View File
@@ -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 1090, 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,
},
]
}
+77 -31
View File
@@ -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(),