diff --git a/android-client/web/app.html b/android-client/web/app.html
index 5ad06b6..fcb7a74 100644
--- a/android-client/web/app.html
+++ b/android-client/web/app.html
@@ -1043,6 +1043,8 @@ var ICONS = {
+ ''
+ '',
close: '',
+ qr: ''
+ + '',
// Un arc, pas un cercle : c'est l'ouverture qui rend la rotation visible.
loader: '',
eyeOff: ''
@@ -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 '';
+}
+
+/// 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 '';
+}
+
+/// UN VRAI toast() GLOBAL. Sans lui, le nom designait l'element
- 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 '';
@@ -1389,9 +1426,9 @@ function newBodyControl(kind) {
return '';
- return '' + 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')
+ + '
'
// 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() {
? ''
- : '')
- + (r.e === 'password' ? genButton('editpwd') : '')
+ + (r.e === 'password' ? eyeButton('editpwd') + genButton('editpwd') : '')
+ + (r.e === 'totp_secret' ? eyeButton('edittotp') + scanButton('edittotp') : '')
+ '
';
}
// 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.
diff --git a/mainApk.pas b/mainApk.pas
index f334715..bf106e0 100644
--- a/mainApk.pas
+++ b/mainApk.pas
@@ -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,7 +3794,10 @@ begin
except
// Pas d'autofocus continu : l'image restera nette a bonne distance.
end;
- FScanLbl.Text := 'Point the camera at the QR shown on the PC';
+ 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;
Log('QR scan started');
@@ -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)));