feat(apk): afficher/masquer les secrets, scanner le QR 2FA, et toast() reparé

- Mot de passe et secret 2FA naissent masques dans la creation et l'edition,
  avec un oeil pour les reveler.
- Un bouton QR dans le champ 2FA ouvre la camera du scan des reglages ; le lien
  otpauth:// lu part a la page, qui n'en garde que le secret. Le journal ne note
  que le fait.
- toast() designait l'element <div id="toast"> (chaque id est une variable
  globale) : sept appels de la page levaient "toast is not a function" et leur
  message ne s'affichait jamais. Une vraie fonction globale les repare tous.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
r-zakarya
2026-09-28 11:20:31 +01:00
parent 22c418f933
commit 3acf78b1ca
2 changed files with 85 additions and 9 deletions
+64 -6
View File
@@ -1043,6 +1043,8 @@ var ICONS = {
+ '<circle cx="9" cy="9" r="1.2"/><circle cx="15" cy="15" r="1.2"/>'
+ '<circle cx="15" cy="9" r="1.2"/><circle cx="9" cy="15" r="1.2"/>',
close: '<path d="M6 6l12 12M18 6L6 18"/>',
qr: '<rect x="4" y="4" width="6" height="6" rx="1"/><rect x="14" y="4" width="6" height="6" rx="1"/>'
+ '<rect x="4" y="14" width="6" height="6" rx="1"/><path d="M14 14h2v2h-2zM20 14v.01M14 20h.01M17 20h3v-3"/>',
// Un arc, pas un cercle : c'est l'ouverture qui rend la rotation visible.
loader: '<path d="M12 3a9 9 0 019 9"/>',
eyeOff: '<path d="M4 4l16 16"/>'
@@ -1141,6 +1143,18 @@ document.getElementById('fields').addEventListener('click', function (ev) {
send('touch');
return;
}
var eye = t.getAttribute('data-eye');
if (eye) {
var ef = document.getElementById(eye);
if (!ef) return;
var shown = ef.type === 'text';
ef.type = shown ? 'password' : 'text';
t.innerHTML = ico(shown ? 'eye' : 'eyeOff', shown ? 'Show' : 'Hide');
send('touch');
return;
}
var sc = t.getAttribute('data-scan');
if (sc) { totpTarget = sc; send('scanqr?for=totp'); return; }
var ia = t.getAttribute('data-iconact');
if (ia !== null) {
// L'ICONE SE COMMET COMME LE RESTE, donc Delphi rouvre l'entree et la vue
@@ -1379,6 +1393,29 @@ function genPassword(n) {
}
return out;
}
/// AFFICHER / MASQUER. Le champ nait masque : on ouvre un formulaire dans un
/// lieu public autant qu'on le remplit, et le mot de passe n'a pas a
/// s'afficher tant qu'on ne le demande pas. Un appui le revele pour relire ce
/// qu'on tape.
function eyeButton(target) {
return '<button class="btn ico-btn" data-eye="' + target + '">'
+ ico('eye', 'Show') + '</button>';
}
/// SCANNER LE QR QU'AFFICHE LE SITE a l'activation de la 2FA. La camera est
/// celle du scan des reglages ; seul ce qu'elle cherche change.
function scanButton(target) {
return '<button class="btn ico-btn" data-scan="' + target + '">'
+ ico('qr', 'Scan the 2FA QR code') + '</button>';
}
/// UN VRAI toast() GLOBAL. Sans lui, le nom designait l'element <div
/// id="toast"> - le navigateur expose chaque id comme variable globale - et
/// chaque appel toast('...') de cette page levait "toast is not a function" :
/// le message ne s'affichait pas, et le reste du gestionnaire ne s'executait
/// pas davantage. Une declaration de fonction l'emporte sur l'id.
function toast(text) { PM.toast(text); }
function genButton(target) {
return '<button class="btn ico-btn" data-gen="' + target + '">'
+ ico('dice', 'Generate') + '</button>';
@@ -1389,9 +1426,9 @@ function newBodyControl(kind) {
return '<textarea class="field-in" id="nPwd" rows="6" enterkeyhint="enter"'
+ ' autocorrect="off" spellcheck="false" style="' + FIELD_STYLE
+ ';height:auto;padding:10px 14px;resize:vertical"></textarea>';
return '<input class="field-in" id="nPwd" type="text" autocomplete="off"'
return '<input class="field-in" id="nPwd" type="password" autocomplete="off"'
+ ' autocorrect="off" autocapitalize="none" spellcheck="false"'
+ ' style="' + FIELD_STYLE + '">' + genButton('nPwd');
+ ' style="' + FIELD_STYLE + '">' + eyeButton('nPwd') + genButton('nPwd');
}
function openNewEntry() {
@@ -1423,7 +1460,11 @@ function openNewEntry() {
// deja : on pouvait poser un second facteur sur une entree existante, pas
// sur une entree qu'on venait de creer - il fallait enregistrer puis
// rouvrir. Meme intitule que l'editeur pour qu'on reconnaisse le champ.
+ newField('nTotp', '2FA secret', 'nTotpRow')
+ '<div class="field" id="nTotpRow"><div class="cap">2FA secret</div>'
+ '<div class="line"><input class="field-in" id="nTotp" type="password"'
+ ' autocomplete="off" autocorrect="off" autocapitalize="characters"'
+ ' spellcheck="false" style="' + FIELD_STYLE + '">'
+ eyeButton('nTotp') + scanButton('nTotp') + '</div></div>'
+ '</div>'
+ '<div class="line" style="display:flex;margin-top:12px">'
// flex:1 et non .wide : .wide ne regle que le rembourrage, et la largeur
@@ -1530,6 +1571,8 @@ function openMenu() {
/// Ce que le formulaire porte a cet instant, par nom de champ. Sert a le
/// rouvrir tel quel apres une action d'icone, qui passe par un enregistrement.
var editAgain = null;
/// Le champ 2FA qui attend le resultat du scan - creation ou edition.
var totpTarget = '';
/// Le formulaire occupe l'ecran du detail : 'view' reste donc 'detail', et
/// c'est ce drapeau qui dit qu'on y modifie - la fleche et la touche retour
/// s'en servent pour refermer le formulaire avant de quitter l'entree.
@@ -1564,11 +1607,15 @@ function openEditEntry() {
? '<textarea class="field-in" data-ef="' + esc(r.e) + '" rows="5"'
+ ' enterkeyhint="enter" autocorrect="off" spellcheck="false"'
+ ' style="' + FIELD_STYLE + ';height:auto;resize:vertical"></textarea>'
: '<input class="field-in"' + (r.e === 'password' ? ' id="editpwd"' : '')
+ ' data-ef="' + esc(r.e) + '" type="text"'
: '<input class="field-in"'
+ (r.e === 'password' ? ' id="editpwd"'
: r.e === 'totp_secret' ? ' id="edittotp"' : '')
+ ' data-ef="' + esc(r.e) + '" type="'
+ (r.e === 'password' || r.e === 'totp_secret' ? 'password' : 'text') + '"'
+ ' autocomplete="off" autocorrect="off" autocapitalize="none"'
+ ' spellcheck="false" style="' + FIELD_STYLE + '">')
+ (r.e === 'password' ? genButton('editpwd') : '')
+ (r.e === 'password' ? eyeButton('editpwd') + genButton('editpwd') : '')
+ (r.e === 'totp_secret' ? eyeButton('edittotp') + scanButton('edittotp') : '')
+ '</div></div>';
}
// L'ICONE SE CHANGE OU SE CHANGENT LES CHAMPS. Elle vivait dans le menu de
@@ -2250,6 +2297,17 @@ var PM = {
if (view === 'detail') goList();
},
cancelEdit: function () { renderDetail(lastDetail); },
/// Le QR d'un site porte otpauth://totp/Label?secret=XXXX&issuer=... - on
/// n'en garde que le secret, comme le champ l'attend. Un QR sans secret
/// (un autre lien) ne touche a rien.
totpFromQr: function (uri) {
var m = /[?&]secret=([^&#]+)/i.exec(uri || '');
var el = document.getElementById(totpTarget);
if (!m || !el) { PM.toast('Not a 2FA QR code'); return; }
el.value = decodeURIComponent(m[1]).replace(/\s+/g, '').toUpperCase();
PM.toast('2FA secret read from the QR code');
},
/// La touche retour annule la selection avant de verrouiller le coffre.
/// Sans cela, un geste d'annulation fermait le coffre - le contraire de ce
/// qu'il veut dire.
+20 -2
View File
@@ -386,6 +386,9 @@ type
// Scanner du QR affiche par le poste (ZXing.Delphi, Apache 2.0).
var
FScanning, FScanBusy: Boolean;
/// Le scan en cours cherche un QR de 2FA (otpauth://) et non le lien des
/// reglages. Pose par 'scanqr?for=totp', lu au decodage.
FScanTotp: Boolean;
/// L'action de la galerie survit a l'appel : le selecteur d'Android rend
/// la main tout de suite et rappelle plus tard.
FPickIcon: TTakePhotoFromLibraryAction;
@@ -2062,7 +2065,10 @@ begin
// SCANNER LE QR DU POSTE depuis l'app : le scanner du systeme envoyait
// le lien a une recherche web ou demandait avec quoi l'ouvrir.
else if LAction = 'scanqr' then
StartScan
begin
FScanTotp := Param('for') = 'totp';
StartScan;
end
else if LAction = 'pinclear' then
ClearUnlockCode(TNetEncoding.URL.Decode(Param('c')))
@@ -3788,6 +3794,9 @@ begin
except
// Pas d'autofocus continu : l'image restera nette a bonne distance.
end;
if FScanTotp then
FScanLbl.Text := 'Point the camera at the 2FA QR code'
else
FScanLbl.Text := 'Point the camera at the QR shown on the PC';
FCam.Active := True;
FScanWatch.Enabled := True;
@@ -3875,7 +3884,16 @@ begin
// CHERCHE, NE COMPARE PAS LE DEBUT : le texte decode est arrive
// avec un caractere invisible en tete (104 lus pour 103 emis),
// et le lien etait ignore alors qu'il etait lu.
if Pos('pmserver://sync?', LText) > 0 then
// 2FA : le lien otpauth:// porte le SECRET. Il part a la page,
// qui le range dans le champ ; le journal n'en dit que le fait.
if FScanTotp and (Pos('otpauth://', LowerCase(LText)) > 0) then
begin
StopScan;
Log('QR scan: 2FA code read');
WebCall('PM.totpFromQr(' + JsArg(Trim(Copy(LText,
Pos('otpauth://', LowerCase(LText)), MaxInt))) + ')');
end
else if (not FScanTotp) and (Pos('pmserver://sync?', LText) > 0) then
begin
StopScan;
ApplySyncLink(Trim(Copy(LText, Pos('pmserver://sync?', LText), MaxInt)));