From d651166c6a039c0770b8748db08a227e351c700b Mon Sep 17 00:00:00 2001 From: "Adrien W." Date: Sat, 10 Oct 2026 16:51:43 +0200 Subject: [PATCH] Improving UI and sync process --- SYNC.md | 38 +++-- package-lock.json | 46 ++++++ package.json | 1 + server/app.js | 2 +- src/App.svelte | 3 - src/lib/sync/client.js | 36 ++++- src/lib/sync/crypto.js | 4 +- src/lib/sync/store.js | 62 +++++++- src/routes/SyncSettings.svelte | 272 ++++++++++++++++++++++++--------- 9 files changed, 363 insertions(+), 101 deletions(-) diff --git a/SYNC.md b/SYNC.md index 06953cf..f597baa 100644 --- a/SYNC.md +++ b/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** : -- Préparer le coffre, télécharger `pardis-secours.json`, le conserver à part, - puis confirmer l’activation. Le document donne accès au coffre : il doit être - protégé comme un mot de passe de gestionnaire de secrets. +- Choisir **Créer mon coffre**. L’application prépare alors le coffre et son + document de secours, puis amène directement à l’étape obligatoire de sauvegarde. + 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, 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 - brutale, le bail serveur expire sous 25 secondes. -- Saisir les huit caractères sur le nouvel appareil ou scanner le QR avec l’appareil - photo du téléphone. Le QR ouvre l’application web ; le navigateur/profil qui + quelques secondes. En quittant l’écran ou en passant à l’appareil photo, les + battements s’arrêtent et le bail serveur expire sous 25 secondes si l’invitation + n’est pas reprise. +- 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 installées et les navigateurs peuvent avoir des stockages distincts, notamment 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, - cocher leur égalité et confirmer sur l’appareil existant. Les demandes ne sont +- Le nouvel appareil peut scanner le QR de l’appareil existant. Si l’appareil + 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é. -- Les données déjà présentes sur le nouvel appareil sont conservées. Les journées - distinctes sont réunies et les dates communes divergentes deviennent des conflits. - Les réglages déjà enregistrés peuvent eux aussi nécessiter un choix. +- Après confirmation, choisir de conserver tout l’état du coffre, tout l’état de + l’appareil courant, ou d’ouvrir la fusion détaillée. Le premier choix remplace + 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 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 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é. -- 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. - 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. diff --git a/package-lock.json b/package-lock.json index 30c34be..940a485 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.8.0", "dependencies": { "@hpke/core": "^1.9.0", + "@zxing/browser": "^0.2.1", "dexie": "^4.4.4", "qrcode": "^1.5.4" }, @@ -3116,6 +3117,41 @@ "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": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.18.0.tgz", @@ -6874,6 +6910,16 @@ "dev": true, "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": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", diff --git a/package.json b/package.json index e9cb47d..3b89e44 100644 --- a/package.json +++ b/package.json @@ -30,6 +30,7 @@ }, "dependencies": { "@hpke/core": "^1.9.0", + "@zxing/browser": "^0.2.1", "dexie": "^4.4.4", "qrcode": "^1.5.4" } diff --git a/server/app.js b/server/app.js index 5c28a2f..42d142f 100644 --- a/server/app.js +++ b/server/app.js @@ -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 (match[2] === 'heartbeat' && req.method === 'POST') { 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 (invite.approved) return { ok: true }; diff --git a/src/App.svelte b/src/App.svelte index b359b9b..4ae041b 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -90,9 +90,6 @@ {#if !ready}
{#if bootError}

{bootError}

{:else}

Chargement…

{/if}
{:else} - {#if syncState.linked && !['sync', 'data'].includes(view)} -
- {/if} {#if syncState.enabled && syncState.owner && syncState.owner !== syncState.deviceId && !roleDismissed && view !== 'sync'}
Les automatismes sont exécutés sur un autre appareil.
{#if roleError}

{roleError}

{/if} diff --git a/src/lib/sync/client.js b/src/lib/sync/client.js index 23900e1..5da7759 100644 --- a/src/lib/sync/client.js +++ b/src/lib/sync/client.js @@ -1,7 +1,7 @@ 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 { 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'; 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 synchronize(); } -async function attach(config) { +async function attach(config, initialChoice = '') { await db.transaction('rw', tables(db), async () => { 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(); await db.syncState.delete('cursor'); await db.syncState.delete('server'); await db.syncState.delete('lastSync'); - await saveConfig(config); - await captureChanges(); // Existing local dates become independent versions, never silently replaced. + if (initialChoice === 'remote') { await db.days.clear(); await db.entries.clear(); } + 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'); }); } @@ -130,6 +133,18 @@ export async function cancelInvitation() { const current = invitation; invitation = null; 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) { 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 }); @@ -164,14 +179,22 @@ export async function approveInvitation(code, applicant, name) { export async function finishJoining() { const pending = (await db.syncState.get('joining'))?.value; 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}` }); if (result.waiting) return false; 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))); - await attach({ vault: result.vault, device: pending.device, enabled: true, owner: '', ring }); - await synchronize(); + await db.syncState.put({ key: 'joining', value: { ...pending, ring, approved: 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) { const state = await api('/state', { device: config.device }); if (state.vault !== config.vault) throw new Error('Coffre inattendu.'); @@ -229,6 +252,7 @@ async function syncOnce() { more = page.more; 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() }); } catch (error) { syncState.error = error.message; diff --git a/src/lib/sync/crypto.js b/src/lib/sync/crypto.js index 574ec84..2b66a9b 100644 --- a/src/lib/sync/crypto.js +++ b/src/lib/sync/crypto.js @@ -65,9 +65,9 @@ export async function objectId(secret, key) { } export async function fingerprint(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. - 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 async function requestProof(privateKey, method, path, body, who) { diff --git a/src/lib/sync/store.js b/src/lib/sync/store.js index 1614a9a..20af4d0 100644 --- a/src/lib/sync/store.js +++ b/src/lib/sync/store.js @@ -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 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 }); } -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 result = new Map(days.map(day => [`day:${day.date}`, dayValue(day, entries)])); // 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; } -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() }; await database.syncVersions.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) { return database.transaction('rw', tables(database), async () => { 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 previous = await database.syncObjects.toArray(); 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 }); }); } -async function install(database, key, value) { +export async function install(database, key, value) { validateValue(key, value); if (key.startsWith('setting:')) { 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 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) }); }); } @@ -125,6 +127,54 @@ export async function resolveConflict(key, chosenId, expectedHeads, database = d 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) { const config = await getConfig(database); if (!config) return true; diff --git a/src/routes/SyncSettings.svelte b/src/routes/SyncSettings.svelte index 9cd7939..e755204 100644 --- a/src/routes/SyncSettings.svelte +++ b/src/routes/SyncSettings.svelte @@ -1,28 +1,33 @@
-

Synchronisation et appareils

- +

{showConflicts ? 'Fusion manuelle des données' : 'Synchronisation et appareils'}

+

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.

{#if error}{/if} {#if message}

{message}

{/if} {#if busy}

Opération en cours…

{/if} {#if !syncState.linked} - -
-

Créer mon coffre

-

Le serveur ne peut pas lire votre historique. Sans appareil autorisé ni document de secours, l’accès sera perdu.

- -
-
-

Lier à un appareil existant

-

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.

-

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.

- - - {#if joining} -

Comparez toute cette empreinte avec celle affichée sur l’appareil d’origine, puis confirmez sur celui-ci :

- {joining.fingerprint} -

En attente de confirmation sur l’autre appareil…

- - {/if} -
-
-

Récupérer mon coffre

-

Utilisez votre document de secours. Il donne accès à tout votre coffre ; gardez-le à l’abri.

- - -

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.

- + {#if joining} +
+

{joining.approved ? 'Choisir les données à conserver' : 'Demande envoyée'}

+ {#if joining.approved} +

Choisissez une option. La fusion détaillée permet ensuite de départager chaque date ou réglage.

+

Remplacer les données de cet appareil par celles du coffre existant. L’historique chiffré reste conservé.

+ +

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.

+ +

Réunir les données des deux appareils et résoudre séparément les différences.

+ + {:else} +

Vérifiez cette empreinte sur l’appareil d’origine. Celui-ci doit confirmer la demande avant de continuer.

+ {joining.fingerprint} + {#if joiningQr}QR code de cette demande, à scanner depuis l’appareil d’origine{/if} +

En attente de confirmation sur l’autre appareil…

+ + {#if scanning && scanPurpose === 'join'}{/if} + + {/if} +
+ {:else} + +
+

Créer mon coffre

+

Le serveur ne peut pas lire votre historique. Sans appareil autorisé ni document de secours, l’accès sera perdu.

+ + {#if creationFeedback}

{creationFeedback}

{/if} +
+
+

Lier à un appareil existant

+

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.

+ + + + {#if scanning && scanPurpose === 'join'}{/if} +
+
+

Récupérer mon coffre

+

Utilisez votre document de secours. Il donne accès à tout votre coffre ; gardez-le à l’abri.

+ + +

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.

+ +
+ {/if} + {:else if showConflicts} +
+

Versions à départager ({syncState.conflicts.length})

+ {#if !syncState.conflicts.length}

Aucun conflit à résoudre.

{/if} + {#each syncState.conflicts as conflict} +
+

{conflict.key.startsWith('day:') ? `Journée du ${conflict.key.slice(4)}` : settingLabels[conflict.key.slice(8)]}

+

{conflict.reason} Les automatismes sont suspendus sur cet appareil pendant la résolution.

+ {#each conflict.versions as version} +
+ {deviceName(version.device)} · {when(version.modifiedAt)} + {#if version.value === null}

Suppression

+ {:else if conflict.key.startsWith('day:')} +

{version.value.day.status === 'active' ? 'En cours' : 'Terminée'}

+
    {#each version.value.entries as entry}
  • {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 ?? ''}
  • {/each}
+ {:else}

{settingValue(conflict.key, version.value)}

{/if} + +
+ {/each} + +

Les autres versions restent dans l’historique chiffré. Vous pouvez ensuite corriger la journée dans son écran habituel.

+
+ {/each}
{:else}
@@ -148,6 +295,7 @@

{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'}

Dernière synchronisation : {when(syncState.lastSync)}.

{#if syncState.error}

{syncState.error} Vos données restent enregistrées ici.

{/if} + {#if !syncState.revoked} @@ -171,8 +319,10 @@ {invite.code.slice(0, 4)} {invite.code.slice(4)} {#if qr}QR code d’association à scanner sur le nouvel appareil{/if} {#if applicant} -

Une demande est arrivée. Comparez l’empreinte complète sur les deux écrans :

+

Une demande est arrivée. Vérifiez l’empreinte sur les deux écrans ou scannez le QR affiché par le nouvel appareil :

{comparison} + + {#if scanning && scanPurpose === 'approve'}{/if} @@ -181,29 +331,6 @@ {/if}
-
-

Versions à départager ({syncState.conflicts.length})

- {#if !syncState.conflicts.length}

Aucun conflit.

{/if} - {#each syncState.conflicts as conflict} -
-

{conflict.key.startsWith('day:') ? `Journée du ${conflict.key.slice(4)}` : settingLabels[conflict.key.slice(8)]}

-

{conflict.reason} Les automatismes sont suspendus sur cet appareil pendant la résolution.

- {#each conflict.versions as version} -
- {deviceName(version.device)} · {when(version.modifiedAt)} - {#if version.value === null}

Suppression

- {:else if conflict.key.startsWith('day:')} -

{version.value.day.status === 'active' ? 'En cours' : 'Terminée'}

-
    {#each version.value.entries as entry}
  • {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 ?? ''}
  • {/each}
- {:else}

{settingValue(conflict.key, version.value)}

{/if} - -
- {/each} - -

Les autres versions restent dans l’historique chiffré. Vous pouvez ensuite corriger la journée dans son écran habituel.

-
- {/each} -

Secours et déconnexion

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.

@@ -214,13 +341,13 @@ {/if} {#if recovery} -
+

Conserver le document de secours

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.

Afficher le document pour le copier
- +
{/if} @@ -254,7 +381,12 @@ textarea { width: 100%; box-sizing: border-box; font: inherit; } .fingerprint, .code { display: block; overflow-wrap: anywhere; font-size: 1.15rem; font-weight: bold; } 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; } ul { padding-left: 1.2rem; overflow-wrap: anywhere; }