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