From 50e1333d99b73cdb6a3d28aec58bbe677ac8d277 Mon Sep 17 00:00:00 2001 From: xavix-yo Date: Mon, 27 Jul 2026 23:49:50 +0100 Subject: [PATCH] mobile-connector: merge pairing+devices into one self-service Mobile App page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/core-api/src/plugin.rs | 22 +- crates/plugin-mobile-connector/i18n/en.json | 4 +- crates/plugin-mobile-connector/i18n/fr.json | 4 +- crates/plugin-mobile-connector/i18n/it.json | 4 +- crates/plugin-mobile-connector/src/app.rs | 15 +- crates/plugin-mobile-connector/src/lib.rs | 41 +- crates/plugin-mobile-connector/src/router.rs | 108 ++-- crates/plugin-mobile-connector/web/app.js | 475 ++++++++++++++++++ crates/plugin-mobile-connector/web/common.js | 13 +- crates/plugin-mobile-connector/web/devices.js | 145 ------ crates/plugin-mobile-connector/web/i18n.js | 188 +++++-- crates/plugin-mobile-connector/web/pairing.js | 112 ----- crates/skald-core/src/plugin/mod.rs | 26 +- crates/skald-relay-client/src/client.rs | 11 + crates/skald-relay-client/src/state.rs | 18 + crates/skald-relay-client/src/ws.rs | 1 + docs/plugins/mobile-connector.md | 39 +- web/components/plugin-detail.js | 19 +- 18 files changed, 834 insertions(+), 411 deletions(-) create mode 100644 crates/plugin-mobile-connector/web/app.js delete mode 100644 crates/plugin-mobile-connector/web/devices.js delete mode 100644 crates/plugin-mobile-connector/web/pairing.js diff --git a/crates/core-api/src/plugin.rs b/crates/core-api/src/plugin.rs index cb5d871..16ae57e 100644 --- a/crates/core-api/src/plugin.rs +++ b/crates/core-api/src/plugin.rs @@ -136,14 +136,24 @@ pub trait Plugin: Send + Sync { /// Whether the plugin decides *who may use it* through its own binding / /// pairing lifecycle rather than the generic `plugin_access` grants — e.g. - /// the mobile connector, whose access is the admin-mediated device→user - /// binding (§13). When `true`, the admin Plugins UI suppresses the "User - /// access" checklist (it would control nothing) and the plugin never appears - /// in a user's "My plugins" view. Default `false`: access is the admin's - /// per-user `plugin_access` grant (as Telegram uses — its grant gates the - /// bot at runtime even though pairing is self-service). + /// the mobile connector, whose access is the device→user binding (§13). + /// When `true`, the admin Plugins UI suppresses the "User access" + /// checklist (it would control nothing), the plugin never appears in a + /// user's "My plugins" view, and its non-`admin_only` `web_pages()` are + /// visible to every logged-in user — the page itself scopes what each + /// caller sees (e.g. admin sees all devices, others only their own). + /// Default `false`: access is the admin's per-user `plugin_access` grant + /// (as Telegram uses — its grant gates the bot at runtime even though + /// pairing is self-service). fn manages_own_access(&self) -> bool { false } + /// Whether the admin plugin-detail page renders the generic + /// `config_schema` form for this plugin. Default `true`. A plugin that + /// hosts its own configuration UI inside one of its `web_pages()` (e.g. + /// the mobile connector, whose Mobile App page has a settings dialog) + /// returns `false` so the config is not edited in two places. + fn config_in_detail_page(&self) -> bool { true } + /// Called whenever the enabled flag or config changes — including at startup. /// The plugin is responsible for diffing state and restarting only what changed. async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()>; diff --git a/crates/plugin-mobile-connector/i18n/en.json b/crates/plugin-mobile-connector/i18n/en.json index 29e0986..8c7fd68 100644 --- a/crates/plugin-mobile-connector/i18n/en.json +++ b/crates/plugin-mobile-connector/i18n/en.json @@ -2,5 +2,7 @@ "plugin.mobile-connector.err.relay_not_connected": "Relay not connected. Set the connector's relay_url and make sure the relay is reachable, then try again.", "plugin.mobile-connector.err.admin_only": "Admin only.", "plugin.mobile-connector.err.user_id_empty": "The user must not be empty.", - "plugin.mobile-connector.err.pubkey_hex": "The device key must be 32-byte hex." + "plugin.mobile-connector.err.pubkey_hex": "The device key must be 32-byte hex.", + "plugin.mobile-connector.err.not_device_owner": "You can only revoke your own devices.", + "plugin.mobile-connector.err.not_pairing_owner": "Only whoever opened the pairing window can close it." } diff --git a/crates/plugin-mobile-connector/i18n/fr.json b/crates/plugin-mobile-connector/i18n/fr.json index 467e2a5..f3e6ecd 100644 --- a/crates/plugin-mobile-connector/i18n/fr.json +++ b/crates/plugin-mobile-connector/i18n/fr.json @@ -2,5 +2,7 @@ "plugin.mobile-connector.err.relay_not_connected": "Relais non connecté. Renseignez le relay_url du connecteur et assurez-vous que le relais est joignable, puis réessayez.", "plugin.mobile-connector.err.admin_only": "Administrateur uniquement.", "plugin.mobile-connector.err.user_id_empty": "L'utilisateur ne doit pas être vide.", - "plugin.mobile-connector.err.pubkey_hex": "La clé de l'appareil doit être en hexadécimal de 32 octets." + "plugin.mobile-connector.err.pubkey_hex": "La clé de l'appareil doit être en hexadécimal de 32 octets.", + "plugin.mobile-connector.err.not_device_owner": "Vous ne pouvez révoquer que vos propres appareils.", + "plugin.mobile-connector.err.not_pairing_owner": "Seule la personne qui a ouvert la fenêtre d'association peut la fermer." } diff --git a/crates/plugin-mobile-connector/i18n/it.json b/crates/plugin-mobile-connector/i18n/it.json index bf4727e..2625a80 100644 --- a/crates/plugin-mobile-connector/i18n/it.json +++ b/crates/plugin-mobile-connector/i18n/it.json @@ -2,5 +2,7 @@ "plugin.mobile-connector.err.relay_not_connected": "Relay non connesso. Imposta il relay_url del connettore e assicurati che il relay sia raggiungibile, poi riprova.", "plugin.mobile-connector.err.admin_only": "Solo amministratore.", "plugin.mobile-connector.err.user_id_empty": "L'utente non può essere vuoto.", - "plugin.mobile-connector.err.pubkey_hex": "La chiave del dispositivo deve essere esadecimale di 32 byte." + "plugin.mobile-connector.err.pubkey_hex": "La chiave del dispositivo deve essere esadecimale di 32 byte.", + "plugin.mobile-connector.err.not_device_owner": "Puoi revocare solo i tuoi dispositivi.", + "plugin.mobile-connector.err.not_pairing_owner": "Solo chi ha aperto la finestra di associazione può chiuderla." } diff --git a/crates/plugin-mobile-connector/src/app.rs b/crates/plugin-mobile-connector/src/app.rs index c7d9773..38e6269 100644 --- a/crates/plugin-mobile-connector/src/app.rs +++ b/crates/plugin-mobile-connector/src/app.rs @@ -57,7 +57,7 @@ pub struct RelayApp { /// Per-user debounced notifiers, created on demand by the forwarders. pub(crate) notifiers: Mutex>>, /// 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>, @@ -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) { *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 { @@ -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"), } diff --git a/crates/plugin-mobile-connector/src/lib.rs b/crates/plugin-mobile-connector/src/lib.rs index 80daaf0..fb54a53 100644 --- a/crates/plugin-mobile-connector/src/lib.rs +++ b/crates/plugin-mobile-connector/src/lib.rs @@ -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 { 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, - }, ] } diff --git a/crates/plugin-mobile-connector/src/router.rs b/crates/plugin-mobile-connector/src/router.rs index a2e2d48..fd61256 100644 --- a/crates/plugin-mobile-connector/src/router.rs +++ b/crates/plugin-mobile-connector/src/router.rs @@ -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>>>`), 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>>>; // 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, 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, 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) -> 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, } -/// 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, Extension(caller): Extension, Json(body): Json, ) -> 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, Extension(caller): Extension, ) -> 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, Extension(caller): Extension, ) -> 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 = 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 = 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, } -/// 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, Extension(caller): Extension, @@ -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, Extension(caller): Extension, Json(body): Json, ) -> 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(), diff --git a/crates/plugin-mobile-connector/web/app.js b/crates/plugin-mobile-connector/web/app.js new file mode 100644 index 0000000..a3873ae --- /dev/null +++ b/crates/plugin-mobile-connector/web/app.js @@ -0,0 +1,475 @@ +// Mobile-connector "Mobile App" console (page_id `app`) — the plugin's single +// page: relay connection status, the device list, the pairing dialog, and — +// for admins — the settings dialog (the plugin's config lives here, not in the +// generic plugin-detail form; see `Plugin::config_in_detail_page`). +// +// Self-scoped per caller: an admin sees every device and may reassign/revoke +// any of them; anyone else sees only their own devices, can pair a new one +// (it auto-binds to them) and revoke their own. Default-exports the element +// class; the host registers it. +import { html, nothing } from 'lit'; +import { MobileBase, jf, ago, deviceLabel, t } from './common.js'; + +const P = 'plugin.mobile-connector'; + +// Relay presets offered in the settings dialog. The official relay is not in +// service yet — shown disabled (the value is still recognised if configured +// by hand). A "custom" choice free-forms the wss:// URL. +const RELAY_OFFICIAL = 'wss://relay.skaldagent.net/v1/ws'; +const RELAY_TEST = 'wss://relay-test.skaldagent.net/v1/ws'; + +export default class MobileAppPage extends MobileBase { + static get properties() { + return { + _status: { state: true }, // { running, connected, relay_url, last_error } | null + _devices: { state: true }, // [] | null (loading) + _isAdmin: { state: true }, + _users: { state: true }, // admin: [{id, username, display_name}] + _pick: { state: true }, // admin: { [pubkey]: user_id } reassign selections + _error: { state: true }, + _pair: { state: true }, // dialog state | null + _cfg: { state: true }, // dialog state | null + }; + } + + constructor() { + super(); + this._status = null; + this._devices = null; + this._isAdmin = false; + this._users = []; + this._pick = {}; + this._error = null; + this._pair = null; + this._cfg = null; + this._poll = null; + this._pairPoll = null; + this._pairTimer = null; + this._knownPubkeys = new Set(); + } + + connectedCallback() { + super.connectedCallback(); + this._init(); + this._poll = setInterval(() => this._load(true), 5000); + } + + disconnectedCallback() { + super.disconnectedCallback(); + if (this._poll) { clearInterval(this._poll); this._poll = null; } + this._stopPairWatch(); + } + + async _init() { + try { + const me = await jf('/api/auth/me'); + this._isAdmin = me?.role_id === 'admin'; + } catch { this._isAdmin = false; } + await this._load(); + } + + async _load(quiet = false) { + if (!quiet) this._error = null; + try { + this._status = await jf(`${this.api}/status`); + } catch (e) { + if (!quiet) this._error = e.message; + this._status = { running: false, connected: false, relay_url: null, last_error: null }; + } + if (!this._status.running) { + this._devices = []; + return; + } + try { + const d = await jf(`${this.api}/devices`); + this._devices = d.devices || []; + if (this._isAdmin && !this._users.length) { + try { this._users = await jf('/api/users'); } catch { /* the reassign dropdown stays empty */ } + } + this._detectPairing(); + } catch (e) { + if (!quiet) this._error = e.message; + if (this._devices === null) this._devices = []; + } + } + + // ── Pairing dialog ───────────────────────────────────────────────────────── + + _detectPairing() { + // While the dialog is open, a pubkey we have never seen means the phone + // just scanned the QR — switch the dialog to its success state. + if (!this._pair || !this._pair.session || this._pair.paired) { + this._knownPubkeys = new Set((this._devices || []).map(d => d.pubkey)); + return; + } + const fresh = (this._devices || []).find(d => !this._knownPubkeys.has(d.pubkey)); + if (fresh) { + this._pair = { ...this._pair, paired: true }; + this._stopPairWatch(); + } + } + + _startPairWatch() { + this._stopPairWatch(); + this._pairPoll = setInterval(() => this._load(true), 2000); + const tick = () => { + if (!this._pair?.session) return this._stopPairWatch(); + const remain = Math.max(0, Math.round((this._pair.session.expires_at - Date.now()) / 1000)); + this._pair = { ...this._pair, remain }; + if (remain <= 0 && this._pairTimer) { clearInterval(this._pairTimer); this._pairTimer = null; } + }; + tick(); + this._pairTimer = setInterval(tick, 1000); + } + + _stopPairWatch() { + if (this._pairPoll) { clearInterval(this._pairPoll); this._pairPoll = null; } + if (this._pairTimer) { clearInterval(this._pairTimer); this._pairTimer = null; } + } + + async _openPairing() { + this._pair = { session: null, remain: 0, busy: true, error: null, paired: false }; + this._knownPubkeys = new Set((this._devices || []).map(d => d.pubkey)); + try { + const session = await jf(`${this.api}/pairing`, { method: 'POST', body: JSON.stringify({}) }); + this._pair = { ...this._pair, session, busy: false }; + this._startPairWatch(); + } catch (e) { + this._pair = { ...this._pair, busy: false, error: e.message }; + } + } + + async _closePairing() { + const had = this._pair?.session && !this._pair.paired; + this._stopPairWatch(); + this._pair = null; + // Best-effort close of the window we opened (a consumed/expired one is + // already gone server-side; a paired one belongs to the new device). + if (had) { try { await jf(`${this.api}/pairing`, { method: 'DELETE' }); } catch { /* ignore */ } } + } + + // ── Device actions ───────────────────────────────────────────────────────── + + _userName(id) { + const u = this._users.find(x => x.id === id); + return u ? (u.display_name || u.username) : id; + } + + async _bind(pubkey) { + const user_id = this._pick[pubkey]; + if (!user_id) return; + try { + await jf(`${this.api}/devices/bind`, { method: 'POST', body: JSON.stringify({ pubkey, user_id }) }); + await this._load(); + } catch (e) { this._error = e.message; } + } + + async _revoke(pubkey) { + if (!confirm(t(`${P}.devices.revoke_confirm`))) return; + try { + await jf(`${this.api}/devices/revoke`, { method: 'POST', body: JSON.stringify({ pubkey }) }); + await this._load(); + } catch (e) { this._error = e.message; } + } + + // ── Settings dialog (admin) ──────────────────────────────────────────────── + + async _openConfig() { + this._cfg = { loading: true, error: null, ok: false, draft: null, relayChoice: 'test', customUrl: '', enabled: true }; + try { + const all = await jf('/api/plugins'); + const p = (all ?? []).find(x => x.id === 'mobile-connector'); + if (!p) throw new Error(t(`${P}.cfg.not_found`)); + const c = p.config || {}; + const url = c.relay_url || ''; + const relayChoice = url === RELAY_OFFICIAL ? 'official' : (url === RELAY_TEST || !url) ? 'test' : 'custom'; + this._cfg = { + ...this._cfg, + loading: false, + enabled: !!p.enabled, + relayChoice, + customUrl: relayChoice === 'custom' ? url : '', + draft: { + relay_url: url, + pairing_ttl: c.pairing_ttl ?? 300, + require_device_confirmation: c.require_device_confirmation !== false, + notify_delay_secs: c.notify_delay_secs ?? 20, + }, + }; + } catch (e) { + this._cfg = { ...this._cfg, loading: false, error: e.message }; + } + } + + _patchCfg(key, value) { + this._cfg = { ...this._cfg, draft: { ...this._cfg.draft, [key]: value }, ok: false }; + } + + async _saveConfig() { + const { draft, relayChoice, customUrl, enabled } = this._cfg; + const relay_url = relayChoice === 'custom' ? (customUrl || '').trim() + : relayChoice === 'official' ? RELAY_OFFICIAL : RELAY_TEST; + if (relayChoice === 'custom' && !/^wss?:\/\/.+/.test(relay_url)) { + this._cfg = { ...this._cfg, error: t(`${P}.cfg.bad_url`), ok: false }; + return; + } + this._cfg = { ...this._cfg, busy: true, error: null, ok: false }; + try { + await jf('/api/plugins/mobile-connector', { + method: 'PUT', + body: JSON.stringify({ enabled, config: { ...draft, relay_url } }), + }); + this._cfg = { ...this._cfg, busy: false, ok: true, draft: { ...draft, relay_url } }; + // The plugin reloads on save; the status poll picks up the reconnection. + setTimeout(() => { if (this._cfg?.ok) this._cfg = null; this._load(true); }, 900); + } catch (e) { + this._cfg = { ...this._cfg, busy: false, error: e.message }; + } + } + + // ── Render ───────────────────────────────────────────────────────────────── + + render() { + return html` +
+
+

${t(`${P}.app.title`)}

+
+ ${this._renderStatusPill()} + + ${this._isAdmin ? html` + ` : nothing} +
+
+
+ ${this._renderStatusAlerts()} + ${this._error ? html`
${this._error}
` : nothing} + ${this._renderDevices()} +
+ ${this._renderPairDialog()} + ${this._renderConfigDialog()} +
`; + } + + _renderStatusPill() { + const s = this._status; + const [cls, icon, key] = !s ? ['text-bg-secondary', 'bi-hourglass-split', 'loading'] + : !s.running ? ['text-bg-secondary', 'bi-pause-circle', 'off'] + : s.connected ? ['text-bg-success', 'bi-check-circle', 'connected'] + : ['text-bg-warning', 'bi-arrow-repeat', 'connecting']; + return html` + + ${t(`${P}.status.${key}`)} + `; + } + + _renderStatusAlerts() { + const s = this._status; + if (!s) return nothing; + if (!s.running) { + return html` +
+ +
${t(this._isAdmin ? `${P}.status.off_hint_admin` : `${P}.status.off_hint`)}
+
`; + } + if (!s.connected) { + return html` +
+
+ +
+ ${t(`${P}.status.connecting_hint`)} + ${s.last_error ? html` +
+ ${t(`${P}.status.last_error`)}: ${s.last_error} +
` : nothing} +
+
+
`; + } + return nothing; + } + + _renderDevices() { + if (this._devices === null) { + return html`
${t(`${P}.devices.loading`)}
`; + } + if (!this._devices.length) { + return html` +
+ +

${t(`${P}.devices.empty`)}

+ ${this._status?.connected ? html`

${t(`${P}.devices.empty_hint`)}

` : nothing} +
`; + } + return html`
${this._devices.map(d => this._renderDevice(d))}
`; + } + + _renderDevice(d) { + const authorized = d.state === 'authorized'; + return html` +
+
+
+ +
+
+
+ ${deviceLabel(d)} + + ${t(`${P}.devices.state_${d.state}`)} + + ${this._isAdmin && d.bound_user ? html` + + ${this._userName(d.bound_user)} + ` : nothing} +
+
+ ${d.pubkey.slice(0, 16)}… + · ${t(`${P}.devices.col_last_seen`)}: ${ago(d.last_seen)} +
+
+
+ ${this._isAdmin ? html` + + ` : nothing} + +
+
+
`; + } + + _renderPairDialog() { + const p = this._pair; + if (!p) return nothing; + const expired = p.session && p.remain <= 0; + return html` +
{ if (e.target.classList.contains('um-modal-overlay')) this._closePairing(); }}> +
+
+ + ${t(`${P}.pair.title`)} + +
+
+ ${p.error ? html` +
${p.error}
+ ${!p.session && !p.busy ? html` + ` : nothing}` : nothing} + ${p.busy ? html` +
${t(`${P}.pair.opening`)}
` : nothing} + ${p.paired ? html` +
+ +
${t(`${P}.pair.done`)}
+
${t(`${P}.pair.done_hint`)}
+
` : nothing} + ${p.session && !p.paired ? html` +
+ ${t(`${P}.pair.qr_alt`)} + ${expired + ? html`
${t(`${P}.pair.expired`)}
` + : html`
${t(`${P}.pair.scan_within`, { n: p.remain })}
`} +
${t(`${P}.pair.intro`)}
+
` : nothing} +
+ ${p.paired || p.session ? html` + ` : nothing} +
+
`; + } + + _renderConfigDialog() { + const c = this._cfg; + if (!c) return nothing; + const d = c.draft || {}; + return html` +
{ if (e.target.classList.contains('um-modal-overlay')) this._cfg = null; }}> +
+
+ + ${t(`${P}.cfg.title`)} + +
+
+ ${c.loading ? html`
` : html` + ${c.error ? html`
${c.error}
` : nothing} + ${c.ok ? html`
${t(`${P}.cfg.saved`)}
` : nothing} + +
+ + + ${c.relayChoice === 'custom' ? html` + this._cfg = { ...this._cfg, customUrl: e.target.value, ok: false }} />` : nothing} +
+ +
+ + this._patchCfg('pairing_ttl', Number(e.target.value))} /> +
${t(`${P}.cfg.pairing_ttl_desc`)}
+
+ +
+
+ this._patchCfg('require_device_confirmation', e.target.checked)} /> + +
+
${t(`${P}.cfg.require_confirmation_desc`)}
+
+ +
+ + this._patchCfg('notify_delay_secs', Number(e.target.value))} /> +
${t(`${P}.cfg.notify_delay_desc`)}
+
`} +
+ +
+
`; + } +} diff --git a/crates/plugin-mobile-connector/web/common.js b/crates/plugin-mobile-connector/web/common.js index ab95307..2cc4a11 100644 --- a/crates/plugin-mobile-connector/web/common.js +++ b/crates/plugin-mobile-connector/web/common.js @@ -1,11 +1,12 @@ -// Shared helpers for the mobile-connector console fragments. +// Shared helpers for the mobile-connector "Mobile App" page fragment. // // Served at `/api/plugin/mobile-connector/web/common.js` and imported by the -// two page fragments via a relative `./common.js` specifier. Everything the -// fragments need is self-contained here — the host injects no APIs (see -// `Plugin::web_pages` contract): they talk only to `/api/plugin//…` and, -// for the user directory used by the reassign dropdown, the host `/api/users` -// (the fragment runs with the logged-in admin's full session privileges). +// page fragment via a relative `./common.js` specifier. Everything the +// fragment needs is self-contained here — the host injects no APIs (see +// `Plugin::web_pages` contract): it talks only to `/api/plugin//…` and, +// for the user directory used by the admin reassign dropdown plus the caller's +// role, the host `/api/users` and `/api/auth/me` (the fragment runs with the +// logged-in user's full session privileges). // // i18n: the plugin ships its own dictionary (`./i18n.js`) and registers it into // the host's shared strings via `addStrings` (imported from the app root by the diff --git a/crates/plugin-mobile-connector/web/devices.js b/crates/plugin-mobile-connector/web/devices.js deleted file mode 100644 index 3293396..0000000 --- a/crates/plugin-mobile-connector/web/devices.js +++ /dev/null @@ -1,145 +0,0 @@ -// Mobile-connector "Mobile devices" console (page_id `devices`). -// -// Lists every paired device with its state and bound user, and lets an admin -// reassign a device to another user (`POST /devices/bind`) or revoke it -// (`POST /devices/revoke`). The user directory for the reassign dropdown comes -// from the host `/api/users` (the fragment runs with the admin's session). -// Default-exports the element class; the host registers it. -import { html, nothing } from 'lit'; -import { MobileBase, jf, ago, deviceLabel, t } from './common.js'; - -const P = 'plugin.mobile-connector'; - -export default class MobileDevicesPage extends MobileBase { - static get properties() { - return { - _devices: { state: true }, // [] | null (loading) - _users: { state: true }, // [{id, username, display_name}] - _error: { state: true }, - _pick: { state: true }, // { [pubkey]: user_id } reassign selections - }; - } - - constructor() { - super(); - this._devices = null; - this._users = []; - this._error = null; - this._pick = {}; - this._poll = null; - } - - connectedCallback() { - super.connectedCallback(); - this._load(); - this._poll = setInterval(() => this._load(true), 5000); - } - - disconnectedCallback() { - super.disconnectedCallback(); - if (this._poll) { clearInterval(this._poll); this._poll = null; } - } - - async _load(quiet = false) { - if (!quiet) this._error = null; - try { - const [d, u] = await Promise.all([ - jf(`${this.api}/devices`), - this._users.length ? Promise.resolve({ list: this._users }) : jf('/api/users').then(list => ({ list })), - ]); - this._devices = d.devices || []; - if (u.list) this._users = u.list; - } catch (e) { - if (!quiet) this._error = e.message; - } - } - - _userName(id) { - const u = this._users.find(x => x.id === id); - return u ? (u.display_name || u.username) : id; - } - - async _bind(pubkey) { - const user_id = this._pick[pubkey]; - if (!user_id) return; - try { - await jf(`${this.api}/devices/bind`, { method: 'POST', body: JSON.stringify({ pubkey, user_id }) }); - await this._load(); - } catch (e) { this._error = e.message; } - } - - async _revoke(pubkey) { - if (!confirm(t(`${P}.devices.revoke_confirm`))) return; - try { - await jf(`${this.api}/devices/revoke`, { method: 'POST', body: JSON.stringify({ pubkey }) }); - await this._load(); - } catch (e) { this._error = e.message; } - } - - render() { - const loading = this._devices === null && !this._error; - return html` -
-
-

