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:
+12
-2
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user