Improving UI and sync process
Some checks failed
Tests et déploiement / validate (push) Failing after 4m57s
Tests et déploiement / deploy (push) Has been skipped

This commit is contained in:
2026-10-10 16:51:43 +02:00
parent 5686ee5072
commit d651166c6a
9 changed files with 363 additions and 101 deletions

38
SYNC.md
View File

@@ -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
View File

@@ -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",

View File

@@ -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"
} }

View File

@@ -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 };

View File

@@ -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}

View File

@@ -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;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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>