Release 0.2.0 #4
@@ -136,14 +136,24 @@ pub trait Plugin: Send + Sync {
|
|||||||
|
|
||||||
/// Whether the plugin decides *who may use it* through its own binding /
|
/// Whether the plugin decides *who may use it* through its own binding /
|
||||||
/// pairing lifecycle rather than the generic `plugin_access` grants — e.g.
|
/// pairing lifecycle rather than the generic `plugin_access` grants — e.g.
|
||||||
/// the mobile connector, whose access is the admin-mediated device→user
|
/// the mobile connector, whose access is the device→user binding (§13).
|
||||||
/// binding (§13). When `true`, the admin Plugins UI suppresses the "User
|
/// When `true`, the admin Plugins UI suppresses the "User access"
|
||||||
/// access" checklist (it would control nothing) and the plugin never appears
|
/// checklist (it would control nothing), the plugin never appears in a
|
||||||
/// in a user's "My plugins" view. Default `false`: access is the admin's
|
/// user's "My plugins" view, and its non-`admin_only` `web_pages()` are
|
||||||
/// per-user `plugin_access` grant (as Telegram uses — its grant gates the
|
/// visible to every logged-in user — the page itself scopes what each
|
||||||
/// bot at runtime even though pairing is self-service).
|
/// 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 }
|
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.
|
/// Called whenever the enabled flag or config changes — including at startup.
|
||||||
/// The plugin is responsible for diffing state and restarting only what changed.
|
/// The plugin is responsible for diffing state and restarting only what changed.
|
||||||
async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()>;
|
async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()>;
|
||||||
|
|||||||
@@ -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.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.admin_only": "Admin only.",
|
||||||
"plugin.mobile-connector.err.user_id_empty": "The user must not be empty.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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.admin_only": "Administrateur uniquement.",
|
||||||
"plugin.mobile-connector.err.user_id_empty": "L'utilisateur ne doit pas être vide.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.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.admin_only": "Solo amministratore.",
|
||||||
"plugin.mobile-connector.err.user_id_empty": "L'utente non può essere vuoto.",
|
"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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ pub struct RelayApp {
|
|||||||
/// Per-user debounced notifiers, created on demand by the forwarders.
|
/// Per-user debounced notifiers, created on demand by the forwarders.
|
||||||
pub(crate) notifiers: Mutex<HashMap<String, Arc<DelayedNotifier>>>,
|
pub(crate) notifiers: Mutex<HashMap<String, Arc<DelayedNotifier>>>,
|
||||||
/// The user a device paired *during the current window* auto-binds to — set
|
/// 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
|
/// the agent-tool flow (`mobile_start_pairing`), which leaves the device
|
||||||
/// Pending for an explicit `mobile_bind_device`. Cleared on stop-pairing.
|
/// Pending for an explicit `mobile_bind_device`. Cleared on stop-pairing.
|
||||||
pending_owner: Mutex<Option<String>>,
|
pending_owner: Mutex<Option<String>>,
|
||||||
@@ -91,7 +91,7 @@ impl RelayApp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Set (or clear) the user that devices paired during the current window
|
/// 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>) {
|
pub(crate) async fn set_pending_owner(&self, user_id: Option<String>) {
|
||||||
*self.pending_owner.lock().await = user_id;
|
*self.pending_owner.lock().await = user_id;
|
||||||
}
|
}
|
||||||
@@ -107,6 +107,11 @@ impl RelayApp {
|
|||||||
&self.client
|
&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
|
/// Backend localizer — the router resolves its error strings to the caller's
|
||||||
/// language through this (`app.i18n().for_user(user_id, key, &[])`).
|
/// language through this (`app.i18n().for_user(user_id, key, &[])`).
|
||||||
pub(crate) fn i18n(&self) -> &Arc<dyn I18nApi> {
|
pub(crate) fn i18n(&self) -> &Arc<dyn I18nApi> {
|
||||||
@@ -363,16 +368,16 @@ impl RelayApp {
|
|||||||
self.apply_client_payload(&from, &payload).await;
|
self.apply_client_payload(&from, &payload).await;
|
||||||
}
|
}
|
||||||
Ok(RelayEvent::ClientPaired { ed25519_pub, .. }) => {
|
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
|
// the pending owner, so bind (and thereby authorize) the
|
||||||
// device to them straight away — usable on the phone at
|
// 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 {
|
if let Some(owner) = self.pending_owner().await {
|
||||||
match self.bind_device(ed25519_pub, owner.clone(), None).await {
|
match self.bind_device(ed25519_pub, owner.clone(), None).await {
|
||||||
Ok(()) => info!(
|
Ok(()) => info!(
|
||||||
plugin = PLUGIN_ID, user_id = %owner,
|
plugin = PLUGIN_ID, user_id = %owner,
|
||||||
device = %hex::encode(ed25519_pub),
|
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"),
|
Err(e) => warn!(plugin = PLUGIN_ID, error = %e, "auto-bind on pair failed"),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
//! - `events` — per-user event forwarders (drive the notifiers)
|
//! - `events` — per-user event forwarders (drive the notifiers)
|
||||||
//! - `notifier` — per-user debounced Inbox pushes
|
//! - `notifier` — per-user debounced Inbox pushes
|
||||||
//! - `proxy` — HTTP reverse proxy to the local web UI (user-agnostic)
|
//! - `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
|
//! - `agent` — the `RelayAgent` control trait
|
||||||
//! - `tools` — `Tool` impls callable by the host (registered in the main crate)
|
//! - `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.
|
/// the admin Plugins UI hides the "User access" checklist for this plugin.
|
||||||
fn manages_own_access(&self) -> bool { true }
|
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 {
|
fn config_schema(&self) -> Value {
|
||||||
json!({
|
json!({
|
||||||
"type": "object",
|
"type": "object",
|
||||||
@@ -275,14 +279,15 @@ impl Plugin for MobileConnectorPlugin {
|
|||||||
if !self.running.load(Ordering::Relaxed) {
|
if !self.running.load(Ordering::Relaxed) {
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// Synchronous status: report connection flag from the live client.
|
// Synchronous status: report connection flag + last error from the
|
||||||
let connected = self
|
// live client (surfaced on the Mobile App page for troubleshooting).
|
||||||
|
let (connected, last_error) = self
|
||||||
.inner
|
.inner
|
||||||
.try_lock()
|
.try_lock()
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|g| g.as_ref().map(|app| app.client().is_connected()))
|
.and_then(|g| g.as_ref().map(|app| (app.client().is_connected(), app.client().last_error())))
|
||||||
.unwrap_or(false);
|
.unwrap_or((false, None));
|
||||||
Some(json!({ "connected": connected }))
|
Some(json!({ "connected": connected, "last_error": last_error }))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reload(&self, enabled: bool, config: Value, ctx: PluginContext) -> Result<()> {
|
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)))
|
Some(router::build(Arc::clone(&self.inner)))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Two admin-only console pages served from this plugin's own router
|
/// The single "Mobile App" console page served from this plugin's own
|
||||||
/// (`web/*.js`). `manages_own_access` already hides them from non-admins.
|
/// 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> {
|
fn web_pages(&self) -> Vec<PluginPage> {
|
||||||
vec![
|
vec![
|
||||||
PluginPage {
|
PluginPage {
|
||||||
page_id: "pairing",
|
page_id: "app",
|
||||||
title: "Pair a device".into(),
|
title: "Mobile App".into(),
|
||||||
icon: "qr-code",
|
icon: "phone",
|
||||||
entry: "web/pairing.js".into(),
|
entry: "web/app.js".into(),
|
||||||
admin_only: true,
|
admin_only: false,
|
||||||
// Sidebar priority: core "Your space" items live in 10–90, so
|
// Sidebar priority: core "Your space" items live in 10–90, so
|
||||||
// plugin pages use ≥100 to land after them (see sidebar.js NAV).
|
// plugin pages use ≥100 to land after them (see sidebar.js NAV).
|
||||||
priority: 100,
|
priority: 100,
|
||||||
},
|
},
|
||||||
PluginPage {
|
|
||||||
page_id: "devices",
|
|
||||||
title: "Mobile devices".into(),
|
|
||||||
icon: "phone",
|
|
||||||
entry: "web/devices.js".into(),
|
|
||||||
admin_only: true,
|
|
||||||
priority: 110,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,16 +4,16 @@
|
|||||||
//! Two audiences on one router:
|
//! Two audiences on one router:
|
||||||
//! - the **QR endpoint** (`/pairingqrcode`) — renders the pairing QR PNG on
|
//! - the **QR endpoint** (`/pairingqrcode`) — renders the pairing QR PNG on
|
||||||
//! demand from the in-memory session (no QR ever touches disk);
|
//! demand from the in-memory session (no QR ever touches disk);
|
||||||
//! - the **admin pairing console** — the JSON API + the two page fragments
|
//! - the **Mobile App console** — the JSON API + the page fragment
|
||||||
//! (`web/pairing.js`, `web/devices.js`) that let an admin pair, list, bind and
|
//! (`web/app.js`) behind the single "Mobile App" menu page: connection
|
||||||
//! revoke devices from the browser instead of driving the LLM control tools.
|
//! status, device list, self-service pairing, and device revocation.
|
||||||
//!
|
//!
|
||||||
//! Every request resolves the *current* [`RelayApp`] through the shared state
|
//! Every request resolves the *current* [`RelayApp`] through the shared state
|
||||||
//! cell (`Arc<Mutex<Option<Arc<RelayApp>>>>`), so a reconfigure (reload → fresh
|
//! cell (`Arc<Mutex<Option<Arc<RelayApp>>>>`), so a reconfigure (reload → fresh
|
||||||
//! `RelayApp`) is transparent. Management endpoints are admin-only: the router
|
//! `RelayApp`) is transparent. Access is self-scoped per caller: any logged-in
|
||||||
//! runs inside `require_auth` (which injects [`Caller`]) and gates on
|
//! user may pair a device (it auto-binds to them), list their own devices and
|
||||||
//! [`UserChannelApi::plugin_access`], which — because the connector
|
//! revoke them; listing every device and (re)binding to another user stays
|
||||||
//! `manages_own_access` — returns `true` only for admins.
|
//! admin-only (gated on [`UserChannelApi::is_admin`]).
|
||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -37,12 +37,13 @@ use crate::PLUGIN_ID;
|
|||||||
type StateCell = Arc<Mutex<Option<Arc<RelayApp>>>>;
|
type StateCell = Arc<Mutex<Option<Arc<RelayApp>>>>;
|
||||||
|
|
||||||
// Namespaced i18n keys for the router's user-facing strings (backend tables in
|
// 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
|
// `../i18n/*.json`). Resolved to the caller's language via `app.i18n()`.
|
||||||
// use sits after `admin_app`, so the app — hence the localizer — is present.
|
|
||||||
const KEY_RELAY_NOT_CONNECTED: &str = "plugin.mobile-connector.err.relay_not_connected";
|
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_ADMIN_ONLY: &str = "plugin.mobile-connector.err.admin_only";
|
||||||
const KEY_USER_ID_EMPTY: &str = "plugin.mobile-connector.err.user_id_empty";
|
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_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
|
/// Build the plugin's router. Takes the shared state cell so each request
|
||||||
/// resolves the *current* `RelayApp` — not a snapshot from startup.
|
/// resolves the *current* `RelayApp` — not a snapshot from startup.
|
||||||
@@ -50,11 +51,11 @@ pub fn build(state_cell: StateCell) -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/pairingqrcode", get(pairing_qr))
|
.route("/pairingqrcode", get(pairing_qr))
|
||||||
// Page fragments (served as ES modules to the browser).
|
// Page fragments (served as ES modules to the browser).
|
||||||
.route("/web/pairing.js", get(|| async { serve_js(include_str!("../web/pairing.js")) }))
|
.route("/web/app.js", get(|| async { serve_js(include_str!("../web/app.js")) }))
|
||||||
.route("/web/devices.js", get(|| async { serve_js(include_str!("../web/devices.js")) }))
|
|
||||||
.route("/web/common.js", get(|| async { serve_js(include_str!("../web/common.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")) }))
|
.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("/pairing", post(start_pairing).delete(stop_pairing))
|
||||||
.route("/devices", get(list_devices))
|
.route("/devices", get(list_devices))
|
||||||
.route("/devices/bind", post(bind_device))
|
.route("/devices/bind", post(bind_device))
|
||||||
@@ -62,7 +63,7 @@ pub fn build(state_cell: StateCell) -> Router {
|
|||||||
.with_state(state_cell)
|
.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
|
/// Resolve the live app, or `503` when the plugin is enabled but its runloop is
|
||||||
/// not up (e.g. no `relay_url` configured).
|
/// 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
|
/// Fail-closed admin gate, via [`UserChannelApi::is_admin`].
|
||||||
/// `plugin_access` grant, so this is `true` only for the built-in admin role.
|
|
||||||
async fn require_admin(app: &RelayApp, caller: &Caller) -> Result<(), Response> {
|
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(())
|
Ok(())
|
||||||
} else {
|
} else {
|
||||||
let msg = app.i18n().for_user(&caller.user_id, KEY_ADMIN_ONLY, &[]).await;
|
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> {
|
async fn admin_app(cell: &StateCell, caller: &Caller) -> Result<Arc<RelayApp>, Response> {
|
||||||
let app = app_or_503(cell).await?;
|
let app = app_or_503(cell).await?;
|
||||||
require_admin(&app, caller).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 ────────────────────────────────────────────────────────
|
// ── POST/DELETE /pairing ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
@@ -113,14 +136,15 @@ struct StartPairingBody {
|
|||||||
ttl: Option<u32>,
|
ttl: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open a pairing window and return the QR URL. The caller (an admin) becomes
|
/// Open a pairing window and return the QR URL. Self-service: the caller
|
||||||
/// the pending owner, so a device that pairs in this window auto-binds to them.
|
/// becomes the pending owner, so a device that pairs in this window
|
||||||
|
/// auto-binds to them.
|
||||||
async fn start_pairing(
|
async fn start_pairing(
|
||||||
State(cell): State<StateCell>,
|
State(cell): State<StateCell>,
|
||||||
Extension(caller): Extension<Caller>,
|
Extension(caller): Extension<Caller>,
|
||||||
Json(body): Json<StartPairingBody>,
|
Json(body): Json<StartPairingBody>,
|
||||||
) -> Response {
|
) -> 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
|
// 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
|
// send `pairing_start` on ("WS outbound channel closed"). Fail with an
|
||||||
// actionable message instead of the transport-level one.
|
// 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(
|
async fn stop_pairing(
|
||||||
State(cell): State<StateCell>,
|
State(cell): State<StateCell>,
|
||||||
Extension(caller): Extension<Caller>,
|
Extension(caller): Extension<Caller>,
|
||||||
) -> Response {
|
) -> 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;
|
app.set_pending_owner(None).await;
|
||||||
match app.client().stop_pairing().await {
|
match app.client().stop_pairing().await {
|
||||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
@@ -159,32 +191,37 @@ async fn stop_pairing(
|
|||||||
|
|
||||||
// ── GET /devices ────────────────────────────────────────────────────────────────
|
// ── 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(
|
async fn list_devices(
|
||||||
State(cell): State<StateCell>,
|
State(cell): State<StateCell>,
|
||||||
Extension(caller): Extension<Caller>,
|
Extension(caller): Extension<Caller>,
|
||||||
) -> Response {
|
) -> 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 rows = app.client().list_clients().await;
|
||||||
let bindings = app.bindings.read().await;
|
let bindings = app.bindings.read().await;
|
||||||
let devices: Vec<Value> = rows
|
let devices: Vec<Value> = rows
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| {
|
.filter_map(|r| {
|
||||||
let pk_hex = hex::encode(r.ed25519_pub);
|
let pk_hex = hex::encode(r.ed25519_pub);
|
||||||
let bound_user = bindings.user_for_pubkey(&pk_hex);
|
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> =
|
let device_info: Option<Value> =
|
||||||
r.device_info.as_deref().and_then(|s| serde_json::from_str(s).ok());
|
r.device_info.as_deref().and_then(|s| serde_json::from_str(s).ok());
|
||||||
json!({
|
Some(json!({
|
||||||
"pubkey": pk_hex,
|
"pubkey": pk_hex,
|
||||||
"state": if r.state == ClientState::Authorized { "authorized" } else { "pending" },
|
"state": if r.state == ClientState::Authorized { "authorized" } else { "pending" },
|
||||||
"bound_user": bound_user,
|
"bound_user": bound_user,
|
||||||
"platform": r.platform,
|
"platform": r.platform,
|
||||||
"device_info": device_info,
|
"device_info": device_info,
|
||||||
"last_seen": r.last_seen,
|
"last_seen": r.last_seen,
|
||||||
})
|
}))
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
Json(json!({ "devices": devices })).into_response()
|
Json(json!({ "devices": devices, "is_admin": is_admin })).into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── POST /devices/bind + /devices/revoke ────────────────────────────────────────
|
// ── POST /devices/bind + /devices/revoke ────────────────────────────────────────
|
||||||
@@ -197,7 +234,8 @@ struct BindBody {
|
|||||||
display: Option<String>,
|
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(
|
async fn bind_device(
|
||||||
State(cell): State<StateCell>,
|
State(cell): State<StateCell>,
|
||||||
Extension(caller): Extension<Caller>,
|
Extension(caller): Extension<Caller>,
|
||||||
@@ -219,14 +257,22 @@ struct RevokeBody {
|
|||||||
pubkey: String,
|
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(
|
async fn revoke_device(
|
||||||
State(cell): State<StateCell>,
|
State(cell): State<StateCell>,
|
||||||
Extension(caller): Extension<Caller>,
|
Extension(caller): Extension<Caller>,
|
||||||
Json(body): Json<RevokeBody>,
|
Json(body): Json<RevokeBody>,
|
||||||
) -> Response {
|
) -> 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 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 {
|
match app.revoke_device(pk).await {
|
||||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
|||||||
@@ -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>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
// Served at `/api/plugin/mobile-connector/web/common.js` and imported by the
|
||||||
// two page fragments via a relative `./common.js` specifier. Everything the
|
// page fragment via a relative `./common.js` specifier. Everything the
|
||||||
// fragments need is self-contained here — the host injects no APIs (see
|
// fragment needs is self-contained here — the host injects no APIs (see
|
||||||
// `Plugin::web_pages` contract): they talk only to `/api/plugin/<id>/…` and,
|
// `Plugin::web_pages` contract): it talks only to `/api/plugin/<id>/…` and,
|
||||||
// for the user directory used by the reassign dropdown, the host `/api/users`
|
// for the user directory used by the admin reassign dropdown plus the caller's
|
||||||
// (the fragment runs with the logged-in admin's full session privileges).
|
// 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
|
// 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
|
// 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>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
// Served at `/api/plugin/mobile-connector/web/i18n.js` and imported by
|
||||||
// `common.js`, which registers it into the host's shared dictionaries via
|
// `common.js`, which registers it into the host's shared dictionaries via
|
||||||
@@ -10,30 +10,60 @@ const P = 'plugin.mobile-connector';
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
en: {
|
en: {
|
||||||
[`${P}.pairing.title`]: 'Pair a device',
|
[`${P}.app.title`]: 'Mobile App',
|
||||||
[`${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}.app.pair_new`]: 'Pair new device',
|
||||||
[`${P}.pairing.open`]: 'Open pairing window',
|
|
||||||
[`${P}.pairing.opening`]: 'Opening…',
|
[`${P}.status.loading`]: 'Checking…',
|
||||||
[`${P}.pairing.qr_alt`]: 'Pairing QR',
|
[`${P}.status.connected`]: 'Connected',
|
||||||
[`${P}.pairing.expired`]: 'Window expired',
|
[`${P}.status.connecting`]: 'Connecting…',
|
||||||
[`${P}.pairing.scan_within`]: 'Scan within {n}s',
|
[`${P}.status.off`]: 'Not running',
|
||||||
[`${P}.pairing.new_code`]: 'New code',
|
[`${P}.status.connecting_hint`]: 'The connector is not reachable at the moment — reconnecting automatically. Pairing is unavailable until the connection is back.',
|
||||||
[`${P}.pairing.close`]: 'Close',
|
[`${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.loading`]: 'Loading…',
|
||||||
[`${P}.devices.empty`]: 'No paired devices yet.',
|
[`${P}.devices.empty`]: 'No paired devices yet.',
|
||||||
[`${P}.devices.empty_hint`]: 'Use the Pair a device page to add one.',
|
[`${P}.devices.empty_hint`]: 'Use "Pair new device" above to add one.',
|
||||||
[`${P}.devices.col_device`]: 'Device',
|
|
||||||
[`${P}.devices.col_state`]: 'State',
|
|
||||||
[`${P}.devices.col_bound`]: 'Bound to',
|
|
||||||
[`${P}.devices.col_last_seen`]: 'Last seen',
|
[`${P}.devices.col_last_seen`]: 'Last seen',
|
||||||
[`${P}.devices.col_actions`]: 'Actions',
|
|
||||||
[`${P}.devices.state_authorized`]: 'authorized',
|
[`${P}.devices.state_authorized`]: 'authorized',
|
||||||
[`${P}.devices.state_pending`]: 'pending',
|
[`${P}.devices.state_pending`]: 'pending',
|
||||||
[`${P}.devices.assign_to`]: 'Assign to…',
|
[`${P}.devices.assign_to`]: 'Assign to…',
|
||||||
[`${P}.devices.bind`]: 'Bind',
|
[`${P}.devices.bind`]: 'Bind',
|
||||||
|
[`${P}.devices.revoke`]: 'Revoke',
|
||||||
[`${P}.devices.revoke_confirm`]: 'Revoke this device? It loses access immediately.',
|
[`${P}.devices.revoke_confirm`]: 'Revoke this device? It loses access immediately.',
|
||||||
[`${P}.devices.unknown`]: 'Unknown device',
|
[`${P}.devices.unknown`]: 'Unknown device',
|
||||||
|
|
||||||
@@ -45,30 +75,60 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
it: {
|
it: {
|
||||||
[`${P}.pairing.title`]: 'Associa un dispositivo',
|
[`${P}.app.title`]: 'Mobile App',
|
||||||
[`${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}.app.pair_new`]: 'Associa nuovo dispositivo',
|
||||||
[`${P}.pairing.open`]: 'Apri finestra di associazione',
|
|
||||||
[`${P}.pairing.opening`]: 'Apertura…',
|
[`${P}.status.loading`]: 'Verifica…',
|
||||||
[`${P}.pairing.qr_alt`]: 'QR di associazione',
|
[`${P}.status.connected`]: 'Connesso',
|
||||||
[`${P}.pairing.expired`]: 'Finestra scaduta',
|
[`${P}.status.connecting`]: 'Connessione…',
|
||||||
[`${P}.pairing.scan_within`]: 'Scansiona entro {n}s',
|
[`${P}.status.off`]: 'Non attivo',
|
||||||
[`${P}.pairing.new_code`]: 'Nuovo codice',
|
[`${P}.status.connecting_hint`]: 'Il connettore non è raggiungibile al momento — riconnessione automatica in corso. L’associazione non è disponibile finché la connessione non torna.',
|
||||||
[`${P}.pairing.close`]: 'Chiudi',
|
[`${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.loading`]: 'Caricamento…',
|
||||||
[`${P}.devices.empty`]: 'Nessun dispositivo associato.',
|
[`${P}.devices.empty`]: 'Nessun dispositivo associato.',
|
||||||
[`${P}.devices.empty_hint`]: 'Usa la pagina Associa un dispositivo per aggiungerne uno.',
|
[`${P}.devices.empty_hint`]: 'Usa "Associa nuovo dispositivo" qui sopra per aggiungerne uno.',
|
||||||
[`${P}.devices.col_device`]: 'Dispositivo',
|
|
||||||
[`${P}.devices.col_state`]: 'Stato',
|
|
||||||
[`${P}.devices.col_bound`]: 'Assegnato a',
|
|
||||||
[`${P}.devices.col_last_seen`]: 'Ultimo accesso',
|
[`${P}.devices.col_last_seen`]: 'Ultimo accesso',
|
||||||
[`${P}.devices.col_actions`]: 'Azioni',
|
|
||||||
[`${P}.devices.state_authorized`]: 'autorizzato',
|
[`${P}.devices.state_authorized`]: 'autorizzato',
|
||||||
[`${P}.devices.state_pending`]: 'in attesa',
|
[`${P}.devices.state_pending`]: 'in attesa',
|
||||||
[`${P}.devices.assign_to`]: 'Assegna a…',
|
[`${P}.devices.assign_to`]: 'Assegna a…',
|
||||||
[`${P}.devices.bind`]: 'Associa',
|
[`${P}.devices.bind`]: 'Associa',
|
||||||
|
[`${P}.devices.revoke`]: 'Revoca',
|
||||||
[`${P}.devices.revoke_confirm`]: 'Revocare questo dispositivo? Perderà l’accesso immediatamente.',
|
[`${P}.devices.revoke_confirm`]: 'Revocare questo dispositivo? Perderà l’accesso immediatamente.',
|
||||||
[`${P}.devices.unknown`]: 'Dispositivo sconosciuto',
|
[`${P}.devices.unknown`]: 'Dispositivo sconosciuto',
|
||||||
|
|
||||||
@@ -80,30 +140,60 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
fr: {
|
fr: {
|
||||||
[`${P}.pairing.title`]: 'Associer un appareil',
|
[`${P}.app.title`]: 'Mobile App',
|
||||||
[`${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}.app.pair_new`]: 'Associer un appareil',
|
||||||
[`${P}.pairing.open`]: 'Ouvrir la fenêtre d’association',
|
|
||||||
[`${P}.pairing.opening`]: 'Ouverture…',
|
[`${P}.status.loading`]: 'Vérification…',
|
||||||
[`${P}.pairing.qr_alt`]: 'QR d’association',
|
[`${P}.status.connected`]: 'Connecté',
|
||||||
[`${P}.pairing.expired`]: 'Fenêtre expirée',
|
[`${P}.status.connecting`]: 'Connexion…',
|
||||||
[`${P}.pairing.scan_within`]: 'Scannez sous {n}s',
|
[`${P}.status.off`]: 'Inactif',
|
||||||
[`${P}.pairing.new_code`]: 'Nouveau code',
|
[`${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}.pairing.close`]: 'Fermer',
|
[`${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.loading`]: 'Chargement…',
|
||||||
[`${P}.devices.empty`]: 'Aucun appareil associé.',
|
[`${P}.devices.empty`]: 'Aucun appareil associé.',
|
||||||
[`${P}.devices.empty_hint`]: 'Utilisez la page Associer un appareil pour en ajouter un.',
|
[`${P}.devices.empty_hint`]: 'Utilisez « Associer un appareil » ci-dessus pour en ajouter un.',
|
||||||
[`${P}.devices.col_device`]: 'Appareil',
|
|
||||||
[`${P}.devices.col_state`]: 'État',
|
|
||||||
[`${P}.devices.col_bound`]: 'Assigné à',
|
|
||||||
[`${P}.devices.col_last_seen`]: 'Vu la dernière fois',
|
[`${P}.devices.col_last_seen`]: 'Vu la dernière fois',
|
||||||
[`${P}.devices.col_actions`]: 'Actions',
|
|
||||||
[`${P}.devices.state_authorized`]: 'autorisé',
|
[`${P}.devices.state_authorized`]: 'autorisé',
|
||||||
[`${P}.devices.state_pending`]: 'en attente',
|
[`${P}.devices.state_pending`]: 'en attente',
|
||||||
[`${P}.devices.assign_to`]: 'Assigner à…',
|
[`${P}.devices.assign_to`]: 'Assigner à…',
|
||||||
[`${P}.devices.bind`]: 'Associer',
|
[`${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.revoke_confirm`]: 'Révoquer cet appareil ? Il perd l’accès immédiatement.',
|
||||||
[`${P}.devices.unknown`]: 'Appareil inconnu',
|
[`${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>`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -42,6 +42,9 @@ pub struct PluginInfo {
|
|||||||
/// Whether the plugin gates access through its own binding lifecycle — the
|
/// Whether the plugin gates access through its own binding lifecycle — the
|
||||||
/// admin UI hides the "User access" checklist when true (see the trait).
|
/// admin UI hides the "User access" checklist when true (see the trait).
|
||||||
pub manages_own_access: bool,
|
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>,
|
pub runtime_status: Option<Value>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -404,6 +407,7 @@ impl PluginManager {
|
|||||||
user_config_schema: plugin.user_config_schema(),
|
user_config_schema: plugin.user_config_schema(),
|
||||||
has_router: plugin.http_router().is_some(),
|
has_router: plugin.http_router().is_some(),
|
||||||
manages_own_access: plugin.manages_own_access(),
|
manages_own_access: plugin.manages_own_access(),
|
||||||
|
config_in_detail_page: plugin.config_in_detail_page(),
|
||||||
runtime_status: plugin.runtime_status(),
|
runtime_status: plugin.runtime_status(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -463,8 +467,9 @@ impl PluginManager {
|
|||||||
/// entry of every **enabled** plugin, filtered by audience — `admin_only`
|
/// entry of every **enabled** plugin, filtered by audience — `admin_only`
|
||||||
/// pages go to the admin role only; the others require the `plugin_access`
|
/// pages go to the admin role only; the others require the `plugin_access`
|
||||||
/// grant (admins see all). Binding-managed plugins (`manages_own_access`)
|
/// grant (admins see all). Binding-managed plugins (`manages_own_access`)
|
||||||
/// keep their pages admin-only unless the page says otherwise, mirroring
|
/// own their access model (e.g. the device↔user binding), so their
|
||||||
/// `list_accessible`.
|
/// 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>> {
|
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 {
|
let granted: std::collections::HashSet<String> = if is_admin {
|
||||||
std::collections::HashSet::new()
|
std::collections::HashSet::new()
|
||||||
@@ -484,8 +489,10 @@ impl PluginManager {
|
|||||||
for page in pages {
|
for page in pages {
|
||||||
let visible = if is_admin {
|
let visible = if is_admin {
|
||||||
true
|
true
|
||||||
} else if page.admin_only || owns_access {
|
} else if page.admin_only {
|
||||||
false
|
false
|
||||||
|
} else if owns_access {
|
||||||
|
true
|
||||||
} else {
|
} else {
|
||||||
granted.contains(plugin.id())
|
granted.contains(plugin.id())
|
||||||
};
|
};
|
||||||
@@ -640,15 +647,18 @@ mod tests {
|
|||||||
assert_eq!(admin[0].api_version, 1);
|
assert_eq!(admin[0].api_version, 1);
|
||||||
|
|
||||||
// Non-admin: only the non-admin_only page of a granted, enabled,
|
// Non-admin: only the non-admin_only page of a granted, enabled,
|
||||||
// non-binding-managed plugin — beta is disabled, gamma manages its own
|
// non-binding-managed plugin — beta is disabled, alpha's admin console
|
||||||
// access, alpha's admin console is admin_only.
|
// 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 user = mgr.web_pages_for("u1", false).await.unwrap();
|
||||||
let got: Vec<(&str, &str)> = user.iter()
|
let got: Vec<(&str, &str)> = user.iter()
|
||||||
.map(|p| (p.plugin_id.as_str(), p.page_id.as_str())).collect();
|
.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();
|
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")]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,4 +235,15 @@ impl RelayClient {
|
|||||||
pub fn is_connected(&self) -> bool {
|
pub fn is_connected(&self) -> bool {
|
||||||
self.state.is_connected()
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,6 +58,9 @@ pub(crate) struct RelayState {
|
|||||||
/// Derived from the seed + the client's x25519 pubkey; never persisted.
|
/// Derived from the seed + the client's x25519 pubkey; never persisted.
|
||||||
aes_cache: Mutex<HashMap<[u8; 32], [u8; 32]>>,
|
aes_cache: Mutex<HashMap<[u8; 32], [u8; 32]>>,
|
||||||
connected: AtomicBool,
|
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.
|
/// Broadcast sink for [`RelayEvent`]s consumed by the application layer.
|
||||||
events_tx: broadcast::Sender<RelayEvent>,
|
events_tx: broadcast::Sender<RelayEvent>,
|
||||||
/// Pending `open_pipe` waiters: connection_id → accept/reject delivery
|
/// Pending `open_pipe` waiters: connection_id → accept/reject delivery
|
||||||
@@ -84,6 +87,7 @@ impl RelayState {
|
|||||||
outbound: Mutex::new(None),
|
outbound: Mutex::new(None),
|
||||||
aes_cache: Mutex::new(HashMap::new()),
|
aes_cache: Mutex::new(HashMap::new()),
|
||||||
connected: AtomicBool::new(false),
|
connected: AtomicBool::new(false),
|
||||||
|
last_error: Mutex::new(None),
|
||||||
events_tx,
|
events_tx,
|
||||||
pipe_waiters: Mutex::new(HashMap::new()),
|
pipe_waiters: Mutex::new(HashMap::new()),
|
||||||
incoming_pipes_tx,
|
incoming_pipes_tx,
|
||||||
@@ -115,6 +119,10 @@ impl RelayState {
|
|||||||
|
|
||||||
pub(crate) fn set_connected(&self, v: bool) {
|
pub(crate) fn set_connected(&self, v: bool) {
|
||||||
let was = self.connected.swap(v, Ordering::Relaxed);
|
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 {
|
if was != v {
|
||||||
self.emit(if v { RelayEvent::Connected } else { RelayEvent::Disconnected });
|
self.emit(if v { RelayEvent::Connected } else { RelayEvent::Disconnected });
|
||||||
}
|
}
|
||||||
@@ -124,6 +132,16 @@ impl RelayState {
|
|||||||
self.connected.load(Ordering::Relaxed)
|
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>>) {
|
pub(crate) fn set_outbound(&self, tx: mpsc::UnboundedSender<Vec<u8>>) {
|
||||||
*self.outbound.lock().unwrap() = Some(tx);
|
*self.outbound.lock().unwrap() = Some(tx);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ pub(crate) async fn run_loop(
|
|||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
warn!(crate_name = "skald-relay-client", error = %e, "relay connection ended");
|
warn!(crate_name = "skald-relay-client", error = %e, "relay connection ended");
|
||||||
|
state.set_last_error(e.to_string());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
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.
|
Everything lives in one sidebar page — **Mobile App** (`#plugin/mobile-connector/app`), visible to every logged-in user:
|
||||||
- The companion mobile app installed on the user's phone.
|
|
||||||
|
|
||||||
## 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**.
|
## Configuring (admin)
|
||||||
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.)
|
|
||||||
|
|
||||||
## 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.
|
## Pairing a device (self-service)
|
||||||
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`).
|
1. Open the **Mobile App** page and click **Pair new device** — a dialog shows a QR code.
|
||||||
4. The admin picks which user account to bind it to and confirms — only then can that device see that user's Inbox.
|
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
|
## Notes
|
||||||
|
|
||||||
- A device stays bound until an admin revokes it from the Mobile devices page.
|
- 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 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.
|
- 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.
|
||||||
|
|||||||
@@ -97,11 +97,14 @@ export class PluginDetailPage extends LightElement {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this._plugin = p;
|
this._plugin = p;
|
||||||
// If the plugin ships its own admin page (an `admin_only` web-page), the
|
// If the plugin ships its own page(s), the generic config form may defer
|
||||||
// generic config form defers to it — see `_renderConfig`.
|
// 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 {
|
try {
|
||||||
const pages = await jf('/api/plugins/pages');
|
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; }
|
} catch { this._customPage = null; }
|
||||||
// Keep whatever the admin has already typed across a reload triggered by a save.
|
// Keep whatever the admin has already typed across a reload triggered by a save.
|
||||||
this._draft = { ...(p.config || {}), ...(this._draft || {}) };
|
this._draft = { ...(p.config || {}), ...(this._draft || {}) };
|
||||||
@@ -276,11 +279,13 @@ export class PluginDetailPage extends LightElement {
|
|||||||
_renderConfig() {
|
_renderConfig() {
|
||||||
const p = this._plugin;
|
const p = this._plugin;
|
||||||
const fields = schemaFields(p.config_schema);
|
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
|
// 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
|
// instance-config to show.
|
||||||
// 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.
|
|
||||||
if (fields.length === 0 && this._customPage) return this._renderConfigLink();
|
if (fields.length === 0 && this._customPage) return this._renderConfigLink();
|
||||||
const draft = this._draft || {};
|
const draft = this._draft || {};
|
||||||
return html`
|
return html`
|
||||||
|
|||||||
Reference in New Issue
Block a user