fe00fe8484
Il n'affichait rien, et pas parce qu'il etait vide. togglePanel parcourait ['cfg','log'] et cherchait pour chacun le bouton qui le commande - or btnCfg a perdu son data-panel le jour ou les reglages sont devenus un ecran. La premiere iteration levait sur un null, et le journal n'etait jamais deplie, quel que soit le chemin. Il vivait de plus sur l'accueil : "Show the log" refermait les reglages pour le deplier derriere, c'est-a-dire sur un ecran qu'on ne regarde plus des que le coffre est ouvert. Il est maintenant DANS les reglages, deplie sur place - le seul ecran atteignable depuis l'accueil comme depuis le coffre. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1752 lines
79 KiB
HTML
1752 lines
79 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); }
|
|
|
|
/* LE CLAVIER RECOUVRE LA PAGE, IL NE LA RETRECIT PAS. Le cadre est cloue a
|
|
l'ecran entier, donc quand le clavier monte le contenu ne deborde de rien
|
|
et le tiers du bas devient inatteignable : le champ mot de passe du
|
|
formulaire ne se voyait qu'en repliant le clavier. Reserver dessous la
|
|
hauteur qu'il occupe rend au panneau de quoi defiler. */
|
|
.scroll { flex: 1; overflow-y: auto; overflow-x: hidden;
|
|
-webkit-overflow-scrolling: touch;
|
|
overscroll-behavior: contain;
|
|
padding: 0 12px 16px; }
|
|
/* DE LA PLACE SOUS LE CONTENU PENDANT QU'ON TAPE, et rien a mesurer.
|
|
Le clavier recouvre la page sans que rien ne le dise : ni la fenetre, qui
|
|
reste en plein ecran, ni visualViewport, qui rapporte la meme hauteur
|
|
clavier leve. Le panneau n'avait donc aucun debordement et ne pouvait pas
|
|
defiler - le bas du formulaire restait sous le clavier, definitivement.
|
|
Reserver 60 % de hauteur le temps de la saisie lui en donne, sans
|
|
dependre d'une mesure qui n'arrive jamais. Elle disparait au premier
|
|
champ quitte, donc rien ne flotte quand on ne tape pas. */
|
|
body.typing .scroll { padding-bottom: 60vh; }
|
|
/* Une zone de texte occupe plusieurs lignes : la rangee cesse d'etre une
|
|
rangee. Sans cela les boutons la compriment a la hauteur d'un champ.
|
|
UNE CLASSE POSEE A L'OUVERTURE, pas un selecteur :has() - celui-ci
|
|
n'existe pas avant Chrome 105, et un moteur plus ancien ignorerait la
|
|
regle sans rien dire. */
|
|
.line.wrap { flex-wrap: wrap; }
|
|
.line.wrap textarea { flex-basis: 100%; }
|
|
|
|
/* --- 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); }
|
|
/* Rouge sourd : assez pour qu'on ne le confonde pas avec Copy, pas assez
|
|
pour attirer l'oeil sur la seule action irreversible de l'ecran. */
|
|
.btn.danger { background: rgba(220, 80, 80, .30); }
|
|
/* Un bouton d'icone est CARRE et etroit : c'est ce qui le distingue d'un
|
|
bouton de texte au premier coup d'oeil, et ce qui laisse la place au nom
|
|
de l'entree sur une ligne de liste. La cible reste a 44 px, ce qu'un
|
|
doigt attend. */
|
|
.btn.ico-btn { padding: 0; width: 44px; min-width: 44px; height: 44px;
|
|
display: inline-flex; align-items: center;
|
|
justify-content: center;
|
|
/* SANS FOND : le dessin seul. La pastille repetee trois fois
|
|
par ligne pesait plus que ce qu'elle designait. La CIBLE
|
|
reste a 44 px - c'est ce qu'un doigt attend, et elle n'a pas
|
|
besoin d'etre visible pour exister. */
|
|
background: none; }
|
|
.btn.ico-btn svg { width: 22px; height: 22px; }
|
|
/* Le retour d'appui ne peut plus venir du fond : il vient du trait. */
|
|
.btn.ico-btn:active { opacity: .45; }
|
|
/* Le point des modifications en attente. Sur le cadenas parce qu'il est
|
|
toujours a l'ecran de la liste, et qu'il parle deja de l'etat du coffre. */
|
|
/* DEUX TEMOINS, DEUX COULEURS, DEUX GESTES.
|
|
Orange : des choses attendent ICI et le depot les ignore.
|
|
Bleu : le depot porte des choses que nous n'avons pas.
|
|
Les confondre en un seul point dirait "quelque chose ne va pas" sans dire
|
|
quoi - or l'un se resout en publiant, l'autre en tirant. */
|
|
.ico.pending, .btn.pending, .ico.ahead, .btn.ahead { position: relative; }
|
|
.ico.pending::after, .btn.pending::after,
|
|
.ico.ahead::before, .btn.ahead::before {
|
|
content: ''; position: absolute; top: 6px;
|
|
width: 9px; height: 9px; border-radius: 50%;
|
|
box-shadow: 0 0 0 2px var(--bg-top);
|
|
}
|
|
.ico.pending::after, .btn.pending::after { right: 6px; background: #ffb44d; }
|
|
/* Decale, pour que les deux se voient ensemble quand les deux sont vrais. */
|
|
.ico.ahead::before, .btn.ahead::before { right: 20px; background: #5ec8ff; }
|
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
/* L'ENREGISTREMENT DURE UNE A DEUX SECONDES, et le bouton doit le dire. Le
|
|
mot "Saving..." le disait ; une coche immobile ne dirait rien, et c'est
|
|
precisement pendant cette attente qu'on appuie une seconde fois. */
|
|
.btn.spin svg { animation: spin .9s linear infinite; }
|
|
|
|
.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; }
|
|
/* LE MENU DE L'ENTREE. Il ne contient qu'une action, et c'est voulu : elle
|
|
est sans retour, et une action sans retour ne doit pas etre a portee d'un
|
|
appui distrait. Deux gestes deliberes - ouvrir, puis confirmer - valent
|
|
mieux qu'un bouton rouge en permanence sous les yeux. */
|
|
.menu { position: absolute; right: 12px; top: 58px; z-index: 60;
|
|
background: var(--bg-top); border-radius: var(--radius-s);
|
|
border: 1px solid rgba(255,255,255,.14);
|
|
box-shadow: 0 8px 24px rgba(0,0,0,.35);
|
|
min-width: 190px; overflow: hidden; }
|
|
.menu button { display: block; width: 100%; text-align: left; border: 0;
|
|
background: none; color: var(--text); font: inherit;
|
|
padding: 14px 16px; }
|
|
.menu button:active { background: rgba(255,255,255,.10); }
|
|
.menu button.danger { color: #ffb4b4; }
|
|
/* LA BARRE DE SELECTION prend la place de la recherche, comme dans les
|
|
applications d'Android : on ne cherche pas pendant qu'on choisit quoi
|
|
supprimer, et deux rangees de commandes se disputeraient le pouce. */
|
|
#selbar { display: none; }
|
|
body.selecting #selbar { display: flex; }
|
|
body.selecting .top { display: none; }
|
|
#selcount { flex: 1; font-size: 16px; font-weight: 600; }
|
|
/* La ligne choisie se voit, sinon on supprime a l'aveugle. */
|
|
.row.sel { outline: 2px solid var(--accent); outline-offset: -2px; }
|
|
.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; }
|
|
/* PRESERVER LES RETOURS A LA LIGNE A L'AFFICHAGE AUSSI. La valeur par defaut
|
|
de white-space les replie en espaces : le corps d'une note se lisait en un
|
|
seul paragraphe, alors que l'editeur, lui, les montrait - la mise en forme
|
|
avait l'air perdue jusqu'a ce qu'on ouvre le crayon.
|
|
Et break-all coupe au milieu des mots, ce qui convient a un mot de passe
|
|
et pas a de la prose : on ne coupe qu'a la necessite. */
|
|
.field .val.multi { white-space: pre-wrap; word-break: normal;
|
|
overflow-wrap: anywhere; }
|
|
.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); }
|
|
/* La fleche dit si le panneau est deplie, et de quel bouton il descend. */
|
|
.chev { display: inline-block; margin-left: 6px; font-size: 11px;
|
|
opacity: .7; transition: transform .18s ease; }
|
|
.btn.open .chev { transform: rotate(180deg); }
|
|
/* Un panneau est visiblement ATTACHE au bouton qui l'ouvre : sans ce trait
|
|
et ce retrait, les reglages et le journal se ressemblaient trop pour
|
|
qu'on sache lequel etait deplie. */
|
|
.panel { margin-top: 8px; padding-left: 10px;
|
|
border-left: 2px solid rgba(255,255,255,.18); }
|
|
|
|
.in-wrap { position: relative; }
|
|
/* Le champ laisse la place a la croix, sinon le texte passe dessous. */
|
|
.in-wrap .field-in { padding-right: 44px; }
|
|
.in-x {
|
|
position: absolute; right: 0; top: 8px; width: 44px; height: 46px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
color: var(--muted); font-size: 22px;
|
|
}
|
|
.in-x:active { opacity: .5; }
|
|
#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;
|
|
}
|
|
/* LES REGLAGES SONT UN VOILE, PAS UNE QUATRIEME VUE DU RAIL. Chaque vue du
|
|
rail coute ses regles de transformation et une place dans la navigation
|
|
par la touche retour ; les reglages ne sont pas un lieu ou l'on circule,
|
|
c'est une parenthese qu'on ouvre et qu'on referme. */
|
|
.sheet { position: fixed; inset: 0; z-index: 80; display: none;
|
|
flex-direction: column;
|
|
background: linear-gradient(var(--bg-top), var(--bg-bot)); }
|
|
body.on-settings #settings, body.on-history #history { display: flex; }
|
|
.sheet h2 { margin: 18px 4px 6px; font-size: 13px; font-weight: 600;
|
|
color: var(--muted); text-transform: uppercase;
|
|
letter-spacing: .06em; }
|
|
.sheet .note { color: var(--muted); font-size: 13px; margin: 6px 4px 0;
|
|
line-height: 1.45; }
|
|
|
|
[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; }
|
|
/* DIRE QUE CA TRAVAILLE, sur n'importe quel ecran. Les boutons de l'accueil
|
|
palissaient deja pendant une operation, mais depuis la liste rien ne
|
|
bougeait : supprimer ou dupliquer prend une a deux secondes - le trajet du
|
|
conteneur - et l'ecran restait identique, comme si l'appui n'avait pas
|
|
pris. Le voile empeche aussi le second appui, qui n'attendrait pas mieux. */
|
|
/* DEUX NOMS DIFFERENTS, ET C'EST LE FOND DU PIEGE. En appelant `busy` a la
|
|
fois la classe du voile et l'etat du corps, la regle ci-dessous
|
|
s'appliquait AUSSI au corps : `display: none` sur le body, donc la page
|
|
entiere effacee et le bleu de la fenetre a nu. L'etat porte donc un nom
|
|
que rien d'autre ne porte. */
|
|
.busy { position: fixed; inset: 0; z-index: 90; display: none;
|
|
align-items: center; justify-content: center;
|
|
background: rgba(0, 0, 0, .38); }
|
|
body.is-busy .busy { display: flex; }
|
|
.busy > div { background: var(--bg-top); border-radius: var(--radius);
|
|
padding: 20px 26px; display: flex; align-items: center;
|
|
gap: 12px; font-size: 15px; }
|
|
.busy svg { width: 22px; height: 22px; animation: spin .9s linear infinite; }
|
|
</style>
|
|
|
|
<div class="screen" id="unlock">
|
|
<div class="scroll">
|
|
<div class="status" id="status"></div>
|
|
<!-- SE CONNECTER, comme sur le poste. Le mot de passe maitre ouvre le
|
|
conteneur local - c'est le dechiffrement lui-meme qui verifie, aucun
|
|
secret n'est compare - et le nom dit a qui appartient ce coffre.
|
|
Cache des qu'un cache existe : redemander six secondes de derivation
|
|
a chaque lancement alors que le cache est la n'apporterait rien, et
|
|
c'est le code de deverrouillage qui garde cette porte-la. -->
|
|
<div id="authbox" class="stack" hidden>
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="user" placeholder="username"
|
|
autocomplete="off" autocorrect="off" autocapitalize="none"
|
|
spellcheck="false">
|
|
<div class="in-x" data-clear="user">×</div>
|
|
</div>
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="pass" placeholder="master password"
|
|
type="password" autocomplete="off">
|
|
<div class="in-x" data-clear="pass">×</div>
|
|
</div>
|
|
<button class="btn wide main" id="btnAuth">Sign in</button>
|
|
</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">
|
|
<!-- Plus de chevron : ce bouton n'ouvre plus un panneau sous lui,
|
|
il ouvre un ecran. Le garder promettrait un depliage.
|
|
|
|
IL NE PARAIT QUE TANT QU'IL N'Y A PAS DE COFFRE. La roue dentee de
|
|
la liste mene au meme ecran, donc le repeter ici ne sert qu'a
|
|
encombrer l'accueil. Mais on ne peut pas le retirer tout a fait :
|
|
sans coffre local, la liste n'existe pas, et il faut bien pouvoir
|
|
saisir l'adresse du depot pour aller le chercher. C'est setActions
|
|
qui le montre ou le cache, sur le meme signal que "Open vault". -->
|
|
<button class="btn" id="btnCfg">⚙ Settings</button>
|
|
<button class="btn" id="btnLog" data-panel="log">≡
|
|
Log<span class="chev">▾</span></button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- LE CODE EST ICI, PAS DANS LES REGLAGES DU SERVEUR. Il ne concerne que
|
|
cet appareil - il scelle le cache local - alors que les trois autres
|
|
champs decrivent le depot distant, partage entre les appareils. Range
|
|
parmi eux, rien n'indiquait qu'il fallait le retaper, et un coffre
|
|
scelle proposait de se retelecharger plutot que de se laisser ouvrir.
|
|
JAMAIS PRE-REMPLI : le retenir reviendrait a ne rien proteger. -->
|
|
<div id="pinbox">
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="pin" placeholder="unlock code"
|
|
type="password" inputmode="numeric" autocomplete="off">
|
|
<div class="in-x" data-clear="pin">×</div>
|
|
</div>
|
|
<!-- CHANGER LE CODE demande de connaitre l'actuel : le champ du dessus
|
|
doit ouvrir le coffre pour que celui-ci prenne effet. Sans cette
|
|
regle, toute ecriture du cache rescellait sous ce qui trainait dans
|
|
le champ - une synchronisation, qui ne demande pourtant aucun code,
|
|
suffisait a le changer sans qu'un mot le dise.
|
|
|
|
PLUS JAMAIS MONTRE. Les reglages posent et changent le code, et ce
|
|
champ y faisait doublon sur l'ecran ou l'on veut seulement ouvrir.
|
|
Il RESTE dans la page, cache : c'est lui que "Set this code" remplit
|
|
avant pushConfig, donc il porte toujours la valeur - il ne la
|
|
demande plus. -->
|
|
<div class="in-wrap" id="pin2wrap" hidden>
|
|
<input class="field-in" id="pin2" placeholder="new unlock code"
|
|
type="password" inputmode="numeric" autocomplete="off">
|
|
<div class="in-x" data-clear="pin2">×</div>
|
|
</div>
|
|
</div>
|
|
<!-- UNE CROIX PAR CHAMP. Vider par la touche retour demande une quarantaine
|
|
d'appuis sur un jeton, et laisse facilement un caractere - ce qui donne
|
|
un 401 dont la cause est invisible. La croix est UN FRERE du champ,
|
|
posee par-dessus, jamais un enfant : un enfant d'input ne recoit pas
|
|
l'appui.
|
|
L'indication d'aide sert de libelle, et le jeton comme le mot de passe
|
|
sont masques - le placeholder reste donc le seul moyen de les
|
|
distinguer quand ils sont vides. -->
|
|
<div id="cfg" class="panel" hidden>
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="url" placeholder="endpoint URL"
|
|
autocomplete="off" autocorrect="off" autocapitalize="none"
|
|
spellcheck="false" inputmode="url">
|
|
<div class="in-x" data-clear="url">×</div>
|
|
</div>
|
|
<!-- 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. -->
|
|
<div class="in-wrap">
|
|
<!-- NEUTRE : c'est l'URL qui decide de la nature du jeton, pas ce
|
|
champ. Une adresse d'API Gitea en fait un jeton d'acces personnel
|
|
envoye en "token" ; l'endpoint PHP en fait un "Bearer". Le
|
|
nommer d'apres un seul des deux le rend faux pour l'autre. -->
|
|
<input class="field-in" id="tok" placeholder="access token"
|
|
type="password" autocomplete="off">
|
|
<div class="in-x" data-clear="tok">×</div>
|
|
</div>
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="pwd" placeholder="sync password"
|
|
type="password" autocomplete="off">
|
|
<div class="in-x" data-clear="pwd">×</div>
|
|
</div>
|
|
</div>
|
|
</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="gear" aria-label="Settings">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round">
|
|
<circle cx="12" cy="12" r="3.2" />
|
|
<path d="M12 3.5v2M12 18.5v2M3.5 12h2M18.5 12h2M6 6l1.4 1.4M16.6 16.6L18 18M18 6l-1.4 1.4M7.4 16.6L6 18" />
|
|
</svg>
|
|
</button>
|
|
<button class="ico" id="new" aria-label="New entry">
|
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"
|
|
stroke-width="2" stroke-linecap="round">
|
|
<path d="M12 5v14M5 12h14" />
|
|
</svg>
|
|
</button>
|
|
<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="top" id="selbar">
|
|
<button class="ico" id="selCancel" aria-label="Cancel selection"></button>
|
|
<div id="selcount"></div>
|
|
<button class="ico" id="selDup" aria-label="Duplicate"></button>
|
|
<button class="ico" id="selDel" aria-label="Delete"></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>
|
|
<button class="btn ico-btn" id="kebab" aria-label="Entry menu"></button>
|
|
</div>
|
|
<div class="scroll"><div id="fields"></div></div>
|
|
<div class="menu" id="menu" hidden></div>
|
|
</div>
|
|
|
|
<!-- REGLAGES. Ils rassemblent ce qui vivait en deux panneaux repliables sur
|
|
l'ecran d'accueil : les coordonnees du depot d'un cote, le code de
|
|
deverrouillage de l'autre, et le journal en troisieme. Trois choses de
|
|
nature differente sur l'ecran ou l'on veut surtout ouvrir son coffre. -->
|
|
<div class="sheet" id="settings">
|
|
<div class="head">
|
|
<button class="btn" id="setBack">←</button>
|
|
<h1>Settings</h1>
|
|
</div>
|
|
<div class="scroll">
|
|
<h2>Repository</h2>
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="sUrl" placeholder="endpoint URL"
|
|
autocomplete="off" autocorrect="off" autocapitalize="none"
|
|
spellcheck="false" inputmode="url">
|
|
<div class="in-x" data-clear="sUrl">×</div>
|
|
</div>
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="sTok" placeholder="access token"
|
|
type="password" autocomplete="off">
|
|
<div class="in-x" data-clear="sTok">×</div>
|
|
</div>
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="sPwd" placeholder="master password"
|
|
type="password" autocomplete="off">
|
|
<div class="in-x" data-clear="sPwd">×</div>
|
|
</div>
|
|
<div class="note">The master password never leaves this device. It is what
|
|
decrypts the vault; the token only opens the repository.</div>
|
|
|
|
<h2>Unlock code</h2>
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="sPinNew" placeholder="new unlock code"
|
|
type="password" inputmode="numeric" autocomplete="off">
|
|
<div class="in-x" data-clear="sPinNew">×</div>
|
|
</div>
|
|
<button class="btn wide" id="sPinSet">Set this code</button>
|
|
<button class="btn wide danger" id="sPinClear">Remove the unlock code</button>
|
|
<div class="note">The code seals the copy kept on this device. Removing it
|
|
leaves the vault readable by anyone holding the phone unlocked.</div>
|
|
|
|
<h2>Diagnostics</h2>
|
|
<button class="btn wide" id="sLog">Show the log</button>
|
|
<!-- LE JOURNAL EST ICI, PAS SUR L'ACCUEIL. Il y vivait, et depuis que les
|
|
reglages sont un ecran on l'y atteignait par un bouton qui refermait
|
|
les reglages pour le deplier derriere - c'est-a-dire sur l'accueil, que
|
|
l'on ne regarde plus des que le coffre est ouvert. Il se depliait donc
|
|
hors de vue, et paraissait ne rien afficher.
|
|
Deplie SUR PLACE desormais : les reglages sont le seul ecran
|
|
atteignable depuis l'accueil comme depuis le coffre. -->
|
|
<pre id="log" hidden></pre>
|
|
<div class="note" id="sVersion"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- ANCIENS MOTS DE PASSE. Trois au plus, et seulement pour l'entree ouverte.
|
|
Un voile plutot qu'une rangee du detail : on ne les consulte pas en
|
|
passant, et les laisser a l'ecran les exposerait par-dessus l'epaule bien
|
|
apres qu'on a fini de les lire. -->
|
|
<div class="sheet" id="history">
|
|
<div class="head">
|
|
<button class="btn" id="histBack">←</button>
|
|
<h1>Previous passwords</h1>
|
|
</div>
|
|
<div class="scroll"><div id="histBody"></div></div>
|
|
</div>
|
|
|
|
<div class="busy" id="busy"><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'),
|
|
elPin = document.getElementById('pin');
|
|
|
|
// 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)
|
|
+ '&c=' + encodeURIComponent(elPin.value)
|
|
+ '&c2=' + encodeURIComponent(document.getElementById('pin2').value));
|
|
}
|
|
[elUrl, elTok, elPwd, elPin, document.getElementById('pin2')].forEach(function (el) {
|
|
el.addEventListener('change', pushConfig);
|
|
el.addEventListener('blur', pushConfig);
|
|
});
|
|
|
|
// Le meme bouton dans les deux sens : creer un coffre et en ouvrir un ne
|
|
// different que par ce qui existe deja sur l'appareil, et Delphi le sait.
|
|
var authMode = 'open';
|
|
document.getElementById('btnAuth').addEventListener('click', function () {
|
|
var u = document.getElementById('user').value.trim();
|
|
var p = document.getElementById('pass').value;
|
|
// Le mot de passe part, puis le champ se vide : il n'a plus rien a faire
|
|
// dans le document une fois la commande envoyee.
|
|
send((authMode === 'register' ? 'register' : 'login')
|
|
+ '?u=' + encodeURIComponent(u) + '&p=' + encodeURIComponent(p));
|
|
document.getElementById('pass').value = '';
|
|
});
|
|
|
|
document.getElementById('btnOpen').addEventListener('click', function () {
|
|
pushConfig(); // le code vient peut-etre d'etre tape
|
|
send('openvault');
|
|
});
|
|
document.getElementById('btnSync').addEventListener('click', function () {
|
|
pushConfig(); // ce qui est tape mais pas encore quitte compte aussi
|
|
send('sync');
|
|
});
|
|
// Un seul ecouteur pour les quatre croix : elles ne different que par leur
|
|
// cible. Pose sur l'ECRAN, pas sur le groupe des reglages - le code a son
|
|
// propre bloc depuis qu'il n'appartient plus au serveur, et sa croix serait
|
|
// restee sans effet.
|
|
document.getElementById('settings').addEventListener('click', function (ev) {
|
|
var id = ev.target.getAttribute('data-clear');
|
|
if (!id) return;
|
|
var el = document.getElementById(id);
|
|
el.value = '';
|
|
el.focus();
|
|
});
|
|
|
|
document.getElementById('unlock').addEventListener('click', function (ev) {
|
|
var id = ev.target.getAttribute('data-clear');
|
|
if (!id) return;
|
|
var el = document.getElementById(id);
|
|
el.value = '';
|
|
el.focus();
|
|
// Delphi doit l'apprendre tout de suite : le champ ne perdra pas le focus,
|
|
// donc l'evenement qui remonte d'habitude la configuration n'aura pas lieu.
|
|
pushConfig();
|
|
});
|
|
|
|
// UN SEUL PANNEAU DEPLIE A LA FOIS. Les deux s'ouvraient l'un sous l'autre,
|
|
// dans la meme colonne et avec le meme fond : le journal se lisait comme la
|
|
// suite des reglages.
|
|
// PLUS DE BASCULE A DEUX PANNEAUX, ET C'EST ELLE QUI CASSAIT TOUT. Elle
|
|
// parcourait ['cfg','log'] et cherchait pour chacun le bouton qui le
|
|
// commande - or btnCfg a perdu son data-panel le jour ou les reglages sont
|
|
// devenus un ecran. La premiere iteration levait donc sur un null, et le
|
|
// journal n'etait jamais deplie, quel que soit le chemin emprunte.
|
|
//
|
|
// Un seul panneau reste : une bascule suffit, sans table a tenir a jour.
|
|
function showLog(on) {
|
|
document.getElementById('log').hidden = !on;
|
|
document.getElementById('sLog').textContent =
|
|
on ? 'Hide the log' : 'Show the log';
|
|
}
|
|
document.getElementById('btnCfg').addEventListener('click', openSettings);
|
|
document.getElementById('btnLog').addEventListener('click', function () {
|
|
// Le journal vit dans les reglages : le bouton de l'accueil y mene et le
|
|
// deplie, plutot que de promettre un depliage sur place qui n'existe plus.
|
|
openSettings();
|
|
showLog(true);
|
|
});
|
|
|
|
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 ico-btn" data-copy="user">'
|
|
+ ico('user', 'Copy username') + '</button>' : '')
|
|
+ '<button class="btn ico-btn" data-copy="pwd">'
|
|
+ (e.isNote ? ico('note', 'Copy text') : ico('key', 'Copy password'))
|
|
+ '</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;
|
|
// REMONTER AU BOUTON. Depuis que ces boutons portent un dessin, la cible de
|
|
// l'appui est le <svg> ou l'un de ses traits, pas le bouton : lire
|
|
// l'attribut sur la cible rendait null, et l'appui ouvrait l'entree au lieu
|
|
// de copier. Le doigt tombant tantot sur le glyphe tantot sur la marge, cela
|
|
// ne ratait qu'une fois sur deux - la pire des pannes a diagnostiquer.
|
|
var btn = ev.target.closest('[data-copy]');
|
|
if (btn) { send('copy?i=' + row.getAttribute('data-i')
|
|
+ '&f=' + btn.getAttribute('data-copy')); return; }
|
|
// EN SELECTION, UN APPUI CHOISIT. Ouvrir une entree pendant qu'on en
|
|
// designe d'autres n'a pas de sens, et c'est aussi la sortie : deselectionner
|
|
// la derniere ligne referme la barre.
|
|
if (document.body.classList.contains('selecting')) {
|
|
toggleSel(row.getAttribute('data-i'));
|
|
return;
|
|
}
|
|
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();
|
|
});
|
|
|
|
// LES ICONES, dessinees une fois et nommees.
|
|
//
|
|
// Elles remplacent "User", "Pass", "Copy", "Edit" : quatre mots sur une ligne
|
|
// de liste laissaient peu de place au nom de l'entree, et se relisaient a
|
|
// chaque fois. Un trait suffit a les distinguer.
|
|
//
|
|
// aria-label EST OBLIGATOIRE : une icone sans nom n'existe pas pour qui
|
|
// n'ecoute que le lecteur d'ecran, et remplacer un mot par un dessin ne doit
|
|
// pas retirer l'information a quelqu'un.
|
|
var ICONS = {
|
|
user: '<path d="M12 12a4 4 0 100-8 4 4 0 000 8z"/>'
|
|
+ '<path d="M4.5 20a7.5 7.5 0 0115 0"/>',
|
|
key: '<circle cx="8" cy="12" r="3.5"/><path d="M11.5 12H21l-2 2.5"/>'
|
|
+ '<path d="M17 12v3"/>',
|
|
note: '<path d="M6 3h9l4 4v14H6z"/><path d="M9 12h7M9 16h5"/>',
|
|
copy: '<rect x="9" y="9" width="11" height="11" rx="2"/>'
|
|
+ '<path d="M15 5H6a2 2 0 00-2 2v9"/>',
|
|
edit: '<path d="M4 20h4L20 8l-4-4L4 16z"/>',
|
|
eye: '<path d="M2 12s3.5-6 10-6 10 6 10 6-3.5 6-10 6-10-6-10-6z"/>'
|
|
+ '<circle cx="12" cy="12" r="3"/>',
|
|
down: '<path d="M12 4v11M7.5 11l4.5 4.5L16.5 11M5 19.5h14"/>',
|
|
trash: '<path d="M5 7h14M10 7V4.5h4V7M6.5 7l1 12.5h9L18 7"/>'
|
|
+ '<path d="M10.5 10.5v6M13.5 10.5v6"/>',
|
|
dots: '<circle cx="12" cy="5" r="1.4"/><circle cx="12" cy="12" r="1.4"/>'
|
|
+ '<circle cx="12" cy="19" r="1.4"/>',
|
|
check: '<path d="M4 12.5l5.5 5.5L20 7"/>',
|
|
close: '<path d="M6 6l12 12M18 6L6 18"/>',
|
|
// 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"/>'
|
|
+ '<path d="M9.5 5.4A9.8 9.8 0 0112 5c6.5 0 10 6 10 6a17 17 0 01-3.3 3.7"/>'
|
|
+ '<path d="M6.3 7.7A17 17 0 002 11s3.5 6 10 6a9.7 9.7 0 003.4-.6"/>'
|
|
};
|
|
|
|
function ico(name, label) {
|
|
return '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor"'
|
|
+ ' stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round"'
|
|
+ ' aria-label="' + label + '" role="img">' + ICONS[name] + '</svg>';
|
|
}
|
|
|
|
// --- detail ----------------------------------------------------------------
|
|
var detailRows = [];
|
|
// Le dernier detail recu, pour redessiner la ligne apres une annulation sans
|
|
// avoir a le redemander a Delphi.
|
|
//
|
|
// IL FAUT L'ECRIRE. Il a ete introduit avec ses deux lecteurs et aucun
|
|
// ecrivain : Cancel appelait donc renderDetail(null), qui levait sur d.rows et
|
|
// ne faisait rien du tout. editDone, qui s'en sert aussi pour rendre la main
|
|
// apres un refus, etait mort de la meme facon - ce qui explique pourquoi les
|
|
// corrections successives de "Cancel ne repond pas" n'y changeaient rien.
|
|
var lastDetail = null;
|
|
|
|
function renderDetail(d) {
|
|
if (!d) return;
|
|
lastDetail = 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 ico-btn" data-copy="totp">'
|
|
+ ico('copy', 'Copy code') + '</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' : '')
|
|
+ (r.m ? ' multi' : '')
|
|
+ '" data-v="' + i + '">'
|
|
+ (r.s ? '•'.repeat(Math.min(r.v.length, 14)) : esc(r.v))
|
|
+ '</div>'
|
|
+ (r.s ? '<button class="btn ico-btn" data-show="' + i + '">'
|
|
+ ico('eye', 'Show') + '</button>' : '')
|
|
+ '<button class="btn ico-btn" data-copy="row" data-k="' + i + '">'
|
|
+ ico('copy', 'Copy') + '</button>'
|
|
// 'a' porte le rang de la piece jointe : sa presence dit qu'il y a
|
|
// quelque chose a enregistrer, et Delphi seul sait ou.
|
|
+ (r.a === undefined ? ''
|
|
: '<button class="btn ico-btn" data-save="' + i + '">'
|
|
+ ico('down', 'Save to Downloads') + '</button>')
|
|
+ (r.e ? '<button class="btn ico-btn" data-edit="' + i + '">'
|
|
+ ico('edit', 'Edit') + '</button>' : '')
|
|
+ '</div></div>';
|
|
}
|
|
document.getElementById('fields').innerHTML = html;
|
|
closeMenu();
|
|
document.body.classList.add('on-detail');
|
|
view = 'detail';
|
|
send('state?view=detail');
|
|
}
|
|
|
|
document.getElementById('fields').addEventListener('click', function (ev) {
|
|
// Meme raison que dans la liste : le dessin recoit l'appui, le bouton porte
|
|
// l'intention. Tout ce qui suit lit et ecrit sur le bouton.
|
|
var t = ev.target.closest('button') || ev.target;
|
|
var 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.innerHTML = ico(hidden ? 'eyeOff' : 'eye', hidden ? 'Hide' : 'Show');
|
|
send('touch');
|
|
return;
|
|
}
|
|
var save = t.getAttribute('data-save');
|
|
if (save !== null) { send('savefile?k=' + save); return; }
|
|
var copy = t.getAttribute('data-copy');
|
|
if (copy === 'totp') { send('copy?f=totp'); return; }
|
|
if (copy === 'row') { send('copy?f=row&k=' + t.getAttribute('data-k')); return; }
|
|
|
|
var edit = t.getAttribute('data-edit');
|
|
if (edit !== null) { openEditor(edit); return; }
|
|
if (t.getAttribute('data-editshow') !== null) {
|
|
// Masque par defaut, revelable a la demande : on tape un mot de passe
|
|
// qu'on ne peut relire nulle part ailleurs, mais il n'a pas a rester
|
|
// expose a l'ecran pendant qu'on cherche le bouton.
|
|
var iv = document.getElementById('editval');
|
|
var hid = iv.type === 'password';
|
|
iv.type = hid ? 'text' : 'password';
|
|
t.innerHTML = ico(hid ? 'eyeOff' : 'eye', hid ? 'Hide' : 'Show');
|
|
return;
|
|
}
|
|
if (t.getAttribute('data-editcancel') !== null) { renderDetail(lastDetail); return; }
|
|
if (t.getAttribute('data-editsave') !== null) {
|
|
var input = document.getElementById('editval');
|
|
if (!input) return;
|
|
// VIDER EST LEGITIME sur un site ou un identifiant - pas sur un mot de
|
|
// passe, ou ce serait presque toujours une fausse manoeuvre. Le refus se
|
|
// dit, sinon le bouton semble ne pas prendre l'appui.
|
|
if (!input.value && editField === 'password') {
|
|
toast('An empty password is not saved');
|
|
return;
|
|
}
|
|
// DIRE QUE CA TRAVAILLE. L'enregistrement dechiffre, rechiffre et publie :
|
|
// une a deux secondes pendant lesquelles rien ne bougeait, et le bouton
|
|
// semblait n'avoir pas pris l'appui. La coche cede la place a un arc qui
|
|
// tourne - immobile, elle laisserait croire que c'est fini.
|
|
t.innerHTML = ico('loader', 'Saving');
|
|
t.classList.add('spin');
|
|
t.disabled = true;
|
|
var cancel = document.querySelector('[data-editcancel]');
|
|
if (cancel) cancel.disabled = true;
|
|
input.disabled = true;
|
|
send('setfield?f=' + encodeURIComponent(editField)
|
|
+ '&v=' + encodeURIComponent(input.value));
|
|
return;
|
|
}
|
|
});
|
|
|
|
/// LE DOSSIER SE CHOISIT, il ne se tape pas.
|
|
///
|
|
/// C'est une chaine simple dans l'instantane, mais elle doit correspondre a un
|
|
/// dossier existant : une faute de frappe rangerait l'entree dans un dossier
|
|
/// que rien n'affiche, ni ici ni sur le poste - l'entree semblerait disparue.
|
|
/// La liste des dossiers arrive deja avec le coffre, autant s'en servir.
|
|
///
|
|
/// 'All' est ajoute d'office : c'est le dossier par defaut du poste, et il ne
|
|
/// figure pas toujours dans la liste.
|
|
function editorControl(k) {
|
|
var val = detailRows[k].v;
|
|
var style = ' style="margin:0;flex:1;min-width:0;width:auto"';
|
|
// UNE ZONE DE TEXTE POUR CE QUI TIENT SUR PLUSIEURS LIGNES. Un <input>
|
|
// applique la sanitisation de HTML et retire les retours a la ligne en
|
|
// silence : le corps d'une note y perdait sa mise en forme, et la perte
|
|
// partait au depot a l'enregistrement suivant.
|
|
if (detailRows[k].m)
|
|
return '<textarea class="field-in" id="editval" rows="6"'
|
|
+ ' enterkeyhint="enter" autocorrect="off" spellcheck="false"'
|
|
+ ' style="margin:0;flex:1;min-width:0;width:auto;height:auto;'
|
|
+ 'padding:10px 14px;resize:vertical"></textarea>';
|
|
if (editField !== 'folder')
|
|
return '<input class="field-in" id="editval" autocomplete="off"'
|
|
+ ' type="' + (detailRows[k].s ? 'password' : 'text') + '"'
|
|
+ ' autocorrect="off" autocapitalize="none" spellcheck="false"'
|
|
+ style + '>';
|
|
|
|
var names = ['All'], i;
|
|
for (i = 0; i < folders.length; i++)
|
|
if (folders[i] && names.indexOf(folders[i]) < 0) names.push(folders[i]);
|
|
// Le dossier courant peut ne plus figurer dans la liste - il a ete renomme
|
|
// ailleurs, ou l'entree vient d'un import. Le proposer quand meme evite de
|
|
// le remplacer silencieusement en ouvrant simplement l'editeur.
|
|
if (val && names.indexOf(val) < 0) names.push(val);
|
|
|
|
var html = '<select class="field-in" id="editval"' + style + '>';
|
|
for (i = 0; i < names.length; i++)
|
|
html += '<option value="' + esc(names[i]) + '"'
|
|
+ (names[i] === val ? ' selected' : '') + '>' + esc(names[i])
|
|
+ '</option>';
|
|
// CHOISIR NE SUFFIT PAS : le premier dossier d'un coffre n'existe pas encore,
|
|
// et rien ne permettait de le nommer. Cette option echange la liste contre un
|
|
// champ libre, sans quitter l'editeur.
|
|
html += '<option value="' + NEW_FOLDER + '">+ new folder</option>';
|
|
return html + '</select>';
|
|
}
|
|
|
|
var NEW_FOLDER = '\u0001new';
|
|
|
|
// Le choix "+ new folder" remplace la liste par un champ. Delegue sur #fields
|
|
// parce que le controle nait et meurt avec l'editeur.
|
|
document.getElementById('fields').addEventListener('change', function (ev) {
|
|
if (ev.target.id !== 'editval' || ev.target.value !== NEW_FOLDER) return;
|
|
var line = ev.target.parentNode;
|
|
var input = document.createElement('input');
|
|
input.className = 'field-in';
|
|
input.id = 'editval';
|
|
input.setAttribute('placeholder', 'folder name');
|
|
input.setAttribute('autocomplete', 'off');
|
|
input.setAttribute('autocapitalize', 'none');
|
|
input.style.cssText = 'margin:0;flex:1;min-width:0;width:auto';
|
|
line.replaceChild(input, ev.target);
|
|
input.focus();
|
|
});
|
|
|
|
// EDITEUR EN PLACE, pas window.prompt : une invite JavaScript demande au
|
|
// moteur un WebChromeClient que FMX ne fournit pas, et prompt() rend alors
|
|
// null sans un mot - le bouton semblerait ne rien faire.
|
|
//
|
|
// La valeur s'affiche EN CLAIR pendant la saisie. On tape un mot de passe
|
|
// qu'on ne peut pas relire ailleurs ; le masquer ferait valider a l'aveugle
|
|
// une frappe qu'aucune confirmation ne rattrape.
|
|
var editField = '';
|
|
|
|
function openEditor(k) {
|
|
var row = document.querySelector('[data-v="' + k + '"]');
|
|
if (!row) return;
|
|
// 'e' porte le nom du champ dans l'instantane, pas un booleen : c'est Delphi
|
|
// qui decide de ce qui se modifie, la page n'a pas a le deduire d'un libelle.
|
|
editField = detailRows[k].e || '';
|
|
if (!editField) return;
|
|
var line = row.parentNode;
|
|
line.classList.toggle('wrap', !!detailRows[k].m);
|
|
line.innerHTML = editorControl(k)
|
|
// Show n'a de sens que sur un champ masque. Sur un site en clair il
|
|
// n'aurait rien a reveler - et l'inverse : il le CACHERAIT.
|
|
//
|
|
// LES TROIS EN ICONES, comme les lignes au-dessus : ils partageaient la
|
|
// rangee avec le champ de saisie, et trois mots y laissaient a peine de
|
|
// quoi lire ce qu'on tape. La croix et la coche se lisent sans traduction.
|
|
+ (detailRows[k].s ? '<button class="btn ico-btn" data-editshow>'
|
|
+ ico('eye', 'Show') + '</button>' : '')
|
|
+ '<button class="btn ico-btn" data-editcancel>'
|
|
+ ico('close', 'Cancel') + '</button>'
|
|
+ '<button class="btn ico-btn main" data-editsave>'
|
|
+ ico('check', 'Save') + '</button>';
|
|
var input = document.getElementById('editval');
|
|
input.value = detailRows[k].v;
|
|
input.focus();
|
|
send('touch');
|
|
}
|
|
|
|
// RIEN NE GLISSE DE COTE, sauf la bande de dossiers.
|
|
//
|
|
// overflow:hidden n'interdit que le GESTE. Le moteur, lui, garde le droit de
|
|
// deplacer une boite pour amener a l'ecran le champ qui vient de prendre le
|
|
// focus, et les ecrans hors-champ du rail - poses a translateX(100%) - lui en
|
|
// laissent la place a droite. La touche de validation du clavier suffisait
|
|
// alors a pousser tout le panneau de cote, sans aucun moyen de le ramener
|
|
// puisque le geste, lui, reste interdit.
|
|
//
|
|
// On l'attrape en phase de capture, donc quelle que soit la boite qui bouge et
|
|
// quelle qu'en soit la cause : ici, aucune vue n'a de contenu a droite.
|
|
addEventListener('scroll', function (ev) {
|
|
var box = ev.target === document ? document.scrollingElement : ev.target;
|
|
if (box && box.scrollLeft && box.id !== 'folders') box.scrollLeft = 0;
|
|
}, true);
|
|
|
|
// LE CLAVIER RECOUVRE LA PAGE, ET PERSONNE NE LE DIT.
|
|
//
|
|
// Le cadre est cloue a l'ecran entier et le moteur ne le redimensionne pas
|
|
// quand le clavier monte. Trois sources ont ete interrogees sur ce qu'il
|
|
// recouvre - visualViewport, innerHeight, la fenetre elle-meme via
|
|
// windowSoftInputMode - et les trois repondent comme s'il n'existait pas :
|
|
// clavier leve, la boite defilante montrait 659 pour 659 de contenu. Aucun
|
|
// debordement, donc rien a faire defiler, et le bas du formulaire restait
|
|
// sous le clavier definitivement.
|
|
//
|
|
// On cesse donc de demander. Le temps de la saisie, le panneau reserve de la
|
|
// place sous son contenu (body.typing, plus haut) - c'est du debordement, donc
|
|
// ca defile - et le champ focalise remonte en haut, la ou aucun clavier ne
|
|
// peut l'atteindre quelle que soit sa hauteur.
|
|
document.addEventListener('focusin', function (ev) {
|
|
var el = ev.target;
|
|
if (!el.classList || !el.classList.contains('field-in')) return;
|
|
// UNE LISTE DEROULANTE NE LEVE PAS DE CLAVIER. Elle ouvre une liste native
|
|
// par-dessus la page, qui n'occupe rien et disparait au choix. Lui reserver
|
|
// 60 % de hauteur et la faire remonter en tete de panneau fait sauter
|
|
// l'ecran sans qu'aucun clavier ne le justifie - c'est le champ Dossier qui
|
|
// l'a montre.
|
|
if (el.tagName === 'SELECT') return;
|
|
document.body.classList.add('typing');
|
|
// Differe d'un tour : la place reservee ci-dessus n'est pas encore prise en
|
|
// compte, et il n'y aurait nulle part ou remonter.
|
|
setTimeout(function () {
|
|
var row = el.closest && el.closest('.field, .in-wrap');
|
|
var box = el.closest && el.closest('.scroll');
|
|
if (!row || !box || !row.scrollIntoView) return;
|
|
// NE BOUGER QUE SI C'EST UTILE, et le seuil doit dire ce que le clavier
|
|
// recouvre : un peu moins de la moitie basse. A 40 % on faisait remonter
|
|
// des champs qui restaient parfaitement visibles, donc on deplacait sous
|
|
// l'oeil ce qui allait bien.
|
|
var top = row.getBoundingClientRect().top - box.getBoundingClientRect().top;
|
|
if (top > box.clientHeight * 0.55) row.scrollIntoView({ block: 'start' });
|
|
}, 250);
|
|
});
|
|
document.addEventListener('focusout', function () {
|
|
document.body.classList.remove('typing');
|
|
});
|
|
|
|
// AUCUN CHAMP D'UNE VUE AU REPOS NE GARDE LE FOCUS.
|
|
//
|
|
// Meme origine que le glissement ci-dessus. La touche de validation du clavier
|
|
// demande "champ suivant", et le suivant dans le document est sur une vue
|
|
// rangee hors du cadre. On y atterrissait sans rien voir : le clavier changeait
|
|
// de forme - numerique pour le code, alphabetique pour la recherche - et ne se
|
|
// refermait plus, puisque le champ qui le tenait ouvert etait hors de l'ecran.
|
|
addEventListener('focusin', function (ev) {
|
|
var scr = ev.target.closest && ev.target.closest('.screen');
|
|
if (scr && scr.id !== view) ev.target.blur();
|
|
});
|
|
|
|
// La validation ferme le clavier au lieu de sauter au champ suivant. Dans
|
|
// l'editeur, elle enregistre : c'est ce qu'on attend de cette touche apres
|
|
// avoir tape un mot de passe.
|
|
addEventListener('keydown', function (ev) {
|
|
var el = ev.target;
|
|
if (ev.key !== 'Enter' || !el.classList || !el.classList.contains('field-in'))
|
|
return;
|
|
// DANS UNE ZONE DE TEXTE, ENTREE VEUT DIRE ENTREE. Cette regle vaut pour les
|
|
// champs d'une ligne, ou la touche ne peut que sauter au suivant ; appliquee
|
|
// au corps d'une note, elle rendait impossible d'y sauter une ligne - le
|
|
// clavier semblait ne pas en avoir la touche.
|
|
if (el.tagName === 'TEXTAREA') return;
|
|
ev.preventDefault();
|
|
el.blur();
|
|
if (el.id !== 'editval') return;
|
|
var save = document.querySelector('[data-editsave]');
|
|
if (save && !save.disabled) save.click();
|
|
});
|
|
|
|
// NOUVELLE ENTREE - dans l'ecran de DETAIL, pas dans une quatrieme vue.
|
|
//
|
|
// Le rail en porte trois, et chacune coute ses regles de transformation. Le
|
|
// detail a deja tout ce qu'il faut : un titre, un retour, une colonne qui
|
|
// defile. Une entree qu'on cree et une entree qu'on consulte occupent la meme
|
|
// place a l'ecran, et c'est exactement ce que cette vue veut dire.
|
|
var newKind = 'login';
|
|
|
|
/// Le dossier a la creation. Le meme choix que dans l'editeur, et pour la
|
|
/// meme raison : une entree rangee des le depart evite d'avoir a la deplacer.
|
|
function newFolderField() {
|
|
var names = ['All'], i;
|
|
for (i = 0; i < folders.length; i++)
|
|
if (folders[i] && names.indexOf(folders[i]) < 0) names.push(folders[i]);
|
|
var html = '<div class="field"><div class="cap">Folder</div><div class="line">'
|
|
+ '<select class="field-in" id="nFolder"'
|
|
+ ' style="margin:0;flex:1;min-width:0;width:auto">';
|
|
for (i = 0; i < names.length; i++)
|
|
html += '<option value="' + esc(names[i]) + '">' + esc(names[i]) + '</option>';
|
|
return html + '</select></div></div>';
|
|
}
|
|
|
|
var FIELD_STYLE = 'margin:0;flex:1;min-width:0;width:auto';
|
|
|
|
function newField(id, label, rowId) {
|
|
return '<div class="field" id="' + rowId + '"><div class="cap">' + label
|
|
+ '</div><div class="line"><input class="field-in" id="' + id + '"'
|
|
+ ' type="text" autocomplete="off" autocorrect="off"'
|
|
+ ' autocapitalize="none" spellcheck="false" style="' + FIELD_STYLE
|
|
+ '"></div></div>';
|
|
}
|
|
|
|
/// LE CORPS D'UNE NOTE SE SAISIT SUR PLUSIEURS LIGNES. Un <input> applique la
|
|
/// sanitisation de HTML et retire les retours a la ligne : on pouvait lire et
|
|
/// modifier du multiligne, mais pas en creer - il fallait creer la note puis
|
|
/// l'ouvrir pour la mettre en forme.
|
|
function newBodyControl(kind) {
|
|
if (kind === 'note')
|
|
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"'
|
|
+ ' autocorrect="off" autocapitalize="none" spellcheck="false"'
|
|
+ ' style="' + FIELD_STYLE + '">';
|
|
}
|
|
|
|
function openNewEntry() {
|
|
newKind = 'login';
|
|
detailRows = [];
|
|
document.getElementById('dtitle').textContent = 'New entry';
|
|
document.getElementById('fields').innerHTML =
|
|
'<div class="field"><div class="cap">Kind</div><div class="line">'
|
|
+ '<button class="btn main" data-kind="login">Login</button>'
|
|
+ '<button class="btn" data-kind="note">Note</button></div></div>'
|
|
+ newFolderField()
|
|
+ newField('nTitle', 'Name', 'nTitleRow')
|
|
+ newField('nSite', 'Site', 'nSiteRow')
|
|
+ newField('nUser', 'Username', 'nUserRow')
|
|
// EN CLAIR pendant la saisie, comme l'editeur : on tape un mot de passe
|
|
// qu'aucune confirmation ne rattrape et qu'on ne peut relire nulle part.
|
|
+ '<div class="field" id="nBodyRow"><div class="cap">Password</div>'
|
|
+ '<div class="line">' + newBodyControl('login') + '</div></div>'
|
|
+ '<div class="field"><div class="line">'
|
|
// flex:1 et non .wide : .wide ne regle que le rembourrage, et la largeur
|
|
// pleine vient d'etre etire dans son conteneur.
|
|
+ '<button class="btn main wide" id="nSave" style="flex:1">Save</button>'
|
|
+ '</div></div>';
|
|
document.body.classList.remove('on-list');
|
|
document.body.classList.add('on-detail');
|
|
view = 'detail';
|
|
send('state?view=detail');
|
|
}
|
|
|
|
// Un seul ecouteur pour le formulaire : ses boutons naissent et meurent avec
|
|
// lui, et en attacher a chaque ouverture les empilerait.
|
|
document.getElementById('fields').addEventListener('click', function (ev) {
|
|
var kind = ev.target.getAttribute('data-kind');
|
|
if (kind) {
|
|
newKind = kind;
|
|
var all = document.querySelectorAll('[data-kind]'), i;
|
|
for (i = 0; i < all.length; i++)
|
|
all[i].classList.toggle('main', all[i].getAttribute('data-kind') === kind);
|
|
|
|
// PAR IDENTIFIANT, PAS PAR LIBELLE. Les rangees se reconnaissaient a leur
|
|
// intitule - de la prose - donc renommer un champ aurait suffi a ce que
|
|
// ce code cesse silencieusement d'agir sur lui.
|
|
//
|
|
// Une note n'a ni site ni identifiant : les laisser proposes revient a
|
|
// demander de remplir ce que rien n'affichera.
|
|
document.getElementById('nSiteRow').hidden = (kind === 'note');
|
|
document.getElementById('nUserRow').hidden = (kind === 'note');
|
|
|
|
// Le corps d'une note EST son mot de passe cote stockage : meme champ,
|
|
// autre intitule, et rien a masquer - mais plusieurs lignes.
|
|
var row = document.getElementById('nBodyRow');
|
|
row.querySelector('.cap').textContent =
|
|
(kind === 'note') ? 'Content' : 'Password';
|
|
var line = row.querySelector('.line');
|
|
// CE QUI EST DEJA TAPE SURVIT AU CHANGEMENT. Remplacer le controle sans le
|
|
// reporter effacerait la saisie sans un mot, pour un simple changement
|
|
// d'avis sur le genre.
|
|
var old = document.getElementById('nPwd');
|
|
var kept = old ? old.value : '';
|
|
line.classList.toggle('wrap', kind === 'note');
|
|
line.innerHTML = newBodyControl(kind);
|
|
document.getElementById('nPwd').value = kept;
|
|
return;
|
|
}
|
|
if (ev.target.id !== 'nSave') return;
|
|
var v = function (id) { return document.getElementById(id).value.trim(); };
|
|
ev.target.disabled = true;
|
|
var fold = document.getElementById('nFolder');
|
|
send('newentry?d=' + encodeURIComponent(fold ? fold.value : 'All')
|
|
+ '&t=' + encodeURIComponent(v('nTitle'))
|
|
+ '&s=' + encodeURIComponent(v('nSite'))
|
|
+ '&u=' + encodeURIComponent(v('nUser'))
|
|
+ '&p=' + encodeURIComponent(document.getElementById('nPwd').value)
|
|
+ '&k=' + newKind);
|
|
});
|
|
document.getElementById('new').addEventListener('click', openNewEntry);
|
|
document.getElementById('gear').addEventListener('click', openSettings);
|
|
|
|
// LE MENU DE L'ENTREE, et la suppression qui y vit.
|
|
//
|
|
// Elle etait un bouton pleine largeur au bas de chaque detail : rouge, present
|
|
// en permanence, pour une action qu'on fait trois fois par an. Ici il faut
|
|
// l'ouvrir, puis confirmer - deux gestes deliberes, et rien qui traine sous
|
|
// les yeux le reste du temps.
|
|
//
|
|
// SANS RETOUR, toujours : il n'y a pas de corbeille sur le telephone, et la
|
|
// pierre tombale fait disparaitre l'entree du poste aussi, sans passer par la
|
|
// sienne. Le seul filet est la sauvegarde du poste.
|
|
function closeMenu() {
|
|
var m = document.getElementById('menu');
|
|
m.hidden = true;
|
|
m.innerHTML = '';
|
|
}
|
|
|
|
/// AMenu vit dans l'ecran courant : le detail a le sien, la liste le sien.
|
|
/// ATop, quand il est donne, ancre le menu a la hauteur de la ligne touchee -
|
|
/// un menu qui apparait loin du doigt ne se rattache a rien.
|
|
function openMenu() {
|
|
var m = document.getElementById('menu');
|
|
m.innerHTML = '<button data-hist>Previous passwords</button>'
|
|
+ '<button data-icon="fetch">Fetch icon</button>'
|
|
+ '<button data-icon="clear">Remove icon</button>'
|
|
+ '<button data-dup>Duplicate entry</button>'
|
|
+ '<button class="danger" data-del>Delete entry</button>';
|
|
m.hidden = false;
|
|
}
|
|
|
|
document.getElementById('kebab').innerHTML = ico('dots', 'Entry menu');
|
|
document.getElementById('kebab').addEventListener('click', function (ev) {
|
|
ev.stopPropagation();
|
|
if (document.getElementById('menu').hidden) openMenu(); else closeMenu();
|
|
});
|
|
|
|
function onMenuClick(ev) {
|
|
var b = ev.target.closest('button');
|
|
if (!b) return;
|
|
if (b.getAttribute('data-hist') !== null) {
|
|
closeMenu();
|
|
send('history');
|
|
return;
|
|
}
|
|
var ic = b.getAttribute('data-icon');
|
|
if (ic !== null) {
|
|
closeMenu();
|
|
send('icon?a=' + ic);
|
|
return;
|
|
}
|
|
if (b.getAttribute('data-dup') !== null) {
|
|
closeMenu();
|
|
send('dupentry');
|
|
return;
|
|
}
|
|
if (b.getAttribute('data-del') !== null) {
|
|
// Le premier appui n'arme que la confirmation. Ouvrir le menu etait deja
|
|
// delibere ; ceci ferme la porte a l'appui qui derape dans la liste.
|
|
b.removeAttribute('data-del');
|
|
b.setAttribute('data-delconfirm', '1');
|
|
b.textContent = 'Confirm delete';
|
|
return;
|
|
}
|
|
if (b.getAttribute('data-delconfirm') === null) return;
|
|
b.disabled = true;
|
|
b.textContent = 'Deleting...';
|
|
// L'index designe l'entree quand on supprime depuis la liste. Absent, c'est
|
|
// l'entree ouverte - Delphi tient deja laquelle.
|
|
send('delentry');
|
|
}
|
|
document.getElementById('menu').addEventListener('click', onMenuClick);
|
|
|
|
// SELECTIONNER DANS LA LISTE - appui long, puis la barre du haut change.
|
|
//
|
|
// Le menu flottant qu'il y avait ici se refermait au premier appui suivant,
|
|
// donc ouvrir une entree apres coup demandait deux gestes et donnait
|
|
// l'impression qu'un bouton restait accroche. La barre contextuelle est le
|
|
// modele d'Android : elle prend la place de la recherche, elle dit combien de
|
|
// lignes sont prises, et elle disparait quand on n'en a plus.
|
|
//
|
|
// Ni une croix par ligne - une action sans retour a un pouce d'ecart sur une
|
|
// liste qu'on fait defiler - ni un troisieme bouton par ligne, alors qu'on
|
|
// vient de les degager.
|
|
var selected = {};
|
|
|
|
function selCount() {
|
|
var n = 0, k;
|
|
for (k in selected) if (selected[k]) n++;
|
|
return n;
|
|
}
|
|
|
|
function renderSel() {
|
|
var n = selCount(), i, all = document.querySelectorAll('.row');
|
|
document.body.classList.toggle('selecting', n > 0);
|
|
document.getElementById('selcount').textContent =
|
|
n + (n === 1 ? ' selected' : ' selected');
|
|
for (i = 0; i < all.length; i++)
|
|
all[i].classList.toggle('sel', !!selected[all[i].getAttribute('data-i')]);
|
|
// Delphi doit le savoir : la touche retour ferme la selection avant de
|
|
// verrouiller le coffre, sinon un geste d'annulation le fermerait.
|
|
send('state?view=' + (n > 0 ? 'select' : 'list'));
|
|
}
|
|
|
|
function clearSel() { selected = {}; renderSel(); }
|
|
|
|
function toggleSel(i) {
|
|
if (selected[i]) delete selected[i]; else selected[i] = true;
|
|
renderSel();
|
|
}
|
|
|
|
function selList() {
|
|
var out = [], k;
|
|
for (k in selected) if (selected[k]) out.push(k);
|
|
return out.join(',');
|
|
}
|
|
|
|
(function () {
|
|
var timer = null, moved = false;
|
|
var rows = document.getElementById('rows');
|
|
rows.addEventListener('touchstart', function (ev) {
|
|
var row = ev.target.closest('.row');
|
|
// Pas sur les boutons de copie : ils ont deja leur geste.
|
|
if (!row || ev.target.closest('[data-copy]')) return;
|
|
moved = false;
|
|
timer = setTimeout(function () {
|
|
if (moved) return;
|
|
toggleSel(row.getAttribute('data-i'));
|
|
send('touch');
|
|
}, 500);
|
|
});
|
|
rows.addEventListener('touchmove', function () { moved = true; clearTimeout(timer); });
|
|
['touchend', 'touchcancel'].forEach(function (e) {
|
|
rows.addEventListener(e, function () { clearTimeout(timer); });
|
|
});
|
|
})();
|
|
|
|
document.getElementById('selCancel').innerHTML = ico('close', 'Cancel selection');
|
|
document.getElementById('selDup').innerHTML = ico('copy', 'Duplicate');
|
|
document.getElementById('selDel').innerHTML = ico('trash', 'Delete');
|
|
document.getElementById('selCancel').addEventListener('click', clearSel);
|
|
document.getElementById('selDup').addEventListener('click', function () {
|
|
var l = selList();
|
|
if (!l) return;
|
|
clearSel();
|
|
send('dupentry?i=' + l);
|
|
});
|
|
document.getElementById('selDel').addEventListener('click', function (ev) {
|
|
var b = ev.currentTarget, l = selList();
|
|
if (!l) return;
|
|
// Deux temps, comme partout ailleurs pour ce geste : il est sans retour.
|
|
if (b.getAttribute('data-armed') === null) {
|
|
b.setAttribute('data-armed', '1');
|
|
b.innerHTML = ico('check', 'Confirm delete');
|
|
setTimeout(function () {
|
|
b.removeAttribute('data-armed');
|
|
b.innerHTML = ico('trash', 'Delete');
|
|
}, 4000);
|
|
toast('Tap again to delete ' + selCount());
|
|
return;
|
|
}
|
|
b.removeAttribute('data-armed');
|
|
b.innerHTML = ico('trash', 'Delete');
|
|
clearSel();
|
|
send('delentry?i=' + l);
|
|
});
|
|
|
|
// Un appui n'importe ou ailleurs referme le menu - y compris sur le contenu,
|
|
// sinon il resterait ouvert pendant qu'on lit l'entree.
|
|
document.addEventListener('click', function (ev) {
|
|
if (document.getElementById('menu').hidden) return;
|
|
if (ev.target.closest('#menu, #kebab')) return;
|
|
closeMenu();
|
|
});
|
|
|
|
// LES REGLAGES. Ils lisent et ecrivent les MEMES champs que l'accueil - la
|
|
// page n'en garde qu'une copie, Delphi reste la seule source.
|
|
function openSettings() {
|
|
document.getElementById('sUrl').value = elUrl.value;
|
|
document.getElementById('sTok').value = elTok.value;
|
|
document.getElementById('sPwd').value = elPwd.value;
|
|
document.getElementById('sPinNew').value = '';
|
|
showLog(false);
|
|
document.body.classList.add('on-settings');
|
|
send('state?view=settings');
|
|
}
|
|
|
|
function closeSettings() {
|
|
// Ce qui a ete tape ici redescend vers Delphi avant de fermer, sinon un
|
|
// reglage modifie puis referme sans quitter le champ serait perdu.
|
|
elUrl.value = document.getElementById('sUrl').value.trim();
|
|
elTok.value = document.getElementById('sTok').value.trim();
|
|
elPwd.value = document.getElementById('sPwd').value;
|
|
pushConfig();
|
|
document.body.classList.remove('on-settings');
|
|
send('state?view=' + view);
|
|
}
|
|
|
|
document.getElementById('setBack').addEventListener('click', closeSettings);
|
|
|
|
function closeHistory() {
|
|
// Le clair part avec la vue, pas seulement de l'ecran.
|
|
document.getElementById('histBody').innerHTML = '';
|
|
document.body.classList.remove('on-history');
|
|
send('state?view=' + view);
|
|
}
|
|
document.getElementById('histBack').addEventListener('click', closeHistory);
|
|
document.getElementById('histBody').addEventListener('click', function (ev) {
|
|
var b = ev.target.closest('[data-hcopy]');
|
|
if (!b) return;
|
|
// UN RANG, JAMAIS UNE VALEUR. C'est la regle du pont partout ailleurs -
|
|
// depuis la liste, un indice de coffre ; depuis le detail, un rang de ligne -
|
|
// et il n'y a pas de raison qu'un ancien mot de passe fasse exception parce
|
|
// qu'il se trouve deja affiche.
|
|
send('copyhist?k=' + b.getAttribute('data-hcopy'));
|
|
});
|
|
document.getElementById('sLog').addEventListener('click', function () {
|
|
showLog(document.getElementById('log').hidden);
|
|
});
|
|
document.getElementById('sPinSet').addEventListener('click', function () {
|
|
var v = document.getElementById('sPinNew').value;
|
|
if (!v) { toast('Type a code first'); return; }
|
|
// LE MEME CHEMIN QUE L'ACCUEIL : c'est l'ouverture du coffre qui pose ou
|
|
// change le sceau, et elle seule. Ici on ne fait que remplir le champ.
|
|
document.getElementById('pin2').value = v;
|
|
pushConfig();
|
|
closeSettings();
|
|
toast('Open the vault to apply the new code');
|
|
});
|
|
document.getElementById('sPinClear').addEventListener('click', function (ev) {
|
|
var b = ev.currentTarget;
|
|
if (b.getAttribute('data-armed') === null) {
|
|
b.setAttribute('data-armed', '1');
|
|
b.textContent = 'Really remove - tap again';
|
|
setTimeout(function () {
|
|
b.removeAttribute('data-armed');
|
|
b.textContent = 'Remove the unlock code';
|
|
}, 4000);
|
|
return;
|
|
}
|
|
b.removeAttribute('data-armed');
|
|
b.textContent = 'Remove the unlock code';
|
|
send('pinclear');
|
|
closeSettings();
|
|
});
|
|
|
|
function goList() {
|
|
closeMenu();
|
|
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');
|
|
selected = {};
|
|
document.body.classList.remove('selecting');
|
|
// LE FORMULAIRE PART AVEC LA VUE, pas seulement de l'ecran. Il garde le
|
|
// mot de passe qu'on vient de taper ; le laisser dans le document apres
|
|
// l'enregistrement le laisserait la jusqu'au verrouillage.
|
|
detailRows = [];
|
|
document.getElementById('fields').innerHTML = '';
|
|
view = 'list';
|
|
renderFolders();
|
|
renderRows();
|
|
},
|
|
|
|
/// Sort du mode edition sans rien demander a Delphi. Sert aux refus
|
|
/// immediats : l'editeur y restait ouvert, boutons desactives, et Cancel
|
|
/// lui-meme ne repondait plus.
|
|
editDone: function () {
|
|
// Le formulaire de creation passe par ici aussi : son bouton se desactive
|
|
// a l'envoi, et sans cela un refus le laissait mort avec le texte saisi
|
|
// derriere - a retaper sans savoir pourquoi.
|
|
var save = document.getElementById('nSave');
|
|
if (save) { save.disabled = false; return; }
|
|
// RENDRE LA MAIN A CE QUI EST A L'ECRAN, pas a ce qui y etait. Apres une
|
|
// suppression la vue est deja revenue a la liste, et redessiner le dernier
|
|
// detail rouvrait l'entree qu'on venait d'effacer - elle semblait revenue.
|
|
if (view !== 'detail') return;
|
|
if (lastDetail) renderDetail(lastDetail);
|
|
},
|
|
|
|
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;
|
|
// Un coffre lisible => la roue dentee de la liste est atteignable, et ce
|
|
// bouton fait double emploi. Pas de coffre => c'est la seule porte vers
|
|
// l'adresse du depot, sans laquelle il n'y a rien a telecharger.
|
|
document.getElementById('btnCfg').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;
|
|
},
|
|
/// LES CHAMPS RESTENT LA SOURCE, l'ecran de reglages n'en est qu'une vue.
|
|
/// Ils ne s'affichent plus - le panneau repliable a cede la place au voile -
|
|
/// mais c'est toujours d'eux que part pushConfig, et Delphi ne connait qu'eux.
|
|
/// Les vider ferait perdre la configuration a la premiere synchronisation.
|
|
setConfig: function (url, tok, pwd, show) {
|
|
// elPin volontairement absent : rien ne le restaure, jamais.
|
|
elUrl.value = url; elTok.value = tok; elPwd.value = pwd;
|
|
// Rien a deplier : c'est le voile des reglages qui les montre desormais.
|
|
// Un depot pas encore configure s'annonce donc autrement - le bouton
|
|
// d'accueil dit "Download vault" et l'ecran, "No vault on this device".
|
|
if (show) toast('Set the repository in Settings');
|
|
},
|
|
|
|
/// LE JOURNAL SORT DE LA VUE EN RELEASE, sans disparaitre.
|
|
///
|
|
/// Il ne contient rien de secret - le jeton n'y figure que par empreinte -
|
|
/// mais un bouton "Log" en permanence sur l'ecran d'accueil d'un coffre a
|
|
/// mots de passe est du bruit de developpement, et il invite a lire ce qu'on
|
|
/// n'a pas a lire. Un appui long sur la ligne d'etat le ramene : les
|
|
/// diagnostics restent atteignables le jour ou quelque chose cloche a
|
|
/// distance, ce qui arrivera.
|
|
hideLog: function () {
|
|
document.getElementById('btnLog').hidden = true;
|
|
var st = document.getElementById('status'), timer = null;
|
|
st.addEventListener('touchstart', function () {
|
|
timer = setTimeout(function () {
|
|
document.getElementById('btnLog').hidden = false;
|
|
toast('Log available');
|
|
}, 1500);
|
|
});
|
|
['touchend', 'touchmove', 'touchcancel'].forEach(function (e) {
|
|
st.addEventListener(e, function () { clearTimeout(timer); });
|
|
});
|
|
},
|
|
|
|
/// Le coffre local est scelle sous un code : il faut le retaper pour
|
|
/// l'ouvrir. Delphi le sait en lisant le premier octet du cache.
|
|
/// TOUJOURS VISIBLE, seule l'indication change. Le cacher quand aucun code
|
|
/// n'est pose supprimerait le seul endroit ou en poser un ; le montrer sans
|
|
/// rien dire laisserait croire qu'il en faut un pour ouvrir. C'est donc le
|
|
/// texte d'aide qui porte la difference.
|
|
/// Dans lequel des trois cas se trouve cet appareil : rien du tout,
|
|
/// un conteneur sans cache, ou un cache pret a s'ouvrir.
|
|
setAuthMode: function (mode) {
|
|
authMode = mode;
|
|
var box = document.getElementById('authbox');
|
|
box.hidden = (mode === 'open');
|
|
document.getElementById('pinbox').hidden = (mode !== 'open');
|
|
document.getElementById('btnAuth').textContent =
|
|
(mode === 'register') ? 'Create vault' : 'Sign in';
|
|
},
|
|
|
|
setPinNeeded: function (on) {
|
|
document.getElementById('pin').placeholder =
|
|
on ? 'unlock code' : 'set an unlock code (optional)';
|
|
// pin2wrap n'est plus deplie : le champ du nouveau code vit dans les
|
|
// reglages, celui-ci n'en est que le porteur.
|
|
},
|
|
/// Le code vient d'etre change : le champ du nouveau n'a plus rien a dire,
|
|
/// et le laisser rempli le reproposerait a chaque ouverture.
|
|
pinChanged: function () {
|
|
document.getElementById('pin2').value = '';
|
|
document.getElementById('pin').value = '';
|
|
},
|
|
focusPin: function () { document.getElementById('pin').focus(); },
|
|
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);
|
|
},
|
|
/// Delphi l'appelle des qu'il part travailler et des qu'il revient. Le
|
|
/// filet est son minuteur de 25 s : meme si une operation ne rend jamais la
|
|
/// main, le voile se leve.
|
|
/// DES MODIFICATIONS ATTENDENT ICI. Un point sur le cadenas : discret tant
|
|
/// qu'on ne le cherche pas, mais present a chaque coup d'oeil - c'est la
|
|
/// seule facon de savoir que le depot ignore encore ce qu'on vient de taper.
|
|
setPending: function (on) {
|
|
// LES DEUX ECRANS, pas seulement la liste. Apres un verrouillage on
|
|
// atterrit sur l'accueil - precisement la ou l'on decide de synchroniser -
|
|
// et le cadenas de la liste n'y est plus. Le temoin n'y disait donc rien.
|
|
document.getElementById('lock').classList.toggle('pending', !!on);
|
|
document.getElementById('btnSync').classList.toggle('pending', !!on);
|
|
},
|
|
/// LE DEPOT A BOUGE. Signale, jamais adopte de soi-meme : tirer pendant que
|
|
/// quelqu'un lit une entree la ferait changer sous ses yeux.
|
|
setRemoteAhead: function (on) {
|
|
document.getElementById('lock').classList.toggle('ahead', !!on);
|
|
document.getElementById('btnSync').classList.toggle('ahead', !!on);
|
|
},
|
|
/// Les anciennes versions descendent ICI et nulle part ailleurs : une seule
|
|
/// entree, sur demande. PM.wipe les efface avec le reste du clair.
|
|
showHistory: function (json) {
|
|
var rows = JSON.parse(json), html = '', i;
|
|
for (i = 0; i < rows.length; i++)
|
|
html += '<div class="field"><div class="cap">'
|
|
+ esc(rows[i].at.replace('T', ' ').slice(0, 16)) + '</div>'
|
|
+ '<div class="line"><div class="val" data-h="' + i + '">'
|
|
+ esc(rows[i].pwd) + '</div>'
|
|
+ '<button class="btn ico-btn" data-hcopy="' + i + '">'
|
|
+ ico('copy', 'Copy') + '</button></div></div>';
|
|
document.getElementById('histBody').innerHTML = html;
|
|
document.body.classList.add('on-history');
|
|
send('state?view=history');
|
|
},
|
|
setVersion: function (v) {
|
|
document.getElementById('sVersion').textContent = v;
|
|
},
|
|
setBusy: function (on) {
|
|
if (on) document.getElementById('busy').firstChild.innerHTML =
|
|
ico('loader', 'Working') + '<span>Please wait...</span>';
|
|
document.body.classList.toggle('is-busy', !!on);
|
|
},
|
|
back: function () { if (view === 'detail') goList(); },
|
|
/// 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.
|
|
clearSelection: function () { clearSel(); },
|
|
closeHistory: function () { closeHistory(); },
|
|
/// La touche retour referme les reglages avant de toucher au coffre.
|
|
closeSettings: function () { closeSettings(); },
|
|
// 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 = '';
|
|
document.getElementById('histBody').innerHTML = '';
|
|
document.body.classList.remove('on-history', 'on-settings');
|
|
q.value = '';
|
|
document.body.classList.remove('on-list', 'on-detail', 'searching');
|
|
view = 'unlock';
|
|
}
|
|
};
|
|
window.PM = PM;
|
|
</script>
|