119b493a43
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2127 lines
98 KiB
HTML
2127 lines
98 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); }
|
|
.btn:disabled { opacity: .45; }
|
|
.field-in:disabled { opacity: .5; }
|
|
/* 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. */
|
|
/* RONDS, ET NON ARRONDIS. 50% plutot qu'un rayon en pixels : le cercle suit
|
|
la taille du bouton au lieu d'etre juste pour une seule valeur, et la
|
|
barre de selection qui reutilise .ico en herite sans rien redire. */
|
|
.ico {
|
|
flex: 0 0 46px; width: 46px; height: 46px; border: 0; padding: 0;
|
|
border-radius: 50%; 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; }
|
|
/* UN REFUS N'EST PAS UN ETAT. "Master password: 8 characters at least" se
|
|
lisait du meme gris que "Vault on this device" - on ne voyait pas que
|
|
l'application venait de refuser quelque chose. */
|
|
.status.err, .sheet .note.err { color: #ff9d9d; }
|
|
.stack { display: flex; flex-direction: column; gap: 8px; }
|
|
/* L'espace de 8 px vit A L'INTERIEUR de chaque pile. Deux piles voisines se
|
|
touchaient donc, et "Create vault" collait a "Download vault" alors que
|
|
"Download vault" et "Settings" respiraient - les premiers etant dans deux
|
|
piles, les seconds dans une seule. */
|
|
.stack + .stack { margin-top: 8px; }
|
|
/* DEUX ESPACEMENTS QUI S'ADDITIONNENT. Un champ porte son propre margin-top
|
|
de 8 px - il en a besoin dans les reglages, ou les champs se suivent en
|
|
flux normal sans gap. Dans une pile, ce margin s'ajoute au gap : deux
|
|
champs etaient donc a 16 px l'un de l'autre, un champ et un bouton a 8.
|
|
C'est le gap qui commande ici. */
|
|
.stack .field-in { margin-top: 0; }
|
|
.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;
|
|
/* Le body interdit la selection (user-select: none) et les champs en
|
|
HERITAIENT : le curseur ne suivait pas le doigt et le texte ne defilait
|
|
jamais - la fin d'une URL longue restait hors d'atteinte. */
|
|
-webkit-user-select: text; user-select: text;
|
|
}
|
|
.field-in.multi { height: auto; min-height: 46px; overflow: hidden; padding: 12px 44px 12px 14px; resize: none;
|
|
line-height: 1.35; word-break: break-all; display: block; }
|
|
.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; }
|
|
/* ANCREE EN BAS, PAS EN HAUT. Le champ porte un margin-top de 8 px, que les
|
|
piles annulent : caler la croix a `top: 8px` la laissait donc 8 px trop bas
|
|
des que le margin disparaissait. Le bord INFERIEUR, lui, est celui que le
|
|
champ touche dans les deux cas - le margin est au-dessus. */
|
|
.in-x {
|
|
/* 32 px pour 44 px de marge : les 12 px entre le texte et la croix
|
|
recoivent le toucher, qui pose le curseur en fin de texte au lieu
|
|
d'effacer le champ. */
|
|
position: absolute; right: 0; bottom: 0; width: 32px; 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; }
|
|
/* LES BOUTONS DES REGLAGES RESPIRENT. Ils suivent un champ, qui porte son
|
|
margin-top, mais eux n'en avaient aucun : le champ du code et ses deux
|
|
boutons se touchaient, et se lisaient comme un seul bloc. Les piles de
|
|
l'accueil ont leur gap ; ici les elements se suivent en flux normal, donc
|
|
c'est au bouton de reserver sa place. */
|
|
.sheet .btn.wide { margin-top: 8px; }
|
|
|
|
[hidden] { display: none !important; }
|
|
|
|
/* AU-DESSUS DES VOILES. Sans z-index, le message se peignait DERRIERE
|
|
l'ecran des reglages (80) : "Unlock code set" partait bien, mais sous la
|
|
page, et ne se decouvrait qu'en sortant - quand il ne s'etait pas deja
|
|
eteint. Un message qui ne se voit pas la ou on l'a provoque ne sert a
|
|
rien. Au-dessus du voile d'attente (90) aussi, pour la meme raison. */
|
|
.toast {
|
|
position: fixed; left: 12px; right: 12px; bottom: 14px; z-index: 100;
|
|
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; }
|
|
/* LE CLAVIER RECOUVRE LE BAS DE L'ECRAN, donc le toast avec. Il ne se
|
|
decouvrait qu'en quittant l'ecran, quand le clavier se repliait - et depuis
|
|
qu'un appui sur un bouton ne vole plus le focus, le clavier RESTE ouvert,
|
|
ce qui a rendu le defaut systematique au lieu d'occasionnel.
|
|
On le remonte au-dessus de la reserve que body.typing menage deja pour le
|
|
clavier : meme mesure, meme raison. */
|
|
body.typing .toast { bottom: 62vh; }
|
|
/* 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>
|
|
|
|
<div class="stack">
|
|
<button class="btn wide main" id="btnOpen">➜ Open vault</button>
|
|
<!-- LA SORTIE DE SECOURS DU VERROUILLAGE D'ECRAN : empreinte refusee,
|
|
schema oublie - le mot de passe du coffre ouvre toujours. -->
|
|
<button class="btn wide" id="btnUsePwd" hidden>Use the vault password</button>
|
|
<button class="btn wide" id="btnSync">↻ Sync</button>
|
|
<!-- UN SEUL BOUTON, ET IL NE MENT PAS SUR SA DESTINATION.
|
|
Il y en avait deux, "Settings" et "Log", et le second a fini par
|
|
ouvrir le premier - le journal ayant demenage dans les reglages. Un
|
|
bouton nomme "Log" qui ouvre "Settings" se ment sur son etiquette.
|
|
|
|
TOUJOURS VISIBLE, contrairement a ce qu'on avait essaye. Le cacher
|
|
des qu'un coffre existe laissait l'accueil sans aucune porte vers les
|
|
reglages - et c'est precisement quand le coffre REFUSE de s'ouvrir
|
|
(code faux, adresse fausse) qu'on en a besoin, avec le journal.
|
|
Ce n'est pas un doublon de la roue dentee : celle-ci est DANS le
|
|
coffre, celui-ci est sur l'accueil. Jamais cote a cote. -->
|
|
<button class="btn wide" id="btnCfg">⚙ Settings</button>
|
|
</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">
|
|
<!-- DEUX SECRETS, DEUX NOMS. Celui-ci ouvre le coffre garde sur cet
|
|
appareil. Il s'appelait "master password" et servait aussi de
|
|
secret du depot - un coffre cree ici pretendait alors sceller le
|
|
depot avec lui, que le poste ne savait plus ouvrir. -->
|
|
<input class="field-in" id="pass" placeholder="vault 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>
|
|
|
|
<!-- 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">
|
|
<!-- PAS DE inputmode="numeric". Rien n'a jamais impose des chiffres -
|
|
aucune validation ne filtre, ni ici ni cote Delphi - mais le
|
|
clavier numerique le laissait croire, et personne n'essayait un
|
|
mot de passe. Le clavier complet rend le choix visible ; les
|
|
chiffres y sont toujours. -->
|
|
<input class="field-in" id="pin" placeholder="unlock code or password"
|
|
type="password" autocomplete="off">
|
|
<div class="in-x" data-clear="pin">×</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="note err" id="sRepoErr" hidden></div>
|
|
<div class="in-wrap">
|
|
<!-- ZONE DE TEXTE ET NON input : l'URL passe a la ligne et se lit en
|
|
entier. Un input d'une ligne ne defilait pas sous le doigt dans la
|
|
WebView de l'app, et la fin d'une URL d'API restait inatteignable. -->
|
|
<textarea class="field-in multi" id="sUrl" rows="1"
|
|
placeholder="repository URL - Gitea or github.com/owner/repo (private)"
|
|
autocomplete="off" autocorrect="off" autocapitalize="none"
|
|
spellcheck="false" inputmode="url"></textarea>
|
|
<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="sync password"
|
|
type="password" autocomplete="off">
|
|
<div class="in-x" data-clear="sPwd">×</div>
|
|
</div>
|
|
<button class="btn wide" id="sScan">Scan the QR shown on the PC</button>
|
|
<button class="btn wide" id="sSave" disabled>Save</button>
|
|
<div class="note">The sync password never leaves this device. It is what
|
|
encrypts the copy kept in the repository, so it must be the same on every
|
|
device that shares it; the token only opens the repository. It is asked
|
|
for once: afterwards the vault opens with the phone screen lock, an app
|
|
code, or this password - your choice, under <b>Unlock with</b><span
|
|
id="sUnlockHint"></span>.</div>
|
|
|
|
<!-- COFFRE FERME, SECTION CACHEE. Poser comme retirer un code exigent le
|
|
clair en main : les deux ne pourraient que refuser, et proposer une
|
|
action qui refuse toujours vaut moins que ne rien proposer. -->
|
|
<div id="secCode" hidden>
|
|
<h2>Unlock with</h2>
|
|
<!-- TROIS FACONS D'OUVRIR, UNE SEULE COPIE LOCALE. Le coffre lui-meme n'est
|
|
jamais touche : seul le scellement de la copie rapide change. Le champ
|
|
sert a tout - nouveau code, mot de passe du coffre, ou code courant
|
|
pour revenir au verrouillage d'ecran (retirer une protection demande
|
|
de la connaitre). -->
|
|
<div class="in-wrap">
|
|
<input class="field-in" id="sPinNew" placeholder="code or password"
|
|
type="password" autocomplete="off">
|
|
<div class="in-x" data-clear="sPinNew">×</div>
|
|
</div>
|
|
<button class="btn wide" id="sModeDevice">Screen lock (fingerprint, pattern...)</button>
|
|
<button class="btn wide" id="sModeCode">App code (type it above)</button>
|
|
<button class="btn wide" id="sModePwd">Vault password (type it above)</button>
|
|
<div class="note" id="sModeNote"></div>
|
|
</div>
|
|
|
|
<!-- LE TITRE SUIT SON BOUTON : sans lui, une release affichait
|
|
"Diagnostics" au-dessus de rien. -->
|
|
<h2 id="sDiagH">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>
|
|
|
|
<!-- DEBOGAGE SEULEMENT. Delphi ne devoile cette section que dans une
|
|
version de debogage, et la commande qui l'accompagne n'est meme pas
|
|
compilee en release : le bouton ne peut donc pas reapparaitre par
|
|
accident dans une version publiee. En bas de l'ecran, apres tout le
|
|
reste, parce qu'on ne tombe pas dessus par megarde. -->
|
|
<div id="secDebug" hidden>
|
|
<h2>Debug</h2>
|
|
<button class="btn wide danger" id="sWipe">Delete the local vault</button>
|
|
<div class="note">Removes the cached vault and any vault created here,
|
|
along with anything not yet published. The repository is untouched -
|
|
Download vault brings it back.</div>
|
|
</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));
|
|
}
|
|
[elUrl, elTok, elPwd, elPin].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';
|
|
// LE CHAMP DU CODE NE SERT PLUS QU'A OUVRIR, donc il ne parait que lorsqu'un
|
|
// code est reellement pose. Il proposait aussi d'en poser un, et c'est ce
|
|
// double sens qui defaisait les retraits : le champ gardait une valeur, et la
|
|
// prochaine ouverture rescellait sous elle. On pose un code dans les reglages.
|
|
//
|
|
// DEUX ECRIVAINS, UNE SEULE REGLE : Delphi annonce l'un puis l'autre, et le
|
|
// dernier ecrasait le premier. Chacun met a jour sa variable, la fonction
|
|
// tranche.
|
|
var pinNeeded = false;
|
|
// COFFRE OUVERT OU NON, du point de vue de la page. Delphi le dit sans le dire :
|
|
// setVault n'arrive qu'avec un coffre en main, et wipe accompagne chaque
|
|
// verrouillage. Deux points, et ils suffisent.
|
|
var vaultOpen = false;
|
|
// L'ACCUEIL SE LIT DE HAUT EN BAS : aller chercher le coffre, regler, et creer
|
|
// en dernier - c'est l'ordre des frequences, pas celui du code. La creation
|
|
// etait en tete parce que son bloc etait ecrit en premier dans la page.
|
|
function syncAuthbox() {
|
|
document.getElementById('authbox').hidden =
|
|
(authMode === 'open') || (authMode === 'download');
|
|
// L'ACCENT SUIT LE GESTE ATTENDU. Creer un coffre neuf le portait alors qu'un
|
|
// depot est configure - c'est-a-dire exactement le mauvais geste. Il revient
|
|
// a celui qui vient chercher le coffre, et ne reste sur la creation que
|
|
// lorsqu'il n'y a rien a chercher.
|
|
var repo = elUrl.value.trim() !== '';
|
|
document.getElementById('btnSync').classList.toggle('main', repo);
|
|
document.getElementById('btnAuth').classList.toggle('main', !repo);
|
|
}
|
|
|
|
function syncPinbox() {
|
|
document.getElementById('pinbox').hidden = !pinNeeded || (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 = '';
|
|
if (el.classList.contains('multi')) autoGrow(el);
|
|
syncSaveBtn(false);
|
|
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);
|
|
|
|
// LE PREMIER APPUI NE DOIT PAS ETRE MANGE PAR LE CLAVIER.
|
|
//
|
|
// Un bouton touche pendant qu'un champ a le focus recevait d'abord le blur :
|
|
// le clavier se repliait, la reserve de body.typing disparaissait, la page
|
|
// remontait - et le bouton n'etait plus sous le doigt quand le clic partait. Il
|
|
// fallait donc appuyer deux fois, la premiere ne servant qu'a fermer le
|
|
// clavier. C'est ce qui se voyait sur "Set this code".
|
|
//
|
|
// Empecher le defaut du mousedown empeche le TRANSFERT DE FOCUS, pas le clic :
|
|
// le champ garde le curseur, rien ne bouge, et l'appui atteint sa cible du
|
|
// premier coup. Pose une fois pour toute la page - le piege n'a aucune raison
|
|
// d'etre propre aux reglages.
|
|
document.addEventListener('mousedown', function (ev) {
|
|
var b = ev.target.closest('button');
|
|
if (!b) return;
|
|
var a = document.activeElement;
|
|
if (a && (a.tagName === 'INPUT' || a.tagName === 'TEXTAREA')) ev.preventDefault();
|
|
}, true);
|
|
|
|
// ET ON LE REND UNE FOIS LE CLIC PASSE.
|
|
//
|
|
// Garder le focus au mousedown fait atteindre la cible du premier coup, mais
|
|
// laisse le clavier ouvert : apres "Open vault" il restait dresse par-dessus la
|
|
// liste. Les deux moities vont ensemble - on retient le focus le temps de
|
|
// l'appui, on le lache des que l'action est partie.
|
|
//
|
|
// SAUF SI LE BOUTON AGIT SUR LE CHAMP LUI-MEME : un oeil qui devoile, une
|
|
// fleche qui deroule. Ceux-la vivent dans la meme rangee que le champ, et
|
|
// replier le clavier apres eux obligerait a le rouvrir pour continuer a taper.
|
|
document.addEventListener('click', function (ev) {
|
|
var b = ev.target.closest('button');
|
|
if (!b) return;
|
|
var a = document.activeElement;
|
|
if (!a || (a.tagName !== 'INPUT' && a.tagName !== 'TEXTAREA')) return;
|
|
var row = a.closest('.in-wrap, .field');
|
|
if (row && row.contains(b)) return;
|
|
a.blur();
|
|
});
|
|
|
|
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"/>',
|
|
dice: '<rect x="4" y="4" width="16" height="16" rx="3"/>'
|
|
+ '<circle cx="9" cy="9" r="1.2"/><circle cx="15" cy="15" r="1.2"/>'
|
|
+ '<circle cx="15" cy="9" r="1.2"/><circle cx="9" cy="15" r="1.2"/>',
|
|
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 gen = t.getAttribute('data-gen');
|
|
if (gen) {
|
|
// Visible une fois tire : on garde ce qu'on voit, pas une suite de points.
|
|
var gi = document.getElementById(gen);
|
|
gi.value = genPassword(20);
|
|
gi.type = 'text';
|
|
send('touch');
|
|
return;
|
|
}
|
|
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();
|
|
});
|
|
|
|
// Le meme echange sur le formulaire de creation. Delegue sur #fields comme
|
|
// ci-dessus : les deux controles y vivent, seul leur identifiant differe.
|
|
document.getElementById('fields').addEventListener('change', function (ev) {
|
|
if (ev.target.id !== 'nFolder' || ev.target.value !== NEW_FOLDER) return;
|
|
var line = ev.target.parentNode;
|
|
var input = document.createElement('input');
|
|
input.className = 'field-in';
|
|
input.id = 'nFolder';
|
|
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)
|
|
+ (editField === 'password' ? genButton('editval') : '')
|
|
// 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>';
|
|
// LA MEME OPTION QU'A L'EDITION, et il a fallu la reclamer pour s'en rendre
|
|
// compte : creer une entree ET son dossier d'un coup etait impossible. Il
|
|
// fallait creer l'entree, la rouvrir, changer son dossier - trois gestes pour
|
|
// ce que le meme controle sait faire ailleurs en un seul.
|
|
html += '<option value="' + NEW_FOLDER + '">+ new folder</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.
|
|
/// MOT DE PASSE ALEATOIRE, tire de crypto.getRandomValues. Rejet des octets
|
|
/// au-dela du dernier multiple de la taille du jeu : un simple modulo
|
|
/// favoriserait les premiers caracteres.
|
|
var GEN_CHARS = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789'
|
|
+ '!@#$%^&*-_=+?';
|
|
function genPassword(n) {
|
|
var out = '', buf = new Uint8Array(1),
|
|
lim = 256 - (256 % GEN_CHARS.length);
|
|
while (out.length < (n || 20)) {
|
|
crypto.getRandomValues(buf);
|
|
if (buf[0] < lim) out += GEN_CHARS.charAt(buf[0] % GEN_CHARS.length);
|
|
}
|
|
return out;
|
|
}
|
|
function genButton(target) {
|
|
return '<button class="btn ico-btn" data-gen="' + target + '">'
|
|
+ ico('dice', 'Generate') + '</button>';
|
|
}
|
|
|
|
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 + '">' + genButton('nPwd');
|
|
}
|
|
|
|
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>'
|
|
// LE SECRET, pas le code. Il manquait ici alors que l'editeur le portait
|
|
// deja : on pouvait poser un second facteur sur une entree existante, pas
|
|
// sur une entree qu'on venait de creer - il fallait enregistrer puis
|
|
// rouvrir. Meme intitule que l'editeur pour qu'on reconnaisse le champ.
|
|
+ newField('nTotp', '2FA secret', 'nTotpRow')
|
|
+ '<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');
|
|
document.getElementById('nTotpRow').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');
|
|
// Le champ libre peut rester vide, et le marqueur du "+ new folder" ne doit
|
|
// jamais partir tel quel : les deux retombent sur All.
|
|
var foldName = fold ? fold.value.trim() : '';
|
|
if (foldName === '' || foldName === NEW_FOLDER) foldName = 'All';
|
|
send('newentry?d=' + encodeURIComponent(foldName)
|
|
+ '&t=' + encodeURIComponent(v('nTitle'))
|
|
+ '&s=' + encodeURIComponent(v('nSite'))
|
|
+ '&u=' + encodeURIComponent(v('nUser'))
|
|
+ '&p=' + encodeURIComponent(document.getElementById('nPwd').value)
|
|
+ '&o=' + encodeURIComponent(v('nTotp'))
|
|
+ '&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 = shortRepoUrl(elUrl.value);
|
|
document.getElementById('sTok').value = elTok.value;
|
|
document.getElementById('sPwd').value = elPwd.value;
|
|
document.getElementById('sPinNew').value = '';
|
|
document.getElementById('secCode').hidden = !vaultOpen;
|
|
document.getElementById('sRepoErr').hidden = true;
|
|
// UN COFFRE EXISTE MAIS EST FERME : le mot de passe de synchro ne se change
|
|
// pas d'ici. Il ne vit que dans le coffre scelle, et seul un coffre ouvert
|
|
// permet de le verifier puis de l'y ranger - tape coffre ferme, il etait
|
|
// perdu a la fermeture de l'app.
|
|
var sp = document.getElementById('sPwd'),
|
|
locked = (authMode === 'open' || authMode === 'login') && !vaultOpen;
|
|
sp.disabled = locked;
|
|
sp.placeholder = locked ? 'sync password - open the vault to change it'
|
|
: 'sync password';
|
|
cfgBase = cfgNow();
|
|
syncSaveBtn(false);
|
|
// La section n'existe que coffre ouvert : le dire tant qu'elle manque, et se
|
|
// taire quand elle est juste en dessous.
|
|
document.getElementById('sUnlockHint').textContent =
|
|
vaultOpen ? ' below' : ', which appears in these settings once the vault is open';
|
|
showLog(false);
|
|
document.body.classList.add('on-settings');
|
|
autoGrow(document.getElementById('sUrl')); // mesurable seulement une fois affiche
|
|
send('state?view=settings');
|
|
}
|
|
|
|
// ENREGISTRER EST UN GESTE, FERMER N'ENREGISTRE RIEN. Fermer enregistrait :
|
|
// une URL effacee par erreur partait avec le retour, sans moyen de revenir.
|
|
// Desormais retour = annuler, et la page repart des valeurs enregistrees.
|
|
// SAVE NE S'ALLUME QUE SI QUELQUE CHOSE A CHANGE, compare aux valeurs
|
|
// enregistrees et non a la derniere frappe : taper un caractere puis l'effacer
|
|
// le rend de nouveau inactif. Apres un Save : coche, inactif, nouvelle base.
|
|
var cfgBase = '';
|
|
function cfgNow() {
|
|
return JSON.stringify([document.getElementById('sUrl').value.trim(),
|
|
document.getElementById('sTok').value.trim(),
|
|
document.getElementById('sPwd').value]);
|
|
}
|
|
function syncSaveBtn(justSaved) {
|
|
var b = document.getElementById('sSave'), dirty = cfgNow() !== cfgBase;
|
|
b.disabled = !dirty;
|
|
b.classList.toggle('main', dirty);
|
|
b.textContent = (!dirty && justSaved) ? String.fromCharCode(0x2713) + ' Saved' : 'Save';
|
|
}
|
|
['sUrl', 'sTok', 'sPwd'].forEach(function (id) {
|
|
document.getElementById(id).addEventListener('input', function () { syncSaveBtn(false); });
|
|
});
|
|
|
|
// L'ADRESSE DU DEPOT TELLE QUE LE NAVIGATEUR L'AFFICHE suffit :
|
|
// https://git.example.com/OWNER/REPO (ou .../src/branch/main/FICHIER) devient
|
|
// l'URL de l'API des contenus. Une URL d'API ou d'endpoint PHP passe telle quelle,
|
|
// et une forme non reconnue aussi - Sync dira alors ce qui ne va pas.
|
|
// LA ZONE DE L'URL PREND LA HAUTEUR DE SON TEXTE : une ligne pour une adresse
|
|
// courte, davantage si elle passe a la ligne.
|
|
function autoGrow(el) {
|
|
el.style.height = 'auto';
|
|
el.style.height = el.scrollHeight + 'px';
|
|
}
|
|
document.addEventListener('input', function (ev) {
|
|
if (ev.target.classList && ev.target.classList.contains('multi')) autoGrow(ev.target);
|
|
});
|
|
|
|
// L'inverse, pour l'affichage : l'URL d'API se relit comme l'adresse du depot.
|
|
function shortRepoUrl(u) {
|
|
var g = /^https:\/\/api\.github\.com\/repos\/([^\/]+)\/([^\/]+)\/contents\/(.+)$/i.exec(u || '');
|
|
if (g) return 'https://github.com/' + g[1] + '/' + g[2] +
|
|
(g[3] === 'vault-sync.json' ? '' : '/blob/main/' + g[3]);
|
|
var m = /^(https?:\/\/[^\/]+)\/api\/v1\/repos\/([^\/]+)\/([^\/]+)\/contents\/(.+)$/i.exec(u || '');
|
|
if (!m) return u;
|
|
return m[1] + '/' + m[2] + '/' + m[3] +
|
|
(m[4] === 'vault-sync.json' ? '' : '/src/branch/main/' + m[4]);
|
|
}
|
|
function normalizeRepoUrl(u) {
|
|
u = u.replace(/\s+/g, '');
|
|
if (!u || /\/api\/v1\/repos\//i.test(u) || /api\.github\.com\/repos\//i.test(u) ||
|
|
/\.php(\?|$)/i.test(u)) return u;
|
|
// GITHUB : l'adresse du depot est sur github.com, l'API sur api.github.com.
|
|
var g = u.match(/^https?:\/\/(www\.)?github\.com\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/blob\/[^\/]+\/(.+?))?\/?$/i);
|
|
if (g) return 'https://api.github.com/repos/' + g[2] + '/' + g[3] +
|
|
'/contents/' + (g[4] || 'vault-sync.json');
|
|
var m = u.match(/^(https?:\/\/[^\/]+)\/([^\/]+)\/([^\/]+?)(?:\.git)?(?:\/src\/branch\/[^\/]+\/(.+?))?\/?$/i);
|
|
if (!m) return u;
|
|
return m[1] + '/api/v1/repos/' + m[2] + '/' + m[3] + '/contents/' + (m[4] || 'vault-sync.json');
|
|
}
|
|
document.getElementById('sScan').addEventListener('click', function () {
|
|
send('scanqr');
|
|
});
|
|
document.getElementById('sSave').addEventListener('click', function () {
|
|
var sUrl = document.getElementById('sUrl');
|
|
elUrl.value = normalizeRepoUrl(sUrl.value);
|
|
sUrl.value = shortRepoUrl(elUrl.value);
|
|
autoGrow(sUrl);
|
|
elTok.value = document.getElementById('sTok').value.trim();
|
|
elPwd.value = document.getElementById('sPwd').value;
|
|
pushConfig();
|
|
document.getElementById('sRepoErr').hidden = true;
|
|
cfgBase = cfgNow();
|
|
syncSaveBtn(true);
|
|
});
|
|
|
|
function closeSettings() {
|
|
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);
|
|
});
|
|
// FACON D'OUVRIR. Delphi dit laquelle est en place (PM.setUnlockMode) ; chaque
|
|
// bouton envoie la commande qui existe deja pour la poser. Revenir au
|
|
// verrouillage d'ecran, c'est retirer le code : il faut donc le code courant.
|
|
var unlockMode = 'none', canDeviceLock = false, hasContainer = false;
|
|
function syncUnlockUi() {
|
|
var pw = hasContainer ? 'vault password' : 'sync password';
|
|
document.getElementById('sModePwd').dataset.label =
|
|
pw.charAt(0).toUpperCase() + pw.slice(1) + ' (type it above)';
|
|
['device', 'code', 'password'].forEach(function (m) {
|
|
var id = { device: 'sModeDevice', code: 'sModeCode', password: 'sModePwd' }[m];
|
|
var b = document.getElementById(id), on = unlockMode === m;
|
|
if (!b.dataset.label) b.dataset.label = b.textContent;
|
|
b.textContent = (on ? String.fromCharCode(0x2713) + ' ' : '') + b.dataset.label;
|
|
b.classList.toggle('main', on);
|
|
});
|
|
document.getElementById('sModeDevice').style.opacity = canDeviceLock ? 1 : .4;
|
|
document.getElementById('sModeNote').textContent =
|
|
{ device: 'Now: screen lock. Changing the screen lock or adding a fingerprint never deletes the vault.',
|
|
code: 'Now: app code. To go back to the screen lock, type the current code above.',
|
|
password: 'Now: ' + pw + '. To go back to the screen lock, type it above.',
|
|
none: 'Now: no protection - anyone holding the phone unlocked can open the vault.' }[unlockMode]
|
|
+ (canDeviceLock ? '' : ' This phone has no screen lock.');
|
|
document.getElementById('btnUsePwd').hidden =
|
|
!(authMode === 'open' && unlockMode === 'device' && hasContainer);
|
|
}
|
|
function modeField() {
|
|
var el = document.getElementById('sPinNew'), v = el.value;
|
|
el.value = '';
|
|
return v;
|
|
}
|
|
document.getElementById('sModeCode').addEventListener('click', function () {
|
|
var v = modeField();
|
|
if (!v) { toast('Type the new code above first'); return; }
|
|
send('pinset?c=' + encodeURIComponent(v));
|
|
});
|
|
document.getElementById('sModePwd').addEventListener('click', function () {
|
|
var v = modeField();
|
|
if (!v) { toast('Type the ' + (hasContainer ? 'vault' : 'sync') + ' password above first'); return; }
|
|
send('pinset?pw=1&c=' + encodeURIComponent(v));
|
|
});
|
|
document.getElementById('sModeDevice').addEventListener('click', function () {
|
|
if (!canDeviceLock) { toast('No screen lock on this phone - set one in Android settings first'); return; }
|
|
if (unlockMode === 'device') { toast('Already using the screen lock'); return; }
|
|
var v = modeField();
|
|
if (!v && unlockMode !== 'none') { toast('Type the current code or password above first'); return; }
|
|
send('pinclear?c=' + encodeURIComponent(v));
|
|
});
|
|
document.getElementById('btnUsePwd').addEventListener('click', function () {
|
|
PM.setAuthMode('login');
|
|
document.getElementById('pass').focus();
|
|
});
|
|
document.getElementById('sWipe').addEventListener('click', function (ev) {
|
|
// DEUX APPUIS, comme pour retirer le code : un effacement sans retour ne se
|
|
// declenche pas sur un doigt qui glisse.
|
|
var b = ev.currentTarget;
|
|
if (b.getAttribute('data-armed') === null) {
|
|
b.setAttribute('data-armed', '1');
|
|
b.textContent = 'Really delete - tap again';
|
|
setTimeout(function () {
|
|
b.removeAttribute('data-armed');
|
|
b.textContent = 'Delete the local vault';
|
|
}, 4000);
|
|
return;
|
|
}
|
|
b.removeAttribute('data-armed');
|
|
b.textContent = 'Delete the local vault';
|
|
send('wipelocal');
|
|
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) {
|
|
vaultOpen = true;
|
|
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';
|
|
},
|
|
/// Erreur de depot, affichee dans les reglages au-dessus des champs a corriger.
|
|
settingsError: function (msg) {
|
|
var e = document.getElementById('sRepoErr');
|
|
e.textContent = msg;
|
|
e.hidden = false;
|
|
},
|
|
|
|
setStatus: function (text, err) {
|
|
var s = document.getElementById('status');
|
|
s.textContent = text;
|
|
// classList.toggle avec un second argument : la classe suit le drapeau,
|
|
// sans qu'un message neutre ait a penser a l'enlever.
|
|
s.classList.toggle('err', !!err);
|
|
},
|
|
/// 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;
|
|
},
|
|
/// 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.
|
|
/// Reglages lus dans le QR du poste : ils remplissent les champs, Save valide.
|
|
pasteConfig: function (url, tok) {
|
|
var u = document.getElementById('sUrl');
|
|
u.value = shortRepoUrl(url);
|
|
autoGrow(u);
|
|
document.getElementById('sTok').value = tok;
|
|
syncSaveBtn(false);
|
|
toast('URL and token pasted - type the sync password, then Save');
|
|
document.getElementById('sPwd').focus();
|
|
},
|
|
|
|
setConfig: function (url, tok, pwd, show) {
|
|
// elPin volontairement absent : rien ne le restaure, jamais.
|
|
elUrl.value = url; elTok.value = tok; elPwd.value = pwd;
|
|
// L'adresse decide de la mise en page ci-dessus, et elle peut arriver apres
|
|
// setAuthMode : sans ce rappel, l'accueil garderait la forme qu'il avait
|
|
// avant de savoir qu'un depot est configure.
|
|
syncAuthbox();
|
|
// 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 () {
|
|
// C'EST LE BOUTON DES REGLAGES QUI S'EFFACE, plus celui de l'accueil : le
|
|
// journal a demenage, et l'accueil n'en porte plus. Le geste qui le ramene
|
|
// ne change pas - un appui long sur la ligne d'etat.
|
|
document.getElementById('sLog').hidden = true;
|
|
document.getElementById('sDiagH').hidden = true;
|
|
var st = document.getElementById('status'), timer = null;
|
|
st.addEventListener('touchstart', function () {
|
|
timer = setTimeout(function () {
|
|
document.getElementById('sLog').hidden = false;
|
|
document.getElementById('sDiagH').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;
|
|
syncAuthbox();
|
|
syncPinbox();
|
|
document.getElementById('btnAuth').textContent =
|
|
(mode === 'register') ? 'Create vault' : 'Sign in';
|
|
syncUnlockUi();
|
|
},
|
|
|
|
setUnlockMode: function (mode, canDevice, container) {
|
|
unlockMode = mode; canDeviceLock = canDevice; hasContainer = container;
|
|
syncUnlockUi();
|
|
},
|
|
|
|
setPinNeeded: function (on) {
|
|
pinNeeded = on;
|
|
syncPinbox();
|
|
},
|
|
/// Le code vient de changer : le champ d'ouverture n'a plus rien a dire, et
|
|
/// le laisser rempli rescellerait sous lui a la prochaine ecriture.
|
|
pinChanged: function () {
|
|
document.getElementById('pin').value = '';
|
|
pushConfig();
|
|
},
|
|
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');
|
|
},
|
|
/// Version de debogage : Delphi le dit, la page l'affiche. Jamais l'inverse.
|
|
showDebug: function () {
|
|
document.getElementById('secDebug').hidden = false;
|
|
},
|
|
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 () {
|
|
vaultOpen = false;
|
|
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>
|