${t(`${P}.devices.title`)}

- -
-
- ${this._error ? html`
${this._error}
` : nothing} - ${loading ? html`
${t(`${P}.devices.loading`)}
` : this._renderList()} -
-
`; - } - - _renderList() { - const rows = this._devices || []; - if (!rows.length) { - return html`
-

${t(`${P}.devices.empty`)}

-

${t(`${P}.devices.empty_hint`)}

-
`; - } - return html` -
- - - - - ${rows.map(d => this._renderRow(d))} -
${t(`${P}.devices.col_device`)}${t(`${P}.devices.col_state`)}${t(`${P}.devices.col_bound`)}${t(`${P}.devices.col_last_seen`)}${t(`${P}.devices.col_actions`)}
-
`; - } - - _renderRow(d) { - const authorized = d.state === 'authorized'; - return html` - - -
${deviceLabel(d)}
-
- ${d.pubkey.slice(0, 16)}…
- - - ${t(`${P}.devices.state_${d.state}`)} - - ${d.bound_user ? this._userName(d.bound_user) : html``} - ${ago(d.last_seen)} - -
- - - -
- - `; - } -} diff --git a/crates/plugin-mobile-connector/web/i18n.js b/crates/plugin-mobile-connector/web/i18n.js index 785d3c8..4421ec4 100644 --- a/crates/plugin-mobile-connector/web/i18n.js +++ b/crates/plugin-mobile-connector/web/i18n.js @@ -1,4 +1,4 @@ -// Frontend translations for the mobile-connector page fragments. +// Frontend translations for the mobile-connector "Mobile App" page fragment. // // Served at `/api/plugin/mobile-connector/web/i18n.js` and imported by // `common.js`, which registers it into the host's shared dictionaries via @@ -10,30 +10,60 @@ const P = 'plugin.mobile-connector'; export default { en: { - [`${P}.pairing.title`]: 'Pair a device', - [`${P}.pairing.intro`]: 'Open a pairing window, then scan the QR code with the Skald mobile app. The device is linked to you and works immediately — you can reassign it to another user from the Mobile devices page.', - [`${P}.pairing.open`]: 'Open pairing window', - [`${P}.pairing.opening`]: 'Opening…', - [`${P}.pairing.qr_alt`]: 'Pairing QR', - [`${P}.pairing.expired`]: 'Window expired', - [`${P}.pairing.scan_within`]: 'Scan within {n}s', - [`${P}.pairing.new_code`]: 'New code', - [`${P}.pairing.close`]: 'Close', + [`${P}.app.title`]: 'Mobile App', + [`${P}.app.pair_new`]: 'Pair new device', + + [`${P}.status.loading`]: 'Checking…', + [`${P}.status.connected`]: 'Connected', + [`${P}.status.connecting`]: 'Connecting…', + [`${P}.status.off`]: 'Not running', + [`${P}.status.connecting_hint`]: 'The connector is not reachable at the moment — reconnecting automatically. Pairing is unavailable until the connection is back.', + [`${P}.status.last_error`]: 'Last error', + [`${P}.status.off_hint`]: 'The mobile connector is not running. Ask an administrator to configure it.', + [`${P}.status.off_hint_admin`]: 'The mobile connector is not running — open the settings (gear icon) and pick a relay server to bring it up.', + + [`${P}.pair.title`]: 'Pair new device', + [`${P}.pair.intro`]: 'Scan the QR code with the Skald mobile app. The device is linked to your account and works immediately.', + [`${P}.pair.opening`]: 'Opening…', + [`${P}.pair.qr_alt`]: 'Pairing QR', + [`${P}.pair.expired`]: 'Code expired', + [`${P}.pair.scan_within`]: 'Scan within {n}s', + [`${P}.pair.new_code`]: 'New code', + [`${P}.pair.retry`]: 'Try again', + [`${P}.pair.cancel`]: 'Cancel', + [`${P}.pair.close`]: 'Done', + [`${P}.pair.done`]: 'Device paired!', + [`${P}.pair.done_hint`]: 'The device has been linked to your account and appears in the list.', + + [`${P}.cfg.title`]: 'Mobile connector settings', + [`${P}.cfg.open`]: 'Settings', + [`${P}.cfg.not_found`]: 'Plugin not found.', + [`${P}.cfg.relay`]: 'Relay server', + [`${P}.cfg.relay_official`]: 'SkaldCircle — Official Relay Server', + [`${P}.cfg.relay_test`]: 'SkaldCircle — Test Server', + [`${P}.cfg.relay_custom`]: 'Custom — enter the URL manually', + [`${P}.cfg.coming_soon`]: 'coming soon', + [`${P}.cfg.bad_url`]: 'Enter a valid ws:// or wss:// URL.', + [`${P}.cfg.pairing_ttl`]: 'Pairing code lifetime (seconds)', + [`${P}.cfg.pairing_ttl_desc`]: 'How long a pairing QR code stays valid. Max 600.', + [`${P}.cfg.require_confirmation`]: 'Require device confirmation', + [`${P}.cfg.require_confirmation_desc`]:'A device paired outside a web pairing window stays pending until an admin assigns it (recommended).', + [`${P}.cfg.notify_delay`]: 'Notification delay (seconds)', + [`${P}.cfg.notify_delay_desc`]: 'Wait this long before pushing an approval/question to the phone. If you answer on the computer within the window, no phone notification is sent. 0 = push immediately.', + [`${P}.cfg.cancel`]: 'Cancel', + [`${P}.cfg.save`]: 'Save', + [`${P}.cfg.saving`]: 'Saving…', + [`${P}.cfg.saved`]: 'Saved — the connector is restarting with the new settings.', - [`${P}.devices.title`]: 'Mobile devices', - [`${P}.devices.refresh`]: 'Refresh', [`${P}.devices.loading`]: 'Loading…', [`${P}.devices.empty`]: 'No paired devices yet.', - [`${P}.devices.empty_hint`]: 'Use the Pair a device page to add one.', - [`${P}.devices.col_device`]: 'Device', - [`${P}.devices.col_state`]: 'State', - [`${P}.devices.col_bound`]: 'Bound to', + [`${P}.devices.empty_hint`]: 'Use "Pair new device" above to add one.', [`${P}.devices.col_last_seen`]: 'Last seen', - [`${P}.devices.col_actions`]: 'Actions', [`${P}.devices.state_authorized`]: 'authorized', [`${P}.devices.state_pending`]: 'pending', [`${P}.devices.assign_to`]: 'Assign to…', [`${P}.devices.bind`]: 'Bind', + [`${P}.devices.revoke`]: 'Revoke', [`${P}.devices.revoke_confirm`]: 'Revoke this device? It loses access immediately.', [`${P}.devices.unknown`]: 'Unknown device', @@ -45,30 +75,60 @@ export default { }, it: { - [`${P}.pairing.title`]: 'Associa un dispositivo', - [`${P}.pairing.intro`]: 'Apri una finestra di associazione, poi scansiona il codice QR con l’app Skald sul telefono. Il dispositivo viene collegato a te e funziona subito — puoi riassegnarlo a un altro utente dalla pagina Dispositivi mobili.', - [`${P}.pairing.open`]: 'Apri finestra di associazione', - [`${P}.pairing.opening`]: 'Apertura…', - [`${P}.pairing.qr_alt`]: 'QR di associazione', - [`${P}.pairing.expired`]: 'Finestra scaduta', - [`${P}.pairing.scan_within`]: 'Scansiona entro {n}s', - [`${P}.pairing.new_code`]: 'Nuovo codice', - [`${P}.pairing.close`]: 'Chiudi', + [`${P}.app.title`]: 'Mobile App', + [`${P}.app.pair_new`]: 'Associa nuovo dispositivo', + + [`${P}.status.loading`]: 'Verifica…', + [`${P}.status.connected`]: 'Connesso', + [`${P}.status.connecting`]: 'Connessione…', + [`${P}.status.off`]: 'Non attivo', + [`${P}.status.connecting_hint`]: 'Il connettore non è raggiungibile al momento — riconnessione automatica in corso. L’associazione non è disponibile finché la connessione non torna.', + [`${P}.status.last_error`]: 'Ultimo errore', + [`${P}.status.off_hint`]: 'Il connettore mobile non è attivo. Chiedi a un amministratore di configurarlo.', + [`${P}.status.off_hint_admin`]: 'Il connettore mobile non è attivo — apri le impostazioni (icona a ingranaggio) e scegli un relay server per avviarlo.', + + [`${P}.pair.title`]: 'Associa nuovo dispositivo', + [`${P}.pair.intro`]: 'Scansiona il codice QR con l’app Skald sul telefono. Il dispositivo viene collegato al tuo account e funziona subito.', + [`${P}.pair.opening`]: 'Apertura…', + [`${P}.pair.qr_alt`]: 'QR di associazione', + [`${P}.pair.expired`]: 'Codice scaduto', + [`${P}.pair.scan_within`]: 'Scansiona entro {n}s', + [`${P}.pair.new_code`]: 'Nuovo codice', + [`${P}.pair.retry`]: 'Riprova', + [`${P}.pair.cancel`]: 'Annulla', + [`${P}.pair.close`]: 'Fatto', + [`${P}.pair.done`]: 'Dispositivo associato!', + [`${P}.pair.done_hint`]: 'Il dispositivo è stato collegato al tuo account e compare nell’elenco.', + + [`${P}.cfg.title`]: 'Impostazioni connettore mobile', + [`${P}.cfg.open`]: 'Impostazioni', + [`${P}.cfg.not_found`]: 'Plugin non trovato.', + [`${P}.cfg.relay`]: 'Relay server', + [`${P}.cfg.relay_official`]: 'SkaldCircle — Relay Server ufficiale', + [`${P}.cfg.relay_test`]: 'SkaldCircle — Test Server', + [`${P}.cfg.relay_custom`]: 'Personalizzato — inserisci l’URL a mano', + [`${P}.cfg.coming_soon`]: 'in arrivo', + [`${P}.cfg.bad_url`]: 'Inserisci un URL ws:// o wss:// valido.', + [`${P}.cfg.pairing_ttl`]: 'Durata del codice di associazione (secondi)', + [`${P}.cfg.pairing_ttl_desc`]: 'Per quanto tempo un QR di associazione resta valido. Massimo 600.', + [`${P}.cfg.require_confirmation`]: 'Richiedi conferma del dispositivo', + [`${P}.cfg.require_confirmation_desc`]:'Un dispositivo associato fuori da una finestra web resta in attesa finché un amministratore non lo assegna (consigliato).', + [`${P}.cfg.notify_delay`]: 'Ritardo notifiche (secondi)', + [`${P}.cfg.notify_delay_desc`]: 'Attendi questo tempo prima di inviare un’approvazione/domanda al telefono. Se rispondi dal computer entro la finestra, nessuna notifica viene inviata. 0 = invia subito.', + [`${P}.cfg.cancel`]: 'Annulla', + [`${P}.cfg.save`]: 'Salva', + [`${P}.cfg.saving`]: 'Salvataggio…', + [`${P}.cfg.saved`]: 'Salvato — il connettore si sta riavviando con le nuove impostazioni.', - [`${P}.devices.title`]: 'Dispositivi mobili', - [`${P}.devices.refresh`]: 'Aggiorna', [`${P}.devices.loading`]: 'Caricamento…', [`${P}.devices.empty`]: 'Nessun dispositivo associato.', - [`${P}.devices.empty_hint`]: 'Usa la pagina Associa un dispositivo per aggiungerne uno.', - [`${P}.devices.col_device`]: 'Dispositivo', - [`${P}.devices.col_state`]: 'Stato', - [`${P}.devices.col_bound`]: 'Assegnato a', + [`${P}.devices.empty_hint`]: 'Usa "Associa nuovo dispositivo" qui sopra per aggiungerne uno.', [`${P}.devices.col_last_seen`]: 'Ultimo accesso', - [`${P}.devices.col_actions`]: 'Azioni', [`${P}.devices.state_authorized`]: 'autorizzato', [`${P}.devices.state_pending`]: 'in attesa', [`${P}.devices.assign_to`]: 'Assegna a…', [`${P}.devices.bind`]: 'Associa', + [`${P}.devices.revoke`]: 'Revoca', [`${P}.devices.revoke_confirm`]: 'Revocare questo dispositivo? Perderà l’accesso immediatamente.', [`${P}.devices.unknown`]: 'Dispositivo sconosciuto', @@ -80,30 +140,60 @@ export default { }, fr: { - [`${P}.pairing.title`]: 'Associer un appareil', - [`${P}.pairing.intro`]: 'Ouvrez une fenêtre d’association, puis scannez le QR code avec l’app mobile Skald. L’appareil est lié à vous et fonctionne immédiatement — vous pouvez le réassigner à un autre utilisateur depuis la page Appareils mobiles.', - [`${P}.pairing.open`]: 'Ouvrir la fenêtre d’association', - [`${P}.pairing.opening`]: 'Ouverture…', - [`${P}.pairing.qr_alt`]: 'QR d’association', - [`${P}.pairing.expired`]: 'Fenêtre expirée', - [`${P}.pairing.scan_within`]: 'Scannez sous {n}s', - [`${P}.pairing.new_code`]: 'Nouveau code', - [`${P}.pairing.close`]: 'Fermer', + [`${P}.app.title`]: 'Mobile App', + [`${P}.app.pair_new`]: 'Associer un appareil', + + [`${P}.status.loading`]: 'Vérification…', + [`${P}.status.connected`]: 'Connecté', + [`${P}.status.connecting`]: 'Connexion…', + [`${P}.status.off`]: 'Inactif', + [`${P}.status.connecting_hint`]: 'Le connecteur est injoignable pour le moment — reconnexion automatique en cours. L’association est indisponible jusqu’au retour de la connexion.', + [`${P}.status.last_error`]: 'Dernière erreur', + [`${P}.status.off_hint`]: 'Le connecteur mobile est inactif. Demandez à un administrateur de le configurer.', + [`${P}.status.off_hint_admin`]: 'Le connecteur mobile est inactif — ouvrez les réglages (icône engrenage) et choisissez un serveur relais pour le démarrer.', + + [`${P}.pair.title`]: 'Associer un appareil', + [`${P}.pair.intro`]: 'Scannez le QR code avec l’app mobile Skald. L’appareil est lié à votre compte et fonctionne immédiatement.', + [`${P}.pair.opening`]: 'Ouverture…', + [`${P}.pair.qr_alt`]: 'QR d’association', + [`${P}.pair.expired`]: 'Code expiré', + [`${P}.pair.scan_within`]: 'Scannez sous {n}s', + [`${P}.pair.new_code`]: 'Nouveau code', + [`${P}.pair.retry`]: 'Réessayer', + [`${P}.pair.cancel`]: 'Annuler', + [`${P}.pair.close`]: 'Terminé', + [`${P}.pair.done`]: 'Appareil associé !', + [`${P}.pair.done_hint`]: 'L’appareil a été lié à votre compte et apparaît dans la liste.', + + [`${P}.cfg.title`]: 'Réglages du connecteur mobile', + [`${P}.cfg.open`]: 'Réglages', + [`${P}.cfg.not_found`]: 'Plugin introuvable.', + [`${P}.cfg.relay`]: 'Serveur relais', + [`${P}.cfg.relay_official`]: 'SkaldCircle — Serveur relais officiel', + [`${P}.cfg.relay_test`]: 'SkaldCircle — Serveur de test', + [`${P}.cfg.relay_custom`]: 'Personnalisé — saisir l’URL manuellement', + [`${P}.cfg.coming_soon`]: 'bientôt disponible', + [`${P}.cfg.bad_url`]: 'Saisissez une URL ws:// ou wss:// valide.', + [`${P}.cfg.pairing_ttl`]: 'Durée de vie du code d’association (secondes)', + [`${P}.cfg.pairing_ttl_desc`]: 'Durée de validité d’un QR d’association. Max 600.', + [`${P}.cfg.require_confirmation`]: 'Exiger une confirmation de l’appareil', + [`${P}.cfg.require_confirmation_desc`]:'Un appareil associé hors d’une fenêtre web reste en attente jusqu’à son assignation par un admin (recommandé).', + [`${P}.cfg.notify_delay`]: 'Délai de notification (secondes)', + [`${P}.cfg.notify_delay_desc`]: 'Attendre ce délai avant de pousser une approbation/question sur le téléphone. Si vous répondez sur l’ordinateur dans ce délai, aucune notification n’est envoyée. 0 = envoi immédiat.', + [`${P}.cfg.cancel`]: 'Annuler', + [`${P}.cfg.save`]: 'Enregistrer', + [`${P}.cfg.saving`]: 'Enregistrement…', + [`${P}.cfg.saved`]: 'Enregistré — le connecteur redémarre avec les nouveaux réglages.', - [`${P}.devices.title`]: 'Appareils mobiles', - [`${P}.devices.refresh`]: 'Actualiser', [`${P}.devices.loading`]: 'Chargement…', [`${P}.devices.empty`]: 'Aucun appareil associé.', - [`${P}.devices.empty_hint`]: 'Utilisez la page Associer un appareil pour en ajouter un.', - [`${P}.devices.col_device`]: 'Appareil', - [`${P}.devices.col_state`]: 'État', - [`${P}.devices.col_bound`]: 'Assigné à', + [`${P}.devices.empty_hint`]: 'Utilisez « Associer un appareil » ci-dessus pour en ajouter un.', [`${P}.devices.col_last_seen`]: 'Vu la dernière fois', - [`${P}.devices.col_actions`]: 'Actions', [`${P}.devices.state_authorized`]: 'autorisé', [`${P}.devices.state_pending`]: 'en attente', [`${P}.devices.assign_to`]: 'Assigner à…', [`${P}.devices.bind`]: 'Associer', + [`${P}.devices.revoke`]: 'Révoquer', [`${P}.devices.revoke_confirm`]: 'Révoquer cet appareil ? Il perd l’accès immédiatement.', [`${P}.devices.unknown`]: 'Appareil inconnu', diff --git a/crates/plugin-mobile-connector/web/pairing.js b/crates/plugin-mobile-connector/web/pairing.js deleted file mode 100644 index 2e95f70..0000000 --- a/crates/plugin-mobile-connector/web/pairing.js +++ /dev/null @@ -1,112 +0,0 @@ -// Mobile-connector "Pair a device" console (page_id `pairing`). -// -// Opens a pairing window on the plugin (`POST /pairing`), shows the QR the phone -// scans, and counts down to expiry. A device that pairs in this window is -// auto-bound to the admin who opened it (server-side, on `ClientPaired`) — so it -// is usable on the phone immediately and can be reassigned later from the -// Devices page. Default-exports the element class; the host registers it. -import { html, nothing } from 'lit'; -import { MobileBase, jf, t } from './common.js'; - -const P = 'plugin.mobile-connector'; - -export default class MobilePairingPage extends MobileBase { - static get properties() { - return { - _session: { state: true }, // { url, code, expires_at } | null - _remain: { state: true }, // seconds until expiry - _busy: { state: true }, - _error: { state: true }, - }; - } - - constructor() { - super(); - this._session = null; - this._remain = 0; - this._busy = false; - this._error = null; - this._timer = null; - } - - disconnectedCallback() { - super.disconnectedCallback(); - this._stopTimer(); - // Best-effort close so a forgotten window does not linger. - if (this._session) jf(`${this.api}/pairing`, { method: 'DELETE' }).catch(() => {}); - } - - _stopTimer() { if (this._timer) { clearInterval(this._timer); this._timer = null; } } - - _startTimer() { - this._stopTimer(); - const tick = () => { - const remain = Math.max(0, Math.round((this._session.expires_at - Date.now()) / 1000)); - this._remain = remain; - if (remain <= 0) { this._stopTimer(); } - }; - tick(); - this._timer = setInterval(tick, 1000); - } - - async _open() { - this._busy = true; - this._error = null; - try { - this._session = await jf(`${this.api}/pairing`, { method: 'POST', body: JSON.stringify({}) }); - this._startTimer(); - } catch (e) { - this._error = e.message; - this._session = null; - } finally { - this._busy = false; - } - } - - async _stop() { - this._stopTimer(); - const had = this._session; - this._session = null; - if (had) { try { await jf(`${this.api}/pairing`, { method: 'DELETE' }); } catch { /* ignore */ } } - } - - render() { - const expired = this._session && this._remain <= 0; - return html` -
-
-

