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:
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 ? '•'.repeat(Math.min(r.v.length, 14)) : esc(r.v))
|
+ (r.s ? '•'.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
@@ -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;
|
||||||
|
|||||||
Reference in New Issue
Block a user