@dotrino/vaultd 0.26.2 → 0.46.2
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 +145 -25
- package/bin/dotrino-vaultd.js +4 -4
- package/lib/README.md +13 -2
- package/lib/src/admin.js +92 -3
- package/lib/src/atrest.js +0 -0
- package/lib/src/config.js +1 -1
- package/lib/src/enroll.js +38 -25
- 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 +22 -0
- package/lib/src/service.js +497 -135
- package/package.json +11 -6
- package/src/ctl.js +639 -98
- package/src/daemon.js +321 -52
- package/src/manager.js +12 -6
- package/src/profiles.js +109 -13
- package/src/sealKey.js +80 -0
- package/src/sealer.js +170 -0
- package/src/secretsStore.js +881 -29
- package/src/store.js +3 -1
- package/src/transport.js +2 -2
- package/src/tui/app.js +625 -129
- package/src/tui/i18n.js +145 -24
- package/src/vault.js +972 -48
- package/src/vaultControl.js +286 -61
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
|
}
|
|
@@ -123,6 +138,13 @@ function activeProfile (st) {
|
|
|
123
138
|
return list.find((p) => p.current) || list[0] || null
|
|
124
139
|
}
|
|
125
140
|
const activeId = (st) => activeProfile(st)?.id || undefined
|
|
141
|
+
/**
|
|
142
|
+
* La contraseña del perfil ACTIVO, que la TUI ya guarda para toda la sesión al
|
|
143
|
+
* desbloquear (ver `reunlockSilently`). Las operaciones que SELLAN una variable la
|
|
144
|
+
* necesitan: sin ella el daemon cae a la llave de la máquina, que no abre la copia
|
|
145
|
+
* maestra de un perfil con contraseña, y la escritura falla con «wrong password».
|
|
146
|
+
*/
|
|
147
|
+
const activePwd = (st) => st.sessionPwd?.get(activeId(st)) || undefined
|
|
126
148
|
|
|
127
149
|
function lockGlyph (p) {
|
|
128
150
|
if (!p?.protected) return ''
|
|
@@ -235,15 +257,21 @@ function deviceRows (st, t) {
|
|
|
235
257
|
}
|
|
236
258
|
for (const d of devices) {
|
|
237
259
|
const label = d.label || t.muted(i.noLabel)
|
|
238
|
-
const
|
|
260
|
+
const vars = devVarsOf(st, d.sub).length
|
|
261
|
+
const debt = debtOf(st, d.sub)
|
|
262
|
+
const extra = (d.certCount > 1 ? t.muted(` certs:${d.certCount}`) : '') +
|
|
263
|
+
(vars ? t.muted(` vars:${vars}`) : '') +
|
|
264
|
+
// EN DEUDA: en el acta y sin poder abrir lo suyo. Va en color de aviso al lado de
|
|
265
|
+
// sus variables, que es donde se mira cuando algo no arranca.
|
|
266
|
+
(debt ? t.warn(` ${i.deviceDebt(debt)}`) : '')
|
|
239
267
|
// SIN ACCESO: está en el acta y no puede entrar. Es un aviso, no un adorno, así que va
|
|
240
268
|
// en el color de aviso y en el sitio donde estaría su vencimiento.
|
|
241
|
-
const
|
|
269
|
+
const status = d.noAccess
|
|
242
270
|
? t.warn(i.deviceNoAccess)
|
|
243
271
|
: d.isMaster
|
|
244
272
|
? t.muted(i.thisVault)
|
|
245
273
|
: t.muted('scope:' + shortScope(d.scope)) + ' ' + t.muted('exp:' + fmtExp(d.exp))
|
|
246
|
-
rows.push({ text: ` ${t.bold(d.deviceId)} ${label} ${
|
|
274
|
+
rows.push({ text: ` ${t.bold(d.deviceId)} ${label} ${status}${extra}`, sel: true, meta: d })
|
|
247
275
|
}
|
|
248
276
|
const revoked = st.devices?.revoked || []
|
|
249
277
|
if (revoked.length) {
|
|
@@ -256,11 +284,15 @@ function deviceRows (st, t) {
|
|
|
256
284
|
/**
|
|
257
285
|
* LA PREGUNTA DEL EMPAREJAMIENTO. La decisión es del vault (es quien lo inicia) y
|
|
258
286
|
* 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).
|
|
287
|
+
* cuál entra el dispositivo. Se responde con las tres formas que existen —una
|
|
288
|
+
* cuenta que ya vive aquí, una nueva que se estrena para él, o un SERVICIO de la
|
|
289
|
+
* cuenta activa—; la cuarta («adoptar la que trae el aparato») necesita el
|
|
290
|
+
* protocolo de adopción y se muestra desactivada para no prometer lo que todavía
|
|
291
|
+
* no hace (docs/vinculacion-de-cuentas.md §5).
|
|
292
|
+
*
|
|
293
|
+
* Lo del servicio estaba SOLO en la línea de comandos (`pair --service <ns>`), y una
|
|
294
|
+
* máquina que sirve el proxy no se empareja de otra manera: sin esta opción, la TUI
|
|
295
|
+
* te dejaba a medio camino y había que salirse a la terminal a terminar el trabajo.
|
|
264
296
|
*/
|
|
265
297
|
function pairModeRows (st, t) {
|
|
266
298
|
const i = L(st)
|
|
@@ -272,6 +304,9 @@ function pairModeRows (st, t) {
|
|
|
272
304
|
rows.push({ text: ` ${t.bold(i.pairModeNew)}`, sel: true, meta: { mode: 'new' } })
|
|
273
305
|
rows.push({ text: t.muted(' ' + i.pairModeNewHint), sel: false })
|
|
274
306
|
rows.push({ text: '', sel: false })
|
|
307
|
+
rows.push({ text: ` ${t.bold(i.pairModeService)}`, sel: true, meta: { mode: 'service' } })
|
|
308
|
+
rows.push({ text: t.muted(' ' + i.pairModeServiceHint), sel: false })
|
|
309
|
+
rows.push({ text: '', sel: false })
|
|
275
310
|
rows.push({ text: ' ' + t.muted(i.pairModeAdopt), sel: false })
|
|
276
311
|
rows.push({ text: t.muted(' (' + i.pairModeAdoptSoon + ')'), sel: false })
|
|
277
312
|
return rows
|
|
@@ -282,25 +317,25 @@ function pairModeRows (st, t) {
|
|
|
282
317
|
* una marca de si los tiene. El de administrar va aparte y avisado: es el único que deja
|
|
283
318
|
* a ese aparato meter y sacar dispositivos sin venir aquí.
|
|
284
319
|
*/
|
|
285
|
-
const
|
|
320
|
+
const CAPS_ORDER = ['sign', 'store', 'read', 'admin']
|
|
286
321
|
|
|
287
322
|
function capsRows (st, t) {
|
|
288
323
|
const i = L(st)
|
|
289
|
-
const
|
|
290
|
-
if (!
|
|
291
|
-
const
|
|
292
|
-
if (!
|
|
324
|
+
const target = st.capsFor
|
|
325
|
+
if (!target) return [{ text: t.muted(i.loading), sel: false }]
|
|
326
|
+
const member = (st.members || []).find((m) => m.pub === target.pub)
|
|
327
|
+
if (!member) return [{ text: t.muted(i.capsNoMember), sel: false }]
|
|
293
328
|
|
|
294
|
-
const
|
|
329
|
+
const has = new Set(member.caps || [])
|
|
295
330
|
const rows = [
|
|
296
|
-
{ text: ' ' + t.bold(i.capsFor(
|
|
331
|
+
{ text: ' ' + t.bold(i.capsFor(target.deviceId, member.label || '')), sel: false },
|
|
297
332
|
{ text: '', sel: false }
|
|
298
333
|
]
|
|
299
|
-
for (const cap of
|
|
300
|
-
const
|
|
301
|
-
const
|
|
302
|
-
const
|
|
303
|
-
rows.push({ text:
|
|
334
|
+
for (const cap of CAPS_ORDER) {
|
|
335
|
+
const mark = has.has(cap) ? '[x]' : '[ ]'
|
|
336
|
+
const name = i.capName[cap]
|
|
337
|
+
const line = ` ${mark} ${cap === 'admin' ? t.bold(name) : name}`
|
|
338
|
+
rows.push({ text: line, sel: true, meta: { cap } })
|
|
304
339
|
rows.push({ text: t.muted(' ' + i.capHint[cap]), sel: false })
|
|
305
340
|
}
|
|
306
341
|
rows.push({ text: '', sel: false })
|
|
@@ -308,21 +343,90 @@ function capsRows (st, t) {
|
|
|
308
343
|
return rows
|
|
309
344
|
}
|
|
310
345
|
|
|
346
|
+
/** Las variables por SCOPE: las que comparten todos los aparatos que sirven ese ns. */
|
|
311
347
|
function secretRows (st, t) {
|
|
312
348
|
const i = L(st)
|
|
313
|
-
const ns = st.secrets || {}
|
|
349
|
+
const ns = st.secrets?.ns || {}
|
|
314
350
|
const names = Object.keys(ns).sort()
|
|
315
351
|
const rows = []
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
352
|
+
// El puntero a la otra pantalla va SIEMPRE, con scopes y sin ellos: es la mitad de la
|
|
353
|
+
// función y quien la busca no tiene por qué adivinar que vive en Dispositivos.
|
|
354
|
+
const footer = [{ text: '', sel: false }, { text: t.muted(' ' + i.devVarsElsewhere), sel: false }]
|
|
355
|
+
// ARRIBA DEL TODO, antes que las variables: lo que está sin sellar significa que esos
|
|
356
|
+
// aparatos NO están leyendo su configuración ahora mismo, y que solo la contraseña lo
|
|
357
|
+
// arregla. Un aviso que hay que buscar no es un aviso.
|
|
358
|
+
const head = []
|
|
359
|
+
for (const [owner, info] of Object.entries(st.secrets?.pending || {})) {
|
|
360
|
+
const who = (info?.members || []).map((m) => deviceIdOf(st, m.pub) + ' (' + m.keys.join(', ') + ')').join(', ')
|
|
361
|
+
head.push({ text: t.warn(' ' + i.pendingSeal(owner, info?.kind, who)), sel: false })
|
|
319
362
|
}
|
|
363
|
+
const p = activeProfile(st)
|
|
364
|
+
if (p && !p.protected) head.push({ text: t.warn(' ' + i.noPasswordWarn), sel: false })
|
|
365
|
+
if (head.length) head.push({ text: '', sel: false })
|
|
366
|
+
if (!names.length) return [...head, { text: t.muted(i.noScopes), sel: false }, ...footer]
|
|
367
|
+
rows.push(...head)
|
|
320
368
|
for (const n of names) {
|
|
321
369
|
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:
|
|
370
|
+
for (const k of sortByKey(ns[n])) {
|
|
371
|
+
rows.push({ text: varLine(k, t, i), sel: true, meta: { ns: n, key: k.key, public: k.public } })
|
|
324
372
|
}
|
|
325
373
|
}
|
|
374
|
+
return [...rows, ...footer]
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Las claves guardadas para UN aparato (`pub`), o `[]`. Cada una es `{key, public}`. */
|
|
378
|
+
const devVarsOf = (st, pub) => (st.secrets?.dev || []).find((x) => x.pub === pub)?.keys || []
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Lo que ese aparato NO puede abrir (§8.11). Un servicio que entra después de escrita
|
|
382
|
+
* una variable no tiene envoltura de ella, y hasta que alguien se la reparta está en el
|
|
383
|
+
* acta sin poder arrancar del todo. Aquí se cuenta cuántas, que es lo que cabe en una
|
|
384
|
+
* fila; el detalle está en la consola.
|
|
385
|
+
*/
|
|
386
|
+
/** El ID corto de un aparato a partir de su llave, tal como lo enseña la lista de Aparatos. */
|
|
387
|
+
const deviceIdOf = (st, pub) =>
|
|
388
|
+
(st.devices?.issued || []).find((d) => d.sub === pub)?.deviceId || (st.members || []).find((m) => m.pub === pub)?.id || pub.slice(0, 8)
|
|
389
|
+
|
|
390
|
+
const debtOf = (st, pub) => {
|
|
391
|
+
const d = (st.secrets?.incomplete || []).find((x) => x.pub === pub)
|
|
392
|
+
return d ? [...new Set(Object.values(d.owners || {}).flat())].length : 0
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
const sortByKey = (list) => (list || []).slice().sort((a, b) => a.key.localeCompare(b.key))
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Una variable: su nombre y su valor. La PÚBLICA enseña el suyo —pública significa que ese
|
|
399
|
+
* valor puede salir de esta máquina, así que taparlo delante de su dueño, en la máquina
|
|
400
|
+
* donde vive, era lo único que la marca no quería decir— con el aviso de que viaja cuando
|
|
401
|
+
* la consola remota lo pide. La privada sigue tapada: no sale ni a esta pantalla.
|
|
402
|
+
*/
|
|
403
|
+
const varLine = (v, t, i) => ` ${v.key} ` +
|
|
404
|
+
(v.public ? `${short(v.value)} ${t.warn(i.varPublic)}` : t.muted('••••••'))
|
|
405
|
+
|
|
406
|
+
/** Un valor largo no puede empujar la marca «pública» fuera de la pantalla. */
|
|
407
|
+
const short = (s) => {
|
|
408
|
+
const v = String(s ?? '')
|
|
409
|
+
return v.length > 40 ? v.slice(0, 39) + '…' : v
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Las variables de UN aparato. Se entra desde Dispositivos con `e`, ya con el aparato
|
|
414
|
+
* elegido: por eso aquí no se vuelve a elegir, solo se agrega y se quita.
|
|
415
|
+
*/
|
|
416
|
+
function devVarRows (st, t) {
|
|
417
|
+
const i = L(st)
|
|
418
|
+
const target = st.varsFor
|
|
419
|
+
if (!target) return [{ text: t.muted(i.loading), sel: false }]
|
|
420
|
+
const keys = sortByKey(devVarsOf(st, target.pub))
|
|
421
|
+
const rows = [
|
|
422
|
+
{ text: ' ' + t.bold(i.devVarsFor(target.deviceId, target.label || '')), sel: false },
|
|
423
|
+
// Dato, no explicación: qué servicio es este aparato es lo que decide qué namespace
|
|
424
|
+
// lee, y por lo tanto a qué variables del scope le ganan estas.
|
|
425
|
+
{ text: t.muted(' ' + i.devVarsService(target.cn)), sel: false },
|
|
426
|
+
{ text: '', sel: false }
|
|
427
|
+
]
|
|
428
|
+
if (!keys.length) rows.push({ text: t.muted(' ' + i.noDevVars), sel: false })
|
|
429
|
+
for (const k of keys) rows.push({ text: varLine(k, t, i), sel: true, meta: { key: k.key, public: k.public } })
|
|
326
430
|
return rows
|
|
327
431
|
}
|
|
328
432
|
|
|
@@ -341,27 +445,27 @@ function meRows (st, t) {
|
|
|
341
445
|
if (!me) return [{ text: t.muted(i.noProfile), sel: false }, { text: '', sel: false }, { text: t.muted(i.noProfileHint), sel: false }]
|
|
342
446
|
|
|
343
447
|
const rows = []
|
|
344
|
-
const
|
|
345
|
-
text: ` ${t.muted(String(
|
|
448
|
+
const field = (label, value, hidden) => rows.push({
|
|
449
|
+
text: ` ${t.muted(String(label).padEnd(12))} ${value}${hidden ? t.muted(i.hidden) : ''}`, sel: false
|
|
346
450
|
})
|
|
347
451
|
rows.push({ text: t.muted(i.profileUpdated(me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—')), sel: false })
|
|
348
452
|
rows.push({ text: '', sel: false })
|
|
349
|
-
|
|
350
|
-
|
|
453
|
+
field(i.fieldName, me.nickname ? t.bold(me.nickname) : t.muted(i.noName))
|
|
454
|
+
field(i.fieldPhoto, me.avatar
|
|
351
455
|
? `${me.avatar.type || '?'} · ${(me.avatar.bytes / 1024).toFixed(1)} KB`
|
|
352
456
|
: t.muted(i.no))
|
|
353
457
|
|
|
354
458
|
const STD = [['nombres', i.fieldFirstName], ['apellidos', i.fieldLastName], ['email', i.fieldEmail],
|
|
355
459
|
['telefono', i.fieldPhone], ['direccion', i.fieldAddress]]
|
|
356
|
-
const
|
|
357
|
-
if (
|
|
358
|
-
for (const [k,
|
|
460
|
+
const filled = STD.filter(([k]) => me[k])
|
|
461
|
+
if (filled.length) rows.push({ text: '', sel: false })
|
|
462
|
+
for (const [k, label] of filled) field(label, me[k], me[k + 'Visible'] === false)
|
|
359
463
|
|
|
360
|
-
for (const [
|
|
361
|
-
if (!Array.isArray(
|
|
464
|
+
for (const [title, list] of [[i.links, me.links], [i.otherData, me.fields]]) {
|
|
465
|
+
if (!Array.isArray(list) || !list.length) continue
|
|
362
466
|
rows.push({ text: '', sel: false })
|
|
363
|
-
rows.push({ text: t.accent(' ▸ ' +
|
|
364
|
-
for (const x of
|
|
467
|
+
rows.push({ text: t.accent(' ▸ ' + title), sel: false })
|
|
468
|
+
for (const x of list) field(x.type || x.label || '', x.value, x.visible === false)
|
|
365
469
|
}
|
|
366
470
|
return rows
|
|
367
471
|
}
|
|
@@ -381,28 +485,60 @@ async function guard (term, st, msg, fn) {
|
|
|
381
485
|
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
486
|
}
|
|
383
487
|
|
|
384
|
-
|
|
385
|
-
|
|
488
|
+
/**
|
|
489
|
+
* ¿La bóveda ACTIVA está cerrada? El candado es POR BÓVEDA, no del vault: que una esté
|
|
490
|
+
* cerrada no puede dejarte sin la lista ni sin poder entrar a otra.
|
|
491
|
+
*/
|
|
492
|
+
const activeLocked = (st) => { const p = activeProfile(st); return !!(p?.protected && p.locked) }
|
|
493
|
+
|
|
494
|
+
// `api` es `vaultControl` — se recibe para poder probar ESTA función (la que se rompió)
|
|
495
|
+
// sin un daemon detrás, que es donde vive la regla de qué se pide y en qué orden.
|
|
496
|
+
async function refreshAll (term, st, api = vc) {
|
|
497
|
+
// LA LISTA DE BÓVEDAS VA PRIMERO Y APARTE. Antes esto pedía el volcado de la bóveda
|
|
498
|
+
// activa y, si esa era la cerrada, se salía sin llegar a guardar la lista: la TUI abría
|
|
499
|
+
// en blanco, sin bóvedas y con un error rojo. O sea que la contraseña de UNA bóveda te
|
|
500
|
+
// dejaba fuera del vault entero, que es exactamente lo que un candado por perfil no debe
|
|
501
|
+
// hacer.
|
|
502
|
+
const p = await guard(term, st, L(st).loadingVaults, () => api.listProfiles())
|
|
503
|
+
if (p.ok) st.profiles = p.v
|
|
504
|
+
// Cerrada: no se pide su contenido, y tampoco se enseña un error por mirarla desde
|
|
505
|
+
// fuera. Lo que hubiera cargado se suelta, para no dejar en pantalla lo de antes.
|
|
506
|
+
if (activeLocked(st)) {
|
|
507
|
+
st.devices = null; st.secrets = null; st.members = []; st.me = undefined
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
const r = await guard(term, st, L(st).loading, () => api.snapshot(activeId(st)))
|
|
386
511
|
if (!r.ok) return
|
|
387
|
-
const { devices, secrets, profiles,
|
|
512
|
+
const { devices, secrets, profiles, record } = r.v
|
|
388
513
|
if (profiles) st.profiles = profiles
|
|
389
|
-
|
|
514
|
+
// Los DOS cajones de variables viajan juntos: `ns` (por scope) y `dev` (por aparato).
|
|
515
|
+
if (secrets) st.secrets = { ns: secrets.ns || {}, dev: Array.isArray(secrets.dev) ? secrets.dev : [] }
|
|
390
516
|
// El ACTA entra en el volcado normal: es de donde sale la lista de dispositivos (ver
|
|
391
517
|
// `mergeMembersAndCerts`). Antes solo se pedía al abrir la pantalla de permisos.
|
|
392
|
-
if (
|
|
518
|
+
if (record) st.members = record.members || []
|
|
393
519
|
if (devices) {
|
|
394
520
|
const issued = (devices.issued || devices.active || devices.delegations || [])
|
|
395
|
-
st.devices = { issued: await Promise.all(issued.map(async (d) => ({ ...d, deviceId: d.sub ? await
|
|
521
|
+
st.devices = { issued: await Promise.all(issued.map(async (d) => ({ ...d, deviceId: d.sub ? await api.deviceIdOf(d.sub) : '????-????' }))), revoked: devices.revoked || [] }
|
|
396
522
|
}
|
|
397
523
|
}
|
|
398
524
|
|
|
525
|
+
/**
|
|
526
|
+
* Guarda un volcado de dispositivos EN LOS DOS SITIOS.
|
|
527
|
+
*
|
|
528
|
+
* La lista se pinta desde el ACTA (`st.members`) con los certificados pegados
|
|
529
|
+
* (`st.devices`), así que quedarse solo con la mitad deja la pantalla mintiendo: aprobar
|
|
530
|
+
* un aparato no lo hacía aparecer y quitarlo no lo hacía desaparecer, hasta que algo
|
|
531
|
+
* volviera a pedir el acta. Peor todavía en una lista: el aparato recién entrado no salía,
|
|
532
|
+
* así que la fila «la última» era otra y quitarla se llevaba por delante a quien no era.
|
|
533
|
+
*/
|
|
534
|
+
function applyDump (st, v) {
|
|
535
|
+
st.devices = v
|
|
536
|
+
if (Array.isArray(v?.members)) st.members = v.members
|
|
537
|
+
}
|
|
538
|
+
|
|
399
539
|
async function refreshDevices (term, st) {
|
|
400
540
|
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
|
|
541
|
+
if (r.ok) applyDump(st, r.v)
|
|
406
542
|
}
|
|
407
543
|
async function refreshSecrets (term, st) {
|
|
408
544
|
const r = await guard(term, st, L(st).loadingSecrets, () => vc.listSecrets(activeId(st)))
|
|
@@ -416,24 +552,66 @@ async function refreshMe (term, st) {
|
|
|
416
552
|
const r = await guard(term, st, L(st).loadingProfile, () => vc.getMe(activeId(st)))
|
|
417
553
|
st.me = r.ok ? r.v : null
|
|
418
554
|
}
|
|
419
|
-
async function refreshProfiles (term, st) {
|
|
420
|
-
const r = await guard(term, st, L(st).loadingVaults, () =>
|
|
555
|
+
async function refreshProfiles (term, st, api = vc) {
|
|
556
|
+
const r = await guard(term, st, L(st).loadingVaults, () => api.listProfiles())
|
|
421
557
|
if (r.ok) st.profiles = r.v
|
|
422
558
|
}
|
|
423
559
|
|
|
424
|
-
/**
|
|
425
|
-
|
|
560
|
+
/**
|
|
561
|
+
* TECLEADA UNA VEZ, VALE PARA TODA LA SESIÓN de la TUI.
|
|
562
|
+
*
|
|
563
|
+
* El candado vive en la MEMORIA DEL DAEMON, así que cualquier cosa que se lleve ese
|
|
564
|
+
* estado —un `systemctl restart` al actualizar, un reinicio del servicio, una petición
|
|
565
|
+
* que se perdió— dejaba la bóveda cerrada otra vez EN MITAD de la sesión, y la TUI
|
|
566
|
+
* volvía a pedir la contraseña como si nunca se hubiera tecleado.
|
|
567
|
+
*
|
|
568
|
+
* Por eso la contraseña de lo que se abre aquí se guarda en `st.sessionPwd` (SOLO en
|
|
569
|
+
* memoria de este proceso) y se vuelve a usar en silencio para reabrir la misma bóveda.
|
|
570
|
+
* Se olvida con el candado (`k`), al quitar la contraseña y al salir de la TUI, que es
|
|
571
|
+
* exactamente donde el dueño dijo que tiene que volver a hacer falta.
|
|
572
|
+
*/
|
|
573
|
+
// `api` es `vaultControl` — se recibe para poder probar esto sin un daemon detrás.
|
|
574
|
+
async function reunlockSilently (term, st, p, api = vc) {
|
|
575
|
+
const pwd = st.sessionPwd?.get(p.id)
|
|
576
|
+
if (!pwd) return false
|
|
577
|
+
const r = await guard(term, st, L(st).unlocking, () => api.unlockProfile(p.id, pwd))
|
|
578
|
+
// Ya no vale (se la cambiaron desde otro sitio, o el freno está esperando): se olvida
|
|
579
|
+
// y se vuelve al camino normal, que es preguntar diciendo por qué.
|
|
580
|
+
if (!r.ok) { st.sessionPwd.delete(p.id); return false }
|
|
581
|
+
await refreshProfiles(term, st, api)
|
|
582
|
+
return true
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
/**
|
|
586
|
+
* Pide la contraseña si hace falta y sigue. Lo que se abre aquí queda anotado en
|
|
587
|
+
* `st.unlockedHere` para volver a cerrarlo AL SALIR (ver `runTui`): la contraseña dura lo
|
|
588
|
+
* que dura la sesión, no hasta que alguien reinicie el servicio. Lo que ya estaba abierto
|
|
589
|
+
* antes de entrar no se toca — no lo abrió esta pantalla, no le toca cerrarlo.
|
|
590
|
+
*/
|
|
591
|
+
async function ensureUnlocked (term, st, p, thenFn, reason = null, api = vc) {
|
|
426
592
|
if (!p.protected || !p.locked) return thenFn()
|
|
593
|
+
// Cerrada, pero la contraseña ya se tecleó en esta sesión: se reabre sin molestar.
|
|
594
|
+
if (await reunlockSilently(term, st, p, api)) {
|
|
595
|
+
const fresh = (st.profiles?.profiles || []).find((x) => x.id === p.id) || p
|
|
596
|
+
return thenFn(fresh)
|
|
597
|
+
}
|
|
427
598
|
const i = L(st)
|
|
428
599
|
setInput(st, {
|
|
429
600
|
label: i.passwordOf(p.name || p.id),
|
|
430
601
|
mask: true,
|
|
431
|
-
|
|
602
|
+
// Si la anterior fue rechazada, el motivo se queda AQUÍ, pegado al campo, en vez de
|
|
603
|
+
// irse en un aviso de cuatro segundos que se lleva el siguiente redibujado. Eso era lo
|
|
604
|
+
// que hacía que un rechazo pareciera «me la vuelve a pedir porque sí».
|
|
605
|
+
hint: reason || i.passwordToEdit,
|
|
432
606
|
onSubmit: async (pwd) => {
|
|
433
607
|
st.input = null
|
|
434
|
-
const r = await guard(term, st, i.unlocking, () =>
|
|
435
|
-
|
|
436
|
-
|
|
608
|
+
const r = await guard(term, st, i.unlocking, () => api.unlockProfile(p.id, pwd))
|
|
609
|
+
// Rechazada: se vuelve a pedir en el acto, diciendo por qué. Cerrar el campo obligaba
|
|
610
|
+
// a adivinar qué había pasado y a empezar de nuevo.
|
|
611
|
+
if (!r.ok) return ensureUnlocked(term, st, p, thenFn, humanErr(r.e, st), api)
|
|
612
|
+
st.unlockedHere?.add(p.id)
|
|
613
|
+
st.sessionPwd?.set(p.id, pwd) // vale para toda la sesión (ver reunlockSilently)
|
|
614
|
+
await refreshProfiles(term, st, api)
|
|
437
615
|
const fresh = (st.profiles.profiles || []).find((x) => x.id === p.id) || p
|
|
438
616
|
await thenFn(fresh)
|
|
439
617
|
},
|
|
@@ -445,7 +623,7 @@ async function ensureUnlocked (term, st, p, thenFn) {
|
|
|
445
623
|
|
|
446
624
|
function moveSel (st, key, screen, count) {
|
|
447
625
|
if (count <= 0) { st.sel[screen] = 0; return }
|
|
448
|
-
// Clampa el índice guardado ANTES de
|
|
626
|
+
// Clampa el índice guardado ANTES de apply el delta: si la lista encogió, la
|
|
449
627
|
// primera flecha debe moverse desde la posición visible, no desde un índice viejo.
|
|
450
628
|
st.sel[screen] = Math.max(0, Math.min(st.sel[screen], count - 1))
|
|
451
629
|
if (key.name === 'up') st.sel[screen] = Math.max(0, st.sel[screen] - 1)
|
|
@@ -474,27 +652,38 @@ async function onKeyProfiles (term, st, key) {
|
|
|
474
652
|
if (key.name === 'enter' && cur) {
|
|
475
653
|
// Entrar a la bóveda: la activa (si no lo estaba ya) y pasa a sus pestañas
|
|
476
654
|
// (Dispositivos/Scopes) — así siempre es explícito de qué bóveda son los ítems.
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
655
|
+
//
|
|
656
|
+
// Y si tiene candado, se pide la contraseña AQUÍ, antes de enseñar nada: dentro se ven
|
|
657
|
+
// los aparatos, las variables y tus datos, que es justo lo que la contraseña tapa.
|
|
658
|
+
await ensureUnlocked(term, st, cur, async (p = cur) => {
|
|
659
|
+
if (!p.current) {
|
|
660
|
+
const r = await guard(term, st, i.switchingVault, () => vc.useProfile(p.id))
|
|
661
|
+
if (!r.ok) return
|
|
662
|
+
flash(st, i.vaultNowActive(p.name || p.id))
|
|
663
|
+
}
|
|
664
|
+
// ENTRAR RECARGA TODO, SIEMPRE. Antes solo se recargaba al CAMBIAR de bóveda, y lo
|
|
665
|
+
// que traía era `refreshDevices`: aparatos y acta, no las variables. Así que entrar a
|
|
666
|
+
// la bóveda que ya estaba activa —el caso normal cuando estaba cerrada y acabas de
|
|
667
|
+
// teclear la contraseña, que es cuando la memoria está vacía a propósito— dejaba
|
|
668
|
+
// Scopes en blanco hasta que alguien pulsara F5. Un volcado trae las tres cosas.
|
|
481
669
|
await refreshAll(term, st)
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
await refreshDevices(term, st)
|
|
670
|
+
st.screen = 'devices'
|
|
671
|
+
})
|
|
485
672
|
} else if (ch === 'p' && cur) {
|
|
486
673
|
// Emparejar SIN tener que entrar antes: `p` significa lo mismo aquí que en la
|
|
487
674
|
// pestaña Dispositivos. Se activa la bóveda elegida (el QR sale de UNA, y las
|
|
488
675
|
// acciones siguientes —aprobar, revocar— miran a la activa) y se abre la
|
|
489
676
|
// pregunta de a qué cuenta entra el dispositivo.
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
677
|
+
await ensureUnlocked(term, st, cur, async (p = cur) => {
|
|
678
|
+
if (!p.current) {
|
|
679
|
+
const r = await guard(term, st, i.switchingVault, () => vc.useProfile(p.id))
|
|
680
|
+
if (!r.ok) return
|
|
681
|
+
await refreshAll(term, st)
|
|
682
|
+
}
|
|
683
|
+
st.sel.pairmode = 0
|
|
684
|
+
st.scroll.pairmode = { value: 0 }
|
|
685
|
+
st.screen = 'pairmode'
|
|
686
|
+
})
|
|
498
687
|
} else if (ch === 'n') {
|
|
499
688
|
setInput(st, {
|
|
500
689
|
label: i.newVaultLabel,
|
|
@@ -545,8 +734,10 @@ async function onKeyProfiles (term, st, key) {
|
|
|
545
734
|
onSubmit: async (again) => {
|
|
546
735
|
st.input = null
|
|
547
736
|
if (again !== pwd) { flash(st, i.passwordMismatch, 'danger'); return }
|
|
548
|
-
const r = await guard(term, st, i.savingPassword, () => vc.setProfilePassword(p.id, pwd))
|
|
549
|
-
|
|
737
|
+
const r = await guard(term, st, i.savingPassword, () => vc.setProfilePassword(p.id, pwd, st.sessionPwd?.get(p.id)))
|
|
738
|
+
// La nueva es la que vale para el resto de la sesión: guardar la vieja dejaría
|
|
739
|
+
// a la TUI reabriendo con una contraseña que ya no existe.
|
|
740
|
+
if (r.ok) { st.sessionPwd?.set(p.id, pwd); flash(st, i.passwordSaved); await refreshProfiles(term, st) }
|
|
550
741
|
},
|
|
551
742
|
onCancel: () => { st.input = null }
|
|
552
743
|
})
|
|
@@ -556,8 +747,8 @@ async function onKeyProfiles (term, st, key) {
|
|
|
556
747
|
} else if (ch === 'x' && cur) { // quitar contraseña
|
|
557
748
|
if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
|
|
558
749
|
await ensureUnlocked(term, st, cur, async (p = cur) => {
|
|
559
|
-
const r = await guard(term, st, i.removingPassword, () => vc.removeProfilePassword(p.id))
|
|
560
|
-
if (r.ok) { flash(st, i.passwordRemoved); await refreshProfiles(term, st) }
|
|
750
|
+
const r = await guard(term, st, i.removingPassword, () => vc.removeProfilePassword(p.id, st.sessionPwd?.get(p.id)))
|
|
751
|
+
if (r.ok) { st.sessionPwd?.delete(p.id); flash(st, i.passwordRemoved); await refreshProfiles(term, st) }
|
|
561
752
|
})
|
|
562
753
|
} else if (ch === 'u' && cur) {
|
|
563
754
|
if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
|
|
@@ -566,7 +757,10 @@ async function onKeyProfiles (term, st, key) {
|
|
|
566
757
|
} else if (ch === 'k' && cur) { // locK (antes `l`, que ahora es el idioma)
|
|
567
758
|
if (!cur.protected) { flash(st, i.noPasswordSet, 'warn'); return true }
|
|
568
759
|
const r = await guard(term, st, i.lockingVault, () => vc.lockProfile(cur.id))
|
|
569
|
-
|
|
760
|
+
// Echar el candado a mano es DECIR que vuelva a hacer falta la contraseña: si la TUI
|
|
761
|
+
// se quedara con ella, la siguiente tecla la reabriría sola y el candado no cerraría
|
|
762
|
+
// nada.
|
|
763
|
+
if (r.ok) { st.sessionPwd?.delete(cur.id); st.unlockedHere?.delete(cur.id); flash(st, i.vaultLocked); await refreshProfiles(term, st) }
|
|
570
764
|
}
|
|
571
765
|
return true
|
|
572
766
|
}
|
|
@@ -605,7 +799,7 @@ async function onKeyDevices (term, st, key) {
|
|
|
605
799
|
st.confirm = null
|
|
606
800
|
// Por `sub`: se le retiran TODOS los certificados, no solo el de esta fila.
|
|
607
801
|
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
|
|
802
|
+
if (r.ok) { flash(st, i.deviceRevoked(cur.deviceId)); applyDump(st, r.v); st.sel.devices = 0 }
|
|
609
803
|
},
|
|
610
804
|
onNo: () => { st.confirm = null }
|
|
611
805
|
})
|
|
@@ -617,11 +811,11 @@ async function onKeyDevices (term, st, key) {
|
|
|
617
811
|
label: i.renameDeviceLabel(cur.deviceId),
|
|
618
812
|
hint: i.renameDeviceHint,
|
|
619
813
|
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
|
|
814
|
+
onSubmit: async (raw) => {
|
|
815
|
+
const name = String(raw || '').trim()
|
|
816
|
+
if (!name) return
|
|
817
|
+
const r = await guard(term, st, i.renaming, () => vc.setDeviceLabel(cur.sub, name, activeId(st)))
|
|
818
|
+
if (r.ok) { applyDump(st, r.v); flash(st, i.deviceRenamed(name)) }
|
|
625
819
|
}
|
|
626
820
|
})
|
|
627
821
|
} else if (ch === 'c' && cur?.sub) {
|
|
@@ -629,16 +823,44 @@ async function onKeyDevices (term, st, key) {
|
|
|
629
823
|
st.sel.caps = 0
|
|
630
824
|
await refreshMembers(term, st)
|
|
631
825
|
st.screen = 'caps'
|
|
826
|
+
} else if (ch === 'e' && cur?.sub) {
|
|
827
|
+
// Variables de ESTE aparato. Solo un servicio las lee (es el único que pide su
|
|
828
|
+
// bundle), así que a un teléfono se le dice que no y por qué, en vez de dejarle
|
|
829
|
+
// guardar configuración que no va a leer nadie.
|
|
830
|
+
if (!cur.cn) { flash(st, i.devVarsOnlyServices, 'warn'); return true }
|
|
831
|
+
st.varsFor = { pub: cur.sub, deviceId: cur.deviceId, label: cur.label || '', cn: cur.cn }
|
|
832
|
+
st.sel.devvars = 0
|
|
833
|
+
await refreshSecrets(term, st)
|
|
834
|
+
st.screen = 'devvars'
|
|
632
835
|
} else if (key.name === 'f5') {
|
|
633
836
|
await refreshDevices(term, st)
|
|
634
837
|
}
|
|
635
838
|
return true
|
|
636
839
|
}
|
|
637
840
|
|
|
638
|
-
/**
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
841
|
+
/**
|
|
842
|
+
* Tira la cuenta que NACIÓ para un emparejamiento que no llegó a término y vuelve a la que
|
|
843
|
+
* estabas usando. Es una cuenta recién creada y vacía —el aparato nunca entró—, así que no
|
|
844
|
+
* hay nada dentro que perder; lo que sí molesta es que se queden acumulando.
|
|
845
|
+
*/
|
|
846
|
+
async function descartarCuenta (term, st, id, volverA) {
|
|
847
|
+
const i = L(st)
|
|
848
|
+
const r = await guard(term, st, i.discardingAccount, () => vc.removeProfile(id))
|
|
849
|
+
if (volverA) await guard(term, st, i.switchingVault, () => vc.useProfile(volverA))
|
|
850
|
+
await refreshAll(term, st)
|
|
851
|
+
if (r.ok) flash(st, i.accountDiscarded)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/**
|
|
855
|
+
* Abre el emparejamiento contra `profile` y salta a la pantalla del QR. Con `service`,
|
|
856
|
+
* el QR es el de un SERVICIO de ese namespace (cert limitado a sus variables).
|
|
857
|
+
*/
|
|
858
|
+
async function beginPairing (term, st, profile, service = null) {
|
|
859
|
+
const r = await guard(term, st, L(st).startingPairing, () => vc.startPairing({ profile, ...(service ? { service } : {}) }))
|
|
860
|
+
// `service` se pega al estado porque el daemon no lo devuelve: la pantalla del QR
|
|
861
|
+
// tiene que poder decir qué se está entregando, que no es lo mismo un aparato tuyo
|
|
862
|
+
// que una máquina que solo va a leer la configuración del proxy.
|
|
863
|
+
if (r.ok) { st.pairing = { ...r.v, ...(service ? { service } : {}) }; st.pending = null; st.scroll.pairing = { value: 0 }; st.screen = 'pairing' }
|
|
642
864
|
return r.ok
|
|
643
865
|
}
|
|
644
866
|
|
|
@@ -659,27 +881,27 @@ async function onKeyCaps (term, st, key) {
|
|
|
659
881
|
if (key.name === 'f5') { await refreshMembers(term, st); return true }
|
|
660
882
|
if ((key.name !== 'enter' && ch !== ' ') || !cur) return true
|
|
661
883
|
|
|
662
|
-
const
|
|
663
|
-
if (!
|
|
664
|
-
const caps = new Set(
|
|
665
|
-
const
|
|
666
|
-
if (
|
|
884
|
+
const member = (st.members || []).find((m) => m.pub === st.capsFor?.pub)
|
|
885
|
+
if (!member) return true
|
|
886
|
+
const caps = new Set(member.caps || [])
|
|
887
|
+
const giving = !caps.has(cur.cap)
|
|
888
|
+
if (giving) caps.add(cur.cap); else caps.delete(cur.cap)
|
|
667
889
|
|
|
668
|
-
const
|
|
669
|
-
const r = await guard(term, st, i.applyingCaps, () => vc.setDeviceCaps(
|
|
890
|
+
const apply = async () => {
|
|
891
|
+
const r = await guard(term, st, i.applyingCaps, () => vc.setDeviceCaps(member.pub, [...caps], activeId(st)))
|
|
670
892
|
if (!r.ok) return
|
|
671
|
-
st
|
|
893
|
+
applyDump(st, r.v)
|
|
672
894
|
await refreshMembers(term, st)
|
|
673
|
-
flash(st,
|
|
895
|
+
flash(st, giving ? i.capGiven(i.capName[cur.cap]) : i.capTaken(i.capName[cur.cap]))
|
|
674
896
|
}
|
|
675
897
|
|
|
676
898
|
// Administrar se PREGUNTA: es el permiso que deja a ese aparato admitir y expulsar
|
|
677
899
|
// 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:
|
|
900
|
+
if (cur.cap === 'admin' && giving) {
|
|
901
|
+
setConfirm(st, { text: i.confirmAdmin(st.capsFor.deviceId), onYes: apply })
|
|
680
902
|
return true
|
|
681
903
|
}
|
|
682
|
-
await
|
|
904
|
+
await apply()
|
|
683
905
|
return true
|
|
684
906
|
}
|
|
685
907
|
|
|
@@ -696,24 +918,48 @@ async function onKeyPairMode (term, st, key) {
|
|
|
696
918
|
|
|
697
919
|
if (cur.mode === 'here') { await beginPairing(term, st, activeId(st)); return true }
|
|
698
920
|
|
|
921
|
+
// SERVICIO: entra a la cuenta activa, pero con un certificado que solo sirve para
|
|
922
|
+
// pedir las variables de SU namespace. El nombre del ns es el que luego pide el
|
|
923
|
+
// servicio al arrancar, así que se valida aquí con la misma regla que la CLI: un
|
|
924
|
+
// ns con mayúsculas o espacios se enrola igual y falla el día del despliegue.
|
|
925
|
+
if (cur.mode === 'service') {
|
|
926
|
+
setInput(st, {
|
|
927
|
+
label: i.serviceNsLabel,
|
|
928
|
+
hint: i.serviceNsHint,
|
|
929
|
+
onSubmit: async (raw) => {
|
|
930
|
+
st.input = null
|
|
931
|
+
const ns = String(raw || '').trim().toLowerCase()
|
|
932
|
+
if (!/^[a-z0-9-]{1,32}$/.test(ns)) { flash(st, i.serviceNsBad, 'danger'); return }
|
|
933
|
+
await beginPairing(term, st, activeId(st), ns)
|
|
934
|
+
},
|
|
935
|
+
onCancel: () => { st.input = null }
|
|
936
|
+
})
|
|
937
|
+
return true
|
|
938
|
+
}
|
|
939
|
+
|
|
699
940
|
// Cuenta nueva: se crea aquí, se ACTIVA (así aprobar/rechazar y las listas miran
|
|
700
941
|
// a la misma que el QR) y recién entonces se abre el emparejamiento contra ella.
|
|
701
942
|
setInput(st, {
|
|
702
943
|
label: i.newAccountLabel,
|
|
703
944
|
hint: i.newAccountHint,
|
|
704
|
-
onSubmit: async (
|
|
945
|
+
onSubmit: async (raw) => {
|
|
705
946
|
st.input = null
|
|
706
|
-
const
|
|
707
|
-
if (!
|
|
708
|
-
const
|
|
947
|
+
const name = raw.trim()
|
|
948
|
+
if (!name) { flash(st, i.nameEmpty, 'danger'); return }
|
|
949
|
+
const previa = activeId(st)
|
|
950
|
+
const r = await guard(term, st, i.creatingVault, () => vc.addProfile(name))
|
|
709
951
|
if (!r.ok) return
|
|
710
|
-
const
|
|
711
|
-
if (!
|
|
712
|
-
const u = await guard(term, st, i.switchingVault, () => vc.useProfile(
|
|
952
|
+
const created = r.v?.id || (r.v?.profiles || []).find((p) => p.name === name)?.id
|
|
953
|
+
if (!created) { flash(st, i.errNoReply, 'danger'); return }
|
|
954
|
+
const u = await guard(term, st, i.switchingVault, () => vc.useProfile(created))
|
|
713
955
|
if (!u.ok) return
|
|
714
956
|
await refreshAll(term, st)
|
|
715
|
-
flash(st, i.accountCreated(
|
|
716
|
-
|
|
957
|
+
flash(st, i.accountCreated(name))
|
|
958
|
+
// Si el emparejamiento no llega a abrirse, la cuenta que se creó PARA él se va con
|
|
959
|
+
// él: si no, cada intento fallido dejaba una cuenta vacía —y encima activa— que
|
|
960
|
+
// luego había que ir a borrar a mano adivinando cuál era.
|
|
961
|
+
if (!await beginPairing(term, st, created)) await descartarCuenta(term, st, created, previa)
|
|
962
|
+
else st.pairing.born = { id: created, from: previa }
|
|
717
963
|
},
|
|
718
964
|
onCancel: () => { st.input = null }
|
|
719
965
|
})
|
|
@@ -729,7 +975,18 @@ function promptApprove (term, st) {
|
|
|
729
975
|
st.input = null
|
|
730
976
|
if (!code.trim()) { flash(st, i.codeMissing, 'danger'); return }
|
|
731
977
|
const r = await guard(term, st, i.approving, () => vc.approvePending(code.trim(), activeId(st)))
|
|
732
|
-
if (r.ok) {
|
|
978
|
+
if (r.ok) {
|
|
979
|
+
flash(st, i.deviceApproved)
|
|
980
|
+
st.pending = null
|
|
981
|
+
// El aparato entró: la cuenta que se creó para esto ya es de alguien, así que deja
|
|
982
|
+
// de estar en la lista de las que se descartan al salir.
|
|
983
|
+
st.pairing = null
|
|
984
|
+
st.screen = 'devices'
|
|
985
|
+
// Sin lista (el volcado se perdió): se pide otra vez. El aparato ya está dentro;
|
|
986
|
+
// lo único que falta es la foto, y esa se vuelve a pedir sin drama.
|
|
987
|
+
if (r.v) applyDump(st, r.v)
|
|
988
|
+
else await refreshDevices(term, st)
|
|
989
|
+
}
|
|
733
990
|
},
|
|
734
991
|
onCancel: () => { st.input = null }
|
|
735
992
|
})
|
|
@@ -765,11 +1022,26 @@ async function onKeyPairing (term, st, key) {
|
|
|
765
1022
|
return true
|
|
766
1023
|
}
|
|
767
1024
|
if (ch === 'r') { // restart: reiniciar el emparejamiento
|
|
1025
|
+
const born = st.pairing?.born
|
|
768
1026
|
const r = await guard(term, st, i.restartingPairing, () => vc.startPairing({ profile: activeId(st) }))
|
|
769
|
-
if (r.ok) { st.pairing = r.v; st.pending = null; st.scroll.pairing = { value: 0 } }
|
|
1027
|
+
if (r.ok) { st.pairing = { ...r.v, ...(born ? { born } : {}) }; st.pending = null; st.scroll.pairing = { value: 0 } }
|
|
770
1028
|
return true
|
|
771
1029
|
}
|
|
772
|
-
if (key.name === 'escape' || ch === 'b') {
|
|
1030
|
+
if (key.name === 'escape' || ch === 'b') {
|
|
1031
|
+
// Te vas sin que nadie haya entrado, y la cuenta se creó PARA esto: se pregunta antes
|
|
1032
|
+
// de dejarla ahí. Es la otra mitad del mismo descuido — con «cuenta nueva» era fácil
|
|
1033
|
+
// acabar con tres cuentas vacías y ninguna forma de saber cuál era cuál.
|
|
1034
|
+
const born = st.pairing?.born
|
|
1035
|
+
if (born) {
|
|
1036
|
+
setConfirm(st, {
|
|
1037
|
+
text: i.confirmDiscardAccount,
|
|
1038
|
+
onYes: async () => { st.confirm = null; st.pairing = null; st.screen = 'devices'; await descartarCuenta(term, st, born.id, born.from) },
|
|
1039
|
+
onNo: async () => { st.confirm = null; st.pairing = null; st.screen = 'devices'; await refreshDevices(term, st) }
|
|
1040
|
+
})
|
|
1041
|
+
return true
|
|
1042
|
+
}
|
|
1043
|
+
st.screen = 'devices'; st.pairing = null; await refreshDevices(term, st)
|
|
1044
|
+
}
|
|
773
1045
|
return true
|
|
774
1046
|
}
|
|
775
1047
|
|
|
@@ -792,6 +1064,8 @@ async function onKeySecrets (term, st, key) {
|
|
|
792
1064
|
|
|
793
1065
|
if (ch === 'n') {
|
|
794
1066
|
promptNewVariable(term, st)
|
|
1067
|
+
} else if (ch === 'i') {
|
|
1068
|
+
promptLoadScopeVars(term, st)
|
|
795
1069
|
} else if ((ch === 'x' || key.name === 'delete') && cur) {
|
|
796
1070
|
if (cur.key) {
|
|
797
1071
|
setConfirm(st, {
|
|
@@ -804,7 +1078,7 @@ async function onKeySecrets (term, st, key) {
|
|
|
804
1078
|
onNo: () => { st.confirm = null }
|
|
805
1079
|
})
|
|
806
1080
|
} else {
|
|
807
|
-
const count = (st.secrets?.[cur.ns] || []).length
|
|
1081
|
+
const count = (st.secrets?.ns?.[cur.ns] || []).length
|
|
808
1082
|
setConfirm(st, {
|
|
809
1083
|
text: i.removeScopeConfirm(cur.ns, count),
|
|
810
1084
|
onYes: async () => {
|
|
@@ -815,38 +1089,234 @@ async function onKeySecrets (term, st, key) {
|
|
|
815
1089
|
onNo: () => { st.confirm = null }
|
|
816
1090
|
})
|
|
817
1091
|
}
|
|
1092
|
+
} else if (ch === 'v' && cur?.key) {
|
|
1093
|
+
await revealValue(term, st, `ns:${cur.ns}`, cur.key, cur.public)
|
|
1094
|
+
} else if (ch === 't' && cur?.key) {
|
|
1095
|
+
await toggleVisibility(term, st, cur.public, () => vc.setSecretVisibility(cur.ns, cur.key, !cur.public, activeId(st), activePwd(st)))
|
|
818
1096
|
} else if (key.name === 'f5') {
|
|
819
1097
|
await refreshSecrets(term, st)
|
|
820
1098
|
}
|
|
821
1099
|
return true
|
|
822
1100
|
}
|
|
823
1101
|
|
|
1102
|
+
/**
|
|
1103
|
+
* Hacer pública una variable es dejar que su valor SALGA de esta máquina, así que se
|
|
1104
|
+
* pregunta; volverla privada no expone nada y se aplica directo.
|
|
1105
|
+
*/
|
|
1106
|
+
/**
|
|
1107
|
+
* VER el valor de una variable. Es lo único que la contraseña guarda desde v5, y en la
|
|
1108
|
+
* máquina de la bóveda no hay otro camino: la llave de este aparato vive en este mismo
|
|
1109
|
+
* disco, así que si abriera sin frase una copia del disco abriría todo.
|
|
1110
|
+
*
|
|
1111
|
+
* Si el perfil no tiene contraseña se abre con la llave de la máquina y no se pregunta
|
|
1112
|
+
* nada — pero se dice, para que no parezca una protección que no está puesta.
|
|
1113
|
+
*/
|
|
1114
|
+
async function revealValue (term, st, owner, key, isPublic) {
|
|
1115
|
+
const i = L(st)
|
|
1116
|
+
const p = activeProfile(st)
|
|
1117
|
+
const mostrar = async (pwd) => {
|
|
1118
|
+
const r = await guard(term, st, i.revealing, () => vc.revealSecret(owner, key, activeId(st), pwd))
|
|
1119
|
+
if (r.ok) flash(st, i.revealed(key, r.v), 'ok')
|
|
1120
|
+
}
|
|
1121
|
+
if (isPublic) return mostrar(undefined) // una pública no está cerrada
|
|
1122
|
+
if (!p?.protected) { flash(st, i.revealNoPwd, 'warn'); return mostrar(undefined) }
|
|
1123
|
+
const guardada = activePwd(st)
|
|
1124
|
+
if (guardada) return mostrar(guardada)
|
|
1125
|
+
setInput(st, {
|
|
1126
|
+
label: i.revealAsk,
|
|
1127
|
+
hint: i.revealHint,
|
|
1128
|
+
mask: true,
|
|
1129
|
+
onSubmit: async (pwd) => {
|
|
1130
|
+
st.input = null
|
|
1131
|
+
if (!pwd) return
|
|
1132
|
+
await mostrar(pwd)
|
|
1133
|
+
},
|
|
1134
|
+
onCancel: () => { st.input = null }
|
|
1135
|
+
})
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
async function toggleVisibility (term, st, wasPublic, apply) {
|
|
1139
|
+
const i = L(st)
|
|
1140
|
+
const run = async () => {
|
|
1141
|
+
const r = await guard(term, st, i.changingVisibility, apply)
|
|
1142
|
+
if (r.ok) { flash(st, wasPublic ? i.nowPrivate : i.nowPublic); st.secrets = r.v }
|
|
1143
|
+
}
|
|
1144
|
+
if (wasPublic) return run()
|
|
1145
|
+
setConfirm(st, { text: i.makePublicConfirm, onYes: run, onNo: () => { st.confirm = null } })
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* Teclas de las variables de UN aparato: agregar y quitar. Nada más — el aparato ya se
|
|
1150
|
+
* eligió en Dispositivos, y de ahí se vuelve con Esc.
|
|
1151
|
+
*/
|
|
1152
|
+
async function onKeyDevVars (term, st, key) {
|
|
1153
|
+
const i = L(st)
|
|
1154
|
+
const rows = devVarRows(st, term.t)
|
|
1155
|
+
const sels = rows.filter((r) => r.sel).map((r) => r.meta)
|
|
1156
|
+
moveSel(st, key, 'devvars', sels.length)
|
|
1157
|
+
const cur = sels[Math.min(st.sel.devvars || 0, sels.length - 1)]
|
|
1158
|
+
const ch = key.name === 'char' ? key.ch.toLowerCase() : null
|
|
1159
|
+
const target = st.varsFor
|
|
1160
|
+
|
|
1161
|
+
if (key.name === 'escape' || ch === 'b') {
|
|
1162
|
+
st.screen = 'devices'; st.varsFor = null
|
|
1163
|
+
await refreshDevices(term, st)
|
|
1164
|
+
return true
|
|
1165
|
+
}
|
|
1166
|
+
if (key.name === 'f5') { await refreshSecrets(term, st); return true }
|
|
1167
|
+
if (ch === 'n') { promptNewDeviceVariable(term, st); return true }
|
|
1168
|
+
if (ch === 'i' && target) { promptLoadVars(term, st, { pub: target.pub, where: target.deviceId }); return true }
|
|
1169
|
+
if (ch === 't' && cur && target) {
|
|
1170
|
+
await toggleVisibility(term, st, cur.public, () => vc.setDeviceSecretVisibility(target.pub, cur.key, !cur.public, activeId(st), activePwd(st)))
|
|
1171
|
+
} else if (ch === 'v' && cur?.key) {
|
|
1172
|
+
await revealValue(term, st, `dev:${target.pub}`, cur.key, cur.public)
|
|
1173
|
+
return true
|
|
1174
|
+
}
|
|
1175
|
+
if ((ch === 'x' || key.name === 'delete') && cur && target) {
|
|
1176
|
+
setConfirm(st, {
|
|
1177
|
+
text: i.removeDevVarConfirm(target.deviceId, cur.key),
|
|
1178
|
+
onYes: async () => {
|
|
1179
|
+
st.confirm = null
|
|
1180
|
+
const r = await guard(term, st, i.removingVar, () => vc.deleteDeviceSecret(target.pub, cur.key, activeId(st)))
|
|
1181
|
+
if (r.ok) { flash(st, i.varRemoved); st.secrets = r.v; st.sel.devvars = Math.max(0, st.sel.devvars - 1) }
|
|
1182
|
+
},
|
|
1183
|
+
onNo: () => { st.confirm = null }
|
|
1184
|
+
})
|
|
1185
|
+
}
|
|
1186
|
+
return true
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
/**
|
|
1190
|
+
* Al crear una variable se PREGUNTA si su valor puede salir de esta máquina. Se pregunta
|
|
1191
|
+
* al crearla, y no después, porque es cuando quien la escribe sabe qué es: un puerto se
|
|
1192
|
+
* puede enseñar, una llave de producción no. La respuesta por defecto —Enter, o `n`— es
|
|
1193
|
+
* la privada.
|
|
1194
|
+
*/
|
|
1195
|
+
function askVisibility (term, st, done) {
|
|
1196
|
+
const i = L(st)
|
|
1197
|
+
setConfirm(st, {
|
|
1198
|
+
text: i.newVarPublicAsk,
|
|
1199
|
+
onYes: () => { st.confirm = null; done(true) },
|
|
1200
|
+
onNo: () => { st.confirm = null; done(false) }
|
|
1201
|
+
})
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
function promptNewDeviceVariable (term, st) {
|
|
1205
|
+
const i = L(st)
|
|
1206
|
+
const target = st.varsFor
|
|
1207
|
+
if (!target) return
|
|
1208
|
+
setInput(st, {
|
|
1209
|
+
label: i.keyLabel(target.deviceId),
|
|
1210
|
+
hint: i.keyHint,
|
|
1211
|
+
onSubmit: (key) => {
|
|
1212
|
+
const kv = key.trim()
|
|
1213
|
+
if (!KEY_RE.test(kv)) { flash(st, i.keyInvalid, 'danger'); return }
|
|
1214
|
+
st.input = null
|
|
1215
|
+
setInput(st, {
|
|
1216
|
+
label: i.valueLabel(target.deviceId, kv),
|
|
1217
|
+
mask: true,
|
|
1218
|
+
hint: i.valueHint,
|
|
1219
|
+
onSubmit: async (value) => {
|
|
1220
|
+
st.input = null
|
|
1221
|
+
if (!value) { flash(st, i.valueEmpty, 'danger'); return }
|
|
1222
|
+
askVisibility(term, st, async (isPublic) => {
|
|
1223
|
+
const r = await guard(term, st, i.savingVar, () => vc.setDeviceSecret(target.pub, kv, value, activeId(st), isPublic))
|
|
1224
|
+
if (r.ok) { flash(st, i.varSaved(target.deviceId, kv)); st.secrets = r.v }
|
|
1225
|
+
})
|
|
1226
|
+
},
|
|
1227
|
+
onCancel: () => { st.input = null }
|
|
1228
|
+
})
|
|
1229
|
+
},
|
|
1230
|
+
onCancel: () => { st.input = null }
|
|
1231
|
+
})
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
/**
|
|
1235
|
+
* CARGAR VARIAS DE UNA VEZ (tecla `i`, de *import*, la misma palabra que en el CLI).
|
|
1236
|
+
*
|
|
1237
|
+
* Guardar las variables de un servicio una por una es, para la bóveda, un cambio de
|
|
1238
|
+
* configuración por variable: el servicio obedece el primero —sale y lo levanta su
|
|
1239
|
+
* supervisor— y arranca con media configuración mientras se teclea el resto. Cargarlas
|
|
1240
|
+
* juntas hace que se reinicie UNA vez, con todo puesto.
|
|
1241
|
+
*
|
|
1242
|
+
* Se acepta lo que se pueda escribir en una línea (`CLAVE=valor CLAVE2=valor2`) o la
|
|
1243
|
+
* RUTA de un `.env`, que es como suele llegar la configuración de un servicio.
|
|
1244
|
+
*/
|
|
1245
|
+
function promptLoadVars (term, st, { ns = null, pub = null, where }) {
|
|
1246
|
+
const i = L(st)
|
|
1247
|
+
setInput(st, {
|
|
1248
|
+
label: i.loadLabel(where),
|
|
1249
|
+
hint: i.loadHint,
|
|
1250
|
+
onSubmit: async (raw) => {
|
|
1251
|
+
const text = raw.trim()
|
|
1252
|
+
st.input = null
|
|
1253
|
+
if (!text) return
|
|
1254
|
+
let content = text
|
|
1255
|
+
// Sin un `=` no es una lista de variables: es la ruta de un archivo.
|
|
1256
|
+
if (!text.includes('=')) {
|
|
1257
|
+
try { content = fs.readFileSync(text, 'utf8') } catch (_) { flash(st, i.loadNoFile(text), 'danger'); return }
|
|
1258
|
+
}
|
|
1259
|
+
const { items, errors } = parseEnvInput(content)
|
|
1260
|
+
// Un archivo con un problema no se carga a medias: se dice qué línea y no se
|
|
1261
|
+
// escribe nada. Media configuración aplicada es peor que ninguna.
|
|
1262
|
+
if (errors.length) { flash(st, i.loadNothing + ' ' + i.envErr[errors[0].code](errors[0]), 'danger'); return }
|
|
1263
|
+
askVisibility(term, st, async (isPublic) => {
|
|
1264
|
+
const withVisibility = items.map((it) => ({ ...it, public: isPublic }))
|
|
1265
|
+
const r = await guard(term, st, i.loadingVars, () => (pub
|
|
1266
|
+
? vc.applyDeviceSecrets(pub, withVisibility, activeId(st))
|
|
1267
|
+
: vc.applySecrets(ns, withVisibility, activeId(st))))
|
|
1268
|
+
if (r.ok) { flash(st, i.loadedVars(items.length, where)); st.secrets = r.v }
|
|
1269
|
+
})
|
|
1270
|
+
},
|
|
1271
|
+
onCancel: () => { st.input = null }
|
|
1272
|
+
})
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
/** Cargar varias en un SCOPE: primero cuál, luego el bloque. */
|
|
1276
|
+
function promptLoadScopeVars (term, st) {
|
|
1277
|
+
const i = L(st)
|
|
1278
|
+
const existing = Object.keys(st.secrets?.ns || {})
|
|
1279
|
+
setInput(st, {
|
|
1280
|
+
label: i.nsLabel,
|
|
1281
|
+
hint: existing.length ? i.nsHintExisting(existing.join(', ')) : i.nsHint,
|
|
1282
|
+
onSubmit: (ns) => {
|
|
1283
|
+
const nsName = ns.trim()
|
|
1284
|
+
if (!NS_RE.test(nsName)) { flash(st, i.nsInvalid, 'danger'); return }
|
|
1285
|
+
st.input = null
|
|
1286
|
+
promptLoadVars(term, st, { ns: nsName, where: nsName })
|
|
1287
|
+
},
|
|
1288
|
+
onCancel: () => { st.input = null }
|
|
1289
|
+
})
|
|
1290
|
+
}
|
|
1291
|
+
|
|
824
1292
|
function promptNewVariable (term, st) {
|
|
825
1293
|
const i = L(st)
|
|
826
|
-
const existing = Object.keys(st.secrets || {})
|
|
1294
|
+
const existing = Object.keys(st.secrets?.ns || {})
|
|
827
1295
|
setInput(st, {
|
|
828
1296
|
label: i.nsLabel,
|
|
829
1297
|
hint: existing.length ? i.nsHintExisting(existing.join(', ')) : i.nsHint,
|
|
830
1298
|
onSubmit: (ns) => {
|
|
831
|
-
const
|
|
832
|
-
if (!NS_RE.test(
|
|
1299
|
+
const nsName = ns.trim()
|
|
1300
|
+
if (!NS_RE.test(nsName)) { flash(st, i.nsInvalid, 'danger'); promptNewVariable(term, st); return }
|
|
833
1301
|
st.input = null
|
|
834
1302
|
setInput(st, {
|
|
835
|
-
label: i.keyLabel(
|
|
1303
|
+
label: i.keyLabel(nsName),
|
|
836
1304
|
hint: i.keyHint,
|
|
837
1305
|
onSubmit: (key) => {
|
|
838
1306
|
const kv = key.trim()
|
|
839
1307
|
if (!KEY_RE.test(kv)) { flash(st, i.keyInvalid, 'danger'); return }
|
|
840
1308
|
st.input = null
|
|
841
1309
|
setInput(st, {
|
|
842
|
-
label: i.valueLabel(
|
|
1310
|
+
label: i.valueLabel(nsName, kv),
|
|
843
1311
|
mask: true,
|
|
844
1312
|
hint: i.valueHint,
|
|
845
1313
|
onSubmit: async (value) => {
|
|
846
1314
|
st.input = null
|
|
847
1315
|
if (!value) { flash(st, i.valueEmpty, 'danger'); return }
|
|
848
|
-
|
|
849
|
-
|
|
1316
|
+
askVisibility(term, st, async (isPublic) => {
|
|
1317
|
+
const r = await guard(term, st, i.savingVar, () => vc.setSecret(nsName, kv, value, activeId(st), isPublic))
|
|
1318
|
+
if (r.ok) { flash(st, i.varSaved(nsName, kv)); st.secrets = r.v }
|
|
1319
|
+
})
|
|
850
1320
|
},
|
|
851
1321
|
onCancel: () => { st.input = null }
|
|
852
1322
|
})
|
|
@@ -900,13 +1370,20 @@ const helpSegs = (i, screen, st = {}) => {
|
|
|
900
1370
|
pairing: i.helpPairing,
|
|
901
1371
|
pairmode: i.helpPairMode,
|
|
902
1372
|
me: i.helpMe,
|
|
903
|
-
caps: i.helpCaps
|
|
1373
|
+
caps: i.helpCaps,
|
|
1374
|
+
devvars: i.helpDevVars
|
|
904
1375
|
}[screen] || []
|
|
905
1376
|
if (typeof segs !== 'function') return segs
|
|
1377
|
+
// El aparato señalado ahora mismo: `e variables` solo tiene sentido en un servicio (es
|
|
1378
|
+
// el único que las lee), y las filas seleccionables de la lista son justo los aparatos.
|
|
1379
|
+
const devs = mergeMembersAndCerts(st.members, st.devices?.issued || [])
|
|
1380
|
+
const cur = devs[Math.min(st.sel?.devices || 0, devs.length - 1)]
|
|
906
1381
|
return segs({
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
1382
|
+
pending: !!st.pending,
|
|
1383
|
+
hasDevices: (st.devices?.issued || []).length > 0,
|
|
1384
|
+
isService: !!cur?.cn,
|
|
1385
|
+
hasSecrets: Object.keys(st.secrets?.ns || {}).length > 0,
|
|
1386
|
+
hasVars: devVarsOf(st, st.varsFor?.pub).length > 0
|
|
910
1387
|
})
|
|
911
1388
|
}
|
|
912
1389
|
|
|
@@ -914,7 +1391,8 @@ const title = (i, screen) => ({
|
|
|
914
1391
|
profiles: i.titleProfiles,
|
|
915
1392
|
pairing: i.titlePairing,
|
|
916
1393
|
pairmode: i.titlePairMode,
|
|
917
|
-
caps: i.titleCaps
|
|
1394
|
+
caps: i.titleCaps,
|
|
1395
|
+
devvars: i.titleDevVars
|
|
918
1396
|
})[screen] || ''
|
|
919
1397
|
|
|
920
1398
|
/** Barra de pestañas horizontal (Dispositivos | Scopes y variables) de la bóveda entrada. */
|
|
@@ -937,6 +1415,9 @@ function pairingBody (st, t, cols, height) {
|
|
|
937
1415
|
const acct = info.profileName || ap?.name || info.profile || ap?.id || '—'
|
|
938
1416
|
const left = Math.max(0, Math.round((info.expiresAt - Date.now()) / 60000))
|
|
939
1417
|
lines.push(t.bold(i.pairAccount(acct, left)))
|
|
1418
|
+
// Y si lo que se entrega es un SERVICIO, se dice: el papel que sale de este QR no
|
|
1419
|
+
// firma ni ve el contenido, solo lee las variables de ese namespace.
|
|
1420
|
+
if (info.service) lines.push(t.warn(i.pairService(info.service)))
|
|
940
1421
|
// QR: se dibuja siempre que quepa de ancho; si es más alto que la pantalla se
|
|
941
1422
|
// puede hacer scroll hacia arriba/abajo para verlo completo.
|
|
942
1423
|
let qr = ''
|
|
@@ -977,9 +1458,9 @@ function render (term, st) {
|
|
|
977
1458
|
|
|
978
1459
|
const s = st.state
|
|
979
1460
|
const up = st.daemonUp
|
|
980
|
-
const
|
|
1461
|
+
const version = s?.version || 'dev'
|
|
981
1462
|
const daemonTxt = up ? i.daemonRunning : i.daemonStopped
|
|
982
|
-
lines[0] = t.bar(`dotrino-vault ${
|
|
1463
|
+
lines[0] = t.bar(`dotrino-vault ${version} daemon: ${daemonTxt} ${vc.vaultDir()}`, cols)
|
|
983
1464
|
|
|
984
1465
|
const ap = activeProfile(st)
|
|
985
1466
|
const apTxt = ap ? `${t.accent('●')} ${t.bold(ap.name || i.noName)} ${lockGlyph(ap)} ${t.muted('· ' + (ap.fingerprint || '—'))}` : t.muted('—')
|
|
@@ -1001,6 +1482,7 @@ function render (term, st) {
|
|
|
1001
1482
|
else if (st.screen === 'secrets') body = renderList(secretRows(st, t), st.sel.secrets, contentH, cols, t, scrollRef)
|
|
1002
1483
|
else if (st.screen === 'me') body = renderList(meRows(st, t), -1, contentH, cols, t, scrollRef)
|
|
1003
1484
|
else if (st.screen === 'caps') body = renderList(capsRows(st, t), st.sel.caps || 0, contentH, cols, t, scrollRef)
|
|
1485
|
+
else if (st.screen === 'devvars') body = renderList(devVarRows(st, t), st.sel.devvars || 0, contentH, cols, t, scrollRef)
|
|
1004
1486
|
else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
|
|
1005
1487
|
else if (st.screen === 'pairing') {
|
|
1006
1488
|
const pb = pairingBody(st, t, cols, contentH)
|
|
@@ -1086,7 +1568,13 @@ export async function runTui () {
|
|
|
1086
1568
|
const st = {
|
|
1087
1569
|
screen: 'profiles', // se arranca en la lista de bóvedas: hay que ENTRAR a una
|
|
1088
1570
|
lang: loadLang(), // es/en — se conmuta con `l` y se recuerda en prefs.json
|
|
1089
|
-
sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0 },
|
|
1571
|
+
sel: { profiles: 0, devices: 0, secrets: 0, pairmode: 0, devvars: 0 },
|
|
1572
|
+
// Las bóvedas que ha abierto ESTA sesión, para volver a cerrarlas al salir.
|
|
1573
|
+
unlockedHere: new Set(),
|
|
1574
|
+
// Su contraseña, SOLO en memoria y SOLO mientras la TUI esté abierta: sirve para
|
|
1575
|
+
// reabrir sin volver a preguntar si el daemon pierde el estado (ver
|
|
1576
|
+
// `reunlockSilently`). Se olvida con `k`, al quitar la contraseña y al salir.
|
|
1577
|
+
sessionPwd: new Map(),
|
|
1090
1578
|
scroll: {},
|
|
1091
1579
|
profiles: null,
|
|
1092
1580
|
devices: null,
|
|
@@ -1155,13 +1643,21 @@ export async function runTui () {
|
|
|
1155
1643
|
else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
|
|
1156
1644
|
else if (st.screen === 'me') running = await onKeyMe(term, st, key)
|
|
1157
1645
|
else if (st.screen === 'caps') running = await onKeyCaps(term, st, key)
|
|
1646
|
+
else if (st.screen === 'devvars') running = await onKeyDevVars(term, st, key)
|
|
1158
1647
|
else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
|
|
1159
1648
|
else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
|
|
1160
1649
|
}
|
|
1161
1650
|
} finally {
|
|
1651
|
+
// AL SALIR SE VUELVE A CERRAR lo que se abrió aquí. Sin esto, teclear la contraseña una
|
|
1652
|
+
// vez dejaba la bóveda abierta para todo el que pasara por esta máquina hasta el
|
|
1653
|
+
// siguiente reinicio del servicio — un candado que solo se cierra reiniciando no es un
|
|
1654
|
+
// candado. (Si la TUI muere de un tirón —kill, ventana cerrada— no hay quien lo haga:
|
|
1655
|
+
// ahí el cierre lo pone el reinicio, como antes.)
|
|
1656
|
+
st.sessionPwd.clear()
|
|
1657
|
+
for (const id of st.unlockedHere) { try { await vc.lockProfile(id) } catch (_) {} }
|
|
1162
1658
|
term.close()
|
|
1163
1659
|
}
|
|
1164
1660
|
}
|
|
1165
1661
|
|
|
1166
1662
|
// 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 }
|
|
1663
|
+
export const __test = { render, activeLocked, refreshAll, ensureUnlocked, profileRows, deviceRows, secretRows, devVarRows, meRows, capsRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang, mergeMembersAndCerts }
|