Files
pardis/src/routes/SyncSettings.svelte
Adrien W. 5686ee5072
All checks were successful
Tests et déploiement / validate (push) Successful in 2m44s
Tests et déploiement / deploy (push) Successful in 51s
Added optionnal online storage and cross-client sync
2026-10-10 16:01:06 +02:00

261 lines
18 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>