Improving UI and sync process
This commit is contained in:
38
SYNC.md
38
SYNC.md
@@ -8,24 +8,36 @@ Une écriture de journée reste locale et transactionnelle, même sans réseau.
|
|||||||
|
|
||||||
Dans **Données et sauvegardes → Synchronisation et appareils** :
|
Dans **Données et sauvegardes → Synchronisation et appareils** :
|
||||||
|
|
||||||
- Préparer le coffre, télécharger `pardis-secours.json`, le conserver à part,
|
- Choisir **Créer mon coffre**. L’application prépare alors le coffre et son
|
||||||
puis confirmer l’activation. Le document donne accès au coffre : il doit être
|
document de secours, puis amène directement à l’étape obligatoire de sauvegarde.
|
||||||
protégé comme un mot de passe de gestionnaire de secrets.
|
Télécharger `pardis-secours.json`, le conserver à part et confirmer l’activation.
|
||||||
|
Le document donne accès au coffre : il doit être protégé comme un mot de passe
|
||||||
|
de gestionnaire de secrets.
|
||||||
- Ajouter un appareil depuis ce même écran. L’invitation expire après cinq minutes,
|
- Ajouter un appareil depuis ce même écran. L’invitation expire après cinq minutes,
|
||||||
n’accepte qu’un candidat et doit être renouvelée par l’écran visible toutes les
|
n’accepte qu’un candidat et doit être renouvelée par l’écran visible toutes les
|
||||||
quelques secondes. Quitter ou masquer l’écran l’annule ; en cas de fermeture
|
quelques secondes. En quittant l’écran ou en passant à l’appareil photo, les
|
||||||
brutale, le bail serveur expire sous 25 secondes.
|
battements s’arrêtent et le bail serveur expire sous 25 secondes si l’invitation
|
||||||
- Saisir les huit caractères sur le nouvel appareil ou scanner le QR avec l’appareil
|
n’est pas reprise.
|
||||||
photo du téléphone. Le QR ouvre l’application web ; le navigateur/profil qui
|
- Saisir les huit caractères sur le nouvel appareil ou scanner le QR avec la caméra
|
||||||
|
d’un appareil compatible. Le QR ouvre l’application web ; le navigateur/profil qui
|
||||||
reçoit ce lien doit être celui dans lequel on veut utiliser Pardis. Les copies
|
reçoit ce lien doit être celui dans lequel on veut utiliser Pardis. Les copies
|
||||||
installées et les navigateurs peuvent avoir des stockages distincts, notamment
|
installées et les navigateurs peuvent avoir des stockages distincts, notamment
|
||||||
sur iOS ; le code manuel permet d’associer directement la bonne copie.
|
sur iOS ; le code manuel permet d’associer directement la bonne copie.
|
||||||
- Comparer **toute** l’empreinte de huit groupes hexadécimaux sur les deux écrans,
|
- Le nouvel appareil peut scanner le QR de l’appareil existant. Si l’appareil
|
||||||
cocher leur égalité et confirmer sur l’appareil existant. Les demandes ne sont
|
existant est un smartphone, il peut aussi scanner le QR de la demande affiché par
|
||||||
|
le nouvel appareil. Dans les deux cas, la confirmation finale se fait sur
|
||||||
|
l’appareil existant. Les QR contiennent un lien vers l’écran d’association, que
|
||||||
|
la caméra native du téléphone peut ouvrir ; les navigateurs avec lecteur caméra
|
||||||
|
intégré peuvent aussi les lire directement. La saisie manuelle reste disponible.
|
||||||
|
- Vérifier l’empreinte de cinq groupes hexadécimaux, ou faire vérifier les deux
|
||||||
|
empreintes par QR, puis confirmer sur l’appareil existant. Les demandes ne sont
|
||||||
ni surveillées ni affichées en dehors de l’écran dédié.
|
ni surveillées ni affichées en dehors de l’écran dédié.
|
||||||
- Les données déjà présentes sur le nouvel appareil sont conservées. Les journées
|
- Après confirmation, choisir de conserver tout l’état du coffre, tout l’état de
|
||||||
distinctes sont réunies et les dates communes divergentes deviennent des conflits.
|
l’appareil courant, ou d’ouvrir la fusion détaillée. Le premier choix remplace
|
||||||
Les réglages déjà enregistrés peuvent eux aussi nécessiter un choix.
|
les journées locales par celles du coffre. Le second fait de l’état courant la
|
||||||
|
nouvelle version partagée. La fusion détaillée réunit les dates distinctes et
|
||||||
|
permet de départager ensuite les dates ou réglages divergents. Les anciennes
|
||||||
|
révisions chiffrées restent dans l’historique du coffre.
|
||||||
|
|
||||||
Les automatismes ont un responsable. Un autre appareil propose discrètement de
|
Les automatismes ont un responsable. Un autre appareil propose discrètement de
|
||||||
prendre le rôle ; ce transfert exige le réseau et un numéro de rôle encore actuel.
|
prendre le rôle ; ce transfert exige le réseau et un numéro de rôle encore actuel.
|
||||||
@@ -94,7 +106,7 @@ la validation et l’installation transactionnelle de la page reçue.
|
|||||||
la confidentialité ; l’authentification de l’association vient de la comparaison
|
la confidentialité ; l’authentification de l’association vient de la comparaison
|
||||||
de l’empreinte liant invitation et clés des deux participants. Le QR épingle en
|
de l’empreinte liant invitation et clés des deux participants. Le QR épingle en
|
||||||
plus l’identité de l’appareil parrain. Le code court ne dérive aucune clé.
|
plus l’identité de l’appareil parrain. Le code court ne dérive aucune clé.
|
||||||
- L’empreinte humaine a 128 bits. Elle n’est pas remplacée par un petit code décimal
|
- L’empreinte humaine a 80 bits. Elle n’est pas remplacée par un petit code décimal
|
||||||
qui, sans protocole d’engagement adapté, serait vulnérable à une recherche hors ligne.
|
qui, sans protocole d’engagement adapté, serait vulnérable à une recherche hors ligne.
|
||||||
- Chaque appareil a sa clé de signature et sa clé de réception. Le document de
|
- Chaque appareil a sa clé de signature et sa clé de réception. Le document de
|
||||||
secours possède une identité distincte et une clé privée de déchiffrement HPKE.
|
secours possède une identité distincte et une clé privée de déchiffrement HPKE.
|
||||||
|
|||||||
46
package-lock.json
generated
46
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.8.0",
|
"version": "0.8.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hpke/core": "^1.9.0",
|
"@hpke/core": "^1.9.0",
|
||||||
|
"@zxing/browser": "^0.2.1",
|
||||||
"dexie": "^4.4.4",
|
"dexie": "^4.4.4",
|
||||||
"qrcode": "^1.5.4"
|
"qrcode": "^1.5.4"
|
||||||
},
|
},
|
||||||
@@ -3116,6 +3117,41 @@
|
|||||||
"url": "https://opencollective.com/vitest"
|
"url": "https://opencollective.com/vitest"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@zxing/browser": {
|
||||||
|
"version": "0.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@zxing/browser/-/browser-0.2.1.tgz",
|
||||||
|
"integrity": "sha512-92pVfVDUXbc15xu9vIEmhNyqIEASMEkDF92CwT6T+7sg2EHXJRktH9CQKoQSXe51UtbNsj+Fm09H/JBWHMs/Sw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@zxing/text-encoding": "^0.9.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@zxing/library": "^0.23.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@zxing/library": {
|
||||||
|
"version": "0.23.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@zxing/library/-/library-0.23.0.tgz",
|
||||||
|
"integrity": "sha512-6fkkoFwP8CHxl6ugnPsj74PLJgX2iRv5zczGAyt5OBzQgxFhuhF0NCEc4t4OvSr8xAv2MRLlI0Iu9ZGDZQ2urA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"peer": true,
|
||||||
|
"dependencies": {
|
||||||
|
"ts-custom-error": "^3.3.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 24.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@zxing/text-encoding": "~0.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@zxing/text-encoding": {
|
||||||
|
"version": "0.9.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz",
|
||||||
|
"integrity": "sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA==",
|
||||||
|
"license": "(Unlicense OR Apache-2.0)",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.18.0",
|
"version": "8.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz",
|
||||||
@@ -6874,6 +6910,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/ts-custom-error": {
|
||||||
|
"version": "3.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ts-custom-error/-/ts-custom-error-3.3.1.tgz",
|
||||||
|
"integrity": "sha512-5OX1tzOjxWEgsr/YEUWSuPrQ00deKLh6D7OTWcvNHm12/7QPyRh8SYpyWvA4IZv8H/+GQWQEh/kwo95Q9OVW1A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
|
"engines": {
|
||||||
|
"node": ">=14.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
"version": "2.8.1",
|
"version": "2.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@hpke/core": "^1.9.0",
|
"@hpke/core": "^1.9.0",
|
||||||
|
"@zxing/browser": "^0.2.1",
|
||||||
"dexie": "^4.4.4",
|
"dexie": "^4.4.4",
|
||||||
"qrcode": "^1.5.4"
|
"qrcode": "^1.5.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ export function createSyncServer({ filename = ':memory:', origin = '', maxBytes
|
|||||||
if (req.method === 'DELETE') { run('DELETE FROM invitations WHERE code=?', invite.code); return { ok: true }; }
|
if (req.method === 'DELETE') { run('DELETE FROM invitations WHERE code=?', invite.code); return { ok: true }; }
|
||||||
if (match[2] === 'heartbeat' && req.method === 'POST') {
|
if (match[2] === 'heartbeat' && req.method === 'POST') {
|
||||||
if (!invite.approved) run('UPDATE invitations SET lease=? WHERE code=?', now() + 25000, invite.code);
|
if (!invite.approved) run('UPDATE invitations SET lease=? WHERE code=?', now() + 25000, invite.code);
|
||||||
return { applicant: invite.applicant ? parse(invite.applicant) : null, approved: !!invite.approved };
|
return { applicant: invite.applicant ? parse(invite.applicant) : null, approved: !!invite.approved, expires: invite.expires };
|
||||||
}
|
}
|
||||||
if (match[2] === 'approve' && req.method === 'POST') {
|
if (match[2] === 'approve' && req.method === 'POST') {
|
||||||
if (invite.approved) return { ok: true };
|
if (invite.approved) return { ok: true };
|
||||||
|
|||||||
@@ -90,9 +90,6 @@
|
|||||||
{#if !ready}
|
{#if !ready}
|
||||||
<div class="boot">{#if bootError}<div class="stack"><p role="alert">{bootError}</p><button onclick={() => (retry += 1)}>Réessayer</button></div>{:else}<p>Chargement…</p>{/if}</div>
|
<div class="boot">{#if bootError}<div class="stack"><p role="alert">{bootError}</p><button onclick={() => (retry += 1)}>Réessayer</button></div>{:else}<p>Chargement…</p>{/if}</div>
|
||||||
{:else}
|
{:else}
|
||||||
{#if syncState.linked && !['sync', 'data'].includes(view)}
|
|
||||||
<div class="update"><button onclick={() => go('sync')}>{syncState.conflicts.length ? `${syncState.conflicts.length} conflit(s) à résoudre` : syncState.revoked ? 'Appareil révoqué' : !syncState.enabled ? 'Synchronisation en pause' : syncState.pending ? 'Enregistré ici · en attente de synchronisation' : 'Synchronisation'}</button></div>
|
|
||||||
{/if}
|
|
||||||
{#if syncState.enabled && syncState.owner && syncState.owner !== syncState.deviceId && !roleDismissed && view !== 'sync'}
|
{#if syncState.enabled && syncState.owner && syncState.owner !== syncState.deviceId && !roleDismissed && view !== 'sync'}
|
||||||
<div class="update"><span>Les automatismes sont exécutés sur un autre appareil.</span><button disabled={syncState.busy} onclick={takeRole}>Utiliser cet appareil pour les automatismes</button><button onclick={() => { roleDismissed = true; }}>Plus tard</button></div>
|
<div class="update"><span>Les automatismes sont exécutés sur un autre appareil.</span><button disabled={syncState.busy} onclick={takeRole}>Utiliser cet appareil pour les automatismes</button><button onclick={() => { roleDismissed = true; }}>Plus tard</button></div>
|
||||||
{#if roleError}<p role="status">{roleError}</p>{/if}
|
{#if roleError}<p role="status">{roleError}</p>{/if}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { db } from '../db.js';
|
import { db } from '../db.js';
|
||||||
import { canonical, identity, publicIdentity, randomSecret, requestProof, b64, bytes, encrypt, decrypt, seal, open, sign, verify, objectId, fingerprint, pairingTranscript } from './crypto.js';
|
import { canonical, identity, publicIdentity, randomSecret, requestProof, b64, bytes, encrypt, decrypt, seal, open, sign, verify, objectId, fingerprint, pairingTranscript } from './crypto.js';
|
||||||
import { revisionHeader, validateValue } from './model.js';
|
import { revisionHeader, validateValue } from './model.js';
|
||||||
import { captureChanges, detach, getConfig, receiveVersions, saveConfig, tables } from './store.js';
|
import { captureChanges, detach, finishInitialChoice, getConfig, receiveVersions, saveConfig, snapshotLocalValues, tables } from './store.js';
|
||||||
import { syncState } from './state.svelte.js';
|
import { syncState } from './state.svelte.js';
|
||||||
|
|
||||||
const API = '/api/sync';
|
const API = '/api/sync';
|
||||||
@@ -80,13 +80,16 @@ export async function activateVault() {
|
|||||||
await db.transaction('rw', db.syncState, async () => { await saveConfig(linked); await db.syncState.delete('setup'); });
|
await db.transaction('rw', db.syncState, async () => { await saveConfig(linked); await db.syncState.delete('setup'); });
|
||||||
await synchronize();
|
await synchronize();
|
||||||
}
|
}
|
||||||
async function attach(config) {
|
async function attach(config, initialChoice = '') {
|
||||||
await db.transaction('rw', tables(db), async () => {
|
await db.transaction('rw', tables(db), async () => {
|
||||||
if (await getConfig()) throw new Error('Cet appareil est déjà lié.');
|
if (await getConfig()) throw new Error('Cet appareil est déjà lié.');
|
||||||
|
const local = initialChoice === 'local' ? await snapshotLocalValues() : null;
|
||||||
for (const t of [db.syncObjects, db.syncVersions, db.syncOutbox, db.syncConflicts]) await t.clear();
|
for (const t of [db.syncObjects, db.syncVersions, db.syncOutbox, db.syncConflicts]) await t.clear();
|
||||||
await db.syncState.delete('cursor'); await db.syncState.delete('server'); await db.syncState.delete('lastSync');
|
await db.syncState.delete('cursor'); await db.syncState.delete('server'); await db.syncState.delete('lastSync');
|
||||||
await saveConfig(config);
|
if (initialChoice === 'remote') { await db.days.clear(); await db.entries.clear(); }
|
||||||
await captureChanges(); // Existing local dates become independent versions, never silently replaced.
|
await saveConfig(initialChoice ? { ...config, initialChoice } : config);
|
||||||
|
if (initialChoice === 'local') await db.syncState.put({ key: 'initial-local', value: local });
|
||||||
|
if (!initialChoice) await captureChanges(); // Manual merge keeps local dates as independent versions.
|
||||||
await db.syncState.delete('joining'); await db.syncState.delete('setup'); await db.syncState.delete('recovery-reset');
|
await db.syncState.delete('joining'); await db.syncState.delete('setup'); await db.syncState.delete('recovery-reset');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -130,6 +133,18 @@ export async function cancelInvitation() {
|
|||||||
const current = invitation; invitation = null;
|
const current = invitation; invitation = null;
|
||||||
if (current) await api(`/invitations/${current.code}`, { method: 'DELETE', body: {}, device: current.device }).catch(() => {});
|
if (current) await api(`/invitations/${current.code}`, { method: 'DELETE', body: {}, device: current.device }).catch(() => {});
|
||||||
}
|
}
|
||||||
|
export async function resumeInvitation(code) {
|
||||||
|
const config = await getConfig();
|
||||||
|
const normalized = code.toUpperCase().replace(/[\s-]/g, '');
|
||||||
|
if (!config?.enabled || config.revoked || !/^[A-Z2-9]{8}$/.test(normalized)) throw new Error('Invitation invalide sur cet appareil.');
|
||||||
|
const previous = invitation;
|
||||||
|
invitation = { code: normalized, expires: 0, sponsor: publicIdentity(config.device), device: config.device };
|
||||||
|
try {
|
||||||
|
const result = await pollInvitation(normalized);
|
||||||
|
invitation.expires = result.expires;
|
||||||
|
return { invite: { ...invitation, url: '' }, ...result };
|
||||||
|
} catch (error) { invitation = previous; throw error; }
|
||||||
|
}
|
||||||
export async function pollInvitation(code) {
|
export async function pollInvitation(code) {
|
||||||
if (invitation?.code !== code || document.visibilityState === 'hidden') throw new Error('Écran d’association fermé.');
|
if (invitation?.code !== code || document.visibilityState === 'hidden') throw new Error('Écran d’association fermé.');
|
||||||
const result = await api(`/invitations/${code}/heartbeat`, { method: 'POST', body: {}, device: invitation.device });
|
const result = await api(`/invitations/${code}/heartbeat`, { method: 'POST', body: {}, device: invitation.device });
|
||||||
@@ -164,14 +179,22 @@ export async function approveInvitation(code, applicant, name) {
|
|||||||
export async function finishJoining() {
|
export async function finishJoining() {
|
||||||
const pending = (await db.syncState.get('joining'))?.value;
|
const pending = (await db.syncState.get('joining'))?.value;
|
||||||
if (!pending?.vault) throw new Error('Aucune association en cours.');
|
if (!pending?.vault) throw new Error('Aucune association en cours.');
|
||||||
|
if (pending.approved) return true;
|
||||||
const result = await api(`/invitations/${pending.code}/result`, { device: pending.device, who: `pair:${pending.code}` });
|
const result = await api(`/invitations/${pending.code}/result`, { device: pending.device, who: `pair:${pending.code}` });
|
||||||
if (result.waiting) return false;
|
if (result.waiting) return false;
|
||||||
if (result.vault !== pending.vault) throw new Error('Coffre inattendu.');
|
if (result.vault !== pending.vault) throw new Error('Coffre inattendu.');
|
||||||
const ring = validateRing(await open(pending.device.wrapPrivate, result.envelope, wrapContext(result.vault, pending.device.id)));
|
const ring = validateRing(await open(pending.device.wrapPrivate, result.envelope, wrapContext(result.vault, pending.device.id)));
|
||||||
await attach({ vault: result.vault, device: pending.device, enabled: true, owner: '', ring });
|
await db.syncState.put({ key: 'joining', value: { ...pending, ring, approved: true } });
|
||||||
await synchronize();
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
export async function chooseJoiningStrategy(strategy) {
|
||||||
|
if (!['remote', 'local', 'manual'].includes(strategy)) throw new Error('Choix de fusion invalide.');
|
||||||
|
const pending = (await db.syncState.get('joining'))?.value;
|
||||||
|
if (!pending?.approved || !pending.ring) throw new Error('Attendez la confirmation de l’appareil d’origine.');
|
||||||
|
const config = { vault: pending.vault, device: pending.device, enabled: true, owner: '', ring: pending.ring };
|
||||||
|
await attach(config, strategy === 'manual' ? '' : strategy);
|
||||||
|
await synchronize();
|
||||||
|
}
|
||||||
async function fetchState(config) {
|
async function fetchState(config) {
|
||||||
const state = await api('/state', { device: config.device });
|
const state = await api('/state', { device: config.device });
|
||||||
if (state.vault !== config.vault) throw new Error('Coffre inattendu.');
|
if (state.vault !== config.vault) throw new Error('Coffre inattendu.');
|
||||||
@@ -229,6 +252,7 @@ async function syncOnce() {
|
|||||||
more = page.more;
|
more = page.more;
|
||||||
if (more && !records.length) throw new Error('Réponse de synchronisation incomplète.');
|
if (more && !records.length) throw new Error('Réponse de synchronisation incomplète.');
|
||||||
}
|
}
|
||||||
|
if (await finishInitialChoice()) await syncOnce();
|
||||||
await db.syncState.put({ key: 'lastSync', value: Date.now() });
|
await db.syncState.put({ key: 'lastSync', value: Date.now() });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
syncState.error = error.message;
|
syncState.error = error.message;
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ export async function objectId(secret, key) {
|
|||||||
}
|
}
|
||||||
export async function fingerprint(value) {
|
export async function fingerprint(value) {
|
||||||
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes(canonical(value))));
|
const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', bytes(canonical(value))));
|
||||||
// 128 bits, compared in full: short decimal SAS without a commitment phase
|
// 80 bits, compared in full: short decimal SAS without a commitment phase
|
||||||
// would let an active relay search for a colliding key offline.
|
// would let an active relay search for a colliding key offline.
|
||||||
return [...hash.slice(0, 16)].map(n => n.toString(16).padStart(2, '0')).join('').match(/.{4}/g).join(' ');
|
return [...hash.slice(0, 10)].map(n => n.toString(16).padStart(2, '0')).join('').match(/.{4}/g).join(' ');
|
||||||
}
|
}
|
||||||
export const pairingTranscript = (invite, applicant) => ({ protocol: 'pardis-pair-v1', code: invite.code, sponsor: invite.sponsor, applicant: publicIdentity(applicant) });
|
export const pairingTranscript = (invite, applicant) => ({ protocol: 'pardis-pair-v1', code: invite.code, sponsor: invite.sponsor, applicant: publicIdentity(applicant) });
|
||||||
export async function requestProof(privateKey, method, path, body, who) {
|
export async function requestProof(privateKey, method, path, body, who) {
|
||||||
|
|||||||
@@ -5,14 +5,14 @@ import { dayValue, headsOf, same, settingKeys, validateValue } from './model.js'
|
|||||||
export const tables = database => [database.days, database.entries, database.settings, database.syncState, database.syncObjects, database.syncVersions, database.syncOutbox, database.syncConflicts];
|
export const tables = database => [database.days, database.entries, database.settings, database.syncState, database.syncObjects, database.syncVersions, database.syncOutbox, database.syncConflicts];
|
||||||
export async function getConfig(database = db) { return (await database.syncState.get('config'))?.value ?? null; }
|
export async function getConfig(database = db) { return (await database.syncState.get('config'))?.value ?? null; }
|
||||||
export async function saveConfig(value, database = db) { await database.syncState.put({ key: 'config', value }); }
|
export async function saveConfig(value, database = db) { await database.syncState.put({ key: 'config', value }); }
|
||||||
async function values(database) {
|
async function values(database, { completeSettings = false } = {}) {
|
||||||
const [days, entries, settings] = await Promise.all([database.days.toArray(), database.entries.toArray(), database.settings.get('app')]);
|
const [days, entries, settings] = await Promise.all([database.days.toArray(), database.entries.toArray(), database.settings.get('app')]);
|
||||||
const result = new Map(days.map(day => [`day:${day.date}`, dayValue(day, entries)]));
|
const result = new Map(days.map(day => [`day:${day.date}`, dayValue(day, entries)]));
|
||||||
// A joining device with no saved preferences must not overwrite existing rules.
|
// A joining device with no saved preferences must not overwrite existing rules.
|
||||||
if (settings) for (const key of settingKeys) result.set(`setting:${key}`, settings.value[key] ?? DEFAULT_SETTINGS[key]);
|
if (settings || completeSettings) for (const key of settingKeys) result.set(`setting:${key}`, settings?.value[key] ?? DEFAULT_SETTINGS[key]);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
async function localRevision(database, config, key, value, parents) {
|
export async function localRevision(database, config, key, value, parents) {
|
||||||
const version = { id: crypto.randomUUID(), key, value, parents, device: config.device.id, modifiedAt: Date.now() };
|
const version = { id: crypto.randomUUID(), key, value, parents, device: config.device.id, modifiedAt: Date.now() };
|
||||||
await database.syncVersions.add(version);
|
await database.syncVersions.add(version);
|
||||||
await database.syncOutbox.add(version);
|
await database.syncOutbox.add(version);
|
||||||
@@ -24,7 +24,9 @@ async function localRevision(database, config, key, value, parents) {
|
|||||||
export async function captureChanges(database = db) {
|
export async function captureChanges(database = db) {
|
||||||
return database.transaction('rw', tables(database), async () => {
|
return database.transaction('rw', tables(database), async () => {
|
||||||
const config = await getConfig(database);
|
const config = await getConfig(database);
|
||||||
if (!config?.enabled) return;
|
// Initial-choice linking must download the remote graph before making the
|
||||||
|
// selected local or remote state authoritative.
|
||||||
|
if (!config?.enabled || config.initialChoice) return;
|
||||||
const current = await values(database);
|
const current = await values(database);
|
||||||
const previous = await database.syncObjects.toArray();
|
const previous = await database.syncObjects.toArray();
|
||||||
const byKey = new Map(previous.map(o => [o.key, o]));
|
const byKey = new Map(previous.map(o => [o.key, o]));
|
||||||
@@ -38,7 +40,7 @@ export async function captureChanges(database = db) {
|
|||||||
await database.syncState.put({ key: 'dirty', value: false });
|
await database.syncState.put({ key: 'dirty', value: false });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function install(database, key, value) {
|
export async function install(database, key, value) {
|
||||||
validateValue(key, value);
|
validateValue(key, value);
|
||||||
if (key.startsWith('setting:')) {
|
if (key.startsWith('setting:')) {
|
||||||
const rec = await database.settings.get('app');
|
const rec = await database.settings.get('app');
|
||||||
@@ -106,7 +108,7 @@ export async function receiveVersions(records, cursor, vault, database = db) {
|
|||||||
}
|
}
|
||||||
await database.syncVersions.add(record);
|
await database.syncVersions.add(record);
|
||||||
}
|
}
|
||||||
await reconcileObjects(database);
|
if (!config.initialChoice) await reconcileObjects(database);
|
||||||
await database.syncState.put({ key: 'cursor', value: Math.max(cursor, (await database.syncState.get('cursor'))?.value ?? 0) });
|
await database.syncState.put({ key: 'cursor', value: Math.max(cursor, (await database.syncState.get('cursor'))?.value ?? 0) });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -125,6 +127,54 @@ export async function resolveConflict(key, chosenId, expectedHeads, database = d
|
|||||||
await reconcileObjects(database);
|
await reconcileObjects(database);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
export async function finishInitialChoice(database = db) {
|
||||||
|
return database.transaction('rw', tables(database), async () => {
|
||||||
|
const config = await getConfig(database);
|
||||||
|
if (!['local', 'remote'].includes(config?.initialChoice)) return false;
|
||||||
|
if (config.initialChoice === 'remote') {
|
||||||
|
await database.days.clear();
|
||||||
|
await database.entries.clear();
|
||||||
|
const settings = await database.settings.get('app');
|
||||||
|
if (settings) await database.settings.put({ key: 'app', value: { ...DEFAULT_SETTINGS, theme: settings.value.theme ?? DEFAULT_SETTINGS.theme } });
|
||||||
|
await reconcileObjects(database);
|
||||||
|
delete config.initialChoice;
|
||||||
|
await saveConfig(config, database);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const original = new Map((await database.syncState.get('initial-local'))?.value ?? []);
|
||||||
|
const grouped = new Map();
|
||||||
|
for (const version of await database.syncVersions.toArray()) {
|
||||||
|
if (!grouped.has(version.key)) grouped.set(version.key, []);
|
||||||
|
grouped.get(version.key).push(version);
|
||||||
|
}
|
||||||
|
const keys = new Set([...grouped.keys(), ...original.keys()]);
|
||||||
|
// Clear remote active days before restoring the one (if any) on this device.
|
||||||
|
const ordered = [...keys].sort((a, b) => Number(original.get(a)?.day?.status === 'active') - Number(original.get(b)?.day?.status === 'active'));
|
||||||
|
for (const key of ordered) {
|
||||||
|
const versions = grouped.get(key) ?? [];
|
||||||
|
const heads = headsOf(versions);
|
||||||
|
const value = original.has(key) ? original.get(key) : null;
|
||||||
|
if (!versions.length && value === null) continue;
|
||||||
|
if (!await install(database, key, value)) {
|
||||||
|
await database.syncConflicts.put({ key, versions: heads, localValue: value, reason: 'Une autre journée est déjà en cours sur cet appareil.' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Revisions are needed when this choice overrides a remote head, or to
|
||||||
|
// join identical but independently-created heads into one graph branch.
|
||||||
|
if (heads.length === 0 || heads.length > 1 || heads.some(v => !same(v.value, value))) {
|
||||||
|
await localRevision(database, config, key, value, heads.map(v => v.id));
|
||||||
|
} else await database.syncObjects.put({ key, value, localHeads: heads.map(v => v.id) });
|
||||||
|
await database.syncConflicts.delete(key);
|
||||||
|
}
|
||||||
|
delete config.initialChoice;
|
||||||
|
await saveConfig(config, database);
|
||||||
|
await database.syncState.delete('initial-local');
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
export async function snapshotLocalValues(database = db) {
|
||||||
|
return [...await values(database, { completeSettings: true })];
|
||||||
|
}
|
||||||
export async function canAutomate(database = db) {
|
export async function canAutomate(database = db) {
|
||||||
const config = await getConfig(database);
|
const config = await getConfig(database);
|
||||||
if (!config) return true;
|
if (!config) return true;
|
||||||
|
|||||||
@@ -1,28 +1,33 @@
|
|||||||
<script>
|
<script>
|
||||||
import { onMount } from 'svelte';
|
import { onMount, tick } from 'svelte';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
import { go } from '../lib/router.svelte.js';
|
import { go } from '../lib/router.svelte.js';
|
||||||
import { dayState, refresh } from '../lib/day.svelte.js';
|
import { dayState, refresh } from '../lib/day.svelte.js';
|
||||||
import { loadSettings, WEEKDAY_LABELS } from '../lib/settings.svelte.js';
|
import { loadSettings, WEEKDAY_LABELS } from '../lib/settings.svelte.js';
|
||||||
import { AUTOMATION_OPTIONS } from '../lib/automation-options.js';
|
import { AUTOMATION_OPTIONS } from '../lib/automation-options.js';
|
||||||
import { syncState } from '../lib/sync/state.svelte.js';
|
import { syncState } from '../lib/sync/state.svelte.js';
|
||||||
import { prepareVault, activateVault, prepareRecoveryReset, commitRecoveryReset, recoverVault, startInvitation, cancelInvitation, pollInvitation, joinInvitation, approveInvitation, finishJoining, synchronize, setSyncEnabled, revokeDevice, disconnect, deleteVault, loadSyncStatus, takeAutomationRole } from '../lib/sync/client.js';
|
import { prepareVault, activateVault, prepareRecoveryReset, commitRecoveryReset, recoverVault, startInvitation, resumeInvitation, cancelInvitation, pollInvitation, joinInvitation, approveInvitation, finishJoining, chooseJoiningStrategy, synchronize, setSyncEnabled, revokeDevice, disconnect, deleteVault, loadSyncStatus, takeAutomationRole } from '../lib/sync/client.js';
|
||||||
import { resolveConflict } from '../lib/sync/store.js';
|
import { resolveConflict } from '../lib/sync/store.js';
|
||||||
|
|
||||||
const query = new URLSearchParams(location.hash.split('?')[1] ?? '');
|
const query = new URLSearchParams(location.hash.split('?')[1] ?? '');
|
||||||
let name = $state('Mon appareil');
|
let name = $state('Mon appareil');
|
||||||
let code = $state(query.get('invite') ?? '');
|
let code = $state(query.get('invite') ?? '');
|
||||||
|
let pin = $state(query.get('pin') ?? '');
|
||||||
let error = $state('');
|
let error = $state('');
|
||||||
let busy = $state(false);
|
let busy = $state(false);
|
||||||
let recovery = $state('');
|
let recovery = $state('');
|
||||||
let recoveryReset = $state(false);
|
let recoveryReset = $state(false);
|
||||||
let savedRecovery = $state(false);
|
let savedRecovery = $state(false);
|
||||||
let recoveryInput = $state('');
|
let recoveryInput = $state('');
|
||||||
|
let recoveryPanel = $state(null);
|
||||||
|
let creationFeedback = $state('');
|
||||||
let invite = $state(null);
|
let invite = $state(null);
|
||||||
let qr = $state('');
|
let qr = $state('');
|
||||||
|
let joiningQr = $state('');
|
||||||
let applicant = $state(null);
|
let applicant = $state(null);
|
||||||
let comparison = $state('');
|
let comparison = $state('');
|
||||||
let compared = $state(false);
|
let compared = $state(false);
|
||||||
|
let scannedFingerprint = $state('');
|
||||||
let applicantName = $state('Nouvel appareil');
|
let applicantName = $state('Nouvel appareil');
|
||||||
let joining = $state(null);
|
let joining = $state(null);
|
||||||
let message = $state('');
|
let message = $state('');
|
||||||
@@ -30,6 +35,11 @@
|
|||||||
let target = $state('');
|
let target = $state('');
|
||||||
let deleteText = $state('');
|
let deleteText = $state('');
|
||||||
let erase = $state(false);
|
let erase = $state(false);
|
||||||
|
let showConflicts = $state(false);
|
||||||
|
let scanning = $state(false);
|
||||||
|
let scanPurpose = $state('');
|
||||||
|
let scanVideo = $state(null);
|
||||||
|
let scanControls = null;
|
||||||
let alive = true;
|
let alive = true;
|
||||||
let polling = false;
|
let polling = false;
|
||||||
|
|
||||||
@@ -37,7 +47,7 @@
|
|||||||
if (busy || dayState.importing) return;
|
if (busy || dayState.importing) return;
|
||||||
busy = true; error = ''; message = '';
|
busy = true; error = ''; message = '';
|
||||||
try { await work(); await loadSyncStatus(); await loadSettings(); await refresh(); }
|
try { await work(); await loadSyncStatus(); await loadSettings(); await refresh(); }
|
||||||
catch (cause) { error = cause.message; }
|
catch (cause) { error = cause.message; if (creationFeedback.startsWith('Création du coffre')) creationFeedback = ''; }
|
||||||
finally { busy = false; }
|
finally { busy = false; }
|
||||||
}
|
}
|
||||||
function download(text, filename) {
|
function download(text, filename) {
|
||||||
@@ -47,11 +57,84 @@
|
|||||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||||
}
|
}
|
||||||
async function newInvitation() {
|
async function newInvitation() {
|
||||||
invite = await startInvitation(); applicant = null; comparison = ''; compared = false;
|
invite = await startInvitation(); applicant = null; comparison = ''; compared = false; scannedFingerprint = '';
|
||||||
qr = await QRCode.toDataURL(invite.url, { width: 260, margin: 2, errorCorrectionLevel: 'M' });
|
qr = await QRCode.toDataURL(invite.url, { width: 260, margin: 2, errorCorrectionLevel: 'M' });
|
||||||
}
|
}
|
||||||
|
async function makeJoiningQr(request) {
|
||||||
|
const url = `${location.origin}${location.pathname}#/sync?pairback=${request.code}&verify=${encodeURIComponent(request.fingerprint)}`;
|
||||||
|
return QRCode.toDataURL(url, { width: 240, margin: 2, errorCorrectionLevel: 'M' });
|
||||||
|
}
|
||||||
|
async function createVault() {
|
||||||
|
await run(async () => {
|
||||||
|
creationFeedback = 'Création du coffre et préparation du document de secours…';
|
||||||
|
recovery = await prepareVault(name); recoveryReset = false; savedRecovery = false;
|
||||||
|
await tick();
|
||||||
|
recoveryPanel?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
recoveryPanel?.focus({ preventScroll: true });
|
||||||
|
creationFeedback = 'Coffre prêt. Enregistrez le document de secours ci-dessous pour continuer.';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function stopScanner() {
|
||||||
|
scanning = false;
|
||||||
|
scanControls?.stop();
|
||||||
|
scanControls = null;
|
||||||
|
if (scanVideo) scanVideo.srcObject = null;
|
||||||
|
}
|
||||||
|
async function handleQr(raw, purpose) {
|
||||||
|
try {
|
||||||
|
error = '';
|
||||||
|
if (purpose === 'join') {
|
||||||
|
const url = new URL(raw);
|
||||||
|
const parts = url.hash.replace(/^#\/?/, '').split('?');
|
||||||
|
const params = new URLSearchParams(parts[1] ?? '');
|
||||||
|
const scannedCode = params.get('invite') ?? '';
|
||||||
|
const pairingPin = params.get('pin') ?? '';
|
||||||
|
if (url.origin !== location.origin || parts[0] !== 'sync' || !/^[A-Z2-9]{8}$/.test(scannedCode) || !pairingPin) throw new Error('Ce QR code n’est pas une invitation Pardis valide.');
|
||||||
|
code = scannedCode;
|
||||||
|
await run(async () => {
|
||||||
|
joining = await joinInvitation(scannedCode, name, pairingPin);
|
||||||
|
joiningQr = await makeJoiningQr(joining);
|
||||||
|
await poll();
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
let payload;
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
const parts = url.hash.replace(/^#\/?/, '').split('?');
|
||||||
|
const params = new URLSearchParams(parts[1] ?? '');
|
||||||
|
if (url.origin === location.origin && parts[0] === 'sync') payload = { code: params.get('pairback'), fingerprint: params.get('verify') };
|
||||||
|
} catch { /* Accept an earlier compact QR payload during a rollout. */ }
|
||||||
|
if (!payload) try { payload = JSON.parse(raw); } catch { throw new Error('Ce QR code de demande est illisible.'); }
|
||||||
|
if (payload?.code !== invite?.code || payload?.fingerprint !== comparison || !applicant) throw new Error('Le QR code ne correspond pas à la demande affichée.');
|
||||||
|
scannedFingerprint = payload.fingerprint;
|
||||||
|
compared = true;
|
||||||
|
message = 'Empreintes vérifiées par QR code. Confirmez l’autorisation sur cet écran.';
|
||||||
|
}
|
||||||
|
} catch (cause) {
|
||||||
|
error = cause.message ?? 'Impossible de lire ce QR code.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function scanQr(purpose) {
|
||||||
|
try {
|
||||||
|
error = ''; scanPurpose = purpose; scanning = true;
|
||||||
|
await tick();
|
||||||
|
const { BrowserQRCodeReader } = await import('@zxing/browser');
|
||||||
|
const reader = new BrowserQRCodeReader();
|
||||||
|
scanControls = await reader.decodeFromVideoDevice(undefined, scanVideo, result => {
|
||||||
|
if (result && scanning) {
|
||||||
|
const raw = result.getText();
|
||||||
|
void stopScanner().then(() => handleQr(raw, purpose));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (!scanning) await stopScanner();
|
||||||
|
} catch (cause) {
|
||||||
|
await stopScanner();
|
||||||
|
error = cause.message ?? 'Impossible d’ouvrir la caméra. Utilisez le code manuel.';
|
||||||
|
}
|
||||||
|
}
|
||||||
async function stopInvitation() {
|
async function stopInvitation() {
|
||||||
invite = null; applicant = null; comparison = ''; compared = false; qr = '';
|
invite = null; applicant = null; comparison = ''; compared = false; qr = '';
|
||||||
|
scannedFingerprint = '';
|
||||||
await cancelInvitation();
|
await cancelInvitation();
|
||||||
}
|
}
|
||||||
async function poll() {
|
async function poll() {
|
||||||
@@ -64,24 +147,41 @@
|
|||||||
if (alive && invite?.code === current) {
|
if (alive && invite?.code === current) {
|
||||||
if (comparison !== result.fingerprint) compared = false;
|
if (comparison !== result.fingerprint) compared = false;
|
||||||
applicant = result.applicant; comparison = result.fingerprint;
|
applicant = result.applicant; comparison = result.fingerprint;
|
||||||
|
if (scannedFingerprint) compared = !!result.fingerprint && result.fingerprint === scannedFingerprint;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (joining && await finishJoining()) {
|
if (joining && !joining.approved && await finishJoining()) {
|
||||||
joining = null; message = 'Appareil lié. Les journées distinctes sont réunies ; les versions concurrentes sont conservées ci-dessous.';
|
joining = { ...joining, approved: true };
|
||||||
await loadSettings(); await refresh();
|
message = 'Appareil confirmé. Choisissez maintenant quelles données conserver.';
|
||||||
}
|
}
|
||||||
} catch (cause) {
|
} catch (cause) {
|
||||||
if (alive) { error = cause.message; await stopInvitation(); joining = null; }
|
if (alive) { error = cause.message; await stopInvitation(); joining = null; }
|
||||||
} finally { polling = false; }
|
} finally { polling = false; }
|
||||||
}
|
}
|
||||||
|
async function resumePairback() {
|
||||||
|
const params = new URLSearchParams(location.hash.split('?')[1] ?? '');
|
||||||
|
const pairback = params.get('pairback');
|
||||||
|
if (params.get('invite')) { code = params.get('invite'); pin = params.get('pin') ?? ''; }
|
||||||
|
if (!pairback) return;
|
||||||
|
if (pairback === invite?.code) {
|
||||||
|
scannedFingerprint = params.get('verify') ?? '';
|
||||||
|
compared = !!comparison && comparison === scannedFingerprint;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await run(async () => {
|
||||||
|
const resumed = await resumeInvitation(pairback);
|
||||||
|
invite = resumed.invite; applicant = resumed.applicant; comparison = resumed.fingerprint;
|
||||||
|
scannedFingerprint = params.get('verify') ?? '';
|
||||||
|
compared = !!comparison && comparison === scannedFingerprint;
|
||||||
|
});
|
||||||
|
}
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
void loadSyncStatus();
|
void loadSyncStatus();
|
||||||
|
void resumePairback();
|
||||||
|
const hash = () => { void resumePairback(); };
|
||||||
|
window.addEventListener('hashchange', hash);
|
||||||
const timer = setInterval(poll, 3000);
|
const timer = setInterval(poll, 3000);
|
||||||
const hide = () => { if (document.visibilityState === 'hidden' && invite) void stopInvitation(); };
|
return () => { alive = false; clearInterval(timer); window.removeEventListener('hashchange', hash); void stopScanner(); };
|
||||||
document.addEventListener('visibilitychange', hide);
|
|
||||||
const leave = () => { void stopInvitation(); };
|
|
||||||
window.addEventListener('pagehide', leave);
|
|
||||||
return () => { alive = false; clearInterval(timer); document.removeEventListener('visibilitychange', hide); window.removeEventListener('pagehide', leave); void cancelInvitation(); };
|
|
||||||
});
|
});
|
||||||
const settingLabels = {
|
const settingLabels = {
|
||||||
workdays: 'Jours travaillés', nonWorkingDates: 'Jours non travaillés',
|
workdays: 'Jours travaillés', nonWorkingDates: 'Jours non travaillés',
|
||||||
@@ -104,43 +204,90 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<fieldset class="screen" disabled={busy || dayState.importing}>
|
<fieldset class="screen" disabled={busy || dayState.importing}>
|
||||||
<h1>Synchronisation et appareils</h1>
|
<h1>{showConflicts ? 'Fusion manuelle des données' : 'Synchronisation et appareils'}</h1>
|
||||||
<button onclick={() => go('data')}>Retour aux données</button>
|
<button onclick={() => showConflicts ? (showConflicts = false) : go('data')}>{showConflicts ? 'Retour aux appareils' : 'Retour aux données'}</button>
|
||||||
<p>Vos saisies sont toujours enregistrées sur cet appareil. La synchronisation facultative partage vos journées et règles avec vos appareils autorisés, sous forme chiffrée.</p>
|
<p>Vos saisies sont toujours enregistrées sur cet appareil. La synchronisation facultative partage vos journées et règles avec vos appareils autorisés, sous forme chiffrée.</p>
|
||||||
{#if error}<p class="notice" role="alert">{error}</p>{/if}
|
{#if error}<p class="notice" role="alert">{error}</p>{/if}
|
||||||
{#if message}<p role="status">{message}</p>{/if}
|
{#if message}<p role="status">{message}</p>{/if}
|
||||||
{#if busy}<p role="status">Opération en cours…</p>{/if}
|
{#if busy}<p role="status">Opération en cours…</p>{/if}
|
||||||
|
|
||||||
{#if !syncState.linked}
|
{#if !syncState.linked}
|
||||||
<label>Nom de cet appareil<input bind:value={name} maxlength="80" autocomplete="off" /></label>
|
{#if joining}
|
||||||
<section class="card stack">
|
<section class="card stack">
|
||||||
<h2>Créer mon coffre</h2>
|
<h2>{joining.approved ? 'Choisir les données à conserver' : 'Demande envoyée'}</h2>
|
||||||
<p>Le serveur ne peut pas lire votre historique. Sans appareil autorisé ni document de secours, l’accès sera perdu.</p>
|
{#if joining.approved}
|
||||||
<button onclick={() => run(async () => { recovery = await prepareVault(name); recoveryReset = false; savedRecovery = false; })}>Préparer la synchronisation</button>
|
<p>Choisissez une option. La fusion détaillée permet ensuite de départager chaque date ou réglage.</p>
|
||||||
</section>
|
<p>Remplacer les données de cet appareil par celles du coffre existant. L’historique chiffré reste conservé.</p>
|
||||||
<section class="card stack">
|
<button onclick={() => run(async () => { await chooseJoiningStrategy('remote'); joining = null; message = 'Les données du coffre sont conservées sur cet appareil.'; })}>Tout conserver du coffre existant</button>
|
||||||
<h2>Lier à un appareil existant</h2>
|
<p>Remplacer l’état partagé actuel par les données de cet appareil. Les autres appareils recevront ce nouvel état ; les anciennes révisions chiffrées restent conservées.</p>
|
||||||
<p>Sur l’appareil déjà lié, ouvrez cet écran puis « Ajouter un appareil ». Saisissez son code ici ou scannez son QR code avec l’appareil photo de votre téléphone.</p>
|
<button onclick={() => run(async () => { await chooseJoiningStrategy('local'); joining = null; message = 'Les données de cet appareil remplacent l’état partagé actuel.'; })}>Tout conserver sur cet appareil</button>
|
||||||
<p>Les journées déjà présentes ici seront conservées et fusionnées. Les dates communes et les règles différentes pourront nécessiter un choix.</p>
|
<p>Réunir les données des deux appareils et résoudre séparément les différences.</p>
|
||||||
<label>Code d’association<input bind:value={code} maxlength="12" autocomplete="off" autocapitalize="characters" spellcheck="false" /></label>
|
<button onclick={() => run(async () => { await chooseJoiningStrategy('manual'); joining = null; await loadSyncStatus(); showConflicts = syncState.conflicts.length > 0; message = showConflicts ? 'Choisissez les données à garder pour chaque conflit.' : 'Les données des deux appareils ont été réunies.'; })}>Fusion manuelle détaillée…</button>
|
||||||
<button disabled={!!joining} onclick={() => run(async () => { joining = await joinInvitation(code, name, query.get('pin') ?? ''); await poll(); })}>Demander la liaison</button>
|
{:else}
|
||||||
{#if joining}
|
<p>Vérifiez cette empreinte sur l’appareil d’origine. Celui-ci doit confirmer la demande avant de continuer.</p>
|
||||||
<p>Comparez toute cette empreinte avec celle affichée sur l’appareil d’origine, puis confirmez sur celui-ci :</p>
|
<code class="fingerprint">{joining.fingerprint}</code>
|
||||||
<code class="fingerprint">{joining.fingerprint}</code>
|
{#if joiningQr}<img src={joiningQr} width="240" height="240" alt="QR code de cette demande, à scanner depuis l’appareil d’origine" />{/if}
|
||||||
<p role="status">En attente de confirmation sur l’autre appareil…</p>
|
<p role="status">En attente de confirmation sur l’autre appareil…</p>
|
||||||
<button onclick={() => { joining = null; }}>Arrêter l’attente</button>
|
<button onclick={() => scanQr('join')}>Scanner le QR de l’appareil d’origine</button>
|
||||||
{/if}
|
{#if scanning && scanPurpose === 'join'}<video bind:this={scanVideo} playsinline muted aria-label="Caméra de lecture QR"></video><button onclick={stopScanner}>Arrêter la caméra</button>{/if}
|
||||||
</section>
|
<button onclick={async () => { await stopScanner(); joining = null; joiningQr = ''; }}>Arrêter l’attente</button>
|
||||||
<section class="card stack">
|
{/if}
|
||||||
<h2>Récupérer mon coffre</h2>
|
</section>
|
||||||
<p>Utilisez votre document de secours. Il donne accès à tout votre coffre ; gardez-le à l’abri.</p>
|
{:else}
|
||||||
<label>Document de secours<input type="file" accept="application/json,.json" onchange={event => run(async () => {
|
<label>Nom de cet appareil<input bind:value={name} maxlength="80" autocomplete="off" /></label>
|
||||||
const file = event.currentTarget.files?.[0];
|
<section class="card stack">
|
||||||
if (file) { if (file.size > 50000) throw new Error('Document trop volumineux.'); recoveryInput = await file.text(); }
|
<h2>Créer mon coffre</h2>
|
||||||
})} /></label>
|
<p>Le serveur ne peut pas lire votre historique. Sans appareil autorisé ni document de secours, l’accès sera perdu.</p>
|
||||||
<label>Ou coller le document<textarea bind:value={recoveryInput} rows="3" spellcheck="false" autocomplete="off"></textarea></label>
|
<button onclick={createVault}>Créer mon coffre</button>
|
||||||
<p>Les données présentes ici seront conservées. La récupération retire les anciens appareils et renouvelle les clés ; vous pourrez les associer à nouveau.</p>
|
{#if creationFeedback}<p role="status">{creationFeedback}</p>{/if}
|
||||||
<button disabled={!recoveryInput} onclick={() => run(async () => { await recoverVault(recoveryInput, name); recoveryInput = ''; message = 'Accès récupéré. Les anciens appareils ont été retirés.'; })}>Récupérer l’accès</button>
|
</section>
|
||||||
|
<section class="card stack">
|
||||||
|
<h2>Lier à un appareil existant</h2>
|
||||||
|
<p>Ouvrez cet écran sur l’appareil déjà lié. Vous pouvez saisir son code ici, ou scanner un QR code avec un smartphone, quel que soit l’appareil qui le présente.</p>
|
||||||
|
<label>Code d’association<input bind:value={code} maxlength="12" autocomplete="off" autocapitalize="characters" spellcheck="false" /></label>
|
||||||
|
<button disabled={!!joining} onclick={() => run(async () => {
|
||||||
|
joining = await joinInvitation(code, name, pin);
|
||||||
|
joiningQr = await makeJoiningQr(joining);
|
||||||
|
await poll();
|
||||||
|
})}>Demander la liaison</button>
|
||||||
|
<button onclick={() => scanQr('join')}>Scanner le QR de l’appareil d’origine</button>
|
||||||
|
{#if scanning && scanPurpose === 'join'}<video bind:this={scanVideo} playsinline muted aria-label="Caméra de lecture QR"></video><button onclick={stopScanner}>Arrêter la caméra</button>{/if}
|
||||||
|
</section>
|
||||||
|
<section class="card stack">
|
||||||
|
<h2>Récupérer mon coffre</h2>
|
||||||
|
<p>Utilisez votre document de secours. Il donne accès à tout votre coffre ; gardez-le à l’abri.</p>
|
||||||
|
<label>Document de secours<input type="file" accept="application/json,.json" onchange={event => run(async () => {
|
||||||
|
const file = event.currentTarget.files?.[0];
|
||||||
|
if (file) { if (file.size > 50000) throw new Error('Document trop volumineux.'); recoveryInput = await file.text(); }
|
||||||
|
})} /></label>
|
||||||
|
<label>Ou coller le document<textarea bind:value={recoveryInput} rows="3" spellcheck="false" autocomplete="off"></textarea></label>
|
||||||
|
<p>Les données présentes ici seront conservées. La récupération retire les anciens appareils et renouvelle les clés ; vous pourrez les associer à nouveau.</p>
|
||||||
|
<button disabled={!recoveryInput} onclick={() => run(async () => { await recoverVault(recoveryInput, name); recoveryInput = ''; message = 'Accès récupéré. Les anciens appareils ont été retirés.'; })}>Récupérer l’accès</button>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{:else if showConflicts}
|
||||||
|
<section class="manual-screen stack">
|
||||||
|
<h2>Versions à départager ({syncState.conflicts.length})</h2>
|
||||||
|
{#if !syncState.conflicts.length}<p>Aucun conflit à résoudre.</p>{/if}
|
||||||
|
{#each syncState.conflicts as conflict}
|
||||||
|
<article class="conflict stack">
|
||||||
|
<h3>{conflict.key.startsWith('day:') ? `Journée du ${conflict.key.slice(4)}` : settingLabels[conflict.key.slice(8)]}</h3>
|
||||||
|
<p>{conflict.reason} Les automatismes sont suspendus sur cet appareil pendant la résolution.</p>
|
||||||
|
{#each conflict.versions as version}
|
||||||
|
<div class="version stack">
|
||||||
|
<strong>{deviceName(version.device)} · {when(version.modifiedAt)}</strong>
|
||||||
|
{#if version.value === null}<p>Suppression</p>
|
||||||
|
{:else if conflict.key.startsWith('day:')}
|
||||||
|
<p>{version.value.day.status === 'active' ? 'En cours' : 'Terminée'}</p>
|
||||||
|
<ul>{#each version.value.entries as entry}<li>{new Date(entry.startTs).toLocaleTimeString('fr-FR', {hour: '2-digit', minute: '2-digit'})}–{entry.endTs === null ? 'en cours' : new Date(entry.endTs).toLocaleTimeString('fr-FR', {hour: '2-digit', minute: '2-digit'})} · {entry.task} {entry.client ?? ''}</li>{/each}</ul>
|
||||||
|
{:else}<p>{settingValue(conflict.key, version.value)}</p>{/if}
|
||||||
|
<button disabled={!syncState.enabled} onclick={() => run(async () => { await resolveConflict(conflict.key, version.id, conflict.versions.map(v => v.id)); await synchronize(); })}>Conserver cette version</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<button disabled={!syncState.enabled} onclick={() => run(async () => { await resolveConflict(conflict.key, 'local', conflict.versions.map(v => v.id)); await synchronize(); })}>Conserver l’état actuel de cet appareil</button>
|
||||||
|
<p class="dim">Les autres versions restent dans l’historique chiffré. Vous pouvez ensuite corriger la journée dans son écran habituel.</p>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
</section>
|
</section>
|
||||||
{:else}
|
{:else}
|
||||||
<section class="card stack">
|
<section class="card stack">
|
||||||
@@ -148,6 +295,7 @@
|
|||||||
<p>{syncState.revoked ? 'Cet appareil a été révoqué.' : syncState.enabled ? syncState.busy ? 'Synchronisation en cours…' : syncState.pending ? 'Enregistré sur cet appareil · en attente d’envoi' : 'Modifications envoyées' : 'Synchronisation en pause'}</p>
|
<p>{syncState.revoked ? 'Cet appareil a été révoqué.' : syncState.enabled ? syncState.busy ? 'Synchronisation en cours…' : syncState.pending ? 'Enregistré sur cet appareil · en attente d’envoi' : 'Modifications envoyées' : 'Synchronisation en pause'}</p>
|
||||||
<p>Dernière synchronisation : {when(syncState.lastSync)}.</p>
|
<p>Dernière synchronisation : {when(syncState.lastSync)}.</p>
|
||||||
{#if syncState.error}<p role="status">{syncState.error} Vos données restent enregistrées ici.</p>{/if}
|
{#if syncState.error}<p role="status">{syncState.error} Vos données restent enregistrées ici.</p>{/if}
|
||||||
|
<button disabled={!syncState.conflicts.length} onclick={() => { showConflicts = true; }}>Fusion manuelle détaillée ({syncState.conflicts.length})</button>
|
||||||
{#if !syncState.revoked}
|
{#if !syncState.revoked}
|
||||||
<button disabled={!syncState.enabled || syncState.busy} onclick={() => run(synchronize)}>Synchroniser maintenant</button>
|
<button disabled={!syncState.enabled || syncState.busy} onclick={() => run(synchronize)}>Synchroniser maintenant</button>
|
||||||
<button onclick={() => run(() => setSyncEnabled(!syncState.enabled))}>{syncState.enabled ? 'Mettre en pause' : 'Reprendre la synchronisation'}</button>
|
<button onclick={() => run(() => setSyncEnabled(!syncState.enabled))}>{syncState.enabled ? 'Mettre en pause' : 'Reprendre la synchronisation'}</button>
|
||||||
@@ -171,8 +319,10 @@
|
|||||||
<strong class="code">{invite.code.slice(0, 4)} {invite.code.slice(4)}</strong>
|
<strong class="code">{invite.code.slice(0, 4)} {invite.code.slice(4)}</strong>
|
||||||
{#if qr}<img src={qr} width="260" height="260" alt="QR code d’association à scanner sur le nouvel appareil" />{/if}
|
{#if qr}<img src={qr} width="260" height="260" alt="QR code d’association à scanner sur le nouvel appareil" />{/if}
|
||||||
{#if applicant}
|
{#if applicant}
|
||||||
<p>Une demande est arrivée. Comparez l’empreinte complète sur les deux écrans :</p>
|
<p>Une demande est arrivée. Vérifiez l’empreinte sur les deux écrans ou scannez le QR affiché par le nouvel appareil :</p>
|
||||||
<code class="fingerprint">{comparison}</code>
|
<code class="fingerprint">{comparison}</code>
|
||||||
|
<button onclick={() => scanQr('approve')}>Scanner le QR du nouvel appareil</button>
|
||||||
|
{#if scanning && scanPurpose === 'approve'}<video bind:this={scanVideo} playsinline muted aria-label="Caméra de lecture QR"></video><button onclick={stopScanner}>Arrêter la caméra</button>{/if}
|
||||||
<label>Nom du nouvel appareil<input bind:value={applicantName} maxlength="80" /></label>
|
<label>Nom du nouvel appareil<input bind:value={applicantName} maxlength="80" /></label>
|
||||||
<label class="check"><input type="checkbox" bind:checked={compared} /> Les deux empreintes sont identiques</label>
|
<label class="check"><input type="checkbox" bind:checked={compared} /> Les deux empreintes sont identiques</label>
|
||||||
<button disabled={!compared} onclick={() => run(async () => { await approveInvitation(invite.code, applicant, applicantName); invite = null; applicant = null; message = 'Appareil autorisé.'; })}>Autoriser cet appareil</button>
|
<button disabled={!compared} onclick={() => run(async () => { await approveInvitation(invite.code, applicant, applicantName); invite = null; applicant = null; message = 'Appareil autorisé.'; })}>Autoriser cet appareil</button>
|
||||||
@@ -181,29 +331,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="card stack">
|
|
||||||
<h2>Versions à départager ({syncState.conflicts.length})</h2>
|
|
||||||
{#if !syncState.conflicts.length}<p>Aucun conflit.</p>{/if}
|
|
||||||
{#each syncState.conflicts as conflict}
|
|
||||||
<article class="stack conflict">
|
|
||||||
<h3>{conflict.key.startsWith('day:') ? `Journée du ${conflict.key.slice(4)}` : settingLabels[conflict.key.slice(8)]}</h3>
|
|
||||||
<p>{conflict.reason} Les automatismes sont suspendus sur cet appareil pendant la résolution.</p>
|
|
||||||
{#each conflict.versions as version}
|
|
||||||
<div class="version stack">
|
|
||||||
<strong>{deviceName(version.device)} · {when(version.modifiedAt)}</strong>
|
|
||||||
{#if version.value === null}<p>Suppression</p>
|
|
||||||
{:else if conflict.key.startsWith('day:')}
|
|
||||||
<p>{version.value.day.status === 'active' ? 'En cours' : 'Terminée'}</p>
|
|
||||||
<ul>{#each version.value.entries as entry}<li>{new Date(entry.startTs).toLocaleTimeString('fr-FR', {hour: '2-digit', minute: '2-digit'})}–{entry.endTs === null ? 'en cours' : new Date(entry.endTs).toLocaleTimeString('fr-FR', {hour: '2-digit', minute: '2-digit'})} · {entry.task} {entry.client ?? ''}</li>{/each}</ul>
|
|
||||||
{:else}<p>{settingValue(conflict.key, version.value)}</p>{/if}
|
|
||||||
<button disabled={!syncState.enabled} onclick={() => run(async () => { await resolveConflict(conflict.key, version.id, conflict.versions.map(v => v.id)); await synchronize(); })}>Conserver cette version</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
<button disabled={!syncState.enabled} onclick={() => run(async () => { await resolveConflict(conflict.key, 'local', conflict.versions.map(v => v.id)); await synchronize(); })}>Conserver l’état actuel de cet appareil</button>
|
|
||||||
<p class="dim">Les autres versions restent dans l’historique chiffré. Vous pouvez ensuite corriger la journée dans son écran habituel.</p>
|
|
||||||
</article>
|
|
||||||
{/each}
|
|
||||||
</section>
|
|
||||||
<section class="card stack">
|
<section class="card stack">
|
||||||
<h2>Secours et déconnexion</h2>
|
<h2>Secours et déconnexion</h2>
|
||||||
<p>Le document de secours n’est pas conservé sur les appareils liés. Vous pouvez en préparer un nouveau ; après validation, le précédent ne permettra plus de récupérer le coffre.</p>
|
<p>Le document de secours n’est pas conservé sur les appareils liés. Vous pouvez en préparer un nouveau ; après validation, le précédent ne permettra plus de récupérer le coffre.</p>
|
||||||
@@ -214,13 +341,13 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if recovery}
|
{#if recovery}
|
||||||
<section class="card stack">
|
<section class="card stack" bind:this={recoveryPanel} tabindex="-1">
|
||||||
<h2>Conserver le document de secours</h2>
|
<h2>Conserver le document de secours</h2>
|
||||||
<p>Enregistrez-le ailleurs que sur cet appareil, par exemple dans un gestionnaire de mots de passe. Toute personne qui le possède peut accéder au coffre.</p>
|
<p>Enregistrez-le ailleurs que sur cet appareil, par exemple dans un gestionnaire de mots de passe. Toute personne qui le possède peut accéder au coffre.</p>
|
||||||
<button onclick={() => download(recovery, 'pardis-secours.json')}>Télécharger le document de secours</button>
|
<button onclick={() => download(recovery, 'pardis-secours.json')}>Télécharger le document de secours</button>
|
||||||
<details><summary>Afficher le document pour le copier</summary><textarea readonly value={recovery} rows="6" aria-label="Contenu du document de secours" spellcheck="false"></textarea></details>
|
<details><summary>Afficher le document pour le copier</summary><textarea readonly value={recovery} rows="6" aria-label="Contenu du document de secours" spellcheck="false"></textarea></details>
|
||||||
<label class="check"><input type="checkbox" bind:checked={savedRecovery} /> J’ai conservé une copie du document à part</label>
|
<label class="check"><input type="checkbox" bind:checked={savedRecovery} /> J’ai conservé une copie du document à part</label>
|
||||||
<button disabled={!savedRecovery} onclick={() => run(async () => { if (recoveryReset) await commitRecoveryReset(); else await activateVault(); recovery = ''; message = 'Synchronisation configurée.'; })}>{recoveryReset ? 'Valider le nouveau secours' : 'Activer la synchronisation'}</button>
|
<button disabled={!savedRecovery} onclick={() => run(async () => { if (recoveryReset) await commitRecoveryReset(); else await activateVault(); recovery = ''; creationFeedback = ''; message = recoveryReset ? 'Document de secours renouvelé.' : 'Coffre créé et synchronisation activée.'; })}>{recoveryReset ? 'Valider le nouveau secours' : 'Créer le coffre et activer la synchronisation'}</button>
|
||||||
<button onclick={() => { recovery = ''; }}>Fermer</button>
|
<button onclick={() => { recovery = ''; }}>Fermer</button>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -254,7 +381,12 @@
|
|||||||
textarea { width: 100%; box-sizing: border-box; font: inherit; }
|
textarea { width: 100%; box-sizing: border-box; font: inherit; }
|
||||||
.fingerprint, .code { display: block; overflow-wrap: anywhere; font-size: 1.15rem; font-weight: bold; }
|
.fingerprint, .code { display: block; overflow-wrap: anywhere; font-size: 1.15rem; font-weight: bold; }
|
||||||
img { max-width: 100%; height: auto; align-self: center; }
|
img { max-width: 100%; height: auto; align-self: center; }
|
||||||
.device, .version, .conflict { border: var(--border) solid var(--line); border-radius: .4rem; padding: .6rem; }
|
.device { border: var(--border) solid var(--line); border-radius: .4rem; padding: .6rem; }
|
||||||
|
.manual-screen { padding: 0 .2rem; }
|
||||||
|
.conflict { border-top: var(--border) solid var(--line); padding: .8rem 0; }
|
||||||
|
.version { border-left: .2rem solid var(--line); padding: .2rem .8rem; }
|
||||||
|
video { width: min(100%, 28rem); max-height: 55vh; align-self: center; background: #111; }
|
||||||
|
[tabindex="-1"]:focus { outline: .15rem solid var(--line); outline-offset: .2rem; }
|
||||||
.notice { color: var(--danger-fg); background: var(--danger); padding: .6rem; }
|
.notice { color: var(--danger-fg); background: var(--danger); padding: .6rem; }
|
||||||
ul { padding-left: 1.2rem; overflow-wrap: anywhere; }
|
ul { padding-left: 1.2rem; overflow-wrap: anywhere; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user