${t(`${P}.pairing.title`)}

-
-
- ${this._error ? html`
${this._error}
` : nothing} - - ${!this._session ? html` -

- ${t(`${P}.pairing.intro`)} -

- - ` : html` -
- ${t(`${P}.pairing.qr_alt`)} - ${expired - ? html`
${t(`${P}.pairing.expired`)}
` - : html`
- ${t(`${P}.pairing.scan_within`, { n: this._remain })} -
`} -
- ${expired - ? html`` - : html``} -
-
- `} -
-
`; - } -} diff --git a/crates/skald-core/src/plugin/mod.rs b/crates/skald-core/src/plugin/mod.rs index 13909a4..406f6ad 100644 --- a/crates/skald-core/src/plugin/mod.rs +++ b/crates/skald-core/src/plugin/mod.rs @@ -42,6 +42,9 @@ pub struct PluginInfo { /// Whether the plugin gates access through its own binding lifecycle — the /// admin UI hides the "User access" checklist when true (see the trait). pub manages_own_access: bool, + /// Whether the plugin-detail page shows the generic `config_schema` form + /// (`false` = the plugin hosts its own config UI in one of its pages). + pub config_in_detail_page: bool, pub runtime_status: Option, } @@ -404,6 +407,7 @@ impl PluginManager { user_config_schema: plugin.user_config_schema(), has_router: plugin.http_router().is_some(), manages_own_access: plugin.manages_own_access(), + config_in_detail_page: plugin.config_in_detail_page(), runtime_status: plugin.runtime_status(), }); } @@ -463,8 +467,9 @@ impl PluginManager { /// entry of every **enabled** plugin, filtered by audience — `admin_only` /// pages go to the admin role only; the others require the `plugin_access` /// grant (admins see all). Binding-managed plugins (`manages_own_access`) - /// keep their pages admin-only unless the page says otherwise, mirroring - /// `list_accessible`. + /// own their access model (e.g. the device↔user binding), so their + /// non-`admin_only` pages are visible to every logged-in user and the page + /// itself scopes what each caller sees. pub async fn web_pages_for(&self, user_id: &str, is_admin: bool) -> Result> { let granted: std::collections::HashSet = if is_admin { std::collections::HashSet::new() @@ -484,8 +489,10 @@ impl PluginManager { for page in pages { let visible = if is_admin { true - } else if page.admin_only || owns_access { + } else if page.admin_only { false + } else if owns_access { + true } else { granted.contains(plugin.id()) }; @@ -640,15 +647,18 @@ mod tests { assert_eq!(admin[0].api_version, 1); // Non-admin: only the non-admin_only page of a granted, enabled, - // non-binding-managed plugin — beta is disabled, gamma manages its own - // access, alpha's admin console is admin_only. + // non-binding-managed plugin — beta is disabled, alpha's admin console + // is admin_only. gamma manages its own access, so its page is visible + // to everyone and self-scopes per caller. let user = mgr.web_pages_for("u1", false).await.unwrap(); let got: Vec<(&str, &str)> = user.iter() .map(|p| (p.plugin_id.as_str(), p.page_id.as_str())).collect(); - assert_eq!(got, vec![("alpha", "user-dash")]); + assert_eq!(got, vec![("gamma", "pairing"), ("alpha", "user-dash")]); - // A user with no grants sees nothing. + // A user with no grants sees only the binding-managed page. let stranger = mgr.web_pages_for("u2", false).await.unwrap(); - assert!(stranger.is_empty()); + let got: Vec<(&str, &str)> = stranger.iter() + .map(|p| (p.plugin_id.as_str(), p.page_id.as_str())).collect(); + assert_eq!(got, vec![("gamma", "pairing")]); } } diff --git a/crates/skald-relay-client/src/client.rs b/crates/skald-relay-client/src/client.rs index dd144f0..94db312 100644 --- a/crates/skald-relay-client/src/client.rs +++ b/crates/skald-relay-client/src/client.rs @@ -235,4 +235,15 @@ impl RelayClient { pub fn is_connected(&self) -> bool { self.state.is_connected() } + + /// The configured relay URL ("" when not configured — the loop stays idle). + pub fn relay_url(&self) -> String { + self.state.relay_url() + } + + /// The error that ended the last WS session, if any (UI troubleshooting). + /// Cleared on the next successful connect. + pub fn last_error(&self) -> Option { + self.state.last_error() + } } diff --git a/crates/skald-relay-client/src/state.rs b/crates/skald-relay-client/src/state.rs index 13a2083..824609a 100644 --- a/crates/skald-relay-client/src/state.rs +++ b/crates/skald-relay-client/src/state.rs @@ -58,6 +58,9 @@ pub(crate) struct RelayState { /// Derived from the seed + the client's x25519 pubkey; never persisted. aes_cache: Mutex>, connected: AtomicBool, + /// Last connection error that ended a WS session, for UI troubleshooting. + /// Cleared on the next successful connect. + last_error: Mutex>, /// Broadcast sink for [`RelayEvent`]s consumed by the application layer. events_tx: broadcast::Sender, /// Pending `open_pipe` waiters: connection_id → accept/reject delivery @@ -84,6 +87,7 @@ impl RelayState { outbound: Mutex::new(None), aes_cache: Mutex::new(HashMap::new()), connected: AtomicBool::new(false), + last_error: Mutex::new(None), events_tx, pipe_waiters: Mutex::new(HashMap::new()), incoming_pipes_tx, @@ -115,6 +119,10 @@ impl RelayState { pub(crate) fn set_connected(&self, v: bool) { let was = self.connected.swap(v, Ordering::Relaxed); + if v { + // A live connection means the previous error is resolved. + *self.last_error.lock().unwrap() = None; + } if was != v { self.emit(if v { RelayEvent::Connected } else { RelayEvent::Disconnected }); } @@ -124,6 +132,16 @@ impl RelayState { self.connected.load(Ordering::Relaxed) } + /// Record the error that ended a WS session (surfaced to the UI). + pub(crate) fn set_last_error(&self, msg: String) { + *self.last_error.lock().unwrap() = Some(msg); + } + + /// The last recorded connection error, if any. + pub(crate) fn last_error(&self) -> Option { + self.last_error.lock().unwrap().clone() + } + pub(crate) fn set_outbound(&self, tx: mpsc::UnboundedSender>) { *self.outbound.lock().unwrap() = Some(tx); } diff --git a/crates/skald-relay-client/src/ws.rs b/crates/skald-relay-client/src/ws.rs index 13f20a9..c9cecd9 100644 --- a/crates/skald-relay-client/src/ws.rs +++ b/crates/skald-relay-client/src/ws.rs @@ -46,6 +46,7 @@ pub(crate) async fn run_loop( } Err(e) => { warn!(crate_name = "skald-relay-client", error = %e, "relay connection ended"); + state.set_last_error(e.to_string()); } } diff --git a/docs/plugins/mobile-connector.md b/docs/plugins/mobile-connector.md index 31eb206..741cb19 100644 --- a/docs/plugins/mobile-connector.md +++ b/docs/plugins/mobile-connector.md @@ -10,30 +10,33 @@ Bridges the assistant's Inbox — pending approvals, clarification questions, an Unlike most plugins, per-user access here is **not** the usual grant checklist — it's the device↔user binding itself (see pairing below), so this plugin doesn't show the normal "user access" list in the admin UI. -## Requirements +## The Mobile App page -- A relay server URL (`wss://…`) to connect through. -- The companion mobile app installed on the user's phone. +Everything lives in one sidebar page — **Mobile App** (`#plugin/mobile-connector/app`), visible to every logged-in user: -## Enabling & configuring (admin) +- A **connection status** pill at the top (connected / connecting / not running). When the connection is down, the last connection error is shown to help troubleshooting. +- The **device list**: an admin sees every paired device (and can reassign or revoke any of them); anyone else sees only their own devices and can revoke them. +- **Pair new device** (top-right): opens the pairing dialog with the QR code. +- **Settings** (gear icon, admin only): opens the plugin's configuration dialog. This plugin's settings live *here*, not in the generic plugin configuration page. -1. Plugin catalog → **Mobile Connector** → enable, then **Configure**. -2. Fields: - - **`relay_url`** (required) — the relay server's WebSocket URL. - - **`pairing_ttl`** (default `300`, max `600`) — seconds a pairing QR code stays valid. - - **`require_device_confirmation`** (default `true`, recommended) — a newly paired device stays "pending" until an admin explicitly authorizes it; don't turn this off without a good reason. - - **`notify_delay_secs`** (default `20`) — grace period before pushing an approval/question to the phone, so answering on the computer first skips the redundant phone notification. `0` = push immediately. (Elicitations are always pushed immediately regardless of this setting.) +## Configuring (admin) -## Pairing a device (admin-mediated) +Open the settings dialog from the Mobile App page (gear icon). Fields: -This is intentionally **not** self-service, unlike Telegram: +- **Relay server** — pick *SkaldCircle — Test Server*, or *Custom* to enter any `wss://` URL by hand. (*SkaldCircle — Official Relay Server* is listed but not available yet.) +- **Pairing code lifetime** (default `300`, max `600`) — seconds a pairing QR code stays valid. +- **Require device confirmation** (default `true`, recommended) — a device paired outside a web pairing window (e.g. via the assistant) stays "pending" until an admin explicitly assigns it; don't turn this off without a good reason. +- **Notification delay** (default `20`) — grace period before pushing an approval/question to the phone, so answering on the computer first skips the redundant phone notification. `0` = push immediately. (Elicitations are always pushed immediately regardless of this setting.) -1. Admin opens **Pair a device** (sidebar, admin-only — `#plugin/mobile-connector/pairing`), which shows a QR code. -2. The user scans it from the mobile app. -3. The new device appears as "pending" on the **Mobile devices** page (`#plugin/mobile-connector/devices`). -4. The admin picks which user account to bind it to and confirms — only then can that device see that user's Inbox. +## Pairing a device (self-service) + +1. Open the **Mobile App** page and click **Pair new device** — a dialog shows a QR code. +2. Scan it from the mobile app. +3. The device is automatically linked to *your* account and works immediately — the dialog confirms the pairing. + +An admin can later reassign a device to another user from the device list. ## Notes -- A device stays bound until an admin revokes it from the Mobile devices page. -- If a user asks "why isn't my phone getting notifications", check: the plugin is enabled, their device is bound (not still pending), and — if it's not urgent — that they're not just inside the `notify_delay_secs` grace window. +- A device stays bound until revoked from the Mobile App page (an admin can revoke any device; you can revoke your own). +- If a user asks "why isn't my phone getting notifications", check: the status pill on the Mobile App page is "Connected", their device is bound (not still pending), and — if it's not urgent — that they're not just inside the notification-delay grace window. diff --git a/web/components/plugin-detail.js b/web/components/plugin-detail.js index caf46f1..15c64fa 100644 --- a/web/components/plugin-detail.js +++ b/web/components/plugin-detail.js @@ -97,11 +97,14 @@ export class PluginDetailPage extends LightElement { return; } this._plugin = p; - // If the plugin ships its own admin page (an `admin_only` web-page), the - // generic config form defers to it — see `_renderConfig`. + // If the plugin ships its own page(s), the generic config form may defer + // to them — see `_renderConfig`. Prefer an `admin_only` console page; + // otherwise any page of this plugin will do (e.g. mobile-connector's + // Mobile App page, which hosts its own settings dialog). try { const pages = await jf('/api/plugins/pages'); - this._customPage = (pages ?? []).find(pg => pg.plugin_id === this._id && pg.admin_only) ?? null; + const mine = (pages ?? []).filter(pg => pg.plugin_id === this._id); + this._customPage = mine.find(pg => pg.admin_only) ?? mine[0] ?? null; } catch { this._customPage = null; } // Keep whatever the admin has already typed across a reload triggered by a save. this._draft = { ...(p.config || {}), ...(this._draft || {}) }; @@ -276,11 +279,13 @@ export class PluginDetailPage extends LightElement { _renderConfig() { const p = this._plugin; const fields = schemaFields(p.config_schema); + // The plugin hosts its own config UI in one of its pages (e.g. the mobile + // connector's settings dialog): link out instead of duplicating the form. + if (p.config_in_detail_page === false) { + return this._customPage ? this._renderConfigLink() : nothing; + } // Defer to the plugin's own admin page only when there is no generic - // instance-config to show. A plugin like mobile-connector ships operational - // pages (pairing, devices) *alongside* a real `config_schema` (relay_url, - // …); those pages are complements — already reachable from the sidebar — not - // replacements, so the config form must still render. + // instance-config to show. if (fields.length === 0 && this._customPage) return this._renderConfigLink(); const draft = this._draft || {}; return html`