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.
This commit is contained in:
r-zakarya
2026-08-21 21:51:42 +01:00
parent c25d68b721
commit 0badc5fcfc
4 changed files with 118 additions and 21 deletions
+14
View File
@@ -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 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, 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. 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 **Supprimer** — fait le 2026-08-21. `DeleteEntry` retire la ligne **et** pose
la pierre tombale `{uuid, deleted_at}` : les deux, sinon rien. Retirer la 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 ligne seule ne supprime pas — le poste tient toujours la sienne, et le
+15 -8
View File
@@ -166,7 +166,6 @@ function BuildRows(const E: TVaultEntry): TArray<TDetailRow>;
var var
LRows: TArray<TDetailRow>; LRows: TArray<TDetailRow>;
I: Integer; I: Integer;
LMeta: string;
procedure Add(const ACaption, AValue: string; ASecret: Boolean; procedure Add(const ACaption, AValue: string; ASecret: Boolean;
const AField: string = ''); const AField: string = '');
@@ -195,6 +194,12 @@ begin
Add('Site', E.Site, False, 'site'); Add('Site', E.Site, False, 'site');
Add('Username', E.Username, False, 'username'); Add('Username', E.Username, False, 'username');
Add('Password', E.Password, True, 'password'); 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; end;
for I := 0 to High(E.Fields) do for I := 0 to High(E.Fields) do
@@ -207,13 +212,15 @@ begin
Add('Attachment - ' + HumanSize(E.Attachments[I].SizeBytes), Add('Attachment - ' + HumanSize(E.Attachments[I].SizeBytes),
E.Attachments[I].FileName, False); E.Attachments[I].FileName, False);
LMeta := E.Folder; // DOSSIER ET ETIQUETTES SEPARES, parce qu'ils ne se modifient pas pareil.
if E.Tags <> '' then // Le dossier est une chaine simple, choisie parmi celles qui existent - la
begin // page en fait une liste plutot qu'un champ libre, sinon une faute de frappe
if LMeta <> '' then LMeta := LMeta + ' - '; // rangerait l'entree dans un dossier que rien n'affiche.
LMeta := LMeta + E.Tags; // Les etiquettes, elles, sont un TABLEAU dans l'instantane : les ecrire comme
end; // une chaine casserait leur forme pour le poste. Lecture seule tant que le
Add('Folder / tags', LMeta, False); // patch ne sait pas ecrire un tableau.
Add('Folder', E.Folder, False, 'folder');
Add('Tags', E.Tags, False);
Add('Type', E.Template, False); Add('Type', E.Template, False);
// Horodatage tel quel : de l'ISO 8601 UTC, lisible, et le reformater // 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. // demanderait de decider d'un fuseau alors qu'il n'y a rien a arbitrer.
+87 -12
View File
@@ -157,6 +157,14 @@
/* Rouge sourd : assez pour qu'on ne le confonde pas avec Copy, pas assez /* 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. */ pour attirer l'oeil sur la seule action irreversible de l'ecran. */
.btn.danger { background: rgba(220, 80, 80, .30); } .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; } .empty { text-align: center; color: var(--muted); padding: 48px 16px; }
@@ -539,8 +547,10 @@ function renderRows() {
+ '</div>' + '</div>'
+ '<div class="txt"><b>' + esc(e.name) + '</b>' + '<div class="txt"><b>' + esc(e.name) + '</b>'
+ (e.sub ? '<span>' + esc(e.sub) + '</span>' : '') + '</div>' + (e.sub ? '<span>' + esc(e.sub) + '</span>' : '') + '</div>'
+ (e.hasUser ? '<button class="btn" data-copy="user">User</button>' : '') + (e.hasUser ? '<button class="btn ico-btn" data-copy="user">'
+ '<button class="btn" data-copy="pwd">' + (e.isNote ? 'Text' : 'Pass') + ico('user', 'Copy username') + '</button>' : '')
+ '<button class="btn ico-btn" data-copy="pwd">'
+ (e.isNote ? ico('note', 'Copy text') : ico('key', 'Copy password'))
+ '</button></div>'; + '</button></div>';
} }
document.getElementById('rows').innerHTML = shown ? html document.getElementById('rows').innerHTML = shown ? html
@@ -582,6 +592,37 @@ document.getElementById('qx').addEventListener('click', function () {
renderRows(); 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: '<path d="M12 12a4 4 0 100-8 4 4 0 000 8z"/>'
+ '<path d="M4.5 20a7.5 7.5 0 0115 0"/>',
key: '<circle cx="8" cy="12" r="3.5"/><path d="M11.5 12H21l-2 2.5"/>'
+ '<path d="M17 12v3"/>',
note: '<path d="M6 3h9l4 4v14H6z"/><path d="M9 12h7M9 16h5"/>',
copy: '<rect x="9" y="9" width="11" height="11" rx="2"/>'
+ '<path d="M15 5H6a2 2 0 00-2 2v9"/>',
edit: '<path d="M4 20h4L20 8l-4-4L4 16z"/>',
eye: '<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"/>'
+ '<circle cx="12" cy="12" r="3"/>',
eyeOff: '<path d="M4 4l16 16"/>'
+ '<path d="M9.5 5.4A9.8 9.8 0 0112 5c6.5 0 10 6 10 6a17 17 0 01-3.3 3.7"/>'
+ '<path d="M6.3 7.7A17 17 0 002 11s3.5 6 10 6a9.7 9.7 0 003.4-.6"/>'
};
function ico(name, label) {
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"'
+ ' stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"'
+ ' aria-label="' + label + '" role="img">' + ICONS[name] + '</svg>';
}
// --- detail ---------------------------------------------------------------- // --- detail ----------------------------------------------------------------
var detailRows = []; var detailRows = [];
// Le dernier detail recu, pour redessiner la ligne apres une annulation sans // Le dernier detail recu, pour redessiner la ligne apres une annulation sans
@@ -603,7 +644,8 @@ function renderDetail(d) {
if (d.totp) if (d.totp)
html += '<div class="field totp" id="totp"><div class="cap">2FA code</div>' html += '<div class="field totp" id="totp"><div class="cap">2FA code</div>'
+ '<div class="line"><div class="val" id="totpv">------</div>' + '<div class="line"><div class="val" id="totpv">------</div>'
+ '<button class="btn" data-copy="totp">Copy</button></div>' + '<button class="btn ico-btn" data-copy="totp">'
+ ico('copy', 'Copy code') + '</button></div>'
+ '<div class="bar"><i id="totpb"></i></div></div>'; + '<div class="bar"><i id="totpb"></i></div></div>';
for (i = 0; i < detailRows.length; i++) { for (i = 0; i < detailRows.length; i++) {
var r = detailRows[i]; var r = detailRows[i];
@@ -612,9 +654,12 @@ function renderDetail(d) {
+ '" data-v="' + i + '">' + '" data-v="' + i + '">'
+ (r.s ? '&#8226;'.repeat(Math.min(r.v.length, 14)) : esc(r.v)) + (r.s ? '&#8226;'.repeat(Math.min(r.v.length, 14)) : esc(r.v))
+ '</div>' + '</div>'
+ (r.s ? '<button class="btn" data-show="' + i + '">Show</button>' : '') + (r.s ? '<button class="btn ico-btn" data-show="' + i + '">'
+ '<button class="btn" data-copy="row" data-k="' + i + '">Copy</button>' + ico('eye', 'Show') + '</button>' : '')
+ (r.e ? '<button class="btn" data-edit="' + i + '">Edit</button>' : '') + '<button class="btn ico-btn" data-copy="row" data-k="' + i + '">'
+ ico('copy', 'Copy') + '</button>'
+ (r.e ? '<button class="btn ico-btn" data-edit="' + i + '">'
+ ico('edit', 'Edit') + '</button>' : '')
+ '</div></div>'; + '</div></div>';
} }
// SUPPRIMER, tout en bas et en deux temps. Sans retour possible depuis le // 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.classList.toggle('mask', !hidden);
cell.textContent = hidden ? detailRows[k].v cell.textContent = hidden ? detailRows[k].v
: '\u2022'.repeat(Math.min(detailRows[k].v.length, 14)); : '\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'); send('touch');
return; 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 '<input class="field-in" id="editval" autocomplete="off"'
+ ' type="' + (detailRows[k].s ? 'password' : 'text') + '"'
+ ' autocorrect="off" autocapitalize="none" spellcheck="false"'
+ style + '>';
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 = '<select class="field-in" id="editval"' + style + '>';
for (i = 0; i < names.length; i++)
html += '<option value="' + esc(names[i]) + '"'
+ (names[i] === val ? ' selected' : '') + '>' + esc(names[i])
+ '</option>';
return html + '</select>';
}
// EDITEUR EN PLACE, pas window.prompt : une invite JavaScript demande au // EDITEUR EN PLACE, pas window.prompt : une invite JavaScript demande au
// moteur un WebChromeClient que FMX ne fournit pas, et prompt() rend alors // moteur un WebChromeClient que FMX ne fournit pas, et prompt() rend alors
// null sans un mot - le bouton semblerait ne rien faire. // null sans un mot - le bouton semblerait ne rien faire.
@@ -719,11 +798,7 @@ function openEditor(k) {
editField = detailRows[k].e || ''; editField = detailRows[k].e || '';
if (!editField) return; if (!editField) return;
var line = row.parentNode; var line = row.parentNode;
line.innerHTML = line.innerHTML = editorControl(k)
'<input class="field-in" id="editval" autocomplete="off"'
+ ' type="' + (detailRows[k].s ? 'password' : 'text') + '"'
+ ' autocorrect="off" autocapitalize="none" spellcheck="false"'
+ ' style="margin:0;flex:1;min-width:0;width:auto">'
// Show n'a de sens que sur un champ masque. Sur un site en clair il // 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. // n'aurait rien a reveler - et l'inverse : il le CACHERAIT.
+ (detailRows[k].s ? '<button class="btn" data-editshow>Show</button>' : '') + (detailRows[k].s ? '<button class="btn" data-editshow>Show</button>' : '')
+2 -1
View File
@@ -1016,7 +1016,8 @@ begin
// des pieces jointes et des horodatages, que PatchEntryField ecraserait sans // des pieces jointes et des horodatages, que PatchEntryField ecraserait sans
// rien y comprendre. // rien y comprendre.
if (AField <> 'title') and (AField <> 'site') 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 begin
RefuseChange('That field cannot be changed from here'); RefuseChange('That field cannot be changed from here');
Exit; Exit;