feat(android): every field of an entry can be changed, not just the password

PatchEntryField already took a field name; what was missing was carrying it
from the row. It travels in the detail row itself rather than being guessed
from the label - a caption is prose, and the day it changes the page would
send a field name that does not exist, or quietly stop offering the edit.

Delphi does not take it on trust either: four names accepted, the rest
refused. The snapshot also carries tombstones, attachments and timestamps
that PatchEntryField would overwrite without understanding them.

An empty row now shows IF it can be filled - a missing username you cannot
add is a dead end. Emptying is allowed, except on a password, where it would
almost always be a slip; the refusal says so rather than doing nothing.

Show only appears on a masked field: on a site in clear it has nothing to
reveal, and would in fact hide it. A note keeps only Name and Content - a
site and a username mean nothing there.
This commit is contained in:
r-zakarya
2026-08-21 16:13:35 +01:00
parent 4785dc116a
commit 732c52a64e
4 changed files with 88 additions and 36 deletions
+12 -2
View File
@@ -659,8 +659,18 @@ téléphone qu'on voudra ensuite ouvrir sur le poste demandera un import.
1. **Identité** : écran connexion / inscription, conteneur local, coffre vide.
2. **Créer une entrée** — l'étape qui rend l'application utile seule. Un uuid
neuf, `updated_at` posé ; pas de tombstone, on ne supprime pas encore.
3. **Modifier n'importe quel champ, supprimer** : `PatchEntryField` prend déjà
le nom du champ ; supprimer exige les tombstones.
3. **Modifier n'importe quel champ** — fait le 2026-08-21. `Name`, `Site`,
`Username`, `Mot de passe` (le corps pour une note) sont éditables. Le nom du
champ voyage dans la ligne du détail (`TDetailRow.Field`) plutôt que d'être
déduit du libellé côté page : un intitulé est de la prose, et le jour où il
change, la page enverrait un nom de champ inexistant. Delphi ne le croit pas
sur parole pour autant — quatre noms admis, le reste refusé, parce que
l'instantané porte aussi des tombstones et des pièces jointes que
`PatchEntryField` écraserait sans rien y comprendre.
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.
**Reste : supprimer**, qui exige les tombstones.
4. **Réconcilier avec la synchronisation** : un coffre né seul auquel on
ajoute un dépôt. C'est là que la fusion revient, et elle a été évitée
jusqu'ici pour de bonnes raisons.
+30 -19
View File
@@ -149,14 +149,17 @@ type
TDetailRow = record
Caption, Value: string;
Secret: Boolean;
/// Modifiable depuis le telephone. UN SEUL champ l'est pour l'instant - le
/// mot de passe - et c'est deliberé : creer ou supprimer une entree
/// demanderait des tombstones et la generation d'uuid, alors que changer un
/// mot de passe couvre l'essentiel du besoin sans rien de tout cela.
/// Marque ICI plutot que reconnu au libelle cote page : un intitule est de
/// la prose, et le jour ou il change la page cesserait silencieusement de
/// proposer la modification.
Editable: Boolean;
/// LE NOM DU CHAMP DANS L'INSTANTANE, vide si la ligne ne se modifie pas.
///
/// Il porte les deux choses a la fois : qu'on puisse editer, et quoi
/// editer. Marque ICI plutot que devine du libelle cote page : un intitule
/// est de la prose, et le jour ou il change la page enverrait un nom de
/// champ inexistant - ou, pire, cesserait silencieusement de proposer la
/// modification.
///
/// Supprimer une entree n'y figure pas : cela demande des tombstones, et
/// c'est l'etape 3 de la §7.
Field: string;
end;
function BuildRows(const E: TVaultEntry): TArray<TDetailRow>;
@@ -166,26 +169,33 @@ var
LMeta: string;
procedure Add(const ACaption, AValue: string; ASecret: Boolean;
AEditable: Boolean = False);
const AField: string = '');
begin
if AValue = '' then Exit; // une ligne vide n'apprend rien
// Une ligne vide n'apprend rien - SAUF si elle se remplit. Un identifiant
// absent qu'on ne peut pas ajouter serait une impasse.
if (AValue = '') and (AField = '') then Exit;
SetLength(LRows, Length(LRows) + 1);
LRows[High(LRows)].Caption := ACaption;
LRows[High(LRows)].Value := AValue;
LRows[High(LRows)].Secret := ASecret;
LRows[High(LRows)].Editable := AEditable;
LRows[High(LRows)].Field := AField;
end;
begin
Add('Site', E.Site, False);
Add('Username', E.Username, False);
Add('Name', E.Title, False, 'title');
// Le corps d'une note est range dans le champ mot de passe : meme donnee,
// autre intitule, et pas de raison de la masquer comme un secret.
// Seule la ligne du mot de passe est modifiable. Le corps d'une note le
// serait en principe, mais il se saisit sur plusieurs lignes - un autre
// controle, une autre fois.
if E.IsNote then Add('Content', E.Password, False)
else Add('Password', E.Password, True, True);
//
// Un site et un identifiant n'ont aucun sens pour une note. Les proposer
// vides reviendrait a inviter a remplir ce que rien n'affichera jamais.
if E.IsNote then
Add('Content', E.Password, False, 'password')
else
begin
Add('Site', E.Site, False, 'site');
Add('Username', E.Username, False, 'username');
Add('Password', E.Password, True, 'password');
end;
for I := 0 to High(E.Fields) do
Add(E.Fields[I].Caption, E.Fields[I].Value, E.Fields[I].Secret);
@@ -233,7 +243,8 @@ begin
LObj.AddPair('c', LRows[I].Caption);
LObj.AddPair('v', LRows[I].Value);
LObj.AddPair('s', TJSONBool.Create(LRows[I].Secret));
if LRows[I].Editable then LObj.AddPair('e', TJSONBool.Create(True));
// 'e' porte le nom du champ, et sa presence vaut "modifiable".
if LRows[I].Field <> '' then LObj.AddPair('e', LRows[I].Field);
LArr.AddElement(LObj);
end;
Result := LRoot.ToJSON;
+21 -4
View File
@@ -630,7 +630,14 @@ document.getElementById('fields').addEventListener('click', function (ev) {
if (t.getAttribute('data-editcancel') !== null) { renderDetail(lastDetail); return; }
if (t.getAttribute('data-editsave') !== null) {
var input = document.getElementById('editval');
if (!input || !input.value) return; // vide : on n'ecrase pas par megarde
if (!input) return;
// VIDER EST LEGITIME sur un site ou un identifiant - pas sur un mot de
// passe, ou ce serait presque toujours une fausse manoeuvre. Le refus se
// dit, sinon le bouton semble ne pas prendre l'appui.
if (!input.value && editField === 'password') {
toast('An empty password is not saved');
return;
}
// DIRE QUE CA TRAVAILLE. L'enregistrement tire, dechiffre, rechiffre et
// publie : plusieurs secondes pendant lesquelles rien ne bougeait, et le
// bouton semblait n'avoir pas pris l'appui.
@@ -639,7 +646,8 @@ document.getElementById('fields').addEventListener('click', function (ev) {
var cancel = document.querySelector('[data-editcancel]');
if (cancel) cancel.disabled = true;
input.disabled = true;
send('setpwd?v=' + encodeURIComponent(input.value));
send('setfield?f=' + encodeURIComponent(editField)
+ '&v=' + encodeURIComponent(input.value));
return;
}
});
@@ -651,15 +659,24 @@ document.getElementById('fields').addEventListener('click', function (ev) {
// La valeur s'affiche EN CLAIR pendant la saisie. On tape un mot de passe
// qu'on ne peut pas relire ailleurs ; le masquer ferait valider a l'aveugle
// une frappe qu'aucune confirmation ne rattrape.
var editField = '';
function openEditor(k) {
var row = document.querySelector('[data-v="' + k + '"]');
if (!row) return;
// 'e' porte le nom du champ dans l'instantane, pas un booleen : c'est Delphi
// qui decide de ce qui se modifie, la page n'a pas a le deduire d'un libelle.
editField = detailRows[k].e || '';
if (!editField) return;
var line = row.parentNode;
line.innerHTML =
'<input class="field-in" id="editval" type="password" autocomplete="off"'
'<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">'
+ '<button class="btn" data-editshow>Show</button>'
// 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 ? '<button class="btn" data-editshow>Show</button>' : '')
+ '<button class="btn" data-editcancel>Cancel</button>'
+ '<button class="btn main" data-editsave>Save</button>';
var input = document.getElementById('editval');
+22 -8
View File
@@ -236,9 +236,11 @@ type
procedure RefuseChange(const AMessage: string);
procedure CommitChange(const ALabel, AComment, AReopenUuid: string;
const APatch: TFunc<string, string>);
procedure SaveEntryPassword(const ANewValue: string);
/// Change UN champ de l'entree ouverte. Le nom du champ vient de la page,
/// et n'est donc pas cru sur parole.
procedure SaveEntryField(const AField, ANewValue: string);
/// Ajoute une entree au coffre. Le mot de passe voyage par le pont,
/// comme 'setpwd' - meme regle, meme journalisation muette.
/// comme 'setfield' - meme regle, meme journalisation muette.
procedure CreateEntry(const ATitle, ASite, AUser, APwd, AKind: string);
procedure ReopenEntryByUuid(const AUuid: string);
procedure OnClipTimer(Sender: TObject);
@@ -870,7 +872,7 @@ begin
end).Start;
end;
procedure TForm8.SaveEntryPassword(const ANewValue: string);
procedure TForm8.SaveEntryField(const AField, ANewValue: string);
var
LUuid, LTitle: string;
begin
@@ -879,12 +881,23 @@ begin
RefuseChange('This entry has no uuid - cannot be updated');
Exit;
end;
// LA PAGE NE CHOISIT PAS QUOI ECRIRE. Le nom vient d'elle, mais il traverse
// le pont, et ce qui traverse un pont se traite comme une entree exterieure.
// Ces quatre-la seulement : le reste de l'instantane porte des tombstones,
// 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
begin
RefuseChange('That field cannot be changed from here');
Exit;
end;
LUuid := FCurrent.Uuid;
LTitle := FCurrent.Display;
CommitChange('Saved: ' + LTitle, 'password change from phone', LUuid,
CommitChange('Saved: ' + LTitle, AField + ' change from phone', LUuid,
function (APlain: string): string
begin
Result := PatchEntryField(APlain, LUuid, 'password', ANewValue);
Result := PatchEntryField(APlain, LUuid, AField, ANewValue);
end);
end;
@@ -1027,7 +1040,7 @@ begin
else if LAction = 'sync' then
DoSync(nil)
// AVEC 'setpwd', LES SEULES COMMANDES QUI TRANSPORTENT UN SECRET. Meme
// AVEC 'setfield', LES SEULES COMMANDES QUI TRANSPORTENT UN SECRET. Meme
// regle qu'elle : la valeur ne quitte pas le processus, l'iframe qui la
// porte est detruite, et seule l'action est journalisee - jamais le mot de
// passe, ni sa longueur.
@@ -1048,8 +1061,9 @@ begin
TNetEncoding.URL.Decode(Param('p')),
TNetEncoding.URL.Decode(Param('k')))
else if LAction = 'setpwd' then
SaveEntryPassword(TNetEncoding.URL.Decode(Param('v')))
else if LAction = 'setfield' then
SaveEntryField(TNetEncoding.URL.Decode(Param('f')),
TNetEncoding.URL.Decode(Param('v')))
else if LAction = 'copy' then
begin