@dotrino/vaultd 0.14.0 → 0.15.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dotrino/vaultd",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "type": "module",
5
5
  "description": "Certificador personal de Dotrino: daemon headless que custodia la clave maestra y delega capacidades a tus dispositivos por el proxy. Tu CA propia.",
6
6
  "bin": {
package/src/tui/app.js CHANGED
@@ -231,6 +231,47 @@ function secretRows (st, t) {
231
231
  return rows
232
232
  }
233
233
 
234
+ /**
235
+ * PERFIL del usuario: nombre, foto y datos, tal como los tiene la bóveda. Es lo que se
236
+ * edita en un dispositivo emparejado y se sincroniza aquí; esta pantalla sirve para
237
+ * comprobar que llegó.
238
+ *
239
+ * Solo lectura A PROPÓSITO: el perfil se edita donde lo usas (el aparato), no en el
240
+ * servidor donde vive la bóveda. Aquí se mira.
241
+ */
242
+ function meRows (st, t) {
243
+ const i = L(st)
244
+ const me = st.me
245
+ if (me === undefined) return [{ text: t.muted(i.loading), sel: false }]
246
+ if (!me) return [{ text: t.muted(i.noProfile), sel: false }, { text: '', sel: false }, { text: t.muted(i.noProfileHint), sel: false }]
247
+
248
+ const rows = []
249
+ const campo = (etiqueta, valor, oculto) => rows.push({
250
+ text: ` ${t.muted(String(etiqueta).padEnd(12))} ${valor}${oculto ? t.muted(i.hidden) : ''}`, sel: false
251
+ })
252
+ rows.push({ text: t.muted(i.profileUpdated(me.updatedAt ? new Date(me.updatedAt).toLocaleString() : '—')), sel: false })
253
+ rows.push({ text: '', sel: false })
254
+ campo(i.fieldName, me.nickname ? t.bold(me.nickname) : t.muted(i.noName))
255
+ campo(i.fieldPhoto, me.avatar
256
+ ? `${me.avatar.type || '?'} · ${(me.avatar.bytes / 1024).toFixed(1)} KB`
257
+ : t.muted(i.no))
258
+
259
+ const STD = [['nombres', i.fieldFirstName], ['apellidos', i.fieldLastName], ['email', i.fieldEmail],
260
+ ['telefono', i.fieldPhone], ['direccion', i.fieldAddress]]
261
+ const puestos = STD.filter(([k]) => me[k])
262
+ if (puestos.length) rows.push({ text: '', sel: false })
263
+ for (const [k, etiqueta] of puestos) campo(etiqueta, me[k], me[k + 'Visible'] === false)
264
+
265
+ for (const [titulo, lista] of [[i.links, me.links], [i.otherData, me.fields]]) {
266
+ if (!Array.isArray(lista) || !lista.length) continue
267
+ rows.push({ text: '', sel: false })
268
+ rows.push({ text: t.accent(' ▸ ' + titulo), sel: false })
269
+ for (const x of lista) campo(x.type || x.label || '', x.value, x.visible === false)
270
+ }
271
+ if (me.avatar) { rows.push({ text: '', sel: false }); rows.push({ text: t.muted(i.savePhotoHint), sel: false }) }
272
+ return rows
273
+ }
274
+
234
275
  // --------------------------------- entrada ---------------------------------
235
276
 
236
277
  function setInput (st, opts) {
@@ -266,6 +307,10 @@ async function refreshSecrets (term, st) {
266
307
  const r = await guard(term, st, L(st).loadingSecrets, () => vc.listSecrets(activeId(st)))
267
308
  if (r.ok) st.secrets = r.v
268
309
  }
310
+ async function refreshMe (term, st) {
311
+ const r = await guard(term, st, L(st).loadingProfile, () => vc.getMe(activeId(st)))
312
+ st.me = r.ok ? r.v : null
313
+ }
269
314
  async function refreshProfiles (term, st) {
270
315
  const r = await guard(term, st, L(st).loadingVaults, () => vc.listProfiles())
271
316
  if (r.ok) st.profiles = r.v
@@ -557,6 +602,31 @@ async function onKeyPairing (term, st, key) {
557
602
  return true
558
603
  }
559
604
 
605
+ /**
606
+ * Teclas del PERFIL: refrescar y guardar la foto. Nada de editar — el perfil se edita en
607
+ * el dispositivo que usas, no en la máquina donde vive la bóveda.
608
+ */
609
+ async function onKeyMe (term, st, key) {
610
+ const i = L(st)
611
+ const ch = key.name === 'char' ? key.ch.toLowerCase() : null
612
+ if (ch === 'r') {
613
+ await refreshMe(term, st)
614
+ } else if (ch === 'f' && st.me?.avatar) {
615
+ setInput(st, {
616
+ label: i.savePhotoLabel,
617
+ hint: i.savePhotoHintInput,
618
+ value: 'perfil.png',
619
+ onSubmit: async (valor) => {
620
+ const destino = String(valor || '').trim()
621
+ if (!destino) return
622
+ const r = await guard(term, st, i.savingPhoto, () => vc.saveAvatar(destino, activeId(st)))
623
+ if (r.ok && r.v) flash(st, i.photoSaved(r.v))
624
+ }
625
+ })
626
+ }
627
+ return true
628
+ }
629
+
560
630
  async function onKeySecrets (term, st, key) {
561
631
  const i = L(st)
562
632
  const rows = secretRows(st, term.t)
@@ -659,15 +729,16 @@ async function onConfirmKey (st, key) {
659
729
 
660
730
  // Pestañas INTERNAS de una bóveda ya elegida: se cambian con ←→. La lista de
661
731
  // bóvedas (profiles) es el nivel de arriba (se entra con Enter, no es una pestaña).
662
- const INNER_TABS = ['devices', 'secrets']
663
- const tabLabel = (i, k) => (k === 'devices' ? i.tabDevices : i.tabSecrets)
732
+ const INNER_TABS = ['devices', 'secrets', 'me']
733
+ const tabLabel = (i, k) => ({ devices: i.tabDevices, secrets: i.tabSecrets, me: i.tabMe })[k]
664
734
 
665
735
  const helpSegs = (i, screen) => ({
666
736
  profiles: i.helpProfiles,
667
737
  devices: i.helpDevices,
668
738
  secrets: i.helpSecrets,
669
739
  pairing: i.helpPairing,
670
- pairmode: i.helpPairMode
740
+ pairmode: i.helpPairMode,
741
+ me: i.helpMe
671
742
  })[screen] || []
672
743
 
673
744
  const title = (i, screen) => ({
@@ -758,6 +829,7 @@ function render (term, st) {
758
829
  if (st.screen === 'profiles') body = renderList(profileRows(st, t), st.sel.profiles, contentH, cols, t, scrollRef)
759
830
  else if (st.screen === 'devices') body = renderList(deviceRows(st, t), st.sel.devices, contentH, cols, t, scrollRef)
760
831
  else if (st.screen === 'secrets') body = renderList(secretRows(st, t), st.sel.secrets, contentH, cols, t, scrollRef)
832
+ else if (st.screen === 'me') body = renderList(meRows(st, t), -1, contentH, cols, t, scrollRef)
761
833
  else if (st.screen === 'pairmode') body = renderList(pairModeRows(st, t), st.sel.pairmode, contentH, cols, t, scrollRef)
762
834
  else if (st.screen === 'pairing') {
763
835
  const pb = pairingBody(st, t, cols, contentH)
@@ -896,6 +968,9 @@ export async function runTui () {
896
968
  if ((key.name === 'left' || key.name === 'right') && INNER_TABS.includes(st.screen)) {
897
969
  const n = INNER_TABS.indexOf(st.screen)
898
970
  st.screen = INNER_TABS[(n + (key.name === 'right' ? 1 : -1) + INNER_TABS.length) % INNER_TABS.length]
971
+ // El perfil se pide al ENTRAR en su pestaña, no al arrancar: es contenido del
972
+ // usuario y no hay por qué sacarlo del cifrado si nadie lo está mirando.
973
+ if (st.screen === 'me' && st.me === undefined) await refreshMe(term, st)
899
974
  continue
900
975
  }
901
976
  // Esc/'b' desde una pestaña vuelve a la lista de bóvedas (salir de la bóveda
@@ -907,6 +982,7 @@ export async function runTui () {
907
982
  if (st.screen === 'profiles') running = await onKeyProfiles(term, st, key)
908
983
  else if (st.screen === 'devices') running = await onKeyDevices(term, st, key)
909
984
  else if (st.screen === 'secrets') running = await onKeySecrets(term, st, key)
985
+ else if (st.screen === 'me') running = await onKeyMe(term, st, key)
910
986
  else if (st.screen === 'pairmode') running = await onKeyPairMode(term, st, key)
911
987
  else if (st.screen === 'pairing') running = await onKeyPairing(term, st, key)
912
988
  }
@@ -916,4 +992,4 @@ export async function runTui () {
916
992
  }
917
993
 
918
994
  // Solo para pruebas headless (render sin terminal real). No usar en runtime.
919
- export const __test = { render, profileRows, deviceRows, secretRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang }
995
+ export const __test = { render, profileRows, deviceRows, secretRows, meRows, pairModeRows, pairingBody, scrollBody, fitHelp, toggleLang }
package/src/tui/i18n.js CHANGED
@@ -34,6 +34,31 @@ const es = {
34
34
  // pestañas y títulos
35
35
  tabDevices: 'Dispositivos',
36
36
  tabSecrets: 'Scopes y variables',
37
+ tabMe: 'Perfil',
38
+ // Perfil del usuario (lo que sincronizan los dispositivos). Solo lectura: se edita en
39
+ // el aparato, no en la máquina donde vive la bóveda.
40
+ loadingProfile: 'Cargando el perfil…',
41
+ noProfile: ' (esta bóveda todavía no tiene perfil)',
42
+ noProfileHint: ' Edita tu nombre o tu foto en un dispositivo emparejado y pulsa R.',
43
+ profileUpdated: (cuando) => ` actualizado ${cuando}`,
44
+ fieldName: 'nombre',
45
+ fieldPhoto: 'foto',
46
+ fieldFirstName: 'nombres',
47
+ fieldLastName: 'apellidos',
48
+ fieldEmail: 'correo',
49
+ fieldPhone: 'teléfono',
50
+ fieldAddress: 'dirección',
51
+ links: 'Enlaces',
52
+ otherData: 'Otros datos',
53
+ hidden: ' (oculto)',
54
+ noName: '(sin nombre)',
55
+ no: 'no',
56
+ savePhotoHint: ' Pulsa F para guardar la foto y poder verla.',
57
+ savePhotoLabel: '¿Dónde guardo la foto?',
58
+ savePhotoHintInput: 'ruta del archivo (Esc cancela)',
59
+ savingPhoto: 'Guardando la foto…',
60
+ photoSaved: (ruta) => `Foto guardada en ${ruta}`,
61
+ helpMe: ['←→ pestaña', 'r refrescar', 'f guardar foto', 'Esc bóvedas', 'l English', 'q salir'],
37
62
  tabsHint: ' (←→ cambiar)',
38
63
  titleProfiles: 'Bóvedas',
39
64
  titlePairing: 'Emparejar un dispositivo',
@@ -203,6 +228,29 @@ const en = {
203
228
 
204
229
  tabDevices: 'Devices',
205
230
  tabSecrets: 'Scopes & variables',
231
+ tabMe: 'Profile',
232
+ loadingProfile: 'Loading profile…',
233
+ noProfile: ' (this vault has no profile yet)',
234
+ noProfileHint: ' Edit your name or photo on a paired device and press R.',
235
+ profileUpdated: (cuando) => ` updated ${cuando}`,
236
+ fieldName: 'name',
237
+ fieldPhoto: 'photo',
238
+ fieldFirstName: 'first name',
239
+ fieldLastName: 'last name',
240
+ fieldEmail: 'email',
241
+ fieldPhone: 'phone',
242
+ fieldAddress: 'address',
243
+ links: 'Links',
244
+ otherData: 'Other data',
245
+ hidden: ' (hidden)',
246
+ noName: '(no name)',
247
+ no: 'no',
248
+ savePhotoHint: ' Press F to save the photo so you can look at it.',
249
+ savePhotoLabel: 'Where should I save the photo?',
250
+ savePhotoHintInput: 'file path (Esc cancels)',
251
+ savingPhoto: 'Saving the photo…',
252
+ photoSaved: (ruta) => `Photo saved to ${ruta}`,
253
+ helpMe: ['←→ tab', 'r refresh', 'f save photo', 'Esc vaults', 'l Español', 'q quit'],
206
254
  tabsHint: ' (←→ switch)',
207
255
  titleProfiles: 'Vaults',
208
256
  titlePairing: 'Pair a device',
@@ -32,6 +32,7 @@ const F = {
32
32
  devices: 'devices.json',
33
33
  profilesList: 'profiles-list.json',
34
34
  secretsList: 'secrets-list.json',
35
+ me: 'me.json',
35
36
  // peticiones (las escribe el control; el daemon las consume y borra)
36
37
  pairReq: 'pair-request.json',
37
38
  approveReq: 'approve-request.json',
@@ -39,6 +40,7 @@ const F = {
39
40
  revokeReq: 'revoke-request.json',
40
41
  secretReq: 'secret-request.json',
41
42
  profileReq: 'profile-request.json',
43
+ meReq: 'me-request.json',
42
44
  dumpReq: 'dump-request.json'
43
45
  }
44
46
 
@@ -206,6 +208,44 @@ export async function revokeDevice (nonce, profile) {
206
208
  return listDevices(profile)
207
209
  }
208
210
 
211
+ // ---------------------------------------------------------------------------
212
+ // Perfil del usuario (lo que sincronizan los dispositivos)
213
+ // ---------------------------------------------------------------------------
214
+
215
+ /**
216
+ * El PERFIL del usuario tal como lo tiene la bóveda: nombre, foto y datos. Es lo que se
217
+ * edita en cualquier dispositivo emparejado y se sincroniza aquí, así que sirve para
218
+ * comprobar que lo que cambiaste en el aparato llegó.
219
+ *
220
+ * No es lo mismo que `listProfiles` (las cuentas de ESTE PC) ni que el acta (quién es del
221
+ * perfil): esto es el CONTENIDO. La foto llega resumida (tipo y tamaño), no en bytes:
222
+ * nadie va a mirar un data-URI de 90 KB en una terminal.
223
+ *
224
+ * El volcado es contenido del usuario, así que se lee y se BORRA en el acto.
225
+ */
226
+ export async function getMe (profile) {
227
+ requireAlive()
228
+ rm(F.me)
229
+ writeReq(F.meReq, {}, profile)
230
+ signalOrCleanup('SIGUSR2', [F.meReq])
231
+ const d = await waitFor(F.me)
232
+ rm(F.me)
233
+ if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
234
+ return d.me || null
235
+ }
236
+
237
+ /** Escribe la foto de perfil en `destino` (para poder mirarla) y devuelve la ruta. */
238
+ export async function saveAvatar (destino, profile) {
239
+ requireAlive()
240
+ rm(F.me)
241
+ writeReq(F.meReq, { avatarPath: destino }, profile)
242
+ signalOrCleanup('SIGUSR2', [F.meReq])
243
+ const d = await waitFor(F.me)
244
+ rm(F.me)
245
+ if (!d) throw coded('the daemon did not reply', 'NO_REPLY')
246
+ return d.avatarGuardada || null
247
+ }
248
+
209
249
  // ---------------------------------------------------------------------------
210
250
  // Secretos: scopes (namespaces) y variables (claves)
211
251
  // ---------------------------------------------------------------------------