Files
Password-Manager/android-client/web/app.html
T
r-zakarya c73ac66313 fix(android): the token is stored, not displayed
Kept in settings.bin, because the silent refresh needs it and so does the
first download on a fresh device - there is no vault to hide it in yet. But it
was the only secret on that screen shown in clear, right beside a masked
password field, and it is also the least protected of the three: settings.bin
is sealed by the no-auth Keystore key, readable without authentication, unlike
the vault.

Masked now. It is typed once, and reading it back has no use - a wrong token
shows up as a 401 in the log, not by squinting at the field.

What it opens stays worth knowing: read access to the account's repositories,
whose only content is ciphertext.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 16:49:06 +01:00

566 lines
22 KiB
HTML

<!doctype html>
<!--
Interface du client Android. Liste, recherche, dossiers, detail.
ASCII UNIQUEMENT dans ce fichier. FMX charge la page par
loadDataWithBaseURL en laissant l'encodage a nil : le moteur lit alors de
l'UTF-8 comme du latin-1. Tout caractere hors ASCII doit s'ecrire en
reference numerique (&#1605;). Les DONNEES, elles, n'ont pas ce probleme :
elles arrivent par evaluateJavascript, qui passe par une chaine JNI.
CE QUI VIT ICI ET CE QUI N'Y VIT PAS. La liste recoit les noms, sites et
identifiants - ce qui est deja a l'ecran. Elle ne recoit AUCUN mot de passe :
copier depuis une ligne envoie un indice, et Delphi copie depuis le coffre.
Seule l'entree ouverte a ses valeurs ici, une a la fois, effacees a la
fermeture et au passage en arriere-plan.
-->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<title>Vault</title>
<style>
:root {
--bg-top: #3E6394;
--bg-bot: #1B3563;
--surface: rgba(255,255,255,.12);
--raised: rgba(255,255,255,.18);
--accent: rgba(255,255,255,.30);
--text: #fff;
--muted: rgba(232,240,250,.70);
--radius: 20px;
--radius-s: 14px;
}
* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }
html, body {
/* CLOUE AU CADRE. overflow:hidden ne suffit pas dans un WebView Android :
un glissement qui ne trouve rien a faire defiler fait quand meme
glisser le document entier, et on voit alors le degrade du corps passer
sous celui de l'ecran - deux degrades empiles, sans le moindre contenu.
position:fixed lui retire cette liberte. */
position: fixed; inset: 0;
margin: 0; overflow: hidden;
overscroll-behavior: none;
background: var(--bg-bot);
color: var(--text);
font: 15px/1.4 system-ui, "Roboto", sans-serif;
/* Le texte de l'interface ne se selectionne pas - un appui maintenu sur
une ligne doit ouvrir l'entree, pas armer une poignee de selection.
Les valeurs du detail font exception, plus bas. */
-webkit-user-select: none; user-select: none;
}
/* --- ecran : les deux vues glissent l'une sur l'autre ------------------ */
.screen {
position: absolute; inset: 0;
display: flex; flex-direction: column;
transition: transform .22s ease;
/* CHAQUE ECRAN PORTE SON FOND. Sans cela ils sont transparents, celui du
corps se voit a travers les deux, et la liste reste lisible sous le
detail - dont les blocs sont eux-memes du blanc translucide. */
background: linear-gradient(var(--bg-top), var(--bg-bot));
}
/* Trois vues sur un rail. Le deverrouillage est a gauche, la liste au
milieu, le detail a droite ; deux classes sur le corps disent ou on est. */
#list, #detail { transform: translateX(100%); }
body.on-list #unlock { transform: translateX(-100%); }
body.on-list #list { transform: translateX(0); }
body.on-detail #unlock { transform: translateX(-100%); }
body.on-detail #list { transform: translateX(-22%); }
body.on-detail #detail { transform: translateX(0); }
.scroll { flex: 1; overflow-y: auto; -webkit-overflow-scrolling: touch;
overscroll-behavior: contain; padding: 0 12px 16px; }
/* --- barre de recherche ------------------------------------------------ */
.top { padding: 10px 12px 6px; display: flex; gap: 8px; }
.search { flex: 1; position: relative; }
.search input {
width: 100%; height: 46px; padding: 0 38px 0 40px;
border: 0; border-radius: var(--radius-s);
background: rgba(255,255,255,.10); color: var(--text);
font-size: 15px; outline: none;
}
.search input::placeholder { color: var(--muted); }
.glass, .clear {
position: absolute; top: 0; height: 46px; width: 38px;
display: flex; align-items: center; justify-content: center;
color: var(--muted); font-size: 17px;
}
.glass { left: 2px; }
.glass svg { width: 19px; height: 19px; }
.clear { right: 2px; display: none; font-size: 20px; }
body.searching .glass { display: none; }
body.searching .clear { display: flex; }
/* --- dossiers : une rangee de pastilles, pas un panneau ---------------- */
.folders {
display: flex; gap: 8px; overflow-x: auto; padding: 4px 12px 8px;
scrollbar-width: none;
}
.folders::-webkit-scrollbar { display: none; }
.chip {
flex: 0 0 auto; padding: 8px 14px; border-radius: 999px;
background: rgba(255,255,255,.10); color: var(--muted);
font-size: 13px; white-space: nowrap;
}
.chip.on { background: var(--accent); color: var(--text); font-weight: 500; }
.chip .n { opacity: .6; margin-left: 6px; }
/* --- lignes ------------------------------------------------------------ */
.row {
display: flex; align-items: center; gap: 10px;
background: var(--surface); border-radius: var(--radius);
padding: 10px 10px 10px 12px; margin-bottom: 8px;
}
.row:active { background: var(--raised); }
.av {
flex: 0 0 40px; height: 40px; border-radius: 20px;
background: rgba(255,255,255,.18);
display: flex; align-items: center; justify-content: center;
font-weight: 600; font-size: 16px; overflow: hidden;
}
/* PAS DE PASTILLE SOUS UNE ICONE. Le rond translucide sert de fond aux
initiales ; une favicon a son propre fond transparent, et le rond se
voyait donc au travers et autour d'elle - une soucoupe sous le dessin.
`contain` plutot que `cover` : une icone n'est pas une photo, on ne la
recadre pas. */
.av.has-img { background: none; }
.av img { width: 100%; height: 100%; object-fit: contain; }
.txt { flex: 1; min-width: 0; }
.txt b { display: block; font-weight: 500; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.txt span { display: block; color: var(--muted); font-size: 13px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.btn {
flex: 0 0 auto; border: 0; border-radius: var(--radius-s);
background: var(--raised); color: var(--text);
padding: 11px 13px; font-size: 13px; font-family: inherit;
}
.btn:active { opacity: .62; }
.btn.wide { padding: 13px 18px; font-size: 15px; }
.btn.main { background: var(--accent); }
.empty { text-align: center; color: var(--muted); padding: 48px 16px; }
/* --- boutons a icone ---------------------------------------------------- */
/* Une icone seule ne dit ce qu'elle fait que si elle est archi-connue. Le
cadenas l'est ; c'est pourquoi il est le seul a se passer d'intitule. */
.ico {
flex: 0 0 46px; width: 46px; height: 46px; border: 0; padding: 0;
border-radius: var(--radius-s); background: rgba(255,255,255,.10);
color: var(--text); display: flex; align-items: center;
justify-content: center;
}
.ico:active { opacity: .62; }
.ico svg { width: 21px; height: 21px; }
/* --- detail ------------------------------------------------------------ */
.head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; }
.head h1 { flex: 1; margin: 0; font-size: 19px; font-weight: 600;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.field {
background: var(--surface); border-radius: var(--radius);
padding: 10px 10px 12px 14px; margin-bottom: 8px;
}
.field .cap { color: var(--muted); font-size: 12px; margin-bottom: 4px; }
.field .line { display: flex; align-items: center; gap: 8px; }
.field .val {
flex: 1; min-width: 0; word-break: break-all;
-webkit-user-select: text; user-select: text;
}
.field .val.mask { letter-spacing: 2px; }
.field.totp .val { font-size: 30px; font-weight: 300; letter-spacing: 2px; }
.bar { height: 3px; border-radius: 2px; background: rgba(255,255,255,.18);
margin-top: 10px; overflow: hidden; }
.bar i { display: block; height: 100%; background: var(--text);
transition: width 1s linear; }
/* --- deverrouillage ----------------------------------------------------- */
.status { color: var(--muted); margin: 22px 4px 14px; min-height: 20px; }
.stack { display: flex; flex-direction: column; gap: 8px; }
.pair { display: flex; gap: 8px; }
.pair .btn { flex: 1; }
.field-in {
width: 100%; height: 46px; padding: 0 14px; margin-top: 8px;
border: 0; border-radius: var(--radius-s);
background: rgba(255,255,255,.10); color: var(--text);
font: inherit; outline: none;
}
.field-in::placeholder { color: var(--muted); }
#log {
margin: 10px 0 0; padding: 10px 12px; max-height: 34vh; overflow: auto;
background: rgba(0,0,0,.20); border-radius: var(--radius-s);
font: 11px/1.45 monospace; white-space: pre-wrap; word-break: break-all;
-webkit-user-select: text; user-select: text;
}
[hidden] { display: none !important; }
.toast {
position: fixed; left: 12px; right: 12px; bottom: 14px;
background: rgba(10,20,40,.92); color: var(--text);
border-radius: var(--radius-s); padding: 12px 16px; font-size: 14px;
text-align: center; opacity: 0; transition: opacity .2s;
pointer-events: none;
}
.toast.on { opacity: 1; }
</style>
<div class="screen" id="unlock">
<div class="scroll">
<div class="status" id="status"></div>
<div class="stack">
<button class="btn wide main" id="btnOpen">&#10140;&nbsp; Open vault</button>
<button class="btn wide" id="btnSync">&#8635;&nbsp; Sync</button>
<div class="pair">
<button class="btn" id="btnCfg">&#9881;&nbsp; Server settings</button>
<button class="btn" id="btnLog">&#8801;&nbsp; Log</button>
</div>
</div>
<div id="cfg" hidden>
<input class="field-in" id="url" placeholder="endpoint URL"
autocomplete="off" autocorrect="off" autocapitalize="none"
spellcheck="false" inputmode="url">
<!-- MASQUE, comme le mot de passe juste en dessous. Il s'affichait en
clair alors qu'il ouvre le depot : le moins grave des trois secrets
de cet ecran, mais le seul qu'un regard par-dessus l'epaule
attrapait. On le saisit une fois ; le relire n'a aucun usage. -->
<input class="field-in" id="tok" placeholder="bearer token"
type="password" autocomplete="off">
<input class="field-in" id="pwd" placeholder="sync password"
type="password" autocomplete="off">
</div>
<pre id="log" hidden></pre>
</div>
</div>
<div class="screen" id="list">
<div class="top">
<div class="search">
<div class="glass">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round">
<circle cx="11" cy="11" r="6.5" /><path d="M16 16l4.5 4.5" />
</svg>
</div>
<input id="q" placeholder="Search" autocomplete="off" autocorrect="off"
autocapitalize="none" spellcheck="false">
<div class="clear" id="qx">&#215;</div>
</div>
<button class="ico" id="lock" aria-label="Lock vault">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2" stroke-linecap="round">
<path d="M7.5 10.5V7a4.5 4.5 0 0 1 9 0v3.5" />
<rect x="4" y="10.5" width="16" height="10.5" rx="3" fill="currentColor"
stroke="none" />
</svg>
</button>
</div>
<div class="folders" id="folders"></div>
<div class="scroll"><div id="rows"></div></div>
</div>
<div class="screen" id="detail">
<div class="head">
<button class="btn" id="back">&#8592;</button>
<h1 id="dtitle"></h1>
</div>
<div class="scroll"><div id="fields"></div></div>
</div>
<div class="toast" id="toast"></div>
<script>
"use strict";
// --- pont vers Delphi ------------------------------------------------------
// PAS location.href, UNE IFRAME JETABLE. FMX ecoute bien la navigation, mais
// il rend toujours "je ne la prends pas en charge" au moteur, qui essaie alors
// de charger cmd:// lui-meme, echoue sur un schema inconnu et remplace la page
// par son ecran d'erreur. Dans une iframe, l'echec reste confine et la page
// vit. C'est le prix a payer pour un pont sans interface Java.
//
// Et les commandes partent UNE PAR UNE : deux navigations dans le meme tour de
// boucle se fondent en une seule, et la premiere disparait - sur une copie,
// cela donnerait un presse-papiers vide sans rien signaler.
var queue = [], sending = false;
function send(cmd) {
queue.push(cmd);
if (!sending) drain();
}
function drain() {
if (!queue.length) { sending = false; return; }
sending = true;
var f = document.createElement('iframe');
f.style.display = 'none';
f.src = 'cmd://' + queue.shift();
document.body.appendChild(f);
setTimeout(function () {
// LE try/finally EST L'INTERET DE CE BLOC. Sans lui, une exception au
// retrait de l'iframe laisse "sending" a vrai pour toujours : plus une
// seule commande ne repart, et l'interface devient muette sans rien
// signaler. Le meme piege existait cote Delphi sur l'indicateur d'occupation.
try {
if (f.parentNode) f.parentNode.removeChild(f);
} finally {
drain();
}
}, 60);
}
var entries = [], folders = [], folder = '', query = '', view = 'unlock';
// --- deverrouillage --------------------------------------------------------
var elUrl = document.getElementById('url'),
elTok = document.getElementById('tok'),
elPwd = document.getElementById('pwd');
// Les trois valeurs remontent A LA PERTE DU FOCUS, pas a chaque touche : c'est
// une navigation par champ modifie au lieu d'une par caractere. Delphi les
// garde, la page n'est qu'un editeur.
function pushConfig() {
send('cfg?u=' + encodeURIComponent(elUrl.value.trim())
+ '&t=' + encodeURIComponent(elTok.value.trim())
+ '&p=' + encodeURIComponent(elPwd.value));
}
[elUrl, elTok, elPwd].forEach(function (el) {
el.addEventListener('change', pushConfig);
el.addEventListener('blur', pushConfig);
});
document.getElementById('btnOpen').addEventListener('click', function () {
send('openvault');
});
document.getElementById('btnSync').addEventListener('click', function () {
pushConfig(); // ce qui est tape mais pas encore quitte compte aussi
send('sync');
});
document.getElementById('btnCfg').addEventListener('click', function () {
var c = document.getElementById('cfg');
c.hidden = !c.hidden;
});
document.getElementById('btnLog').addEventListener('click', function () {
var l = document.getElementById('log');
l.hidden = !l.hidden;
});
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
return { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c];
});
}
// --- liste -----------------------------------------------------------------
function matches(e) {
if (folder && e.folder !== folder) return false;
if (!query) return true;
return (e.name + ' ' + e.sub + ' ' + (e.tags || '')).toLowerCase()
.indexOf(query) >= 0;
}
function renderFolders() {
var counts = {}, total = 0, i;
for (i = 0; i < entries.length; i++) {
counts[entries[i].folder] = (counts[entries[i].folder] || 0) + 1;
total++;
}
var html = '<div class="chip' + (folder ? '' : ' on') + '" data-f="">All'
+ '<span class="n">' + total + '</span></div>';
for (i = 0; i < folders.length; i++) {
var f = folders[i], n = counts[f] || 0;
if (!n) continue; // un dossier vide n'a rien a offrir
html += '<div class="chip' + (folder === f ? ' on' : '') + '" data-f="'
+ esc(f) + '">' + esc(f) + '<span class="n">' + n + '</span></div>';
}
document.getElementById('folders').innerHTML = html;
}
function renderRows() {
var html = '', shown = 0;
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
if (!matches(e)) continue;
shown++;
html += '<div class="row" data-i="' + e.i + '">'
+ '<div class="av' + (e.icon ? ' has-img' : '') + '">'
+ (e.icon ? '<img src="' + esc(e.icon) + '">' : esc(e.initial))
+ '</div>'
+ '<div class="txt"><b>' + esc(e.name) + '</b>'
+ (e.sub ? '<span>' + esc(e.sub) + '</span>' : '') + '</div>'
+ (e.hasUser ? '<button class="btn" data-copy="user">User</button>' : '')
+ '<button class="btn" data-copy="pwd">' + (e.isNote ? 'Text' : 'Pass')
+ '</button></div>';
}
document.getElementById('rows').innerHTML = shown ? html
: '<div class="empty">Nothing here</div>';
}
// Un seul ecouteur pour toute la liste : les lignes sont refaites a chaque
// frappe, en attacher un par bouton les recreerait autant de fois.
document.getElementById('rows').addEventListener('click', function (ev) {
var row = ev.target.closest('.row');
if (!row) return;
var copy = ev.target.getAttribute('data-copy');
if (copy) send('copy?i=' + row.getAttribute('data-i') + '&f=' + copy);
else send('open?i=' + row.getAttribute('data-i'));
});
document.getElementById('folders').addEventListener('click', function (ev) {
var chip = ev.target.closest('.chip');
if (!chip) return;
folder = chip.getAttribute('data-f');
renderFolders();
renderRows();
send('touch');
});
var q = document.getElementById('q');
q.addEventListener('input', function () {
query = q.value.trim().toLowerCase();
document.body.classList.toggle('searching', q.value !== '');
renderRows();
send('touch');
});
document.getElementById('lock').addEventListener('click', function () {
send('lock');
});
document.getElementById('qx').addEventListener('click', function () {
q.value = ''; query = '';
document.body.classList.remove('searching');
renderRows();
});
// --- detail ----------------------------------------------------------------
var detailRows = [];
function renderDetail(d) {
detailRows = d.rows || [];
document.getElementById('dtitle').textContent = d.title || '';
var html = '', i;
if (d.totp)
html += '<div class="field totp" id="totp"><div class="cap">2FA code</div>'
+ '<div class="line"><div class="val" id="totpv">------</div>'
+ '<button class="btn" data-copy="totp">Copy</button></div>'
+ '<div class="bar"><i id="totpb"></i></div></div>';
for (i = 0; i < detailRows.length; i++) {
var r = detailRows[i];
html += '<div class="field"><div class="cap">' + esc(r.c) + '</div>'
+ '<div class="line"><div class="val' + (r.s ? ' mask' : '')
+ '" data-v="' + i + '">'
+ (r.s ? '&#8226;'.repeat(Math.min(r.v.length, 14)) : esc(r.v))
+ '</div>'
+ (r.s ? '<button class="btn" data-show="' + i + '">Show</button>' : '')
+ '<button class="btn" data-copy="row" data-k="' + i + '">Copy</button>'
+ '</div></div>';
}
document.getElementById('fields').innerHTML = html;
document.body.classList.add('on-detail');
view = 'detail';
send('state?view=detail');
}
document.getElementById('fields').addEventListener('click', function (ev) {
var t = ev.target, k = t.getAttribute('data-show');
if (k !== null) {
var cell = document.querySelector('[data-v="' + k + '"]');
var hidden = cell.classList.contains('mask');
cell.classList.toggle('mask', !hidden);
cell.textContent = hidden ? detailRows[k].v
: '\u2022'.repeat(Math.min(detailRows[k].v.length, 14));
t.textContent = hidden ? 'Hide' : 'Show';
send('touch');
return;
}
var copy = t.getAttribute('data-copy');
if (copy === 'totp') send('copy?f=totp');
else if (copy === 'row') send('copy?f=row&k=' + t.getAttribute('data-k'));
});
function goList() {
document.body.classList.remove('on-detail');
document.body.classList.add('on-list');
view = 'list';
// Les valeurs en clair partent avec la vue, pas seulement de l'ecran.
detailRows = [];
document.getElementById('fields').innerHTML = '';
send('state?view=list');
}
document.getElementById('back').addEventListener('click', goList);
// --- appele par Delphi -----------------------------------------------------
var PM = {
setVault: function (json) {
var d = JSON.parse(json);
entries = d.entries || [];
folders = d.folders || [];
folder = '';
q.value = ''; query = '';
document.body.classList.remove('searching', 'on-detail');
document.body.classList.add('on-list');
view = 'list';
renderFolders();
renderRows();
},
showUnlock: function () {
document.body.classList.remove('on-list', 'on-detail', 'searching');
view = 'unlock';
},
setStatus: function (text) {
document.getElementById('status').textContent = text;
},
/// Etat des deux actions : ouvrir n'a de sens qu'avec un coffre local
/// lisible, et synchroniser s'appelle autrement quand il n'y en a pas.
setActions: function (canOpen, syncLabel, busy) {
var o = document.getElementById('btnOpen'), s = document.getElementById('btnSync');
o.hidden = !canOpen;
// textContent et non innerHTML : l'intitule vient de Delphi, et une valeur
// interpretee comme du balisage n'a pas de raison de l'etre.
s.textContent = '\u21BB ' + syncLabel;
o.disabled = busy; s.disabled = busy;
o.style.opacity = busy ? .4 : 1;
s.style.opacity = busy ? .4 : 1;
},
setConfig: function (url, tok, pwd, show) {
elUrl.value = url; elTok.value = tok; elPwd.value = pwd;
// Replier des champs vides revient a cacher la seule chose a faire.
document.getElementById('cfg').hidden = !show;
},
log: function (line) {
var l = document.getElementById('log');
l.textContent += line + '\n';
l.scrollTop = l.scrollHeight;
},
clearLog: function () { document.getElementById('log').textContent = ''; },
setDetail: function (json) { renderDetail(JSON.parse(json)); },
setTotp: function (code, left, period) {
var v = document.getElementById('totpv');
if (!v) return;
v.textContent = code.length === 6 ? code.slice(0, 3) + ' ' + code.slice(3) : code;
var b = document.getElementById('totpb');
// Le retour a plein ne s'anime pas : la transition jouerait le saut a
// l'envers et donnerait l'impression que la barre se fige.
b.style.transition = left >= period - 1 ? 'none' : 'width 1s linear';
b.style.width = (left / period * 100) + '%';
},
toast: function (text) {
var t = document.getElementById('toast');
t.textContent = text;
t.classList.add('on');
clearTimeout(t._h);
t._h = setTimeout(function () { t.classList.remove('on'); }, 2600);
},
back: function () { if (view === 'detail') goList(); },
// Efface tout ce qui est en clair. Appele au passage en arriere-plan et au
// verrouillage : le tas JS n'est pas efface par le masquage de la vue.
wipe: function () {
entries = []; folders = []; detailRows = [];
document.getElementById('rows').innerHTML = '';
document.getElementById('fields').innerHTML = '';
document.getElementById('folders').innerHTML = '';
q.value = '';
document.body.classList.remove('on-list', 'on-detail', 'searching');
view = 'unlock';
}
};
window.PM = PM;
</script>