From 0badc5fcfc084c89d5dcd654c1d0574713683986 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 21 Aug 2026 21:51:42 +0100 Subject: [PATCH] feat(android): folder and 2FA secret can be changed, and the buttons wear icons The folder had no row of its own - it shared "Folder / tags" with the tags, so neither could be touched. It is now CHOSEN from the known folders rather than typed: a typo would file the entry under a folder nothing displays, here or on the desktop, and it would look lost. Tags stay read-only because they are an array in the snapshot, and writing one as a string would break its shape for the desktop. The 2FA secret gets its own masked row. The code has had a panel for a long time, but it is computed and renewed every thirty seconds; the secret is what one sets, changes and removes, and nothing allowed that from the phone. User, Pass, Copy, Show and Edit become drawings. Four words on a list row left little room for the entry's name and had to be re-read every time. Every icon carries an aria-label: replacing a word with a drawing must not take the information away from someone listening to a screen reader. --- android-client/PLAN.md | 14 +++++ android-client/PMA.WebUI.pas | 23 ++++++--- android-client/web/app.html | 99 +++++++++++++++++++++++++++++++----- mainApk.pas | 3 +- 4 files changed, 118 insertions(+), 21 deletions(-) diff --git a/android-client/PLAN.md b/android-client/PLAN.md index 9b3963a..c70ae56 100644 --- a/android-client/PLAN.md +++ b/android-client/PLAN.md @@ -723,6 +723,20 @@ téléphone qu'on voudra ensuite ouvrir sur le poste demandera un import. Une ligne vide s'affiche désormais **si elle est éditable** : un identifiant absent qu'on ne pourrait pas ajouter serait une impasse. Vider est permis, sauf sur un mot de passe où ce serait presque toujours une fausse manoeuvre. + **Dossier et secret 2FA** — ajoutés le 2026-08-21. Le dossier n'avait pas de + ligne à lui : il partageait « Folder / tags » avec les étiquettes, donc rien + n'était modifiable. Il se **choisit** dans la liste des dossiers connus + plutôt que de se taper — une faute de frappe rangerait l'entrée dans un + dossier que rien n'affiche, ni ici ni sur le poste, et elle semblerait + disparue. Les étiquettes restent en lecture seule : c'est un **tableau** + dans l'instantané, et l'écrire comme une chaîne casserait sa forme pour le + poste. + + Le **secret** 2FA a désormais sa ligne, masquée comme un mot de passe. Le + *code* avait sa case depuis longtemps, mais il est calculé et renouvelé + toutes les trente secondes ; c'est le secret qui se pose, se change et se + retire, et rien ne permettait de le faire depuis le téléphone. + **Supprimer** — fait le 2026-08-21. `DeleteEntry` retire la ligne **et** pose la pierre tombale `{uuid, deleted_at}` : les deux, sinon rien. Retirer la ligne seule ne supprime pas — le poste tient toujours la sienne, et le diff --git a/android-client/PMA.WebUI.pas b/android-client/PMA.WebUI.pas index e245f08..00ff67a 100644 --- a/android-client/PMA.WebUI.pas +++ b/android-client/PMA.WebUI.pas @@ -166,7 +166,6 @@ function BuildRows(const E: TVaultEntry): TArray; var LRows: TArray; I: Integer; - LMeta: string; procedure Add(const ACaption, AValue: string; ASecret: Boolean; const AField: string = ''); @@ -195,6 +194,12 @@ begin Add('Site', E.Site, False, 'site'); Add('Username', E.Username, False, 'username'); Add('Password', E.Password, True, 'password'); + // LE SECRET, pas le code. Le code a sa propre case en haut, calculee et + // renouvelee toutes les trente secondes ; c'est le secret qui se pose, se + // change et se retire, et sans cette ligne il n'y avait aucun moyen de le + // faire depuis le telephone. Masque comme un mot de passe : il vaut le + // second facteur en entier. + Add('2FA secret', E.Totp, True, 'totp_secret'); end; for I := 0 to High(E.Fields) do @@ -207,13 +212,15 @@ begin Add('Attachment - ' + HumanSize(E.Attachments[I].SizeBytes), E.Attachments[I].FileName, False); - LMeta := E.Folder; - if E.Tags <> '' then - begin - if LMeta <> '' then LMeta := LMeta + ' - '; - LMeta := LMeta + E.Tags; - end; - Add('Folder / tags', LMeta, False); + // DOSSIER ET ETIQUETTES SEPARES, parce qu'ils ne se modifient pas pareil. + // Le dossier est une chaine simple, choisie parmi celles qui existent - la + // page en fait une liste plutot qu'un champ libre, sinon une faute de frappe + // rangerait l'entree dans un dossier que rien n'affiche. + // Les etiquettes, elles, sont un TABLEAU dans l'instantane : les ecrire comme + // une chaine casserait leur forme pour le poste. Lecture seule tant que le + // patch ne sait pas ecrire un tableau. + Add('Folder', E.Folder, False, 'folder'); + Add('Tags', E.Tags, False); Add('Type', E.Template, False); // Horodatage tel quel : de l'ISO 8601 UTC, lisible, et le reformater // demanderait de decider d'un fuseau alors qu'il n'y a rien a arbitrer. diff --git a/android-client/web/app.html b/android-client/web/app.html index f1860d8..b2e5d6a 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -157,6 +157,14 @@ /* Rouge sourd : assez pour qu'on ne le confonde pas avec Copy, pas assez pour attirer l'oeil sur la seule action irreversible de l'ecran. */ .btn.danger { background: rgba(220, 80, 80, .30); } + /* Un bouton d'icone est CARRE et etroit : c'est ce qui le distingue d'un + bouton de texte au premier coup d'oeil, et ce qui laisse la place au nom + de l'entree sur une ligne de liste. La cible reste a 44 px, ce qu'un + doigt attend. */ + .btn.ico-btn { padding: 0; width: 44px; min-width: 44px; height: 44px; + display: inline-flex; align-items: center; + justify-content: center; } + .btn.ico-btn svg { width: 20px; height: 20px; } .empty { text-align: center; color: var(--muted); padding: 48px 16px; } @@ -539,8 +547,10 @@ function renderRows() { + '' + '
' + esc(e.name) + '' + (e.sub ? '' + esc(e.sub) + '' : '') + '
' - + (e.hasUser ? '' : '') - + '' : '') + + ''; } document.getElementById('rows').innerHTML = shown ? html @@ -582,6 +592,37 @@ document.getElementById('qx').addEventListener('click', function () { renderRows(); }); +// LES ICONES, dessinees une fois et nommees. +// +// Elles remplacent "User", "Pass", "Copy", "Edit" : quatre mots sur une ligne +// de liste laissaient peu de place au nom de l'entree, et se relisaient a +// chaque fois. Un trait suffit a les distinguer. +// +// aria-label EST OBLIGATOIRE : une icone sans nom n'existe pas pour qui +// n'ecoute que le lecteur d'ecran, et remplacer un mot par un dessin ne doit +// pas retirer l'information a quelqu'un. +var ICONS = { + user: '' + + '', + key: '' + + '', + note: '', + copy: '' + + '', + edit: '', + eye: '' + + '', + eyeOff: '' + + '' + + '' +}; + +function ico(name, label) { + return '' + ICONS[name] + ''; +} + // --- detail ---------------------------------------------------------------- var detailRows = []; // Le dernier detail recu, pour redessiner la ligne apres une annulation sans @@ -603,7 +644,8 @@ function renderDetail(d) { if (d.totp) html += '
2FA code
' + '
------
' - + '
' + + '
' + '
'; for (i = 0; i < detailRows.length; i++) { var r = detailRows[i]; @@ -612,9 +654,12 @@ function renderDetail(d) { + '" data-v="' + i + '">' + (r.s ? '•'.repeat(Math.min(r.v.length, 14)) : esc(r.v)) + '' - + (r.s ? '' : '') - + '' - + (r.e ? '' : '') + + (r.s ? '' : '') + + '' + + (r.e ? '' : '') + ''; } // SUPPRIMER, tout en bas et en deux temps. Sans retour possible depuis le @@ -657,7 +702,7 @@ document.getElementById('fields').addEventListener('click', function (ev) { cell.classList.toggle('mask', !hidden); cell.textContent = hidden ? detailRows[k].v : '\u2022'.repeat(Math.min(detailRows[k].v.length, 14)); - t.textContent = hidden ? 'Hide' : 'Show'; + t.innerHTML = ico(hidden ? 'eyeOff' : 'eye', hidden ? 'Hide' : 'Show'); send('touch'); return; } @@ -702,6 +747,40 @@ document.getElementById('fields').addEventListener('click', function (ev) { } }); +/// LE DOSSIER SE CHOISIT, il ne se tape pas. +/// +/// C'est une chaine simple dans l'instantane, mais elle doit correspondre a un +/// dossier existant : une faute de frappe rangerait l'entree dans un dossier +/// que rien n'affiche, ni ici ni sur le poste - l'entree semblerait disparue. +/// La liste des dossiers arrive deja avec le coffre, autant s'en servir. +/// +/// 'All' est ajoute d'office : c'est le dossier par defaut du poste, et il ne +/// figure pas toujours dans la liste. +function editorControl(k) { + var val = detailRows[k].v; + var style = ' style="margin:0;flex:1;min-width:0;width:auto"'; + if (editField !== 'folder') + return ''; + + var names = ['All'], i; + for (i = 0; i < folders.length; i++) + if (folders[i] && names.indexOf(folders[i]) < 0) names.push(folders[i]); + // Le dossier courant peut ne plus figurer dans la liste - il a ete renomme + // ailleurs, ou l'entree vient d'un import. Le proposer quand meme evite de + // le remplacer silencieusement en ouvrant simplement l'editeur. + if (val && names.indexOf(val) < 0) names.push(val); + + var html = ''; +} + // EDITEUR EN PLACE, pas window.prompt : une invite JavaScript demande au // moteur un WebChromeClient que FMX ne fournit pas, et prompt() rend alors // null sans un mot - le bouton semblerait ne rien faire. @@ -719,11 +798,7 @@ function openEditor(k) { editField = detailRows[k].e || ''; if (!editField) return; var line = row.parentNode; - line.innerHTML = - '' + line.innerHTML = editorControl(k) // Show n'a de sens que sur un champ masque. Sur un site en clair il // n'aurait rien a reveler - et l'inverse : il le CACHERAIT. + (detailRows[k].s ? '' : '') diff --git a/mainApk.pas b/mainApk.pas index f017174..f6acf92 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -1016,7 +1016,8 @@ begin // des pieces jointes et des horodatages, que PatchEntryField ecraserait sans // rien y comprendre. if (AField <> 'title') and (AField <> 'site') - and (AField <> 'username') and (AField <> 'password') then + and (AField <> 'username') and (AField <> 'password') + and (AField <> 'folder') and (AField <> 'totp_secret') then begin RefuseChange('That field cannot be changed from here'); Exit;