fbd4b992f5
* fix(privacy): make privacy policy page dark mode friendly Replace the hardcoded light gradient background with bg-background and add dark:prose-invert to the prose blocks so body text is readable on dark cards. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(cloud-backup): sync archives to Dropbox alongside Google Drive Introduce a CloudStorageProvider interface so performSync builds the archive set once and talks to storage only through it. Google Drive keeps its existing behaviour; Dropbox is a second implementation, so the compliance-relevant half (fingerprints, per-year layout, size fallback, progressive persistence) cannot drift between targets. Dropbox uses App folder access, matching the drive.file scope's "only what the app created" guarantee. Uploads are single-shot under 8 MB and chunked upload sessions above, every write verified against Dropbox's content_hash. Call arguments are ASCII-escaped per UTF-16 code unit so Swedish file names survive the Dropbox-API-Arg header. Each provider owns its extension_data keys, schedule, failure counter and alert throttle, so a dead Dropbox token cannot pause a healthy Drive backup. The google_drive_* keys and the /oauth/callback path are untouched: both are wire format for already-connected companies. isConfigured() gates /connect only. A deployment that loses its OAuth credentials must not trap users with a connection they cannot remove or a schedule they cannot switch off. Requires DROPBOX_APP_KEY and DROPBOX_APP_SECRET; the provider row renders disabled without them. No migration: state is extension_data JSON throughout. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix: remove merge-conflict markers committed in DECISIONS.md The merge that brought main into this branch staged DECISIONS.md while it still carried conflict markers, so cdc3a513 shipped an unresolved hunk (compliance swarm ISO 27001 A.8.32). DECISIONS.md is an append-only log, so both sides are kept: main's systemdokumentation entry followed by this branch's Dropbox entries. No decision was dropped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
105 lines
3.5 KiB
TypeScript
105 lines
3.5 KiB
TypeScript
'use client'
|
|
|
|
import { useEffect, useState } from 'react'
|
|
import Link from 'next/link'
|
|
import { AlertTriangle } from 'lucide-react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { ENABLED_EXTENSION_IDS } from '@/lib/extensions/_generated/enabled-extensions'
|
|
|
|
// Local mirror of the cloud-backup status shape: core must not import from
|
|
// @/extensions/, so the fields we read are declared here.
|
|
interface BackupProviderStatus {
|
|
provider: string
|
|
connected: boolean
|
|
needs_reauth: boolean
|
|
schedule: { last_auto_sync_status: 'success' | 'error' | null } | null
|
|
}
|
|
|
|
interface BackupStatus {
|
|
providers?: BackupProviderStatus[]
|
|
// Pre-multi-provider shape, describing Google Drive alone.
|
|
connected: boolean
|
|
needs_reauth: boolean
|
|
schedule: { last_auto_sync_status: 'success' | 'error' | null } | null
|
|
}
|
|
|
|
/** Brand names stay untranslated; the sentence around them is localised. */
|
|
const PROVIDER_LABELS: Record<string, string> = {
|
|
google_drive: 'Google Drive',
|
|
dropbox: 'Dropbox',
|
|
}
|
|
|
|
/**
|
|
* Warning shown on the dashboard ONLY when a connected cloud backup is failing
|
|
* (dead token or errored auto-sync). A backup that silently stops is worse
|
|
* than none; this makes the failure visible where the user actually is.
|
|
* Renders nothing when the extension is off, disconnected, or healthy.
|
|
*
|
|
* With more than one destination connected, a failure on either one surfaces:
|
|
* a working Drive backup does not make a broken Dropbox backup acceptable.
|
|
*/
|
|
export default function BackupHealthBanner() {
|
|
const t = useTranslations('extensions')
|
|
const [status, setStatus] = useState<BackupStatus | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!ENABLED_EXTENSION_IDS.has('cloud-backup')) return
|
|
let cancelled = false
|
|
fetch('/api/extensions/ext/cloud-backup/status')
|
|
.then((res) => (res.ok ? res.json() : null))
|
|
.then((body) => {
|
|
if (!cancelled && body?.data) setStatus(body.data as BackupStatus)
|
|
})
|
|
.catch(() => {
|
|
// Fail silent: the dashboard must not degrade over a status probe.
|
|
})
|
|
return () => {
|
|
cancelled = true
|
|
}
|
|
}, [])
|
|
|
|
if (!status) return null
|
|
|
|
const providers: BackupProviderStatus[] = status.providers ?? [
|
|
{
|
|
provider: 'google_drive',
|
|
connected: status.connected,
|
|
needs_reauth: status.needs_reauth,
|
|
schedule: status.schedule,
|
|
},
|
|
]
|
|
|
|
const failing = providers.filter(
|
|
(p) =>
|
|
p.connected &&
|
|
(p.needs_reauth || p.schedule?.last_auto_sync_status === 'error')
|
|
)
|
|
if (failing.length === 0) return null
|
|
|
|
// One sentence covering everything that is broken, so two dead connections
|
|
// do not stack two banners on the dashboard.
|
|
const names = failing
|
|
.map((p) => PROVIDER_LABELS[p.provider] ?? p.provider)
|
|
.join(' + ')
|
|
const allNeedReauth = failing.every((p) => p.needs_reauth)
|
|
|
|
return (
|
|
<div className="flex items-start gap-3 rounded-lg border border-border bg-muted/30 p-4">
|
|
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-warning" />
|
|
<div className="flex-1 text-sm">
|
|
<p className="font-medium">
|
|
{allNeedReauth
|
|
? t('ext_cloud_backup_banner_reauth', { provider: names })
|
|
: t('ext_cloud_backup_banner_failing', { provider: names })}
|
|
</p>
|
|
<Link
|
|
href="/import#cloud-backup"
|
|
className="mt-1 inline-block text-muted-foreground underline underline-offset-4 hover:text-foreground"
|
|
>
|
|
{t('ext_cloud_backup_banner_action')}
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|