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') + + '
2FA secret
' + + '
' + + eyeButton('nTotp') + scanButton('nTotp') + '
' + '
' + '
' // 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)));