@dotrino/vaultd 0.26.2 → 0.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +142 -25
- package/bin/dotrino-vaultd.js +4 -4
- package/lib/README.md +13 -2
- package/lib/src/admin.js +88 -3
- package/lib/src/config.js +1 -1
- package/lib/src/enroll.js +26 -22
- package/lib/src/env.js +37 -17
- package/lib/src/envtext.js +94 -0
- package/lib/src/index.js +4 -4
- package/lib/src/invite.js +8 -8
- package/lib/src/protocol.js +12 -0
- package/lib/src/service.js +218 -92
- package/package.json +9 -4
- package/src/ctl.js +375 -90
- package/src/daemon.js +167 -45
- package/src/manager.js +6 -6
- package/src/profiles.js +27 -4
- package/src/secretsStore.js +191 -25
- package/src/transport.js +2 -2
- package/src/tui/app.js +512 -126
- package/src/tui/i18n.js +118 -22
- package/src/vault.js +351 -44
- package/src/vaultControl.js +253 -64
package/src/tui/app.js
CHANGED
|
@@ -8,6 +8,13 @@
|
|
|
8
8
|
* · Dispositivos (pares): ver · emparejar · aprobar/rechazar · revocar
|
|
9
9
|
* · Scopes y variables (secretos): ver · agregar · quitar
|
|
10
10
|
*
|
|
11
|
+
* LAS VARIABLES DE ENTORNO SE PONEN EN DOS SITIOS, y cada uno está donde se elige lo
|
|
12
|
+
* que las distingue: las del SCOPE, que comparten todos los aparatos del perfil que
|
|
13
|
+
* sirven ese namespace, en su pestaña; las de UN APARATO, que solo lee él y pisan a
|
|
14
|
+
* las del scope, dentro de Dispositivos (tecla `e`), que es donde ya elegiste el
|
|
15
|
+
* aparato. No se repiten en las dos pantallas a propósito: la de scopes enlaza a la
|
|
16
|
+
* otra en vez de duplicarla.
|
|
17
|
+
*
|
|
11
18
|
* Cada "bóveda" es un PERFIL (maestra propia, dir propio, dispositivos y secretos
|
|
12
19
|
* propios). Las acciones operan sobre la bóveda ACTIVA; para operar otra, cámbiala
|
|
13
20
|
* en la pantalla de bóvedas.
|
|
@@ -22,7 +29,9 @@
|
|
|
22
29
|
* en todas las pantallas: `p` es SIEMPRE emparejar (también en Bóvedas, sin tener
|
|
23
30
|
* que entrar antes), el candado es `k` (la `l` es el idioma) y la contraseña `c`.
|
|
24
31
|
*/
|
|
32
|
+
import fs from 'node:fs'
|
|
25
33
|
import { execFile } from 'node:child_process'
|
|
34
|
+
import { parseEnvInput } from '../../lib/src/envtext.js'
|
|
26
35
|
import { createTerm, widthOf } from './term.js'
|
|
27
36
|
import { qrToString } from '../qr.js'
|
|
28
37
|
import { dict, otherLang, loadLang, saveLang } from './i18n.js'
|
|
@@ -46,13 +55,19 @@ const L = (st) => dict(st?.lang)
|
|
|
46
55
|
*/
|
|
47
56
|
function humanErr (e, st) {
|
|
48
57
|
const t = L(st)
|
|
58
|
+
// Con dato: «contraseña incorrecta (van 9 intentos)» y «espera 32 s» son lo que hace
|
|
59
|
+
// falta para saber qué está pasando; «error» a secas parece que la pantalla se colgó.
|
|
60
|
+
if (e?.code === 'WRONG_PASSWORD') return t.errWrongPassword(e.tries)
|
|
61
|
+
if (e?.code === 'TOO_MANY_TRIES') return t.errTooManyTries(e.waitSec)
|
|
49
62
|
const byCode = {
|
|
50
63
|
DAEMON_DOWN: t.errDaemonDown,
|
|
51
64
|
NO_REPLY: t.errNoReply,
|
|
52
65
|
NOT_APPLIED: t.errNotApplied,
|
|
53
66
|
NOT_DELETED: t.errNotDeleted,
|
|
54
67
|
PAIR_FAILED: t.errPairFailed,
|
|
55
|
-
|
|
68
|
+
APPROVE_FAILED: t.errWrongCode,
|
|
69
|
+
MASTER_WITH_MEMBERS: t.errMasterWithMembers,
|
|
70
|
+
PROFILE_LOCKED: t.errProfileLocked
|
|
56
71
|
}
|
|
57
72
|
return byCode[e?.code] || e?.message || String(e)
|
|
58
73
|
}
|
|
@@ -235,15 +250,17 @@ function deviceRows (st, t) {
|
|
|
235
250
|
}
|
|
236
251
|
for (const d of devices) {
|
|
237
252
|
const label = d.label || t.muted(i.noLabel)
|
|
238
|
-
const
|
|
253
|
+
const vars = devVarsOf(st, d.sub).length
|
|
254
|
+
const extra = (d.certCount > 1 ? t.muted(` certs:${d.certCount}`) : '') +
|
|
255
|
+
(vars ? t.muted(` vars:${vars}`) : '')
|
|
239
256
|
// SIN ACCESO: está en el acta y no puede entrar. Es un aviso, no un adorno, así que va
|
|
240
257
|
// en el color de aviso y en el sitio donde estaría su vencimiento.
|
|
241
|
-
const
|
|
258
|
+
const status = d.noAccess
|
|
242
259
|
? t.warn(i.deviceNoAccess)
|
|
243
260
|
: d.isMaster
|
|
244
261
|
? t.muted(i.thisVault)
|
|
245
262
|
: t.muted('scope:' + shortScope(d.scope)) + ' ' + t.muted('exp:' + fmtExp(d.exp))
|
|
246
|
-
rows.push({ text: ` ${t.bold(d.deviceId)} ${label} ${
|
|
263
|
+
rows.push({ text: ` ${t.bold(d.deviceId)} ${label} ${status}${extra}`, sel: true, meta: d })
|
|
247
264
|
}
|
|
248
265
|
const revoked = st.devices?.revoked || []
|
|
249
266
|
if (revoked.length) {
|
|
@@ -256,11 +273,15 @@ function deviceRows (st, t) {
|
|
|
256
273
|
/**
|
|
257
274
|
* LA PREGUNTA DEL EMPAREJAMIENTO. La decisión es del vault (es quien lo inicia) y
|
|
258
275
|
* este daemon puede tener varias cuentas: antes de mostrar el QR hay que decir a
|
|
259
|
-
* cuál entra el dispositivo.
|
|
260
|
-
* cuenta que ya vive aquí,
|
|
261
|
-
* («adoptar la que trae el aparato») necesita el
|
|
262
|
-
* muestra desactivada para no prometer lo que todavía
|
|
263
|
-
* (docs/vinculacion-de-cuentas.md §5).
|
|
276
|
+
* cuál entra el dispositivo. Se responde con las tres formas que existen —una
|
|
277
|
+
* cuenta que ya vive aquí, una nueva que se estrena para él, o un SERVICIO de la
|
|
278
|
+
* cuenta activa—; la cuarta («adoptar la que trae el aparato») necesita el
|
|
279
|
+
* protocolo de adopción y se muestra desactivada para no prometer lo que todavía
|
|
280
|
+
* no hace (docs/vinculacion-de-cuentas.md §5).
|
|
281
|
+
*
|
|
282
|
+
* Lo del servicio estaba SOLO en la línea de comandos (`pair --service <ns>`), y una
|
|
283
|
+
* máquina que sirve el proxy no se empareja de otra manera: sin esta opción, la TUI
|
|
284
|
+
* te dejaba a medio camino y había que salirse a la terminal a terminar el trabajo.
|
|
264
285
|
*/
|
|
265
286
|
function pairModeRows (st, t) {
|
|
266
287
|
const i = L(st)
|
|
@@ -272,6 +293,9 @@ function pairModeRows (st, t) {
|
|
|
272
293
|
rows.push({ text: ` ${t.bold(i.pairModeNew)}`, sel: true, meta: { mode: 'new' } })
|
|
273
294
|
rows.push({ text: t.muted(' ' + i.pairModeNewHint), sel: false })
|
|
274
295
|
rows.push({ text: '', sel: false })
|
|
296
|
+
rows.push({ text: ` ${t.bold(i.pairModeService)}`, sel: true, meta: { mode: 'service' } })
|
|
297
|
+
rows.push({ text: t.muted(' ' + i.pairModeServiceHint), sel: false })
|
|
298
|
+
rows.push({ text: '', sel: false })
|
|
275
299
|
rows.push({ text: ' ' + t.muted(i.pairModeAdopt), sel: false })
|
|
276
300
|
rows.push({ text: t.muted(' (' + i.pairModeAdoptSoon + ')'), sel: false })
|
|
277
301
|
return rows
|
|
@@ -282,25 +306,25 @@ function pairModeRows (st, t) {
|
|
|
282
306
|
* una marca de si los tiene. El de administrar va aparte y avisado: es el único que deja
|
|
283
307
|
* a ese aparato meter y sacar dispositivos sin venir aquí.
|
|
284
308
|
*/
|
|
285
|
-
const
|
|
309
|
+
const CAPS_ORDER = ['sign', 'store', 'read', 'admin']
|
|
286
310
|
|
|
287
311
|
function capsRows (st, t) {
|
|
288
312
|
const i = L(st)
|
|
289
|
-
const
|
|
290
|
-
if (!
|
|
291
|
-
const
|
|
292
|
-
if (!
|
|
313
|
+
const target = st.capsFor
|
|
314
|
+
if (!target) return [{ text: t.muted(i.loading), sel: false }]
|
|
315
|
+
const member = (st.members || []).find((m) => m.pub === target.pub)
|
|
316
|
+
if (!member) return [{ text: t.muted(i.capsNoMember), sel: false }]
|
|
293
317
|
|
|
294
|
-
const
|
|
318
|
+
const has = new Set(member.caps || [])
|
|
295
319
|
const rows = [
|
|
296
|
-
{ text: ' ' + t.bold(i.capsFor(
|
|
320
|
+
{ text: ' ' + t.bold(i.capsFor(target.deviceId, member.label || '')), sel: false },
|
|
297
321
|
{ text: '', sel: false }
|
|
298
322
|
]
|
|
299
|
-
for (const cap of
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
const
|
|
303
|
-
rows.push({ text:
|
|
323
|
+
for (const cap of CAPS_ORDER) {
|
|
324
|
+
const mark = has.has(cap) ? '[x]' : '[ ]'
|
|
325
|
+
const name = i.capName[cap]
|
|
326
|
+
const line = ` ${mark} ${cap === 'admin' ? t.bold(name) : name}`
|
|
327
|
+
rows.push({ text: line, sel: true, meta: { cap } })
|
|
304
328
|
rows.push({ text: t.muted(' ' + i.capHint[cap]), sel: false })
|
|
305
329
|
}
|
|
306
330
|
rows.push({ text: '', sel: false })
|
|
@@ -308,21 +332,63 @@ function capsRows (st, t) {
|
|
|
308
332
|
return rows
|
|
309
333
|
}
|
|
310
334
|
|
|
335
|
+
/** Las variables por SCOPE: las que comparten todos los aparatos que sirven ese ns. */
|
|
311
336
|
function secretRows (st, t) {
|
|
312
337
|
const i = L(st)
|
|
313
|
-
const ns = st.secrets || {}
|
|
338
|
+
const ns = st.secrets?.ns || {}
|
|
314
339
|
const names = Object.keys(ns).sort()
|
|
315
340
|
const rows = []
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
}
|
|
341
|
+
// El puntero a la otra pantalla va SIEMPRE, con scopes y sin ellos: es la mitad de la
|
|
342
|
+
// función y quien la busca no tiene por qué adivinar que vive en Dispositivos.
|
|
343
|
+
const footer = [{ text: '', sel: false }, { text: t.muted(' ' + i.devVarsElsewhere), sel: false }]
|
|
344
|
+
if (!names.length) return [{ text: t.muted(i.noScopes), sel: false }, ...footer]
|
|
320
345
|
for (const n of names) {
|
|
321
346
|
rows.push({ text: t.accent(` ▸ ${n}`) + t.muted(i.scopeOf(n)), sel: true, meta: { ns: n, key: null } })
|
|
322
|
-
for (const k of ns[n]
|
|
323
|
-
rows.push({ text:
|
|
347
|
+
for (const k of sortByKey(ns[n])) {
|
|
348
|
+
rows.push({ text: varLine(k, t, i), sel: true, meta: { ns: n, key: k.key, public: k.public } })
|
|
324
349
|
}
|
|
325
350
|
}
|
|
351
|
+
return [...rows, ...footer]
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/** Las claves guardadas para UN aparato (`pub`), o `[]`. Cada una es `{key, public}`. */
|
|
355
|
+
const devVarsOf = (st, pub) => (st.secrets?.dev || []).find((x) => x.pub === pub)?.keys || []
|
|
356
|
+
|
|
357
|
+
const sortByKey = (list) => (list || []).slice().sort((a, b) => a.key.localeCompare(b.key))
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Una variable: su nombre y su valor. La PÚBLICA enseña el suyo —pública significa que ese
|
|
361
|
+
* valor puede salir de esta máquina, así que taparlo delante de su dueño, en la máquina
|
|
362
|
+
* donde vive, era lo único que la marca no quería decir— con el aviso de que viaja cuando
|
|
363
|
+
* la consola remota lo pide. La privada sigue tapada: no sale ni a esta pantalla.
|
|
364
|
+
*/
|
|
365
|
+
const varLine = (v, t, i) => ` ${v.key} ` +
|
|
366
|
+
(v.public ? `${short(v.value)} ${t.warn(i.varPublic)}` : t.muted('••••••'))
|
|
367
|
+
|
|
368
|
+
/** Un valor largo no puede empujar la marca «pública» fuera de la pantalla. */
|
|
369
|
+
const short = (s) => {
|
|
370
|
+
const v = String(s ?? '')
|
|
371
|
+
return v.length > 40 ? v.slice(0, 39) + '…' : v
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/**
|
|
375
|
+
* Las variables de UN aparato. Se entra desde Dispositivos con `e`, ya con el aparato
|
|
376
|
+
* elegido: por eso aquí no se vuelve a elegir, solo se agrega y se quita.
|
|
377
|
+
*/
|
|
378
|
+
function devVarRows (st, t) {
|
|
379
|
+
const i = L(st)
|
|
380
|
+
const target = st.varsFor
|
|
381
|
+
if (!target) return [{ text: t.muted(i.loading), sel: false }]
|
|
382
|
+
const keys = sortByKey(devVarsOf(st, target.pub))
|
|
383
|
+
const rows = [
|
|
384
|
+
{ text: ' ' + t.bold(i.devVarsFor(target.deviceId, target.label || '')), sel: false },
|
|
385
|
+
// Dato, no explicación: qué servicio es este aparato es lo que decide qué namespace
|
|
386
|
+
// lee, y por lo tanto a qué variables del scope le ganan estas.
|
|
387
|
+
{ text: t.muted(' ' + i.devVarsService(target.cn)), sel: false },
|
|
388
|
+
{ text: '', sel: false }
|
|
389
|
+
]
|
|
390
|
+
if (!keys.length) rows.push({ text: t.muted(' ' + i.noDevVars), sel: false })
|
|
391
|
+
for (const k of keys) rows.push({ text: varLine(k, t, i), sel: true, meta: { key: k.key, public: k.public } })
|
|
326
392
|
return rows
|
|
327
393
|
}
|
|
328
394
|
|
|
@@ -341,27 +407,27 @@ function meRows (st, t) {
|
|
|
341
407
|
if (!me) return [{ text: t.muted(i.noProfile), sel: false }, { text: '', sel: false }, { text: t.muted(i.noProfileHint), sel: false }]
|
|
342
408
|
|
|
343
409
|
const rows = []
|
|
344
|
-
const
|
|
345
|
-
text: ` ${t.muted(String(
|
|
410
|
+
const field = (label, value, hidden) => rows.push({
|
|
411
|
+
text: ` ${t.muted(String(label).padEnd(12))} ${value}${hidden ? t.muted(i.hidden) : ''}`, sel: false
|
|
346
412
|
})
|
|
347
413
|
rows.push({ text: t.muted(i.profileUpdated(me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—')), sel: false })
|
|
348
414
|
rows.push({ text: '', sel: false })
|
|
349
|
-
|
|
350
|
-
|
|
415
|
+
field(i.fieldName, me.nickname ? t.bold(me.nickname) : t.muted(i.noName))
|
|
416
|
+
field(i.fieldPhoto, me.avatar
|
|
351
417
|
? `${me.avatar.type || '?'} · ${(me.avatar.bytes / 1024).toFixed(1)} KB`
|
|
352
418
|
: t.muted(i.no))
|
|
353
419
|
|
|
354
420
|
const STD = [['nombres', i.fieldFirstName], ['apellidos', i.fieldLastName], ['email', i.fieldEmail],
|
|
355
421
|
['telefono', i.fieldPhone], ['direccion', i.fieldAddress]]
|
|
356
|
-
const
|
|
357
|
-
if (
|
|
358
|
-
for (const [k,
|
|
422
|
+
const filled = STD.filter(([k]) => me[k])
|
|
423
|
+
if (filled.length) rows.push({ text: '', sel: false })
|
|
424
|
+
for (const [k, label] of filled) field(label, me[k], me[k + 'Visible'] === false)
|
|
359
425
|
|
|
360
|
-
for (const [
|
|
361
|
-
if (!Array.isArray(
|
|
426
|
+
for (const [title, list] of [[i.links, me.links], [i.otherData, me.fields]]) {
|
|
427
|
+
if (!Array.isArray(list) || !list.length) continue
|
|
362
428
|
rows.push({ text: '', sel: false })
|
|
363
|
-
rows.push({ text: t.accent(' ▸ ' +
|
|
364
|
-
for (const x of
|
|
429
|
+
rows.push({ text: t.accent(' ▸ ' + title), sel: false })
|
|
430
|
+
for (const x of list) field(x.type || x.label || '', x.value, x.visible === false)
|
|
365
431
|
}
|
|
366
432
|
return rows
|
|
367
433
|
}
|
|
@@ -381,28 +447,60 @@ async function guard (term, st, msg, fn) {
|
|
|
381
447
|
try { const v = await fn(); st.busy = null; return { ok: true, v } } catch (e) { st.busy = null; flash(st, humanErr(e, st), 'danger'); return { ok: false, e } }
|
|
382
448
|
}
|
|
383
449
|
|
|
384
|
-
|
|
385
|
-
|
|
450
|
+
/**
|
|
451
|
+
* ¿La bóveda ACTIVA está cerrada? El candado es POR BÓVEDA, no del vault: que una esté
|
|
452
|
+
* cerrada no puede dejarte sin la lista ni sin poder entrar a otra.
|
|
453
|
+
*/
|
|
454
|
+
const activeLocked = (st) => { const p = activeProfile(st); return !!(p?.protected && p.locked) }
|
|
455
|
+
|
|
456
|
+
// `api` es `vaultControl` — se recibe para poder probar ESTA función (la que se rompió)
|
|
457
|
+
// sin un daemon detrás, que es donde vive la regla de qué se pide y en qué orden.
|
|
458
|
+
async function refreshAll (term, st, api = vc) {
|
|
459
|
+
// LA LISTA DE BÓVEDAS VA PRIMERO Y APARTE. Antes esto pedía el volcado de la bóveda
|
|
460
|
+
// activa y, si esa era la cerrada, se salía sin llegar a guardar la lista: la TUI abría
|
|
461
|
+
// en blanco, sin bóvedas y con un error rojo. O sea que la contraseña de UNA bóveda te
|
|
462
|
+
// dejaba fuera del vault entero, que es exactamente lo que un candado por perfil no debe
|
|
463
|
+
// hacer.
|
|
464
|
+
const p = await guard(term, st, L(st).loadingVaults, () => api.listProfiles())
|
|
465
|
+
if (p.ok) st.profiles = p.v
|
|
466
|
+
// Cerrada: no se pide su contenido, y tampoco se enseña un error por mirarla desde
|
|
467
|
+
// fuera. Lo que hubiera cargado se suelta, para no dejar en pantalla lo de antes.
|
|
468
|
+
if (activeLocked(st)) {
|
|
469
|
+
st.devices = null; st.secrets = null; st.members = []; st.me = undefined
|
|
470
|
+
return
|
|
471
|
+
}
|
|
472
|
+
const r = await guard(term, st, L(st).loading, () => api.snapshot(activeId(st)))
|
|
386
473
|
if (!r.ok) return
|
|
387
|
-
const { devices, secrets, profiles,
|
|
474
|
+
const { devices, secrets, profiles, record } = r.v
|
|
388
475
|
if (profiles) st.profiles = profiles
|
|
389
|
-
|
|
476
|
+
// Los DOS cajones de variables viajan juntos: `ns` (por scope) y `dev` (por aparato).
|
|
477
|
+
if (secrets) st.secrets = { ns: secrets.ns || {}, dev: Array.isArray(secrets.dev) ? secrets.dev : [] }
|
|
390
478
|
// El ACTA entra en el volcado normal: es de donde sale la lista de dispositivos (ver
|
|
391
479
|
// `mergeMembersAndCerts`). Antes solo se pedía al abrir la pantalla de permisos.
|
|
392
|
-
if (
|
|
480
|
+
if (record) st.members = record.members || []
|
|
393
481
|
if (devices) {
|
|
394
482
|
const issued = (devices.issued || devices.active || devices.delegations || [])
|
|
395
|
-
st.devices = { issued: await Promise.all(issued.map(async (d) => ({ ...d, deviceId: d.sub ? await
|
|
483
|
+
st.devices = { issued: await Promise.all(issued.map(async (d) => ({ ...d, deviceId: d.sub ? await api.deviceIdOf(d.sub) : '????-????' }))), revoked: devices.revoked || [] }
|
|
396
484
|
}
|
|
397
485
|
}
|
|
398
486
|
|
|
487
|
+
/**
|
|
488
|
+
* Guarda un volcado de dispositivos EN LOS DOS SITIOS.
|
|
489
|
+
*
|
|
490
|
+
* La lista se pinta desde el ACTA (`st.members`) con los certificados pegados
|
|
491
|
+
* (`st.devices`), así que quedarse solo con la mitad deja la pantalla mintiendo: aprobar
|
|
492
|
+
* un aparato no lo hacía aparecer y quitarlo no lo hacía desaparecer, hasta que algo
|
|
493
|
+
* volviera a pedir el acta. Peor todavía en una lista: el aparato recién entrado no salía,
|
|
494
|
+
* así que la fila «la última» era otra y quitarla se llevaba por delante a quien no era.
|
|
495
|
+
*/
|
|
496
|
+
function applyDump (st, v) {
|
|
497
|
+
st.devices = v
|
|
498
|
+
if (Array.isArray(v?.members)) st.members = v.members
|
|
499
|
+
}
|
|
500
|
+
|
|
399
501
|
async function refreshDevices (term, st) {
|
|
400
502
|
const r = await guard(term, st, L(st).loadingDevices, () => vc.listDevices(activeId(st)))
|
|
401
|
-
if (
|
|
402
|
-
st.devices = r.v
|
|
403
|
-
// La lista se pinta desde el acta; traerla aparte dejaba la pantalla con los miembros de
|
|
404
|
-
// hace dos operaciones (quitar uno no lo quitaba de la vista).
|
|
405
|
-
if (Array.isArray(r.v.members)) st.members = r.v.members
|
|
503
|
+
if (r.ok) applyDump(st, r.v)
|
|
406
504
|
}
|
|
407
505
|
async function refreshSecrets (term, st) {
|
|
408
506
|
const r = await guard(term, st, L(st).loadingSecrets, () => vc.listSecrets(activeId(st)))
|
|
@@ -416,24 +514,66 @@ async function refreshMe (term, st) {
|
|
|
416
514
|
const r = await guard(term, st, L(st).loadingProfile, () => vc.getMe(activeId(st)))
|
|
417
515
|
st.me = r.ok ? r.v : null
|
|
418
516
|
}
|
|
419
|
-
async function refreshProfiles (term, st) {
|
|
420
|
-
const r = await guard(term, st, L(st).loadingVaults, () =>
|
|
517
|
+
async function refreshProfiles (term, st, api = vc) {
|
|
518
|
+
const r = await guard(term, st, L(st).loadingVaults, () => api.listProfiles())
|
|
421
519
|
if (r.ok) st.profiles = r.v
|
|
422
520
|
}
|
|
423
521
|
|
|
424
|
-
/**
|
|
425
|
-
|
|
522
|
+
/**
|
|
523
|
+
* TECLEADA UNA VEZ, VALE PARA TODA LA SESIÓN de la TUI.
|
|
524
|
+
*
|
|
525
|
+
* El candado vive en la MEMORIA DEL DAEMON, así que cualquier cosa que se lleve ese
|
|
526
|
+
* estado —un `systemctl restart` al actualizar, un reinicio del servicio, una petición
|
|
527
|
+
* que se perdió— dejaba la bóveda cerrada otra vez EN MITAD de la sesión, y la TUI
|
|
528
|
+
* volvía a pedir la contraseña como si nunca se hubiera tecleado.
|
|
529
|
+
*
|
|
530
|
+
* Por eso la contraseña de lo que se abre aquí se guarda en `st.sessionPwd` (SOLO en
|
|
531
|
+
* memoria de este proceso) y se vuelve a usar en silencio para reabrir la misma bóveda.
|
|
532
|
+
* Se olvida con el candado (`k`), al quitar la contraseña y al salir de la TUI, que es
|
|
533
|
+
* exactamente donde el dueño dijo que tiene que volver a hacer falta.
|
|
534
|
+
*/
|
|
535
|
+
// `api` es `vaultControl` — se recibe para poder probar esto sin un daemon detrás.
|
|
536
|
+
async function reunlockSilently (term, st, p, api = vc) {
|
|
537
|
+
const pwd = st.sessionPwd?.get(p.id)
|
|
538
|
+
if (!pwd) return false
|
|
539
|
+
const r = await guard(term, st, L(st).unlocking, () => api.unlockProfile(p.id, pwd))
|
|
540
|
+
// Ya no vale (se la cambiaron desde otro sitio, o el freno está esperando): se olvida
|
|
541
|
+
// y se vuelve al camino normal, que es preguntar diciendo por qué.
|
|
542
|
+
if (!r.ok) { st.sessionPwd.delete(p.id); return false }
|
|
543
|
+
await refreshProfiles(term, st, api)
|
|
544
|
+
return true
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Pide la contraseña si hace falta y sigue. Lo que se abre aquí queda anotado en
|
|
549
|
+
* `st.unlockedHere` para volver a cerrarlo AL SALIR (ver `runTui`): la contraseña dura lo
|
|
550
|
+
* que dura la sesión, no hasta que alguien reinicie el servicio. Lo que ya estaba abierto
|
|
551
|
+
* antes de entrar no se toca — no lo abrió esta pantalla, no le toca cerrarlo.
|
|
552
|
+
*/
|
|
553
|
+
async function ensureUnlocked (term, st, p, thenFn, reason = null, api = vc) {
|
|
426
554
|
if (!p.protected || !p.locked) return thenFn()
|
|
555
|
+
// Cerrada, pero la contraseña ya se tecleó en esta sesión: se reabre sin molestar.
|
|
556
|
+
if (await reunlockSilently(term, st, p, api)) {
|
|
557
|
+
const fresh = (st.profiles?.profiles || []).find((x) => x.id === p.id) || p
|
|
558
|
+
return thenFn(fresh)
|
|
559
|
+
}
|
|
427
560
|
const i = L(st)
|
|
428
561
|
setInput(st, {
|
|
429
562
|
label: i.passwordOf(p.name || p.id),
|
|
430
563
|
mask: true,
|
|
431
|
-
|
|
564
|
+
// Si la anterior fue rechazada, el motivo se queda AQUÍ, pegado al campo, en vez de
|
|
565
|
+
// irse en un aviso de cuatro segundos que se lleva el siguiente redibujado. Eso era lo
|
|
566
|
+
// que hacía que un rechazo pareciera «me la vuelve a pedir porque sí».
|
|
567
|
+
hint: reason || i.passwordToEdit,
|
|
432
568
|
onSubmit: async (pwd) => {
|
|
433
569
|
st.input = null
|
|
434
|
-
const r = await guard(term, st, i.unlocking, () =>
|
|
435
|
-
|
|
436
|
-
|
|
570
|
+
const r = await guard(term, st, i.unlocking, () => api.unlockProfile(p.id, pwd))
|
|
571
|
+
// Rechazada: se vuelve a pedir en el acto, diciendo por qué. Cerrar el campo obligaba
|
|
572
|
+
// a adivinar qué había pasado y a empezar de nuevo.
|
|
573
|
+
if (!r.ok) return ensureUnlocked(term, st, p, thenFn, humanErr(r.e, st), api)
|
|
574
|
+
st.unlockedHere?.add(p.id)
|
|
575
|
+
st.sessionPwd?.set(p.id, pwd) // vale para toda la sesión (ver reunlockSilently)
|
|
576
|
+
await refreshProfiles(term, st, api)
|
|
437
577
|
const fresh = (st.profiles.profiles || []).find((x) => x.id === p.id) || p
|
|
438
578
|
await thenFn(fresh)
|
|
439
579
|
},
|
|
@@ -445,7 +585,7 @@ async function ensureUnlocked (term, st, p, thenFn) {
|
|
|
445
585
|
|
|
446
586
|
function moveSel (st, key, screen, count) {
|
|
447
587
|
if (count <= 0) { st.sel[screen] = 0; return }
|
|
448
|
-
// Clampa el índice guardado ANTES de
|
|
588
|
+
// Clampa el índice guardado ANTES de apply el delta: si la lista encogió, la
|
|
449
589
|
// primera flecha debe moverse desde la posición visible, no desde un índice viejo.
|
|
450
590
|
st.sel[screen] = Math.max(0, Math.min(st.sel[screen], count - 1))
|
|
451
591
|
if (key.name === 'up') st.sel[screen] = Math.max(0, st.sel[screen] - 1)
|
|
@@ -474,27 +614,38 @@ async function onKeyProfiles (term, st, key) {
|
|
|
474
614
|
if (key.name === 'enter' && cur) {
|
|
475
615
|
// Entrar a la bóveda: la activa (si no lo estaba ya) y pasa a sus pestañas
|
|
476
616
|
// (Dispositivos/Scopes) — así siempre es explícito de qué bóveda son los ítems.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
617
|
+
//
|
|
618
|
+
// Y si tiene candado, se pide la contraseña AQUÍ, antes de enseñar nada: dentro se ven
|
|
619
|
+
// los aparatos, las variables y tus datos, que es justo lo que la contraseña tapa.
|
|
620
|
+
await ensureUnlocked(term, st, cur, async (p = cur) => {
|
|
621
|
+
if (!p.current) {
|
|
622
|
+
const r = await guard(term, st, i.switchingVault, () => vc.useProfile(p.id))
|
|
623
|
+
if (!r.ok) return
|
|
624
|
+
flash(st, i.vaultNowActive(p.name || p.id))
|
|
625
|
+
}
|
|
626
|
+
// ENTRAR RECARGA TODO, SIEMPRE. Antes solo se recargaba al CAMBIAR de bóveda, y lo
|
|
627
|
+
// que traía era `refreshDevices`: aparatos y acta, no las variables. Así que entrar a
|
|
628
|
+
// la bóveda que ya estaba activa —el caso normal cuando estaba cerrada y acabas de
|
|
629
|
+
// teclear la contraseña, que es cuando la memoria está vacía a propósito— dejaba
|
|
630
|
+
// Scopes en blanco hasta que alguien pulsara F5. Un volcado trae las tres cosas.
|
|
481
631
|
await refreshAll(term, st)
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
await refreshDevices(term, st)
|
|
632
|
+
st.screen = 'devices'
|
|
633
|
+
})
|
|
485
634
|
} else if (ch === 'p' && cur) {
|
|
486
635
|
// Emparejar SIN tener que entrar antes: `p` significa lo mismo aquí que en la
|
|
487
636
|
// pestaña Dispositivos. Se activa la bóveda elegida (el QR sale de UNA, y las
|
|
488
637
|
// acciones siguientes —aprobar, revocar— miran a la activa) y se abre la
|
|
489
638
|
// pregunta de a qué cuenta entra el dispositivo.
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
639
|
+
await ensureUnlocked(term, st, cur, async (p = cur) => {
|
|
640
|
+
if (!p.current) {
|
|
641
|
+
const r = await guard(term, st, i.switchingVault, () => vc.useProfile(p.id))
|
|
642
|
+
if (!r.ok) return
|
|
643
|
+
await refreshAll(term, st)
|
|
644
|
+
}
|
|
645
|
+
st.sel.pairmode = 0
|
|
646
|
+
st.scroll.pairmode = { value: 0 }
|
|
647
|
+
st.screen = 'pairmode'
|
|
648
|
+
})
|
|
498
649
|
} else if (ch === 'n') {
|
|
499
650
|
setInput(st, {
|
|
500
651
|
label: i.newVaultLabel,
|
|
@@ -546,7 +697,9 @@ async function onKeyProfiles (term, st, key) {
|
|
|
546
697
|
st.input = null
|
|
547
698
|
if (again !== pwd) { flash(st, i.passwordMismatch, 'danger'); return }
|
|
548
699
|
const r = await guard(term, st, i.savingPassword, () => vc.setProfilePassword(p.id, pwd))
|
|
549
|
-
|
|
700
|
+
// La nueva es la que vale para el resto de la sesión: guardar la vieja dejaría
|
|
701
|
+
// a la TUI reabriendo con una contraseña que ya no existe.
|
|
702
|
+
if (r.ok) { st.sessionPwd?.set(p.id, pwd); flash(st, i.passwordSaved); await refreshProfiles(term, st) }
|
|
550
703
|
},
|
|
551
704
|
onCancel: () => { st.input = null }
|
|
552
705
|
})
|
|
@@ -557,7 +710,7 @@ async function onKeyProfiles (term, st, key) {
|
|
|
557
710
|
if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
|
|
558
711
|
await ensureUnlocked(term, st, cur, async (p = cur) => {
|
|
559
712
|
const r = await guard(term, st, i.removingPassword, () => vc.removeProfilePassword(p.id))
|
|
560
|
-
if (r.ok) { flash(st, i.passwordRemoved); await refreshProfiles(term, st) }
|
|
713
|
+
if (r.ok) { st.sessionPwd?.delete(p.id); flash(st, i.passwordRemoved); await refreshProfiles(term, st) }
|
|
561
714
|
})
|
|
562
715
|
} else if (ch === 'u' && cur) {
|
|
563
716
|
if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
|
|
@@ -566,7 +719,10 @@ async function onKeyProfiles (term, st, key) {
|
|
|
566
719
|
} else if (ch === 'k' && cur) { // locK (antes `l`, que ahora es el idioma)
|
|
567
720
|
if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
|
|
568
721
|
const r = await guard(term, st, i.lockingVault, () => vc.lockProfile(cur.id))
|
|
569
|
-
|
|
722
|
+
// Echar el candado a mano es DECIR que vuelva a hacer falta la contraseña: si la TUI
|
|
723
|
+
// se quedara con ella, la siguiente tecla la reabriría sola y el candado no cerraría
|
|
724
|
+
// nada.
|
|
725
|
+
if (r.ok) { st.sessionPwd?.delete(cur.id); st.unlockedHere?.delete(cur.id); flash(st, i.vaultLocked); await refreshProfiles(term, st) }
|
|
570
726
|
}
|
|
571
727
|
return true
|
|
572
728
|
}
|
|
@@ -605,7 +761,7 @@ async function onKeyDevices (term, st, key) {
|
|
|
605
761
|
st.confirm = null
|
|
606
762
|
// Por `sub`: se le retiran TODOS los certificados, no solo el de esta fila.
|
|
607
763
|
const r = await guard(term, st, i.revoking, () => vc.revokeDevice({ sub: cur.sub, nonce: cur.nonce }, activeId(st)))
|
|
608
|
-
if (r.ok) { flash(st, i.deviceRevoked(cur.deviceId)); st
|
|
764
|
+
if (r.ok) { flash(st, i.deviceRevoked(cur.deviceId)); applyDump(st, r.v); st.sel.devices = 0 }
|
|
609
765
|
},
|
|
610
766
|
onNo: () => { st.confirm = null }
|
|
611
767
|
})
|
|
@@ -617,11 +773,11 @@ async function onKeyDevices (term, st, key) {
|
|
|
617
773
|
label: i.renameDeviceLabel(cur.deviceId),
|
|
618
774
|
hint: i.renameDeviceHint,
|
|
619
775
|
value: cur.label || '',
|
|
620
|
-
onSubmit: async (
|
|
621
|
-
const
|
|
622
|
-
if (!
|
|
623
|
-
const r = await guard(term, st, i.renaming, () => vc.setDeviceLabel(cur.sub,
|
|
624
|
-
if (r.ok) { st
|
|
776
|
+
onSubmit: async (raw) => {
|
|
777
|
+
const name = String(raw || '').trim()
|
|
778
|
+
if (!name) return
|
|
779
|
+
const r = await guard(term, st, i.renaming, () => vc.setDeviceLabel(cur.sub, name, activeId(st)))
|
|
780
|
+
if (r.ok) { applyDump(st, r.v); flash(st, i.deviceRenamed(name)) }
|
|
625
781
|
}
|
|
626
782
|
})
|
|
627
783
|
} else if (ch === 'c' && cur?.sub) {
|
|
@@ -629,16 +785,31 @@ async function onKeyDevices (term, st, key) {
|
|
|
629
785
|
st.sel.caps = 0
|
|
630
786
|
await refreshMembers(term, st)
|
|
631
787
|
st.screen = 'caps'
|
|
788
|
+
} else if (ch === 'e' && cur?.sub) {
|
|
789
|
+
// Variables de ESTE aparato. Solo un servicio las lee (es el único que pide su
|
|
790
|
+
// bundle), así que a un teléfono se le dice que no y por qué, en vez de dejarle
|
|
791
|
+
// guardar configuración que no va a leer nadie.
|
|
792
|
+
if (!cur.cn) { flash(st, i.devVarsOnlyServices, 'warn'); return true }
|
|
793
|
+
st.varsFor = { pub: cur.sub, deviceId: cur.deviceId, label: cur.label || '', cn: cur.cn }
|
|
794
|
+
st.sel.devvars = 0
|
|
795
|
+
await refreshSecrets(term, st)
|
|
796
|
+
st.screen = 'devvars'
|
|
632
797
|
} else if (key.name === 'f5') {
|
|
633
798
|
await refreshDevices(term, st)
|
|
634
799
|
}
|
|
635
800
|
return true
|
|
636
801
|
}
|
|
637
802
|
|
|
638
|
-
/**
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
803
|
+
/**
|
|
804
|
+
* Abre el emparejamiento contra `profile` y salta a la pantalla del QR. Con `service`,
|
|
805
|
+
* el QR es el de un SERVICIO de ese namespace (cert limitado a sus variables).
|
|
806
|
+
*/
|
|
807
|
+
async function beginPairing (term, st, profile, service = null) {
|
|
808
|
+
const r = await guard(term, st, L(st).startingPairing, () => vc.startPairing({ profile, ...(service ? { service } : {}) }))
|
|
809
|
+
// `service` se pega al estado porque el daemon no lo devuelve: la pantalla del QR
|
|
810
|
+
// tiene que poder decir qué se está entregando, que no es lo mismo un aparato tuyo
|
|
811
|
+
// que una máquina que solo va a leer la configuración del proxy.
|
|
812
|
+
if (r.ok) { st.pairing = { ...r.v, ...(service ? { service } : {}) }; st.pending = null; st.scroll.pairing = { value: 0 }; st.screen = 'pairing' }
|
|
642
813
|
return r.ok
|
|
643
814
|
}
|
|
644
815
|
|
|
@@ -659,27 +830,27 @@ async function onKeyCaps (term, st, key) {
|
|
|
659
830
|
if (key.name === 'f5') { await refreshMembers(term, st); return true }
|
|
660
831
|
if ((key.name !== 'enter' && ch !== ' ') || !cur) return true
|
|
661
832
|
|
|
662
|
-
const
|
|
663
|
-
if (!
|
|
664
|
-
const caps = new Set(
|
|
665
|
-
const
|
|
666
|
-
if (
|
|
833
|
+
const member = (st.members || []).find((m) => m.pub === st.capsFor?.pub)
|
|
834
|
+
if (!member) return true
|
|
835
|
+
const caps = new Set(member.caps || [])
|
|
836
|
+
const giving = !caps.has(cur.cap)
|
|
837
|
+
if (giving) caps.add(cur.cap); else caps.delete(cur.cap)
|
|
667
838
|
|
|
668
|
-
const
|
|
669
|
-
const r = await guard(term, st, i.applyingCaps, () => vc.setDeviceCaps(
|
|
839
|
+
const apply = async () => {
|
|
840
|
+
const r = await guard(term, st, i.applyingCaps, () => vc.setDeviceCaps(member.pub, [...caps], activeId(st)))
|
|
670
841
|
if (!r.ok) return
|
|
671
|
-
st
|
|
842
|
+
applyDump(st, r.v)
|
|
672
843
|
await refreshMembers(term, st)
|
|
673
|
-
flash(st,
|
|
844
|
+
flash(st, giving ? i.capGiven(i.capName[cur.cap]) : i.capTaken(i.capName[cur.cap]))
|
|
674
845
|
}
|
|
675
846
|
|
|
676
847
|
// Administrar se PREGUNTA: es el permiso que deja a ese aparato admitir y expulsar
|
|
677
848
|
// dispositivos sin pasar por aquí. Los otros tres se marcan y ya.
|
|
678
|
-
if (cur.cap === 'admin' &&
|
|
679
|
-
setConfirm(st, { text: i.confirmAdmin(st.capsFor.deviceId), onYes:
|
|
849
|
+
if (cur.cap === 'admin' && giving) {
|
|
850
|
+
setConfirm(st, { text: i.confirmAdmin(st.capsFor.deviceId), onYes: apply })
|
|
680
851
|
return true
|
|
681
852
|
}
|
|
682
|
-
await
|
|
853
|
+
await apply()
|
|
683
854
|
return true
|
|
684
855
|
}
|
|
685
856
|
|
|
@@ -696,24 +867,43 @@ async function onKeyPairMode (term, st, key) {
|
|
|
696
867
|
|
|
697
868
|
if (cur.mode === 'here') { await beginPairing(term, st, activeId(st)); return true }
|
|
698
869
|
|
|
870
|
+
// SERVICIO: entra a la cuenta activa, pero con un certificado que solo sirve para
|
|
871
|
+
// pedir las variables de SU namespace. El nombre del ns es el que luego pide el
|
|
872
|
+
// servicio al arrancar, así que se valida aquí con la misma regla que la CLI: un
|
|
873
|
+
// ns con mayúsculas o espacios se enrola igual y falla el día del despliegue.
|
|
874
|
+
if (cur.mode === 'service') {
|
|
875
|
+
setInput(st, {
|
|
876
|
+
label: i.serviceNsLabel,
|
|
877
|
+
hint: i.serviceNsHint,
|
|
878
|
+
onSubmit: async (raw) => {
|
|
879
|
+
st.input = null
|
|
880
|
+
const ns = String(raw || '').trim().toLowerCase()
|
|
881
|
+
if (!/^[a-z0-9-]{1,32}$/.test(ns)) { flash(st, i.serviceNsBad, 'danger'); return }
|
|
882
|
+
await beginPairing(term, st, activeId(st), ns)
|
|
883
|
+
},
|
|
884
|
+
onCancel: () => { st.input = null }
|
|
885
|
+
})
|
|
886
|
+
return true
|
|
887
|
+
}
|
|
888
|
+
|
|
699
889
|
// Cuenta nueva: se crea aquí, se ACTIVA (así aprobar/rechazar y las listas miran
|
|
700
890
|
// a la misma que el QR) y recién entonces se abre el emparejamiento contra ella.
|
|
701
891
|
setInput(st, {
|
|
702
892
|
label: i.newAccountLabel,
|
|
703
893
|
hint: i.newAccountHint,
|
|
704
|
-
onSubmit: async (
|
|
894
|
+
onSubmit: async (raw) => {
|
|
705
895
|
st.input = null
|
|
706
|
-
const
|
|
707
|
-
if (!
|
|
708
|
-
const r = await guard(term, st, i.creatingVault, () => vc.addProfile(
|
|
896
|
+
const name = raw.trim()
|
|
897
|
+
if (!name) { flash(st, i.nameEmpty, 'danger'); return }
|
|
898
|
+
const r = await guard(term, st, i.creatingVault, () => vc.addProfile(name))
|
|
709
899
|
if (!r.ok) return
|
|
710
|
-
const
|
|
711
|
-
if (!
|
|
712
|
-
const u = await guard(term, st, i.switchingVault, () => vc.useProfile(
|
|
900
|
+
const created = r.v?.id || (r.v?.profiles || []).find((p) => p.name === name)?.id
|
|
901
|
+
if (!created) { flash(st, i.errNoReply, 'danger'); return }
|
|
902
|
+
const u = await guard(term, st, i.switchingVault, () => vc.useProfile(created))
|
|
713
903
|
if (!u.ok) return
|
|
714
904
|
await refreshAll(term, st)
|
|
715
|
-
flash(st, i.accountCreated(
|
|
716
|
-
await beginPairing(term, st,
|
|
905
|
+
flash(st, i.accountCreated(name))
|
|
906
|
+
await beginPairing(term, st, created)
|
|
717
907
|
},
|
|
718
908
|
onCancel: () => { st.input = null }
|
|
719
909
|
})
|
|
@@ -729,7 +919,15 @@ function promptApprove (term, st) {
|
|
|
729
919
|
st.input = null
|
|
730
920
|
if (!code.trim()) { flash(st, i.codeMissing, 'danger'); return }
|
|
731
921
|
const r = await guard(term, st, i.approving, () => vc.approvePending(code.trim(), activeId(st)))
|
|
732
|
-
if (r.ok) {
|
|
922
|
+
if (r.ok) {
|
|
923
|
+
flash(st, i.deviceApproved)
|
|
924
|
+
st.pending = null
|
|
925
|
+
st.screen = 'devices'
|
|
926
|
+
// Sin lista (el volcado se perdió): se pide otra vez. El aparato ya está dentro;
|
|
927
|
+
// lo único que falta es la foto, y esa se vuelve a pedir sin drama.
|
|
928
|
+
if (r.v) applyDump(st, r.v)
|
|
929
|
+
else await refreshDevices(term, st)
|
|
930
|
+
}
|
|
733
931
|
},
|
|
734
932
|
onCancel: () => { st.input = null }
|
|
735
933
|
})
|
|
@@ -792,6 +990,8 @@ async function onKeySecrets (term, st, key) {
|
|
|
792
990
|
|
|
793
991
|
if (ch === 'n') {
|
|
794
992
|
promptNewVariable(term, st)
|
|
993
|
+
} else if (ch === 'i') {
|
|
994
|
+
promptLoadScopeVars(term, st)
|
|
795
995
|
} else if ((ch === 'x' || key.name === 'delete') && cur) {
|
|
796
996
|
if (cur.key) {
|
|
797
997
|
setConfirm(st, {
|
|
@@ -804,7 +1004,7 @@ async function onKeySecrets (term, st, key) {
|
|
|
804
1004
|
onNo: () => { st.confirm = null }
|
|
805
1005
|
})
|
|
806
1006
|
} else {
|
|
807
|
-
const count = (st.secrets?.[cur.ns] || []).length
|
|
1007
|
+
const count = (st.secrets?.ns?.[cur.ns] || []).length
|
|
808
1008
|
setConfirm(st, {
|
|
809
1009
|
text: i.removeScopeConfirm(cur.ns, count),
|
|
810
1010
|
onYes: async () => {
|
|
@@ -815,38 +1015,198 @@ async function onKeySecrets (term, st, key) {
|
|
|
815
1015
|
onNo: () => { st.confirm = null }
|
|
816
1016
|
})
|
|
817
1017
|
}
|
|
1018
|
+
} else if (ch === 't' && cur?.key) {
|
|
1019
|
+
await toggleVisibility(term, st, cur.public, () => vc.setSecretVisibility(cur.ns, cur.key, !cur.public, activeId(st)))
|
|
818
1020
|
} else if (key.name === 'f5') {
|
|
819
1021
|
await refreshSecrets(term, st)
|
|
820
1022
|
}
|
|
821
1023
|
return true
|
|
822
1024
|
}
|
|
823
1025
|
|
|
1026
|
+
/**
|
|
1027
|
+
* Hacer pública una variable es dejar que su valor SALGA de esta máquina, así que se
|
|
1028
|
+
* pregunta; volverla privada no expone nada y se aplica directo.
|
|
1029
|
+
*/
|
|
1030
|
+
async function toggleVisibility (term, st, wasPublic, apply) {
|
|
1031
|
+
const i = L(st)
|
|
1032
|
+
const run = async () => {
|
|
1033
|
+
const r = await guard(term, st, i.changingVisibility, apply)
|
|
1034
|
+
if (r.ok) { flash(st, wasPublic ? i.nowPrivate : i.nowPublic); st.secrets = r.v }
|
|
1035
|
+
}
|
|
1036
|
+
if (wasPublic) return run()
|
|
1037
|
+
setConfirm(st, { text: i.makePublicConfirm, onYes: run, onNo: () => { st.confirm = null } })
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
/**
|
|
1041
|
+
* Teclas de las variables de UN aparato: agregar y quitar. Nada más — el aparato ya se
|
|
1042
|
+
* eligió en Dispositivos, y de ahí se vuelve con Esc.
|
|
1043
|
+
*/
|
|
1044
|
+
async function onKeyDevVars (term, st, key) {
|
|
1045
|
+
const i = L(st)
|
|
1046
|
+
const rows = devVarRows(st, term.t)
|
|
1047
|
+
const sels = rows.filter((r) => r.sel).map((r) => r.meta)
|
|
1048
|
+
moveSel(st, key, 'devvars', sels.length)
|
|
1049
|
+
const cur = sels[Math.min(st.sel.devvars || 0, sels.length - 1)]
|
|
1050
|
+
const ch = key.name === 'char' ? key.ch.toLowerCase() : null
|
|
1051
|
+
const target = st.varsFor
|
|
1052
|
+
|
|
1053
|
+
if (key.name === 'escape' || ch === 'b') {
|
|
1054
|
+
st.screen = 'devices'; st.varsFor = null
|
|
1055
|
+
await refreshDevices(term, st)
|
|
1056
|
+
return true
|
|
1057
|
+
}
|
|
1058
|
+
if (key.name === 'f5') { await refreshSecrets(term, st); return true }
|
|
1059
|
+
if (ch === 'n') { promptNewDeviceVariable(term, st); return true }
|
|
1060
|
+
if (ch === 'i' && target) { promptLoadVars(term, st, { pub: target.pub, where: target.deviceId }); return true }
|
|
1061
|
+
if (ch === 't' && cur && target) {
|
|
1062
|
+
await toggleVisibility(term, st, cur.public, () => vc.setDeviceSecretVisibility(target.pub, cur.key, !cur.public, activeId(st)))
|
|
1063
|
+
return true
|
|
1064
|
+
}
|
|
1065
|
+
if ((ch === 'x' || key.name === 'delete') && cur && target) {
|
|
1066
|
+
setConfirm(st, {
|
|
1067
|
+
text: i.removeDevVarConfirm(target.deviceId, cur.key),
|
|
1068
|
+
onYes: async () => {
|
|
1069
|
+
st.confirm = null
|
|
1070
|
+
const r = await guard(term, st, i.removingVar, () => vc.deleteDeviceSecret(target.pub, cur.key, activeId(st)))
|
|
1071
|
+
if (r.ok) { flash(st, i.varRemoved); st.secrets = r.v; st.sel.devvars = Math.max(0, st.sel.devvars - 1) }
|
|
1072
|
+
},
|
|
1073
|
+
onNo: () => { st.confirm = null }
|
|
1074
|
+
})
|
|
1075
|
+
}
|
|
1076
|
+
return true
|
|
1077
|
+
}
|
|
1078
|
+
|
|
1079
|
+
/**
|
|
1080
|
+
* Al crear una variable se PREGUNTA si su valor puede salir de esta máquina. Se pregunta
|
|
1081
|
+
* al crearla, y no después, porque es cuando quien la escribe sabe qué es: un puerto se
|
|
1082
|
+
* puede enseñar, una llave de producción no. La respuesta por defecto —Enter, o `n`— es
|
|
1083
|
+
* la privada.
|
|
1084
|
+
*/
|
|
1085
|
+
function askVisibility (term, st, done) {
|
|
1086
|
+
const i = L(st)
|
|
1087
|
+
setConfirm(st, {
|
|
1088
|
+
text: i.newVarPublicAsk,
|
|
1089
|
+
onYes: () => { st.confirm = null; done(true) },
|
|
1090
|
+
onNo: () => { st.confirm = null; done(false) }
|
|
1091
|
+
})
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function promptNewDeviceVariable (term, st) {
|
|
1095
|
+
const i = L(st)
|
|
1096
|
+
const target = st.varsFor
|
|
1097
|
+
if (!target) return
|
|
1098
|
+
setInput(st, {
|
|
1099
|
+
label: i.keyLabel(target.deviceId),
|
|
1100
|
+
hint: i.keyHint,
|
|
1101
|
+
onSubmit: (key) => {
|
|
1102
|
+
const kv = key.trim()
|
|
1103
|
+
if (!KEY_RE.test(kv)) { flash(st, i.keyInvalid, 'danger'); return }
|
|
1104
|
+
st.input = null
|
|
1105
|
+
setInput(st, {
|
|
1106
|
+
label: i.valueLabel(target.deviceId, kv),
|
|
1107
|
+
mask: true,
|
|
1108
|
+
hint: i.valueHint,
|
|
1109
|
+
onSubmit: async (value) => {
|
|
1110
|
+
st.input = null
|
|
1111
|
+
if (!value) { flash(st, i.valueEmpty, 'danger'); return }
|
|
1112
|
+
askVisibility(term, st, async (isPublic) => {
|
|
1113
|
+
const r = await guard(term, st, i.savingVar, () => vc.setDeviceSecret(target.pub, kv, value, activeId(st), isPublic))
|
|
1114
|
+
if (r.ok) { flash(st, i.varSaved(target.deviceId, kv)); st.secrets = r.v }
|
|
1115
|
+
})
|
|
1116
|
+
},
|
|
1117
|
+
onCancel: () => { st.input = null }
|
|
1118
|
+
})
|
|
1119
|
+
},
|
|
1120
|
+
onCancel: () => { st.input = null }
|
|
1121
|
+
})
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* CARGAR VARIAS DE UNA VEZ (tecla `i`, de *import*, la misma palabra que en el CLI).
|
|
1126
|
+
*
|
|
1127
|
+
* Guardar las variables de un servicio una por una es, para la bóveda, un cambio de
|
|
1128
|
+
* configuración por variable: el servicio obedece el primero —sale y lo levanta su
|
|
1129
|
+
* supervisor— y arranca con media configuración mientras se teclea el resto. Cargarlas
|
|
1130
|
+
* juntas hace que se reinicie UNA vez, con todo puesto.
|
|
1131
|
+
*
|
|
1132
|
+
* Se acepta lo que se pueda escribir en una línea (`CLAVE=valor CLAVE2=valor2`) o la
|
|
1133
|
+
* RUTA de un `.env`, que es como suele llegar la configuración de un servicio.
|
|
1134
|
+
*/
|
|
1135
|
+
function promptLoadVars (term, st, { ns = null, pub = null, where }) {
|
|
1136
|
+
const i = L(st)
|
|
1137
|
+
setInput(st, {
|
|
1138
|
+
label: i.loadLabel(where),
|
|
1139
|
+
hint: i.loadHint,
|
|
1140
|
+
onSubmit: async (raw) => {
|
|
1141
|
+
const text = raw.trim()
|
|
1142
|
+
st.input = null
|
|
1143
|
+
if (!text) return
|
|
1144
|
+
let content = text
|
|
1145
|
+
// Sin un `=` no es una lista de variables: es la ruta de un archivo.
|
|
1146
|
+
if (!text.includes('=')) {
|
|
1147
|
+
try { content = fs.readFileSync(text, 'utf8') } catch (_) { flash(st, i.loadNoFile(text), 'danger'); return }
|
|
1148
|
+
}
|
|
1149
|
+
const { items, errors } = parseEnvInput(content)
|
|
1150
|
+
// Un archivo con un problema no se carga a medias: se dice qué línea y no se
|
|
1151
|
+
// escribe nada. Media configuración aplicada es peor que ninguna.
|
|
1152
|
+
if (errors.length) { flash(st, i.loadNothing + ' ' + i.envErr[errors[0].code](errors[0]), 'danger'); return }
|
|
1153
|
+
askVisibility(term, st, async (isPublic) => {
|
|
1154
|
+
const withVisibility = items.map((it) => ({ ...it, public: isPublic }))
|
|
1155
|
+
const r = await guard(term, st, i.loadingVars, () => (pub
|
|
1156
|
+
? vc.applyDeviceSecrets(pub, withVisibility, activeId(st))
|
|
1157
|
+
: vc.applySecrets(ns, withVisibility, activeId(st))))
|
|
1158
|
+
if (r.ok) { flash(st, i.loadedVars(items.length, where)); st.secrets = r.v }
|
|
1159
|
+
})
|
|
1160
|
+
},
|
|
1161
|
+
onCancel: () => { st.input = null }
|
|
1162
|
+
})
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/** Cargar varias en un SCOPE: primero cuál, luego el bloque. */
|
|
1166
|
+
function promptLoadScopeVars (term, st) {
|
|
1167
|
+
const i = L(st)
|
|
1168
|
+
const existing = Object.keys(st.secrets?.ns || {})
|
|
1169
|
+
setInput(st, {
|
|
1170
|
+
label: i.nsLabel,
|
|
1171
|
+
hint: existing.length ? i.nsHintExisting(existing.join(', ')) : i.nsHint,
|
|
1172
|
+
onSubmit: (ns) => {
|
|
1173
|
+
const nsName = ns.trim()
|
|
1174
|
+
if (!NS_RE.test(nsName)) { flash(st, i.nsInvalid, 'danger'); return }
|
|
1175
|
+
st.input = null
|
|
1176
|
+
promptLoadVars(term, st, { ns: nsName, where: nsName })
|
|
1177
|
+
},
|
|
1178
|
+
onCancel: () => { st.input = null }
|
|
1179
|
+
})
|
|
1180
|
+
}
|
|
1181
|
+
|
|
824
1182
|
function promptNewVariable (term, st) {
|
|
825
1183
|
const i = L(st)
|
|
826
|
-
const existing = Object.keys(st.secrets || {})
|
|
1184
|
+
const existing = Object.keys(st.secrets?.ns || {})
|
|
827
1185
|
setInput(st, {
|
|
828
1186
|
label: i.nsLabel,
|
|
829
1187
|
hint: existing.length ? i.nsHintExisting(existing.join(', ')) : i.nsHint,
|
|
830
1188
|
onSubmit: (ns) => {
|
|
831
|
-
const
|
|
832
|
-
if (!NS_RE.test(
|
|
1189
|
+
const nsName = ns.trim()
|
|
1190
|
+
if (!NS_RE.test(nsName)) { flash(st, i.nsInvalid, 'danger'); promptNewVariable(term, st); return }
|
|
833
1191
|
st.input = null
|
|
834
1192
|
setInput(st, {
|
|
835
|
-
label: i.keyLabel(
|
|
1193
|
+
label: i.keyLabel(nsName),
|
|
836
1194
|
hint: i.keyHint,
|
|
837
1195
|
onSubmit: (key) => {
|
|
838
1196
|
const kv = key.trim()
|
|
839
1197
|
if (!KEY_RE.test(kv)) { flash(st, i.keyInvalid, 'danger'); return }
|
|
840
1198
|
st.input = null
|
|
841
1199
|
setInput(st, {
|
|
842
|
-
label: i.valueLabel(
|
|
1200
|
+
label: i.valueLabel(nsName, kv),
|
|
843
1201
|
mask: true,
|
|
844
1202
|
hint: i.valueHint,
|
|
845
1203
|
onSubmit: async (value) => {
|
|
846
1204
|
st.input = null
|
|
847
1205
|
if (!value) { flash(st, i.valueEmpty, 'danger'); return }
|
|
848
|
-
|
|
849
|
-
|
|
1206
|
+
askVisibility(term, st, async (isPublic) => {
|
|
1207
|
+
const r = await guard(term, st, i.savingVar, () => vc.setSecret(nsName, kv, value, activeId(st), isPublic))
|
|
1208
|
+
if (r.ok) { flash(st, i.varSaved(nsName, kv)); st.secrets = r.v }
|
|
1209
|
+
})
|
|
850
1210
|
},
|
|
851
1211
|
onCancel: () => { st.input = null }
|
|
852
1212
|
})
|
|
@@ -900,13 +1260,20 @@ const helpSegs = (i, screen, st = {}) => {
|
|
|
900
1260
|
pairing: i.helpPairing,
|
|
901
1261
|
pairmode: i.helpPairMode,
|
|
902
1262
|
me: i.helpMe,
|
|
903
|
-
caps: i.helpCaps
|
|
1263
|
+
caps: i.helpCaps,
|
|
1264
|
+
devvars: i.helpDevVars
|
|
904
1265
|
}[screen] || []
|
|
905
1266
|
if (typeof segs !== 'function') return segs
|
|
1267
|
+
// El aparato señalado ahora mismo: `e variables` solo tiene sentido en un servicio (es
|
|
1268
|
+
// el único que las lee), y las filas seleccionables de la lista son justo los aparatos.
|
|
1269
|
+
const devs = mergeMembersAndCerts(st.members, st.devices?.issued || [])
|
|
1270
|
+
const cur = devs[Math.min(st.sel?.devices || 0, devs.length - 1)]
|
|
906
1271
|
return segs({
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1272
|
+
pending: !!st.pending,
|
|
1273
|
+
hasDevices: (st.devices?.issued || []).length > 0,
|
|
1274
|
+
isService: !!cur?.cn,
|
|
1275
|
+
hasSecrets: Object.keys(st.secrets?.ns || {}).length > 0,
|
|
1276
|
+
hasVars: devVarsOf(st, st.varsFor?.pub).length > 0
|
|
910
1277
|
})
|
|
911
1278
|
}
|
|
912
1279
|
|
|
@@ -914,7 +1281,8 @@ const title = (i, screen) => ({
|
|
|
914
1281
|
profiles: i.titleProfiles,
|
|
915
1282
|
pairing: i.titlePairing,
|
|
916
1283
|
pairmode: i.titlePairMode,
|
|
917
|
-
caps: i.titleCaps
|
|
1284
|
+
caps: i.titleCaps,
|
|
1285
|
+
devvars: i.titleDevVars
|
|
918
1286
|
})[screen] || ''
|
|
919
1287
|
|
|
920
1288
|
/** Barra de pestañas horizontal (Dispositivos | Scopes y variables) de la bóveda entrada. */
|
|
@@ -937,6 +1305,9 @@ function pairingBody (st, t, cols, height) {
|
|
|
937
1305
|
const acct = info.profileName || ap?.name || info.profile || ap?.id || '—'
|
|
938
1306
|
const left = Math.max(0, Math.round((info.expiresAt - Date.now()) / 60000))
|
|
939
1307
|
lines.push(t.bold(i.pairAccount(acct, left)))
|
|
1308
|
+
// Y si lo que se entrega es un SERVICIO, se dice: el papel que sale de este QR no
|
|
1309
|
+
// firma ni ve el contenido, solo lee las variables de ese namespace.
|
|
1310
|
+
if (info.service) lines.push(t.warn(i.pairService(info.service)))
|
|
940
1311
|
// QR: se dibuja siempre que quepa de ancho; si es más alto que la pantalla se
|
|
941
1312
|
// puede hacer scroll hacia arriba/abajo para verlo completo.
|
|
942
1313
|
let qr = ''
|
|
@@ -977,9 +1348,9 @@ function render (term, st) {
|
|
|
977
1348
|
|
|
978
1349
|
const s = st.state
|
|
979
1350
|
const up = st.daemonUp
|
|
980
|
-
const
|
|
1351
|
+
const version = s?.version || 'dev'
|
|
981
1352
|
const daemonTxt = up ? i.daemonRunning : i.daemonStopped
|
|
982
|
-
lines[0] = t.bar(`dotrino-vault ${
|
|
1353
|
+
lines[0] = t.bar(`dotrino-vault ${version} daemon: ${daemonTxt} ${vc.vaultDir()}`, cols)
|
|
983
1354
|
|
|
984
1355
|
const ap = activeProfile(st)
|
|
985
1356
|
const apTxt = ap ? `${t.accent('●')} ${t.bold(ap.name || i.noName)} ${lockGlyph(ap)} ${t.muted('· ' + (ap.fingerprint || '—'))}` : t.muted('—')
|
|
@@ -1001,6 +1372,7 @@ function render (term, st) {
|
|
|
1001
1372
|
else if (st.screen === 'secrets') body = renderList(secretRows(st, t), st.sel.secrets, contentH, cols, t, scrollRef)
|
|
1002
1373
|
else if (st.screen === 'me') body = renderList(meRows(st, t), -1, contentH, cols, t, scrollRef)
|
|
1003
1374
|
else if (st.screen === 'caps') body = renderList(capsRows(st, t), st.sel.caps || 0, contentH, cols, t, scrollRef)
|
|
1375
|
+
else if (st.screen === 'devvars') body = renderList(devVarRows(st, t), st.sel.devvars || 0, contentH, cols, t, scrollRef)
|
|
1004
1376
|
else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
|
|
1005
1377
|
else if (st.screen === 'pairing') {
|
|
1006
1378
|
const pb = pairingBody(st, t, cols, contentH)
|
|
@@ -1086,7 +1458,13 @@ export async function runTui () {
|
|
|
1086
1458
|
const st = {
|
|
1087
1459
|
screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
|
|
1088
1460
|
lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
|
|
1089
|
-
sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0 },
|
|
1461
|
+
sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, devvars: 0 },
|
|
1462
|
+
// Las bóvedas que ha abierto ESTA sesión, para volver a cerrarlas al salir.
|
|
1463
|
+
unlockedHere: new Set(),
|
|
1464
|
+
// Su contraseña, SOLO en memoria y SOLO mientras la TUI esté abierta: sirve para
|
|
1465
|
+
// reabrir sin volver a preguntar si el daemon pierde el estado (ver
|
|
1466
|
+
// `reunlockSilently`). Se olvida con `k`, al quitar la contraseña y al salir.
|
|
1467
|
+
sessionPwd: new Map(),
|
|
1090
1468
|
scroll: {},
|
|
1091
1469
|
profiles: null,
|
|
1092
1470
|
devices: null,
|
|
@@ -1155,13 +1533,21 @@ export async function runTui () {
|
|
|
1155
1533
|
else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
|
|
1156
1534
|
else if (st.screen === 'me') running = await onKeyMe(term, st, key)
|
|
1157
1535
|
else if (st.screen === 'caps') running = await onKeyCaps(term, st, key)
|
|
1536
|
+
else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
|
|
1158
1537
|
else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
|
|
1159
1538
|
else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
|
|
1160
1539
|
}
|
|
1161
1540
|
} finally {
|
|
1541
|
+
// AL SALIR SE VUELVE A CERRAR lo que se abrió aquí. Sin esto, teclear la contraseña una
|
|
1542
|
+
// vez dejaba la bóveda abierta para todo el que pasara por esta máquina hasta el
|
|
1543
|
+
// siguiente reinicio del servicio — un candado que solo se cierra reiniciando no es un
|
|
1544
|
+
// candado. (Si la TUI muere de un tirón —kill, ventana cerrada— no hay quien lo haga:
|
|
1545
|
+
// ahí el cierre lo pone el reinicio, como antes.)
|
|
1546
|
+
st.sessionPwd.clear()
|
|
1547
|
+
for (const id of st.unlockedHere) { try { await vc.lockProfile(id) } catch (_) {} }
|
|
1162
1548
|
term.close()
|
|
1163
1549
|
}
|
|
1164
1550
|
}
|
|
1165
1551
|
|
|
1166
1552
|
// Solo para pruebas headless (render sin terminal real). No usar en runtime.
|
|
1167
|
-
export const __test = { render, profileRows, deviceRows, secretRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts }
|
|
1553
|
+
export const __test = { render, activeLocked, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts }
|