Added optionnal online storage and cross-client sync
This commit is contained in:
260
src/routes/SyncSettings.svelte
Normal file
260
src/routes/SyncSettings.svelte
Normal file
@@ -0,0 +1,260 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import QRCode from 'qrcode';
|
||||
import { go } from '../lib/router.svelte.js';
|
||||
import { dayState, refresh } from '../lib/day.svelte.js';
|
||||
import { loadSettings, WEEKDAY_LABELS } from '../lib/settings.svelte.js';
|
||||
import { AUTOMATION_OPTIONS } from '../lib/automation-options.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 { resolveConflict } from '../lib/sync/store.js';
|
||||
|
||||
const query = new URLSearchParams(location.hash.split('?')[1] ?? '');
|
||||
let name = $state('Mon appareil');
|
||||
let code = $state(query.get('invite') ?? '');
|
||||
let error = $state('');
|
||||
let busy = $state(false);
|
||||
let recovery = $state('');
|
||||
let recoveryReset = $state(false);
|
||||
let savedRecovery = $state(false);
|
||||
let recoveryInput = $state('');
|
||||
let invite = $state(null);
|
||||
let qr = $state('');
|
||||
let applicant = $state(null);
|
||||
let comparison = $state('');
|
||||
let compared = $state(false);
|
||||
let applicantName = $state('Nouvel appareil');
|
||||
let joining = $state(null);
|
||||
let message = $state('');
|
||||
let action = $state('');
|
||||
let target = $state('');
|
||||
let deleteText = $state('');
|
||||
let erase = $state(false);
|
||||
let alive = true;
|
||||
let polling = false;
|
||||
|
||||
async function run(work) {
|
||||
if (busy || dayState.importing) return;
|
||||
busy = true; error = ''; message = '';
|
||||
try { await work(); await loadSyncStatus(); await loadSettings(); await refresh(); }
|
||||
catch (cause) { error = cause.message; }
|
||||
finally { busy = false; }
|
||||
}
|
||||
function download(text, filename) {
|
||||
const url = URL.createObjectURL(new Blob([text], { type: 'application/json' }));
|
||||
const link = document.createElement('a'); link.href = url; link.download = filename;
|
||||
document.body.appendChild(link); link.click(); link.remove();
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60000);
|
||||
}
|
||||
async function newInvitation() {
|
||||
invite = await startInvitation(); applicant = null; comparison = ''; compared = false;
|
||||
qr = await QRCode.toDataURL(invite.url, { width: 260, margin: 2, errorCorrectionLevel: 'M' });
|
||||
}
|
||||
async function stopInvitation() {
|
||||
invite = null; applicant = null; comparison = ''; compared = false; qr = '';
|
||||
await cancelInvitation();
|
||||
}
|
||||
async function poll() {
|
||||
if (polling || !alive || document.visibilityState === 'hidden') return;
|
||||
polling = true;
|
||||
try {
|
||||
if (invite) {
|
||||
const current = invite.code;
|
||||
const result = await pollInvitation(current);
|
||||
if (alive && invite?.code === current) {
|
||||
if (comparison !== result.fingerprint) compared = false;
|
||||
applicant = result.applicant; comparison = result.fingerprint;
|
||||
}
|
||||
}
|
||||
if (joining && await finishJoining()) {
|
||||
joining = null; message = 'Appareil lié. Les journées distinctes sont réunies ; les versions concurrentes sont conservées ci-dessous.';
|
||||
await loadSettings(); await refresh();
|
||||
}
|
||||
} catch (cause) {
|
||||
if (alive) { error = cause.message; await stopInvitation(); joining = null; }
|
||||
} finally { polling = false; }
|
||||
}
|
||||
onMount(() => {
|
||||
void loadSyncStatus();
|
||||
const timer = setInterval(poll, 3000);
|
||||
const hide = () => { if (document.visibilityState === 'hidden' && invite) void stopInvitation(); };
|
||||
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 = {
|
||||
workdays: 'Jours travaillés', nonWorkingDates: 'Jours non travaillés',
|
||||
dayStart: 'Début de journée', dayEnd: 'Fin de journée', lunchStart: 'Début du repas',
|
||||
lunchMinutes: 'Durée du repas', lunchType: 'Type de repas', roundingMinutes: 'Arrondi',
|
||||
retentionDays: 'Durée de conservation', ...Object.fromEntries(AUTOMATION_OPTIONS.map(option => [option.key, `Automatisme : ${option.name}`])),
|
||||
};
|
||||
function settingValue(key, value) {
|
||||
const name = key.slice(8);
|
||||
if (typeof value === 'boolean') return value ? 'Activé' : 'Désactivé';
|
||||
if (name === 'workdays') return value.map(day => WEEKDAY_LABELS.find(label => label.value === day)?.label).join(', ') || 'Aucun jour';
|
||||
if (name === 'nonWorkingDates') return value.join(', ') || 'Aucune date';
|
||||
if (name === 'lunchType') return value === 'panier' ? 'Panier' : 'Restaurant';
|
||||
if (name === 'retentionDays') return value ? `${value} jours` : 'Sans limite';
|
||||
if (['lunchMinutes', 'roundingMinutes'].includes(name)) return `${value} minutes`;
|
||||
return String(value);
|
||||
}
|
||||
const when = value => value ? new Date(value).toLocaleString('fr-FR') : 'jamais';
|
||||
const deviceName = id => syncState.devices.find(d => d.id === id)?.name ?? 'Appareil';
|
||||
</script>
|
||||
|
||||
<fieldset class="screen" disabled={busy || dayState.importing}>
|
||||
<h1>Synchronisation et appareils</h1>
|
||||
<button onclick={() => go('data')}>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>
|
||||
{#if error}<p class="notice" role="alert">{error}</p>{/if}
|
||||
{#if message}<p role="status">{message}</p>{/if}
|
||||
{#if busy}<p role="status">Opération en cours…</p>{/if}
|
||||
|
||||
{#if !syncState.linked}
|
||||
<label>Nom de cet appareil<input bind:value={name} maxlength="80" autocomplete="off" /></label>
|
||||
<section class="card stack">
|
||||
<h2>Créer mon coffre</h2>
|
||||
<p>Le serveur ne peut pas lire votre historique. Sans appareil autorisé ni document de secours, l’accès sera perdu.</p>
|
||||
<button onclick={() => run(async () => { recovery = await prepareVault(name); recoveryReset = false; savedRecovery = false; })}>Préparer la synchronisation</button>
|
||||
</section>
|
||||
<section class="card stack">
|
||||
<h2>Lier à un appareil existant</h2>
|
||||
<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>
|
||||
<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>
|
||||
<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, query.get('pin') ?? ''); await poll(); })}>Demander la liaison</button>
|
||||
{#if joining}
|
||||
<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>
|
||||
<p role="status">En attente de confirmation sur l’autre appareil…</p>
|
||||
<button onclick={() => { joining = null; }}>Arrêter l’attente</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>
|
||||
{:else}
|
||||
<section class="card stack">
|
||||
<h2>État de la synchronisation</h2>
|
||||
<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>
|
||||
{#if syncState.error}<p role="status">{syncState.error} Vos données restent enregistrées ici.</p>{/if}
|
||||
{#if !syncState.revoked}
|
||||
<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>
|
||||
{/if}
|
||||
</section>
|
||||
<section class="card stack">
|
||||
<h2>Appareils autorisés</h2>
|
||||
{#each syncState.devices as device}
|
||||
<div class="device stack">
|
||||
<strong>{device.name}{device.id === syncState.deviceId ? ' (cet appareil)' : ''}</strong>
|
||||
<span>{device.revoked ? 'Révoqué' : device.id === syncState.owner ? 'Responsable des automatismes' : 'Saisie manuelle et consultation'}</span>
|
||||
<span class="dim">Dernier contact : {when(device.seen)}</span>
|
||||
{#if !device.revoked && device.id !== syncState.deviceId}<button disabled={!syncState.enabled} onclick={() => { action = 'revoke'; target = device.id; }}>Retirer cet appareil…</button>{/if}
|
||||
</div>
|
||||
{/each}
|
||||
{#if syncState.owner !== syncState.deviceId && !syncState.revoked}<button disabled={!syncState.enabled} onclick={() => run(takeAutomationRole)}>Utiliser cet appareil pour les automatismes</button>{/if}
|
||||
<p class="dim">Les automatismes nécessitent que l’appareil responsable puisse exécuter l’application. Un ancien responsable hors ligne apprend le transfert à sa reconnexion ; ses changements éventuels sont conservés.</p>
|
||||
<button disabled={!syncState.enabled || !!invite} onclick={() => run(newInvitation)}>Ajouter un appareil</button>
|
||||
{#if invite}
|
||||
<p>Invitation valable jusqu’à {new Date(invite.expires).toLocaleTimeString('fr-FR')}. Gardez cet écran visible ; le quitter annule l’invitation.</p>
|
||||
<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 applicant}
|
||||
<p>Une demande est arrivée. Comparez l’empreinte complète sur les deux écrans :</p>
|
||||
<code class="fingerprint">{comparison}</code>
|
||||
<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>
|
||||
<button disabled={!compared} onclick={() => run(async () => { await approveInvitation(invite.code, applicant, applicantName); invite = null; applicant = null; message = 'Appareil autorisé.'; })}>Autoriser cet appareil</button>
|
||||
{:else}<p role="status">En attente d’une demande sur cet écran…</p>{/if}
|
||||
<button onclick={() => run(stopInvitation)}>Annuler l’invitation</button>
|
||||
{/if}
|
||||
</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">
|
||||
<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>
|
||||
<button disabled={!syncState.enabled} onclick={() => run(async () => { recovery = await prepareRecoveryReset(); recoveryReset = true; savedRecovery = false; })}>Renouveler le document de secours…</button>
|
||||
<button onclick={() => { action = 'disconnect'; erase = false; }}>Déconnecter cet appareil…</button>
|
||||
<button disabled={syncState.revoked} class="btn-danger" onclick={() => { action = 'delete'; deleteText = ''; }}>Supprimer le coffre en ligne…</button>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if recovery}
|
||||
<section class="card stack">
|
||||
<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>
|
||||
<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>
|
||||
<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 onclick={() => { recovery = ''; }}>Fermer</button>
|
||||
</section>
|
||||
{/if}
|
||||
{#if action}
|
||||
<section class="card stack" aria-label="Confirmation">
|
||||
{#if action === 'revoke'}
|
||||
<h2>Retirer {deviceName(target)} ?</h2>
|
||||
<p>Son accès sera coupé et les clés renouvelées. Ses copies déjà téléchargées ne peuvent pas être effacées à distance. S’il possède le document de secours, renouvelez-le aussi.</p>
|
||||
<button class="btn-danger" onclick={() => run(async () => { await revokeDevice(target); action = ''; })}>Confirmer le retrait</button>
|
||||
{:else if action === 'disconnect'}
|
||||
<h2>Déconnecter cet appareil ?</h2>
|
||||
<p>Les changements non envoyés ne seront plus synchronisés. Les autres appareils et le coffre restent accessibles.</p>
|
||||
<label class="check"><input type="checkbox" bind:checked={erase} /> Effacer aussi toutes les journées, réglages et sauvegardes locales de cet appareil</label>
|
||||
<button class="btn-danger" onclick={() => run(async () => { await disconnect(erase); action = ''; })}>Confirmer la déconnexion</button>
|
||||
{:else}
|
||||
<h2>Supprimer le coffre en ligne ?</h2>
|
||||
<p>Les données en ligne et les accès des appareils seront supprimés. Les copies déjà téléchargées restent sur les appareils. Les sauvegardes de l’hébergeur suivent sa durée de conservation.</p>
|
||||
<label>Tapez SUPPRIMER<input bind:value={deleteText} autocomplete="off" /></label>
|
||||
<button disabled={deleteText !== 'SUPPRIMER'} class="btn-danger" onclick={() => run(async () => { await deleteVault(); action = ''; })}>Confirmer la suppression du coffre</button>
|
||||
{/if}
|
||||
<button onclick={() => { action = ''; }}>Annuler</button>
|
||||
</section>
|
||||
{/if}
|
||||
</fieldset>
|
||||
|
||||
<style>
|
||||
fieldset.screen { border: 0; margin: 0; min-width: 0; }
|
||||
label { display: flex; flex-direction: column; gap: var(--gap); }
|
||||
.check { flex-direction: row; align-items: start; }
|
||||
.check input { width: auto; min-height: auto; margin-top: .3rem; }
|
||||
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; }
|
||||
.notice { color: var(--danger-fg); background: var(--danger); padding: .6rem; }
|
||||
ul { padding-left: 1.2rem; overflow-wrap: anywhere; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user