Release 0.2.0 #4

Merged
dguiducci merged 96 commits from main into release 2026-08-17 18:07:20 +01:00
18 changed files with 834 additions and 411 deletions
Showing only changes of commit 50e1333d99 - Show all commits
+16 -6
View File
@@ -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 usesits 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 userthe 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<()>;
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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."
}
+3 -1
View File
@@ -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."
}
+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(),
+475
View File
@@ -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`
<div class="um-page">
<div class="um-header d-flex justify-content-between align-items-center" style="flex-wrap:wrap;gap:.5rem">
<h2 class="um-title"><i class="bi bi-phone me-2"></i>${t(`${P}.app.title`)}</h2>
<div class="d-inline-flex gap-2 align-items-center">
${this._renderStatusPill()}
<button class="btn btn-sm btn-primary" @click=${() => this._openPairing()}
?disabled=${!this._status?.connected}>
<i class="bi bi-qr-code-scan me-1"></i>${t(`${P}.app.pair_new`)}
</button>
${this._isAdmin ? html`
<button class="btn btn-sm btn-outline-secondary" title=${t(`${P}.cfg.open`)} @click=${() => this._openConfig()}>
<i class="bi bi-gear"></i>
</button>` : nothing}
</div>
</div>
<div style="padding:0 1.25rem 1.5rem; max-width:860px">
${this._renderStatusAlerts()}
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
${this._renderDevices()}
</div>
${this._renderPairDialog()}
${this._renderConfigDialog()}
</div>`;
}
_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`
<span class="badge ${cls} d-inline-flex align-items-center gap-1" style="font-size:.72rem">
<i class="bi ${icon}"></i>${t(`${P}.status.${key}`)}
</span>`;
}
_renderStatusAlerts() {
const s = this._status;
if (!s) return nothing;
if (!s.running) {
return html`
<div class="alert alert-secondary py-2 d-flex align-items-start gap-2" style="font-size:.85rem">
<i class="bi bi-info-circle mt-1"></i>
<div>${t(this._isAdmin ? `${P}.status.off_hint_admin` : `${P}.status.off_hint`)}</div>
</div>`;
}
if (!s.connected) {
return html`
<div class="alert alert-warning py-2" style="font-size:.85rem">
<div class="d-flex align-items-start gap-2">
<i class="bi bi-exclamation-triangle mt-1"></i>
<div>
${t(`${P}.status.connecting_hint`)}
${s.last_error ? html`
<div class="mt-1" style="font-family:var(--font-mono,monospace);font-size:.75rem;word-break:break-all">
${t(`${P}.status.last_error`)}: ${s.last_error}
</div>` : nothing}
</div>
</div>
</div>`;
}
return nothing;
}
_renderDevices() {
if (this._devices === null) {
return html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t(`${P}.devices.loading`)}</div>`;
}
if (!this._devices.length) {
return html`
<div class="um-empty" style="padding:2rem 1rem">
<i class="bi bi-phone"></i>
<p>${t(`${P}.devices.empty`)}</p>
${this._status?.connected ? html`<p style="font-size:.8rem;opacity:.7">${t(`${P}.devices.empty_hint`)}</p>` : nothing}
</div>`;
}
return html`<div class="d-flex flex-column gap-2">${this._devices.map(d => this._renderDevice(d))}</div>`;
}
_renderDevice(d) {
const authorized = d.state === 'authorized';
return html`
<div class="connector-card" style="cursor:default">
<div class="d-flex align-items-center gap-3" style="flex-wrap:wrap">
<div class="connector-card-icon connector-card-icon--empty" style="width:40px;height:40px;flex:none">
<i class="bi bi-phone"></i>
</div>
<div style="min-width:0;flex:1">
<div class="d-flex align-items-center gap-2" style="flex-wrap:wrap">
<span style="font-weight:600">${deviceLabel(d)}</span>
<span class="badge ${authorized ? 'text-bg-success' : 'text-bg-secondary'}" style="font-size:.68rem">
${t(`${P}.devices.state_${d.state}`)}
</span>
${this._isAdmin && d.bound_user ? html`
<span class="badge text-bg-light" style="font-size:.68rem">
<i class="bi bi-person me-1"></i>${this._userName(d.bound_user)}
</span>` : nothing}
</div>
<div class="text-body-secondary" style="font-size:.72rem">
<span style="font-family:var(--font-mono,monospace)">${d.pubkey.slice(0, 16)}…</span>
· ${t(`${P}.devices.col_last_seen`)}: ${ago(d.last_seen)}
</div>
</div>
<div class="d-inline-flex gap-1 align-items-center">
${this._isAdmin ? html`
<select class="form-select form-select-sm" style="width:auto"
.value=${this._pick[d.pubkey] || d.bound_user || ''}
@change=${(e) => { this._pick = { ...this._pick, [d.pubkey]: e.target.value }; }}>
<option value="">${t(`${P}.devices.assign_to`)}</option>
${this._users.map(u => html`<option value=${u.id}>${u.display_name || u.username}</option>`)}
</select>
<button class="btn btn-sm btn-primary"
?disabled=${!this._pick[d.pubkey] || this._pick[d.pubkey] === d.bound_user}
@click=${() => this._bind(d.pubkey)}>${t(`${P}.devices.bind`)}</button>` : nothing}
<button class="btn btn-sm btn-outline-danger" title=${t(`${P}.devices.revoke`)} @click=${() => this._revoke(d.pubkey)}>
<i class="bi bi-trash"></i>
</button>
</div>
</div>
</div>`;
}
_renderPairDialog() {
const p = this._pair;
if (!p) return nothing;
const expired = p.session && p.remain <= 0;
return html`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._closePairing(); }}>
<div class="um-modal" style="max-width:420px">
<div class="um-modal-header">
<i class="bi bi-qr-code-scan"></i>
<span>${t(`${P}.pair.title`)}</span>
<button class="um-btn-icon ms-auto" @click=${() => this._closePairing()}><i class="bi bi-x-lg"></i></button>
</div>
<div class="um-modal-body">
${p.error ? html`
<div class="alert alert-danger py-2" style="font-size:.85rem">${p.error}</div>
${!p.session && !p.busy ? html`
<button class="btn btn-sm btn-primary" @click=${() => this._openPairing()}>
<i class="bi bi-arrow-repeat me-1"></i>${t(`${P}.pair.retry`)}</button>` : nothing}` : nothing}
${p.busy ? html`
<div class="um-empty" style="padding:2rem"><i class="bi bi-hourglass-split"></i> ${t(`${P}.pair.opening`)}</div>` : nothing}
${p.paired ? html`
<div class="d-flex flex-column align-items-center gap-2 py-3">
<i class="bi bi-check-circle" style="font-size:2.5rem;color:var(--bs-success,#198754)"></i>
<div style="font-weight:600">${t(`${P}.pair.done`)}</div>
<div class="text-body-secondary" style="font-size:.85rem;text-align:center">${t(`${P}.pair.done_hint`)}</div>
</div>` : nothing}
${p.session && !p.paired ? html`
<div class="d-flex flex-column align-items-center gap-3">
<img src=${p.session.url} alt=${t(`${P}.pair.qr_alt`)} width="256" height="256"
style="image-rendering:pixelated;border-radius:var(--radius-md,12px);${expired ? 'opacity:.25' : ''}" />
${expired
? html`<div class="text-danger" style="font-size:.9rem"><i class="bi bi-clock-history me-1"></i>${t(`${P}.pair.expired`)}</div>`
: html`<div class="text-body-secondary" style="font-size:.9rem">${t(`${P}.pair.scan_within`, { n: p.remain })}</div>`}
<div class="text-body-secondary" style="font-size:.8rem;text-align:center">${t(`${P}.pair.intro`)}</div>
</div>` : nothing}
</div>
${p.paired || p.session ? html`
<div class="um-modal-footer">
${p.paired ? html`
<button class="btn btn-sm btn-primary" @click=${() => this._closePairing()}>
<i class="bi bi-check-lg me-1"></i>${t(`${P}.pair.close`)}</button>` : nothing}
${expired ? html`
<button class="btn btn-sm btn-primary" @click=${() => this._openPairing()}>
<i class="bi bi-arrow-repeat me-1"></i>${t(`${P}.pair.new_code`)}</button>` : nothing}
${p.session && !p.paired && !expired ? html`
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._closePairing()}>
${t(`${P}.pair.cancel`)}</button>` : nothing}
</div>` : nothing}
</div>
</div>`;
}
_renderConfigDialog() {
const c = this._cfg;
if (!c) return nothing;
const d = c.draft || {};
return html`
<div class="um-modal-overlay" @click=${(e) => { if (e.target.classList.contains('um-modal-overlay')) this._cfg = null; }}>
<div class="um-modal" style="max-width:520px">
<div class="um-modal-header">
<i class="bi bi-gear"></i>
<span>${t(`${P}.cfg.title`)}</span>
<button class="um-btn-icon ms-auto" @click=${() => this._cfg = null}><i class="bi bi-x-lg"></i></button>
</div>
<div class="um-modal-body">
${c.loading ? html`<div class="um-empty" style="padding:2rem"><i class="bi bi-hourglass-split"></i></div>` : html`
${c.error ? html`<div class="alert alert-danger py-2 mb-3" style="font-size:.85rem">${c.error}</div>` : nothing}
${c.ok ? html`<div class="alert alert-success py-2 mb-3" style="font-size:.85rem">${t(`${P}.cfg.saved`)}</div>` : nothing}
<div class="mb-3">
<label class="form-label">${t(`${P}.cfg.relay`)}</label>
<select class="form-select" .value=${c.relayChoice}
@change=${(e) => this._cfg = { ...this._cfg, relayChoice: e.target.value, ok: false }}>
<option value="official" disabled>
${t(`${P}.cfg.relay_official`)}${RELAY_OFFICIAL} (${t(`${P}.cfg.coming_soon`)})
</option>
<option value="test">${t(`${P}.cfg.relay_test`)}${RELAY_TEST}</option>
<option value="custom">${t(`${P}.cfg.relay_custom`)}</option>
</select>
${c.relayChoice === 'custom' ? html`
<input class="form-control mt-2" style="font-family:var(--font-mono,monospace);font-size:.8rem"
placeholder="wss://relay.example.com/v1/ws" .value=${c.customUrl}
@input=${(e) => this._cfg = { ...this._cfg, customUrl: e.target.value, ok: false }} />` : nothing}
</div>
<div class="mb-3">
<label class="form-label">${t(`${P}.cfg.pairing_ttl`)}</label>
<input class="form-control" type="number" min="30" max="600" .value=${String(d.pairing_ttl ?? 300)}
@input=${(e) => this._patchCfg('pairing_ttl', Number(e.target.value))} />
<div class="form-text" style="font-size:.72rem">${t(`${P}.cfg.pairing_ttl_desc`)}</div>
</div>
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="mc-cfg-confirm"
.checked=${!!d.require_device_confirmation}
@change=${(e) => this._patchCfg('require_device_confirmation', e.target.checked)} />
<label class="form-check-label" for="mc-cfg-confirm">${t(`${P}.cfg.require_confirmation`)}</label>
</div>
<div class="form-text" style="font-size:.72rem">${t(`${P}.cfg.require_confirmation_desc`)}</div>
</div>
<div class="mb-1">
<label class="form-label">${t(`${P}.cfg.notify_delay`)}</label>
<input class="form-control" type="number" min="0" .value=${String(d.notify_delay_secs ?? 20)}
@input=${(e) => this._patchCfg('notify_delay_secs', Number(e.target.value))} />
<div class="form-text" style="font-size:.72rem">${t(`${P}.cfg.notify_delay_desc`)}</div>
</div>`}
</div>
<div class="um-modal-footer">
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._cfg = null}>${t(`${P}.cfg.cancel`)}</button>
<button class="btn btn-sm btn-primary" ?disabled=${c.loading || c.busy} @click=${() => this._saveConfig()}>
<i class="bi bi-check-lg me-1"></i>${c.busy ? t(`${P}.cfg.saving`) : t(`${P}.cfg.save`)}
</button>
</div>
</div>
</div>`;
}
}
+7 -6
View File
@@ -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/<id>/…` 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/<id>/…` 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
@@ -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`
<div class="um-page">
<div class="um-header d-flex justify-content-between align-items-center">
<h2 class="um-title"><i class="bi bi-phone me-2"></i>${t(`${P}.devices.title`)}</h2>
<button class="btn btn-sm btn-outline-secondary" @click=${() => this._load()}>
<i class="bi bi-arrow-repeat me-1"></i>${t(`${P}.devices.refresh`)}</button>
</div>
<div style="padding:0 1.25rem 1.5rem">
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
${loading ? html`<div class="um-empty"><i class="bi bi-hourglass-split"></i> ${t(`${P}.devices.loading`)}</div>` : this._renderList()}
</div>
</div>`;
}
_renderList() {
const rows = this._devices || [];
if (!rows.length) {
return html`<div class="um-empty" style="padding:1rem">
<i class="bi bi-phone"></i><p>${t(`${P}.devices.empty`)}</p>
<p style="font-size:.8rem;opacity:.7">${t(`${P}.devices.empty_hint`)}</p>
</div>`;
}
return html`
<div class="table-responsive">
<table class="table align-middle" style="font-size:.88rem">
<thead><tr>
<th>${t(`${P}.devices.col_device`)}</th><th>${t(`${P}.devices.col_state`)}</th><th>${t(`${P}.devices.col_bound`)}</th><th>${t(`${P}.devices.col_last_seen`)}</th><th class="text-end">${t(`${P}.devices.col_actions`)}</th>
</tr></thead>
<tbody>${rows.map(d => this._renderRow(d))}</tbody>
</table>
</div>`;
}
_renderRow(d) {
const authorized = d.state === 'authorized';
return html`
<tr>
<td>
<div>${deviceLabel(d)}</div>
<div class="text-body-secondary" style="font-size:.72rem; font-family:var(--font-mono,monospace)">
${d.pubkey.slice(0, 16)}…</div>
</td>
<td>
<span class="badge ${authorized ? 'text-bg-success' : 'text-bg-secondary'}">${t(`${P}.devices.state_${d.state}`)}</span>
</td>
<td>${d.bound_user ? this._userName(d.bound_user) : html`<span class="text-body-secondary">—</span>`}</td>
<td class="text-body-secondary">${ago(d.last_seen)}</td>
<td class="text-end">
<div class="d-inline-flex gap-1 align-items-center">
<select class="form-select form-select-sm" style="width:auto"
.value=${this._pick[d.pubkey] || d.bound_user || ''}
@change=${(e) => { this._pick = { ...this._pick, [d.pubkey]: e.target.value }; }}>
<option value="">${t(`${P}.devices.assign_to`)}</option>
${this._users.map(u => html`<option value=${u.id}>${u.display_name || u.username}</option>`)}
</select>
<button class="btn btn-sm btn-primary"
?disabled=${!this._pick[d.pubkey] || this._pick[d.pubkey] === d.bound_user}
@click=${() => this._bind(d.pubkey)}>${t(`${P}.devices.bind`)}</button>
<button class="btn btn-sm btn-outline-danger" @click=${() => this._revoke(d.pubkey)}>
<i class="bi bi-trash"></i></button>
</div>
</td>
</tr>`;
}
}
+139 -49
View File
@@ -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 lapp 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. Lassociazione 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 lapp 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 nellelenco.',
[`${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 lURL 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 unapprovazione/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à laccesso 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 dassociation, puis scannez le QR code avec lapp mobile Skald. Lappareil 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 dassociation',
[`${P}.pairing.opening`]: 'Ouverture…',
[`${P}.pairing.qr_alt`]: 'QR dassociation',
[`${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. Lassociation est indisponible jusquau 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 lapp mobile Skald. Lappareil est lié à votre compte et fonctionne immédiatement.',
[`${P}.pair.opening`]: 'Ouverture…',
[`${P}.pair.qr_alt`]: 'QR dassociation',
[`${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`]: 'Lappareil 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 lURL 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 dassociation (secondes)',
[`${P}.cfg.pairing_ttl_desc`]: 'Durée de validité dun QR dassociation. Max 600.',
[`${P}.cfg.require_confirmation`]: 'Exiger une confirmation de lappareil',
[`${P}.cfg.require_confirmation_desc`]:'Un appareil associé hors dune 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 lordinateur dans ce délai, aucune notification nest 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 laccès immédiatement.',
[`${P}.devices.unknown`]: 'Appareil inconnu',
@@ -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`
<div class="um-page">
<div class="um-header">
<h2 class="um-title"><i class="bi bi-qr-code me-2"></i>${t(`${P}.pairing.title`)}</h2>
</div>
<div style="padding:0 1.25rem 1.5rem; max-width:640px">
${this._error ? html`<div class="alert alert-danger py-2" style="font-size:.85rem">${this._error}</div>` : nothing}
${!this._session ? html`
<p class="text-body-secondary" style="font-size:.9rem">
${t(`${P}.pairing.intro`)}
</p>
<button class="btn btn-primary" ?disabled=${this._busy} @click=${() => this._open()}>
<i class="bi bi-qr-code-scan me-1"></i>${this._busy ? t(`${P}.pairing.opening`) : t(`${P}.pairing.open`)}
</button>
` : html`
<div class="d-flex flex-column align-items-center gap-3 p-3"
style="border:1px solid var(--border-color,#ddd); border-radius:var(--radius-md,12px)">
<img src=${this._session.url} alt=${t(`${P}.pairing.qr_alt`)} width="256" height="256"
style="image-rendering:pixelated; ${expired ? 'opacity:.25' : ''}" />
${expired
? html`<div class="text-danger" style="font-size:.9rem"><i class="bi bi-clock-history me-1"></i>${t(`${P}.pairing.expired`)}</div>`
: html`<div class="text-body-secondary" style="font-size:.9rem">
${t(`${P}.pairing.scan_within`, { n: this._remain })}
</div>`}
<div class="d-flex gap-2">
${expired
? html`<button class="btn btn-primary btn-sm" @click=${() => this._open()}>
<i class="bi bi-arrow-repeat me-1"></i>${t(`${P}.pairing.new_code`)}</button>`
: html`<button class="btn btn-outline-secondary btn-sm" @click=${() => this._stop()}>
<i class="bi bi-x-lg me-1"></i>${t(`${P}.pairing.close`)}</button>`}
</div>
</div>
`}
</div>
</div>`;
}
}
+18 -8
View File
@@ -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<Value>,
}
@@ -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<Vec<PluginPageInfo>> {
let granted: std::collections::HashSet<String> = 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")]);
}
}
+11
View File
@@ -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<String> {
self.state.last_error()
}
}
+18
View File
@@ -58,6 +58,9 @@ pub(crate) struct RelayState {
/// Derived from the seed + the client's x25519 pubkey; never persisted.
aes_cache: Mutex<HashMap<[u8; 32], [u8; 32]>>,
connected: AtomicBool,
/// Last connection error that ended a WS session, for UI troubleshooting.
/// Cleared on the next successful connect.
last_error: Mutex<Option<String>>,
/// Broadcast sink for [`RelayEvent`]s consumed by the application layer.
events_tx: broadcast::Sender<RelayEvent>,
/// 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<String> {
self.last_error.lock().unwrap().clone()
}
pub(crate) fn set_outbound(&self, tx: mpsc::UnboundedSender<Vec<u8>>) {
*self.outbound.lock().unwrap() = Some(tx);
}
+1
View File
@@ -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());
}
}
+21 -18
View File
@@ -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.
+12 -7
View File
@@ -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`