90e8fabf58
TSyncClient.Put publishes a container to the Gitea contents API: token header, JSON envelope with the file base64 inside, sha omitted rather than empty when creating, branch omitted so the repo default is used. 409 becomes its own status, ssConflict, because it means re-read and re-apply rather than retry. The first PUT is where a mistake costs data, so the probe writes to write-test.json rather than the vault. Same repository, same token, same envelope, same concurrency check - and the vault is never at stake. Reachable from a debug-only button; the page asks Delphi whether to show it. Two mistakes of mine on the way, both about replacing information with a guess: - the probe did not push the config first, unlike Sync, so a token that had just been pasted had not reached Delphi and the 403 blamed the wrong thing. - 401 and 403 were rewritten as "token lacks write access", discarding Gitea's body. That is an interpretation, not an observation: the same code covers a read-only repository, a protected branch, a token from another account. The body says which - here, plainly, "token scope=read:repository". Also recorded: the sha comparison now short-circuits (304 where it used to decrypt), so the difference was case or whitespace, absorbed by SameText on trimmed values. The offending character itself was never seen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
581 lines
23 KiB
HTML
581 lines
23 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 (م). 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">➜ Open vault</button>
|
|
<button class="btn wide" id="btnSync">↻ Sync</button>
|
|
<div class="pair">
|
|
<button class="btn" id="btnCfg">⚙ Server settings</button>
|
|
<button class="btn" id="btnLog">≡ Log</button>
|
|
</div>
|
|
<!-- Essai du chemin d'ecriture, visible uniquement en build de debogage :
|
|
Delphi appelle PM.setDebug(true). Il ecrit dans un AUTRE fichier du
|
|
depot, jamais dans le coffre. -->
|
|
<button class="btn wide" id="btnWriteTest" hidden>Write test (debug)</button>
|
|
</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">×</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">←</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;
|
|
});
|
|
document.getElementById('btnWriteTest').addEventListener('click', function () {
|
|
// pushConfig D'ABORD, comme Sync. Sans cela, un jeton qui vient d'etre colle
|
|
// et dont le champ n'a pas encore perdu le focus n'est pas parti vers Delphi,
|
|
// qui agit alors avec l'ancien - et rend un 403 parfaitement trompeur.
|
|
pushConfig();
|
|
document.getElementById('log').hidden = false;
|
|
send('writetest');
|
|
});
|
|
|
|
function esc(s) {
|
|
return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) {
|
|
return { '&': '&', '<': '<', '>': '>', '"': '"' }[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 ? '•'.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();
|
|
},
|
|
|
|
setDebug: function (on) {
|
|
document.getElementById('btnWriteTest').hidden = !on;
|
|
},
|
|
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>
|