From 732c52a64e7689d2eff6b24d5557d7dc77cbb526 Mon Sep 17 00:00:00 2001 From: r-zakarya <82443831+r-zakarya@users.noreply.github.com> Date: Fri, 21 Aug 2026 16:13:35 +0100 Subject: [PATCH] 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. --- android-client/PLAN.md | 14 +++++++-- android-client/PMA.WebUI.pas | 55 +++++++++++++++++++++--------------- android-client/web/app.html | 25 +++++++++++++--- mainApk.pas | 30 ++++++++++++++------ 4 files changed, 88 insertions(+), 36 deletions(-) diff --git a/android-client/PLAN.md b/android-client/PLAN.md index e9b6b94..18901bb 100644 --- a/android-client/PLAN.md +++ b/android-client/PLAN.md @@ -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. diff --git a/android-client/PMA.WebUI.pas b/android-client/PMA.WebUI.pas index c3a3f03..e245f08 100644 --- a/android-client/PMA.WebUI.pas +++ b/android-client/PMA.WebUI.pas @@ -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; @@ -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)].Caption := ACaption; + LRows[High(LRows)].Value := AValue; + LRows[High(LRows)].Secret := ASecret; + 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; diff --git a/android-client/web/app.html b/android-client/web/app.html index 4266d7c..86c3fee 100644 --- a/android-client/web/app.html +++ b/android-client/web/app.html @@ -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 = - '' - + '' + // 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 ? '' : '') + '' + ''; var input = document.getElementById('editval'); diff --git a/mainApk.pas b/mainApk.pas index 96f861f..b5db585 100644 --- a/mainApk.pas +++ b/mainApk.pas @@ -236,9 +236,11 @@ type procedure RefuseChange(const AMessage: string); procedure CommitChange(const ALabel, AComment, AReopenUuid: string; const APatch: TFunc); - 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