Gmails refresh-token lå i klartekst i databasen. Det er ikke filen på serveren, der er problemet, men kopierne af den: sikkerhedskopier kan hentes ned gennem fladen, så hver eneste kopi, der er taget indtil nu, bar et levende token. Derfor ligger nøglen uden for databasen — i miljøet, eller i en fil ved siden af dataene med tilstand 0600. VAPID-parret er præcedens for en hemmelighed, instansen selv laver og gemmer i basen, men det mønster dur ikke her: en nøgle i samme fil som det, den låser, er ingen lås. Backup er VACUUM INTO af selve databasen, så filen følger ikke med. Kun de navngivne felter forsegles — refresh_token, access_token, password, client_secret. Resten af klatten er det, man læser, når en postkasse driller: hvilken mappe, hvilken etiket, hvilken adresse. At kryptere det med ville ikke købe noget og ville koste muligheden for at kigge. Ingen migration, og ingen der skal forbinde forfra: en værdi uden mærket læses som sig selv, og næste skrivning lægger den forseglet på plads. Kolonnen konverterer sig selv efterhånden som den bruges, i stedet for i én omgang, der skal have ret om hver eneste række på én gang. Der er en prøve for netop det. Fladen siger det på siden, hvor man henter kopien — for den dag nøglen betyder noget, er den dag, man gendanner et andet sted og undrer sig over, at postkassen er tavs. Runen er på v4; en ny variabel uden et versionsspring er usynlig. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
542 lines
13 KiB
Svelte
542 lines
13 KiB
Svelte
<script>
|
|
/** Import, export and project templates. */
|
|
import { api, humanMessage } from '$lib/api.js';
|
|
import { app } from '$lib/stores.svelte.js';
|
|
import { goto } from '$app/navigation';
|
|
import { ago } from '$lib/when.js';
|
|
import { t } from '$lib/i18n.svelte.js';
|
|
|
|
// --- backups ---------------------------------------------------------------------
|
|
|
|
let backups = $state([]);
|
|
let backupsLoaded = $state(false);
|
|
let backingUp = $state(false);
|
|
let lastBackup = $state('');
|
|
|
|
$effect(() => {
|
|
if (!app.user?.is_admin) return;
|
|
api
|
|
.listBackups()
|
|
.then((r) => {
|
|
backups = r.backups;
|
|
backupsLoaded = true;
|
|
})
|
|
.catch((e) => app.toast(humanMessage(e)));
|
|
});
|
|
|
|
/**
|
|
* Takes one now.
|
|
*
|
|
* Worth a button of its own rather than trusting the nightly job: waiting until
|
|
* tonight to find out whether backups work at all is how somebody finds out on
|
|
* the day they need one. It is also the only way to get a snapshot from *before*
|
|
* something you are about to do.
|
|
*/
|
|
async function backupNow() {
|
|
backingUp = true;
|
|
lastBackup = '';
|
|
try {
|
|
const made = await api.runBackup();
|
|
backups = [made, ...backups];
|
|
lastBackup = t('data.backupMade', { size: size(made.size_bytes) });
|
|
} catch (e) {
|
|
app.toast(humanMessage(e));
|
|
} finally {
|
|
backingUp = false;
|
|
}
|
|
}
|
|
|
|
function size(bytes) {
|
|
if (!bytes) return t('data.empty');
|
|
const mb = bytes / 1024 / 1024;
|
|
if (mb >= 1) return `${mb.toFixed(1)} MB`;
|
|
return `${Math.max(1, Math.round(bytes / 1024))} KB`;
|
|
}
|
|
|
|
|
|
// --- Todoist import ------------------------------------------------------------
|
|
|
|
let importing = $state(false);
|
|
let result = $state(null);
|
|
|
|
async function importTodoist(event) {
|
|
const file = event.target.files?.[0];
|
|
if (!file) return;
|
|
event.target.value = ''; // so the same file can be picked twice after a failure
|
|
|
|
importing = true;
|
|
result = null;
|
|
try {
|
|
result = await api.importTodoist(file);
|
|
await app.refreshProjects();
|
|
} catch (e) {
|
|
app.toast(e.fields?.file ?? humanMessage(e));
|
|
} finally {
|
|
importing = false;
|
|
}
|
|
}
|
|
|
|
// --- templates --------------------------------------------------------------
|
|
|
|
let templates = $state([]);
|
|
let loadingTemplates = $state(true);
|
|
|
|
let saveFrom = $state('');
|
|
let templateName = $state('');
|
|
let savingTemplate = $state(false);
|
|
|
|
let usingId = $state('');
|
|
let newProjectName = $state('');
|
|
let startDate = $state('');
|
|
|
|
$effect(() => {
|
|
api
|
|
.listTemplates()
|
|
.then((r) => (templates = r.templates))
|
|
.catch((e) => app.toast(humanMessage(e)))
|
|
.finally(() => (loadingTemplates = false));
|
|
});
|
|
|
|
let projects = $derived(app.projects.filter((p) => !p.is_inbox));
|
|
|
|
async function saveTemplate(event) {
|
|
event.preventDefault();
|
|
if (!saveFrom) return;
|
|
savingTemplate = true;
|
|
try {
|
|
const created = await api.saveTemplate({
|
|
project_id: saveFrom,
|
|
name: templateName.trim() || app.projectById(saveFrom)?.name || 'Skabelon',
|
|
description: ''
|
|
});
|
|
templates = [created, ...templates];
|
|
saveFrom = '';
|
|
templateName = '';
|
|
} catch (e) {
|
|
app.toast(humanMessage(e));
|
|
} finally {
|
|
savingTemplate = false;
|
|
}
|
|
}
|
|
|
|
async function useTemplate(event) {
|
|
event.preventDefault();
|
|
if (!usingId) return;
|
|
try {
|
|
const project = await api.useTemplate(usingId, {
|
|
name: newProjectName.trim(),
|
|
start_date: startDate
|
|
});
|
|
await app.refreshProjects();
|
|
usingId = '';
|
|
newProjectName = '';
|
|
startDate = '';
|
|
goto(`/projekt/${project.id}`);
|
|
} catch (e) {
|
|
app.toast(e.fields?.start_date ?? humanMessage(e));
|
|
}
|
|
}
|
|
|
|
async function deleteTemplate(template) {
|
|
if (!confirm(t('data.deleteTemplate', { name: template.name }))) return;
|
|
|
|
const previous = templates;
|
|
templates = templates.filter((t) => t.id !== template.id);
|
|
try {
|
|
await api.deleteTemplate(template.id);
|
|
} catch (e) {
|
|
templates = previous;
|
|
app.toast(humanMessage(e));
|
|
}
|
|
}
|
|
|
|
let exportProject = $state('');
|
|
|
|
// --- the trash -------------------------------------------------------------
|
|
|
|
let trashed = $state([]);
|
|
let loadingTrash = $state(true);
|
|
|
|
$effect(() => {
|
|
api
|
|
.listTrashedProjects()
|
|
.then((r) => (trashed = r.projects))
|
|
.catch((e) => app.toast(humanMessage(e)))
|
|
.finally(() => (loadingTrash = false));
|
|
});
|
|
|
|
async function restore(project) {
|
|
const previous = trashed;
|
|
trashed = trashed.filter((p) => p.id !== project.id);
|
|
try {
|
|
await api.restoreProject(project.id);
|
|
await app.refreshProjects();
|
|
} catch (e) {
|
|
trashed = previous;
|
|
app.toast(humanMessage(e));
|
|
}
|
|
}
|
|
|
|
/** Whole days left, rounded down — "0 dage tilbage" is the truthful last day. */
|
|
function daysLeft(iso) {
|
|
return Math.max(0, Math.floor((new Date(iso) - Date.now()) / 86400000));
|
|
}
|
|
</script>
|
|
|
|
<section class="panel">
|
|
<header>
|
|
<h2>{t('data.import')}</h2>
|
|
<p class="hint">
|
|
{t('data.importHint')}
|
|
</p>
|
|
</header>
|
|
|
|
<div class="field">
|
|
<label for="todoist">{t('data.csvFile')}</label>
|
|
<input id="todoist" type="file" accept=".csv,text/csv" onchange={importTodoist} disabled={importing} />
|
|
</div>
|
|
|
|
{#if importing}
|
|
<p class="hint">{t('data.importing')}</p>
|
|
{/if}
|
|
|
|
{#if result}
|
|
<div class="result">
|
|
<p class="hint">
|
|
{result.tasks} opgave{result.tasks === 1 ? '' : 'r'}, {result.sections}
|
|
sektion{result.sections === 1 ? '' : 'er'} og {result.comments}
|
|
kommentar{result.comments === 1 ? '' : 'er'} hentet ind.
|
|
</p>
|
|
{#if result.warnings?.length}
|
|
<!-- Reported rather than silently dropped: a partial import that says
|
|
nothing is discovered weeks later. -->
|
|
<ul class="warnings">
|
|
{#each result.warnings as warning, i (i)}
|
|
<li>{warning}</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
{#if result.project_id}
|
|
<div class="row">
|
|
<a class="link" href="/projekt/{result.project_id}">{t('data.openProject')}</a>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<header>
|
|
<h2>{t('data.trash')}</h2>
|
|
<p class="hint">
|
|
{t('data.trashHint')}
|
|
</p>
|
|
</header>
|
|
|
|
{#if loadingTrash}
|
|
<p class="empty">…</p>
|
|
{:else if trashed.length === 0}
|
|
<p class="empty">{t('data.trashEmpty')}</p>
|
|
{:else}
|
|
<ul class="list">
|
|
{#each trashed as project (project.id)}
|
|
<li>
|
|
<div class="what">
|
|
<span class="title">{project.name}</span>
|
|
<span class="meta">
|
|
{project.task_count} opgave{project.task_count === 1 ? '' : 'r'} ·
|
|
{daysLeft(project.purge_after)} dage tilbage
|
|
</span>
|
|
</div>
|
|
<button class="use" onclick={() => restore(project)}>{t('data.restore')}</button>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</section>
|
|
|
|
<!-- Administrators only, like the error log and for a sharper reason: a backup file
|
|
is a complete copy of the database, so this panel hands out everybody's data. -->
|
|
{#if app.user?.is_admin}
|
|
<section class="panel">
|
|
<header>
|
|
<h2>{t('data.backups')}</h2>
|
|
<p class="hint">
|
|
{t('data.backupsHint')}
|
|
</p>
|
|
<p class="hint">
|
|
{t('data.backupsAttachments')}
|
|
</p>
|
|
<!-- Said here rather than only in the documentation: this is the page where
|
|
somebody downloads the file, and the day the key matters is the day
|
|
they are restoring it somewhere else and wondering why the mailbox is
|
|
silent. -->
|
|
<p class="hint">
|
|
{t('data.backupsSecrets')}
|
|
</p>
|
|
</header>
|
|
|
|
<div class="row">
|
|
<button class="secondary" onclick={backupNow} disabled={backingUp}>
|
|
{backingUp ? t('data.backingUp') : t('data.backupNow')}
|
|
</button>
|
|
{#if lastBackup}<span class="saved">{lastBackup}</span>{/if}
|
|
</div>
|
|
|
|
{#if backups.length}
|
|
<ul class="list">
|
|
{#each backups as backup (backup.id)}
|
|
<li>
|
|
<div class="what">
|
|
<span class="primary-line">
|
|
{ago(backup.started_at)}
|
|
{#if backup.error}<span class="failed">{t('data.backupFailed')}</span>{/if}
|
|
</span>
|
|
<span class="secondary">
|
|
{#if backup.error}
|
|
{backup.error}
|
|
{:else}
|
|
{size(backup.size_bytes)}{#if !backup.present}{' · '}{t('data.swept')}{/if}
|
|
{/if}
|
|
</span>
|
|
</div>
|
|
{#if backup.present && !backup.error}
|
|
<a class="link" href={api.backupURL(backup.id)} download>{t('data.download')}</a>
|
|
{/if}
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{:else if backupsLoaded}
|
|
<p class="empty">{t('data.noBackups')}</p>
|
|
{/if}
|
|
|
|
<p class="hint">
|
|
{t('data.restoreHint')}
|
|
</p>
|
|
</section>
|
|
{/if}
|
|
|
|
<section class="panel">
|
|
<header>
|
|
<h2>{t('data.export')}</h2>
|
|
<p class="hint">
|
|
{t('data.exportHint')}
|
|
</p>
|
|
</header>
|
|
|
|
<div class="row">
|
|
<!-- Plain links, not fetch: the browser's own download handling knows what to
|
|
do with a Content-Disposition, and the session cookie rides along. -->
|
|
<a class="link" href={api.exportAccountURL()} download>{t('data.exportAccount')}</a>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="export-project">{t('data.exportOne')}</label>
|
|
<select id="export-project" bind:value={exportProject}>
|
|
<option value="">{t('data.pickProject')}</option>
|
|
{#each app.projects as project (project.id)}
|
|
<option value={project.id}>{project.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
{#if exportProject}
|
|
<div class="row">
|
|
<a class="link" href={api.exportProjectCSVURL(exportProject)} download>{t('data.csv')}</a>
|
|
<a class="link" href={api.exportProjectICSURL(exportProject)} download>{t('data.ics')}</a>
|
|
</div>
|
|
{/if}
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<header>
|
|
<h2>{t('data.templates')}</h2>
|
|
<p class="hint">
|
|
{t('data.templatesHint')}
|
|
</p>
|
|
</header>
|
|
|
|
<form onsubmit={saveTemplate}>
|
|
<div class="field">
|
|
<label for="save-from">{t('data.saveAsTemplate')}</label>
|
|
<select id="save-from" bind:value={saveFrom}>
|
|
<option value="">{t('data.pickProject')}</option>
|
|
{#each projects as project (project.id)}
|
|
<option value={project.id}>{project.name}</option>
|
|
{/each}
|
|
</select>
|
|
</div>
|
|
|
|
{#if saveFrom}
|
|
<div class="field">
|
|
<label for="template-name">{t('data.templateName')}</label>
|
|
<input
|
|
id="template-name"
|
|
bind:value={templateName}
|
|
placeholder={app.projectById(saveFrom)?.name ?? ''}
|
|
/>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<button class="primary" type="submit" disabled={savingTemplate}>{t('data.saveTemplate')}</button>
|
|
</div>
|
|
{/if}
|
|
</form>
|
|
|
|
{#if loadingTemplates}
|
|
<p class="empty">…</p>
|
|
{:else if templates.length === 0}
|
|
<p class="empty">{t('data.noTemplates')}</p>
|
|
{:else}
|
|
<ul class="list">
|
|
{#each templates as template (template.id)}
|
|
<li>
|
|
<div class="what">
|
|
<span class="title">{template.name}</span>
|
|
<span class="meta">
|
|
{template.task_count} opgave{template.task_count === 1 ? '' : 'r'}
|
|
</span>
|
|
</div>
|
|
<button class="use" onclick={() => (usingId = usingId === template.id ? '' : template.id)}>
|
|
{t('data.use')}
|
|
</button>
|
|
<button class="remove" onclick={() => deleteTemplate(template)}>{t('data.delete')}</button>
|
|
</li>
|
|
|
|
{#if usingId === template.id}
|
|
<li class="use-form">
|
|
<form onsubmit={useTemplate}>
|
|
<div class="field">
|
|
<label for="new-name">{t('data.newProjectName')}</label>
|
|
<input id="new-name" bind:value={newProjectName} placeholder={template.name} />
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="start">{t('data.startDate')}</label>
|
|
<input id="start" type="date" bind:value={startDate} />
|
|
<p class="hint">{t('data.startDateHint')}</p>
|
|
</div>
|
|
|
|
<div class="row">
|
|
<button class="primary" type="submit">{t('data.createProject')}</button>
|
|
</div>
|
|
</form>
|
|
</li>
|
|
{/if}
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
</section>
|
|
|
|
<style>
|
|
/* The backup list, which is a list of rows with a right-hand action — the same
|
|
shape the settings pages use elsewhere, said once here because this is the
|
|
only panel on the page that has one. */
|
|
.what {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
}
|
|
|
|
.primary-line {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: var(--s2);
|
|
font-size: var(--text-sm);
|
|
}
|
|
|
|
.secondary {
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
overflow-wrap: anywhere;
|
|
}
|
|
|
|
.failed {
|
|
font-size: var(--text-xs);
|
|
font-weight: 560;
|
|
color: var(--danger);
|
|
border: 1px solid var(--danger);
|
|
border-radius: var(--radius-sm);
|
|
padding: 0 var(--s1);
|
|
}
|
|
|
|
form {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--s4);
|
|
}
|
|
|
|
.result {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: var(--s2);
|
|
padding: var(--s4);
|
|
background: var(--surface-sunken);
|
|
border: 1px solid var(--line);
|
|
border-radius: var(--radius);
|
|
}
|
|
|
|
.warnings {
|
|
margin: 0;
|
|
padding-left: var(--s5);
|
|
font-size: var(--text-sm);
|
|
color: var(--ink-muted);
|
|
}
|
|
|
|
.what {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 2px;
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.title {
|
|
font-size: var(--text-sm);
|
|
}
|
|
|
|
.meta {
|
|
font-size: var(--text-xs);
|
|
color: var(--ink-faint);
|
|
}
|
|
|
|
.use,
|
|
.remove {
|
|
flex: none;
|
|
font-size: var(--text-xs);
|
|
padding: var(--s1) var(--s2);
|
|
border-radius: var(--radius-sm);
|
|
color: var(--ink-faint);
|
|
transition: color var(--fast) var(--ease);
|
|
}
|
|
|
|
.use:hover {
|
|
color: var(--accent);
|
|
}
|
|
|
|
.remove:hover {
|
|
color: var(--danger);
|
|
background: var(--danger-sunken);
|
|
}
|
|
|
|
.use-form {
|
|
background: var(--surface-sunken);
|
|
}
|
|
|
|
.use-form form {
|
|
width: 100%;
|
|
}
|
|
|
|
.link {
|
|
font-size: var(--text-sm);
|
|
color: var(--accent);
|
|
text-decoration: none;
|
|
}
|
|
|
|
.link:hover {
|
|
text-decoration: underline;
|
|
}
|
|
</style>
|